Kiko boxes every turn above your prompt: Knowledge In, Knowledge Out, K.O.!

Experiments with Claude Code mods: plugins of function hooks that add live panes, bands, status lines, toasts, tools and hooks inside Claude Code (terminal CLI and the desktop Code tab), and hot-reload while you build them.
The mods API is early access and changes between releases. Everything here was built and tested against Claude Code 2.1.286. Run
/whiteboard doctorif something looks off.
| Mod | What it does |
|---|---|
whiteboard/ | Gives Claude a draw tool: Mermaid, D2 and PlantUML diagrams rendered locally and shown in a side pane, with history, versions, export, copy and share. |
kiko/ | Knowledge In, Knowledge Out: a TUI critter boxes every turn above your prompt, chomping what Claude reads, punching with what it writes, and finishing with a K.O. |
Ask Claude for a diagram ("show me the auth flow as a sequence diagram", "draw the class structure of this module") or run /whiteboard arch, and it appears in a Whiteboard pane beside the conversation. Rendering is local: nothing leaves your machine unless you press Share.
◀ 3/5 ▶ Checkout sequence ‹ v2/3 ›
[Export] [Copy] [Copy MD] [Share] [Open]
┌──────────────────────────────────────────────────────────┐
│ desktop / VS Code / mobile: the rendered SVG │
│ Ghostty / kitty: the rendered PNG │
│ other terminals: the source in a code block │
└──────────────────────────────────────────────────────────┘
Ask Claude to change this diagram… send
draw tool for Claude: { title, source, language? } with language one of mermaid (default), d2, plantuml. Rendering happens before the tool returns, so a syntax error goes straight back to Claude, which fixes it and redraws in the same turn./whiteboard arch (architecture), /whiteboard flow <file or area> (control flow), /whiteboard schema (data model). Each asks Claude to draw.h / l while the pane has focus.‹ v2/3 › steps between them.diagrams/<title>.<mmd|d2|puml> and .svg into the working directory, never overwriting.o, the SVG in your default app).default, neutral, dark, forest, plus an optional Mermaid config file for team colours./whiteboard doctor checks Claude Code's version, each renderer, a test render, the GitHub CLI and terminal images, and says what to fix.| Surface | What you see |
|---|---|
| Desktop Code tab, VS Code | Rendered SVG and everything above |
| Mobile | Rendered SVG and buttons (no text field yet) |
| Terminal, Ghostty or kitty | Rendered PNG inline |
| Other terminals (iTerm2, Terminal.app, …) | Source + Open to view the SVG in your browser |
macOS, Linux and Windows are supported (open / xdg-open / start, zsh / bash / where). In the terminal a pane opens by itself only in the fullscreen layout at 144 or more columns; otherwise run /whiteboard.
npm i -g @mermaid-js/mermaid-cli
brew install d2 plantuml
The mod finds them through your login shell, then nvm's and Homebrew's folders, so they work even when the desktop app's PATH doesn't include them. If it can't find mmdc, set the plugin option mmdcPath.
env block of ~/.claude/settings.json: {
"env": {
"CLAUDE_CODE_PLUGIN_DIRS": "/path/to/claude-code-mods/whiteboard"
}
}
or try it for one session:
claude --plugin-dir /path/to/claude-code-mods/whiteboard
The repo is also a plugin marketplace (.claude-plugin/marketplace.json). Whether your Claude Code build loads function-hook mods from installed plugins depends on the build; the two options above always work.
/whiteboard doctor.| Option | Default | What it does |
|---|---|---|
mmdcPath | empty | Absolute path to mmdc when it can't be found automatically |
theme | default | default, neutral, dark or forest |
mermaidConfig | empty | Absolute path to a Mermaid JSON config, for example team colours and fonts |
Set them in Claude Code's config menu, or under pluginConfigs.whiteboard in your settings.
sequenceDiagram
participant C as Claude
participant W as whiteboard
participant R as renderer (mmdc / d2 / plantuml)
participant P as Pane
C->>W: draw {title, source, language}
W->>R: <id>.<ext> → <id>.svg (spawned; interrupt stops it)
alt syntax error
R-->>W: exit 1 + parse error
W-->>C: error → Claude fixes and redraws
else rendered
W->>W: add to history, save for the project
W->>P: open pane
W-->>C: Drawn 'title' (n/total)
end
~/.claude/whiteboard/<project>/; history is saved per project. Diagrams pushed out of the last 20 have their files removed.--no-font-embed keeps Mermaid SVGs small: mermaid-cli 12 otherwise inlines ~160 KB of web fonts, past the 128 KB the pane draws inline. Text falls back to Arial..claude-plugin/marketplace.json the repo as a plugin marketplace
.github/workflows/ci.yml validate, test and smoke test on every push
whiteboard/
.claude-plugin/plugin.json manifest and options
hooks/register.tsx engine wiring: tool, command, pane, buttons
hooks/history.ts pure history logic
hooks/render.ts pure rendering, platform and version helpers
hooks/actions.ts pure pane, command and doctor helpers
hooks/*.test.ts(x) claude plugin test suites
hooks/testkit.ts fake host for the tests
types/index.d.ts session-state contract
scripts/smoke-mmdc.sh renders with the real mmdc
claude plugin validate whiteboard # what the engine will load, call and refuse
claude plugin test whiteboard # 85 tests across terminal, desktop and mobile
claude plugin test kiko # 36 tests: round logic, sprites, the band on every surface
whiteboard/scripts/smoke-mmdc.sh # real mmdc: SVG size limit, PNG, syntax errors
See CONTRIBUTING.md and CHANGELOG.md.
K-I-K-O: Knowledge In, Knowledge Out. While Claude works, Kiko boxes your problem in a band above the prompt. Every turn is a round; what Claude reads is Knowledge In, what it writes is Knowledge Out, and the end of the turn is the K.O.
ROUND 3 ── KIKO vs. THE FLAKY AUTH TEST ─────────────── 0:42
KI █████████░░░ 12.4k KO ████████░░░░ 2.1k
/\_/\ ,_,
[app.ts]›››( O.O ) {fix.ts} (x_x)
/| |=> ‹ jab! \ /
> reading app.ts
[file]›››) for every read, search or fetch, a punch ({file} → (x_x)) for every edit or write, a dodge for other tools.K.O. ▸ … notice in the transcript (the terminal shows it; the desktop doesn't display notices yet)./kiko stats (wins, streak, fastest and biggest K.O., last opponents)./kiko off and /kiko on (saved; the record still counts while off). Interrupted or failed turns end quietly.Kiko only watches: every hook passes the turn, its stream and each tool call through unchanged, and never calls a model.
Install: add :/path/to/claude-code-mods/kiko to CLAUDE_CODE_PLUGIN_DIRS (see the whiteboard's install above), or claude --plugin-dir /path/to/claude-code-mods/kiko.
| Surface | What Kiko shows |
|---|---|
| Terminal | The band as text rows, spinner words, the K.O. line in the transcript |
| Desktop Code tab | The band as one fixed-width code block, spinner words while Claude thinks or replies |
| VS Code, mobile | Nothing yet: the engine only raises the band and spinner on the terminal and desktop |
Useful if you're writing your own mod. These are things the type declarations don't spell out up front:
$ can only be passed to functions declared at the top level of the same file. Helpers in other files must be pure; the engine refuses to load the module otherwise.h. JSX compiles to bare h(...) calls, and a local h shadows the factory.Text, Svg and Markdown drop a key prop. Tests find them by type and text; Buttons and Inputs keep their keys.Svg that draws nothing. Pick the body by e.surface, not by 'Svg' in elements.$.fs paths resolve against the engine's cwd, not necessarily the session's: build paths from $.session.cwd().$.prompt.submit directly: the command holds the turn the prompt would wait for. Submit from a timer ($.clock.after(0, …)) or a later event.$.process.spawn kills its child when the dispatch is abandoned, so a render started from a tool call stops when the user interrupts. $.process.run has a timeout but no abort.Client surface modules: any module, even ten lines with no imports, is torn down with "did not load within 10s" (in ~/Library/Logs/Claude/claude.ai-web.log). Animate from the hooks module instead: a $.clock.every started in session.start writing a frame counter to $.state, with the band drawing rows (Text on the terminal, Code elsewhere for fixed-width columns). The test kit runs Client modules fine, so it won't catch this.session.start. A $.clock.after set inside turn.complete never fires.Client that draws nothing, like the terminal's Svg.message ("Running tools…"), so a spinner rewrite that respects message only shows while Claude thinks or replies.claude plugin test:$ carries only engine events (tool.call, ui.mount, session.start, command.run…), not plugin calls (fs, env, process), so test through the plugin's own tools, commands and panes.tool.register / command.register have no implementation there and must be stubbed.{ value } or { deny }; one that throws is skipped, not rejected.process.spawn stub is an async generator that yields chunks and returns { value: { code, signal } }.mock.clock holds timers until the test calls advance.session.append; check appended notices live.The spec and the implementation plan for 0.1 are in docs/superpowers/.
hooks/register.tsx 229 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { KikoRecord, Round } from '../types'
5import {
6 EMPTY_RECORD, IDLE, TICK_MS, applyUsage, beatFor, beatStepOf, classifyTool, isKikoRecord, koLine, opponentName,
7 recordLine, spinnerKind, spinnerWord, statsText, tick, updateRecord,
8} from './round'
9import { koCard, layout } from './sprites'
10import type { Usage } from './round'
11
12const round = atom({ plugin: 'kiko', key: 'round' } as const, IDLE)
13const enabled = atom({ plugin: 'kiko', key: 'enabled' } as const, true)
14
15const RECORD_KEY = 'kiko:record'
16const ENABLED_KEY = 'kiko:enabled'
17
18const isSub = (e: unknown) => Boolean((e as { agentId?: string }).agentId)
19
20// The saved record, and whether it may be written back: a failed read or a value that isn't
21// a record is shown as a fresh one but never overwritten.
22async function loadRecord($: EngineInterface): Promise<{ rec: KikoRecord; isWritable: boolean }> {
23 let saved: unknown
24 try {
25 saved = await $.store.get(RECORD_KEY)
26 } catch {
27 return { rec: EMPTY_RECORD, isWritable: false }
28 }
29 if (saved === undefined) return { rec: EMPTY_RECORD, isWritable: true }
30 return isKikoRecord(saved) ? { rec: saved, isWritable: true } : { rec: EMPTY_RECORD, isWritable: false }
31}
32
33async function startRound($: EngineInterface, text: string, turnId: string): Promise<void> {
34 const current = (await read($, round)) ?? IDLE
35 // The same turn again is a no-op; a different one starts a new round even if the last
36 // turn's turn.complete never came.
37 if (current.phase === 'fight' && current.turnId === turnId) return
38 const { rec } = await loadRecord($)
39 const now = await $.clock.now()
40 await update($, round, r => {
41 const prev = r ?? IDLE
42 const seq = prev.seq + 1
43 return {
44 ...IDLE, n: prev.n + 1, seq, turnId, phase: 'fight', opponent: opponentName(text), startedAt: now,
45 status: 'touching gloves', beat: { id: seq, kind: 'bell', label: '' }, record: recordLine(rec),
46 }
47 })
48}
49
50async function setMode($: EngineInterface, turnId: string, mode: Round['mode']): Promise<void> {
51 await update($, round, r => (r && r.turnId === turnId && r.phase === 'fight' && r.mode !== mode ? { ...r, mode, status: '' } : r ?? IDLE))
52}
53
54async function addUsage($: EngineInterface, turnId: string, usage: Usage): Promise<void> {
55 await update($, round, r => (r && r.turnId === turnId && r.phase === 'fight' ? applyUsage(r, usage) : r ?? IDLE))
56}
57
58async function finishRound($: EngineInterface, e: { turnId: string; reason: string; isAborted: boolean; durationMs: number }): Promise<void> {
59 const r = await read($, round)
60 if (!r || r.phase !== 'fight' || r.turnId !== e.turnId) return
61 if (e.reason !== 'answer' || e.isAborted) {
62 await update($, round, x => ({ ...(x ?? IDLE), phase: 'idle' }))
63 return
64 }
65 const saved = await loadRecord($)
66 const rec = updateRecord(saved.rec, { ms: e.durationMs, tokens: r.tokensIn + r.tokensOut, opponent: r.opponent })
67 if (saved.isWritable) await $.store.set(RECORD_KEY, rec)
68 const done: Round = { ...r, phase: 'ko', endedAt: await $.clock.now(), record: recordLine(rec), status: 'K.O.!' }
69 await update($, round, () => done)
70 if ((await read($, enabled)) ?? true) {
71 await $.session.append({ message: { type: 'system', content: [{ type: 'text', text: koLine(done, rec) }] } }).catch(() => undefined)
72 }
73}
74
75// The band's clock. Timers only outlive the dispatch that starts them from session.start,
76// and the desktop can't run a Client surface module, so the band animates from here: each
77// tick advances a fight's frame or ends a stale K.O. card, and writes nothing while idle.
78async function tickRound($: EngineInterface): Promise<void> {
79 const r = await read($, round)
80 if (!r || r.phase === 'idle') return
81 const now = await $.clock.now()
82 if (tick(r, now)) await update($, round, x => (x ? tick(x, now) ?? x : IDLE))
83}
84
85async function setEnabled($: EngineInterface, value: boolean): Promise<void> {
86 await update($, enabled, () => value)
87 await $.store.set(ENABLED_KEY, value)
88}
89
90async function kikoCommand($: EngineInterface, args: string): Promise<string> {
91 const word = args.trim().toLowerCase()
92 if (word === 'on') {
93 await setEnabled($, true)
94 return 'Kiko is on. Ding ding!'
95 }
96 if (word === 'off') {
97 await setEnabled($, false)
98 return 'Kiko is off. The record still counts; /kiko on to bring Kiko back.'
99 }
100 const { rec } = await loadRecord($)
101 if (word === 'stats') return statsText(rec)
102 if (word === '') return `Kiko is ${((await read($, enabled)) ?? true) ? 'on' : 'off'} · ${recordLine(rec)}. Try /kiko stats, /kiko off.`
103 return 'Usage: /kiko on, /kiko off, /kiko stats'
104}
105
106export const register: Register = on => {
107 on('session.start', async ($, e, next) => {
108 try {
109 await $.command.register({ name: 'kiko', description: 'Kiko, your KIKO fighter: Knowledge In, Knowledge Out', argumentHint: '[on | off | stats]', immediate: true })
110 const saved = await $.store.get(ENABLED_KEY).catch(() => undefined)
111 await update($, enabled, () => saved !== false)
112 const { rec } = await loadRecord($)
113 await update($, round, r => ({ ...IDLE, n: r?.n ?? 0, seq: r?.seq ?? 0, record: recordLine(rec) }))
114 $.clock.every(TICK_MS, () => void tickRound($).catch(() => undefined))
115 } catch {
116 // Kiko never stops a session from starting.
117 }
118 return next(e)
119 })
120
121 on('turn.start', async ($, e, next) => {
122 if (!isSub(e)) {
123 try {
124 await startRound($, e.text, e.turnId)
125 } catch {
126 // A failed round start leaves the turn alone.
127 }
128 }
129 return next(e)
130 })
131
132 on('turn.step', async function* ($, e, next) {
133 const stream = next(e)
134 const r = isSub(e) ? undefined : await read($, round).catch(() => undefined)
135 if (!r || r.phase !== 'fight' || r.turnId !== e.turnId) return yield* stream
136 let mode: Round['mode'] | undefined
137 for (;;) {
138 const item = await stream.next()
139 if (item.done) return item.value
140 const chunk = item.value
141 try {
142 if (chunk.kind === 'thinking' || chunk.kind === 'text') {
143 const next_ = chunk.kind === 'thinking' ? 'thinking' : 'responding'
144 if (next_ !== mode) {
145 mode = next_
146 await setMode($, e.turnId, next_)
147 }
148 } else if (chunk.kind === 'stop' && chunk.usage) {
149 await addUsage($, e.turnId, chunk.usage)
150 }
151 } catch {
152 // Watching the stream never changes it.
153 }
154 yield chunk
155 }
156 })
157
158 on('tool.call', async ($, e, next) => {
159 let isOurs = false
160 try {
161 const r = isSub(e) ? undefined : await read($, round)
162 if (r && r.phase === 'fight') {
163 isOurs = true
164 const { kind, label } = classifyTool(e.tool, e)
165 await update($, round, x => beatFor(x ?? IDLE, kind, label, e.tool))
166 }
167 } catch {
168 isOurs = false
169 }
170 const result = await next(e)
171 if (isOurs) {
172 try {
173 await update($, round, x => (x && x.phase === 'fight' ? { ...x, mode: 'requesting', status: '' } : x ?? IDLE))
174 } catch {
175 // The tool's result goes back unchanged either way.
176 }
177 }
178 return result
179 })
180
181 on('turn.complete', async ($, e, next) => {
182 try {
183 return await next(e)
184 } finally {
185 // The K.O. lands whether or not the chain beneath finished cleanly; its error, if any,
186 // still reaches the engine.
187 if (!isSub(e)) {
188 try {
189 await finishRound($, e as never)
190 } catch {
191 // A failed K.O. leaves the turn's result alone.
192 }
193 }
194 }
195 })
196
197 on('command.run', { command: 'kiko' }, async ($, e) => ({ text: await kikoCommand($, e.args ?? '') }))
198
199 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
200 const isOn = (await read($, enabled)) ?? true
201 const r = (await read($, round)) ?? IDLE
202 if (!isOn || r.phase === 'idle' || e.props.hasSurvey) return next(e)
203 const columns = Math.max(20, e.props.bodyColumns)
204 const rows = r.phase === 'ko'
205 ? koCard(r, columns)
206 : layout(r, { frame: r.frame, beatStep: beatStepOf(r), now: await $.clock.now() }, columns, e.props.maxRows)
207 const els = $.ui.resolve(e)
208 // The terminal draws Text in its own fixed-width cells; elsewhere one Code block keeps
209 // the columns straight.
210 if (e.surface === 'terminal') {
211 const { Box, Text } = els
212 return (
213 <Box flexDirection="column">
214 {rows.map(row => <Text wrap="truncate">{row}</Text>)}
215 </Box>
216 )
217 }
218 const { Code } = els
219 return <Code source={rows.join('\n')} />
220 })
221
222 on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
223 const isOn = (await read($, enabled)) ?? true
224 const r = (await read($, round)) ?? IDLE
225 if (!isOn || r.phase !== 'fight' || e.props.message) return next(e)
226 return next({ ...e, props: { ...e.props, word: spinnerWord(spinnerKind(e.props.mode, r), r.n) } })
227 })
228}
229hooks/round.ts 190 lines1// Kiko's brain: the pure rules of a round (opponent names, knowledge in and out, the
2// band's clock, the record and the K.O. line), with no `$`, so the tests drive it directly.
3import type { BeatKind, KikoRecord, Mode, Round } from '../types'
4
5export const IDLE: Round = {
6 phase: 'idle', n: 0, turnId: null, opponent: '', startedAt: 0, endedAt: null, mode: 'requesting', status: '',
7 tokensIn: 0, tokensOut: 0, reads: 0, writes: 0, seq: 0, beat: null, record: '0-0 | streak 0', frame: 0, beatAt: 0,
8}
9
10export const EMPTY_RECORD: KikoRecord = { wins: 0, streak: 0, bestStreak: 0, fastestMs: null, biggestTokens: 0, recent: [] }
11
12const STOP = new Set(`a an and are as at be by can could do does fix for from help how i in into is it its just let lets me
13 my need now of on or our please should show so some tell than that the then this to up us want was we what when why will
14 with would you your make add build create write update change look check new get set run see find think about
15 makes give me tell explain`.split(/\s+/))
16
17const NAME_MAX = 28
18
19export function opponentName(text: string): string {
20 const words = text
21 .normalize('NFKD').replace(/[̀-ͯ]/g, '')
22 .toLowerCase()
23 .replace(/`[^`]*`/g, ' ')
24 .replace(/<\/?[a-z][\w-]*[^>]*>/g, ' ')
25 .match(/[a-z][a-z0-9'-]*/g) ?? []
26 const kept = words.filter(w => w.length > 1 && !STOP.has(w)).slice(0, 3)
27 if (kept.length === 0) return 'THE UNKNOWN BUG'
28 let name = `THE ${kept.join(' ').toUpperCase()}`
29 while (name.length > NAME_MAX && name.includes(' ', 4)) name = name.slice(0, name.lastIndexOf(' '))
30 return name.slice(0, NAME_MAX)
31}
32
33const IN_TOOLS = new Set(['Read', 'Grep', 'Glob', 'WebFetch', 'WebSearch', 'LS', 'NotebookRead'])
34const OUT_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit'])
35
36const clip = (s: string, n = 20) => (s.length > n ? s.slice(0, n) : s)
37// Labels land in fixed-width art: anything but printable ASCII becomes '?'.
38const ascii = (s: string) => s.replace(/[^\x20-\x7e]/gu, '?')
39const lastSegment = (p: string) => p.split(/[/\\]/).filter(Boolean).pop() ?? p
40
41export function classifyTool(tool: string, input: unknown): { kind: 'in' | 'out' | 'dodge'; label: string } {
42 const args = (input ?? {}) as Record<string, unknown>
43 const str = (k: string) => (typeof args[k] === 'string' ? (args[k] as string) : '')
44 const path = str('file_path') || str('notebook_path') || str('path')
45 let host = ''
46 try { host = str('url') ? new URL(str('url')).host : '' } catch { host = '' }
47 const label = clip(ascii(path ? lastSegment(path) : str('pattern') || str('query') || host || tool))
48 if (IN_TOOLS.has(tool)) return { kind: 'in', label }
49 if (OUT_TOOLS.has(tool)) return { kind: 'out', label }
50 if (tool.startsWith('mcp__')) {
51 const short = clip(ascii(tool.slice(tool.lastIndexOf('__') + 2)))
52 if (/(read|search|get|list|fetch|query|find)/i.test(short)) return { kind: 'in', label: short }
53 if (/(write|create|update|edit|delete|send|post|set|add)/i.test(short)) return { kind: 'out', label: short }
54 return { kind: 'dodge', label: short }
55 }
56 return { kind: 'dodge', label: clip(ascii(tool)) }
57}
58
59const BEAT: Record<'in' | 'out' | 'dodge', BeatKind> = { in: 'chomp', out: 'punch', dodge: 'dodge' }
60
61export function beatFor(round: Round, kind: 'in' | 'out' | 'dodge', label: string, tool: string): Round {
62 const seq = round.seq + 1
63 const status = kind === 'in' ? `reading ${label}` : kind === 'out' ? `editing ${label}` : `running ${tool}`
64 return {
65 ...round,
66 seq,
67 mode: 'tool',
68 status,
69 reads: round.reads + (kind === 'in' ? 1 : 0),
70 writes: round.writes + (kind === 'out' ? 1 : 0),
71 beat: { id: seq, kind: BEAT[kind], label },
72 beatAt: round.frame,
73 }
74}
75
76export const TICK_MS = 200
77export const BEAT_FRAMES = 6
78export const KO_CARD_MS = 5_000
79
80// Which frame of its move the current beat is on, or null once it has played out.
81export function beatStepOf(round: Round): number | null {
82 if (!round.beat) return null
83 const step = round.frame - round.beatAt
84 return step >= 0 && step < BEAT_FRAMES ? step : null
85}
86
87// One tick of the band's clock: the next frame of a fight, the end of a stale K.O. card,
88// or null when nothing changes (idle, or a card still showing).
89export function tick(round: Round, now: number): Round | null {
90 if (round.phase === 'fight') return { ...round, frame: round.frame + 1 }
91 if (round.phase === 'ko' && round.endedAt !== null && now - round.endedAt >= KO_CARD_MS) return { ...round, phase: 'idle' }
92 return null
93}
94
95export type Usage = { input_tokens: number; output_tokens: number; cache_read_input_tokens: number; cache_creation_input_tokens: number }
96
97const count = (x: unknown) => (typeof x === 'number' && Number.isFinite(x) ? x : 0)
98
99export function applyUsage(round: Round, usage: Usage): Round {
100 return {
101 ...round,
102 // Cache reads are the conversation re-read, not new knowledge.
103 tokensIn: round.tokensIn + count(usage.input_tokens) + count(usage.cache_creation_input_tokens),
104 tokensOut: round.tokensOut + count(usage.output_tokens),
105 }
106}
107
108export function updateRecord(rec: KikoRecord, fight: { ms: number; tokens: number; opponent: string }): KikoRecord {
109 const streak = rec.streak + 1
110 return {
111 wins: rec.wins + 1,
112 streak,
113 bestStreak: Math.max(rec.bestStreak, streak),
114 fastestMs: rec.fastestMs === null ? fight.ms : Math.min(rec.fastestMs, fight.ms),
115 biggestTokens: Math.max(rec.biggestTokens, fight.tokens),
116 recent: [fight.opponent, ...rec.recent].slice(0, 5),
117 }
118}
119
120export function isKikoRecord(v: unknown): v is KikoRecord {
121 const r = v as KikoRecord | undefined
122 return Boolean(r) && typeof r!.wins === 'number' && typeof r!.streak === 'number' && typeof r!.bestStreak === 'number' &&
123 typeof r!.biggestTokens === 'number' && Array.isArray(r!.recent)
124}
125
126
127
128export function mmss(ms: number): string {
129 const s = Math.max(0, Math.floor(ms / 1000))
130 return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`
131}
132
133export function fmtTokens(n: number): string {
134 if (n < 1000) return String(n)
135 if (n < 1_000_000) return `${(n / 1000).toFixed(1)}k`
136 return `${(n / 1_000_000).toFixed(1)}M`
137}
138
139export function recordLine(rec: KikoRecord): string {
140 return `${rec.wins}-0 | streak ${rec.streak}`
141}
142
143export function koLine(round: Round, rec: KikoRecord): string {
144 const took = mmss((round.endedAt ?? round.startedAt) - round.startedAt)
145 return `K.O. ▸ Kiko beats ${round.opponent} in ${took} · read ${round.reads} · wrote ${round.writes} · ` +
146 `${fmtTokens(round.tokensIn)} in / ${fmtTokens(round.tokensOut)} out · ${rec.wins}-0`
147}
148
149export function oneLine(round: Round): string {
150 return `ROUND ${round.n} · KIKO vs. ${round.opponent} · KI ${fmtTokens(round.tokensIn)} · KO ${fmtTokens(round.tokensOut)}`
151}
152
153export function statsText(rec: KikoRecord): string {
154 return [
155 `**Kiko's record: ${rec.wins}-0**`,
156 '',
157 `- Streak: ${rec.streak} (best ${rec.bestStreak})`,
158 `- Fastest K.O.: ${rec.fastestMs === null ? 'none yet' : mmss(rec.fastestMs)}`,
159 `- Biggest K.O.: ${fmtTokens(rec.biggestTokens)} tokens`,
160 `- Last opponents: ${rec.recent.length ? rec.recent.join(', ') : 'none yet'}`,
161 ].join('\n')
162}
163
164export type SpinnerKind = 'thinking' | 'in' | 'out' | 'responding' | 'tool' | 'requesting'
165
166export function spinnerKind(engineMode: string, round: Round): SpinnerKind {
167 if (engineMode === 'thinking') return 'thinking'
168 if (engineMode === 'responding') return 'responding'
169 if (engineMode === 'tool-use' || engineMode === 'tool-input') {
170 return round.beat?.kind === 'chomp' ? 'in' : round.beat?.kind === 'punch' ? 'out' : 'tool'
171 }
172 return 'requesting'
173}
174
175const WORDS: Record<SpinnerKind, readonly string[]> = {
176 thinking: ['Kikonsidering', 'Sizing up the opponent', 'Plotting the combo', 'Reading the ring'],
177 in: ['Chomping knowledge', 'Knowledge in', 'Studying the tape', 'Nom-nom-noting'],
178 out: ['Winding up the KO', 'Knowledge out', 'Landing the uppercut', 'Jab, jab, cross'],
179 responding: ['Trash-talking', 'Ringside commentary', 'Calling the shot', 'Talking the talk'],
180 tool: ['Bobbing and weaving', 'Footwork', 'Working the corner', 'Kiko-ing'],
181 requesting: ['Kiko-ing', 'Touching gloves', 'Circling', 'Bouncing on toes'],
182}
183
184export function spinnerWord(kind: SpinnerKind, n: number): string {
185 const words = WORDS[kind]
186 return words[Math.abs(n) % words.length]!
187}
188
189export type { Mode }
190hooks/sprites.ts 125 lines1import type { Round } from '../types'
2import { BEAT_FRAMES, fmtTokens, mmss } from './round'
3
4export { BEAT_FRAMES }
5
6type Frame = readonly [string, string, string]
7const KIKO_W = 9
8const FOE_W = 5
9const pad = (s: string, w: number) => (s.length >= w ? s.slice(0, w) : s + ' '.repeat(w - s.length))
10const kiko = (top: string, face: string, body: string): Frame => [pad(top, KIKO_W), pad(face, KIKO_W), pad(body, KIKO_W)]
11const foe = (top: string, face: string, body: string): Frame => [pad(top, FOE_W), pad(face, FOE_W), pad(body, FOE_W)]
12
13const EARS = ' /\\_/\\'
14const ARMS = ' /| |\\'
15
16export const SPRITES = {
17 guard: [kiko(EARS, '( o.o )', ARMS), kiko(EARS, '( o.o )', ' \\| |/')],
18 thinking: [
19 kiko(' ?', '( o.o )', ARMS), kiko(' ?', '( @.@ )', ARMS), kiko(' ? ?', '( -.- )', ARMS), kiko(' ?', '( @.@ )', ARMS),
20 ],
21 responding: [kiko(EARS, '( o.o )', ARMS), kiko(EARS, '( oOo )', ARMS), kiko(EARS, '( o-o )', ARMS)],
22 chomp: [kiko(EARS, '( O.O )', ARMS), kiko(EARS, '( O.O )', ARMS), kiko(EARS, '( >.< )', ARMS), kiko(EARS, '( ^.^ )', ARMS)],
23 punch: [kiko(EARS, '( >.< )', ARMS), kiko(EARS, '( >.< )', ' /| |=>'), kiko(EARS, '( ^.^ )', ' /| |=>')],
24 dodge: [kiko(EARS, '( >.> )', ARMS), kiko(EARS, '( <.< )', ARMS)],
25} as const
26
27export const FOE = {
28 idle: [foe(' ,_,', '(o_o)', ' / \\'), foe(' ,_,', '(-_o)', ' / \\')],
29 hit: [foe(' ,_,', '(x_x)', ' \\ /'), foe(' ,_,', ' (x_x', ' \\ /')],
30} as const
31
32export const KO_LETTERS: readonly string[] = ['╦╔═ ╔═╗ ', '╠╩╗ ║ ║ ', '╩ ╩o╚═╝o ']
33
34export function isArtChar(ch: string): boolean {
35 const c = ch.codePointAt(0) ?? 0
36 return ch.length === 1 && ((c >= 0x20 && c <= 0x7e) || (c >= 0x2500 && c <= 0x259f) || c === 0x2039 || c === 0x203a)
37}
38
39export function fitRow(text: string, columns: number): string {
40 return text.length >= columns ? text.slice(0, columns) : text + ' '.repeat(columns - text.length)
41}
42
43const FULL = 200_000
44
45export function bar(value: number, width: number): string {
46 const fraction = Math.min(1, Math.log10(1 + Math.max(0, value)) / Math.log10(1 + FULL))
47 const filled = Math.round(fraction * width)
48 return '█'.repeat(filled) + '░'.repeat(width - filled)
49}
50
51function header(round: Round, view: { beatStep: number | null; now: number }, columns: number): string {
52 const isBell = round.beat?.kind === 'bell' && view.beatStep !== null
53 const left = isBell ? ` ROUND ${round.n} ── FIGHT! ` : ` ROUND ${round.n} ── KIKO vs. ${round.opponent} `
54 const clock = ` ${mmss((round.endedAt ?? view.now) - round.startedAt)} `
55 const fill = Math.max(1, columns - left.length - clock.length)
56 return fitRow(left + '─'.repeat(fill) + clock, columns)
57}
58
59function bars(round: Round, columns: number): string {
60 const label = (name: string, value: number, width: number) => `${name} ${bar(value, width)} ${fmtTokens(value)}`
61 const width = Math.max(4, Math.min(12, Math.floor((columns - 24) / 2)))
62 const left = ` ${label('KI', round.tokensIn, width)}`
63 const right = `${label('KO', round.tokensOut, width)} `
64 return fitRow(left + ' '.repeat(Math.max(2, columns - left.length - right.length)) + right, columns)
65}
66
67function kikoFrame(round: Round, view: { frame: number; beatStep: number | null }): Frame {
68 const beat = round.beat && view.beatStep !== null ? round.beat.kind : null
69 if (beat === 'chomp') return SPRITES.chomp[Math.min(view.beatStep! >> 1, 3)]!
70 if (beat === 'punch') return SPRITES.punch[Math.min(view.beatStep! >> 1, 2)]!
71 if (beat === 'dodge') return SPRITES.dodge[view.beatStep! % 2]!
72 const loop = round.mode === 'thinking' ? SPRITES.thinking : round.mode === 'responding' ? SPRITES.responding : SPRITES.guard
73 return loop[view.frame % loop.length]!
74}
75
76function arena(round: Round, view: { frame: number; beatStep: number | null }, columns: number): string[] {
77 const hasFoe = columns >= 60
78 const LEFT = 14
79 const me = kikoFrame(round, view)
80 const step = view.beatStep
81 const isHit = round.beat?.kind === 'punch' && step !== null && step >= 3
82 const them = isHit ? FOE.hit[step! % 2]! : FOE.idle[(view.frame >> 2) % 2]!
83 const gap = Math.max(4, columns - LEFT - me[0].length - (hasFoe ? them[0].length + 2 : 0))
84 const lanes = [' '.repeat(LEFT), ' '.repeat(LEFT), ' '.repeat(LEFT)]
85 const mid = [' '.repeat(gap), ' '.repeat(gap), ' '.repeat(gap)]
86 const put = (row: string, at: number, text: string) => (row.slice(0, at) + text + row.slice(at + text.length)).slice(0, row.length)
87
88 if (round.beat?.kind === 'chomp' && step !== null && step < 4) {
89 const item = `[${round.beat.label}]›››`.slice(0, LEFT)
90 lanes[1] = put(lanes[1]!, Math.min(step * 3, LEFT - item.length), item)
91 }
92 if (round.beat?.kind === 'punch' && step !== null && step >= 1 && step < 4) {
93 const item = `{${round.beat.label}}`.slice(0, gap)
94 mid[1] = put(mid[1]!, Math.min((step - 1) * Math.floor(gap / 3), gap - item.length), item)
95 if (step === 3) mid[2] = put(mid[2]!, Math.max(0, gap - 7), '‹ jab!')
96 }
97 if (round.mode === 'responding' && step === null) {
98 const letters = 'k i k o'
99 mid[1] = put(mid[1]!, (view.frame * 2) % Math.max(1, gap - letters.length), letters)
100 }
101 return [0, 1, 2].map(i => fitRow(`${lanes[i]}${me[i]}${mid[i]}${hasFoe ? `${them[i]} ` : ''}`, columns))
102}
103
104export function layout(
105 round: Round,
106 view: { frame: number; beatStep: number | null; now: number },
107 columns: number,
108 maxRows = 6,
109): string[] {
110 const status = fitRow(` > ${round.status || (round.mode === 'thinking' ? 'thinking' : round.mode === 'responding' ? 'replying' : 'circling')}`, columns)
111 if (columns < 30) return [header(round, view, columns), status]
112 const rows = [header(round, view, columns), bars(round, columns), ...arena(round, view, columns), status]
113 return maxRows < 6 ? [rows[0]!, ...rows.slice(2)] : rows
114}
115
116export function koCard(round: Round, columns: number): string[] {
117 const took = mmss((round.endedAt ?? round.startedAt) - round.startedAt)
118 const lines = [
119 `K.O.! Kiko beats ${round.opponent} in ${took}`,
120 `read ${round.reads} | wrote ${round.writes} | ${fmtTokens(round.tokensIn)} in / ${fmtTokens(round.tokensOut)} out`,
121 `record ${round.record}`,
122 ]
123 return lines.map((text, i) => fitRow(` ${KO_LETTERS[i]} ${text}`, columns))
124}
125types/index.d.ts 42 lines1export type Phase = 'idle' | 'fight' | 'ko'
2export type Mode = 'requesting' | 'thinking' | 'responding' | 'tool'
3export type BeatKind = 'bell' | 'chomp' | 'punch' | 'dodge'
4export type Beat = { id: number; kind: BeatKind; label: string }
5
6export type Round = {
7 phase: Phase
8 n: number
9 turnId: string | null
10 opponent: string
11 startedAt: number
12 endedAt: number | null
13 mode: Mode
14 status: string
15 tokensIn: number
16 tokensOut: number
17 reads: number
18 writes: number
19 seq: number
20 beat: Beat | null
21 record: string
22 /** The band's animation frame, advanced every tick while fighting. */
23 frame: number
24 /** The frame the current beat started on. */
25 beatAt: number
26}
27
28export type KikoRecord = {
29 wins: number
30 streak: number
31 bestStreak: number
32 fastestMs: number | null
33 biggestTokens: number
34 recent: string[]
35}
36
37declare module 'claude-code' {
38 interface PluginState {
39 kiko: { round: Round; enabled: boolean }
40 }
41}
42