A live panel of this session's subagents as tiny vehicles: what each is doing, its model, effort, steps, context, tokens and time, with an activity bar and a…

A Claude Code mod: a live panel of the session's subagents, each drawn as a tiny animated vehicle that shows what it is doing.
| Agent is… | Vehicle |
|---|---|
| Searching or reading (Read, Grep, Glob) | Submarine |
| Editing (Edit, Write) | Bulldozer |
| Running commands (Bash) | Race car |
| On the web or using MCP tools | Rocket |
| Writing its final answer | Typewriter |
| Not started yet | Parked car |
Each vehicle is painted in its model's color (Opus clay, Sonnet blue, Haiku green; grey if it failed). In Ghostty and kitty the vehicles are real images; every other terminal gets the same vehicles in block characters.
Each row also shows the title, agent type, model and effort, steps reported so far with the agent's note, context use, tokens and elapsed time, plus an activity bar that sweeps while the agent works. Press a title to open that agent's transcript. When the agents don't fit, rows collapse to title and bar. Finished rows clear themselves an hour after the agent ends.
| Command | Does |
|---|---|
/squad | open the panel |
/squad close | close it |
/squad reset | clear every row |
The panel also opens by itself when an agent starts, and a one-line band above the prompt shows how many are running.
/config)autoOpen (default on): open the panel when an agent starts.askForSteps (default on): add one line to each subagent's prompt asking it to report its steps through the mod's step tool.claude plugin marketplace add anpato/claude-plugins
claude plugin install squad@apato-plugins
Mods are an early-access Claude Code feature, and their API can change between releases.
claude plugin validate plugins/squad and claude plugin test plugins/squad.
hooks/register.tsx 516 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { SquadActivity, SquadAgent, SquadUsage } from '../types'
5import { formatDuration, formatTokens, modelLabel, totalTokens } from './format'
6import { HIRES_COLUMNS, HIRES_ROWS, hiresPixels } from './hires'
7import { ACTIVITY_LABEL, SPRITE_COLUMNS, SPRITE_ROWS, hex, modelColor, spriteCells } from './sprite'
8
9const PANE = 'squad'
10const STEP_TOOL = 'mcp__squad__step'
11const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
12const DONE_COLOR = '#5fb87a'
13const FAILED_COLOR = '#e06c75'
14const BAR_TRACK = '#3a3f4b'
15const BAR_TRACK_RGB = 0x3a3f4b
16const VIEWED_COLOR = '#61afef'
17// Cells kept clear at the pane's left and right edges.
18const PANE_PADDING = 2
19// The bar shows activity, not progress: a pulse sweeps left to right, fading
20// behind it, while the agent works; finished it is solid.
21const SWEEP_MS = 100
22const SWEEP_TAIL = 10
23// Rows a full agent row takes (four lines and a gap) and a compact one (title and bar).
24const FULL_ROW_LINES = 5
25const COMPACT_ROW_LINES = 2
26
27const STEP_NOTE =
28 '\n\n(Progress reporting: if `mcp__squad__step` is among your tools, call it once you have ' +
29 'a plan, with `total` steps and `done: 0`, then again with `done` and a few-word `note` as ' +
30 'each step finishes. Skip it if the tool is absent.)'
31
32const agents = atom({ plugin: 'squad', key: 'agents' } as const, [])
33const tick = atom({ plugin: 'squad', key: 'tick' } as const, 0)
34const sweep = atom({ plugin: 'squad', key: 'sweep' } as const, 0)
35const openable = atom({ plugin: 'squad', key: 'openable' } as const, [])
36const contextWindow = atom({ plugin: 'squad', key: 'contextWindow' } as const, 0)
37const isFinishedCollapsed = atom({ plugin: 'squad', key: 'isFinishedCollapsed' } as const, false)
38const hasPixels = atom({ plugin: 'squad', key: 'hasPixels' } as const, false)
39
40// A finished agent's row is dropped this long after it ended.
41const FINISHED_ROW_TTL_MS = 60 * 60 * 1000
42
43const SEARCH_TOOLS = new Set(['Read', 'Grep', 'Glob', 'LSP', 'ToolSearch', 'NotebookRead'])
44const EDIT_TOOLS = new Set(['Edit', 'MultiEdit', 'Write', 'NotebookEdit'])
45const RUN_TOOLS = new Set(['Bash', 'PowerShell', 'Monitor', 'TaskStop'])
46const WEB_TOOLS = new Set(['WebFetch', 'WebSearch'])
47
48// What a tool call says the agent is doing; undefined for calls that say nothing.
49const activityOfTool = (tool: string): SquadActivity | undefined => {
50 if (tool === STEP_TOOL) return undefined
51 if (SEARCH_TOOLS.has(tool)) return 'search'
52 if (EDIT_TOOLS.has(tool)) return 'edit'
53 if (RUN_TOOLS.has(tool)) return 'run'
54 if (WEB_TOOLS.has(tool) || tool.startsWith('mcp__')) return 'web'
55 return undefined
56}
57
58const withActivity = (a: SquadAgent, activity: SquadActivity): SquadAgent => ({
59 ...a,
60 activity,
61 activityCounts: { ...a.activityCounts, [activity]: (a.activityCounts?.[activity] ?? 0) + 1 },
62})
63
64// The vehicle to draw: what a running agent is doing now, what a finished one
65// did most; an Explore agent that has not called a tool yet is searching.
66const vehicleOf = (a: SquadAgent): SquadActivity | undefined => {
67 if (a.status !== 'running' && a.activityCounts !== undefined) {
68 const counted = Object.entries(a.activityCounts) as [SquadActivity, number][]
69 const top = counted.filter(([kind]) => kind !== 'answer').sort((x, y) => y[1] - x[1])[0]
70 if (top !== undefined) return top[0]
71 }
72 return a.activity ?? (a.type === 'Explore' ? 'search' : undefined)
73}
74
75const ZERO: SquadUsage = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }
76
77const addUsage = (a: SquadUsage, b: SquadUsage): SquadUsage => ({
78 input: a.input + b.input,
79 output: a.output + b.output,
80 cacheRead: a.cacheRead + b.cacheRead,
81 cacheWrite: a.cacheWrite + b.cacheWrite,
82})
83
84const patchAgent = ($: EngineInterface, id: string, patch: (a: SquadAgent) => SquadAgent) =>
85 update($, agents, list => list.map(a => (a.id === id ? patch(a) : a)))
86
87const openPane = ($: EngineInterface) => $.ui.open({ id: PANE, title: 'Squad', columns: 52 })
88
89const isPaneOpen = async ($: EngineInterface) =>
90 (await $.ui.panes().catch(() => [])).some(p => p.id === PANE)
91
92// The agents whose transcripts can still be opened, read once a second and on
93// each spawn rather than on every redraw.
94const refreshOpenable = async ($: EngineInterface) => {
95 const listed = await $.agent.list().catch(() => [])
96 const ids = listed.map(a => a.id).sort()
97 const current = await read($, openable)
98 if (ids.join() !== [...current].sort().join()) await update($, openable, () => ids)
99}
100
101// A whole, non-negative count from whatever the model sent; undefined for
102// anything that is not a finite number (or numeric string).
103const toCount = (value: unknown): number | undefined => {
104 const n = typeof value === 'string' && value.trim() !== '' ? Number(value) : value
105 return typeof n === 'number' && Number.isFinite(n) ? Math.max(0, Math.round(n)) : undefined
106}
107
108// A color between two, `t` from 0 (the first) to 1 (the second).
109const mix = (from: number, to: number, t: number): number => {
110 const ch = (shift: number) =>
111 Math.round(((from >> shift) & 0xff) + (((to >> shift) & 0xff) - ((from >> shift) & 0xff)) * t)
112 return (ch(16) << 16) | (ch(8) << 8) | ch(0)
113}
114
115// The lit stretch of a working bar: a pulse at `head` with a tail fading out
116// over the SWEEP_TAIL - 1 cells behind it. Every frame lights at least one
117// cell (the cycle is width + SWEEP_TAIL - 1 long, so it never goes dark).
118const sweepPulse = (width: number, step: number): { from: number; levels: number[] } => {
119 const head = step % (width + SWEEP_TAIL - 1)
120 const from = Math.max(0, head - SWEEP_TAIL + 1)
121 const to = Math.min(width - 1, head)
122 const levels: number[] = []
123 for (let i = from; i <= to; i++) levels.push(1 - (head - i) / SWEEP_TAIL)
124 return { from, levels }
125}
126
127const stepsLabel = (a: SquadAgent): string => {
128 if (a.stepsTotal !== undefined) {
129 const done = a.status === 'done' ? a.stepsTotal : Math.min(a.stepsTotal, a.stepsDone ?? 0)
130 return `${done}/${a.stepsTotal}`
131 }
132 return a.status === 'running' ? '…' : ''
133}
134
135export const register: Register = (on, options) => {
136 // Whether the pane is open, refreshed once a second: the sweep only runs then.
137 let isOpen = false
138
139 on('session.start', async ($, e, next) => {
140 await $.command.register({
141 name: 'squad',
142 description: 'Show the subagents panel (open, close or reset)',
143 argumentHint: '[open|close|reset]',
144 })
145 await $.tool.register({
146 name: 'step',
147 description:
148 'Report progress on your own task to the squad panel. After reading your task, call it ' +
149 'with `total` (your plan in 3-8 steps) and `done: 0`; call it again as each step ' +
150 'finishes. Silent and cheap: it only draws a progress bar.',
151 inputSchema: {
152 type: 'object',
153 properties: {
154 done: { type: 'integer', minimum: 0, description: 'Steps finished so far.' },
155 total: { type: 'integer', minimum: 1, description: 'Steps planned; may change.' },
156 note: { type: 'string', description: 'The step in progress, a few words.' },
157 },
158 required: ['done'],
159 },
160 isDeferred: false,
161 })
162
163 // Ghostty and kitty draw the Image element as real pixels; elsewhere it
164 // would fall back to text, so those terminals keep the block vehicles.
165 const program = (await $.env.get('TERM_PROGRAM')) ?? ''
166 const term = (await $.env.get('TERM')) ?? ''
167 await update($, hasPixels, () => /ghostty|kitty/i.test(`${program} ${term}`))
168
169 const { context } = await $.session.usage()
170 await update($, contextWindow, () => context.window)
171
172 // Redraw clocks and walking mascots once a second while anything runs, and
173 // drop finished rows an hour after they ended.
174 $.clock.every(1000, async () => {
175 isOpen = await isPaneOpen($)
176 if (isOpen) await refreshOpenable($)
177 const list = await read($, agents)
178 if (list.some(a => a.status === 'running')) await update($, tick, n => n + 1)
179
180 const now = await $.clock.now()
181 const isExpired = (a: SquadAgent) =>
182 a.status !== 'running' && a.endedAt !== undefined && now - a.endedAt > FINISHED_ROW_TTL_MS
183 if (list.some(isExpired)) await update($, agents, all => all.filter(a => !isExpired(a)))
184 })
185 // Only while the pane is open: nothing else shows the sweep.
186 $.clock.every(SWEEP_MS, async () => {
187 if (!isOpen) return
188 const list = await read($, agents)
189 if (list.some(a => a.status === 'running')) await update($, sweep, n => n + 1)
190 })
191
192 return next(e)
193 })
194
195 on('command.run', { command: 'squad' }, async ($, e) => {
196 const arg = e.args.trim()
197 if (arg === 'close') {
198 await $.ui.close({ id: PANE })
199 return { text: 'Squad panel closed.' }
200 }
201 if (arg === 'reset') {
202 await update($, agents, () => [])
203 return { text: 'Squad cleared.' }
204 }
205 await openPane($)
206 return { text: 'Squad panel opened.' }
207 })
208
209 on('agent.spawn', async ($, e, next) => {
210 const canRewrite = e.workflow === undefined && e.resume === undefined
211 const shouldAsk = options.askForSteps !== false && canRewrite && !e.prompt.includes(STEP_TOOL)
212 const started = await next(shouldAsk ? { ...e, prompt: e.prompt + STEP_NOTE } : e)
213 if (started.deny !== undefined || started.agentId === undefined) return started
214
215 const agent: SquadAgent = {
216 id: started.agentId,
217 title: e.description || e.subagentType,
218 type: e.subagentType,
219 model: started.model,
220 status: 'running',
221 startedAt: await $.clock.now(),
222 usage: ZERO,
223 contextTokens: 0,
224 }
225 await update($, agents, list => [...list.filter(a => a.id !== agent.id), agent].slice(-100))
226 await refreshOpenable($)
227
228 if (options.autoOpen !== false && !(await isPaneOpen($))) {
229 void openPane($).catch(() => undefined)
230 }
231 return started
232 }).catch(($, e, next) => next(e)) // never block a spawn over the panel's bookkeeping
233
234 // A teammate wakes for another turn: it is running again.
235 on('turn.start', async ($, e, next) => {
236 const { agentId } = e
237 if (agentId !== undefined) {
238 await patchAgent($, agentId, a =>
239 a.status === 'running' ? a : { ...a, status: 'running', endedAt: undefined },
240 )
241 }
242 return next(e)
243 })
244
245 // Each model request of a subagent: its tokens, effort and live context.
246 on('turn.step', async function* ($, e, next) {
247 const result = yield* next(e)
248 const { agentId } = e
249 const reported = result.usage
250 if (agentId === undefined) return result
251 if (result.toolUses.length === 0 && result.answer !== '') {
252 await patchAgent($, agentId, a => withActivity(a, 'answer'))
253 }
254 if (reported === null) return result
255
256 const usage: SquadUsage = {
257 input: reported.input_tokens,
258 output: reported.output_tokens,
259 cacheRead: reported.cache_read_input_tokens,
260 cacheWrite: reported.cache_creation_input_tokens,
261 }
262 await patchAgent($, agentId, a => ({
263 ...a,
264 model: reported.model,
265 effort: e.effort === undefined ? a.effort : String(e.effort),
266 usage: addUsage(a.usage, usage),
267 contextTokens: usage.input + usage.cacheRead + usage.cacheWrite,
268 }))
269 return result
270 })
271
272 on('turn.complete', async ($, e, next) => {
273 const { agentId } = e
274 if (agentId !== undefined) {
275 const endedAt = await $.clock.now()
276 const status = e.reason === 'answer' ? 'done' : 'failed'
277 await patchAgent($, agentId, a => ({ ...a, status, endedAt }))
278 }
279 return next(e)
280 })
281
282 // Every tool a subagent calls says what it is doing; the step tool is
283 // answered here too (one hook: two on tool.call are too deep for the checker).
284 on('tool.call', async ($, e, next) => {
285 // Read as a plain shape: the input's full union is too deep for the checker.
286 const input = e as { agentId?: string; tool: string; done?: unknown; total?: unknown; note?: unknown }
287 const { agentId, tool } = input
288
289 if (tool === STEP_TOOL) {
290 if (agentId === undefined) return { result: 'ignored: only subagents report steps' }
291 // A missing, invalid or zero total keeps the plan already reported.
292 const total = toCount(input.total)
293 const done = toCount(input.done)
294 const note = typeof input.note === 'string' ? input.note.trim() : ''
295 await patchAgent($, agentId, a => ({
296 ...a,
297 stepsTotal: total !== undefined && total > 0 ? total : a.stepsTotal,
298 stepsDone: done ?? a.stepsDone,
299 note: note || undefined,
300 }))
301 return { result: 'ok' }
302 }
303
304 const activity = agentId === undefined ? undefined : activityOfTool(tool)
305 if (agentId !== undefined && activity !== undefined) {
306 await patchAgent($, agentId, a => withActivity(a, activity))
307 }
308 return next(e)
309 }).catch(($, e, next) => next(e)) // never block a tool over the panel's bookkeeping
310
311 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
312 const ui = $.ui.resolve(e)
313 const { Box, Text, Button } = ui
314 const Raster = e.surface === 'terminal' && 'Raster' in ui ? ui.Raster : undefined
315 const Image = e.surface === 'terminal' && 'Image' in ui && (await read($, hasPixels)) ? ui.Image : undefined
316
317 const list = await read($, agents)
318 const frame = await read($, tick)
319 const step = await read($, sweep)
320 const window = await read($, contextWindow)
321 const isCollapsed = await read($, isFinishedCollapsed)
322 const now = await $.clock.now()
323 const viewed = e.props.view.agentId
324 // Claude Code keeps a finished agent's transcript only for a while, and none
325 // from an earlier process: only the agents it still lists can be opened.
326 const canOpen = new Set(await read($, openable))
327
328 const running = list.filter(a => a.status === 'running')
329 const finished = list.filter(a => a.status !== 'running')
330
331 const barWidth = Math.max(4, e.props.bodyColumns - PANE_PADDING * 2 - (Image || Raster ? SPRITE_COLUMNS + 2 : 0))
332
333 // More agents than the pane can show in full: each becomes its title and bar.
334 const chrome = 2 + (viewed !== undefined ? 1 : 0) + (running.length > 0 ? 2 : 0) + (finished.length > 0 ? 2 : 0)
335 const shown = running.length + (isCollapsed ? 0 : finished.length)
336 const isCompact = chrome + shown * FULL_ROW_LINES > e.props.scroll.bodyRows
337 const compactBarWidth = Math.max(4, e.props.bodyColumns - PANE_PADDING * 2)
338
339 // Opens the agent's transcript in the main view, as the tasks list does;
340 // pressing the one already shown goes back to the main conversation.
341 const show = async (agentId: string) => {
342 const shown = await $.ui.view(viewed === agentId ? {} : { agentId })
343 if (shown.deny !== undefined) $.ui.toast(`squad: can't open that transcript (${shown.deny})`)
344 }
345
346 const row = (a: SquadAgent) => {
347 const color = hex(modelColor(a.model))
348 const vehicle = vehicleOf(a)
349 const isViewed = viewed === a.id
350 const mark =
351 a.status === 'running' ? (
352 <Text color={color}>{SPINNER[frame % SPINNER.length]}</Text>
353 ) : a.status === 'done' ? (
354 <Text color={DONE_COLOR}>✓</Text>
355 ) : (
356 <Text color={FAILED_COLOR}>✗</Text>
357 )
358 const ctx = window > 0 ? `ctx ${Math.round((a.contextTokens / window) * 100)}% · ` : ''
359 const meta = `${ctx}${formatTokens(totalTokens(a.usage))} · ${formatDuration((a.endedAt ?? now) - a.startedAt)}`
360
361 const bar = (width: number) => {
362 if (a.status !== 'running') {
363 return <Text color={a.status === 'failed' ? FAILED_COLOR : color}>{'━'.repeat(width)}</Text>
364 }
365 // Working: a dim track with a bright pulse moving left to right, drawn
366 // as the track before it, its lit cells, and the track after it.
367 const paint = modelColor(a.model)
368 const { from, levels } = sweepPulse(width, step)
369 return (
370 <Text>
371 <Text color={BAR_TRACK}>{'─'.repeat(from)}</Text>
372 {levels.map(level => (
373 <Text color={hex(mix(BAR_TRACK_RGB, paint, level))}>━</Text>
374 ))}
375 <Text color={BAR_TRACK}>{'─'.repeat(Math.max(0, width - from - levels.length))}</Text>
376 </Text>
377 )
378 }
379 const title = canOpen.has(a.id) ? (
380 <Button key={`open-${a.id}`} plain onPress={() => show(a.id)}>
381 <Text bold color={isViewed ? VIEWED_COLOR : undefined}>
382 {isViewed ? '▶ ' : ''}
383 {a.title}
384 </Text>
385 </Button>
386 ) : (
387 <Text bold dimColor wrap="truncate-end">
388 {a.title}
389 </Text>
390 )
391
392 if (isCompact) {
393 return (
394 <Box key={a.id} flexDirection="column">
395 <Box flexDirection="row" justifyContent="space-between" columnGap={1}>
396 {title}
397 <Text>
398 <Text dimColor>{stepsLabel(a)} </Text>
399 {mark}
400 </Text>
401 </Box>
402 {bar(compactBarWidth)}
403 </Box>
404 )
405 }
406
407 return (
408 <Box key={a.id} flexDirection="row" columnGap={2} marginBottom={1}>
409 {Image ? (
410 <Image
411 key={`sprite-${a.id}`}
412 columns={HIRES_COLUMNS}
413 rows={HIRES_ROWS}
414 alt=" "
415 source={hiresPixels({
416 activity: vehicle,
417 paint: modelColor(a.model),
418 isFailed: a.status === 'failed',
419 frame: a.status === 'running' ? frame : 0,
420 })}
421 />
422 ) : (
423 Raster && (
424 <Raster
425 key={`sprite-${a.id}`}
426 columns={SPRITE_COLUMNS}
427 rows={SPRITE_ROWS}
428 cells={spriteCells({
429 activity: vehicle,
430 model: a.model,
431 isFailed: a.status === 'failed',
432 frame: a.status === 'running' ? frame : 0,
433 })}
434 />
435 )
436 )}
437 <Box flexDirection="column" flexGrow={1}>
438 <Box flexDirection="row" justifyContent="space-between">
439 {title}
440 {mark}
441 </Box>
442 <Text wrap="truncate-end">
443 <Text color={color}>{a.type}</Text> {modelLabel(a.model)}
444 {a.effort !== undefined ? ` · ${a.effort}` : ''}
445 {vehicle !== undefined ? <Text dimColor> · {ACTIVITY_LABEL[vehicle]}</Text> : ''}
446 </Text>
447 <Box flexDirection="row" justifyContent="space-between" columnGap={1}>
448 <Text wrap="truncate-end">
449 {stepsLabel(a)}
450 {a.note !== undefined && a.status === 'running' ? <Text dimColor> {a.note}</Text> : ''}
451 </Text>
452 <Text dimColor>{meta}</Text>
453 </Box>
454 {bar(barWidth)}
455 </Box>
456 </Box>
457 )
458 }
459
460 return (
461 <Box flexDirection="column" paddingLeft={PANE_PADDING} paddingRight={PANE_PADDING}>
462 {viewed !== undefined && (
463 <Button key="back" plain onPress={() => $.ui.view({})}>
464 <Text color={VIEWED_COLOR}>← back to the main conversation</Text>
465 </Button>
466 )}
467 {list.length === 0 && <Text dimColor>No subagents yet this session.</Text>}
468 {list.length > 0 && (
469 <Text dimColor>Press a title to open its transcript (dim ones are closed).</Text>
470 )}
471 {running.length > 0 && (
472 <Box flexDirection="column" marginTop={1}>
473 <Text bold>Running · {running.length}</Text>
474 {running.map(row)}
475 </Box>
476 )}
477 {finished.length > 0 && (
478 <Box flexDirection="column" marginTop={1}>
479 <Button
480 key="toggle-finished"
481 plain
482 onPress={() => update($, isFinishedCollapsed, v => !v)}
483 >
484 {isCollapsed ? '▸' : '▾'} Finished · {finished.length}
485 </Button>
486 {!isCollapsed && finished.slice().reverse().map(row)}
487 </Box>
488 )}
489 </Box>
490 )
491 })
492
493 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
494 const list = await read($, agents)
495 const running = list.filter(a => a.status === 'running')
496 if (e.props.hasSurvey || running.length === 0) return next(e)
497
498 const { Box, Text, Button } = $.ui.resolve(e)
499 const frame = await read($, tick)
500 const now = await $.clock.now()
501 const done = list.length - running.length
502 const span = now - Math.min(...running.map(a => a.startedAt))
503
504 return (
505 <Box flexDirection="row" columnGap={1}>
506 <Text color={hex(modelColor(running[0]?.model ?? ''))}>{SPINNER[frame % SPINNER.length]}</Text>
507 <Text>
508 squad · {running.length} running{done > 0 ? ` · ${done} done` : ''}
509 </Text>
510 <Text dimColor>{formatDuration(span)}</Text>
511 <Button key="open" label="open" onPress={() => openPane($)} />
512 </Box>
513 )
514 })
515}
516hooks/format.ts 29 lines1import type { SquadUsage } from '../types'
2
3export const totalTokens = (usage: SquadUsage): number =>
4 usage.input + usage.output + usage.cacheRead + usage.cacheWrite
5
6// 'claude-opus-5-5' -> 'Opus 5.5'; anything else is shown as given.
7export const modelLabel = (model: string): string => {
8 const match = /(fable|mythos|opus|sonnet|haiku)-(\d+)(?:-(\d+))?/i.exec(model)
9 if (!match) return model
10 const [, family = '', major, minor] = match
11 const name = family.charAt(0).toUpperCase() + family.slice(1).toLowerCase()
12 return minor === undefined ? `${name} ${major}` : `${name} ${major}.${minor}`
13}
14
15export const formatTokens = (n: number): string => {
16 if (n < 1000) return String(n)
17 if (n < 10_000) return `${(n / 1000).toFixed(1)}k`
18 if (n < 1_000_000) return `${Math.round(n / 1000)}k`
19 return `${(n / 1_000_000).toFixed(1)}M`
20}
21
22export const formatDuration = (ms: number): string => {
23 const total = Math.max(0, Math.floor(ms / 1000))
24 const h = Math.floor(total / 3600)
25 const m = Math.floor((total % 3600) / 60)
26 const s = String(total % 60).padStart(2, '0')
27 return h > 0 ? `${h}:${String(m).padStart(2, '0')}:${s}` : `${m}:${s}`
28}
29hooks/hires.ts 219 lines1import type { SquadActivity } from '../types'
2
3// High-resolution vehicles for terminals that draw real pixels (Ghostty, kitty):
4// 28x24 pixel art, scaled up 4x with hard edges and sent as an `Image` over the
5// same 7x3 cells the block version uses. Same vehicles, same model paint.
6
7export const HIRES_COLUMNS = 7
8export const HIRES_ROWS = 3
9const W = 28
10const H = 24
11const SCALE = 4
12
13type Look = { activity?: SquadActivity; paint: number; isFailed: boolean; frame: number }
14
15const FAILED_PAINT = 0x6b6b6b
16const GLASS = 0xa9d6f5
17const GLINT = 0xeaf6ff
18const TYRE = 0x3a3a3c
19const STEEL = 0x9aa0a8
20const STEEL_DARK = 0x6e737b
21const WHITE = 0xf4f3ee
22const INK = 0x2b2b2b
23const YELLOW = 0xf5c542
24const FLAME = 0xff7b3a
25const RED = 0xe0565b
26const DIRT = 0x8a5a33
27const BUBBLE = 0xbfe3fb
28
29const shade = (color: number, factor: number): number => {
30 const ch = (shift: number) => Math.min(255, Math.max(0, Math.round(((color >> shift) & 0xff) * factor)))
31 return (ch(16) << 16) | (ch(8) << 8) | ch(0)
32}
33
34// A 28x24 canvas: 0 is transparent, anything else is 0x1RRGGBB (bit 24 marks it set).
35class Canvas {
36 readonly px = new Uint32Array(W * H)
37
38 rect(x: number, y: number, w: number, h: number, color: number): this {
39 for (let j = Math.max(0, y); j < Math.min(H, y + h); j++) {
40 for (let i = Math.max(0, x); i < Math.min(W, x + w); i++) this.px[j * W + i] = color | 0x1000000
41 }
42 return this
43 }
44
45 // A filled ellipse centred on pixel centres (cx, cy are in pixel units).
46 oval(cx: number, cy: number, rx: number, ry: number, color: number): this {
47 for (let j = 0; j < H; j++) {
48 for (let i = 0; i < W; i++) {
49 const dx = (i + 0.5 - cx) / rx
50 const dy = (j + 0.5 - cy) / ry
51 if (dx * dx + dy * dy <= 1) this.px[j * W + i] = color | 0x1000000
52 }
53 }
54 return this
55 }
56
57 disc(cx: number, cy: number, r: number, color: number): this {
58 return this.oval(cx, cy, r, r, color)
59 }
60
61 dot(x: number, y: number, color: number): this {
62 return this.rect(x, y, 1, 1, color)
63 }
64}
65
66const wheel = (c: Canvas, cx: number, cy: number, r: number, frame: number) => {
67 c.disc(cx, cy, r, TYRE).disc(cx, cy, r * 0.45, STEEL)
68 // The spoke flips between horizontal and vertical as the wheel turns.
69 if (frame === 0) c.rect(Math.round(cx - r * 0.7), Math.round(cy - 0.5), Math.round(r * 1.4), 1, STEEL_DARK)
70 else c.rect(Math.round(cx - 0.5), Math.round(cy - r * 0.7), 1, Math.round(r * 1.4), STEEL_DARK)
71}
72
73type Draw = (c: Canvas, paint: number, frame: number) => void
74
75// A small parked car: nothing has started yet.
76const idle: Draw = (c, paint) => {
77 const dark = shade(paint, 0.7)
78 const light = shade(paint, 1.25)
79 c.rect(9, 7, 11, 1, light).rect(8, 8, 13, 5, paint)
80 c.rect(10, 9, 4, 3, GLASS).rect(15, 9, 4, 3, GLASS).dot(10, 9, GLINT).dot(15, 9, GLINT)
81 c.rect(3, 13, 22, 5, paint).rect(3, 13, 22, 1, light).rect(3, 17, 22, 1, dark)
82 c.rect(23, 14, 2, 2, YELLOW).rect(3, 14, 1, 2, RED)
83 wheel(c, 8.5, 19.5, 3, 0)
84 wheel(c, 19.5, 19.5, 3, 0)
85}
86
87const VEHICLES: Record<SquadActivity, Draw> = {
88 // Submarine with a periscope; the propeller turns and bubbles rise.
89 search: (c, paint, frame) => {
90 const dark = shade(paint, 0.7)
91 const light = shade(paint, 1.25)
92 c.rect(16, 2, 1, 6, STEEL).rect(16, 2, 4, 1, STEEL).rect(19, 2, 1, 2, GLASS)
93 c.rect(11, 7, 7, 5, paint).rect(11, 7, 7, 1, light)
94 c.oval(14, 15.5, 11.5, 5.5, paint)
95 // The keel: the hull's lower rows in the darker shade.
96 for (let j = 18; j < 22; j++) {
97 for (let i = 0; i < W; i++) if (c.px[j * W + i]) c.px[j * W + i] = dark | 0x1000000
98 }
99 c.rect(8, 11, 12, 1, light)
100 for (const x of [9, 14, 19]) c.disc(x, 14.5, 1.6, GLASS).dot(x - 1, 13, GLINT)
101 c.rect(1, 15, 2, 1, STEEL_DARK)
102 if (frame === 0) c.rect(0, 12, 2, 3, STEEL).rect(0, 16, 2, 3, STEEL)
103 else c.rect(0, 14, 2, 3, STEEL)
104 if (frame === 0) c.disc(24.5, 8.5, 1.2, BUBBLE).disc(26.5, 4.5, 1.6, BUBBLE)
105 else c.disc(25.5, 6.5, 1.6, BUBBLE).disc(23.5, 2.5, 1.2, BUBBLE)
106 },
107
108 // Bulldozer; the treads roll and the blade pushes dirt.
109 edit: (c, paint, frame) => {
110 const dark = shade(paint, 0.7)
111 const light = shade(paint, 1.25)
112 c.rect(15, 5, 1, 6, STEEL_DARK).rect(14, 4, 3, 1, STEEL_DARK)
113 c.rect(5, 4, 8, 7, paint).rect(5, 4, 8, 1, light).rect(7, 6, 4, 3, GLASS).dot(7, 6, GLINT)
114 c.rect(3, 11, 16, 6, paint).rect(3, 11, 16, 1, light).rect(3, 16, 16, 1, dark)
115 c.rect(19, 13, 4, 2, dark)
116 c.oval(11, 20.5, 9.5, 3, TYRE)
117 for (const x of [5, 9, 13, 17]) c.disc(x, 20.5, 1.6, STEEL)
118 for (let x = 3 + frame; x < 20; x += 2) c.dot(x, 17, STEEL_DARK)
119 c.rect(22, 9, 3, 13, STEEL).rect(24, 9, 1, 13, STEEL_DARK).rect(22, 21, 4, 1, STEEL_DARK)
120 if (frame === 0) c.disc(26.5, 20.5, 1.8, DIRT).dot(27, 18, DIRT)
121 else c.disc(26.5, 21, 1.4, DIRT).dot(26, 17, DIRT)
122 },
123
124 // Race car with a spoiler; the wheels spin and exhaust puffs out behind it.
125 run: (c, paint, frame) => {
126 const dark = shade(paint, 0.7)
127 const light = shade(paint, 1.25)
128 c.rect(1, 8, 6, 2, dark).rect(3, 10, 1, 4, dark)
129 c.disc(13.5, 11, 2.6, WHITE).rect(13, 10, 3, 1, INK)
130 c.rect(2, 13, 21, 5, paint).rect(2, 13, 21, 1, light)
131 for (let i = 0; i < 4; i++) c.rect(23 + i, 14 + Math.floor(i / 2), 1, 4 - Math.floor(i / 2), paint)
132 c.rect(2, 17, 24, 1, dark)
133 c.disc(17, 15.5, 1.8, WHITE).dot(17, 15, INK)
134 c.rect(9, 15, 4, 1, light)
135 wheel(c, 6.5, 19, 3.4, frame)
136 wheel(c, 21.5, 19, 3.4, frame)
137 if (frame === 0) c.disc(1, 17.5, 1.3, STEEL).dot(0, 15, STEEL)
138 else c.disc(0.5, 16, 1, STEEL)
139 },
140
141 // Rocket; the flame flickers and a star twinkles.
142 web: (c, paint, frame) => {
143 const dark = shade(paint, 0.7)
144 const light = shade(paint, 1.25)
145 c.rect(6, 4, 3, 1, dark).rect(6, 5, 4, 4, dark)
146 c.rect(6, 16, 4, 4, dark).rect(6, 20, 3, 1, dark)
147 c.rect(7, 9, 14, 7, paint).rect(7, 9, 14, 1, light).rect(7, 15, 14, 1, dark)
148 for (let i = 0; i < 4; i++) c.rect(21 + i, 9 + i, 1, 7 - 2 * i, paint)
149 c.dot(25, 12, light)
150 c.disc(15, 12.5, 2.4, WHITE).disc(15, 12.5, 1.6, GLASS).dot(14, 11, GLINT)
151 c.rect(5, 10, 2, 5, STEEL)
152 if (frame === 0) c.rect(1, 11, 4, 3, FLAME).rect(3, 12, 2, 1, YELLOW).dot(0, 12, FLAME)
153 else c.rect(2, 10, 3, 5, FLAME).rect(0, 12, 4, 1, YELLOW)
154 const [sx, sy] = frame === 0 ? [25, 3] : [23, 20]
155 c.dot(sx, sy, YELLOW).dot(sx - 1, sy, YELLOW).dot(sx + 1, sy, YELLOW).dot(sx, sy - 1, YELLOW).dot(sx, sy + 1, YELLOW)
156 },
157
158 // Typewriter; the keys go down and a line of text grows on the page.
159 answer: (c, paint, frame) => {
160 const dark = shade(paint, 0.7)
161 const light = shade(paint, 1.25)
162 c.rect(8, 1, 12, 9, WHITE).rect(10, 3, 8, 1, STEEL).rect(10, 5, frame === 0 ? 4 : 7, 1, STEEL)
163 c.rect(5, 9, 18, 3, INK).rect(3, 9, 2, 3, STEEL).rect(23, 9, 2, 3, STEEL).rect(24, 7, 3, 1, STEEL)
164 c.rect(4, 12, 20, 9, paint).rect(4, 12, 20, 1, light).rect(4, 20, 20, 1, dark)
165 for (let row = 0; row < 2; row++) {
166 for (let k = 0; k < 6; k++) {
167 const isPressed = (frame + row + k) % 4 === 0
168 c.rect(6 + k * 3, 14 + row * 3 + (isPressed ? 1 : 0), 2, 2, isPressed ? STEEL_DARK : WHITE)
169 }
170 }
171 },
172}
173
174const base64 = (bytes: Uint8Array): string => {
175 let binary = ''
176 for (const byte of bytes) binary += String.fromCharCode(byte)
177 return btoa(binary)
178}
179
180// The picture as RGBA bytes, `SCALE` times larger with hard edges, so the
181// terminal's own scaling into the 7x3 box does not blur it.
182type Picture = { rgba: string; width: number; height: number }
183
184// The panel redraws ten times a second while agents work (the bar's sweep), and
185// each picture only changes with its look, so pictures are kept by look.
186const cache = new Map<string, Picture>()
187
188export const hiresPixels = (look: Look): Picture => {
189 const key = `${look.activity ?? 'idle'}|${look.paint}|${look.isFailed}|${Math.abs(Math.trunc(look.frame)) % 2}`
190 const kept = cache.get(key)
191 if (kept !== undefined) return kept
192 const picture = drawPicture(look)
193 if (cache.size > 200) cache.clear()
194 cache.set(key, picture)
195 return picture
196}
197
198const drawPicture = ({ activity, paint, isFailed, frame }: Look): Picture => {
199 const c = new Canvas()
200 const draw = activity === undefined ? idle : VEHICLES[activity]
201 draw(c, isFailed ? FAILED_PAINT : paint, Math.abs(Math.trunc(frame)) % 2)
202
203 const width = W * SCALE
204 const height = H * SCALE
205 const out = new Uint8Array(width * height * 4)
206 for (let y = 0; y < height; y++) {
207 for (let x = 0; x < width; x++) {
208 const p = c.px[Math.floor(y / SCALE) * W + Math.floor(x / SCALE)] ?? 0
209 if (p === 0) continue
210 const o = (y * width + x) * 4
211 out[o] = (p >> 16) & 0xff
212 out[o + 1] = (p >> 8) & 0xff
213 out[o + 2] = p & 0xff
214 out[o + 3] = 0xff
215 }
216 }
217 return { rgba: base64(out), width, height }
218}
219hooks/sprite.ts 186 lines1import type { SquadActivity } from '../types'
2
3// Tiny vehicles drawn as a Raster of quadrant blocks: each cell holds 2x2
4// pixels in two colors, so 7 columns by 3 rows carry a 14x6 picture. The
5// vehicle says what the agent is doing, its paint says the model, and a
6// running agent's vehicle moves (two frames).
7
8export const SPRITE_COLUMNS = 7
9export const SPRITE_ROWS = 3
10const WIDTH = SPRITE_COLUMNS * 2
11const HEIGHT = SPRITE_ROWS * 2
12
13const DEFAULT = 0x01000000 // the terminal's own color: a transparent pixel
14const SPACE = 0x20
15// The glyph for each set of lit quadrants: top-left 1, top-right 2,
16// bottom-left 4, bottom-right 8.
17const QUADRANTS = [
18 SPACE, 0x2598, 0x259d, 0x2580, 0x2596, 0x258c, 0x259e, 0x259b,
19 0x2597, 0x259a, 0x2590, 0x259c, 0x2584, 0x2599, 0x259f, 0x2588,
20]
21
22const CLAY = 0xd97757
23const FAILED_PAINT = 0x6b6b6b
24
25// Fixed colors; `B` is the model's paint and `D` a darker shade of it.
26const COLORS: Record<string, number> = {
27 W: 0xa9d6f5, // glass
28 w: 0xf4f3ee, // paper
29 K: 0x4a4a48, // tyres, keys (dark grey: black vanishes on a dark terminal)
30 G: 0x8e929a, // steel
31 Y: 0xf5c542, // flame core, stars
32 R: 0xff7b3a, // flame
33 d: 0x8a5a33, // dirt
34 o: 0xbfe3fb, // bubbles
35}
36
37type Frames = [string[], string[]]
38
39// Each picture is 6 rows of 14 pixels; a pixel is half a cell wide and half a
40// cell tall, so two across look square. Every vehicle faces right. A cell draws
41// two colors, so each 2x2 block (even column, even row) holds at most two.
42const IDLE: Frames = [
43 ['..............', '....BBBBBB....', '...BWWBBWWB...', '.BBBBBBBBBBBB.', '.BBBBBBBBBBBB.', '..KK......KK..'],
44 ['..............', '....BBBBBB....', '...BWWBBWWB...', '.BBBBBBBBBBBB.', '.BBBBBBBBBBBB.', '..KK......KK..'],
45]
46
47const VEHICLES: Record<SquadActivity, Frames> = {
48 // Submarine with a periscope; the propeller turns and bubbles rise.
49 search: [
50 ['.......GGG....', '.......G....o.', '...BBBBBBBBB..', 'G.BBWBBWBBWBB.', '.GBBBBBBBBBBB.', '...DDDDDDDDD..'],
51 ['.......GGG..o.', '.......G......', '...BBBBBBBBB..', '.GBBWBBWBBWBB.', 'G.BBBBBBBBBBB.', '...DDDDDDDDD..'],
52 ],
53 // Bulldozer; the treads roll and the blade pushes dirt.
54 edit: [
55 ['..BBBB........', '..BWWB........', '.BBBBBBBBBB.GG', '.BBBBBBBBBBGGG', 'KKKKKKKKKKK.GG', 'KGKGKGKGKGK.dd'],
56 ['..BBBB........', '..BWWB........', '.BBBBBBBBBB.GG', '.BBBBBBBBBBGGG', 'KKKKKKKKKKK.GG', 'GKGKGKGKGKG.d.'],
57 ],
58 // Race car with a spoiler; exhaust puffs out behind it.
59 run: [
60 ['..............', 'D.....WWW.....', 'DBBBBBBBBBB...', 'BBBBBBBBBBBBBB', '..BBBBBBBBBB..', 'GGKK......KK..'],
61 ['..............', 'D.....WWW.....', 'DBBBBBBBBBB...', 'BBBBBBBBBBBBBB', '..BBBBBBBBBB..', '.GKK......KK..'],
62 ],
63 // Rocket; the flame flickers and a star blinks.
64 web: [
65 ['..DD.........Y', '..BBBBBBBBB...', 'RYBBBBWWBBBBB.', 'YRBBBBWWBBBBB.', '..BBBBBBBBB...', '..DD..........'],
66 ['..DD..........', '..BBBBBBBBB...', 'YRBBBBWWBBBBB.', 'RYBBBBWWBBBBB.', '..BBBBBBBBB...', '..DD..........'],
67 ],
68 // Typewriter; the keys go down and a letter lands on the page.
69 answer: [
70 ['...wwwwww.....', '...wKwwww.....', '.DDDDDDDDDD...', '.BBBBBBBBBB.G.', '.BKBKBKBKBB...', '.BBBBBBBBBB...'],
71 ['...wwwwww.....', '...wKwKww.....', '.DDDDDDDDDD...', '.BBBBBBBBBB.G.', '.BBKBKBKBKB...', '.BBBBBBBBBB...'],
72 ],
73}
74
75export const ACTIVITY_LABEL: Record<SquadActivity, string> = {
76 search: 'searching',
77 edit: 'editing',
78 run: 'running commands',
79 web: 'on the web',
80 answer: 'writing up',
81}
82
83export const hex = (color: number): string => `#${color.toString(16).padStart(6, '0')}`
84
85const darker = (color: number): number => {
86 const r = Math.floor(((color >> 16) & 0xff) * 0.7)
87 const g = Math.floor(((color >> 8) & 0xff) * 0.7)
88 const b = Math.floor((color & 0xff) * 0.7)
89 return (r << 16) | (g << 8) | b
90}
91
92const distance = (a: number, b: number): number => {
93 if (a === DEFAULT || b === DEFAULT) return a === b ? 0 : Number.POSITIVE_INFINITY
94 const dr = ((a >> 16) & 0xff) - ((b >> 16) & 0xff)
95 const dg = ((a >> 8) & 0xff) - ((b >> 8) & 0xff)
96 const db = (a & 0xff) - (b & 0xff)
97 return dr * dr + dg * dg + db * db
98}
99
100// One cell from its four pixels (top-left, top-right, bottom-left, bottom-right):
101// a cell draws two colors, so a third is drawn as the nearer of the two kept.
102const cellOf = (pixels: readonly number[]): [number, number, number] => {
103 const counts = new Map<number, number>()
104 for (const p of pixels) counts.set(p, (counts.get(p) ?? 0) + 1)
105 const kept = [...counts.entries()].sort((x, y) => y[1] - x[1]).map(([color]) => color)
106 const first = kept[0] ?? DEFAULT
107 const second = kept[1] ?? DEFAULT
108 // The lit quadrants take the foreground; a transparent color is always the background.
109 const fg = first === DEFAULT ? second : first
110 const bg = first === DEFAULT ? DEFAULT : second
111 if (fg === DEFAULT) return [SPACE, DEFAULT, DEFAULT]
112
113 let mask = 0
114 pixels.forEach((p, i) => {
115 const isFg = p === fg || (p !== bg && distance(p, fg) <= distance(p, bg))
116 if (isFg) mask |= 1 << i
117 })
118 return [QUADRANTS[mask] ?? SPACE, fg, bg]
119}
120
121type Look = { activity?: SquadActivity; model: string; isFailed: boolean; frame: number }
122
123// The Raster's `cells`: base64 of little-endian u32 triplets per cell.
124export const spriteCells = ({ activity, model, isFailed, frame }: Look): string => {
125 const frames = activity === undefined ? IDLE : VEHICLES[activity]
126 const step = Math.abs(Math.trunc(frame)) % 2
127 const picture = frames[step] ?? frames[0]
128 const paint = isFailed ? FAILED_PAINT : modelColor(model)
129
130 const pixel = (x: number, y: number): number => {
131 const glyph = picture[y]?.[x]
132 if (glyph === undefined || glyph === '.') return DEFAULT
133 if (glyph === 'B') return paint
134 if (glyph === 'D') return darker(paint)
135 return COLORS[glyph] ?? DEFAULT
136 }
137
138 const view = new DataView(new ArrayBuffer(SPRITE_COLUMNS * SPRITE_ROWS * 12))
139 for (let row = 0; row < SPRITE_ROWS; row++) {
140 for (let col = 0; col < SPRITE_COLUMNS; col++) {
141 const x = col * 2
142 const y = row * 2
143 const cell = cellOf([pixel(x, y), pixel(x + 1, y), pixel(x, y + 1), pixel(x + 1, y + 1)])
144 const at = (row * SPRITE_COLUMNS + col) * 12
145 cell.forEach((word, k) => view.setUint32(at + k * 4, word, true))
146 }
147 }
148
149 let binary = ''
150 for (const byte of new Uint8Array(view.buffer)) binary += String.fromCharCode(byte)
151 return btoa(binary)
152}
153
154// The picture's pixels, row-major, for previews: 14x6 colors (DEFAULT = none).
155export const spritePixels = (look: Look): number[] => {
156 const cells = Uint8Array.from(atob(spriteCells(look)), c => c.charCodeAt(0))
157 const view = new DataView(cells.buffer)
158 const out = new Array<number>(WIDTH * HEIGHT).fill(DEFAULT)
159 for (let row = 0; row < SPRITE_ROWS; row++) {
160 for (let col = 0; col < SPRITE_COLUMNS; col++) {
161 const at = (row * SPRITE_COLUMNS + col) * 12
162 const glyph = view.getUint32(at, true)
163 const fg = view.getUint32(at + 4, true)
164 const bg = view.getUint32(at + 8, true)
165 const mask = Math.max(0, QUADRANTS.indexOf(glyph))
166 for (let i = 0; i < 4; i++) {
167 const x = col * 2 + (i % 2)
168 const y = row * 2 + Math.floor(i / 2)
169 out[y * WIDTH + x] = mask & (1 << i) ? fg : bg
170 }
171 }
172 }
173 return out
174}
175
176// The model's color, for its name in the row's text.
177const MODEL_COLORS: readonly (readonly [RegExp, number])[] = [
178 [/opus/i, CLAY],
179 [/sonnet/i, 0x6c9cf0],
180 [/haiku/i, 0x63c58a],
181 [/fable|mythos/i, 0xb48ead],
182]
183
184export const modelColor = (model: string): number =>
185 MODEL_COLORS.find(([pattern]) => pattern.test(model))?.[1] ?? 0xb0b0b0
186types/index.d.ts 52 lines1export type SquadUsage = {
2 input: number
3 output: number
4 cacheRead: number
5 cacheWrite: number
6}
7
8// What an agent is doing, read from the tools it calls.
9export type SquadActivity = 'search' | 'edit' | 'run' | 'web' | 'answer'
10
11export type SquadAgent = {
12 id: string
13 title: string
14 type: string
15 model: string
16 effort?: string
17 status: 'running' | 'done' | 'failed'
18 startedAt: number
19 endedAt?: number
20 usage: SquadUsage
21 // Input side of the latest request: what the agent's context holds now.
22 contextTokens: number
23 stepsDone?: number
24 stepsTotal?: number
25 note?: string
26 // What it is doing now; and, for a finished agent's costume, how often it did each.
27 activity?: SquadActivity
28 activityCounts?: Partial<Record<SquadActivity, number>>
29}
30
31declare module 'claude-code' {
32 // The mod's own tool, so `tool.call`'s matcher and input know it.
33 interface McpToolInputs {
34 mcp__squad__step: { done: number; total?: number; note?: string }
35 }
36
37 interface PluginState {
38 squad: {
39 agents: SquadAgent[]
40 tick: number
41 // A faster counter that drives the working bars' sweep.
42 sweep: number
43 // Agents Claude Code still lists, so their transcripts can be opened.
44 openable: string[]
45 contextWindow: number
46 isFinishedCollapsed: boolean
47 // The terminal draws real pixels (Ghostty, kitty): vehicles are images, not blocks.
48 hasPixels: boolean
49 }
50 }
51}
52