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.

hooks/register.tsx 388 lines1import { 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}
388hooks/page.ts 253 lines1// 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'
253types/index.d.ts 40 lines1export 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