Skip to content

Instantly share code, notes, and snippets.

@nevyn
Last active October 5, 2026 17:51
Show Gist options
  • Select an option

  • Save nevyn/3ffbe3e138e413dc47c846db3e980f70 to your computer and use it in GitHub Desktop.

Select an option

Save nevyn/3ffbe3e138e413dc47c846db3e980f70 to your computer and use it in GitHub Desktop.
api-tour: a Claude Code mod that tours the whole mods API (drawing elements, panes, Raster, Image, Client, and every noun on $)

api-tour: a guided tour of Claude Code mods

A Claude Code mod that exercises the whole mods API, so you can see what a mod can draw and do instead of reading about it. Run /tour and step through eight tabs: every drawing element, hover effects, form controls, a 10 fps pixel animation, a Julia set drawn as an image, a tiny paint program running on the drawing thread, and about twenty buttons that each call a different part of the engine.

Image tab in Ghostty: a Julia set from raw RGBA bytes, 5 fps Raster tab in Ghostty: a plasma of half-block cells, 10 fps
The Image tab in Ghostty, drawing a colourful Julia set The Raster tab in Ghostty, drawing a plasma animation and a colour ramp
Raster tab on desktop: the Svg fallback, animated Engine tab: one button per noun on $
The Raster tab in the desktop app, drawing an animated SVG gradient The Engine tab in the desktop app, with probe buttons and their answers

Written against Claude Code 2.1.286. The API moves between releases; /plugin-types writes the declaration file for the build you run, and that file is the authority.

Install

Needs Claude Code 2.1.286 or newer; older engines refuse the module (claude --version, then claude update). A gist cannot hold folders, so two files move into place after cloning:

git clone https://gist.github.com/3ffbe3e138e413dc47c846db3e980f70.git api-tour
cd api-tour
mkdir -p .claude-plugin hooks
mv plugin.json .claude-plugin/
mv hooks.json hooks/
claude --plugin-dir .

Then type /tour. Digits 1-8 switch tabs while the pane has focus; /tour raster opens straight on a tab. claude plugin validate . and claude plugin test . check it without starting a session.

Raster and Image are terminal-only, and Image needs a terminal with the kitty graphics protocol (kitty, Ghostty) to show pixels; elsewhere it shows its alt text. The desktop app draws an animated SVG in their place.

What a mod is

A mod is a plugin folder whose hooks/hooks.json names one TypeScript module exporting register(on, options). The module runs in a sandbox of its own (no DOM, no Node, no fetch); everything outside it goes through $.

on(event, matcher?, hook) adds a hook, and every hook has the shape ($, e, next). Hooks form a chain around the engine's own behaviour, like Koa middleware:

Move Code Effect
Pass return next(e) Observe only
Rewrite return next({ ...e, command: e.command.trim() }) Everything below sees your version (e is frozen)
Answer return { deny: 'no' } Nothing below runs
Post-process const r = await next(e); ...; return r Act on the result on its way back up

A matcher narrows both the dispatch and the type of e: on('tool.call', { tool: 'Bash' }, ...) gets a typed e.command.

Events a mod can hook

Area Events
Tools tool.call, tool.check, tool.describe
Prompt prompt.submit, .edit, .fill, .suggest, .attachment
System prompt prompt.compose, prompt.section, prompt.context
Turn turn.start, turn.step (streams), turn.complete
Session session.start, .end, .append (every stored row), .receive, .send, .compact, .attach, .detach, .measure
UI ui.render, ui.press, ui.input, ui.select, ui.focus, ui.scroll, ui.message, ui.copy
Commands, config command.run, command.describe, config.set, config.describe
Agents, skills agent.offer, agent.spawn, skill.prompt
Meta engine.create (add nouns to $), plugin.register (admit or refuse other plugins)
Classic hooks classic.Stop, classic.PreToolUse, ...: the settings.json shell hooks as events

Every call on $ is an event too (fs.read, http.fetch, state.set, ...), so one mod can guard or rewrite what another mod does.

What $ offers

Noun What it does
ui Panes, toasts, the status line, transcript log lines, questions to the person, the clipboard, focus and scroll, repainting pixels without a render pass
model complete (one call on the session's own credentials), fork (one question over the session's transcript, served from the prompt cache), classify
session Messages, cwd, repo, model, usage, version; append a row; compact
prompt Submit a prompt that starts a turn; read or fill the prompt box
tool, command, agent List them, call them, register new ones the model or the person can use
fs, http, process Files, network and host commands, all through the engine
state, store Session values that redraw their readers when written; JSON kept across sessions
clock, audio, env, settings, config, mcp, telemetry Timers, speech, environment, settings (read only), /config rows, MCP servers, analytics

Drawing

A ui.render hook on a site (Pane, AbovePrompt, Spinner, TurnDuration, CommandOutput, messages, tool rows, ...) returns a tree, rewrites the engine's props with next({ ...e, props }), or leaves it alone with next(e). The elements come from the surface's own table, const { Box, Text } = $.ui.resolve(e), because each surface draws a different set:

Element Surfaces
Box, Text, Button, Link, Code, Markdown all
Input, Select all but mobile
Svg all but the terminal
Raster (a packed grid of coloured cells), Image (pixels) terminal only
Client (a module running on the drawing thread, with its own state, keys and pointer) terminal and desktop

State that a drawing reads belongs in $.state: a read made while drawing subscribes the drawing, and a later write redraws exactly those readers.

The tour

Tab Shows
1 Elements Text styles, border styles, flex layout, truncation, every Button variant, Link, Code as source and as a diff, Markdown whose link raises ui.press instead of opening
2 Hover Hover styles that run on the surface alone, a card that appears on hover without moving anything, a hover group shared by two separate elements
3 Input Input and Select backed by $.state; moving focus, filling and reading the prompt box, copying, $.ui.ask
4 Raster A 12-row plasma at 10 fps, two pixels per cell, repainted with $.ui.blit
5 Image A Julia set computed as RGBA bytes and swapped in at 5 fps
6 Client canvas.tsx, a paint program with its own state, timer, keys and pointer, posting each stroke back to the mod
7 Engine One button per noun on $, each listing its answer
8 Events What the hooks saw: stored rows by type, system-prompt sections, plugins admitted, usage, presses, focus, Client messages

Outside the pane, the mod adds a band above the prompt with a sparkline of tool calls, puts the last tool call on the status line, registers a roll_dice tool the model can call, and adds /tour-card, a command whose output is drawn as a custom box.

Two options in /config turn on rewrites of the engine's own UI, both off by default: rewriteEngineSites (spinner word, turn-duration line) and rewritePrompts (:shrug: becomes ¯\_(ツ)_/¯).

Engine-tab buttons marked "tokens" call a model, "talks" speaks out loud, and "starts a turn" submits a prompt. The fs.* button writes /tmp/api-tour-probe.txt; the settings button lists top-level key names, never values.

Things learned building it

  • A hover style needs a keyed Box around it, or a scope; otherwise the whole tree is refused.
  • A tree that does not validate is not drawn: the engine draws its own and logs the reason. claude plugin test with $.ui.mount catches these before a session does.
  • A render hook never writes state; write from a handler or another event.
  • A terminal frame shows at most 1024 distinct colour pairs, so the plasma quantizes its hues to 32 steps.
  • A Client module starts its timers and listeners once, while its state is still undefined; three setState calls in a row with no input between them unmount it as a render loop.
  • Module variables reset on every hot reload; $.state and $.store survive.

Files

File Role
plugin.json Manifest, goes in .claude-plugin/
hooks.json Names the hooks module, goes in hooks/
register.tsx The hooks module
canvas.tsx The Client surface module
tour-state.d.ts Type contract for the mod's $.state values
tour.test.tsx Mounts every tab, the band and the card on terminal and desktop
import type { ClientModule, ClientSurface } from 'claude-code'
type Canvas = { x: number; y: number; ink: string[]; isCursorShown: boolean; strokes: number; lastKey: string }
const WIDTH = 40
const HEIGHT = 8
const INK = '█'
const canvas: ClientModule<{ title: string }, Canvas> = (props, surface) => {
const { Box, Text } = surface.elements
const s = surface.state ?? start(surface)
return (
<Box flexDirection="column">
<Text bold>
{props.title} · {surface.columns}x{surface.rows} cells · strokes {s.strokes} · last key {s.lastKey || '-'}
</Text>
{s.ink.map((row, y) => (
<Text color="cyan">
{y === s.y && s.isCursorShown ? row.slice(0, s.x) + '◆' + row.slice(s.x + 1) : row}
</Text>
))}
<Text dimColor>click to focus · arrows move · space paints · c clears · click/drag paints · Esc leaves</Text>
</Box>
)
}
export default canvas
function start(surface: ClientSurface<Canvas>): Canvas {
const initial: Canvas = {
x: 0,
y: 0,
ink: Array.from({ length: HEIGHT }, () => '·'.repeat(WIDTH)),
isCursorShown: true,
strokes: 0,
lastKey: '',
}
surface.setState(initial)
surface.every(500, () => {
const s = surface.state
if (s) surface.setState({ ...s, isCursorShown: !s.isCursorShown })
})
surface.onKey(k => {
const s = surface.state
if (!s) return
const moved = { ...s, lastKey: k.key }
if (k.key === 'left') moved.x = Math.max(0, s.x - 1)
if (k.key === 'right') moved.x = Math.min(WIDTH - 1, s.x + 1)
if (k.key === 'up') moved.y = Math.max(0, s.y - 1)
if (k.key === 'down') moved.y = Math.min(HEIGHT - 1, s.y + 1)
if (k.key === ' ') return paint(surface, moved, moved.x, moved.y)
if (k.key === 'c') moved.ink = initial.ink
surface.setState(moved)
})
surface.onPointer(p => {
const s = surface.state
if (!s) return
const isPainting = p.type === 'down' || (p.type === 'move' && p.button === 'left')
// Row 0 is the title line.
if (isPainting) paint(surface, s, p.x, p.y - 1)
})
return initial
}
function paint(surface: ClientSurface<Canvas>, s: Canvas, x: number, y: number) {
if (x < 0 || y < 0 || x >= WIDTH || y >= HEIGHT) return
const row = s.ink[y] ?? ''
const ink = s.ink.map((r, i) => (i === y ? row.slice(0, x) + INK + row.slice(x + 1) : r))
const next = { ...s, x, y, ink, strokes: s.strokes + 1 }
surface.setState(next)
surface.post({ strokes: next.strokes, x, y })
}
{ "modules": ["../register.tsx"] }
{
"name": "api-tour",
"version": "0.2.0",
"description": "A guided tour of the Claude Code mods API: every element, drawing site and noun on $",
"author": { "name": "Nevyn Bengtsson" },
"types": "./tour-state.d.ts",
"userConfig": {
"rewriteEngineSites": {
"type": "boolean",
"title": "Rewrite engine sites",
"description": "Append (toured) to the spinner word and replace the turn-duration line with one that counts tool calls",
"default": false
},
"rewritePrompts": {
"type": "boolean",
"title": "Rewrite prompts",
"description": "Replace :shrug: in submitted prompts",
"default": false
}
}
}
import { atom, read, update } from 'claude-code'
import type { EngineInterface, Register, RenderSurface } from 'claude-code'
import type { TourLine, TourProbe, TourSection, TourStats, TourTab } from './tour-state'
const PANE = 'api-tour'
const TOOL = 'mcp__api-tour__roll_dice'
const HAIKU = 'claude-haiku-4-5-20251001'
const TABS: readonly { tab: TourTab; label: string }[] = [
{ tab: 'elements', label: 'Elements' },
{ tab: 'hover', label: 'Hover' },
{ tab: 'input', label: 'Input' },
{ tab: 'raster', label: 'Raster' },
{ tab: 'image', label: 'Image' },
{ tab: 'client', label: 'Client' },
{ tab: 'engine', label: 'Engine' },
{ tab: 'events', label: 'Events' },
]
const EMPTY_STATS: TourStats = { prompts: 0, tools: 0, turns: 0, turnTools: 0, lastTurnMs: 0, doors: {} }
const tab = atom({ plugin: 'api-tour', key: 'tab' } as const, 'elements')
const log = atom({ plugin: 'api-tour', key: 'log' } as const, [])
const stats = atom({ plugin: 'api-tour', key: 'stats' } as const, EMPTY_STATS)
const activity = atom({ plugin: 'api-tour', key: 'activity' } as const, Array.from({ length: 30 }, () => 0))
const probes = atom({ plugin: 'api-tour', key: 'probes' } as const, [])
const draft = atom({ plugin: 'api-tour', key: 'draft' } as const, '')
const choice = atom({ plugin: 'api-tour', key: 'choice' } as const, 'umami')
const isBandHidden = atom({ plugin: 'api-tour', key: 'isBandHidden' } as const, false)
const sections = atom({ plugin: 'api-tour', key: 'sections' } as const, [])
const plugins = atom({ plugin: 'api-tour', key: 'plugins' } as const, [])
const canvasPosts = atom({ plugin: 'api-tour', key: 'canvasPosts' } as const, 0)
// Mirrors of state the animation timer checks every tick without a $.state round trip.
let liveTab: TourTab = 'elements'
let frame = 0
let turnStartedAt = 0
export const register: Register = (on, options) => {
const isRewritingSites = options.rewriteEngineSites === true
const isRewritingPrompts = options.rewritePrompts === true
// ---------------------------------------------------------------- lifecycle
on('session.start', async ($, e, next) => {
liveTab = await read($, tab)
await $.command.register({ name: 'tour', description: 'Open the api-tour pane', argumentHint: '[tab]' })
await $.command.register({ name: 'tour-card', description: 'Print an api-tour card drawn as a custom CommandOutput' })
await $.command.register({ name: 'tour-close', description: 'Close the api-tour pane' })
await $.tool.register({
name: 'roll_dice',
description: 'Demo tool from the api-tour mod. Rolls dice. Call it only when the user asks to roll dice.',
inputSchema: {
type: 'object',
properties: {
sides: { type: 'integer', minimum: 2, maximum: 1000 },
count: { type: 'integer', minimum: 1, maximum: 20 },
},
required: ['sides'],
},
})
$.clock.every(100, () => void animate($))
$.clock.every(10_000, () => void update($, activity, a => [...a.slice(1), 0]))
await note($, 'session.start', `surface=${e.surface ?? 'none'} interactive=${e.isInteractive}`)
return next(e)
})
on('session.end', async ($, e, next) => {
await $.store.set('lastEnd', { at: await $.clock.now(), reason: e.reason })
return next(e)
})
on('plugin.register', async ($, e, next) => {
await update($, plugins, list => [...list.filter(p => !p.startsWith(`${e.name} `)), `${e.name} (${e.tier})`])
return next(e)
})
// ---------------------------------------------------------------- commands
on('command.run', { command: 'tour' }, async ($, e) => {
const wanted = TABS.find(t => t.tab === e.args.trim())
if (wanted) await selectTab($, wanted.tab)
const opened = await $.ui.open({ id: PANE, title: 'API tour', focus: true, rows: 30, columns: 90 })
return { text: opened.isPlaced ? 'API tour opened. Digits 1-8 switch tabs while the pane has focus.' : `Pane not placed: ${opened.reason}` }
})
on('command.run', { command: 'tour-close' }, async $ => {
await $.ui.close({ id: PANE })
return { text: 'API tour closed.' }
})
on('command.run', { command: 'tour-card' }, async $ => {
const s = await read($, stats)
return {
text: `api-tour card: ${s.prompts} prompts, ${s.tools} tool calls, ${s.turns} turns.`,
context: ['The api-tour card was printed for the user; no action needed.'],
}
})
// A CommandOutput site drawn as a tree: the model still reads the text above.
on('ui.render', { component: 'CommandOutput', props: { command: 'tour-card' } }, async ($, e) => {
const { Box, Text, Markdown, Button } = $.ui.resolve(e)
const s = await read($, stats)
const doors = Object.entries(s.doors)
.map(([door, n]) => `| ${door} | ${n} |`)
.join('\n')
return (
<Box flexDirection="column" borderStyle="double" borderColor="magenta" paddingX={1}>
<Text bold color="magenta">api-tour card</Text>
<Markdown text={`**${s.prompts}** prompts · **${s.tools}** tool calls · **${s.turns}** turns\n\n| door | rows |\n|---|---|\n${doors}`} />
<Button key="open" label="Open the tour" variant="primary" onPress={() => void $.ui.open({ id: PANE, title: 'API tour', focus: true })} />
</Box>
)
})
// ---------------------------------------------------------------- tools, prompts, turns
on('tool.call', { tool: TOOL }, async ($, e) => {
const input = e as unknown as { sides?: number; count?: number }
const sides = Math.max(2, Math.min(1000, Math.floor(input.sides ?? 6)))
const count = Math.max(1, Math.min(20, Math.floor(input.count ?? 1)))
const rolls = Array.from({ length: count }, () => 1 + Math.floor(Math.random() * sides))
const total = rolls.reduce((a, b) => a + b, 0)
$.ui.toast(`🎲 ${count}d${sides}: ${rolls.join(' + ')} = ${total}`)
return { result: { rolls, total }, text: `Rolled ${count}d${sides}: ${rolls.join(', ')} (total ${total}).` }
})
on('tool.call', async ($, e, next) => {
const started = await $.clock.now()
await update($, stats, s => ({ ...s, tools: s.tools + 1, turnTools: s.turnTools + 1 }))
await update($, activity, a => [...a.slice(0, -1), (a[a.length - 1] ?? 0) + 1])
const ran = await next(e)
const ms = (await $.clock.now()) - started
const outcome = ran.deny !== undefined ? 'denied' : ran.isError ? 'error' : 'ok'
$.ui.status(`tour: ${e.tool} ${outcome} ${ms}ms`)
await note($, 'tool.call', `${e.tool} ${outcome} ${ms}ms`)
return ran
})
on('prompt.submit', async ($, e, next) => {
await update($, stats, s => ({ ...s, prompts: s.prompts + 1 }))
if (!isRewritingPrompts) return next(e)
const text = e.text.replaceAll(':shrug:', '¯\\_(ツ)_/¯')
if (text !== e.text) await note($, 'prompt.submit', 'rewrote :shrug:')
return next({ ...e, text })
})
on('turn.start', async ($, e, next) => {
turnStartedAt = await $.clock.now()
await update($, stats, s => ({ ...s, turnTools: 0 }))
await note($, 'turn.start', e.turnId)
return next(e)
})
on('turn.complete', async ($, e, next) => {
await update($, stats, s => ({ ...s, turns: s.turns + 1, lastTurnMs: e.durationMs }))
await note($, 'turn.complete', `${Math.round(e.durationMs / 1000)}s aborted=${e.isAborted}`)
if (e.durationMs > 120_000) $.ui.toast(`That turn took ${Math.round(e.durationMs / 1000)}s`, { timeoutMs: 6000 })
return next(e)
})
// ---------------------------------------------------------------- observation only
on('session.append', async ($, e, next) => {
await update($, stats, s => ({ ...s, doors: { ...s.doors, [e.door]: (s.doors[e.door] ?? 0) + 1 } }))
return next(e)
})
on('prompt.compose', async ($, e, next) => {
const composed = await next(e)
const seen: TourSection[] = composed.sections.map(s => ({ id: s.id, chars: s.text.length, scope: s.scope }))
await update($, sections, () => seen)
return composed
})
on('session.measure', async ($, e, next) => {
await note($, 'session.measure', `context ${e.context.percent ?? '?'}% changed=${e.changed.join(',')}`)
return next(e)
})
on('ui.press', async ($, e, next) => {
if (e.plugin === 'api-tour') await note($, 'ui.press', `${e.component}/${e.element} on ${e.surface}`)
return next(e)
})
on('ui.focus', async ($, e, next) => {
await note($, 'ui.focus', JSON.stringify(e).slice(0, 80))
return next(e)
})
on('ui.message', async ($, e, next) => {
await update($, canvasPosts, n => n + 1)
await note($, 'ui.message', `${e.element}: ${JSON.stringify(e.data)}`)
return next(e)
})
on('classic.Stop', async ($, e, next) => {
await note($, 'classic.Stop', 'settings-hook event seen as a function hook')
return next(e)
})
// ---------------------------------------------------------------- engine sites rewritten
on('ui.render', { component: 'Spinner' }, ($, e, next) =>
isRewritingSites ? next({ ...e, props: { ...e.props, word: `${e.props.word} (toured)` } }) : next(e),
)
on('ui.render', { component: 'TurnDuration' }, async ($, e, next) => {
if (!isRewritingSites) return next(e)
const { Box, Text } = $.ui.resolve(e)
const s = await read($, stats)
return (
<Box gap={1}>
<Text dimColor>⏱ {e.props.word} {(e.props.durationMs / 1000).toFixed(1)}s</Text>
<Text color="magenta" dimColor>· {s.turnTools} tool calls · api-tour</Text>
</Box>
)
})
// ---------------------------------------------------------------- the band
on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
if (e.props.hasSurvey || (await read($, isBandHidden))) return next(e)
const s = await read($, stats)
const buckets = await read($, activity)
const working = e.props.isWorking ? ' · working' : ''
const long = `api-tour · ${s.prompts} prompts · ${s.tools} tools · ${s.turns} turns${working}`
// Sparkline, gaps and the two plain buttons take about buckets + 14 columns.
const summary = long.length + buckets.length + 14 <= e.props.bodyColumns ? long : `tour ${s.prompts}p ${s.tools}t ${s.turns}T${working}`
if (e.surface === 'terminal') {
const { Box, Text, Button, Raster } = $.ui.resolve(e)
return (
<Box gap={1}>
<Raster key="spark" columns={buckets.length} rows={1} cells={sparkCells(buckets)} />
<Text dimColor>{summary}</Text>
<Button key="tour" label="tour" plain dimColor onPress={() => void $.ui.open({ id: PANE, title: 'API tour', focus: true })} />
<Button key="hide" label="hide" plain dimColor onPress={() => update($, isBandHidden, () => true)} />
</Box>
)
}
if (e.surface === 'desktop' || e.surface === 'vscode' || e.surface === 'mobile') {
const { Box, Text, Button, Svg } = $.ui.resolve(e)
return (
<Box gap={1}>
<Svg source={sparkSvg(buckets)} alt="tool calls per 10 seconds" width={120} height={16} />
<Text dimColor>{summary}</Text>
<Button key="hide" label="hide" onPress={() => update($, isBandHidden, () => true)} />
</Box>
)
}
return next(e)
})
// ---------------------------------------------------------------- the pane
on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
const { Box, Text, Button } = $.ui.resolve(e)
const current = await read($, tab)
const width = Math.max(20, e.props.bodyColumns)
return (
<Box flexDirection="column" width={width}>
<Box gap={1} flexWrap="wrap">
{TABS.map((t, i) => (
<Button
key={`tab-${t.tab}`}
label={t.label}
hotkey={String(i + 1)}
plain
dimColor={t.tab !== current}
variant={t.tab === current ? 'primary' : undefined}
onPress={() => selectTab($, t.tab)}
/>
))}
<Button key="close" label="Close" role="dismiss" plain dimColor onPress={() => void $.ui.close({ id: PANE })} />
</Box>
<Text dimColor>
{e.surface} · body {e.props.bodyColumns}x{e.props.scroll.bodyRows} · scroll {e.props.scroll.offset} · {e.props.placement}
{e.props.isFocused ? ' · focused' : ''} · viewport {e.viewport ? `${e.viewport.columns}x${e.viewport.rows}` : 'unmeasured'}
</Text>
{await drawTab($, e.surface, current, width)}
</Box>
)
})
}
// ------------------------------------------------------------------ tabs
async function drawTab($: EngineInterface, surface: RenderSurface, current: TourTab, width: number) {
if (current === 'elements') return drawElements($, surface)
if (current === 'hover') return drawHover($, surface)
if (current === 'input') return drawInput($, surface)
if (current === 'raster') return drawRaster($, surface, width)
if (current === 'image') return drawImage($, surface, width)
if (current === 'client') return drawClient($, surface)
if (current === 'engine') return drawEngine($, surface)
return drawEvents($, surface)
}
function drawElements($: EngineInterface, surface: RenderSurface) {
const { Box, Text, Button, Link, Code, Markdown } = $.ui.resolve({ surface, component: 'Pane' })
return (
<Box flexDirection="column" gap={1}>
<Box gap={1} flexWrap="wrap">
<Text bold>bold</Text>
<Text italic>italic</Text>
<Text underline>underline</Text>
<Text strikethrough>strike</Text>
<Text inverse>inverse</Text>
<Text dimColor>dim</Text>
<Text color="red">red</Text>
<Text color="#ff8800">#ff8800</Text>
<Text color="black" backgroundColor="yellow"> bg </Text>
</Box>
<Box gap={1} flexWrap="wrap">
{['single', 'double', 'round', 'bold', 'classic', 'singleDouble'].map(style => (
<Box borderStyle={style} borderColor="cyan" paddingX={1}>
<Text>{style}</Text>
</Box>
))}
<Box borderStyle="single" borderDimColor paddingX={1}>
<Text>borderDimColor</Text>
</Box>
</Box>
<Box borderStyle="single" justifyContent="space-between" width={50}>
<Text>left</Text>
<Text>space-between</Text>
<Text>right</Text>
</Box>
<Box width={50} borderStyle="single">
<Text wrap="truncate-middle">truncate-middle: a very long line that the surface cuts in the middle to fit the box width</Text>
</Box>
<Box gap={1} flexWrap="wrap">
<Button key="b-primary" label="primary" variant="primary" onPress={() => $.ui.toast('primary pressed')} />
<Button key="b-secondary" label="secondary" variant="secondary" onPress={() => $.ui.toast('secondary pressed')} />
<Button key="b-plain" label="plain + hotkey" hotkey="p" plain onPress={() => $.ui.toast('plain pressed (or p)')} />
<Button key="b-dim" label="dimColor" dimColor onPress={() => $.ui.toast('dim pressed')} />
<Box key="b-hover-scope">
<Button key="b-hover" label="hover style" hover={{ color: 'green', bold: true }} onPress={() => $.ui.toast('hover pressed')} />
</Box>
<Button key="b-glyph" label="🔊" plain onPress={() => void $.audio.speak('api tour')} />
</Box>
<Link href="https://docs.claude.com/en/docs/claude-code" label="Link: Claude Code docs" />
<Code language="ts" path="example.ts" startLine={10} source={'const x: number = 42\nexport function twice(n: number) {\n return n * 2\n}'} />
<Code
format="diff"
source={'@@ -1,3 +1,3 @@\n const a = 1\n-const b = 2\n+const b = 3\n const c = 4'}
/>
<Markdown
key="md"
text={
'### Markdown\n- **bold**, _italic_, `code`\n- [a pressable link](https://example.com/pressed) raises `ui.press`\n- [a normal link](https://claude.com)\n\n| col | val |\n|---|---|\n| a | 1 |\n\n```py\nprint("fenced")\n```'
}
pressableLinks={['https://example.com/pressed']}
onLinkPress={link => $.ui.toast(`link pressed: ${link.href}`)}
/>
</Box>
)
}
function drawHover($: EngineInterface, surface: RenderSurface) {
const { Box, Text } = $.ui.resolve({ surface, component: 'Pane' })
return (
<Box flexDirection="column" gap={1}>
<Text dimColor>Hover styles run on the surface: no hook fires and nothing crosses to the plugin.</Text>
<Box key="hov-box" borderStyle="single" paddingX={1} hover={{ borderColor: 'yellow', backgroundColor: '#202840' }}>
<Text hover={{ bold: true, color: 'yellow' }}>A keyed Box is a hover scope: border, background and this Text change.</Text>
</Box>
<Box marginTop={3} gap={4}>
<Box key="hov-card">
<Text underline>ⓘ hover me for a card</Text>
<Box
position="absolute"
top={-3}
left={4}
display="none"
hover={{ display: 'flex' }}
borderStyle="single"
borderColor="magenta"
backgroundColor="#301830"
paddingX={1}
>
<Text>position: absolute · display none→flex on hover · no reflow</Text>
</Box>
</Box>
<Text hover={{ scope: 'pair', inverse: true }}>scope "pair" A</Text>
<Text hover={{ scope: 'pair', inverse: true }}>scope "pair" B</Text>
</Box>
<Text dimColor>The two "pair" Texts share a hover scope across the tree: hovering either inverts both.</Text>
</Box>
)
}
async function drawInput($: EngineInterface, surface: RenderSurface) {
const typed = await read($, draft)
const picked = await read($, choice)
if (surface === 'mobile') {
const { Text } = $.ui.resolve({ surface, component: 'Pane' })
return <Text>mobile draws no Input or Select yet.</Text>
}
const { Box, Text, Button, Input, Select } = $.ui.resolve({ surface, component: 'Pane' })
return (
<Box flexDirection="column" gap={1}>
<Input
key="draft"
label="Input"
placeholder="type; onInput fires per edit, Enter submits"
value={typed}
submitLabel="Send"
onInput={value => void update($, draft, () => value)}
onSubmit={value => {
$.ui.toast(`submitted: ${value}`)
void update($, draft, () => '')
}}
/>
<Text dimColor>live value: {typed || '(empty)'} · {typed.length} chars</Text>
<Select
key="flavor"
label="Select"
value={picked}
options={[
{ value: 'sweet', label: 'Sweet' },
{ value: 'sour', label: 'Sour' },
{ value: 'umami', label: 'Umami (旨味, うまみ)' },
]}
onSelect={value => void update($, choice, () => value)}
/>
<Text dimColor>picked: {picked}</Text>
<Box gap={1} flexWrap="wrap">
<Button key="focus-input" label="$.ui.focus → Input" onPress={() => void $.ui.focus({ requestId: PANE, key: 'draft' })} />
<Button
key="fill"
label="$.prompt.fill"
onPress={() => void $.prompt.fill({ text: typed || 'filled by api-tour', mode: 'append' })}
/>
<Button
key="read-prompt"
label="$.prompt.read"
onPress={async () => {
const box = await $.prompt.read()
$.ui.toast(`prompt box: "${box.text.slice(0, 40)}" cursor ${box.cursor}`)
}}
/>
<Button
key="copy"
label="$.ui.copy"
onPress={async ev => {
const copied = await $.ui.copy({ text: typed || 'api-tour', surface: ev.surface })
$.ui.toast(copied.isCopied ? 'copied' : `not copied: ${copied.reason}`)
}}
/>
<Button
key="ask"
label="$.ui.ask"
onPress={async () => {
const answer = await $.ui.ask('Which drawing element surprised you most?', {
header: 'api-tour',
options: ['Raster', 'Image', 'Client', 'Svg'],
})
await setProbe($, { title: 'ui.ask', text: answer, isError: false })
}}
/>
</Box>
</Box>
)
}
function drawRaster($: EngineInterface, surface: RenderSurface, width: number) {
if (surface === 'terminal') {
const { Box, Text, Raster } = $.ui.resolve({ surface, component: 'Pane' })
const columns = Math.min(width - 2, 72)
return (
<Box flexDirection="column">
<Text dimColor>One Raster, {columns}x12 cells, '▀' with fg/bg = two pixels per cell; $.ui.blit repaints it at 10 fps without a render pass.</Text>
<Raster key="plasma" columns={columns} rows={12} cells={plasmaCells(columns, 12, frame / 10)} />
<Text dimColor>Gradient + braille, drawn once:</Text>
<Raster key="ramp" columns={columns} rows={2} cells={rampCells(columns)} />
</Box>
)
}
const { Box, Text, Svg } = $.ui.resolve({ surface, component: 'Pane' })
return (
<Box flexDirection="column">
<Text dimColor>Raster is terminal-only; {surface} gets an interactive Svg with SMIL animation and :hover.</Text>
<Svg source={plasmaSvg()} alt="animated gradient" width={480} height={160} isInteractive />
</Box>
)
}
function drawImage($: EngineInterface, surface: RenderSurface, width: number) {
if (surface === 'terminal') {
const { Box, Text, Image } = $.ui.resolve({ surface, component: 'Pane' })
const columns = Math.min(width - 2, 64)
return (
<Box flexDirection="column">
<Text dimColor>An Image from RGBA bytes ({JULIA_W}x{JULIA_H} px) scaled into {columns}x16 cells; kitty graphics where supported, alt text elsewhere. Blitted at 5 fps.</Text>
<Image key="julia" source={juliaSource(frame / 20)} columns={columns} rows={16} alt="[a Julia set; this terminal draws no images]" />
</Box>
)
}
const { Box, Text, Svg } = $.ui.resolve({ surface, component: 'Pane' })
return (
<Box flexDirection="column">
<Text dimColor>Image is terminal-only; {surface} gets a static Svg.</Text>
<Svg source={plasmaSvg()} alt="gradient" width={320} height={120} />
</Box>
)
}
async function drawClient($: EngineInterface, surface: RenderSurface) {
const posts = await read($, canvasPosts)
if (surface === 'terminal' || surface === 'desktop') {
const { Box, Text, Client } = $.ui.resolve({ surface, component: 'Pane' })
return (
<Box flexDirection="column" gap={1}>
<Text dimColor>
A Client runs ./canvas.tsx on the drawing thread: its own state, frame clock, keys and pointer. Each stroke posts to the
hooks module (ui.message). Posts received: {posts}
</Text>
<Client key="canvas" module="./canvas.tsx" props={{ title: 'canvas.tsx' }} height={11} />
</Box>
)
}
const { Text } = $.ui.resolve({ surface, component: 'Pane' })
return <Text>{surface} has no Client element.</Text>
}
async function drawEngine($: EngineInterface, surface: RenderSurface) {
const { Box, Text, Button } = $.ui.resolve({ surface, component: 'Pane' })
const results = await read($, probes)
const run = (title: string, fn: () => Promise<string>) => () => void probe($, title, fn)
return (
<Box flexDirection="column" gap={1}>
<Text dimColor>Each button calls $ and lists the answer below. Model buttons spend tokens; speak talks out loud.</Text>
<Box gap={1} flexWrap="wrap">
<Button key="p-session" label="session.*" onPress={run('session', async () => {
const [id, cwd, root, model, turns, repo, here, all, version] = await Promise.all([
$.session.id(), $.session.cwd(), $.session.root(), $.session.model(), $.session.turns(),
$.session.repo(), $.session.surface(), $.session.surfaces(), $.session.version(),
])
return `id ${id.slice(0, 8)} · model ${model} · turns ${turns}\ncwd ${cwd} · root ${root}\nrepo ${repo ? `${repo.name ?? '?'} @ ${repo.root}` : 'none'}\nsurface ${here} of [${all.join(', ')}] · v${version.version} built ${version.builtAt ?? '?'}`
})} />
<Button key="p-usage" label="session.usage" onPress={run('usage', async () => {
const u = await $.session.usage()
const limits = u.rateLimits.map(r => `${r.kind} ${r.percentUsed}%`).join(', ')
return `context ${u.context.tokens ?? '?'}/${u.context.window} (${u.context.percent ?? '?'}%) · limits ${limits || 'none'}`
})} />
<Button key="p-registry" label="tool/command/agent/config.list" onPress={run('registries', async () => {
const [tools, commands, agents, rows] = await Promise.all([$.tool.list(), $.command.list(), $.agent.list(), $.config.list()])
const mine = tools.filter(t => t.name.startsWith('mcp__api-tour')).map(t => t.name)
return `${tools.length} tools (mine: ${mine.join(', ') || 'none'}) · ${commands.length} commands · ${agents.length} agents · ${rows.length} config rows`
})} />
<Button key="p-fs" label="fs.* (writes /tmp)" onPress={run('fs', async () => {
const found = await $.fs.ancestors({ names: ['CLAUDE.md', 'AGENTS.md'] })
const here = await $.fs.stat('.', { resolve: true })
const entries = await $.fs.list('.')
const path = '/tmp/api-tour-probe.txt'
await $.fs.write(path, `written ${new Date().toISOString()}`)
const back = await $.fs.read(path)
return `ancestors: ${found.map(f => `${f.directory}/${f.name}`).join(', ') || 'none'}\n. → ${here.realPath} (${entries.length} entries)\nwrote+read ${path}: ${back}`
})} />
<Button key="p-store" label="store" onPress={run('store', async () => {
const opens = ((await $.store.get('probes')) as number | undefined) ?? 0
await $.store.set('probes', opens + 1)
return `probes run across sessions: ${opens + 1} · keys ${(await $.store.keys()).join(', ')}`
})} />
<Button key="p-settings" label="settings (keys only)" onPress={run('settings', async () => {
const s = await $.settings.read()
return `top-level keys: ${Object.keys(s).join(', ')}`
})} />
<Button key="p-env" label="env.get" onPress={run('env', async () => `TERM=${await $.env.get('TERM')} TERM_PROGRAM=${await $.env.get('TERM_PROGRAM')}`)} />
<Button key="p-run" label="process.run" onPress={run('process.run', async () => {
const r = await $.process.run(['uname', '-sm'], { timeoutMs: 5000 })
return `exit ${r.exitCode}: ${r.stdout.trim()}`
})} />
<Button key="p-spawn" label="process.spawn (stream)" onPress={run('process.spawn', async () => {
const stream = $.process.spawn({ argv: ['sh', '-c', 'for i in 1 2 3 4; do echo tick $i; sleep 0.4; done'] })
const lines: string[] = []
for await (const chunk of stream) {
lines.push(chunk.text.trim())
await setProbe($, { title: 'process.spawn', text: `streaming… ${lines.join(' | ')}`, isError: false })
}
const end = await stream.result
return `${lines.join(' | ')} → code ${end.code}`
})} />
<Button key="p-http" label="http.fetch (GitHub zen)" onPress={run('http.fetch', async () => {
const r = await $.http.fetch('https://api.github.com/zen', { headers: { 'user-agent': 'api-tour' } })
return `${r.status}: ${r.text.trim()}`
})} />
<Button key="p-complete" label="model.complete (tokens)" onPress={run('model.complete', async () => {
const r = await $.model.complete({ model: HAIKU, prompt: 'Write a three-line haiku about terminal UI mods. Only the haiku.', maxTokens: 120, timeoutMs: 30_000 })
return r.isAnswered ? `${r.text.trim()}\n(${r.usage.input_tokens} in / ${r.usage.output_tokens} out)` : `not answered: ${r.reason}`
})} />
<Button key="p-fork" label="model.fork (tokens)" onPress={run('model.fork', async () => {
const r = await $.model.fork({ prompt: 'In one sentence, what is this session about?' })
if (!r.isAnswered) return `not answered: ${r.reason}`
return `${r.text.trim()}\n(cache read ${r.usage.cache_read_input_tokens} tokens)`
})} />
<Button key="p-classify" label="model.classify (tokens)" onPress={run('model.classify', async () => {
const text = (await read($, draft)) || 'How do I draw a sparkline?'
const label = await $.model.classify(text, ['question', 'command', 'statement'], { model: HAIKU })
return `"${text}" → ${label ?? 'no label'}`
})} />
<Button key="p-speak" label="audio.speak (talks)" onPress={run('audio.speak', async () => {
const r = await $.audio.speak('Hello from the API tour.')
return `spoken via ${r.via}`
})} />
<Button key="p-ui" label="toast/status/log/notice" onPress={run('ui', async () => {
$.ui.toast('a toast from api-tour', { timeoutMs: 4000 })
$.ui.status('api-tour: status line entry')
$.ui.log('api-tour: a line logged to the transcript', { to: 'transcript' })
const panes = await $.ui.panes()
return `toast + status + transcript log sent · panes: ${panes.map(p => `${p.id}${p.isFocused ? '*' : ''}`).join(', ')}`
})} />
<Button key="p-append" label="session.append (notice)" onPress={run('session.append', async () => {
const r = await $.session.append({ message: { type: 'system', content: [{ type: 'text', text: 'api-tour appended this notice; the model never reads it.' }] } })
return JSON.stringify(r).slice(0, 160)
})} />
<Button key="p-submit" label="prompt.submit (starts a turn)" variant="primary" onPress={run('prompt.submit', async () => {
await $.prompt.submit({ text: "Reply with exactly: 'api-tour says hi'. Then roll 2d6 with the api-tour dice tool." })
return 'queued; the turn starts once the session is idle'
})} />
<Button key="p-clear" label="clear results" dimColor onPress={() => void update($, probes, () => [])} />
</Box>
{results.map(p => (
<Box flexDirection="column" borderStyle="single" borderColor={p.isError ? 'red' : 'gray'} paddingX={1}>
<Text bold color={p.isError ? 'red' : undefined}>{p.title}</Text>
<Text>{p.text}</Text>
</Box>
))}
</Box>
)
}
async function drawEvents($: EngineInterface, surface: RenderSurface) {
const { Box, Text, Button } = $.ui.resolve({ surface, component: 'Pane' })
const [lines, s, seen, admitted] = await Promise.all([read($, log), read($, stats), read($, sections), read($, plugins)])
return (
<Box flexDirection="column" gap={1}>
<Box gap={2} flexWrap="wrap">
<Text>session.append by door:</Text>
{Object.entries(s.doors).map(([door, n]) => (
<Text color="cyan">{door} {n}</Text>
))}
</Box>
<Text>
prompt.compose ({seen.length} sections): {seen.map(x => `${x.id}:${x.chars}${x.scope === 'session' ? '*' : ''}`).join(' ')}
</Text>
<Text>plugin.register: {admitted.join(', ') || '(none seen since load)'}</Text>
<Box gap={1}>
<Button key="scroll-end" label="$.ui.scroll → end" onPress={() => void $.ui.scroll({ to: 'end', in: PANE })} />
<Button key="clear-log" label="clear log" dimColor onPress={() => void update($, log, () => [])} />
<Button key="show-band" label="show band" dimColor onPress={() => void update($, isBandHidden, () => false)} />
</Box>
<Box flexDirection="column">
{lines.slice().reverse().map(l => (
<Box gap={1}>
<Text dimColor>{new Date(l.at).toLocaleTimeString()}</Text>
<Text color="yellow">{l.event}</Text>
<Text wrap="truncate-end">{l.text}</Text>
</Box>
))}
</Box>
</Box>
)
}
// ------------------------------------------------------------------ helpers that take $
async function selectTab($: EngineInterface, next: TourTab) {
liveTab = next
await update($, tab, () => next)
}
async function note($: EngineInterface, event: string, text: string) {
const at = await $.clock.now()
const line: TourLine = { at, event, text }
await update($, log, lines => [...lines, line].slice(-60))
}
async function setProbe($: EngineInterface, p: TourProbe) {
await update($, probes, list => [p, ...list.filter(x => x.title !== p.title)].slice(0, 12))
}
async function probe($: EngineInterface, title: string, fn: () => Promise<string>) {
await setProbe($, { title, text: 'running…', isError: false })
try {
await setProbe($, { title, text: await fn(), isError: false })
} catch (err) {
await setProbe($, { title, text: err instanceof Error ? `${err.name}: ${err.message}` : String(err), isError: true })
}
}
async function animate($: EngineInterface) {
frame += 1
if (liveTab === 'raster') {
await $.ui.blit({ requestId: PANE, key: 'plasma', cells: plasmaCells(lastPlasmaColumns, 12, frame / 10) })
} else if (liveTab === 'image' && frame % 2 === 0) {
await $.ui.blit({ requestId: PANE, key: 'julia', source: juliaSource(frame / 20) })
}
}
// ------------------------------------------------------------------ pixels
let lastPlasmaColumns = 60
const JULIA_W = 256
const JULIA_H = 128
const DEFAULT_COLOR = 0x01000000
function b64(bytes: Uint8Array): string {
const native = bytes as Uint8Array & { toBase64?: () => string }
if (native.toBase64) return native.toBase64()
let s = ''
for (const b of bytes) s += String.fromCharCode(b)
return btoa(s)
}
function cells(columns: number, rows: number, at: (x: number, y: number) => [number, number, number]): string {
const words = new Uint32Array(columns * rows * 3)
for (let y = 0; y < rows; y++) {
for (let x = 0; x < columns; x++) {
const [glyph, fg, bg] = at(x, y)
const i = (y * columns + x) * 3
words[i] = glyph
words[i + 1] = fg
words[i + 2] = bg
}
}
return b64(new Uint8Array(words.buffer))
}
// Hue quantized to 32 steps so the frame stays within the terminal's 1024 distinct color pairs.
function hue(degrees: number): number {
const h = ((Math.round(degrees / 11.25) * 11.25) % 360 + 360) % 360
const x = 1 - Math.abs(((h / 60) % 2) - 1)
const [r, g, b] =
h < 60 ? [1, x, 0] : h < 120 ? [x, 1, 0] : h < 180 ? [0, 1, x] : h < 240 ? [0, x, 1] : h < 300 ? [x, 0, 1] : [1, 0, x]
return (Math.round(r * 255) << 16) | (Math.round(g * 255) << 8) | Math.round(b * 255)
}
function plasmaAt(x: number, y: number, t: number): number {
const v = Math.sin(x / 7 + t) + Math.sin(y / 5 + t * 1.3) + Math.sin((x + y) / 9 + t * 0.7) + Math.sin(Math.hypot(x - 30, y - 12) / 5 - t)
return hue(((v + 4) / 8) * 360 + t * 30)
}
function plasmaCells(columns: number, rows: number, t: number): string {
lastPlasmaColumns = columns
return cells(columns, rows, (x, y) => [0x2580, plasmaAt(x, y * 2, t), plasmaAt(x, y * 2 + 1, t)])
}
function rampCells(columns: number): string {
const braille = '⠁⠃⠇⡇⡏⡟⡿⣿'
return cells(columns, 2, (x, y) =>
y === 0
? [0x2588, hue((x / columns) * 360), DEFAULT_COLOR]
: [braille.charCodeAt(x % braille.length), hue(360 - (x / columns) * 360), 0x101010],
)
}
function sparkCells(buckets: readonly number[]): string {
const levels = ' ▁▂▃▄▅▆▇█'
const max = Math.max(1, ...buckets)
return cells(buckets.length, 1, x => {
const v = buckets[x] ?? 0
const level = Math.max(1, Math.round((v / max) * 8))
return [levels.charCodeAt(level), hue(120 - (v / max) * 120), DEFAULT_COLOR]
})
}
function juliaSource(t: number) {
const bytes = new Uint8Array(JULIA_W * JULIA_H * 4)
const cr = 0.7885 * Math.cos(t)
const ci = 0.7885 * Math.sin(t)
for (let py = 0; py < JULIA_H; py++) {
for (let px = 0; px < JULIA_W; px++) {
let zr = ((px / JULIA_W) * 2 - 1) * 1.6
let zi = ((py / JULIA_H) * 2 - 1) * 0.9
let n = 0
while (n < 48 && zr * zr + zi * zi < 4) {
const next = zr * zr - zi * zi + cr
zi = 2 * zr * zi + ci
zr = next
n++
}
const rgb = n === 48 ? 0 : hue(200 + n * 12)
const i = (py * JULIA_W + px) * 4
bytes[i] = (rgb >> 16) & 0xff
bytes[i + 1] = (rgb >> 8) & 0xff
bytes[i + 2] = rgb & 0xff
bytes[i + 3] = 255
}
}
return { rgba: b64(bytes), width: JULIA_W, height: JULIA_H }
}
function sparkSvg(buckets: readonly number[]): string {
const max = Math.max(1, ...buckets)
const points = buckets.map((v, i) => `${(i / (buckets.length - 1)) * 120},${16 - (v / max) * 14 - 1}`).join(' ')
return `<svg xmlns="http://www.w3.org/2000/svg" width="120" height="16"><polyline points="${points}" fill="none" stroke="#3c9" stroke-width="1.5"/></svg>`
}
function plasmaSvg(): string {
return `<svg xmlns="http://www.w3.org/2000/svg" width="480" height="160" viewBox="0 0 480 160">
<defs><linearGradient id="g" x1="0" x2="1"><stop offset="0" stop-color="#f0f"><animate attributeName="stop-color" values="#f0f;#0ff;#ff0;#f0f" dur="4s" repeatCount="indefinite"/></stop><stop offset="1" stop-color="#0ff"/></linearGradient>
<style>circle:hover{fill:#fff}</style></defs>
<rect width="480" height="160" fill="url(#g)"/>
<circle cx="80" cy="80" r="30" fill="#222"><title>hover me</title><animate attributeName="cx" values="80;400;80" dur="3s" repeatCount="indefinite"/></circle>
</svg>`
}
export type TourTab = 'elements' | 'hover' | 'input' | 'raster' | 'image' | 'client' | 'engine' | 'events'
/** One line of the Events tab's log: which event fired and what it carried. */
export type TourLine = { at: number; event: string; text: string }
/** The outcome of one Engine-tab probe; a re-run replaces the entry with the same title. */
export type TourProbe = { title: string; text: string; isError: boolean }
export type TourStats = {
prompts: number
tools: number
turns: number
turnTools: number
lastTurnMs: number
/** session.append rows counted by door. */
doors: Record<string, number>
}
/** A system-prompt section as prompt.compose answered it, text reduced to its length. */
export type TourSection = { id: string; chars: number; scope: string }
declare module 'claude-code' {
interface PluginState {
'api-tour': {
tab: TourTab
log: TourLine[]
stats: TourStats
/** Tool calls per 10-second bucket, oldest first. */
activity: number[]
probes: TourProbe[]
draft: string
choice: string
isBandHidden: boolean
sections: TourSection[]
plugins: string[]
canvasPosts: number
}
}
}
import { expect, test } from 'claude-code/testing'
const TABS = [
['elements', 'b-primary'],
['hover', 'hov-card'],
['input', 'draft'],
['raster', 'plasma'],
['image', 'julia'],
['client', 'canvas'],
['engine', 'p-session'],
['events', 'scroll-end'],
] as const
const VIEWPORT = { columns: 160, rows: 50, isFullscreen: true }
test('every tab draws a valid tree on terminal and desktop', async $ => {
for (const surface of ['terminal', 'desktop'] as const) {
const ui = await $.ui.mount({
plugin: 'api-tour',
surface,
component: 'Pane',
requestId: 'api-tour',
viewport: VIEWPORT,
props: {
title: 'API tour',
isFocused: true,
bodyColumns: 100,
placement: 'dock',
scroll: { offset: 0, bodyRows: 48 },
view: {},
},
})
for (const [tab, marker] of TABS) {
await ui.press({ key: `tab-${tab}` })
await ui.redraw()
// Raster and Image are terminal-only; desktop draws an Svg in their place.
const isTerminalOnly = marker === 'plasma' || marker === 'julia'
if (surface === 'terminal' || !isTerminalOnly) expect(await ui.find({ key: marker })).toBeDefined()
}
await ui.unmount()
}
})
test('band, turn duration and card draw on terminal and desktop', { options: { rewriteEngineSites: true } }, async $ => {
for (const surface of ['terminal', 'desktop'] as const) {
const band = await $.ui.mount({
plugin: 'api-tour',
surface,
component: 'AbovePrompt',
viewport: VIEWPORT,
props: { hasSurvey: false, isWorking: false, maxRows: 3, bodyColumns: 100, scroll: { offset: 0, bodyRows: 3 }, view: {} },
})
expect(await band.find({ key: 'hide' })).toBeDefined()
await band.unmount()
const duration = await $.ui.mount({
plugin: 'api-tour',
surface,
component: 'TurnDuration',
viewport: VIEWPORT,
props: { word: 'Cooked', durationMs: 4200 },
})
await duration.unmount()
const card = await $.ui.mount({
plugin: 'api-tour',
surface,
component: 'CommandOutput',
viewport: VIEWPORT,
props: { command: 'tour-card', args: '', text: 'card', isErrored: false },
})
expect(await card.find({ key: 'open' })).toBeDefined()
await card.unmount()
}
})
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment