SLOPSHOPPER

spotter

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

newpanebandguardcommandtool
★ 1v1.0.5MITupdated 2026-10-15Steady-Matter/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 │ ┃ ╰─────────────────────────────────────────── │ ┃ No helpers yet. A Pal hatches here when Clau ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ out. │ ✻ 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 │ ╰──────────────────────────────────────────────────────╯ No helpers yet. A Pal hatches here when Claude sends one out.
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 Steady-Matter/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.

Tip: close the app's own session panel (the one with the computer, folder, context and connectors) and let Spotter have the side. Spotter shows the context too, plus your limits, the cache and every helper.

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 Steady Matter. MIT licence.

Source 8 files
hooks/register.tsx 760 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    // A new chat in the app often has nothing attached yet: opening then printed a no-surface
171    // line in the chat, so the panel waits for the app to attach (session.attach below).
172    if (cfg.openOnStart && e.surface !== 'terminal' && (await $.session.surfaces()).length > 0) void openPane($).catch(() => undefined)
173    return next(e)
174  })
175
176  on('session.attach', async ($, e, next) => {
177    if (cfg.openOnStart && e.surface !== 'terminal') void openPane($).catch(() => undefined)
178    return next(e)
179  })
180
181  on('session.end', async ($, e, next) => {
182    if (e.reason === 'clear') {
183      await resetAll($)
184    }
185    return next(e)
186  })
187
188  on('command.run', { command: 'spotter' }, async ($, e) => {
189    const verb = e.args.trim().split(/\s+/)[0] || 'open'
190    if (verb === 'close') {
191      await $.ui.close({ id: PANE })
192      return { text: 'Spotter closed.' }
193    }
194    if (verb === 'reset') {
195      await resetAll($)
196      return { text: 'Spotter reset.' }
197    }
198    const opened = await openPane($)
199    if (!opened.isPlaced) return { text: `Spotter is not shown yet: ${opened.reason}` }
200    return { text: 'Spotter opened. Click an agent for its details, or focus the pane with ctrl+x tab: 1-6 open agents.' }
201  })
202
203  on('turn.start', async ($, e, next) => {
204    const [now, cost] = await Promise.all([$.clock.now(), costNow($)])
205    await update($, turn, () => ({ ...DEFAULT_TURN, startedAt: now, costAtStart: cost }))
206    await update($, main, m => ({ ...normalize(DEFAULT_MAIN, m), isRunning: true }))
207    // A finished bar stays up until the next turn starts.
208    if ((await getPlan($))?.isFinished) await update($, plan, () => null)
209    return next(e)
210  })
211
212  on('turn.step', async function* ($, e, next) {
213    if (!e.agentId) {
214      const result = yield* next(e)
215      // Each request renews the prompt cache: the session box counts down from here.
216      const now = await $.clock.now()
217      await update($, main, m => {
218        const x = normalize(DEFAULT_MAIN, m)
219        return { ...x, lastRequestAt: now, cacheTtlMs: cacheTtlOf(result.usage, x.cacheTtlMs) }
220      })
221      return result
222    }
223    const result = yield* next(e)
224    await updateCard($, e.agentId, c => applyStep(c, { model: e.model, usage: result.usage, stopReason: result.stopReason }))
225    return result
226  })
227
228  // The bar's own tool only draws: allowed without asking.
229  on('tool.check', async (_$, e, next) => (String(e.tool) === PROGRESS_TOOL ? { decision: 'allow' } : next(e)))
230
231  on('tool.call', async ($, e, next) => {
232    // The bar's own tool: answered here, and kept off the cards.
233    if (String(e.tool) === PROGRESS_TOOL) {
234      // A helper agent's call counts on its own card; the bar is the main session's.
235      if (e.agentId) {
236        await updateCard($, e.agentId, c => mergeAgentSteps(c, e))
237        return { result: 'ok' }
238      }
239      const p = mergePlan(await getPlan($), e, await $.clock.now())
240      await update($, plan, () => p)
241      return { result: `ok: ${planLabel(p)}` }
242    }
243    const ran = await next(e)
244    // Starting an agent and its hand-back are not work to count.
245    if (String(e.tool) === 'SubagentHandback' || e.tool === 'Agent') return ran
246    const didRun = ran.deny === undefined
247    const hasFailed = didRun && ran.isError === true
248    const isEdit = !hasFailed && didRun && EDIT_TOOLS.has(e.tool)
249    if (isEdit || (hasFailed && !e.agentId)) {
250      await update($, turn, t => afterCall(normalize(DEFAULT_TURN, t), { inSubagent: Boolean(e.agentId), hasFailed, isEdit }))
251    }
252    if (e.agentId) {
253      const text = shorten(describeInput(e.tool, e), 80)
254      await updateCard($, e.agentId, c => noteTool(c, { text, isError: hasFailed || ran.deny !== undefined }))
255    }
256    return ran
257  })
258
259  on('agent.spawn', async ($, e, next) => {
260    const started = await next(e)
261    if (started.deny !== undefined || !started.agentId) return started
262    const card: AgentCard = {
263      ...normalizeCard({}),
264      id: started.agentId,
265      type: e.name ?? e.subagentType,
266      model: started.model,
267      description: e.description,
268      spawnedAt: await $.clock.now(),
269    }
270    await update($, agents, list => [...listOf<unknown>(list).map(normalizeCard), card].slice(-24))
271    return started
272  })
273
274  on('turn.complete', async ($, e, next) => {
275    const done = await next(e)
276    const id = e.agentId
277    if (!id) {
278      const [t, cards, cost, last] = await Promise.all([getTurn($), getCards($), costNow($), getReceipt($)])
279      const r = receiptOf(t, {
280        durationMs: e.durationMs,
281        agentsSince: cards.filter(c => c.spawnedAt >= t.startedAt).length,
282        costNow: cost,
283        reason: e.reason,
284        streakBefore: last?.streak ?? 0,
285      })
286      await update($, receipt, () => r)
287      await update($, main, m => ({ ...normalize(DEFAULT_MAIN, m), isRunning: false }))
288      return done
289    }
290    const now = await $.clock.now()
291    const status = e.reason === 'answer' ? 'done' : e.reason === 'aborted' ? 'stopped' : 'failed'
292    // An agent that finished has finished its own plan, whatever count it last reported.
293    await updateCard($, id, c => ({ ...c, status, endedAt: now, answer: e.answer.trim().slice(0, 3000), stepDone: status === 'done' && c.stepTotal ? c.stepTotal : c.stepDone }))
294    return done
295  })
296
297  // ---------------------------------------------------------------- drawing
298
299  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
300    const els = $.ui.resolve(e)
301    const { Box, Text, Button } = els
302    const hasClient = 'Client' in els
303    const [m, cards, t, r, v, now, u] = await Promise.all([getMain($), getCards($), getTurn($), getReceipt($), getView($), $.clock.now(), $.session.usage().catch(() => null)])
304    const W = Math.max(32, e.props.bodyColumns)
305    // Off the terminal the font is not fixed-pitch: ━ ▰ draw about two cells wide, so rows
306    // built from them hold half as many and clip at their box.
307    const wide = (cells: number) => (e.surface === 'terminal' ? cells : Math.floor(cells / 2))
308    const viewed = e.props.view?.agentId ?? null
309    const running = cards.filter(c => c.status === 'running')
310    const hasReceipt = m.isRunning || r !== null
311    // The session box: how full the context is, the plan's limits, and how long the cache has left.
312    const pct = u?.context.percent
313    const limits = u?.rateLimits ?? []
314    const hasSession = pct !== undefined || limits.length > 0 || m.lastRequestAt > 0 || hasReceipt
315
316    const clock = clockOf(els, now)
317
318    // ---- agents: full rows in the desktop app, one line each in the terminal and when seated inline
319    const statusWords = (c: AgentCard) => (c.status === 'done' ? 'finished' : c.status)
320    const expandOnPress = (id: string) => () =>
321      update($, view, x => ({ ...normalize(DEFAULT_VIEW, x), expanded: normalize(DEFAULT_VIEW, x).expanded === id ? null : id }))
322
323    // Each agent's Pal, picked by its job: a small drawing where the surface draws them (it hops
324    // while the agent works), an emoji in the terminal. Only a drawing big enough to show it grows.
325    const spriteOf = (c: AgentCard, scale: number, grows = false) => {
326      const k = palOf(c)
327      return e.surface !== 'terminal' && 'Svg' in els ? (
328        <els.Svg
329          source={palSvg(k, palLook(c), scale, c.status === 'running', cfg.motion, !grows)}
330          alt={`${palWords(k, palLook(c)).name}${palLook(c).isDown ? '' : `, ${palLook(c).stars} of 4 stars`}`}
331          width={Math.ceil(PAL_GRID.w * scale)}
332          height={Math.ceil(PAL_GRID.h * scale)}
333        />
334      ) : (
335        <Text>{palEmoji(k, palLook(c))}</Text>
336      )
337    }
338    const palLine = (c: AgentCard, withJob: boolean) => {
339      const look = palLook(c)
340      const words = palWords(palOf(c), look)
341      return (
342        <Text wrap="truncate">
343          <Text color={look.isDown ? C.warn : PALS[palOf(c)].color} bold>
344            {words.name}
345          </Text>
346          {withJob && words.job ? <Text dimColor>{` · ${words.job}`}</Text> : null}
347          {!look.isDown && look.stars > 0 ? <Text color={C.amber}>{` ${'★'.repeat(look.stars)}`}</Text> : null}
348        </Text>
349      )
350    }
351    // The agent's own plan as a small gauge when it reports one; how far it has gone otherwise.
352    const stepsRow = (c: AgentCard, w: number) => {
353      const ratio = agentRatio(c)
354      if (ratio === null) {
355        return (
356          <Text dimColor wrap="truncate">
357            {c.steps > 0 ? `${plural(c.steps, 'step')} so far · memory ${kTokens(c.ctx)}` : 'starting…'}
358          </Text>
359        )
360      }
361      const cells = wide(Math.max(4, Math.min(10, w - 12)))
362      const full = Math.round(cells * ratio)
363      return (
364        <Text wrap="truncate">
365          <Text color={PALS[palOf(c)].color}>{'▰'.repeat(full)}</Text>
366          <Text color={C.faint}>{'▱'.repeat(cells - full)}</Text>
367          <Text bold>{` step ${agentSteps(c)}`}</Text>
368          {c.stepNote ? <Text dimColor>{` · ${c.stepNote}`}</Text> : null}
369        </Text>
370      )
371    }
372
373    const isInline = e.props.placement === 'inline'
374    const agentsPanel = (w: number) => {
375      const header = (
376        <Box justifyContent="space-between" width={w}>
377          <Text bold>{`agents · ${running.length} running · ${cards.length} total`}</Text>
378          {w >= 62 ? (
379            <Text color={C.faint}>{e.surface === 'terminal' ? `1-${Math.min(cards.length, 6)} for details` : 'click a name for details'}</Text>
380          ) : null}
381        </Box>
382      )
383      // Where the Pals are drawn (not the terminal) each agent gets a full-width row of its own:
384      // a Pal big enough to see it grow, the whole name, its job and its steps.
385      if (!isInline && e.surface !== 'terminal' && 'Svg' in els) {
386        const shown = cards.slice(-6)
387        return (
388          <Box flexDirection="column" width={w}>
389            {header}
390            {cards.length > shown.length ? <Text color={C.faint}>{`+${cards.length - shown.length} earlier`}</Text> : null}
391            {shown.map((c, i) => {
392              const isViewed = viewed === c.id
393              // The whole name, on two lines when one cannot hold it. The app's font is narrower
394              // than a terminal cell, so a line holds about a third more letters than cells.
395              const room = Math.floor((w - 14) * 1.3)
396              const [first, rest] = titleLines(cardTitle(c), room - 4, room)
397              return (
398                <Box
399                  borderStyle={isViewed ? 'double' : 'bold'}
400                  borderColor={c.lastStop === 'max_tokens' || palLook(c).isDown ? C.warn : PALS[palOf(c)].color}
401                  width={w}
402                  paddingX={1}
403                  columnGap={2}
404                  alignItems="center"
405                >
406                  <Box flexShrink={0}>{spriteOf(c, w >= 40 ? 1.6 : 1, true)}</Box>
407                  {/* minWidth 0 keeps every line inside the card: the left words shorten so the clock and model stay in. */}
408                  <Box flexDirection="column" flexGrow={1} flexShrink={1} minWidth={0}>
409                    <Button key={`card-${c.id}`} plain hotkey={String(i + 1)} label={first} onPress={expandOnPress(c.id)} />
410                    {rest ? <Text wrap="truncate">{rest}</Text> : null}
411                    <Box justifyContent="space-between">
412                      <Box flexShrink={1} minWidth={0}>{palLine(c, true)}</Box>
413                      <Box flexShrink={0}>
414                        <Text color={c.lastStop === 'max_tokens' ? C.warn : statusColor(c)}>{`${glyph(c)} `}</Text>
415                        {clock(`stack-clock-${c.id}`, c.spawnedAt, c.endedAt, C.dim)}
416                      </Box>
417                    </Box>
418                    {/* Its steps on the left, the model it runs on at the right. */}
419                    <Box justifyContent="space-between">
420                      <Box flexShrink={1} minWidth={0}>{stepsRow({ ...c, stepNote: '' }, w - 14)}</Box>
421                      {c.model ? (
422                        <Box flexShrink={0} marginLeft={1}>
423                          <Text color={C.dim}>{prettyModel(c.model)}</Text>
424                        </Box>
425                      ) : null}
426                    </Box>
427                    {c.stepNote && agentRatio(c) !== null ? (
428                      <Text dimColor wrap="truncate">
429                        {c.stepNote}
430                      </Text>
431                    ) : null}
432                  </Box>
433                </Box>
434              )
435            })}
436          </Box>
437        )
438      }
439      const shown = cards.slice(-6)
440      const nameW = Math.max(12, Math.min(30, w - 40))
441      // Beside the name: the Pal, and from 50 columns the agent's own step count.
442      const stepsW = w >= 50 ? 6 : 0
443      const barW = Math.max(4, w - nameW - 13 - stepsW)
444      const geo = lanes(shown, now, wide(barW))
445      return (
446        <Box flexDirection="column" width={w}>
447          {header}
448          {cards.length > shown.length ? <Text color={C.faint}>{`+${cards.length - shown.length} earlier`}</Text> : null}
449          {shown.map((c, i) => {
450            const gm = geo[i]
451            const isViewed = viewed === c.id
452            return (
453              <Box width={w}>
454                <Text color={statusColor(c)} bold={isViewed}>{`${isViewed ? '▶' : glyph(c)} `}</Text>
455                <Box flexShrink={0} marginRight={1}>
456                  {spriteOf(c, 0.5)}
457                </Box>
458                <Box width={nameW}>
459                  <Button key={`card-${c.id}`} plain hotkey={String(i + 1)} label={shorten(cardTitle(c), nameW - 3)} onPress={expandOnPress(c.id)} />
460                </Box>
461                <Box flexGrow={1} overflow="hidden">
462                  <Text color={C.faint}>{' ' + '·'.repeat(gm?.before ?? 0)}</Text>
463                  <Text color={statusColor(c)}>{'━'.repeat(gm?.bar ?? 1)}</Text>
464                  <Text color={C.faint}>{'·'.repeat(gm?.after ?? 0) + ' '}</Text>
465                </Box>
466                {stepsW > 0 ? (
467                  <Box width={stepsW} flexShrink={0}>
468                    <Text color={PALS[palOf(c)].color} wrap="truncate">
469                      {agentSteps(c) ?? ''}
470                    </Text>
471                  </Box>
472                ) : null}
473                <Box flexShrink={0}>{clock(`lane-clock-${c.id}`, c.spawnedAt, c.endedAt, C.dim)}</Box>
474              </Box>
475            )
476          })}
477        </Box>
478      )
479    }
480
481    const expandedCard = cards.find(c => c.id === v.expanded)
482    const expandedPanel = (w: number) =>
483      expandedCard ? (
484        <Box flexDirection="column" borderStyle="single" borderColor={C.agent} paddingX={1} width={w}>
485          <Box columnGap={1} alignItems="center">
486            <Box flexShrink={0}>{spriteOf(expandedCard, 0.75)}</Box>
487            {palLine(expandedCard, true)}
488          </Box>
489          <Text bold wrap="wrap">
490            {expandedCard.description || expandedCard.type}
491          </Text>
492          <Text dimColor wrap="truncate">
493            {`${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`}
494          </Text>
495          {agentRatio(expandedCard) !== null ? stepsRow(expandedCard, w - 4) : null}
496          <Text bold>{expandedCard.tools.length >= 12 ? 'What it did (latest 12):' : 'What it did:'}</Text>
497          {expandedCard.tools.length === 0 ? <Text color={C.faint}>nothing yet</Text> : null}
498          {expandedCard.tools.map(n => (
499            <Text color={n.isError ? C.warn : C.text} wrap="wrap">
500              {`${n.isError ? '✗ failed ·' : '·'} ${n.text}`}
501            </Text>
502          ))}
503          <Text bold>What it reported:</Text>
504          {expandedCard.answer ? null : <Text color={C.faint}>{expandedCard.status === 'running' ? 'still working' : 'nothing'}</Text>}
505          {expandedCard.answer
506            .split('\n')
507            .filter(l => l.trim())
508            .slice(0, 60)
509            .map(l => (
510              <Text dimColor wrap="wrap">
511                {l.trimEnd()}
512              </Text>
513            ))}
514          {expandedCard.answer.length >= 3000 ? <Text color={C.faint}>(cut at 3,000 characters)</Text> : null}
515          <Text color={C.faint}>{e.surface === 'terminal' ? 'press its number again to close' : 'click its name again to close'}</Text>
516        </Box>
517      ) : null
518
519    const row = (label: string, value: RenderChildren) => (
520      <Box>
521        <Box width={e.surface === 'terminal' ? 14 : 10} flexShrink={0}>
522          <Text dimColor>{label}</Text>
523        </Box>
524        {/* minWidth 0 lets the value narrow to the box, so a long one wraps instead of spilling past the border. */}
525        <Box flexGrow={1} flexShrink={1} minWidth={0}>
526          {value}
527        </Box>
528      </Box>
529    )
530    // The reply under way, or the last one: only the counts that are not zero.
531    const helpersNow = t.startedAt > 0 ? cards.filter(c => c.spawnedAt >= t.startedAt).length : 0
532    const counts = (helpers: number, edits: number, errors: number) => {
533      const quiet = [helpers > 0 ? plural(helpers, 'helper') : '', edits > 0 ? plural(edits, 'file change') : ''].filter(Boolean).join(' · ')
534      if (!quiet && errors === 0) return null
535      return row(
536        '',
537        <Text wrap="truncate">
538          <Text dimColor>{quiet}</Text>
539          {errors > 0 ? <Text color={C.warn}>{`${quiet ? ' · ' : ''}${plural(errors, 'error')}`}</Text> : null}
540        </Text>,
541      )
542    }
543    // One box at the top: how full the context is, the plan's limits, how long the cache has
544    // left, and the reply under way or the last one.
545    const sessionPanel = (w: number) => {
546      const cells = wide(10)
547      const full = Math.round((cells * Math.min(100, pct ?? 0)) / 100)
548      const left = u?.context.tokens !== undefined ? Math.max(0, u.context.window - u.context.tokens) : null
549      const cache = cacheLabel(m.lastRequestAt + m.cacheTtlMs - now)
550      const cacheColor = { plain: C.dim, soon: C.amber, cold: C.warn }
551      return (
552        <Box flexDirection="column" borderStyle="round" borderColor={C.dim} paddingX={1} width={w}>
553          {pct !== undefined
554            ? row(
555                'Context',
556                <Text wrap="truncate">
557                  <Text color={pct >= 70 ? C.warn : pct >= 40 ? C.amber : C.good}>{'▰'.repeat(full)}</Text>
558                  <Text color={C.faint}>{'▱'.repeat(cells - full)}</Text>
559                  <Text bold>{` ${Math.round(pct)}% used`}</Text>
560                </Text>,
561              )
562            : null}
563          {pct !== undefined && left !== null && u ? row('', <Text dimColor wrap="truncate">{`${kTokens(left)} left of ${kTokens(u.context.window)}`}</Text>) : null}
564          {limits.map(l =>
565            row(
566              limitLabel(l.kind),
567              <Text color={l.percentUsed >= 80 ? C.warn : l.percentUsed >= 50 ? C.amber : C.text} wrap="truncate">{`${Math.round(l.percentUsed)}% used`}</Text>,
568            ),
569          )}
570          {m.lastRequestAt > 0
571            ? row(
572                'Cache',
573                hasClient ? (
574                  <els.Client key="cache-clock" module="./cache.tsx" props={{ expiresAt: m.lastRequestAt + m.cacheTtlMs, now, ...cacheColor }} />
575                ) : (
576                  <Text color={cacheColor[cache.tone]} wrap="wrap">
577                    {cache.text}
578                  </Text>
579                ),
580              )
581            : null}
582          {m.isRunning
583            ? row(
584                'This reply',
585                <Box>
586                  <Text color={C.main}>{'◐ working · '}</Text>
587                  <Box flexShrink={0}>{clock('turn-clock', t.startedAt, null, C.main)}</Box>
588                </Box>,
589              )
590            : r
591              ? row('Last reply', <Text wrap="truncate">{`${fmtDuration(r.durationMs)}${r.costDelta !== null ? ` · ${fmtUsd(r.costDelta)}` : ''}`}</Text>)
592              : null}
593          {m.isRunning ? counts(helpersNow, t.edits, t.errors) : r ? counts(r.agents, r.edits, r.errors) : null}
594          {!m.isRunning && r ? (
595            <Text color={toneColor(receiptHeadline(r).tone)} bold wrap="wrap">
596              {headlineText(r)}
597            </Text>
598          ) : null}
599          {!m.isRunning && r && r.streak >= 2 ? <Text color={C.amber}>{`🔥 ${r.streak} clean replies in a row`}</Text> : null}
600        </Box>
601      )
602    }
603
604    // Seated inline above the prompt the pane has 8 rows: the agents' lines alone fill them.
605    if (isInline) {
606      return (
607        <Box flexDirection="column" width={W}>
608          {cards.length > 0 ? agentsPanel(W) : <Text color={C.faint}>No helpers yet.</Text>}
609        </Box>
610      )
611    }
612
613    return (
614      <Box flexDirection="column" width={W}>
615        {hasSession ? sessionPanel(W) : null}
616        {cards.length > 0 ? (
617          agentsPanel(W)
618        ) : (
619          <Text color={C.faint} wrap="wrap">
620            No helpers yet. A Pal hatches here when Claude sends one out.
621          </Text>
622        )}
623        {expandedPanel(W)}
624      </Box>
625    )
626  })
627
628  // Above the prompt: the progress bar from the plan Claude reported, and in the terminal the
629  // strip that stands in for the side panel: a line per helper while any runs, else the last reply.
630  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
631    if (e.props.hasSurvey) return next(e)
632    const isTerminal = e.surface === 'terminal'
633    const [p, cards, m, r, now] = await Promise.all([getPlan($), getCards($), getMain($), getReceipt($), $.clock.now()])
634    const running = cards.filter(c => c.status === 'running')
635    // While any helper runs, every helper of that batch keeps its line, the finished ones ticked.
636    const since = Math.min(...running.map(c => c.spawnedAt))
637    const batch = isTerminal && running.length > 0 ? cards.filter(c => c.status === 'running' || c.spawnedAt >= since) : []
638    const head = isTerminal && batch.length === 0 && !m.isRunning && r ? receiptHeadline(r) : null
639    if (!p && batch.length === 0 && !head) return next(e)
640    // Another mod's band (the radio, say) keeps its row under this one.
641    const beneath = await next(e)
642    const els = $.ui.resolve(e)
643    const { Box, Text, Button } = els
644    const cols = e.props.bodyColumns || 80
645
646    const helperLine = (c: AgentCard) => {
647      const k = palOf(c)
648      const look = palLook(c)
649      const ratio = agentRatio(c)
650      const full = Math.round(6 * (ratio ?? 0))
651      return (
652        <Box width={cols}>
653          <Text color={statusColor(c)}>{`${glyph(c)} `}</Text>
654          {/* The Pal and its stars sit in fixed slots, so the names line up as the Pals grow. */}
655          <Box width={5} flexShrink={0}>
656            <Text>{palEmoji(k, look)}</Text>
657          </Box>
658          <Box width={5} flexShrink={0}>
659            <Text color={C.amber}>{look.isDown ? '' : '★'.repeat(look.stars)}</Text>
660          </Box>
661          <Box width={Math.max(12, Math.min(44, cols - 48))} flexShrink={0}>
662            <Text wrap="truncate">{cardTitle(c)}</Text>
663          </Box>
664          <Box flexGrow={1} overflow="hidden">
665            {ratio === null ? (
666              <Text dimColor wrap="truncate">{c.steps > 0 ? ` ${plural(c.steps, 'step')} so far` : ' starting…'}</Text>
667            ) : (
668              <Text wrap="truncate">
669                <Text color={PALS[k].color}>{` ${'▰'.repeat(full)}`}</Text>
670                <Text color={C.faint}>{'▱'.repeat(6 - full)}</Text>
671                <Text bold>{` ${agentSteps(c)}`}</Text>
672                {c.stepNote && cols >= 80 ? <Text dimColor>{` · ${c.stepNote}`}</Text> : null}
673              </Text>
674            )}
675          </Box>
676          {c.model && cols >= 90 ? (
677            <Box width={12} flexShrink={0}>
678              <Text color={C.dim} wrap="truncate">
679                {prettyModel(c.model)}
680              </Text>
681            </Box>
682          ) : null}
683          <Box flexShrink={0}>{clockOf(els, now)(`strip-clock-${c.id}`, c.spawnedAt, c.endedAt, C.dim)}</Box>
684        </Box>
685      )
686    }
687    const shown = batch.slice(-5)
688    const strip = (
689      <Box flexDirection="column">
690        {batch.length > shown.length ? <Text color={C.faint}>{`+${batch.length - shown.length} more helpers · /spotter for all`}</Text> : null}
691        {shown.map(helperLine)}
692        {head && r ? (
693          <Text wrap="truncate">
694            <Text color={toneColor(head.tone)}>{headlineText(r)}</Text>
695            <Text dimColor>
696              {`${head.text.includes(fmtDuration(r.durationMs)) ? '' : ` · ${fmtDuration(r.durationMs)}`}${r.costDelta !== null ? ` · ${fmtUsd(r.costDelta)}` : ''}`}
697            </Text>
698            {r.streak >= 2 ? <Text color={C.amber}>{` · 🔥 ${r.streak} in a row`}</Text> : null}
699          </Text>
700        ) : null}
701      </Box>
702    )
703    if (!p) {
704      return (
705        <Box flexDirection="column">
706          {strip}
707          {beneath}
708        </Box>
709      )
710    }
711
712    const color = p.isFinished ? DONE : ACCENT
713    const percent = `${Math.round(planRatio(p) * 100)}%`
714    const crew = cards.length > 0 ? <Button key="bar-agents" plain label={`🐾×${cards.length}`} onPress={() => void openPane($).catch(() => undefined)} /> : null
715    const barPal = palEmoji(palOf({ type: '', description: p.title, model: '' }), planLook(p))
716    const dismiss = <Button key="bar-dismiss" plain label="✕" onPress={() => update($, plan, () => null)} />
717
718    // The terminal's table carries an Svg that draws nothing, so the surface decides, not the table.
719    if (!isTerminal && 'Svg' in els) {
720      // About 8 px to the column; the rest of the row is the two buttons and their gaps.
721      const width = Math.max(180, Math.min(1600, cols * 8 - 96))
722      return (
723        <Box flexDirection="column">
724          <Box flexDirection="row" alignItems="center" gap={1}>
725            <els.Svg source={barSvg(p, width, e.props.isWorking)} alt={`${p.title}: ${planLabel(p)}, ${percent}`} width={width} height={BAR_HEIGHT} />
726            {crew}
727            {dismiss}
728          </Box>
729          {beneath}
730        </Box>
731      )
732    }
733
734    const titleW = Math.max(8, Math.min(30, p.title.length + 2, Math.floor(cols / 3)))
735    return (
736      <Box flexDirection="column">
737        <Box flexDirection="row" gap={2}>
738          <Box width={titleW} flexShrink={0}>
739            <Text color={color}>● </Text>
740            <Text wrap="truncate">{p.title}</Text>
741          </Box>
742          {cols >= 90 ? (
743            <Text color={p.isFinished ? DONE : undefined} dimColor={!p.isFinished} wrap="truncate">
744              {shorten(planHype(p), 24)}
745            </Text>
746          ) : null}
747          <Text color={color}>{planBar(p, Math.max(6, Math.min(40, cols - titleW - (cols >= 90 ? 64 : 38))))}</Text>
748          <Text bold>{planLabel(p)}</Text>
749          <Text dimColor>{percent}</Text>
750          <Text>{barPal}</Text>
751          {crew}
752          {dismiss}
753        </Box>
754        {strip}
755        {beneath}
756      </Box>
757    )
758  })
759}
760
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 417 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  // Break at a space, or else just after a - : / or _, so a name like read:projects-mods is not cut mid-word.
246  const space = t.lastIndexOf(' ', first)
247  const mark = Math.max(...['-', ':', '/', '_'].map(m => t.lastIndexOf(m, first - 1) + 1))
248  const at = space > 0 ? space : mark > 1 ? mark : first
249  return [t.slice(0, at).trim(), shorten(t.slice(at), rest)]
250}
251
252// ---------------------------------------------------------------- plan (the bar above the prompt)
253
254const DEFAULT_PLAN: Plan = { title: 'Working', total: 0, done: 0, isFinished: false, step: '', startedAt: 0, endedAt: null }
255
256export const normalizePlan = (stored: unknown): Plan | null => (isObject(stored) ? normalize(DEFAULT_PLAN, stored) : null)
257
258const count = (v: unknown, fallback: number) => (typeof v === 'number' && Number.isFinite(v) ? Math.max(0, Math.round(v)) : fallback)
259
260/**
261 * The plan after one `progress` call at `now`. Fields left out keep their value; a new title, or
262 * a call after the last plan finished, starts a fresh plan so counts never carry over.
263 */
264export const mergePlan = (prev: Plan | null, input: unknown, now = 0): Plan => {
265  const i = isObject(input) ? input : {}
266  const title = typeof i.title === 'string' ? shorten(i.title, 60) : ''
267  const isFresh = !prev || prev.isFinished || (title !== '' && title !== prev.title)
268  const base = isFresh ? { ...DEFAULT_PLAN, startedAt: now } : prev
269  const total = count(i.total, base.total)
270  const done = count(i.done, base.done)
271  const isFinished = i.finished === true
272  return {
273    title: title || base.title,
274    total,
275    done: total > 0 ? Math.min(total, done) : done,
276    isFinished,
277    step: isFinished ? '' : typeof i.step === 'string' ? shorten(i.step, 40) : base.step,
278    startedAt: base.startedAt,
279    endedAt: isFinished ? now : null,
280  }
281}
282
283export const planRatio = (p: Plan) => (p.isFinished ? 1 : p.total > 0 ? p.done / p.total : 0)
284
285const planTook = (p: Plan) => (p.endedAt !== null && p.startedAt > 0 && p.endedAt >= p.startedAt ? fmtDuration(p.endedAt - p.startedAt) : '')
286
287export const planLabel = (p: Plan) =>
288  p.isFinished ? (planTook(p) ? `Done in ${planTook(p)}` : 'Done') : p.total > 0 ? `Steps ${p.done}/${p.total}` : 'Working'
289
290/** The few words beside the title: the step under way when Claude named it, a nudge otherwise. */
291export const planHype = (p: Plan) => {
292  if (p.isFinished) return 'Nailed it'
293  if (p.step) return p.step
294  if (p.total <= 0) return 'On it'
295  if (p.done === 0) return 'Warming up'
296  if (p.done >= p.total) return 'Wrapping up'
297  if (p.total - p.done === 1) return 'One step left'
298  return planRatio(p) >= 0.75 ? 'Home stretch' : planRatio(p) >= 0.5 ? 'Past halfway' : 'Rolling'
299}
300
301/** The bar as the terminal draws it: █ filled, ░ empty. */
302export const planBar = (p: Plan, width: number) => {
303  const full = Math.max(0, Math.min(width, Math.round(width * planRatio(p))))
304  return '█'.repeat(full) + '░'.repeat(width - full)
305}
306
307// ---------------------------------------------------------------- Pals (one per agent, picked by its job)
308
309/** A helper agent's own plan after one of its `progress` calls: the count on its card. */
310export const mergeAgentSteps = (c: AgentCard, input: unknown): AgentCard => {
311  const i = isObject(input) ? input : {}
312  const total = count(i.total, c.stepTotal ?? 0)
313  const done = i.finished === true && total > 0 ? total : count(i.done, c.stepDone ?? 0)
314  const note = typeof i.step === 'string' ? shorten(i.step, 40) : typeof i.title === 'string' && c.stepTotal === null ? shorten(i.title, 40) : c.stepNote
315  return { ...c, stepTotal: total, stepDone: total > 0 ? Math.min(total, done) : done, stepNote: i.finished === true ? '' : note }
316}
317
318/** `2/5` when the agent reports a plan of its own, null when it does not. */
319export const agentSteps = (c: AgentCard) => (c.stepTotal !== null && c.stepTotal > 0 ? `${c.stepDone ?? 0}/${c.stepTotal}` : null)
320
321export const agentRatio = (c: AgentCard) => (c.stepTotal !== null && c.stepTotal > 0 ? Math.min(1, (c.stepDone ?? 0) / c.stepTotal) : null)
322
323// First match wins, so the order is the tie-break: "review the build" is checking, Depresso's job.
324const PAL_RULES: [Pal, RegExp][] = [
325  ['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],
326  ['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],
327  ['hoocrates', /plan|architect|strateg|design the|spec\b|outlin|roadmap|propos|scope|brainstorm/i],
328  ['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],
329  ['foxparks', /quick|fast|status ?line|rename|format|tidy|remov|delet|clean|\bdrop\b|retir|trim|\bmove\b|prune/i],
330  ['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],
331]
332
333/** Types that say nothing about the job: the task description decides. */
334const GENERIC_TYPES = /^(general-purpose|claude|agent|fork)$/i
335
336/** A type names its job in a word, so the rules apply in their order. */
337const palFor = (text: string) => PAL_RULES.find(([, re]) => re.test(text))?.[0] ?? null
338
339/** A task is a sentence: the job is its first telling word ("Add tests" builds, "Test the add" checks). */
340const palForTask = (text: string): Pal | null => {
341  let best: { k: Pal; at: number } | null = null
342  for (const [k, re] of PAL_RULES) {
343    const at = text.search(re)
344    if (at >= 0 && (!best || at < best.at)) best = { k, at }
345  }
346  return best?.k ?? null
347}
348
349/**
350 * The Pal for an agent: its type decides when the type names a job (Explore, a reviewer), its
351 * task description otherwise; a small fast model with nothing else to go on is Foxparks, and
352 * anything left is building, the commonest job, so every agent has a Pal.
353 */
354export const palOf = (c: Pick<AgentCard, 'type' | 'description' | 'model'>): Pal =>
355  (GENERIC_TYPES.test(c.type) ? null : palFor(c.type)) ?? palForTask(c.description) ?? (/haiku/i.test(c.model) ? 'foxparks' : 'anubis')
356
357/** Each Pal's name, the job it stands for, a terminal stand-in for its drawing, and its accent colour. */
358export const PALS: Record<Pal, { name: string; job: string; emoji: string; color: string }> = {
359  vixy: { name: 'Vixy', job: 'scouting', emoji: '🦊', color: '#E0A526' },
360  depresso: { name: 'Depresso', job: 'checking', emoji: '🐨', color: '#5B9BD5' },
361  anubis: { name: 'Anubis', job: 'building', emoji: '🐺', color: '#E08A3A' },
362  lifmunk: { name: 'Lifmunk', job: 'writing', emoji: '🌿', color: '#5FBF8F' },
363  hoocrates: { name: 'Hoocrates', job: 'planning', emoji: '🦉', color: '#8F8CF4' },
364  foxparks: { name: 'Foxparks', job: 'quick job', emoji: '🔥', color: '#E05A5A' },
365}
366
367/** About one agent in twenty has a Lucky Pal; the same agent always gets the same answer. It shows once the agent is done. */
368export const isLucky = (id: string) => [...id].reduce((h, ch) => (h * 31 + ch.charCodeAt(0)) >>> 0, 7) % 20 === 0
369
370/**
371 * How an agent's Pal looks now. It is an egg until the agent has done something, then grows with
372 * the agent's own plan when it reports one and with its steps when it does not, and is fully
373 * grown once the agent is done, which is also when a Lucky one shows it: every Pal grows the
374 * same way first. An agent that failed or was stopped has a knocked-out Pal.
375 */
376export const palLook = (c: AgentCard): PalLook => {
377  const ratio = agentRatio(c)
378  const growing = ratio !== null ? (ratio <= 0 ? 0 : ratio < 0.5 ? 1 : 2) : c.steps === 0 ? 0 : c.steps < 5 ? 1 : 2
379  return { stars: c.status === 'done' ? 4 : growing, isLucky: c.status === 'done' && isLucky(c.id), isDown: c.status === 'failed' || c.status === 'stopped' }
380}
381
382/** The Pal in words: `Vixy · scouting`, `Egg · incubating`, `Lucky Lifmunk · writing`, or that it is knocked out. */
383export const palWords = (k: Pal, look: PalLook) =>
384  look.isDown
385    ? { name: `✗ ${PALS[k].name} knocked out`, job: '' }
386    : look.stars === 0
387      ? { name: 'Egg', job: 'incubating' }
388      : { name: `${look.isLucky ? 'Lucky ' : ''}${PALS[k].name}`, job: PALS[k].job }
389
390/** The bar's own Pal: an egg until the first step is done, then grown, and Lucky once the job is finished. */
391export const planLook = (p: Plan): PalLook => ({ stars: p.isFinished ? 4 : p.done > 0 ? 2 : 0, isLucky: p.isFinished, isDown: false })
392
393/** The terminal's stand-in for the drawing: an egg, the Pal's emoji (sparkling when Lucky), or a skull. */
394export const palEmoji = (k: Pal, look: PalLook) => (look.isDown ? '💀' : look.stars === 0 ? '🥚' : `${PALS[k].emoji}${look.isLucky ? '✨' : ''}`)
395
396// ---------------------------------------------------------------- the session box
397
398/**
399 * How long the prompt cache lasts, read from what a request wrote to it: five minutes when it
400 * wrote only five-minute entries, an hour when it wrote hour ones, the last known value otherwise.
401 */
402export const cacheTtlOf = (usage: unknown, previous: number): number => {
403  const w = (usage as { cache_creation?: { ephemeral_5m_input_tokens?: number; ephemeral_1h_input_tokens?: number } } | null)?.cache_creation
404  if ((w?.ephemeral_1h_input_tokens ?? 0) > 0) return 3_600_000
405  if ((w?.ephemeral_5m_input_tokens ?? 0) > 0) return 300_000
406  return previous
407}
408
409/** The cache line for the time left on it. `hooks/cache.tsx` draws the same three cases as it counts down. */
410export const cacheLabel = (msLeft: number): { text: string; tone: 'plain' | 'soon' | 'cold' } => {
411  const min = Math.ceil(msLeft / 60_000)
412  if (min <= 0) return { text: 'cold · next message re-reads it all', tone: 'cold' }
413  return { text: `warm · ~${min} min left`, tone: min <= 10 ? 'soon' : 'plain' }
414}
415
416export const limitLabel = (kind: string) => (kind === 'five_hour' ? '5-hour limit' : kind === 'seven_day' ? 'Weekly limit' : kind.replace(/_/g, ' '))
417
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} wrap="wrap">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