SLOPSHOPPER

storyboard

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…

newpanebandguardcommandprompt
★ 4v0.1.0MITupdated 2026-10-09ersinkoc/claude-mods/mods/storyboard
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · storyboard
│ ┃ kz-storyboard ✕ › fix the failing auth test and add an audit log call │ ┃ ✦ STORYBOARD · 0 chapters │ ┃ No chapters yet: Claude marks them on ⏺ Read(src/auth.ts) │ ┃ multi-step tasks. ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /storyboard │ ⎿ storyboard: Storyboard band hidden. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · kz-storyboard
✦ STORYBOARD · 0 chapters No chapters yet: Claude marks them on multi-step tasks.
README

KOZMOS Storyboard

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.

  • Band above the prompt: the chapter title, step 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).
  • Chapter log pane: /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.

Türkçe

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.

  • İstem üstü bant: bölüm başlığı, 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).
  • Bölüm günlüğü paneli: /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.

Source 4 files
hooks/register.tsx 236 lines
1import { 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}
236
hooks/lib/kz.ts 510 lines
1// 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> = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }
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}
510
hooks/story.ts 225 lines
1// 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}
225
types/index.d.ts 30 lines
1export 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