SLOPSHOPPER

picture-this

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

newrowsguardtoastprompttool
★ 1v0.5.0MITupdated 2026-10-11danielriddell21/skills/plugins/picture-this
A shopper browsing a rack in a slop shop
README

picture-this

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.

  • How Claude draws: a fenced ``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.
  • Components: 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.
  • Live controls, no model call: 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 back: a send button fills {id}s from the block's controls and puts the prompt in your input box. It never submits; you press Enter.
  • Picking a picture: each prompt goes through a cheap classifier that suggests a component (table, chart, diagram, calculator, map, choice). While 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.
  • Rules Claude is given: one idea per block, short labels, say when numbers are estimates or sample data, never invent live data, every control must change an output, and the answer must stand without the picture.
  • Hooks for other plugins: 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.

Source 7 files
hooks/register.tsx 450 lines
1import { 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}
450
hooks/formula.ts 200 lines
1// 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}
200
hooks/spec.ts 272 lines
1// 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}
272
hooks/svg.ts 240 lines
1// 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('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;')
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}
240
hooks/text.ts 152 lines
1// 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}
152
hooks/slider.tsx 64 lines
1// 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}
64
types/index.d.ts 49 lines
1export 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