SLOPSHOPPER

kiko

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

newbandspinnerguardcommandtimer
v0.1.0MITupdated 2026-10-04kikostefanov-lab/claude-code-mods/kiko
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · kiko
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ 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 › /kiko ⎿ kiko: Kiko is on · 0-0 | streak 0. Try /kiko stats, /kiko off. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

Claude Code mods

CI

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 doctor if something looks off.

ModWhat 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.

Whiteboard

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

Features

  • 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.
  • Starter commands: /whiteboard arch (architecture), /whiteboard flow <file or area> (control flow), /whiteboard schema (data model). Each asks Claude to draw.
  • History per project: the last 20 diagrams, kept across sessions. ◀ ▶ buttons, or h / l while the pane has focus.
  • Versions: redrawing a title keeps the earlier versions; ‹ v2/3 › steps between them.
  • Ask Claude to change this: type a request under the diagram; Claude gets it with the current source and redraws.
  • Export writes diagrams/<title>.<mmd|d2|puml> and .svg into the working directory, never overwriting.
  • Copy (source), Copy MD (a fenced block that renders in GitHub, GitLab and Notion), Open (o, the SVG in your default app).
  • Share creates a secret GitHub gist after asking you first, and copies the link. Needs the GitHub CLI.
  • Themes: 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.

Where it works

SurfaceWhat you see
Desktop Code tab, VS CodeRendered SVG and everything above
MobileRendered SVG and buttons (no text field yet)
Terminal, Ghostty or kittyRendered 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.

Install

  1. Renderers. Mermaid is required, the others are optional:
   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.

  1. The plugin. Clone this repo, then either load it for every session (CLI and desktop) by adding it to the 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.

  1. Check. Run /whiteboard doctor.

Options

OptionDefaultWhat it does
mmdcPathemptyAbsolute path to mmdc when it can't be found automatically
themedefaultdefault, neutral, dark or forest
mermaidConfigemptyAbsolute 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.

How it works

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
  • Rendered files live in ~/.claude/whiteboard/<project>/; history is saved per project. Diagrams pushed out of the last 20 have their files removed.
  • Diagram text only ever goes into a file, never onto a command line; renderers run by argv, without a shell.
  • Renders time out after 20 s, and stop when you interrupt Claude.
  • --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.

Layout

.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

Development

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.

Known limitations

  • Mermaid's state-diagram grammar is lenient: some typos render as odd states instead of failing.
  • Sources longer than about 9,800 characters show truncated in the source view; Copy and Export still give the full text.
  • Windows support is written and unit-tested but has not been run on a Windows machine yet.

Kiko

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
  • Rounds: "ROUND n ── FIGHT!" when a turn starts; the opponent is named from your prompt.
  • Moves: swirly eyes while Claude thinks, talking while it replies, a chomp ([file]›››) for every read, search or fetch, a punch ({file} → (x_x)) for every edit or write, a dodge for other tools.
  • Bars: KI is new input tokens, KO is output tokens (log scale).
  • K.O.: a 3-row card for 5 seconds, plus a K.O. ▸ … notice in the transcript (the terminal shows it; the desktop doesn't display notices yet).
  • Career record across sessions: /kiko stats (wins, streak, fastest and biggest K.O., last opponents).
  • Spinner words while Claude thinks or replies: "Kikonsidering", "Winding up the KO", "Trash-talking"…
  • /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.

SurfaceWhat Kiko shows
TerminalThe band as text rows, spinner words, the K.O. line in the transcript
Desktop Code tabThe band as one fixed-width code block, spinner words while Claude thinks or replies
VS Code, mobileNothing yet: the engine only raises the band and spinner on the terminal and desktop

Notes on the mods API (learned the hard way)

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.
  • Never name a variable 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.
  • The terminal's element table includes an Svg that draws nothing. Pick the body by e.surface, not by 'Svg' in elements.
  • Relative $.fs paths resolve against the engine's cwd, not necessarily the session's: build paths from $.session.cwd().
  • A slash command can't call $.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.
  • The desktop app (2.1.286) can't run 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.
  • Desktop panes show one at a time; only the visible pane runs its content. Keep that in mind when probing with several panes.
  • Timers only outlive the dispatch that starts them from session.start. A $.clock.after set inside turn.complete never fires.
  • VS Code's element table lists a Client that draws nothing, like the terminal's Svg.
  • On the desktop, a running tool sets the spinner's message ("Running tools…"), so a spinner rewrite that respects message only shows while Claude thinks or replies.
  • In claude plugin test:
  • The test's $ 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.
  • Stubs answer { value } or { deny }; one that throws is skipped, not rejected.
  • A process.spawn stub is an async generator that yields chunks and returns { value: { code, signal } }.
  • mock.clock holds timers until the test calls advance.
  • Inline test plugins are written to a temp folder, so they can't name a surface module of the plugin under test.
  • The CLI's runner (2.1.284) can't hook or observe session.append; check appended notices live.

Design docs

The spec and the implementation plan for 0.1 are in docs/superpowers/.

License

MIT

Source 4 files
hooks/register.tsx 229 lines
1import { 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}
229
hooks/round.ts 190 lines
1// 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 }
190
hooks/sprites.ts 125 lines
1import 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}
125
types/index.d.ts 42 lines
1export 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