SLOPSHOPPER

cc-dash-kit

Composable local dashboard for Claude Code

newpaneguardcommandtoastprompt
v0.1.0MITupdated 2026-10-07coolthor/cc-dash-kit
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · cc-dash-kit
│ ┃ Dashboard ✕ › fix the failing auth test and add an audit log call │ ┃ ╭─────────────────────────────────────────── │ ┃ │ Claude usage ⏺ Read(src/auth.ts) │ ┃ │ 5 hours ● 31% ━━━━ ⎿ Read 6 lines │ ┃ │ Weekly ● Unavailable ⏺ Update(src/auth.ts) │ ┃ ╰─────────────────────────────────────────── ⎿ Added 2 lines, removed 1 line │ ┃ ╭─────────────────────────────────────────── ⏺ Bash(bun test) │ ┃ │ Current session ⎿ 3 pass, 1 fail │ ┃ │ Read failed: Error: sample timed out │ ┃ ╰─────────────────────────────────────────── ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ ● cc-dash-kit │ ✻ Worked for 42s · done 4:20 PM │ │ › /cc-dash │ ⎿ cc-dash-kit: Dashboard opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Dashboard
╭──────────────────────────────────────────────────────╮ │ Claude usage │ │ 5 hours ● 31% ━━━━────────── │ │ Weekly ● Unavailable │ ╰──────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────╮ │ Current session │ │ Read failed: Error: sample timed out │ ╰──────────────────────────────────────────────────────╯ ● cc-dash-kit
README

cc-dash-kit

Install this Claude Code dashboard for me: https://github.com/coolthor/cc-dash-kit

Claude Code: follow INSTALL.md and ask which installation scope to use before installing.

Your Claude Code status, right alongside the chat. · 繁體中文

Claude Code conversation with the dashboard in a right-hand sidebar

One-prompt install

Paste the sentence above into Claude Code. It follows INSTALL.md, asks before changing your terminal layout, and offers to customize the dashboard after installation.

Install without customizing:

claude plugin marketplace add coolthor/cc-dash-kit
claude plugin install --scope user cc-dash-kit@cc-dash-kit

User scope works across projects; use --scope local to try it in a single project. Reload plugins after installation. The terminal sidebar requires /tui fullscreen; see INSTALL.md for the layout step and Desktop Code behavior.

Customize it with one prompt

Customize this dashboard around what I check while I work.

Claude follows ASSEMBLE.md, asks three questions, and writes a local dash.config.json. The default quota and session cards work out of the box.

Before: ready without configAfter: GPU and two local services
English default dashboard with quota and session cardsEnglish customized dashboard with GPU and two local services

Cards

CardNeedsWhen unavailable
Claude usageSession usage dataShows “Unavailable” until usage data arrives
SessionCurrent session metadataShows available fields
GPUnvidia-smiHidden
Diskdf and a configured pathShows a read error
EndpointsConfigured HTTP URLsShows each endpoint as offline
Dispatch pauseConfigured local flag pathHidden until configured

Also in this repo: image-peek

A second mod that shows a thumbnail of each pasted image directly under the prompt you pasted it into, instead of a bare [Image #1]. Works in Ghostty and kitty; needs Python 3 with Pillow.

claude plugin install --scope user image-peek@cc-dash-kit

Requirements, behavior in other terminals, privacy, and troubleshooting: plugins/image-peek/README.md.

Safety

The mod runs with Claude Code permissions. Review its code before installing. It reads session metadata and only the local resources you configure. Endpoint checks have short timeouts. The pause button changes only its configured local flag file; pause_status is advisory. Never put credentials in the config.

Development

Run claude plugin validate . and claude plugin test .. See dash.config.example.json for the config shape. Local dash.config.json is gitignored.

License

MIT © coolthor.

Source 14 files
hooks/register.tsx 190 lines
1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3import { DEFAULT_CONFIG, parseConfig } from '../src/config'
4import type { DashConfig } from '../src/config'
5import type { CardData, SourceConfig } from '../src/sources/types'
6import { setPaused } from '../src/sources/pause-flag'
7import { collectSource } from '../src/sources/collect'
8import { Card, StatusDot } from '../src/ui/components'
9import { parseLocale, setLocale, t } from '../src/i18n'
10
11const PANE = 'cc-dash'
12const snapshot = atom({ plugin: 'cc-dash-kit', key: 'snapshot' } as const, {} as Record<string, CardData>)
13let config: DashConfig = DEFAULT_CONFIG
14let configError: string | null = null
15let started = false
16let pauseToolRegistered = false
17let detectedLocale: 'en' | 'zh-TW' = 'en'
18let paneCanDock = false
19let inlineHintShown = false
20let fullscreenFromEnv = false
21let autoOpened = false
22
23async function loadConfig($: any) {
24  const path = `${$.plugin.root}/dash.config.json`
25  if (!await $.fs.exists(path)) return DEFAULT_CONFIG
26  return parseConfig(JSON.parse(await $.fs.read(path)))
27}
28
29async function sampleOne($: any, card: SourceConfig): Promise<CardData | null> {
30  try {
31    return await collectSource({
32      session: {
33        usage: () => $.session.usage(),
34        id: () => $.session.id(),
35        cwd: () => $.session.cwd(),
36      },
37      process: { run: (argv: string[], options: any) => $.process.run(argv, options) },
38      fs: {
39        exists: (path: string) => $.fs.exists(path),
40        write: (path: string, content: string) => $.fs.write(path, content),
41      },
42      clock: { sleep: (ms: number) => $.clock.sleep(ms) },
43    }, card)
44  } catch (error) {
45    return { rows: [], error: `${t('loadFailed')}: ${String(error).slice(0, 100)}` }
46  }
47}
48
49async function refresh($: any) {
50  try { config = await loadConfig($); configError = null }
51  catch (error) {
52    config = DEFAULT_CONFIG
53    configError = `${t('configFailed')}: ${String(error).slice(0, 100)}`
54  }
55  setLocale(config.locale && config.locale !== 'auto' ? config.locale : detectedLocale)
56  if (config.cards.some(card => card.id === 'pause-flag') && !pauseToolRegistered) {
57    await registerPauseTool($)
58    pauseToolRegistered = true
59  }
60  const pairs = await Promise.all(config.cards.map(async card => [card.id, await sampleOne($, card)] as const))
61  await update($, snapshot, () => Object.fromEntries(pairs.filter(([, data]) => data !== null)))
62}
63
64async function detectLocale($: any): Promise<'en' | 'zh-TW'> {
65  for (const key of ['LC_ALL', 'LC_MESSAGES', 'LANG']) {
66    try {
67      const result = await $.process.run(['printenv', key], { timeoutMs: 1000 })
68      if (result.exitCode === 0 && result.stdout.trim()) return parseLocale(result.stdout.trim()) || 'en'
69    } catch {}
70  }
71  try {
72    const result = await $.process.run(['defaults', 'read', '-g', 'AppleLanguages'], { timeoutMs: 1000 })
73    if (result.exitCode === 0) return parseLocale(result.stdout.match(/["']?([a-z]{2}(?:[-_][A-Za-z]+)?)/i)?.[1]) || 'en'
74  } catch {}
75  return 'en'
76}
77
78async function envIsFullscreen($: any) {
79  try {
80    const [enabled, disabled] = await Promise.all([
81      $.process.run(['printenv', 'CLAUDE_CODE_NO_FLICKER'], { timeoutMs: 1000 }),
82      $.process.run(['printenv', 'CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN'], { timeoutMs: 1000 }),
83    ])
84    return enabled.stdout.trim() === '1' && disabled.stdout.trim() !== '1'
85  } catch { return false }
86}
87
88function canDock(e: any) {
89  if (e.surface === 'desktop') return true
90  if (e.viewport?.isFullscreen !== undefined) return e.viewport.isFullscreen === true
91  return e.surface === 'terminal' && fullscreenFromEnv
92}
93
94async function autoOpen($: any, e: any) {
95  if (autoOpened || !canDock(e)) return
96  autoOpened = true
97  paneCanDock = true
98  try { await $.ui.open({ id: PANE, title: t('dashboard') }) }
99  catch { autoOpened = false }
100}
101
102async function start($: any) {
103  if (started) return
104  started = true
105  detectedLocale = await detectLocale($)
106  setLocale(detectedLocale)
107  void refresh($)
108  $.clock.every(config.refreshMs, () => void refresh($))
109}
110
111export const register: Register = on => {
112  on('session.start', async ($, e, next) => {
113    fullscreenFromEnv = await envIsFullscreen($)
114    paneCanDock = canDock(e)
115    inlineHintShown = false
116    autoOpened = false
117    await $.command.register({ name: 'cc-dash', description: 'Open the dashboard' })
118    await start($)
119    void autoOpen($, e)
120    return next(e)
121  })
122
123  on('session.attach', async ($, e, next) => {
124    await start($)
125    void autoOpen($, e)
126    return next(e)
127  })
128
129  on('prompt.submit', async ($, e, next) => {
130    void autoOpen($, e)
131    return next(e)
132  })
133
134  on('command.run', { command: 'cc-dash' }, async $ => {
135    await start($)
136    await refresh($)
137    await $.ui.close({ id: PANE })
138    const result = await $.ui.open({ id: PANE, title: t('dashboard'), focus: true })
139    if (result.isPlaced) autoOpened = true
140    return { text: result.isPlaced ? t('opened') : `${t('waiting')}: ${result.reason || 'pane unavailable'}` }
141  })
142
143  on('tool.call', { tool: 'mcp__cc-dash-kit__pause_status' }, async $ => {
144    const flags = config.cards.filter(card => card.id === 'pause-flag')
145    const rows = await Promise.all(flags.map(async card => {
146      try { return `${card.title || card.path}: ${await $.fs.exists(card.path) ? 'paused' : 'available'}` }
147      catch { return `${card.title || card.path}: unknown; do not dispatch until checked` }
148    }))
149    return { result: rows.join('\n') || 'No pause flags configured.' }
150  })
151
152  on('turn.complete', async ($, e, next) => {
153    if (!e.agentId) void refresh($)
154    void autoOpen($, e)
155    return next(e)
156  })
157
158  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
159    paneCanDock = e.props.placement === 'dock'
160    if (e.props.placement === 'inline' && !inlineHintShown) {
161      inlineHintShown = true
162      $.ui.toast(t('fullscreenHint'))
163    }
164    const ui = $.ui.resolve(e)
165    const { Box, Text, Button } = ui
166    const Svg = e.surface === 'desktop' ? (ui as any).Svg : undefined
167    const data = await read($, snapshot)
168    const width = Math.max(16, e.props.bodyColumns || 40)
169    return <Box flexDirection="column">
170      {config.cards.map(card => {
171        const item = data[card.id]
172        if (!item) return null
173        const extra = card.id === 'pause-flag' ? <Button key={`toggle-${card.id}`} autoFocus label={item.rows[0]?.value === t('paused') ? t('resume') : t('pause')} onPress={async () => {
174          try { await setPaused({ fs: { write: (path: string, content: string) => $.fs.write(path, content) }, process: { run: (argv: string[], options: any) => $.process.run(argv, options) } }, card.path!, item.rows[0]?.value !== t('paused')); await refresh($) }
175          catch (error) { $.ui.toast(`${t('flagFailed')}: ${String(error)}`) }
176        }} /> : null
177        const titleKey = { quota: 'quota', session: 'session', gpu: 'gpu', disk: 'disk', 'http-probe': 'httpProbe', 'pause-flag': 'pauseFlag' }[card.id] as Parameters<typeof t>[0]
178        return Card(ui, card.id, card.title || t(titleKey), item, width, extra)
179      })}
180      {configError && Card(ui, 'config', t('config'), { rows: [], error: configError }, width)}
181      {Svg && <Box><Svg alt="Dashboard status" width={12} height={12} source={'<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12"><circle cx="6" cy="6" r="4" fill="#38b46a"/></svg>'} /><Text dimColor> cc-dash-kit</Text></Box>}
182      {e.surface !== 'desktop' && <Box>{StatusDot(ui, 'ok')}<Text dimColor>cc-dash-kit</Text></Box>}
183    </Box>
184  })
185}
186
187async function registerPauseTool($: any) {
188  await $.tool.register({ name: 'pause_status', description: 'Read configured pause flags before dispatching work to another AI. This is advisory, not a permission boundary.', inputSchema: { type: 'object', properties: {} } })
189}
190
src/config.ts 31 lines
1import type { Locale } from './i18n'
2import type { SourceConfig } from './sources/types'
3
4export type DashConfig = { cards: SourceConfig[]; refreshMs: number; locale?: Locale | 'auto' }
5export const DEFAULT_CONFIG: DashConfig = { cards: [{ id: 'quota' }, { id: 'session' }], refreshMs: 30000 }
6const IDS = new Set(['quota', 'session', 'gpu', 'disk', 'http-probe', 'pause-flag'])
7
8export function parseConfig(value: unknown): DashConfig {
9  if (value == null) return DEFAULT_CONFIG
10  if (typeof value !== 'object' || Array.isArray(value)) throw new Error('config must be an object')
11  const input = value as Record<string, unknown>
12  if (!Array.isArray(input.cards)) throw new Error('cards must be an array')
13  const cards = input.cards.map((raw, index) => {
14    if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw new Error(`cards[${index}] must be an object`)
15    const card = raw as Record<string, unknown>
16    if (typeof card.id !== 'string' || !IDS.has(card.id)) throw new Error(`cards[${index}] has unknown source`)
17    if (card.title !== undefined && (typeof card.title !== 'string' || card.title.length > 60)) throw new Error(`cards[${index}].title invalid`)
18    if (card.path !== undefined && (typeof card.path !== 'string' || !card.path.startsWith('/'))) throw new Error(`cards[${index}].path must be absolute`)
19    if (card.id === 'pause-flag' && !card.path) throw new Error(`cards[${index}].path required`)
20    if (card.id === 'http-probe' && !card.url && !card.endpoints) throw new Error(`cards[${index}] needs url or endpoints`)
21    if (card.endpoints !== undefined && (!Array.isArray(card.endpoints) || !card.endpoints.length || card.endpoints.some((e: any) => !e || typeof e.name !== 'string' || !e.name || typeof e.url !== 'string' || !/^https?:\/\//.test(e.url)))) throw new Error(`cards[${index}].endpoints invalid`)
22    if (card.url !== undefined && (typeof card.url !== 'string' || !/^https?:\/\//.test(card.url))) throw new Error(`cards[${index}].url invalid`)
23    return { id: card.id, title: card.title as string | undefined, path: card.path as string | undefined, url: card.url as string | undefined, endpoints: card.endpoints as SourceConfig["endpoints"] }
24  })
25  if (new Set(cards.map(card => card.id)).size !== cards.length) throw new Error('duplicate source id')
26  const refreshMs = input.refreshMs === undefined ? 30000 : Number(input.refreshMs)
27  if (!Number.isInteger(refreshMs) || refreshMs < 5000 || refreshMs > 300000) throw new Error('refreshMs must be 5000..300000')
28  if (input.locale !== undefined && !['en', 'zh-TW', 'auto'].includes(String(input.locale))) throw new Error('locale invalid')
29  return { cards, refreshMs, locale: input.locale as DashConfig['locale'] }
30}
31
src/sources/types.ts 17 lines
1export type RowData = { label: string; value: string; percent?: number; status?: 'ok' | 'warn' | 'error' }
2export type CardData = { rows: RowData[]; note?: string; error?: string }
3export type SourceConfig = { id: string; title?: string; path?: string; url?: string; endpoints?: { name: string; url: string }[] }
4export type Source = {
5  id: string
6  title: string
7  detect: ($: any, config: SourceConfig) => Promise<boolean>
8  sample: ($: any, config: SourceConfig) => Promise<CardData>
9  card: (data: CardData) => CardData
10}
11export const identityCard = (data: CardData) => data
12export async function run($: any, argv: string[], timeoutMs = 3000) {
13  const result = await $.process.run(argv, { timeoutMs })
14  if (result.exitCode !== 0) throw new Error(`${argv[0]} exited ${result.exitCode}`)
15  return result.stdout as string
16}
17
src/sources/pause-flag.ts 18 lines
1import { t } from '../i18n'
2import type { Source } from './types'
3import { identityCard } from './types'
4
5export const pauseFlag: Source = {
6  id: 'pause-flag', title: t('pauseFlag'), detect: async (_, config) => Boolean(config.path),
7  sample: async ($, config) => ({ rows: [{ label: t('dispatch'), value: await $.fs.exists(config.path) ? t('paused') : t('available') }] }),
8  card: identityCard,
9}
10
11export async function setPaused($: any, path: string, paused: boolean) {
12  if (paused) await $.fs.write(path, 'paused\n')
13  else {
14    const result = await $.process.run(['rm', '-f', '--', path], { timeoutMs: 3000 })
15    if (result.exitCode !== 0) throw new Error(`rm exited ${result.exitCode}`)
16  }
17}
18
src/sources/collect.ts 13 lines
1import { sources } from './index'
2import type { CardData, SourceConfig } from './types'
3
4export async function collectSource($: any, card: SourceConfig): Promise<CardData | null> {
5  const source = sources[card.id]
6  if (!await source.detect($, card)) return null
7  const data = await Promise.race([
8    source.sample($, card),
9    $.clock.sleep(4000).then(() => { throw new Error('sample timed out') }),
10  ])
11  return source.card(data)
12}
13
src/ui/components.tsx 41 lines
1import type { CardData, RowData } from '../sources/types'
2
3export function Bar(ui: any, percent: number | undefined, bodyColumns: number) {
4  const { Text } = ui
5  if (percent === undefined) return null
6  const width = Math.max(2, Math.min(14, bodyColumns - 18))
7  const fill = Math.round(Math.max(0, Math.min(100, percent)) * width / 100)
8  return <Text><Text color={percent >= 90 ? 'red' : percent >= 75 ? 'yellow' : 'green'}>{'━'.repeat(fill)}</Text><Text color="gray">{'─'.repeat(width - fill)}</Text></Text>
9}
10
11export function StatusDot(ui: any, status: RowData['status']) {
12  const { Text } = ui
13  return <Text color={status === 'error' ? 'red' : status === 'warn' ? 'yellow' : 'green'}>● </Text>
14}
15
16export function Row(ui: any, row: RowData, bodyColumns: number, key: string) {
17  const { Box, Text } = ui
18  return <Box key={key} flexDirection="row">
19    <Box width={bodyColumns < 28 ? 7 : 10} flexShrink={0}><Text wrap="truncate-end">{row.label}</Text></Box>
20    {row.status && StatusDot(ui, row.status)}
21    <Box flexGrow={1} flexShrink={1}><Text wrap="truncate-end">{row.value}</Text></Box>
22    {bodyColumns >= 30 && Bar(ui, row.percent, bodyColumns)}
23  </Box>
24}
25
26export function List(ui: any, data: CardData, bodyColumns: number) {
27  const { Box, Text } = ui
28  return <Box flexDirection="column" width={Math.max(1, bodyColumns - 4)}>
29    {data.error ? <Text color="red">{data.error}</Text> : data.rows.map((row, i) => Row(ui, row, bodyColumns, String(i)))}
30    {data.note && bodyColumns >= 35 && <Text dimColor wrap="truncate-end">{data.note}</Text>}
31  </Box>
32}
33
34export function Card(ui: any, id: string, title: string, data: CardData, bodyColumns: number, extra?: any) {
35  const { Box, Text } = ui
36  return <Box key={`card-${id}`} width={Math.max(12, bodyColumns - 1)} flexDirection="column" borderStyle="round" borderColor={data.error ? 'red' : 'blue'} paddingX={1}>
37    <Box justifyContent="space-between"><Text bold>{title}</Text>{extra}</Box>
38    {List(ui, data, bodyColumns)}
39  </Box>
40}
41
src/i18n.ts 24 lines
1export const messages = {
2  en: {
3    dashboard: 'Dashboard', quota: 'Claude usage', session: 'Current session', gpu: 'GPU', disk: 'Disk', httpProbe: 'Endpoints', pauseFlag: 'Dispatch pause', config: 'Config',
4    fiveHour: '5 hours', weekly: 'Weekly', unavailable: 'Unavailable', quotaHint: 'Usage may appear after your first message', directory: 'Folder', context: 'Context', usage: 'Used', free: 'Free',
5    http: 'HTTP', dispatch: 'Dispatch', paused: 'Paused', available: 'Available', pause: 'Pause', resume: 'Resume', status: 'Status', offline: 'Offline', loadFailed: 'Read failed', configFailed: 'Config error', flagFailed: 'Flag update failed',
6    opened: 'Dashboard opened.', waiting: 'Dashboard waiting', fullscreenHint: 'Sidebar requires fullscreen layout. Run /tui fullscreen, then /cc-dash.', footer: 'cc-dash-kit',
7  },
8  'zh-TW': {
9    dashboard: '看板', quota: 'Claude 額度', session: '目前 Session', gpu: 'GPU', disk: '磁碟', httpProbe: '端點', pauseFlag: '派工暫停', config: '設定',
10    fiveHour: '5 小時', weekly: '每週', unavailable: '未提供', quotaHint: '訂閱額度資料可能要送出首則訊息後才有', directory: '目錄', context: 'Context', usage: '使用率', free: '可用',
11    http: 'HTTP', dispatch: '派工', paused: '暫停', available: '可用', pause: '暫停', resume: '恢復', status: '狀態', offline: '離線', loadFailed: '讀取失敗', configFailed: '設定錯誤', flagFailed: '旗標更新失敗',
12    opened: '看板已開啟。', waiting: '看板等待中', fullscreenHint: '側邊欄需要全螢幕版面。執行 /tui fullscreen,再執行 /cc-dash。', footer: 'cc-dash-kit',
13  },
14} as const
15export type Locale = keyof typeof messages
16export type MessageKey = keyof typeof messages.en
17let current: Locale = 'en'
18export const setLocale = (locale: Locale) => { current = locale }
19export const t = (key: MessageKey): string => messages[current][key]
20export function parseLocale(value: unknown): Locale | undefined {
21  if (typeof value !== 'string') return undefined
22  return /^zh(?:[_-]|$)/i.test(value) ? 'zh-TW' : value.toLowerCase().startsWith('en') ? 'en' : undefined
23}
24
src/sources/index.ts 10 lines
1import { quota } from './quota'
2import { session } from './session'
3import { gpu } from './gpu'
4import { disk } from './disk'
5import { httpProbe } from './http-probe'
6import { pauseFlag } from './pause-flag'
7import type { Source } from './types'
8
9export const sources: Record<string, Source> = { quota, session, gpu, disk, 'http-probe': httpProbe, 'pause-flag': pauseFlag }
10
src/sources/quota.ts 22 lines
1import { t } from '../i18n'
2import type { Source } from './types'
3import { identityCard } from './types'
4
5export function parseQuota(usage: any) {
6  const limits = Array.isArray(usage?.rateLimits) ? usage.rateLimits : []
7  return ['five_hour', 'seven_day'].map(kind => {
8    const item = limits.find((one: any) => one.kind === kind)
9    return { label: kind === 'five_hour' ? t('fiveHour') : t('weekly'), value: item ? `${Math.round(item.percentUsed)}%` : t('unavailable'), percent: item?.percentUsed, status: item?.percentUsed >= 90 ? 'warn' as const : 'ok' as const }
10  })
11}
12
13export const quota: Source = {
14  id: 'quota', title: t('quota'), detect: async () => true,
15  sample: async $ => {
16    let usage: any = null
17    try { usage = await $.session.usage() } catch {}
18    return { rows: parseQuota(usage), note: !Array.isArray(usage?.rateLimits) || usage.rateLimits.length === 0 ? t('quotaHint') : undefined }
19  },
20  card: identityCard,
21}
22
src/sources/session.ts 22 lines
1import { t } from '../i18n'
2import type { Source } from './types'
3import { identityCard } from './types'
4
5export const session: Source = {
6  id: 'session', title: t('session'), detect: async () => true,
7  sample: async $ => {
8    const [id, cwd, usage] = await Promise.all([
9      $.session.id().catch(() => t('unavailable')),
10      $.session.cwd().catch(() => '/'),
11      $.session.usage().catch(() => null),
12    ])
13    const name = String(cwd).split('/').filter(Boolean).pop() || '/'
14    const used = usage?.context?.percent
15    return { rows: [
16      { label: t('directory'), value: name },
17      { label: 'ID', value: String(id).slice(0, 12) },
18      { label: t('context'), value: typeof used === 'number' ? `${Math.round(used)}%` : t('unavailable'), percent: used },
19    ] }
20  }, card: identityCard,
21}
22
src/sources/gpu.ts 18 lines
1import { t } from '../i18n'
2import type { Source } from './types'
3import { identityCard, run } from './types'
4
5export function parseGpu(output: string) {
6  const [name, usedText, totalText] = output.trim().split(/\s*,\s*/)
7  const used = Number(usedText), total = Number(totalText)
8  if (!name || !Number.isFinite(used) || !Number.isFinite(total) || total <= 0) throw new Error('invalid nvidia-smi output')
9  return { rows: [{ label: name, value: `${used} / ${total} MiB`, percent: used / total * 100 }] }
10}
11
12export const gpu: Source = {
13  id: 'gpu', title: 'GPU',
14  detect: async $ => { try { await run($, ['nvidia-smi', '--help'], 1500); return true } catch { return false } },
15  sample: async $ => parseGpu(await run($, ['nvidia-smi', '--query-gpu=name,memory.used,memory.total', '--format=csv,noheader,nounits'], 3000)),
16  card: identityCard,
17}
18
src/sources/disk.ts 19 lines
1import { t } from '../i18n'
2import type { Source } from './types'
3import { identityCard, run } from './types'
4
5export function parseDisk(output: string) {
6  const line = output.trim().split('\n').at(-1) || ''
7  const parts = line.trim().split(/\s+/)
8  const text = parts.find(x => /^\d+%$/.test(x))
9  const pct = Number(text?.slice(0, -1))
10  if (!text || !Number.isFinite(pct)) throw new Error('invalid df output')
11  return { rows: [{ label: t('usage'), value: `${pct}%`, percent: pct }, { label: t('available'), value: parts[3] || '—' }] }
12}
13
14export const disk: Source = {
15  id: 'disk', title: t('disk'), detect: async () => true,
16  sample: async ($, config) => parseDisk(await run($, ['df', '-h', config.path || '/'], 3000)),
17  card: identityCard,
18}
19