Spotter: a Claude Code dashboard by Shawn White. It watches the work and counts the steps. A progress bar above the prompt, a row per helper agent with a pixel…

Your Claude Code helpers, as pixel Pals that hatch, grow and get knocked out.
Spotter is a mod for Claude Code. It watches the work and counts the steps: a progress bar for the job, a Pal for every helper agent, and a headline for every reply. It works in the desktop app and in the terminal.

claude plugin install spotter --marketplace swhite1122/spotter-pals
Start a new session and it is on. Nothing to set up.
Needs Claude Code 2.1.287 or later (2.1.286 in the desktop app). Built and tested on 2.1.296.
Real screenshots from a live session, with the chat covered up. The side panel is Spotter; the bar above the prompt is Spotter too.

<img src="art/panel-growing.png" width="46%" alt="The panel early in a job: one Pal finished, one still an egg, four growing"> <img src="art/panel-done.png" width="46%" alt="The same panel at the end: five Pals at four stars and one knocked out">
Left: half a minute in, with one helper still an egg. Right: the same job finished.
When Claude sends a helper agent off to do something, Spotter gives it a Pal picked by the job:
| Pal | Job | Shows up when a helper is… |
|---|---|---|
| Vixy | scouting | searching, reading, exploring the code |
| Depresso | checking | reviewing, testing, auditing |
| Anubis | building | writing code, fixing, wiring things up |
| Lifmunk | writing | drafting docs, notes and design |
| Hoocrates | planning | planning, scoping, architecture |
| Foxparks | quick job | renames, tidying, anything fast |


On any job of three or more steps Claude reports its plan, and Spotter draws it above the prompt: the job, the step under way, how many are left, and how long it took at the end. Helpers can report their own steps too, and each one gets its own count.
No side panel needed. Helpers show up as lines above the prompt while they run, then clear:
● Ship the release Running the tests ████████░░░░░░░░ Steps 3/6 50% 🐺 🐾×3 ✕ ◐ 🦊 ★★ Map the call sites ▰▰▰▰▱▱ 3/4 · Reading hooks Sonnet 5.5 0:42 ◐ 🐨 ★ Review the parser ▰▰▱▱▱▱ 1/4 · Running tests Sonnet 5.5 0:31 ◐ 🥚 Draft the release note ▱▱▱▱▱▱ 0/3 · Starting Haiku 5.5 0:04
Each Pal has an emoji there: 🥚 for the egg, ✨ for Lucky, 💀 for knocked out. /spotter opens the full panel when you want it.
/spotter open the panel /spotter close close it /spotter reset clear its helpers and counters
Two settings, in /config: openOnStart (open the panel by itself in the desktop app) and motion (set it to off to keep the Pals still).
progress, to report steps. That is how the bar gets its numbers, and it costs a few tokens per update.Spotter began as a fork of Flightdeck by Stephen Casella (MIT, see LICENSE). The progress bar's look is adapted from savvy-progress by johnnyvizz (MIT).
The Pals are unofficial fan art: pixel drawings made for this mod of creatures from Palworld. Palworld and its Pals belong to Pocketpair, Inc. Spotter is not affiliated with or endorsed by Pocketpair.
Made by Shawn White. MIT licence.
hooks/register.tsx 745 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderChildren } from 'claude-code'
3
4import type { AgentCard, Main, Plan, Receipt, Turn, View } from '../types'
5import { ACCENT, BAR_HEIGHT, DONE, barSvg } from './bar'
6import {
7 C,
8 PALS,
9 DEFAULT_MAIN,
10 DEFAULT_TURN,
11 DEFAULT_VIEW,
12 EDIT_TOOLS,
13 afterCall,
14 agentRatio,
15 agentSteps,
16 applyStep,
17 cacheLabel,
18 cacheTtlOf,
19 cardTitle,
20 palEmoji,
21 palLook,
22 palOf,
23 palWords,
24 describeInput,
25 fmtDuration,
26 fmtTimer,
27 fmtUsd,
28 kTokens,
29 lanes,
30 limitLabel,
31 listOf,
32 mergeAgentSteps,
33 mergePlan,
34 normalize,
35 normalizeCard,
36 normalizePlan,
37 normalizeReceipt,
38 noteTool,
39 parseConfig,
40 planBar,
41 planHype,
42 planLabel,
43 planLook,
44 planRatio,
45 plural,
46 prettyModel,
47 receiptHeadline,
48 receiptOf,
49 shorten,
50 titleLines,
51} from './core'
52import type { Config } from './core'
53import { PAL_GRID, palSvg } from './pals'
54
55const PANE = 'spotter'
56const TITLE = 'Spotter'
57const PANE_COLUMNS = 48
58const PROGRESS_TOOL = 'mcp__spotter__progress'
59
60// ---------------------------------------------------------------- state
61
62const main = atom({ plugin: 'spotter', key: 'main' } as const, DEFAULT_MAIN)
63const agents = atom({ plugin: 'spotter', key: 'agents' } as const, [])
64const turn = atom({ plugin: 'spotter', key: 'turn' } as const, DEFAULT_TURN)
65const receipt = atom({ plugin: 'spotter', key: 'receipt' } as const, null)
66const view = atom({ plugin: 'spotter', key: 'view' } as const, DEFAULT_VIEW)
67const plan = atom({ plugin: 'spotter', key: 'plan' } as const, null)
68
69// Every read goes through these, so a value with a field missing still reads.
70async function getMain($: EngineInterface): Promise<Main> {
71 return normalize(DEFAULT_MAIN, await read($, main))
72}
73async function getCards($: EngineInterface): Promise<AgentCard[]> {
74 return listOf<unknown>(await read($, agents)).map(normalizeCard)
75}
76async function getTurn($: EngineInterface): Promise<Turn> {
77 return normalize(DEFAULT_TURN, await read($, turn))
78}
79async function getView($: EngineInterface): Promise<View> {
80 return normalize(DEFAULT_VIEW, await read($, view))
81}
82async function getPlan($: EngineInterface): Promise<Plan | null> {
83 return normalizePlan(await read($, plan))
84}
85async function getReceipt($: EngineInterface): Promise<Receipt | null> {
86 return normalizeReceipt(await read($, receipt))
87}
88
89async function openPane($: EngineInterface) {
90 // columns apply when docked beside the transcript, rows when seated inline above the prompt.
91 return $.ui.open({ id: PANE, title: TITLE, columns: PANE_COLUMNS, rows: 8 })
92}
93
94async function resetAll($: EngineInterface) {
95 await update($, main, () => DEFAULT_MAIN)
96 await update($, agents, () => [])
97 await update($, turn, () => DEFAULT_TURN)
98 await update($, receipt, () => null)
99 await update($, view, () => DEFAULT_VIEW)
100 await update($, plan, () => null)
101}
102
103/** One helper's card changed in place; a no-op when there is no card with that id. */
104async function updateCard($: EngineInterface, id: string, change: (c: AgentCard) => AgentCard) {
105 await update($, agents, list => listOf<unknown>(list).map(normalizeCard).map(c => (c.id === id ? change(c) : c)))
106}
107
108/** The session's cost read fresh: the receipt subtracts two of these. */
109async function costNow($: EngineInterface): Promise<number | null> {
110 const u = await $.session.usage().catch(() => null)
111 return u?.cost?.usd ?? null
112}
113
114const statusColor = (c: AgentCard) => (c.status === 'failed' ? C.warn : c.status === 'done' ? C.good : C.agent)
115/**
116 * A clock for one surface: it ticks by itself where the surface runs client modules, and is a
117 * fixed reading where it does not. A start time of 0 is unknown: no clock, not decades.
118 */
119const clockOf = (els: ReturnType<EngineInterface['ui']['resolve']>, now: number) => (key: string, since: number, endAt: number | null, color: string) =>
120 since <= 0 ? (
121 <els.Text color={color}>—</els.Text>
122 ) : 'Client' in els ? (
123 <els.Client key={key} module="./elapsed.tsx" props={{ since, now, endAt, color }} />
124 ) : (
125 <els.Text color={color}>{fmtTimer((endAt ?? now) - since)}</els.Text>
126 )
127
128const toneColor = (tone: 'good' | 'bad' | 'plain') => (tone === 'good' ? C.good : tone === 'bad' ? C.warn : C.text)
129/** The last reply's headline with its tick or cross in front. */
130const headlineText = (r: Receipt) => `${r.reason === 'answer' ? '✓' : '✗'} ${receiptHeadline(r).text}`
131const glyph = (c: AgentCard) => (c.status === 'running' ? '◐' : c.status === 'done' ? '✓' : c.status === 'failed' ? '✗' : '■')
132
133// ---------------------------------------------------------------- hooks
134
135export const register: Register = (on, options) => {
136 const cfg = parseConfig(options)
137
138 on('session.start', async ($, e, next) => {
139 await $.command.register({
140 name: 'spotter',
141 description: 'Spotter, the live agent dashboard: open, close or reset',
142 argumentHint: '[open|close|reset]',
143 })
144 // The bar above the prompt draws what Claude reports here. Listed from the start, not behind
145 // tool search: a tool Claude has to look up first is one it never reaches for.
146 await $.tool
147 .register({
148 name: 'progress',
149 description:
150 'Shows the person a progress bar above the prompt. Use it when a job has 3 or more distinct steps: ' +
151 'once at the start with `title` (2 to 4 words) and `total` (the number of steps), again with `done` each time a step is finished, ' +
152 'and once with `finished: true` when the job is over. `step` names the step now under way. Fields left out keep their value. ' +
153 'Skip it for short jobs. A helper agent with 3 or more steps of its own may call it the same way: its count shows on its own card. ' +
154 'It costs a round trip when sent alone, so send it beside other tool calls: the start beside the first step\'s first call, ' +
155 'a finished step beside the next step\'s first call, and `finished: true` beside the last step\'s last call.',
156 inputSchema: {
157 type: 'object',
158 properties: {
159 title: { type: 'string', description: 'Short name of the job, 2 to 4 words.' },
160 total: { type: 'integer', minimum: 0, description: 'How many steps the job has.' },
161 done: { type: 'integer', minimum: 0, description: 'How many steps are finished.' },
162 step: { type: 'string', description: 'The step now under way, 2 to 5 words.' },
163 finished: { type: 'boolean', description: 'True once the whole job is over.' },
164 },
165 },
166 isDeferred: false,
167 })
168 .catch(() => undefined)
169 // The terminal gets the strip above the prompt; its side panel opens on /spotter only.
170 if (cfg.openOnStart && e.surface !== 'terminal') void openPane($).catch(() => undefined)
171 return next(e)
172 })
173
174 on('session.end', async ($, e, next) => {
175 if (e.reason === 'clear') {
176 await resetAll($)
177 }
178 return next(e)
179 })
180
181 on('command.run', { command: 'spotter' }, async ($, e) => {
182 const verb = e.args.trim().split(/\s+/)[0] || 'open'
183 if (verb === 'close') {
184 await $.ui.close({ id: PANE })
185 return { text: 'Spotter closed.' }
186 }
187 if (verb === 'reset') {
188 await resetAll($)
189 return { text: 'Spotter reset.' }
190 }
191 const opened = await openPane($)
192 if (!opened.isPlaced) return { text: `Spotter is not shown yet: ${opened.reason}` }
193 return { text: 'Spotter opened. Click an agent for its details, or focus the pane with ctrl+x tab: 1-6 open agents.' }
194 })
195
196 on('turn.start', async ($, e, next) => {
197 const [now, cost] = await Promise.all([$.clock.now(), costNow($)])
198 await update($, turn, () => ({ ...DEFAULT_TURN, startedAt: now, costAtStart: cost }))
199 await update($, main, m => ({ ...normalize(DEFAULT_MAIN, m), isRunning: true }))
200 // A finished bar stays up until the next turn starts.
201 if ((await getPlan($))?.isFinished) await update($, plan, () => null)
202 return next(e)
203 })
204
205 on('turn.step', async function* ($, e, next) {
206 if (!e.agentId) {
207 const result = yield* next(e)
208 // Each request renews the prompt cache: the session box counts down from here.
209 const now = await $.clock.now()
210 await update($, main, m => {
211 const x = normalize(DEFAULT_MAIN, m)
212 return { ...x, lastRequestAt: now, cacheTtlMs: cacheTtlOf(result.usage, x.cacheTtlMs) }
213 })
214 return result
215 }
216 const result = yield* next(e)
217 await updateCard($, e.agentId, c => applyStep(c, { model: e.model, usage: result.usage, stopReason: result.stopReason }))
218 return result
219 })
220
221 // The bar's own tool only draws: allowed without asking.
222 on('tool.check', async (_$, e, next) => (String(e.tool) === PROGRESS_TOOL ? { decision: 'allow' } : next(e)))
223
224 on('tool.call', async ($, e, next) => {
225 // The bar's own tool: answered here, and kept off the cards.
226 if (String(e.tool) === PROGRESS_TOOL) {
227 // A helper agent's call counts on its own card; the bar is the main session's.
228 if (e.agentId) {
229 await updateCard($, e.agentId, c => mergeAgentSteps(c, e))
230 return { result: 'ok' }
231 }
232 const p = mergePlan(await getPlan($), e, await $.clock.now())
233 await update($, plan, () => p)
234 return { result: `ok: ${planLabel(p)}` }
235 }
236 const ran = await next(e)
237 // Starting an agent and its hand-back are not work to count.
238 if (String(e.tool) === 'SubagentHandback' || e.tool === 'Agent') return ran
239 const didRun = ran.deny === undefined
240 const hasFailed = didRun && ran.isError === true
241 const isEdit = !hasFailed && didRun && EDIT_TOOLS.has(e.tool)
242 if (isEdit || (hasFailed && !e.agentId)) {
243 await update($, turn, t => afterCall(normalize(DEFAULT_TURN, t), { inSubagent: Boolean(e.agentId), hasFailed, isEdit }))
244 }
245 if (e.agentId) {
246 const text = shorten(describeInput(e.tool, e), 80)
247 await updateCard($, e.agentId, c => noteTool(c, { text, isError: hasFailed || ran.deny !== undefined }))
248 }
249 return ran
250 })
251
252 on('agent.spawn', async ($, e, next) => {
253 const started = await next(e)
254 if (started.deny !== undefined || !started.agentId) return started
255 const card: AgentCard = {
256 ...normalizeCard({}),
257 id: started.agentId,
258 type: e.name ?? e.subagentType,
259 model: started.model,
260 description: e.description,
261 spawnedAt: await $.clock.now(),
262 }
263 await update($, agents, list => [...listOf<unknown>(list).map(normalizeCard), card].slice(-24))
264 return started
265 })
266
267 on('turn.complete', async ($, e, next) => {
268 const done = await next(e)
269 const id = e.agentId
270 if (!id) {
271 const [t, cards, cost, last] = await Promise.all([getTurn($), getCards($), costNow($), getReceipt($)])
272 const r = receiptOf(t, {
273 durationMs: e.durationMs,
274 agentsSince: cards.filter(c => c.spawnedAt >= t.startedAt).length,
275 costNow: cost,
276 reason: e.reason,
277 streakBefore: last?.streak ?? 0,
278 })
279 await update($, receipt, () => r)
280 await update($, main, m => ({ ...normalize(DEFAULT_MAIN, m), isRunning: false }))
281 return done
282 }
283 const now = await $.clock.now()
284 const status = e.reason === 'answer' ? 'done' : e.reason === 'aborted' ? 'stopped' : 'failed'
285 // An agent that finished has finished its own plan, whatever count it last reported.
286 await updateCard($, id, c => ({ ...c, status, endedAt: now, answer: e.answer.trim().slice(0, 3000), stepDone: status === 'done' && c.stepTotal ? c.stepTotal : c.stepDone }))
287 return done
288 })
289
290 // ---------------------------------------------------------------- drawing
291
292 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
293 const els = $.ui.resolve(e)
294 const { Box, Text, Button } = els
295 const hasClient = 'Client' in els
296 const [m, cards, t, r, v, now, u] = await Promise.all([getMain($), getCards($), getTurn($), getReceipt($), getView($), $.clock.now(), $.session.usage().catch(() => null)])
297 const W = Math.max(32, e.props.bodyColumns)
298 // Off the terminal the font is not fixed-pitch: ━ ▰ draw about two cells wide, so rows
299 // built from them hold half as many and clip at their box.
300 const wide = (cells: number) => (e.surface === 'terminal' ? cells : Math.floor(cells / 2))
301 const viewed = e.props.view?.agentId ?? null
302 const running = cards.filter(c => c.status === 'running')
303 const hasReceipt = m.isRunning || r !== null
304 // The session box: how full the context is, the plan's limits, and how long the cache has left.
305 const pct = u?.context.percent
306 const limits = u?.rateLimits ?? []
307 const hasSession = pct !== undefined || limits.length > 0 || m.lastRequestAt > 0 || hasReceipt
308 const nothingYet = cards.length === 0 && !hasSession ? <Text color={C.faint}>Nothing to show yet.</Text> : null
309
310 const clock = clockOf(els, now)
311
312 // ---- agents: full rows in the desktop app, one line each in the terminal and when seated inline
313 const statusWords = (c: AgentCard) => (c.status === 'done' ? 'finished' : c.status)
314 const expandOnPress = (id: string) => () =>
315 update($, view, x => ({ ...normalize(DEFAULT_VIEW, x), expanded: normalize(DEFAULT_VIEW, x).expanded === id ? null : id }))
316
317 // Each agent's Pal, picked by its job: a small drawing where the surface draws them (it hops
318 // while the agent works), an emoji in the terminal. Only a drawing big enough to show it grows.
319 const spriteOf = (c: AgentCard, scale: number, grows = false) => {
320 const k = palOf(c)
321 return e.surface !== 'terminal' && 'Svg' in els ? (
322 <els.Svg
323 source={palSvg(k, palLook(c), scale, c.status === 'running', cfg.motion, !grows)}
324 alt={`${palWords(k, palLook(c)).name}${palLook(c).isDown ? '' : `, ${palLook(c).stars} of 4 stars`}`}
325 width={Math.ceil(PAL_GRID.w * scale)}
326 height={Math.ceil(PAL_GRID.h * scale)}
327 />
328 ) : (
329 <Text>{palEmoji(k, palLook(c))}</Text>
330 )
331 }
332 const palLine = (c: AgentCard, withJob: boolean) => {
333 const look = palLook(c)
334 const words = palWords(palOf(c), look)
335 return (
336 <Text wrap="truncate">
337 <Text color={look.isDown ? C.warn : PALS[palOf(c)].color} bold>
338 {words.name}
339 </Text>
340 {withJob && words.job ? <Text dimColor>{` · ${words.job}`}</Text> : null}
341 {!look.isDown && look.stars > 0 ? <Text color={C.amber}>{` ${'★'.repeat(look.stars)}`}</Text> : null}
342 </Text>
343 )
344 }
345 // The agent's own plan as a small gauge when it reports one; how far it has gone otherwise.
346 const stepsRow = (c: AgentCard, w: number) => {
347 const ratio = agentRatio(c)
348 if (ratio === null) {
349 return (
350 <Text dimColor wrap="truncate">
351 {c.steps > 0 ? `${plural(c.steps, 'step')} so far · memory ${kTokens(c.ctx)}` : 'starting…'}
352 </Text>
353 )
354 }
355 const cells = wide(Math.max(4, Math.min(10, w - 12)))
356 const full = Math.round(cells * ratio)
357 return (
358 <Text wrap="truncate">
359 <Text color={PALS[palOf(c)].color}>{'▰'.repeat(full)}</Text>
360 <Text color={C.faint}>{'▱'.repeat(cells - full)}</Text>
361 <Text bold>{` step ${agentSteps(c)}`}</Text>
362 {c.stepNote ? <Text dimColor>{` · ${c.stepNote}`}</Text> : null}
363 </Text>
364 )
365 }
366
367 const isInline = e.props.placement === 'inline'
368 const agentsPanel = (w: number) => {
369 const header = (
370 <Box justifyContent="space-between" width={w}>
371 <Text bold>{`agents · ${running.length} running · ${cards.length} total`}</Text>
372 {w >= 62 ? (
373 <Text color={C.faint}>{e.surface === 'terminal' ? `1-${Math.min(cards.length, 6)} for details` : 'click a name for details'}</Text>
374 ) : null}
375 </Box>
376 )
377 // Where the Pals are drawn (not the terminal) each agent gets a full-width row of its own:
378 // a Pal big enough to see it grow, the whole name, its job and its steps.
379 if (!isInline && e.surface !== 'terminal' && 'Svg' in els) {
380 const shown = cards.slice(-6)
381 return (
382 <Box flexDirection="column" width={w}>
383 {header}
384 {cards.length > shown.length ? <Text color={C.faint}>{`+${cards.length - shown.length} earlier`}</Text> : null}
385 {shown.map((c, i) => {
386 const isViewed = viewed === c.id
387 // The whole name, on two lines when one cannot hold it. The app's font is narrower
388 // than a terminal cell, so a line holds about a third more letters than cells.
389 const room = Math.floor((w - 14) * 1.3)
390 const [first, rest] = titleLines(cardTitle(c), room - 4, room)
391 return (
392 <Box
393 borderStyle={isViewed ? 'double' : 'bold'}
394 borderColor={c.lastStop === 'max_tokens' || palLook(c).isDown ? C.warn : PALS[palOf(c)].color}
395 width={w}
396 paddingX={1}
397 columnGap={2}
398 alignItems="center"
399 >
400 <Box flexShrink={0}>{spriteOf(c, w >= 40 ? 1.6 : 1, true)}</Box>
401 <Box flexDirection="column" flexGrow={1}>
402 <Button key={`card-${c.id}`} plain hotkey={String(i + 1)} label={first} onPress={expandOnPress(c.id)} />
403 {rest ? <Text wrap="truncate">{rest}</Text> : null}
404 <Box justifyContent="space-between">
405 {palLine(c, true)}
406 <Box flexShrink={0}>
407 <Text color={c.lastStop === 'max_tokens' ? C.warn : statusColor(c)}>{`${glyph(c)} `}</Text>
408 {clock(`stack-clock-${c.id}`, c.spawnedAt, c.endedAt, C.dim)}
409 </Box>
410 </Box>
411 {/* Its steps on the left, the model it runs on at the right. */}
412 <Box justifyContent="space-between">
413 {stepsRow({ ...c, stepNote: '' }, w - 14)}
414 {c.model ? (
415 <Box flexShrink={0} marginLeft={1}>
416 <Text color={C.dim}>{prettyModel(c.model)}</Text>
417 </Box>
418 ) : null}
419 </Box>
420 {c.stepNote && agentRatio(c) !== null ? (
421 <Text dimColor wrap="truncate">
422 {c.stepNote}
423 </Text>
424 ) : null}
425 </Box>
426 </Box>
427 )
428 })}
429 </Box>
430 )
431 }
432 const shown = cards.slice(-6)
433 const nameW = Math.max(12, Math.min(30, w - 40))
434 // Beside the name: the Pal, and from 50 columns the agent's own step count.
435 const stepsW = w >= 50 ? 6 : 0
436 const barW = Math.max(4, w - nameW - 13 - stepsW)
437 const geo = lanes(shown, now, wide(barW))
438 return (
439 <Box flexDirection="column" width={w}>
440 {header}
441 {cards.length > shown.length ? <Text color={C.faint}>{`+${cards.length - shown.length} earlier`}</Text> : null}
442 {shown.map((c, i) => {
443 const gm = geo[i]
444 const isViewed = viewed === c.id
445 return (
446 <Box width={w}>
447 <Text color={statusColor(c)} bold={isViewed}>{`${isViewed ? '▶' : glyph(c)} `}</Text>
448 <Box flexShrink={0} marginRight={1}>
449 {spriteOf(c, 0.5)}
450 </Box>
451 <Box width={nameW}>
452 <Button key={`card-${c.id}`} plain hotkey={String(i + 1)} label={shorten(cardTitle(c), nameW - 3)} onPress={expandOnPress(c.id)} />
453 </Box>
454 <Box flexGrow={1} overflow="hidden">
455 <Text color={C.faint}>{' ' + '·'.repeat(gm?.before ?? 0)}</Text>
456 <Text color={statusColor(c)}>{'━'.repeat(gm?.bar ?? 1)}</Text>
457 <Text color={C.faint}>{'·'.repeat(gm?.after ?? 0) + ' '}</Text>
458 </Box>
459 {stepsW > 0 ? (
460 <Box width={stepsW} flexShrink={0}>
461 <Text color={PALS[palOf(c)].color} wrap="truncate">
462 {agentSteps(c) ?? ''}
463 </Text>
464 </Box>
465 ) : null}
466 <Box flexShrink={0}>{clock(`lane-clock-${c.id}`, c.spawnedAt, c.endedAt, C.dim)}</Box>
467 </Box>
468 )
469 })}
470 </Box>
471 )
472 }
473
474 const expandedCard = cards.find(c => c.id === v.expanded)
475 const expandedPanel = (w: number) =>
476 expandedCard ? (
477 <Box flexDirection="column" borderStyle="single" borderColor={C.agent} paddingX={1} width={w}>
478 <Box columnGap={1} alignItems="center">
479 <Box flexShrink={0}>{spriteOf(expandedCard, 0.75)}</Box>
480 {palLine(expandedCard, true)}
481 </Box>
482 <Text bold wrap="wrap">
483 {expandedCard.description || expandedCard.type}
484 </Text>
485 <Text dimColor wrap="truncate">
486 {`${expandedCard.type} agent · ${prettyModel(expandedCard.model)} · ${statusWords(expandedCard)}${expandedCard.spawnedAt > 0 ? ` ${expandedCard.endedAt ? 'in' : 'for'} ${fmtDuration((expandedCard.endedAt ?? now) - expandedCard.spawnedAt)}` : ''} · ${plural(expandedCard.steps, 'step')} so far`}
487 </Text>
488 {agentRatio(expandedCard) !== null ? stepsRow(expandedCard, w - 4) : null}
489 <Text bold>{expandedCard.tools.length >= 12 ? 'What it did (latest 12):' : 'What it did:'}</Text>
490 {expandedCard.tools.length === 0 ? <Text color={C.faint}>nothing yet</Text> : null}
491 {expandedCard.tools.map(n => (
492 <Text color={n.isError ? C.warn : C.text} wrap="wrap">
493 {`${n.isError ? '✗ failed ·' : '·'} ${n.text}`}
494 </Text>
495 ))}
496 <Text bold>What it reported:</Text>
497 {expandedCard.answer ? null : <Text color={C.faint}>{expandedCard.status === 'running' ? 'still working' : 'nothing'}</Text>}
498 {expandedCard.answer
499 .split('\n')
500 .filter(l => l.trim())
501 .slice(0, 60)
502 .map(l => (
503 <Text dimColor wrap="wrap">
504 {l.trimEnd()}
505 </Text>
506 ))}
507 {expandedCard.answer.length >= 3000 ? <Text color={C.faint}>(cut at 3,000 characters)</Text> : null}
508 <Text color={C.faint}>{e.surface === 'terminal' ? 'press its number again to close' : 'click its name again to close'}</Text>
509 </Box>
510 ) : null
511
512 const row = (label: string, value: RenderChildren) => (
513 <Box>
514 <Box width={e.surface === 'terminal' ? 14 : 10} flexShrink={0}>
515 <Text dimColor>{label}</Text>
516 </Box>
517 {value}
518 </Box>
519 )
520 // The reply under way, or the last one: only the counts that are not zero.
521 const helpersNow = t.startedAt > 0 ? cards.filter(c => c.spawnedAt >= t.startedAt).length : 0
522 const counts = (helpers: number, edits: number, errors: number) => {
523 const quiet = [helpers > 0 ? plural(helpers, 'helper') : '', edits > 0 ? plural(edits, 'file change') : ''].filter(Boolean).join(' · ')
524 if (!quiet && errors === 0) return null
525 return row(
526 '',
527 <Text wrap="truncate">
528 <Text dimColor>{quiet}</Text>
529 {errors > 0 ? <Text color={C.warn}>{`${quiet ? ' · ' : ''}${plural(errors, 'error')}`}</Text> : null}
530 </Text>,
531 )
532 }
533 // One box at the top: how full the context is, the plan's limits, how long the cache has
534 // left, and the reply under way or the last one.
535 const sessionPanel = (w: number) => {
536 const cells = wide(10)
537 const full = Math.round((cells * Math.min(100, pct ?? 0)) / 100)
538 const left = u?.context.tokens !== undefined ? Math.max(0, u.context.window - u.context.tokens) : null
539 const cache = cacheLabel(m.lastRequestAt + m.cacheTtlMs - now)
540 const cacheColor = { plain: C.dim, soon: C.amber, cold: C.warn }
541 return (
542 <Box flexDirection="column" borderStyle="round" borderColor={C.dim} paddingX={1} width={w}>
543 {pct !== undefined
544 ? row(
545 'Context',
546 <Text wrap="truncate">
547 <Text color={pct >= 70 ? C.warn : pct >= 40 ? C.amber : C.good}>{'▰'.repeat(full)}</Text>
548 <Text color={C.faint}>{'▱'.repeat(cells - full)}</Text>
549 <Text bold>{` ${Math.round(pct)}% used`}</Text>
550 </Text>,
551 )
552 : null}
553 {pct !== undefined && left !== null && u ? row('', <Text dimColor wrap="truncate">{`${kTokens(left)} left of ${kTokens(u.context.window)}`}</Text>) : null}
554 {limits.map(l =>
555 row(
556 limitLabel(l.kind),
557 <Text color={l.percentUsed >= 80 ? C.warn : l.percentUsed >= 50 ? C.amber : C.text} wrap="truncate">{`${Math.round(l.percentUsed)}% used`}</Text>,
558 ),
559 )}
560 {m.lastRequestAt > 0
561 ? row(
562 'Cache',
563 hasClient ? (
564 <els.Client key="cache-clock" module="./cache.tsx" props={{ expiresAt: m.lastRequestAt + m.cacheTtlMs, now, ...cacheColor }} />
565 ) : (
566 <Text color={cacheColor[cache.tone]} wrap="truncate">
567 {cache.text}
568 </Text>
569 ),
570 )
571 : null}
572 {m.isRunning
573 ? row(
574 'This reply',
575 <Box>
576 <Text color={C.main}>{'◐ working · '}</Text>
577 <Box flexShrink={0}>{clock('turn-clock', t.startedAt, null, C.main)}</Box>
578 </Box>,
579 )
580 : r
581 ? row('Last reply', <Text wrap="truncate">{`${fmtDuration(r.durationMs)}${r.costDelta !== null ? ` · ${fmtUsd(r.costDelta)}` : ''}`}</Text>)
582 : null}
583 {m.isRunning ? counts(helpersNow, t.edits, t.errors) : r ? counts(r.agents, r.edits, r.errors) : null}
584 {!m.isRunning && r ? (
585 <Text color={toneColor(receiptHeadline(r).tone)} bold wrap="wrap">
586 {headlineText(r)}
587 </Text>
588 ) : null}
589 {!m.isRunning && r && r.streak >= 2 ? <Text color={C.amber}>{`🔥 ${r.streak} clean replies in a row`}</Text> : null}
590 </Box>
591 )
592 }
593
594 // Seated inline above the prompt the pane has 8 rows: the agents' lines alone fill them.
595 if (isInline) {
596 return (
597 <Box flexDirection="column" width={W}>
598 {cards.length > 0 ? agentsPanel(W) : <Text color={C.faint}>No helpers yet.</Text>}
599 </Box>
600 )
601 }
602
603 return (
604 <Box flexDirection="column" width={W}>
605 {hasSession ? sessionPanel(W) : null}
606 {cards.length > 0 ? agentsPanel(W) : null}
607 {expandedPanel(W)}
608 {nothingYet}
609 </Box>
610 )
611 })
612
613 // Above the prompt: the progress bar from the plan Claude reported, and in the terminal the
614 // strip that stands in for the side panel: a line per helper while any runs, else the last reply.
615 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
616 if (e.props.hasSurvey) return next(e)
617 const isTerminal = e.surface === 'terminal'
618 const [p, cards, m, r, now] = await Promise.all([getPlan($), getCards($), getMain($), getReceipt($), $.clock.now()])
619 const running = cards.filter(c => c.status === 'running')
620 // While any helper runs, every helper of that batch keeps its line, the finished ones ticked.
621 const since = Math.min(...running.map(c => c.spawnedAt))
622 const batch = isTerminal && running.length > 0 ? cards.filter(c => c.status === 'running' || c.spawnedAt >= since) : []
623 const head = isTerminal && batch.length === 0 && !m.isRunning && r ? receiptHeadline(r) : null
624 if (!p && batch.length === 0 && !head) return next(e)
625 // Another mod's band (the radio, say) keeps its row under this one.
626 const beneath = await next(e)
627 const els = $.ui.resolve(e)
628 const { Box, Text, Button } = els
629 const cols = e.props.bodyColumns || 80
630
631 const helperLine = (c: AgentCard) => {
632 const k = palOf(c)
633 const look = palLook(c)
634 const ratio = agentRatio(c)
635 const full = Math.round(6 * (ratio ?? 0))
636 return (
637 <Box width={cols}>
638 <Text color={statusColor(c)}>{`${glyph(c)} `}</Text>
639 {/* The Pal and its stars sit in fixed slots, so the names line up as the Pals grow. */}
640 <Box width={5} flexShrink={0}>
641 <Text>{palEmoji(k, look)}</Text>
642 </Box>
643 <Box width={5} flexShrink={0}>
644 <Text color={C.amber}>{look.isDown ? '' : '★'.repeat(look.stars)}</Text>
645 </Box>
646 <Box width={Math.max(12, Math.min(44, cols - 48))} flexShrink={0}>
647 <Text wrap="truncate">{cardTitle(c)}</Text>
648 </Box>
649 <Box flexGrow={1} overflow="hidden">
650 {ratio === null ? (
651 <Text dimColor wrap="truncate">{c.steps > 0 ? ` ${plural(c.steps, 'step')} so far` : ' starting…'}</Text>
652 ) : (
653 <Text wrap="truncate">
654 <Text color={PALS[k].color}>{` ${'▰'.repeat(full)}`}</Text>
655 <Text color={C.faint}>{'▱'.repeat(6 - full)}</Text>
656 <Text bold>{` ${agentSteps(c)}`}</Text>
657 {c.stepNote && cols >= 80 ? <Text dimColor>{` · ${c.stepNote}`}</Text> : null}
658 </Text>
659 )}
660 </Box>
661 {c.model && cols >= 90 ? (
662 <Box width={12} flexShrink={0}>
663 <Text color={C.dim} wrap="truncate">
664 {prettyModel(c.model)}
665 </Text>
666 </Box>
667 ) : null}
668 <Box flexShrink={0}>{clockOf(els, now)(`strip-clock-${c.id}`, c.spawnedAt, c.endedAt, C.dim)}</Box>
669 </Box>
670 )
671 }
672 const shown = batch.slice(-5)
673 const strip = (
674 <Box flexDirection="column">
675 {batch.length > shown.length ? <Text color={C.faint}>{`+${batch.length - shown.length} more helpers · /spotter for all`}</Text> : null}
676 {shown.map(helperLine)}
677 {head && r ? (
678 <Text wrap="truncate">
679 <Text color={toneColor(head.tone)}>{headlineText(r)}</Text>
680 <Text dimColor>
681 {`${head.text.includes(fmtDuration(r.durationMs)) ? '' : ` · ${fmtDuration(r.durationMs)}`}${r.costDelta !== null ? ` · ${fmtUsd(r.costDelta)}` : ''}`}
682 </Text>
683 {r.streak >= 2 ? <Text color={C.amber}>{` · 🔥 ${r.streak} in a row`}</Text> : null}
684 </Text>
685 ) : null}
686 </Box>
687 )
688 if (!p) {
689 return (
690 <Box flexDirection="column">
691 {strip}
692 {beneath}
693 </Box>
694 )
695 }
696
697 const color = p.isFinished ? DONE : ACCENT
698 const percent = `${Math.round(planRatio(p) * 100)}%`
699 const crew = cards.length > 0 ? <Button key="bar-agents" plain label={`🐾×${cards.length}`} onPress={() => void openPane($).catch(() => undefined)} /> : null
700 const barPal = palEmoji(palOf({ type: '', description: p.title, model: '' }), planLook(p))
701 const dismiss = <Button key="bar-dismiss" plain label="✕" onPress={() => update($, plan, () => null)} />
702
703 // The terminal's table carries an Svg that draws nothing, so the surface decides, not the table.
704 if (!isTerminal && 'Svg' in els) {
705 // About 8 px to the column; the rest of the row is the two buttons and their gaps.
706 const width = Math.max(180, Math.min(1600, cols * 8 - 96))
707 return (
708 <Box flexDirection="column">
709 <Box flexDirection="row" alignItems="center" gap={1}>
710 <els.Svg source={barSvg(p, width, e.props.isWorking)} alt={`${p.title}: ${planLabel(p)}, ${percent}`} width={width} height={BAR_HEIGHT} />
711 {crew}
712 {dismiss}
713 </Box>
714 {beneath}
715 </Box>
716 )
717 }
718
719 const titleW = Math.max(8, Math.min(30, p.title.length + 2, Math.floor(cols / 3)))
720 return (
721 <Box flexDirection="column">
722 <Box flexDirection="row" gap={2}>
723 <Box width={titleW} flexShrink={0}>
724 <Text color={color}>● </Text>
725 <Text wrap="truncate">{p.title}</Text>
726 </Box>
727 {cols >= 90 ? (
728 <Text color={p.isFinished ? DONE : undefined} dimColor={!p.isFinished} wrap="truncate">
729 {shorten(planHype(p), 24)}
730 </Text>
731 ) : null}
732 <Text color={color}>{planBar(p, Math.max(6, Math.min(40, cols - titleW - (cols >= 90 ? 64 : 38))))}</Text>
733 <Text bold>{planLabel(p)}</Text>
734 <Text dimColor>{percent}</Text>
735 <Text>{barPal}</Text>
736 {crew}
737 {dismiss}
738 </Box>
739 {strip}
740 {beneath}
741 </Box>
742 )
743 })
744}
745hooks/bar.ts 103 lines1// The progress bar's desktop drawing: one SVG row above the prompt. Its look (the colours, the
2// dithered fill and the label pill) is adapted from savvy-progress,
3// https://github.com/JohnnyVizz/claude-kit, MIT License, Copyright (c) 2026 johnnyvizz.
4import type { Plan } from '../types'
5import { palOf, planHype, planLabel, planLook, planRatio } from './core'
6import { PAL_CSS, PAL_GRID, pal } from './pals'
7
8export const ACCENT = '#8f8cf4'
9export const DONE = '#5fbf8f'
10
11export const BAR_HEIGHT = 22
12const FILL_H = 16
13const PAL_W = 26
14const CELL = 3
15const FONT = "-apple-system,BlinkMacSystemFont,'SF Pro Text','Segoe UI',sans-serif"
16
17const xml = (s: string) => s.replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c] ?? c)
18
19// Rough advance of system UI text, in em: good enough to size the title's slot.
20const charEm = (ch: string) => (/[\s.,:;'|!il1()[\]]/.test(ch) ? 0.3 : /[A-Zmw@%]/.test(ch) ? 0.72 : 0.56)
21const textWidth = (s: string, size: number) => [...s].reduce((w, ch) => w + charEm(ch) * size, 0)
22
23const fitText = (s: string, size: number, maxW: number) => {
24 if (textWidth(s, size) <= maxW) return s
25 let out = ''
26 for (const ch of s) {
27 if (textWidth(`${out}${ch}…`, size) > maxW) break
28 out += ch
29 }
30 return `${out}…`
31}
32
33// Deterministic noise, so the dither does not shimmer between redraws.
34const noise = (x: number, y: number) => {
35 const s = Math.sin(x * 12.9898 + y * 78.233) * 43758.5453
36 return s - Math.floor(s)
37}
38
39const CSS = (isFinished: boolean) => `<style>
40.t{fill:#1f1f1f}.m{fill:#8a8a8a}.k{fill:#e4e4e2}.tk{fill:#b4b4b0}
41@media (prefers-color-scheme: dark){.t{fill:#ececec}.m{fill:#9a9a9a}.k{fill:#2c2c2c}.tk{fill:#5a5a5a}}
42.t0,.t1,.t2,.t3{animation:tw ${isFinished ? 3.2 : 2.2}s ease-in-out infinite}
43.t1{animation-duration:${isFinished ? 3.8 : 2.8}s;animation-delay:-.7s}.t2{animation-duration:${isFinished ? 4.4 : 1.9}s;animation-delay:-1.3s}.t3{animation-duration:${isFinished ? 3.5 : 3.3}s;animation-delay:-.4s}
44@keyframes tw{0%,100%{opacity:1}50%{opacity:${isFinished ? 0.8 : 0.3}}}
45@media (prefers-reduced-motion: reduce){.t0,.t1,.t2,.t3{animation:none!important}}
46${PAL_CSS}</style>`
47
48/**
49 * The whole row as one drawing `width` pixels wide: title, the step under way, bar, label pill,
50 * percent and a Pal, picked by the job the title names: an egg at first, Lucky when it is done.
51 */
52export const barSvg = (p: Plan, width: number, isWorking: boolean) => {
53 // The title and the words beside it take what they need, up to 45% of the row; the bar takes the rest.
54 const slot = Math.max(60, width * 0.45)
55 const title = fitText(p.title, 13, Math.min(slot, Math.max(60, width * 0.28)))
56 const subRoom = slot - textWidth(title, 13) - 10
57 const sub = subRoom >= 44 ? fitText(planHype(p), 12, subRoom) : ''
58 const subX = Math.round(16 + textWidth(title, 13) + 8)
59 const barX = Math.round(sub ? subX + textWidth(sub, 12) + 12 : subX + 4)
60 const barW = Math.max(60, width - barX - 46 - PAL_W)
61 const color = p.isFinished ? DONE : ACCENT
62 const fillW = Math.round(barW * planRatio(p))
63
64 // Dithered fill: sparse at the start, dense toward the head.
65 const cols = Math.floor(fillW / CELL)
66 const rows = Math.floor(FILL_H / CELL)
67 const dots: string[] = []
68 for (let c = 0; c < cols; c += 1) {
69 const density = 0.35 + 0.6 * Math.pow(c / Math.max(1, cols), 1.2)
70 for (let r = 0; r < rows; r += 1) {
71 if (noise(c, r) < density) dots.push(`<rect class="t${Math.floor(noise(r, c) * 4)}" x="${c * CELL + 1}" y="${r * CELL + 1}" width="2" height="2"/>`)
72 }
73 }
74
75 // One tick per step still ahead.
76 const ticks: string[] = []
77 for (let i = 1; i < p.total && p.total <= 40; i += 1) {
78 const x = Math.round((barW * i) / p.total)
79 if (x > fillW + 4) ticks.push(`<rect x="${x}" y="${FILL_H / 2 - 4}" width="1.5" height="8" rx="0.75"/>`)
80 }
81
82 const label = planLabel(p)
83 const pillW = Math.round(18 + label.length * 6.6)
84 const pillX = Math.max(0, Math.min(barW - pillW, fillW - pillW))
85 const mid = BAR_HEIGHT / 2
86
87 return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${BAR_HEIGHT}" viewBox="0 0 ${width} ${BAR_HEIGHT}">
88${CSS(p.isFinished)}
89<defs><clipPath id="c"><rect x="0" y="0" width="${barW}" height="${FILL_H}" rx="${FILL_H / 2}"/></clipPath></defs>
90<circle cx="5" cy="${mid}" r="4" fill="${color}"/>
91<text class="t" x="16" y="${mid + 4.5}" font-family="${FONT}" font-size="13" font-weight="600">${xml(title)}</text>
92${sub ? `<text ${p.isFinished ? `fill="${DONE}"` : 'class="m"'} x="${subX}" y="${mid + 4.5}" font-family="${FONT}" font-size="12">${xml(sub)}</text>` : ''}
93<g transform="translate(${barX},${(BAR_HEIGHT - FILL_H) / 2})">
94<rect class="k" width="${barW}" height="${FILL_H}" rx="${FILL_H / 2}"/>
95<g clip-path="url(#c)"><g fill="${color}">${dots.join('')}</g><g class="tk">${ticks.join('')}</g></g>
96<rect x="${pillX}" width="${pillW}" height="${FILL_H}" rx="${FILL_H / 2}" fill="${color}"/>
97<text x="${pillX + pillW / 2}" y="${FILL_H / 2 + 4}" text-anchor="middle" font-family="${FONT}" font-size="11" font-weight="600" fill="#ffffff">${xml(label)}</text>
98</g>
99<text class="m" x="${width - PAL_W - 6}" y="${mid + 4.5}" text-anchor="end" font-family="${FONT}" font-size="12.5" font-variant-numeric="tabular-nums">${Math.round(planRatio(p) * 100)}%</text>
100${pal(width - PAL_W + 2, 0, palOf({ type: '', description: p.title, model: '' }), planLook(p), BAR_HEIGHT / PAL_GRID.h, isWorking && !p.isFinished, true, true)}
101</svg>`
102}
103hooks/core.ts 415 lines1// Pure data: defaults, reducers, formatting and layout math. Nothing here touches `$`, so every
2// behaviour is testable directly (the test kit cannot raise a subagent's tool call inside a
3// call; these functions are what the hooks apply).
4import type { AgentCard, Main, Pal, PalLook, Plan, Receipt, ToolNote, Turn, View } from '../types'
5
6// ---------------------------------------------------------------- defaults
7
8export const DEFAULT_MAIN: Main = { isRunning: false, lastRequestAt: 0, cacheTtlMs: 3_600_000 }
9export const DEFAULT_TURN: Turn = { edits: 0, errors: 0, startedAt: 0, costAtStart: null }
10export const DEFAULT_VIEW: View = { expanded: null }
11
12const isObject = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v)
13
14/** A stored object merged over its defaults, so a value with a field missing still reads. */
15export const normalize = <T extends object>(def: T, stored: unknown): T =>
16 isObject(stored) ? ({ ...def, ...stored } as T) : def
17
18/** A stored list, or empty when what is stored is not a list. */
19export const listOf = <T>(stored: unknown): T[] => (Array.isArray(stored) ? (stored as T[]) : [])
20
21export const normalizeCard = (stored: unknown): AgentCard =>
22 normalize<AgentCard>(
23 {
24 id: '',
25 type: 'agent',
26 model: '',
27 description: '',
28 status: 'running',
29 spawnedAt: 0,
30 endedAt: null,
31 ctx: 0,
32 steps: 0,
33 lastStop: null,
34 tools: [],
35 answer: '',
36 stepDone: null,
37 stepTotal: null,
38 stepNote: '',
39 },
40 stored,
41 )
42
43// ---------------------------------------------------------------- config
44
45export type Config = { motion: boolean; openOnStart: boolean }
46
47/** The plugin's `/config` values, read leniently: anything malformed falls back to the default. */
48export const parseConfig = (o: Readonly<Record<string, unknown>>): Config => ({
49 motion: o.motion !== 'off',
50 openOnStart: o.openOnStart !== false,
51})
52
53/** The person's own theme colours, by key: they follow light, dark and colour-blind themes. */
54export const C = {
55 main: 'claude',
56 agent: 'suggestion',
57 good: 'success',
58 amber: 'warning',
59 warn: 'error',
60 dim: 'inactive',
61 faint: 'subtle',
62 text: 'text',
63} as const
64
65// ---------------------------------------------------------------- formatting
66
67/**
68 * A model id as people say it, from any provider's spelling: `claude-opus-5-5[1m]` → `Opus 5.5 1M`,
69 * `us.anthropic.claude-sonnet-4-5-20250929-v1:0` → `Sonnet 4.5`, `claude-3-5-haiku-20241022` →
70 * `Haiku 3.5`. Anything else is shown as given, cut to 22 characters.
71 */
72export const prettyModel = (id: string) => {
73 if (!id) return '—'
74 const cap = (f: string) => f.charAt(0).toUpperCase() + f.slice(1)
75 const big = /\[1m\]|-1m\b/i.test(id) ? ' 1M' : ''
76 const now = /claude-([a-z]+)-(\d+)(?:-(\d{1,2}))?(?:-\d{8})?(?![\d])/i.exec(id)
77 if (now?.[1] && !/^\d/.test(now[1])) return `${cap(now[1].toLowerCase())} ${now[2]}${now[3] ? `.${now[3]}` : ''}${big}`
78 const old = /claude-(\d+)(?:-(\d))?-([a-z]+)/i.exec(id)
79 if (old?.[3]) return `${cap(old[3].toLowerCase())} ${old[1]}${old[2] ? `.${old[2]}` : ''}${big}`
80 return shorten(id, 22)
81}
82
83export const shorten = (s: string, n: number) => {
84 const one = s.replace(/\s+/g, ' ').trim()
85 return n <= 0 ? '' : one.length > n ? `${one.slice(0, Math.max(0, n - 1)).trimEnd()}…` : one
86}
87
88export const kTokens = (n: number) =>
89 n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)}M` : n >= 1000 ? `${Math.round(n / 1000)}k` : String(n)
90
91export const fmtDuration = (ms: number) => {
92 const s = Math.max(0, Math.round(ms / 1000))
93 if (s < 60) return `${s}s`
94 const m = Math.floor(s / 60)
95 return m < 60 ? `${m}m${String(s % 60).padStart(2, '0')}s` : `${Math.floor(m / 60)}h${String(m % 60).padStart(2, '0')}m`
96}
97
98/** A running clock as the live cards draw it: m:ss, or XhYY past an hour. */
99export const fmtTimer = (ms: number) => {
100 const s = Math.max(0, Math.floor(ms / 1000))
101 const m = Math.floor(s / 60)
102 return m < 60 ? `${m}:${String(s % 60).padStart(2, '0')}` : `${Math.floor(m / 60)}h${String(m % 60).padStart(2, '0')}`
103}
104
105/** `1 error`, `2 errors`. */
106export const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? '' : 's'}`
107
108export const fmtUsd = (n: number) => (n >= 100 ? `$${Math.round(n)}` : `$${n.toFixed(2)}`)
109
110// ---------------------------------------------------------------- redaction
111
112const SECRETS: [RegExp, string][] = [
113 [/(authorization\s*[:=]\s*)(bearer\s+|basic\s+)?\S+/gi, '$1$2•••'],
114 [/\b(bearer)\s+[A-Za-z0-9._~+/-]{8,}=*/gi, '$1 •••'],
115 [/\b(sk|pk|rk|ghp|gho|ghs|github_pat|xox[abprs])[-_][A-Za-z0-9_-]{8,}/g, '•••'],
116 [/((?:api[_-]?key|access[_-]?token|token|secret|password|passwd|pwd)\s*[=:]\s*)("[^"]*"|'[^']*'|\S+)/gi, '$1•••'],
117 [/(--(?:token|password|api-key|secret)[= ])\S+/gi, '$1•••'],
118 [/(\b[A-Z][A-Z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD)=)\S+/g, '$1•••'],
119 [/(\b[a-z][a-z0-9+.-]*:\/\/[^\s/:@]+:)[^\s@]+@/gi, '$1•••@'],
120]
121
122/** What an agent's details may store: credentials masked before anything is written to state. */
123export const redact = (s: string) => SECRETS.reduce((t, [re, to]) => t.replace(re, to), s)
124
125// ---------------------------------------------------------------- tools
126
127export const EDIT_TOOLS = new Set(['Edit', 'Write', 'NotebookEdit'])
128
129const TOOL_WORDS: Record<string, string> = {
130 Bash: 'Command',
131 PowerShell: 'Command',
132 Read: 'Read file',
133 Edit: 'Edit file',
134 Write: 'Write file',
135 NotebookEdit: 'Edit notebook',
136 Glob: 'Find files',
137 Grep: 'Search text',
138 WebFetch: 'Open web page',
139 WebSearch: 'Web search',
140 Agent: 'Start agent',
141 Skill: 'Use skill',
142 ToolSearch: 'Look up a tool',
143}
144
145/** A tool's name in plain words: `Bash` → `Command`, `mcp__chrome__open_url` → `chrome: open url`. */
146const toolWords = (tool: string) => {
147 const mcp = /^mcp__(.+?)__(.+)$/.exec(tool)
148 return TOOL_WORDS[tool] ?? (mcp ? `${mcp[1] ?? ''}: ${(mcp[2] ?? '').replace(/_/g, ' ')}` : tool)
149}
150
151/** One plain line saying what a call was about: its command, path, pattern or search; redacted. */
152export const describeInput = (tool: string, input: unknown) => {
153 const i = isObject(input) ? input : {}
154 const path = typeof i.file_path === 'string' ? i.file_path.split(/[\\/]/).slice(-2).join('/') : ''
155 const text = (k: string) => (typeof i[k] === 'string' ? (i[k] as string) : '')
156 const what = text('command') || path || text('pattern') || text('url') || text('query') || text('skill') || text('description')
157 return what ? `${toolWords(tool)}: ${redact(what)}` : toolWords(tool)
158}
159
160// ---------------------------------------------------------------- turn and receipt
161
162/**
163 * The turn after one tool call. Errors count in the main loop only (a subagent's failure is its
164 * own); edits count from every loop.
165 */
166export const afterCall = (t: Turn, c: { inSubagent: boolean; hasFailed: boolean; isEdit: boolean }): Turn => ({
167 ...t,
168 errors: t.errors + (!c.inSubagent && c.hasFailed ? 1 : 0),
169 edits: t.edits + (c.isEdit ? 1 : 0),
170})
171
172export const receiptOf = (
173 t: Turn,
174 o: { durationMs: number; agentsSince: number; costNow: number | null; reason: string; streakBefore?: number },
175): Receipt => ({
176 durationMs: o.durationMs,
177 agents: o.agentsSince,
178 edits: t.edits,
179 errors: t.errors,
180 costDelta: o.costNow !== null && t.costAtStart !== null && o.costNow - t.costAtStart >= 0.005 ? o.costNow - t.costAtStart : null,
181 reason: o.reason,
182 streak: o.reason === 'answer' && t.errors === 0 ? (o.streakBefore ?? 0) + 1 : 0,
183})
184
185export const normalizeReceipt = (stored: unknown): Receipt | null =>
186 isObject(stored) ? normalize<Receipt>({ durationMs: 0, agents: 0, edits: 0, errors: 0, costDelta: null, reason: 'answer', streak: 0 }, stored) : null
187
188/**
189 * The last reply as one headline: the single thing worth knowing about it, said with a wink.
190 * Every number in it is the receipt's own; `tone` picks the colour.
191 */
192export const receiptHeadline = (r: Receipt): { text: string; tone: 'good' | 'bad' | 'plain' } => {
193 const took = fmtDuration(r.durationMs)
194 if (r.reason !== 'answer') return { text: r.reason === 'aborted' ? `Stopped early after ${took}` : `This one did not finish (${took})`, tone: 'bad' }
195 if (r.errors >= 3) return { text: `${r.errors} errors hit. It finished anyway.`, tone: 'bad' }
196 if (r.errors > 0) return { text: `${plural(r.errors, 'stumble')}, still landed it`, tone: 'plain' }
197 if (r.agents >= 2) return { text: `${r.agents} Pals went in. Zero errors came out.`, tone: 'good' }
198 if (r.durationMs >= 600_000) return { text: `A ${Math.round(r.durationMs / 60_000)}-minute marathon, zero errors`, tone: 'good' }
199 if (r.edits === 0 && r.agents === 0) {
200 return { text: r.durationMs < 10_000 ? `Answered in ${took}. Blink and you missed it.` : 'Just talk: no files were changed', tone: 'plain' }
201 }
202 // One helper and no edits: there are no file changes to count.
203 if (r.edits === 0) return { text: '1 Pal went in. Zero errors came out.', tone: 'good' }
204 if (r.edits >= 5) return { text: `${r.edits} file changes, zero errors. Big one.`, tone: 'good' }
205 return { text: `Clean run: ${plural(r.edits, 'file change')}, zero errors`, tone: 'good' }
206}
207
208// ---------------------------------------------------------------- agents
209
210type StepUsage = { input_tokens?: number; cache_read_input_tokens?: number; cache_creation_input_tokens?: number } | null
211
212/** A card after one of its model requests: its context is the latest step's whole input. */
213export const applyStep = (c: AgentCard, s: { model: string; usage: StepUsage; stopReason: string | null }): AgentCard => {
214 const u = s.usage ?? {}
215 const ctx = (u.input_tokens ?? 0) + (u.cache_read_input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0)
216 return {
217 ...c,
218 model: c.model || s.model,
219 steps: c.steps + 1,
220 ctx: ctx > 0 ? ctx : c.ctx,
221 lastStop: s.stopReason,
222 }
223}
224
225export const noteTool = (c: AgentCard, n: ToolNote): AgentCard => ({ ...c, tools: [...c.tools, n].slice(-12) })
226
227/** Swimlane geometry: each agent's bar on one shared axis from the first spawn to now. */
228export const lanes = (cards: AgentCard[], now: number, width: number) => {
229 const start = Math.min(...cards.map(c => c.spawnedAt).filter(n => n > 0), now)
230 const span = Math.max(1, now - start)
231 return cards.map(c => {
232 const from = Math.floor(((Math.max(c.spawnedAt, start) - start) / span) * width)
233 const to = Math.max(from + 1, Math.ceil((((c.endedAt ?? now) - start) / span) * width))
234 return { id: c.id, before: Math.min(from, width), bar: Math.min(to, width) - Math.min(from, width), after: width - Math.min(to, width) }
235 })
236}
237
238/** A card's title row: the task in the agent's own words, the type only when there is none. */
239export const cardTitle = (c: AgentCard) => c.description || c.type
240
241/** A title split over two rows at a word boundary: `first` cells on row one, `rest` on row two. */
242export const titleLines = (title: string, first: number, rest: number): [string, string] => {
243 const t = title.replace(/\s+/g, ' ').trim()
244 if (t.length <= first) return [t, '']
245 const cut = t.lastIndexOf(' ', first)
246 const at = cut > 0 ? cut : first
247 return [t.slice(0, at).trim(), shorten(t.slice(at), rest)]
248}
249
250// ---------------------------------------------------------------- plan (the bar above the prompt)
251
252const DEFAULT_PLAN: Plan = { title: 'Working', total: 0, done: 0, isFinished: false, step: '', startedAt: 0, endedAt: null }
253
254export const normalizePlan = (stored: unknown): Plan | null => (isObject(stored) ? normalize(DEFAULT_PLAN, stored) : null)
255
256const count = (v: unknown, fallback: number) => (typeof v === 'number' && Number.isFinite(v) ? Math.max(0, Math.round(v)) : fallback)
257
258/**
259 * The plan after one `progress` call at `now`. Fields left out keep their value; a new title, or
260 * a call after the last plan finished, starts a fresh plan so counts never carry over.
261 */
262export const mergePlan = (prev: Plan | null, input: unknown, now = 0): Plan => {
263 const i = isObject(input) ? input : {}
264 const title = typeof i.title === 'string' ? shorten(i.title, 60) : ''
265 const isFresh = !prev || prev.isFinished || (title !== '' && title !== prev.title)
266 const base = isFresh ? { ...DEFAULT_PLAN, startedAt: now } : prev
267 const total = count(i.total, base.total)
268 const done = count(i.done, base.done)
269 const isFinished = i.finished === true
270 return {
271 title: title || base.title,
272 total,
273 done: total > 0 ? Math.min(total, done) : done,
274 isFinished,
275 step: isFinished ? '' : typeof i.step === 'string' ? shorten(i.step, 40) : base.step,
276 startedAt: base.startedAt,
277 endedAt: isFinished ? now : null,
278 }
279}
280
281export const planRatio = (p: Plan) => (p.isFinished ? 1 : p.total > 0 ? p.done / p.total : 0)
282
283const planTook = (p: Plan) => (p.endedAt !== null && p.startedAt > 0 && p.endedAt >= p.startedAt ? fmtDuration(p.endedAt - p.startedAt) : '')
284
285export const planLabel = (p: Plan) =>
286 p.isFinished ? (planTook(p) ? `Done in ${planTook(p)}` : 'Done') : p.total > 0 ? `Steps ${p.done}/${p.total}` : 'Working'
287
288/** The few words beside the title: the step under way when Claude named it, a nudge otherwise. */
289export const planHype = (p: Plan) => {
290 if (p.isFinished) return 'Nailed it'
291 if (p.step) return p.step
292 if (p.total <= 0) return 'On it'
293 if (p.done === 0) return 'Warming up'
294 if (p.done >= p.total) return 'Wrapping up'
295 if (p.total - p.done === 1) return 'One step left'
296 return planRatio(p) >= 0.75 ? 'Home stretch' : planRatio(p) >= 0.5 ? 'Past halfway' : 'Rolling'
297}
298
299/** The bar as the terminal draws it: █ filled, ░ empty. */
300export const planBar = (p: Plan, width: number) => {
301 const full = Math.max(0, Math.min(width, Math.round(width * planRatio(p))))
302 return '█'.repeat(full) + '░'.repeat(width - full)
303}
304
305// ---------------------------------------------------------------- Pals (one per agent, picked by its job)
306
307/** A helper agent's own plan after one of its `progress` calls: the count on its card. */
308export const mergeAgentSteps = (c: AgentCard, input: unknown): AgentCard => {
309 const i = isObject(input) ? input : {}
310 const total = count(i.total, c.stepTotal ?? 0)
311 const done = i.finished === true && total > 0 ? total : count(i.done, c.stepDone ?? 0)
312 const note = typeof i.step === 'string' ? shorten(i.step, 40) : typeof i.title === 'string' && c.stepTotal === null ? shorten(i.title, 40) : c.stepNote
313 return { ...c, stepTotal: total, stepDone: total > 0 ? Math.min(total, done) : done, stepNote: i.finished === true ? '' : note }
314}
315
316/** `2/5` when the agent reports a plan of its own, null when it does not. */
317export const agentSteps = (c: AgentCard) => (c.stepTotal !== null && c.stepTotal > 0 ? `${c.stepDone ?? 0}/${c.stepTotal}` : null)
318
319export const agentRatio = (c: AgentCard) => (c.stepTotal !== null && c.stepTotal > 0 ? Math.min(1, (c.stepDone ?? 0) / c.stepTotal) : null)
320
321// First match wins, so the order is the tie-break: "review the build" is checking, Depresso's job.
322const PAL_RULES: [Pal, RegExp][] = [
323 ['vixy', /explor|search|find|scout|archaeolog|research|look ?up|locat|guide|map\b|\bread|pull|fetch|scan|probe|trace|inventor|sweep|gather|collect|crawl|hunt|brows|survey|\bsourc|extract|\blist\b|recon/i],
324 ['depresso', /review|verif|audit|check|inspect|test|debug|investigat|diagnos|analy[sz]|assess|measur|compar|classif|benchmark|evaluat|validat|\bqa\b|council|baseline|critiq|judg|grad(e|ing)|\bseat\b|\bcount|bench|\brank|evidence|proof|feasib/i],
325 ['hoocrates', /plan|architect|strateg|design the|spec\b|outlin|roadmap|propos|scope|brainstorm/i],
326 ['lifmunk', /design|asset|document|write|draft|\bdocs?\b|copy|styl|summar|digest|compil|consolidat|\brecord\b|handoff|runbook|caption|script|\bnotes?\b|polish|revis/i],
327 ['foxparks', /quick|fast|status ?line|rename|format|tidy|remov|delet|clean|\bdrop\b|retir|trim|\bmove\b|prune/i],
328 ['anubis', /build|implement|fix|engineer|refactor|code|migrat|edit|appl|\badd\b|chang|creat|set ?up|wire|\b(re)?run\b|split|finish|install|updat|\bport\b|integrat|patch|backtest|convert|generat/i],
329]
330
331/** Types that say nothing about the job: the task description decides. */
332const GENERIC_TYPES = /^(general-purpose|claude|agent|fork)$/i
333
334/** A type names its job in a word, so the rules apply in their order. */
335const palFor = (text: string) => PAL_RULES.find(([, re]) => re.test(text))?.[0] ?? null
336
337/** A task is a sentence: the job is its first telling word ("Add tests" builds, "Test the add" checks). */
338const palForTask = (text: string): Pal | null => {
339 let best: { k: Pal; at: number } | null = null
340 for (const [k, re] of PAL_RULES) {
341 const at = text.search(re)
342 if (at >= 0 && (!best || at < best.at)) best = { k, at }
343 }
344 return best?.k ?? null
345}
346
347/**
348 * The Pal for an agent: its type decides when the type names a job (Explore, a reviewer), its
349 * task description otherwise; a small fast model with nothing else to go on is Foxparks, and
350 * anything left is building, the commonest job, so every agent has a Pal.
351 */
352export const palOf = (c: Pick<AgentCard, 'type' | 'description' | 'model'>): Pal =>
353 (GENERIC_TYPES.test(c.type) ? null : palFor(c.type)) ?? palForTask(c.description) ?? (/haiku/i.test(c.model) ? 'foxparks' : 'anubis')
354
355/** Each Pal's name, the job it stands for, a terminal stand-in for its drawing, and its accent colour. */
356export const PALS: Record<Pal, { name: string; job: string; emoji: string; color: string }> = {
357 vixy: { name: 'Vixy', job: 'scouting', emoji: '🦊', color: '#E0A526' },
358 depresso: { name: 'Depresso', job: 'checking', emoji: '🐨', color: '#5B9BD5' },
359 anubis: { name: 'Anubis', job: 'building', emoji: '🐺', color: '#E08A3A' },
360 lifmunk: { name: 'Lifmunk', job: 'writing', emoji: '🌿', color: '#5FBF8F' },
361 hoocrates: { name: 'Hoocrates', job: 'planning', emoji: '🦉', color: '#8F8CF4' },
362 foxparks: { name: 'Foxparks', job: 'quick job', emoji: '🔥', color: '#E05A5A' },
363}
364
365/** About one agent in twenty has a Lucky Pal; the same agent always gets the same answer. It shows once the agent is done. */
366export const isLucky = (id: string) => [...id].reduce((h, ch) => (h * 31 + ch.charCodeAt(0)) >>> 0, 7) % 20 === 0
367
368/**
369 * How an agent's Pal looks now. It is an egg until the agent has done something, then grows with
370 * the agent's own plan when it reports one and with its steps when it does not, and is fully
371 * grown once the agent is done, which is also when a Lucky one shows it: every Pal grows the
372 * same way first. An agent that failed or was stopped has a knocked-out Pal.
373 */
374export const palLook = (c: AgentCard): PalLook => {
375 const ratio = agentRatio(c)
376 const growing = ratio !== null ? (ratio <= 0 ? 0 : ratio < 0.5 ? 1 : 2) : c.steps === 0 ? 0 : c.steps < 5 ? 1 : 2
377 return { stars: c.status === 'done' ? 4 : growing, isLucky: c.status === 'done' && isLucky(c.id), isDown: c.status === 'failed' || c.status === 'stopped' }
378}
379
380/** The Pal in words: `Vixy · scouting`, `Egg · incubating`, `Lucky Lifmunk · writing`, or that it is knocked out. */
381export const palWords = (k: Pal, look: PalLook) =>
382 look.isDown
383 ? { name: `✗ ${PALS[k].name} knocked out`, job: '' }
384 : look.stars === 0
385 ? { name: 'Egg', job: 'incubating' }
386 : { name: `${look.isLucky ? 'Lucky ' : ''}${PALS[k].name}`, job: PALS[k].job }
387
388/** The bar's own Pal: an egg until the first step is done, then grown, and Lucky once the job is finished. */
389export const planLook = (p: Plan): PalLook => ({ stars: p.isFinished ? 4 : p.done > 0 ? 2 : 0, isLucky: p.isFinished, isDown: false })
390
391/** The terminal's stand-in for the drawing: an egg, the Pal's emoji (sparkling when Lucky), or a skull. */
392export const palEmoji = (k: Pal, look: PalLook) => (look.isDown ? '💀' : look.stars === 0 ? '🥚' : `${PALS[k].emoji}${look.isLucky ? '✨' : ''}`)
393
394// ---------------------------------------------------------------- the session box
395
396/**
397 * How long the prompt cache lasts, read from what a request wrote to it: five minutes when it
398 * wrote only five-minute entries, an hour when it wrote hour ones, the last known value otherwise.
399 */
400export const cacheTtlOf = (usage: unknown, previous: number): number => {
401 const w = (usage as { cache_creation?: { ephemeral_5m_input_tokens?: number; ephemeral_1h_input_tokens?: number } } | null)?.cache_creation
402 if ((w?.ephemeral_1h_input_tokens ?? 0) > 0) return 3_600_000
403 if ((w?.ephemeral_5m_input_tokens ?? 0) > 0) return 300_000
404 return previous
405}
406
407/** The cache line for the time left on it. `hooks/cache.tsx` draws the same three cases as it counts down. */
408export const cacheLabel = (msLeft: number): { text: string; tone: 'plain' | 'soon' | 'cold' } => {
409 const min = Math.ceil(msLeft / 60_000)
410 if (min <= 0) return { text: 'cold · next message re-reads it all', tone: 'cold' }
411 return { text: `warm · ~${min} min left`, tone: min <= 10 ? 'soon' : 'plain' }
412}
413
414export const limitLabel = (kind: string) => (kind === 'five_hour' ? '5-hour limit' : kind === 'seven_day' ? 'Weekly limit' : kind.replace(/_/g, ' '))
415hooks/pals.ts 90 lines1// The pixel Pals: one for the bar and one per helper agent, picked by the job it runs. A Pal starts
2// as an egg, hatches small and grows as its agent works; a Lucky one is bigger and sparkles; one
3// whose agent failed is knocked out. Which Pal goes with which job is in core.ts.
4import type { Pal, PalLook } from '../types'
5import { EGG_SPRITE, PAL_SPRITES } from './pal-sprites'
6
7/** The Pals' grid: 40×38 cells; a drawing of it at `scale` is this many pixels. */
8export const PAL_GRID = { w: 40, h: 38 }
9
10// How much of the drawing each stage fills, by its stars (0 is the egg). A Lucky Pal fills all of it.
11const SIZE: Record<PalLook['stars'], number> = { 0: 0.65, 1: 0.57, 2: 0.72, 4: 0.87 }
12
13const LETTERS = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
14
15const CROSS = ['xx...xx', 'xxx.xxx', '.xxxxx.', '..xxx..', '.xxxxx.', 'xxx.xxx', 'xx...xx']
16const GHOST = ['..www..', '.wwwww.', 'wwkwkww', 'wwwwwww', 'wwwwwww', '.wwwww.', '.ww.ww.', '..w..w.', '...w...']
17const SPARKLE = ['.g.', 'gwg', '.g.']
18const INKS: Record<string, string> = { x: '#F04A4A', w: '#E9EEF5', k: '#3A3A45', g: '#F5C542' }
19
20// Pure CSS, run by the compositor: no redraws. A working Pal hops and a working egg rocks; a Lucky
21// Pal's sparkles blink and a knocked-out Pal's ghost drifts whenever motion is on.
22export const PAL_CSS = `
23.pal g{transform-box:fill-box}
24.run .bd{animation:hop .5s steps(1) infinite}
25.run .eg{transform-origin:50% 100%;animation:rock .7s ease-in-out infinite}
26.mv .sp{animation:blink .8s steps(1) infinite}.mv .sp2{animation:blink .8s steps(1) infinite -.4s}
27.mv .gh{animation:drift 1.6s ease-in-out infinite}
28@keyframes hop{50%{transform:translateY(-1px)}}@keyframes rock{25%{transform:rotate(-7deg)}75%{transform:rotate(7deg)}}
29@keyframes blink{50%{opacity:.15}}@keyframes drift{50%{transform:translateY(-2px)}}
30@media (prefers-reduced-motion: reduce){.pal g{animation:none!important}}
31`
32
33// One path per colour, each run of cells along a row a single box: a Pal is a dozen paths, not a
34// thousand rectangles.
35const paths = (rows: string[], colorOf: (ch: string) => string, ox = 0, oy = 0) => {
36 const d = new Map<string, string>()
37 rows.forEach((row, y) => {
38 for (let x = 0; x < row.length; ) {
39 const ch = row[x] ?? '.'
40 let n = 1
41 while (row[x + n] === ch) n += 1
42 if (ch !== '.') {
43 const c = colorOf(ch)
44 d.set(c, `${d.get(c) ?? ''}M${ox + x} ${oy + y}h${n}v1h-${n}z`)
45 }
46 x += n
47 }
48 })
49 return [...d].map(([c, p]) => `<path fill="${c}" d="${p}"/>`).join('')
50}
51
52const ink = (rows: string[], x: number, y: number) => paths(rows, ch => INKS[ch] ?? '', x, y)
53
54// A knocked-out Pal's colours: its own, drained to grey and a little darker.
55const drained = (c: string) => {
56 const [r = 0, g = 0, b = 0] = [1, 3, 5].map(i => parseInt(c.slice(i, i + 2), 16))
57 const v = Math.round((0.3 * r + 0.59 * g + 0.11 * b) * 0.8 + 5)
58 return `rgb(${v},${v},${v})`
59}
60
61/**
62 * One Pal as SVG markup at (x, y), standing on the bottom of a `PAL_GRID` box at `scale`. It is
63 * drawn at its stage's size inside that box, or filling it when `fills` (a drawing too small to
64 * show growth). Needs `PAL_CSS` in the drawing.
65 */
66export const pal = (x: number, y: number, k: Pal, look: PalLook, scale: number, isWorking: boolean, hasMotion = true, fills = false) => {
67 const s = PAL_SPRITES[k]
68 const isEgg = look.stars === 0 && !look.isDown
69 const isLucky = look.isLucky && !isEgg && !look.isDown
70 const size = fills || isLucky ? 1 : SIZE[look.isDown ? 4 : look.stars]
71 const colorOf = (ch: string) => s.colors[LETTERS.indexOf(ch)] ?? ''
72 const body = isEgg
73 ? `<g class="eg">${paths(EGG_SPRITE.rows, ch => (ch === '*' ? s.egg : (EGG_SPRITE.colors[LETTERS.indexOf(ch)] ?? '')))}</g>`
74 : `<g class="bd">${paths(s.rows, look.isDown ? ch => drained(colorOf(ch)) : colorOf)}${look.isDown ? s.eyes.map(([ex, ey]) => ink(CROSS, ex - 3, ey - 3)).join('') : ''}</g>`
75 const ghost = look.isDown ? `<g class="gh">${ink(GHOST, 33, 0)}</g>` : ''
76 const sparkles = isLucky
77 ? `<g class="sp">${ink(SPARKLE, 1, 3)}${ink(SPARKLE, 36, 29)}${ink(SPARKLE, 33, 0)}</g><g class="sp2">${ink(SPARKLE, 36, 5)}${ink(SPARKLE, 2, 30)}</g>`
78 : ''
79 const dx = x + ((PAL_GRID.w * (1 - size)) / 2) * scale
80 const dy = y + PAL_GRID.h * (1 - size) * scale
81 return `<g transform="translate(${dx.toFixed(2)},${dy.toFixed(2)}) scale(${(scale * size).toFixed(3)})" shape-rendering="crispEdges"><g class="pal${hasMotion ? ' mv' : ''}${isWorking && hasMotion ? ' run' : ''}">${body}${ghost}${sparkles}</g></g>`
82}
83
84/** A Pal on its own, for an agent's row: it hops while its agent works. */
85export const palSvg = (k: Pal, look: PalLook, scale: number, isWorking: boolean, hasMotion = true, fills = false) => {
86 const w = Math.ceil(PAL_GRID.w * scale)
87 const h = Math.ceil(PAL_GRID.h * scale)
88 return `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}"><style>${PAL_CSS}</style>${pal(0, 0, k, look, scale, isWorking, hasMotion, fills)}</svg>`
89}
90hooks/elapsed.tsx 38 lines1// A running agent's clock, ticking on the surface's frame clock so the pane need not redraw.
2// `since` and `now` come from the hooks module's $.clock; between redraws it adds its own ticks.
3import type { ClientModule } from 'claude-code'
4
5type Props = { since: number; now: number; endAt: number | null; color: string }
6type Ref = { base: number; ticks: number; lastNow: number; isRunning: boolean }
7type State = { ref: Ref }
8
9const fmt = (ms: number) => {
10 const s = Math.max(0, Math.floor(ms / 1000))
11 const m = Math.floor(s / 60)
12 return m < 60 ? `${m}:${String(s % 60).padStart(2, '0')}` : `${Math.floor(m / 60)}h${String(m % 60).padStart(2, '0')}`
13}
14
15const Elapsed: ClientModule<Props, State> = (props, surface) => {
16 const { Text } = surface.elements
17 const ref = surface.state?.ref ?? { base: 0, ticks: 0, lastNow: -1, isRunning: true }
18 if (props.now !== ref.lastNow) {
19 // A fresh reading from the hooks module: restart the local count from it.
20 ref.lastNow = props.now
21 ref.base = (props.endAt ?? props.now) - props.since
22 ref.ticks = 0
23 }
24 ref.isRunning = props.endAt === null
25 if (surface.state === undefined) {
26 surface.setState({ ref })
27 surface.every(1000, () => {
28 if (ref.isRunning) {
29 ref.ticks += 1
30 surface.setState({ ref })
31 }
32 })
33 }
34 return <Text color={props.color}>{fmt(ref.base + ref.ticks * 1000)}</Text>
35}
36
37export default Elapsed
38hooks/cache.tsx 34 lines1// The prompt cache's countdown, ticking on the surface's frame clock so it stays right while idle.
2// `expiresAt` and `now` come from the hooks module's $.clock; the wording matches cacheLabel in core.ts.
3import type { ClientModule } from 'claude-code'
4
5type Props = { expiresAt: number; now: number; plain: string; soon: string; cold: string }
6type Ref = { left: number; lastNow: number }
7type State = { ref: Ref }
8
9const Cache: ClientModule<Props, State> = (props, surface) => {
10 const { Text } = surface.elements
11 const ref = surface.state?.ref ?? { left: 0, lastNow: -1 }
12 if (props.now !== ref.lastNow) {
13 // A fresh reading from the hooks module: restart the local count from it.
14 ref.lastNow = props.now
15 ref.left = props.expiresAt - props.now
16 }
17 if (surface.state === undefined) {
18 surface.setState({ ref })
19 surface.every(15_000, () => {
20 ref.left -= 15_000
21 surface.setState({ ref })
22 })
23 }
24 const min = Math.ceil(ref.left / 60_000)
25 if (min <= 0) return <Text color={props.cold}>cold · next message re-reads it all</Text>
26 return (
27 <Text color={min <= 10 ? props.soon : props.plain} wrap="truncate">
28 {`warm · ~${min} min left`}
29 </Text>
30 )
31}
32
33export default Cache
34hooks/pal-sprites.ts 325 lines1// The Pals' pixels, one character a cell on a 40×38 grid: a letter picks from `colors`, a dot is
2// empty, and `*` in the egg is the spot colour of the Pal inside. `eyes` is where a knocked-out
3// Pal's crosses go. Drawn for this mod from the game's own icons; unofficial fan art of Palworld
4// (Pocketpair), not affiliated with or endorsed by it.
5import type { Pal } from '../types'
6
7type Sprite = { colors: string[]; rows: string[] }
8
9export const PAL_SPRITES: Record<Pal, Sprite & { eyes: [number, number][]; egg: string }> = {
10 vixy: {
11 colors: ['#D9A45F', '#F2A6A0', '#F2D24B', '#1F1E1D', '#FFFFFF', '#2E9E3A', '#6FD86B', '#BF8A47', '#F6E3B4'],
12 rows: [
13 '........................................',
14 '.......a........................a.......',
15 '.......aa......................aa.......',
16 '.......aaa....................aaa.......',
17 '.......abba..................abba.......',
18 '......abbba..................abbba......',
19 '......abbbba................abbbba......',
20 '......abbbbba..............abbbbba......',
21 '......abbbbbba..cccccccc..abbbbbba......',
22 '......abbbbbbaaaaccccccaaaabbbbbba......',
23 '......abbbbbaaaaaccacccaaaaabbbbba......',
24 '......abbbbaaaaaaacaacaaaaaaabbba.......',
25 '..........aaaaaaaaaaaaaaaaaaaa..........',
26 '..........aaddddddaaaaadddddda..........',
27 '.........aaedffffeeeeeeeffffdaa.........',
28 '.........aaeefegfeeeeeeefegfeaa.........',
29 '.........aeeefddfeeeeeeefddfeea.........',
30 '.........aaeefddfeeeeeeefddfeaa.........',
31 '.........aaeeffffeeeeeeeffffeaa.........',
32 '.......cc.aeeeeeeeeeeeeeeeeeea.cc.......',
33 '.......cccaaeeeeeeeddeeeeeeeaaccc.......',
34 '........cc.aeeeeeeeeeeeeeeeea.cc........',
35 '............aeeeeeehheeeeeea.....ii.....',
36 '..............eeeeeeeeeeee.....aiiii....',
37 '...............ieeeeeeeei....aaiiiiii...',
38 '..............iiiiiiiiiiii..aaaiiiiii...',
39 '.............iiiiiiiiiiiiii.aaaaiiiia...',
40 '..............iiiiiiiiiiii..aaaaaiiaa...',
41 '.............aaiiiiiiiiiiaa.aaaaaaaaa...',
42 '.............aaaaaaaaaaaaaa.aaaaaaaaa...',
43 '.............aaaaaaaaaaaaaa.aaaaaaaaa...',
44 '.............aaaaaaaaaaaaaa.aaaaaaaaa...',
45 '..............aaaaaaaaaaaa..aaaaaaaaa...',
46 '...............aaaaaaaaaa....aaaaaaa....',
47 '..............ccccaaaacccc.....aaa......',
48 '..............cccc....cccc..............',
49 '..............cccc....cccc..............',
50 '........................................',
51 ],
52 eyes: [[14, 16], [25, 16]],
53 egg: '#C9C2B0',
54 },
55 depresso: {
56 colors: ['#6E3D7A', '#5AA5E0', '#86C4F2', '#8A4E8F', '#1F1E1D', '#FFFFFF', '#E9CDEF', '#2B2350', '#8E8AA8', '#4686C4'],
57 rows: [
58 '........................................',
59 '........................................',
60 '........................................',
61 '....................a...................',
62 '..................aaa...................',
63 '..................aaaa..................',
64 '..................aaaaa.................',
65 '.................aaaaaa.................',
66 '................bbbbbbbb................',
67 '.............bbbbbbbbbbbbbb.............',
68 '...........bbbbbbbbbbbbbbbbbb...........',
69 '.......b..bbbccccccccbbbbbbbbb..b.......',
70 '.....bbbbbbbccccccccccbbbbbbbbbbbbb.....',
71 '....bdddbbbbccccccccccbbbbbbbbbbdddb....',
72 '....bdddbbbbbccccccccbbbbbbbbbbbdddb....',
73 '...bddddbbbbbbbbbbbbbbbbbbbbbbbbddddb...',
74 '....bdddbbeeeeebbbbbbbbbbeeeeeebdddb....',
75 '....bdddbbeeeeeeeeebbbeeeeeeeeebdddb....',
76 '.....bbbbbffghheeeebbbeeehhgffbbbbb.....',
77 '.......bbbfgghhggfbbbbfgghhggfbbb.......',
78 '........bbbfggggfbbbbbbfggggfbbb........',
79 '.........bbbbffbbbbbbbbbbffbbbb.........',
80 '..........bbbbbbbbbbbbbbbbbbbb..........',
81 '...........bbbbbbbbbbbbbbbbbb...........',
82 '.............bbbbeeeeeebbbb.............',
83 '.............iiibbbbbbbbiii.............',
84 '.............iiiiiiiiiiiiii.............',
85 '.............iiiiiiiiiiiiii.............',
86 '...........bbbccccccccccccbbb...........',
87 '...........bbbccccccccccccbbb...........',
88 '...........bbbccccccccccccbbb...........',
89 '...........bbbccccccccccccbbb...........',
90 '...........bbbccccccccccccbbb...........',
91 '...............cccccccccc...............',
92 '..............jjjjccccjjjj..............',
93 '..............jjjj....jjjj..............',
94 '..............jjjj....jjjj..............',
95 '........................................',
96 ],
97 eyes: [[14, 19], [26, 19]],
98 egg: '#7A63B8',
99 },
100 anubis: {
101 colors: ['#50505E', '#39B8E8', '#E8B62E', '#C99A1E', '#1F1E1D', '#3A3A47', '#FFFFFF', '#C9986B', '#8A6240'],
102 rows: [
103 '...........aa..............aa...........',
104 '...........aa..............aa...........',
105 '..........aba.............aba...........',
106 '..........abba............abba..........',
107 '..........abba............abba..........',
108 '..........abbba..........abbba..........',
109 '..........abbba..........abbba..........',
110 '..........abbba..........abbba..........',
111 '..........abbbba........abbbba..........',
112 '..........abbbba.aaaaaa.abbbba..........',
113 '.........abbbbbaaaaccaaaabbbbba.........',
114 '.........abbbbaaaaaccaaaaabbbba.........',
115 '..........abbaaaaaaddaaaaaabbba.........',
116 '............aaaaaaaaaaaaaaaa............',
117 '............eaaaaaaaaaaaaaae............',
118 '............eeeeaaaaaaaaeeee............',
119 '.........ffaagebaaaaaaaabegaaff.........',
120 '.........ffbagbbaaaaaaaabbgabff.........',
121 '.........fffbbbbaaaaaaaabbbbfff.........',
122 '.........fffaaaaaaahhaaaaaaafff.........',
123 '.........fff.aaaahheehhaaaa.fff.........',
124 '.........fff..aaahheehhaaa..fff.........',
125 '.........ccc...ahhhhhhhha...ccc.........',
126 '.........fff...aahhhhhhaa...fff.........',
127 '.........fff.....ahiiha.....fff.........',
128 '.........cff................ffc.........',
129 '.........ccccccccccbbcccccccccc..c......',
130 '.........ccccccccccbbcccccccccc.aa......',
131 '.........fff.ddddddbbdddddd.fff.a.......',
132 '.........cccaaaaaaaaaaaaaaaaccc.a.......',
133 '.........cccaaaaccccccccaaaaccc.a.......',
134 '............cccaafbbbbfaaccc....a.......',
135 '............aaaaafbbbbfaaaaa....a.......',
136 '............aaaaafbbbbfaaaaaaaaaa.......',
137 '................afbbbbfa................',
138 '...............ccccffcccc...............',
139 '...............aaaa..aaaa...............',
140 '........................................',
141 ],
142 eyes: [[14, 17], [25, 17]],
143 egg: '#A9865C',
144 },
145 lifmunk: {
146 colors: ['#4E9A3A', '#7BC25A', '#DDE59A', '#D8362B', '#F59A8C', '#E5492B', '#FFFFFF', '#B8281A', '#1F1E1D', '#C2CD7A', '#F0F4CC'],
147 rows: [
148 '.........aa..................aa.........',
149 '........aab..................aba........',
150 '........aaba................aaba........',
151 '........aaba................aaba........',
152 '........aabaa..............aaaba........',
153 '........aaaba..............aabaa........',
154 '........aaaba..............aabaa........',
155 '.........aabaa............aaaba.........',
156 '.........aaba..............aaba.........',
157 '..........aab..ccccddcccc..aba..........',
158 '...........aacccccdeddcccccaa...........',
159 '...........cccccccddddccccccc...........',
160 '..........ccccccccddddcccccccc..........',
161 '..........cccccccccddccccccccc..........',
162 '.........cccccccccccccccccccccc.........',
163 '.........cccccccccccccccccccccc.........',
164 '.........cccccccccccccccccccccc.........',
165 '........ccccffffccccccccffffcccc........',
166 '.........cccggffccccccccggffccc.........',
167 '.........ccffhhhfccccccffhhhfcc.........',
168 '.....aa..cccfhhfccccccccfhhfccc..aa.....',
169 '....aaa...ccffffccccccccffffcc...aaa....',
170 '.....a....ccccccccciiccccccccc....a.....',
171 '...........cccccccccccccccccc...ggggg...',
172 '.............cccccjccjccccc.....ggggg...',
173 '...............ccccjjcccc......ggggggg..',
174 '............a.aacaaccaacaa.a..ccggggg...',
175 '............aaaaaaaaaaaaaaaa.cccgggggc..',
176 '.............aaccaakkaaccaa..ccccccccc..',
177 '.............ccckkkkkkkkccc..ccccccccc..',
178 '.............ccckkkkkkkkccc..ccccccccc..',
179 '.............cckkkkkkkkkkcc..ccccccccc..',
180 '.............ccckkkkkkkkccc..ccccccccc..',
181 '..............cckkkkkkkkcc....ccccccc...',
182 '...............ccckkkkccc......ccccc....',
183 '..............jjjjccccjjjj......ccc.....',
184 '..............jjjj....jjjj..............',
185 '........................................',
186 ],
187 eyes: [[14, 19], [26, 19]],
188 egg: '#5FAE4E',
189 },
190 hoocrates: {
191 colors: ['#2A3450', '#3A4764', '#A9D6E0', '#C6E6EE', '#F2D21F', '#1F1E1D', '#E8962E'],
192 rows: [
193 '........................................',
194 '........................................',
195 '........................................',
196 '......................aa................',
197 '.....................aa.................',
198 '...................aaaa.................',
199 '.................aaaaaa.................',
200 '................aaaaaaaa................',
201 '................aaaaaaaa................',
202 '...............aaaaaaaaaa...............',
203 '..............aaaaaaaaaaaa..............',
204 '.............aaaaaaaaaaaaaa.............',
205 '............aaaaaaaaaaaaaaaa............',
206 '...........bbbbbbbbbbbbbbbbbb...........',
207 '..........bbbbbbbbbbbbbbbbbbbb..........',
208 '....bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb....',
209 '...bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb...',
210 '.....cccccccccccccccccccccccccccccc.....',
211 '..........dddddddbbbbbbddddddd..........',
212 '........ddddddddddbbbbdddddddddd........',
213 '.........dd.dddddddbbddddddd.dd.........',
214 '.........a.bbeedddbbbbdddeebb.a.........',
215 '........aaabeeffeebbbbeeffeebaaa........',
216 '.......aaaaabeffebggggbeffebaaaaa.......',
217 '.......aaaaabbeebbggggbbeebbaaaaa.......',
218 '.......aaaaabbbbcccggcccbbbbaaaaa.......',
219 '.......aaaaabbbccccccccccbbbaaaaa.......',
220 '.......aaaaabbccccccaaacccbbaaaaa.......',
221 '.......aaaaabccccccaaccccccbaaaaa.......',
222 '.......aaaaabcccccaaaaaccccbaaaaa.......',
223 '.......aaaaabcccccccaacccccbaaaaa.......',
224 '........aaabbccccccaaccccccbbaaa........',
225 '.........a.bbbccccaaccccccbbb.a.........',
226 '............bbbccccccccccbbb............',
227 '.............bbbccccccccbbb.............',
228 '...............bbbbbbbbbb...............',
229 '...............gggg..gggg...............',
230 '...............gggg..gggg...............',
231 ],
232 eyes: [[15, 22], [25, 22]],
233 egg: '#7A63B8',
234 },
235 foxparks: {
236 colors: ['#5A2E1A', '#F07A2A', '#F7E0BE', '#FFD23F', '#FF8A2A', '#E23A2E', '#FFFFFF', '#1F1E1D', '#D9482B', '#D55E1A'],
237 rows: [
238 '.........a....................a.........',
239 '.........aa..................aa.........',
240 '.........aa..................aa.........',
241 '.........bcb................bcb.........',
242 '........bccb................bccb........',
243 '........bcccb..............bcccb........',
244 '........bcccb..............bcccb........',
245 '........bccccb............bccccb........',
246 '........bccccb............bccccb........',
247 '........bcccccb..........bcccccb........',
248 '........bcccccb..........bcccccbd.......',
249 '........bccccccbbbbbbbbbbccccccbd..d....',
250 '........bccccbbbbbbbbbbbbbbccccbed.d....',
251 '............bbbbbbbbbbbbbbbb..deeedd....',
252 '...........bbbbbbbbbbbbbbbbbb.defeed....',
253 '...........bbgggbbbbbbbbgggbbdeeffeed...',
254 '..........bbbbbbbbbbbbbbbbbbbbeffffed...',
255 '..........bbbhghbbbbbbbbhghbbbeffffe....',
256 '........ffbbbhiibbbbbbbbhiibbbff........',
257 '.......fffbbbhiibbbbbbbbhiibbbfff.......',
258 '........ffbbbbbbbccccccbbbbbbbffaaaa....',
259 '.......fff.bbbbbccchhcccbbbbbafffaaa....',
260 '........f..bbbbccccccccccbbbbbbfbbb.....',
261 '............bbbbccccccccbbbbbbbbbbbb....',
262 '.............bbbbccccccbbbbbbbbbbbbbb...',
263 '...............bbbbbbbbbb..bbbbbbbbbb...',
264 '................cccccccc...bbbbbbbbbb...',
265 '...............bccccccccb..bbbbbbbbbb...',
266 '..............bccccccccccb.bbbbbbbbbb...',
267 '..............bbccccccccbb.bbbbbbbbbb...',
268 '.............bbbccccccccbbb.bbbbbbbb....',
269 '..............bbbbccccbbbb...bbbbbb.....',
270 '..............bbbbbbbbbbbb....bbbb......',
271 '............d..bbbbbbbbbb..d............',
272 '.............d..bbbbbbbb..d.............',
273 '..............jjjjbbbbjjjj..............',
274 '..............fjjj....jjjf..............',
275 '.............f............f.............',
276 ],
277 eyes: [[14, 18], [25, 18]],
278 egg: '#E0503A',
279 },
280}
281
282export const EGG_SPRITE: Sprite = {
283 colors: ['#F4EEDD', '#B7B2A6', '#FFFFFF'],
284 rows: [
285 '........................................',
286 '........................................',
287 '........................................',
288 '........................................',
289 '........................................',
290 '........................................',
291 '........................................',
292 '........................................',
293 '........................................',
294 '........................................',
295 '........................................',
296 '........................................',
297 '........................................',
298 '........................................',
299 '........................................',
300 '........................................',
301 '................aaaaaaaa................',
302 '..............baaabbaaaaa...............',
303 '..............abbbcabbaaaa..............',
304 '.............acccbccaabaaaa.............',
305 '.............accccccaaa**aa.............',
306 '............aaccccccaa****aa............',
307 '............aaccccccaa****aa............',
308 '............aaaccccaaaa**aaa............',
309 '............aaaaaaaaaaaaaaaa............',
310 '............aa**aaaaaaaaaaaa............',
311 '............a****aaaaaaaa**a............',
312 '............a****aaaaaaaa**a............',
313 '............aa**aaaaaaaaaaaa............',
314 '............aaaaaaaaa**aaaaa............',
315 '............aaaaaaaa****aaaa............',
316 '.............aaaaaaa****aaa.............',
317 '.............aaaa**aa**aaaa.............',
318 '..............aaa**aaaaaaa..............',
319 '...............aaaaaaaaaa...............',
320 '................aaaaaaaa................',
321 '........................................',
322 '........................................',
323 ],
324}
325types/index.d.ts 82 lines1export type Main = {
2 isRunning: boolean
3 /** When the main conversation's last request finished; 0 before the first. The cache countdown starts here. */
4 lastRequestAt: number
5 /** How long the prompt cache lasts after a request: an hour, or five minutes when the requests say so. */
6 cacheTtlMs: number
7}
8
9export type ToolNote = { text: string; isError: boolean }
10
11export type AgentCard = {
12 id: string
13 type: string
14 model: string
15 description: string
16 status: string
17 spawnedAt: number
18 endedAt: number | null
19 /** The agent's context now: input + cache read + cache write of its latest step. */
20 ctx: number
21 steps: number
22 lastStop: string | null
23 tools: ToolNote[]
24 answer: string
25 /** Its own plan, when it reports one through the `progress` tool; null until it does. */
26 stepDone: number | null
27 stepTotal: number | null
28 stepNote: string
29}
30
31export type Turn = {
32 edits: number
33 errors: number
34 startedAt: number
35 costAtStart: number | null
36}
37
38export type Receipt = {
39 durationMs: number
40 agents: number
41 edits: number
42 errors: number
43 costDelta: number | null
44 reason: string
45 /** Replies in a row, this one included, that finished with no errors. */
46 streak: number
47}
48
49export type View = { expanded: string | null }
50
51/** The plan Claude reported through the `progress` tool: what the bar above the prompt draws. */
52export type Plan = {
53 title: string
54 total: number
55 done: number
56 isFinished: boolean
57 /** What the step now under way is, in Claude's words; empty when it did not say. */
58 step: string
59 /** When the plan began and ended; 0 and null when unknown. */
60 startedAt: number
61 endedAt: number | null
62}
63
64/** The Pal that stands for an agent, picked from the kind of job it runs. */
65export type Pal = 'vixy' | 'depresso' | 'anubis' | 'lifmunk' | 'hoocrates' | 'foxparks'
66
67/** How a Pal is drawn: `stars` 0 is the egg and 4 fully grown; Lucky is the rare bigger one; down is knocked out. */
68export type PalLook = { stars: 0 | 1 | 2 | 4; isLucky: boolean; isDown: boolean }
69
70declare module 'claude-code' {
71 interface PluginState {
72 'spotter': {
73 main: Main
74 agents: AgentCard[]
75 turn: Turn
76 receipt: Receipt | null
77 view: View
78 plan: Plan | null
79 }
80 }
81}
82