|
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>` |
|
} |