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

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.
↑↓/jk, ⏎ open, ⌫ up, @ to prompt, / filter), file preview.--ff-only), push (asks first), stash and pop, the last 15 commits.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.
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.
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).
hooks/register.tsx 245 lines1import { 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}
245hooks/filelist.tsx 158 lines1import 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}
158hooks/files.tsx 136 lines1import 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}
136hooks/git.tsx 170 lines1import 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}
170hooks/lib.ts 249 lines1import 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}
249hooks/probes.ts 99 lines1import 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)]
99hooks/repo.ts 43 lines1import 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}
43hooks/system.tsx 125 lines1import 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}
125hooks/ui.tsx 84 lines1import 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))}…`
84types/index.d.ts 50 lines1export 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