Mod: Intelligent UI for Claude Code. Claude answers with charts, tables, cards, diagrams, maps and live calculators (```ui blocks or the show tool), drawn…

Mod. Intelligent UI for Claude Code, after Open Intelligent UI and ChatGPT's Intelligent UI: Claude answers with native components instead of prose when a picture is clearer, and you can work them without another model call.
ui block in the reply, one JSON component per line (finished lines draw while the rest streams; the unfinished part shows drawing…), or the mcp__picture-this__show tool with {title, components} when the picture is the whole answer. Older ``viz blocks still draw.bars, line, scatter (or quadrant), pie, table, tree, flow, graph, card, stat, map (schematic: numbered stops, no tiles), calc, choice, send. On desktop, VS Code and mobile charts, flows, graphs and maps are SVG and tables are markdown; the terminal gets text forms of everything.calc inputs move with a slider (terminal and desktop; drag it, or click and use ←/→, PgUp/PgDn, Home/End) and with − / + (every surface); outputs recompute from their formulas (a small safe evaluator: numbers, input ids, + - * / % ^, min max abs round floor ceil sqrt pow log exp; no eval). choice buttons pick an option; tables sort by a column.send button fills {id}s from the block's controls and puts the prompt in your input box. It never submits; you press Enter.matchmaker is on it skips this, since matchmaker already classifies the prompt and its hint points here: one call per prompt either way. Option classify: false turns it off.too-long-didnt-read publishes summary charts in its own cards state and picture-this draws each under the answer it belongs to. It reads picture-this.ready to know the charts will be drawn.Not possible in a mod: real map tiles, 3D, or HTML and JavaScript written by Claude (a mod draws only the host's own elements). For those, the show-dont-tell skill falls back to a file in the side panel.
Skill: show-dont-tell, which says when and how to show instead of tell.
hooks/register.tsx 450 lines1import { atom, read, update } from 'claude-code'
2import type { Hook, Register } from 'claude-code'
3
4import type { CalcInput, CalcOutput, Component, Sort } from '../types'
5
6import { evaluate, fmtNum } from './formula'
7import { answerKey, hasBlock, normalize, splitBlocks } from './spec'
8import { svgFor } from './svg'
9import { sliderBar, tableLines, sortedRows, textLines, titleOf } from './text'
10
11const values = atom({ plugin: 'picture-this', key: 'values' } as const, {} as Record<string, number | string>)
12const sorts = atom({ plugin: 'picture-this', key: 'sorts' } as const, {} as Record<string, Sort>)
13const shown = atom({ plugin: 'picture-this', key: 'shown' } as const, {} as Record<string, Component[]>)
14const noClient = atom({ plugin: 'picture-this', key: 'noClient' } as const, false)
15const ready = atom({ plugin: 'picture-this', key: 'ready' } as const, false)
16
17export const TOOL = 'mcp__picture-this__show'
18const MAX_KEYS = 400
19const MAX_SHOWN = 50
20
21type Engine = Parameters<Hook<'session.start'>>[0]
22type RenderEvent = Parameters<Hook<'ui.render'>>[1]
23
24export const RULE = `picture-this draws UI natively in the thread. When a picture beats prose (comparisons, trends, shares, structure, flows, itineraries, what-if numbers), put a fenced \`\`\`ui block in the reply with ONE JSON object per line, or call the \`${TOOL}\` tool with {title?, components:[...]}. Components:
25{"type":"bars","title":"…","values":{"A":3,"B":5},"unit":"ms"} · {"type":"line","x":["Jan","Feb"],"series":{"Revenue":[1,2]}} · {"type":"scatter","x":"effort","y":"impact","points":[{"label":"A","x":2,"y":8}],"quadrant":true} · {"type":"pie","values":{"A":60,"B":40}}
26{"type":"table","columns":["Name","Score"],"rows":[["A",3]]} · {"type":"tree","nodes":[{"label":"x","children":[{"label":"y"}]}]} · {"type":"flow","lanes":[["plan","build","ship"]]} · {"type":"graph","nodes":[{"id":"api","label":"API"},{"id":"db","label":"DB"}],"edges":[["api","db","reads"]]}
27{"type":"card","title":"…","body":"…","fields":{"k":"v"},"tags":["t"]} · {"type":"stat","label":"p95","value":"120 ms","delta":"-8%"} · {"type":"map","stops":[{"label":"Cork","lat":51.9,"lon":-8.5,"note":"day 1"}]} (schematic, not a real map)
28Live controls (they run locally, no model call): {"type":"calc","title":"…","inputs":[{"id":"rate","label":"Rate","value":5,"min":0,"max":20,"step":0.5,"unit":"%"}],"outputs":[{"id":"total","label":"Total","formula":"1000*(1+rate/100)^years","digits":2}]} (formulas: numbers, input ids, + - * / % ^, min max abs round floor ceil sqrt pow log exp) · {"type":"choice","id":"plan","label":"Plan","options":["A","B"]} · {"type":"send","label":"Use these","prompt":"Go with {plan} at {rate}%"} (a button that puts the prompt, with {id}s filled from the controls in the same block, in the user's input box; it never sends by itself).
29Rules: one idea per block, short labels, a line of prose around it; say when numbers are estimates or sample data and never invent live data; every control must change an output; keep a text answer that stands without the picture.`
30
31export const LABELS = ['prose', 'table', 'chart', 'diagram', 'calculator', 'map', 'choice'] as const
32export const HINT: Record<string, string> = {
33 table: 'picture-this: exact values or records: answer with a ```ui table component.',
34 chart: 'picture-this: magnitudes, trends or shares: answer with a ```ui chart (bars, line, scatter or pie).',
35 diagram: 'picture-this: structure or steps: answer with a ```ui flow, tree or graph.',
36 calculator: 'picture-this: a what-if calculation: answer with a ```ui calc (inputs + formula outputs) and a send button.',
37 map: 'picture-this: places or a route: answer with a ```ui map (schematic stops with notes).',
38 choice: 'picture-this: the user picks between options: cards for each, a ```ui choice and a send button.',
39}
40
41export const shouldClassify = (text: string, hasOrigin: boolean, minChars = 40): boolean => {
42 const t = text.trim()
43 return !hasOrigin && !t.startsWith('/') && t.length >= minChars
44}
45
46type Vars = Record<string, number | string>
47
48const numbersOf = (vars: Vars): Record<string, number> =>
49 Object.fromEntries(Object.entries(vars).filter((kv): kv is [string, number] => typeof kv[1] === 'number'))
50
51const inputValue = (raw: number | string | undefined, fallback: number): number => (typeof raw === 'number' ? raw : fallback)
52
53/** The values a block's controls hold now: calc inputs (as moved), choices, then each output with an id. */
54export const varsOf = (components: Component[], scope: string, vals: Vars): Vars => {
55 const vars: Vars = {}
56 const calcs = components.filter((c): c is Extract<Component, { type: 'calc' }> => c.type === 'calc')
57 for (const c of calcs) for (const i of c.inputs) vars[i.id] = inputValue(vals[`${scope}/${i.id}`], i.value)
58 for (const c of components) if (c.type === 'choice') vars[c.id] = vals[`${scope}/${c.id}`] ?? c.value ?? ''
59 const outputs = calcs.flatMap(c => c.outputs).filter(o => o.id)
60 for (const o of outputs) {
61 const v = evaluate(o.formula, numbersOf(vars))
62 if (v !== undefined) vars[o.id as string] = Number(v.toFixed(o.digits ?? 6))
63 }
64 return vars
65}
66
67/** The send button's prompt with {id}s filled; an id with no value stays as written. */
68export const fillPrompt = (template: string, vars: Vars): string =>
69 template.replaceAll(/\{(\w+)\}/g, (m, id: string) => {
70 const v = vars[id]
71 if (v === undefined || v === '') return m
72 return typeof v === 'number' ? fmtNum(v) : v
73 })
74
75const trim = <T,>(m: Record<string, T>, n: number): Record<string, T> => Object.fromEntries(Object.entries(m).slice(-n))
76
77const mdCell = (c: string | number) => (typeof c === 'number' ? fmtNum(c) : c).replaceAll('|', String.raw`\|`)
78
79const arrowOf = (sort: Sort) => (sort.desc ? '↓' : '↑')
80const headerOf = (col: string, i: number, sort?: Sort) => (sort?.col === i ? `${col} ${arrowOf(sort)}` : col)
81const withUnit = (text: string, unit?: string) => (unit ? `${text} ${unit}` : text)
82
83export const tableMarkdown = (columns: string[], rows: (string | number)[][], sort?: Sort): string => {
84 const head = columns.map((c, i) => headerOf(c, i, sort))
85 const rule = columns.map(() => ' --- ').join('|')
86 const body = sortedRows(rows, sort).map(r => '| ' + r.map(mdCell).join(' | ') + ' |')
87 return ['| ' + head.map(mdCell).join(' | ') + ' |', '|' + rule + '|', ...body].join('\n')
88}
89
90/** Next sort for a press on column `col`: ascending, then descending, then none. */
91export const nextSort = (cur: Sort | undefined, col: number): Sort | undefined => {
92 if (cur?.col !== col) return { col, desc: false }
93 return cur.desc ? undefined : { col, desc: true }
94}
95
96/** React keys from labels that stay unique when a label repeats. */
97export const keysFor = (labels: string[]): string[] => {
98 const seen = new Map<string, number>()
99 return labels.map(l => {
100 const n = (seen.get(l) ?? 0) + 1
101 seen.set(l, n)
102 return n === 1 ? l : `${l}~${n}`
103 })
104}
105
106async function setValue($: Engine, key: string, v: number | string): Promise<void> {
107 await update($, values, m => trim({ ...m, [key]: v }, MAX_KEYS))
108}
109
110async function cycleSort($: Engine, k: string, col: number): Promise<void> {
111 await update($, sorts, m => {
112 const n = nextSort(m[k], col)
113 const rest = Object.fromEntries(Object.entries(m).filter(([kk]) => kk !== k))
114 return trim(n ? { ...rest, [k]: n } : rest, MAX_KEYS)
115 })
116}
117
118async function resetInputs($: Engine, keys: string[]): Promise<void> {
119 await update($, values, m => Object.fromEntries(Object.entries(m).filter(([kk]) => !keys.includes(kk))))
120}
121
122async function sendPrompt($: Engine, text: string): Promise<void> {
123 await $.prompt.fill({ text })
124 $.ui.toast('picture-this: in your prompt; press Enter to send', { timeoutMs: 4000 })
125}
126
127// What every renderer needs besides `$`: the surface's elements and the block's state.
128type Ctx = { el: Record<string, any>; scope: string; vars: Vars; sorts: Record<string, Sort>; terminal: boolean; clientOk: boolean }
129
130const linesOf = (ctx: Ctx, k: string, ls: string[]) => {
131 const { Box, Text } = ctx.el
132 return (
133 <Box key={k} flexDirection="column">
134 {keysFor(ls).map((key, j) => (
135 <Text key={key}>{ls[j]}</Text>
136 ))}
137 </Box>
138 )
139}
140
141const headOf = (ctx: Ctx, k: string, c: Component) => {
142 const title = titleOf(c)
143 return title ? (
144 <ctx.el.Text key={`${k}-h`} bold>
145 {title}
146 </ctx.el.Text>
147 ) : null
148}
149
150function drawTable($: Engine, ctx: Ctx, k: string, c: Extract<Component, { type: 'table' }>) {
151 const { Box, Text, Button, Markdown } = ctx.el
152 const sort = ctx.sorts[k]
153 const body = ctx.terminal ? linesOf(ctx, `${k}-b`, tableLines(c.columns, c.rows, sort)) : <Markdown key={`${k}-b`} text={tableMarkdown(c.columns, c.rows, sort)} />
154 const cols = c.columns.slice(0, 6)
155 return (
156 <Box key={k} flexDirection="column">
157 {headOf(ctx, k, c)}
158 {body}
159 {c.rows.length > 1 && (
160 <Box key={`${k}-s`} flexWrap="wrap">
161 <Text key="l" dimColor>
162 sort:{' '}
163 </Text>
164 {keysFor(cols).map((ck, ci) => (
165 <Button key={`sort-${ck}`} label={headerOf(cols[ci] ?? ck, ci, sort)} plain onPress={() => cycleSort($, k, ci)} />
166 ))}
167 </Box>
168 )}
169 </Box>
170 )
171}
172
173function drawInput($: Engine, ctx: Ctx, inp: CalcInput) {
174 const { Box, Text, Button, Client } = ctx.el
175 const key = `${ctx.scope}/${inp.id}`
176 const v = ctx.vars[inp.id] as number
177 const step = (d: number) => setValue($, key, Math.min(inp.max, Math.max(inp.min, Number((v + d).toFixed(6)))))
178 const bar = ctx.clientOk ? (
179 <Client key={`slider:${key}`} module="./slider.tsx" props={{ value: v, min: inp.min, max: inp.max, step: inp.step }} width={20} />
180 ) : (
181 <Text key="bar" color="cyan">
182 {sliderBar(v, inp.min, inp.max)}
183 </Text>
184 )
185 return (
186 <Box key={`in-${inp.id}`} flexWrap="wrap">
187 <Text key="l">{inp.label + ': '}</Text>
188 <Text key="v" bold>
189 {withUnit(fmtNum(v), inp.unit) + ' '}
190 </Text>
191 {bar}
192 <Button key={`dn-${inp.id}`} label="−" onPress={() => step(-inp.step)} />
193 <Button key={`up-${inp.id}`} label="+" onPress={() => step(inp.step)} />
194 </Box>
195 )
196}
197
198function drawOutput(ctx: Ctx, o: CalcOutput, key: string) {
199 const { Text } = ctx.el
200 const out = evaluate(o.formula, numbersOf(ctx.vars))
201 const shown = out === undefined ? '—' : withUnit(fmtNum(out, o.digits), o.unit)
202 return (
203 <Text key={key}>
204 {o.label + ': '}
205 <Text key="n" bold color={out === undefined ? 'yellow' : 'green'}>
206 {shown}
207 </Text>
208 </Text>
209 )
210}
211
212function drawCalc($: Engine, ctx: Ctx, k: string, c: Extract<Component, { type: 'calc' }>) {
213 const { Box, Button } = ctx.el
214 const inputKeys = c.inputs.map(inp => `${ctx.scope}/${inp.id}`)
215 const outKeys = keysFor(c.outputs.map(o => `out-${o.id ?? o.label}`))
216 return (
217 <Box key={k} flexDirection="column">
218 {headOf(ctx, k, c)}
219 {c.inputs.map(inp => drawInput($, ctx, inp))}
220 {c.outputs.map((o, j) => drawOutput(ctx, o, outKeys[j] ?? o.label))}
221 <Box key="reset-row">
222 <Button key="reset" label="Reset" plain dimColor onPress={() => resetInputs($, inputKeys)} />
223 </Box>
224 </Box>
225 )
226}
227
228function drawChoice($: Engine, ctx: Ctx, k: string, c: Extract<Component, { type: 'choice' }>) {
229 const { Box, Button } = ctx.el
230 const cur = ctx.vars[c.id]
231 return (
232 <Box key={k} flexDirection="column">
233 {headOf(ctx, k, c)}
234 <Box key="opts" flexWrap="wrap">
235 {c.options.map(o => (
236 <Button key={`o-${o}`} label={`${cur === o ? '●' : '○'} ${o}`} variant={cur === o ? 'primary' : 'secondary'} onPress={() => setValue($, `${ctx.scope}/${c.id}`, o)} />
237 ))}
238 </Box>
239 </Box>
240 )
241}
242
243function drawSend($: Engine, ctx: Ctx, k: string, c: Extract<Component, { type: 'send' }>) {
244 const { Box, Text, Button } = ctx.el
245 const text = fillPrompt(c.prompt, ctx.vars)
246 return (
247 <Box key={k} flexDirection="column">
248 <Button key="send" label={`↩ ${c.label}`} variant="primary" onPress={() => sendPrompt($, text)} />
249 <Text key="p" dimColor>
250 {text}
251 </Text>
252 </Box>
253 )
254}
255
256function drawStat(ctx: Ctx, k: string, c: Extract<Component, { type: 'stat' }>) {
257 const { Box, Text } = ctx.el
258 const delta = c.delta ? (
259 <Text key="d" color={c.delta.startsWith('-') ? 'red' : 'green'}>
260 {' ' + c.delta}
261 </Text>
262 ) : null
263 return (
264 <Box key={k} flexDirection="column">
265 <Text key="l" dimColor>
266 {c.label}
267 </Text>
268 <Text key="v" bold>
269 {c.value}
270 {delta}
271 </Text>
272 {c.note ? (
273 <Text key="n" dimColor>
274 {c.note}
275 </Text>
276 ) : null}
277 </Box>
278 )
279}
280
281function drawStatic(ctx: Ctx, k: string, c: Component) {
282 const { Box, Svg } = ctx.el
283 const svg = !ctx.terminal && Svg ? svgFor(c) : undefined
284 if (svg) return <Svg key={k} source={svg} alt={titleOf(c) ?? c.type} />
285 return (
286 <Box key={k} flexDirection="column">
287 {headOf(ctx, k, c)}
288 {linesOf(ctx, `${k}-b`, textLines(c, ctx.sorts[k]))}
289 </Box>
290 )
291}
292
293function drawPiece($: Engine, ctx: Ctx, c: Component, k: string) {
294 switch (c.type) {
295 case 'table':
296 return drawTable($, ctx, k, c)
297 case 'calc':
298 return drawCalc($, ctx, k, c)
299 case 'choice':
300 return drawChoice($, ctx, k, c)
301 case 'send':
302 return drawSend($, ctx, k, c)
303 case 'stat':
304 return drawStat(ctx, k, c)
305 default:
306 return drawStatic(ctx, k, c)
307 }
308}
309
310const skippedText = (n: number) => (n === 1 ? '(1 piece could not be drawn)' : `(${n} pieces could not be drawn)`)
311
312/** One block of components, with their live controls; `scope` keeps each block's values apart. */
313async function drawBlock($: Engine, e: RenderEvent, scope: string, components: Component[], pending: boolean, skipped: number) {
314 const el = $.ui.resolve(e) as unknown as Record<string, any>
315 const { Box, Text } = el
316 // Client runs on terminal and desktop only; elsewhere a text bar and the − / + buttons.
317 const clientOk = (e.surface === 'terminal' || e.surface === 'desktop') && el.Client !== undefined && !(await read($, noClient))
318 const ctx: Ctx = { el, scope, vars: varsOf(components, scope, await read($, values)), sorts: await read($, sorts), terminal: e.surface === 'terminal', clientOk }
319 return (
320 <Box key={`blk-${scope}`} flexDirection="column" gap={1}>
321 {components.map((c, i) => drawPiece($, ctx, c, `${scope}#${i}`))}
322 {pending && (
323 <Text key="pending" dimColor>
324 drawing…
325 </Text>
326 )}
327 {skipped > 0 && !pending && (
328 <Text key="skipped" dimColor>
329 {skippedText(skipped)}
330 </Text>
331 )}
332 </Box>
333 )
334}
335
336/** A ```ui or ```viz answer drawn as prose and pictures, with any too-long-didnt-read card for it underneath. */
337async function drawMessage($: Engine, e: RenderEvent, text: string, next: () => Promise<unknown>) {
338 const key = answerKey(text)
339 const card = ((await $.state.get({ plugin: 'too-long-didnt-read', key: 'cards' })).value ?? []).findLast(c => c.key === key)
340 const { Box, Text, Markdown } = $.ui.resolve(e) as unknown as Record<string, any>
341
342 let body: unknown
343 if (hasBlock(text)) {
344 const segments = splitBlocks(text)
345 const keys = keysFor(segments.map(s => (s.kind === 'text' ? `t:${s.text.slice(0, 40)}` : `b:${s.block.index}`)))
346 const parts = await Promise.all(
347 segments.map((s, i) =>
348 s.kind === 'text' ? <Markdown key={keys[i]} text={s.text} /> : drawBlock($, e, `${key}#${s.block.index}`, s.block.components, s.block.pending, s.block.skipped),
349 ),
350 )
351 body = <Box flexDirection="column">{parts}</Box>
352 } else body = await next()
353
354 if (!card) return body
355 const viz = normalize(card.viz)
356 return (
357 <Box flexDirection="column">
358 {body}
359 {viz && (
360 <Box key="tldr-card" flexDirection="column">
361 <Text bold color="cyan">
362 ≡ TL;DR
363 </Text>
364 {await drawBlock($, e, `tldr:${card.id}`, [viz], false, 0)}
365 </Box>
366 )}
367 </Box>
368 )
369}
370
371export const register: Register = (on, options) => {
372 const classify = options.classify !== false
373
374 on('session.start', async ($, e, next) => {
375 await $.tool.register({
376 name: 'show',
377 description:
378 'Draw native UI in the thread: charts, tables, cards, diagrams, schematic maps and live calculators. Same components as a ```ui block; ' +
379 'prefer the block inside a prose answer, the tool when the picture is the whole answer.',
380 inputSchema: {
381 type: 'object',
382 properties: {
383 title: { type: 'string', description: 'A few words naming what is shown.' },
384 components: { type: 'array', items: { type: 'object' }, description: 'Component objects, as in a ```ui block.' },
385 },
386 required: ['components'],
387 },
388 })
389 await update($, ready, () => true)
390 return next(e)
391 })
392
393 on('prompt.compose', async ($, e, next) => {
394 const r = await next(e)
395 return { sections: [...r.sections, { id: 'picture-this:rule', text: RULE, scope: 'session' as const }] }
396 })
397
398 // Pick a picture per prompt. matchmaker, when on, already classifies every prompt and hints at this plugin: one call, not two.
399 on('prompt.submit', async ($, e, next) => {
400 if (!classify || !shouldClassify(e.text, e.origin !== undefined)) return next(e)
401 if ((await $.state.get({ plugin: 'matchmaker', key: 'isOff' })).value === false) return next(e)
402 const label = await $.model.classify(e.text.trim(), LABELS).catch(() => undefined)
403 const hint = label ? HINT[label] : undefined
404 return hint ? next({ ...e, context: [...(e.context ?? []), hint] }) : next(e)
405 })
406
407 on('tool.call', { tool: TOOL }, async ($, e) => {
408 const raw = (e as unknown as { components?: unknown }).components
409 const list = Array.isArray(raw) ? raw.slice(0, 24) : []
410 const components = list.flatMap(c => normalize(c) ?? [])
411 if (!components.length) return { result: { shown: 0, error: 'nothing drawable: check each component against the picture-this rule' } }
412 const id = e.tool_use_id ?? `t${Date.now()}`
413 await update($, shown, m => trim({ ...m, [id]: components }, MAX_SHOWN))
414 return { result: { shown: components.length, types: components.map(c => c.type), skipped: list.length - components.length } }
415 })
416
417 on('ui.render', { component: 'ToolUse' }, ($, e, next) => {
418 if (e.props.tool !== TOOL) return next(e)
419 const { Text } = $.ui.resolve(e)
420 const title = (e.props.input as { title?: string } | undefined)?.title
421 return <Text dimColor>▣ picture: {title ?? 'drawing'}</Text>
422 })
423
424 on('ui.render', { component: 'ToolResult' }, async ($, e, next) => {
425 if (e.props.tool !== TOOL) return next(e)
426 const components = (await read($, shown))[e.props.tool_use_id]
427 if (!components?.length) return next(e)
428 return (await drawBlock($, e, `tool:${e.props.tool_use_id}`, components, false, 0)) as never
429 })
430
431 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
432 const text = e.props.text
433 if (!hasBlock(text) && !((await $.state.get({ plugin: 'too-long-didnt-read', key: 'cards' })).value ?? []).length) return next(e)
434 return (await drawMessage($, e, text, () => next(e))) as never
435 })
436
437 // A slider moved: its key is `slider:<scope>/<id>`, the value key it drives.
438 on('ui.message', async ($, e, next) => {
439 const v = (e.data as { value?: unknown } | null)?.value
440 if (e.element.startsWith('slider:') && typeof v === 'number' && Number.isFinite(v)) await setValue($, e.element.slice('slider:'.length), v)
441 return next(e)
442 })
443
444 // A slider that cannot run here: draw the text bar and buttons from now on.
445 on('ui.fault', async ($, e, next) => {
446 await update($, noClient, () => true)
447 return next(e)
448 })
449}
450hooks/formula.ts 200 lines1// A small arithmetic evaluator for calc outputs: numbers, input ids, + - * / % ^, parentheses and a few
2// functions. No eval, no property access: anything else is an error, shown as "—" instead of NaN.
3
4const FUNCS: Record<string, (...a: number[]) => number> = {
5 min: Math.min,
6 max: Math.max,
7 abs: Math.abs,
8 round: (x, d = 0) => Math.round(x * 10 ** d) / 10 ** d,
9 floor: Math.floor,
10 ceil: Math.ceil,
11 sqrt: Math.sqrt,
12 pow: Math.pow,
13 log: Math.log,
14 log10: Math.log10,
15 exp: Math.exp,
16}
17
18type Tok = { k: 'num'; v: number } | { k: 'id'; v: string } | { k: 'op'; v: string }
19
20const MAX_LEN = 300
21const OPS = '+-*/%^(),'
22
23const isDigit = (c: string | undefined) => c !== undefined && c >= '0' && c <= '9'
24const isIdStart = (c: string | undefined) => c !== undefined && /[A-Za-z_]/.test(c)
25const isIdChar = (c: string | undefined) => isIdStart(c) || isDigit(c)
26
27/** Where a number starting at `i` ends: digits, an optional fraction, an optional exponent. */
28const numberEnd = (src: string, i: number): number => {
29 let j = i
30 while (isDigit(src[j])) j++
31 if (src[j] === '.' && isDigit(src[j + 1])) {
32 j++
33 while (isDigit(src[j])) j++
34 }
35 if (src[j] === 'e' || src[j] === 'E') {
36 let k = j + 1
37 if (src[k] === '+' || src[k] === '-') k++
38 if (isDigit(src[k])) {
39 while (isDigit(src[k])) k++
40 j = k
41 }
42 }
43 return j
44}
45
46export const tokenize = (src: string): Tok[] | undefined => {
47 if (src.length > MAX_LEN) return undefined
48 const out: Tok[] = []
49 let i = 0
50 while (i < src.length) {
51 const c = src[i] as string
52 if (c.trim() === '') {
53 i++
54 } else if (isDigit(c)) {
55 const j = numberEnd(src, i)
56 out.push({ k: 'num', v: Number(src.slice(i, j)) })
57 i = j
58 } else if (isIdStart(c)) {
59 let j = i + 1
60 while (isIdChar(src[j])) j++
61 out.push({ k: 'id', v: src.slice(i, j) })
62 i = j
63 } else if (OPS.includes(c)) {
64 out.push({ k: 'op', v: c })
65 i++
66 } else return undefined
67 }
68 return out
69}
70
71class Bad extends Error {}
72const fail = (): never => {
73 throw new Bad('bad formula')
74}
75
76/** Recursive descent over the tokens: expr := term (+|- term)*, term := power (*|/|% power)*, power := unary (^ power)?. */
77class Parser {
78 private i = 0
79 constructor(
80 private readonly toks: Tok[],
81 private readonly vars: Record<string, number>,
82 ) {}
83
84 get done(): boolean {
85 return this.i === this.toks.length
86 }
87
88 private isOp(v: string): boolean {
89 const t = this.toks[this.i]
90 return t?.k === 'op' && t.v === v
91 }
92
93 private take(v: string): void {
94 if (!this.isOp(v)) fail()
95 this.i++
96 }
97
98 expr(): number {
99 let v = this.term()
100 while (this.isOp('+') || this.isOp('-')) {
101 const plus = this.isOp('+')
102 this.i++
103 const r = this.term()
104 v = plus ? v + r : v - r
105 }
106 return v
107 }
108
109 private term(): number {
110 let v = this.power()
111 for (let op = this.mulOp(); op; op = this.mulOp()) {
112 this.i++
113 const r = this.power()
114 if (op === '*') v *= r
115 else if (op === '/') v /= r
116 else v %= r
117 }
118 return v
119 }
120
121 private mulOp(): string | undefined {
122 return ['*', '/', '%'].find(o => this.isOp(o))
123 }
124
125 private power(): number {
126 const base = this.unary()
127 if (!this.isOp('^')) return base
128 this.i++
129 return base ** this.power()
130 }
131
132 private unary(): number {
133 if (this.isOp('-') || this.isOp('+')) {
134 const minus = this.isOp('-')
135 this.i++
136 const v = this.power()
137 return minus ? -v : v
138 }
139 return this.primary()
140 }
141
142 private primary(): number {
143 const t = this.toks[this.i++] ?? fail()
144 if (t.k === 'num') return t.v
145 if (t.k === 'id') return this.isOp('(') ? this.call(t.v) : this.variable(t.v)
146 if (t.v !== '(') fail()
147 const v = this.expr()
148 this.take(')')
149 return v
150 }
151
152 private variable(name: string): number {
153 return (Object.hasOwn(this.vars, name) ? this.vars[name] : undefined) ?? fail()
154 }
155
156 private call(name: string): number {
157 const fn = (Object.hasOwn(FUNCS, name) ? FUNCS[name] : undefined) ?? fail()
158 this.take('(')
159 const args: number[] = []
160 if (!this.isOp(')')) {
161 args.push(this.expr())
162 while (this.isOp(',')) {
163 this.i++
164 args.push(this.expr())
165 }
166 }
167 this.take(')')
168 return fn(...args)
169 }
170}
171
172/** The formula's value with `vars` bound; undefined when it does not parse, names an unknown id, or is not finite. */
173export const evaluate = (src: string, vars: Record<string, number>): number | undefined => {
174 const toks = tokenize(src)
175 if (!toks || toks.length === 0) return undefined
176 try {
177 const p = new Parser(toks, vars)
178 const v = p.expr()
179 return p.done && Number.isFinite(v) ? v : undefined
180 } catch {
181 return undefined
182 }
183}
184
185const group = (whole: string): string => {
186 const parts: string[] = []
187 for (let end = whole.length; end > 0; end -= 3) parts.unshift(whole.slice(Math.max(0, end - 3), end))
188 return parts.join(',')
189}
190
191/** A number for display: grouped thousands, `digits` decimals (default: up to 2). */
192export const fmtNum = (n: number, digits?: number): string => {
193 const d = digits ?? (Number.isInteger(n) ? 0 : 2)
194 const fixed = Math.abs(n).toFixed(Math.max(0, Math.min(6, d)))
195 const [whole = '', frac] = fixed.split('.')
196 const sign = n < 0 ? '-' : ''
197 const tail = frac ? '.' + frac : ''
198 return sign + group(whole) + tail
199}
200hooks/spec.ts 272 lines1// From what Claude wrote to components that are safe to draw: ```ui blocks hold one JSON component per
2// line (JSON Lines, so finished lines draw while the rest streams); ```viz blocks hold one older chart.
3import type { CalcInput, CalcOutput, Component, Point, Stop, TreeNode } from '../types'
4
5export type Block = { index: number; components: Component[]; skipped: number; pending: boolean }
6export type Segment = { kind: 'text'; text: string } | { kind: 'block'; block: Block }
7
8const LABEL = 48
9const ITEMS = 12
10const ROWS = 50
11const COLS = 8
12const SERIES = 4
13const POINTS = 20
14const SLICES = 4
15const TEXT = 600
16
17const isObj = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v)
18const clip = (s: string, n: number) => (s.length > n ? `${s.slice(0, n - 1)}…` : s)
19const str = (v: unknown, n = LABEL): string | undefined => {
20 if (typeof v === 'number' && Number.isFinite(v)) return String(v)
21 return typeof v === 'string' && v.trim() ? clip(v.replace(/\s+/g, ' ').trim(), n) : undefined
22}
23const num = (v: unknown): number | undefined => (typeof v === 'number' && Number.isFinite(v) ? v : undefined)
24const ident = (v: unknown): string | undefined => (typeof v === 'string' && /^[A-Za-z_]\w{0,31}$/.test(v) ? v : undefined)
25const arr = (v: unknown, n: number): unknown[] => (Array.isArray(v) ? v.slice(0, n) : [])
26const opt = <T extends object>(o: T): T => Object.fromEntries(Object.entries(o).filter(([, v]) => v !== undefined)) as T
27
28const numMap = (raw: unknown, n: number, nonNeg: boolean): Record<string, number> | undefined => {
29 if (!isObj(raw)) return undefined
30 const out: Record<string, number> = {}
31 for (const [k, v] of Object.entries(raw).slice(0, n)) {
32 const key = str(k)
33 const val = num(v)
34 if (key && val !== undefined && (!nonNeg || val >= 0)) out[key] = val
35 }
36 return Object.keys(out).length ? out : undefined
37}
38
39const nodes = (raw: unknown, depth = 0): TreeNode[] =>
40 arr(raw, ITEMS).flatMap(n => {
41 const l = isObj(n) ? str(n.label) : str(n)
42 if (!l) return []
43 const kids = isObj(n) && depth < 3 ? nodes(n.children, depth + 1) : []
44 return [kids.length ? { label: l, children: kids } : { label: l }]
45 })
46
47type Norm = (r: Record<string, unknown>, title: string | undefined) => Component | undefined
48
49const bars: Norm = (r, title) => {
50 const values = numMap(r.values, ITEMS, true)
51 return values ? opt({ type: 'bars' as const, title, values, max: num(r.max), unit: str(r.unit, 12) }) : undefined
52}
53
54const line: Norm = (r, title) => {
55 const x = arr(r.x, 40).flatMap(v => str(v, 16) ?? [])
56 if (x.length < 2 || !isObj(r.series)) return undefined
57 const series: Record<string, number[]> = {}
58 for (const [k, v] of Object.entries(r.series).slice(0, SERIES)) {
59 const ys = arr(v, x.length).map(num)
60 if (str(k) && ys.length === x.length && ys.every(y => y !== undefined)) series[str(k) as string] = ys as number[]
61 }
62 return Object.keys(series).length ? opt({ type: 'line' as const, title, x, series, unit: str(r.unit, 12) }) : undefined
63}
64
65const scatter = (quadrant: boolean): Norm => (r, title) => {
66 const points = arr(r.points, POINTS).flatMap((p): Point[] => {
67 const l = isObj(p) ? str(p.label ?? p.id) : undefined
68 const x = isObj(p) ? num(p.x) : undefined
69 const y = isObj(p) ? num(p.y) : undefined
70 return l && x !== undefined && y !== undefined ? [{ label: l, x, y }] : []
71 })
72 return points.length ? opt({ type: 'scatter' as const, title, x: str(r.x) ?? 'x', y: str(r.y) ?? 'y', points, quadrant: quadrant || r.quadrant === true || undefined }) : undefined
73}
74
75const pie: Norm = (r, title) => {
76 const all = numMap(r.values, ITEMS, true)
77 if (!all || !Object.values(all).some(v => v > 0)) return undefined
78 // Past four slices the colours stop being tellable apart: keep the three biggest and fold the rest into Other.
79 const sorted = Object.entries(all).sort((a, b) => b[1] - a[1])
80 const values = sorted.length > SLICES ? { ...Object.fromEntries(sorted.slice(0, SLICES - 1)), Other: sorted.slice(SLICES - 1).reduce((a, [, v]) => a + v, 0) } : all
81 return { type: 'pie', title, values }
82}
83
84const table: Norm = (r, title) => {
85 const columns = arr(r.columns, COLS).flatMap(c => str(c, 24) ?? [])
86 if (!columns.length) return undefined
87 const rows = arr(r.rows, ROWS).flatMap(row => {
88 if (!Array.isArray(row)) return []
89 const cells = columns.map((_, i) => num(row[i]) ?? str(row[i], 40) ?? '')
90 return cells.some(c => c !== '') ? [cells] : []
91 })
92 return rows.length ? { type: 'table', title, columns, rows } : undefined
93}
94
95const tree: Norm = (r, title) => {
96 const n = nodes(r.nodes)
97 return n.length ? { type: 'tree', title, nodes: n } : undefined
98}
99
100const flow: Norm = (r, title) => {
101 const raw = Array.isArray(r.lanes) ? r.lanes : [r.steps]
102 const lanes = raw
103 .slice(0, 4)
104 .map(l => arr(l, 8).flatMap(s => str(s, 32) ?? []))
105 .filter(l => l.length >= 2)
106 return lanes.length ? { type: 'flow', title, lanes } : undefined
107}
108
109const edgeParts = (e: unknown): unknown[] => {
110 if (Array.isArray(e)) return e
111 return isObj(e) ? [e.from, e.to, e.label] : []
112}
113
114const graph: Norm = (r, title) => {
115 const ns = arr(r.nodes, ITEMS).flatMap(n => {
116 const id = isObj(n) ? str(n.id, 32) : str(n, 32)
117 return id ? [{ id, label: (isObj(n) ? str(n.label, 32) : undefined) ?? id }] : []
118 })
119 const ids = new Set(ns.map(n => n.id))
120 const edges = arr(r.edges, 24).flatMap(e => {
121 const [from, to, label] = edgeParts(e)
122 const f = str(from, 32)
123 const t = str(to, 32)
124 return f && t && ids.has(f) && ids.has(t) ? [opt({ from: f, to: t, label: str(label, 24) })] : []
125 })
126 return ns.length >= 2 && edges.length ? { type: 'graph', title, nodes: ns, edges } : undefined
127}
128
129const card: Norm = (r, title) => {
130 if (!title) return undefined
131 const fields: Record<string, string> = {}
132 if (isObj(r.fields)) for (const [k, v] of Object.entries(r.fields).slice(0, ITEMS)) if (str(k, 24) && str(v)) fields[str(k, 24) as string] = str(v) as string
133 const tags = arr(r.tags, 6).flatMap(t => str(t, 20) ?? [])
134 return opt({ type: 'card' as const, title, body: str(r.body, TEXT), fields: Object.keys(fields).length ? fields : undefined, tags: tags.length ? tags : undefined })
135}
136
137const stat: Norm = r => {
138 const label = str(r.label)
139 const value = str(r.value, 24)
140 return label && value ? opt({ type: 'stat' as const, label, value, delta: str(r.delta, 24), note: str(r.note) }) : undefined
141}
142
143const calcInput = (i: unknown): CalcInput[] => {
144 if (!isObj(i)) return []
145 const id = ident(i.id)
146 const min = num(i.min) ?? 0
147 const max = num(i.max) ?? 100
148 if (!id || max <= min) return []
149 const step = num(i.step) && (i.step as number) > 0 ? (i.step as number) : (max - min) / 100
150 const value = Math.min(max, Math.max(min, num(i.value) ?? min))
151 return [opt({ id, label: str(i.label) ?? id, value, min, max, step, unit: str(i.unit, 12) })]
152}
153
154const calc: Norm = (r, title) => {
155 const inputs = arr(r.inputs, 8).flatMap(calcInput)
156 const outputs = arr(r.outputs, 8).flatMap((o): CalcOutput[] => {
157 if (!isObj(o) || typeof o.formula !== 'string' || o.formula.length > 300) return []
158 return [opt({ id: ident(o.id), label: str(o.label) ?? 'result', formula: o.formula, unit: str(o.unit, 12), digits: num(o.digits) })]
159 })
160 return inputs.length && outputs.length ? { type: 'calc', title, inputs, outputs } : undefined
161}
162
163const choice: Norm = r => {
164 const id = ident(r.id)
165 const options = arr(r.options, 8).flatMap(o => str(o, 32) ?? [])
166 if (!id || options.length < 2) return undefined
167 const value = str(r.value, 32)
168 return opt({ type: 'choice' as const, id, label: str(r.label) ?? id, options, value: value && options.includes(value) ? value : undefined })
169}
170
171const map: Norm = (r, title) => {
172 const stops = arr(r.stops, ITEMS).flatMap((s): Stop[] => {
173 const label = isObj(s) ? str(s.label) : str(s)
174 if (!label) return []
175 const lat = isObj(s) ? num(s.lat) : undefined
176 const lon = isObj(s) ? num(s.lon) : undefined
177 const ok = lat !== undefined && lon !== undefined && Math.abs(lat) <= 90 && Math.abs(lon) <= 180
178 return [opt({ label, lat: ok ? lat : undefined, lon: ok ? lon : undefined, note: isObj(s) ? str(s.note, 80) : undefined })]
179 })
180 return stops.length ? { type: 'map', title, stops } : undefined
181}
182
183const send: Norm = r => {
184 const label = str(r.label, 32)
185 const prompt = typeof r.prompt === 'string' && r.prompt.trim() ? r.prompt.trim().slice(0, 1000) : undefined
186 return label && prompt ? { type: 'send', label, prompt } : undefined
187}
188
189const NORMS: Record<string, Norm> = {
190 bars,
191 bar: bars,
192 line,
193 scatter: scatter(false),
194 quadrant: scatter(true),
195 pie,
196 table,
197 tree,
198 flow,
199 graph,
200 card,
201 stat,
202 calc,
203 calculator: calc,
204 choice,
205 map,
206 send,
207}
208
209export const TYPES = Object.keys(NORMS)
210
211/** One component from whatever JSON came in; undefined when it is not one we can draw honestly. */
212export const normalize = (raw: unknown): Component | undefined => {
213 if (!isObj(raw) || typeof raw.type !== 'string') return undefined
214 const norm = Object.hasOwn(NORMS, raw.type) ? NORMS[raw.type] : undefined
215 return norm?.(raw, str(raw.title, 64))
216}
217
218const parse = (s: string): unknown => {
219 try {
220 return JSON.parse(s)
221 } catch {
222 return undefined
223 }
224}
225
226/** A block's body: one JSON array or object, else one object per line. `open` keeps the last, unfinished line out. */
227export const parseBody = (body: string, open: boolean): { components: Component[]; skipped: number } => {
228 const whole = open ? undefined : parse(body)
229 let raws: unknown[]
230 if (Array.isArray(whole)) raws = whole
231 else if (whole !== undefined) raws = [whole]
232 else {
233 const lines = body.split('\n')
234 if (open) lines.pop()
235 raws = lines.filter(l => l.trim()).map(l => parse(l.trim()) ?? null)
236 }
237 const components = raws.slice(0, 24).flatMap(r => normalize(r) ?? [])
238 return { components, skipped: raws.length - components.length }
239}
240
241const FENCE = /```(ui|viz)[ \t]*\n([\s\S]*?)```/g
242const OPEN = /```(ui|viz)[ \t]*(\n|$)/
243
244export const hasBlock = (text: string): boolean => OPEN.test(text)
245
246/** The message split into prose and blocks; an unclosed block at the end is drawn so far and marked pending. */
247export const splitBlocks = (text: string): Segment[] => {
248 const out: Segment[] = []
249 let at = 0
250 let index = 0
251 for (const m of text.matchAll(FENCE)) {
252 const i = m.index ?? 0
253 if (i > at) out.push({ kind: 'text', text: text.slice(at, i) })
254 out.push({ kind: 'block', block: { index: index++, ...parseBody(m[2] ?? '', false), pending: false } })
255 at = i + m[0].length
256 }
257 const rest = text.slice(at)
258 const open = OPEN.exec(rest)
259 if (open?.index !== undefined) {
260 if (open.index > 0) out.push({ kind: 'text', text: rest.slice(0, open.index) })
261 const body = rest.slice(open.index + open[0].length)
262 out.push({ kind: 'block', block: { index, ...parseBody(body, true), pending: true } })
263 } else if (rest) out.push({ kind: 'text', text: rest })
264 return out
265}
266
267/** Identifies an answer's text; the same function is in too-long-didnt-read, which keys its cards with it. */
268export const answerKey = (text: string): string => {
269 const t = text.trim()
270 return `${t.length}:${t.slice(0, 60)}`
271}
272hooks/svg.ts 240 lines1// SVG for the components that read better as pictures; desktop, VS Code and mobile draw these (no Svg in a terminal).
2import type { Component } from '../types'
3
4import { fmtNum } from './formula'
5
6// Categorical slots 1-4 of the reference palette, in fixed order; mid steps that hold up on light and dark.
7export const SERIES = ['#2a78d6', '#eb6834', '#1baf7a', '#6250d6']
8const INK = '#7d7d7d'
9const FONT = 'font-family="system-ui,-apple-system,Segoe UI,sans-serif"'
10export const MAX_SVG = 131072
11
12export const esc = (s: string) => s.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"')
13const r1 = (n: number) => Math.round(n * 10) / 10
14const svg = (w: number, h: number, body: string, title?: string) => {
15 const head = title ? `<text x="0" y="14" font-size="13" font-weight="600" fill="${INK}">${esc(title)}</text>` : ''
16 return `<svg xmlns="http://www.w3.org/2000/svg" ${FONT} viewBox="0 0 ${w} ${h}">${head}${body}</svg>`
17}
18const label = (x: number, y: number, s: string, extra = '') => `<text x="${r1(x)}" y="${r1(y)}" font-size="11" fill="${INK}"${extra}>${esc(s)}</text>`
19const tip = (text: string) => '<title>' + esc(text) + '</title>'
20const unitText = (n: number, unit?: string) => (unit ? fmtNum(n) + ' ' + unit : fmtNum(n))
21const endAnchor = (left: boolean) => (left ? ' text-anchor="end"' : '')
22const top = (title?: string) => (title ? 26 : 4)
23
24const bars = (c: Extract<Component, { type: 'bars' }>) => {
25 const rows = Object.entries(c.values)
26 const max = c.max ?? Math.max(1, ...rows.map(r => r[1]))
27 const lw = Math.min(140, 8 + 7 * Math.max(...rows.map(r => r[0].length)))
28 const y0 = top(c.title)
29 const body = rows
30 .map(([k, n], i) => {
31 const y = y0 + i * 26
32 const w = Math.max(2, Math.round((n / max) * 200))
33 const value = unitText(n, c.unit)
34 return `${label(0, y + 12, k)}<rect x="${lw}" y="${y}" width="${w}" height="16" rx="4" fill="${SERIES[0]}">${tip(k + ': ' + value)}</rect>${label(lw + w + 6, y + 12, value)}`
35 })
36 .join('')
37 return svg(lw + 290, y0 + rows.length * 26 + 4, body, c.title)
38}
39
40const line = (c: Extract<Component, { type: 'line' }>) => {
41 const all = Object.values(c.series).flat()
42 const lo = Math.min(0, ...all)
43 const hi = Math.max(...all)
44 const [x0, y0, w, h] = [44, top(c.title) + 6, 300, 140]
45 const sx = (i: number) => x0 + (i / (c.x.length - 1)) * w
46 const sy = (v: number) => y0 + h - ((v - lo) / (hi - lo || 1)) * h
47 const grid = [lo, (lo + hi) / 2, hi]
48 .map(v => `<line x1="${x0}" x2="${x0 + w}" y1="${r1(sy(v))}" y2="${r1(sy(v))}" stroke="${INK}" opacity=".2"/>${label(x0 - 6, sy(v) + 4, fmtNum(v), ' text-anchor="end"')}`)
49 .join('')
50 const names = Object.keys(c.series)
51 const paths = names
52 .map((k, s) => {
53 const ys = c.series[k] ?? []
54 const d = ys.map((v, i) => `${i ? 'L' : 'M'}${r1(sx(i))} ${r1(sy(v))}`).join(' ')
55 const dots = ys.map((v, i) => `<circle cx="${r1(sx(i))}" cy="${r1(sy(v))}" r="4" fill="${SERIES[s]}">${tip(k + ', ' + (c.x[i] ?? '') + ': ' + fmtNum(v))}</circle>`).join('')
56 return `<path d="${d}" fill="none" stroke="${SERIES[s]}" stroke-width="2"/>${dots}`
57 })
58 .join('')
59 const xl = `${label(x0, y0 + h + 16, c.x[0] ?? '')}${label(x0 + w, y0 + h + 16, c.x.at(-1) ?? '', ' text-anchor="end"')}`
60 const legend = names.length > 1 ? names.map((k, s) => `<rect x="${x0 + s * 90}" y="${y0 + h + 26}" width="10" height="10" rx="2" fill="${SERIES[s]}"/>${label(x0 + s * 90 + 14, y0 + h + 35, k)}`).join('') : ''
61 return svg(x0 + w + 16, y0 + h + (names.length > 1 ? 44 : 24), `${grid}${paths}${xl}${legend}`, c.title)
62}
63
64const scatter = (c: Extract<Component, { type: 'scatter' }>) => {
65 const xs = c.points.map(p => p.x)
66 const ys = c.points.map(p => p.y)
67 const [xa, xb] = c.quadrant ? [0, 10] : [Math.min(...xs), Math.max(...xs)]
68 const [ya, yb] = c.quadrant ? [0, 10] : [Math.min(...ys), Math.max(...ys)]
69 const [x0, y0, w, h] = [40, top(c.title), 260, 150]
70 const sx = (v: number) => x0 + 10 + ((v - xa) / (xb - xa || 1)) * (w - 20)
71 const sy = (v: number) => y0 + h - 10 - ((v - ya) / (yb - ya || 1)) * (h - 20)
72 const mid = c.quadrant
73 ? `<line x1="${x0 + w / 2}" y1="${y0}" x2="${x0 + w / 2}" y2="${y0 + h}" stroke="${INK}" opacity=".25"/><line x1="${x0}" y1="${y0 + h / 2}" x2="${x0 + w}" y2="${y0 + h / 2}" stroke="${INK}" opacity=".25"/>`
74 : ''
75 const dots = c.points
76 .map(p => {
77 const cx = sx(p.x)
78 const cy = sy(p.y)
79 const left = cx > x0 + w * 0.65
80 const lx = left ? cx - 10 : cx + 10
81 return `<circle cx="${r1(cx)}" cy="${r1(cy)}" r="6" fill="${SERIES[0]}">${tip(p.label + ': ' + fmtNum(p.x) + ', ' + fmtNum(p.y))}</circle>${label(lx, cy + 4, p.label, endAnchor(left))}`
82 })
83 .join('')
84 const rotate = ' text-anchor="middle" transform="rotate(-90 12 ' + r1(y0 + h / 2) + ')"'
85 const xAxis = label(x0 + w / 2, y0 + h + 18, c.x + ' →', ' text-anchor="middle"')
86 const yAxis = label(12, y0 + h / 2, c.y + ' →', rotate)
87 const axes = `<rect x="${x0}" y="${y0}" width="${w}" height="${h}" fill="none" stroke="${INK}" opacity=".5"/>${xAxis}${yAxis}`
88 return svg(x0 + w + 10, y0 + h + 26, `${axes}${mid}${dots}`, c.title)
89}
90
91const pie = (c: Extract<Component, { type: 'pie' }>) => {
92 const rows = Object.entries(c.values)
93 const total = rows.reduce((a, [, v]) => a + v, 0) || 1
94 const [cx, cy, r, ri] = [80, top(c.title) + 74, 70, 42]
95 let a = -Math.PI / 2
96 const pt = (ang: number, rad: number) => `${r1(cx + rad * Math.cos(ang))} ${r1(cy + rad * Math.sin(ang))}`
97 const slices = rows
98 .map(([k, v], i) => {
99 const b = a + (v / total) * Math.PI * 2
100 const big = b - a > Math.PI ? 1 : 0
101 const d = v >= total ? `M${pt(0, r)} A${r} ${r} 0 1 1 ${pt(Math.PI - 0.0001, r)} A${r} ${r} 0 1 1 ${pt(0, r)} Z` : `M${pt(a, r)} A${r} ${r} 0 ${big} 1 ${pt(b, r)} L${pt(b, ri)} A${ri} ${ri} 0 ${big} 0 ${pt(a, ri)} Z`
102 a = b
103 const color = SERIES[i % SERIES.length]
104 const pct = Math.round((v / total) * 100)
105 const share = pct + '%'
106 return `<path d="${d}" fill="${color}" stroke="none">${tip(k + ': ' + fmtNum(v) + ' (' + share + ')')}</path><rect x="170" y="${top(c.title) + 14 + i * 22}" width="10" height="10" rx="2" fill="${color}"/>${label(186, top(c.title) + 23 + i * 22, k + ' ' + share)}`
107 })
108 .join('')
109 return svg(330, top(c.title) + 152, slices, c.title)
110}
111
112const NODE_W = 100
113const NODE_H = 30
114const GAP_X = 44
115const GAP_Y = 22
116
117/** Columns by longest path from a source; cycles fall back to the order given. */
118export const layers = (ids: string[], edges: { from: string; to: string }[]): Map<string, number> => {
119 const depth = new Map(ids.map(id => [id, 0]))
120 for (const _ of ids) {
121 let moved = false
122 for (const e of edges) {
123 const d = (depth.get(e.from) ?? 0) + 1
124 if (e.from !== e.to && d > (depth.get(e.to) ?? 0) && d < ids.length) {
125 depth.set(e.to, d)
126 moved = true
127 }
128 }
129 if (!moved) break
130 }
131 return depth
132}
133
134const graph = (c: Extract<Component, { type: 'graph' }>) => {
135 const depth = layers(
136 c.nodes.map(n => n.id),
137 c.edges,
138 )
139 const rows = new Map<number, number>()
140 const pos = new Map<string, { x: number; y: number; col: number }>()
141 const y0 = top(c.title) + 4
142 for (const n of c.nodes) {
143 const col = depth.get(n.id) ?? 0
144 const row = rows.get(col) ?? 0
145 rows.set(col, row + 1)
146 pos.set(n.id, { x: col * (NODE_W + GAP_X), y: y0 + row * (NODE_H + GAP_Y), col })
147 }
148 const lines = c.edges
149 .map(e => {
150 const a = pos.get(e.from) ?? { x: 0, y: 0, col: 0 }
151 const b = pos.get(e.to) ?? { x: 0, y: 0, col: 0 }
152 const span = b.col - a.col
153 let d: string
154 let mid: { x: number; y: number }
155 if (span <= 0) {
156 // Back or same-column edge: out of the bottom, into the bottom, looping under.
157 const [x1, y1, x2, y2] = [a.x + NODE_W / 2, a.y + NODE_H, b.x + NODE_W / 2, b.y + NODE_H]
158 const dip = Math.max(y1, y2) + GAP_Y
159 d = `M${x1} ${y1} C${x1} ${dip} ${x2} ${dip} ${x2} ${y2 + 2}`
160 mid = { x: (x1 + x2) / 2, y: dip - 2 }
161 } else {
162 // Forward: right side to left side; an edge that skips a column bends below the row it passes.
163 const [x1, y1, x2, y2] = [a.x + NODE_W, a.y + NODE_H / 2, b.x - 2, b.y + NODE_H / 2]
164 const off = span > 1 ? NODE_H / 2 + GAP_Y / 2 : 0
165 const k = GAP_X * 0.8
166 d = span > 1 ? `M${x1} ${y1} C${x1 + k} ${y1 + off} ${x1 + k} ${y1 + off} ${x1 + GAP_X} ${y1 + off} L${x2 - GAP_X} ${y2 + off} C${x2 - k} ${y2 + off} ${x2 - k} ${y2} ${x2} ${y2}` : `M${x1} ${y1} C${x1 + k} ${y1} ${x2 - k} ${y2} ${x2} ${y2}`
167 mid = span > 1 ? { x: (x1 + x2) / 2, y: (y1 + y2) / 2 + off - 4 } : { x: (x1 + x2) / 2, y: (y1 + y2) / 2 - 5 }
168 }
169 const lab = e.label ? label(mid.x, mid.y, e.label, ' text-anchor="middle" font-size="10"') : ''
170 return `<path d="${d}" fill="none" stroke="${INK}" stroke-width="1.5" marker-end="url(#pt-arrow)"/>${lab}`
171 })
172 .join('')
173 const boxes = c.nodes
174 .map(n => {
175 const p = pos.get(n.id) ?? { x: 0, y: 0 }
176 const text = n.label.length > 14 ? `${n.label.slice(0, 13)}…` : n.label
177 return `<rect x="${p.x}" y="${p.y}" width="${NODE_W}" height="${NODE_H}" rx="6" fill="none" stroke="${SERIES[0]}" stroke-width="1.5"><title>${esc(n.label)}</title></rect><text x="${p.x + NODE_W / 2}" y="${p.y + 19}" font-size="12" text-anchor="middle" fill="${INK}">${esc(text)}</text>`
178 })
179 .join('')
180 const cols = Math.max(...depth.values()) + 1
181 const maxRows = Math.max(...rows.values())
182 const defs = `<defs><marker id="pt-arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto"><path d="M0 0L10 5L0 10z" fill="${INK}"/></marker></defs>`
183 return svg(cols * (NODE_W + GAP_X) - GAP_X + 2, y0 + maxRows * (NODE_H + GAP_Y) + GAP_Y, `${defs}${lines}${boxes}`, c.title)
184}
185
186const flow = (c: Extract<Component, { type: 'flow' }>) =>
187 graph({
188 type: 'graph',
189 title: c.title,
190 nodes: c.lanes.flatMap((l, li) => l.map((s, i) => ({ id: `${li}:${i}`, label: s }))),
191 edges: c.lanes.flatMap((l, li) => l.slice(1).map((_, i) => ({ from: `${li}:${i}`, to: `${li}:${i + 1}` }))),
192 })
193
194const map = (c: Extract<Component, { type: 'map' }>) => {
195 const [w, h, m] = [340, 200, 24]
196 const y0 = top(c.title)
197 const geo = c.stops.every(s => s.lat !== undefined && s.lon !== undefined)
198 const lats = c.stops.map(s => s.lat ?? 0)
199 const lons = c.stops.map(s => s.lon ?? 0)
200 const [la, lb, oa, ob] = [Math.min(...lats), Math.max(...lats), Math.min(...lons), Math.max(...lons)]
201 // Equirectangular with the longitude squeezed by cos(latitude), one scale on both axes, centred.
202 const kx = Math.cos((((la + lb) / 2) * Math.PI) / 180)
203 const spanX = (ob - oa) * kx || 1
204 const spanY = lb - la || 1
205 const scale = Math.min((w - 2 * m - 60) / spanX, (h - 2 * m - 14) / spanY)
206 const ox = (w - spanX * scale) / 2
207 const oy = y0 + m + (h - 2 * m - 14 - spanY * scale) / 2
208 const at = (i: number) => {
209 if (!geo) return { x: m + (i / Math.max(1, c.stops.length - 1)) * (w - 2 * m), y: y0 + h / 2 + (i % 2 ? 30 : -30) }
210 return { x: ox + ((lons[i] ?? 0) - oa) * kx * scale, y: oy + (lb - (lats[i] ?? 0)) * scale }
211 }
212 const pts = c.stops.map((_, i) => at(i))
213 const routeD = pts.map((p, i) => (i ? 'L' : 'M') + r1(p.x) + ' ' + r1(p.y)).join(' ')
214 const route = pts.length > 1 ? `<path d="${routeD}" fill="none" stroke="${SERIES[0]}" stroke-width="2" stroke-dasharray="5 4"/>` : ''
215 const dots = c.stops
216 .map((s, i) => {
217 const p = pts[i] ?? { x: 0, y: 0 }
218 const left = p.x > w * 0.7
219 const lx = left ? p.x - 13 : p.x + 13
220 const note = s.note ? s.label + ': ' + s.note : s.label
221 return `<circle cx="${r1(p.x)}" cy="${r1(p.y)}" r="9" fill="${SERIES[1]}">${tip(note)}</circle><text x="${r1(p.x)}" y="${r1(p.y + 4)}" font-size="10" font-weight="700" text-anchor="middle" fill="#ffffff">${i + 1}</text>${label(lx, p.y + 4, s.label, endAnchor(left))}`
222 })
223 .join('')
224 const frame = `<rect x="0" y="${y0}" width="${w}" height="${h}" rx="8" fill="none" stroke="${INK}" opacity=".35"/>${label(w - 6, y0 + h - 6, geo ? 'schematic, not to scale' : 'schematic: no coordinates', ' text-anchor="end" opacity=".7"')}`
225 return svg(w, y0 + h + 2, `${frame}${route}${dots}`, c.title)
226}
227
228/** The component as SVG, or undefined when it reads better as text (tree, card, table, stat) or is a control. */
229export const svgFor = (c: Component): string | undefined => {
230 let out: string | undefined
231 if (c.type === 'bars') out = bars(c)
232 else if (c.type === 'line') out = line(c)
233 else if (c.type === 'scatter') out = scatter(c)
234 else if (c.type === 'pie') out = pie(c)
235 else if (c.type === 'graph') out = graph(c)
236 else if (c.type === 'flow') out = flow(c)
237 else if (c.type === 'map') out = map(c)
238 return out && out.length <= MAX_SVG ? out : undefined
239}
240hooks/text.ts 152 lines1// Every drawable component as plain text lines: the terminal's form, and the fallback anywhere Svg is missing.
2import type { Component, Sort, TreeNode } from '../types'
3
4import { fmtNum } from './formula'
5
6const BAR = 12
7const W = 25
8const H = 9
9const SPARK = '▁▂▃▄▅▆▇█'
10
11const pad = (s: string, n: number) => s + ' '.repeat(Math.max(0, n - s.length))
12const padL = (s: string, n: number) => ' '.repeat(Math.max(0, n - s.length)) + s
13const unitOf = (u?: string) => (u ? ` ${u}` : '')
14
15const treeLines = (nodes: TreeNode[], prefix = ''): string[] =>
16 nodes.flatMap((n, i) => {
17 const last = i === nodes.length - 1
18 return [`${prefix}${last ? '└─ ' : '├─ '}${n.label}`, ...treeLines(n.children ?? [], prefix + (last ? ' ' : '│ '))]
19 })
20
21const barLines = (values: Record<string, number>, max: number, suffix: (n: number) => string): string[] => {
22 const w = Math.max(...Object.keys(values).map(k => k.length))
23 return Object.entries(values).map(([k, n]) => {
24 const f = Math.max(0, Math.min(BAR, Math.round((n / (max || 1)) * BAR)))
25 return `${pad(k, w)} ${'█'.repeat(f)}${'░'.repeat(BAR - f)} ${suffix(n)}`
26 })
27}
28
29export const spark = (ys: number[]): string => {
30 const lo = Math.min(...ys)
31 const hi = Math.max(...ys)
32 return ys.map(y => SPARK[hi === lo ? 3 : Math.round(((y - lo) / (hi - lo)) * (SPARK.length - 1))]).join('')
33}
34
35/** Rows in display order for a sort; numbers compare as numbers. */
36export const sortedRows = (rows: (string | number)[][], sort?: Sort): (string | number)[][] => {
37 if (!sort) return rows
38 const cmp = (a: string | number, b: string | number) => (typeof a === 'number' && typeof b === 'number' ? a - b : String(a).localeCompare(String(b)))
39 const out = [...rows].sort((a, b) => cmp(a[sort.col] ?? '', b[sort.col] ?? ''))
40 return sort.desc ? out.reverse() : out
41}
42
43const headerText = (c: string, i: number, sort?: Sort): string => {
44 if (sort?.col !== i) return c
45 return sort.desc ? c + ' ↓' : c + ' ↑'
46}
47
48const cell = (c: string | number) => (typeof c === 'number' ? fmtNum(c) : c)
49
50export const tableLines = (columns: string[], rows: (string | number)[][], sort?: Sort): string[] => {
51 const shown = sortedRows(rows, sort)
52 const widths = columns.map((c, i) => Math.max(c.length + 2, ...shown.map(r => cell(r[i] ?? '').length)))
53 const head = columns.map((c, i) => pad(headerText(c, i, sort), widths[i] ?? 0)).join(' │ ')
54 const rule = widths.map(w => '─'.repeat(w)).join('─┼─')
55 const body = shown.map(r => r.map((c, i) => (typeof c === 'number' ? padL(cell(c), widths[i] ?? 0) : pad(c, widths[i] ?? 0))).join(' │ '))
56 return [head, rule, ...body]
57}
58
59const scatterLines = (c: Extract<Component, { type: 'scatter' }>): string[] => {
60 const xs = c.points.map(p => p.x)
61 const ys = c.points.map(p => p.y)
62 const [x0, x1] = c.quadrant ? [0, 10] : [Math.min(...xs), Math.max(...xs)]
63 const [y0, y1] = c.quadrant ? [0, 10] : [Math.min(...ys), Math.max(...ys)]
64 const grid = Array.from({ length: H }, () => new Array<string>(W).fill(' '))
65 if (c.quadrant) {
66 for (const row of grid) row[Math.floor(W / 2)] = '┊'
67 grid[Math.floor(H / 2)]?.fill('┈')
68 }
69 c.points.forEach((p, i) => {
70 const col = Math.max(0, Math.min(W - 1, Math.round(((p.x - x0) / (x1 - x0 || 1)) * (W - 1))))
71 const row = H - 1 - Math.max(0, Math.min(H - 1, Math.round(((p.y - y0) / (y1 - y0 || 1)) * (H - 1))))
72 const cells = grid[row]
73 if (cells) cells[col] = String.fromCodePoint(65 + i)
74 })
75 return [
76 `${c.y} ↑`,
77 ...grid.map(row => `│${row.join('')}`),
78 `└${'─'.repeat(W)}→ ${c.x}`,
79 ...c.points.map((p, i) => `${String.fromCodePoint(65 + i)} ${p.label} (${fmtNum(p.x)}, ${fmtNum(p.y)})`),
80 ]
81}
82
83const graphLines = (c: Extract<Component, { type: 'graph' }>): string[] => {
84 const label = new Map(c.nodes.map(n => [n.id, n.label]))
85 const arrow = (l?: string) => (l ? '── ' + l + ' ▶' : '──▶')
86 const edges = c.edges.map(e => `${label.get(e.from)} ${arrow(e.label)} ${label.get(e.to)}`)
87 const linked = new Set(c.edges.flatMap(e => [e.from, e.to]))
88 return [...edges, ...c.nodes.filter(n => !linked.has(n.id)).map(n => `${n.label} (alone)`)]
89}
90
91/** Lines for a static component (no title: the caller draws it bold). Interactive ones (calc, choice, send) give []. */
92export const textLines = (c: Component, sort?: Sort): string[] => {
93 switch (c.type) {
94 case 'bars': {
95 const max = c.max ?? Math.max(1, ...Object.values(c.values))
96 return barLines(c.values, max, n => `${fmtNum(n)}${unitOf(c.unit)}`)
97 }
98 case 'pie': {
99 const total = Object.values(c.values).reduce((a, b) => a + b, 0) || 1
100 return barLines(c.values, total, n => `${Math.round((n / total) * 100)}%`)
101 }
102 case 'line': {
103 const w = Math.max(...Object.keys(c.series).map(k => k.length))
104 return [
105 ...Object.entries(c.series).map(
106 ([k, ys]) => `${pad(k, w)} ${spark(ys)} ${fmtNum(ys[0] ?? 0)} → ${fmtNum(ys.at(-1) ?? 0)}${unitOf(c.unit)} (low ${fmtNum(Math.min(...ys))}, high ${fmtNum(Math.max(...ys))})`,
107 ),
108 `${' '.repeat(w + 1)}${c.x[0]} … ${c.x.at(-1)}`,
109 ]
110 }
111 case 'scatter':
112 return scatterLines(c)
113 case 'table':
114 return tableLines(c.columns, c.rows, sort)
115 case 'tree':
116 return treeLines(c.nodes)
117 case 'flow':
118 return c.lanes.map(l => l.join(' ──▶ '))
119 case 'graph':
120 return graphLines(c)
121 case 'card':
122 return [
123 ...(c.body ? [c.body] : []),
124 ...Object.entries(c.fields ?? {}).map(([k, v]) => `${k}: ${v}`),
125 ...(c.tags?.length ? [c.tags.map(t => `[${t}]`).join(' ')] : []),
126 ]
127 case 'stat':
128 return [c.delta ? c.value + ' ' + c.delta : c.value, ...(c.note ? [c.note] : [])]
129 case 'map':
130 return [
131 c.stops.map((s, i) => `(${i + 1}) ${s.label}`).join(' ──▶ '),
132 ...c.stops.flatMap((s, i) => (s.note ? [` ${i + 1}. ${s.note}`] : [])),
133 ...(c.stops.some(s => s.lat !== undefined) ? [] : [' (schematic: no coordinates)']),
134 ]
135 default:
136 return []
137 }
138}
139
140/** The heading a component draws above its lines. */
141export const titleOf = (c: Component): string | undefined => {
142 if (c.type === 'stat' || c.type === 'choice') return c.label
143 if (c.type === 'send') return undefined
144 return c.title
145}
146
147/** A text slider: `[████░░░░░░] 40`. */
148export const sliderBar = (value: number, min: number, max: number, width = 16): string => {
149 const f = Math.max(0, Math.min(width, Math.round(((value - min) / (max - min || 1)) * width)))
150 return `[${'█'.repeat(f)}${'░'.repeat(width - f)}]`
151}
152hooks/slider.tsx 64 lines1// Surface module for a calc input (terminal and desktop): drag the bar or use ←/→ (PgUp/PgDn ×10, Home/End)
2// after a click. It moves at once, locally, and posts the value; the hooks module recomputes the outputs.
3type Props = { value: number; min: number; max: number; step: number }
4type State = { value: number; seen: number; dragging: boolean }
5type Surface = {
6 elements: { Box: any; Text: any }
7 state: State | undefined
8 setState: (s: State) => void
9 columns: number
10 onPointer: (fn: (e: { type: string; x: number; button?: string }) => void) => () => void
11 onKey: (fn: (e: { key: string }) => void) => () => void
12 post: (data: unknown) => void
13}
14
15const latest = new WeakMap<object, Props>()
16
17const snap = (p: Props, v: number) => {
18 const n = Math.round((v - p.min) / p.step) * p.step + p.min
19 return Math.min(p.max, Math.max(p.min, Number(n.toFixed(6))))
20}
21
22const widthOf = (s: Surface) => Math.max(8, (s.columns || 18) - 2)
23
24export default function Slider(props: Props, s: Surface) {
25 const { Box, Text } = s.elements
26 latest.set(s, props)
27 const current = (): number => (s.state?.seen === latest.get(s)?.value && s.state ? s.state.value : (latest.get(s)?.value ?? props.value))
28 const move = (v: number, dragging = s.state?.dragging ?? false) => {
29 const p = latest.get(s) ?? props
30 const next = snap(p, v)
31 const prev = current()
32 s.setState({ value: next, seen: p.value, dragging })
33 if (next !== prev) s.post({ value: next })
34 }
35
36 if (s.state === undefined) {
37 s.onKey(e => {
38 const p = latest.get(s) ?? props
39 const big = p.step * 10
40 const by: Record<string, number> = { left: -p.step, right: p.step, h: -p.step, l: p.step, down: -p.step, up: p.step, pagedown: -big, pageup: big }
41 if (e.key === 'home') move(p.min)
42 else if (e.key === 'end') move(p.max)
43 else if (by[e.key] !== undefined) move(current() + (by[e.key] ?? 0))
44 })
45 s.onPointer(e => {
46 const p = latest.get(s) ?? props
47 const at = (x: number) => p.min + ((x - 1) / (widthOf(s) - 1)) * (p.max - p.min)
48 if (e.type === 'down') move(at(e.x), true)
49 else if (e.type === 'move' && s.state?.dragging && e.button) move(at(e.x), true)
50 else if (e.type === 'up' && s.state) s.setState({ ...s.state, dragging: false })
51 })
52 }
53
54 const p = latest.get(s) ?? props
55 const v = current()
56 const w = widthOf(s)
57 const f = Math.max(0, Math.min(w, Math.round(((v - p.min) / (p.max - p.min || 1)) * w)))
58 return (
59 <Box>
60 <Text color="cyan">{`[${'█'.repeat(f)}${'░'.repeat(w - f)}]`}</Text>
61 </Box>
62 )
63}
64types/index.d.ts 49 lines1export type TreeNode = { label: string; children?: TreeNode[] }
2export type Point = { label: string; x: number; y: number }
3export type CalcInput = { id: string; label: string; value: number; min: number; max: number; step: number; unit?: string }
4export type CalcOutput = { id?: string; label: string; formula: string; unit?: string; digits?: number }
5export type Stop = { label: string; lat?: number; lon?: number; note?: string }
6
7// One drawable piece of a ```ui block (one JSON object per line) or of the show tool.
8export type Component =
9 | { type: 'bars'; title?: string; values: Record<string, number>; max?: number; unit?: string }
10 | { type: 'line'; title?: string; x: string[]; series: Record<string, number[]>; unit?: string }
11 | { type: 'scatter'; title?: string; x: string; y: string; points: Point[]; quadrant?: boolean }
12 | { type: 'pie'; title?: string; values: Record<string, number> }
13 | { type: 'table'; title?: string; columns: string[]; rows: (string | number)[][] }
14 | { type: 'tree'; title?: string; nodes: TreeNode[] }
15 | { type: 'flow'; title?: string; lanes: string[][] }
16 | { type: 'graph'; title?: string; nodes: { id: string; label: string }[]; edges: { from: string; to: string; label?: string }[] }
17 | { type: 'card'; title: string; body?: string; fields?: Record<string, string>; tags?: string[] }
18 | { type: 'stat'; label: string; value: string; delta?: string; note?: string }
19 | { type: 'calc'; title?: string; inputs: CalcInput[]; outputs: CalcOutput[] }
20 | { type: 'choice'; id: string; label: string; options: string[]; value?: string }
21 | { type: 'map'; title?: string; stops: Stop[] }
22 | { type: 'send'; label: string; prompt: string }
23
24// The older your-call chart spec; a subset of Component, still drawn.
25export type Viz =
26 | { type: 'bars'; title?: string; values: Record<string, number>; max?: number }
27 | { type: 'flow'; title?: string; lanes: string[][] }
28 | { type: 'tree'; title?: string; nodes: TreeNode[] }
29export type SummaryCard = { id: number; key: string; viz: Viz }
30export type Sort = { col: number; desc: boolean }
31
32declare module 'claude-code' {
33 interface PluginState {
34 'picture-this': {
35 // Control values (calc inputs, choices) by `<scope>/<component>/<field>`.
36 values: Record<string, number | string>
37 sorts: Record<string, Sort>
38 // Components the show tool was given, by tool_use_id.
39 shown: Record<string, Component[]>
40 noClient: boolean
41 ready: boolean
42 }
43 // Published by too-long-didnt-read: summary charts drawn under the answer they belong to.
44 'too-long-didnt-read': { cards: SummaryCard[] }
45 // Published by matchmaker; picture-this skips its own classifier while matchmaker is on.
46 'matchmaker': { isOff: boolean }
47 }
48}
49