Your current task above the prompt and a Workbench side pane: the task and its work items, what Claude is reading and editing, git status, file search and a…

A Claude Code mod that keeps your current task in view while Claude works. A band above the prompt shows the task and its progress. A side pane shows the task's work items, what Claude is reading and editing right now, your git branch, and a file tree.
Tasks are plain markdown files in a plans/ folder in your project. You edit them by hand, with /task, or Claude edits them as it works. No accounts, no services, no network calls.

<img src="media/pane.png" alt="The Workbench pane: the task box with work items and Up next, the files box with Right now, Claude touched and the file tree, and In Flight collapsed" width="420">
<sub>Mockups drawn with the mod's own drawing code and sample data.</sub>
Needs Claude Code 2.1.287 or later, in a terminal or the desktop app's Code tab.
/plugin marketplace add eascendco/workbench
/plugin install workbench@eascend
Start a new session and the pane opens. Then start your first task:
/task new Seven-day forecast
Each task is one file in plans/ at your project root. /task new <title> writes one for you:
---
task: seven-day-forecast
project: weather-app
short: weather-app
title: Seven-day forecast
date: 2026-10-08
end: 2026-10-08
status: open
---
# Seven-day forecast
## Work items
- [x] Pick an API `spec`
- [~] Forecast screen `build`
Seven days, with the hourly view behind a tap.
- [ ] Offline cache `build`
- [?] Release build `ship`
[ ] to do, [~] in progress, [x] done, [?] a draft waiting for your approval.spec, design, build, track, test or ship.date. The next three are Up next.status becomes done and the next task moves up.## Notes section, is kept as you wrote it.Claude gets a work_items tool to create tasks, add items and update their status. Ask it to "break this task down" and it adds draft items for you to approve.
| Command | What it does |
|---|---|
/task | The current task, its work items and Up next |
/task new <title> | Start a task |
/task <n> | One work item with its details |
/task start <n>, /task done <n>, /task reopen <n> | Change an item's status |
/task approve [n] | Approve one draft, or all of them |
/task add [stage:] title -- details | Add a work item |
/task break | Ask Claude to draft the work items |
/task pane | Open the side pane |
/task <anything else> | Ask Claude, with the task and its plan file attached |
Task box. The current task with a progress bar and what's in progress. The work items, where clicking an item's square moves it along. A + Add work item button. Up next.
Files box. What Claude is reading or editing right now, your branch and how many files aren't committed. Find a file, with buttons to refresh, collapse all, show sizes, go up a folder, go back to the project and show or hide hidden files. Every file Claude touched this session. The file tree, with a dot on files Claude read or edited and on files not yet committed.
In Flight. What Claude and its subagents are doing right now, adapted from claude-flightdeck by Stephen Casella (MIT). It only watches: it never blocks or changes a tool call.
<img src="media/inflight.png" alt="The In Flight box expanded, with numbered panels: main model, architect, permission gate, agents, the turn and the session log" width="420">
file, shell and other open the last five checks of that kind, with credentials masked.Panels with nothing to show take no room, and Reset clears the box. It reads its data only while open.
Each box collapses to one row. Tasks starts open, Files and In Flight closed, and the pane remembers what you left.
The arrow at the end of the band opens and closes the pane. In the desktop app the pane draws icons; in a terminal it uses plain text.
In /config, under Workbench:
plans.on or off.on or off.If you use the skins mod, the pane takes its colors from your active skin, light or dark. Without it, the pane uses its own colors.
Your plan files, folder listings for the tree, and git status for the branch and changes. In Flight reads the session's own events (tool calls and their permission verdicts, subagent steps, context and cost) and keeps short summaries for the session only. It reads no file contents beyond the plan files, makes no network calls, and stores nothing outside your project. Run claude plugin validate on this folder to see every call it makes.
Disable it in /plugin under Installed, or:
claude plugin disable workbench@eascend
MIT. Made by eascend. The In Flight box is adapted from claude-flightdeck, MIT, Copyright (c) 2026 Stephen Casella; its notice is in hooks/deck.ts.
hooks/register.tsx 1425 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Hook, Register } from 'claude-code'
3
4import type { DevEntry, Entry, Files, Git, Group, PaneUi, Snap, Touch, Activity, WorkbenchUi, WorkItem } from '../types'
5import type { BarIn } from './draw'
6import { ACCENT, bandBarSvg, bandTitleSvg, DOING, DONE, MUTED, paneBarSvg, STAGE_COLOR } from './draw'
7import { activeItem, byStatus, clip, counts, localDate, newId, nextStatus, parseAdd, STAGES, TITLE_MAX, withStages } from './logic'
8import type { Plan, PlanFront } from './plans'
9import { allDone, devEntryOf, newPlanText, newTaskKey, parsePlan, plansDirName, setFront, setItems, snapshotOfPlans } from './plans'
10import {
11 absOf, addTouch, badge, baseOf, extOf, FILE_TOOLS, foldersTo, humanSize, isDirty, join,
12 inkOf, paletteOf, parentOf, parseStatus, relDir, rowsOf, search, THEME_KEY,
13} from './bench'
14import type { SkinCustom, SkinPrefs, Slot } from './bench'
15import type { DeckAgent, DeckArchitect, DeckCheck, DeckLogLine, DeckLoop, DeckReceipt, DeckView } from '../types'
16import {
17 adviceLine, afterCall, applyStep, bucketOf, cardTitle, DEFAULT_ARCHITECT, DEFAULT_GATE, DEFAULT_MAIN, DEFAULT_ROSTER, DEFAULT_TURN,
18 DEFAULT_USAGE, DEFAULT_VIEW, describeInput, EDIT_TOOLS, endConsult, fmtDuration, handbackOf, listOf, momentOf, normalize,
19 normalizeCard, normalizeGate, normalizeLog, noteTool, promptLine, receiptOf, recordCheck, RESET, settleCheck, shorten, startConsult, stepLoop,
20} from './deck'
21import type { DeckData } from './deck'
22import { deckKids, deckTitle } from './deck-view'
23import { caps, fileTag, hero, icon, pill, rule } from './icons'
24import type { IconName, Ink } from './icons'
25
26const TOOL = 'work_items'
27const TOOL_FULL = 'mcp__workbench__work_items'
28/** Item and task titles in lists are cut here. */
29const ROW_CHARS = 30
30
31const snapAtom = atom({ plugin: 'workbench', key: 'snap' } as const, { status: 'idle', shorts: [], prev: [], next: [] } as Snap)
32const uiAtom = atom({ plugin: 'workbench', key: 'ui' } as const, { showPrev: false } as PaneUi)
33
34type $ = EngineInterface
35
36/* ── the Workbench: the side pane (task box and file box) ── */
37
38const PANE = 'workbench'
39const TITLE = 'Workbench'
40
41const filesAtom = atom({ plugin: 'workbench', key: 'files' } as const, {
42 root: '', project: '', expanded: [], dirs: {}, showSizes: false, showHidden: false, query: '', all: [],
43} as Files)
44const gitAtom = atom({ plugin: 'workbench', key: 'git' } as const, { isRepo: false, branch: '', top: '', dirty: [] } as Git)
45const touchedAtom = atom({ plugin: 'workbench', key: 'touched' } as const, [] as Touch[])
46const activityAtom = atom({ plugin: 'workbench', key: 'activity' } as const, { busy: false } as Activity)
47const benchAtom = atom({ plugin: 'workbench', key: 'bench' } as const, { itemsOpen: true } as WorkbenchUi)
48
49// The skin /skin has on. Skins is optional (not a dependency): its refs are cast because its
50// contract is not laid here, and with it off the pane uses its own colors.
51const SKIN_PREFS = { plugin: 'skins', key: 'prefs' } as const
52const SKIN_CUSTOM = { plugin: 'skins', key: 'custom' } as const
53const SKIN_LIGHT = { plugin: 'skins', key: 'isLight' } as const
54
55const quiet = <T,>(p: Promise<T>) => p.catch(() => undefined)
56/** The label of a blank button laid over a drawn icon: two em spaces. Plain spaces collapse to one on the desktop, which left only the icon's left edge clickable. */
57const COVER = '\u2003\u2003'
58
59/* ── files ── */
60
61async function listDir($: $, dir: string): Promise<Entry[]> {
62 const list = await $.fs.list(dir)
63 const out: Entry[] = []
64 for (const x of list) {
65 let isDir = x.kind === 'dir'
66 if (x.isLink) isDir = (await quiet($.fs.stat(join(dir, x.name))))?.kind === 'dir'
67 out.push({ name: x.name, dir: isDir, size: x.size })
68 }
69 return out
70}
71
72async function loadDirs($: $, dirs: readonly string[]) {
73 const got: Record<string, Entry[]> = {}
74 for (const d of dirs) got[d] = (await quiet(listDir($, d))) ?? []
75 await update($, filesAtom, f => ({ ...f, dirs: { ...f.dirs, ...got } }))
76}
77
78async function setRoot($: $, root: string) {
79 await update($, filesAtom, f => ({ ...f, root, expanded: [], dirs: {}, query: '', all: [] }))
80 await loadDirs($, [root])
81 await refreshGit($)
82}
83
84async function refreshFiles($: $) {
85 const f = await read($, filesAtom)
86 await update($, filesAtom, x => ({ ...x, dirs: {}, all: [] }))
87 await loadDirs($, [f.root, ...f.expanded])
88 if (f.query) await loadAll($)
89 await refreshGit($)
90}
91
92async function toggleDir($: $, path: string) {
93 const f = await read($, filesAtom)
94 const isOpen = f.expanded.includes(path)
95 await update($, filesAtom, x => ({ ...x, expanded: isOpen ? x.expanded.filter(p => p !== path) : [...x.expanded, path] }))
96 if (!isOpen && !f.dirs[path]) await loadDirs($, [path])
97}
98
99/** Every file under root, for search: git's list in a repo, else a bounded walk. */
100async function loadAll($: $) {
101 const { root } = await read($, filesAtom)
102 const r = await quiet($.process.run(['git', '-C', root, 'ls-files', '-co', '--exclude-standard', '-z'], { timeoutMs: 10000 }))
103 let all: string[] = []
104 if (r && r.exitCode === 0) all = r.stdout.split('\0').filter(Boolean)
105 else {
106 const queue: [string, number][] = [[root, 0]]
107 while (queue.length && all.length < 5000) {
108 const [dir, depth] = queue.shift()!
109 for (const en of (await quiet(listDir($, dir))) ?? []) {
110 const p = join(dir, en.name)
111 if (en.dir) depth < 5 && en.name !== '.git' && en.name !== 'node_modules' && queue.push([p, depth + 1])
112 else all.push(p.slice(root.length + 1))
113 }
114 }
115 }
116 await update($, filesAtom, f => ({ ...f, all }))
117}
118
119async function reveal($: $, path: string) {
120 const f = await read($, filesAtom)
121 const folders = foldersTo(path, f.root)
122 await update($, filesAtom, x => ({ ...x, query: '', expanded: [...new Set([...x.expanded, ...folders])] }))
123 await loadDirs($, folders.filter(d => !f.dirs[d]))
124}
125
126async function refreshGit($: $) {
127 const { root } = await read($, filesAtom)
128 const head = await quiet($.process.run(['git', '-C', root, 'rev-parse', '--show-toplevel', '--abbrev-ref', 'HEAD'], { timeoutMs: 5000 }))
129 if (!head || head.exitCode !== 0) return void (await update($, gitAtom, () => ({ isRepo: false, branch: '', top: '', dirty: [] })))
130 const [top = root, branch = ''] = head.stdout.trim().split('\n')
131 const st = await quiet($.process.run(['git', '-C', top, 'status', '--porcelain=v1', '-z', '-uall'], { timeoutMs: 10000 }))
132 const dirty = st && st.exitCode === 0 ? parseStatus(st.stdout, top) : []
133 await update($, gitAtom, () => ({ isRepo: true, branch, top, dirty }))
134}
135
136/* ── work items, written by the plan writer below ── */
137
138async function save($: $, key: string, fn: (e: DevEntry) => DevEntry) {
139 const msg = await writeEntry($, key, fn).catch((err: Error) => String(err.message ?? err))
140 if (msg !== 'Saved') void $.ui.toast(msg)
141}
142
143const setStatus = ($: $, key: string, id: string, status: WorkItem['status']) =>
144 save($, key, x => ({ ...x, items: x.items.map(i => (i.id === id ? { ...i, status } : i)) }))
145
146/* ── drawing ── */
147
148const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
149const shortDate = (d: string) => `${MONTHS[Number(d.slice(5, 7)) - 1] ?? ''} ${Number(d.slice(8, 10))}`
150
151async function paletteNow($: $) {
152 const prefs = (await quiet($.state.get(SKIN_PREFS as never)))?.value as SkinPrefs
153 const custom = (await quiet($.state.get(SKIN_CUSTOM as never)))?.value as SkinCustom
154 const isLight = (await quiet($.state.get(SKIN_LIGHT as never)))?.value as boolean | undefined
155 return paletteOf(prefs, custom, isLight)
156}
157
158async function openPane($: $) {
159 await $.ui.open({ id: PANE, title: TITLE })
160}
161
162/** The band's chevron and `/task pane`. */
163async function togglePane($: $) {
164 const isUp = (await $.ui.panes()).some(p => p.id === PANE)
165 if (isUp) await $.ui.close({ id: PANE })
166 else await openPane($)
167}
168
169/** At session start: the tree on the session's folder, and the pane opens unless the setting says no. */
170async function startWorkbench($: $) {
171 const cwd = await $.session.cwd()
172 await update($, filesAtom, f => ({ ...f, project: cwd }))
173 void setRoot($, cwd)
174 // The sections as last left, from the store.
175 const sections = sectionsOf(await quiet($.store.get(SECTIONS)))
176 await update($, benchAtom, u => ({ ...u, sections }))
177 if (settings.openPane) void openPane($)
178}
179
180/** After each turn: commits and checkouts happen in Bash, so the branch line catches up. */
181/** Which Workbench sections are open, kept across sessions: Tasks open, Files and In Flight collapsed until changed. */
182const SECTIONS = 'sections'
183type Sections = { tasks: boolean; files: boolean; deck: boolean }
184const sectionsOf = (v: unknown): Sections => ({ tasks: true, files: false, deck: false, ...(v && typeof v === 'object' ? (v as Partial<Sections>) : {}) })
185async function toggleSection($: $, k: keyof Sections) {
186 const ui = await update($, benchAtom, u => {
187 const cur = sectionsOf(u.sections)
188 return { ...u, sections: { ...cur, [k]: !cur[k] } }
189 })
190 await quiet($.store.set(SECTIONS, ui.sections))
191}
192
193/* ── the In Flight card: its state, read and drawn here; its hooks are further down ── */
194
195const mainAtom = atom({ plugin: 'workbench', key: 'deckMain' } as const, DEFAULT_MAIN)
196const usageAtom = atom({ plugin: 'workbench', key: 'deckUsage' } as const, DEFAULT_USAGE)
197const archAtom = atom({ plugin: 'workbench', key: 'deckArchitect' } as const, DEFAULT_ARCHITECT)
198const gateAtom = atom({ plugin: 'workbench', key: 'deckGate' } as const, DEFAULT_GATE)
199const agentsAtom = atom({ plugin: 'workbench', key: 'deckAgents' } as const, [] as DeckAgent[])
200const loopsAtom = atom({ plugin: 'workbench', key: 'deckLoops' } as const, [] as DeckLoop[])
201const logAtom = atom({ plugin: 'workbench', key: 'deckLog' } as const, [] as DeckLogLine[])
202const turnAtom = atom({ plugin: 'workbench', key: 'deckTurn' } as const, DEFAULT_TURN)
203const receiptAtom = atom({ plugin: 'workbench', key: 'deckReceipt' } as const, null as DeckReceipt | null)
204const viewAtom = atom({ plugin: 'workbench', key: 'deckView' } as const, DEFAULT_VIEW)
205const rosterAtom = atom({ plugin: 'workbench', key: 'deckRoster' } as const, DEFAULT_ROSTER)
206
207async function readDeck($: $): Promise<DeckData> {
208 const [main, usage, arch, gate, cards, loops, log, turn, receipt, view, now] = await Promise.all([
209 getMain($), getUsage($), getArch($), getGate($), getCards($), getLoops($), getLog($), getTurn($), read($, receiptAtom), getView($), $.clock.now(),
210 ])
211 return { main, usage, arch, gate, cards, loops, log, turn, receipt, view, now }
212}
213
214/** The card's Reset: agents, checks, consults, log and the turn clear; cost, rate limits and compactions stay. */
215async function resetDeck($: $) {
216 await update($, mainAtom, RESET.main)
217 await update($, archAtom, () => DEFAULT_ARCHITECT)
218 await update($, gateAtom, () => DEFAULT_GATE)
219 await update($, agentsAtom, () => [])
220 await update($, loopsAtom, () => [])
221 await update($, logAtom, () => [])
222 await update($, turnAtom, () => DEFAULT_TURN)
223 await update($, receiptAtom, () => null)
224 await update($, viewAtom, () => DEFAULT_VIEW)
225 await update($, usageAtom, RESET.usage)
226}
227
228const afterTurnWorkbench = ($: $) => {
229 void quiet(refreshGit($))
230 // The turn is over: Right now goes back to idle.
231 void quiet(update($, activityAtom, a => ({ ...a, busy: false })))
232}
233
234
235/* ── Plan files: the source of truth, read from disk ── */
236
237/** The plans folder this session works in, and the plans read from it last. */
238let plansDir: string | undefined
239let plans: Plan[] = []
240
241/** The settings from /config (the manifest's userConfig), read when the module loads. */
242const settings = { plansDir: 'plans', openPane: true, band: true }
243
244/** The nearest plans folder at or above the session's folder, stopping at the home folder. */
245async function findPlansDir($: $) {
246 const home = ((await $.env.get('HOME')) ?? '').replace(/\/+$/, '')
247 let dir = (await $.session.cwd()).replace(/\/+$/, '')
248 while (dir && dir !== home && dir !== '/') {
249 if (await $.fs.exists(`${dir}/${settings.plansDir}`)) return `${dir}/${settings.plansDir}`
250 dir = dir.slice(0, dir.lastIndexOf('/'))
251 }
252 return undefined
253}
254
255async function loadPlans($: $) {
256 plansDir = await findPlansDir($)
257 return (plans = plansDir ? await loadPlansIn($, plansDir) : [])
258}
259
260async function refresh($: $) {
261 try {
262 await loadPlans($)
263 const snap: Snap = plansDir
264 ? snapshotOfPlans(plans, plansDir.slice(0, -settings.plansDir.length - 1))
265 : { status: 'none', shorts: [], prev: [], next: [] }
266 await update($, snapAtom, () => snap)
267 return snap
268 } catch (err) {
269 const snap: Snap = { status: 'error', message: String((err as Error).message ?? err), shorts: [], prev: [], next: [] }
270 await update($, snapAtom, () => snap)
271 return snap
272 }
273}
274
275/** Applies `fn` to one plan's work items and writes the file; a plan with every item done is marked done. */
276async function writeEntry($: $, key: string, fn: (e: DevEntry) => DevEntry): Promise<string> {
277 await loadPlans($)
278 const plan = plans.find(p => p.task === key)
279 if (!plan) return `Not saved: no plan file for ${key}`
280 const entry = withStages(fn(devEntryOf(plan)))
281 let text = setItems(plan.text, entry.items)
282 if (allDone(entry.items) && plan.status !== 'done') text = setFront(text, { ...plan, status: 'done' })
283 await $.fs.write(plan.file, text)
284 await refresh($)
285 return 'Saved'
286}
287
288/** `/task new <title>` and the tool's "create": a new plan file, today's date unless one is given. */
289async function createTask($: $, title: string, date?: string): Promise<string> {
290 const t = title.trim()
291 if (!t) return 'Give the task a title: /task new <title>'
292 await loadPlans($)
293 const dir = plansDir ?? `${(await $.session.cwd()).replace(/\/+$/, '')}/${settings.plansDir}`
294 const project = baseOf(dir.slice(0, -settings.plansDir.length - 1)) || 'project'
295 const key = newTaskKey(t, plans.map(p => p.task))
296 const day = date && /^\d{4}-\d{2}-\d{2}$/.test(date) ? date : localDate()
297 const front: PlanFront = { task: key, project, short: project, title: t, date: day, end: day, status: 'open' }
298 const file = `${dir}/${key}.md`
299 await $.fs.write(file, newPlanText(front, []))
300 await refresh($)
301 return `Created ${file} (task ${key})`
302}
303
304async function loadPlansIn($: $, dir: string) {
305 const files = (await $.fs.list(dir)).filter(f => f.kind === 'file' && f.name.endsWith('.md'))
306 const read = await Promise.all(files.map(async f => parsePlan(await $.fs.read(`${dir}/${f.name}`), `${dir}/${f.name}`)))
307 return read.filter((p): p is Plan => !!p)
308}
309
310/* ── shared view values ── */
311
312const dateShort = (d: string) => new Date(d + 'T00:00:00').toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
313const md = (d: string) => d.slice(5).replace('-', '/')
314const when = (g: Group) => (g.start === g.end ? md(g.start) : `${md(g.start)}→${md(g.end)}`)
315const openDate = (g: Group) => g.parts.find(p => p.status !== 'done')?.date ?? g.start
316const box = (s: WorkItem['status']) => ({ draft: '[?]', todo: '[ ]', doing: '[~]', done: '[x]' })[s]
317const GLYPH: Record<WorkItem['status'], string> = { draft: '◌', todo: '□', doing: '◧', done: '■' }
318
319function barIn(g: Group): BarIn {
320 const c = counts(g.items)
321 const n = byStatus(g.items)
322 const now = activeItem(g.items.filter(i => i.status !== 'done'))
323 const finished = c.total > 0 && c.done === c.total
324 const pill = finished ? 'done' : !g.items.length ? 'break down' : !c.total ? `${n.draft} draft${n.draft === 1 ? '' : 's'}` : (now?.stage ?? 'build')
325 // The bar takes the current stage's color, as the pane's stage tags do.
326 return { done: c.done, doing: n.doing, total: c.total, pill, finished, color: now ? STAGE_COLOR[now.stage] : undefined }
327}
328
329/** The item the band names: in progress, else the next one to do. */
330const inHand = (g: Group) => activeItem(g.items.filter(i => i.status === 'doing' || i.status === 'todo'))
331
332function breakPrompt(g: Group) {
333 return [
334 `Break the current dev task into work items.`,
335 `Task: "${g.title}" (${g.short}, ${when(g)}, key ${g.key}). Its plan file: ${g.file ?? 'none yet'}.`,
336 g.items.length ? `Existing items: ${g.items.map(i => `${i.stage}: ${i.title}`).join('; ')}.` : '',
337 `Read the project's docs and code as needed. Use only the stages that apply, from: ${STAGES.join(', ')} (track = analytics or monitoring for the feature).`,
338 `Each item is one concrete, checkable step; 3 to 10 items. Titles are short labels of 2 to 5 words, at most ${ROW_CHARS} characters, no sentences and no trailing period (good: "Auth screen", "Seed demo data"). Put the what, why and done-when in "details", one or two sentences.`,
339 `Then call the ${TOOL} tool with action "add", task "${g.key}" and draft true, and show me the list.`,
340 ]
341 .filter(Boolean)
342 .join('\n')
343}
344
345const NONE = 'No tasks here yet. `/task new <title>` starts one: a plan file in `' + 'plans/` that you, /task and Claude all edit.'
346
347const USAGE = [
348 '`/task` the task, its work items and Up next',
349 '`/task <n>` one item with its details',
350 '`/task start <n>` · `/task done <n>` · `/task reopen <n>`',
351 '`/task approve [n]` approve one draft, or all',
352 '`/task add [stage:] title -- details`',
353 '`/task break` ask Claude to draft the work items',
354 '`/task pane` open the side pane',
355 '`/task new <title>` start a task: a plan file in the plans folder',
356 '`/task refresh` re-read the plan files',
357 '`/task <anything else>` ask Claude, with the task and its items attached',
358].join('\n')
359const textBar = (b: BarIn, width = 20) => {
360 const f = b.total ? Math.round((width * b.done) / b.total) : 0
361 return '█'.repeat(f) + '░'.repeat(width - f)
362}
363
364/** What `/task` prints: the pane as markdown, for the terminal where the pane is out of sight. */
365function textView(snap: Snap, selected?: string) {
366 const cur = snap.cur!
367 const bar = barIn(cur)
368 const pct = bar.total ? Math.round((bar.done / bar.total) * 100) : 0
369 const act = activeItem(cur.items, selected)
370 const head = [
371 `**${cur.title}**`,
372 `${cur.short} · ${dateShort(openDate(cur))}${cur.size ? ' · ' + cur.size : ''}${cur.stages.length ? ' · ' + cur.stages.join(' → ') : ''}`,
373 bar.total ? `\`${textBar(bar)}\` ${bar.pill} · ${pct}%` : '',
374 ].filter(Boolean)
375 const now = act ? ['', `**${act.status === 'doing' ? 'In progress' : act.status === 'draft' ? 'Draft' : act.status === 'done' ? 'Done' : 'Up first'}: ${act.title}** · ${act.stage}`, act.details ?? '_No details yet._'] : []
376 const items = cur.items.length
377 ? ['', '**Work items**', ...cur.items.map((i, k) => `${k + 1}. ${GLYPH[i.status]} ${clip(i.title, ROW_CHARS)} · ${i.stage}${i.status === 'draft' ? ' · draft' : ''}`)]
378 : ['', '**Work items**', 'Not broken down yet: `/task break`']
379 const next = snap.next.length ? ['', '**Up next**', ...snap.next.map(g => `- ${clip(g.title, ROW_CHARS)} · ${dateShort(openDate(g))}`)] : []
380 return [...head, ...now, ...items, ...next].join('\n')
381}
382
383/** Sends a prompt once the command has finished: a command holds its turn, so submitting inside it is refused. */
384function submitLater($: $, text: string) {
385 $.clock.after(300, () => void $.prompt.submit({ text }))
386}
387
388/** `/task <words>`: the person's request, with the task and its plan file, for Claude to act on. */
389function workPrompt(snap: Snap, request: string) {
390 const g = snap.cur!
391 const items = g.items.map((i, k) => `${k + 1}. [${i.status}] ${i.stage}: ${i.title}${i.details ? `: ${i.details}` : ''}`)
392 return [
393 request,
394 '',
395 `Current task: "${g.title}" (${g.short}, key ${g.key}). Its plan file: ${g.file ?? 'none yet'}.`,
396 items.length ? `Work items:\n${items.join('\n')}` : 'It has no work items yet: break it down first with the work_items tool (action "add", draft true).',
397 snap.next.length ? `Up next after it: ${snap.next.map(x => x.title).join('; ')}.` : '',
398 `As you work, keep the plan file current: mark an item [~] when you start it and [x] when it is finished (or use the ${TOOL} tool). Add notes under the item or in the file's Notes section.`,
399 ]
400 .filter(Boolean)
401 .join('\n')
402}
403
404/** What `/task <n>` prints: one item whole. */
405function itemView(it: WorkItem, n: number) {
406 const state = { draft: 'draft', todo: 'to do', doing: 'in progress', done: 'done' }[it.status]
407 return [`**${n}. ${it.title}**`, `${it.stage} · ${state}`, '', it.details ?? '_No details yet._'].join('\n')
408}
409
410/** After each turn: Claude may have edited a plan file, so re-read it. */
411const afterTurn: Hook<'turn.complete'> = async ($, e, next) => {
412 const r = await next(e)
413 await quiet(deckTurnDone($, e))
414 afterTurnWorkbench($)
415 if (plansDir) await refresh($)
416 return r
417}
418
419/** `/task` and `/workbench:task`: the view, one item, or a change to an item. */
420async function taskCommand($: $, e: { args?: string }) {
421 const args = (e.args ?? '').trim()
422 const [verb = '', ...rest] = args.split(/\s+/)
423 if (verb === 'new') return { text: await createTask($, rest.join(' ')) }
424 const fresh = !args || verb === 'refresh' || verb === 'pane'
425 const snap = fresh || (await read($, snapAtom)).status !== 'ready' ? await refresh($) : await read($, snapAtom)
426 if (verb === 'pane') await openPane($)
427 if (snap.status === 'none') return { text: NONE }
428 if (snap.status === 'error') return { text: 'Could not read the plans: ' + snap.message }
429 const cur = snap.cur
430 if (!cur) return { text: 'No open tasks here. `/task new <title>` starts one.' }
431 const ui = await read($, uiAtom)
432 if (fresh) return { text: textView(snap, ui.selected) }
433
434 const nth = (v?: string) => cur.items[Number(v) - 1]
435 const missing = (v?: string) => ({ text: `No item ${v ?? ''}. ${cur.items.map((i, k) => `${k + 1} ${i.title}`).join(' · ')}` })
436 if (/^\d+$/.test(verb)) {
437 const it = nth(verb)
438 if (!it) return missing(verb)
439 await update($, uiAtom, u => ({ ...u, selected: it.id }))
440 return { text: itemView(it, Number(verb)) }
441 }
442 if (verb === 'break') {
443 submitLater($, breakPrompt(cur))
444 return { text: `Asking Claude to draft work items for "${cur.title}".` }
445 }
446 if (verb === 'add') {
447 const { stage, title, details } = parseAdd(rest.join(' '), ui.stage ?? cur.stages[0] ?? 'build')
448 if (!title) return { text: 'Usage: /task add [stage:] title -- details' }
449 const msg = await writeEntry($, cur.key, x => ({ ...x, items: [...x.items, { id: newId(), title, stage, status: 'todo', ...(details ? { details } : {}) }] }))
450 return { text: `${msg}: ${stage}: ${title}` }
451 }
452 if (verb === 'approve') {
453 const it = rest[0] ? nth(rest[0]) : undefined
454 if (rest[0] && !it) return missing(rest[0])
455 const n = it ? 1 : cur.items.filter(i => i.status === 'draft').length
456 if (!n) return { text: 'No drafts to approve.' }
457 const msg = await writeEntry($, cur.key, x => ({ ...x, items: x.items.map(i => (i.status === 'draft' && (!it || i.id === it.id) ? { ...i, status: 'todo' } : i)) }))
458 return { text: `${msg}: approved ${it ? it.title : `${n} draft${n === 1 ? '' : 's'}`}` }
459 }
460 const to = ({ start: 'doing', done: 'done', reopen: 'todo' } as const)[verb as 'start' | 'done' | 'reopen']
461 if (to) {
462 const it = nth(rest[0])
463 if (!it) return missing(rest[0])
464 const msg = await writeEntry($, cur.key, x => ({ ...x, items: x.items.map(i => (i.id === it.id ? { ...i, status: to } : i)) }))
465 return { text: `${msg}: ${it.title} → ${to}` }
466 }
467 if (verb === 'help') return { text: USAGE }
468 // Anything else is a request in words: hand it to Claude with the task and every item's details.
469 submitLater($, workPrompt(snap, args))
470 return { text: textView(snap, ui.selected) }
471}
472
473/* ── In Flight's hooks: they watch the session and keep the card's state; each passes its event on unchanged ── */
474
475/** Agent types and server tools that count as the on-call architect. */
476const ARCHITECT = /advisor|architect/i
477
478type ServerBlock = { type: string; id?: string; name?: string; tool_use_id?: string }
479
480// Every read goes through these, so a value saved under an older shape still reads after a reload.
481const getMain = async ($: $) => normalize(DEFAULT_MAIN, await read($, mainAtom))
482const getUsage = async ($: $) => normalize(DEFAULT_USAGE, await read($, usageAtom))
483async function getArch($: $): Promise<DeckArchitect> {
484 const a = normalize(DEFAULT_ARCHITECT, await read($, archAtom))
485 return { ...a, consults: listOf(a.consults), ids: listOf(a.ids), seen: listOf(a.seen) }
486}
487const getGate = async ($: $) => normalizeGate(await read($, gateAtom))
488const getCards = async ($: $) => listOf<unknown>(await read($, agentsAtom)).map(normalizeCard)
489const getLoops = async ($: $) => listOf<DeckLoop>(await read($, loopsAtom))
490const getLog = async ($: $) => normalizeLog(await read($, logAtom))
491const getTurn = async ($: $) => normalize(DEFAULT_TURN, await read($, turnAtom))
492const getView = async ($: $): Promise<DeckView> => normalize(DEFAULT_VIEW, await read($, viewAtom))
493const getRoster = async ($: $) => ({ architectTypes: listOf<string>(normalize(DEFAULT_ROSTER, await read($, rosterAtom)).architectTypes) })
494
495async function say($: $, who: string, text: string, kind: DeckLogLine['kind'] = 'info', agentId: string | null = null) {
496 const line: DeckLogLine = { at: await $.clock.now(), who, text, kind, agentId }
497 await update($, logAtom, list => [...normalizeLog(list), line].slice(-60))
498}
499
500async function whoIs($: $, agentId: string | undefined) {
501 if (!agentId) return 'main'
502 const card = (await getCards($)).find(c => c.id === agentId)
503 return card ? shorten(cardTitle(card), 14) : 'agent'
504}
505
506async function consultStarted($: $, id: string, via: string) {
507 const moment = momentOf(await getTurn($))
508 const at = await $.clock.now()
509 await update($, archAtom, a => startConsult(normalize(DEFAULT_ARCHITECT, a), { id, at, moment, via }))
510 if (moment === 'before done') await update($, turnAtom, x => ({ ...normalize(DEFAULT_TURN, x), isReviewing: true }))
511 await say($, 'architect', `${moment} · ${via}`, 'consult')
512}
513
514async function consultEnded($: $, advice: string | null, id?: string) {
515 const at = await $.clock.now()
516 const first = advice?.split('\n').find(l => l.trim()) ?? null
517 const text = first ? shorten(first.replace(/^[#>*\s-]+/, ''), 160) : null
518 await update($, archAtom, a => endConsult(normalize(DEFAULT_ARCHITECT, a), at, text, id))
519 await update($, turnAtom, t => ({ ...normalize(DEFAULT_TURN, t), isReviewing: false }))
520 await say($, 'architect', text ? `advice: ${shorten(text, 60)}` : 'advice returned', 'consult')
521}
522
523async function noteAdvice($: $, advice: string) {
524 await update($, archAtom, x => ({ ...normalize(DEFAULT_ARCHITECT, x), lastAdvice: advice }))
525 await say($, 'architect', `advice: ${shorten(advice, 60)}`, 'consult')
526}
527
528const isArchitectType = async ($: $, type: string) => ARCHITECT.test(type) || (await getRoster($)).architectTypes.includes(type)
529
530/** The session's cost read fresh, not from the last measurement: the receipt subtracts two of these. */
531const costNow = async ($: $) => (await $.session.usage().catch(() => null))?.cost?.usd ?? null
532
533async function noteMode($: $, mode: string | undefined) {
534 if (mode) await update($, mainAtom, m => (normalize(DEFAULT_MAIN, m).mode === mode ? normalize(DEFAULT_MAIN, m) : { ...normalize(DEFAULT_MAIN, m), mode }))
535}
536
537/* ── hooks: each passes its event on unchanged ── */
538
539// tool.check carries no loop id; the tool.call around it does, keyed by the call's id.
540const callLoop = new Map<string, string | null>()
541type CallIn = Parameters<Hook<'tool.call'>>[1]
542type CallOut = Awaited<ReturnType<Hook<'tool.call'>>>
543type TurnIn = Parameters<Hook<'turn.complete'>>[1]
544
545/** After a tool call: settle its permission check, note it on its agent's card, log edits, errors and refusals. */
546async function noteCall($: $, e: CallIn, ran: CallOut) {
547 const didRun = ran.deny === undefined
548 // Settle this call's pending ask, if it had one; skip the write (and the redraw) otherwise.
549 const g0 = await getGate($)
550 if (settleCheck(g0, e.tool_use_id, didRun) !== g0) await update($, gateAtom, g => settleCheck(normalizeGate(g), e.tool_use_id, didRun))
551 // A background agent hands its report back through this tool; an architect's report is its advice.
552 if (String(e.tool) === 'SubagentHandback') {
553 const message = (e as unknown as { message?: unknown }).message
554 const a = e.agentId ? await getArch($) : null
555 if (a && e.agentId && a.ids.includes(e.agentId) && typeof message === 'string') {
556 const advice = adviceLine(message)
557 if (advice && advice !== a.lastAdvice) await noteAdvice($, advice)
558 }
559 return
560 }
561 if (e.tool === 'Agent') return
562 const hasFailed = didRun && ran.isError === true
563 const isEdit = !hasFailed && didRun && EDIT_TOOLS.has(e.tool)
564 const t0 = await getTurn($)
565 if (isEdit || hasFailed || (!e.agentId && t0.errorStreak > 0))
566 await update($, turnAtom, t => afterCall(normalize(DEFAULT_TURN, t), { inSubagent: Boolean(e.agentId), hasFailed, isEdit }))
567 const text = shorten(describeInput(e.tool, e), 64)
568 if (e.agentId) {
569 const id = e.agentId
570 await update($, agentsAtom, list =>
571 listOf<unknown>(list)
572 .map(normalizeCard)
573 .map(c => (c.id === id ? noteTool(c, { tool: e.tool, text, isError: hasFailed || ran.deny !== undefined }) : c)),
574 )
575 }
576 // The log keeps what is worth a glance: refusals, errors and edits; the rest is on the cards.
577 if (ran.deny !== undefined) await say($, await whoIs($, e.agentId), `${text} denied`, 'error', e.agentId ?? null)
578 else if (hasFailed) await say($, await whoIs($, e.agentId), `${text} ✗`, 'error', e.agentId ?? null)
579 else if (isEdit) await say($, await whoIs($, e.agentId), text, 'info', e.agentId ?? null)
580}
581
582/** In Flight at session start: the first usage reading. A host without usage just starts without it. */
583async function deckStart($: $) {
584 const u = await $.session.usage().catch(() => null)
585 if (u)
586 await update($, usageAtom, x => ({
587 ...normalize(DEFAULT_USAGE, x),
588 pct: u.context.percent ?? null,
589 tokens: u.context.tokens ?? null,
590 window: u.context.window,
591 costUsd: u.cost?.usd ?? null,
592 limits: u.rateLimits.map(r => ({ kind: r.kind, pct: r.percentUsed })),
593 }))
594}
595
596/** In Flight around a tool call: it runs the call, then notes it; nothing after the call may throw into it. */
597async function deckToolCall($: $, e: CallIn, next: (e: CallIn) => Promise<CallOut>): Promise<CallOut> {
598 callLoop.set(e.tool_use_id, e.agentId ?? null)
599 const ran = await next(e).finally(() => callLoop.delete(e.tool_use_id))
600 await noteCall($, e, ran).catch(() => undefined)
601 return ran
602}
603
604/** In Flight after a turn, the main loop's or an agent's: the receipt, or the agent's card done. */
605async function deckTurnDone($: $, e: TurnIn) {
606 const id = e.agentId
607 const now = await $.clock.now()
608 if (!id) {
609 const [t, cards, cost] = await Promise.all([getTurn($), getCards($), costNow($)])
610 const r = receiptOf(t, { durationMs: e.durationMs, agentsSince: cards.filter(c => c.spawnedAt >= t.startedAt).length, costNow: cost, reason: e.reason })
611 await update($, receiptAtom, () => r)
612 await update($, mainAtom, m => ({ ...normalize(DEFAULT_MAIN, m), isRunning: false }))
613 return
614 }
615 if ((await getArch($)).ids.includes(id)) {
616 await consultEnded($, e.answer, id)
617 return
618 }
619 const cards = await getCards($)
620 if (cards.some(c => c.id === id)) {
621 const status = e.reason === 'answer' ? 'done' : e.reason === 'aborted' ? 'stopped' : 'failed'
622 await update($, agentsAtom, list =>
623 listOf<unknown>(list)
624 .map(normalizeCard)
625 .map(c => (c.id === id ? { ...c, status, endedAt: now, answer: shorten(e.answer, 400) } : c)),
626 )
627 const card = cards.find(c => c.id === id)
628 await say($, await whoIs($, id), status === 'done' ? `done · ${card ? fmtDuration(now - card.spawnedAt) : ''}` : status, status === 'done' ? 'done' : 'error', id)
629 } else {
630 await update($, loopsAtom, l => listOf<DeckLoop>(l).map(x => (x.id === id ? { ...x, isDone: true, lastAt: now } : x)))
631 }
632}
633
634export const register: Register = (on, options) => {
635 settings.plansDir = plansDirName(options.plans_dir)
636 settings.openPane = options.open_pane !== 'off'
637 settings.band = options.band !== 'off'
638
639 // In Flight: the events only it watches. session.start, tool.call and turn.complete go through the Workbench's own hooks.
640 on('session.end', async ($, e, next) => {
641 if (e.reason === 'clear') await resetDeck($)
642 return next(e)
643 })
644
645 on('classic.UserPromptSubmit', async ($, e, next) => {
646 await noteMode($, e.permission_mode)
647 return next(e)
648 })
649
650 on('agent.offer', async ($, e, next) => {
651 const offered = await next(e)
652 if (ARCHITECT.test(e.agent))
653 await update($, rosterAtom, r => {
654 const x = { architectTypes: listOf<string>(normalize(DEFAULT_ROSTER, r).architectTypes) }
655 return x.architectTypes.includes(e.agent) ? x : { architectTypes: [...x.architectTypes, e.agent].slice(-20) }
656 })
657 return offered
658 })
659
660 on('turn.start', async ($, e, next) => {
661 const [now, cost] = await Promise.all([$.clock.now(), costNow($)])
662 await update($, turnAtom, () => ({ ...DEFAULT_TURN, startedAt: now, costAtStart: cost }))
663 await update($, mainAtom, m => ({ ...normalize(DEFAULT_MAIN, m), isRunning: true }))
664 // A background architect's report reaches the main loop as the text opening this turn. The
665 // SubagentHandback tool call (in tool.call) normally carries it first; this is the fallback.
666 const back = e.text ? handbackOf(e.text) : null
667 const a = back ? await getArch($) : null
668 if (back && a && a.ids.includes(back.from)) {
669 const advice = adviceLine(back.body)
670 if (advice && advice !== a.lastAdvice) await noteAdvice($, advice)
671 } else if (e.text) {
672 const p = promptLine(e.text)
673 await say($, p.who, p.text)
674 }
675 return next(e)
676 })
677
678 on('turn.step', async function* ($, e, next) {
679 // The main loop's model is known when its request starts; a long first request shouldn't read "-".
680 if (!e.agentId) {
681 await update($, mainAtom, m => {
682 const x = normalize(DEFAULT_MAIN, m)
683 return { ...x, model: e.model, effort: String(e.effort ?? x.effort), steps: x.steps + 1 }
684 })
685 return yield* next(e)
686 }
687 const result = yield* next(e)
688 const id = e.agentId
689 const [cards, a] = await Promise.all([getCards($), getArch($)])
690 if (cards.some(c => c.id === id)) {
691 const step = { model: e.model, usage: result.usage, stopReason: result.stopReason }
692 await update($, agentsAtom, list => listOf<unknown>(list).map(normalizeCard).map(c => (c.id === id ? applyStep(c, step) : c)))
693 if (result.stopReason === 'max_tokens') await say($, await whoIs($, id), 'hit max_tokens', 'error', id)
694 } else if (!a.ids.includes(id)) {
695 const now = await $.clock.now()
696 await update($, loopsAtom, l => stepLoop(listOf<DeckLoop>(l), id, now))
697 }
698 return result
699 })
700
701 on('session.measure', async ($, e, next) => {
702 await update($, usageAtom, x => ({
703 ...normalize(DEFAULT_USAGE, x),
704 pct: e.context.percent ?? null,
705 tokens: e.context.tokens ?? null,
706 window: e.context.window,
707 costUsd: e.cost?.usd ?? null,
708 limits: e.rateLimits.map(r => ({ kind: r.kind, pct: r.percentUsed })),
709 }))
710 return next(e)
711 })
712
713 on('session.compact', async ($, e, next) => {
714 const done = await next(e)
715 if (!e.agentId && e.trigger !== 'precompute') {
716 const now = await $.clock.now()
717 await update($, usageAtom, x => {
718 const u = normalize(DEFAULT_USAGE, x)
719 return { ...u, compactions: u.compactions + 1, lastCompactAt: now }
720 })
721 await say($, 'main', `context compacted (${e.trigger})`)
722 }
723 return done
724 })
725
726 on('tool.check', async ($, e, next) => {
727 const verdict = await next(e)
728 if (e.tool_use_id) {
729 const check: DeckCheck = {
730 id: e.tool_use_id,
731 tool: e.tool,
732 bucket: bucketOf(e.tool),
733 verdict: verdict.decision === 'allow' ? 'rule' : verdict.decision,
734 inSubagent: Boolean(callLoop.get(e.tool_use_id)),
735 detail: shorten(describeInput(e.tool, e.input), 90),
736 at: await $.clock.now(),
737 }
738 await update($, gateAtom, g => recordCheck(normalizeGate(g), check))
739 if (verdict.decision === 'deny') await say($, 'gate', `denied by rule · ${check.detail}`, 'error')
740 }
741 return verdict
742 })
743
744 // A server-side review tool never reaches tool.call: it shows only in the assistant's rows.
745 on('session.append', async ($, e, next) => {
746 if (!e.agentId && e.message.type === 'assistant') {
747 const a = await getArch($)
748 // Consults this row opened: their result may be in the same row, after the stale read above.
749 const opened = new Set<string>()
750 for (const block of e.message.content as unknown as readonly ServerBlock[]) {
751 if (block.type === 'server_tool_use' && block.name && block.id && ARCHITECT.test(block.name)) {
752 if (a.seen.includes(block.id)) continue
753 const id = block.id
754 await update($, archAtom, x => {
755 const y = normalize(DEFAULT_ARCHITECT, x)
756 return { ...y, seen: [...listOf<string>(y.seen), id].slice(-60) }
757 })
758 opened.add(id)
759 await consultStarted($, id, `${block.name} tool`)
760 } else if (block.type.endsWith('_tool_result') && block.tool_use_id) {
761 const id = block.tool_use_id
762 const isOpen = opened.has(id) || (await getArch($)).consults.some(c => c.id === id && c.endAt === null)
763 if (isOpen) await consultEnded($, null, id)
764 }
765 }
766 }
767 return next(e)
768 })
769
770 on('agent.spawn', async ($, e, next) => {
771 const started = await next(e)
772 if (!e.parentAgentId) await noteMode($, e.permissionMode)
773 if (started.deny !== undefined || !started.agentId) return started
774 const id = started.agentId
775 if (await isArchitectType($, e.subagentType)) {
776 await update($, archAtom, a => {
777 const x = normalize(DEFAULT_ARCHITECT, a)
778 return { ...x, ids: [...listOf<string>(x.ids), id].slice(-40) }
779 })
780 await update($, loopsAtom, l => listOf<DeckLoop>(l).filter(x => x.id !== id))
781 await consultStarted($, id, e.subagentType.split(':').pop() ?? 'agent')
782 return started
783 }
784 const card: DeckAgent = { ...normalizeCard({}), id, type: e.name ?? e.subagentType, model: started.model, description: e.description, spawnedAt: await $.clock.now() }
785 await update($, agentsAtom, list => [...listOf<unknown>(list).map(normalizeCard), card].slice(-24))
786 await update($, loopsAtom, l => listOf<DeckLoop>(l).filter(x => x.id !== id))
787 await say($, shorten(cardTitle(card), 12), `spawned · ${card.type}`, 'info', id)
788 return started
789 })
790
791
792 on('session.start', async ($, e, next) => {
793 await $.command.register({
794 name: 'task',
795 description: 'The current task from the plans folder, its work items and details. /task new <title> starts one, /task <n> shows an item',
796 argumentHint: '[new <title> | <n> | start|done|reopen <n> | approve [n] | add <stage>: <title> -- <details> | break | pane]',
797 })
798 await $.tool.register({
799 name: TOOL,
800 description:
801 'Reads and updates tasks and their work items: one markdown plan file per task in the plans folder at the project root. ' +
802 'action "create" starts a task (title, optional date YYYY-MM-DD); "list" shows the task and its numbered items; "add" adds items; "status" sets item n to todo, doing or done; ' +
803 '"edit" changes item n\'s title, details or stage. ' +
804 `Titles are short labels of 2 to 5 words (at most ${ROW_CHARS} characters, refused past ${TITLE_MAX}), never sentences; ` +
805 'put the explanation in details. Mark an item doing when you start it and done when it is finished. ' +
806 'You may also edit the plan file directly: "- [ ] Title `stage`" with the details indented below; [~] in progress, [x] done, [?] draft.',
807 inputSchema: {
808 type: 'object',
809 properties: {
810 action: { type: 'string', enum: ['create', 'list', 'add', 'status', 'edit'] },
811 task: { type: 'string', description: 'Task key; defaults to the current task' },
812 items: {
813 type: 'array',
814 description: 'For add',
815 items: {
816 type: 'object',
817 properties: {
818 title: { type: 'string', maxLength: TITLE_MAX, description: 'Short label, 2 to 5 words, e.g. "Auth screen"' },
819 stage: { type: 'string', enum: STAGES },
820 details: { type: 'string', description: 'What to do and when it counts as done, 1 to 2 sentences' },
821 },
822 required: ['title', 'stage'],
823 },
824 },
825 draft: { type: 'boolean', description: 'For add: true marks items for the person to approve (default true)' },
826 n: { type: 'number', description: 'For status and edit: item number from list, 1-based' },
827 status: { type: 'string', enum: ['todo', 'doing', 'done'] },
828 title: { type: 'string', description: 'For create: the task title. For edit: the item title (a short label)' },
829 date: { type: 'string', description: 'For create: the day it starts, YYYY-MM-DD; today when absent' },
830 details: { type: 'string', description: 'For edit' },
831 stage: { type: 'string', enum: STAGES, description: 'For edit' },
832 },
833 required: ['action'],
834 },
835 isDeferred: false,
836 })
837 const r = await next(e)
838 void $.ui.status(undefined)
839 void quiet(deckStart($))
840 void startWorkbench($)
841 void refresh($)
842 return r
843 })
844
845 // `task` is registered at start; `workbench:task` is commands/task.md, which the app lists before a session starts.
846 on('turn.complete', afterTurn)
847
848 on('command.run', { command: 'task' }, taskCommand)
849 on('command.run', { command: 'workbench:task' }, taskCommand)
850
851 on('tool.call', { tool: TOOL_FULL }, async ($, e) => {
852 const input = e as unknown as {
853 action: 'create' | 'list' | 'add' | 'status' | 'edit'
854 date?: string
855 task?: string
856 items?: { title: string; stage: string; details?: string }[]
857 draft?: boolean
858 n?: number
859 status?: 'todo' | 'doing' | 'done'
860 title?: string
861 details?: string
862 stage?: string
863 }
864 if (input.action === 'create') return { result: await createTask($, input.title ?? '', input.date) }
865 let snap = await read($, snapAtom)
866 if (snap.status !== 'ready') snap = await refresh($)
867 if (snap.status !== 'ready') return { result: snap.status === 'none' ? NONE : 'Plans unavailable: ' + snap.message }
868 const pool = [snap.cur, ...snap.next, ...snap.prev].filter(Boolean) as Group[]
869 const g = input.task ? pool.find(x => x.key === input.task) : snap.cur
870 if (!g) return { result: `No task ${input.task ?? '(current)'} here. Tasks: ${pool.map(x => `${x.key} ${x.title}`).join('; ')}` }
871 const tooLong = (t?: string) => !!t && t.trim().length > TITLE_MAX
872
873 if (input.action === 'list') {
874 const lines = g.items.map((i, k) => `${k + 1}. ${box(i.status)} ${i.stage}: ${i.title}${i.details ? `\n ${i.details}` : ''}`)
875 const head = `${g.key} · ${g.title} (${g.short}, ${when(g)})\nPlan file: ${g.file ?? '(none)'}\nStages: ${g.stages.join(', ') || 'none'}`
876 return { result: g.items.length ? `${head}\n${lines.join('\n')}` : `${head}\nNo work items yet: break the task down with action "add".` }
877 }
878 if (input.action === 'add') {
879 const add = (input.items ?? []).filter(i => i.title?.trim() && STAGES.includes(i.stage))
880 if (!add.length) return { result: 'Nothing to add: give items as {title, stage, details}.' }
881 const long = add.filter(i => tooLong(i.title))
882 if (long.length)
883 return { result: `Not saved. These titles are sentences, not labels: ${long.map(i => `"${i.title}"`).join(', ')}. Use 2 to 5 words (at most ${ROW_CHARS} characters) and move the rest to details.` }
884 const status: WorkItem['status'] = input.draft === false ? 'todo' : 'draft'
885 const made = add.map(i => ({ id: newId(), title: i.title.trim(), stage: i.stage, status, ...(i.details?.trim() ? { details: i.details.trim() } : {}) }))
886 const msg = await writeEntry($, g.key, x => ({ ...x, items: [...x.items, ...made] }))
887 return { result: `${msg}: ${add.length} item(s) on "${g.title}"${status === 'draft' ? ' as drafts for the person to approve' : ''}.` }
888 }
889 const it = g.items[(input.n ?? 0) - 1]
890 if (!it) return { result: 'Give n, the item number from list (1-based).' }
891 if (input.action === 'edit') {
892 if (tooLong(input.title)) return { result: `Not saved: the title is a sentence. Use 2 to 5 words and put the rest in details.` }
893 if (input.stage && !STAGES.includes(input.stage)) return { result: `Stage is one of ${STAGES.join(', ')}.` }
894 const patch: Partial<WorkItem> = {
895 ...(input.title?.trim() ? { title: input.title.trim() } : {}),
896 ...(input.details !== undefined ? { details: input.details.trim() || undefined } : {}),
897 ...(input.stage ? { stage: input.stage } : {}),
898 }
899 if (!Object.keys(patch).length) return { result: 'Give title, details or stage to change.' }
900 const msg = await writeEntry($, g.key, x => ({ ...x, items: x.items.map(i => (i.id === it.id ? { ...i, ...patch } : i)) }))
901 return { result: `${msg}: ${patch.title ?? it.title}` }
902 }
903 if (!input.status) return { result: 'Give status: todo, doing or done.' }
904 const msg = await writeEntry($, g.key, x => ({ ...x, items: x.items.map(i => (i.id === it.id ? { ...i, status: input.status! } : i)) }))
905 return { result: `${msg}: ${it.title} → ${input.status}` }
906 }).catch(() => ({ result: 'Workbench could not read or write the plan files. Try /task refresh.' }))
907
908 /* ── band above the prompt: line 1 the task, line 2 the bar ── */
909
910 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
911 const snap = await read($, snapAtom)
912 if (!settings.band || e.props.hasSurvey || snap.status !== 'ready' || !snap.cur) return next(e)
913 await read($, uiAtom)
914 const els = $.ui.resolve(e)
915 const { Box, Text, Button } = els
916 const cur = snap.cur
917 const n = byStatus(cur.items)
918 const bar = barIn(cur)
919 const now = inHand(cur)
920 const isUp = (await $.ui.panes()).some(p => p.id === PANE)
921 const dot = bar.finished ? DONE : n.doing ? DOING : cur.items.length ? ACCENT : MUTED
922 const chevron = <Button key="band-chev" plain label={isUp ? '❮' : '❯'} onPress={() => void togglePane($)} />
923
924 if (e.surface === 'desktop' && 'Svg' in els) {
925 const { Svg } = els
926 // About 8 CSS px a column; the rest is the chevron and its gap. A row wider than the slot wraps.
927 const W = Math.max(180, Math.min(1600, (e.props.bodyColumns || 100) * 8 - 64))
928 return (
929 <Box flexDirection="column">
930 <Box flexDirection="row" alignItems="center" gap={1}>
931 <Svg key="band-title" width={W} height={20} alt={`${cur.short}: ${cur.title}${now ? `, now ${now.title}` : ''}`} source={bandTitleSvg(W, dot, cur.short, cur.title, now?.title)} />
932 {chevron}
933 </Box>
934 <Svg key="band-bar" width={W} height={20} alt={`${bar.pill}, ${bar.total ? Math.round((bar.done / bar.total) * 100) : 0}% done`} source={bandBarSvg(W, bar)} />
935 </Box>
936 )
937 }
938
939 const cols = e.props.bodyColumns ?? e.viewport?.columns ?? 80
940 const width = Math.max(8, Math.min(48, cols - bar.pill.length - 10))
941 const filled = bar.total ? Math.round((width * bar.done) / bar.total) : 0
942 const running = bar.total ? Math.min(width - filled, Math.round((width * bar.doing) / bar.total)) : 0
943 const pct = `${bar.total ? Math.round((bar.done / bar.total) * 100) : 0}%`
944 return (
945 <Box flexDirection="column">
946 <Box flexDirection="row" columnGap={1} flexWrap="nowrap">
947 <Text color={dot}>●</Text>
948 <Text dimColor>{cur.short}</Text>
949 <Box flexGrow={1} flexShrink={1} minWidth={0}>
950 <Text bold wrap="truncate-end">
951 {cur.title}
952 {now ? <Text dimColor bold={false}>{` now · ${now.title}`}</Text> : ''}
953 </Text>
954 </Box>
955 {chevron}
956 </Box>
957 <Box flexDirection="row" columnGap={1}>
958 <Text>
959 <Text color={bar.finished ? DONE : ACCENT}>{'█'.repeat(filled)}</Text>
960 <Text color={ACCENT} dimColor>{'▓'.repeat(running)}</Text>
961 <Text dimColor>{'░'.repeat(Math.max(0, width - filled - running))}</Text>
962 </Text>
963 <Text color={bar.finished ? DONE : ACCENT} bold>{bar.pill}</Text>
964 <Text dimColor>{pct}</Text>
965 </Box>
966 </Box>
967 )
968 })
969
970 /* ── side pane: the Workbench ── */
971
972 // A prompt starts a turn: Right now shows Claude working until the turn ends.
973 on('prompt.submit', async ($, e, next) => {
974 const r = await next(e)
975 await quiet(update($, activityAtom, () => ({ busy: true })))
976 return r
977 }).catch(($, e, next) => next(e))
978
979 // What Claude reads and edits, for "Right now" and "Claude touched"; commands and searches for "Right now".
980 // Every call also runs through In Flight, which settles its permission check and logs edits and errors.
981 on('tool.call', async ($, e, next) => {
982 const kind = FILE_TOOLS[e.tool]
983 const args = e as unknown as { file_path?: unknown; notebook_path?: unknown; command?: unknown; description?: unknown; pattern?: unknown }
984 const raw = args.file_path ?? args.notebook_path
985 if (!kind || typeof raw !== 'string') {
986 const text = (v: unknown) => (typeof v === 'string' ? v.split('\n')[0]!.trim() : '')
987 const act: Activity =
988 e.tool === 'Bash' ? { busy: true, kind: 'run', label: text(args.description) || text(args.command) }
989 : (e.tool as string) === 'Grep' || (e.tool as string) === 'Glob' ? { busy: true, kind: 'search', label: text(args.pattern) } // where the build has them
990 : { busy: true }
991 await quiet(update($, activityAtom, a => (act.kind ? act : { ...a, busy: true })))
992 return deckToolCall($, e, next)
993 }
994 const path = absOf(raw, (await quiet($.session.cwd())) ?? '')
995 await quiet(update($, activityAtom, () => ({ busy: true, kind, path })))
996 await quiet(update($, touchedAtom, l => addTouch(l, { path, kind, active: true, at: Date.now() })))
997 try {
998 return await deckToolCall($, e, next)
999 } finally {
1000 await quiet(update($, touchedAtom, l => addTouch(l, { path, kind, active: false, at: Date.now() })))
1001 if (kind === 'edited') {
1002 void quiet(refreshGit($))
1003 const f = await quiet(read($, filesAtom))
1004 if (f?.dirs[parentOf(path)]) void quiet(loadDirs($, [parentOf(path)]))
1005 }
1006 }
1007 }).catch(($, e, next) => next(e)) // never in the way of a tool call
1008
1009 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
1010 const els = $.ui.resolve(e)
1011 const { Box, Text, Button } = els
1012 const Input = 'Input' in els ? els.Input : undefined
1013 const cols = Math.max(28, e.props?.bodyColumns || 44)
1014 const pal = await paletteNow($)
1015 const snap = await read($, snapAtom)
1016 const ui = await read($, benchAtom)
1017 const files = await read($, filesAtom)
1018 const git = await read($, gitAtom)
1019 const touched = await read($, touchedAtom)
1020 const activity = await read($, activityAtom)
1021 const open = sectionsOf(ui.sections)
1022
1023 // Text in a skin color; with no skin, Claude Code's theme color for the slot (it follows light and dark).
1024 const c = (slot: Slot) => (pal.themed ? { color: pal[slot] } : THEME_KEY[slot] ? { color: THEME_KEY[slot] } : slot === 'muted' ? { dimColor: true } : {})
1025 // Desktop draws Lucide icons, caps labels and pills as SVG; the terminal gets text and glyphs.
1026 const Svg = e.surface === 'desktop' && 'Svg' in els ? els.Svg : undefined
1027 // A drawing's color: the skin's, or with no skin a light and dark pair.
1028 const ink = (slot: Slot): Ink => inkOf(pal, slot)
1029 const mutedInk = ink('muted')
1030 // Room inside `depth` nested cards, in px (about 8 a column; each card takes its border and padding).
1031 const room = (depth: number) => Math.max(140, cols * 8 - 24 - depth * 56)
1032 const capsText = (key: string, text: string, slot: Slot = 'muted', size = 12) => {
1033 if (!Svg) return <Text key={key} bold {...c(slot)}>{text.toUpperCase()}</Text>
1034 const l = caps(text, ink(slot), size)
1035 return <Svg key={key} width={l.w} height={l.h} alt={text} source={l.source} />
1036 }
1037 const label = (key: string, text: string, right?: string) => (
1038 <Box key={key} flexDirection="row" justifyContent="space-between" alignItems="center" marginTop={1} width="100%">
1039 {capsText(key + '-l', text)}
1040 {right ? capsText(key + '-r', right) : null}
1041 </Box>
1042 )
1043 // A tinted card (Current task, Right now) takes the app's message surface, which follows light and dark.
1044 const card = (key: string, kids: unknown[], marginTop = 0, tinted = false) => (
1045 <Box key={key} flexDirection="column" alignItems="stretch" width="100%" marginTop={marginTop} backgroundColor={tinted ? 'userMessageBackground' : undefined} borderStyle="round" borderColor={pal.themed ? pal.muted : undefined} borderDimColor={!pal.themed} paddingX={2} paddingY={1}>
1046 {kids as never}
1047 </Box>
1048 )
1049 const dot = (key: string, slot: Slot) => <Text key={key} {...c(slot)}>●</Text>
1050
1051 /* ── box 1: task management ── */
1052
1053 const ico = (key: string, name: IconName, color: Ink, size = 16) => (Svg ? <Svg key={key} width={size} height={size} alt={name} source={icon(name, color, size)} /> : null)
1054 const divider = (key: string) =>
1055 Svg ? (
1056 <Box key={key} marginY={1} width="100%"><Svg height={9} alt="divider" source={rule(mutedInk)} /></Box>
1057 ) : (
1058 <Text key={key} {...c('muted')} wrap="truncate-end">{'─'.repeat(200)}</Text>
1059 )
1060
1061 const task: unknown[] = []
1062 const cur = snap.cur
1063 if (snap.status === 'idle' || snap.status === 'loading') task.push(<Text key="t-wait" {...c('muted')}>Reading plans…</Text>)
1064 else if (snap.status === 'none') task.push(<Text key="t-none" {...c('muted')}>{'No tasks here yet. /task new <title> starts one.'}</Text>)
1065 else if (snap.status === 'error') task.push(<Text key="t-err" {...c('err')}>{`Plans unavailable: ${snap.message ?? ''}`}</Text>)
1066 else if (!cur) task.push(<Text key="t-free" {...c('muted')}>No open tasks here.</Text>)
1067 else {
1068 const done = cur.items.filter(i => i.status === 'done').length
1069 const doing = cur.items.filter(i => i.status === 'doing')
1070 const total = cur.items.length
1071 const pct = total ? Math.round((done / total) * 100) : 0
1072 // The bar takes the row less its text (about 7 px a character) and a gap.
1073 const tail = `${pct}% · ${total ? `${done} of ${total} done` : 'not broken down'}`
1074 const barW = Math.max(60, room(2) - tail.length * 7 - 12)
1075 const when = cur.start === cur.end ? shortDate(cur.start) : `${shortDate(cur.start)} → ${shortDate(cur.end)}`
1076 // Each stage in its own color, the one the band's bar takes while that stage is current.
1077 const stageTag = (key: string, stage: string) => <Text key={key} color={STAGE_COLOR[stage] ?? MUTED}>{stage}</Text>
1078 const STATUS_ICON = { draft: 'square-dashed', todo: 'square', doing: 'square-dot', done: 'square-check' } as const
1079 const statusInk = (s: WorkItem['status']) => ink(s === 'done' ? 'ok' : s === 'doing' ? 'write' : 'muted')
1080 // The status square: a Lucide icon with a press over it; a glyph on the terminal.
1081 const square = (it: WorkItem) => {
1082 const press = () => setStatus($, cur.key, it.id, nextStatus(it.status))
1083 if (!Svg) return <Button key={'sq-' + it.id} plain label={GLYPH[it.status]} onPress={press} />
1084 return (
1085 <Box key={'sq-' + it.id} width={2} height={1} flexShrink={0} justifyContent="center" alignItems="center">
1086 <Svg width={16} height={16} alt={it.status} source={icon(STATUS_ICON[it.status], statusInk(it.status), 16)} />
1087 <Box position="absolute" top={0} left={0} right={0} bottom={0}>
1088 <Button key={'sqb-' + it.id} plain label={COVER} onPress={press} />
1089 </Box>
1090 </Box>
1091 )
1092 }
1093
1094 // Current task: the check disc, the title, the bar; then what is in progress
1095 const curKids: unknown[] = [
1096 // On the desktop the top is one drawing; the card under it is tinted
1097 Svg ? (
1098 <Svg key="ct-top" width={room(2)} height={78} alt={`Current task: ${cur.title}`} source={hero({ icon: 'check', accent: ink('ok'), label: 'Current task', title: cur.title, sub: `${cur.short} · ${when}`, w: room(2), panel: false })} />
1099 ) : (
1100 <Box key="ct-top" flexDirection="column">
1101 {capsText('ct-cap', 'Current task', 'ok')}
1102 <Text bold wrap="wrap" {...c('fg')}>{cur.title}</Text>
1103 <Text wrap="truncate-end" {...c('muted')}>{`${cur.short} · ${when}`}</Text>
1104 </Box>
1105 ),
1106 Svg ? (
1107 // The band's bar, then the percent and the count flush with the card's right edge.
1108 <Box key="ct-bar" flexDirection="row" justifyContent="space-between" alignItems="center" columnGap={1} marginTop={1} width="100%">
1109 <Svg width={barW} height={16} alt={`${barIn(cur).pill}, ${pct}% done`} source={paneBarSvg(barW, barIn(cur))} />
1110 <Box flexShrink={0}><Text {...c('muted')}>{tail}</Text></Box>
1111 </Box>
1112 ) : (
1113 <Text key="ct-bar" wrap="truncate-end">
1114 <Text {...c('ok')}>{'█'.repeat(Math.round((Math.max(8, cols - 12) * done) / Math.max(1, total)))}</Text>
1115 <Text {...c('muted')}>{'░'.repeat(200)}</Text>
1116 </Text>
1117 ),
1118 // On the desktop the count rides at the end of the bar's line, beside the percent.
1119 Svg ? null : (
1120 <Box key="ct-count" flexDirection="row" justifyContent="space-between" width="100%">
1121 <Text {...c('muted')}>{total ? `${done} of ${total} done` : 'Not broken down yet'}</Text>
1122 <Text bold {...c('ok')}>{`${pct}%`}</Text>
1123 </Box>
1124 ),
1125 divider('ct-hr'),
1126 capsText('ip-cap', 'In progress', 'write'),
1127 ]
1128 if (!doing.length) curKids.push(<Box key="ip-none" marginTop={1}><Text {...c('muted')}>Nothing in progress</Text></Box>)
1129 for (const it of doing)
1130 curKids.push(
1131 <Box key={'ip-' + it.id} flexDirection="row" columnGap={1} alignItems="center" marginTop={1} width="100%">
1132 {square(it)}
1133 <Box flexGrow={1} flexShrink={1} minWidth={0}><Text bold wrap="truncate-end" {...c('fg')}>{it.title}</Text></Box>
1134 {stageTag('ips-' + it.id, it.stage)}
1135 <Button key={'ipd-' + it.id} onPress={() => setStatus($, cur.key, it.id, 'done')}>Done</Button>
1136 </Box>,
1137 )
1138 task.push(card('ct-card', curKids, 1, true))
1139
1140 // Work items, in their own box
1141 const wiKids: unknown[] = [
1142 <Box key="wi-h" flexDirection="row" justifyContent="space-between" alignItems="center" width="100%">
1143 <Box flexDirection="row" columnGap={1} alignItems="center">
1144 {capsText('wi-cap', 'Work items')}
1145 <Button key="wi-toggle" plain label={ui.itemsOpen ? '▾' : '▸'} onPress={() => void update($, benchAtom, u => ({ ...u, itemsOpen: !u.itemsOpen }))} />
1146 </Box>
1147 {capsText('wi-count', `${done}/${total}`)}
1148 </Box>,
1149 ]
1150 if (ui.itemsOpen) {
1151 if (!total) wiKids.push(<Box key="wi-none" marginTop={1}><Text {...c('muted')}>No work items yet</Text></Box>)
1152 for (const it of cur.items)
1153 wiKids.push(
1154 <Box key={'wi-' + it.id} flexDirection="row" columnGap={1} alignItems="center" marginTop={1} width="100%">
1155 {square(it)}
1156 <Box flexGrow={1} flexShrink={1} minWidth={0}>
1157 <Text wrap="truncate-end" {...(it.status === 'done' ? c('muted') : c('fg'))}>{it.title}</Text>
1158 </Box>
1159 {stageTag('wis-' + it.id, it.stage)}
1160 </Box>,
1161 )
1162 }
1163 task.push(card('wi-card', wiKids, 1))
1164
1165 // Add work item: a button that opens the field
1166 const fallback = doing[0]?.stage ?? cur.stages[0] ?? 'build'
1167 if (ui.adding && Input)
1168 task.push(
1169 <Box key="wi-add" flexDirection="column" alignItems="stretch" marginTop={1} width="100%">
1170 <Input
1171 key="wi-add"
1172 autoFocus
1173 placeholder="Title, or stage: title"
1174 submitLabel="add"
1175 onSubmit={(v: string) => {
1176 const { stage, title, details } = parseAdd(v, fallback)
1177 if (title) save($, cur.key, x => ({ ...x, items: [...x.items, { id: newId(), title, stage, status: 'todo', ...(details ? { details } : {}) }] }))
1178 void update($, benchAtom, u => ({ ...u, adding: false }))
1179 }}
1180 />
1181 <Box marginTop={1}>
1182 <Button key="wi-cancel" onPress={() => void update($, benchAtom, u => ({ ...u, adding: false }))}>Cancel</Button>
1183 </Box>
1184 </Box>,
1185 )
1186 else
1187 task.push(
1188 <Box key="wi-add" flexDirection="row" marginTop={1}>
1189 <Button key="wi-add-btn" onPress={() => void update($, benchAtom, u => ({ ...u, adding: true, itemsOpen: true }))}>+ Add work item</Button>
1190 </Box>,
1191 )
1192
1193 // Up next
1194 task.push(divider('nx-hr'), capsText('nx-cap', 'Up next'))
1195 const nx = snap.next.slice(0, 3)
1196 if (!nx.length) task.push(<Box key="nx-none" marginTop={1}><Text {...c('muted')}>Nothing queued</Text></Box>)
1197 for (const g of nx)
1198 task.push(
1199 <Box key={'nx-' + g.key} flexDirection="row" columnGap={1} alignItems="center" marginTop={1} width="100%">
1200 {dot('nxd-' + g.key, g.items.some(i => i.status === 'doing') ? 'write' : 'muted')}hooks/draw.ts 117 lines1/* Desktop drawings. The desktop has no font sizes and wraps sibling elements,
2 * so anything with small type or several parts on one line is one SVG.
3 * Every Svg needs a non-empty alt: the desktop drops one whose alt is empty. */
4
5export const ACCENT = '#8f8cf4'
6export const DONE = '#5fbf8f'
7export const DOING = '#BA7517'
8export const MUTED = '#888780'
9/** Each work item stage's color: the pane's stage tags and the band's bar while that stage is current. */
10export const STAGE_COLOR: Record<string, string> = { spec: '#7F77DD', design: '#D4537E', build: '#378ADD', track: '#1D9E75', test: '#BA7517', ship: '#639922' }
11
12export const FONT = "-apple-system,BlinkMacSystemFont,'SF Pro Text','Segoe UI',sans-serif"
13const CSS = `<style>
14.t{fill:#1f1f1f}.m{fill:#8a8a8a}.k{fill:#e4e4e2}.tk{fill:#b4b4b0}.bg{fill:#f0eee6}.k2{fill:#dfdcd2}
15@media (prefers-color-scheme: dark){.t{fill:#ececec}.m{fill:#9a9a9a}.k{fill:#2c2c2c}.tk{fill:#5a5a5a}.bg{fill:#30302e}.k2{fill:#454542}}
16</style>`
17
18const svg = (W: number, H: number, body: string) =>
19 `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">${CSS}${body}</svg>`
20
21export const xml = (s: string) => s.replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c] ?? c)
22
23/** Rough advance of system UI text, in em (as savvy-progress measures). */
24const charEm = (ch: string) => (/[\s.,:;'|!il1()[\]]/.test(ch) ? 0.3 : /[A-Z@%mw]/.test(ch) ? 0.72 : 0.56)
25export const textWidth = (s: string, size: number) => [...s].reduce((w, ch) => w + charEm(ch) * size, 0)
26
27export function fitText(s: string, size: number, maxW: number) {
28 if (textWidth(s, size) <= maxW) return s
29 let out = ''
30 for (const ch of s) {
31 if (textWidth(out + ch + '…', size) > maxW) break
32 out += ch
33 }
34 return out.trimEnd() + '…'
35}
36
37/** Deterministic noise so the dither does not shimmer between redraws. */
38const noise = (x: number, y: number) => {
39 const s = Math.sin(x * 12.9898 + y * 78.233) * 43758.5453
40 return s - Math.floor(s)
41}
42
43export type BarIn = { done: number; doing: number; total: number; pill: string; finished: boolean; color?: string }
44
45/** savvy-flow's bar: dithered fill, a faint layer for work in progress, step ticks, a pill at the head. */
46function barG(x: number, y: number, W: number, H: number, b: BarIn, id: string, track = 'k') {
47 const CELL = 3
48 const color = b.finished ? DONE : (b.color ?? ACCENT)
49 const fillW = b.total ? Math.round((W * b.done) / b.total) : 0
50 const runW = b.total ? Math.round((W * Math.min(b.total, b.done + b.doing)) / b.total) : 0
51 const rows = Math.floor(H / CELL)
52 const dots: string[] = []
53 const cols = Math.floor(fillW / CELL)
54 for (let c = 0; c < cols; c++) {
55 const density = 0.35 + 0.6 * Math.pow(c / Math.max(1, cols), 1.2)
56 for (let r = 0; r < rows; r++) if (noise(c, r) < density) dots.push(`<rect x="${c * CELL + 1}" y="${r * CELL + 1}" width="2" height="2"/>`)
57 }
58 const faint: string[] = []
59 for (let c = cols; c < Math.floor(runW / CELL); c++)
60 for (let r = 0; r < rows; r++) if (noise(c + 7, r + 3) < 0.25) faint.push(`<rect x="${c * CELL + 1}" y="${r * CELL + 1}" width="1.7" height="1.7"/>`)
61 const ticks: string[] = []
62 for (let i = 1; i < b.total; i++) {
63 const tx = Math.round((W * i) / b.total)
64 if (tx > fillW + 4) ticks.push(`<rect x="${tx}" y="${H / 2 - 3}" width="1.5" height="6" rx="0.75"/>`)
65 }
66 const pillSize = H >= 16 ? 11 : 9.5
67 const pillW = Math.round(textWidth(b.pill, pillSize) + 16)
68 const pillX = Math.max(0, Math.min(W - pillW, fillW - pillW))
69 return `<defs><clipPath id="${id}"><rect width="${W}" height="${H}" rx="${H / 2}"/></clipPath></defs>
70<g transform="translate(${x},${y})">
71<rect class="${track}" width="${W}" height="${H}" rx="${H / 2}"/>
72<g clip-path="url(#${id})"><g fill="${color}">${dots.join('')}</g><g fill="${color}" opacity="0.45">${faint.join('')}</g><g class="tk">${ticks.join('')}</g></g>
73<rect x="${pillX}" width="${pillW}" height="${H}" rx="${H / 2}" fill="${color}"/>
74<text x="${pillX + pillW / 2}" y="${H / 2 + pillSize * 0.36}" text-anchor="middle" font-family="${FONT}" font-size="${pillSize}" font-weight="600" fill="#ffffff">${xml(b.pill)}</text>
75</g>`
76}
77
78const pct = (b: BarIn) => `${b.total ? Math.round((b.done / b.total) * 100) : 0}%`
79
80/** Band line 2: the bar and the percent, no count. */
81export function bandBarSvg(W: number, b: BarIn) {
82 const H = 20
83 const BAR_H = 16
84 const barW = Math.max(60, W - 44)
85 return svg(
86 W,
87 H,
88 `${barG(0, (H - BAR_H) / 2, barW, BAR_H, b, 'bb')}
89<text class="m" x="${W}" y="${H / 2 + 4.5}" text-anchor="end" font-family="${FONT}" font-size="12.5" font-variant-numeric="tabular-nums">${pct(b)}</text>`,
90 )
91}
92
93/** The pane's bar alone, the band's look; the pane puts its percent and count beside it as text. */
94export function paneBarSvg(W: number, b: BarIn) {
95 const H = 16
96 return `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">${CSS}${barG(0, 0, W, H, b, 'pb')}</svg>`
97}
98
99/** Band line 1: status dot, project, task title, the item in hand. */
100export function bandTitleSvg(W: number, dot: string, short: string, title: string, now?: string) {
101 const H = 20
102 const y = H / 2 + 4.5
103 const tx = 16 + textWidth(short, 11.5) + 10
104 const nowLabel = now ? `now · ${now}` : ''
105 const nowMax = now ? Math.min(textWidth(nowLabel, 12), W * 0.38) : 0
106 const t = fitText(title, 13, Math.max(40, W - tx - (now ? nowMax + 18 : 0)))
107 const nx = tx + textWidth(t, 13) + 14
108 return svg(
109 W,
110 H,
111 `<circle cx="5" cy="${H / 2}" r="4" fill="${dot}"/>
112<text class="m" x="16" y="${y - 0.5}" font-family="${FONT}" font-size="11.5" font-weight="500" letter-spacing="0.3">${xml(short)}</text>
113<text class="t" x="${tx}" y="${y}" font-family="${FONT}" font-size="13" font-weight="600">${xml(t)}</text>
114${now ? `<text class="m" x="${nx}" y="${y}" font-family="${FONT}" font-size="12">${xml(fitText(nowLabel, 12, Math.max(30, W - nx)))}</text>` : ''}`,
115 )
116}
117hooks/logic.ts 57 lines1import type { DevEntry, WorkItem } from '../types'
2
3export const STAGES = ['spec', 'design', 'build', 'track', 'test', 'ship']
4
5export const counts = (items: WorkItem[]) => {
6 const real = items.filter(i => i.status !== 'draft')
7 return { done: real.filter(i => i.status === 'done').length, total: real.length, drafts: items.length - real.length }
8}
9
10export const nextStatus = (s: WorkItem['status']): WorkItem['status'] =>
11 ({ draft: 'todo', todo: 'doing', doing: 'done', done: 'todo' } as const)[s]
12
13export const newId = () => 'w' + Date.now().toString(36) + Math.random().toString(36).slice(2, 5)
14
15/** Keeps stages in canonical order and adds any the items use. */
16export function withStages(entry: DevEntry, extra: string[] = []): DevEntry {
17 const want = new Set([...entry.stages, ...extra, ...entry.items.map(i => i.stage)])
18 return { ...entry, stages: STAGES.filter(s => want.has(s)) }
19}
20
21/** `/task add build: Create monorepo -- pnpm workspaces` gives stage, title and optional details. */
22export function parseAdd(text: string, fallback: string): { stage: string; title: string; details?: string } {
23 const m = text.match(/^\s*(\w+)\s*:\s*(.+)$/)
24 const st = m?.[1]?.toLowerCase()
25 const isStage = !!(m && st && STAGES.includes(st))
26 const stage = isStage ? st! : fallback
27 const body = (isStage ? m![2] ?? '' : text).trim()
28 const [title = '', ...rest] = body.split(/\s+(?:—|--)\s+/)
29 const details = rest.join(' -- ').trim()
30 return details ? { stage, title: title.trim(), details } : { stage, title: title.trim() }
31}
32
33/** Titles are labels, not sentences: the tool refuses longer ones. */
34export const TITLE_MAX = 40
35
36/** The item the pane details and the band names: the one picked, else in progress, else the next to do. */
37export function activeItem(items: WorkItem[], selected?: string): WorkItem | undefined {
38 return (
39 items.find(i => i.id === selected) ??
40 items.find(i => i.status === 'doing') ??
41 items.find(i => i.status === 'todo') ??
42 items.find(i => i.status === 'draft')
43 )
44}
45
46export const clip = (s: string, n: number) => (s.length > n ? s.slice(0, n - 1).replace(/[\uD800-\uDBFF]$/, '').trimEnd() + '…' : s)
47
48export const byStatus = (items: WorkItem[]) => {
49 const n = { draft: 0, todo: 0, doing: 0, done: 0 }
50 for (const i of items) n[i.status]++
51 return n
52}
53
54export function localDate(d = new Date()) {
55 return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
56}
57hooks/plans.ts 184 lines1import type { DevEntry, Group, Snap, WorkItem } from '../types'
2import { STAGES, withStages } from './logic'
3
4/* Plan files: one markdown file per task, in `plans/` at the project's root (the folder name is a setting).
5 * The file is the only home of a task and its work items: edit it by hand, with /task, or let Claude use the tool. */
6
7export const PLANS_DIR = 'plans'
8
9/** A folder name for `plans_dir`: one plain segment, else the default. */
10export const plansDirName = (v: unknown) => (typeof v === 'string' && /^[\w.-]+$/.test(v.trim()) && !/^\.+$/.test(v.trim()) ? v.trim() : PLANS_DIR)
11
12export type PlanFront = {
13 task: string
14 project: string
15 short: string
16 title: string
17 date: string
18 end: string
19 size?: string
20 status: 'open' | 'done'
21}
22export type Plan = PlanFront & { file: string; items: WorkItem[]; text: string }
23
24const MARK: Record<WorkItem['status'], string> = { todo: ' ', doing: '~', done: 'x', draft: '?' }
25const FROM_MARK: Record<string, WorkItem['status']> = { ' ': 'todo', '~': 'doing', x: 'done', X: 'done', '?': 'draft' }
26const ITEMS_HEAD = '## Work items'
27
28const yamlValue = (v: string) => (/^[\w./-]+$/.test(v) ? v : JSON.stringify(v))
29
30function readFront(text: string): Record<string, string> {
31 const m = text.match(/^---\n([\s\S]*?)\n---\n?/)
32 const out: Record<string, string> = {}
33 if (!m) return out
34 for (const line of m[1]!.split('\n')) {
35 const kv = line.match(/^(\w+):\s*(.*)$/)
36 if (!kv) continue
37 const raw = kv[2]!.trim()
38 try {
39 out[kv[1]!] = raw.startsWith('"') ? (JSON.parse(raw) as string) : raw
40 } catch {
41 out[kv[1]!] = raw
42 }
43 }
44 return out
45}
46
47function writeFront(f: PlanFront) {
48 const keys: (keyof PlanFront)[] = ['task', 'project', 'short', 'title', 'date', 'end', 'size', 'status']
49 return ['---', ...keys.filter(k => f[k] !== undefined && f[k] !== '').map(k => `${k}: ${yamlValue(String(f[k]))}`), '---'].join('\n')
50}
51
52/** The work items under `## Work items`: `- [ ] Title \`stage\`` with indented detail lines. */
53export function parseItems(text: string, key: string): WorkItem[] {
54 const start = text.indexOf(ITEMS_HEAD)
55 if (start < 0) return []
56 const rest = text.slice(start + ITEMS_HEAD.length)
57 const stop = rest.search(/\n## /)
58 const block = stop < 0 ? rest : rest.slice(0, stop)
59 const items: WorkItem[] = []
60 for (const line of block.split('\n')) {
61 const m = line.match(/^\s*[-*] \[( |x|X|~|\?)\]\s+(.+?)\s*$/)
62 if (m) {
63 let title = m[2]!
64 let stage = 'build'
65 const st = title.match(/\s*`(\w+)`\s*$/)
66 if (st && STAGES.includes(st[1]!.toLowerCase())) {
67 stage = st[1]!.toLowerCase()
68 title = title.slice(0, st.index).trim()
69 }
70 items.push({ id: `${key}-${items.length + 1}`, title, stage, status: FROM_MARK[m[1]!] ?? 'todo' })
71 continue
72 }
73 const last = items[items.length - 1]
74 const detail = line.match(/^\s{2,}(\S.*)$/)
75 if (last && detail) last.details = last.details ? `${last.details} ${detail[1]!.trim()}` : detail[1]!.trim()
76 }
77 return items
78}
79
80export function renderItems(items: WorkItem[]) {
81 return items.map(i => `- [${MARK[i.status]}] ${i.title} \`${i.stage}\`${i.details ? `\n ${i.details}` : ''}`).join('\n')
82}
83
84/** Replaces the work items block, keeping everything else in the file as written. */
85export function setItems(text: string, items: WorkItem[]) {
86 const body = renderItems(items)
87 const start = text.indexOf(ITEMS_HEAD)
88 if (start < 0) return text.replace(/\s*$/, '') + `\n\n${ITEMS_HEAD}\n\n${body}\n`
89 const after = start + ITEMS_HEAD.length
90 const rest = text.slice(after)
91 const stop = rest.search(/\n## /)
92 return text.slice(0, after) + '\n\n' + (body ? body + '\n' : '') + (stop < 0 ? '' : rest.slice(stop))
93}
94
95/** Rewrites the header fields, keeping the body. */
96export function setFront(text: string, f: PlanFront) {
97 const m = text.match(/^---\n[\s\S]*?\n---\n?/)
98 return writeFront(f) + '\n' + (m ? text.slice(m[0].length) : '\n' + text)
99}
100
101export function parsePlan(text: string, file: string): Plan | undefined {
102 const f = readFront(text)
103 if (!f.task || !f.title || !f.date) return undefined
104 return {
105 file,
106 text,
107 task: f.task,
108 project: f.project ?? '',
109 short: f.short ?? f.project ?? '',
110 title: f.title,
111 date: f.date,
112 end: f.end || f.date,
113 size: f.size || undefined,
114 status: f.status === 'done' ? 'done' : 'open',
115 items: parseItems(text, f.task),
116 }
117}
118
119/** Lowercase words joined by hyphens, whole words only, up to 40 characters. */
120function slug(s: string) {
121 let out = ''
122 for (const w of s.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean)) {
123 if (out && out.length + 1 + w.length > 40) break
124 out = out ? `${out}-${w}` : w.slice(0, 40)
125 }
126 return out
127}
128/** A key for a new task: its title's slug, cut short, made unique among `taken`. */
129export function newTaskKey(title: string, taken: readonly string[]) {
130 const base = slug(title).split('-').slice(0, 4).join('-') || 'task'
131 let key = base
132 for (let n = 2; taken.includes(key); n++) key = `${base}-${n}`
133 return key
134}
135
136export function newPlanText(f: PlanFront, items: WorkItem[], notes?: string) {
137 return [
138 writeFront(f),
139 '',
140 `# ${f.title}`,
141 '',
142 `${ITEMS_HEAD}`,
143 '',
144 ...(items.length ? [renderItems(items)] : []),
145 ...(notes?.trim() ? ['', '## Notes', '', notes.trim()] : []),
146 '',
147 ].join('\n')
148}
149
150/** A plan's work items with the stages they use. */
151export const devEntryOf = (p: Plan): DevEntry => withStages({ stages: [], items: p.items })
152
153/** Every item approved and done: the task counts as finished. */
154export const allDone = (items: WorkItem[]) => items.length > 0 && items.every(i => i.status === 'done')
155
156/** A plan as the band and pane draw a task. */
157export function groupOf(p: Plan): Group & { file: string } {
158 const entry = devEntryOf(p)
159 return {
160 key: p.task,
161 title: p.title,
162 project: p.project,
163 short: p.short,
164 size: p.size,
165 start: p.date,
166 end: p.end,
167 open: p.status !== 'done',
168 parts: [],
169 stages: entry.stages,
170 items: p.items,
171 file: p.file,
172 }
173}
174
175/** Current task: the earliest open plan by date; then Up next and recently done. */
176export function snapshotOfPlans(plans: Plan[], folder: string): Snap {
177 if (!plans.length) return { status: 'none', shorts: [], prev: [], next: [], folder }
178 const byDate = [...plans].sort((a, b) => a.date.localeCompare(b.date) || a.task.localeCompare(b.task))
179 const open = byDate.filter(p => p.status !== 'done').map(groupOf)
180 const done = byDate.filter(p => p.status === 'done').sort((a, b) => a.end.localeCompare(b.end)).map(groupOf)
181 const shorts = [...new Set(plans.map(p => p.short).filter(Boolean))]
182 return { status: 'ready', folder, shorts, cur: open[0], next: open.slice(1, 5), prev: done.slice(-3) }
183}
184hooks/bench.ts 165 lines1import type { ThemeKey } from 'claude-code'
2
3import type { Entry, Touch } from '../types'
4import type { Ink } from './icons'
5
6/* ── colors from the active /skin ── */
7
8export type Slot = 'read' | 'write' | 'run' | 'search' | 'user' | 'fg' | 'muted' | 'surface' | 'ok' | 'err' | 'warn'
9export type Palette = Record<Slot, string>
10
11// The built-in skins' dark palettes, as the skins mod ships them (hooks/themes).
12const SKINS: Record<string, Palette> = {
13 noir: { read: '#ededed', write: '#ededed', run: '#ededed', search: '#ededed', user: '#f5f5f5', fg: '#ededed', muted: '#8c8c8c', surface: '#151515', ok: '#ededed', err: '#f87171', warn: '#a3a3a3' },
14 'tokyo-night': { read: '#7dcfff', write: '#ff9e64', run: '#e0af68', search: '#bb9af7', user: '#7aa2f7', fg: '#c0caf5', muted: '#737aa2', surface: '#1f2335', ok: '#9ece6a', err: '#f7768e', warn: '#e0af68' },
15 dracula: { read: '#8be9fd', write: '#ffb86c', run: '#f1fa8c', search: '#bd93f9', user: '#bd93f9', fg: '#f8f8f2', muted: '#6272a4', surface: '#2b2d3a', ok: '#50fa7b', err: '#ff5555', warn: '#f1fa8c' },
16 nord: { read: '#8fbcbb', write: '#d08770', run: '#ebcb8b', search: '#b48ead', user: '#88c0d0', fg: '#e5e9f0', muted: '#7b88a1', surface: '#3b4252', ok: '#a3be8c', err: '#bf616a', warn: '#ebcb8b' },
17 gruvbox: { read: '#83a598', write: '#fe8019', run: '#b8bb26', search: '#d3869b', user: '#fabd2f', fg: '#ebdbb2', muted: '#928374', surface: '#32302f', ok: '#b8bb26', err: '#fb4934', warn: '#fe8019' },
18 catppuccin: { read: '#89dceb', write: '#fab387', run: '#f9e2af', search: '#cba6f7', user: '#cba6f7', fg: '#cdd6f4', muted: '#7f849c', surface: '#252536', ok: '#a6e3a1', err: '#f38ba8', warn: '#f9e2af' },
19 mono: { read: '#9e9e9e', write: '#e0e0e0', run: '#bdbdbd', search: '#9e9e9e', user: '#e0e0e0', fg: '#e0e0e0', muted: '#7a7a7a', surface: '#262626', ok: '#e0e0e0', err: '#ffffff', warn: '#bdbdbd' },
20}
21const NOIR_LIGHT: Palette = { read: '#111111', write: '#111111', run: '#111111', search: '#111111', user: '#111111', fg: '#151515', muted: '#6b6b6b', surface: '#ffffff', ok: '#151515', err: '#c42b2b', warn: '#6b6b6b' }
22
23/** No skin on: drawings carry a light and a dark color (`inkOf`), text takes Claude Code's theme colors (`THEME_KEY`). */
24export const PLAIN: Palette & { themed: false } = { read: '', write: '', run: '', search: '', user: '', fg: '', muted: '', surface: '', ok: '', err: '', warn: '', themed: false }
25const PLAIN_DARK: Palette = { read: '#c084fc', write: '#fb923c', run: '#e9c46a', search: '#c084fc', user: '#60a5fa', fg: '#ececec', muted: '#9a9a96', surface: '#1f1f1e', ok: '#4ade80', err: '#f87171', warn: '#e9c46a' }
26const PLAIN_LIGHT: Palette = { read: '#7c3aed', write: '#c2410c', run: '#a16207', search: '#7c3aed', user: '#2563eb', fg: '#1f1f1f', muted: '#6b6b6b', surface: '#ffffff', ok: '#15803d', err: '#b91c1c', warn: '#a16207' }
27
28/** A drawing's color for `slot`: the skin's, or with no skin a light and dark pair. */
29export const inkOf = (pal: Palette & { themed: boolean }, slot: Slot): Ink => (pal.themed ? pal[slot] : { light: PLAIN_LIGHT[slot], dark: PLAIN_DARK[slot] })
30
31/** With no skin, text in these slots takes Claude Code's own theme colors, which follow light and dark. */
32export const THEME_KEY: Partial<Record<Slot, ThemeKey>> = { read: 'suggestion', search: 'suggestion', write: 'claude', run: 'warning', warn: 'warning', ok: 'success', err: 'error', user: 'permission' }
33
34const deepen = (hex: string, amount: number) =>
35 '#' + [1, 3, 5].map(i => Math.round(parseInt(hex.slice(i, i + 2), 16) * (1 - amount)).toString(16).padStart(2, '0')).join('')
36
37/** The skins mod's own light derivation (hooks/light.ts). */
38function toLight(p: Palette): Palette {
39 const d = (h: string) => deepen(h, 0.45)
40 return { read: d(p.read), write: d(p.write), run: d(p.run), search: d(p.search), user: d(p.user), fg: '#1f1f1f', muted: '#6b6b6b', surface: '#ffffff', ok: deepen(p.ok, 0.5), err: deepen(p.err, 0.25), warn: deepen(p.warn, 0.5) }
41}
42
43export type SkinPrefs = { skin?: string } | undefined
44export type SkinCustom = Record<string, { base?: string; palette?: Partial<Record<string, string>> }> | undefined
45
46/** The palette of the skin /skin has on, light or dark; PLAIN when skins is off or absent. */
47export function paletteOf(prefs: SkinPrefs, custom: SkinCustom, isLight: boolean | undefined): Palette & { themed: boolean } {
48 const name = prefs?.skin
49 if (!name || name === 'off') return PLAIN
50 const made = custom?.[name]
51 const baseName = made ? (made.base ?? 'noir') : name
52 const base = SKINS[baseName]
53 if (!base) return PLAIN
54 const dark = { ...base, ...(made?.palette as Partial<Palette> | undefined) }
55 const p = isLight ? (baseName === 'noir' && !made ? NOIR_LIGHT : toLight(dark)) : dark
56 return { ...p, themed: true }
57}
58
59/* ── paths ── */
60
61export const parentOf = (p: string) => (p.lastIndexOf('/') > 0 ? p.slice(0, p.lastIndexOf('/')) : '/')
62export const baseOf = (p: string) => p.slice(p.lastIndexOf('/') + 1)
63export const join = (a: string, b: string) => (a.endsWith('/') ? a + b : `${a}/${b}`)
64export const absOf = (p: string, cwd: string) => (p.startsWith('/') ? p : join(cwd, p.replace(/^\.\//, '')))
65/** `dir` relative to `root`, for "in tests"; the project name at the root itself. */
66export function relDir(path: string, root: string) {
67 const dir = parentOf(path)
68 if (dir === root) return baseOf(root)
69 return dir.startsWith(root + '/') ? dir.slice(root.length + 1) : dir
70}
71/* ── git ── */
72
73/** Absolute paths of changed and untracked files, from `git status --porcelain=v1 -z`. */
74export function parseStatus(out: string, top: string): string[] {
75 const parts = out.split('\0')
76 const dirty: string[] = []
77 for (let i = 0; i < parts.length; i++) {
78 const rec = parts[i]
79 if (!rec || rec.length < 4) continue
80 dirty.push(join(top, rec.slice(3).replace(/\/$/, '')))
81 if (rec[0] === 'R' || rec[0] === 'C') i++ // the old path follows a rename or copy
82 }
83 return dirty
84}
85
86export const isDirty = (path: string, dirty: readonly string[]) => dirty.some(d => d === path || d.startsWith(path + '/'))
87
88/* ── tree ── */
89
90export type Row = { path: string; name: string; depth: number; dir: boolean; size: number; open: boolean }
91
92/** The visible rows: each loaded, expanded folder's entries below it, folders first. */
93export function rowsOf(root: string, dirs: Record<string, Entry[]>, expanded: readonly string[], showHidden: boolean, max = 400): Row[] {
94 const rows: Row[] = []
95 const open = new Set(expanded)
96 const walk = (dir: string, depth: number) => {
97 for (const en of sortEntries(dirs[dir] ?? [], showHidden)) {
98 if (rows.length >= max) return
99 const path = join(dir, en.name)
100 const isOpen = en.dir && open.has(path)
101 rows.push({ path, name: en.name, depth, dir: en.dir, size: en.size, open: isOpen })
102 if (isOpen) walk(path, depth + 1)
103 }
104 }
105 walk(root, 0)
106 return rows
107}
108
109export function sortEntries(list: readonly Entry[], showHidden: boolean): Entry[] {
110 return list
111 .filter(e => e.name !== '.DS_Store' && (showHidden || !e.name.startsWith('.')))
112 .sort((a, b) => (a.dir === b.dir ? a.name.localeCompare(b.name) : a.dir ? -1 : 1))
113}
114
115/** Up to `max` relative paths whose name or path holds every word of `query`. */
116export function search(all: readonly string[], query: string, showHidden: boolean, max = 30): string[] {
117 const words = query.toLowerCase().split(/\s+/).filter(Boolean)
118 if (!words.length) return []
119 const hits: string[] = []
120 for (const p of all) {
121 if (!showHidden && p.split('/').some(s => s.startsWith('.'))) continue
122 const l = p.toLowerCase()
123 if (words.every(w => l.includes(w))) hits.push(p)
124 if (hits.length >= max) break
125 }
126 return hits.sort((a, b) => a.length - b.length)
127}
128
129/** Every folder from `root` down to the one holding `path`, to reveal it. */
130export function foldersTo(path: string, root: string): string[] {
131 const out: string[] = []
132 let d = parentOf(path)
133 while (d.startsWith(root + '/')) {
134 out.unshift(d)
135 d = parentOf(d)
136 }
137 return out
138}
139
140export function humanSize(n: number) {
141 if (n < 1024) return `${n} B`
142 if (n < 1024 * 1024) return `${(n / 1024).toFixed(n < 10240 ? 1 : 0)} KB`
143 return `${(n / 1024 / 1024).toFixed(1)} MB`
144}
145
146/* ── Claude's activity ── */
147
148/** Records a touch: the newest first, one row per file, an edit outranking a read. */
149export function addTouch(list: readonly Touch[], t: Touch, max = 50): Touch[] {
150 const old = list.find(x => x.path === t.path)
151 const kind = old?.kind === 'edited' && t.kind === 'read' && !t.active ? 'edited' : t.kind
152 return [{ ...t, kind }, ...list.filter(x => x.path !== t.path)].slice(0, max)
153}
154
155export const badge = (t: Touch) => (t.active ? (t.kind === 'read' ? 'Reading' : 'Editing') : t.kind === 'read' ? 'Read' : 'Edited')
156
157/** Tools whose target is one file, and whether they write it. */
158export const FILE_TOOLS: Record<string, 'read' | 'edited'> = { Read: 'read', Edit: 'edited', Write: 'edited', MultiEdit: 'edited', NotebookEdit: 'edited' }
159/** "ts" for "a/b.test.ts"; "" for a name with no dot. */
160export const extOf = (p: string) => {
161 const name = baseOf(p)
162 const i = name.lastIndexOf('.')
163 return i > 0 ? name.slice(i + 1) : ''
164}
165hooks/deck.ts 422 lines1// In Flight, the Workbench's third card: Flightdeck ported into the pane. Pure data: defaults, reducers and
2// formatting; nothing here touches `$`, so every behaviour is testable directly.
3//
4// Adapted from claude-flightdeck (https://github.com/scasella/claude-flightdeck, hooks/core.ts),
5// MIT License, Copyright (c) 2026 Stephen Casella. Permission is hereby granted, free of charge, to
6// any person obtaining a copy of this software and associated documentation files (the "Software"),
7// to deal in the Software without restriction, including without limitation the rights to use, copy,
8// modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit
9// persons to whom the Software is furnished to do so, subject to the following conditions: The above
10// copyright notice and this permission notice shall be included in all copies or substantial portions
11// of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
12// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
13// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES
14// OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
15// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
16import type {
17 DeckAgent,
18 DeckArchitect,
19 DeckBucket,
20 DeckCheck,
21 DeckGate,
22 DeckLogLine,
23 DeckLoop,
24 DeckMain,
25 DeckMoment,
26 DeckReceipt,
27 DeckRoster,
28 DeckTally,
29 DeckToolNote,
30 DeckTurn,
31 DeckUsage,
32 DeckView,
33} from '../types'
34
35// ---------------------------------------------------------------- defaults
36
37export const DEFAULT_MAIN: DeckMain = { model: '', effort: '', mode: '', steps: 0, isRunning: false }
38export const DEFAULT_USAGE: DeckUsage = {
39 pct: null,
40 tokens: null,
41 window: 0,
42 costUsd: null,
43 limits: [],
44 compactions: 0,
45 lastCompactAt: null,
46}
47export const DEFAULT_ARCHITECT: DeckArchitect = { consults: [], ids: [], seen: [], lastAdvice: '' }
48const ZERO: DeckTally = { rule: 0, ask: 0, cleared: 0, deny: 0 }
49export const DEFAULT_GATE: DeckGate = { recent: [], totals: { file: ZERO, shell: ZERO, other: ZERO } }
50export const DEFAULT_TURN: DeckTurn = { edits: 0, errorStreak: 0, errors: 0, isReviewing: false, startedAt: 0, costAtStart: null }
51export const DEFAULT_VIEW: DeckView = { expanded: null, gateOpen: null }
52export const DEFAULT_ROSTER: DeckRoster = { architectTypes: [] }
53
54const isObject = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v)
55
56/** A stored object merged over its defaults, so a value saved under an older shape still reads. */
57export const normalize = <T extends object>(def: T, stored: unknown): T =>
58 isObject(stored) ? ({ ...def, ...stored } as T) : def
59
60/** A stored list, or empty when what is stored is not a list. */
61export const listOf = <T>(stored: unknown): T[] => (Array.isArray(stored) ? (stored as T[]) : [])
62
63export const normalizeGate = (stored: unknown): DeckGate => {
64 const g = normalize(DEFAULT_GATE, stored)
65 const totals = normalize(DEFAULT_GATE.totals, g.totals)
66 return {
67 recent: listOf<DeckCheck>(g.recent),
68 totals: { file: normalize(ZERO, totals.file), shell: normalize(ZERO, totals.shell), other: normalize(ZERO, totals.other) },
69 }
70}
71
72export const normalizeCard = (stored: unknown): DeckAgent =>
73 normalize<DeckAgent>(
74 {
75 id: '',
76 type: 'agent',
77 model: '',
78 description: '',
79 status: 'running',
80 spawnedAt: 0,
81 endedAt: null,
82 ctx: 0,
83 out: 0,
84 steps: 0,
85 lastStop: null,
86 tools: [],
87 answer: '',
88 },
89 stored,
90 )
91
92export const normalizeLog = (stored: unknown): DeckLogLine[] =>
93 listOf<Record<string, unknown>>(stored).map(l => ({
94 at: typeof l.at === 'number' ? l.at : 0,
95 who: String(l.who ?? ''),
96 text: String(l.text ?? ''),
97 agentId: typeof l.agentId === 'string' ? l.agentId : null,
98 kind: l.kind === 'error' || l.kind === 'consult' || l.kind === 'done' ? l.kind : 'info',
99 }))
100
101// ---------------------------------------------------------------- colors
102
103/** SVG cannot name theme keys: mid-tone colors that read on light and dark backgrounds. */
104export const SVG_COLORS = { running: '#3b82f6', done: '#16a34a', failed: '#dc2626', other: '#8b5cf6', label: '#6b7280' }
105
106// ---------------------------------------------------------------- formatting
107
108/** `claude-opus-5-5` → `Opus 5.5`; anything else is shown as given. */
109/**
110 * A model id as people say it, from any provider's spelling: `claude-opus-5-5[1m]` → `Opus 5.5 1M`,
111 * `us.anthropic.claude-sonnet-4-5-20250929-v1:0` → `Sonnet 4.5`, `claude-3-5-haiku-20241022` →
112 * `Haiku 3.5`. Anything else is shown as given, cut to 22 characters.
113 */
114export const prettyModel = (id: string) => {
115 if (!id) return '-'
116 const cap = (f: string) => f.charAt(0).toUpperCase() + f.slice(1)
117 const big = /\[1m\]|-1m\b/i.test(id) ? ' 1M' : ''
118 const now = /claude-([a-z]+)-(\d+)(?:-(\d{1,2}))?(?:-\d{8})?(?![\d])/i.exec(id)
119 if (now?.[1] && !/^\d/.test(now[1])) return `${cap(now[1].toLowerCase())} ${now[2]}${now[3] ? `.${now[3]}` : ''}${big}`
120 const old = /claude-(\d+)(?:-(\d))?-([a-z]+)/i.exec(id)
121 if (old?.[3]) return `${cap(old[3].toLowerCase())} ${old[1]}${old[2] ? `.${old[2]}` : ''}${big}`
122 return shorten(id, 22)
123}
124
125export const shorten = (s: string, n: number) => {
126 const one = s.replace(/\s+/g, ' ').trim()
127 return n <= 0 ? '' : one.length > n ? `${one.slice(0, Math.max(0, n - 1)).trimEnd()}…` : one
128}
129
130export const kTokens = (n: number) =>
131 n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)}M` : n >= 1000 ? `${Math.round(n / 1000)}k` : String(n)
132
133export const fmtDuration = (ms: number) => {
134 const s = Math.max(0, Math.round(ms / 1000))
135 if (s < 60) return `${s}s`
136 const m = Math.floor(s / 60)
137 return m < 60 ? `${m}m${String(s % 60).padStart(2, '0')}s` : `${Math.floor(m / 60)}h${String(m % 60).padStart(2, '0')}m`
138}
139
140/** A running clock as the live cards draw it: m:ss, or XhYY past an hour. */
141export const fmtTimer = (ms: number) => {
142 const s = Math.max(0, Math.floor(ms / 1000))
143 const m = Math.floor(s / 60)
144 return m < 60 ? `${m}:${String(s % 60).padStart(2, '0')}` : `${Math.floor(m / 60)}h${String(m % 60).padStart(2, '0')}`
145}
146
147export const fmtClock = (ms: number) => (ms > 0 ? new Date(ms).toTimeString().slice(0, 8) : '--:--:--')
148
149/** `1 error`, `2 errors`. */
150export const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? '' : 's'}`
151
152export const fmtUsd = (n: number) => (n >= 100 ? `$${Math.round(n)}` : `$${n.toFixed(2)}`)
153
154/** A gauge of `width` cells: ▰ filled, ▱ empty. */
155export const gauge = (pct: number, width: number) => {
156 const full = Math.max(0, Math.min(width, Math.round((pct / 100) * width)))
157 return { on: '▰'.repeat(full), off: '▱'.repeat(width - full) }
158}
159
160/** A rate-limit window's short name: `five_hour` → `5h`, `seven_day_opus` → `7d opus`. */
161export const limitLabel = (kind: string) =>
162 kind
163 .replace(/five[_ -]?hours?/i, '5h')
164 .replace(/seven[_ -]?days?/i, '7d')
165 .replace(/[_-]+/g, ' ')
166 .trim()
167
168// ---------------------------------------------------------------- redaction
169
170const SECRETS: [RegExp, string][] = [
171 [/(authorization\s*[:=]\s*)(bearer\s+|basic\s+)?\S+/gi, '$1$2•••'],
172 [/\b(bearer)\s+[A-Za-z0-9._~+/-]{8,}=*/gi, '$1 •••'],
173 [/\b(sk|pk|rk|ghp|gho|ghs|github_pat|xox[abprs])[-_][A-Za-z0-9_-]{8,}/g, '•••'],
174 [/((?:api[_-]?key|access[_-]?token|token|secret|password|passwd|pwd)\s*[=:]\s*)("[^"]*"|'[^']*'|\S+)/gi, '$1•••'],
175 [/(--(?:token|password|api-key|secret)[= ])\S+/gi, '$1•••'],
176 [/(\b[A-Z][A-Z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD)=)\S+/g, '$1•••'],
177 [/(\b[a-z][a-z0-9+.-]*:\/\/[^\s/:@]+:)[^\s@]+@/gi, '$1•••@'],
178]
179
180/** What the gate drill-down may store: credentials masked before anything is written to state. */
181export const redact = (s: string) => SECRETS.reduce((t, [re, to]) => t.replace(re, to), s)
182
183// ---------------------------------------------------------------- tools and gate
184
185const FILE_TOOLS = new Set(['Read', 'Edit', 'Write', 'NotebookEdit', 'Glob', 'Grep'])
186const SHELL_TOOLS = new Set(['Bash', 'PowerShell'])
187export const EDIT_TOOLS = new Set(['Edit', 'Write', 'NotebookEdit'])
188
189export const bucketOf = (tool: string): DeckBucket =>
190 FILE_TOOLS.has(tool) ? 'file' : SHELL_TOOLS.has(tool) ? 'shell' : 'other'
191
192/** One line saying what a call was about: its command, path or pattern; redacted. */
193export const describeInput = (tool: string, input: unknown) => {
194 const i = isObject(input) ? input : {}
195 const path = typeof i.file_path === 'string' ? i.file_path.split(/[\\/]/).slice(-2).join('/') : ''
196 const what =
197 typeof i.command === 'string'
198 ? i.command
199 : path || (typeof i.pattern === 'string' ? i.pattern : typeof i.url === 'string' ? i.url : typeof i.description === 'string' ? i.description : '')
200 return redact(what ? `${tool} → ${what}` : tool)
201}
202
203/** Keeps the last `max` checks, but never drops a pending ask: its settle must still find it. */
204export const trimRecent = (list: DeckCheck[], max: number): DeckCheck[] => {
205 let extra = list.length - max
206 if (extra <= 0) return list
207 const out: DeckCheck[] = []
208 for (const c of list) {
209 if (extra > 0 && c.verdict !== 'ask') {
210 extra -= 1
211 continue
212 }
213 out.push(c)
214 }
215 return out.slice(-max * 2)
216}
217
218export const recordCheck = (g: DeckGate, c: DeckCheck): DeckGate => {
219 const t = g.totals[c.bucket]
220 return {
221 recent: trimRecent([...g.recent, c], 80),
222 totals: { ...g.totals, [c.bucket]: { ...t, [c.verdict]: t[c.verdict] + 1 } },
223 }
224}
225
226/** An `ask` settled by the call that followed it: it ran (cleared) or was refused (deny). */
227export const settleCheck = (g: DeckGate, id: string, didRun: boolean): DeckGate => {
228 const c = g.recent.find(r => r.id === id && r.verdict === 'ask')
229 if (!c) return g
230 const verdict = didRun ? 'cleared' : 'deny'
231 const t = g.totals[c.bucket]
232 return {
233 recent: g.recent.map(r => (r === c ? { ...r, verdict } : r)),
234 totals: { ...g.totals, [c.bucket]: { ...t, ask: Math.max(0, t.ask - 1), [verdict]: t[verdict] + 1 } },
235 }
236}
237
238export const gateSummary = (g: DeckGate) => {
239 const all = (['file', 'shell', 'other'] as const).reduce(
240 (s, k) => ({
241 rule: s.rule + g.totals[k].rule,
242 ask: s.ask + g.totals[k].ask,
243 cleared: s.cleared + g.totals[k].cleared,
244 deny: s.deny + g.totals[k].deny,
245 }),
246 { ...ZERO },
247 )
248 return { ...all, total: all.rule + all.ask + all.cleared + all.deny }
249}
250
251// ---------------------------------------------------------------- turn and architect
252
253/**
254 * The turn after one tool call. Errors count in the main loop only (a subagent's failure is its
255 * own); edits count from every loop, so delegated work still reaches "before done".
256 */
257export const afterCall = (t: DeckTurn, c: { inSubagent: boolean; hasFailed: boolean; isEdit: boolean }): DeckTurn => ({
258 ...t,
259 errorStreak: c.inSubagent ? t.errorStreak : c.hasFailed ? t.errorStreak + 1 : 0,
260 errors: t.errors + (!c.inSubagent && c.hasFailed ? 1 : 0),
261 edits: t.edits + (c.isEdit ? 1 : 0),
262})
263
264/** Which of the architect's three moments a consult falls at: an inference over this turn so far. */
265export const momentOf = (t: Pick<DeckTurn, 'edits' | 'errorStreak'>): DeckMoment =>
266 t.errorStreak >= 2 ? 'error repeats' : t.edits === 0 ? 'before a plan' : 'before done'
267
268export const startConsult = (a: DeckArchitect, c: { id: string; at: number; moment: DeckMoment; via: string }): DeckArchitect =>
269 a.consults.some(x => x.id === c.id) ? a : { ...a, consults: [...a.consults, { ...c, endAt: null }].slice(-40) }
270
271/** Ends the open consult (the latest without an end), or the one named. */
272export const endConsult = (a: DeckArchitect, at: number, advice: string | null, id?: string): DeckArchitect => {
273 const open = [...a.consults].reverse().find(c => c.endAt === null && (id === undefined || c.id === id))
274 return {
275 ...a,
276 consults: a.consults.map(c => (c === open ? { ...c, endAt: at } : c)),
277 lastAdvice: advice ?? a.lastAdvice,
278 }
279}
280
281export const isAdvising = (a: DeckArchitect) => a.consults.some(c => c.endAt === null)
282
283/** A one-row timeline of consults across `width` cells: ◆ a consult, ━ while it ran. */
284export const consultTimeline = (a: DeckArchitect, now: number, width: number) => {
285 if (a.consults.length === 0 || width < 4) return '─'.repeat(Math.max(0, width))
286 const first = a.consults[0]?.at ?? now
287 const span = Math.max(1, now - first)
288 const cells = Array.from({ length: width }, () => '─')
289 for (const c of a.consults) {
290 const from = Math.min(width - 1, Math.floor(((c.at - first) / span) * (width - 1)))
291 const to = Math.min(width - 1, Math.floor((((c.endAt ?? now) - first) / span) * (width - 1)))
292 for (let i = from + 1; i <= to; i += 1) cells[i] = '━'
293 cells[from] = '◆'
294 }
295 return cells.join('')
296}
297
298export const receiptOf = (t: DeckTurn, o: { durationMs: number; agentsSince: number; costNow: number | null; reason: string }): DeckReceipt => ({
299 durationMs: o.durationMs,
300 agents: o.agentsSince,
301 edits: t.edits,
302 errors: t.errors,
303 costDelta: o.costNow !== null && t.costAtStart !== null && o.costNow - t.costAtStart >= 0.005 ? o.costNow - t.costAtStart : null,
304 reason: o.reason,
305})
306
307// ---------------------------------------------------------------- agents and loops
308
309type StepUsage = { input_tokens?: number; cache_read_input_tokens?: number; cache_creation_input_tokens?: number; output_tokens?: number } | null
310
311/** A card after one of its model requests: its context is the latest step's whole input; output adds up. */
312export const applyStep = (c: DeckAgent, s: { model: string; usage: StepUsage; stopReason: string | null }): DeckAgent => {
313 const u = s.usage ?? {}
314 const ctx = (u.input_tokens ?? 0) + (u.cache_read_input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0)
315 return {
316 ...c,
317 model: c.model || s.model,
318 steps: c.steps + 1,
319 ctx: ctx > 0 ? ctx : c.ctx,
320 out: c.out + (u.output_tokens ?? 0),
321 lastStop: s.stopReason,
322 }
323}
324
325export const noteTool = (c: DeckAgent, n: DeckToolNote): DeckAgent => ({ ...c, tools: [...c.tools, n].slice(-3) })
326
327export const stepLoop = (loops: DeckLoop[], id: string, at: number): DeckLoop[] => {
328 const found = loops.find(l => l.id === id)
329 const next = found
330 ? loops.map(l => (l === found ? { ...l, steps: l.steps + 1, lastAt: at } : l))
331 : [...loops, { id, steps: 1, firstAt: at, lastAt: at, isDone: false }]
332 return next.slice(-60)
333}
334
335export const LOOP_ACTIVE_MS = 15_000
336export const isLoopActive = (l: DeckLoop, now: number) => !l.isDone && now - l.lastAt < LOOP_ACTIVE_MS
337
338/** Swimlane geometry: each agent's bar on one shared axis from the first spawn to now. */
339export const lanes = (cards: DeckAgent[], now: number, width: number) => {
340 const start = Math.min(...cards.map(c => c.spawnedAt).filter(n => n > 0), now)
341 const span = Math.max(1, now - start)
342 return cards.map(c => {
343 const from = Math.floor(((Math.max(c.spawnedAt, start) - start) / span) * width)
344 const to = Math.max(from + 1, Math.ceil((((c.endedAt ?? now) - start) / span) * width))
345 return { id: c.id, before: Math.min(from, width), bar: Math.min(to, width) - Math.min(from, width), after: width - Math.min(to, width) }
346 })
347}
348
349// ---------------------------------------------------------------- text
350
351/** A card's title row: the task in the agent's own words, the type only when there is none. */
352export const cardTitle = (c: DeckAgent) => c.description || c.type
353
354/** A title split over two rows at a word boundary: `first` cells on row one, `rest` on row two. */
355export const titleLines = (title: string, first: number, rest: number): [string, string] => {
356 const t = title.replace(/\s+/g, ' ').trim()
357 if (t.length <= first) return [t, '']
358 const cut = t.lastIndexOf(' ', first)
359 const at = cut > 0 ? cut : first
360 return [t.slice(0, at).trim(), shorten(t.slice(at), rest)]
361}
362
363/**
364 * What a turn's opening text was, for the log: the person's words, or for a turn the engine
365 * opened with a tagged message (a subagent's hand-back, a task notification), that message's kind.
366 */
367export const promptLine = (text: string): { who: string; text: string } => {
368 const tag = /^\s*<([a-z][\w-]*)/i.exec(text)?.[1]
369 if (!tag) return { who: 'you', text: shorten(text, 70) }
370 const from = /\bfrom="([^"]+)"/.exec(text)?.[1]
371 return { who: 'engine', text: shorten(`${tag.replace(/[-_]/g, ' ')}${from ? ` from ${from.slice(0, 8)}` : ''}`, 70) }
372}
373
374/**
375 * A subagent's hand-back message: who sent it and the first line of what it said. The report
376 * follows a framing header in the message; without one, the first line after the opening tag.
377 */
378export const handbackOf = (text: string): { from: string; body: string } | null => {
379 const from = /^\s*<agent-message\s+from="([^"]+)"/.exec(text)?.[1]
380 if (!from) return null
381 const afterHeader = text.split(/The report follows:\s*\n/)[1]
382 const rest = afterHeader ?? text.replace(/^\s*<agent-message[^>]*>/, '')
383 const body =
384 rest
385 .split('\n')
386 .map(l => l.trim())
387 .find(l => l && !l.startsWith('[') && !l.startsWith('<') && !l.startsWith('</')) ?? ''
388 return body ? { from, body } : null
389}
390
391/** The advice line a pane shows for a report: its first real line, markdown markers stripped, cut to 160. */
392export const adviceLine = (report: string) => {
393 const first = report.split('\n').map(l => l.trim()).find(l => l && !l.startsWith('[') && !l.startsWith('<')) ?? ''
394 return shorten(first.replace(/\*\*|__/g, '').replace(/^[#>*\s-]+/, ''), 160)
395}
396
397export const elapsedOf = (c: DeckAgent, now: number) => (c.endedAt ?? now) - c.spawnedAt
398
399// ---------------------------------------------------------------- the card
400
401/** Everything the card draws, read once per redraw. */
402export type DeckData = {
403 main: DeckMain
404 usage: DeckUsage
405 arch: DeckArchitect
406 gate: DeckGate
407 cards: DeckAgent[]
408 loops: DeckLoop[]
409 log: DeckLogLine[]
410 turn: DeckTurn
411 receipt: DeckReceipt | null
412 view: DeckView
413 now: number
414}
415
416/** What a reset writes: agents, checks, consults, log and the turn clear; model, mode, cost, rate limits and compactions stay. */
417export const RESET = {
418 main: (m: unknown): DeckMain => ({ ...DEFAULT_MAIN, model: normalize(DEFAULT_MAIN, m).model, mode: normalize(DEFAULT_MAIN, m).mode }),
419 // The context gauge waits for the next measurement rather than showing the pre-clear fill.
420 usage: (u: unknown): DeckUsage => ({ ...normalize(DEFAULT_USAGE, u), pct: null, tokens: null }),
421}
422hooks/deck-view.tsx 316 lines1// The In Flight card's drawing, stacked for the Workbench's one column: main vitals, the
2// architect, the permission gate, agent cards and lanes, other loops, the turn receipt and the
3// session log. Pure: register.tsx reads the state and passes the presses back in.
4//
5// Adapted from claude-flightdeck (https://github.com/scasella/claude-flightdeck, hooks/register.tsx),
6// MIT License, Copyright (c) 2026 Stephen Casella. The full notice is at the top of ./deck.ts.
7import type { ThemeKey } from 'claude-code'
8
9import type { DeckAgent, DeckBucket, DeckCheck, DeckLogLine } from '../types'
10import type { Palette, Slot } from './bench'
11import {
12 cardTitle, consultTimeline, fmtDuration, fmtTimer, fmtUsd, gateSummary, gauge, isAdvising, isLoopActive, kTokens,
13 limitLabel, plural, prettyModel, shorten, titleLines,
14} from './deck'
15import type { DeckData } from './deck'
16
17/** Agent tiles side by side; earlier agents are listed below them, this many rows at most. */
18const TILES = 3
19const EARLIER_ROWS = 6
20const ARCH_LABEL = 'Architect'
21
22type Role = 'main' | 'agent' | 'gate' | 'cleared' | 'arch' | 'amber' | 'warn' | 'dim' | 'faint' | 'text'
23/** Each role's skin slot, and with no skin the Claude Code theme key the original Flightdeck uses. */
24const ROLE: Record<Role, [Slot, ThemeKey]> = {
25 main: ['write', 'claude'],
26 agent: ['read', 'suggestion'],
27 gate: ['ok', 'success'],
28 cleared: ['user', 'permission'],
29 arch: ['search', 'merged'],
30 amber: ['warn', 'warning'],
31 warn: ['err', 'error'],
32 dim: ['muted', 'inactive'],
33 faint: ['muted', 'subtle'],
34 text: ['fg', 'text'],
35}
36
37/** What the pane lends the card: its elements, width, palette and helpers, so the card looks like the other two. */
38export type DeckKit = {
39 // eslint-disable-next-line @typescript-eslint/no-explicit-any
40 els: any
41 cols: number
42 pal: Palette & { themed: boolean }
43 card: (key: string, kids: unknown[], marginTop?: number, tinted?: boolean) => unknown
44 capsText: (key: string, text: string, slot?: Slot, size?: number) => unknown
45 /** The agent whose transcript the person is viewing: the log narrows to it. */
46 viewed: string | null
47 onGate: (b: DeckBucket) => void
48 onExpand: (id: string) => void
49 onReset: () => void
50}
51
52/** The section's title: the main model and whether it works. */
53export function deckTitle(m: DeckData['main']) {
54 return `In Flight · ${m.model ? prettyModel(m.model) : 'main'} ${m.isRunning ? 'working' : 'idle'}`
55}
56
57export function deckKids(d: DeckData, kit: DeckKit): unknown[] {
58 const { els, cols, pal, card, capsText, viewed } = kit
59 const { Box, Text, Button } = els
60 const hasClient = 'Client' in els
61 const { main: m, usage: u, arch: a, gate: g, cards, loops: lp, log: lines, turn: t, receipt: r, view: v, now } = d
62 const col = (role: Role) => (pal.themed ? pal[ROLE[role][0]] : ROLE[role][1])
63 const slot = (role: Role) => ROLE[role][0]
64 // The text width inside a card inside the section's card: each takes its border and padding.
65 const w = Math.max(20, cols - 13)
66 const modelName = prettyModel(m.model)
67 const advising = isAdvising(a)
68 const running = cards.filter(c => c.status === 'running')
69 const decider = m.mode === 'auto' ? 'classifier' : 'you'
70 const head = (key: string, left: string, role: Role, right?: unknown) => (
71 <Box key={key} flexDirection="row" justifyContent="space-between" alignItems="center" width="100%">
72 {capsText(key + '-l', left, slot(role))}
73 {right ?? null}
74 </Box>
75 )
76 // A start time of 0 is unknown: no clock rather than decades. The client ticks between redraws.
77 const clock = (key: string, since: number, endAt: number | null, color: string) =>
78 since <= 0 ? <Text key={key} color={color}>-</Text>
79 : hasClient ? <els.Client key={key} module="./elapsed.tsx" props={{ since, now, endAt, color }} />
80 : <Text key={key} color={color}>{fmtTimer((endAt ?? now) - since)}</Text>
81 const out: unknown[] = []
82
83 // ── main: model, effort, context, cost and rate limits
84 const effortN = ({ low: 1, medium: 2, high: 3, xhigh: 4, max: 4 } as Record<string, number>)[m.effort] ?? 0
85 const ctx = u.pct !== null ? gauge(u.pct, Math.min(16, Math.max(6, w - 18))) : null
86 out.push(
87 card('fd-main', [
88 head('fd-main-h', `${modelName} · main`, 'main', <Text key="fd-run" color={m.isRunning ? col('main') : col('dim')}>{m.isRunning ? '● working' : '○ idle'}</Text>),
89 <Text key="fd-effort" wrap="truncate-end">
90 <Text dimColor>effort </Text>
91 <Text color={col('main')}>{'▮'.repeat(effortN) + '▯'.repeat(4 - effortN)} </Text>
92 <Text color={col('main')} bold>{m.effort || '-'}</Text>
93 {m.mode ? <Text dimColor>{` mode ${m.mode}`}</Text> : null}
94 <Text dimColor>{` ${m.steps} req`}</Text>
95 </Text>,
96 ctx ? (
97 <Text key="fd-ctx" wrap="truncate-end">
98 <Text dimColor>ctx </Text>
99 <Text color={(u.pct ?? 0) >= 80 ? col('warn') : col('main')}>{ctx.on}</Text>
100 <Text color={col('faint')}>{ctx.off}</Text>
101 <Text bold>{` ${Math.round(u.pct ?? 0)}%`}</Text>
102 {u.tokens !== null ? <Text dimColor>{` ${kTokens(u.tokens)}/${kTokens(u.window)}`}</Text> : null}
103 {u.compactions > 0 ? <Text color={col('amber')}>{` ⟲${u.compactions}`}</Text> : null}
104 </Text>
105 ) : null,
106 u.costUsd !== null || u.limits.length > 0 ? (
107 <Box key="fd-cost" flexDirection="row" flexWrap="wrap" columnGap={2}>
108 {u.costUsd !== null ? <Text key="fd-usd" color={col('text')}>{fmtUsd(u.costUsd)}</Text> : null}
109 {u.limits.slice(0, 2).map(l => {
110 const lg = gauge(l.pct, 5)
111 return (
112 <Text key={'fd-lim-' + l.kind}>
113 <Text dimColor>{`${limitLabel(l.kind)} `}</Text>
114 <Text color={l.pct >= 80 ? col('warn') : col('main')}>{lg.on}</Text>
115 <Text color={col('faint')}>{lg.off}</Text>
116 <Text dimColor>{` ${Math.round(l.pct)}%`}</Text>
117 </Text>
118 )
119 })}
120 </Box>
121 ) : null,
122 ], 1, true),
123 )
124
125 // ── architect: only once one has been consulted or spawned
126 if (a.consults.length > 0 || a.ids.length > 0) {
127 const last = a.consults[a.consults.length - 1]
128 out.push(
129 card('fd-arch', [
130 head('fd-arch-h', `${ARCH_LABEL} · ${advising ? 'advising' : 'on call'}`, 'arch', <Text key="fd-arch-n"><Text dimColor>consults </Text><Text color={col('arch')} bold>{a.consults.length}</Text></Text>),
131 <Text key="fd-arch-tl" color={col('arch')}>{consultTimeline(a, now, w)}</Text>,
132 <Text key="fd-arch-last" dimColor wrap="truncate-end">
133 {!last ? 'not consulted yet'
134 : advising ? `consulting for ${fmtDuration(now - last.at)}`
135 : `last ${fmtDuration(now - (last.endAt ?? last.at))} ago · took ${fmtDuration((last.endAt ?? now) - last.at)}`}
136 </Text>,
137 <Box key="fd-arch-mo" flexDirection="row" flexWrap="wrap" columnGap={2}>
138 {(['before a plan', 'error repeats', 'before done'] as const).map(mo => (
139 <Text key={'fd-mo-' + mo} color={last?.moment === mo ? col('arch') : col('dim')} bold={last?.moment === mo}>{`${last?.moment === mo ? '◆' : '◇'} ${mo}`}</Text>
140 ))}
141 <Text key="fd-mo-inf" color={col('faint')}>(inferred)</Text>
142 </Box>,
143 a.lastAdvice ? <Text key="fd-arch-adv" color={col('arch')} wrap="truncate-end">{`» ${a.lastAdvice}`}</Text> : null,
144 ], 1),
145 )
146 }
147
148 // ── gate: one cell per permission check, totals, a drill-down per tool family
149 const s = gateSummary(g)
150 if (g.recent.length > 0 || s.total > 0) {
151 const verdictColor = (c: DeckCheck) => (c.verdict === 'rule' ? col('gate') : c.verdict === 'cleared' ? col('cleared') : c.verdict === 'ask' ? col('amber') : col('warn'))
152 const said = (c: DeckCheck) => (c.verdict === 'rule' ? 'allowed' : c.verdict === 'cleared' ? decider : c.verdict === 'ask' ? 'pending' : 'denied')
153 const open = v.gateOpen
154 const kids: unknown[] = [
155 head('fd-gate-h', 'Gate · permissions', 'gate', <Text key="fd-gate-n" dimColor>{plural(s.total, 'check')}</Text>),
156 <Text key="fd-strip" wrap="truncate-end">
157 {g.recent.slice(-w).map((c, i) => <Text key={'fd-c-' + i} color={verdictColor(c)} dimColor={c.inSubagent}>{c.verdict === 'deny' ? '✗' : '■'}</Text>)}
158 </Text>,
159 <Box key="fd-gate-sum" flexDirection="row" flexWrap="wrap" columnGap={2}>
160 <Text key="fd-g-r"><Text color={col('gate')}>■</Text><Text dimColor>{` ${s.rule} allowed`}</Text></Text>
161 <Text key="fd-g-c"><Text color={col('cleared')}>■</Text><Text dimColor>{` ${s.cleared} ${decider}`}</Text></Text>
162 {s.ask > 0 ? <Text key="fd-g-a" color={col('amber')}>{`■ ${s.ask} pending`}</Text> : null}
163 <Text key="fd-g-d" color={s.deny > 0 ? col('warn') : col('dim')}>{`✗ ${s.deny} denied`}</Text>
164 </Box>,
165 <Box key="fd-gate-b" flexDirection="row" columnGap={2} marginTop={1}>
166 {(['file', 'shell', 'other'] as const).map((b: DeckBucket) => {
167 const tl = g.totals[b]
168 const n = tl.rule + tl.ask + tl.cleared + tl.deny
169 return (
170 <Button key={'fd-gb-' + b} plain hotkey={b[0]} label={`${b} ${n}${open === b ? ' ▾' : ''}`} dimColor={n === 0}
171 onPress={() => kit.onGate(b)} />
172 )
173 })}
174 </Box>,
175 ]
176 if (open)
177 for (const [i, c] of g.recent.filter(x => x.bucket === open).slice(-5).entries())
178 kids.push(
179 <Text key={'fd-gd-' + i} wrap="truncate-end">
180 <Text color={verdictColor(c)}>{c.verdict === 'deny' ? '✗ ' : '■ '}</Text>
181 <Text color={col('dim')}>{`${said(c).padEnd(10)} `}</Text>
182 <Text dimColor={c.inSubagent}>{c.detail}</Text>
183 </Text>,
184 )
185 out.push(card('fd-gate', kids, 1))
186 }
187
188 // ── agents: the newest three as tiles side by side, any earlier ones listed below
189 if (cards.length > 0) {
190 const statusColor = (c: DeckAgent) => (c.status === 'failed' ? col('warn') : c.status === 'done' ? col('gate') : col('agent'))
191 const glyph = (c: DeckAgent) => (c.status === 'running' ? '◐' : c.status === 'done' ? '✓' : c.status === 'failed' ? '✗' : '■')
192 const expand = (id: string) => () => kit.onExpand(id)
193 const tiles = cards.slice(-TILES)
194 const earlier = cards.slice(0, -TILES).reverse()
195 // Each tile shares the row; its text gets the tile less its border and padding.
196 const inner = Math.max(6, Math.floor((w - (tiles.length - 1)) / tiles.length) - 4)
197 const kids: unknown[] = [head('fd-ag-h', `Agents · ${running.length} running · ${cards.length} total`, 'agent')]
198 kids.push(
199 <Box key="fd-tiles" flexDirection="row" columnGap={1} marginTop={1} width="100%">
200 {tiles.map((c, i) => {
201 const title = titleLines(cardTitle(c), inner - 3, inner)
202 const sameModel = !c.model || prettyModel(c.model) === modelName
203 const isMax = c.lastStop === 'max_tokens'
204 return (
205 <Box key={'fd-cd-' + c.id} flexDirection="column" width={`${Math.floor(100 / tiles.length)}%`} flexShrink={1} minWidth={0}
206 borderStyle={v.expanded === c.id ? 'double' : 'round'} borderColor={isMax ? col('warn') : col('agent')} borderDimColor={c.status !== 'running' && v.expanded !== c.id} paddingX={1}>
207 <Button key={'fd-cb-' + c.id} plain hotkey={String(i + 1)} label={title[0]} onPress={expand(c.id)} />
208 <Text bold wrap="truncate-end">{title[1] || ' '}</Text>
209 <Text color={col('dim')} wrap="truncate-end">{sameModel ? c.type : `${c.type} · ${prettyModel(c.model)}`}</Text>
210 <Text dimColor wrap="truncate-end">{c.steps > 0 ? `${kTokens(c.ctx)} ctx · ${c.steps} st` : 'starting…'}</Text>
211 <Box flexDirection="row" columnGap={1}>
212 <Text color={isMax ? col('warn') : statusColor(c)} wrap="truncate-end">{`${glyph(c)} ${isMax ? 'max_tokens' : c.status}`}</Text>
213 <Box flexShrink={0}>{clock('fd-cc-' + c.id, c.spawnedAt, c.endedAt, col('dim'))}</Box>
214 </Box>
215 </Box>
216 )
217 })}
218 </Box>,
219 )
220 if (earlier.length) {
221 kids.push(<Box key="fd-ag-earlier" marginTop={1}>{capsText('fd-ag-earlier-l', `Earlier · ${earlier.length}`, slot('dim'))}</Box>)
222 for (const [i, c] of earlier.slice(0, EARLIER_ROWS).entries())
223 kids.push(
224 <Box key={'fd-er-' + c.id} flexDirection="row" columnGap={1} alignItems="center" width="100%">
225 <Text color={statusColor(c)}>{glyph(c)}</Text>
226 <Box flexGrow={1} flexShrink={1} minWidth={0}>
227 <Button key={'fd-eb-' + c.id} plain hotkey={i + TILES < 9 ? String(i + TILES + 1) : undefined} label={shorten(cardTitle(c), Math.max(8, w - 18))} onPress={expand(c.id)} />
228 </Box>
229 {c.steps > 0 ? <Text dimColor>{`${kTokens(c.ctx)} ctx`}</Text> : null}
230 <Box flexShrink={0}>{clock('fd-ec-' + c.id, c.spawnedAt, c.endedAt, col('dim'))}</Box>
231 </Box>,
232 )
233 if (earlier.length > EARLIER_ROWS) kids.push(<Text key="fd-ag-more" color={col('faint')}>{`+${earlier.length - EARLIER_ROWS} more`}</Text>)
234 }
235 const x = cards.find(c => c.id === v.expanded)
236 if (x) {
237 kids.push(
238 <Box key="fd-exp" flexDirection="column" marginTop={1} width="100%" borderStyle="single" borderColor={col('agent')} paddingX={1}>
239 <Text bold wrap="wrap">{x.description || x.type}</Text>
240 <Text dimColor wrap="truncate-end">{`${x.type} · ${prettyModel(x.model)} · ${x.status} · ${plural(x.steps, 'step')}`}</Text>
241 {x.steps > 0 ? <Text dimColor wrap="truncate-end">{`ctx ${kTokens(x.ctx)} · out ${kTokens(x.out)}`}</Text> : null}
242 {x.tools.length === 0 ? <Text color={col('faint')}>no tool calls yet</Text> : null}
243 {x.tools.map((n, i) => <Text key={'fd-et-' + i} color={n.isError ? col('warn') : col('text')} wrap="truncate-end">{`${n.isError ? '✗' : '·'} ${n.text}`}</Text>)}
244 {x.answer ? <Text dimColor wrap="wrap">{`» ${shorten(x.answer, 240)}`}</Text> : null}
245 </Box>,
246 )
247 }
248 kids.push(<Text key="fd-ag-hint" color={col('faint')}>{`press an agent to expand it · 1-${Math.min(9, cards.length)} when the pane has focus`}</Text>)
249 out.push(card('fd-agents', kids, 1))
250 }
251
252 // ── other loops: model loops no card claims (workflow agents, compactions, memory forks)
253 if (lp.length > 0) {
254 const active = lp.filter(l => isLoopActive(l, now)).length
255 out.push(
256 <Box key="fd-loops" flexDirection="row" columnGap={1} marginTop={1} width="100%">
257 <Text bold>other loops</Text>
258 <Text dimColor>{`${lp.length} seen · ${active} active`}</Text>
259 <Text wrap="truncate-end">
260 {lp.slice(-Math.max(4, w - 28)).map((l, i) => (
261 <Text key={'fd-lp-' + i} color={isLoopActive(l, now) ? col('agent') : l.isDone ? col('dim') : col('faint')}>{isLoopActive(l, now) ? '●' : l.isDone ? '✓' : '○'}</Text>
262 ))}
263 </Text>
264 </Box>,
265 )
266 }
267
268 // ── receipt: the turn now, or the last one
269 if (m.isRunning || r) {
270 out.push(
271 card('fd-receipt', [
272 m.isRunning ? (
273 <Box key="fd-rc" flexDirection="row" flexWrap="wrap" columnGap={1}>
274 <Text color={col('main')}>{`◐ ${modelName.toLowerCase()} · turn`}</Text>
275 <Box flexShrink={0}>{clock('fd-turn', t.startedAt, null, col('main'))}</Box>
276 <Text dimColor>{`· ${plural(t.edits, 'edit')} · ${plural(t.errors, 'error')}`}</Text>
277 </Box>
278 ) : r ? (
279 <Text key="fd-rc" wrap="wrap">
280 <Text color={r.reason === 'answer' ? col('gate') : col('warn')}>{r.reason === 'answer' ? '✓ ' : '✗ '}</Text>
281 <Text>{`last turn ${fmtDuration(r.durationMs)} · ${plural(r.agents, 'agent')} · ${plural(r.edits, 'edit')} · ${plural(r.errors, 'error')}`}</Text>
282 {r.costDelta !== null ? <Text color={col('main')}>{` · +${fmtUsd(r.costDelta)}`}</Text> : null}
283 </Text>
284 ) : null,
285 t.isReviewing ? <Text key="fd-rv" color={col('arch')}>architect reviewing before done (inferred)</Text> : null,
286 ], 1),
287 )
288 }
289
290 // ── log: prompts, spawns, completions, consults, edits, errors and denials; one agent's while you view it
291 const shown = (viewed ? lines.filter(l => l.agentId === viewed) : lines).slice(-8)
292 const colorOf = (l: DeckLogLine) =>
293 l.kind === 'error' ? col('warn') : l.kind === 'consult' ? col('arch') : l.who === 'main' ? col('main') : l.who === 'gate' ? col('gate') : l.who === 'you' ? col('text') : col('agent')
294 const hhmm = (ms: number) => (ms > 0 ? new Date(ms).toTimeString().slice(0, 5) : '--:--')
295 out.push(
296 card('fd-log', [
297 head('fd-log-h', 'Session log', 'dim'),
298 ...(shown.length === 0 ? [<Text key="fd-log-none" color={col('faint')}>nothing yet</Text>] : []),
299 ...shown.map((l, i) => (
300 <Box key={'fd-l-' + i} flexDirection="row" columnGap={1} width="100%">
301 <Box width={5} flexShrink={0}><Text color={col('faint')}>{hhmm(l.at)}</Text></Box>
302 <Box width={9} flexShrink={0}><Text color={colorOf(l)} bold wrap="truncate-end">{l.who}</Text></Box>
303 <Box flexGrow={1} flexShrink={1} minWidth={0}><Text color={l.kind === 'error' ? col('warn') : col('text')} wrap="truncate-end">{l.text}</Text></Box>
304 </Box>
305 )),
306 ], 1),
307 )
308
309 out.push(
310 <Box key="fd-reset" flexDirection="row" marginTop={1}>
311 <Button key="fd-reset-b" onPress={() => kit.onReset()}>Reset</Button>
312 </Box>,
313 )
314 return out
315}
316hooks/icons.ts 172 lines1// Lucide icons (ISC license, lucide.dev), drawn as small SVGs on the desktop surface.
2
3const PATHS = {
4 eye: '<path d="M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0"/><circle cx="12" cy="12" r="3"/>',
5 pencil: '<path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z"/><path d="m15 5 4 4"/>',
6 coffee: '<path d="M10 2v2"/><path d="M14 2v2"/><path d="M16 8a1 1 0 0 1 1 1v8a4 4 0 0 1-4 4H7a4 4 0 0 1-4-4V9a1 1 0 0 1 1-1h14a4 4 0 1 1 0 8h-1"/><path d="M6 2v2"/>',
7 'git-branch': '<line x1="6" x2="6" y1="3" y2="15"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 0 1-9 9"/>',
8 folder: '<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"/>',
9 'folder-open': '<path d="m6 14 1.5-2.9A2 2 0 0 1 9.24 10H20a2 2 0 0 1 1.94 2.5l-1.54 6a2 2 0 0 1-1.95 1.5H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H18a2 2 0 0 1 2 2v2"/>',
10 file: '<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/>',
11 search: '<circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/>',
12 'chevron-right': '<path d="m9 18 6-6-6-6"/>',
13 'chevron-down': '<path d="m6 9 6 6 6-6"/>',
14 check: '<path d="M20 6 9 17l-5-5"/>',
15 square: '<rect width="18" height="18" x="3" y="3" rx="2"/>',
16 'square-dot': '<rect width="18" height="18" x="3" y="3" rx="2"/><circle cx="12" cy="12" r="1"/>',
17 'square-check': '<rect width="18" height="18" x="3" y="3" rx="2"/><path d="m9 12 2 2 4-4"/>',
18 'square-dashed': '<path d="M5 3a2 2 0 0 0-2 2"/><path d="M19 3a2 2 0 0 1 2 2"/><path d="M21 19a2 2 0 0 1-2 2"/><path d="M5 21a2 2 0 0 1-2-2"/><path d="M9 3h1"/><path d="M9 21h1"/><path d="M14 3h1"/><path d="M14 21h1"/><path d="M3 9v1"/><path d="M21 9v1"/><path d="M3 14v1"/><path d="M21 14v1"/>',
19 plus: '<path d="M5 12h14"/><path d="M12 5v14"/>',
20 'list-checks': '<path d="m3 17 2 2 4-4"/><path d="m3 7 2 2 4-4"/><path d="M13 6h8"/><path d="M13 12h8"/><path d="M13 18h8"/>',
21 terminal: '<polyline points="4 17 10 11 4 5"/><line x1="12" x2="20" y1="19" y2="19"/>',
22 'loader-circle': '<path d="M21 12a9 9 0 1 1-6.219-8.56"/>',
23 gauge: '<path d="m12 14 4-4"/><path d="M3.34 19a10 10 0 1 1 17.32 0"/>',
24} as const
25
26export type IconName = keyof typeof PATHS
27
28/** A color, or a pair the drawing picks from by light or dark mode. */
29export type Ink = string | { light: string; dark: string }
30
31/** `[style block, value]` for an ink: a pair becomes a CSS variable that follows prefers-color-scheme. */
32const paint = (c: Ink, name = 'ink'): [string, string] =>
33 typeof c === 'string'
34 ? ['', c]
35 : [`<style>svg{--${name}:${c.light}}@media (prefers-color-scheme: dark){svg{--${name}:${c.dark}}}</style>`, `var(--${name})`]
36
37const FONT = 'font-family="-apple-system, BlinkMacSystemFont, system-ui, sans-serif"'
38const esc = (s: string) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
39
40/** One Lucide icon, `size` px square, stroked in `color`. */
41export const icon = (name: IconName, color: Ink, size = 16) => {
42 const [css, c] = paint(color)
43 return `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" style="stroke:${c}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${css}${PATHS[name]}</svg>`
44}
45
46/** An icon centred on a tinted disc, as the "Right now" card leads with. */
47export const disc = (name: IconName, color: Ink, size = 44) => {
48 const [css, c] = paint(color)
49 const s = Math.round(size * 0.5)
50 const o = (size - s) / 2
51 return (
52 `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 ${size} ${size}">${css}` +
53 `<circle cx="${size / 2}" cy="${size / 2}" r="${size / 2}" style="fill:${c}" fill-opacity="0.18"/>` +
54 `<g transform="translate(${o} ${o}) scale(${s / 24})" fill="none" style="stroke:${c}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${PATHS[name]}</g></svg>`
55 )
56}
57
58/** A rounded label on a tint of its own color: "Reading", "Edited". */
59export const pill = (text: string, color: Ink) => {
60 const [css, c] = paint(color)
61 const w = Math.round(text.length * 7.4 + 22)
62 const h = 22
63 return {
64 w,
65 h,
66 source:
67 `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">${css}` +
68 `<rect width="${w}" height="${h}" rx="${h / 2}" style="fill:${c}" fill-opacity="0.18"/>` +
69 `<text x="${w / 2}" y="15" text-anchor="middle" font-size="12" font-weight="600" style="fill:${c}" ${FONT}>${esc(text)}</text></svg>`,
70 }
71}
72
73/** A file-type tag: the extension, short, on a square tint. */
74export const fileTag = (ext: string, color: Ink) => {
75 const [css, c] = paint(color)
76 const label = (ext || '·').slice(0, 4).toUpperCase()
77 const w = Math.max(26, Math.round(label.length * 7 + 12))
78 const h = 20
79 return {
80 w,
81 h,
82 source:
83 `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">${css}` +
84 `<rect width="${w}" height="${h}" rx="5" style="fill:${c}" fill-opacity="0.16"/>` +
85 `<text x="${w / 2}" y="14" text-anchor="middle" font-size="10" font-weight="700" style="fill:${c}" ${FONT}>${esc(label)}</text></svg>`,
86 }
87}
88
89/** A hairline: drawn without a width it fills its slot, stretched by preserveAspectRatio. */
90export const rule = (color: Ink) => {
91 const [css, c] = paint(color)
92 return `<svg xmlns="http://www.w3.org/2000/svg" width="2000" height="9" viewBox="0 0 2000 9" preserveAspectRatio="none">${css}<line x1="0" y1="4.5" x2="2000" y2="4.5" style="stroke:${c}" stroke-opacity="0.7" stroke-width="1" vector-effect="non-scaling-stroke"/></svg>`
93}
94
95/** A letter-spaced caps label ("RIGHT NOW", "ALL FILES"), its width from its text. */
96export const caps = (text: string, color: Ink, size = 12) => {
97 const [css, c] = paint(color)
98 const t = text.toUpperCase()
99 const w = Math.ceil(t.length * (size * 0.68 + 1.6)) + 2
100 const h = Math.round(size * 1.5)
101 return {
102 w,
103 h,
104 source:
105 `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">${css}` +
106 `<text x="0" y="${Math.round(size * 1.1)}" font-size="${size}" font-weight="700" letter-spacing="1.6" style="fill:${c}" ${FONT}>${esc(t)}</text></svg>`,
107 }
108}
109
110/** A headline in one line, cut with an ellipsis to fit `maxW` px. */
111export const headline = (text: string, color: Ink, maxW: number, size = 17) => {
112 const [css, c] = paint(color)
113 const per = size * 0.56
114 const fit = Math.max(4, Math.floor(maxW / per))
115 const t = text.length > fit ? text.slice(0, fit - 1) + '…' : text
116 const w = Math.min(maxW, Math.ceil(t.length * per) + 4)
117 const h = Math.round(size * 1.45)
118 return {
119 w,
120 h,
121 source:
122 `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">${css}` +
123 `<text x="0" y="${Math.round(size * 1.1)}" font-size="${size}" font-weight="700" style="fill:${c}" ${FONT}>${esc(t)}</text></svg>`,
124 }
125}
126
127/** The tree's small filled disclosure triangle. */
128export const triangle = (open: boolean, color: Ink, size = 10) => {
129 const [css, c] = paint(color)
130 return `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 10 10">${css}<path d="${open ? 'M1.5 3h7L5 7.5z' : 'M3 1.5v7L7.5 5z'}" style="fill:${c}"/></svg>`
131}
132
133/** A progress bar that fills its slot: `done` of `total`, the doing share lighter. */
134export const progress = (done: number, doing: number, total: number, color: Ink, track: Ink) => {
135 const [cssA, c] = paint(color, 'bar')
136 const [cssB, t] = paint(track, 'track')
137 const W = 1000
138 const a = total ? (W * done) / total : 0
139 const b = total ? (W * doing) / total : 0
140 return (
141 `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="8" viewBox="0 0 ${W} 8" preserveAspectRatio="none">${cssA}${cssB}` +
142 `<rect width="${W}" height="8" style="fill:${t}" fill-opacity="0.22"/>` +
143 `<rect x="${a}" width="${b}" height="8" style="fill:${c}" fill-opacity="0.45"/>` +
144 `<rect width="${a}" height="8" style="fill:${c}"/></svg>`
145 )
146}
147
148/** The top of a hero card: icon disc, caps label, title, a line under it; on its own tinted panel unless `panel` is false.
149 * The disc's left edge sits at x 0, in line with the card's rows below it. */
150export function hero(o: { icon: IconName; accent: Ink; label: string; title: string; sub: string; w: number; panel?: boolean }) {
151 const [css, c] = paint(o.accent, 'acc')
152 const H = 78
153 const x = 58
154 const room = Math.max(60, o.w - x - 14)
155 const cut = (s: string, size: number, bold: boolean) => {
156 const per = size * (bold ? 0.58 : 0.52)
157 const fit = Math.max(4, Math.floor(room / per))
158 return s.length > fit ? s.slice(0, fit - 1) + '…' : s
159 }
160 const label = o.label.toUpperCase()
161 return (
162 `<svg xmlns="http://www.w3.org/2000/svg" width="${o.w}" height="${H}" viewBox="0 0 ${o.w} ${H}">${css}` +
163 `<style>.bg{fill:#f0eee6}.t{fill:#1f1f1f}.m{fill:#5f5f5c}@media (prefers-color-scheme: dark){.bg{fill:#30302e}.t{fill:#ececec}.m{fill:#9a9a96}}</style>` +
164 (o.panel === false ? '' : `<rect class="bg" width="${o.w}" height="${H}" rx="12"/>`) +
165 `<circle cx="22" cy="${H / 2}" r="22" style="fill:${c}" fill-opacity="0.18"/>` +
166 `<g transform="translate(11 ${H / 2 - 11}) scale(${22 / 24})" fill="none" style="stroke:${c}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${PATHS[o.icon]}</g>` +
167 `<text x="${x}" y="24" font-size="11" font-weight="700" letter-spacing="1.6" style="fill:${c}" ${FONT}>${esc(label)}</text>` +
168 `<text class="t" x="${x}" y="47" font-size="17" font-weight="700" ${FONT}>${esc(cut(o.title, 17, true))}</text>` +
169 `<text class="m" x="${x}" y="66" font-size="12.5" ${FONT}>${esc(cut(o.sub, 12.5, false))}</text></svg>`
170 )
171}
172hooks/elapsed.tsx 38 lines1// A running agent's clock, ticking on the surface's frame clock so the pane need not redraw.
2// `since` and `now` come from the hooks module's $.clock; between redraws it adds its own ticks.
3import type { ClientModule } from 'claude-code'
4
5type Props = { since: number; now: number; endAt: number | null; color: string }
6type Ref = { base: number; ticks: number; lastNow: number; isRunning: boolean }
7type State = { ref: Ref }
8
9const fmt = (ms: number) => {
10 const s = Math.max(0, Math.floor(ms / 1000))
11 const m = Math.floor(s / 60)
12 return m < 60 ? `${m}:${String(s % 60).padStart(2, '0')}` : `${Math.floor(m / 60)}h${String(m % 60).padStart(2, '0')}`
13}
14
15const Elapsed: ClientModule<Props, State> = (props, surface) => {
16 const { Text } = surface.elements
17 const ref = surface.state?.ref ?? { base: 0, ticks: 0, lastNow: -1, isRunning: true }
18 if (props.now !== ref.lastNow) {
19 // A fresh reading from the hooks module: restart the local count from it.
20 ref.lastNow = props.now
21 ref.base = (props.endAt ?? props.now) - props.since
22 ref.ticks = 0
23 }
24 ref.isRunning = props.endAt === null
25 if (surface.state === undefined) {
26 surface.setState({ ref })
27 surface.every(1000, () => {
28 if (ref.isRunning) {
29 ref.ticks += 1
30 surface.setState({ ref })
31 }
32 })
33 }
34 return <Text color={props.color}>{fmt(ref.base + ref.ticks * 1000)}</Text>
35}
36
37export default Elapsed
38types/index.d.ts 127 lines1export type ItemStatus = 'draft' | 'todo' | 'doing' | 'done'
2/** `title` is a short label (a few words); anything longer goes in `details`. */
3export type WorkItem = { id: string; title: string; stage: string; status: ItemStatus; details?: string }
4export type DevEntry = { stages: string[]; items: WorkItem[] }
5export type Part = { id: string; date: string; status: string }
6export type Group = {
7 key: string
8 title: string
9 project: string
10 short: string
11 size?: string
12 start: string
13 end: string
14 open: boolean
15 parts: Part[]
16 stages: string[]
17 items: WorkItem[]
18 /** The plan file this task's work items live in. */
19 file?: string
20}
21export type Snap = {
22 status: 'idle' | 'loading' | 'ready' | 'none' | 'error'
23 message?: string
24 folder?: string
25 shorts: string[]
26 cur?: Group
27 prev: Group[]
28 next: Group[]
29 version?: number
30}
31export type PaneUi = { showPrev: boolean; stage?: string; paneOpen?: boolean; selected?: string }
32
33/* The Workbench pane */
34/** One file Claude read or edited this session; `active` while the tool runs. */
35export type Touch = { path: string; kind: 'read' | 'edited'; active: boolean; at: number }
36/** What Claude did last in the turn under way: `busy` from the prompt to the end of the turn; `kind` the last file read or edit, command or search. */
37export type Activity = { busy: boolean; kind?: 'read' | 'edited' | 'run' | 'search'; label?: string; path?: string }
38/** One directory entry as the tree shows it. */
39export type Entry = { name: string; dir: boolean; size: number }
40export type Files = {
41 /** The folder the tree shows, absolute. */
42 root: string
43 /** The session's folder, where "Back to project" returns. */
44 project: string
45 expanded: string[]
46 dirs: Record<string, Entry[]>
47 showSizes: boolean
48 showHidden: boolean
49 query: string
50 /** Every file under root, relative, read once a search starts. */
51 all: string[]
52}
53export type Git = { isRepo: boolean; branch: string; top: string; dirty: string[] }
54/** `sections`: which of the pane's three cards are open (kept across sessions in the store). */
55export type WorkbenchUi = { itemsOpen: boolean; adding?: boolean; sections?: { tasks: boolean; files: boolean; deck: boolean } }
56
57/* The Flightdeck card (adapted from claude-flightdeck, MIT, Stephen Casella) */
58export type DeckMoment = 'before a plan' | 'error repeats' | 'before done'
59export type DeckBucket = 'file' | 'shell' | 'other'
60/** rule: settings allowed it. ask: put to the decider, outcome pending. cleared: asked, then ran. deny: refused. */
61export type DeckVerdict = 'rule' | 'ask' | 'cleared' | 'deny'
62export type DeckMain = { model: string; effort: string; mode: string; steps: number; isRunning: boolean }
63export type DeckUsage = {
64 pct: number | null
65 tokens: number | null
66 window: number
67 costUsd: number | null
68 limits: { kind: string; pct: number }[]
69 compactions: number
70 lastCompactAt: number | null
71}
72export type DeckConsult = { id: string; at: number; endAt: number | null; moment: DeckMoment; via: string }
73export type DeckArchitect = { consults: DeckConsult[]; ids: string[]; seen: string[]; lastAdvice: string }
74export type DeckCheck = { id: string; tool: string; bucket: DeckBucket; verdict: DeckVerdict; inSubagent: boolean; detail: string; at: number }
75export type DeckTally = { rule: number; ask: number; cleared: number; deny: number }
76export type DeckGate = { recent: DeckCheck[]; totals: Record<DeckBucket, DeckTally> }
77export type DeckToolNote = { tool: string; text: string; isError: boolean }
78export type DeckAgent = {
79 id: string
80 type: string
81 model: string
82 description: string
83 status: string
84 spawnedAt: number
85 endedAt: number | null
86 /** The agent's context now: input + cache read + cache write of its latest step. */
87 ctx: number
88 /** Output tokens summed over its steps. */
89 out: number
90 steps: number
91 lastStop: string | null
92 tools: DeckToolNote[]
93 answer: string
94}
95/** A model loop whose id matches no card: a workflow agent, a compaction or a memory fork. */
96export type DeckLoop = { id: string; steps: number; firstAt: number; lastAt: number; isDone: boolean }
97export type DeckLogLine = { at: number; who: string; text: string; agentId: string | null; kind: 'info' | 'error' | 'consult' | 'done' }
98export type DeckTurn = { edits: number; errorStreak: number; errors: number; isReviewing: boolean; startedAt: number; costAtStart: number | null }
99export type DeckReceipt = { durationMs: number; agents: number; edits: number; errors: number; costDelta: number | null; reason: string }
100export type DeckView = { expanded: string | null; gateOpen: DeckBucket | null }
101export type DeckRoster = { architectTypes: string[] }
102
103declare module 'claude-code' {
104 interface PluginState {
105 workbench: {
106 snap: Snap
107 ui: PaneUi
108 files: Files
109 git: Git
110 touched: Touch[]
111 activity: Activity
112 bench: WorkbenchUi
113 deckMain: DeckMain
114 deckUsage: DeckUsage
115 deckArchitect: DeckArchitect
116 deckGate: DeckGate
117 deckAgents: DeckAgent[]
118 deckLoops: DeckLoop[]
119 deckLog: DeckLogLine[]
120 deckTurn: DeckTurn
121 deckReceipt: DeckReceipt | null
122 deckView: DeckView
123 deckRoster: DeckRoster
124 }
125 }
126}
127