SLOPSHOPPER

explain-change

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

newpaneguardcommandtool
★ 2v0.1.1MITupdated 2026-10-21ravnhq/ravn-core/plugins/mods/explain-change
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · explain-change
│ ┃ explain-change ✕ › fix the failing auth test and add an audit log call │ ┃ No explanation yet. Ask Claude to explain a │ ┃ change. ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /explain-change-pane │ ⎿ explain-change: No explanation yet. Ask Claude to explain a chan │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · explain-change
No explanation yet. Ask Claude to explain a change.
README

Explain Change

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:

  • Background: the system the change lives in, from a skippable primer down to the part the change touches.
  • Intuition: the core idea, walked through with toy data.
  • Code: the diff in reading order, with path:line references.
  • Quiz: multiple-choice questions. Pick an answer to see why it's right or wrong, and your score once you finish.

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.

Install

/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

Use

  • Run /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.
  • Press 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.

Sidebar or inline

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.

Credits

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.

Source 5 files
hooks/register.tsx 301 lines
1import { 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}
301
lib/explanation.ts 215 lines
1import 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}
215
lib/markdown.ts 72 lines
1// 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}
72
lib/flowchart.ts 699 lines
1// 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}
699
types/index.d.ts 27 lines
1export 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