SLOPSHOPPER

workbench

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…

newpanebandguardcommandtoast
v0.1.26MITupdated 2026-10-09eascendco/workbench
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · workbench
│ ┃ Workbench ✕ › fix the failing auth test and add an audit log call │ ┃ ╭──────────────────────────────────────────╮ │ ┃ │ │ ⏺ Read(src/auth.ts) │ ┃ │ ▼ Task │ ⎿ Read 6 lines │ ┃ │ No tasks here yet. /task new <title> │ ⏺ Update(src/auth.ts) │ ┃ │ starts one. │ ⎿ Added 2 lines, removed 1 line │ ┃ │ │ ⏺ Bash(bun test) │ ┃ ╰──────────────────────────────────────────╯ ⎿ 3 pass, 1 fail │ ┃ │ ┃ ╭──────────────────────────────────────────╮ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ │ │ ┃ │ ▶ Files │ ✻ Worked for 42s · done 4:20 PM │ ┃ │ │ │ ┃ ╰──────────────────────────────────────────╯ › /task │ ┃ ⎿ workbench: No tasks here yet. `/task new <title>` starts one: a │ ┃ ╭──────────────────────────────────────────╮ │ ┃ │ │ │ ┃ │ ▶ In Flight │ │ ┃ │ │ │ ┃ ╰──────────────────────────────────────────╯ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Workbench
╭──────────────────────────────────────────────────────────╮ │ │ │ ▼ Task │ │ No tasks here yet. /task new <title> starts one. │ │ │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ │ │ ▶ Files │ │ │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ │ │ ▶ In Flight │ │ │ ╰──────────────────────────────────────────────────────────╯
README

Workbench

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.

The band above the prompt: the task, the item in progress and a progress bar

<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>

Install

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

How tasks work

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.
  • The word in backticks is the stage: spec, design, build, track, test or ship.
  • Indented lines under an item are its details.
  • The current task is the earliest open plan by date. The next three are Up next.
  • When every item is done, the plan's status becomes done and the next task moves up.
  • Anything else in the file, like a ## 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.

Commands

CommandWhat it does
/taskThe 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 -- detailsAdd a work item
/task breakAsk Claude to draft the work items
/task paneOpen the side pane
/task <anything else>Ask Claude, with the task and its plan file attached

The pane

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">

  1. Main. The model, whether it's working, effort, permission mode and requests so far. The context gauge (⟲ counts compactions), the session's cost and your 5-hour and 7-day limits.
  2. Architect. Shows up once Claude consults an advisor or architect agent: each consult on a timeline, when it happened (before a plan, after repeated errors, before done; inferred), and the last advice.
  3. Gate. One square per permission check. Green was allowed by your settings, blue by the auto-mode classifier or you, amber is waiting for you, red ✗ was denied; checks inside subagents are dimmed. file, shell and other open the last five checks of that kind, with credentials masked.
  4. Agents. The three newest subagents as tiles: task, type, context, steps, status and a running clock. Earlier ones are listed below. Press one to see its last tool calls and the start of its answer.
  5. Turn. The turn in progress with its clock, edits and errors, or a receipt for the last one with its cost.
  6. Session log. Prompts, spawns, consults, edits, errors and denials as they happen.

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.

Settings

In /config, under Workbench:

  • Plans folder: the folder name for plan files. Default plans.
  • Open the pane at start: on or off.
  • Band above the prompt: on or off.

Works with skins

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.

What it reads

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.

Turn it off

Disable it in /plugin under Installed, or:

claude plugin disable workbench@eascend

License

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.

Source 10 files
hooks/register.tsx 1425 lines
1import { 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 lines
1/* 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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[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}
117
hooks/logic.ts 57 lines
1import 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}
57
hooks/plans.ts 184 lines
1import 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}
184
hooks/bench.ts 165 lines
1import 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}
165
hooks/deck.ts 422 lines
1// 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}
422
hooks/deck-view.tsx 316 lines
1// 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}
316
hooks/icons.ts 172 lines
1// 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
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}
172
hooks/elapsed.tsx 38 lines
1// 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
38
types/index.d.ts 127 lines
1export 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