SLOPSHOPPER

session-timeline

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

newpanebandspinnerrowsguard
v0.9.1no licenseupdated 2026-10-06sunfootsoldier-suraj/session-timeline
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · session-timeline
│ ┃ Timeline ✕ › fix the failing auth test and add an audit log call │ ┃ ● Session 30m 1s 2 turns · 10 calls · 2 fa… │ ┃ overview view · model labels ⏺ Read(src/auth.ts) │ ┃ ▂█ ⎿ Read 6 lines │ ┃ ──────────────────────────────────────────── ⏺ Update(src/auth.ts) │ ┃ ──── ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ ▀▀▀▀▀▀ ▸ fix the failing auth test ⎿ 3 pass, 1 fail │ ┃ ▀▀▀▀▀▀ ● OK │ ┃ └ ✗ earlier session ■ 1 other · ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ ▀▀▀▀▀▀ ▸ 08:53 fix the failing auth test and │ ┃ ▀▀▀▀▀▀ ● OK ✻ Worked for 42s · done 4:20 PM │ ┃ └ ✗ 42s ■■■■■■■■■ 4 commands · │ ┃ Tanjiro › /timeline │ ┃ ▄▄▄▄▄▄ ⎿ session-timeline: Timeline open. │ ┃ ▄▀▀▀▀▀▀▀▀▀▀▄ ▄▀▀▀▀▀▀▄ ⎿ session-timeline: Views: o overview · d deep · n narrow · v next │ ┃ ▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀ ⎿ session-timeline: Filters: e errors · s slow (30s+) · f tool typ │ ┃ ▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀ ⎿ session-timeline: Move: g jump to the end · h jump to the start │ ┃ ▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀ ⎿ session-timeline: Other: p pets (slayer, cat, off) · y slayer (r │ ┃ ▀▄▀▀▀▀ ⎿ session-timeline: A press on ▸ or ▾ opens or closes one turn. Ta │ ┃ ──────────────────────────────────────────── │ ┃ ──── │ ┃ ✓ Ready · last turn ended 0s ago │ ┃ ctrl+x tab for keys · /timeline help │ ✓ Ready │ last turn 42s · 9 calls · 1 failed │ OK ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
✓ Ready │ last turn 42s · 9 calls · 1 failed │ OK
Pane · Timeline
● Session 30m 1s 2 turns · 10 calls · 2 failed overview view · model labels ▂█ ──────────────────────────────────────────────── ▀▀▀▀▀▀ ▸ fix the failing auth test ▀▀▀▀▀▀ ● OK └ ✗ earlier session ■ 1 other · 1 failed ▀▀▀▀▀▀ ▸ 08:53 fix the failing auth test and add an a… ▀▀▀▀▀▀ ● OK └ ✗ 42s ■■■■■■■■■ 4 commands · 3 edits · 2 read Tanjiro ▄▄▄▄▄▄ ▄▀▀▀▀▀▀▀▀▀▀▄ ▄▀▀▀▀▀▀▄ ▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀ ▀▄▀▀▀▀ ──────────────────────────────────────────────── ✓ Ready · last turn ended 0s ago ctrl+x tab for keys · /timeline help
README

session-timeline

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.

  • Timeline: one row per turn, with a few-word summary from a small model, the tool calls under it, and a lane for each subagent. The pane scrolls as far as the session goes.
  • Turn portraits: a small face beside each turn shows how the turn went. Zenitsu, Tanjiro and Rengoku mark turns that worked, by length. Rui, Akaza and Muzan mark failed calls, by count.
  • Yard: a chibi head floats under the timeline. Inosuke is the default. While work runs, the cast rotates each turn and each 30 seconds: Inosuke, Tanjiro, Zenitsu, Nezuko, Rengoku, Giyu. Each one shows their own breathing technique while a tool runs. A crow flies for each running subagent. Rui appears when a call fails. A cat theme is also included.

Install

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.

Use

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

Develop

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.

Fan art notice

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.

Source 4 files
hooks/register.tsx 1660 lines
1import { 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 lines
1// 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}
306
hooks/slayer.ts 513 lines
1// 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}
513
types/index.d.ts 91 lines
1export 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