Shows ravn-core's explain-change lessons in a Claude Code pane: background, intuition, a code walkthrough with text diagrams, and a clickable quiz.

Explain Change gives ravn-core's explain-change skill a pane inside Claude Code. Ask Claude to explain a diff, branch, commit, or PR, and the lesson opens beside the conversation in four parts:
path:line references.Flows are Mermaid flowcharts that the pane draws as boxes and connectors at its own width. Widen or narrow the pane and they lay out again. Long labels wrap rather than clip. Connections it cannot draw unambiguously are listed below the drawing; Mermaid it cannot parse stays as source. Other diagrams are text: call trees, file trees, component trees, and diff-shaped sketches of what changed. Text diagrams and diff sketches clip at the pane's width. Code is syntax-highlighted, and diff sketches show added lines in green and removed ones in red.
Cache saves by content hash
Saving unchanged content now returns the cached result.
[ Background ] [ Intuition ] [ Code ] [ Quiz ]
save(a) -> write
save(a) -> cached
Requires Claude Code 2.1.287 or later, and ravn-core for the skill. Without this mod, the skill writes the lesson in chat, in every harness ravn-core supports.
/plugin marketplace add ravnhq/ravn-core
/plugin install ravn-core@ravn
/plugin install explain-change@ravn
For a local session:
claude --plugin-dir ./plugins/agent/ravn-core --plugin-dir ./plugins/mods/explain-change
/ravn-core:explain-change, optionally with a PR number, branch, ref range, or commit, or ask in your own words ("explain this branch"). With no target, Claude explains the current branch against the default branch, plus uncommitted work.1 to 4 to switch parts, or click them. Press ctrl+x tab to move the keyboard into the pane, and use the arrow keys to scroll./explain-change-pane reopens the pane after you close it. A new explanation replaces the last one and resets the quiz.The pane docks as a sidebar only in Claude Code's fullscreen renderer, on a terminal at least 110 columns wide. In the classic renderer it opens above the prompt and shows a tip to switch. Run /tui fullscreen to switch for good, or start a single session with CLAUDE_CODE_NO_FLICKER=1 claude.
The skill combines HumanLayer's show-me skill (MIT License, Copyright (c) 2026 HumanLayer), for its catalogue of small text diagrams, with Geoffrey Litt's explain-diff prompt, for the background, intuition, code, and quiz structure. Drawing Mermaid flowcharts as text that fits the pane follows Ray Amjad's diagram-pane mod.
hooks/register.tsx 301 lines1import { atom, read, update } from "claude-code";
2import type { EngineInterface, Register } from "claude-code";
3
4import {
5 INPUT_SCHEMA,
6 PROSE,
7 SECTIONS,
8 parseExplanation,
9 score,
10 shuffleOptions,
11} from "../lib/explanation.ts";
12import { DIAGRAM_LANGUAGES, segments } from "../lib/markdown.ts";
13import { drawFlowchart, parseFlowchart } from "../lib/flowchart.ts";
14import type { Segment } from "../lib/markdown.ts";
15import type { Question, QuizOption } from "../types";
16
17const PANE = "explain-change";
18
19const TOOL = "show";
20
21const COMMAND = "explain-change-pane";
22
23const TITLE = "Explain change";
24
25const view = atom({ plugin: "explain-change", key: "view" } as const, null);
26
27type Elements = ReturnType<EngineInterface["ui"]["resolve"]>;
28
29function openPane($: EngineInterface) {
30 return $.ui.open({ id: PANE, title: TITLE, focus: true });
31}
32
33// A section opens at its top, wherever the person had scrolled the last one.
34// The scroll is cosmetic: where no surface holds the pane, the switch still lands.
35async function showSection($: EngineInterface, section: number) {
36 await update($, view, (now) => now && { ...now, section });
37 await $.ui.scroll({ to: "start", in: PANE }).catch(() => undefined);
38}
39
40export const register: Register = (on) => {
41 on("session.start", async ($, e, next) => {
42 await Promise.all([
43 $.tool.register({
44 name: TOOL,
45 description:
46 "Show an explanation of a code change in the explain-change pane: background, intuition, a code walkthrough (markdown, text diagrams in fences), and a multiple-choice quiz.",
47 inputSchema: INPUT_SCHEMA,
48 }),
49 $.command.register({
50 name: COMMAND,
51 description: "Reopen the explain-change pane",
52 }),
53 ]);
54
55 return next(e);
56 });
57
58 on("tool.call", { tool: "mcp__explain-change__show" }, async ($, e) => {
59 // The tool's arguments arrive spread beside `tool` and `tool_use_id`.
60 const parsed = parseExplanation(JSON.stringify(e));
61
62 if (!parsed.isValid) return { deny: `Not shown: ${parsed.error}.` };
63
64 const explanation = {
65 ...parsed.explanation,
66 quiz: shuffleOptions(parsed.explanation.quiz),
67 };
68
69 await update($, view, () => ({
70 explanation,
71 section: 0,
72 answers: explanation.quiz.map(() => null),
73 }));
74
75 // Where the pane sits is the render's to say; the result says only whether it shows.
76 const { isPlaced } = await openPane($);
77
78 return {
79 result: isPlaced
80 ? `Shown in the explain-change pane. /${COMMAND} reopens it.`
81 : `Saved; the pane opens once the terminal has room, or with /${COMMAND}.`,
82 };
83 }).catch(() => ({ deny: "Not shown: the explain-change pane failed." }));
84
85 on("command.run", { command: COMMAND }, async ($) => {
86 if ((await read($, view)) === null)
87 return { text: "No explanation yet. Ask Claude to explain a change." };
88
89 await openPane($);
90
91 return { text: "Explain-change pane opened." };
92 }).catch(() => ({ text: "The explain-change pane failed to open." }));
93
94 on("ui.render", { component: "Pane", requestId: PANE }, async ($, e) => {
95 const ui = $.ui.resolve(e);
96 const { Box, Text, Button } = ui;
97 const shown = await read($, view);
98
99 if (shown === null)
100 return (
101 <Text dimColor>
102 No explanation yet. Ask Claude to explain a change.
103 </Text>
104 );
105
106 const { explanation, section, answers } = shown;
107 const field = PROSE[section];
108
109 return (
110 <Box flexDirection="column" gap={1}>
111 <Box flexDirection="column">
112 <Text bold color="claude">
113 {explanation.title}
114 </Text>
115 <Text dimColor>{explanation.summary}</Text>
116 {e.props.placement === "inline" && (
117 <Text dimColor>Run /tui fullscreen to show this as a sidebar.</Text>
118 )}
119 </Box>
120 <Box flexDirection="row" flexWrap="wrap" columnGap={1}>
121 {SECTIONS.map((name, index) => (
122 <Button
123 key={`section-${index}`}
124 label={name}
125 hotkey={String(index + 1)}
126 variant={index === section ? "primary" : "secondary"}
127 onPress={() => showSection($, index)}
128 />
129 ))}
130 </Box>
131 {field === undefined
132 ? quizView(ui, explanation.quiz, answers, (q, o) =>
133 update(
134 $,
135 view,
136 (now) =>
137 now && {
138 ...now,
139 answers: now.answers.map((one, at) => (at === q ? o : one)),
140 },
141 ),
142 )
143 : sectionView(ui, explanation[field], e.props.bodyColumns)}
144 </Box>
145 );
146 });
147};
148
149// Prose draws as markdown; source fences are highlighted, diff sketches
150// coloured line by line, mermaid flowcharts laid out to the pane's width, and
151// text diagrams drawn in an accent. A resize redraws, so flowcharts refit.
152function sectionView(ui: Elements, text: string, width: number) {
153 const { Box } = ui;
154
155 return (
156 <Box flexDirection="column" gap={1}>
157 {segments(text).map((segment, index) =>
158 segmentView(ui, segment, index, width),
159 )}
160 </Box>
161 );
162}
163
164function segmentView(
165 { Box, Text, Markdown, Code }: Elements,
166 segment: Segment,
167 index: number,
168 width: number,
169) {
170 if (segment.kind === "prose")
171 return <Markdown key={`prose-${index}`} text={segment.text} />;
172
173 if (segment.language === "diff")
174 return (
175 <Box key={`diff-${index}`} flexDirection="column">
176 {segment.source.split("\n").map((line, at) => (
177 <Text
178 key={`diff-${index}-${at}`}
179 color={diffColor(line)}
180 wrap="truncate-end"
181 >
182 {line === "" ? " " : line}
183 </Text>
184 ))}
185 </Box>
186 );
187
188 const graph =
189 segment.language === "mermaid" ? parseFlowchart(segment.source) : null;
190
191 if (graph !== null)
192 return (
193 <Text key={`flow-${index}`} color="suggestion" wrap="truncate-end">
194 {drawFlowchart(graph, width).join("\n")}
195 </Text>
196 );
197
198 // A hand-drawn diagram keeps its layout: a narrow pane cuts it, never rewraps it.
199 if (DIAGRAM_LANGUAGES.has(segment.language))
200 return (
201 <Text key={`diagram-${index}`} color="suggestion" wrap="truncate-end">
202 {segment.source}
203 </Text>
204 );
205
206 return (
207 <Code
208 key={`code-${index}`}
209 source={segment.source}
210 language={segment.language}
211 />
212 );
213}
214
215function diffColor(line: string): string | undefined {
216 if (line.startsWith("+")) return "success";
217
218 if (line.startsWith("-")) return "error";
219
220 return undefined;
221}
222
223function quizView(
224 { Box, Text, Button, Markdown }: Elements,
225 quiz: readonly Question[],
226 picked: readonly (number | null)[],
227 onPick: (question: number, option: number) => void,
228) {
229 const answered = picked.filter((one) => one !== null).length;
230
231 return (
232 <Box flexDirection="column" gap={1}>
233 {answered === quiz.length ? (
234 <Text bold color="success">
235 {`${score(quiz, picked)} of ${quiz.length} correct.`}
236 </Text>
237 ) : (
238 <Text dimColor>
239 {`${answered} of ${quiz.length} answered. Pick an option to see why it's right or wrong.`}
240 </Text>
241 )}
242 {quiz.map((question, q) => {
243 const choice = picked[q] ?? null;
244 const chosen = choice === null ? undefined : question.options[choice];
245
246 return (
247 <Box key={`question-${q}`} flexDirection="column">
248 <Text bold>
249 <Text color="claude">{q + 1}.</Text> {question.question}
250 </Text>
251 {question.options.map((option, o) => {
252 if (chosen === undefined)
253 return (
254 <Button
255 key={`answer-${q}-${o}`}
256 plain
257 label={`${letter(o)}) ${option.text}`}
258 onPress={() => onPick(q, o)}
259 />
260 );
261
262 const { glyph, color } = badge(option, o === choice);
263
264 return (
265 <Text
266 key={`answer-${q}-${o}`}
267 dimColor={color === undefined}
268 color={color}
269 >
270 {`${glyph} ${letter(o)}) ${option.text}`}
271 </Text>
272 );
273 })}
274 {chosen && (
275 <Box flexDirection="column">
276 <Text bold color={chosen.isCorrect ? "success" : "error"}>
277 {chosen.isCorrect ? "Correct" : "Not quite"}
278 </Text>
279 <Markdown text={chosen.feedback} />
280 </Box>
281 )}
282 </Box>
283 );
284 })}
285 </Box>
286 );
287}
288
289function letter(index: number): string {
290 return String.fromCharCode(97 + index);
291}
292
293// How an answered question marks one option: the right one, the wrong pick, or neither.
294function badge(option: QuizOption, isPicked: boolean) {
295 if (option.isCorrect) return { glyph: "✓", color: "success" };
296
297 return isPicked
298 ? { glyph: "✗", color: "error" }
299 : { glyph: " ", color: undefined };
300}
301lib/explanation.ts 215 lines1import type { Explanation, Question, QuizOption } from "../types";
2
3// The markdown sections, in tab order; the quiz is the tab after them.
4export const PROSE = ["background", "intuition", "code"] as const;
5
6export const SECTIONS = ["Background", "Intuition", "Code", "Quiz"] as const;
7
8const TEXT_FIELDS = ["title", "summary", ...PROSE] as const;
9
10const MAX_QUESTIONS = 10;
11
12const MIN_OPTIONS = 2;
13
14const MAX_OPTIONS = 6;
15
16// The input schema the model reads. `parseExplanation` enforces it.
17export const INPUT_SCHEMA = {
18 type: "object",
19 properties: {
20 title: { type: "string", description: "What changed, in a few words." },
21 summary: {
22 type: "string",
23 description: "One sentence: what the change does and why.",
24 },
25 background: { type: "string", description: "Markdown." },
26 intuition: { type: "string", description: "Markdown." },
27 code: { type: "string", description: "Markdown." },
28 quiz: {
29 type: "array",
30 minItems: 1,
31 maxItems: MAX_QUESTIONS,
32 items: {
33 type: "object",
34 properties: {
35 question: { type: "string" },
36 options: {
37 type: "array",
38 minItems: MIN_OPTIONS,
39 maxItems: MAX_OPTIONS,
40 items: {
41 type: "object",
42 properties: {
43 text: { type: "string" },
44 isCorrect: { type: "boolean" },
45 feedback: {
46 type: "string",
47 description: "Why this option is right or wrong.",
48 },
49 },
50 required: ["text", "isCorrect", "feedback"],
51 },
52 },
53 },
54 required: ["question", "options"],
55 },
56 },
57 },
58 required: [...TEXT_FIELDS, "quiz"],
59};
60
61type Parsed =
62 | { isValid: true; explanation: Explanation }
63 | { isValid: false; error: string };
64
65type Json = null | boolean | number | string | Json[] | JsonObject;
66
67type JsonObject = { [key: string]: Json };
68
69// Checks a show call's input, as JSON, against INPUT_SCHEMA, naming the first field that fails.
70export function parseExplanation(source: string): Parsed {
71 let input: Json;
72
73 try {
74 input = JSON.parse(source);
75 } catch {
76 return invalid("input must be JSON");
77 }
78
79 if (!isJsonObject(input)) return invalid("input must be an object");
80
81 const missing = TEXT_FIELDS.find((field) => text(input[field]) === undefined);
82
83 if (missing !== undefined)
84 return invalid(`${missing} must be a non-empty string`);
85
86 // Every text field passed the check above, so the fallback never applies.
87 const field = (name: (typeof TEXT_FIELDS)[number]) => text(input[name]) ?? "";
88
89 const { quiz } = input;
90
91 if (!Array.isArray(quiz) || quiz.length === 0 || quiz.length > MAX_QUESTIONS)
92 return invalid(`quiz must hold 1 to ${MAX_QUESTIONS} questions`);
93
94 const questions: Question[] = [];
95
96 for (const [index, entry] of quiz.entries()) {
97 const question = parseQuestion(entry);
98
99 if (question === undefined)
100 return invalid(
101 `quiz[${index}] needs a question and ${MIN_OPTIONS} to ${MAX_OPTIONS} options, each with text, isCorrect, and feedback, exactly one of them correct`,
102 );
103
104 questions.push(question);
105 }
106
107 return {
108 isValid: true,
109 explanation: {
110 title: field("title").trim(),
111 summary: field("summary").trim(),
112 background: field("background"),
113 intuition: field("intuition"),
114 code: field("code"),
115 quiz: questions,
116 },
117 };
118}
119
120function parseQuestion(entry: Json): Question | undefined {
121 if (!isJsonObject(entry)) return undefined;
122
123 const question = text(entry.question);
124 const { options } = entry;
125
126 if (
127 question === undefined ||
128 !Array.isArray(options) ||
129 options.length < MIN_OPTIONS ||
130 options.length > MAX_OPTIONS
131 )
132 return undefined;
133
134 const parsed: QuizOption[] = [];
135 let correct = 0;
136
137 for (const option of options) {
138 if (!isJsonObject(option)) return undefined;
139
140 const label = text(option.text);
141 const feedback = text(option.feedback);
142 const { isCorrect } = option;
143
144 if (
145 label === undefined ||
146 feedback === undefined ||
147 (isCorrect !== true && isCorrect !== false)
148 )
149 return undefined;
150
151 if (isCorrect) correct += 1;
152
153 parsed.push({ text: label, isCorrect, feedback });
154 }
155
156 return correct === 1 ? { question, options: parsed } : undefined;
157}
158
159// Each question's options in random order, so the right answer's letter says
160// nothing about the order the model wrote them in.
161export function shuffleOptions(
162 quiz: readonly Question[],
163 random: () => number = Math.random,
164): Question[] {
165 return quiz.map((question) => {
166 const pool = [...question.options];
167 const options: QuizOption[] = [];
168
169 while (pool.length > 0)
170 options.push(...pool.splice(Math.floor(random() * pool.length), 1));
171
172 return { ...question, options };
173 });
174}
175
176export function score(
177 quiz: readonly Question[],
178 answers: readonly (number | null)[],
179): number {
180 return quiz.filter((question, index) => {
181 const picked = answers[index];
182
183 return picked != null && question.options[picked]?.isCorrect === true;
184 }).length;
185}
186
187function invalid(error: string): Parsed {
188 return { isValid: false, error };
189}
190
191function isJsonObject(value: Json | undefined): value is JsonObject {
192 return value === Object(value) && !Array.isArray(value);
193}
194
195function isString(value: Json | undefined): value is string {
196 return value === String(value);
197}
198
199// Control characters other than tab and newline, which a terminal would read
200// as escape sequences and the pane's elements refuse.
201const CONTROL = new RegExp(
202 "[\\u0000-\\u0008\\u000b-\\u001f\\u007f-\\u009f]",
203 "g",
204);
205
206// The value, stripped of control characters, when it is a string with
207// something besides whitespace.
208function text(value: Json | undefined): string | undefined {
209 if (!isString(value)) return undefined;
210
211 const clean = value.replace(CONTROL, "");
212
213 return clean.trim() === "" ? undefined : clean;
214}
215lib/markdown.ts 72 lines1// A section's markdown, cut at its fenced code blocks so the pane can draw
2// each fence with the element that colours it.
3export type Segment =
4 | { kind: "prose"; text: string }
5 | { kind: "code"; language: string; source: string };
6
7// Fence languages the skill uses for drawings rather than source code.
8export const DIAGRAM_LANGUAGES: ReadonlySet<string> = new Set([
9 "",
10 "text",
11 "txt",
12 "plain",
13]);
14
15// An opening fence: up to three spaces of indent, so a fence nested in a list stays prose.
16const FENCE = /^ {0,3}(`{3,}|~{3,})\s*([\w+#.-]*)/;
17
18export function segments(markdown: string): Segment[] {
19 const result: Segment[] = [];
20 const prose: string[] = [];
21 const lines = markdown.split("\n");
22 let index = 0;
23
24 while (index < lines.length) {
25 const line = lines[index] ?? "";
26 const open = FENCE.exec(line);
27
28 if (open === null) {
29 prose.push(line);
30 index += 1;
31 continue;
32 }
33
34 const marker = open[1] ?? "```";
35
36 const close = lines.findIndex(
37 (candidate, at) => at > index && isClosing(candidate.trim(), marker),
38 );
39
40 // An unclosed fence stays prose, as a markdown renderer would draw it.
41 if (close === -1) {
42 prose.push(line);
43 index += 1;
44 continue;
45 }
46
47 pushProse(result, prose);
48 result.push({
49 kind: "code",
50 language: (open[2] ?? "").toLowerCase(),
51 source: lines.slice(index + 1, close).join("\n"),
52 });
53 index = close + 1;
54 }
55
56 pushProse(result, prose);
57
58 return result;
59}
60
61function pushProse(result: Segment[], prose: string[]): void {
62 const text = prose.join("\n").trim();
63
64 if (text !== "") result.push({ kind: "prose", text });
65
66 prose.length = 0;
67}
68
69function isClosing(line: string, marker: string): boolean {
70 return line.startsWith(marker) && /^(`+|~+)$/.test(line);
71}
72lib/flowchart.ts 699 lines1// Mermaid flowcharts, parsed and drawn as box-and-arrow text that fits a
2// given width. Pure strings: the pane calls it on every render, so a resize
3// lays the chart out again.
4
5export type Frame = "box" | "round" | "decision";
6
7export type FlowNode = { id: string; label: string; frame: Frame };
8
9export type FlowEdge = {
10 from: string;
11 to: string;
12 label: string;
13 direction: "directed" | "undirected";
14};
15
16export type Flowchart = { nodes: FlowNode[]; edges: FlowEdge[] };
17
18const HEADER = /^(?:flowchart|graph)(?:\s+(?:TD|TB|BT|LR|RL))?$/i;
19
20// Lines that style or group a chart without adding nodes or edges.
21const IGNORED =
22 /^(?:subgraph|end|classDef|class|style|linkStyle|click|direction|%%)\b/;
23
24// Longest opener first, so `((` wins over `(`.
25const OPENERS: readonly (readonly [string, string, Frame])[] = [
26 ["((", "))", "round"],
27 ["([", "])", "round"],
28 ["[(", ")]", "round"],
29 ["[[", "]]", "box"],
30 ["{{", "}}", "decision"],
31 ["[", "]", "box"],
32 ["(", ")", "round"],
33 ["{", "}", "decision"],
34];
35
36// An edge between two nodes: `-- label -->`, `-->|label|`, or a bare arrow.
37const LABELLED = /^\s*--\s+(.+?)\s+-->/;
38
39const ARROW = /^\s*(-->|---|==>|-\.->)(?:\s*\|([^|]*)\|)?/;
40
41const NODE_ID = /^\s*([\w-]+)/;
42
43export function parseFlowchart(source: string): Flowchart | null {
44 const statements: string[] = [];
45 let quoted = false;
46 let start = 0;
47
48 for (let at = 0; at <= source.length; at += 1) {
49 if (source[at] === '"') quoted = !quoted;
50
51 if (at === source.length || (!quoted && /[\n;]/.test(source[at] ?? ""))) {
52 const statement = source.slice(start, at).trim();
53
54 if (statement !== "") statements.push(statement);
55 start = at + 1;
56 }
57 }
58
59 if (quoted || !HEADER.test(statements[0] ?? "")) return null;
60
61 const nodes = new Map<string, FlowNode>();
62 const edges: FlowEdge[] = [];
63
64 for (const statement of statements.slice(1)) {
65 if (!IGNORED.test(statement) && !readStatement(statement, nodes, edges))
66 return null;
67 }
68
69 return nodes.size === 0 ? null : { nodes: [...nodes.values()], edges };
70}
71
72// `A[Start] --> B{Ok?} -- yes --> C`: nodes joined by edges, left to right.
73function readStatement(
74 statement: string,
75 nodes: Map<string, FlowNode>,
76 edges: FlowEdge[],
77): boolean {
78 let rest = statement;
79 let previous: Omit<FlowEdge, "to"> | undefined;
80
81 for (;;) {
82 const read = readNode(rest);
83
84 if (read === undefined) return false;
85
86 remember(nodes, read.node);
87
88 if (previous !== undefined)
89 edges.push({
90 ...previous,
91 to: read.node.id,
92 });
93
94 rest = read.rest;
95
96 const labelled = LABELLED.exec(rest);
97 const arrow = labelled === null ? ARROW.exec(rest) : null;
98 const edge = labelled ?? arrow;
99
100 if (edge === null) return rest.trim() === "";
101
102 previous = {
103 from: read.node.id,
104 label: cleanLabel(labelled?.[1] ?? arrow?.[2] ?? ""),
105 direction: arrow?.[1] === "---" ? "undirected" : "directed",
106 };
107 rest = rest.slice(edge[0].length);
108 }
109}
110
111function readNode(text: string): { node: FlowNode; rest: string } | undefined {
112 const id = NODE_ID.exec(text);
113
114 if (id === null || id[1] === undefined) return undefined;
115
116 const after = text.slice(id[0].length);
117 const frame = OPENERS.find(([open]) => after.startsWith(open));
118
119 if (frame === undefined)
120 return { node: { id: id[1], label: id[1], frame: "box" }, rest: after };
121
122 const [open, close, kind] = frame;
123 let quoted = false;
124 let end = open.length;
125
126 for (; end < after.length; end += 1) {
127 if (after[end] === '"') quoted = !quoted;
128
129 if (!quoted && after.startsWith(close, end)) break;
130 }
131
132 if (quoted || end === after.length) return undefined;
133
134 const label = cleanLabel(after.slice(open.length, end)) || id[1];
135
136 return {
137 node: { id: id[1], label, frame: kind },
138 rest: after.slice(end + close.length),
139 };
140}
141
142// A node named again without a label keeps the label it was given first.
143function remember(nodes: Map<string, FlowNode>, node: FlowNode): void {
144 const known = nodes.get(node.id);
145
146 if (known === undefined || known.label === known.id) nodes.set(node.id, node);
147}
148
149function cleanLabel(raw: string): string {
150 return raw
151 .trim()
152 .replace(/^"([\s\S]*)"$/, "$1")
153 .replace(/<br\s*\/?>/gi, " ")
154 .replace(
155 /<\/?[a-z][a-z0-9]*(?:\s+[a-z_:][\w:.-]*(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s"'=<>`]+))?)*\s*\/?>/gi,
156 "",
157 )
158 .replace(/\s+/g, " ")
159 .trim();
160}
161
162// --- Layout ----------------------------------------------------------------
163
164const GLYPHS: Readonly<Record<Frame, string>> = {
165 box: "┌┐└┘─│",
166 round: "╭╮╰╯─│",
167 decision: "╔╗╚╝═║",
168};
169
170// Which ways a connector cell's lines run, as bits, and the glyph for each mix.
171const UP = 1;
172
173const DOWN = 2;
174
175const LEFT = 4;
176
177const RIGHT = 8;
178
179const JOINS = new Map<number, string>([
180 [UP, "│"],
181 [DOWN, "│"],
182 [UP | DOWN, "│"],
183 [LEFT, "─"],
184 [RIGHT, "─"],
185 [LEFT | RIGHT, "─"],
186 [DOWN | RIGHT, "┌"],
187 [DOWN | LEFT, "┐"],
188 [UP | RIGHT, "└"],
189 [UP | LEFT, "┘"],
190 [UP | DOWN | RIGHT, "├"],
191 [UP | DOWN | LEFT, "┤"],
192 [DOWN | LEFT | RIGHT, "┬"],
193 [UP | LEFT | RIGHT, "┴"],
194 [UP | DOWN | LEFT | RIGHT, "┼"],
195]);
196
197const GAP = 2;
198
199const MAX_LABEL = 22;
200
201const MIN_LABEL = 8;
202
203const GRAPHEMES = new Intl.Segmenter(undefined, { granularity: "grapheme" });
204
205type Placed = {
206 node: FlowNode;
207 lines: string[];
208 x: number;
209 y: number;
210 width: number;
211 height: number;
212 row: number;
213};
214
215type Cell = { glyph: string; joins: number };
216
217type Route = {
218 edge: FlowEdge;
219 from: Placed;
220 start: number;
221 end: number;
222 label: string[];
223};
224
225type Bus = { y: number; depth: number; edges: number; routes: Route[] };
226
227export function drawFlowchart(chart: Flowchart, width: number): string[] {
228 const labelWidth = Math.max(MIN_LABEL, Math.min(MAX_LABEL, width - 4));
229
230 const sized = new Map(
231 chart.nodes.map((node) => {
232 const lines = wrapText(node.label, labelWidth);
233
234 return [node.id, { node, lines, width: widest(lines) + 4 }] as const;
235 }),
236 );
237
238 const rows = packRows(chart, width, (id) => sized.get(id)?.width ?? 0);
239 const placed = new Map<string, Placed>();
240
241 // Columns first: where each arrow lands decides how its label wraps, and the
242 // wrapped labels decide how tall each bus between rows is.
243 for (const [index, row] of rows.entries()) {
244 const boxes = row.flatMap((node) => sized.get(node.id) ?? []);
245 const span = boxes.reduce((sum, box) => sum + box.width + GAP, -GAP);
246 let x = Math.max(0, Math.floor((width - span) / 2));
247
248 for (const box of boxes) {
249 placed.set(box.node.id, {
250 ...box,
251 x,
252 y: 0,
253 height: box.lines.length + 2,
254 row: index,
255 });
256 x += box.width + GAP;
257 }
258 }
259
260 const crowded = new Set<FlowEdge>();
261
262 const buses: Bus[] = rows.slice(0, -1).map((row, index) => {
263 const here = new Set(row.map((node) => node.id));
264 const next = new Set((rows[index + 1] ?? []).map((node) => node.id));
265
266 const down = chart.edges.filter(
267 (edge) => here.has(edge.from) && next.has(edge.to),
268 );
269
270 const routes = routeBus(down, placed);
271
272 wrapEdgeLabels(routes, width, crowded);
273
274 return {
275 y: 0,
276 depth: Math.max(1, ...routes.map((route) => route.label.length)),
277 edges: down.length,
278 routes,
279 };
280 });
281
282 let y = 0;
283
284 for (const [index, row] of rows.entries()) {
285 const boxes = row.flatMap((node) => placed.get(node.id) ?? []);
286
287 for (const box of boxes) box.y = y;
288 y += Math.max(...boxes.map((box) => box.height));
289
290 const bus = buses[index];
291
292 if (bus !== undefined) {
293 bus.y = y;
294 y += bus.edges > 0 ? 2 + bus.depth : 1;
295 }
296 }
297
298 const columns = Math.max(
299 width,
300 ...[...placed.values()].map((box) => box.x + box.width),
301 );
302
303 const grid: Cell[][] = Array.from({ length: y }, () =>
304 Array.from({ length: columns }, () => ({ glyph: " ", joins: 0 })),
305 );
306
307 for (const box of placed.values()) paintBox(grid, box);
308
309 const drawn = new Set<FlowEdge>();
310
311 for (const bus of buses) paintBus(grid, bus, drawn);
312
313 const lines = grid.map((row) =>
314 row
315 .map((cell) =>
316 cell.glyph === " " && cell.joins !== 0
317 ? (JOINS.get(cell.joins) ?? " ")
318 : cell.glyph,
319 )
320 .join("")
321 .trimEnd(),
322 );
323
324 // Edges that skip a row, point back up, or had no room for their label.
325 for (const edge of chart.edges) {
326 if (drawn.has(edge) && !crowded.has(edge)) continue;
327
328 const from = placed.get(edge.from);
329 const to = placed.get(edge.to);
330 const isBack = from !== undefined && to !== undefined && to.row <= from.row;
331 const note = edge.label === "" ? "" : ` (${edge.label})`;
332 const marker = edge.direction === "undirected" ? "—" : isBack ? "↺" : "⇢";
333 const link = edge.direction === "undirected" ? "—" : "→";
334
335 const [first = "", ...rest] = wrapText(
336 `${marker} ${labelOf(from)} ${link} ${labelOf(to)}${note}`,
337 Math.max(1, width - 4),
338 );
339
340 lines.push(` ${first}`, ...rest.map((line) => ` ${line}`));
341 }
342
343 return lines;
344}
345
346// Each node one layer below its deepest parent, ignoring edges that close a
347// cycle; a layer too wide for `width` continues on the next row.
348function packRows(
349 chart: Flowchart,
350 width: number,
351 widthOf: (id: string) => number,
352): FlowNode[][] {
353 const layer = layers(chart);
354 const depth = Math.max(0, ...layer.values());
355 const order = new Map<string, number>();
356 const rows: FlowNode[][] = [];
357
358 for (let level = 0; level <= depth; level += 1) {
359 const members = chart.nodes.filter((node) => layer.get(node.id) === level);
360
361 const centre = (node: FlowNode) => {
362 const parents = chart.edges.flatMap((edge) =>
363 edge.to === node.id ? (order.get(edge.from) ?? []) : [],
364 );
365
366 return parents.length === 0
367 ? chart.nodes.indexOf(node)
368 : parents.reduce((sum, at) => sum + at, 0) / parents.length;
369 };
370
371 members.sort((a, b) => centre(a) - centre(b));
372
373 for (const [at, node] of members.entries()) order.set(node.id, at);
374
375 let row: FlowNode[] = [];
376 let used = 0;
377
378 for (const node of members) {
379 const needed = widthOf(node.id) + (row.length > 0 ? GAP : 0);
380
381 if (row.length > 0 && used + needed > width) {
382 rows.push(row);
383 row = [];
384 used = 0;
385 }
386
387 used += widthOf(node.id) + (row.length > 0 ? GAP : 0);
388 row.push(node);
389 }
390
391 if (row.length > 0) rows.push(row);
392 }
393
394 return rows;
395}
396
397function layers(chart: Flowchart): Map<string, number> {
398 const state = new Map<string, "open" | "done">();
399 const forward: FlowEdge[] = [];
400
401 const visit = (id: string) => {
402 state.set(id, "open");
403
404 for (const edge of chart.edges) {
405 if (edge.from !== id || state.get(edge.to) === "open") continue;
406
407 forward.push(edge);
408
409 if (!state.has(edge.to)) visit(edge.to);
410 }
411
412 state.set(id, "done");
413 };
414
415 for (const node of chart.nodes) if (!state.has(node.id)) visit(node.id);
416
417 const layer = new Map(chart.nodes.map((node) => [node.id, 0]));
418
419 for (let pass = 0; pass < chart.nodes.length; pass += 1) {
420 let moved = false;
421
422 for (const edge of forward) {
423 const below = (layer.get(edge.from) ?? 0) + 1;
424
425 if ((layer.get(edge.to) ?? 0) < below) {
426 layer.set(edge.to, below);
427 moved = true;
428 }
429 }
430
431 if (!moved) break;
432 }
433
434 return layer;
435}
436
437function paintBox(grid: Cell[][], box: Placed): void {
438 const [tl, tr, bl, br, h, v] = [...GLYPHS[box.node.frame]];
439 const bottom = box.y + box.height - 1;
440 const right = box.x + box.width - 1;
441
442 put(grid, box.y, box.x, tl);
443 put(grid, box.y, right, tr);
444 put(grid, bottom, box.x, bl);
445 put(grid, bottom, right, br);
446
447 for (let x = box.x + 1; x < right; x += 1) {
448 put(grid, box.y, x, h);
449 put(grid, bottom, x, h);
450 }
451
452 for (const [index, line] of box.lines.entries()) {
453 const y = box.y + 1 + index;
454
455 put(grid, y, box.x, v);
456 put(grid, y, right, v);
457
458 paintText(grid, y, box.x + 2, line);
459 }
460}
461
462// The edges from one row to the next that can be drawn without implying a
463// link that is not there. Each leaves its box's bottom, runs along the bus row
464// under that row of boxes, and drops into its target.
465function routeBus(
466 edges: readonly FlowEdge[],
467 placed: ReadonlyMap<string, Placed>,
468): Route[] {
469 const routes = edges.flatMap((edge) => {
470 const from = placed.get(edge.from);
471 const to = placed.get(edge.to);
472
473 if (from === undefined || to === undefined) return [];
474
475 const start = centreOf(from);
476 const fromAbove = start > to.x + 1 && start < to.x + to.width - 2;
477 const end = fromAbove ? start : centreOf(to);
478
479 return [{ edge, from, start, end, label: [] }];
480 });
481
482 const groups: Route[][] = [];
483
484 for (const route of routes.sort(
485 (a, b) => Math.min(a.start, a.end) - Math.min(b.start, b.end),
486 )) {
487 const group = groups.at(-1);
488
489 if (
490 group !== undefined &&
491 group.some(
492 (other) =>
493 Math.max(other.start, other.end) >= Math.min(route.start, route.end),
494 )
495 )
496 group.push(route);
497 else groups.push([route]);
498 }
499
500 return groups.flatMap((group) => {
501 const ambiguous = group.some((source) =>
502 group.some(
503 (target) =>
504 source.edge.direction !== target.edge.direction ||
505 (source.edge.to === target.edge.to &&
506 source.edge.label !== target.edge.label) ||
507 !group.some(
508 ({ edge }) =>
509 edge.from === source.edge.from && edge.to === target.edge.to,
510 ),
511 ),
512 );
513
514 return ambiguous ? [] : group;
515 });
516}
517
518// Each label wraps beside its arrow, in the columns left before the next arrow
519// to its right. One with too few columns goes in `crowded`, to be listed.
520function wrapEdgeLabels(
521 routes: readonly Route[],
522 width: number,
523 crowded: Set<FlowEdge>,
524): void {
525 for (const route of routes) {
526 const { label } = route.edge;
527
528 if (label === "") continue;
529
530 const x = route.end + 2;
531
532 const next = Math.min(
533 width + 1,
534 ...routes.flatMap((other) => (other.end > route.end ? [other.end] : [])),
535 );
536
537 const room = next - 1 - x;
538
539 if (room < Math.min(MIN_LABEL, displayWidth(label)))
540 crowded.add(route.edge);
541 else route.label = wrapText(label, room);
542 }
543}
544
545// Arrows drop through the bus's label rows; labels sit beside them, their last
546// line level with the arrowhead.
547function paintBus(grid: Cell[][], bus: Bus, drawn: Set<FlowEdge>): void {
548 const tip = bus.y + 1 + bus.depth;
549
550 for (const { edge, from, start, end, label } of bus.routes) {
551 drawn.add(edge);
552
553 for (let y = from.y + from.height; y <= bus.y; y += 1)
554 join(grid, y, start, UP | DOWN);
555
556 join(grid, bus.y + 1, start, UP);
557
558 for (let x = Math.min(start, end); x < Math.max(start, end); x += 1) {
559 join(grid, bus.y + 1, x, RIGHT);
560 join(grid, bus.y + 1, x + 1, LEFT);
561 }
562
563 join(grid, bus.y + 1, end, DOWN);
564
565 for (let y = bus.y + 2; y < tip; y += 1) join(grid, y, end, UP | DOWN);
566
567 if (edge.direction === "directed") put(grid, tip, end, "▼");
568 else join(grid, tip, end, UP | DOWN);
569
570 for (const [at, line] of label.entries())
571 paintText(grid, tip - label.length + 1 + at, end + 2, line);
572 }
573}
574
575function put(grid: Cell[][], y: number, x: number, glyph: string | undefined) {
576 const cell = grid[y]?.[x];
577
578 if (cell !== undefined && glyph !== undefined) cell.glyph = glyph;
579}
580
581function paintText(grid: Cell[][], y: number, x: number, text: string): void {
582 let previous = x - 1;
583
584 for (const { segment: glyph } of GRAPHEMES.segment(text)) {
585 const width = glyphWidth(glyph);
586
587 if (width === 0) {
588 const cell = grid[y]?.[previous];
589
590 if (cell !== undefined) cell.glyph += glyph;
591 continue;
592 }
593
594 put(grid, y, x, glyph);
595
596 for (let at = 1; at < width; at += 1) put(grid, y, x + at, "");
597 previous = x;
598 x += width;
599 }
600}
601
602function join(grid: Cell[][], y: number, x: number, joins: number) {
603 const cell = grid[y]?.[x];
604
605 if (cell !== undefined) cell.joins |= joins;
606}
607
608function centreOf(box: Placed): number {
609 return box.x + Math.floor(box.width / 2);
610}
611
612function labelOf(box: Placed | undefined): string {
613 return box?.node.label ?? "?";
614}
615
616// Word-wrap into lines of at most `width` columns. A word wider than that
617// breaks between graphemes, so no text is ever cut.
618function wrapText(text: string, width: number): string[] {
619 const lines: string[] = [];
620 let line = "";
621
622 for (const word of text.split(" ")) {
623 for (const piece of breakWord(word, width)) {
624 if (line === "") line = piece;
625 else if (displayWidth(line) + 1 + displayWidth(piece) <= width)
626 line += ` ${piece}`;
627 else {
628 lines.push(line.trimEnd());
629 line = piece;
630 }
631 }
632 }
633
634 if (line !== "") lines.push(line.trimEnd());
635
636 return lines.length === 0 ? ["?"] : lines;
637}
638
639function breakWord(word: string, width: number): string[] {
640 if (displayWidth(word) <= width) return [word];
641
642 const pieces: string[] = [];
643 let piece = "";
644
645 for (const { segment: glyph } of GRAPHEMES.segment(word)) {
646 if (piece !== "" && displayWidth(piece) + glyphWidth(glyph) > width) {
647 pieces.push(piece);
648 piece = "";
649 }
650
651 piece += glyph;
652 }
653
654 return [...pieces, piece];
655}
656
657function widest(lines: readonly string[]): number {
658 return Math.max(...lines.map(displayWidth));
659}
660
661function displayWidth(text: string): number {
662 let width = 0;
663
664 for (const { segment: glyph } of GRAPHEMES.segment(text))
665 width += glyphWidth(glyph);
666
667 return width;
668}
669
670function glyphWidth(glyph: string): number {
671 if (/^\p{Mark}+$/u.test(glyph)) return 0;
672
673 if (
674 /\p{Extended_Pictographic}|\p{Regional_Indicator}|\p{Emoji_Presentation}|\p{Emoji}\uFE0F|\u20E3/u.test(
675 glyph,
676 )
677 )
678 return 2;
679
680 const code = glyph.codePointAt(0) ?? 0;
681
682 return code >= 0x1100 &&
683 (code <= 0x115f ||
684 code === 0x2329 ||
685 code === 0x232a ||
686 (code >= 0x2e80 && code <= 0xa4cf && code !== 0x303f) ||
687 (code >= 0xac00 && code <= 0xd7a3) ||
688 (code >= 0xf900 && code <= 0xfaff) ||
689 (code >= 0xfe10 && code <= 0xfe19) ||
690 (code >= 0xfe30 && code <= 0xfe6f) ||
691 (code >= 0xff01 && code <= 0xff60) ||
692 (code >= 0xffe0 && code <= 0xffe6) ||
693 (code >= 0x1b000 && code <= 0x1b2ff) ||
694 (code >= 0x1f200 && code <= 0x1f251) ||
695 (code >= 0x20000 && code <= 0x3fffd))
696 ? 2
697 : 1;
698}
699types/index.d.ts 27 lines1export type QuizOption = { text: string; isCorrect: boolean; feedback: string };
2
3export type Question = { question: string; options: QuizOption[] };
4
5export type Explanation = {
6 title: string;
7 summary: string;
8 background: string;
9 intuition: string;
10 code: string;
11 quiz: Question[];
12};
13
14// What the pane draws: the explanation, the section on screen, and the option
15// picked for each question (null while unanswered), written together.
16export type View = {
17 explanation: Explanation;
18 section: number;
19 answers: (number | null)[];
20};
21
22declare module "claude-code" {
23 interface PluginState {
24 "explain-change": { view: View | null };
25 }
26}
27