SLOPSHOPPER

spotter

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…

newpanebandguardcommandtool
v1.0.0MITupdated 2026-10-09swhite1122/spotter-pals
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · spotter
│ ┃ Spotter ✕ › fix the failing auth test and add an audit log call │ ┃ ╭─────────────────────────────────────────── │ ┃ │ Context ▰▰▰▰▰▱▱▱▱▱ 49% used ⏺ Read(src/auth.ts) │ ┃ │ 103k left of 200k ⎿ Read 6 lines │ ┃ │ 5-hour limit 31% used ⏺ Update(src/auth.ts) │ ┃ │ Last reply 42s ⎿ Added 2 lines, removed 1 line │ ┃ │ 3 file changes · 1 error ⏺ Bash(bun test) │ ┃ │ ✓ 1 stumble, still landed it ⎿ 3 pass, 1 fail │ ┃ ╰─────────────────────────────────────────── │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /spotter │ ⎿ spotter: Spotter opened. Click an agent for its details, or focu │ │ ✓ 1 stumble, still landed it · 42s ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
✓ 1 stumble, still landed it · 42s ⟨Claude Code's own drawing⟩
Pane · Spotter
╭──────────────────────────────────────────────────────╮ │ Context ▰▰▰▰▰▱▱▱▱▱ 49% used │ │ 103k left of 200k │ │ 5-hour limit 31% used │ │ Last reply 42s │ │ 3 file changes · 1 error │ │ ✓ 1 stumble, still landed it │ ╰──────────────────────────────────────────────────────╯
README

Spotter Pals

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.

The six Pals: Vixy scouts, Depresso checks, Anubis builds, Lifmunk writes, Hoocrates plans, Foxparks does the quick jobs

Install

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.

What it looks like in Claude desktop

Real screenshots from a live session, with the chat covered up. The side panel is Spotter; the bar above the prompt is Spotter too.

Claude desktop with the Spotter panel open: six helpers, one knocked out, and the progress bar at step 2 of 4

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

Every helper gets a Pal

When Claude sends a helper agent off to do something, Spotter gives it a Pal picked by the job:

PalJobShows up when a helper is…
Vixyscoutingsearching, reading, exploring the code
Depressocheckingreviewing, testing, auditing
Anubisbuildingwriting code, fixing, wiring things up
Lifmunkwritingdrafting docs, notes and design
Hoocratesplanningplanning, scoping, architecture
Foxparksquick jobrenames, tidying, anything fast

They grow while the work gets done

One Pal's life: an egg, one star, two stars, four stars, a Lucky Pal with sparkles, and a grey knocked-out Pal

  • Egg. Every helper starts as one. You find out who is inside when it does its first step.
  • Stars. One star once it is moving, two at the halfway mark, four when it finishes.
  • Lucky. About one helper in twenty hatches a Lucky Pal: bigger, with gold sparkles. You only find out when it finishes.
  • Knocked out. A helper that fails or gets stopped goes grey, with crossed eyes and a little ghost. You will not miss it.

A progress bar that knows the steps

The progress bar at the start with an egg, halfway with a Pal, and finished with a Lucky Pal

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.

The rest of the panel

  • Session box. How full the context is, your plan limits, and how long the prompt cache stays warm, so you know when a message will be cheap and when it will re-read everything.
  • Last reply. One line for every reply: how long it took, what it cost, how many files changed, and a streak of clean ones. "3 Pals went in. Zero errors came out."
  • Details. Click a helper (or press its number) to see what it did and what it reported.

In the terminal

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.

Commands and settings

/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).

What it touches

  • It watches tool calls, helper agents, and the session's own cost, context and limit readings.
  • It keeps short notes in memory for the session only, with passwords and keys masked.
  • It makes no network calls, reads and writes no files, and runs no programs.
  • It gives Claude one small tool, progress, to report steps. That is how the bar gets its numbers, and it costs a few tokens per update.

Credit

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.

Source 8 files
hooks/register.tsx 745 lines
1import { 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}
745
hooks/bar.ts 103 lines
1// 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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[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}
103
hooks/core.ts 415 lines
1// 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, ' '))
415
hooks/pals.ts 90 lines
1// 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}
90
hooks/elapsed.tsx 38 lines
1// A running agent's clock, ticking on the surface's frame clock so the pane need not redraw.
2// `since` and `now` come from the hooks module's $.clock; between redraws it adds its own ticks.
3import type { ClientModule } from 'claude-code'
4
5type Props = { since: number; now: number; endAt: number | null; color: string }
6type Ref = { base: number; ticks: number; lastNow: number; isRunning: boolean }
7type State = { ref: Ref }
8
9const fmt = (ms: number) => {
10  const s = Math.max(0, Math.floor(ms / 1000))
11  const m = Math.floor(s / 60)
12  return m < 60 ? `${m}:${String(s % 60).padStart(2, '0')}` : `${Math.floor(m / 60)}h${String(m % 60).padStart(2, '0')}`
13}
14
15const Elapsed: ClientModule<Props, State> = (props, surface) => {
16  const { Text } = surface.elements
17  const ref = surface.state?.ref ?? { base: 0, ticks: 0, lastNow: -1, isRunning: true }
18  if (props.now !== ref.lastNow) {
19    // A fresh reading from the hooks module: restart the local count from it.
20    ref.lastNow = props.now
21    ref.base = (props.endAt ?? props.now) - props.since
22    ref.ticks = 0
23  }
24  ref.isRunning = props.endAt === null
25  if (surface.state === undefined) {
26    surface.setState({ ref })
27    surface.every(1000, () => {
28      if (ref.isRunning) {
29        ref.ticks += 1
30        surface.setState({ ref })
31      }
32    })
33  }
34  return <Text color={props.color}>{fmt(ref.base + ref.ticks * 1000)}</Text>
35}
36
37export default Elapsed
38
hooks/cache.tsx 34 lines
1// 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
34
hooks/pal-sprites.ts 325 lines
1// 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}
325
types/index.d.ts 82 lines
1export 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