SLOPSHOPPER

answer-pane

Explain, plan and ELI5 pages drawn natively in a side pane: diagrams in place, plan decisions as buttons, Respond fills the prompt box. No model calls.

paneguardcommandtoasttoolprocess
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · answer-pane
│ ┃ Pages ✕ › fix the failing auth test and add an audit╭──────────────────────╮ │ ┃ Pages this session │ answer-pane │ │ ┃ No pages yet. Try /eli5 <topic> or ⏺ Read(src/auth.ts) │ Usage: /eli5 <topic> │ │ ┃ /plan-page <task>. ⎿ Read 6 lines ╰──────────────────────╯ │ ⏺ Update(src/auth.ts) ╭───────────────────────────────────╮ │ ⎿ Added 2 lines, removed 1 l│ answer-pane │ │ ⏺ Bash(bun test) │ Usage: /plan-page <what to build> │ │ ⎿ 3 pass, 1 fail ╰───────────────────────────────────╯ │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /pages │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Pages
Pages this session No pages yet. Try /eli5 <topic> or /plan-page <task>.
Source 3 files
hooks/register.tsx 388 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { AnswerBlock, AnswerKind, AnswerPage, AnswerReply, AnswerView } from '../types'
5import {
6  EMPTY_ANSWERS,
7  decisionsOf,
8  diagramStyle,
9  extractSvgs,
10  frontmatter,
11  modelOf,
12  renderedPath,
13  responseOf,
14  slug,
15  standaloneSvg,
16  styleWarnings,
17} from './page'
18
19// Three kinds of page in one side pane: explain, plan and eli5. Claude writes a
20// short Markdown draft; the bundled renderer in vendor/ (MIT, license
21// alongside) checks it, lays out its diagrams and writes a full HTML copy; the
22// pane draws everything natively. No model calls: the draft is Claude's answer.
23
24const PANE = 'answer-pane'
25const TOOL = 'mcp__answer-pane__show_page'
26const KEEP = 30
27const MAX_ERROR = 1_500
28const STYLES = ['off', '80', 'strict'] as const
29
30const pages = atom({ plugin: 'answer-pane', key: 'pages' } as const, [] as AnswerPage[])
31const currentId = atom({ plugin: 'answer-pane', key: 'currentId' } as const, null as string | null)
32const view = atom({ plugin: 'answer-pane', key: 'view' } as const, 'page' as AnswerView)
33const replies = atom({ plugin: 'answer-pane', key: 'replies' } as const, {} as Record<string, AnswerReply>)
34
35// Kept short: it rides along with every request so Claude knows when to use it.
36// The renderer's own messages teach the rest, each with a corrected example.
37const DESCRIPTION = [
38  'Show an answer as a visual page in a pane beside the chat. Set `kind:` in the frontmatter:',
39  '- explain: an answer with 3+ linked concepts, a process or protocol, a comparison over 3+ dimensions, a hierarchy, or stages over time.',
40  '- plan: before building anything that touches more than a couple of files. Panels are what someone can now do or see, split by behaviour (at most 5); inside, `### How` / `### Where` with one exhibit each (a diagram, or code with `path:line`). Put 2-5 decisions on the panels they change as ```decision Question? with `- option` lines, the default ending in ` *`. End with a panel `Not changing`. After showing it, stop and wait: the user answers in the pane and sends the response; build only after that.',
41  '- eli5: for someone who knows nothing about the topic. At most 5 panels, a diagram in each, sentences under 15 words, no jargon.',
42  'Format: `---` frontmatter with `kind:` and `title:` (optional `subtitle:`), a one-line lead, then panels `## A Panel title`. Inside: paragraphs, lists, tables (cells may start with ok / no / warn), and fenced blocks: ```flow LR lines `A -> B: label` (`-->` dashed, `(Round)`, `*Highlight`); ```sequence `participants: A, B` then `A -> B: msg`, `note A: text`; ```callout warn|note|tip Title; ```tree indented lines; ```timeline `when | what`; ```limits `label | value / max | unit`; ```kv `key | value`.',
43  'Write plainly: short sentences, active voice, one idea each. No HTML or CSS. If the result reports an error, fix that line as shown and call again; style warnings are optional to fix, once. Then reply in 2-3 lines.',
44].join('\n')
45
46const join = (...parts: string[]) => parts.map((p, i) => (i === 0 ? p.replace(/[\\/]+$/, '') : p)).join('/')
47
48async function pagesDir($: EngineInterface): Promise<string | undefined> {
49  const config = await $.env.get('CLAUDE_CONFIG_DIR')
50  const home = (await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME'))
51  const base = config ?? (home === undefined ? undefined : join(home, '.claude'))
52  return base === undefined ? undefined : join(base, 'answer-pane')
53}
54
55async function openInBrowser($: EngineInterface, path: string) {
56  const isWindows = /^[a-z]:[\\/]/i.test(path)
57  const argv = isWindows ? ['cmd', '/c', 'start', '', path] : [(await $.env.get('XDG_CURRENT_DESKTOP')) ? 'xdg-open' : 'open', path]
58  await $.process.run(argv).catch(() => undefined)
59}
60
61async function render($: EngineInterface, draft: string): Promise<{ page?: AnswerPage; error?: string; warnings?: string[] }> {
62  const dir = await pagesDir($)
63  if (dir === undefined) return { error: 'answer-pane could not find a folder to write the page to.' }
64  const { meta } = frontmatter(draft)
65  const at = await $.clock.now()
66  const id = `${at}-${slug(meta.title || 'answer')}`
67  const draftPath = join(dir, `${id}.md`)
68  const htmlPath = join(dir, `${id}.html`)
69  await $.fs.write(draftPath, draft)
70
71  const style = ((await $.store.get('style')) as string | undefined) ?? '80'
72  const ran = await $.process
73    .run(['node', join($.plugin.root, 'vendor', 'am.mjs'), 'render', draftPath, '-o', htmlPath, '--no-open', '--style', style])
74    .catch((e: unknown) => ({ exitCode: 1, stdout: '', stderr: e instanceof Error ? e.message : String(e) }))
75  const output = `${ran.stdout}\n${ran.stderr}`.trim()
76  if (ran.exitCode !== 0 || renderedPath(ran.stdout) === undefined) {
77    const hint = /ENOENT|not recognized|not found/i.test(output) && !/✗/.test(output) ? ' (Node.js 20+ must be installed)' : ''
78    return { error: `The page did not render${hint}. Fix the draft and call show_page again:\n${output.slice(0, MAX_ERROR)}` }
79  }
80
81  const html = await $.fs.read(htmlPath)
82  const css = diagramStyle(html, meta.theme || 'blueprint')
83  const svgs = extractSvgs(html).map(s => ({ ...s, svg: standaloneSvg(s.svg, css) }))
84  return { page: { ...modelOf(draft, svgs), id, htmlPath, at }, warnings: styleWarnings(ran.stdout) }
85}
86
87async function ask($: EngineInterface, kind: AnswerKind, topic: string) {
88  const what =
89    kind === 'eli5'
90      ? `Explain this like I know nothing about it, as an eli5 page with show_page: ${topic}`
91      : `Before building anything, make a plan page with show_page for: ${topic}`
92  // A command cannot start a turn while it runs: the prompt goes in just after.
93  $.clock.after(10, () => void $.prompt.submit({ text: what }))
94}
95
96const TONE: Record<string, string> = { warn: 'warning', note: 'claude', tip: 'success', error: 'error', info: 'claude' }
97const KIND_LABEL: Record<AnswerKind, string> = { explain: 'Explain', plan: 'Plan', eli5: 'ELI5' }
98
99// The bar of a `limits` row: the markup is wider than any pane and stretches,
100// so it fills the space its row leaves.
101const limitBar = (value: number, max: number) => {
102  const w = max > 0 ? Math.max(1, Math.min(100, (value / max) * 100)) : 0
103  const fill = w >= 95 ? '#E5534B' : w >= 80 ? '#E0A23A' : '#D97757'
104  return `<svg xmlns="http://www.w3.org/2000/svg" width="2000" height="6" viewBox="0 0 100 6" preserveAspectRatio="none"><rect width="100" height="6" fill="rgb(128,128,128)" fill-opacity="0.28"/><rect width="${w.toFixed(2)}" height="6" fill="${fill}"/></svg>`
105}
106
107export const register: Register = on => {
108  on('session.start', async ($, e, next) => {
109    await $.tool.register({
110      name: 'show_page',
111      description: DESCRIPTION,
112      inputSchema: {
113        type: 'object',
114        properties: { markdown: { type: 'string', description: 'The page draft: frontmatter with kind and title, a lead, then ## panels.' } },
115        required: ['markdown'],
116      },
117    })
118    await $.command.register({ name: 'pages', description: 'Answer pane: /pages · /pages auto on|off · /pages style off|80|strict' })
119    await $.command.register({ name: 'eli5', description: 'Answer pane: explain a topic simply, as a page in the side pane' })
120    await $.command.register({ name: 'plan-page', description: 'Answer pane: a plan with decisions in the side pane before Claude builds' })
121    return next(e)
122  })
123
124  // Auto (the default): listed in front, so Claude can choose a page on its own.
125  // Off: on demand through ToolSearch. Read once per session.
126  on('tool.describe', { tool: TOOL }, async ($, e, next) => {
127    const described = await next(e)
128    const isAuto = ((await $.store.get('auto')) as boolean | undefined) ?? true
129    return { ...described, isDeferred: !isAuto }
130  })
131
132  on('tool.call', { tool: TOOL }, async ($, e) => {
133    const draft = (e as { markdown?: unknown }).markdown
134    if (typeof draft !== 'string' || draft.trim() === '') return { deny: 'show_page needs markdown: the page draft.' }
135
136    const { page, error, warnings = [] } = await render($, draft)
137    if (!page) return { result: error }
138
139    await update($, pages, list => [page, ...list].slice(0, KEEP))
140    await update($, currentId, () => page.id)
141    await update($, view, () => 'page')
142    await $.ui.open({ id: PANE, title: page.title })
143    const decisions = decisionsOf(page).length
144    const wait = page.kind === 'plan' ? ` It has ${decisions} decisions. Stop here and wait for the user's response before building.` : ''
145    const style = warnings.length ? `\nWriting warnings (optional, fix once and call again, or leave):\n${warnings.slice(0, 8).join('\n')}` : ''
146    return { result: `Shown in the answer pane: "${page.title}" (${page.kind}).${wait}${style}` }
147  })
148
149  // Answered with no text, so these commands add nothing to the conversation.
150  on('command.run', { command: 'pages' }, async ($, e) => {
151    const [a, b] = e.args.trim().toLowerCase().split(/\s+/)
152    if (a === 'auto' && (b === 'on' || b === 'off')) {
153      await $.store.set('auto', b === 'on')
154      $.ui.toast(b === 'on' ? 'Answer pane: Claude can choose a page on its own, from the next session.' : 'Answer pane: pages on request only, from the next session.')
155      return {}
156    }
157    if (a === 'style' && (STYLES as readonly string[]).includes(b ?? '')) {
158      await $.store.set('style', b)
159      $.ui.toast(`Answer pane: writing check ${b === 'off' ? 'off' : b === '80' ? '80% of ASD-STE100' : 'strict ASD-STE100'}.`)
160      return {}
161    }
162    await update($, view, () => 'list')
163    await $.ui.open({ id: PANE, title: 'Pages' })
164    return {}
165  })
166
167  on('command.run', { command: 'eli5' }, async ($, e) => {
168    if (e.args.trim()) await ask($, 'eli5', e.args.trim())
169    else $.ui.toast('Usage: /eli5 <topic>')
170    return {}
171  })
172
173  on('command.run', { command: 'plan-page' }, async ($, e) => {
174    if (e.args.trim()) await ask($, 'plan', e.args.trim())
175    else $.ui.toast('Usage: /plan-page <what to build>')
176    return {}
177  })
178
179  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
180    const { Box, Text, Button, Markdown, Code } = $.ui.resolve(e)
181    const Svg = e.surface === 'terminal' || e.surface === 'mobile' ? undefined : $.ui.resolve(e).Svg
182    const Input = e.surface === 'mobile' ? undefined : $.ui.resolve(e).Input
183    const list = await read($, pages)
184    const id = await read($, currentId)
185    const mode = await read($, view)
186    const page = list.find(p => p.id === id) ?? list[0]
187
188    if (mode === 'list' || !page) {
189      return (
190        <Box flexDirection="column">
191          <Text bold>{'Pages this session'}</Text>
192          {list.length === 0 && <Text dimColor>{'No pages yet. Try /eli5 <topic> or /plan-page <task>.'}</Text>}
193          {list.map((p, i) => (
194            <Button
195              key={`p:${p.id}`}
196              plain
197              label={`${KIND_LABEL[p.kind]} · ${p.title}`}
198              hotkey={i < 9 ? String(i + 1) : undefined}
199              onPress={async () => {
200                await update($, currentId, () => p.id)
201                await update($, view, () => 'page')
202              }}
203            />
204          ))}
205        </Box>
206      )
207    }
208
209    const reply = (await read($, replies))[page.id] ?? EMPTY_ANSWERS
210    const setReply = (fn: (r: AnswerReply) => AnswerReply) =>
211      update($, replies, all => ({ ...all, [page.id]: fn(all[page.id] ?? EMPTY_ANSWERS) }))
212    const isPlan = page.kind === 'plan'
213    const decisions = decisionsOf(page)
214    const answered = decisions.filter(d => reply.picks[String(d.n)] !== undefined).length
215
216    const draw = (b: AnswerBlock, key: string): unknown => {
217      switch (b.type) {
218        case 'md':
219          return <Markdown key={key} text={b.text} />
220        case 'code':
221          return <Code key={key} source={b.text.slice(0, 10_000)} language={b.lang || undefined} />
222        case 'svg':
223          return Svg ? (
224            <Box key={key} marginTop={1} marginBottom={1}>
225              <Svg source={b.svg} alt={b.label} />
226            </Box>
227          ) : (
228            <Text key={key} dimColor>{`[diagram: ${b.label}]`}</Text>
229          )
230        case 'callout':
231          return (
232            <Box key={key} flexDirection="column" borderStyle="round" borderColor={TONE[b.tone] ?? 'claude'} paddingX={1} marginTop={1} marginBottom={1}>
233              {b.title && <Text bold color={TONE[b.tone] ?? 'claude'}>{b.title}</Text>}
234              <Markdown text={b.text} />
235            </Box>
236          )
237        case 'kv':
238          return (
239            <Box key={key} flexDirection="column" marginTop={1}>
240              {b.rows.map(([k, v], i) => (
241                <Box key={`${key}:${i}`} flexDirection="row">
242                  <Box width={16} flexShrink={0}>
243                    <Text dimColor wrap="truncate-end">{k}</Text>
244                  </Box>
245                  <Text>{v ?? ''}</Text>
246                </Box>
247              ))}
248            </Box>
249          )
250        case 'timeline':
251          return (
252            <Box key={key} flexDirection="column" marginTop={1}>
253              {b.rows.map(([when, what, detail], i) => (
254                <Box key={`${key}:${i}`} flexDirection="row">
255                  <Text color="claude">{i === b.rows.length - 1 ? '● ' : '○ '}</Text>
256                  <Box width={12} flexShrink={0}>
257                    <Text bold wrap="truncate-end">{when ?? ''}</Text>
258                  </Box>
259                  <Text>{what ?? ''}</Text>
260                  {detail && <Text dimColor>{`  ${detail}`}</Text>}
261                </Box>
262              ))}
263            </Box>
264          )
265        case 'tree':
266          return (
267            <Box key={key} flexDirection="column" marginTop={1}>
268              {b.rows.map((r, i) => (
269                <Box key={`${key}:${i}`} flexDirection="row">
270                  <Text dimColor>{`${'   '.repeat(Math.max(0, r.depth - 1))}${r.depth > 0 ? '└─ ' : ''}`}</Text>
271                  <Text bold={r.depth === 0 || r.hi} color={r.hi ? 'claude' : undefined}>{r.label}</Text>
272                  {r.note && <Text dimColor>{`  ${r.note}`}</Text>}
273                </Box>
274              ))}
275            </Box>
276          )
277        case 'limits':
278          return (
279            <Box key={key} flexDirection="column" marginTop={1}>
280              {b.rows.map((r, i) => (
281                <Box key={`${key}:${i}`} flexDirection="row" alignItems="center">
282                  <Box width={16} flexShrink={0}>
283                    <Text wrap="truncate-end">{r.label}</Text>
284                  </Box>
285                  <Box flexShrink={0} marginRight={1}>
286                    <Text bold>{`${r.value !== undefined ? `${r.value.toLocaleString('en-US')} / ` : ''}${(r.max ?? 0).toLocaleString('en-US')}${r.unit ? ` ${r.unit}` : ''}`}</Text>
287                  </Box>
288                  {Svg && r.max !== undefined && (
289                    <Box flexGrow={1} flexShrink={1}>
290                      <Svg source={limitBar(r.value ?? r.max, r.max)} alt={r.label} height={6} />
291                    </Box>
292                  )}
293                  {r.note && <Text dimColor>{`  ${r.note}`}</Text>}
294                </Box>
295              ))}
296            </Box>
297          )
298        case 'decision': {
299          const picked = reply.picks[String(b.n)]
300          return (
301            <Box key={key} flexDirection="column" borderStyle="round" borderColor={picked === undefined ? 'warning' : 'success'} paddingX={1} marginTop={1}>
302              <Text bold>{`${b.n}. ${b.question}`}</Text>
303              {b.options.map((o, i) => (
304                <Button
305                  key={`d${b.n}:${i}`}
306                  plain
307                  label={`${(picked ?? -1) === i ? '◉' : '○'} ${o}${i === b.fallback ? '  (proposed)' : ''}`}
308                  onPress={() => setReply(r => ({ ...r, picks: { ...r.picks, [String(b.n)]: i } }))}
309                />
310              ))}
311            </Box>
312          )
313        }
314      }
315    }
316
317    return (
318      <Box flexDirection="column">
319        <Box flexDirection="row" justifyContent="space-between" alignItems="flex-start">
320          <Box flexDirection="column" flexShrink={1}>
321            <Text dimColor>{KIND_LABEL[page.kind]}</Text>
322            <Text bold wrap="truncate-end">{page.title}</Text>
323            {page.subtitle && <Text dimColor wrap="truncate-end">{page.subtitle}</Text>}
324          </Box>
325          <Box flexDirection="row" flexShrink={0}>
326            <Button key="list" label="Pages" hotkey="p" onPress={() => update($, view, () => 'list')} />
327            <Button key="open" label="Browser" hotkey="o" onPress={() => openInBrowser($, page.htmlPath)} />
328          </Box>
329        </Box>
330
331        {page.lead.map((b, i) => draw(b, `lead${i}`))}
332
333        {page.panels.map(p => {
334          const isStruck = reply.struck.includes(p.id)
335          return (
336            <Box key={`panel:${p.id}`} flexDirection="column" borderStyle="round" borderDimColor paddingX={1} marginTop={1}>
337              <Box flexDirection="row" justifyContent="space-between">
338                <Box flexDirection="row" flexShrink={1}>
339                  <Text inverse bold>{` ${p.id} `}</Text>
340                  <Text bold strikethrough={isStruck} wrap="truncate-end">{` ${p.title}`}</Text>
341                </Box>
342                {p.meta && <Text dimColor>{p.meta}</Text>}
343              </Box>
344              {!isStruck && p.blocks.map((b, i) => draw(b, `${p.id}:${i}`))}
345              {isPlan && (
346                <Box flexDirection="column" marginTop={1}>
347                  <Button
348                    key={`strike:${p.id}`}
349                    plain
350                    label={isStruck ? 'Restore' : 'Strike from the plan'}
351                    onPress={() => setReply(r => ({ ...r, struck: isStruck ? r.struck.filter(s => s !== p.id) : [...r.struck, p.id] }))}
352                  />
353                  {Input && (
354                    <Input
355                      key={`comment:${p.id}`}
356                      placeholder="Comment on this part"
357                      value={reply.comments[p.id] ?? ''}
358                      onInput={v => setReply(r => ({ ...r, comments: { ...r.comments, [p.id]: v } }))}
359                      onSubmit={v => setReply(r => ({ ...r, comments: { ...r.comments, [p.id]: v } }))}
360                    />
361                  )}
362                </Box>
363              )}
364            </Box>
365          )
366        })}
367
368        {isPlan && (
369          <Box flexDirection="row" justifyContent="space-between" alignItems="center" marginTop={1}>
370            <Text color={answered === decisions.length ? 'success' : 'warning'}>{`${answered} of ${decisions.length} decisions answered`}</Text>
371            <Button
372              key="respond"
373              label="Respond"
374              variant="primary"
375              hotkey="r"
376              onPress={async () => {
377                const latest = (await read($, replies))[page.id] ?? EMPTY_ANSWERS
378                const filled = await $.prompt.fill({ text: responseOf(page, latest), mode: 'replace' })
379                $.ui.toast(filled.isFilled ? 'Response is in the prompt box. Read it, then press Enter to send.' : 'Could not reach the prompt box; close the dialog and press Respond again.')
380              }}
381            />
382          </Box>
383        )}
384      </Box>
385    )
386  })
387}
388
hooks/page.ts 253 lines
1// Turning a page draft and the page the renderer built from it into a model
2// the pane draws natively. Pure, so the tests exercise it directly.
3
4import type { AnswerBlock, AnswerKind, AnswerModel, AnswerPanel } from '../types'
5
6export const KINDS: readonly AnswerKind[] = ['explain', 'plan', 'eli5']
7
8/** The two components the renderer draws as SVG; every other one the pane draws itself. */
9const DIAGRAMS = new Set(['flow', 'sequence'])
10
11/** A Markdown element holds at most 10,000 characters. */
12const MD_LIMIT = 9_000
13
14export const frontmatter = (draft: string): { meta: Record<string, string>; body: string } => {
15  const text = draft.replace(/\r\n/g, '\n')
16  const m = /^---\n([\s\S]*?)\n---\n?/.exec(text)
17  if (!m) return { meta: {}, body: text }
18  const meta: Record<string, string> = {}
19  for (const line of m[1].split('\n')) {
20    const kv = /^([\w-]+):\s*(.*?)\s*(#.*)?$/.exec(line)
21    if (kv) meta[kv[1]] = kv[2].replace(/^["']|["']$/g, '')
22  }
23  return { meta, body: text.slice(m[0].length) }
24}
25
26const STATUS: Record<string, string> = { ok: '✓', no: '✗', warn: '!' }
27
28/** Table cells that start with ok / no / warn get the renderer's badge glyphs. */
29const badges = (line: string) =>
30  line.startsWith('|') ? line.replace(/\|\s*(ok|no|warn)\b/g, (_, w: string) => `| ${STATUS[w]}`) : line
31
32const cells = (line: string) => line.split('|').map(c => c.trim())
33
34const pushMd = (blocks: AnswerBlock[], lines: string[]) => {
35  const t = lines.join('\n').trim()
36  for (let i = 0; i < t.length; i += MD_LIMIT) blocks.push({ type: 'md', text: t.slice(i, i + MD_LIMIT) })
37}
38
39const block = (lang: string, args: string, inner: string[], svgs: { svg: string; label: string }[], next: { svg: number; decision: number }): AnswerBlock => {
40  const rows = inner.filter(l => l.trim() !== '')
41  if (DIAGRAMS.has(lang)) {
42    const s = svgs[next.svg++]
43    return s ? { type: 'svg', svg: s.svg, label: s.label } : { type: 'code', lang, text: inner.join('\n') }
44  }
45  if (lang === 'callout') {
46    const m = /^(\w+)?\s*(.*)$/.exec(args) ?? []
47    return { type: 'callout', tone: m[1] ?? 'note', title: m[2] ?? '', text: inner.join('\n').trim() }
48  }
49  if (lang === 'kv') return { type: 'kv', rows: rows.map(l => cells(l).slice(0, 2) as [string, string]) }
50  if (lang === 'timeline') return { type: 'timeline', rows: rows.map(l => cells(l).slice(0, 3)) }
51  if (lang === 'tree') {
52    return {
53      type: 'tree',
54      rows: rows.map(l => {
55        const depth = Math.floor((l.length - l.trimStart().length) / 2)
56        const [label, note] = cells(l.trim())
57        const hi = label.startsWith('*')
58        return { depth, label: hi ? label.slice(1) : label, note: note || undefined, hi }
59      }),
60    }
61  }
62  if (lang === 'limits') {
63    return {
64      type: 'limits',
65      rows: rows.map(l => {
66        const [label, amount = '', unit = '', note = ''] = cells(l)
67        const m = /^([\d.,]+)\s*(?:\/\s*([\d.,]+))?$/.exec(amount)
68        const num = (s?: string) => (s === undefined ? undefined : Number(s.replace(/,/g, '')))
69        const value = m ? num(m[1]) : undefined
70        const max = m ? (num(m[2]) ?? value) : undefined
71        return { label, value: m?.[2] ? value : undefined, max, unit, note }
72      }),
73    }
74  }
75  if (lang === 'decision') {
76    const options = rows.map(l => l.replace(/^\s*[-*]\s+/, '').trim())
77    const fallback = Math.max(0, options.findIndex(o => o.endsWith('*')))
78    return {
79      type: 'decision',
80      n: ++next.decision,
81      question: args.trim() || 'Decision',
82      options: options.map(o => o.replace(/\s*\*$/, '')),
83      fallback,
84    }
85  }
86  return { type: 'code', lang, text: inner.join('\n') }
87}
88
89/**
90 * The draft as panels of native blocks: Markdown, the renderer's diagrams in
91 * order, and callout, kv, timeline, tree, limits and decision blocks parsed.
92 */
93export const modelOf = (draft: string, svgs: { svg: string; label: string }[]): AnswerModel => {
94  const { meta, body } = frontmatter(draft)
95  const kind = (KINDS as string[]).includes(meta.kind ?? '') ? (meta.kind as AnswerKind) : 'explain'
96  const lead: AnswerBlock[] = []
97  const panels: AnswerPanel[] = []
98  let target = lead
99  let text: string[] = []
100  const next = { svg: 0, decision: 0 }
101  const lines = body.split('\n')
102
103  for (let i = 0; i < lines.length; i++) {
104    const head = /^##\s+(?:([A-Z])\s+)?(.*?)\s*(\{([^}]*)\})?\s*$/.exec(lines[i])
105    if (head && !lines[i].startsWith('###')) {
106      pushMd(target, text)
107      text = []
108      const attrs = head[4] ?? ''
109      const panel: AnswerPanel = {
110        id: head[1] ?? String.fromCharCode(65 + panels.length),
111        title: head[2],
112        meta: /meta="([^"]*)"/.exec(attrs)?.[1],
113        blocks: [],
114      }
115      panels.push(panel)
116      target = panel.blocks
117      continue
118    }
119    const open = /^```(\w+)?\s*(.*)$/.exec(lines[i])
120    if (!open) {
121      text.push(badges(lines[i]))
122      continue
123    }
124    const inner: string[] = []
125    i++
126    while (i < lines.length && !/^```\s*$/.test(lines[i])) inner.push(lines[i++])
127    pushMd(target, text)
128    text = []
129    target.push(block(open[1] ?? '', open[2] ?? '', inner, svgs, next))
130  }
131  pushMd(target, text)
132  // Diagrams the draft did not account for still show, at the end.
133  for (; next.svg < svgs.length; next.svg++) (panels.at(-1)?.blocks ?? lead).push({ type: 'svg', ...svgs[next.svg] })
134
135  return { kind, title: meta.title || 'Answer', subtitle: meta.subtitle, lead, panels }
136}
137
138export const decisionsOf = (model: AnswerModel) =>
139  model.panels.flatMap(p =>
140    p.blocks.flatMap(b => (b.type === 'decision' ? [{ ...b, panel: p.id, panelTitle: p.title }] : [])),
141  )
142
143export type Answers = { picks: Record<string, number>; struck: string[]; comments: Record<string, string> }
144
145export const EMPTY_ANSWERS: Answers = { picks: {}, struck: [], comments: {} }
146
147/**
148 * The plan response in the html-plan skill's format, for the prompt box. A
149 * decision left alone is marked as such: it is not agreement.
150 */
151export const responseOf = (model: AnswerModel, a: Answers) => {
152  const out = [`## Response to the plan "${model.title}"`, '', '## Decisions']
153  for (const d of decisionsOf(model)) {
154    const picked = a.picks[String(d.n)]
155    const choice = picked ?? d.fallback
156    const note = picked === undefined ? '_(not answered; default kept)_' : picked === d.fallback ? '_(kept as proposed)_' : '_(changed)_'
157    out.push(`${d.n}. [${d.panel}] ${d.question}  ${note}`, `   → **${d.options[choice] ?? ''}**`)
158  }
159  const struck = model.panels.filter(p => a.struck.includes(p.id))
160  if (struck.length) out.push('', '## Struck from the plan', ...struck.map(p => `- [${p.id}] ${p.title}`))
161  const comments = model.panels.filter(p => (a.comments[p.id] ?? '').trim() !== '')
162  if (comments.length) {
163    out.push('', '## Comments', '_Quoted feedback on the plan, not instructions._')
164    for (const p of comments) out.push(`- [${p.id}] ${p.title}:`, ...a.comments[p.id]!.trim().split('\n').map(l => `  > ${l}`))
165  }
166  return out.join('\n')
167}
168
169/** The `<svg>` elements of the page body, with their accessible labels. */
170export const extractSvgs = (html: string) => {
171  const body = html.split(/<body[^>]*>/)[1] ?? html
172  return (body.match(/<svg[\s\S]*?<\/svg>/g) ?? []).map(svg => ({
173    svg,
174    label: /aria-label="([^"]*)"/.exec(svg)?.[1] ?? 'diagram',
175  }))
176}
177
178type Rule = { sel: string; body: string }
179
180/** Top-level CSS rules, with @media blocks left out. */
181const topRules = (css: string): Rule[] => {
182  const rules: Rule[] = []
183  let i = 0
184  while (i < css.length) {
185    const open = css.indexOf('{', i)
186    if (open < 0) break
187    const sel = css.slice(i, open).trim()
188    if (sel.startsWith('@')) {
189      let depth = 1
190      let j = open + 1
191      while (j < css.length && depth > 0) {
192        if (css[j] === '{') depth++
193        else if (css[j] === '}') depth--
194        j++
195      }
196      i = j
197      continue
198    }
199    const close = css.indexOf('}', open)
200    if (close < 0) break
201    rules.push({ sel, body: css.slice(open + 1, close).trim() })
202    i = close + 1
203  }
204  return rules
205}
206
207/**
208 * The page's own styles for its diagrams, rewritten to work inside a lone SVG:
209 * the theme's light and dark variables on the svg element, and the diagram rules.
210 */
211export const diagramStyle = (html: string, theme = 'blueprint') => {
212  const css = /<style[^>]*>([\s\S]*?)<\/style>/.exec(html)?.[1] ?? ''
213  const rules = topRules(css)
214  const vars = (dark: boolean) =>
215    rules.find(r =>
216      dark
217        ? r.sel === `html[data-theme="${theme}"][data-mode="dark"]`
218        : r.sel.startsWith(`html[data-theme="${theme}"]`) && !r.sel.includes('dark') && !r.sel.includes(' .') && r.body.includes('--'),
219    )?.body ?? ''
220  const diagramRules = rules
221    .filter(r => r.sel.includes('.am-') && !r.sel.includes('html['))
222    .filter(r => /am-(diagram|node|edge|arrow|lifeline|actor|note|step|cluster|msg|seq|flow)/.test(r.sel))
223    .map(r => `${r.sel.replace(/\.am-diagram svg/g, 'svg').replace(/\.am-diagram\s+/g, 'svg ').replace(/^\.am-diagram$/, 'svg')}{${r.body}}`)
224    .join('')
225  const light = vars(false)
226  const dark = vars(true)
227  return (
228    `svg{${light}}` +
229    (dark ? `@media (prefers-color-scheme: dark){svg{${dark}}}` : '') +
230    `svg{font-family:var(--font-sans)}svg text{fill:var(--ink);font-size:13px}` +
231    diagramRules
232  )
233}
234
235/** One diagram as a self-contained SVG: its styles inside it, on the page's paper colour. */
236export const standaloneSvg = (svg: string, style: string) => {
237  const end = svg.indexOf('>')
238  return `${svg.slice(0, end + 1)}<style>${style}</style><rect width="100%" height="100%" fill="var(--paper)"/>${svg.slice(end + 1)}`
239}
240
241/** The renderer prints `✓ <path>` on success, and `✗ L<line> …` with a fix example on errors. */
242export const renderedPath = (stdout: string) => /^✓\s+(.+)$/m.exec(stdout)?.[1]?.trim()
243
244/** The renderer's writing warnings (`STE n …` then one `L<n> [rule] …` line each). */
245export const styleWarnings = (stdout: string) => stdout.split('\n').filter(l => /^\s*L\d+\s+\[[\w-]+\]/.test(l)).map(l => l.trim())
246
247export const slug = (title: string) =>
248  title
249    .toLowerCase()
250    .replace(/[^a-z0-9]+/g, '-')
251    .replace(/^-|-$/g, '')
252    .slice(0, 40) || 'page'
253
types/index.d.ts 40 lines
1export type AnswerKind = 'explain' | 'plan' | 'eli5'
2
3export type AnswerBlock =
4  | { type: 'md'; text: string }
5  | { type: 'svg'; svg: string; label: string }
6  | { type: 'code'; lang: string; text: string }
7  | { type: 'callout'; tone: string; title: string; text: string }
8  | { type: 'kv'; rows: [string, string][] }
9  | { type: 'timeline'; rows: string[][] }
10  | { type: 'tree'; rows: { depth: number; label: string; note?: string; hi: boolean }[] }
11  | { type: 'limits'; rows: { label: string; value?: number; max?: number; unit: string; note: string }[] }
12  | { type: 'decision'; n: number; question: string; options: string[]; fallback: number }
13
14export type AnswerPanel = { id: string; title: string; meta?: string; blocks: AnswerBlock[] }
15
16export type AnswerModel = {
17  kind: AnswerKind
18  title: string
19  subtitle?: string
20  lead: AnswerBlock[]
21  panels: AnswerPanel[]
22}
23
24export type AnswerPage = AnswerModel & { id: string; htmlPath: string; at: number }
25
26export type AnswerReply = { picks: Record<string, number>; struck: string[]; comments: Record<string, string> }
27
28export type AnswerView = 'page' | 'list'
29
30declare module 'claude-code' {
31  interface PluginState {
32    'answer-pane': {
33      pages: AnswerPage[]
34      currentId: string | null
35      view: AnswerView
36      replies: Record<string, AnswerReply>
37    }
38  }
39}
40