SLOPSHOPPER

crab-crew

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…

newpanebandguardcommandtool
v0.1.0no licenseupdated 2026-10-14AllarKalina/crab-crew
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · crab-crew
│ ┃ Crew ✕ › fix the failing auth test and add an audit log call │ ┃ Crew │ ┃ ⏺ Read(src/auth.ts) │ ┃ Cost Tokens Time ⎿ Read 6 lines │ ┃ ≈$0.00 0 0:00 ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ No subagents yet. ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ › /crew │ ┃ ⎿ crab-crew: Crew panel opened. │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Crew
Crew Collapse Cost Tokens Time ≈$0.00 0 0:00 No subagents yet.
README

crab-crew

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 and the panel

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.

CommandEffect
/crewtoggle the panel (the ×N count on the strip does the same)
/crew openbring the panel to the front and give it the keyboard
/crew barshow the strip again after closing it with ✕

Terminal

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.

Desktop, VS Code and mobile

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.

Roles

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.

RoleOutfitPicked for
designerberet and brushImpeccable asset, documenter and manual-edit agents; tasks starting with design, polish, restyle, sketch, mock
reviewerdetectiveImpeccable finish reviewer; types with review, audit, verify; tasks starting with review, audit, verify, check, inspect
scoutpirateExplore; tasks starting with find, research, investigate, search, read, list
plannerhard hatPlan; tasks starting with plan, spec, scope, outline
runnerracertypes with test, regress, runner; tasks starting with run, test, regress, lint
builderchefany other general-purpose task
forkastronauta fork of the main session
otherplain crabany other named agent

Tools

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

Settings

KeyValuesMeaning
autoOpensession (default), flow, offopen the panel once per session (first subagent or plan), at every new flow, or never
stepReportson (default), offappend a line to built-in subagent briefs asking for step calls; plugin agents are left untouched
glyphsnerd (default), plainNerd Font pill caps, chevrons and an octant crab, or half blocks, ▸ ▾ and an ASCII crab

Development

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.

Source 23 files
hooks/register.tsx 256 lines
1import { 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}
256
hooks/band.tsx 182 lines
1import { 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}
182
hooks/defaults.ts 7 lines
1import 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 }
7
hooks/flow.ts 165 lines
1import 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>
165
hooks/pane.tsx 274 lines
1import { 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}
274
hooks/runs.ts 128 lines
1import 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}
128
hooks/term-crab.ts 95 lines
1import 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
95
hooks/tools.ts 84 lines
1import 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.`
84
hooks/format.ts 144 lines
1// 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> = { '<': '&lt;', '>': '&gt;', '&': '&amp;', '"': '&quot;' }
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}
144
hooks/roles.ts 77 lines
1import 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}
77
hooks/glyphs.ts 18 lines
1import 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]
18
hooks/surface.ts 6 lines
1// 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