KOZMOS Storyboard: Claude narrates its own progress. A chapter tool the model calls at each phase change drives a band above the prompt: the chapter title, a…

Claude narrates its own progress. Storyboard registers a tool the model can call, mcp__storyboard__chapter { title, phase, step?, steps?, note? }, and adds one short line to the system prompt asking Claude to call it whenever its phase changes on a multi-step task. Each call becomes a chapter.
3/5 with a small meter, a five-stage rail (explore → plan → build → verify → ship) with the active stage lit, and the note. On the desktop the rail is one SVG with a glowing node, a pulsing halo and a comet orbiting the active stage; on the terminal it is a colored rail. The band stacks under the other mods' bands and hides with its ✕ or /storyboard (remembered between sessions)./storyboard log opens the history of chapters with times, phase chips and notes, plus a time-per-phase bar. /storyboard list prints the log as text.Settings (userConfig): nudge (default on) adds the prompt line; turn it off to keep the prompt untouched and let Claude find the tool on its own. autoOpen (default off) opens the log pane at start.
Claude kendi ilerlemesini anlatır. Storyboard modelin çağırabileceği bir araç kaydeder (mcp__storyboard__chapter { title, phase, step?, steps?, note? }) ve sistem istemine, çok adımlı işlerde her evre değişiminde bu aracı çağırmasını isteyen tek kısa satır ekler. Her çağrı bir bölüm olur.
3/5 adımı ve küçük bir ölçer, beş evreli ray (explore → plan → build → verify → ship) ve not. Masaüstünde ray, etkin evrenin etrafında dönen bir kuyruklu yıldızla parlayan tek bir SVG; terminalde renkli bir ray. Diğer modların bantlarının altına dizilir; ✕ ya da /storyboard ile gizlenir (oturumlar arasında hatırlanır)./storyboard log zamanları, evre etiketleri ve notlarıyla bölüm geçmişini ve evre başına süre çubuğunu açar. /storyboard list günlüğü metin olarak yazar.Ayarlar: nudge (varsayılan açık) istem satırını ekler; kapatılırsa istem değişmez. autoOpen (varsayılan kapalı) günlük panelini açılışta açar.
hooks/register.tsx 236 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { StoryBoard, StoryChapter } from '../types'
5import { KZ, bar, fmtClock, pxOf } from './lib/kz.ts'
6import {
7 CHAPTER_SCHEMA, NUDGE, PHASES, PHASE_COLOR, PHASE_GLYPH, bandSvg, chapterAlt, chapterRowSvg, headerSvg, parseChapter, phaseTimes,
8 railRuns, stepText, timesSvg,
9} from './story.ts'
10
11const PANE = 'kz-storyboard'
12const TITLE = 'KOZMOS · Storyboard'
13const TOOL = 'mcp__storyboard__chapter'
14const MAX_LOG = 120
15
16const boardAtom = atom({ plugin: 'storyboard', key: 'board' } as const, { current: null, log: [], startedAt: 0 })
17const hiddenAtom = atom({ plugin: 'storyboard', key: 'isHidden' } as const, false)
18
19async function setHidden($: EngineInterface, isHidden: boolean): Promise<void> {
20 await update($, hiddenAtom, () => isHidden)
21 try {
22 await $.store.set('hidden', isHidden)
23 } catch {
24 // Not persisted; this session still honours it.
25 }
26}
27
28async function togglePane($: EngineInterface): Promise<boolean> {
29 if ((await $.ui.panes()).some(p => p.id === PANE)) {
30 await $.ui.close({ id: PANE })
31 return false
32 }
33 await $.ui.open({ id: PANE, title: TITLE })
34 return true
35}
36
37async function addChapter($: EngineInterface, c: StoryChapter): Promise<number> {
38 let n = 0
39 await update($, boardAtom, prev => {
40 const log = [...prev.log, c].slice(-MAX_LOG)
41 n = log.length
42 return { ...prev, current: c, log }
43 })
44 return n
45}
46
47function logText(b: StoryBoard): string {
48 if (!b.log.length) return 'Storyboard: no chapters yet. Claude marks them on multi-step tasks.'
49 return [
50 `Storyboard — ${b.log.length} chapter${b.log.length === 1 ? '' : 's'}`,
51 ...b.log.map(c => {
52 const step = stepText(c)
53 return ` ${fmtClock(c.at - b.startedAt)} ${c.phase.padEnd(7)} ${c.title}${step ? ` (${step})` : ''}${c.note ? ` — ${c.note}` : ''}`
54 }),
55 ].join('\n')
56}
57
58export const register: Register = (on, options) => {
59 on('session.start', async ($, e, next) => {
60 const started = await next(e)
61 const now = await $.clock.now()
62 await update($, boardAtom, () => ({ current: null, log: [], startedAt: now }))
63 await $.command.register({
64 name: 'storyboard',
65 description: 'KOZMOS: show or hide the Storyboard chapter band (/storyboard log opens the chapter history)',
66 argumentHint: '[log]',
67 immediate: true,
68 })
69 try {
70 const stored = await $.store.get('hidden')
71 if (typeof stored === 'boolean') await update($, hiddenAtom, () => stored)
72 } catch {
73 // Fresh store.
74 }
75 try {
76 await $.tool.register({
77 name: 'chapter',
78 description: 'Mark a new chapter of your work for the user watching: call it when your phase changes on a multi-step task. Returns "noted".',
79 inputSchema: CHAPTER_SCHEMA,
80 isDeferred: false,
81 })
82 } catch {
83 // No tool this session; the band stays empty.
84 }
85 if (options.autoOpen === true) void $.ui.open({ id: PANE, title: TITLE })
86 return started
87 })
88
89 on('command.run', { command: 'storyboard' }, async ($, e) => {
90 const arg = e.args.trim().toLowerCase()
91 if (arg === 'log') {
92 const isOpen = await togglePane($)
93 return { text: isOpen ? 'Storyboard log open.' : 'Storyboard log closed.' }
94 }
95 if (arg === 'list' || arg === 'print') return { text: logText(await read($, boardAtom)) }
96 const isHidden = !(await read($, hiddenAtom))
97 await setHidden($, isHidden)
98 return { text: isHidden ? 'Storyboard band hidden.' : 'Storyboard band shown.' }
99 })
100
101 // The tool the model calls. Its answer is one word, to cost nothing.
102 on('tool.call', { tool: TOOL }, async ($, e) => {
103 const parsed = parseChapter(e as unknown as Record<string, unknown>, await $.clock.now())
104 if (typeof parsed === 'string') return { result: `not noted: ${parsed}` }
105 await addChapter($, parsed)
106 return { result: 'noted' }
107 }).catch(() => ({ result: 'not noted: the storyboard is unavailable' }))
108
109 // One short section of the system prompt, on the session side of the cache
110 // boundary, only where the tool is offered; `nudge: false` drops it.
111 on('prompt.compose', async ($, e, next) => {
112 const composed = await next(e)
113 if (options.nudge === false) return composed
114 if (e.tools.length > 0 && !e.tools.includes(TOOL)) return composed
115 if (composed.sections.some(s => s.id === 'storyboard:nudge')) return composed
116 return { sections: [...composed.sections, { id: 'storyboard:nudge', text: NUDGE, scope: 'session' as const }] }
117 }).catch(($, e, next) => next(e))
118
119 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
120 const drawn = await next(e)
121 if (e.props.hasSurvey || (await read($, hiddenAtom))) return drawn
122 const c = (await read($, boardAtom)).current
123 if (!c) return drawn
124
125 const ui = $.ui.resolve(e)
126 const { Box, Button, Text } = ui
127 const hide = <Button key="storyboard-hide" label="✕" plain dimColor role="dismiss" onPress={() => void setHidden($, true)} />
128
129 if ('Svg' in ui && e.surface !== 'terminal') {
130 const { Svg } = ui
131 const W = pxOf(e.props.bodyColumns) - 36
132 const { source, height } = bandSvg(c, W)
133 return (
134 <Box flexDirection="column">
135 {drawn}
136 <Box key="storyboard" flexDirection="row">
137 <Svg source={source} alt={chapterAlt(c)} width={W} height={height} />
138 {hide}
139 </Box>
140 </Box>
141 )
142 }
143
144 const cols = Math.max(24, e.props.bodyColumns || 80)
145 const color = PHASE_COLOR[c.phase]
146 const step = stepText(c)
147 const meter = c.step !== undefined && c.steps !== undefined ? ` ${bar(c.step / c.steps, Math.min(10, c.steps * 2))}` : ''
148 const roomy = e.props.maxRows >= 8
149 return (
150 <Box flexDirection="column">
151 {drawn}
152 <Box key="storyboard" flexDirection="column">
153 <Box flexDirection="row" justifyContent="space-between">
154 <Text wrap="truncate-end">
155 <Text color={color} bold>{PHASE_GLYPH[c.phase]} </Text>
156 <Text bold>{c.title}</Text>
157 {step ? <Text color={color}> {step}</Text> : ''}
158 {meter ? <Text color={color} dimColor>{meter}</Text> : ''}
159 </Text>
160 {hide}
161 </Box>
162 <Text wrap="truncate-end">
163 {railRuns(c.phase, cols).map((r, i) => (
164 <Text key={`r${i}`} color={r.color} bold={r.bold} dimColor={r.dim}>{r.text}</Text>
165 ))}
166 </Text>
167 {roomy && c.note ? <Text dimColor wrap="truncate-end"> ↳ {c.note}</Text> : null}
168 </Box>
169 </Box>
170 )
171 })
172
173 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
174 const b = await read($, boardAtom)
175 const now = await $.clock.now()
176 const times = phaseTimes(b.log, now)
177 const ui = $.ui.resolve(e)
178 const { Box, Text } = ui
179
180 if ('Svg' in ui && e.surface !== 'terminal') {
181 const { Svg } = ui
182 const W = pxOf(e.props.bodyColumns, 44)
183 const rows = [...b.log].reverse()
184 return (
185 <Box flexDirection="column" gap={1}>
186 <Svg key="head" source={headerSvg(b.current, b.log.length, W)} alt={b.current ? chapterAlt(b.current) : 'No chapters yet'} width={W} height={78} />
187 {b.log.length > 0 && <Svg key="times" source={timesSvg(times, W)} alt="Time per phase" width={W} height={54} />}
188 {rows.map((c, i) => (
189 <Svg key={`c${b.log.length - i}`} source={chapterRowSvg(c, W, b.startedAt, i === 0, i === rows.length - 1)} alt={chapterAlt(c)} width={W} height={c.note ? 50 : 36} />
190 ))}
191 </Box>
192 )
193 }
194
195 const cols = Math.max(28, e.props.bodyColumns || 40)
196 const total = PHASES.reduce((s, p) => s + times[p], 0)
197 return (
198 <Box flexDirection="column">
199 <Text bold color={KZ.violet}>✦ STORYBOARD <Text dimColor>· {b.log.length} chapters</Text></Text>
200 {b.current ? (
201 <Text wrap="truncate-end">
202 {railRuns(b.current.phase, cols).map((r, i) => (
203 <Text key={`r${i}`} color={r.color} bold={r.bold} dimColor={r.dim}>{r.text}</Text>
204 ))}
205 </Text>
206 ) : <Text dimColor>No chapters yet: Claude marks them on multi-step tasks.</Text>}
207 {total > 0 && (
208 <Box flexDirection="column" marginTop={1}>
209 {PHASES.map(p => (
210 <Box key={`t-${p}`} flexDirection="row">
211 <Box width={9} flexShrink={0}><Text color={PHASE_COLOR[p]}>{p}</Text></Box>
212 <Text color={PHASE_COLOR[p]}>{bar(times[p] / total, Math.max(6, Math.min(24, cols - 20)))}</Text>
213 <Text dimColor> {times[p] ? fmtClock(times[p]) : '—'}</Text>
214 </Box>
215 ))}
216 </Box>
217 )}
218 {[...b.log].reverse().map((c, i) => {
219 const step = stepText(c)
220 return (
221 <Box key={`c${b.log.length - i}`} flexDirection="column" marginTop={i === 0 ? 1 : 0}>
222 <Text wrap="truncate-end">
223 <Text dimColor>{fmtClock(c.at - b.startedAt).padStart(5)} </Text>
224 <Text color={PHASE_COLOR[c.phase]} bold={i === 0}>{i === 0 ? '◉' : '●'} {c.phase.padEnd(7)}</Text>
225 <Text bold={i === 0}>{c.title}</Text>
226 {step ? <Text dimColor> {step}</Text> : ''}
227 </Text>
228 {c.note ? <Text dimColor wrap="truncate-end">{' '}↳ {c.note}</Text> : null}
229 </Box>
230 )
231 })}
232 </Box>
233 )
234 })
235}
236hooks/lib/kz.ts 510 lines1// GENERATED by scripts/sync-shared.mjs from shared/kz.ts. Do not edit here.
2// KOZMOS shared kit. Source of truth: shared/kz.ts at the bundle root.
3// `node scripts/sync-shared.mjs` copies it into every mod as hooks/lib/kz.ts,
4// because a hooks module may only import files inside its own plugin.
5// Edit the root copy, never a mod's copy.
6
7// ---------------------------------------------------------------------------
8// Palette: one neon family across every KOZMOS mod.
9
10export const KZ = {
11 violet: '#a78bfa',
12 magenta: '#f472b6',
13 cyan: '#22d3ee',
14 teal: '#2dd4bf',
15 green: '#4ade80',
16 lime: '#a3e635',
17 yellow: '#facc15',
18 amber: '#fb923c',
19 red: '#f87171',
20 blue: '#60a5fa',
21 clay: '#d97757',
22 ink: '#1f1e1d',
23 mist: '#9ca3af',
24} as const
25
26/** Tool families, colored the same in every mod. */
27export function toolColor(tool: string): string {
28 const t = String(tool)
29 if (t === 'Bash' || t === 'PowerShell') return KZ.green
30 if (t === 'Edit' || t === 'Write' || t === 'NotebookEdit' || t === 'MultiEdit') return KZ.yellow
31 if (t === 'Read' || t === 'Glob' || t === 'Grep' || t === 'LSP') return KZ.blue
32 if (t === 'Agent' || t === 'Task' || t === 'Workflow') return KZ.violet
33 if (t.startsWith('Web')) return KZ.cyan
34 if (t.startsWith('Todo') || t.startsWith('Task')) return KZ.teal
35 if (t.startsWith('mcp__')) return KZ.magenta
36 return KZ.mist
37}
38
39/** A short glyph per tool family. */
40export function toolGlyph(tool: string): string {
41 const t = String(tool)
42 if (t === 'Bash' || t === 'PowerShell') return '$'
43 if (t === 'Edit' || t === 'Write' || t === 'NotebookEdit') return '✎'
44 if (t === 'Read') return '◉'
45 if (t === 'Glob' || t === 'Grep') return '⌕'
46 if (t === 'Agent') return '◈'
47 if (t.startsWith('Web')) return '◍'
48 if (t.startsWith('Todo') || t.startsWith('Task')) return '☑'
49 if (t.startsWith('mcp__')) return '⬡'
50 return '•'
51}
52
53// ---------------------------------------------------------------------------
54// Colors.
55
56export function hexToRgb(hex: string): [number, number, number] {
57 const h = hex.replace('#', '')
58 const n = parseInt(h.length === 3 ? h.split('').map(c => c + c).join('') : h, 16)
59 return [(n >> 16) & 255, (n >> 8) & 255, n & 255]
60}
61
62export function rgbToHex(r: number, g: number, b: number): string {
63 const c = (v: number) => Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, '0')
64 return `#${c(r)}${c(g)}${c(b)}`
65}
66
67export function mix(a: string, b: string, t: number): string {
68 const [r1, g1, b1] = hexToRgb(a)
69 const [r2, g2, b2] = hexToRgb(b)
70 const k = clamp01(t)
71 return rgbToHex(r1 + (r2 - r1) * k, g1 + (g2 - g1) * k, b1 + (b2 - b1) * k)
72}
73
74/** Green at 0, yellow at 0.6, red at 1: the heat of a gauge. */
75export function heat(t: number): string {
76 const k = clamp01(t)
77 return k < 0.6 ? mix(KZ.green, KZ.yellow, k / 0.6) : mix(KZ.yellow, KZ.red, (k - 0.6) / 0.4)
78}
79
80/** A smooth rainbow for hues 0..1 (for auroras, plasma and the like). */
81export function hue(h: number, s = 0.75, l = 0.6): string {
82 const k = ((h % 1) + 1) % 1
83 const a = s * Math.min(l, 1 - l)
84 const f = (n: number) => {
85 const x = (n + k * 12) % 12
86 return l - a * Math.max(-1, Math.min(x - 3, 9 - x, 1))
87 }
88 return rgbToHex(f(0) * 255, f(8) * 255, f(4) * 255)
89}
90
91export function hexToInt(hex: string): number {
92 const [r, g, b] = hexToRgb(hex)
93 return (r << 16) | (g << 8) | b
94}
95
96// ---------------------------------------------------------------------------
97// Numbers and text.
98
99export const clamp01 = (v: number): number => (Number.isFinite(v) ? Math.max(0, Math.min(1, v)) : 0)
100
101export function fmtTokens(n: number): string {
102 if (!Number.isFinite(n)) return '—'
103 // Each unit starts where the one below would round up to 1000 of itself.
104 if (n >= 999.5e6) return `${(n / 1e9).toFixed(1)}B`
105 if (n >= 999_500) return `${(n / 1e6).toFixed(n >= 1e7 ? 0 : 1)}M`
106 if (n >= 999.5) return `${(n / 1e3).toFixed(n >= 1e4 ? 0 : 1)}k`
107 return `${Math.round(n)}`
108}
109
110export function fmtUsd(usd: number): string {
111 if (!Number.isFinite(usd)) return '$—'
112 if (usd < 0.01 && usd > 0) return '<$0.01'
113 return usd < 100 ? `$${usd.toFixed(2)}` : `$${Math.round(usd)}`
114}
115
116/** 0:42, 3:07, 1:02:33. */
117export function fmtClock(ms: number): string {
118 const s = Math.max(0, Math.round(ms / 1000))
119 const h = Math.floor(s / 3600)
120 const m = Math.floor((s % 3600) / 60)
121 const ss = String(s % 60).padStart(2, '0')
122 return h ? `${h}:${String(m).padStart(2, '0')}:${ss}` : `${m}:${ss}`
123}
124
125/** 42s, 7m, 2h41m, 3d4h: compact durations for countdowns. */
126export function fmtSpan(ms: number): string {
127 const s = Math.max(0, Math.round(ms / 1000))
128 if (s < 60) return `${s}s`
129 const m = Math.floor(s / 60)
130 if (m < 60) return `${m}m`
131 const h = Math.floor(m / 60)
132 if (h < 48) return `${h}h${String(m % 60).padStart(2, '0')}m`
133 return `${Math.floor(h / 24)}d${h % 24}h`
134}
135
136export function fmtPct(p: number | undefined): string {
137 return p === undefined || !Number.isFinite(p) ? '—' : `${Math.round(p)}%`
138}
139
140export function clip(s: string, max: number): string {
141 const one = s.replace(/\s+/g, ' ').trim()
142 return one.length > max ? one.slice(0, Math.max(1, max - 1)) + '…' : one
143}
144
145export function padEnd(s: string, n: number): string {
146 return s.length >= n ? s.slice(0, n) : s + ' '.repeat(n - s.length)
147}
148
149export function padStart(s: string, n: number): string {
150 return s.length >= n ? s.slice(s.length - n) : ' '.repeat(n - s.length) + s
151}
152
153/** `claude-opus-5-5[1m]` → `Opus 5.5`. */
154export function modelName(id: string | undefined): string {
155 if (!id) return '—'
156 const m = /(fable|mythos|opus|sonnet|haiku)-(\d+)(?:-(\d{1,2})(?!\d))?/i.exec(id)
157 if (!m) return id.replace(/^claude-/, '').replace(/\[.*\]$/, '')
158 const fam = m[1]!
159 return `${fam.charAt(0).toUpperCase()}${fam.slice(1).toLowerCase()} ${m[2]}${m[3] ? '.' + m[3] : ''}`
160}
161
162/** Last path segment, either slash. */
163export function baseName(p: string): string {
164 const parts = p.split(/[\\/]/).filter(Boolean)
165 return parts[parts.length - 1] ?? p
166}
167
168/** A stable small hash for seeding. */
169export function hash(s: string): number {
170 let h = 2166136261
171 for (let i = 0; i < s.length; i++) {
172 h ^= s.charCodeAt(i)
173 h = Math.imul(h, 16777619)
174 }
175 return h >>> 0
176}
177
178/** Deterministic 0..1 noise from two ints. */
179export function noise(x: number, y: number): number {
180 const s = Math.sin(x * 12.9898 + y * 78.233) * 43758.5453
181 return s - Math.floor(s)
182}
183
184/** A seeded PRNG (mulberry32). */
185export function rng(seed: number): () => number {
186 let a = seed >>> 0
187 return () => {
188 a = (a + 0x6d2b79f5) >>> 0
189 let t = a
190 t = Math.imul(t ^ (t >>> 15), t | 1)
191 t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
192 return ((t ^ (t >>> 14)) >>> 0) / 4294967296
193 }
194}
195
196// ---------------------------------------------------------------------------
197// Text gauges for the terminal.
198
199const EIGHTHS = ['', '▏', '▎', '▍', '▌', '▋', '▊', '▉']
200
201/** A smooth bar with eighth blocks: `█████▍ `. */
202export function bar(ratio: number, width: number, empty = '░'): string {
203 const w = Math.max(1, Math.floor(width))
204 const exact = clamp01(ratio) * w
205 const full = Math.floor(exact)
206 const part = EIGHTHS[Math.floor((exact - full) * 8)] ?? ''
207 const used = full + (part ? 1 : 0)
208 return '█'.repeat(full) + part + empty.repeat(Math.max(0, w - used))
209}
210
211/** A segmented gauge: `▰▰▰▱▱▱`. */
212export function pips(ratio: number, width: number, on = '▰', off = '▱'): string {
213 const w = Math.max(1, Math.floor(width))
214 const n = Math.round(clamp01(ratio) * w)
215 return on.repeat(n) + off.repeat(w - n)
216}
217
218const SPARK = '▁▂▃▄▅▆▇█'
219
220/** A sparkline of the last `width` values, scaled to their own max (or `max`). */
221export function sparkline(values: readonly number[], width: number, max?: number): string {
222 const tail = values.slice(-Math.max(1, width))
223 const top = max ?? Math.max(1e-9, ...tail)
224 const line = tail.map(v => SPARK[Math.min(7, Math.max(0, Math.round((v / top) * 7)))] ?? '▁').join('')
225 return line.padStart(width, ' ')
226}
227
228/** Braille line graph, 2 samples per cell, `rows` cells tall. */
229export function brailleGraph(values: readonly number[], width: number, rows: number, max?: number): string[] {
230 const samples = values.slice(-(width * 2))
231 const top = max ?? Math.max(1e-9, ...samples)
232 const dotsTall = rows * 4
233 const grid: number[][] = Array.from({ length: rows }, () => Array.from({ length: width }, () => 0))
234 const offset = width * 2 - samples.length
235 // Dot bits per column, from the bottom row of a cell up.
236 const LEFT = [0x40, 0x04, 0x02, 0x01]
237 const RIGHT = [0x80, 0x20, 0x10, 0x08]
238 samples.forEach((v, i) => {
239 const x = offset + i
240 const col = Math.floor(x / 2)
241 const isRight = x % 2 === 1
242 const h = Math.round(clamp01(v / top) * (dotsTall - 1))
243 for (let d = 0; d <= h; d++) {
244 const row = rows - 1 - Math.floor(d / 4)
245 const bits = isRight ? RIGHT : LEFT
246 const cellRow = grid[row]
247 if (cellRow && col >= 0) cellRow[col] = (cellRow[col] ?? 0) | bits[d % 4]!
248 }
249 })
250 return grid.map(r => r.map(b => String.fromCharCode(0x2800 + b)).join(''))
251}
252
253// ---------------------------------------------------------------------------
254// Raster: a grid of colored cells, packed as the engine wants it.
255
256const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
257
258export function toBase64(bytes: Uint8Array): string {
259 let out = ''
260 let i = 0
261 for (; i + 2 < bytes.length; i += 3) {
262 const n = (bytes[i]! << 16) | (bytes[i + 1]! << 8) | bytes[i + 2]!
263 out += B64[(n >> 18) & 63]! + B64[(n >> 12) & 63]! + B64[(n >> 6) & 63]! + B64[n & 63]!
264 }
265 const rest = bytes.length - i
266 if (rest === 1) {
267 const n = bytes[i]! << 16
268 out += B64[(n >> 18) & 63]! + B64[(n >> 12) & 63]! + '=='
269 } else if (rest === 2) {
270 const n = (bytes[i]! << 16) | (bytes[i + 1]! << 8)
271 out += B64[(n >> 18) & 63]! + B64[(n >> 12) & 63]! + B64[(n >> 6) & 63]! + '='
272 }
273 return out
274}
275
276/** The terminal's own default color, for a cell's foreground or background. */
277export const DEFAULT_COLOR = 0x01000000
278
279export class Canvas {
280 readonly cols: number
281 readonly rows: number
282 private readonly words: Uint32Array
283
284 constructor(cols: number, rows: number) {
285 this.cols = Math.max(1, Math.min(512, Math.floor(cols)))
286 this.rows = Math.max(1, Math.min(256, Math.floor(rows)))
287 this.words = new Uint32Array(this.cols * this.rows * 3)
288 this.clear()
289 }
290
291 clear(bg: number = DEFAULT_COLOR): void {
292 for (let i = 0; i < this.cols * this.rows; i++) {
293 this.words[i * 3] = 0x20
294 this.words[i * 3 + 1] = DEFAULT_COLOR
295 this.words[i * 3 + 2] = bg
296 }
297 }
298
299 /** Puts one width-1 character; colors are '#rrggbb' or a packed int. */
300 set(x: number, y: number, ch: string, fg?: string | number, bg?: string | number): void {
301 const cx = Math.floor(x)
302 const cy = Math.floor(y)
303 if (cx < 0 || cy < 0 || cx >= this.cols || cy >= this.rows) return
304 const i = (cy * this.cols + cx) * 3
305 const code = ch.codePointAt(0) ?? 0x20
306 this.words[i] = code > 0xffff || code < 0x20 ? 0x20 : code
307 if (fg !== undefined) this.words[i + 1] = typeof fg === 'number' ? fg : hexToInt(fg)
308 if (bg !== undefined) this.words[i + 2] = typeof bg === 'number' ? bg : hexToInt(bg)
309 }
310
311 /** Writes a string left to right, clipped to the canvas. */
312 text(x: number, y: number, s: string, fg?: string | number, bg?: string | number): void {
313 let cx = x
314 for (const ch of s) {
315 this.set(cx, y, ch, fg, bg)
316 cx++
317 }
318 }
319
320 /** Paints a cell's background only, keeping its character. */
321 paint(x: number, y: number, bg: string | number): void {
322 const cx = Math.floor(x)
323 const cy = Math.floor(y)
324 if (cx < 0 || cy < 0 || cx >= this.cols || cy >= this.rows) return
325 this.words[(cy * this.cols + cx) * 3 + 2] = typeof bg === 'number' ? bg : hexToInt(bg)
326 }
327
328 /** Two vertical pixels per cell with the upper-half block: `py` is in half-cells. */
329 pixel(x: number, py: number, color: string): void {
330 const cx = Math.floor(x)
331 const cy = Math.floor(py / 2)
332 if (cx < 0 || cy < 0 || cx >= this.cols || cy >= this.rows) return
333 const i = (cy * this.cols + cx) * 3
334 const c = hexToInt(color)
335 const isTop = Math.floor(py) % 2 === 0
336 if (this.words[i] !== 0x2580) {
337 this.words[i] = 0x2580
338 this.words[i + 1] = DEFAULT_COLOR
339 this.words[i + 2] = DEFAULT_COLOR
340 }
341 if (isTop) this.words[i + 1] = c
342 else this.words[i + 2] = c
343 }
344
345 /** The `cells` prop of a Raster. */
346 encode(): string {
347 return toBase64(new Uint8Array(this.words.buffer))
348 }
349}
350
351// ---------------------------------------------------------------------------
352// SVG for the desktop: one drawing per row (the desktop wraps siblings).
353
354export const FONT = "-apple-system,BlinkMacSystemFont,'Segoe UI',Inter,sans-serif"
355export const MONO = "ui-monospace,'Cascadia Code','SF Mono',Consolas,monospace"
356
357const XML_ESC: Record<string, string> = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }
358
359export function xml(s: string): string {
360 return s.replace(/[&<>"']/g, c => XML_ESC[c]!)
361}
362
363/** Theme-aware classes every KOZMOS drawing shares: t text, s secondary, m muted, k track, p panel. */
364export const SVG_BASE_CSS = `
365.t{fill:#1f1f1f}.s{fill:#5f5f5c}.m{fill:#8e8e8a}.k{fill:#e7e5e0}.p{fill:#f5f4f1}.ln{stroke:#e1dfda}
366@media (prefers-color-scheme: dark){.t{fill:#ededed}.s{fill:#b4b4b0}.m{fill:#7c7c78}.k{fill:#2d2d2b}.p{fill:#232322}.ln{stroke:#363634}}
367.pulse{animation:kzp 1.6s ease-in-out infinite}@keyframes kzp{50%{opacity:.35}}
368.spin{transform-box:fill-box;transform-origin:center;animation:kzs 2s linear infinite}@keyframes kzs{to{transform:rotate(360deg)}}
369@media (prefers-reduced-motion: reduce){*{animation:none!important}}
370`
371
372export function svg(width: number, height: number, body: string, css = ''): string {
373 return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}"><style>${SVG_BASE_CSS}${css}</style>${body}</svg>`
374}
375
376/** Rough width of UI text in px, for fitting labels. */
377export function textWidth(s: string, size: number): number {
378 let w = 0
379 for (const ch of s) w += (/[\s.,:;'|!il1()[\]]/.test(ch) ? 0.3 : /[A-Z@%MWmw]/.test(ch) ? 0.72 : 0.56) * size
380 return w
381}
382
383export function fitText(s: string, size: number, maxW: number): string {
384 if (textWidth(s, size) <= maxW) return s
385 let out = ''
386 for (const ch of s) {
387 if (textWidth(out + ch + '…', size) > maxW) break
388 out += ch
389 }
390 return out + '…'
391}
392
393/** A rounded progress bar as SVG markup. */
394export function svgBar(x: number, y: number, w: number, h: number, ratio: number, color: string): string {
395 const fw = Math.max(0, Math.min(w, w * clamp01(ratio)))
396 return `<rect class="k" x="${x}" y="${y}" width="${w}" height="${h}" rx="${h / 2}"/>` +
397 (fw > 0 ? `<rect x="${x}" y="${y}" width="${Math.max(h, fw)}" height="${h}" rx="${h / 2}" fill="${color}"/>` : '')
398}
399
400/** SVG text helper. */
401export function svgText(x: number, y: number, s: string, opts: { cls?: string; size?: number; weight?: number; anchor?: 'start' | 'middle' | 'end'; fill?: string; mono?: boolean } = {}): string {
402 const { cls = 't', size = 12, weight = 400, anchor = 'start', fill, mono = false } = opts
403 return `<text ${fill ? `fill="${fill}"` : `class="${cls}"`} x="${x}" y="${y}" font-family="${mono ? MONO : FONT}" font-size="${size}" font-weight="${weight}" text-anchor="${anchor}" font-variant-numeric="tabular-nums">${xml(s)}</text>`
404}
405
406/** Pixel width a desktop pane or band gives a drawing for `columns` reported columns. */
407export function pxOf(columns: number | undefined, fallback = 60, slack = 8): number {
408 return Math.max(200, Math.min(1600, (columns || fallback) * 8 - slack))
409}
410
411// ---------------------------------------------------------------------------
412// Prices, USD per million tokens: input, output, cache read, cache write (5 min).
413// Anthropic first-party rates as of 2026-10. Used only where the engine reports
414// tokens and not money (a subagent's own spend); the session total comes from
415// $.session.usage().cost, which is the engine's.
416
417const PRICES: [RegExp, [number, number, number, number]][] = [
418 [/fable|mythos/i, [10, 50, 0.25, 12.5]],
419 [/opus-5-5/i, [4, 20, 0.2, 5]],
420 [/opus/i, [5, 25, 0.5, 6.25]],
421 [/sonnet-5/i, [2, 10, 0.2, 2.5]],
422 [/sonnet/i, [3, 15, 0.3, 3.75]],
423 [/haiku-5/i, [0.1, 0.5, 0.01, 0.125]],
424 [/haiku/i, [1, 5, 0.1, 1.25]],
425]
426
427export type Usage = {
428 input_tokens?: number
429 output_tokens?: number
430 cache_read_input_tokens?: number
431 cache_creation_input_tokens?: number
432}
433
434export function priceOf(model: string): [number, number, number, number] {
435 return PRICES.find(([re]) => re.test(model))?.[1] ?? [4, 20, 0.2, 5]
436}
437
438export function costOf(model: string, u: Usage | null | undefined): number {
439 if (!u) return 0
440 const [i, o, r, w] = priceOf(model)
441 return ((u.input_tokens ?? 0) * i + (u.output_tokens ?? 0) * o + (u.cache_read_input_tokens ?? 0) * r + (u.cache_creation_input_tokens ?? 0) * w) / 1e6
442}
443
444export function tokensOf(u: Usage | null | undefined): number {
445 if (!u) return 0
446 return (u.input_tokens ?? 0) + (u.output_tokens ?? 0) + (u.cache_read_input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0)
447}
448
449/** The context a request filled: everything it read plus what it wrote. */
450export function contextOf(u: Usage | null | undefined): number {
451 return tokensOf(u)
452}
453
454export function windowOf(model: string): number {
455 return /haiku-4/i.test(model) ? 200_000 : 1_000_000
456}
457
458// ---------------------------------------------------------------------------
459// Rate limits.
460
461export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
462
463export function limitLabel(kind: string): string {
464 if (kind === 'five_hour') return '5h'
465 if (kind === 'seven_day') return '7d'
466 if (kind === 'spend_limit') return '$'
467 return kind.replace(/_/g, ' ')
468}
469
470/** Milliseconds until a window resets, or undefined. */
471export function untilReset(l: Limit, now: number): number | undefined {
472 if (!l.resetsAt) return undefined
473 const t = Date.parse(l.resetsAt)
474 return Number.isFinite(t) ? Math.max(0, t - now) : undefined
475}
476
477/** The length of a window in ms, for burn-rate math. */
478export function windowMs(kind: string): number | undefined {
479 if (kind === 'five_hour') return 5 * 3600_000
480 if (kind === 'seven_day') return 7 * 24 * 3600_000
481 return undefined
482}
483
484// ---------------------------------------------------------------------------
485// Tool calls, described in a few words.
486
487/** What a tool call is about: the command, the file, the pattern, the agent's task. */
488export function toolDetail(tool: string, input: unknown): string {
489 const e = (input ?? {}) as Record<string, unknown>
490 const s = (k: string) => (typeof e[k] === 'string' ? (e[k] as string) : '')
491 const t = String(tool)
492 if (t === 'Bash' || t === 'PowerShell') return clip(s('description') || s('command'), 60)
493 if (s('file_path')) return baseName(s('file_path'))
494 if (s('notebook_path')) return baseName(s('notebook_path'))
495 if (t === 'Agent') return clip(s('description') || s('subagent_type'), 60)
496 if (s('pattern')) return clip(s('pattern'), 60)
497 if (s('url')) return clip(s('url').replace(/^https?:\/\//, ''), 60)
498 if (s('query')) return clip(s('query'), 60)
499 if (s('subject')) return clip(s('subject'), 60)
500 if (Array.isArray(e.todos)) return `${(e.todos as unknown[]).length} todos`
501 if (t.startsWith('mcp__')) return t.split('__').slice(1).join(' · ')
502 return ''
503}
504
505/** `mcp__server__tool` → `server·tool`; built-ins unchanged. */
506export function toolName(tool: string): string {
507 const t = String(tool)
508 return t.startsWith('mcp__') ? t.split('__').slice(1).join('·') : t
509}
510hooks/story.ts 225 lines1// Storyboard's pure helpers: reading the model's chapter call, and drawing the
2// phase rail and the chapter log. No `$` here (it may not cross an import).
3
4import type { StoryChapter, StoryPhase } from '../types'
5import { KZ, clip, fitText, fmtClock, svg, svgText, textWidth } from './lib/kz.ts'
6
7export const PHASES: readonly StoryPhase[] = ['explore', 'plan', 'build', 'verify', 'ship']
8
9export const PHASE_COLOR: Record<StoryPhase, string> = {
10 explore: KZ.cyan,
11 plan: KZ.violet,
12 build: KZ.amber,
13 verify: KZ.green,
14 ship: KZ.magenta,
15}
16
17export const PHASE_GLYPH: Record<StoryPhase, string> = {
18 explore: '⌕',
19 plan: '✎',
20 build: '⚒',
21 verify: '✓',
22 ship: '➚',
23}
24
25const SHORT: Record<StoryPhase, string> = { explore: 'expl', plan: 'plan', build: 'bld', verify: 'ver', ship: 'ship' }
26
27export const isPhase = (v: unknown): v is StoryPhase => typeof v === 'string' && (PHASES as readonly string[]).includes(v)
28
29/** The one system-prompt section: short on purpose (it rides every request). */
30export const NUDGE =
31 'On multi-step tasks, call mcp__storyboard__chapter when your phase changes (explore, plan, build, verify, ship): a 2-8 word title, step/steps when counted, an optional short note. It drives a progress band the user watches. Skip it for quick one-step answers.'
32
33/** The input schema of the `chapter` tool, as the model reads it. */
34export const CHAPTER_SCHEMA = {
35 type: 'object',
36 properties: {
37 title: { type: 'string', description: 'What this chapter is about, 2-8 words.' },
38 phase: { type: 'string', enum: [...PHASES] },
39 step: { type: 'integer', minimum: 1, description: 'This step number, when the task has counted steps.' },
40 steps: { type: 'integer', minimum: 1, description: 'How many steps the task has.' },
41 note: { type: 'string', description: 'One short optional line of detail.' },
42 },
43 required: ['title', 'phase'],
44 additionalProperties: false,
45} as const
46
47/** Reads the model's tool input into a chapter, or says what is wrong with it. */
48export function parseChapter(input: Record<string, unknown>, at: number): StoryChapter | string {
49 const title = typeof input.title === 'string' ? clip(input.title, 80) : ''
50 if (!title) return 'title is required (a few words).'
51 if (!isPhase(input.phase)) return `phase must be one of: ${PHASES.join(', ')}.`
52 const int = (v: unknown): number | undefined => (typeof v === 'number' && Number.isFinite(v) && v >= 1 ? Math.floor(v) : undefined)
53 const steps = int(input.steps)
54 let step = int(input.step)
55 if (step !== undefined && steps !== undefined && step > steps) step = steps
56 const note = typeof input.note === 'string' && input.note.trim() ? clip(input.note, 140) : undefined
57 return { title, phase: input.phase, at, ...(step !== undefined ? { step } : {}), ...(steps !== undefined ? { steps } : {}), ...(note ? { note } : {}) }
58}
59
60export function stepText(c: StoryChapter): string {
61 if (c.step !== undefined && c.steps !== undefined) return `${c.step}/${c.steps}`
62 if (c.step !== undefined) return `step ${c.step}`
63 return ''
64}
65
66/** Time spent in each phase, from the gaps between chapters. */
67export function phaseTimes(log: readonly StoryChapter[], now: number): Record<StoryPhase, number> {
68 const out: Record<StoryPhase, number> = { explore: 0, plan: 0, build: 0, verify: 0, ship: 0 }
69 log.forEach((c, i) => {
70 const end = log[i + 1]?.at ?? now
71 out[c.phase] += Math.max(0, end - c.at)
72 })
73 return out
74}
75
76// ---------------------------------------------------------------------------
77// Terminal: the rail as colored runs.
78
79export type Run = { text: string; color?: string; bold?: boolean; dim?: boolean }
80
81export function railRuns(phase: StoryPhase, cols: number): Run[] {
82 const active = PHASES.indexOf(phase)
83 const isShort = cols < 58
84 const isTiny = cols < 34
85 const link = isTiny ? '─' : isShort ? '──' : '───'
86 const runs: Run[] = []
87 PHASES.forEach((p, i) => {
88 const label = isTiny ? '' : isShort ? SHORT[p] : p
89 if (i < active) runs.push({ text: `● ${label}`.trimEnd(), color: PHASE_COLOR[p] })
90 else if (i === active) runs.push({ text: `◉ ${label.toUpperCase()}`.trimEnd(), color: PHASE_COLOR[p], bold: true })
91 else runs.push({ text: `○ ${label}`.trimEnd(), dim: true })
92 if (i < PHASES.length - 1) runs.push(i < active ? { text: ` ${link} `, color: PHASE_COLOR[p] } : { text: ` ${link} `, dim: true })
93 })
94 return runs
95}
96
97// ---------------------------------------------------------------------------
98// Desktop: the rail as one SVG, the active stage glowing with a comet in orbit.
99
100function railMarkup(phase: StoryPhase, x0: number, x1: number, cy: number, labelY: number, uid: string): { body: string; css: string } {
101 const active = PHASES.indexOf(phase)
102 const gap = (x1 - x0) / (PHASES.length - 1)
103 const xs = PHASES.map((_, i) => x0 + i * gap)
104 const parts: string[] = []
105 // The track, then the stretch already travelled in a gradient of the phases passed.
106 parts.push(`<line class="ln" x1="${x0}" y1="${cy}" x2="${x1}" y2="${cy}" stroke-width="3" stroke-linecap="round"/>`)
107 const stops = PHASES.slice(0, active + 1).map((p, i) => `<stop offset="${active === 0 ? 0 : i / active}" stop-color="${PHASE_COLOR[p]}"/>`).join('')
108 parts.push(`<defs><linearGradient id="trail${uid}" x1="0" y1="0" x2="1" y2="0">${stops}</linearGradient>
109<filter id="glow${uid}" x="-1" y="-1" width="3" height="3"><feGaussianBlur stdDeviation="3.2" result="b"/><feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge></filter>
110<radialGradient id="tail${uid}" cx=".5" cy=".5" r=".5"><stop offset="0" stop-color="#fff"/><stop offset="1" stop-color="${PHASE_COLOR[phase]}" stop-opacity="0"/></radialGradient></defs>`)
111 const ax = xs[active]!
112 if (active > 0) parts.push(`<line x1="${x0}" y1="${cy}" x2="${ax}" y2="${cy}" stroke="url(#trail${uid})" stroke-width="3" stroke-linecap="round"/>`)
113 PHASES.forEach((p, i) => {
114 const x = xs[i]!
115 const c = PHASE_COLOR[p]
116 if (i < active) {
117 parts.push(`<circle cx="${x}" cy="${cy}" r="5.5" fill="${c}"/>`)
118 parts.push(`<path d="M${x - 2.4} ${cy}l1.7 1.8 3.2-3.4" stroke="#fff" stroke-width="1.4" fill="none" stroke-linecap="round" stroke-linejoin="round"/>`)
119 } else if (i === active) {
120 parts.push(`<circle class="halo" cx="${x}" cy="${cy}" r="9" fill="none" stroke="${c}" stroke-width="2"/>`)
121 parts.push(`<circle cx="${x}" cy="${cy}" r="7.5" fill="${c}" filter="url(#glow${uid})" class="pulse"/>`)
122 parts.push(`<circle cx="${x}" cy="${cy}" r="3" fill="#fff" opacity=".9"/>`)
123 // The comet: a bright head and a fading tail, orbiting the active stage.
124 parts.push(`<g class="orb${uid}"><path d="M${x} ${cy - 14}A14 14 0 0 0 ${(x - 14 * Math.sin(1.1)).toFixed(2)} ${(cy - 14 * Math.cos(1.1)).toFixed(2)}" stroke="${c}" stroke-width="2.4" fill="none" stroke-linecap="round" opacity=".55"/><circle cx="${x}" cy="${cy - 14}" r="4" fill="url(#tail${uid})"/><circle cx="${x}" cy="${cy - 14}" r="2.1" fill="#fff"/></g>`)
125 } else {
126 parts.push(`<circle cx="${x}" cy="${cy}" r="5" class="p" stroke="${c}" stroke-opacity=".55" stroke-width="1.6"/>`)
127 }
128 parts.push(svgText(x, labelY, p, i === active ? { size: 10.5, weight: 750, anchor: 'middle', fill: c } : { cls: i < active ? 's' : 'm', size: 10, weight: 550, anchor: 'middle' }))
129 })
130 const css = `.orb${uid}{transform-box:view-box;transform-origin:${ax}px ${cy}px;animation:orb${uid} 2.6s linear infinite}@keyframes orb${uid}{to{transform:rotate(360deg)}}
131.halo{transform-box:fill-box;transform-origin:center;animation:halo 2.2s ease-out infinite}@keyframes halo{0%{transform:scale(.7);opacity:.85}100%{transform:scale(2);opacity:0}}`
132 return { body: parts.join(''), css }
133}
134
135/** The band: the chapter on the left, the rail on the right (stacked when narrow). */
136export function bandSvg(c: StoryChapter, W: number): { source: string; height: number } {
137 const isWide = W >= 560
138 const H = isWide ? 60 : 92
139 const color = PHASE_COLOR[c.phase]
140 const textW = isWide ? Math.max(170, Math.min(W * 0.42, 360)) : W - 24
141 const parts: string[] = []
142 parts.push(`<rect class="p" x="0" y="0" width="${W}" height="${H}" rx="12"/>`)
143 parts.push(`<rect x="0" y="0" width="4" height="${H}" rx="2" fill="${color}"/>`)
144 const step = stepText(c)
145 const label = `CHAPTER · ${c.phase.toUpperCase()}${step ? ` · ${step}` : ''}`
146 parts.push(svgText(16, 18, label, { size: 9.5, weight: 700, fill: color }))
147 parts.push(svgText(16, 36, fitText(c.title, 14, textW - 16), { size: 14, weight: 680 }))
148 if (c.note) parts.push(svgText(16, 51, fitText(c.note, 11, textW - 16), { cls: 's', size: 11 }))
149 const rail = isWide ? railMarkup(c.phase, textW + 28, W - 34, 24, 49, 'b') : railMarkup(c.phase, 34, W - 34, 70, 88, 'b')
150 // A small step meter after the label, when the model counts steps.
151 if (c.step !== undefined && c.steps !== undefined && c.steps > 0) {
152 const n = Math.min(c.steps, 12)
153 const done = Math.round((c.step / c.steps) * n)
154 const mx = 16 + textWidth(label, 9.5) + 10
155 for (let i = 0; i < n; i++) parts.push(`<rect x="${mx + i * 8}" y="12.5" width="6" height="4" rx="2" ${i < done ? `fill="${color}"` : 'class="k"'}/>`)
156 }
157 return { source: svg(W, H, parts.join('') + rail.body, rail.css), height: H }
158}
159
160/** The log pane's header: the current rail, or a quiet placeholder. */
161export function headerSvg(c: StoryChapter | null, count: number, W: number): string {
162 const H = 78
163 const parts = [`<rect class="p" x="0" y="0" width="${W}" height="${H}" rx="12"/>`]
164 parts.push(svgText(14, 20, 'STORYBOARD', { size: 10, weight: 750, fill: KZ.violet }))
165 parts.push(svgText(W - 14, 20, `${count} chapter${count === 1 ? '' : 's'}`, { cls: 'm', size: 10.5, anchor: 'end' }))
166 if (!c) {
167 parts.push(svgText(W / 2, 52, 'No chapters yet: Claude marks them on multi-step tasks.', { cls: 's', size: 11.5, anchor: 'middle' }))
168 return svg(W, H, parts.join(''))
169 }
170 const rail = railMarkup(c.phase, 34, W - 34, 44, 68, 'h')
171 return svg(W, H, parts.join('') + rail.body, rail.css)
172}
173
174/** Time per phase as one stacked bar with a legend. */
175export function timesSvg(times: Record<StoryPhase, number>, W: number): string {
176 const total = PHASES.reduce((s, p) => s + times[p], 0)
177 const H = 54
178 const parts = [`<rect class="p" x="0" y="0" width="${W}" height="${H}" rx="12"/>`]
179 parts.push(svgText(14, 18, 'TIME PER PHASE', { cls: 's', size: 9.5, weight: 700 }))
180 const bw = W - 28
181 let x = 14
182 parts.push(`<rect class="k" x="14" y="25" width="${bw}" height="8" rx="4"/>`)
183 if (total > 0) {
184 parts.push(`<clipPath id="tc"><rect x="14" y="25" width="${bw}" height="8" rx="4"/></clipPath><g clip-path="url(#tc)">`)
185 for (const p of PHASES) {
186 const w = (times[p] / total) * bw
187 if (w > 0) parts.push(`<rect x="${x.toFixed(1)}" y="25" width="${w.toFixed(1)}" height="8" fill="${PHASE_COLOR[p]}"/>`)
188 x += w
189 }
190 parts.push('</g>')
191 }
192 const slot = bw / PHASES.length
193 PHASES.forEach((p, i) => {
194 const lx = 14 + i * slot
195 parts.push(`<circle cx="${lx + 4}" cy="45" r="3.5" fill="${PHASE_COLOR[p]}"/>`)
196 parts.push(svgText(lx + 11, 48.5, fitText(`${p} ${times[p] ? fmtClock(times[p]) : '—'}`, 9.5, slot - 14), { cls: 'm', size: 9.5 }))
197 })
198 return svg(W, H, parts.join(''))
199}
200
201/** One chapter of the log as a timeline row. */
202export function chapterRowSvg(c: StoryChapter, W: number, startedAt: number, isCurrent: boolean, isLast: boolean): string {
203 const H = c.note ? 50 : 36
204 const color = PHASE_COLOR[c.phase]
205 const parts: string[] = []
206 parts.push(`<line class="ln" x1="16" y1="0" x2="16" y2="${isLast ? 18 : H}" stroke-width="2"/>`)
207 parts.push(`<circle cx="16" cy="18" r="${isCurrent ? 6 : 4.5}" fill="${color}" class="${isCurrent ? 'pulse' : ''}"/>`)
208 parts.push(svgText(30, 22, fmtClock(c.at - startedAt), { cls: 'm', size: 10.5, mono: true }))
209 const chip = c.phase
210 const chipW = chip.length * 6.4 + 14
211 parts.push(`<rect x="76" y="9" width="${chipW}" height="17" rx="8.5" fill="${color}" opacity=".18"/>`)
212 parts.push(svgText(76 + chipW / 2, 21.5, chip, { size: 10, weight: 650, anchor: 'middle', fill: color }))
213 const step = stepText(c)
214 const tx = 84 + chipW
215 parts.push(svgText(tx, 22, fitText(c.title, 13, W - tx - (step ? 50 : 12)), { size: 13, weight: isCurrent ? 700 : 560 }))
216 if (step) parts.push(svgText(W - 10, 22, step, { cls: 's', size: 10.5, anchor: 'end', mono: true }))
217 if (c.note) parts.push(svgText(tx, 40, fitText(c.note, 11, W - tx - 12), { cls: 's', size: 11 }))
218 return svg(W, H, parts.join(''))
219}
220
221export function chapterAlt(c: StoryChapter): string {
222 const step = stepText(c)
223 return `Chapter: ${c.title}; phase ${c.phase}${step ? `, ${step}` : ''}${c.note ? `; ${c.note}` : ''}`
224}
225types/index.d.ts 30 lines1export type StoryPhase = 'explore' | 'plan' | 'build' | 'verify' | 'ship'
2
3export type StoryChapter = {
4 /** A few words naming what this chapter is about. */
5 title: string
6 phase: StoryPhase
7 /** Which step of the task this is, and how many there are, when the model said. */
8 step?: number
9 steps?: number
10 /** One short line of detail. */
11 note?: string
12 /** When the model called the tool ($.clock.now()). */
13 at: number
14}
15
16export type StoryBoard = {
17 /** The chapter the model is in now, or null before the first call. */
18 current: StoryChapter | null
19 /** Every chapter of this session, oldest first (capped). */
20 log: StoryChapter[]
21 /** When the session started, for relative times in the log. */
22 startedAt: number
23}
24
25declare module 'claude-code' {
26 interface PluginState {
27 storyboard: { board: StoryBoard; isHidden: boolean }
28 }
29}
30