A timeline pane of the session with few-word turn summaries, subagent lanes, turn portraits and an animated pixel yard

A Claude Code mod. It adds a timeline pane to the side of the session, a status band above the prompt, and an animated pixel yard.
Type this at the prompt of a Claude Code terminal session:
/plugin install session-timeline --marketplace sunfootsoldier-suraj/session-timeline
Answer y to add the marketplace, then pick a scope.
/timeline opens the pane. /timeline help lists every key and command.p changes the pets: slayer, cat, or off. y picks a slayer or the rotation. i turns the portraits on or off. m turns the model summaries on or off./timeline cast rotate|inosuke|tanjiro|zenitsu|nezuko|rengoku|giyu pins one slayer.The terminal draws the pixels with half-block characters. The desktop app draws them as SVG. The VS Code extension shows text only.
claude plugin validate .
claude plugin test .
The engine writes the type files to .claude-plugin/types/ when it loads the mod. Run tsc -p . after that.
The Demon Slayer character heads are unofficial fan art, made as small pixel sprites for a free hobby tool. Demon Slayer (Kimetsu no Yaiba) and its characters belong to Koyoharu Gotouge, Shueisha and their licensees. This project has no affiliation with them and no endorsement from them. If a rights holder asks, the sprites will be removed.
hooks/register.tsx 1660 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { AgentRec, Entry, Live, Prefs, Status, ToolKind, Totals, View } from '../types'
5import { advanceYard, newYard, PET_MS, rasterCells, svgSource } from './pets'
6import type { PetMood, PetScene, PetTheme } from './pets'
7import { CAST, castAt, castName, drawScene, portraitFor, portraitPixels, PORTRAIT_NAMES, PORTRAIT_W, sceneAlt, sceneTag } from './slayer'
8import type { Portrait } from './slayer'
9
10const PANE = 'timeline'
11// Rows kept per session. The pane draws all of them and scrolls.
12const MAX = 5000
13const ACCENT = '#7aa2f7'
14const GOOD = '#73c991'
15const BAD = '#f7768e'
16const AGENT = '#bb9af7'
17const STORE_KEY = 'prefs-v4'
18// Each session's rows live in the store under its own id, so a resume finds them.
19const HISTORY_PREFIX = 'history:'
20const HISTORY_KEEP = 30
21
22const entries = atom({ plugin: 'session-timeline', key: 'entries' } as const, [] as Entry[])
23const agents = atom({ plugin: 'session-timeline', key: 'agents' } as const, [] as AgentRec[])
24const expanded = atom({ plugin: 'session-timeline', key: 'expanded' } as const, {} as Record<string, boolean>)
25const live = atom({ plugin: 'session-timeline', key: 'live' } as const, {
26 phase: 'idle',
27 since: 0,
28 turnStartedAt: 0,
29 running: 0,
30} as Live)
31const totals = atom({ plugin: 'session-timeline', key: 'totals' } as const, {
32 startedAt: 0,
33 turns: 0,
34 tools: 0,
35 errors: 0,
36 perTurn: [],
37} as Totals)
38const sessionId = atom({ plugin: 'session-timeline', key: 'sessionId' } as const, '')
39const isQuiet = atom({ plugin: 'session-timeline', key: 'isQuiet' } as const, true)
40const DEFAULT_PREFS: Prefs = {
41 view: 'overview',
42 isBandOn: true,
43 isSubShown: false,
44 isErrorsOnly: false,
45 isClockTime: true,
46 isRecentOnly: false,
47 isKeysPinned: false,
48 isSlowOnly: false,
49 isAiOn: true,
50 pet: 'slayer',
51 isTurnSprites: true,
52 cast: 'rotate',
53 kind: 'all',
54}
55const prefs = atom({ plugin: 'session-timeline', key: 'prefs' } as const, DEFAULT_PREFS)
56
57const SLOW_MS = 30_000
58const KINDS: ToolKind[] = ['all', 'edits', 'commands', 'reads', 'agents', 'web']
59const VIEWS: View[] = ['overview', 'deep', 'narrow']
60const BARS = '▁▂▃▄▅▆▇█'
61// Animation frame period. Frames advance only while something runs.
62const TICK_MS = 500
63const SPIN = '⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏'
64// Tool calls a running turn or subagent makes before the model labels it again.
65const RELABEL_CALLS = 4
66const RELABEL_MS = 15_000
67// Restored turns the model labels after a resume, newest first.
68const RESTORED_LABELS = 3
69
70// ---- text -------------------------------------------------------------------------
71
72function oneLine(text: string, max: number): string {
73 const flat = text.replace(/\s+/g, ' ').trim()
74 return flat.length > max ? `${flat.slice(0, Math.max(1, max - 1))}…` : flat
75}
76
77function base(path: unknown): string {
78 return typeof path === 'string' ? path.split('/').filter(Boolean).pop() ?? path : ''
79}
80
81// A short label for one tool call, from the fields the model sent.
82function label(e: Record<string, unknown>): string {
83 const pick = (key: string) => (typeof e[key] === 'string' ? (e[key] as string) : '')
84 const raw =
85 pick('description') ||
86 base(e.file_path) ||
87 base(e.notebook_path) ||
88 pick('skill') ||
89 pick('subject') ||
90 pick('query') ||
91 pick('pattern') ||
92 pick('url') ||
93 pick('command') ||
94 pick('prompt') ||
95 pick('task_id')
96 return humanText(raw, 60)
97}
98
99// Blocks of machine text that a person never needs to read. The whole block goes.
100const DROP_BLOCKS = [
101 'system-reminder',
102 'local-command-caveat',
103 'local-command-stdout',
104 'local-command-stderr',
105 'bash-stdout',
106 'bash-stderr',
107 'command-message',
108 'command-args',
109 'tool-use-id',
110 'output-file',
111 'status',
112 'task-id',
113]
114
115// Plain words only: no <tag> blocks, no [marker] lines, no markdown link syntax.
116function humanText(text: string, max: number): string {
117 let s = text
118 for (const tag of DROP_BLOCKS) s = s.replace(new RegExp(`<${tag}[^>]*>[\\s\\S]*?</${tag}>`, 'g'), ' ')
119 s = s.replace(/<\/?[a-zA-Z][\w-]*[^>]*>/g, ' ')
120 s = s.replace(/\[([^\]\n]{1,80})\]\([^)\s]*\)/g, '$1')
121 s = s.replace(/(^|\s)\[[^\]\n]{1,160}\]/g, '$1')
122 s = s.replace(/`+/g, '')
123 return oneLine(s, max)
124}
125
126const inside = (text: string, tag: string) => new RegExp(`<${tag}[^>]*>([\\s\\S]*?)</${tag}>`).exec(text)?.[1]?.trim()
127
128// A prompt as a person reads it. Background notices keep only their summary.
129function cleanPrompt(text: string, kind: string): { text: string; isAuto: boolean } {
130 if (text.includes('<task-notification>')) {
131 const summary = humanText(inside(text, 'summary') ?? '', 120)
132 return { text: summary || 'A background task reported back', isAuto: true }
133 }
134 const command = inside(text, 'command-name')
135 if (command) {
136 const args = humanText(inside(text, 'command-args') ?? '', 80)
137 return { text: oneLine(`Ran ${command}${args ? ` ${args}` : ''}`, 120), isAuto: false }
138 }
139 const shell = inside(text, 'bash-input')
140 if (shell) return { text: oneLine(`Shell: ${shell}`, 120), isAuto: false }
141 if (/^\s*\[Request interrupted by user/.test(text)) {
142 return { text: 'You stopped Claude', isAuto: false }
143 }
144 const plain = humanText(text, 120)
145 if (!plain) return { text: 'A background update', isAuto: true }
146 const outside = text.replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, '')
147 const isSystem = /\[SYSTEM NOTIFICATION/.test(outside)
148 return { text: plain, isAuto: isSystem || kind !== 'composer' }
149}
150
151// How a turn ended, in words. The engine's reason codes never reach the pane.
152const ENDINGS: Record<string, string> = {
153 answer: 'replied',
154 replied: 'replied',
155 stopped: 'stopped',
156 aborted: 'stopped',
157 max_tokens: 'hit the length limit',
158 max_turns: 'hit the turn limit',
159 error: 'ended on an error',
160 refusal: 'declined',
161}
162const ending = (why: string) => ENDINGS[why] ?? why.replace(/[_-]+/g, ' ')
163
164function clockTime(ms: number): string {
165 if (!ms) return ''
166 const d = new Date(ms)
167 return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
168}
169
170function span(ms: number): string {
171 const s = Math.max(0, Math.round(ms / 1000))
172 if (s < 60) return `${s}s`
173 const m = Math.floor(s / 60)
174 if (m < 60) return `${m}m ${s % 60}s`
175 return `${Math.floor(m / 60)}h ${m % 60}m`
176}
177
178// A duration in at most 3 characters, for a fixed column.
179function short(ms: number): string {
180 const s = Math.max(0, Math.round(ms / 1000))
181 if (s < 60) return `${s}s`
182 const m = Math.floor(s / 60)
183 return m < 60 ? `${m}m` : `${Math.floor(m / 60)}h`
184}
185
186function spark(values: number[], width: number): string {
187 const tail = values.slice(-width)
188 const top = Math.max(1, ...tail)
189 return tail.map(v => BARS[Math.min(BARS.length - 1, Math.round((v / top) * (BARS.length - 1)))]).join('')
190}
191
192function plural(n: number, one: string, many: string): string {
193 return `${n} ${n === 1 ? one : many}`
194}
195
196function listNames(names: string[]): string {
197 const uniq = [...new Set(names.filter(Boolean))]
198 const shown = uniq.slice(0, 3)
199 const more = uniq.length - shown.length
200 if (more > 0) return `${shown.join(', ')} and ${more} more`
201 return shown.length > 1 ? `${shown.slice(0, -1).join(', ')} and ${shown[shown.length - 1]}` : shown[0] ?? ''
202}
203
204// Transcript rows the quiet mode hides: tool rows, durations, notices and the
205// spinner. Your messages, Claude's replies and questions to you stay.
206async function hideWhenQuiet($: any, e: any, next: any) {
207 if (!(await read($, isQuiet))) return next(e)
208 const { Box } = $.ui.resolve(e)
209 return <Box />
210}
211
212// ---- the animation clock ----------------------------------------------------------
213
214let ticker: { cancel: () => void } | undefined
215let ticks = 0
216
217function stopTicker(): void {
218 ticker?.cancel()
219 ticker = undefined
220}
221
222// Redraw on each frame while the main loop or a subagent runs.
223function startTicker($: any): void {
224 if (ticker) return
225 ticker = $.clock.every(TICK_MS, () => {
226 ticks += 1
227 $.ui.invalidate('ui.render')
228 if (ticks % 4 === 0) void pollAgents($)
229 })
230}
231
232async function stopTickerWhenIdle($: any): Promise<void> {
233 const l = await read($, live)
234 const list = await read($, agents)
235 if (l.phase === 'idle' && !list.some(x => x.status === 'run')) stopTicker()
236}
237
238// ---- tool kinds -------------------------------------------------------------------
239
240function kindOf(tool: string): ToolKind | 'other' {
241 if (['Edit', 'Write', 'MultiEdit', 'NotebookEdit'].includes(tool)) return 'edits'
242 if (['Bash', 'BashOutput', 'Monitor'].includes(tool)) return 'commands'
243 if (['Read', 'Grep', 'Glob', 'LS'].includes(tool)) return 'reads'
244 if (['Agent', 'Task'].includes(tool)) return 'agents'
245 if (['WebFetch', 'WebSearch'].includes(tool) || tool.startsWith('mcp__')) return 'web'
246 return 'other'
247}
248
249// One colour per tool kind, for the activity strips.
250const KIND_TINT: Record<string, string> = {
251 edits: '#e0af68',
252 commands: ACCENT,
253 reads: '#737aa2',
254 agents: AGENT,
255 web: '#7dcfff',
256 other: '#9aa5ce',
257}
258
259function serverName(tool: string): string {
260 const parts = tool.split('__')
261 return (parts[1] ?? tool).replace(/^claude_ai_/, '').replace(/^plugin_[^_]+_/, '').replace(/_/g, ' ')
262}
263
264// The tool name for a fixed column: connector tools show their server.
265function toolName(tool: string): string {
266 return tool.startsWith('mcp__') ? serverName(tool) : tool
267}
268
269// True when a tool row passes the active filters.
270function isShown(x: Entry, p: Prefs): boolean {
271 if (p.isErrorsOnly && x.status !== 'err') return false
272 if (p.isSlowOnly && (x.ms ?? 0) < SLOW_MS && x.status !== 'run') return false
273 if (p.kind !== 'all' && kindOf(x.tool ?? '') !== p.kind) return false
274 return true
275}
276
277const isFiltered = (p: Prefs) => p.isErrorsOnly || p.isSlowOnly || p.kind !== 'all'
278
279// Counts by kind, for one compact line: "4 commands · 2 edits · 3 reads".
280function stats(tools: Entry[]): string {
281 const own = tools.filter(x => !x.isSub)
282 const count = (k: string) => own.filter(x => kindOf(x.tool ?? '') === k).length
283 const parts = [
284 [count('commands'), 'command', 'commands'],
285 [count('edits'), 'edit', 'edits'],
286 [count('reads'), 'read', 'reads'],
287 [count('agents'), 'agent', 'agents'],
288 [count('web'), 'web call', 'web calls'],
289 [count('other'), 'other', 'other'],
290 ] as const
291 const out = parts.filter(([n]) => n > 0).map(([n, one, many]) => plural(n, one, many))
292 const sub = tools.length - own.length
293 if (sub) out.push(`${sub} by subagents`)
294 return out.length ? out.join(' · ') : 'no tools'
295}
296
297// One plain sentence for one turn: what Claude did, what failed, how it ended.
298function summarize(tools: Entry[], answer: Entry | undefined): string {
299 const own = tools.filter(x => !x.isSub)
300 const sub = tools.length - own.length
301 const by = (k: string) => own.filter(x => kindOf(x.tool ?? '') === k)
302 const parts: string[] = []
303 if (by('commands').length) parts.push(`ran ${plural(by('commands').length, 'command', 'commands')}`)
304 if (by('reads').length) parts.push(`looked at ${plural(by('reads').length, 'file', 'files')}`)
305 if (by('edits').length) parts.push(`changed ${listNames(by('edits').map(x => x.text))}`)
306 if (by('agents').length) parts.push(`started ${plural(by('agents').length, 'subagent', 'subagents')}`)
307 if (by('web').length) parts.push(`used ${listNames(by('web').map(x => toolName(x.tool ?? '')))}`)
308 if (by('other').length) parts.push(`used ${listNames(by('other').map(x => x.tool ?? ''))}`)
309 if (sub) parts.push(`subagents made ${plural(sub, 'call', 'calls')}`)
310
311 const failed = tools.filter(x => x.status === 'err').length
312 let line = parts.length ? parts.join(', ') : 'answered without tools'
313 line = line.charAt(0).toUpperCase() + line.slice(1) + '.'
314 if (failed) line += ` ${failed} failed.`
315 if (!answer) return `${line} Still working.`
316 const how = ending(answer.text)
317 const said = how.charAt(0).toUpperCase() + how.slice(1)
318 if (answer.isRestored) return `${line} ${said}.`
319 return `${line} ${said} after ${span(answer.ms ?? 0)}.`
320}
321
322// ---- few-word summaries from the model ---------------------------------------------
323
324const LABEL_SYSTEM = [
325 'You write a label for one step of work in a coding session.',
326 'Write 3 to 6 words.',
327 'Start with a verb in the -ing form.',
328 'Name the concrete thing: the file, test, model, dataset or bug.',
329 'Do not use quotes. Do not end with a full stop.',
330 'Reply with the label only.',
331].join('\n')
332
333// Keys of labels in flight, and when each key last asked, by call count and time.
334const asking = new Set<string>()
335// A final label asked while another ask of the same key runs. It runs when that ends.
336const pendingFinal = new Map<string, () => Promise<void>>()
337
338function finishAsk(key: string): void {
339 asking.delete(key)
340 const queued = pendingFinal.get(key)
341 pendingFinal.delete(key)
342 if (queued) void queued()
343}
344const lastAsk = new Map<string, { at: number; calls: number }>()
345
346function cleanLabel(text: string): string {
347 const first = text.split('\n').map(x => x.trim()).find(Boolean) ?? ''
348 return oneLine(first.replace(/^["'`*\-\s]+|["'`*.\s]+$/g, '').replace(/^label:\s*/i, ''), 56)
349}
350
351async function askLabel($: any, prompt: string): Promise<string | undefined> {
352 try {
353 const r = await $.model.complete({
354 model: 'haiku',
355 system: LABEL_SYSTEM,
356 prompt,
357 maxTokens: 40,
358 effort: 'low',
359 timeoutMs: 10_000,
360 })
361 if (!r?.isAnswered) return undefined
362 return cleanLabel(String(r.text ?? '')) || undefined
363 } catch {
364 return undefined
365 }
366}
367
368// True when a key may ask the model again: none in flight, and enough new work.
369function isDue(key: string, calls: number, now: number, isFinal: boolean): boolean {
370 if (asking.has(key)) return false
371 if (isFinal) return true
372 const was = lastAsk.get(key)
373 if (!was) return calls >= 2
374 return calls - was.calls >= RELABEL_CALLS && now - was.at >= RELABEL_MS
375}
376
377function stepLines(tools: Entry[], max: number): string {
378 return tools
379 .slice(-max)
380 .map(x => `- ${toolName(x.tool ?? '?')}${x.status === 'err' ? ' (failed)' : ''}: ${x.text || '(no label)'}`)
381 .join('\n')
382}
383
384// Label one main-loop turn. Final: what the turn did. Otherwise: what it does now.
385async function labelTurn($: any, turn: number, isFinal: boolean, reply = ''): Promise<void> {
386 if (!(await read($, prefs)).isAiOn) return
387 const key = `turn:${turn}`
388 const now = await $.clock.now()
389 const list = await read($, entries)
390 const prompt = list.find(x => x.kind === 'prompt' && x.turn === turn)
391 if (!prompt) return
392 const tools = list.filter(x => x.kind === 'tool' && x.turn === turn && !x.isSub)
393 if (isFinal && asking.has(key)) {
394 pendingFinal.set(key, () => labelTurn($, turn, true, reply))
395 return
396 }
397 if (!isDue(key, tools.length, now, isFinal)) return
398 const subs = (await read($, agents)).filter(x => x.turn === turn)
399 asking.add(key)
400 lastAsk.set(key, { at: now, calls: tools.length })
401 const ask = [
402 `Request from the person: ${prompt.text}`,
403 tools.length ? `Tool calls, oldest first:\n${stepLines(tools, 30)}` : 'No tool calls.',
404 subs.length ? `Subagents started: ${subs.map(x => x.description).join('; ')}` : '',
405 reply ? `Start of the final reply: ${oneLine(reply, 400)}` : '',
406 isFinal ? 'Write the label for what Claude did in this turn.' : 'Write the label for what Claude does now.',
407 ]
408 .filter(Boolean)
409 .join('\n\n')
410 const said = await askLabel($, ask)
411 finishAsk(key)
412 if (!said) return
413 await update($, entries, all =>
414 all.map(x => (x.kind === 'prompt' && x.turn === turn ? { ...x, summary: said, isAiSummary: true } : x)),
415 )
416 $.ui.invalidate('ui.render')
417 // Save the final label, so a resume shows it.
418 if (isFinal) await saveHistory($, await read($, sessionId))
419}
420
421// Label one subagent from its task and its recent calls.
422async function labelAgent($: any, id: string, isFinal: boolean, reply = ''): Promise<void> {
423 if (!(await read($, prefs)).isAiOn) return
424 const key = `agent:${id}`
425 const now = await $.clock.now()
426 const rec = (await read($, agents)).find(x => x.id === id)
427 if (!rec) return
428 if (isFinal && asking.has(key)) {
429 pendingFinal.set(key, () => labelAgent($, id, true, reply))
430 return
431 }
432 if (!isDue(key, rec.calls, now, isFinal)) return
433 const tools = (await read($, entries)).filter(x => x.kind === 'tool' && x.agentId === id)
434 asking.add(key)
435 lastAsk.set(key, { at: now, calls: rec.calls })
436 const ask = [
437 `Task of the subagent (${rec.type}): ${rec.description}`,
438 tools.length ? `Its tool calls, oldest first:\n${stepLines(tools, 20)}` : 'No tool calls yet.',
439 reply ? `Start of its report: ${oneLine(reply, 400)}` : '',
440 isFinal ? 'Write the label for what the subagent did.' : 'Write the label for what the subagent does now.',
441 ]
442 .filter(Boolean)
443 .join('\n\n')
444 const said = await askLabel($, ask)
445 finishAsk(key)
446 if (!said) return
447 await update($, agents, all => all.map(x => (x.id === id ? { ...x, summary: said, isAiSummary: true } : x)))
448 $.ui.invalidate('ui.render')
449}
450
451// After a resume, label only the newest restored turns. Older turns keep the rule text.
452async function labelRestored($: any): Promise<void> {
453 const list = await read($, entries)
454 const open = list.filter(x => x.kind === 'prompt' && !x.summary && !x.isAuto).slice(-RESTORED_LABELS)
455 for (const x of open) await labelTurn($, x.turn, true)
456}
457
458// ---- subagents --------------------------------------------------------------------
459
460// The record of a subagent, made from the engine's list when its first call comes
461// before its spawn result. The spawn hook fills in the Agent call it belongs to.
462async function ensureAgent($: any, id: string, turn: number, now: number): Promise<AgentRec> {
463 const have = (await read($, agents)).find(x => x.id === id)
464 if (have) return have
465 let info: any
466 try {
467 info = (((await $.agent.list()) as any[]) ?? []).find(y => y.id === id)
468 } catch {
469 info = undefined
470 }
471 const rec: AgentRec = {
472 id,
473 toolUseId: '',
474 turn,
475 description: humanText(String(info?.description ?? ''), 80) || 'Subagent',
476 type: String(info?.type ?? 'agent'),
477 startedAt: now,
478 status: 'run',
479 calls: 0,
480 failed: 0,
481 }
482 await update($, agents, list => (list.some(x => x.id === id) ? list : [...list, rec]))
483 return (await read($, agents)).find(x => x.id === id) ?? rec
484}
485
486const DONE: Record<string, Status> = { completed: 'ok', idle: 'ok', failed: 'err', killed: 'err' }
487
488// Mark the subagents that ended. The list is the engine's record of each loop.
489async function pollAgents($: any): Promise<void> {
490 const mine = await read($, agents)
491 if (!mine.some(x => x.status === 'run')) {
492 await stopTickerWhenIdle($)
493 return
494 }
495 let infos: any[] = []
496 try {
497 infos = ((await $.agent.list()) as any[]) ?? []
498 } catch {
499 return
500 }
501 const now = await $.clock.now()
502 const ended: string[] = []
503 await update($, agents, all =>
504 all.map(x => {
505 if (x.status !== 'run') return x
506 const info = infos.find(y => y.id === x.id)
507 const status = info ? DONE[info.status] : 'ok'
508 if (!status) return x
509 ended.push(x.id)
510 return { ...x, status, endedAt: now }
511 }),
512 )
513 for (const id of ended) void labelAgent($, id, true)
514 await stopTickerWhenIdle($)
515}
516
517// ---- one session's history ----------------------------------------------------------
518
519type Saved = { entries: Entry[]; totals: Totals; agents?: AgentRec[] }
520
521const EMPTY_LIVE: Live = { phase: 'idle', since: 0, turnStartedAt: 0, running: 0 }
522
523function countTotals(list: Entry[], startedAt: number): Totals {
524 const turns = list.filter(x => x.kind === 'prompt').length
525 const tools = list.filter(x => x.kind === 'tool')
526 const perTurn: number[] = []
527 for (const x of list) {
528 if (x.kind === 'prompt') perTurn.push(0)
529 else if (x.kind === 'tool') {
530 if (!perTurn.length) perTurn.push(0)
531 perTurn[perTurn.length - 1] = (perTurn[perTurn.length - 1] ?? 0) + 1
532 }
533 }
534 return {
535 startedAt,
536 turns,
537 tools: tools.length,
538 errors: tools.filter(x => x.status === 'err').length,
539 perTurn: perTurn.slice(-200),
540 }
541}
542
543// User-role text the engine wrote, not the person: skill bodies, compact summaries,
544// image notes and messages from other sessions.
545function isMachineText(text: string): boolean {
546 const head = text.trimStart().slice(0, 120)
547 return (
548 head.startsWith('Base directory for this skill') ||
549 head.startsWith('This session is being continued from a previous conversation') ||
550 head.startsWith('[Image') ||
551 head.startsWith('Another Claude session sent a message') ||
552 head.startsWith('Caveat: The messages below')
553 )
554}
555
556// The rows of a session the timeline did not watch, read from its transcript. The
557// transcript holds no times, so each row is marked as restored.
558async function rebuildFromTranscript($: any): Promise<Entry[]> {
559 const messages = (await $.session.messages()) as any[]
560 const list: Entry[] = []
561 let turn = 0
562 let isOpen = false
563 const close = () => {
564 if (isOpen) list.push({ kind: 'answer', at: 0, turn, text: 'replied', isRestored: true })
565 isOpen = false
566 }
567 let isAnswered = true
568 for (const m of messages) {
569 if (m.role === 'user') {
570 if (!m.text?.trim() || isMachineText(m.text)) continue
571 // Text that follows a prompt before any answer belongs to that prompt (a skill
572 // body, a pasted rule set, an image note). It is not a new turn.
573 if (!isAnswered) continue
574 isAnswered = false
575 close()
576 turn += 1
577 isOpen = true
578 const clean = cleanPrompt(m.text, 'composer')
579 list.push({ kind: 'prompt', at: 0, turn, text: clean.text, isAuto: clean.isAuto, isRestored: true })
580 continue
581 }
582 isAnswered = true
583 for (const u of m.toolUses ?? []) {
584 const isAnswered = u.result !== undefined || u.text !== undefined || u.isError
585 list.push({
586 kind: 'tool',
587 at: 0,
588 turn,
589 tool: u.tool,
590 id: u.tool_use_id,
591 text: label(u.input ?? {}),
592 status: u.isError || !isAnswered ? 'err' : 'ok',
593 isRestored: true,
594 })
595 }
596 }
597 close()
598 return list.slice(-MAX)
599}
600
601async function saveHistory($: any, id: string): Promise<void> {
602 if (!id) return
603 const list = await read($, entries)
604 if (!list.length) return
605 const key = HISTORY_PREFIX + id
606 const saved: Saved = { entries: list, totals: await read($, totals), agents: await read($, agents) }
607 await $.store.set(key, saved)
608 const keys = ((await $.store.keys()) as string[]).filter(k => k.startsWith(HISTORY_PREFIX) && k !== key)
609 for (const old of keys.slice(0, Math.max(0, keys.length - HISTORY_KEEP + 1))) await $.store.delete(old)
610}
611
612let syncing: Promise<void> | undefined
613
614// Make the rows belong to the session that runs now. A new process, a resume and a
615// /clear each change the session id. Saved rows win, then the transcript.
616async function syncSession($: any): Promise<void> {
617 if (syncing) return syncing
618 syncing = (async () => {
619 const id = (await $.session.id()) as string
620 const was = await read($, sessionId)
621 if (was === id) return
622 if (was) await saveHistory($, was)
623 const startedAt = ((await $.session.usage())?.startedAt as number) || (await $.clock.now())
624 const saved = (await $.store.get(HISTORY_PREFIX + id)) as Saved | undefined
625 const list = saved?.entries?.length
626 ? saved.entries.map(x => (x.status === 'run' ? { ...x, status: 'err' as const } : x))
627 : await rebuildFromTranscript($)
628 // An unanswered last turn of a saved history ended when its process ended.
629 const last = list[list.length - 1]
630 if (saved && last && last.kind !== 'answer') {
631 list.push({ kind: 'answer', at: 0, turn: last.turn, text: 'stopped', isRestored: true })
632 }
633 const recs = (saved?.agents ?? []).map(x => (x.status === 'run' ? { ...x, status: 'err' as const } : x))
634 stopTicker()
635 await update($, entries, () => list)
636 await update($, agents, () => recs)
637 await update($, expanded, () => ({}))
638 await update($, totals, () => countTotals(list, startedAt))
639 await update($, live, () => EMPTY_LIVE)
640 await update($, sessionId, () => id)
641 $.ui.invalidate('ui.render')
642 void labelRestored($)
643 })().finally(() => {
644 syncing = undefined
645 })
646 return syncing
647}
648
649// ---- grouping ---------------------------------------------------------------------
650
651type Turn = { n: number; prompt?: Entry; tools: Entry[]; answer?: Entry }
652
653function groupTurns(list: Entry[]): Turn[] {
654 const turns: Turn[] = []
655 const byN = new Map<number, Turn>()
656 for (const x of list) {
657 let t = turns[turns.length - 1]
658 // A subagent's call joins the turn that started the subagent, even later on.
659 if (x.kind === 'tool' && x.agentId && byN.has(x.turn)) {
660 byN.get(x.turn)?.tools.push(x)
661 continue
662 }
663 if (!t || t.n !== x.turn || (x.kind === 'prompt' && t.prompt)) {
664 t = { n: x.turn, tools: [] }
665 turns.push(t)
666 byN.set(x.turn, t)
667 }
668 if (x.kind === 'prompt') t.prompt = x
669 else if (x.kind === 'answer') t.answer = x
670 else t.tools.push(x)
671 }
672 return turns
673}
674
675// Turns in a row that background notices started merge into one block.
676type Block = { turns: Turn[]; isAuto: boolean }
677
678function groupBlocks(turns: Turn[]): Block[] {
679 const blocks: Block[] = []
680 for (const t of turns) {
681 const isAuto = Boolean(t.prompt?.isAuto)
682 const last = blocks[blocks.length - 1]
683 if (isAuto && last?.isAuto) last.turns.push(t)
684 else blocks.push({ turns: [t], isAuto })
685 }
686 return blocks
687}
688
689// Calls in a row with the same tool merge into one step.
690type Step = { tool: string; labels: string[]; ids: string[]; n: number; status: Status; ms: number; isSub: boolean }
691
692function groupSteps(tools: Entry[]): Step[] {
693 const steps: Step[] = []
694 for (const x of tools) {
695 const isSub = Boolean(x.isSub)
696 const tool = isSub ? 'subagent' : toolName(x.tool ?? '?')
697 const last = steps[steps.length - 1]
698 const status = x.status ?? 'ok'
699 // A failed call never merges with a call that passed, so a retry stays visible.
700 // An Agent call never merges, so each subagent keeps its own lane.
701 const isSame =
702 last && last.tool === tool && last.isSub === isSub && (last.status === 'err') === (status === 'err') && tool !== 'Agent'
703 if (last && isSame) {
704 last.n += 1
705 if (x.text) last.labels.push(x.text)
706 if (x.id) last.ids.push(x.id)
707 last.ms += x.ms ?? 0
708 if (status === 'err' || (status === 'run' && last.status !== 'err')) last.status = status
709 } else {
710 steps.push({ tool, labels: x.text ? [x.text] : [], ids: x.id ? [x.id] : [], n: 1, status, ms: x.ms ?? 0, isSub })
711 }
712 }
713 return steps
714}
715
716function stepLabel(s: Step): string {
717 if (s.tool === 'subagent') return `${plural(s.n, 'call', 'calls')} inside subagents`
718 const uniq = [...new Set(s.labels)]
719 if (uniq.length <= 1) return uniq[0] ?? ''
720 return `${uniq[uniq.length - 1]}, and ${uniq.length - 1} more`
721}
722
723function turnStatus(t: Turn): Status {
724 if (!t.answer) return 'run'
725 return t.tools.some(x => x.status === 'err') ? 'err' : 'ok'
726}
727
728const MARK = { run: '◌', ok: '✓', err: '✗' } as const
729const TINT = { run: ACCENT, ok: GOOD, err: BAD } as const
730
731// The turn's few words: the model's label, else the rule-based sentence.
732function turnWords(t: Turn): { text: string; isAi: boolean } {
733 if (t.prompt?.summary) return { text: t.prompt.summary, isAi: Boolean(t.prompt.isAiSummary) }
734 return { text: summarize(t.tools, t.answer), isAi: false }
735}
736
737// The session as plain text, one turn per two lines, for the clipboard.
738function sessionText(list: Entry[]): string {
739 return groupTurns(list)
740 .map(x => {
741 const at = (x.prompt ? clockTime(x.prompt.at) : '').padEnd(5)
742 const who = x.prompt?.isAuto ? 'Update: ' : ''
743 const words = turnWords(x)
744 const extra = words.isAi ? ` (${stats(x.tools)})` : ''
745 return `${at} ${who}${x.prompt?.text ?? '(before the timeline started)'}\n ${words.text}${extra}`
746 })
747 .join('\n')
748}
749
750// ---- preferences ------------------------------------------------------------------
751
752const HELP = [
753 'Views: o overview · d deep · n narrow · v next view',
754 'Filters: e errors · s slow (30s+) · f tool type · a subagent calls · l last 5 turns',
755 'Move: g jump to the end · h jump to the start · arrows scroll',
756 'Other: p pets (slayer, cat, off) · y slayer (rotate, Inosuke, Tanjiro, Zenitsu, Nezuko, Rengoku, Giyu) · i turn portraits · m model labels · q quiet transcript · b status band · t clock or time ago · c copy · k pin keys',
757 'A press on ▸ or ▾ opens or closes one turn. Tab walks these marks.',
758 'The letter keys work from any place in the focused pane.',
759 'Strip colours: blue command · yellow edit · grey read · violet agent · cyan web · red failed.',
760 'The keys work while the pane has the focus (ctrl+x tab, or a click).',
761 'From the prompt: /timeline <overview|deep|narrow|quiet|band|agents|errors|slow|filter [type]|clock|recent|copy|keys|labels|pets [slayer|cat|off]|cast [rotate|inosuke|tanjiro|zenitsu|nezuko|rengoku|giyu]|sprites>',
762 'Turn portraits (6 by 2 cells). A turn that worked: Zenitsu (short), Tanjiro (1 min or 6 calls), Rengoku (5 min or 30 calls). Inosuke: the turn runs now.',
763 'Demons when calls failed (10% or more): Rui (1), Akaza (2 to 4), Muzan (5 or more, or an error end).',
764 'A crow marks a background update. Nezuko asleep marks a turn you stopped.',
765 'Settings are saved between sessions.',
766].join('\n')
767
768const onOff = (b: boolean) => (b ? 'on' : 'off')
769
770// One toggle, by name. The pane keys and /timeline <word> both come here.
771async function applyPref($: any, word: string, surface?: any): Promise<string> {
772 const said = await changePref($, word, surface)
773 await $.store.set(STORE_KEY, { prefs: await read($, prefs), isQuiet: await read($, isQuiet) })
774 return said
775}
776
777async function changePref($: any, word: string, surface?: any): Promise<string> {
778 const w = word.trim().toLowerCase()
779 if (w === 'copy' || w === 'c') {
780 const text = sessionText(await read($, entries))
781 if (!text) return 'Nothing to copy yet.'
782 const done = await $.ui.copy({ text, surface })
783 const said = done.isCopied ? 'Session summary copied.' : 'The copy failed on this surface.'
784 $.ui.toast(said)
785 return said
786 }
787 if (w === 'g' || w === 'end' || w === 'h' || w === 'start') {
788 const to = w === 'g' || w === 'end' ? 'end' : 'start'
789 await $.ui.scroll({ in: PANE, to })
790 return `Timeline: ${to}.`
791 }
792 if (w === 'quiet' || w === 'q') {
793 const q = await update($, isQuiet, x => !x)
794 $.ui.invalidate('ui.render')
795 return q ? 'Transcript: messages only.' : 'Transcript: tool rows and status lines shown.'
796 }
797 if (w === 'filter' || w === 'f' || w.startsWith('filter ')) {
798 const want = w.split(/\s+/)[1] as ToolKind | undefined
799 const p = await update($, prefs, x => ({
800 ...x,
801 kind: want && KINDS.includes(want) ? want : KINDS[(KINDS.indexOf(x.kind) + 1) % KINDS.length] ?? 'all',
802 }))
803 $.ui.invalidate('ui.render')
804 return `Tool type: ${p.kind}.`
805 }
806 if (w === 'p' || w === 'pets' || w.startsWith('pet ') || w.startsWith('pets ')) {
807 const want = w.split(/\s+/)[1] as Prefs['pet'] | undefined
808 const order: Prefs['pet'][] = ['slayer', 'cat', 'off']
809 const p = await update($, prefs, x => ({
810 ...x,
811 pet: want && order.includes(want) ? want : order[(order.indexOf(x.pet) + 1) % order.length] ?? 'slayer',
812 }))
813 $.ui.invalidate('ui.render')
814 return `Pets: ${p.pet}.`
815 }
816 if (w === 'y' || w === 'cast' || w.startsWith('cast ')) {
817 const want = w.split(/\s+/)[1] as Prefs['cast'] | undefined
818 const order: Prefs['cast'][] = ['rotate', ...CAST]
819 const p = await update($, prefs, x => ({
820 ...x,
821 cast: want && order.includes(want) ? want : order[(order.indexOf(x.cast ?? 'rotate') + 1) % order.length] ?? 'rotate',
822 }))
823 $.ui.invalidate('ui.render')
824 return p.cast === 'rotate' ? 'Slayer: the cast rotates.' : `Slayer: ${castName(p.cast)}.`
825 }
826 const views: Record<string, View> = { o: 'overview', overview: 'overview', d: 'deep', deep: 'deep', n: 'narrow', narrow: 'narrow' }
827 if (views[w] || w === 'v' || w === 'view') {
828 const p = await update($, prefs, x => ({
829 ...x,
830 view: views[w] ?? VIEWS[(VIEWS.indexOf(x.view) + 1) % VIEWS.length] ?? 'overview',
831 }))
832 // A new view sets every turn back to its default open or closed state.
833 await update($, expanded, () => ({}))
834 $.ui.invalidate('ui.render')
835 return `Timeline view: ${p.view}.`
836 }
837 const flips: Record<string, [keyof Prefs, string]> = {
838 band: ['isBandOn', 'Status band'],
839 b: ['isBandOn', 'Status band'],
840 agents: ['isSubShown', 'Subagent calls'],
841 a: ['isSubShown', 'Subagent calls'],
842 errors: ['isErrorsOnly', 'Errors only'],
843 e: ['isErrorsOnly', 'Errors only'],
844 slow: ['isSlowOnly', 'Slow calls only'],
845 s: ['isSlowOnly', 'Slow calls only'],
846 clock: ['isClockTime', 'Clock time'],
847 t: ['isClockTime', 'Clock time'],
848 recent: ['isRecentOnly', 'Last 5 turns only'],
849 l: ['isRecentOnly', 'Last 5 turns only'],
850 keys: ['isKeysPinned', 'Key bar pinned'],
851 k: ['isKeysPinned', 'Key bar pinned'],
852 sprites: ['isTurnSprites', 'Turn portraits'],
853 i: ['isTurnSprites', 'Turn portraits'],
854 labels: ['isAiOn', 'Model labels'],
855 m: ['isAiOn', 'Model labels'],
856 }
857 const flip = flips[w]
858 if (!flip) return HELP
859 const [key, name] = flip
860 const p = await update($, prefs, x => ({ ...x, [key]: !x[key] }))
861 $.ui.invalidate('ui.render')
862 return `${name}: ${onOff(Boolean(p[key]))}.`
863}
864
865async function toggleTurn($: any, n: number, isOpenNow: boolean): Promise<void> {
866 await update($, expanded, x => ({ ...x, [String(n)]: !isOpenNow }))
867 $.ui.invalidate('ui.render')
868}
869
870// ---- pets -------------------------------------------------------------------------
871
872// What the pets act out. The pane writes it at each draw. The pet clock reads it.
873type PetFacts = {
874 theme: PetTheme
875 phase: Live['phase']
876 tool: string
877 failAt: number
878 doneAt: number
879 idleSince: number
880 slimes: string[]
881 cast: Prefs['cast']
882 turn: number
883 turnStartedAt: number
884}
885
886const yard = newYard()
887let facts: PetFacts = { theme: 'slayer', phase: 'idle', tool: '', failAt: 0, doneAt: 0, idleSince: 0, slimes: [], cast: 'rotate', turn: 0, turnStartedAt: 0 }
888let petClock: { cancel: () => void } | undefined
889let yardWidth = 0
890let lastCells = ''
891const SAD_MS = 4000
892const HAPPY_MS = 3000
893const SLEEP_MS = 120_000
894
895function stopPets(): void {
896 petClock?.cancel()
897 petClock = undefined
898}
899
900function moodOf(f: PetFacts, now: number): PetMood {
901 if (f.failAt && now - f.failAt < SAD_MS) return 'sad'
902 if (f.phase === 'tool') {
903 const k = kindOf(f.tool)
904 return k === 'edits' ? 'type' : k === 'reads' ? 'read' : k === 'commands' ? 'run' : 'walk'
905 }
906 if (f.phase === 'working') return 'think'
907 if (f.doneAt && now - f.doneAt < HAPPY_MS) return 'happy'
908 if (f.slimes.length) return 'walk'
909 if (f.idleSince && now - f.idleSince > SLEEP_MS) return 'sleep'
910 return 'idle'
911}
912
913function sceneAt(now: number): PetScene {
914 const mood = moodOf(facts, now)
915 // The cast keeps the turn's slayer through the jump at the end of the turn.
916 const isBusy = facts.phase !== 'idle' || facts.slimes.length > 0 || mood === 'happy' || mood === 'sad'
917 const at = facts.phase === 'idle' && facts.doneAt ? facts.doneAt : now
918 const who = castAt({ pin: facts.cast, isBusy, turn: facts.turn, turnStartedAt: facts.turnStartedAt, now: at })
919 return { theme: facts.theme, mood, slimes: facts.slimes, who }
920}
921
922// The terminal pets move between pane draws: each frame repaints the Raster alone.
923function startPets($: any): void {
924 if (petClock) return
925 petClock = $.clock.every(PET_MS, async () => {
926 if (!yardWidth) return
927 const now = await $.clock.now()
928 const scene = sceneAt(now)
929 advanceYard(yard, scene.mood, yardWidth, now)
930 const r = rasterCells(drawScene(yard, scene, yardWidth))
931 if (r.cells === lastCells) return
932 lastCells = r.cells
933 const done = await $.ui.blit({ requestId: PANE, key: 'yard', cells: r.cells, columns: r.columns, rows: r.rows })
934 // Not mounted, or another size: stop. The next pane draw starts the clock again.
935 if (done?.deny) stopPets()
936 })
937}
938
939// The portraits never change, so each frame is made once.
940const faceCache = new Map<string, { cells: string; columns: number; rows: number }>()
941const svgCache = new Map<string, string>()
942
943function faceCells(kind: Portrait, f: number) {
944 const k = `${kind}:${f}`
945 let r = faceCache.get(k)
946 if (!r) faceCache.set(k, (r = rasterCells(portraitPixels(kind, f))))
947 return r
948}
949
950function faceSvg(kind: Portrait, f: number): string {
951 const k = `${kind}:${f}`
952 let r = svgCache.get(k)
953 if (!r) svgCache.set(k, (r = svgSource(portraitPixels(kind, f), 3)))
954 return r
955}
956
957// ---- hooks ------------------------------------------------------------------------
958
959export const register: Register = on => {
960 on('session.start', async ($, e, next) => {
961 const started = await next(e)
962 await syncSession($)
963 const saved = (await $.store.get(STORE_KEY)) as { prefs?: Partial<Prefs>; isQuiet?: boolean } | undefined
964 // Defaults go under the held value, so a field added by a new version is set.
965 await update($, prefs, p => ({ ...DEFAULT_PREFS, ...p, ...(saved?.prefs ?? {}) }))
966 if (typeof saved?.isQuiet === 'boolean') await update($, isQuiet, () => saved.isQuiet as boolean)
967 await $.command.register({
968 name: 'timeline',
969 description: 'Open the timeline, or set a view or toggle (run /timeline help for the list)',
970 })
971 await $.command.register({
972 name: 'timeline-quiet',
973 description: 'Show or hide tool rows and status lines in the transcript',
974 })
975 void $.ui.open({ id: PANE, title: 'Timeline' })
976 return started
977 })
978
979 on('command.run', { command: 'timeline' }, async ($, e) => {
980 if (e.args.trim()) return { text: await applyPref($, e.args) }
981 const opened = await $.ui.open({ id: PANE, title: 'Timeline' })
982 return { text: opened.isPlaced ? `Timeline open.\n${HELP}` : 'Timeline waits for a wider terminal.' }
983 })
984
985 on('command.run', { command: 'timeline-quiet' }, async $ => ({ text: await applyPref($, 'quiet') }))
986
987 on('prompt.submit', async ($, e, next) => {
988 await syncSession($)
989 const now = await $.clock.now()
990 const t = await update($, totals, x => ({ ...x, turns: x.turns + 1, perTurn: [...x.perTurn, 0].slice(-200) }))
991 const clean = cleanPrompt(e.text, e.origin.kind)
992 await update($, entries, list =>
993 [...list, { kind: 'prompt', at: now, turn: t.turns, text: clean.text, isAuto: clean.isAuto } as Entry].slice(-MAX),
994 )
995 await update($, live, () => ({ phase: 'working', since: now, turnStartedAt: now, running: 0 }))
996 startTicker($)
997 return next(e)
998 })
999
1000 // A subagent starts: record it under the turn that started it.
1001 on('agent.spawn', async ($, e, next) => {
1002 const r = await next(e)
1003 if (!r || r.deny !== undefined || !r.agentId) return r
1004 const now = await $.clock.now()
1005 const t = await read($, totals)
1006 const id = r.agentId
1007 const fields = {
1008 toolUseId: e.tool_use_id,
1009 description: humanText(e.description ?? '', 80) || 'Subagent',
1010 type: e.subagentType ?? 'agent',
1011 }
1012 // Its first calls may have made the record already: keep their counts.
1013 await update($, agents, list =>
1014 list.some(x => x.id === id)
1015 ? list.map(x => (x.id === id ? { ...x, ...fields } : x))
1016 : [...list, { id, ...fields, turn: t.turns, startedAt: now, status: 'run' as const, calls: 0, failed: 0 }],
1017 )
1018 startTicker($)
1019 $.ui.invalidate('ui.render')
1020 return r
1021 })
1022
1023 on('tool.call', async ($, e, next) => {
1024 if (!e.agentId) await syncSession($)
1025 const now = await $.clock.now()
1026 const id = e.tool_use_id ?? `${e.tool}-${now}`
1027 const t = await read($, totals)
1028 const rec = e.agentId ? await ensureAgent($, e.agentId, t.turns, now) : undefined
1029 const text = label(e as unknown as Record<string, unknown>)
1030 const entry: Entry = {
1031 kind: 'tool',
1032 at: now,
1033 turn: rec?.turn ?? t.turns,
1034 text,
1035 tool: e.tool,
1036 id,
1037 isSub: Boolean(e.agentId),
1038 agentId: e.agentId,
1039 status: 'run',
1040 }
1041 await update($, entries, list => [...list, entry].slice(-MAX))
1042 await update($, totals, x => {
1043 const perTurn = x.perTurn.length ? [...x.perTurn] : [0]
1044 perTurn[perTurn.length - 1] = (perTurn[perTurn.length - 1] ?? 0) + 1
1045 return { ...x, tools: x.tools + 1, perTurn }
1046 })
1047 if (rec) {
1048 // A call from a subagent that ended means it runs again (a message woke it).
1049 await update($, agents, list =>
1050 list.map(x => (x.id === rec.id ? { ...x, calls: x.calls + 1, status: 'run' as const, endedAt: undefined } : x)),
1051 )
1052 startTicker($)
1053 } else {
1054 await update($, live, l => ({ ...l, phase: 'tool' as const, tool: e.tool, label: text, since: now, running: l.running + 1 }))
1055 }
1056
1057 const ran = await next(e)
1058
1059 const done = await $.clock.now()
1060 const failed = ran.deny !== undefined || ran.isError === true
1061 await update($, entries, list =>
1062 list.map(x => (x.id === id ? { ...x, status: failed ? ('err' as const) : ('ok' as const), ms: done - now } : x)),
1063 )
1064 if (failed) await update($, totals, x => ({ ...x, errors: x.errors + 1 }))
1065 if (rec) {
1066 if (failed) await update($, agents, list => list.map(x => (x.id === rec.id ? { ...x, failed: x.failed + 1 } : x)))
1067 void labelAgent($, rec.id, false)
1068 } else {
1069 await update($, live, l => {
1070 const running = Math.max(0, l.running - 1)
1071 return running > 0 ? { ...l, running } : { ...l, running, phase: 'working' as const, tool: undefined, label: undefined, since: done }
1072 })
1073 void labelTurn($, entry.turn, false)
1074 }
1075 return ran
1076 })
1077
1078 on('turn.complete', async ($, e, next) => {
1079 const result = await next(e)
1080 const reply = typeof e.answer === 'string' ? e.answer : ''
1081 const now = await $.clock.now()
1082 if (e.agentId) {
1083 // One run of the subagent's loop ended. A message may wake it again later.
1084 const status: Status = e.isAborted || e.reason === 'error' ? 'err' : 'ok'
1085 await update($, agents, list => list.map(x => (x.id === e.agentId ? { ...x, status, endedAt: now } : x)))
1086 void labelAgent($, e.agentId, true, reply)
1087 $.ui.invalidate('ui.render')
1088 await stopTickerWhenIdle($)
1089 return result
1090 }
1091 const t = await read($, totals)
1092 const why = e.isAborted ? 'stopped' : ending(e.reason ?? 'answer')
1093 await update($, entries, list =>
1094 [...list, { kind: 'answer', at: now, turn: t.turns, text: why, ms: e.durationMs } as Entry].slice(-MAX),
1095 )
1096 await update($, live, l => ({ ...l, phase: 'idle' as const, since: now, tool: undefined, label: undefined, running: 0 }))
1097 await stopTickerWhenIdle($)
1098 $.ui.invalidate('ui.render')
1099 await saveHistory($, await read($, sessionId))
1100 void labelTurn($, t.turns, true, reply)
1101 return result
1102 })
1103
1104 // Save the rows when a session ends. After a /clear or a resume the process goes on
1105 // under another id, and no session.start fires, so the timer picks up the new one.
1106 on('session.end', async ($, e, next) => {
1107 await saveHistory($, e.sessionId)
1108 if (e.reason === 'clear' || e.reason === 'resume') {
1109 await update($, sessionId, () => '')
1110 $.clock.after(1500, () => void syncSession($))
1111 }
1112 return next(e)
1113 })
1114
1115 // Quiet transcript: draw nothing for tool rows and status lines.
1116 on('ui.render', { component: 'ToolUse' }, hideWhenQuiet)
1117 on('ui.render', { component: 'ToolResult' }, hideWhenQuiet)
1118 on('ui.render', { component: 'ToolGroup' }, hideWhenQuiet)
1119 on('ui.render', { component: 'ToolProgress' }, hideWhenQuiet)
1120 on('ui.render', { component: 'TurnDuration' }, hideWhenQuiet)
1121 on('ui.render', { component: 'InfoNotice' }, hideWhenQuiet)
1122 on('ui.render', { component: 'Spinner' }, hideWhenQuiet)
1123
1124 // Bottom band: one fixed line. Working: elapsed, current step, calls this
1125 // turn. Ready: how the last turn went, and how long ago it ended.
1126 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
1127 if (e.props.hasSurvey) return next(e)
1128 if (!(await read($, prefs)).isBandOn) return next(e)
1129 const { Box, Text } = $.ui.resolve(e)
1130 const l = await read($, live)
1131 const t = await read($, totals)
1132 const list = await read($, entries)
1133 const subs = (await read($, agents)).filter(x => x.status === 'run')
1134 const now = await $.clock.now()
1135 const thisTurn = list.filter(x => x.kind === 'tool' && x.turn === t.turns && !x.isSub)
1136 const calls = thisTurn.length
1137 const failed = thisTurn.filter(x => x.status === 'err').length
1138 const words = list.find(x => x.kind === 'prompt' && x.turn === t.turns)?.summary
1139 const bar = <Text dimColor> │ </Text>
1140 const agentsNote = subs.length ? (
1141 <Text color={AGENT}>
1142 {' │ '}◆ {plural(subs.length, 'subagent', 'subagents')} running
1143 </Text>
1144 ) : (
1145 ''
1146 )
1147
1148 if (l.phase === 'idle') {
1149 const last = list.filter(x => x.kind === 'answer').pop()
1150 return (
1151 <Box>
1152 <Text wrap="truncate-end">
1153 <Text color={GOOD} bold>✓ Ready</Text>
1154 {last ? bar : ''}
1155 {last ? <Text dimColor>last turn {span(last.ms ?? 0)} · {plural(calls, 'call', 'calls')}</Text> : ''}
1156 {failed ? <Text color={BAD}> · {failed} failed</Text> : ''}
1157 {words ? bar : ''}
1158 {words ? <Text>{words}</Text> : ''}
1159 {agentsNote}
1160 </Text>
1161 </Box>
1162 )
1163 }
1164 const dots = SPIN[Math.floor(now / TICK_MS) % SPIN.length] ?? '◌'
1165 const step =
1166 l.phase === 'tool' ? (
1167 <Text>
1168 <Text bold>{toolName(l.tool ?? '')}</Text>
1169 {l.running > 1 ? <Text dimColor> ×{l.running}</Text> : ''}
1170 {l.label ? <Text dimColor> · {oneLine(l.label, 50)}</Text> : ''}
1171 <Text dimColor> · {span(now - l.since)}</Text>
1172 </Text>
1173 ) : (
1174 <Text dimColor>thinking · {span(now - l.since)}</Text>
1175 )
1176 return (
1177 <Box>
1178 <Text wrap="truncate-end">
1179 <Text color={ACCENT} bold>{dots} Working {span(now - l.turnStartedAt)}</Text>
1180 {words ? bar : ''}
1181 {words ? <Text>{words}</Text> : ''}
1182 {bar}
1183 {step}
1184 {bar}
1185 <Text dimColor>{plural(calls, 'call', 'calls')} this turn</Text>
1186 {failed ? <Text color={BAD}> · {failed} failed</Text> : ''}
1187 {agentsNote}
1188 </Text>
1189 </Box>
1190 )
1191 })
1192
1193 // Right pane. Each turn: the prompt, a few words from the model, then the steps.
1194 // Overview opens the running turn. Deep opens every turn. Narrow shows the words only.
1195 // The tree holds every turn. The pane scrolls it and follows the end.
1196 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
1197 const { Box, Text, Button } = $.ui.resolve(e)
1198 const list = await read($, entries)
1199 const recs = await read($, agents)
1200 const opened = await read($, expanded)hooks/pets.ts 306 lines1// Chibi pixel pets for the timeline pane. A cat stands for the main loop, and one
2// slime stands for each running subagent. Each terminal cell holds two pixels: the
3// upper half block takes the top pixel as its foreground and the bottom as its
4// background. Remote surfaces get the same pixels as an SVG.
5
6import type { CastName } from './slayer'
7
8export type PetMood = 'idle' | 'sleep' | 'think' | 'walk' | 'run' | 'type' | 'read' | 'happy' | 'sad'
9
10export type PetTheme = 'cat' | 'slayer'
11
12export type PetScene = {
13 theme: PetTheme
14 mood: PetMood
15 // One entry per running subagent, by type, oldest first.
16 slimes: string[]
17 // The slayer in the yard. The cat theme ignores it.
18 who?: CastName
19}
20
21export type Rgb = number
22export const NONE = -1
23const DEFAULT = 0x01000000
24
25const PALETTE: Record<string, Rgb> = {
26 o: 0x3b3b52, // outline
27 c: 0xf5a25d, // fur
28 w: 0xfff4e6, // belly
29 e: 0x1a1a2e, // eye
30 h: 0xffffff, // eye light
31 p: 0xff8fab, // blush
32 m: 0x5a3a3a, // mouth
33 b: 0x7dcfff, // tear
34 k: 0x9aa5ce, // laptop
35 K: 0x565f89, // laptop edge
36 g: 0x73c991, // screen glow
37 n: 0xe0af68, // book
38}
39
40const SLIME_TINT: Record<string, Rgb> = {
41 Explore: 0x73c991,
42 Plan: 0x7aa2f7,
43 'general-purpose': 0xbb9af7,
44 claude: 0xbb9af7,
45 fork: 0xe0af68,
46}
47const SLIME_OTHER = 0xff8fab
48
49// Half rows mirror into full rows, so the head stays symmetric.
50const mirror = (half: string) => half + [...half].reverse().join('')
51
52const EYES: Record<string, [string, string, string?]> = {
53 open: ['occeeccc', 'occehccc'],
54 blink: ['occccccc', 'occooccc'],
55 down: ['occccccc', 'occeeccc'],
56 happy: ['occcoccc', 'occococc'],
57 sad: ['occococc', 'occcoccc', 'ocbpcccm'],
58 sleep: ['occccccc', 'ocoooccc'],
59}
60
61function catHead(eyes: keyof typeof EYES): string[] {
62 const [a, b, cheek] = EYES[eyes] ?? EYES.open!
63 return [
64 '..o.....',
65 '.oco....',
66 '.occo...',
67 '.occcooo',
68 'occccccc',
69 'occccccc',
70 a,
71 b,
72 cheek ?? 'ocppcccm',
73 '.occcccc',
74 '..oooooo',
75 ].map(mirror)
76}
77
78// The body rows face right: the tail is on the left.
79const BODY: Record<string, string[]> = {
80 sit: ['oc.occwwwwcco...', 'oc.occwwwwcco...', '.coccwwwwwwcco..', '..occcccccccco..', '...oooo..oooo...'],
81 walk1: ['oc.occwwwwcco...', 'oc.occwwwwcco...', '.coccwwwwwwcco..', '..occcccccccco..', '..oo..oo..oo..o.'],
82 walk2: ['.o.occwwwwcco...', 'oc.occwwwwcco...', 'ococcwwwwwwcco..', '..occcccccccco..', '...oo..oo..oo...'],
83 type1: ['oc.occwwwwccoKKK', 'oc.occwwwwccokgk', '.coccwwwwwwcokgk', '..occcccccccoKKK', '...oooo..oooo...'],
84 type2: ['oc.occwwwwcco...', 'oc.occwwwwccoKKK', '.coccwwwwwwcokgk', '..occcccccccoKKK', '...oooo..oooo...'],
85 read: ['oc.occwwwwnnnn..', 'oc.occwwwwnwwn..', '.coccwwwwwnnnn..', '..occcccccccco..', '...oooo..oooo...'],
86 tuck: ['oc.occwwwwcco...', 'oc.occwwwwcco...', '.coccwwwwwwcco..', '..occcccccccco..', '....oooooooo....'],
87}
88
89const SLIME_A = ['....oo', '..ooss', '.osshs', '.ossss', 'osesss', 'osssps', 'osssss', '.ooooo'].map(mirror)
90const SLIME_B = ['......', '...ooo', '.oossh', 'osssss', 'osesss', 'osssps', '.ooooo'].map(mirror)
91
92export type Sprite = string[]
93
94export const flip = (s: Sprite): Sprite => s.map(r => [...r].reverse().join(''))
95
96// The cat's frame for a mood at a tick. Eyes blink about once each 4 seconds.
97function catFrame(mood: PetMood, tick: number): { sprite: Sprite; lift: number } {
98 const blink = tick % 27 === 0 || tick % 27 === 1
99 const step = Math.floor(tick / 2) % 2
100 switch (mood) {
101 case 'walk':
102 case 'run':
103 return { sprite: [...catHead(blink ? 'blink' : 'open'), ...BODY[(mood === 'run' ? tick : step) % 2 ? 'walk1' : 'walk2']!], lift: 0 }
104 case 'type':
105 return { sprite: [...catHead('down'), ...BODY[step ? 'type1' : 'type2']!], lift: 0 }
106 case 'read':
107 return { sprite: [...catHead(blink ? 'blink' : 'down'), ...BODY.read!], lift: 0 }
108 case 'happy': {
109 const up = [0, 2, 3, 2][tick % 4] ?? 0
110 return { sprite: [...catHead('happy'), ...BODY[up ? 'tuck' : 'sit']!], lift: up }
111 }
112 case 'sad':
113 return { sprite: [...catHead('sad'), ...BODY.sit!], lift: 0 }
114 case 'sleep':
115 return { sprite: [...catHead('sleep'), ...BODY.sit!], lift: tick % 8 < 4 ? 0 : 1 }
116 default:
117 return { sprite: [...catHead(blink ? 'blink' : 'open'), ...BODY.sit!], lift: 0 }
118 }
119}
120
121// The yard keeps the cat's place and heading between frames.
122export type Yard = { x: number; dir: 1 | -1; tick: number }
123
124export const newYard = (): Yard => ({ x: 4, dir: 1, tick: 0 })
125
126const CAT_W = 16
127const CAT_H = 16
128export const YARD_ROWS = 9
129export const YARD_H = YARD_ROWS * 2
130
131// Move the cat one tick. Walking moves 1 pixel, running moves 2.
132export function stepYard(y: Yard, mood: PetMood, width: number): void {
133 y.tick += 1
134 const speed = mood === 'run' ? 2 : mood === 'walk' ? 1 : 0
135 const max = Math.max(0, width - CAT_W)
136 if (!speed) {
137 // At rest the cat drifts to a fixed place, so it does not stop on the edge.
138 const home = Math.min(max, 2)
139 if (mood === 'idle' || mood === 'sleep') y.x += Math.sign(home - y.x)
140 return
141 }
142 y.x += speed * y.dir
143 if (y.x >= max) {
144 y.x = max
145 y.dir = -1
146 } else if (y.x <= 0) {
147 y.x = 0
148 y.dir = 1
149 }
150}
151
152// The pixels of one frame: a grid of RGB values, NONE where nothing is drawn.
153export type Pixels = { w: number; h: number; px: Int32Array; marks: { col: number; row: number; ch: string; rgb: Rgb }[] }
154
155export const newPixels = (w: number, h: number): Pixels => ({ w, h, px: new Int32Array(w * h).fill(NONE), marks: [] })
156
157export function paint(p: Pixels, sprite: Sprite, x0: number, y0: number, tint?: Rgb, pal: Record<string, Rgb> = PALETTE): void {
158 sprite.forEach((row, dy) => {
159 for (let dx = 0; dx < row.length; dx++) {
160 const ch = row[dx]!
161 if (ch === '.') continue
162 const x = x0 + dx
163 const y = y0 + dy
164 if (x < 0 || y < 0 || x >= p.w || y >= p.h) continue
165 const rgb = ch === 's' && tint !== undefined ? tint : pal[ch]
166 if (rgb !== undefined) p.px[y * p.w + x] = rgb
167 }
168 })
169}
170
171export function drawYard(y: Yard, scene: PetScene, width: number): Pixels {
172 const p = newPixels(width, YARD_H)
173 const { sprite, lift } = catFrame(scene.mood, y.tick)
174 // The sprites face right. A cat that walks left is flipped.
175 const cat = y.dir < 0 && (scene.mood === 'walk' || scene.mood === 'run') ? flip(sprite) : sprite
176 paint(p, cat, y.x, YARD_H - CAT_H - lift)
177 // The slimes follow the cat in a line behind it. With no room behind, they lead.
178 const slimes = scene.slimes.slice(0, 5)
179 const behind = y.dir > 0 ? y.x - 14 * slimes.length >= 0 : y.x + CAT_W + 14 * slimes.length <= width
180 slimes.forEach((type, i) => {
181 const bounce = (y.tick + i * 2) % 4 < 2
182 const s = bounce ? SLIME_A : SLIME_B
183 const back = y.dir > 0 === behind ? -1 : 1
184 const sx = back < 0 ? y.x - 14 * (i + 1) : y.x + CAT_W + 2 + 14 * i
185 paint(p, s, sx, YARD_H - s.length, SLIME_TINT[type] ?? SLIME_OTHER)
186 })
187 // Text marks over the pixels: Zz for sleep, a ? while it thinks, a ! when it fails.
188 const headCol = y.x + CAT_W
189 if (scene.mood === 'sleep') {
190 const t = y.tick % 12
191 if (t >= 2) p.marks.push({ col: headCol, row: 2, ch: 'z', rgb: 0x7aa2f7 })
192 if (t >= 6) p.marks.push({ col: headCol + 1, row: 1, ch: 'z', rgb: 0x7aa2f7 })
193 if (t >= 9) p.marks.push({ col: headCol + 2, row: 0, ch: 'Z', rgb: 0x7aa2f7 })
194 } else if (scene.mood === 'think') {
195 p.marks.push({ col: headCol, row: 1, ch: y.tick % 6 < 3 ? '?' : '·', rgb: 0xe0af68 })
196 } else if (scene.mood === 'sad') {
197 p.marks.push({ col: headCol, row: 1, ch: '!', rgb: 0xf7768e })
198 } else if (scene.mood === 'happy') {
199 p.marks.push({ col: headCol, row: 1, ch: y.tick % 2 ? '*' : '+', rgb: 0x73c991 })
200 }
201 return p
202}
203
204// ---- terminal: Raster cells ----------------------------------------------------------
205
206const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
207
208function base64(bytes: Uint8Array): string {
209 let out = ''
210 for (let i = 0; i < bytes.length; i += 3) {
211 const a = bytes[i]!
212 const b = bytes[i + 1]
213 const c = bytes[i + 2]
214 const n = (a << 16) | ((b ?? 0) << 8) | (c ?? 0)
215 out += B64[(n >> 18) & 63]! + B64[(n >> 12) & 63]!
216 out += b === undefined ? '=' : B64[(n >> 6) & 63]!
217 out += c === undefined ? '=' : B64[n & 63]!
218 }
219 return out
220}
221
222const UPPER = 0x2580
223const LOWER = 0x2584
224
225export function rasterCells(p: Pixels): { cells: string; columns: number; rows: number } {
226 const columns = p.w
227 const rows = p.h / 2
228 const words = new Uint32Array(columns * rows * 3)
229 for (let r = 0; r < rows; r++) {
230 for (let c = 0; c < columns; c++) {
231 const top = p.px[2 * r * columns + c]!
232 const bot = p.px[(2 * r + 1) * columns + c]!
233 const i = (r * columns + c) * 3
234 if (top === NONE && bot === NONE) {
235 words.set([0x20, DEFAULT, DEFAULT], i)
236 } else if (top === NONE) {
237 words.set([LOWER, bot, DEFAULT], i)
238 } else {
239 words.set([UPPER, top, bot === NONE ? DEFAULT : bot], i)
240 }
241 }
242 }
243 for (const m of p.marks) {
244 if (m.col < 0 || m.col >= columns || m.row < 0 || m.row >= rows) continue
245 const i = (m.row * columns + m.col) * 3
246 words.set([m.ch.codePointAt(0)!, m.rgb, DEFAULT], i)
247 }
248 return { cells: base64(new Uint8Array(words.buffer)), columns, rows }
249}
250
251// ---- remote surfaces: SVG ------------------------------------------------------------
252
253const hex = (rgb: Rgb) => `#${rgb.toString(16).padStart(6, '0')}`
254
255export function svgSource(p: Pixels, scale = 4): string {
256 const rects: string[] = []
257 for (let y = 0; y < p.h; y++) {
258 let x = 0
259 while (x < p.w) {
260 const v = p.px[y * p.w + x]!
261 if (v === NONE) {
262 x += 1
263 continue
264 }
265 let run = 1
266 while (x + run < p.w && p.px[y * p.w + x + run] === v) run += 1
267 rects.push(`<rect x="${x}" y="${y}" width="${run}" height="1" fill="${hex(v)}"/>`)
268 x += run
269 }
270 }
271 const texts = p.marks.map(
272 m => `<text x="${m.col + 0.2}" y="${m.row * 2 + 1.8}" font-size="2.4" fill="${hex(m.rgb)}" font-family="monospace">${m.ch}</text>`,
273 )
274 return (
275 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${p.w} ${p.h}" width="${p.w * scale}" height="${p.h * scale}" ` +
276 `shape-rendering="crispEdges">${rects.join('')}${texts.join('')}</svg>`
277 )
278}
279
280// Words for a reader that cannot see the drawing.
281export function petAlt(scene: PetScene): string {
282 const what: Record<PetMood, string> = {
283 idle: 'The cat sits and waits.',
284 sleep: 'The cat sleeps.',
285 think: 'The cat thinks.',
286 walk: 'The cat walks.',
287 run: 'The cat runs a command.',
288 type: 'The cat types an edit.',
289 read: 'The cat reads a file.',
290 happy: 'The cat jumps: the turn is done.',
291 sad: 'The cat is sad: a call failed.',
292 }
293 const n = scene.slimes.length
294 return `${what[scene.mood]}${n ? ` ${n} subagent slime${n === 1 ? '' : 's'} follow it.` : ''}`
295}
296
297// One pet frame lasts this long, in milliseconds.
298export const PET_MS = 150
299
300// Bring the yard to the frame of a clock time. A long gap moves at most 20 frames.
301export function advanceYard(y: Yard, mood: PetMood, width: number, now: number): void {
302 const target = Math.floor(now / PET_MS)
303 if (y.tick === 0 || target - y.tick > 20) y.tick = target - 20
304 while (y.tick < target) stepYard(y, mood, width)
305}
306hooks/slayer.ts 513 lines1// The slayer theme: chibi fan heads of the Demon Slayer cast, for a personal pane.
2// One slayer head floats in the yard for the main loop. Inosuke is the default. While work runs, the
3// cast rotates each turn and each 30 seconds, and each one shows their own breathing
4// technique. A messenger crow flies for each running subagent. Rui appears when a
5// call fails. Each turn row gets a portrait: a slayer when the turn worked, ranked by
6// how long and how much it ran, and a demon when calls failed, ranked by how many.
7
8import { drawYard as drawCatYard, flip, newPixels, paint, petAlt as catAlt } from './pets'
9import type { PetMood, PetScene, Pixels, Rgb, Sprite, Yard } from './pets'
10
11const PAL: Record<string, Rgb> = {
12 o: 0x3b3b52, // outline and shoes
13 H: 0x2a1f2e, // Tanjiro hair
14 r: 0xb33a3a, // Tanjiro scar
15 s: 0xf6d5b8, // skin
16 e: 0x8b1e2d, // red eye
17 h: 0xffffff, // white light
18 p: 0xff8fab, // blush
19 m: 0x7a3b3b, // mouth
20 a: 0x7dcfff, // light water
21 Z: 0x3d7cc9, // deep water and blue eyes
22 w: 0xf2efe9, // white
23 u: 0x23263a, // uniform
24 q: 0x111118, // sheath
25 y: 0xe0af68, // gold
26 l: 0xc8d3e6, // blade
27 F: 0xff7a3d, // flame
28 Y: 0xffd166, // yellow hair and flame light
29 O: 0xf2a33a, // Zenitsu haori orange
30 A: 0xffb000, // amber eye
31 g: 0x2f8f6a, // Tanjiro haori green
32 c: 0x16161c, // Tanjiro haori black
33 b: 0x8a7f73, // Inosuke boar mask
34 d: 0x5c534a, // boar mask shade
35 N: 0xc9a08a, // boar snout
36 f: 0x7a5a3a, // fur belt
37 j: 0x6f7fa3, // Inosuke trousers
38 X: 0x1c1a2a, // black hair
39 E: 0xe0703a, // Nezuko hair tips
40 M: 0xf78fb3, // pink
41 v: 0x8fbf5a, // bamboo
42 V: 0x4f6f2a, // bamboo knot
43 T: 0x7a4a2a, // Nezuko haori brown
44 D: 0xc0392b, // red
45 W: 0x7a1f2b, // Giyu haori maroon
46 G: 0x4f9f6a, // Giyu haori green
47 K: 0x2e3040, // crow
48 B: 0x5a6b9a, // crow sheen
49 k: 0xd03b3b, // crow eye
50 P: 0xe8e0ef, // pale demon skin
51 S: 0xd9a88a, // muscle shade
52 n: 0xffc8dc, // light pink pattern
53 I: 0x3a1a22, // Tanjiro hair, dark wine
54 J: 0x7a2a32, // Tanjiro hair light
55 C: 0x4a5f88, // Inosuke mask shade, blue grey
56 U: 0x9aa6b8, // Inosuke mask fur
57}
58
59// ---- the cast ------------------------------------------------------------------------
60
61export type CastName = 'inosuke' | 'tanjiro' | 'zenitsu' | 'nezuko' | 'rengoku' | 'giyu'
62
63// The rotation order. The first is the default.
64export const CAST: CastName[] = ['inosuke', 'tanjiro', 'zenitsu', 'nezuko', 'rengoku', 'giyu']
65
66type Member = {
67 name: string
68 art: string
69 // The head: 11 rows, 12 pixels wide, facing front, with a strip of collar at the bottom.
70 head: Sprite
71 // Rows 5 and 6 hold the eyes. These two rows replace them when the eyes close.
72 shut: [string, string]
73}
74
75const MEMBERS: Record<CastName, Member> = {
76 // A blue-grey boar-head mask: pointed ears, hollow eyes, a pale snout, white tusks.
77 inosuke: {
78 name: 'Inosuke',
79 art: 'Beast Breathing',
80 head: [
81 'CC........CC',
82 'CUCUUUUUUCUC',
83 'UUUUUUUUUUUU',
84 'UUUCUUUUCUUU',
85 'UUUUUUUUUUUU',
86 'UooUUUUUUooU',
87 'UohUUNNUUhoU',
88 'UUUUNNNNUUUU',
89 'UwUNoNNoNUwU',
90 '.UwNNNNNNwU.',
91 '..UssssssU..',
92 ],
93 shut: ['UooUUUUUUooU', 'UohUUNNUUhoU'],
94 },
95 // Dark wine hair, the forehead scar, red eyes, hanafuda earrings, the check haori collar.
96 tanjiro: {
97 name: 'Tanjiro',
98 art: 'Water Breathing',
99 head: [
100 '...IIIIII...',
101 '.IIIJIIJIII.',
102 'IIIIIIIIIIII',
103 'IIIIIIIIIDDI',
104 'IIsIsssIsDsI',
105 'IshessssehsI',
106 'IseesssseesI',
107 'wpsssssssspw',
108 'DssssmmssssD',
109 'w.ssssssss.w',
110 '.gcgcuucgcg.',
111 ],
112 shut: ['IssssssssssI', 'IsoossssoosI'],
113 },
114 // Spiky gold hair with orange ends, thick brows, amber eyes, orange haori with white triangles.
115 zenitsu: {
116 name: 'Zenitsu',
117 art: 'Thunder Breathing',
118 head: [
119 '..Y.YYYY.Y..',
120 '.YYYYYYYYYY.',
121 'YYYYYYYYYYYY',
122 'YYYsYYYYsYYY',
123 'OYsYYssYYsYO',
124 'OshAssssAhsO',
125 'OsAAssssAAsO',
126 'OpsssssssspO',
127 '.ssssmmssss.',
128 '..ssssssss..',
129 '.OwOwuuwOwO.',
130 ],
131 shut: ['OssssssssssO', 'OsoossssoosO'],
132 },
133 // Long black hair with orange ends, the pink ribbon, pink eyes, the bamboo muzzle.
134 nezuko: {
135 name: 'Nezuko',
136 art: 'Blood Demon Art',
137 head: [
138 '...XXXXX.MM.',
139 '.XXXXXXXXMMM',
140 'XXXXXXXXXXM.',
141 'XXXXXXXXXXXX',
142 'XXsXssssXsXX',
143 'XshMssssMhsX',
144 'XsMMssssMMsX',
145 'XpsssssssspX',
146 'XVvvvvvvvvVX',
147 'XXssssssssXX',
148 'E.MMnMMnMM.E',
149 ],
150 shut: ['XssssssssssX', 'XsoossssoosX'],
151 },
152 // Gold flame hair with crimson tips, forked brows, gold eyes ringed red, a wide smile.
153 rengoku: {
154 name: 'Rengoku',
155 art: 'Flame Breathing',
156 head: [
157 'D.YD.YY.DY.D',
158 'DYYDYYYYDYYD',
159 'YYYYYYYYYYYY',
160 'YDYYYYYYYYDY',
161 'YYDDssssDDYY',
162 'YDAhsssshADY',
163 'YsAAssssAAsY',
164 'YpsssssssspY',
165 '.ssmwwwwmss.',
166 '..ssmmmmss..',
167 '.wwwuuuuwww.',
168 ],
169 shut: ['YYssssssssYY', 'YsoossssoosY'],
170 },
171 // Untidy black hair, calm half-shut blue eyes, the haori half maroon and half pattern.
172 giyu: {
173 name: 'Giyu',
174 art: 'Water Breathing, Dead Calm',
175 head: [
176 '...XXXXXX...',
177 '.XXXXXXXXXX.',
178 'XXXXXXXXXXXX',
179 'XXXsXXXXsXXX',
180 'XXsXsssXssXX',
181 'XsXXssssXXsX',
182 'XsZhssssZhsX',
183 'XssssssssssX',
184 'XsssssmssssX',
185 'X.ssssssss.X',
186 '.WWWuuuGyGO.',
187 ],
188 shut: ['XsXXssssXXsX', 'XsoossssoosX'],
189 },
190}
191
192export const castName = (c: CastName): string => MEMBERS[c].name
193export const castArt = (c: CastName): string => MEMBERS[c].art
194
195const W = 12
196const H = 11
197// The slayer yard is 6 terminal rows: 12 pixels.
198const YARD_H = 12
199
200export type CastFacts = { pin: CastName | 'rotate'; isBusy: boolean; turn: number; turnStartedAt: number; now: number }
201
202// Who floats in the yard. At rest it is Inosuke. While work runs, the cast rotates
203// with each turn and with each 30 seconds of a long turn.
204export function castAt(f: CastFacts): CastName {
205 if (f.pin !== 'rotate') return f.pin
206 if (!f.isBusy || f.turn < 1) return CAST[0]!
207 const steps = f.turnStartedAt ? Math.max(0, Math.floor((f.now - f.turnStartedAt) / 30_000)) : 0
208 return CAST[(f.turn - 1 + steps) % CAST.length]!
209}
210
211// The technique shows while a tool runs, and for a short time each 6 seconds at rest.
212function isBreathing(mood: PetMood, tick: number): boolean {
213 if (mood === 'run' || mood === 'type' || mood === 'read') return true
214 return mood === 'idle' && tick % 40 >= 32
215}
216
217// The head and how high it floats. It bobs at rest, hops as it moves, and jumps when done.
218function frame(c: CastName, mood: PetMood, tick: number): { sprite: Sprite; lift: number } {
219 const m = MEMBERS[c]
220 const blink = tick % 27 === 0 || tick % 27 === 1
221 const isShut = blink || mood === 'sleep' || mood === 'happy'
222 const sprite = m.head.map((r, i) => (isShut && i === 5 ? m.shut[0] : isShut && i === 6 ? m.shut[1] : r))
223 let lift = 0
224 if (mood === 'walk' || mood === 'run') lift = Math.floor(tick / 2) % 2
225 else if (mood === 'happy') lift = tick % 2
226 else if (mood === 'idle' || mood === 'think' || mood === 'read') lift = tick % 16 < 8 ? 0 : 1
227 return { sprite, lift }
228}
229
230function dot(p: Pixels, x: number, y: number, rgb: Rgb): void {
231 if (x < 0 || y < 0 || x >= p.w || y >= p.h) return
232 p.px[y * p.w + x] = rgb
233}
234
235// ---- breathing techniques: drawn around the head --------------------------------------
236
237// Inosuke: two jagged blades that cross in an X and tear forward like claws.
238function beast(p: Pixels, x: number, dir: number, tick: number): void {
239 const front = dir > 0 ? x + W : x - 1
240 const reach = 4 + (tick % 3) * 2
241 for (let i = 0; i < reach; i++) {
242 const jag = i % 2
243 const tip = i === reach - 1 ? 0xffffff : PAL.l!
244 dot(p, front + dir * i, 1 + i + jag, tip)
245 dot(p, front + dir * i, 10 - i - jag, tip)
246 if (i % 3 === 1) dot(p, front + dir * i, 2 + i + jag, 0x8f9bb3)
247 }
248}
249
250// Tanjiro: a water wheel. An arc of water turns around the head.
251function water(p: Pixels, x: number, _dir: number, tick: number): void {
252 const cx = x + W / 2 - 0.5
253 const cy = 5.5
254 for (let k = 0; k < 22; k++) {
255 const t = (tick * 0.45 + (k / 22) * Math.PI * 1.8) % (Math.PI * 2)
256 const px = Math.round(cx + 8.5 * Math.cos(t))
257 const py = Math.round(cy + 5.5 * Math.sin(t))
258 if (px >= x && px < x + W && py >= 0 && py < H) continue
259 dot(p, px, py, k > 18 ? 0xffffff : k % 2 ? PAL.a! : PAL.Z!)
260 }
261}
262
263// Zenitsu: bolts of lightning that strike forward, with a flicker.
264function thunder(p: Pixels, x: number, dir: number, tick: number): void {
265 if (tick % 3 === 2) return
266 const front = dir > 0 ? x + W : x - 1
267 for (const base of [2, 8]) {
268 let py = base + (tick % 2)
269 for (let i = 0; i < 12; i++) {
270 py += i % 4 === 1 ? 1 : i % 4 === 3 ? -1 : 0
271 dot(p, front + dir * i, py, i % 3 === 0 ? 0xffffff : PAL.Y!)
272 if (i % 4 === 2) dot(p, front + dir * i, py + 1, PAL.Y!)
273 }
274 }
275 // A small spark at each side of the head.
276 dot(p, x - 2 * dir, 1 + (tick % 4), 0xffffff)
277}
278
279// Nezuko: pink demon flames that rise around her.
280function blood(p: Pixels, x: number, tick: number): void {
281 for (let k = 0; k < 10; k++) {
282 const side = k % 2 ? x - 1 - (k % 4) : x + W + (k % 4)
283 const fy = YARD_H - 1 - ((tick + k * 3) % 11)
284 dot(p, side, fy, k % 3 ? 0xff6fb5 : 0xff9ad5)
285 dot(p, side, fy + 1, 0xd94f8a)
286 }
287}
288
289// Rengoku: an arc of fire in front, and flames that dance on the hair.
290function flame(p: Pixels, x: number, dir: number, tick: number): void {
291 const cx = dir > 0 ? x + W : x - 1
292 const cy = 5
293 const sweep = [0.4, 0.7, 1, 1][tick % 4] ?? 1
294 for (let k = 0; k <= 12; k++) {
295 const t = -1.4 + 2.8 * (k / 12) * sweep
296 const px = cx + Math.round(dir * 5 * Math.cos(t))
297 const py = cy + Math.round(5 * Math.sin(t))
298 dot(p, px, py, k % 3 === 0 ? PAL.Y! : PAL.F!)
299 dot(p, px - dir, py, k % 2 ? PAL.F! : PAL.D!)
300 }
301 for (let k = 0; k < 3; k++) dot(p, x + 2 + k * 4 + (tick % 2), (tick + k) % 3 === 0 ? 0 : 1, k % 2 ? PAL.F! : PAL.Y!)
302}
303
304// Giyu: Dead Calm. Still rings of water spread at his sides.
305function calm(p: Pixels, x: number, tick: number): void {
306 const cx = x + W / 2
307 for (const r of [(tick % 6) * 2 + 7, ((tick + 3) % 6) * 2 + 7]) {
308 for (let a = 0; a < 28; a++) {
309 const t = (a / 28) * Math.PI * 2
310 const py = YARD_H - 2 + Math.round(1.5 * Math.sin(t))
311 const px = Math.round(cx + r * Math.cos(t))
312 if (px >= x && px < x + W) continue
313 dot(p, px, py, a % 3 ? PAL.a! : PAL.Z!)
314 }
315 }
316}
317
318function breathe(c: CastName, p: Pixels, x: number, dir: number, tick: number): void {
319 if (c === 'inosuke') beast(p, x, dir, tick)
320 else if (c === 'tanjiro') water(p, x, dir, tick)
321 else if (c === 'zenitsu') thunder(p, x, dir, tick)
322 else if (c === 'nezuko') blood(p, x, tick)
323 else if (c === 'rengoku') flame(p, x, dir, tick)
324 else calm(p, x, tick)
325}
326
327// ---- crows and the demon ----------------------------------------------------------------
328
329const CROW_UP: Sprite = ['.BB....', 'KKBKKk.', '.KKKKKy', '..KK...']
330const CROW_DOWN: Sprite = ['.......', 'KKBKKk.', '.KKKKKy', '.BK....']
331
332// Rui's head: white hair, pale skin, red eyes, red lines down the face, a white collar.
333const RUI: Sprite = [
334 '..wwwwww..',
335 '.wwwwwwww.',
336 'wwwPwwPwww',
337 'wPPPPPPPPw',
338 'wDDPPPPDDw',
339 'wDkPPPPkDw',
340 'wDPPmmPPDw',
341 '.PPPPPPPP.',
342 '..wwDDww..',
343]
344
345function drawSlayerYard(y: Yard, scene: PetScene, width: number): Pixels {
346 const p = newPixels(width, YARD_H)
347 const c = scene.who ?? CAST[0]!
348 const { sprite, lift } = frame(c, scene.mood, y.tick)
349 const isMoving = scene.mood === 'walk' || scene.mood === 'run'
350 const dir = isMoving ? y.dir : 1
351 const top = YARD_H - H - lift
352 const isArt = isBreathing(scene.mood, y.tick)
353 // Water and blood go behind the head, so they are drawn first.
354 const isBehind = c === 'tanjiro' || c === 'giyu' || c === 'nezuko'
355 if (isArt && isBehind) breathe(c, p, y.x, dir, y.tick)
356 paint(p, dir < 0 ? flip(sprite) : sprite, y.x, top, undefined, PAL)
357 if (isArt && !isBehind) breathe(c, p, y.x, dir, y.tick)
358 // Sparkles burst around the head when the turn is done.
359 if (scene.mood === 'happy') {
360 const r = 7 + (y.tick % 3)
361 for (let k = 0; k < 8; k++) {
362 const t = (k / 8) * Math.PI * 2 + y.tick * 0.3
363 dot(p, Math.round(y.x + 5.5 + r * Math.cos(t)), Math.round(5.5 + 0.7 * r * Math.sin(t)), k % 2 ? 0xffd166 : 0xffffff)
364 }
365 }
366 // A failed call brings Rui. Rui floats in front of the slayer and shakes.
367 if (scene.mood === 'sad') {
368 const ox = Math.min(width - RUI[0]!.length, y.x + W + 3) + (y.tick % 2)
369 paint(p, RUI, ox, YARD_H - RUI.length - (y.tick % 4 < 2 ? 0 : 1), undefined, PAL)
370 // A sweat drop on the slayer.
371 dot(p, y.x + W - 1, top + 3 + (y.tick % 3), PAL.a!)
372 }
373 // Messenger crows fly at the top for the running subagents, each a little out of step.
374 scene.slimes.slice(0, 5).forEach((_, i) => {
375 const wing = (y.tick + i * 2) % 4 < 2
376 const s = wing ? CROW_UP : CROW_DOWN
377 const behind = y.x - 9 * (i + 1)
378 const cx = behind >= 0 ? behind : y.x + W + 2 + 9 * i
379 const cy = (y.tick + i * 3) % 6 < 3 ? 0 : 1
380 paint(p, cx < y.x ? s : flip(s), cx, cy, undefined, PAL)
381 })
382 const headCol = y.x + W
383 if (scene.mood === 'sleep') {
384 const t = y.tick % 12
385 if (t >= 2) p.marks.push({ col: headCol, row: 2, ch: 'z', rgb: 0x7aa2f7 })
386 if (t >= 6) p.marks.push({ col: headCol + 1, row: 1, ch: 'z', rgb: 0x7aa2f7 })
387 if (t >= 9) p.marks.push({ col: headCol + 2, row: 0, ch: 'Z', rgb: 0x7aa2f7 })
388 } else if (scene.mood === 'think') {
389 p.marks.push({ col: headCol, row: 1, ch: y.tick % 6 < 3 ? '?' : '·', rgb: 0xe0af68 })
390 }
391 return p
392}
393
394export function drawScene(y: Yard, scene: PetScene, width: number): Pixels {
395 return scene.theme === 'slayer' ? drawSlayerYard(y, scene, width) : drawCatYard(y, scene, width)
396}
397
398// The words beside the yard: who stands there, and the technique while it shows.
399export function sceneTag(y: Yard, scene: PetScene): string {
400 if (scene.theme !== 'slayer') return ''
401 const c = scene.who ?? CAST[0]!
402 return isBreathing(scene.mood, y.tick) ? `${castName(c)} · ${castArt(c)}` : castName(c)
403}
404
405export function sceneAlt(scene: PetScene): string {
406 if (scene.theme !== 'slayer') return catAlt(scene)
407 const who = castName(scene.who ?? CAST[0]!)
408 const art = castArt(scene.who ?? CAST[0]!)
409 const what: Record<PetMood, string> = {
410 idle: `${who} floats and waits.`,
411 sleep: `${who} sleeps.`,
412 think: `${who} thinks.`,
413 walk: `${who} hops along.`,
414 run: `${who} runs a command: ${art}.`,
415 type: `${who} makes an edit: ${art}.`,
416 read: `${who} reads: ${art}.`,
417 happy: `${who} jumps: the turn is done.`,
418 sad: 'Rui appears: a call failed.',
419 }
420 const n = scene.slimes.length
421 return `${what[scene.mood]}${n ? ` ${n} crow${n === 1 ? '' : 's'} fly above for the subagents.` : ''}`
422}
423
424// ---- turn portraits ------------------------------------------------------------------
425
426export type Portrait = 'recruit' | 'swordsman' | 'pillar' | 'fighter' | 'resting' | 'crow' | 'lesser' | 'demon' | 'upper'
427
428// 6 by 4 pixels: two terminal rows, beside the prompt line and the summary line.
429const FACES: Record<Portrait, [Sprite, Sprite]> = {
430 // Zenitsu: gold hair, amber eyes, orange and white haori
431 recruit: [
432 ['YYOYYO', 'OAssAO', '.smms.', 'OwOwOw'],
433 ['YYOYYO', 'OossoO', '.smms.', 'wOwOwO'],
434 ],
435 // Tanjiro: scar, earrings, check haori
436 swordsman: [
437 ['IIIIDI', 'wesseW', 'DsmssD', 'gcgcgc'],
438 ['IIIIDI', 'wossoW', 'DsmssD', 'cgcgcg'],
439 ],
440 // Rengoku: flame hair, gold eyes, flame hem
441 pillar: [
442 ['DYYDYD', 'YAssAY', '.smms.', 'FwuuwF'],
443 ['YDYYDY', 'YAssAY', '.smms.', 'wFuuFw'],
444 ],
445 // Inosuke: boar mask, tusks, fur pelt
446 fighter: [
447 ['CUUUUC', 'UoUUoU', 'UwNNwU', 'ssffss'],
448 ['CUUUUC', 'UoUUoU', 'UwNNwl', 'ssffsl'],
449 ],
450 // Nezuko asleep: pink ribbon, bamboo muzzle
451 resting: [
452 ['XXXXMM', 'XossoX', 'XvvvvX', 'ETMMTE'],
453 ['XXXXMM', 'XossoX', 'XvvvvX', 'ETMMTE'],
454 ],
455 crow: [
456 ['..BK..', 'KBKKk.', '.KKKKy', '..y.y.'],
457 ['.BB...', '.KBKk.', 'KKKKKy', '..y.y.'],
458 ],
459 // Rui: white hair, red eyes and face lines
460 lesser: [
461 ['wwwwww', 'wDPPDw', 'PDmmDP', 'wwDwww'],
462 ['wwwwww', 'wkPPkw', 'PDmmDP', 'wwwDww'],
463 ],
464 // Akaza: pink hair, gold eyes, blue lines
465 demon: [
466 ['MMMMMM', 'MAPPAM', 'ZPZZPZ', '.MuuM.'],
467 ['MMMMMM', 'MYPPYM', 'ZPZZPZ', '.MuuM.'],
468 ],
469 // Muzan: white hat, red eyes, black suit
470 upper: [
471 ['.wqqw.', 'wwwwww', 'XDPPDX', 'qPmmPq'],
472 ['.wqqw.', 'wwwwww', 'XkPPkX', 'qPmmPq'],
473 ],
474}
475
476export const PORTRAIT_W = 6
477export const PORTRAIT_H = 4
478
479export const PORTRAIT_NAMES: Record<Portrait, string> = {
480 recruit: 'Zenitsu: a quick turn that worked',
481 swordsman: 'Tanjiro: a longer turn that worked',
482 pillar: 'Rengoku, a Hashira: a long, hard turn that worked',
483 fighter: 'Inosuke in a fight: the turn runs now',
484 resting: 'Nezuko asleep: you stopped the turn',
485 crow: 'crow: a background update',
486 lesser: 'Rui: one call failed',
487 demon: 'Akaza: several calls failed',
488 upper: 'Muzan: many calls failed, or the turn ended on an error',
489}
490
491export type TurnFacts = { isRunning: boolean; isAuto: boolean; isStopped: boolean; isErrorEnd: boolean; calls: number; failed: number; ms: number }
492
493// The rules that pick a portrait. Failures pick a demon by count. A turn that
494// worked picks a rank by its time and its calls.
495export function portraitFor(f: TurnFacts): Portrait {
496 if (f.isRunning) return 'fighter'
497 if (f.isErrorEnd || f.failed >= 5) return 'upper'
498 if (f.failed >= 2 && f.failed / Math.max(1, f.calls) >= 0.1) return 'demon'
499 if (f.failed >= 1 && f.failed / Math.max(1, f.calls) >= 0.1) return 'lesser'
500 if (f.isStopped) return 'resting'
501 if (f.isAuto) return 'crow'
502 if (f.ms >= 300_000 || f.calls >= 30) return 'pillar'
503 if (f.ms >= 60_000 || f.calls >= 6) return 'swordsman'
504 return 'recruit'
505}
506
507export function portraitPixels(kind: Portrait, frame: number): Pixels {
508 const p = newPixels(PORTRAIT_W, PORTRAIT_H)
509 const pair = FACES[kind] ?? FACES.recruit
510 paint(p, pair[frame % 2]!, 0, 0, undefined, PAL)
511 return p
512}
513types/index.d.ts 91 lines1export type EntryKind = 'prompt' | 'tool' | 'answer'
2
3export type Status = 'run' | 'ok' | 'err'
4
5export type Entry = {
6 kind: EntryKind
7 at: number
8 turn: number
9 text: string
10 tool?: string
11 id?: string
12 isSub?: boolean
13 // The subagent that made this tool call. Absent on the main loop.
14 agentId?: string
15 status?: Status
16 ms?: number
17 isAuto?: boolean
18 isRestored?: boolean
19 // On a prompt row: a few words that tell what the turn did, or does now.
20 summary?: string
21 // True when the model wrote the summary. False or absent: the rule-based text.
22 isAiSummary?: boolean
23}
24
25// One subagent that a turn started.
26export type AgentRec = {
27 id: string
28 // The Agent tool call that started it.
29 toolUseId: string
30 turn: number
31 description: string
32 type: string
33 startedAt: number
34 endedAt?: number
35 status: Status
36 calls: number
37 failed: number
38 summary?: string
39 isAiSummary?: boolean
40}
41
42export type Live = {
43 phase: 'idle' | 'working' | 'tool'
44 since: number
45 turnStartedAt: number
46 tool?: string
47 label?: string
48 running: number
49}
50
51export type View = 'overview' | 'deep' | 'narrow'
52
53export type ToolKind = 'all' | 'edits' | 'commands' | 'reads' | 'agents' | 'web'
54
55export type Prefs = {
56 view: View
57 isBandOn: boolean
58 isSubShown: boolean
59 isErrorsOnly: boolean
60 isClockTime: boolean
61 isRecentOnly: boolean
62 isKeysPinned: boolean
63 isSlowOnly: boolean
64 isAiOn: boolean
65 // The yard pets: a swordsman theme, a cat theme, or none.
66 pet: 'off' | 'cat' | 'slayer'
67 // A portrait on each turn, picked from how the turn went.
68 isTurnSprites: boolean
69 // The slayer in the yard: one by name, or 'rotate' to change with the work.
70 cast: 'rotate' | 'inosuke' | 'tanjiro' | 'zenitsu' | 'nezuko' | 'rengoku' | 'giyu'
71 kind: ToolKind
72}
73
74export type Totals = { startedAt: number; turns: number; tools: number; errors: number; perTurn: number[] }
75
76declare module 'claude-code' {
77 interface PluginState {
78 'session-timeline': {
79 entries: Entry[]
80 agents: AgentRec[]
81 // Turns the person opened or closed by hand, by turn number.
82 expanded: Record<string, boolean>
83 live: Live
84 totals: Totals
85 isQuiet: boolean
86 prefs: Prefs
87 sessionId: string
88 }
89 }
90}
91