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.

newpaneguardcommandtoasttool
★ 4v0.2.0MITupdated 2026-10-08VedantAndhale/claude-pro-kit/plugins/answer-pane
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>.
README

claude-pro-kit

test

Make the $20 Claude Pro plan last longer in Claude Code.

Twenty-two small mods that show you exactly where your usage goes and cut the waste. One mod makes a model call: prompt-polish, once each time you press Improve, and never on its own. The others make none. None adds anything to the system prompt (read-cap adds one tool, which Claude Code lists by name only until Claude first uses it; write-guard adds one line to a tool result at most once per conversation; compact-keeper adds a note of at most 4,000 characters after a compaction): every figure on screen is one Claude Code already reports, or a time the mod measured.

ModWhat it doesWhere
tool-dietLoads tools you have not used lately on demand instead of with every requestEverywhere
skill-dietLists skills you have not used lately in this project by name only, without their descriptionsEverywhere
agent-dietRuns Explore subagents on Haiku instead of your main modelEverywhere
context-xray/xray opens the exact breakdown of what fills your context windowEverywhere
pro-hudLive meters above the prompt for your 5-hour session, your week and the context window, plus a per-turn receipt of tokens in, cached and outClaude desktop app
output-dietTrims long shell output, search results and subagent reports before Claude reads them, keeping the head, the tail and the error lines; the untrimmed text is saved to a file Claude can open without a permission promptEverywhere
reread-guardSkips Claude re-reading a file it already read when the file has not changed, with Read or a plain cat, sed -n, head, tail or Get-Content; a deliberate retry still goes throughEverywhere
write-guardSteers Claude to Edit instead of rewriting an existing file in full with Write; a deliberate rewrite still goes throughEverywhere
loop-guardHolds back a shell command that already failed twice in a row, so Claude changes approachEverywhere
cmd-dietAdds quiet flags to noisy shell commands before they run, so Claude reads short output from the start; errors, failures and warnings stay in fullEverywhere
gh-accountRuns each git push, pull, fetch, clone and gh command as the logged-in GitHub account that can see the repository, without switching the active accountEverywhere
cache-clockCounts down until the prompt cache expires; once it has, shows exactly how many tokens your next message will re-send uncachedEverywhere
read-capStops Claude reading a file over 1,000 lines whole (with Read, cat or Get-Content) and gives it an outline tool, so it reads only the lines it needs; a retry still reads the whole fileEverywhere
session-receipt/receipt opens a pane with the exact tokens every turn of the session spent, and the costliest turnsEverywhere
peekTyping status while background tasks run opens a pane with each one's exact elapsed time and last output lines, instead of sending the prompt to ClaudeEverywhere
budget-guardHolds a prompt back once your 5-hour or weekly usage reaches your limit (90% by default); sending it again goes throughEverywhere
turn-budgetWhen one turn uses more than +5 session points, writes a handoff and continues in a fresh session by itself; /handoff any timeEverywhere
compact-keeperAfter a compaction, adds a note of exact facts from before it: your latest prompt in full, the todo list, the last failed command, files editedEverywhere
collision-guardAsks before Claude edits a file another chat on this machine changed in the last 30 minutesEverywhere
answer-paneExplain, plan and ELI5 pages drawn natively in a side pane; plans have decision buttons and Respond fills the prompt boxDesktop app (no diagrams in the terminal)
prompt-polishAn Improve button beside Send (above the prompt in the terminal) rewrites your draft with Opus at low effort and puts it back in the box; Undo restores it. One model call per pressEverywhere
kit-updatesTells you when an installed mod from this kit has a newer version or a new mod joins the kit; /kit-update installs updates and new modsEverywhere

pro-hud's band updating live while Claude works

  • With tool-diet, every request in a fresh session was 15,954 tokens smaller (−36%): 43,859 → 27,905, as the API reported. Details.
  • With skill-diet on top of the other mods, every request in a fresh session was 8,226 tokens smaller (−30%): 27,423 → 19,197, as the API reported. Details.
  • With agent-diet, a task that sends one Explore subagent cost 33% less on a Sonnet session: $0.05570 → $0.03741, averaged over three runs each. Details.
  • In the benchmark, a debugging task cost 33% less with output-diet and reread-guard on, averaged over three runs each.

Install

In Claude Code:

/plugin marketplace add VedantAndhale/claude-pro-kit
/plugin install tool-diet@claude-pro-kit
/plugin install skill-diet@claude-pro-kit
/plugin install agent-diet@claude-pro-kit
/plugin install context-xray@claude-pro-kit
/plugin install pro-hud@claude-pro-kit
/plugin install output-diet@claude-pro-kit
/plugin install reread-guard@claude-pro-kit
/plugin install write-guard@claude-pro-kit
/plugin install loop-guard@claude-pro-kit
/plugin install cmd-diet@claude-pro-kit
/plugin install gh-account@claude-pro-kit
/plugin install cache-clock@claude-pro-kit
/plugin install read-cap@claude-pro-kit
/plugin install session-receipt@claude-pro-kit
/plugin install peek@claude-pro-kit
/plugin install budget-guard@claude-pro-kit
/plugin install turn-budget@claude-pro-kit
/plugin install compact-keeper@claude-pro-kit
/plugin install collision-guard@claude-pro-kit
/plugin install answer-pane@claude-pro-kit
/plugin install prompt-polish@claude-pro-kit
/plugin install kit-updates@claude-pro-kit

Updates are off by default for marketplaces you add yourself. With kit-updates installed you are told when a fix ships and /kit-update installs it, from the desktop app or the terminal. Without it, turn on auto-update once: in a terminal, run claude, then /plugin → Marketplaces → claude-pro-kit → Enable auto-update; the desktop app has no toggle for it.

Install any one on its own; they do not depend on each other. Mods are not sandboxed, so read the code before installing: each mod is a single file under plugins/<name>/hooks/.

tool-diet

Every tool listed in front sends its whole description and schema with every request. A deferred tool is listed by name only, and Claude loads it through ToolSearch when it needs it; Claude Code already does this for most MCP tools. tool-diet does it for the rest of the tools you are not using: anything outside the core set (Bash, PowerShell, Read, Edit, Write, Glob, Grep, Agent, Skill, ToolSearch, TodoWrite, AskUserQuestion) that you have not used in your last five sessions.

Measured with one prompt, "Reply with just OK.", in a fresh session, as the API reported each request:

Prompt tokens per request
Without tool-diet43,859
With tool-diet27,905
Change−15,954 (−36%)

On that setup, the largest tool moved was Artifact, whose description alone is 19,870 characters. What moves depends on your tools and your habits; /xray shows yours.

  • The first time a deferred tool is used in a session costs one extra step, a ToolSearch call. After that it stays loaded for the session.
  • Using a tool keeps it loaded for your next five sessions, so the set follows what you actually use.
  • The choice is made once per tool per session, so the prompt cache is never disturbed mid-session. Changes apply from the next session.
  • /tool-diet lists what is on demand this session, grouped by where each tool comes from; /tool-diet keep <tool> always loads one, unkeep undoes it, and /tool-diet off|on switches it. Answers are toasts, so they add nothing to the conversation. The status line keeps the count: 38 tools on demand.

The /tool-diet toast: 38 tools on demand, grouped by source

skill-diet

Every request carries the skill listing: each installed skill's name and its whole description. With a few plugins installed that is dozens of skills, most of which a given project never uses (video skills in a backend repo, document skills in a game). skill-diet keeps the skills you used lately in this project listed in full and lists the rest on one line by name only. The Skill tool still loads any of them, and typing /name still works.

Measured with one prompt, "Reply with just OK.", in a fresh session with 45 skills installed and the other mods on, as the API reported the request:

Prompt tokens per request
Without skill-diet27,423
With skill-diet19,197
Change−8,226 (−30%)

In the same setup, asked to fill in a PDF form, Claude found anthropic-skills:pdf from its name alone and loaded it with the Skill tool. What moves depends on your skills; /xray shows yours.

  • A skill used in this project in one of your last five sessions stays listed in full. Every use counts: typed as /name or called by Claude through the Skill tool.
  • Usage is kept per project folder, so a skill you use in one repo does not crowd the listing in another.
  • A skill installed after skill-diet stays listed in full for five sessions, so Claude can find it before you have used it.
  • The listing is decided once per session, so the prompt cache is never disturbed mid-session. Changes apply from the next session.
  • /skill-diet shows what is listed by name only this session and how many characters left the listing; /skill-diet keep <skill> always lists one in full, unkeep undoes it, and /skill-diet off|on switches it. Answers are toasts, so they add nothing to the conversation. The status line keeps the count, in the form <n> skills by name only, <n> characters off.
  • /xray shows the skills line before and after, in tokens.

agent-diet

A subagent runs on your main model unless its definition or Claude's Agent call names another. Explore only searches and reads, yet in the author's own 85 subagent transcripts, every one of the 2,043 Explore requests ran on Opus or Sonnet, reading 147,298,992 input tokens. agent-diet starts the agent types you list (Explore by default) on Haiku.

  • A model Claude names in the Agent call is kept, and a fork always runs on its parent's model.
  • Other agent types (general-purpose, Plan, your own) keep their usual model.
  • /agent-diet shows the setting and how many subagents it moved this session; /agent-diet model haiku|sonnet|opus picks the model, /agent-diet add|remove <agent type> changes the list, and /agent-diet off|on switches it. Answers are toasts, so they add nothing to the conversation. The status line keeps the count, in the form 2 subagents on haiku.

Measured with claude -p --model sonnet --output-format json on a task that sends one Explore subagent to find a file, three runs each in alternating order. Every run found the file. Figures are the cost and tokens Claude Code reported per model:

RunWithout: costWithout: Sonnet tokens inWith: costWith: Sonnet tokens inWith: Haiku tokens in
1$0.0569268,395$0.0372440,21327,848
2$0.0557768,111$0.0376439,59327,872
3$0.0544168,123$0.0373640,18442,770
Average$0.05570$0.03741

context-xray

The /xray pane: what is sent with every request, what loads on demand, memory files and listings

/xray opens a pane with the exact breakdown /context computes: what is sent with every request (system prompt, tools, MCP tools, memory files, skills, messages), what is loaded on demand, which MCP tools load every time, and each memory file's size. It measures when the pane opens and when you press Refresh (or r), never in the background, because the exact count sends one token-count request per tool and memory file.

Opens by itself when the context crosses 60% and again at 80%, with a toast pointing at /compact and /handoff. /xray auto off keeps it to /xray only.

pro-hud

The recording at the top of this page is the band. In text:

Session    ━━━━━━━━━━━━━━──────   69%  resets in 32m
Week       ━━━━━━━━━━━━━━━─────   74%  resets in 4d 17h
Context    ━━━━━━━━────────────   44%  436,034 tokens
This turn  1m 35s · 1 tool · 0 files edited · 436,034 in · 431,260 cached · 580 out

On a wide window the three meters sit side by side on one row; on a narrow one each figure on the turn line wraps whole rather than being cut off.

  • Session / Week: the share of your plan's 5-hour and 7-day allowance used, and when each resets. Account-wide: every chat and device counts.
  • Context: how full this conversation is. Every request resends it, so a fuller context spends your session faster; /compact when it climbs.
  • This turn / Last turn: updates live while Claude works (per tool call and per model request), then shows the turn's final figures and how many points of your session it used.
  • The spinner gains · session 20%, and finished tool calls draw as one line: status dot, tool, target, time.
  • A toast when the session crosses 80% and 90%.

/hud shows what is on; /hud all on|off, or /hud band|spinner|cards on|off. The answer is a toast, so toggling adds nothing to the conversation. It draws in the desktop app only and leaves the terminal as it is. Rows other mods put above the prompt still draw beneath the band.

Weekly toasts at 50%, 75% and 90% of the week, once each, because the weekly limit drains quietly across many sessions.

output-diet

When a Bash or PowerShell result runs past 120 lines or 8,000 characters, Claude reads:

[output-diet: 82/500 lines shown; all 500 in ~/.claude/projects/<project>/<session>/tool-results/output-diet-<call>.txt]

<first 30 lines>
… [lines 31–450 omitted; error/warning lines from them:]
   250│ ERROR: build failed in src/app.ts
   300│ Warning: deprecated API
…
<last 50 lines>
  • You still see the output in the transcript as before; only Claude's copy is trimmed.
  • The status line keeps a running count: 3 trimmed · 41,200 chars saved.
  • The untrimmed text is written to disk first; if the write fails, nothing is trimmed.
  • It goes in the session's own tool-results folder, beside the transcript, where Claude Code keeps the outputs it saves itself, so Claude can open it without a permission prompt. When that folder cannot be found, it goes under ~/.claude/output-diet/ (or CLAUDE_CONFIG_DIR).
  • A Grep result past the same limits keeps its first 100 lines, with a note to narrow the search: [output-diet: first 100/252 lines shown; narrow the search, or read all in …]. In the author's 1,048 past Grep results, 26 went past the limits, and keeping the first 100 lines would have cut 104,052 characters. Glob is left alone: none of 128 went past.
  • A subagent's report past 8,000 characters keeps its first 6,000 and last 2,000 characters, so the opening and the conclusion stay. In the author's 123 past reports, 17 went past it, 313,365 characters between them.
  • Claude Code itself already cuts the middle out of very long shell output (past roughly 10,000 characters) before any mod sees it, and for a failing command no uncut copy is kept. output-diet works on what is left: the saved file holds everything Claude would have read, and an error line Claude Code cut is not in it.

reread-guard

When Claude asks to Read the same range of the same file again in the same conversation, and the file's size and modification time have not changed, the read is skipped and Claude is told to use the copy it has. Any edit, a different range, or a subagent (which has its own context) reads freely. Claude Code can clear old tool results from context, so retrying the identical read straight after a skip always goes through. The record resets on /compact and /clear.

What Claude is told in place of the repeat read, kept to one line because the model reads it:

api.ts unchanged since you read it; use that copy. If it's gone from context, retry the same Read.

Shell reads too. Claude often reads a file with the shell instead of Read: in the author's transcripts, 318 sed -n runs went past the guard. A shell command that only prints one file is now treated the same way: cat FILE, sed -n 'A,Bp' FILE, head -n N / tail -n N, and in PowerShell Get-Content, gc, cat or type (with -TotalCount, -Head, -First, -Tail, -Last or -Raw). The same range of the same unchanged file is skipped once, and retrying the same command goes through. A whole cat after a whole Read that showed every line counts as a repeat. A pipe, a chain, a variable, a glob or any other flag runs untouched. A shell read never counts as a Read, since Edit needs a real one first.

The status line counts them: 2 re-reads skipped.

write-guard

Write sends the whole file as Claude's output, the most expensive kind of token; Edit sends only the lines that change. Claude sometimes rewrites a file it has already read in full to change a few lines. In the author's own 272 session transcripts, 141 writes rewrote a file Claude had already read or written (999,273 characters), and 112 of them followed an earlier rewrite in the same session. Of the 512,847 characters in the rewrites whose previous version was in the transcript, 171,325 had changed.

A hook runs after Claude has written the content, so write-guard cannot save the rewrite it sees; it stops the ones after it:

  • The first rewrite of an existing file in a conversation goes through, with one line for Claude after the result: You rewrote all of api.ts. For changes to an existing file use Edit: it sends only the changed lines.
  • A later rewrite is held back once, and Claude is told: api.ts exists; change it with Edit, not a full Write. If a full rewrite is intended, retry the same Write. Retrying the same Write goes through.
  • New files, and files under 2,048 bytes, are never touched. A subagent has its own conversation and its own first rewrite; a compaction or /clear starts over.
  • The status line counts them: 1 full rewrite held back.

loop-guard

Each retry of a failing command re-sends the whole conversation, and the same command usually fails the same way. In the author's 272 session transcripts, 10 commands failed 3 or more times, 38 runs between them.

Once the exact same Bash or PowerShell command has failed twice in a row, the next try is held back once, and Claude is told:

This exact command failed 2 times in a row. Change the approach instead of rerunning it. If a rerun is intended, retry the same command.
  • Retrying the same command straight after the hold goes through, for a command that is meant to be rerun.
  • A success clears the count, and each command is counted on its own. A subagent counts its own commands; a compaction or /clear starts over.
  • The status line counts them: 1 failing retry held back.

cmd-diet

output-diet trims long output after a command has run; cmd-diet keeps the noise from being printed at all. Before a Bash or PowerShell command runs, a known noisy command gets its own quiet flags, which drop progress lines and keep errors, test failures and warnings:

CommandRuns asOutput, measured
git statusgit status --short --branch415 to 58 characters
pytestpytest -q1,063 to 495 characters, failure details unchanged
cargo build / test / check / clippy / runcargo build -q197 to 0 characters on success, warnings unchanged
npm install / cinpm install --no-audit --no-fund62 to 18 characters
curl (Bash only)curl -sS1,049 to 577 characters, the progress meter removed
mvnmvn -B -ntpnot measured: drops download progress
wget (Bash only)wget -nvnot measured: one line per file
docker pulldocker pull -qnot measured: drops layer progress

In a live headless run (git status && python -m pytest on 41 test files, one failing), the request cost 41,535 tokens without cmd-diet and 40,571 with it, by the API's usage, and both runs named the failing test and its reason correctly. The shorter output stays in context, so every later request in the session sends less too.

  • Each step of a &&, || or ; chain is handled on its own. A step that pipes, redirects or substitutes (| grep, > file, $(...)) is left alone, since something else reads its output; 2>&1 is fine.
  • A command that already sets its output level (git status --porcelain, pytest -v, curl -fsSL, npm install --silent) is left alone, and a flag already present is not added twice.
  • If a tool rejects a flag (an old version), that rule stops for the session and Claude's retry runs the command as typed.
  • The status line counts them: 3 commands quieted.

gh-account

With two GitHub accounts logged in to gh, a push to a repository the other account owns fails, and Claude spends turns on gh auth status and gh auth switch. Before a Bash or PowerShell command runs, gh-account matches each git push, pull, fetch, clone, ls-remote and gh step to the repository's owner, and the owner to a logged-in account. When that account is not gh's active one, that step alone runs with its token:

GH_TOKEN="$(gh auth token --user ACCOUNT)" git push

The token itself never appears in the command or its output, and the active account is not switched. For git it also asks gh for the credential.

  • The owner comes from a URL in the command, -R owner/repo, a gh api repos/<owner>/... path, or the folder's remotes. With no remote named, every remote must point at the same owner.
  • The account is the one whose login is the owner, else the one account that is a member of that organization. The accounts come from gh auth status, and organizations from gh api user/orgs. What it learns is kept across sessions.
  • Nothing is guessed: no owner, more than one matching account, or anything unclear leaves the command as typed. It also leaves alone gh auth and other gh commands that reach no repository, a command that already sets GH_TOKEN, a token from the environment, git over ssh, hosts other than github.com, and subshells or substitutions.
  • /gh-account shows which account each owner uses, and /gh-account forget clears it. Answers are toasts. The status line counts them: 2 commands sent as <account>.

cache-clock

Claude's prompt cache keeps your conversation for a fixed time after each request. Reply within it and the context is read from cache; reply after it and the whole context is sent again at full price. That message pays the cache-write price (1.25 times the input price for a 5-minute cache, 2 times for a 1-hour one) on every token instead of the cache-read price (0.1 times): 12.5 to 20 times more for the same context, and nothing on screen says so.

cache-clock puts the countdown in the status line, restarted by every response from the main conversation:

cache warm · 3m left
cache cold · next message re-sends 61,204 tokens

When the cache runs out, a toast says so once (after the lifetime is known, see below). The token figure is the previous response

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