Follow your subagents at a glance: a status strip over the input box and a /crew panel where every subagent is a costumed pixel crab, with its model, steps…

Watch your subagents work. crab-crew is a Claude Code mod that adds a slim status strip over the input box and a /crew panel listing every subagent. Each subagent shows up as a small pixel crab wearing the outfit of its job.
The strip turns up on its own once the first subagent starts. It is titled with the first line of your prompt and counts how many agents have ended out of how many were spawned. Its crab dresses as whichever agent is busy right now. When every agent has ended and the main loop is idle, it reads Done.
The panel groups agents under Running, Finished and Planned. A row holds the crab, the role, the agent type (skipped for general-purpose and claude, where the role says enough), model, effort, step progress, context use, cost and elapsed time. Press the Finished heading to fold those rows.
| Command | Effect |
|---|---|
/crew | toggle the panel (the ×N count on the strip does the same) |
/crew open | bring the panel to the front and give it the keyboard |
/crew bar | show the strip again after closing it with ✕ |
Colours come from Catppuccin Mocha. Crabs are 14×8 sprites drawn with half blocks; running ones step once a second and planned ones are dimmed. A collapsed panel is a single line of crabs plus a +N tail.
The strip is a periwinkle pill that grows with progress over a dark track and turns green when the flow finishes. Twinkling stars drift across the empty track, and each upcoming task boundary is a small marker that glows as a star passes it. A larger octant crab walks just ahead of the pill in the busy agent's colour, hurrying briefly after each step. When the track has no room or the flow is done, a smaller crab sits next to the percentage instead.
The strip and each panel row are SVG drawings: a dithered pill, a dotted background and a costumed crab per row. Text follows the system light or dark scheme; role colours and bars match the terminal.
The role comes from the agent type, word by word. For general-purpose and claude agents only the first word of the task description counts, so "Fix failing tests" is a builder. See roleOf in hooks/roles.ts.
| Role | Outfit | Picked for |
|---|---|---|
| designer | beret and brush | Impeccable asset, documenter and manual-edit agents; tasks starting with design, polish, restyle, sketch, mock |
| reviewer | detective | Impeccable finish reviewer; types with review, audit, verify; tasks starting with review, audit, verify, check, inspect |
| scout | pirate | Explore; tasks starting with find, research, investigate, search, read, list |
| planner | hard hat | Plan; tasks starting with plan, spec, scope, outline |
| runner | racer | types with test, regress, runner; tasks starting with run, test, regress, lint |
| builder | chef | any other general-purpose task |
| fork | astronaut | a fork of the main session |
| other | plain crab | any other named agent |
mcp__crab-crew__progress belongs to the main session. It sets the title, the planned tasks and the current phase. Subagent calls are ignored. Calling it after a finished flow begins a fresh one.mcp__crab-crew__step is for subagents. They send done, total and a short note, shown as 3/5 reading. An agent that never reports steps shows its context use instead, in a darker tint of its role colour. On a narrow terminal, context and then cost make way for the note.Costs are estimated from token counts and the PRICES table in hooks/cost.ts. They are not your bill.
| Key | Values | Meaning |
|---|---|---|
autoOpen | session (default), flow, off | open the panel once per session (first subagent or plan), at every new flow, or never |
stepReports | on (default), off | append a line to built-in subagent briefs asking for step calls; plugin agents are left untouched |
glyphs | nerd (default), plain | Nerd Font pill caps, chevrons and an octant crab, or half blocks, ▸ ▾ and an ASCII crab |
The mod is a plugin of function hooks with hooks/register.tsx as its entry. types/index.d.ts describes its state and tool inputs. Claude Code generates its own API types into .claude-plugin/types/ (ignored by git) whenever it loads the mod, so type-checking a fresh clone needs one session first.
Run the gate from the repo root:
claude plugin validate .
npx --yes -p typescript tsc -p .
claude plugin test .
scripts/ has helpers for checking the terminal drawing in Ghostty: shot.sh <out.png> grabs the Ghostty window, wins.swift prints visible windows, and swift scripts/px.swift <img> x,y ... reads pixel colours.
hooks/register.tsx 256 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, PaneOpenArgs, Register, UiOpenResult } from 'claude-code'
3
4import type { Flow, Panel } from '../types'
5import { BAR, bandHooks, nextFrame } from './band'
6import { PANEL_DEFAULT } from './defaults'
7import { autoFlow, bandLabel, emptyFlow, isNewFlow, mergeReport, progressInput, promptTitle, runningOf, stepInput } from './flow'
8import { paneHooks } from './pane'
9import { addRun, applyStep, applyUsage, finishRun, withRun } from './runs'
10import { CRAB } from './term-crab'
11import { PROGRESS_SPEC, STEP_BRIEF, STEP_SPEC, STEP_TOOL } from './tools'
12
13// The engine learns a module's state from the atoms written in that module, so
14// every file declares the ones it touches.
15const flow = atom({ plugin: 'crab-crew', key: 'flow' } as const, null)
16const agents = atom({ plugin: 'crab-crew', key: 'agents' } as const, [])
17const panel = atom({ plugin: 'crab-crew', key: 'panel' } as const, PANEL_DEFAULT)
18const now = atom({ plugin: 'crab-crew', key: 'now' } as const, 0)
19const turn = atom({ plugin: 'crab-crew', key: 'turn' } as const, { isActive: false, prompt: '' })
20const pose = atom({ plugin: 'crab-crew', key: 'pose' } as const, CRAB)
21
22// The crew pane's id. Matchers repeat it as a literal (here and in pane.tsx): the
23// engine reads a matcher's literal to list where a module draws.
24const PANE = 'crew'
25const PANE_REF = { id: PANE } as const
26
27const CREW_COMMAND = {
28 name: 'crew',
29 description:
30 'Toggle the crew panel, listing subagents that are running, done or planned, each with role, model, context use, cost and elapsed time. `/crew open` opens and focuses it; `/crew bar` restores a progress bar closed with ✕',
31}
32
33// A tool call turned down, and why.
34const ignored = (why: string) => ({ result: `ignored: ${why}` })
35
36// Known to the surface at all: drawn, waiting to be drawn, or behind another tab. A
37// reload forgets the pane; a narrow terminal or a tab only hides it.
38const isPaneListed = async ($: EngineInterface): Promise<boolean> => (await $.ui.panes()).some(({ id }) => id === PANE)
39
40// Actually drawn, in front or behind another tab. Only then does a toggle close it:
41// opening an id that is already open just retitles it, so a toggle that tried to
42// open a hidden pane would never get to close it.
43const isPanePlaced = async ($: EngineInterface): Promise<boolean> => (await $.ui.panes()).some(({ id, isPlaced }) => id === PANE && isPlaced)
44
45// Remembers whether the pane is up, for a reload to put it back.
46const markOpen = ($: EngineInterface, isOpen: boolean) => update($, panel, prev => ({ ...prev, isOpen }))
47
48// The panel clocks catch up with the engine's; the time is handed back.
49async function syncNow($: EngineInterface): Promise<number> {
50 const t = await $.clock.now()
51 await update($, now, () => t)
52 return t
53}
54
55// Asks for the pane; null when a hook turned the open down (`{ deny }` rejects the
56// call). Only an open the engine took is marked, so a reload never retries a refused
57// one. Drawn or waiting undrawn, the pane counts as open.
58async function openPane($: EngineInterface, more: Pick<PaneOpenArgs, 'focus'> = {}): Promise<UiOpenResult | null> {
59 const answer = await $.ui.open({ ...PANE_REF, title: 'Crew', ...more }).catch(() => null)
60 if (answer) await markOpen($, true)
61 return answer
62}
63
64// Opened at the person's request, with its clocks current before the first draw.
65// `focus` brings it forward with the keys, which the surface allows only while the
66// composer is empty.
67async function showPane($: EngineInterface, more: Pick<PaneOpenArgs, 'focus'> = {}): Promise<string> {
68 await syncNow($)
69 const answer = await openPane($, more)
70 if (!answer) return 'Crew panel not opened: the open was refused.'
71 if (!answer.isPlaced) return `Crew panel open, not drawn yet: ${answer.reason}`
72 return 'Crew panel opened.'
73}
74
75// Drawn (even behind a tab): close it. Not open, or open but undrawn: open it, and
76// at the person's request it draws at any width.
77async function togglePane($: EngineInterface): Promise<string> {
78 if (!(await isPanePlaced($))) return showPane($)
79 // Our own close skips our `ui.close` hook, so the mark is cleared here.
80 await markOpen($, false)
81 await $.ui.close(PANE_REF)
82 return 'Crew panel closed.'
83}
84
85// `/crew bar`: brings back the bar the band's ✕ hid.
86async function showBar($: EngineInterface): Promise<string> {
87 if (!(await read($, flow))) return 'No flow yet: the bar appears when a subagent starts.'
88 await update($, flow, prev => (prev ? { ...prev, isHidden: false } : prev))
89 return 'Progress bar shown.'
90}
91
92// The autoOpen option decides how often the mod opens the pane by itself: once a
93// session, once per flow, or never ('off'). The mark is what makes it once.
94async function autoOpen($: EngineInterface, mode: unknown): Promise<void> {
95 if (mode === 'off') return
96 const scope: Panel['autoOpenedFor'] = mode === 'flow' ? 'flow' : 'session'
97 if ((await read($, panel)).autoOpenedFor === scope) return
98 await update($, panel, prev => ({ ...prev, autoOpenedFor: scope }))
99 // Awaited, so the open mark lands while the hook still runs.
100 await openPane($)
101}
102
103// A new flow, whatever its title, may open the pane again under the 'flow' option.
104const forgetFlowOpen = ($: EngineInterface) =>
105 update($, panel, (prev): Panel => (prev.autoOpenedFor === 'flow' ? { ...prev, autoOpenedFor: '' } : prev))
106
107// Ended runs belong to the flow that is over; agents still at work carry on.
108const keepRunning = ($: EngineInterface) => update($, agents, runs => runs.filter(r => r.status === 'running'))
109
110// The mod's own flow closes once every agent it counted has ended and the main loop
111// is idle.
112async function settle($: EngineInterface): Promise<void> {
113 const current = await read($, flow)
114 if (!current?.isAuto || current.isFinished) return
115 const runs = await read($, agents)
116 if (runs.length === 0 || runningOf(runs) > 0) return
117 if ((await read($, turn)).isActive) return
118 await update($, flow, (prev): Flow | null => (prev?.isAuto ? { ...prev, phase: 'close', isFinished: true } : prev))
119}
120
121export const register: Register = (on, opts) => {
122 on('session.start', async ($, ev, next) => {
123 const result = await next(ev)
124 await $.tool.register(PROGRESS_SPEC)
125 await $.tool.register(STEP_SPEC)
126 await $.command.register(CREW_COMMAND)
127
128 // Once a second the running rows' times move on; with none running, nothing is written.
129 $.clock.every(1000, () => void read($, agents).then(runs => (runningOf(runs) > 0 ? syncNow($) : undefined)))
130 // Between redraws the band's stars drift and its crab walks.
131 $.clock.every(150, () => {
132 const moved = nextFrame()
133 if (!moved) return
134 const { requestId, frame, cells, drop } = moved
135 if (frame) void update($, pose, () => frame)
136 // A blit not taken answers `{ deny }` and a broken one rejects: both drop the frame.
137 if (cells) void $.ui.blit({ requestId, key: BAR, cells }).then(r => (r.deny !== undefined ? drop() : undefined), drop)
138 })
139 // A reload takes the pane away without a word: put it back if it was up.
140 const wasOpen = (await read($, panel)).isOpen
141 if (wasOpen && !(await isPaneListed($))) await openPane($)
142 return result
143 })
144
145 // Reached by the person's close mark, never by a reload.
146 on('ui.close', { id: 'crew' }, async ($, ev, next) => {
147 await markOpen($, false)
148 return next(ev)
149 })
150
151 // Bare `/crew` toggles. `open` only ever opens, with the focus: right after a command
152 // the composer is empty, so the pane comes forward from behind a tab with the keys.
153 on('command.run', { command: 'crew' }, async ($, ev) => {
154 const arg = (ev.args ?? '').trim()
155 if (arg === 'bar') return { text: await showBar($) }
156 if (arg === 'open') return { text: await showPane($, { focus: true }) }
157 return { text: await togglePane($) }
158 })
159
160 // The main loop's turn names a flow no one reports, and its end may close one.
161 on('turn.start', async ($, ev, next) => {
162 // A turn without a prompt of its own (a continuation, a notification) clears the
163 // name: an older prompt would title the wrong work.
164 const prompt = promptTitle(ev.text)
165 await update($, turn, () => ({ isActive: true, prompt }))
166 return next(ev)
167 })
168
169 on('tool.call', { tool: 'mcp__crab-crew__progress' }, async ($, ev) => {
170 // The flow belongs to the main session. A call made inside a subagent's loop has an
171 // agentId and is turned down, so no worker can retitle, close or restart the flow.
172 if (ev.agentId) return ignored('flow reports come from the main session')
173 const input = progressInput(ev)
174 if (isNewFlow(await read($, flow), input)) {
175 await keepRunning($)
176 await forgetFlowOpen($)
177 }
178 const merged = await update($, flow, current => mergeReport(current, input))
179 if (merged && input.tasks?.length) await autoOpen($, opts.autoOpen)
180 const running = runningOf(await read($, agents))
181 return { result: `ok: ${bandLabel(merged ?? emptyFlow(), running)}` }
182 })
183
184 // Step reports come from a subagent about itself. The call runs in that subagent's
185 // loop, so `agentId` identifies the row to update.
186 on('tool.call', { tool: 'mcp__crab-crew__step' }, async ($, ev) => {
187 const input = stepInput(ev)
188 const worker = ev.agentId
189 if (!worker) return ignored('step reports come from subagents')
190 let found = false
191 await update($, agents, runs => {
192 const changed = withRun(runs, worker, a => applyStep(a, input))
193 found = changed.matched
194 return changed.list
195 })
196 return found ? { result: 'ok' } : ignored(`agent ${worker} has no row in the panel`)
197 })
198
199 on('agent.spawn', async ($, ev, next) => {
200 // Briefed on step reports unless the option is off, the agent ships in a plugin
201 // (`name:agent`) or the prompt already names the tool.
202 const needsBrief = opts.stepReports !== 'off' && !ev.subagentType.includes(':') && !ev.prompt.includes(STEP_TOOL)
203 const launched = await next(needsBrief ? { ...ev, prompt: `${ev.prompt}${STEP_BRIEF}` } : ev)
204 if (launched.deny !== undefined) return launched
205
206 // With no flow open, this agent starts one, named after the turn's prompt.
207 const current = await read($, flow)
208 if (!current || current.isFinished) {
209 const { prompt } = await read($, turn)
210 await keepRunning($)
211 await update($, flow, () => autoFlow(prompt))
212 await forgetFlowOpen($)
213 }
214
215 const spawnedAt = await $.clock.now()
216 const spawned = { id: launched.agentId ?? ev.tool_use_id, agentId: launched.agentId, type: ev.subagentType, description: ev.description, model: launched.model }
217 await update($, agents, runs => addRun(runs, spawned, spawnedAt))
218 await update($, now, () => spawnedAt)
219 await autoOpen($, opts.autoOpen)
220 return launched
221 })
222
223 // Every model request a worker makes updates its row: context, tokens, money.
224 on('turn.step', async function* ($, ev, next) {
225 const out = yield* next(ev)
226 const worker = ev.agentId
227 const { usage } = out
228 if (!worker || !usage) return out
229 const model = usage.model || ev.model
230 await update($, agents, runs => withRun(runs, worker, a => applyUsage(a, usage, model, ev.effort)).list)
231 return out
232 })
233
234 on('turn.complete', async ($, ev, next) => {
235 const worker = ev.agentId
236 const endedAt = await $.clock.now()
237 if (!worker) {
238 await update($, turn, t => ({ ...t, isActive: false }))
239 } else {
240 await update($, agents, runs => withRun(runs, worker, a => finishRun(a, ev.reason, endedAt, ev.usage)).list)
241 await update($, now, () => endedAt)
242 }
243 await settle($)
244 return next(ev)
245 })
246
247 paneHooks(on, opts)
248 bandHooks(on, opts)
249 // band.tsx draws the crew count; pressing it toggles the pane, and the pane helpers
250 // take `$`, so the hook lives here.
251 on('ui.press', { element: 'crew-open' }, async ($, ev, next) => {
252 if (ev.plugin === 'crab-crew') await togglePane($)
253 return next(ev)
254 })
255}
256hooks/band.tsx 182 lines1import { read, update, atom } from 'claude-code'
2import type { On, PluginOptions } from 'claude-code'
3
4import { bandLabel, plannedOf, ratio, runningOf, withLiveCounts } from './flow'
5import { clipCells } from './format'
6import { colorOf, roleOf } from './roles'
7import { CAPS, glyphsOf } from './glyphs'
8import { svgSurface } from './surface'
9import { H, rowSvg } from './svg-band'
10import { CRAB_CELLS, barCells, crabInTrack, fitRow, pillOf, rasterAt } from './term-bar'
11import type { BarState } from './term-bar'
12import { CRAB, CRABS, crabFrame, hurryOf, tintOf } from './term-crab'
13import { BAND, M } from './theme'
14
15// The engine lists what a module reads and writes from references written in that
16// module, so each file names the state it uses.
17const flow = atom({ plugin: 'crab-crew', key: 'flow' } as const, null)
18const agents = atom({ plugin: 'crab-crew', key: 'agents' } as const, [])
19const pose = atom({ plugin: 'crab-crew', key: 'pose' } as const, CRAB)
20
21// The band's bar as last drawn, for the timer that keeps its stars adrift and its
22// crab walking. A reload starts these over and the next draw sets them again. One
23// slot: with two terminals drawing the band, the last drawn gets the blits.
24export const BAR = 'crew-bar'
25let band: { requestId: string; state: BarState; title: string; frame: string; cells: string } | null = null
26let tick = 0
27
28// What changed at the timer's next frame of about 150ms: the crab's frame, which
29// redraws the band (Text is out of a blit's reach), and the track's cells, to blit
30// in place. Nothing changes on a finished bar, so it holds still and costs nothing.
31// `drop` forgets a bar the blit could not reach; the next draw sets it again.
32export const nextFrame = (): { requestId: string; frame?: string; cells?: string; drop: () => void } | null => {
33 tick++
34 const site = band
35 if (!site || site.state.isFinished) return null
36 const t = tick
37 const frame = crabInTrack(site.state) ? crabFrame(site.state, t) : CRAB
38 const cells = barCells(site.state, t)
39 const out = {
40 requestId: site.requestId,
41 frame: frame === site.frame ? undefined : frame,
42 cells: cells === site.cells ? undefined : cells,
43 drop: () => {
44 if (band === site) band = null
45 },
46 }
47 site.frame = frame
48 site.cells = cells
49 return out
50}
51
52// The progress band above the prompt. The desktop gets one SVG row; a terminal gets
53// text cells: title, pill, crab, starry track, percentage and crew count.
54export const bandHooks = (on: On, options: PluginOptions): void => {
55 on('ui.render', { component: 'AbovePrompt' }, async ($, ev, next) => {
56 const stored = await read($, flow)
57 if (stored === null || stored.isHidden || ev.props.hasSurvey) {
58 band = null
59 return next(ev)
60 }
61 // Ours is the top row; bands from further down the render chain stack under it.
62 // The engine's stand-in node ({ type: 'engine' }) is left out, since the desktop
63 // throws away any band that contains one.
64 const rest = await Promise.resolve(next(ev)).catch(() => null)
65 const keepRest = typeof rest === 'object' && rest !== null && (rest as { type?: string }).type !== 'engine'
66 const below = keepRest ? rest : null
67
68 const ui = $.ui.resolve(ev)
69 const { Box, Button, Text } = ui
70 const runs = await read($, agents)
71 const shown = withLiveCounts(stored, runs)
72 const running = runningOf(runs)
73 const r = ratio(shown)
74 const label = bandLabel(shown, running)
75 const pct = Math.round(r * 100) + '%'
76 const headcount = '×' + (runs.length + plannedOf(shown, runs).length)
77 // Dress the band's crab as the agent working right now, else the latest one.
78 const lead = runs.findLast(a => a.status === 'running') ?? runs.at(-1)
79 const costume = lead === undefined ? 'other' : roleOf(lead.type, lead.description)
80 // Pressing it is handled in register.tsx, next to the code that opens the pane.
81 const noop = () => undefined
82 const crewButton = <Button key="crew-open" label={headcount} plain onPress={noop} />
83 const hide = () => update($, flow, cur => (cur ? { ...cur, isHidden: true } : cur))
84 const dismiss = <Button key="crew-dismiss" label="✕" plain role="dismiss" onPress={hide} />
85
86 if (svgSurface(ev.surface, ui)) {
87 const { Svg } = ui
88 // A reported column is roughly 8 CSS px; 96 px go to the count, the ✕ and the
89 // gaps. The upper bound is the slot itself: anything wider would wrap.
90 const columns = ev.props.bodyColumns || 100
91 const width = Math.max(180, Math.min(1600, columns * 8 - 96))
92 const row = (
93 <Box flexDirection="row" alignItems="center" gap={1}>
94 <Svg source={rowSvg(shown, width, running, costume)} alt={`${shown.title}: ${label}, ${pct}`} width={width} height={H} />
95 {crewButton}
96 {dismiss}
97 </Box>
98 )
99 return <Box flexDirection="column">{row}{below}</Box>
100 }
101
102 // A terminal always offers Raster; testing for it narrows the element table's type.
103 if (!('Raster' in ui)) {
104 band = null
105 return <Box flexDirection="column">{below}</Box>
106 }
107 const { Raster } = ui
108 // Reading `pose` subscribes this draw to the timer, and doing it before the tick is
109 // sampled keeps draw and timer on the same tick. `pose` exists only to trigger
110 // redraws as the crab steps; invalidating 'ui.render' would redraw the pane as well.
111 await read($, pose)
112 const t = tick
113 const { gap, titleW, barW } = fitRow(ev.props.bodyColumns, shown.title, label, pct, headcount)
114 const glyphs = glyphsOf(options)
115 // Comparing with the previous draw of this flow shows whether it moved forward. A
116 // finished flow is closed: a new one with the same title begins from scratch.
117 const prev = band?.title === shown.title && !band.state.isFinished ? band.state : undefined
118 // Crab colour follows the working agent's role (plain grey before any): as is when
119 // resting beside the percentage, lightened by `tintOf` when walking on the track.
120 const restColor = colorOf(costume)
121 const state: BarState = {
122 columns: barW,
123 ratio: r,
124 isFinished: shown.isFinished,
125 glyphs,
126 label,
127 total: shown.total,
128 done: shown.done,
129 isWalking: running > 0,
130 hurryUntil: hurryOf(prev, r, t),
131 }
132 const fill = shown.isFinished ? BAND.done : BAND.fill
133 const room = pillOf(state) - 1 - label.length
134 const pad = (k: number) => ' '.repeat(Math.max(0, k))
135 const cells = barCells(state, t)
136 const frame = crabInTrack(state) ? crabFrame(state, t) : null
137 band = ev.requestId ? { requestId: ev.requestId, state, title: shown.title, frame: frame ?? CRAB, cells } : null
138 const row = (
139 <Box key="crew-row" flexDirection="row" gap={gap}>
140 <Box flexDirection="row" flexShrink={0}>
141 <Text color={fill}>{titleW ? '● ' : '●'}</Text>
142 {titleW ? (
143 <Text bold color={M.text}>
144 {clipCells(shown.title, titleW)}
145 </Text>
146 ) : null}
147 </Box>
148 <Box flexDirection="row" flexShrink={0}>
149 <Text color={fill}>{String.fromCodePoint(CAPS[glyphs][0])}</Text>
150 <Text backgroundColor={fill}>{pad(Math.floor(room / 2))}</Text>
151 <Text bold color={BAND.ink} backgroundColor={fill}>
152 {label}
153 </Text>
154 <Text backgroundColor={fill}>{pad(Math.ceil(room / 2))}</Text>
155 {/* The walking crab sits in its own Box: a fragment would be laid out as a
156 column and drop the crab one row under the pill's end cap. That rounded
157 cap stays on the track in the pill's colour, and the crab comes right
158 after it, one track column away. */}
159 {frame ? (
160 <Box key="crew-crab" flexDirection="row" flexShrink={0} width={1 + CRAB_CELLS}>
161 <Text color={fill} backgroundColor={BAND.track}>
162 {String.fromCodePoint(CAPS[glyphs][1])}
163 </Text>
164 <Text color={tintOf(costume)} backgroundColor={BAND.track}>
165 {frame}
166 </Text>
167 </Box>
168 ) : null}
169 <Raster key={BAR} columns={barW - pillOf(state) - rasterAt(state)} rows={1} cells={cells} />
170 </Box>
171 <Box flexDirection="row" gap={1} flexShrink={0}>
172 <Text color={M.overlay1}>{pct}</Text>
173 {frame ? null : <Text color={restColor}>{CRABS[glyphs].rest}</Text>}
174 </Box>
175 {crewButton}
176 {dismiss}
177 </Box>
178 )
179 return <Box flexDirection="column">{row}{below}</Box>
180 })
181}
182hooks/defaults.ts 7 lines1import type { Panel } from '../types'
2
3// Panel settings before the first write. Several modules declare their own `panel`
4// atom, because the engine works out a module's reads and writes from the atoms named
5// inside it; sharing this value keeps their starting points identical.
6export const PANEL_DEFAULT: Panel = { isCompact: false, isDoneCollapsed: false, autoOpenedFor: '', isOpen: false }
7hooks/flow.ts 165 lines1import type { AgentRun, Flow, Phase, PlannedTask, ProgressInput, StepInput } from '../types'
2import { clip, normTitle } from './format'
3import { roleName } from './roles'
4
5export const PHASES: readonly Phase[] = ['plan', 'design', 'delegate', 'review', 'close']
6// Upper bounds on what one call can store. The schema is only a suggestion to the
7// model, so oversized input is cut here before it reaches the band or the panel.
8const LIMIT = { tasks: 200, after: 20, title: 120 } as const
9const AUTO_TITLE = 48
10const MAX_NOTE = 80
11
12// The input as sent, schema or not: what is not a string, a finite number or a list
13// where one belongs is left out, as if never sent. A count may come as a numeric
14// string (`"3"`), as models send now and then; any other string is left out.
15const obj = (v: unknown): Record<string, unknown> => (typeof v === 'object' && v !== null ? (v as Record<string, unknown>) : {})
16const str = (v: unknown): string | undefined => (typeof v === 'string' ? v : undefined)
17const num = (v: unknown): number => (typeof v === 'number' ? v : typeof v === 'string' && v.trim() ? Number(v.trim()) : Number.NaN)
18const count = (v: unknown): number | undefined => {
19 const n = num(v)
20 return Number.isFinite(n) ? Math.max(0, Math.round(n)) : undefined
21}
22const list = (v: unknown): unknown[] | undefined => (Array.isArray(v) ? v : undefined)
23
24// A `progress` call's input, each field its own type or left out.
25export const progressInput = (e: unknown): ProgressInput => {
26 const o = obj(e)
27 return {
28 title: str(o.title),
29 total: count(o.total),
30 done: count(o.done),
31 phase: PHASES.find(p => p === o.phase),
32 finished: typeof o.finished === 'boolean' ? o.finished : undefined,
33 tasks: list(o.tasks)?.map(t => {
34 const task = obj(t)
35 return { title: str(task.title), role: str(task.role), after: list(task.after)?.filter((n): n is number => typeof n === 'number') }
36 }),
37 }
38}
39
40// A `step` call's input; the note is a few words, cut to a line.
41export const stepInput = (e: unknown): StepInput => {
42 const o = obj(e)
43 const note = str(o.note)?.trim()
44 return { done: count(o.done), total: count(o.total), note: note ? clip(note, MAX_NOTE) : undefined }
45}
46
47export const emptyFlow = (): Flow => ({
48 title: 'Subagents',
49 total: 0,
50 done: 0,
51 phase: 'plan',
52 isFinished: false,
53 isAuto: false,
54 isHidden: false,
55 tasks: [],
56})
57
58// The first line of the prompt names a flow nobody reported; a command or a
59// notification is not a name.
60export const promptTitle = (text: string): string => {
61 const line = text.trim().split('\n')[0]?.trim() ?? ''
62 return /^[</[]/.test(line) ? '' : clip(line, AUTO_TITLE)
63}
64
65export const autoFlow = (prompt: string): Flow => ({ ...emptyFlow(), title: prompt || 'Subagents', phase: 'delegate', isAuto: true })
66
67// A flow the mod started by itself is taken over by the first report, runs and all.
68// A reported flow is replaced only by a report under another title.
69export const isNewFlow = (prev: Flow | null, input: ProgressInput): boolean => {
70 if (!prev || prev.isFinished) return true
71 if (prev.isAuto || input.title === undefined) return false
72 return clip(input.title.trim(), LIMIT.title) !== prev.title
73}
74
75// Task numbers a task waits on: whole and from 1, at most LIMIT.after of them.
76const waitsOn = (after: number[] | undefined): number[] => (after ?? []).filter(n => n > 0 && Number.isInteger(n)).slice(0, LIMIT.after)
77
78// The plan as the panel keeps it; undefined when the report sent none, so the old one stands.
79const planOf = (sent: ProgressInput['tasks']): PlannedTask[] | undefined => {
80 if (!sent) return undefined
81 const plan: PlannedTask[] = []
82 for (const t of sent) {
83 if (plan.length === LIMIT.tasks) break
84 const title = t.title?.trim()
85 if (title) plan.push({ title, role: roleName(t.role ?? ''), after: waitsOn(t.after) })
86 }
87 return plan
88}
89
90// A whole number from 0 to `max`.
91const within = (n: number, max: number): number => Math.min(max, Math.max(0, Math.round(n)))
92
93export const mergeReport = (prev: Flow | null, input: ProgressInput): Flow => {
94 // Counters belong to one flow: a new one starts from the empty flow, not the last.
95 const base: Flow = prev && !isNewFlow(prev, input) ? { ...emptyFlow(), ...prev } : emptyFlow()
96 const tasks = planOf(input.tasks) ?? base.tasks
97 // A plan without a count is counted by its length.
98 const planned = input.tasks ? tasks.length : base.total
99 const total = within(input.total ?? planned, LIMIT.tasks)
100 const isFinished = input.finished === true
101 const title = clip(input.title?.trim() || base.title, LIMIT.title)
102 return {
103 ...base,
104 title,
105 total,
106 // Done never runs past the total, when there is one.
107 done: within(input.done ?? base.done, total || Infinity),
108 // `progressInput` lets through only a phase of PHASES.
109 phase: isFinished ? 'close' : (input.phase ?? base.phase),
110 isFinished,
111 isAuto: false,
112 tasks,
113 }
114}
115
116export const runningOf = (list: AgentRun[]): number => list.filter(a => a.status === 'running').length
117
118// An auto flow counts the panel's own runs: every spawned agent is a task, every ended one is done.
119export const withLiveCounts = (f: Flow, list: AgentRun[]): Flow =>
120 f.isAuto ? { ...f, total: list.length, done: list.length - runningOf(list) } : f
121
122// Planning and design have no count to show yet.
123const UNCOUNTED: Partial<Record<Phase, string>> = { plan: 'Plan', design: 'Design' }
124
125export const bandLabel = (f: Flow, running: number): string => {
126 if (f.isFinished) return 'Done'
127 const word = UNCOUNTED[f.phase]
128 if (word) return word
129 const tally = f.total ? `${f.done}/${f.total}` : `${running} running`
130 return (f.phase === 'review' ? 'Review ' : 'Tasks ') + tally
131}
132
133export const ratio = (f: Flow): number => {
134 if (f.isFinished) return 1
135 return f.total ? f.done / f.total : 0
136}
137
138// A planned task is matched to its run by title, as the reader sees it: case and
139// punctuation aside.
140export type Planned = PlannedTask & { n: number }
141
142export const plannedOf = (f: Flow | null, list: AgentRun[]): Planned[] => {
143 const open = f && !f.isFinished ? (f.tasks ?? []) : []
144 const handedOut = new Set(list.map(a => normTitle(a.description)))
145 return open.flatMap((t, i) => (handedOut.has(normTitle(t.title)) ? [] : [{ ...t, n: i + 1 }]))
146}
147
148// The panel's header: money and tokens of every run, and the time from the first
149// start to the last end (or now, while one runs).
150export const totals = (list: AgentRun[], at: number) => {
151 let cost = 0
152 let tokens = 0
153 let first = Infinity
154 let last = -Infinity
155 for (const a of list) {
156 cost += a.costUsd
157 tokens += a.tokens
158 first = Math.min(first, a.startedAt)
159 last = Math.max(last, a.endedAt ?? Math.max(at, a.startedAt))
160 }
161 return { cost, tokens, time: list.length ? last - first : 0 }
162}
163
164export type Totals = ReturnType<typeof totals>
165hooks/pane.tsx 274 lines1import { atom, read, update } from 'claude-code'
2import type { On, PluginOptions, RenderNode } from 'claude-code'
3
4import type { AgentRun, Flow, Panel, Role } from '../types'
5import { PANEL_DEFAULT } from './defaults'
6import { plannedOf, totals, withLiveCounts } from './flow'
7import type { Planned } from './flow'
8import { cells, clipCells, fmtCost, fmtTime, fmtTokens, modelName } from './format'
9import { caretOf, glyphsOf } from './glyphs'
10import type { Glyphs } from './glyphs'
11import { colorOf, dimColorOf, roleOf } from './roles'
12import { STATUS_GLYPH, ctxOf, elapsed, metaOf, progressOf, stepsOf } from './runs'
13import { svgSurface } from './surface'
14import { LINE_H, agentSvg, compactSvg, gridSvg, gridTiles, headerHeight, headerSvg, plannedSvg } from './svg-pane'
15import { CRAB_COLS, CRAB_ROWS, crabCells } from './term-sprite'
16import { M } from './theme'
17
18const flow = atom({ plugin: 'crab-crew', key: 'flow' } as const, null)
19const agents = atom({ plugin: 'crab-crew', key: 'agents' } as const, [])
20const panel = atom({ plugin: 'crab-crew', key: 'panel' } as const, PANEL_DEFAULT)
21const now = atom({ plugin: 'crab-crew', key: 'now' } as const, 0)
22
23// Heights of the desktop drawings, in px: a run, a planned task, the collapsed row.
24const AGENT_H = 66
25const PLANNED_H = 46
26const COMPACT_H = 32
27
28const EMPTY = 'No subagents yet.'
29
30// What either surface shows, worked out once: runs newest first and split by
31// whether they still run, plan tasks no run has taken, the totals up to `at`, and
32// the flow's name while it is open (the surface already titles the pane "Crew").
33const crewOf = (list: AgentRun[], stored: Flow | null, clock: number) => {
34 const f = stored && withLiveCounts(stored, list)
35 const at = Math.max(0, clock, ...list.map(a => a.startedAt))
36 const newestFirst = [...list].reverse()
37 const untaken = plannedOf(f, list)
38 return {
39 f,
40 at,
41 running: newestFirst.filter(a => a.status === 'running'),
42 finished: newestFirst.filter(a => a.status !== 'running'),
43 planned: untaken,
44 totals: totals(list, at),
45 title: f && !f.isFinished ? f.title : '',
46 isEmpty: !list.length && !untaken.length,
47 }
48}
49
50// Desktop width from the reported columns, at about 8px each, kept to 240-900px.
51const desktopWidth = (columns: number): number => Math.min(900, Math.max(240, columns * 8 - 8))
52
53// A terminal row's stats beside its step note. The note gets the room it needs:
54// first the context fill goes, then the cost; a note too long even beside the
55// tokens alone is cut there.
56const statsBeside = (a: AgentRun, steps: string, width: number): string => {
57 const tokens = fmtTokens(a.contextTokens)
58 const cost = `≈${fmtCost(a.costUsd)}`
59 const full = `ctx ${ctxOf(a)}% · ${tokens} · ${cost}`
60 const room = (stats: string) => width - cells(stats) - 2
61 if (!a.stepNote || cells(steps) <= room(full)) return full
62 const lean = `${tokens} · ${cost}`
63 return cells(steps) <= room(lean) ? lean : tokens
64}
65
66// The crew pane: one row per subagent spawned this session, then the plan's tasks still waiting.
67export const paneHooks = (on: On, options: PluginOptions): void => {
68 // The literal is `PANE` from register.tsx, written out for the engine's site listing.
69 on('ui.render', { component: 'Pane', requestId: 'crew' }, async ($, ev, next) => {
70 const ui = $.ui.resolve(ev)
71 const { Box, Button, Text } = ui
72 const runs = await read($, agents)
73 const stored = await read($, flow)
74 const prefs: Panel = await read($, panel)
75 const crew = crewOf(runs, stored, await read($, now))
76 const { f, at, running, finished, planned, title, isEmpty } = crew
77 const sum = crew.totals
78 const shownFinished = prefs.isDoneCollapsed ? [] : finished
79
80 const flip = (key: 'isCompact' | 'isDoneCollapsed') => () => update($, panel, (prev): Panel => ({ ...prev, [key]: !prev[key] }))
81 const toggleCompact = <Button key="compact" label={prefs.isCompact ? 'Expand' : 'Collapse'} plain dimColor onPress={flip('isCompact')} />
82 // One Button is the whole Finished heading, caret after the count, so a single
83 // press and a single focus serve it. Buttons take no colour and draw dim, so the
84 // Running and Planned headings are dim Text alike. The caret's glyphs come from
85 // the surface: the desktop font has no Nerd glyphs.
86 const toggleDone = (glyphs: Glyphs) => (
87 <Button key="done" label={`Finished · ${finished.length} ${caretOf(glyphs, prefs.isDoneCollapsed)}`} plain dimColor onPress={flip('isDoneCollapsed')} />
88 )
89 const summary = `≈${fmtCost(sum.cost)}, ${fmtTokens(sum.tokens)} tokens, ${fmtTime(sum.time)}`
90
91 if (svgSurface(ev.surface, ui)) {
92 const { Svg } = ui
93 const W = desktopWidth(ev.props.bodyColumns || 40)
94 const sectionHead = (key: string, text: string) => <Text key={key} dimColor>{text}</Text>
95
96 if (prefs.isCompact) {
97 const compactAlt = `${runs.length} agents, ${summary}`
98 return (
99 <Box gap={1} flexDirection="column">
100 <Svg source={compactSvg(W, runs, planned, sum)} alt={compactAlt} width={W} height={COMPACT_H} />
101 {toggleCompact}
102 </Box>
103 )
104 }
105
106 const headH = headerHeight(title)
107 const headAlt = title ? `${title}: ${summary}` : summary
108 // Text lines between the drawings: the size toggle, then the empty note and
109 // each section heading that shows.
110 const textLines = [true, isEmpty, running.length > 0, finished.length > 0, planned.length > 0].filter(Boolean).length
111 const contentH = headH + textLines * LINE_H + (running.length + shownFinished.length) * AGENT_H + planned.length * PLANNED_H
112 const runCard = (a: AgentRun, state: string) => {
113 const alt = [roleOf(a.type, a.description), a.description, modelName(a.model), state].join(' · ')
114 return <Svg key={a.id} source={agentSvg(W, a, at)} alt={alt} width={W} height={AGENT_H} />
115 }
116 // The dotted grid is what gives the pane its height; the content box is
117 // absolute, so it lies on top of the grid tiles drawn before it.
118 const backdrop = gridTiles(contentH).map((tileH, i) => <Svg key={`grid-${i}`} alt="dotted grid" source={gridSvg(W, tileH)} width={W} height={tileH} />)
119 const content = (
120 <Box position="absolute" top={0} left={0} right={0} flexDirection="column">
121 <Svg source={headerSvg(W, title, sum)} alt={headAlt} width={W} height={headH} />
122 {toggleCompact}
123 {isEmpty && <Text dimColor>{EMPTY}</Text>}
124 {running.length > 0 && sectionHead('h-run', `Running · ${running.length}`)}
125 {running.map(a => runCard(a, 'running'))}
126 {finished.length > 0 && toggleDone('plain')}
127 {shownFinished.map(a => runCard(a, 'finished'))}
128 {planned.length > 0 && sectionHead('h-plan', `Planned · ${planned.length}`)}
129 {planned.map(pl => (
130 <Svg key={`plan-${pl.n}`} source={plannedSvg(W, pl)} alt={`${pl.n}. ${pl.title}: planned`} width={W} height={PLANNED_H} />
131 ))}
132 </Box>
133 )
134 return <Box flexDirection="column">{backdrop}{content}</Box>
135 }
136
137 // In the terminal the crew is drawn in cells and the terminal's own palette,
138 // with one column of margin either side.
139 const paneCols = Math.max(32, ev.props.bodyColumns || 40)
140 const cols = paneCols - 2
141 // Every terminal has a Raster; the check narrows the table.
142 if (!('Raster' in ui)) return next(ev)
143 const { Raster } = ui
144 const textW = Math.max(14, cols - CRAB_COLS - 1)
145 // A running crab steps once a second, on the clock that redraws its row.
146 const frame = Math.floor(at / 1000) % 2
147 const crab = (key: string, role: Role, isWalking: boolean, dim = false) => (
148 <Raster key={`crab-${key}`} columns={CRAB_COLS} rows={CRAB_ROWS} cells={crabCells(role, isWalking ? frame : null, dim)} />
149 )
150 const rule = (key: string) => <Text key={key} color={M.surface0}>{'─'.repeat(cols)}</Text>
151 // Layout pieces the rows share.
152 const ends = (left: RenderNode, right: RenderNode) => <Box justifyContent="space-between" flexDirection="row">{left}{right}</Box>
153 const truncated = (a: RenderNode, b: RenderNode) => <Text wrap="truncate-end">{a}{b}</Text>
154 const besideCrab = (sprite: RenderNode, body: RenderNode) => <Box gap={1} flexDirection="row">{sprite}{body}</Box>
155 const entry = (key: string, body: RenderNode) => <Box key={key} flexDirection="column">{body}{rule(`rule-${key}`)}</Box>
156 // Three tiles and two gaps span the content; the last takes the odd columns. The
157 // caption in subtext0, 5.6 to 1 off the tile (overlay1 was 3.4); the value bold.
158 const tileW = Math.floor((cols - 2) / 3)
159 const tile = (k: string, v: string, width = tileW) => (
160 <Box key={k} flexDirection="column" width={width} paddingX={1} backgroundColor={M.surface0}>
161 <Text color={M.subtext0} backgroundColor={M.surface0}>
162 {k}
163 </Text>
164 <Text bold color={M.text} backgroundColor={M.surface0}>
165 {v}
166 </Text>
167 </Box>
168 )
169 // A heading below another section's rows stands a line clear of them; a label is
170 // drawn dim, as the Finished Button is.
171 const heading = (key: string, label: RenderNode, isAfter: boolean) => (
172 <Box key={key} marginTop={isAfter ? 1 : 0}>
173 {typeof label === 'string' ? <Text dimColor>{label}</Text> : label}
174 </Box>
175 )
176
177 const runRow = (a: AgentRun) => {
178 const role = roleOf(a.type, a.description)
179 const color = colorOf(role)
180 const share = progressOf(a)
181 const time = fmtTime(elapsed(a, at))
182 const steps = stepsOf(a)
183 const stats = statsBeside(a, steps, textW)
184 const stepsW = textW - cells(stats) - 2
185 // With no step count the bar shows the context instead, in the role's colour
186 // dimmed toward the base, so it never passes for the task's progress.
187 const filled = Math.round(textW * (share ?? ctxOf(a) / 100))
188 const fill = share === null ? dimColorOf(role) : color
189 const mark = a.status === 'failed' ? M.red : a.status === 'done' ? M.green : color
190 const timeAndMark = (
191 <Text>
192 <Text color={M.overlay1}>{time} </Text>
193 <Text color={mark}>{STATUS_GLYPH[a.status]}</Text>
194 </Text>
195 )
196 const meta = clipCells(` · ${metaOf(a).join(' · ')}`, textW - cells(role))
197 const bar = (
198 <Text>
199 <Text color={fill}>{'━'.repeat(filled)}</Text>
200 <Text color={M.surface1}>{'━'.repeat(Math.max(0, textW - filled))}</Text>
201 </Text>
202 )
203 const body = (
204 <Box flexDirection="column" width={textW}>
205 {ends(<Text bold color={M.text}>{clipCells(a.description || a.type, textW - time.length - 4)}</Text>, timeAndMark)}
206 {truncated(<Text color={color}>{role}</Text>, <Text color={M.subtext0}>{meta}</Text>)}
207 {ends(<Text color={M.subtext0}>{stepsW > 4 ? clipCells(steps, stepsW) : ''}</Text>, <Text color={M.overlay1}>{stats}</Text>)}
208 {bar}
209 </Box>
210 )
211 return entry(a.id, besideCrab(crab(a.id, role, a.status === 'running'), body))
212 }
213
214 const planRow = (pl: Planned) => {
215 const role = pl.role || 'other'
216 const key = `plan-${pl.n}`
217 const waits = pl.after.length ? clipCells(` · after ${pl.after.join(', ')}`, textW - cells(role)) : ''
218 // Level with the crab's top, as a running or finished row's title is.
219 const body = (
220 <Box flexDirection="column" width={textW}>
221 {ends(<Text color={M.subtext0}>{clipCells(`${pl.n}. ${pl.title}`, textW - 2)}</Text>, <Text color={M.overlay0}>{STATUS_GLYPH.planned}</Text>)}
222 {truncated(<Text color={colorOf(role)}>{role}</Text>, <Text color={M.overlay1}>{waits}</Text>)}
223 </Box>
224 )
225 return entry(key, besideCrab(crab(key, role, false, true), body))
226 }
227
228 // The collapsed pane: one crab per run and planned task, as many as fit, then a count.
229 const everyone = [
230 ...[...running, ...finished].map(a => ({ key: a.id, role: roleOf(a.type, a.description), isWalking: a.status === 'running', dim: false })),
231 ...planned.map(pl => ({ key: `plan-${pl.n}`, role: pl.role || 'other', isWalking: false, dim: true }) as const),
232 ]
233 const fit = Math.max(1, Math.floor(cols / (CRAB_COLS + 1)))
234 const compact = () => (
235 <Box gap={1} marginTop={1} flexDirection="row">
236 {isEmpty && <Text color={M.overlay1}>{EMPTY}</Text>}
237 {everyone.slice(0, fit).map(c => crab(c.key, c.role, c.isWalking, c.dim))}
238 {everyone.length > fit && <Text color={M.overlay1}>+{everyone.length - fit}</Text>}
239 </Box>
240 )
241 const expanded = () => (
242 <Box marginTop={1} flexDirection="column">
243 {isEmpty && <Text color={M.overlay1}>{EMPTY}</Text>}
244 {running.length > 0 && heading('h-run', `Running · ${running.length}`, false)}
245 {running.map(runRow)}
246 {finished.length > 0 && heading('h-done', toggleDone(glyphsOf(options)), running.length > 0)}
247 {shownFinished.map(runRow)}
248 {planned.length > 0 && heading('h-plan', `Planned · ${planned.length}`, running.length + finished.length > 0)}
249 {planned.map(planRow)}
250 </Box>
251 )
252
253 // The pane's own backdrop is a neutral grey; the panel lays the terminal's base over all of it.
254 const paneRows = ev.props.scroll?.bodyRows ?? 0
255 return (
256 <Box flexDirection="column" width={paneCols} minHeight={paneRows} paddingX={1} backgroundColor={M.base}>
257 {/* The toggle stops short of the pane's own close mark in the corner. */}
258 <Box flexDirection="row" justifyContent="space-between" paddingRight={2} marginBottom={1}>
259 <Text bold color={f?.isFinished ? M.subtext0 : M.text}>
260 {clipCells(f?.title || 'Crew', cols - 14)}
261 </Text>
262 {toggleCompact}
263 </Box>
264 <Box gap={1} flexDirection="row">
265 {tile('Cost', `≈${fmtCost(sum.cost)}`)}
266 {tile('Tokens', fmtTokens(sum.tokens))}
267 {tile('Time', fmtTime(sum.time), cols - 2 - tileW * 2)}
268 </Box>
269 {prefs.isCompact ? compact() : expanded()}
270 </Box>
271 )
272 })
273}
274hooks/runs.ts 128 lines1import type { AgentRun, StepInput } from '../types'
2import { costOf, usageSum, windowOf } from './cost'
3import type { Usage } from './cost'
4import { modelName, normTitle } from './format'
5import { bareType, isGeneric, roleOf } from './roles'
6
7// The list behind the panel: one run per subagent spawned this session, oldest first.
8// Pure: register.tsx reads and writes the list, these say what changes.
9
10// How many runs the panel remembers; the oldest go first.
11const MAX_RUNS = 200
12
13type Spawned = { id: string; agentId?: string; type: string; description: string; model: string }
14
15// A just-spawned agent, running and not yet billed. A task handed out again is its
16// next round: the round counts the runs before it under the same title.
17export const addRun = (list: AgentRun[], s: Spawned, at: number): AgentRun[] => {
18 const title = normTitle(s.description)
19 const earlier = s.description ? list.filter(a => normTitle(a.description) === title).length : 0
20 const fresh: AgentRun = {
21 ...s,
22 status: 'running',
23 startedAt: at,
24 contextTokens: 0,
25 ...onModel(s.model),
26 tokens: 0,
27 costUsd: 0,
28 steps: 0,
29 round: earlier + 1,
30 }
31 // A re-sent id replaces its row and moves it to the end.
32 const kept = list.filter(a => a.id !== s.id)
33 kept.push(fresh)
34 return kept.length > MAX_RUNS ? kept.slice(kept.length - MAX_RUNS) : kept
35}
36
37// `fn` applied to the rows of `agentId`; `matched` tells whether there was any.
38export const withRun = (list: AgentRun[], agentId: string, fn: (a: AgentRun) => AgentRun): { list: AgentRun[]; matched: boolean } => {
39 const isIt = (a: AgentRun) => a.agentId === agentId
40 return { list: list.map(a => (isIt(a) ? fn(a) : a)), matched: list.some(isIt) }
41}
42
43const whole = (n: number): number => Math.max(0, Math.round(n))
44
45// The worker's own count of its steps. With a total, done stops there.
46export const applyStep = (a: AgentRun, input: StepInput): AgentRun => {
47 const total = whole(input.total ?? a.stepTotal ?? 0)
48 const done = whole(input.done ?? a.stepDone ?? 0)
49 return { ...a, stepTotal: total, stepDone: total > 0 ? Math.min(done, total) : done, stepNote: input.note }
50}
51
52// The model a run is on, and the context window that comes with it.
53const onModel = (model: string) => ({ model, contextMax: windowOf(model) })
54
55// What a request, or a whole turn, adds to the bill.
56const bill = (model: string, usage: Usage, isRequest: boolean) => ({ tokens: usageSum(usage), costUsd: costOf(model, usage, isRequest) })
57
58// One model request: what it sent and got back fills the context now; tokens and
59// money add to what the run already spent. Any request means the run is alive.
60export const applyUsage = (a: AgentRun, usage: Usage, model: string, effort: unknown): AgentRun => {
61 const request = bill(model, usage, true)
62 return {
63 ...a,
64 ...onModel(model),
65 effort: typeof effort === 'string' ? effort : a.effort,
66 status: 'running',
67 endedAt: undefined,
68 contextTokens: request.tokens,
69 tokens: a.tokens + request.tokens,
70 costUsd: a.costUsd + request.costUsd,
71 steps: a.steps + 1,
72 }
73}
74
75// The turn is over: done on an answer, failed on anything else. Seen requests
76// already billed the run; with none seen, the turn's total stands in, at the plain rate.
77export const finishRun = (a: AgentRun, reason: string, at: number, usage?: Usage & { model?: string }): AgentRun => {
78 const ended: AgentRun = { ...a, status: reason === 'answer' ? 'done' : 'failed', endedAt: at }
79 if (a.steps > 0 || !usage) return ended
80 const model = usage.model || a.model
81 return { ...ended, model, ...bill(model, usage, false) }
82}
83
84// What the panel shows of a run, on either surface.
85
86export const elapsed = (a: AgentRun, at: number): number => {
87 const end = a.endedAt ?? Math.max(at, a.startedAt)
88 return end - a.startedAt
89}
90
91// Share of the task done, from the worker's step count; null when it gave none.
92// A finished run counts as whole whatever it reported.
93export const progressOf = (a: AgentRun): number | null =>
94 a.status === 'done' ? 1 : a.stepTotal ? Math.min(1, (a.stepDone ?? 0) / a.stepTotal) : null
95
96// Context fill in whole percent, never past 100.
97export const ctxOf = (a: AgentRun): number => {
98 if (!a.contextMax) return 0
99 const pct = (a.contextTokens / a.contextMax) * 100
100 return Math.min(100, Math.round(pct))
101}
102
103// Model, then the effort when one was reported.
104const runModel = (a: AgentRun): string => [modelName(a.model), a.effort].filter(Boolean).join(' · ')
105
106// What follows the role word: the agent type when it says more than the role (a generic
107// type never does: the role, read from the task, already names the job), then the model.
108export const metaOf = (a: AgentRun): string[] => {
109 const role = roleOf(a.type, a.description)
110 const type = bareType(a.type)
111 const typeSaysMore = type.toLowerCase() !== role && !isGeneric(type)
112 const parts = [typeSaysMore && type, runModel(a), a.round > 1 && `round ${a.round}`, a.status === 'failed' && 'error']
113 return parts.filter((p): p is string => typeof p === 'string')
114}
115
116// The step line, the same on every surface: `3/5 note`, one space between, as a
117// terminal row has little room; the count alone, or the note alone when the worker
118// reported a note but no total.
119export const stepsOf = (a: AgentRun): string => [a.stepTotal ? [a.stepDone ?? 0, a.stepTotal].join('/') : '', a.stepNote ?? ''].filter(Boolean).join(' ')
120
121// A run's status in the terminal panel, and a planned task's.
122export const STATUS_GLYPH: Record<AgentRun['status'] | 'planned', string> = {
123 running: '●',
124 done: '✓',
125 failed: '✗',
126 planned: '◷',
127}
128hooks/term-crab.ts 95 lines1import type { Role } from '../types'
2import type { Glyphs } from './glyphs'
3import { colorOf } from './roles'
4import type { BarState } from './term-bar'
5import { BAND, M, blend, contrast, hex, rgb } from './theme'
6
7// The crab that walks at the head of the band's track, drawn as Text: a Raster draws
8// BMP glyphs only, and its octants are not.
9// Besides what band.tsx draws with, CRAB_FRAMES, CRAB_REST, PLAIN_FRAMES, PLAIN_REST and
10// LAP are exported for the tests alone.
11
12// The band's crab, sixteen pixels by four in octants (2×4 per cell), seen from the
13// front: both claws raised, open, either side of a wide shell with two eyes (holes),
14// six legs beneath. Pixel columns 0 and 15 are empty, so a column of track always
15// stands between its claw and the pill's rounded head, and between its other claw
16// and what drifts beyond. Only its legs move:
17// A rest R right legs step in L left legs step in
18// .X.X..XXXX..X.X. .X.X..XXXX..X.X. .X.X..XXXX..X.X.
19// .XXXXX.XX.XXXXX. .XXXXX.XX.XXXXX. .XXXXX.XX.XXXXX.
20// ..XXXXXXXXXXXX.. ..XXXXXXXXXXXX.. ..XXXXXXXXXXXX..
21// ..X.X.X..X.X.X.. ..X.X.X.X.X.X... ...X.X.X.X.X.X..
22// Each cell is the BLOCK OCTANT named by its pixels, numbered 1-8 left to right, top
23// to bottom, but for the two claw tips, whose pixels name a QUADRANT instead (24 is
24// UPPER RIGHT, 13 UPPER LEFT): 24, 234567, 34567, 124567, 123568, 34568, 134568, 13
25// (A); 24, 234567, 34567, 124567, 123567, 34567, 134567, 13 (R); 24, 234568, 34568,
26// 124568, 123568, 34568, 134568, 13 (L).
27export const CRAB_FRAMES = {
28 A: '▝\u{1cd6f}\u{1cd6d}\u{1cd6c}\u{1cda3}\u{1cda8}\u{1cda9}▘',
29 R: '▝\u{1cd6f}\u{1cd6d}\u{1cd6c}\u{1cd68}\u{1cd6d}\u{1cd6e}▘',
30 L: '▝\u{1cdaa}\u{1cda8}\u{1cda7}\u{1cda3}\u{1cda8}\u{1cda9}▘',
31} as const
32export const CRAB = CRAB_FRAMES.A
33// The crab at rest beside the percentage, where the row has room for six cells, not
34// eight: the same crab twelve pixels wide, its claws in close by the shell, and no
35// column of track to keep clear at either end:
36// X.X.XXXX.X.X
37// .XXX.XX.XXX.
38// ..XXXXXXXX..
39// .X.X.XX.X.X.
40// BLOCK OCTANT-148, 134568, 124568, 123567, 234567, 237.
41export const CRAB_REST = '\u{1cd79}\u{1cda9}\u{1cda7}\u{1cd68}\u{1cd6f}\u{1cd3b}'
42
43// Without a font that has the octants (the `plain` glyphs), the same crab in ASCII:
44// raised pincers either side of a shell with two eyes, six cells, in every font. In
45// the track's eight cells it scuttles sideways a cell either way. Shuffled left it
46// meets the pill's head, a left half block there: half a cell of track still parts them.
47export const PLAIN_FRAMES = { A: ' V(oo)V ', R: ' V(oo)V', L: 'V(oo)V ' } as const
48export const PLAIN_REST = 'V(oo)V'
49
50// Each glyph set's crab: its frames in the track, and the one at rest beside the percentage.
51export const CRABS = {
52 nerd: { frames: CRAB_FRAMES, rest: CRAB_REST },
53 plain: { frames: PLAIN_FRAMES, rest: PLAIN_REST },
54} as const satisfies Record<Glyphs, { frames: { A: string; R: string; L: string }; rest: string }>
55
56// One side's legs step in, then the other's: a sideways scuttle.
57const walkOf = ({ frames: { A, R, L } }: (typeof CRABS)[Glyphs]): string[] => [A, R, A, L]
58const WALKS: Record<Glyphs, string[]> = { nerd: walkOf(CRABS.nerd), plain: walkOf(CRABS.plain) }
59const WALK_LEN = 4
60
61// Every 3 cycles it stops at rest for 4 ticks: a crab that never stops tires the eye.
62const PAUSE = 4
63export const LAP = 3 * 2 * WALK_LEN + PAUSE
64
65// A frame every 2 ticks, every tick while it hurries; at rest while no agent runs.
66// A scuttle runs through the pauses. From the shared tick, so a redraw does not
67// restart the cycle.
68export const crabFrame = (state: BarState, tick: number): string => {
69 const walk = WALKS[state.glyphs]
70 const still = walk[0]!
71 if (!state.isWalking) return still
72 if (tick < state.hurryUntil) return walk[tick % WALK_LEN]!
73 const at = tick % LAP
74 return at >= LAP - PAUSE ? still : walk[Math.floor(at / 2) % WALK_LEN]!
75}
76
77// The walking crab's colour on the track: its role's, taken toward Mocha's text in
78// steps of a twentieth just far enough to stand 4.5 to 1 off the track, as clear as
79// the dimmest accent, the builder's blue. Only the plain crab's grey needs it.
80const TINT_MIN = 4.5
81export const tintOf = (role: Role): string => {
82 const color = colorOf(role)
83 for (let k = 0; k <= 20; k++) {
84 const c = hex(blend(rgb(color), rgb(M.text), k / 20))
85 if (contrast(c, BAND.track) >= TINT_MIN) return c
86 }
87 return M.text
88}
89
90// A step forward makes it scuttle for a moment. A first draw or a step back does
91// not; a redraw at the same progress keeps the scuttle already under way.
92const HURRY = 8
93export const hurryOf = (prev: BarState | undefined, ratio: number, tick: number): number =>
94 !prev ? 0 : ratio > prev.ratio ? tick + HURRY : prev.hurryUntil
95hooks/tools.ts 84 lines1import type { ToolSpec } from 'claude-code'
2
3import { PHASES } from './flow'
4import { ROLES } from './roles'
5
6// Tool definitions the mod registers with Claude Code. Everything here is read by the
7// model, so it is phrased as instructions to it. A schema only suggests what to send:
8// flow.ts still clamps and filters each call before anything is drawn. The full tool
9// names below also appear in register.tsx's tool.call matchers.
10
11export const STEP_TOOL = 'mcp__crab-crew__step'
12
13// JSON schema shorthands for the property lists below.
14const text = (description: string) => ({ type: 'string', description })
15const count = (minimum: number, description: string) => ({ type: 'integer', minimum, description })
16const numbers = (description: string) => ({ type: 'array', items: { type: 'integer' }, description })
17
18// Roles a planned task may name; `fork` and `other` are only ever inferred.
19const plannable = ROLES.filter(r => r !== 'other' && r !== 'fork')
20
21const progressText = [
22 'For the main session alone: it feeds the progress bar over the input box and the crew panel while you coordinate',
23 'work split across two or more subagent tasks.',
24 'Send it once the plan is set (title, tasks, phase "delegate"), again whenever a task passes review (done),',
25 'whenever the phase changes or the plan is revised (tasks), and a last time with finished: true.',
26 'Any field you omit keeps the value it had.',
27 'Calls made by a subagent are dropped; subagents use `step` instead.',
28].join(' ')
29
30export const PROGRESS_SPEC: ToolSpec = {
31 name: 'progress',
32 description: progressText,
33 inputSchema: {
34 type: 'object',
35 properties: {
36 title: text('What the whole job is called, in a handful of words.'),
37 total: count(0, 'How many subagent tasks the plan has.'),
38 done: count(0, 'How many of those tasks have passed review.'),
39 phase: { type: 'string', enum: PHASES.slice() },
40 finished: { type: 'boolean', description: 'Set to true when the job is wrapped up.' },
41 tasks: {
42 type: 'array',
43 description:
44 'Every planned subagent task, in plan order; task 1 is the first. Give each the exact text you will pass as the Agent tool `description` when delegating it: that is how the panel ties a running agent to its task.',
45 items: {
46 type: 'object',
47 required: ['title'],
48 properties: {
49 title: text('Short, and identical to the Agent description used for it.'),
50 role: { type: 'string', enum: plannable, description: 'The kind of work this task involves.' },
51 after: numbers('Task numbers that must finish before this one starts.'),
52 },
53 },
54 },
55 },
56 },
57}
58
59const stepText = [
60 'Subagents only: shows how far along your own task is in the crew panel.',
61 'As soon as you have read your brief, send `done: 0` with `total` set to the number of steps you plan (3 to 8);',
62 'send it again each time a step is complete. It is quick and prints nothing, it just moves a bar.',
63].join(' ')
64
65export const STEP_SPEC: ToolSpec = {
66 name: 'step',
67 description: stepText,
68 inputSchema: {
69 type: 'object',
70 required: ['done'],
71 properties: {
72 done: count(0, 'How many steps are complete.'),
73 total: count(1, 'How many steps you plan; update it if the plan grows or shrinks.'),
74 note: text('A few words on the step you are working on now.'),
75 },
76 },
77}
78
79// Appended to the prompt of a built-in subagent. Agents that come from a plugin bring
80// their own tool list without this tool, so their prompts are not touched.
81export const STEP_BRIEF = `
82
83Progress reporting: when your tools include \`${STEP_TOOL}\` (if it shows up as deferred, fetch it first with ToolSearch \`select:${STEP_TOOL}\`), start by calling it with \`done: 0\` and \`total\` set to the number of steps you plan (3 to 8), before doing anything else. After each step is complete, call it again with the new \`done\` and a short \`note\`. All it does is move a progress bar.`
84hooks/format.ts 144 lines1// Text helpers shared by the band, the pane and their drawings: short model names,
2// readable numbers, escaping for SVG, and trimming a string to a cell or pixel budget.
3
4const FAMILIES = ['fable', 'mythos', 'opus', 'sonnet', 'haiku']
5// "<family>-<major>" with an optional one- or two-digit "-<minor>"; a longer digit
6// run after the major is a date stamp, not a minor version.
7const FAMILY_VERSION = new RegExp(`(${FAMILIES.join('|')})-(\\d+)(?:-(\\d{1,2})(?!\\d))?`, 'i')
8
9const capitalised = (word: string): string => {
10 const lower = word.toLowerCase()
11 return lower.slice(0, 1).toUpperCase() + lower.slice(1)
12}
13
14// "claude-opus-4-1-20250805" → "Opus 4.1". An id outside the known families keeps its
15// own spelling, minus the vendor prefix and any "[...]" variant tag.
16export const modelName = (modelId: string): string => {
17 const hit = FAMILY_VERSION.exec(modelId)
18 if (hit === null) {
19 const bare = modelId.replace(/^claude-/, '').replace(/\[.*\]$/, '')
20 return bare === '' ? '—' : bare
21 }
22 const [, family = '', major = '', minor] = hit
23 const version = minor ? `${major}.${minor}` : major
24 return `${capitalised(family)} ${version}`
25}
26
27export const fmtTokens = (count: number): string => {
28 if (count >= 1e6) return (count / 1e6).toFixed(1) + 'M'
29 if (count >= 1e3) return Math.round(count / 1e3) + 'k'
30 return String(Math.round(count))
31}
32
33// Cents under ten dollars, dimes from there on.
34export const fmtCost = (dollars: number): string => '$' + dollars.toFixed(dollars < 10 ? 2 : 1)
35
36const pad2 = (n: number): string => String(n).padStart(2, '0')
37
38// Elapsed time as m:ss, or h:mm:ss once it passes the hour.
39export const fmtTime = (elapsedMs: number): string => {
40 const secs = Math.max(0, Math.round(elapsedMs / 1000))
41 const hours = Math.floor(secs / 3600)
42 const mins = Math.floor((secs - hours * 3600) / 60)
43 const tail = pad2(secs % 60)
44 return hours ? `${hours}:${pad2(mins)}:${tail}` : `${mins}:${tail}`
45}
46
47const ENTITY: Record<string, string> = { '<': '<', '>': '>', '&': '&', '"': '"' }
48export const xml = (raw: string): string => raw.replace(/["&<>]/g, c => ENTITY[c] ?? c)
49
50// The form two task titles are matched in (never displayed): letters and digits
51// only, lower-cased, runs of anything else folded to one space.
52export const normTitle = (s: string): string => s.toLowerCase().replace(/[^\p{L}\p{N}]+/gu, ' ').trim()
53
54// Split into user-perceived characters so a cut never lands inside an emoji sequence
55// or between a letter and its combining marks; plain code points as a fallback.
56const segmenter = typeof Intl !== 'undefined' && 'Segmenter' in Intl ? new Intl.Segmenter() : null
57const graphemes = (s: string): string[] => (segmenter ? Array.from(segmenter.segment(s), g => g.segment) : [...s])
58
59// Every cut ends the same way: trailing space dropped, then one "…".
60const ellipsis = (kept: string): string => kept.trimEnd() + '…'
61
62// Code point ranges drawn double width: Hangul jamo, the CJK blocks, Hangul
63// syllables, compatibility and fullwidth forms, emoji, and the supplementary
64// ideograph planes.
65const DOUBLE: ReadonlyArray<readonly [number, number]> = [
66 [0x1100, 0x115f],
67 [0x2e80, 0x303e],
68 [0x3041, 0x33ff],
69 [0x3400, 0x4dbf],
70 [0x4e00, 0x9fff],
71 [0xa960, 0xa97f],
72 [0xac00, 0xd7a3],
73 [0xf900, 0xfaff],
74 [0xfe30, 0xfe4f],
75 [0xff01, 0xff60],
76 [0xffe0, 0xffe6],
77 [0x1f300, 0x1faff],
78 [0x20000, 0x3fffd],
79]
80const inDouble = (cp: number): boolean => DOUBLE.some(([lo, hi]) => cp >= lo && cp <= hi)
81// True when any code point of `text` falls in one of the ranges above.
82const hasDouble = (text: string): boolean => Array.from(text, c => c.codePointAt(0) ?? 0).some(inDouble)
83
84// Approximate glyph advance in the desktop UI font, as a fraction of the font size.
85// Only used to budget space for a title, so four buckets do. Cyrillic gets the
86// same wide letters as Latin, since titles follow the user's language.
87const THIN = /[()[\]\s.,:;'|!1il]/
88const BROAD = /[@%A-ZmwЁА-ЯМШЩЖЮшщжю]/
89const advance = (ch: string): number => {
90 if (hasDouble(ch)) return 1
91 if (THIN.test(ch)) return 0.3
92 if (BROAD.test(ch)) return 0.72
93 return 0.56
94}
95
96export const textWidth = (text: string, px: number): number => {
97 let w = 0
98 for (const c of text) w += advance(c) * px
99 return w
100}
101
102// Longest prefix of `text` that, with a trailing "…", stays within `maxW` pixels.
103// Strings that already fit come back untouched.
104export const fitText = (text: string, px: number, maxW: number): string => {
105 const whole = textWidth(text, px)
106 if (whole <= maxW) return text
107 const tail = advance('…') * px
108 let kept = ''
109 let w = 0
110 for (const c of text) {
111 const step = advance(c) * px
112 if (w + step + tail > maxW) break
113 kept += c
114 w += step
115 }
116 return ellipsis(kept)
117}
118
119// Terminal columns a string occupies: two for each double-width character.
120const columns = (g: string): number => (hasDouble(g) ? 2 : 1)
121export const cells = (s: string): number => graphemes(s).reduce((sum, g) => sum + columns(g), 0)
122
123// Trims to at most `max` characters; the last one becomes "…" when anything is lost.
124export const clip = (text: string, max: number): string => {
125 const parts = graphemes(text)
126 if (parts.length <= max) return text
127 return ellipsis(parts.slice(0, Math.max(1, max - 1)).join(''))
128}
129
130// Trims to at most `max` terminal columns, keeping one column for the "…".
131export const clipCells = (text: string, max: number): string => {
132 if (cells(text) <= max) return text
133 const room = max - 1
134 let kept = ''
135 let used = 0
136 for (const g of graphemes(text)) {
137 const need = columns(g)
138 if (used + need > room) break
139 kept += g
140 used += need
141 }
142 return ellipsis(kept)
143}
144hooks/roles.ts 77 lines1import type { Role } from '../types'
2import { M, blend, hex, rgb } from './theme'
3
4// A role is what a subagent is doing, and picks its costume and color. It is read
5// from the agent type; a general-purpose agent has no trade of its own, so the
6// verb its task description opens with decides.
7
8export const ROLES: readonly Role[] = ['designer', 'reviewer', 'scout', 'planner', 'builder', 'runner', 'fork', 'other']
9
10// Catppuccin Mocha accents, one per role.
11const ROLE_COLOR: Record<Role, string> = {
12 designer: M.pink,
13 reviewer: M.peach,
14 scout: M.green,
15 planner: M.yellow,
16 builder: M.blue,
17 runner: M.teal,
18 fork: M.lavender,
19 other: M.overlay1,
20}
21
22const isRole = (v: string): v is Role => ROLES.some(r => r === v)
23
24export const colorOf = (role: Role): string => ROLE_COLOR[role]
25
26// The role's colour taken 40% toward Mocha's base: a row's bar while it shows the
27// context, not its task's progress, in the terminal and on the desktop alike.
28export const dimColorOf = (role: Role): string => hex(blend(rgb(ROLE_COLOR[role]), rgb(M.base), 0.4))
29
30// Anything other than a listed role becomes '', since callers index the role tables with it.
31export const roleName = (v: string): Role | '' => {
32 const r = v.trim().toLowerCase()
33 return isRole(r) ? r : ''
34}
35
36// Matched against each word of the type, whole: `planetscale-helper` plans nothing.
37// Rows are tried top to bottom, so Impeccable's finish reviewer lands on reviewer, not designer.
38const BY_TYPE: [RegExp, Role][] = [
39 [/^(review(er)?|critic|audit(or)?|verif(y|ier))$/, 'reviewer'],
40 [/^(impeccable|design(er)?|asset)$/, 'designer'],
41 [/^(explorer?|research(er)?|search(er)?|scout)$/, 'scout'],
42 [/^(plan(ner)?|architect)$/, 'planner'],
43 [/^(tests?|tester|regress|runner)$/, 'runner'],
44 [/^fork$/, 'fork'],
45]
46
47// Matched against the description's first word alone: in "Fix failing tests" the
48// work is fixing, and in "Address review comments" it is addressing.
49const BY_VERB: [RegExp, Role][] = [
50 [/^(review|audit|critiqu|verif|inspect|assess|validat)|^check(s|ed|ing)?$/, 'reviewer'],
51 [/^(design|redesign|polish|restyl|styl|sketch|mock|typeset|coloriz|animat)/, 'designer'],
52 [/^(explor|research|locat|investigat|survey|discover|identif|analy[sz])|^(find|search|trac|map|read|look|list)(e|s|es|ed|ped|ing|ping)?$/, 'scout'],
53 [/^(architect|outlin|propos)|^(plan|spec|scop)(e|s|es|ned|ning|ing|ify)?$/, 'planner'],
54 [/^(regress|lint|typecheck|benchmark|execut)|^(run|test)(s|ning|ed|ing)?$/, 'runner'],
55]
56
57// A general-purpose agent: its type names no trade of its own.
58export const isGeneric = (type: string): boolean => /^(general-purpose|claude)$/i.test(type)
59
60const match = (table: [RegExp, Role][], words: string[]): Role | undefined =>
61 table.find(([re]) => words.some(w => re.test(w)))?.[1]
62
63const wordsOf = (s: string): string[] => s.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean)
64
65export const roleOf = (type: string, description = ''): Role => {
66 const byType = match(BY_TYPE, wordsOf(type))
67 if (byType) return byType
68 if (!isGeneric(type)) return 'other'
69 return match(BY_VERB, wordsOf(description).slice(0, 1)) ?? 'builder'
70}
71
72// `impeccable:impeccable-asset-producer` shows as `asset-producer`.
73export const bareType = (type: string): string => {
74 const [plugin, name] = type.includes(':') ? type.split(':', 2) : ['', type]
75 return plugin && name?.startsWith(plugin + '-') ? name.slice(plugin.length + 1) : (name ?? type)
76}
77hooks/glyphs.ts 18 lines1import type { PluginOptions } from 'claude-code'
2
3// The terminal glyphs the `glyphs` option picks between: `nerd` for a Nerd Font (its
4// powerline ends and chevrons, and the band crab's Unicode 16 octants), `plain` for
5// a font without them. The crab's own frames are term-crab.ts's, by the same key.
6export type Glyphs = 'nerd' | 'plain'
7export const glyphsOf = (options: PluginOptions): Glyphs => (options.glyphs === 'plain' ? 'plain' : 'nerd')
8
9// The bar's ends, left then right: rounded from the powerline set, or half blocks.
10export const CAPS = { nerd: [0xe0b6, 0xe0b4], plain: [0x2590, 0x258c] } as const satisfies Record<Glyphs, readonly number[]>
11
12// The Finished heading's caret, closed then open: a Nerd Font's chevrons sit mid-line
13// in the heading's own weight, where ▸ ▾ ride high. The desktop's font has no Nerd
14// glyphs, so it always takes the triangles. CARETS is exported for the tests alone;
15// the pane draws with `caretOf`.
16export const CARETS = { nerd: ['\ueab6', '\ueab4'], plain: ['▸', '▾'] } as const satisfies Record<Glyphs, readonly string[]>
17export const caretOf = (glyphs: Glyphs, isCollapsed: boolean): string => CARETS[glyphs][isCollapsed ? 0 : 1]
18hooks/surface.ts 6 lines1// Where the band and the pane draw SVG: every surface but the terminal has an
2// `Svg` (the terminal's table is completed with one that draws nothing, so the
3// surface is tested too). One rule for both, so vscode and mobile look like the
4// desktop, never half like the terminal.
5export const svgSurface = <U extends object>(surface: string, ui: U): ui is Extract<U, { Svg: unknown }> => surface !== 'terminal' && 'Svg' in ui
6