SLOPSHOPPER

workbench

A pane beside the chat with Files, System and Git tabs, on macOS, Linux and Windows

newpanecommandtoaststatusprocess
v0.3.5no licenseupdated 2026-10-24dimitritholen/workbench
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · workbench
│ ┃ Workbench ✕ › fix the failing auth test and add an audit log call │ ┃ ╭─────────────────────────────────────────── │ ┃ │ 1: tab:files │ 2: tab:system │ 3: tab:git ⏺ Read(src/auth.ts) │ ┃ ╰─────────────────────────────────────────── ⎿ Read 6 lines │ ┃ workbench ⏺ Update(src/auth.ts) │ ┃ app ⎿ Added 2 lines, removed 1 line │ ┃ ╭──────────────────────────────────────────╮ ⏺ Bash(bun test) │ ┃ │ ▣ client module ./filelist.tsx │ ⎿ 3 pass, 1 fail │ ┃ ╰──────────────────────────────────────────╯ │ ┃ Files ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ ./ │ ┃ 0 items ✻ Worked for 42s · done 4:20 PM │ ┃ [click→focus] [↑↓→move] [⏎→open] [⌫→up] [@→… │ › /workbench │ ⎿ workbench: Workbench opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Workbench
╭──────────────────────────────────────────────────────────╮ │ 1: tab:files │ 2: tab:system │ 3: tab:git │ ╰──────────────────────────────────────────────────────────╯ workbench app ╭──────────────────────────────────────────────────────────╮ │ ▣ client module ./filelist.tsx │ ╰──────────────────────────────────────────────────────────╯ Files ./ 0 items [click→focus] [↑↓→move] [⏎→open] [⌫→up] [@→prompt] [/→filte…
README

workbench

A Claude Code mod: one pane beside the chat with three tabs, for the folder the session runs in. Works on macOS, Linux (WSL included) and Windows.

  • Files: a keyboard-driven list (click it, then ↑↓/jk, ⏎ open, ⌫ up, @ to prompt, / filter), file preview.
  • System: a braille CPU graph, memory and load, disks, and the top 10 processes by CPU, each with a kill button that asks first.
  • Git: branch and ahead/behind, stage and unstage per file, commit, pull (--ff-only), push (asks first), stash and pop, the last 15 commits.

Install

At the prompt of a Claude Code terminal session:

/plugin install workbench --marketplace dimitritholen/workbench

Answer y to add the marketplace, then pick a scope. From a shell: claude plugin install workbench --marketplace dimitritholen/workbench. In the desktop app: + next to the prompt box, Plugins, Add marketplace with dimitritholen/workbench, then Add plugin.

Use

The pane opens by itself once the terminal is wide enough, or with /workbench. Keys 1, 2 and 3 switch tabs while the pane has focus.

Develop

claude --plugin-dir .   # run it from a checkout
claude plugin validate .
claude plugin test .

hooks/lib.ts holds the parsers, hooks/probes.ts the per-OS commands, tests/fixtures.ts real output they are tested on (the macOS part is written by hand).

Source 10 files
hooks/register.tsx 245 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Confirm, GitView, Preview, Proc, Snapshot, Tab } from '../types'
5import type { FileListPost } from './filelist'
6import { loadPreview, mention, renderFiles } from './files'
7import { NETWORK_MS, renderGit } from './git'
8import { type Os, isConfirmed, isStaged, join, LINK } from './lib'
9import { REFRESH_MS, detectOs, killArgv, probe } from './probes'
10import { readGit, runGit } from './repo'
11import { renderSystem } from './system'
12import { block } from './ui'
13
14const PANE = 'workbench'
15const TITLE = 'Workbench'
16const TABS: { id: Tab; label: string; hotkey: string }[] = [
17  { id: 'files', label: 'Files', hotkey: '1' },
18  { id: 'system', label: 'System', hotkey: '2' },
19  { id: 'git', label: 'Git', hotkey: '3' },
20]
21const GIT_EVERY_TICKS = 2
22const HISTORY = 120
23
24// Change a shape tag when that value's fields change: it declines what an
25// earlier version stored, which outlives a reload.
26const tab = atom({ plugin: 'workbench', key: 'tab' } as const, 'files' as Tab)
27const dir = atom({ plugin: 'workbench', key: 'dir' } as const, '')
28const preview = atom({ plugin: 'workbench', key: 'preview' } as const, null as Preview | null, {
29  shape: 'preview-1',
30})
31const snapshot = atom({ plugin: 'workbench', key: 'snapshot' } as const, null as Snapshot | null, {
32  shape: 'snapshot-1',
33})
34const git = atom({ plugin: 'workbench', key: 'git' } as const, null as GitView | null, { shape: 'git-1' })
35const confirm = atom({ plugin: 'workbench', key: 'confirm' } as const, null as Confirm | null)
36const message = atom({ plugin: 'workbench', key: 'message' } as const, '')
37
38// Set on every session.start, which a reload runs too.
39let os: Os = 'linux'
40let folder = ''
41
42async function refreshSystem($: EngineInterface) {
43  const snap = await probe($, os).catch(async error => {
44    await $.ui.status(`workbench: ${error instanceof Error ? error.message : error}`)
45    return undefined
46  })
47  if (snap) await update($, snapshot, prev => ({ ...snap, history: [...(prev?.history ?? []), snap.cpu].slice(-HISTORY) }))
48}
49
50async function refreshGit($: EngineInterface) {
51  const view = await readGit($)
52  await update($, git, () => view)
53}
54
55async function refresh($: EngineInterface, t: Tab) {
56  if (t === 'system') await refreshSystem($)
57  if (t === 'git') await refreshGit($)
58}
59
60async function select($: EngineInterface, t: Tab) {
61  await update($, tab, () => t)
62  await refresh($, t)
63}
64
65let isTicking = false
66
67async function tick($: EngineInterface, count: number) {
68  if (isTicking) return
69  isTicking = true
70  try {
71    const pane = (await $.ui.panes()).find(p => p.id === PANE)
72    if (!pane?.isShown) return
73    const current = await read($, tab)
74    if (current === 'system') await refreshSystem($)
75    // git status costs more than ps; every other tick is often enough
76    if (current === 'git' && count % GIT_EVERY_TICKS === 0) await refreshGit($)
77  } finally {
78    isTicking = false
79  }
80}
81
82// True on the second press of `action` within CONFIRM_MS; the first one asks.
83async function confirmed($: EngineInterface, action: string) {
84  if (isConfirmed(await read($, confirm), action)) {
85    await update($, confirm, () => null)
86    return true
87  }
88  await update($, confirm, () => ({ action, at: Date.now() }))
89  return false
90}
91
92async function kill($: EngineInterface, p: Proc) {
93  if (!(await confirmed($, `kill:${p.pid}`))) return
94  const r = await $.process.run(killArgv(os, p.pid)).catch(() => undefined)
95  await $.ui.toast(r?.exitCode === 0 ? `Stopped ${p.name} (${p.pid}).` : `Could not stop ${p.pid}: ${r?.stderr.trim() ?? ''}`)
96  await refreshSystem($)
97}
98
99async function openFile($: EngineInterface, path: string) {
100  const shown = await loadPreview($, path)
101  await update($, preview, () => shown)
102}
103
104async function gitRun($: EngineInterface, args: string[], done: string, timeoutMs?: number) {
105  const g = await read($, git)
106  if (!g?.isRepo) return false
107  const r = await runGit($, args, g.root, timeoutMs)
108  await $.ui.toast(r.ok ? r.out.split('\n')[0] || done : `git ${args[0]}: ${r.out.slice(0, 300)}`)
109  await refreshGit($)
110  return r.ok
111}
112
113async function push($: EngineInterface) {
114  const g = await read($, git)
115  if (!g?.isRepo || !(await confirmed($, 'push'))) return
116  await gitRun($, g.upstream ? ['push'] : ['push', '-u', 'origin', g.branch], 'Pushed.', NETWORK_MS)
117}
118
119async function commit($: EngineInterface) {
120  const g = await read($, git)
121  const text = (await read($, message)).trim()
122  if (!g?.changes.some(isStaged)) return void (await $.ui.toast('Nothing staged to commit.'))
123  if (!text) return void (await $.ui.toast('Write a commit message first.'))
124  if (await gitRun($, ['commit', '-m', text], 'Committed.')) await update($, message, () => '')
125}
126
127// What the file list Client posts: open a folder or file, go up, or mention a path.
128async function fileListMessage($: EngineInterface, post: FileListPost) {
129  const here = await read($, dir)
130  if (post.type === 'tab') {
131    const t = TABS[post.index]
132    return t && select($, t.id)
133  }
134  if (post.type === 'up') return update($, dir, d => d.split('/').slice(0, -1).join('/'))
135  const path = join(here, post.name)
136  if (post.type === 'mention') return mention($, path)
137  return post.isDir ? update($, dir, () => path) : openFile($, path)
138}
139
140async function submit($: EngineInterface, text: string) {
141  await update($, message, () => text)
142  await commit($)
143}
144
145export const register: Register = on => {
146  on('session.start', async ($, e, next) => {
147    os = await detectOs($, e.cwd)
148    folder = e.cwd.split(/[\\/]/).filter(Boolean).pop() ?? e.cwd
149    await $.command.register({ name: 'workbench', description: 'Open or close the Files / System / Git pane' })
150    // A new session starts at the working directory again.
151    await update($, dir, () => '')
152    await update($, preview, () => null)
153    void $.ui.open({ id: PANE, title: TITLE })
154
155    let ticks = 0
156    $.clock.every(REFRESH_MS[os], () => void tick($, ++ticks).catch(() => undefined))
157
158    return next(e)
159  })
160
161  on('command.run', { command: 'workbench' }, async $ => {
162    // a toggle, so the pane closes without a mouse
163    if ((await $.ui.panes()).find(p => p.id === PANE)?.isShown) {
164      await $.ui.close({ id: PANE })
165      return { text: 'Workbench closed.' }
166    }
167    await $.ui.open({ id: PANE, title: TITLE, focus: true })
168    await refresh($, await read($, tab))
169    return { text: 'Workbench opened.' }
170  })
171
172  on('ui.message', { requestId: PANE }, async ($, e) => {
173    await fileListMessage($, e.data as FileListPost)
174    return {}
175  })
176
177  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
178    const ui = $.ui.resolve(e)
179    const { Box, Text, Button } = ui
180    const current = await read($, tab)
181    const columns = e.props.bodyColumns
182    const rows = e.props.scroll.bodyRows
183
184    let body
185    if (current === 'files') {
186      body = await renderFiles($, e, {
187        here: await read($, dir),
188        shown: await read($, preview),
189        columns,
190        rows,
191        onDir: path => update($, dir, () => path),
192        onFile: path => openFile($, path),
193        onBack: () => update($, preview, () => null),
194      })
195    } else if (current === 'system') {
196      body = renderSystem($, e, {
197        snap: await read($, snapshot),
198        pending: await read($, confirm),
199        columns,
200        onKill: p => kill($, p),
201      })
202    } else {
203      body = renderGit(
204        $,
205        e,
206        { g: await read($, git), pending: await read($, confirm), draft: await read($, message), columns },
207        {
208          run: (args, done, timeoutMs) => gitRun($, args, done, timeoutMs),
209          push: () => push($),
210          commit: () => commit($),
211          draft: text => update($, message, () => text),
212          submit: text => submit($, text),
213          refresh: () => refreshGit($),
214        },
215      )
216    }
217
218    return (
219      <Box flexDirection="column">
220        {block(
221          ui,
222          { title: 'workbench', right: <Text color="inactive">{folder}</Text> },
223          <Box>
224            {TABS.map((t, i) => (
225              <Box key={`tabs:${t.id}`}>
226                {i > 0 && <Text color="subtle"> │ </Text>}
227                <Button key={`tab:${t.id}`} plain hotkey={t.hotkey} onPress={() => select($, t.id)}>
228                  {t.id === current ? (
229                    <Text bold underline color="claude">
230                      {t.label}
231                    </Text>
232                  ) : (
233                    <Text color={LINK}>{t.label}</Text>
234                  )}
235                </Button>
236              </Box>
237            ))}
238          </Box>,
239        )}
240        {body}
241      </Box>
242    )
243  })
244}
245
hooks/filelist.tsx 158 lines
1import type { ClientKeyEvent, ClientPointerEvent, ClientSurface } from 'claude-code'
2import { LINK } from './lib'
3
4// What the hooks module hands the list; it answers with posts: open, up, mention.
5export type Entry = { name: string; isDir: boolean; size: string }
6export type FileListProps = { here: string; entries: Entry[]; rows: number; columns: number }
7export type FileListPost =
8  | { type: 'open'; name: string; isDir: boolean }
9  | { type: 'mention'; name: string }
10  | { type: 'up' }
11  | { type: 'tab'; index: number }
12
13type ListState = { here: string; cursor: number; offset: number; filter: string; isFiltering: boolean }
14
15const PAGE = 10
16const NAMED = new Set(['up', 'down', 'left', 'right', 'tab', 'delete', 'pageup', 'pagedown', 'home', 'end', 'escape', 'return', 'backspace', 'space'])
17
18// The newest state per instance: keys can arrive faster than redraws, so a
19// handler reads this instead of the state its render saw.
20const latest = new WeakMap<object, ListState>()
21
22export default function FileList(props: FileListProps, surface: ClientSurface<ListState>) {
23  const { Box, Text } = surface.elements
24
25  // a new folder starts at its top with no filter
26  const current = (): ListState => {
27    const st = latest.get(surface) ?? surface.state
28    return st && st.here === props.here ? st : { here: props.here, cursor: 0, offset: 0, filter: '', isFiltering: false }
29  }
30  const layout = (st: ListState) => {
31    const shown = st.filter
32      ? props.entries.filter(entry => entry.name.toLowerCase().includes(st.filter.toLowerCase()))
33      : props.entries
34    const hasFilterLine = st.isFiltering || st.filter !== ''
35    const rows = Math.max(1, (surface.rows || props.rows) - (hasFilterLine ? 1 : 0))
36    const cursor = Math.max(0, Math.min(st.cursor, shown.length - 1))
37    const offset = Math.min(Math.max(st.offset, cursor - rows + 1, 0), cursor)
38    return { st, shown, hasFilterLine, rows, cursor, offset, selected: shown[cursor] }
39  }
40  const set = (next: Partial<ListState>) => {
41    const merged = { ...current(), ...next }
42    const l = layout(merged)
43    const settled = { ...merged, cursor: l.cursor, offset: l.offset }
44    latest.set(surface, settled)
45    surface.setState(settled)
46  }
47  const open = () => {
48    const { selected } = layout(current())
49    if (selected) surface.post({ type: 'open', name: selected.name, isDir: selected.isDir })
50  }
51
52  const onKey = (e: ClientKeyEvent): void => {
53    // fast typing arrives as one event holding several characters: one at a time
54    if (e.key.length > 1 && !NAMED.has(e.key)) {
55      for (const key of e.key) onKey({ ...e, key })
56      return
57    }
58    const { st, shown, cursor, selected } = layout(current())
59    if (st.isFiltering) {
60      if (e.key === 'return') return set({ isFiltering: false })
61      if (e.key === 'backspace') return set({ filter: st.filter.slice(0, -1), cursor: 0, offset: 0 })
62      if (!NAMED.has(e.key) || e.key === 'space') {
63        if (e.ctrl || e.meta) return
64        return set({ filter: st.filter + (e.key === 'space' ? ' ' : e.key), cursor: 0, offset: 0 })
65      }
66      return
67    }
68    switch (e.key) {
69      case 'up':
70      case 'k':
71        return set({ cursor: cursor - 1 })
72      case 'down':
73      case 'j':
74        return set({ cursor: cursor + 1 })
75      case 'pageup':
76        return set({ cursor: cursor - PAGE })
77      case 'pagedown':
78        return set({ cursor: cursor + PAGE })
79      case 'home':
80      case 'g':
81        return set({ cursor: 0 })
82      case 'end':
83      case 'G':
84        return set({ cursor: shown.length - 1 })
85      case 'return':
86      case 'right':
87      case 'l':
88        return open()
89      case 'backspace':
90      case 'left':
91      case 'h':
92        return st.filter ? set({ filter: '' }) : surface.post({ type: 'up' })
93      case '@':
94        return selected && surface.post({ type: 'mention', name: selected.name })
95      // the list holds the keys once clicked: the pane's tab keys go through it
96      case '1':
97      case '2':
98      case '3':
99        return surface.post({ type: 'tab', index: Number(e.key) - 1 })
100      case '/':
101        return set({ isFiltering: true })
102    }
103  }
104  surface.onKey(onKey)
105
106  surface.onPointer((e: ClientPointerEvent) => {
107    if (e.type !== 'down' || e.button !== 'left') return
108    const { shown, hasFilterLine, cursor, offset } = layout(current())
109    const index = offset + e.y - (hasFilterLine ? 1 : 0)
110    if (index < offset || index >= shown.length) return
111    // a click selects, a click on the selected row opens it
112    if (index === cursor) open()
113    else set({ cursor: index })
114  })
115
116  const { st: state, shown, hasFilterLine, rows, cursor, offset } = layout(current())
117  const columns = surface.columns || props.columns
118  const visible = shown.slice(offset, offset + rows)
119  const hasScrollbar = shown.length > rows
120  const width = Math.max(10, columns - (hasScrollbar ? 2 : 0))
121  const thumbSize = Math.max(1, Math.round((rows * rows) / Math.max(1, shown.length)))
122  const thumbAt = Math.round((offset / Math.max(1, shown.length - rows)) * (rows - thumbSize))
123
124  return (
125    <Box flexDirection="column">
126      {hasFilterLine && (
127        <Text>
128          <Text color="warning">/</Text>
129          {state.filter}
130          {state.isFiltering && <Text inverse> </Text>}
131          <Text dimColor>
132            {'  '}
133            {shown.length} match{shown.length === 1 ? '' : 'es'}
134          </Text>
135        </Text>
136      )}
137      {shown.length === 0 && <Text dimColor>{state.filter ? 'Nothing matches.' : 'Empty folder.'}</Text>}
138      {visible.map((entry, i) => {
139        const isSelected = offset + i === cursor
140        const name = entry.isDir ? `${entry.name}/` : entry.name
141        const room = width - 2 - (entry.isDir ? 0 : entry.size.length + 1)
142        const label = name.length > room ? `${name.slice(0, Math.max(1, room - 1))}…` : name.padEnd(room)
143        const bar = !hasScrollbar ? '' : i >= thumbAt && i < thumbAt + thumbSize ? ' █' : ' │'
144        return (
145          <Text>
146            <Text color="claude">{isSelected ? '→ ' : '  '}</Text>
147            <Text bold={isSelected || entry.isDir} color={entry.isDir ? LINK : 'text'} inverse={isSelected}>
148              {label}
149            </Text>
150            {!entry.isDir && <Text dimColor> {entry.size}</Text>}
151            <Text color="subtle">{bar}</Text>
152          </Text>
153        )
154      })}
155    </Box>
156  )
157}
158
hooks/files.tsx 136 lines
1import type { EngineInterface, RenderInput } from 'claude-code'
2
3import type { Preview } from '../types'
4import type { FileListProps } from './filelist'
5import { bytes, join, LINK } from './lib'
6import { block, fit, hints, INSET } from './ui'
7
8const HIDDEN = new Set(['.git', 'node_modules'])
9const MAX_PREVIEW_BYTES = 256 * 1024
10const PREVIEW_LINES = 200
11// header block (3), this block's borders (2), the hints line (1)
12const CHROME_ROWS = 6
13
14export async function loadPreview($: EngineInterface, path: string): Promise<Preview> {
15  const stat = await $.fs.stat(path).catch(() => undefined)
16  if (!stat) return { path, note: 'Cannot read this file.' }
17  if (stat.size > MAX_PREVIEW_BYTES) return { path, note: `Too large to preview (${bytes(stat.size)}).` }
18  const text = await $.fs.read(path).catch(() => undefined)
19  if (typeof text !== 'string') return { path, note: 'Cannot read this file.' }
20  if (text.includes('\0')) return { path, note: `Binary file (${bytes(stat.size)}).` }
21  return { path, text: text.split('\n').slice(0, PREVIEW_LINES).join('\n') }
22}
23
24export async function mention($: EngineInterface, path: string) {
25  const filled = await $.prompt.fill({ text: `@${path} `, mode: 'insert' })
26  if (!filled.isFilled) await $.ui.toast('The prompt box did not take the path.')
27}
28
29export async function listFolder($: EngineInterface, here: string) {
30  return (await $.fs.list(here || undefined).catch(() => []))
31    .filter(entry => !HIDDEN.has(entry.name))
32    .sort((a, b) => Number(b.kind === 'dir') - Number(a.kind === 'dir') || a.name.localeCompare(b.name))
33}
34
35export type FilesView = {
36  here: string
37  shown: Preview | null
38  columns: number
39  rows: number
40  onDir: (path: string) => unknown
41  onFile: (path: string) => unknown
42  onBack: () => unknown
43}
44
45export async function renderFiles($: EngineInterface, e: RenderInput, v: FilesView) {
46  const ui = $.ui.resolve(e)
47  const { Box, Text, Button, Code } = ui
48  const { here, shown } = v
49  const crumb = <Text color="inactive">{`./${here}`}</Text>
50
51  if (shown) {
52    return (
53      <Box flexDirection="column">
54        {block(
55          ui,
56          { title: shown.path.split('/').pop() ?? shown.path, right: crumb, isFocused: true },
57          <Box flexDirection="column">
58            <Box gap={2}>
59              <Button key="back" plain hotkey="b" onPress={v.onBack}>
60                <Text color={LINK}>← back</Text>
61              </Button>
62              <Button key="mention-open" plain hotkey="a" onPress={() => mention($, shown.path)}>
63                <Text color="claude">@ to prompt</Text>
64              </Button>
65            </Box>
66            {shown.note ? <Text dimColor>{shown.note}</Text> : <Code source={shown.text ?? ''} path={shown.path} startLine={1} />}
67          </Box>,
68        )}
69        {hints(ui, [['b', 'back'], ['a', '@ to prompt']])}
70      </Box>
71    )
72  }
73
74  const entries = await listFolder($, here)
75  const rows = Math.max(5, v.rows - CHROME_ROWS)
76  const footer = `${entries.length} item${entries.length === 1 ? '' : 's'}`
77
78  // A Client is drawn on the terminal only, for now.
79  if (e.surface === 'terminal' && 'Client' in ui) {
80    const props: FileListProps = {
81      here,
82      rows,
83      columns: v.columns - INSET,
84      entries: entries.map(entry => ({ name: entry.name, isDir: entry.kind === 'dir', size: bytes(entry.size) })),
85    }
86    return (
87      <Box flexDirection="column">
88        {block(
89          ui,
90          { title: 'Files', right: crumb, footer, isFocused: true },
91          <ui.Client key="files" module="./filelist.tsx" props={props} height={Math.min(rows, Math.max(1, entries.length + 1))} />,
92        )}
93        {hints(ui, [
94          ['click', 'focus'],
95          ['↑↓', 'move'],
96          ['⏎', 'open'],
97          ['⌫', 'up'],
98          ['@', 'prompt'],
99          ['/', 'filter'],
100        ])}
101      </Box>
102    )
103  }
104
105  // Other surfaces: a list of buttons.
106  const nameWidth = Math.max(10, v.columns - INSET - 12)
107  return block(
108    ui,
109    { title: 'Files', right: crumb, footer, isFocused: true },
110    <Box flexDirection="column">
111      {here && (
112        <Button key="up" plain onPress={() => v.onDir(here.split('/').slice(0, -1).join('/'))}>
113          <Text color="inactive">..</Text>
114        </Button>
115      )}
116      {entries.map(entry => {
117        const path = join(here, entry.name)
118        const isDir = entry.kind === 'dir'
119        return (
120          <Box key={`row:${path}`}>
121            <Button key={`f:${path}`} plain onPress={() => (isDir ? v.onDir(path) : v.onFile(path))}>
122              <Text color={isDir ? LINK : 'text'} bold={isDir}>
123                {fit(isDir ? `${entry.name}/` : entry.name, nameWidth)}
124              </Text>
125              <Text dimColor>{isDir ? ''.padStart(8) : bytes(entry.size).padStart(8)}</Text>
126            </Button>
127            <Button key={`m:${path}`} plain onPress={() => mention($, path)}>
128              <Text color="claude"> @</Text>
129            </Button>
130          </Box>
131        )
132      })}
133    </Box>,
134  )
135}
136
hooks/git.tsx 170 lines
1import type { Color, EngineInterface, RenderInput } from 'claude-code'
2
3import type { Change, Confirm, GitView } from '../types'
4import { isConfirmed, isStaged, isUnstaged, LINK } from './lib'
5import { block, fit, hints, INSET } from './ui'
6
7export type GitActions = {
8  run: (args: string[], done: string, timeoutMs?: number) => unknown
9  push: () => unknown
10  commit: () => unknown
11  draft: (text: string) => unknown
12  submit: (text: string) => unknown
13  refresh: () => unknown
14}
15
16export type GitPaneView = { g: GitView | null; pending: Confirm | null; draft: string; columns: number }
17
18export const NETWORK_MS = 120000
19
20// A porcelain status letter as it is drawn: untracked shows as U.
21const badge = (letter: string): [string, Color] =>
22  letter === '?' ? ['U', 'claude']
23  : letter === 'A' ? ['A', 'success']
24  : letter === 'D' ? ['D', 'error']
25  : letter === 'R' || letter === 'C' ? [letter, LINK]
26  : [letter, 'warning']
27
28export function renderGit($: EngineInterface, e: RenderInput, v: GitPaneView, a: GitActions) {
29  const ui = $.ui.resolve(e)
30  const { Box, Text, Button } = ui
31  const { g, pending, draft } = v
32  if (!g) return block(ui, { title: 'Git' }, <Text dimColor>Loading…</Text>)
33  if (!g.isRepo) {
34    return block(ui, { title: 'Git' }, <Text dimColor>{g.error ?? 'This folder is not a git repository.'}</Text>)
35  }
36
37  const inner = Math.max(20, v.columns - INSET)
38  const staged = g.changes.filter(isStaged)
39  const unstaged = g.changes.filter(isUnstaged)
40  const isAsking = isConfirmed(pending, 'push')
41  const target = g.upstream ?? `origin/${g.branch}`
42
43  const fileRow = (c: Change, letter: string, key: string, args: string[], done: string, sign: string) => {
44    const [shown, color] = badge(letter)
45    return (
46      <Button key={key} plain onPress={() => a.run(args, done)}>
47        <Text color={color} bold>
48          {shown}
49        </Text>{' '}
50        {fit(c.path, inner - 4)}
51        <Text dimColor> {sign}</Text>
52      </Button>
53    )
54  }
55
56  const chip = (key: string, label: string, color: Color, onPress: () => unknown) => (
57    <Button key={key} plain onPress={onPress}>
58      <Text color={color}>{label}</Text>
59    </Button>
60  )
61
62  const status =
63    g.changes.length === 0 ? (
64      <Text color="success">✓ clean</Text>
65    ) : (
66      <Text color="warning">● {g.changes.length} changed</Text>
67    )
68
69  return (
70    <Box flexDirection="column">
71      {block(
72        ui,
73        {
74          title: g.branch || "(no branch)",
75          right: status,
76          footer: `${g.upstream ?? 'no upstream'}${g.ahead ? ` ↑${g.ahead}` : ''}${g.behind ? ` ↓${g.behind}` : ''}`,
77          isFocused: true,
78        },
79        <Box flexDirection="column">
80          {g.error && <Text color="error">{g.error}</Text>}
81          <Box gap={2} flexWrap="wrap">
82        {chip('pull', '↓ pull', LINK, () => a.run(['pull', '--ff-only'], 'Up to date.', NETWORK_MS))}
83        <Button key="push" plain onPress={a.push}>
84          {isAsking ? (
85            <Text inverse bold color="warning">
86              {` ↑ push ${g.upstream ? `${g.ahead} commit(s) ` : ''}to ${target}? `}
87            </Text>
88          ) : (
89            <Text color={LINK}>↑ push</Text>
90          )}
91        </Button>
92        {chip('stash', '⊟ stash', LINK, () => a.run(['stash'], 'Stashed.'))}
93        {g.stashes > 0 && chip('pop', `⊞ pop (${g.stashes})`, LINK, () => a.run(['stash', 'pop'], 'Stash applied.'))}
94        {chip('refresh', '↻', 'inactive', a.refresh)}
95          </Box>
96        </Box>,
97      )}
98
99      {block(
100        ui,
101        { title: 'Staged', footer: `${staged.length}` },
102        staged.length === 0 ? (
103          <Text dimColor>Nothing staged. Click a change to stage it.</Text>
104        ) : (
105          <Box flexDirection="column">
106            {staged.map(c => fileRow(c, c.index, `u:${c.path}`, ['restore', '--staged', '--', c.path], 'Unstaged.', '−'))}
107          </Box>
108        ),
109      )}
110      {block(
111        ui,
112        { title: 'Changes', footer: `${unstaged.length}` },
113        unstaged.length === 0 ? (
114          <Text dimColor>No changes in the working tree.</Text>
115        ) : (
116          <Box flexDirection="column">
117            {unstaged.map(c => fileRow(c, c.tree, `s:${c.path}`, ['add', '--', c.path], 'Staged.', '+'))}
118          </Box>
119        ),
120      )}
121
122      {block(
123        ui,
124        { title: 'Commit' },
125        <Box flexDirection="column">
126          {'Input' in ui ? (
127            <ui.Input
128              key="msg"
129              placeholder="Describe the change…"
130              value={draft}
131              submitLabel="commit"
132              onInput={value => a.draft(value)}
133              onSubmit={value => a.submit(value)}
134            />
135          ) : (
136            <Text dimColor>Commit from the terminal or desktop.</Text>
137          )}
138          <Box>
139            <Button key="commit" label={`Commit ${staged.length} file(s)`} variant="primary" onPress={a.commit} />
140          </Box>
141        </Box>,
142      )}
143
144      {block(
145        ui,
146        { title: 'Log', footer: `${g.log.length}` },
147        g.log.length === 0 ? (
148          <Text dimColor>No commits yet.</Text>
149        ) : (
150          <Box flexDirection="column">
151            {g.log.map(c => (
152              <Box justifyContent="space-between">
153                <Text wrap="truncate-end">
154                  <Text color="claude">●</Text> <Text color={LINK}>{c.hash}</Text> {c.subject}
155                </Text>
156                <Text dimColor> {c.when}</Text>
157              </Box>
158            ))}
159          </Box>
160        ),
161      )}
162      {hints(ui, [
163        ['click', 'stage/unstage'],
164        ['⏎', 'commit'],
165        ['push', 'twice'],
166      ])}
167    </Box>
168  )
169}
170
hooks/lib.ts 249 lines
1import type { Change, Commit, Disk, GitView, Proc, Snapshot } from '../types'
2
3export type Os = 'linux' | 'macos' | 'windows'
4export type CpuTimes = { idle: number; total: number }
5
6// The color of everything you can open or press: a muted teal.
7export const LINK = '#5fafaf'
8
9const numbers = (text: string) => (text.match(/[\d.]+/g) ?? []).map(Number)
10
11// --- Linux: /proc ---
12
13export function parseProcStat(text: string): CpuTimes {
14  const fields = numbers(text.split('\n')[0] ?? '')
15  // user nice system idle iowait irq softirq steal: idle + iowait is idle time
16  const idle = (fields[3] ?? 0) + (fields[4] ?? 0)
17  return { idle, total: fields.slice(0, 8).reduce((a, b) => a + b, 0) }
18}
19
20export function cpuPercent(prev: CpuTimes | undefined, now: CpuTimes): number {
21  if (!prev || now.total <= prev.total) return 0
22  return clamp(100 * (1 - (now.idle - prev.idle) / (now.total - prev.total)))
23}
24
25export function parseMeminfo(text: string) {
26  const kb = (name: string) => Number(text.match(new RegExp(`^${name}:\\s+(\\d+)`, 'm'))?.[1] ?? 0) * 1024
27  const total = kb('MemTotal')
28  return { used: total - kb('MemAvailable'), total }
29}
30
31export const parseLoadavg = (text: string) => numbers(text).slice(0, 3)
32
33// --- Unix: ps and df ---
34
35// `pid comm %cpu %mem`, a header first; a macOS comm is a path that may hold spaces.
36export function parsePs(text: string): Proc[] {
37  return text
38    .split('\n')
39    .slice(1)
40    .flatMap(line => {
41      const m = line.match(/^\s*(\d+)\s+(.+?)\s+([\d.]+)\s+([\d.]+)\s*$/)
42      if (!m) return []
43      const [, pid = '', comm = '', cpu = '', mem = ''] = m
44      return [{ pid: Number(pid), name: comm.split('/').pop() || comm, cpu: Number(cpu), mem: Number(mem) }]
45    })
46}
47
48// `df -kP`: the real disks, not tmpfs, snaps or the volumes macOS keeps for itself.
49export function parseDf(text: string, os: Os): Disk[] {
50  const seen = new Set<string>()
51  return text
52    .split('\n')
53    .slice(1)
54    .flatMap(line => {
55      const m = line.match(/^(.+?)\s+(\d+)\s+(\d+)\s+(\d+)\s+\d+%\s+(.+)$/)
56      if (!m) return []
57      const [, source = '', blocks = '', used = '', , mount = ''] = m
58      const isDevice = source.startsWith('/dev/') && !source.startsWith('/dev/loop')
59      const isReal =
60        os === 'macos'
61          ? isDevice && (!mount.startsWith('/System/Volumes/') || mount === '/System/Volumes/Data')
62          : isDevice || /^[A-Za-z]:\\$/.test(source) // WSL mounts a Windows drive as `C:\`
63      if (!isReal || seen.has(source)) return []
64      seen.add(source)
65      return [{ mount, used: Number(used) * 1024, total: Number(blocks) * 1024 }]
66    })
67}
68
69export const topByCpu = (procs: Proc[], n = 10) => [...procs].sort((a, b) => b.cpu - a.cpu).slice(0, n)
70
71// --- macOS: sysctl and vm_stat ---
72
73// `sysctl -n hw.ncpu hw.memsize vm.loadavg`: three lines, the last `{ 1.2 1.3 1.4 }`.
74export function parseSysctl(text: string) {
75  const [ncpu, memsize, load] = text.trim().split('\n')
76  return { ncpu: Number(ncpu) || 1, memsize: Number(memsize), load: numbers(load ?? '').slice(0, 3) }
77}
78
79// Used memory as Activity Monitor counts it: all but free, inactive and speculative pages.
80export function parseVmStat(text: string, memsize: number): number {
81  const page = Number(text.match(/page size of (\d+)/)?.[1] ?? 4096)
82  const pages = (name: string) => Number(text.match(new RegExp(`Pages ${name}:\\s+(\\d+)`))?.[1] ?? 0)
83  return memsize - (pages('free') + pages('inactive') + pages('speculative')) * page
84}
85
86// --- Windows: one PowerShell call answering JSON ---
87
88type WinRaw = {
89  cores: number
90  cpu: number
91  total: number
92  free: number
93  disks: { n: string; s: number; f: number }[] | { n: string; s: number; f: number }
94  top: { id: number; n: string; c: number; m: number }[] | { id: number; n: string; c: number; m: number }
95}
96const list = <T>(v: T[] | T | null | undefined): T[] => (Array.isArray(v) ? v : v == null ? [] : [v])
97
98export function parseWindows(json: string): Snapshot {
99  const raw = JSON.parse(json) as WinRaw
100  const total = raw.total * 1024
101  return {
102    cpu: clamp(raw.cpu ?? 0),
103    mem: { used: total - raw.free * 1024, total },
104    disks: list(raw.disks).map(d => ({ mount: d.n, used: d.s - d.f, total: d.s })),
105    // PercentProcessorTime is per core already (403 = four cores busy), as htop counts
106    top: topByCpu(
107      list(raw.top).map(p => ({ pid: p.id, name: p.n.replace(/#\d+$/, ''), cpu: p.c, mem: (100 * p.m) / total })),
108    ),
109  }
110}
111
112// --- Git ---
113
114type Status = Pick<GitView, 'branch' | 'upstream' | 'ahead' | 'behind' | 'changes'>
115
116// `git status --porcelain=v1 -b -z`: NUL-separated, a rename followed by its old path.
117export function parseStatus(text: string): Status {
118  const status: Status = { branch: '', ahead: 0, behind: 0, changes: [] }
119  const entries = text.split('\0')
120  for (let i = 0; i < entries.length; i++) {
121    const entry = entries[i]
122    if (!entry) continue
123    if (entry.startsWith('## ')) {
124      const m = entry.match(/^## (?:No commits yet on )?(.+?)(?:\.\.\.(\S+))?(?: \[(.*)\])?$/)
125      status.branch = m?.[1] ?? ''
126      status.upstream = m?.[2]
127      status.ahead = Number(m?.[3]?.match(/ahead (\d+)/)?.[1] ?? 0)
128      status.behind = Number(m?.[3]?.match(/behind (\d+)/)?.[1] ?? 0)
129      continue
130    }
131    const change: Change = { index: entry[0] ?? ' ', tree: entry[1] ?? ' ', path: entry.slice(3) }
132    status.changes.push(change)
133    if (change.index === 'R' || change.index === 'C') i++
134  }
135  return status
136}
137
138export const LOG_FORMAT = '%h%x1f%s%x1f%an%x1f%cr'
139
140export function parseLog(text: string): Commit[] {
141  return text
142    .split('\n')
143    .filter(Boolean)
144    .map(line => {
145      const [hash = '', subject = '', author = '', when = ''] = line.split('\x1f')
146      return { hash, subject, author, when }
147    })
148}
149
150export const isStaged = (c: Change) => c.index !== ' ' && c.index !== '?'
151export const isUnstaged = (c: Change) => c.tree !== ' '
152
153// --- Drawing helpers ---
154
155export function clamp(n: number) {
156  return Math.max(0, Math.min(100, n))
157}
158
159export function bytes(n: number) {
160  const units = ['B', 'KB', 'MB', 'GB', 'TB']
161  let i = 0
162  while (n >= 1024 && i < units.length - 1) {
163    n /= 1024
164    i++
165  }
166  return `${n >= 10 || i === 0 ? Math.round(n) : n.toFixed(1)} ${units[i]}`
167}
168
169export const join = (dir: string, name: string) => (dir ? `${dir}/${name}` : name)
170export const parent = (dir: string) => dir.split('/').slice(0, -1).join('/')
171
172// A destructive press waits for a second one within this time.
173export const CONFIRM_MS = 5000
174export const isConfirmed = (c: { action: string; at: number } | null, action: string, now = Date.now()) =>
175  c?.action === action && now - c.at < CONFIRM_MS
176
177// A bar in braille, half a cell per step: ⣿ full, ⡇ the left half, ⣀ the empty track.
178export function brailleBar(percent: number, width: number) {
179  const halves = Math.round((clamp(percent) / 100) * width * 2)
180  const used = '⣿'.repeat(Math.floor(halves / 2)) + (halves % 2 ? '⡇' : '')
181  return { used, rest: '⣀'.repeat(Math.max(0, width - used.length)) }
182}
183
184// --- Raster cells ---
185
186const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
187
188export function toBase64(bytes: Uint8Array): string {
189  let out = ''
190  for (let i = 0; i < bytes.length; i += 3) {
191    const [a = 0, b = 0, c = 0] = [bytes[i], bytes[i + 1], bytes[i + 2]]
192    const n = (a << 16) | (b << 8) | c
193    out += B64[(n >> 18) & 63]! + B64[(n >> 12) & 63]!
194    out += i + 1 < bytes.length ? B64[(n >> 6) & 63]! : '='
195    out += i + 2 < bytes.length ? B64[n & 63]! : '='
196  }
197  return out
198}
199
200export const DEFAULT_COLOR = 0x01000000
201const BASELINE = 0x4a4a4a
202
203// Green at the bottom of a graph, through yellow, to red at the top.
204export function heat(t: number): number {
205  const x = Math.max(0, Math.min(1, t))
206  const mix = (from: number[], to: number[], f: number) => from.map((c, i) => Math.round(c + ((to[i] ?? c) - c) * f))
207  const [r = 0, g = 0, b = 0] = x < 0.5 ? mix([80, 200, 120], [230, 200, 80], x * 2) : mix([230, 200, 80], [235, 90, 90], (x - 0.5) * 2)
208  return (r << 16) | (g << 8) | b
209}
210
211// Braille dot bits per cell row from the bottom: [left column, right column].
212const DOTS: [number, number][] = [
213  [0x40, 0x80],
214  [0x04, 0x20],
215  [0x02, 0x10],
216  [0x01, 0x08],
217]
218
219// An area chart of percentages in braille, two samples per cell, newest right.
220// Answers the Raster's cells: [codePoint, foreground, background] per cell.
221export function brailleChart(values: number[], columns: number, rows: number): Uint32Array {
222  const cells = new Uint32Array(columns * rows * 3)
223  const samples = values.slice(-columns * 2)
224  const pad = columns * 2 - samples.length
225  const height = (i: number) => {
226    const v = samples[i - pad]
227    return v === undefined ? 0 : Math.round((clamp(v) / 100) * rows * 4)
228  }
229  for (let cy = 0; cy < rows; cy++) {
230    const fromBottom = rows - 1 - cy
231    for (let cx = 0; cx < columns; cx++) {
232      let bits = 0
233      for (const [side, i] of [[0, cx * 2], [1, cx * 2 + 1]] as const) {
234        const h = height(i)
235        DOTS.forEach((dot, k) => {
236          if (fromBottom * 4 + k < h) bits |= dot[side]
237        })
238      }
239      const at = (cy * columns + cx) * 3
240      // an empty bottom cell draws a faint baseline, so an idle graph still reads as one
241      const isBaseline = !bits && fromBottom === 0
242      cells[at] = bits ? 0x2800 + bits : isBaseline ? 0x2800 + 0xc0 : 0x20
243      cells[at + 1] = isBaseline ? BASELINE : heat((fromBottom + 0.5) / rows)
244      cells[at + 2] = DEFAULT_COLOR
245    }
246  }
247  return cells
248}
249
hooks/probes.ts 99 lines
1import type { EngineInterface } from 'claude-code'
2
3import type { Snapshot } from '../types'
4import {
5  type CpuTimes,
6  type Os,
7  cpuPercent,
8  parseDf,
9  parseLoadavg,
10  parseMeminfo,
11  parseProcStat,
12  parsePs,
13  parseSysctl,
14  parseVmStat,
15  parseWindows,
16  topByCpu,
17} from './lib'
18
19export async function detectOs($: EngineInterface, cwd: string): Promise<Os> {
20  if (/^[A-Za-z]:[\\/]/.test(cwd) || cwd.startsWith('\\\\')) return 'windows'
21  const uname = await $.process.run(['uname', '-s']).catch(() => undefined)
22  if (!uname) return 'windows'
23  return uname.stdout.trim() === 'Darwin' ? 'macos' : 'linux'
24}
25
26// The Windows probe took 4 s through WSL; ticks never overlap (see tick in register.tsx).
27export const REFRESH_MS: Record<Os, number> = { linux: 2000, macos: 2000, windows: 5000 }
28
29// One call: PowerShell is slow to start.
30export const WINDOWS_PROBE = [
31  '$n=(Get-CimInstance Win32_ComputerSystem).NumberOfLogicalProcessors',
32  '$o=Get-CimInstance Win32_OperatingSystem',
33  '$c=(Get-CimInstance Win32_Processor | Measure-Object LoadPercentage -Average).Average',
34  '$d=@(Get-CimInstance Win32_LogicalDisk -Filter "DriveType=3" | ForEach-Object { @{ n=$_.DeviceID; s=$_.Size; f=$_.FreeSpace } })',
35  '$p=@(Get-CimInstance Win32_PerfFormattedData_PerfProc_Process | Where-Object { $_.Name -ne "_Total" -and $_.Name -ne "Idle" } | Sort-Object PercentProcessorTime -Descending | Select-Object -First 10 | ForEach-Object { @{ id=$_.IDProcess; n=$_.Name; c=$_.PercentProcessorTime; m=$_.WorkingSetPrivate } })',
36  '@{ cores=$n; cpu=$c; total=$o.TotalVisibleMemorySize; free=$o.FreePhysicalMemory; disks=$d; top=$p } | ConvertTo-Json -Compress -Depth 3',
37].join('; ')
38
39async function out($: EngineInterface, argv: string[]) {
40  const r = await $.process.run(argv, { timeoutMs: 15000 })
41  if (r.exitCode !== 0) throw new Error(`${argv[0]}: ${r.stderr.trim() || `exit ${r.exitCode}`}`)
42  return r.stdout
43}
44
45// /proc lies outside the working directory: where $.fs refuses it, cat reads it.
46async function text($: EngineInterface, path: string) {
47  const read = await $.fs.read(path).catch(() => undefined)
48  return typeof read === 'string' ? read : out($, ['cat', path])
49}
50
51// CPU% on Linux is the change between two readings of /proc/stat.
52let lastCpu: CpuTimes | undefined
53
54export async function probe($: EngineInterface, os: Os): Promise<Snapshot> {
55  if (os === 'windows') {
56    return parseWindows(await out($, ['powershell.exe', '-NoProfile', '-NonInteractive', '-Command', WINDOWS_PROBE]))
57  }
58
59  if (os === 'macos') {
60    const [sysctl, vm, df, ps] = await Promise.all([
61      out($, ['sysctl', '-n', 'hw.ncpu', 'hw.memsize', 'vm.loadavg']),
62      out($, ['vm_stat']),
63      out($, ['df', '-kP']),
64      out($, ['ps', '-Aro', 'pid,comm,%cpu,%mem']),
65    ])
66    const { ncpu, memsize, load } = parseSysctl(sysctl)
67    const procs = parsePs(ps)
68    const busy = procs.reduce((sum, p) => sum + p.cpu, 0) / ncpu
69    return {
70      cpu: Math.min(100, busy),
71      mem: { used: parseVmStat(vm, memsize), total: memsize },
72      load,
73      disks: parseDf(df, os),
74      top: topByCpu(procs),
75    }
76  }
77
78  const [stat, meminfo, loadavg, df, ps] = await Promise.all([
79    text($, '/proc/stat'),
80    text($, '/proc/meminfo'),
81    text($, '/proc/loadavg'),
82    out($, ['df', '-kP']),
83    out($, ['ps', '-eo', 'pid,comm,%cpu,%mem', '--sort=-%cpu']),
84  ])
85  const now = parseProcStat(stat)
86  const cpu = cpuPercent(lastCpu, now)
87  lastCpu = now
88  return {
89    cpu,
90    mem: parseMeminfo(meminfo),
91    load: parseLoadavg(loadavg),
92    disks: parseDf(df, os),
93    top: topByCpu(parsePs(ps)),
94  }
95}
96
97export const killArgv = (os: Os, pid: number) =>
98  os === 'windows' ? ['taskkill', '/PID', String(pid)] : ['kill', String(pid)]
99
hooks/repo.ts 43 lines
1import type { EngineInterface } from 'claude-code'
2
3import type { GitView } from '../types'
4import { LOG_FORMAT, parseLog, parseStatus } from './lib'
5
6// Never wait on a credential prompt nobody can answer: fail and say so instead.
7const ENV = { GIT_TERMINAL_PROMPT: '0' }
8
9export async function runGit($: EngineInterface, args: string[], cwd?: string, timeoutMs = 30000) {
10  try {
11    const r = await $.process.run(['git', ...args], { cwd, env: ENV, timeoutMs })
12    return { ok: r.exitCode === 0, out: (r.exitCode === 0 ? r.stdout || r.stderr : r.stderr || r.stdout).trim() }
13  } catch (error) {
14    return { ok: false, out: String(error instanceof Error ? error.message : error) }
15  }
16}
17
18const EMPTY: GitView = { isRepo: false, root: '', branch: '', ahead: 0, behind: 0, changes: [], log: [], stashes: 0 }
19
20export async function readGit($: EngineInterface): Promise<GitView> {
21  const top = await runGit($, ['rev-parse', '--show-toplevel'])
22  if (!top.ok) {
23    const notRepo = /not a git repository/i.test(top.out)
24    return { ...EMPTY, error: notRepo ? undefined : `git not found or failed: ${top.out}` }
25  }
26  const root = top.out
27  const [status, log, stash] = await Promise.all([
28    runGit($, ['status', '--porcelain=v1', '-b', '-z'], root),
29    runGit($, ['log', '-n', '15', `--format=${LOG_FORMAT}`], root),
30    runGit($, ['stash', 'list'], root),
31  ])
32  if (!status.ok) return { ...EMPTY, isRepo: true, root, error: status.out }
33  return {
34    ...EMPTY,
35    ...parseStatus(status.out),
36    isRepo: true,
37    root,
38    // an empty repository has no log yet: git log fails, the list stays empty
39    log: log.ok ? parseLog(log.out) : [],
40    stashes: stash.ok ? stash.out.split('\n').filter(Boolean).length : 0,
41  }
42}
43
hooks/system.tsx 125 lines
1import type { EngineInterface, RenderInput } from 'claude-code'
2
3import type { Confirm, Proc, Snapshot } from '../types'
4import { brailleChart, bytes, isConfirmed, toBase64, LINK } from './lib'
5import { block, fit, hints, INSET, level, meter, sparkline } from './ui'
6
7export type SystemView = {
8  snap: Snapshot | null
9  pending: Confirm | null
10  columns: number
11  onKill: (p: Proc) => unknown
12}
13
14const GRAPH_ROWS = 4
15const pct = (n: number) => `${n.toFixed(0).padStart(3)}%`
16
17export function renderSystem($: EngineInterface, e: RenderInput, v: SystemView) {
18  const ui = $.ui.resolve(e)
19  const { Box, Text, Button } = ui
20  const { snap } = v
21  if (!snap) return block(ui, { title: 'System' }, <Text dimColor>Measuring…</Text>)
22
23  const inner = Math.max(20, v.columns - INSET)
24  const memPct = snap.mem.total ? (100 * snap.mem.used) / snap.mem.total : 0
25  const history = snap.history ?? []
26  const mountWidth = Math.min(14, Math.max(...snap.disks.map(d => d.mount.length), 4))
27  const diskBar = Math.max(6, inner - mountWidth - 22)
28  // PID 7, CPU% 6, MEM% 6, kill 2, spaces 4
29  const nameWidth = Math.max(8, inner - 25)
30
31  const graph =
32    'Raster' in ui ? (
33      <ui.Raster key="cpu" columns={inner} rows={GRAPH_ROWS} cells={toBase64(new Uint8Array(brailleChart(history, inner, GRAPH_ROWS).buffer))} />
34    ) : (
35      <Text color="claude">{sparkline(history.slice(-inner)).padStart(inner)}</Text>
36    )
37
38  return (
39    <Box flexDirection="column">
40      {block(
41        ui,
42        {
43          title: 'CPU',
44          right: (
45            <Text bold color={level(snap.cpu)}>
46              {pct(snap.cpu)}
47            </Text>
48          ),
49          footer: snap.load ? `load ${snap.load.map(n => n.toFixed(2)).join(' ')}` : undefined,
50          isFocused: true,
51        },
52        graph,
53      )}
54
55      {block(
56        ui,
57        {
58          title: 'Memory',
59          right: (
60            <Text bold color={level(memPct)}>
61              {pct(memPct)}
62            </Text>
63          ),
64          footer: `${bytes(snap.mem.used)} / ${bytes(snap.mem.total)}`,
65        },
66        meter(ui, memPct, inner),
67      )}
68
69      {block(
70        ui,
71        { title: 'Disks', footer: `${snap.disks.length}` },
72        <Box flexDirection="column">
73          {snap.disks.map(d => {
74            const used = d.total ? (100 * d.used) / d.total : 0
75            return (
76              <Text>
77                <Text color={LINK}>{fit(d.mount, mountWidth)}</Text> {meter(ui, used, diskBar)}{' '}
78                <Text color={level(used)}>{pct(used)}</Text>
79                <Text dimColor> {`${bytes(d.used)}/${bytes(d.total)}`.padStart(15)}</Text>
80              </Text>
81            )
82          })}
83        </Box>,
84      )}
85
86      {block(
87        ui,
88        { title: 'Processes', footer: 'top 10 · % of one core' },
89        <Box flexDirection="column">
90          <Text bold>
91            {'    PID'} {fit('NAME', nameWidth)} {'  CPU%'} {'  MEM%'}
92          </Text>
93          {snap.top.map(p => {
94            const isAsking = isConfirmed(v.pending, `kill:${p.pid}`)
95            return (
96              <Box key={`p:${p.pid}`}>
97                <Text>
98                  <Text dimColor>{String(p.pid).padStart(7)}</Text> {fit(p.name, nameWidth)}{' '}
99                  <Text color={level(p.cpu)}>{p.cpu.toFixed(1).padStart(6)}</Text>{' '}
100                  <Text color={level(p.mem)}>{p.mem.toFixed(1).padStart(6)}</Text>{' '}
101                </Text>
102                <Button key={`kill:${p.pid}`} plain onPress={() => v.onKill(p)}>
103                  {isAsking ? (
104                    <Text inverse bold color="error">
105                      {` kill ${p.name}? `}
106                    </Text>
107                  ) : (
108                    <Text color="error" dimColor>
109                      ×
110                    </Text>
111                  )}
112                </Button>
113              </Box>
114            )
115          })}
116        </Box>,
117      )}
118      {hints(ui, [
119        ['×', 'kill (twice)'],
120        ['1-3', 'tab'],
121      ])}
122    </Box>
123  )
124}
125
hooks/ui.tsx 84 lines
1import type { BoxProps, Color, ElementConstructor, RenderChildren, TextProps } from 'claude-code'
2
3import { brailleBar, clamp } from './lib'
4
5// Box and Text are on every surface's table, so any table fits.
6export type Ui = { Box: ElementConstructor<BoxProps>; Text: ElementConstructor<TextProps> }
7
8// A round border and one cell of padding take four columns of the pane.
9export const INSET = 4
10
11const BORDER = '#555555'
12
13export const level = (percent: number): Color => (percent >= 85 ? 'error' : percent >= 60 ? 'warning' : 'success')
14
15export type BlockOptions = { title: string; footer?: string; right?: RenderChildren; isFocused?: boolean }
16
17// A ratatui-style block: the title sits in the top border, a footer in the bottom one.
18// The labels are siblings after the bordered box: an absolute box paints over
19// the siblings before it, never over its own parent's border.
20export function block(ui: Ui, o: BlockOptions, body: RenderChildren) {
21  const { Box, Text } = ui
22  // every border the same dark gray; the focused block shows in its title color
23  return (
24    <Box flexDirection="column">
25      <Box flexDirection="column" borderStyle="round" borderColor={BORDER} paddingX={1}>
26        {body}
27      </Box>
28      <Box position="absolute" top={0} left={1}>
29        <Text bold color={o.isFocused ? 'claude' : 'text'}>
30          {` ${o.title} `}
31        </Text>
32      </Box>
33      {o.right !== undefined && (
34        <Box position="absolute" top={0} right={1}>
35          <Text> {o.right} </Text>
36        </Box>
37      )}
38      {o.footer !== undefined && (
39        <Box position="absolute" bottom={0} right={1}>
40          <Text dimColor>{` ${o.footer} `}</Text>
41        </Box>
42      )}
43    </Box>
44  )
45}
46
47// `[key→action]` hints, the keys in the accent color, as a status line under the pane.
48export function hints(ui: Ui, pairs: [string, string][]) {
49  const { Text } = ui
50  return (
51    <Text wrap="truncate-end">
52      {pairs.map(([key, action]) => (
53        <Text>
54          <Text color="subtle">[</Text>
55          <Text color="warning">{key}</Text>
56          <Text color="subtle">→</Text>
57          <Text dimColor>{action}</Text>
58          <Text color="subtle">] </Text>
59        </Text>
60      ))}
61    </Text>
62  )
63}
64
65// A braille bar in its level's color; blank after it, so the columns behind still line up.
66export function meter(ui: Ui, percent: number, width: number) {
67  const { Text } = ui
68  const { used, rest } = brailleBar(percent, width)
69  return (
70    <Text>
71      <Text color={level(percent)}>{used}</Text>
72      {' '.repeat(rest.length)}
73    </Text>
74  )
75}
76
77const SPARK = '▁▂▃▄▅▆▇█'
78
79export const sparkline = (values: number[]) =>
80  values.map(v => SPARK[Math.min(SPARK.length - 1, Math.floor((clamp(v) / 100) * SPARK.length))]).join('')
81
82export const fit = (text: string, width: number) =>
83  text.length <= width ? text.padEnd(width) : `${text.slice(0, Math.max(0, width - 1))}…`
84
types/index.d.ts 50 lines
1export type Tab = 'files' | 'system' | 'git'
2
3export type Proc = { pid: number; name: string; cpu: number; mem: number }
4export type Disk = { mount: string; used: number; total: number }
5// cpu is % of the whole machine; a Proc's cpu is per core, as htop shows it.
6export type Snapshot = {
7  cpu: number
8  mem: { used: number; total: number }
9  load?: number[]
10  disks: Disk[]
11  top: Proc[]
12  // the last readings of cpu, oldest first, for the sparkline
13  history?: number[]
14}
15
16export type Preview = { path: string; text?: string; note?: string }
17
18// index and tree are the X and Y columns of `git status --porcelain`.
19export type Change = { path: string; index: string; tree: string }
20export type Commit = { hash: string; subject: string; author: string; when: string }
21export type GitView = {
22  isRepo: boolean
23  error?: string
24  root: string
25  branch: string
26  upstream?: string
27  ahead: number
28  behind: number
29  changes: Change[]
30  log: Commit[]
31  stashes: number
32}
33
34// A destructive press waits for a second one: `push` or `kill:<pid>`.
35export type Confirm = { action: string; at: number }
36
37declare module 'claude-code' {
38  interface PluginState {
39    'workbench': {
40      tab: Tab
41      dir: string
42      preview: Shaped<Preview | null>
43      snapshot: Shaped<Snapshot | null>
44      git: Shaped<GitView | null>
45      confirm: Confirm | null
46      message: string
47    }
48  }
49}
50