Numbered jargon hints in Claude Code replies, with a glossary pane and flashcards

Jargon hints for coding agents. Works in pi, Claude Code and Codex, from one repo. Every time the agent introduces a term you might not know, the reply shows it in bold with a superscript number:
A closure¹ keeps the surrounding scope² alive.
Look the number up and a short plain definition comes back. Search to find every term the glossary has ever stored, and quiz yourself on the ones that did not stick.
| pi | Claude Code | Codex | |
|---|---|---|---|
| Entry point | src/vocab.ts (pi extension) | hooks/register.tsx (Claude Code mod) | codex/vocab.ts (Codex hooks plus a vocab command) |
| Open the glossary | alt+w or /vocab | /vocab (opens a pane) | vocab in another terminal pane |
| Look up hint 3 | alt+w, then 3 | /vocab 3 | vocab 3 |
| Search | type in the overlay | /vocab closure, or type in the pane | vocab closure |
| Quiz, export, toggle, forget | /vocab quiz, export, toggle, forget | same commands | vocab quiz, export, toggle, forget |
| Glossary lives | in the pi session (follows branches, new session starts empty) | in the plugin store (shared by every session) | ~/.codex/vocab.json (shared by every session) |
Who writes closure¹ | the extension, from the model's marker | the mod, from the model's marker | the model itself (Codex cannot rewrite a reply) |
| Project kinds (why, where, how) | bold, underlined | bold (Claude Code refuses raw ANSI in replies) | bold |
All hosts share the glossary model, the quiz scoring and the export (src/glossary.ts, src/cards.ts, src/export.ts). pi and Claude Code also share the marker format, its prompt section and the rewriting (src/markers.ts, src/prompt.ts). Only the host glue differs.
pi install /path/to/vocab
Or for a checkout you want to hack on:
ln -s /path/to/vocab/src/vocab.ts ~/.pi/agent/extensions/vocab.ts
The repo root is also a Claude Code plugin (.claude-plugin/plugin.json). Load it from a checkout:
claude --plugin-dir /path/to/vocab
To load it in every session, add the folder to CLAUDE_CODE_PLUGIN_DIRS in the env block of ~/.claude/settings.json. Claude Code watches that folder and reloads the mod on save.
ln -s /path/to/vocab/codex/vocab.ts ~/.local/bin/vocab vocab install
vocab install adds three hooks to ~/.codex/hooks.json (a backup goes to hooks.json.bak-vocab) and leaves every other hook alone. Then open Codex, run /hooks and trust the three vocab hooks: Codex skips a new or changed hook until you do. vocab uninstall takes them out again. Needs Node 24 or newer, which runs the TypeScript directly.
The first time it runs, the Codex glossary starts from the terms in pi's old global file (~/.pi/agent/vocab.json), if there is one.
| Key | Does |
|---|---|
alt+w | Open or close the glossary. Typing a number picks that hint. Typing a word filters the whole glossary. esc closes. |
alt+shift+w | Look up whatever is typed in the input box, without sending it. |
Inside the glossary. The box sits at the top of the screen; the search line and the eight-row list never move, only the detail panel below grows.
| Key | Does |
|---|---|
↑ ↓ | Move one entry. |
ctrl+d ctrl+u | Half a page down or up. pageDown pageUp a full page, home end to the ends. |
ctrl+e | Ask the agent to explain the selected entry again, plainly, with an example from this project. |
tab | Follow the first link of the selected entry (see: ...). |
/vocab opens the glossary as a pane with the keyboard. Type in the search field (a number picks that hint), tab and the arrows walk the list, enter opens an entry. In an entry, e asks Claude to explain it again with an example from this project, and the see: names jump to linked entries. esc closes. /vocab quiz opens the flashcards in a pane: r reveals, y got it, n missed.
The mod's status entry shows vocab N (hints this session) or vocab off.
Codex shows a reply exactly as the model wrote it, and no hook can change the text on screen. So the model writes the finished form, closure¹, and puts each definition in a markdown link reference definition at the end of the reply:
[vocab-1]: # "closure | a function that remembers its surroundings"
Markdown does not draw those lines, so the Codex screen shows only the numbered names, as pi and Claude Code do. When the reply finishes, the Stop hook reads the lines back out and files them in the glossary.
Look things up from a second terminal pane next to Codex:
vocab # the latest session's hints, one per line vocab 3 # hint 3 in full vocab closure # search the glossary vocab quiz # flashcards right in the terminal
vocab means the Codex session that ran a hook most recently. A per-turn hook reminds the model which numbers are taken, so numbering survives long sessions and compaction.
The same in every host. In Codex they are the vocab command's arguments (vocab quiz, not /vocab quiz).
| Command | Does |
|---|---|
/vocab | Open the glossary. /vocab <word or number> opens it already searching. |
/vocab quiz [n] | Five flashcards (or n) from the entries most likely to have slipped: looked up often, missed before, not seen for a while. Self-graded. |
/vocab export [path] [--force] | Write the glossary as GLOSSARY.md in the project, grouped by kind, so other agents and teammates share the names. Refuses to overwrite a file it did not write unless --force. |
/vocab toggle | Stop or resume asking the model to mark terms. On by default. /vocab on and /vocab off too. |
/vocab forget <term> | Remove a term from the glossary. |
Every kind renders as the name plus a number, so the reply does not get busier, but the name's style says which kind it is. The glossary adds a glyph.
| Kind | Answers | In the reply | Glyph |
|---|---|---|---|
| term | what is this called | bold | |
| concept | how does this work | bold italic | ◇ |
| why | why did we do it this way | bold, underlined in pi | ? |
| where | where does this live | bold, underlined in pi | @ |
| how | how do I do this again | bold, underlined in pi | › |
| gotcha | what bit us before | bold, curly underline in pi | ! |
In pi the underlines are raw terminal codes that pi's markdown renderer passes through. A context hook strips them before each model call, so the model sees plain name. Claude Code refuses control characters in reply text, so there those kinds are plain bold.
Entries can link to each other (see: scope, callback). Links go both ways. Every time you open an entry in the glossary it counts as a lookup; entries you keep looking up float to the top of the quiz.
A short system-prompt section asks the model to write new terms as [[term|one plain sentence]], or [[kind: name|definition|see: other]] for the other kinds. While the reply streams, the text the screen shows is swapped for term plus a superscript number the moment each marker is complete; an unfinished marker shows as the bold name, or nothing until the name is complete. When the reply is stored it is rewritten the same way, so the model never sees the markers again, only the bolded term.
| Step | pi | Claude Code | Codex |
|---|---|---|---|
| Prompt section | before_agent_start | prompt.compose | SessionStart hook (codex/notes.ts has the Codex wording) |
| Live display | message_update | ui.render on AssistantMessage | none needed: the model writes the final form |
| Stored reply | message_end | session.append (main conversation only, not subagents) | Stop hook reads [vocab-N] lines |
| Numbers after a resume | read back from the session's replies | the same, on session.start | kept per session in ~/.codex/vocab.json, repeated by the UserPromptSubmit hook |
The model marks a term once. The extension then numbers every other mention in the reply, plurals included, and any term already numbered this session, so a word is never numbered in one sentence and bare in the next. Code, links and URLs are left alone.
Hints are keyboard-only on purpose in pi: pi's normal mode gives extensions no mouse events, so hover is not an option there.
npm test # unit tests for the shared core and the Codex side, plain node npm run typecheck # the pi extension and the Codex side, against pi's own declarations claude plugin validate . # the Claude Code mod, as the engine reads it claude plugin test . # the Claude Code mod's tests (tests/*.test.ts)
The shared core's tests are test/*_test.ts, so claude plugin test, which runs every *.test.ts, only picks up the mod's own.
PI_VOCAB_FILE=/tmp/x.json pi -e ./src/vocab.ts ... runs pi against a scratch settings file instead of the real one. VOCAB_CODEX_FILE=/tmp/x.json vocab ... does the same for the Codex glossary.
hooks/register.tsx 414 lines1/**
2 * vocab for Claude Code: the same jargon hints as the pi extension.
3 *
4 * /vocab open the glossary pane; a number picks a hint,
5 * typing searches everything ever stored
6 * /vocab <word or n> open it already searching
7 * /vocab quiz [n] flashcards for the entries most likely to have slipped
8 * /vocab export [path] [--force] write GLOSSARY.md in the project
9 * /vocab toggle | on | off stop or resume asking the model to mark terms
10 * /vocab forget <term>
11 *
12 * The model writes [[term|definition]]. While a reply streams, the
13 * AssistantMessage drawing swaps each marker for **term**¹; when the reply's
14 * block is stored (session.append) it is rewritten the same way, so the model
15 * never reads the markers back. Numbers are per session and recovered from
16 * the stored replies when a session resumes or the mod reloads. The glossary
17 * is global, in the plugin store, so the pane and the quiz span sessions.
18 */
19
20import { atom, read, update } from "claude-code";
21import type { EngineInterface, Register } from "claude-code";
22
23import { isOurs, renderGlossary } from "../src/export.ts";
24import { type Entry, Glossary, type GlossaryData, isKind, KIND_GLYPH, KIND_LABEL, keyOf, normalize } from "../src/glossary.ts";
25import { type Hint, recoverHints, rewrite, superscript } from "../src/markers.ts";
26import { PROMPT_SECTION } from "../src/prompt.ts";
27import { pickCards } from "../src/cards.ts";
28
29const PANE = "vocab";
30const QUIZ = "vocab-quiz";
31const STORE_GLOSSARY = "glossary";
32const STORE_ENABLED = "enabled";
33
34const query = atom({ plugin: "vocab", key: "query" } as const, "");
35const selected = atom({ plugin: "vocab", key: "selected" } as const, null);
36const rev = atom({ plugin: "vocab", key: "rev" } as const, 0);
37const quiz = atom({ plugin: "vocab", key: "quiz" } as const, null);
38
39type TextBlock = { type: "text"; text: string };
40const isText = (block: { type: string }): block is TextBlock => block.type === "text" && typeof (block as TextBlock).text === "string";
41
42/** Module state. A reload starts it over; session.start fills it again. */
43const glossary = new Glossary();
44let enabled = true;
45/** Every hint of this session, keyed by normalized name. Numbers never change. */
46let known = new Map<string, Hint>();
47
48const hints = (): Hint[] => [...known.values()].sort((a, b) => a.index - b.index);
49const entryOf = (h: Hint): Entry | undefined => glossary.get(h.term, h.kind) ?? glossary.get(h.term);
50const keyOfEntry = (e: Entry): string => keyOf(e.kind, e.term);
51const byKey = (key: string | null): Entry | undefined => (key ? glossary.all().find((e) => keyOfEntry(e) === key) : undefined);
52
53async function save($: EngineInterface) {
54 await $.store.set(STORE_GLOSSARY, glossary.snapshot());
55 await update($, rev, (n) => n + 1);
56}
57
58function showStatus($: EngineInterface) {
59 if (!enabled) return $.ui.status("vocab off");
60 $.ui.status(known.size > 0 ? `vocab ${known.size}` : undefined);
61}
62
63/** The pi extension's old global file kept a few definitions; take them once. */
64async function importLegacy($: EngineInterface) {
65 const home = await $.env.get("HOME");
66 if (!home) return false;
67 try {
68 const parsed = JSON.parse(await $.fs.read(`${home}/.pi/agent/vocab.json`)) as {
69 terms?: Record<string, { term?: string; definition?: string; kind?: string }>;
70 };
71 let added = false;
72 for (const t of Object.values(parsed.terms ?? {})) {
73 if (!t.term || !t.definition) continue;
74 glossary.record(t.term, t.definition, { kind: t.kind && isKind(t.kind) ? t.kind : "term" });
75 added = true;
76 }
77 return added;
78 } catch {
79 return false;
80 }
81}
82
83async function openGlossary($: EngineInterface, initial: string) {
84 await update($, query, () => initial);
85 await update($, selected, () => null);
86 return $.ui.open({ id: PANE, title: "Glossary", focus: true, closeOnEscape: true });
87}
88
89async function startQuiz($: EngineInterface, n: number) {
90 const cards = pickCards(glossary.all(), n);
91 if (cards.length === 0) return false;
92 await update($, quiz, () => ({ keys: cards.map(keyOfEntry), at: 0, isRevealed: false, missed: [] }));
93 await $.ui.open({ id: QUIZ, title: "Quiz", focus: true, closeOnEscape: true });
94 return true;
95}
96
97async function pick($: EngineInterface, entry: Entry) {
98 await update($, selected, () => keyOfEntry(entry));
99 if (glossary.lookup(entry.term, entry.kind)) await save($);
100}
101
102async function forget($: EngineInterface, entry: Entry) {
103 glossary.forget(entry.term, entry.kind);
104 known.delete(normalize(entry.term));
105 await update($, selected, () => null);
106 await save($);
107 showStatus($);
108}
109
110async function explainAgain($: EngineInterface, entry: Entry) {
111 const label = entry.kind === "term" ? "" : ` (${KIND_LABEL[entry.kind]})`;
112 const marker = `${entry.kind === "term" ? "" : `${entry.kind}: `}${entry.term}`;
113 await $.ui.close({ id: PANE });
114 await $.prompt.submit({
115 text: `Explain [[${marker}]] again${label}. Plain words, under 80 words, one concrete example from this project. Keep the [[...]] marker so it keeps its number.`,
116 });
117}
118
119async function answerCard($: EngineInterface, card: Entry | undefined, missed: boolean) {
120 if (card) {
121 glossary.recordQuiz(card, missed);
122 await save($);
123 }
124 await update($, quiz, (s) =>
125 s ? { ...s, at: s.at + 1, isRevealed: false, missed: missed ? [...s.missed, s.keys[s.at]!] : s.missed } : s,
126 );
127}
128
129/** Rows for the list: this session's hints first, then the rest of the glossary. */
130function matches(q: string): { entry: Entry; hint?: Hint }[] {
131 const wanted = normalize(q);
132 const n = /^\d+$/.test(wanted) ? Number(wanted) : 0;
133 const rows: { entry: Entry; hint?: Hint }[] = [];
134 const seen = new Set<string>();
135 for (const h of hints()) {
136 const entry = entryOf(h);
137 if (!entry) continue;
138 if (n ? h.index !== n : wanted && !normalize(entry.term).includes(wanted)) continue;
139 rows.push({ entry, hint: h });
140 seen.add(keyOfEntry(entry));
141 }
142 if (n) return rows;
143 for (const entry of glossary.all()) {
144 if (seen.has(keyOfEntry(entry))) continue;
145 const haystack = normalize(`${entry.term} ${entry.definition}`);
146 if (wanted && !haystack.includes(wanted)) continue;
147 rows.push({ entry });
148 }
149 return rows;
150}
151
152export const register: Register = (on) => {
153 on("session.start", async ($, e, next) => {
154 known = new Map();
155 const stored = (await $.store.get(STORE_GLOSSARY)) as GlossaryData | undefined;
156 glossary.load(stored);
157 enabled = (await $.store.get(STORE_ENABLED)) !== false;
158 if (!stored && (await importLegacy($))) await save($);
159
160 // Resumed session or a reload: read the numbers back out of the stored replies.
161 for (const message of await $.session.messages()) {
162 if (message.role !== "assistant") continue;
163 for (const h of recoverHints(message.text)) {
164 const key = normalize(h.term);
165 if (known.has(key)) continue;
166 const entry = glossary.get(h.term);
167 known.set(key, { ...h, kind: entry?.kind ?? "term", definition: entry?.definition || undefined, related: entry?.related });
168 }
169 }
170
171 await $.command.register({
172 name: "vocab",
173 description: "Glossary of terms Claude explained",
174 argumentHint: "[word | n | quiz [n] | export [path] | toggle | forget <term>]",
175 });
176 showStatus($);
177 return next(e);
178 });
179
180 on("session.end", ($, e, next) => {
181 if (e.reason === "clear") known = new Map();
182 return next(e);
183 });
184
185 on("prompt.compose", async ($, e, next) => {
186 const composed = await next(e);
187 if (!enabled || e.traits.includes("bare")) return composed;
188 return { ...composed, sections: [...composed.sections, { id: "vocab:markers", text: PROMPT_SECTION, scope: "session" }] };
189 });
190
191 // The stored reply: markers become **name**¹ and new entries land in the glossary.
192 on("session.append", async ($, e, next) => {
193 if (e.door !== "response" || e.agentId || e.message.role !== "assistant") return next(e);
194 if (!e.message.content.some((b) => isText(b) && b.text.includes("[["))) return next(e);
195 const before = new Set(known.keys());
196 const content = e.message.content.map((block) => {
197 if (!isText(block)) return block;
198 return { ...block, text: rewrite(block.text, { known, ansi: false }).text };
199 });
200 for (const [key, h] of known) {
201 if (before.has(key)) continue;
202 const entry = glossary.record(h.term, h.definition, { kind: h.kind, related: h.related });
203 if (!h.definition) h.definition = entry.definition || undefined;
204 h.related = entry.related;
205 }
206 if (known.size > before.size) {
207 await save($);
208 showStatus($);
209 }
210 return next({ ...e, message: { ...e.message, content } });
211 });
212
213 // While the reply streams, show what the stored text will be. A copy of the
214 // numbers, so the stored rewrite hands out the same ones.
215 on("ui.render", { component: "AssistantMessage" }, ($, e, next) => {
216 if (!e.props.text.includes("[[")) return next(e);
217 const text = rewrite(e.props.text, { known: new Map(known), ansi: false, streaming: true }).text;
218 return next({ ...e, props: { ...e.props, text } });
219 });
220
221 on("command.run", { command: "vocab" }, async ($, e) => {
222 const [sub, ...rest] = e.args.trim().split(/\s+/).filter(Boolean);
223 if (sub === "toggle" || sub === "on" || sub === "off") {
224 enabled = sub === "toggle" ? !enabled : sub === "on";
225 await $.store.set(STORE_ENABLED, enabled);
226 showStatus($);
227 return { text: enabled ? "vocab on: new terms get marked from the next reply" : "vocab off: no more marking" };
228 }
229 if (sub === "forget") {
230 const term = rest.join(" ");
231 if (!term) return { text: "usage: /vocab forget <term>" };
232 const removed = glossary.forget(term);
233 known.delete(normalize(term));
234 if (removed) await save($);
235 showStatus($);
236 return { text: removed ? `forgot "${term}"` : `"${term}" is not in the glossary` };
237 }
238 if (sub === "quiz") {
239 const n = Number.parseInt(rest[0] ?? "", 10);
240 const started = await startQuiz($, Number.isFinite(n) && n > 0 ? Math.min(n, 20) : 5);
241 return { text: started ? "Quiz opened." : "Nothing to quiz on yet." };
242 }
243 if (sub === "export") {
244 // GLOSSARY.md in the project, or the given path. Never clobbers a file vocab did not write.
245 const target = rest.find((a) => a !== "--force") ?? "GLOSSARY.md";
246 const force = rest.includes("--force");
247 if (glossary.size === 0) return { text: "nothing to export yet" };
248 if (!force && (await $.fs.exists(target)) && !isOurs(await $.fs.read(target))) {
249 return { text: `${target} exists and was not written by vocab. Add --force to overwrite.` };
250 }
251 await $.fs.write(target, renderGlossary(glossary.all()));
252 return { text: `wrote ${glossary.size} entries to ${target}` };
253 }
254 if (sub === "count") return { text: `${known.size} hints this session, ${glossary.size} in the glossary` };
255 const opened = await openGlossary($, sub ? [sub, ...rest].join(" ") : "");
256 return { text: opened.isPlaced ? "Glossary opened." : `Glossary could not open: ${opened.reason}` };
257 });
258
259 on("ui.render", { component: "Pane", requestId: PANE }, async ($, e) => {
260 const elements = $.ui.resolve(e);
261 const { Box, Button, Text } = elements;
262 // Every surface but mobile has a text field; there the list is the whole pane.
263 const Input = "Input" in elements ? elements.Input : undefined;
264 await read($, rev);
265 const q = await read($, query);
266 const current = byKey(await read($, selected));
267 const rows = matches(q);
268 const room = Math.max(3, Math.min(12, (e.viewport?.rows ?? 24) - 14));
269 const hintOf = (entry: Entry) => hints().find((h) => entryOf(h) === entry);
270
271 return (
272 <Box flexDirection="column">
273 {Input && (
274 <Input
275 key="q"
276 placeholder="a number, or a word to search"
277 value={q}
278 autoFocus
279 onInput={(value: string) => update($, query, () => value)}
280 onSubmit={(value: string) => {
281 const first = matches(value)[0];
282 return first ? pick($, first.entry) : undefined;
283 }}
284 />
285 )}
286 {rows.length === 0 && (
287 <Text dimColor italic>
288 {glossary.size === 0 ? "Nothing explained yet." : "No match."}
289 </Text>
290 )}
291 {rows.slice(0, room).map(({ entry, hint }) => (
292 <Button key={`row:${keyOfEntry(entry)}`} plain onPress={() => pick($, entry)}>
293 <Text dimColor>{hint ? `${superscript(hint.index)} `.padStart(4) : " "}</Text>
294 <Text bold italic={entry.kind === "concept"}>
295 {entry.term}
296 </Text>
297 <Text dimColor> {KIND_GLYPH[entry.kind]}</Text>
298 </Button>
299 ))}
300 {rows.length > room && <Text dimColor italic>{`${rows.length - room} more, type to narrow`}</Text>}
301 {current && (
302 <Box flexDirection="column" marginTop={1}>
303 <Text>
304 <Text bold italic={current.kind === "concept"}>
305 {current.term}
306 </Text>
307 <Text dimColor italic>
308 {" "}
309 {KIND_LABEL[current.kind]}
310 {hintOf(current) ? `, ${superscript(hintOf(current)!.index)} this session` : ""}
311 </Text>
312 </Text>
313 <Text>{current.definition || "No definition stored yet."}</Text>
314 {current.related.length > 0 && (
315 <Box>
316 <Text dimColor italic>
317 see:{" "}
318 </Text>
319 {current.related.map((name) => {
320 const other = glossary.get(name);
321 return other ? (
322 <Button key={`see:${keyOfEntry(other)}`} plain onPress={() => pick($, other)}>
323 {name}{" "}
324 </Button>
325 ) : (
326 <Text dimColor>{name} </Text>
327 );
328 })}
329 </Box>
330 )}
331 <Box marginTop={1}>
332 <Button
333 key="explain"
334 label="explain again"
335 hotkey="e"
336 onPress={() => explainAgain($, current)}
337 />
338 <Text> </Text>
339 <Button
340 key="forget"
341 label="forget"
342 onPress={() => forget($, current)}
343 />
344 </Box>
345 </Box>
346 )}
347 </Box>
348 );
349 });
350
351 on("ui.render", { component: "Pane", requestId: QUIZ }, async ($, e) => {
352 const { Box, Button, Text } = $.ui.resolve(e);
353 await read($, rev);
354 const state = await read($, quiz);
355 if (!state) return <Text dimColor italic>No quiz running. /vocab quiz starts one.</Text>;
356
357 if (state.at >= state.keys.length) {
358 const got = state.keys.length - state.missed.length;
359 return (
360 <Box flexDirection="column">
361 <Text bold>{`${got} of ${state.keys.length} remembered.`}</Text>
362 {state.missed.length > 0 && (
363 <Text dimColor italic>{`Worth another look: ${state.missed.map((k) => byKey(k)?.term ?? k).join(", ")}`}</Text>
364 )}
365 <Button key="close" label="close" hotkey="q" role="dismiss" onPress={() => $.ui.close({ id: QUIZ })} />
366 </Box>
367 );
368 }
369
370 const card = byKey(state.keys[state.at] ?? null);
371 if (!card) {
372 void answerCard($, card, false);
373 return <Text dimColor>…</Text>;
374 }
375 // Terms show the definition and ask for the name; other kinds ask the other way round.
376 const asksForName = card.kind === "term";
377 return (
378 <Box flexDirection="column">
379 <Text dimColor italic>{`card ${state.at + 1} of ${state.keys.length}, ${KIND_LABEL[card.kind]}`}</Text>
380 <Box marginTop={1}>
381 {asksForName ? <Text>{card.definition}</Text> : <Text bold italic={card.kind === "concept"}>{card.term}</Text>}
382 </Box>
383 <Box marginTop={1}>
384 {state.isRevealed ? (
385 asksForName ? <Text bold>{card.term}</Text> : <Text>{card.definition}</Text>
386 ) : (
387 <Text dimColor italic>
388 {asksForName ? "What is it called?" : "Say it in your own words."}
389 </Text>
390 )}
391 </Box>
392 <Box marginTop={1}>
393 {state.isRevealed ? (
394 <>
395 <Button key="got" label="got it" hotkey="y" variant="primary" onPress={() => answerCard($, card, false)} />
396 <Text> </Text>
397 <Button key="missed" label="missed" hotkey="n" onPress={() => answerCard($, card, true)} />
398 </>
399 ) : (
400 <Button
401 key="reveal"
402 label="reveal"
403 hotkey="r"
404 variant="primary"
405 autoFocus
406 onPress={() => update($, quiz, (s) => (s ? { ...s, isRevealed: true } : s))}
407 />
408 )}
409 </Box>
410 </Box>
411 );
412 });
413};
414src/export.ts 39 lines1/**
2 * GLOSSARY.md: the session's glossary as plain markdown, for the repo. Other
3 * agents and teammates then share the same names. The file carries a marker
4 * so a later export knows it is safe to overwrite.
5 */
6
7import { type Entry, type Kind, KINDS } from "./glossary.ts";
8
9export const EXPORT_MARKER = "<!-- generated by pi-vocab; edits will be overwritten by /vocab export -->";
10
11const HEADINGS: Record<Kind, string> = {
12 term: "Terms",
13 concept: "Concepts",
14 why: "Why things are the way they are",
15 where: "Where things live",
16 how: "How to do things",
17 gotcha: "Gotchas",
18};
19
20/** Render entries grouped by kind, alphabetical within each group. */
21export function renderGlossary(entries: Entry[], now = new Date()): string {
22 const lines: string[] = [EXPORT_MARKER, "", "# Glossary", "", `Exported ${now.toISOString().slice(0, 10)}. ${entries.length} entries.`, ""];
23 for (const kind of KINDS) {
24 const group = entries.filter((e) => e.kind === kind && e.definition).sort((a, b) => a.term.localeCompare(b.term));
25 if (group.length === 0) continue;
26 lines.push(`## ${HEADINGS[kind]}`, "");
27 for (const e of group) {
28 const see = e.related.length ? ` (see: ${e.related.join(", ")})` : "";
29 lines.push(`- **${e.term}**: ${e.definition}${see}`);
30 }
31 lines.push("");
32 }
33 if (lines[lines.length - 1] !== "") lines.push("");
34 return lines.join("\n");
35}
36
37/** True when an existing file was written by us, so overwriting loses nothing. */
38export const isOurs = (content: string): boolean => content.startsWith(EXPORT_MARKER);
39src/glossary.ts 190 lines1/**
2 * The glossary: everything the agent has explained in this session, with its
3 * plain definition. State lives in memory; whoever owns it decides where it is
4 * persisted (pi-vocab stores a snapshot in the pi session, so it follows the
5 * session and its branches, and a new session starts empty).
6 */
7
8export const KINDS = ["term", "concept", "why", "where", "how", "gotcha"] as const;
9export type Kind = (typeof KINDS)[number];
10
11export const isKind = (s: string): s is Kind => (KINDS as readonly string[]).includes(s);
12
13/** One character per kind for list rows. Terms get none. */
14export const KIND_GLYPH: Record<Kind, string> = {
15 term: "",
16 concept: "◇",
17 why: "?",
18 where: "@",
19 how: "›",
20 gotcha: "!",
21};
22
23export const KIND_LABEL: Record<Kind, string> = {
24 term: "term",
25 concept: "concept",
26 why: "why we do it this way",
27 where: "where it lives",
28 how: "how to do it",
29 gotcha: "gotcha",
30};
31
32export interface Entry {
33 kind: Kind;
34 /** Display spelling, as the model first wrote it. */
35 term: string;
36 definition: string;
37 /** ISO timestamps. */
38 firstSeen: string;
39 lastSeen: string;
40 /** How many replies have marked this entry. */
41 count: number;
42 /** Names of entries this one connects to, as written. */
43 related: string[];
44 /** How often the user opened this entry in the overlay. High means not sticking. */
45 lookups: number;
46 /** Flashcard history. */
47 quiz: { asked: number; missed: number; lastAsked?: string };
48}
49
50export interface GlossaryData {
51 version: 3;
52 terms: Record<string, Entry>;
53}
54
55export const normalize = (term: string): string => term.trim().toLowerCase().replace(/\s+/g, " ");
56
57/** Storage key. Terms keep their bare name; other kinds are prefixed so "SQLite" and "why: SQLite" coexist. */
58export const keyOf = (kind: Kind, term: string): string => (kind === "term" ? normalize(term) : `${kind}:${normalize(term)}`);
59
60export type Persist = (data: GlossaryData) => void;
61
62export class Glossary {
63 private data: GlossaryData = { version: 3, terms: {} };
64 private readonly persist: Persist;
65
66 constructor(persist: Persist = () => {}) {
67 this.persist = persist;
68 }
69
70 /** Replace the contents with a stored snapshot, tolerating older shapes. */
71 load(snapshot: unknown): void {
72 const parsed = (snapshot ?? {}) as Partial<GlossaryData>;
73 const terms: Record<string, Entry> = {};
74 for (const [key, raw] of Object.entries(parsed.terms ?? {})) {
75 const entry = raw as Partial<Entry>;
76 if (!entry.term) continue;
77 terms[key] = {
78 ...entry,
79 kind: entry.kind && isKind(entry.kind) ? entry.kind : "term",
80 related: Array.isArray(entry.related) ? entry.related : [],
81 lookups: typeof entry.lookups === "number" ? entry.lookups : 0,
82 quiz: {
83 asked: entry.quiz?.asked ?? 0,
84 missed: entry.quiz?.missed ?? 0,
85 ...(entry.quiz?.lastAsked ? { lastAsked: entry.quiz.lastAsked } : {}),
86 },
87 } as Entry;
88 }
89 this.data = { version: 3, terms };
90 }
91
92 /** A copy safe to store. */
93 snapshot(): GlossaryData {
94 return JSON.parse(JSON.stringify(this.data)) as GlossaryData;
95 }
96
97 get size(): number {
98 return Object.keys(this.data.terms).length;
99 }
100
101 /** Exact kind when given; otherwise the term, then any other kind with that name. */
102 get(term: string, kind?: Kind): Entry | undefined {
103 if (kind) return this.data.terms[keyOf(kind, term)];
104 return this.data.terms[keyOf("term", term)] ?? KINDS.map((k) => this.data.terms[keyOf(k, term)]).find(Boolean);
105 }
106
107 /** Every entry, most recent first. */
108 all(): Entry[] {
109 return Object.values(this.data.terms).sort((a, b) => (a.lastSeen < b.lastSeen ? 1 : -1));
110 }
111
112 /**
113 * Record a sighting. A new definition replaces the old one only when the old
114 * one is missing, so the first good explanation sticks.
115 */
116 record(term: string, definition: string | undefined, opts: { kind?: Kind; related?: string[]; now?: Date } = {}): Entry {
117 const kind = opts.kind ?? "term";
118 const key = keyOf(kind, term);
119 const iso = (opts.now ?? new Date()).toISOString();
120 let entry = this.data.terms[key];
121 if (entry) {
122 entry.count += 1;
123 entry.lastSeen = iso;
124 if (!entry.definition && definition) entry.definition = definition;
125 } else {
126 entry = {
127 kind,
128 term: term.trim(),
129 definition: definition ?? "",
130 firstSeen: iso,
131 lastSeen: iso,
132 count: 1,
133 related: [],
134 lookups: 0,
135 quiz: { asked: 0, missed: 0 },
136 };
137 this.data.terms[key] = entry;
138 }
139 this.link(entry, opts.related ?? []);
140 return entry;
141 }
142
143 /** Connect both ways, skipping self and duplicates. The other side need not exist yet. */
144 private link(entry: Entry, related: string[]): void {
145 for (const name of related) {
146 if (normalize(name) === normalize(entry.term)) continue;
147 if (!entry.related.some((r) => normalize(r) === normalize(name))) entry.related.push(name.trim());
148 const other = this.get(name);
149 if (other && !other.related.some((r) => normalize(r) === normalize(entry.term))) other.related.push(entry.term);
150 }
151 }
152
153 /** Count a lookup. Returns false when the entry is unknown. */
154 lookup(term: string, kind?: Kind): boolean {
155 const entry = this.get(term, kind);
156 if (!entry) return false;
157 entry.lookups += 1;
158 return true;
159 }
160
161 /** Record a flashcard answer. */
162 recordQuiz(entry: Entry, missed: boolean, now = new Date()): void {
163 entry.quiz.asked += 1;
164 if (missed) entry.quiz.missed += 1;
165 entry.quiz.lastAsked = now.toISOString();
166 }
167
168 /** Remove an entry. Without a kind, removes every kind with that name. */
169 forget(term: string, kind?: Kind): boolean {
170 const keys = kind ? [keyOf(kind, term)] : KINDS.map((k) => keyOf(k, term));
171 let removed = false;
172 for (const key of keys) {
173 if (key in this.data.terms) {
174 delete this.data.terms[key];
175 removed = true;
176 }
177 }
178 if (removed) this.save();
179 return removed;
180 }
181
182 save(): void {
183 try {
184 this.persist(this.snapshot());
185 } catch {
186 // Losing persistence is not worth breaking the reply.
187 }
188 }
189}
190src/markers.ts 285 lines1/**
2 * Markers are what the model writes; hints are what the user sees.
3 *
4 * model: A [[closure|a function that remembers its surroundings]] is handy.
5 * shown: A **closure**¹ is handy.
6 *
7 * A kind prefix makes it something other than a term:
8 *
9 * [[concept: auth here|a token in a cookie, checked by middleware]]
10 * [[why: SQLite|single-user app, nothing to host]]
11 * [[where: scheduler|internal/sched owns every timer]]
12 * [[how: run tests|make test, with the db container up]]
13 * [[gotcha: seeding|run migrations first or seeding silently fails]]
14 *
15 * A third segment links entries: [[closure|def|see: scope, callback]].
16 *
17 * `[[term]]` with no definition means "already explained before": it still gets
18 * a hint, the definition comes from the glossary. Everything renders the same
19 * way, **name** plus a superscript digit, so the reply does not get busier.
20 */
21
22import { isKind, type Kind, normalize } from "./glossary.ts";
23
24export interface Marker {
25 kind: Kind;
26 term: string;
27 definition?: string;
28 /** Names of entries this one connects to. */
29 related?: string[];
30}
31
32export interface Hint {
33 /** 1-based, shown as a superscript. Stable for the whole session. */
34 index: number;
35 kind: Kind;
36 term: string;
37 definition?: string;
38 related?: string[];
39}
40
41export interface Rewrite {
42 text: string;
43 hints: Hint[];
44}
45
46/** An entry the glossary already knows; plain mentions of it get numbered too. */
47export interface KnownTerm {
48 kind: Kind;
49 term: string;
50 definition?: string;
51}
52
53const SUPERSCRIPT_DIGITS = ["⁰", "¹", "²", "³", "⁴", "⁵", "⁶", "⁷", "⁸", "⁹"];
54
55// Raw ANSI that pi's markdown renderer passes through untouched.
56const UNDERLINE = "\x1b[4m";
57const CURLY = "\x1b[4:3m";
58const UNDERLINE_OFF = "\x1b[24m";
59
60/** Any SGR sequence, for stripping before text reaches the model. */
61export const ANSI = /\x1b\[[0-9;:]*m/g;
62export const stripAnsi = (s: string): string => s.replace(ANSI, "");
63
64/**
65 * How a hinted name looks in the reply, per kind. Bold is markdown; the
66 * underlines are raw ANSI, stripped again before the model sees the text.
67 *
68 * term **name**
69 * concept ***name*** bold italic
70 * why/where/how **name** underlined
71 * gotcha **name** curly underline
72 *
73 * Without `ansi` (hosts that refuse control characters in reply text) the
74 * underlined kinds fall back to plain bold.
75 */
76export function styleName(kind: Kind, name: string, ansi = true): string {
77 if (!ansi && kind !== "concept") return `**${name}**`;
78 switch (kind) {
79 case "concept":
80 return `***${name}***`;
81 case "gotcha":
82 return `**${CURLY}${name}${UNDERLINE_OFF}**`;
83 case "why":
84 case "where":
85 case "how":
86 return `**${UNDERLINE}${name}${UNDERLINE_OFF}**`;
87 default:
88 return `**${name}**`;
89 }
90}
91
92/** 12 → ¹². Numbers are per session, so they can grow past nine. */
93export const superscript = (n: number): string =>
94 n > 0 ? String(n).split("").map((d) => SUPERSCRIPT_DIGITS[Number(d)] ?? "").join("") : "";
95
96/** ¹² → 12, or 0 when the text is not a superscript number. */
97export const fromSuperscript = (s: string): number => {
98 let out = "";
99 for (const ch of s) {
100 const d = SUPERSCRIPT_DIGITS.indexOf(ch);
101 if (d < 0) return 0;
102 out += d;
103 }
104 return out ? Number(out) : 0;
105};
106
107// [[name]], [[name|definition]], [[kind: name|definition]]. No nesting, no newlines.
108const MARKER = /\[\[(?:([a-z]+):\s*)?([^\[\]|\n]+?)(?:\|([^\[\]\n]+?))?\]\]/g;
109
110// Fenced code blocks are left alone even when the model slips a marker in.
111const FENCE = /(^|\n)(```|~~~)[^\n]*\n[\s\S]*?\n\2[ \t]*(?=\n|$)/g;
112
113// Spans the highlight pass must not touch: inline code, links, existing hints.
114const PROTECTED = /`[^`\n]*`|\*{2,3}[^*\n]+\*{2,3}[⁰¹²³⁴⁵⁶⁷⁸⁹]*|\[[^\]\n]*\]\([^)\n]*\)|https?:\/\/\S+/g;
115
116// While streaming, an unfinished marker at the very end of the text.
117const OPEN_MARKER = /\[\[(?:([a-z]+):\s*)?([^\[\]|\n]*)(?:\|([^\[\]\n]*))?$/;
118
119const escapeRegExp = (s: string): string => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
120
121/** Parse a marker's pieces. An unknown prefix is kept as part of the name. */
122function parseMarker(prefix: string | undefined, rawTerm: string, rawDef: string | undefined): Marker | undefined {
123 let kind: Kind = "term";
124 let term = rawTerm.trim();
125 if (prefix) {
126 if (isKind(prefix)) kind = prefix;
127 else term = `${prefix}: ${term}`;
128 }
129 if (!term) return undefined;
130 // The definition may carry a trailing "|see: a, b" segment.
131 let definition: string | undefined;
132 let related: string[] | undefined;
133 for (const part of (rawDef ?? "").split("|")) {
134 const seg = part.trim();
135 if (!seg) continue;
136 const link = /^(?:see|related|links?)\s*:\s*(.+)$/i.exec(seg);
137 if (link) {
138 related = [...(related ?? []), ...link[1]!.split(/\s*,\s*/).map((r) => r.trim()).filter(Boolean)];
139 } else if (!definition) {
140 definition = seg;
141 }
142 }
143 const marker: Marker = { kind, term, definition };
144 if (related) marker.related = related;
145 return marker;
146}
147
148/** One regex matching any of the names as whole words, longest first, with an optional plural s. */
149function termPattern(terms: string[]): RegExp | undefined {
150 const parts = [...new Set(terms.map(normalize).filter(Boolean))]
151 .sort((a, b) => b.length - a.length)
152 .map((t) => escapeRegExp(t).replace(/ /g, "\\s+"));
153 if (parts.length === 0) return undefined;
154 return new RegExp(`(?<![\\w-])(?:${parts.join("|")})(?:e?s)?(?![\\w-])`, "gi");
155}
156
157export interface RewriteOptions {
158 /** Entries already numbered, keyed by normalized name. New ones are added here. */
159 known?: Map<string, Hint>;
160 /** Entries the glossary knows; plain mentions of terms and concepts get numbered too. */
161 glossary?: KnownTerm[];
162 /**
163 * The text is still arriving. An unfinished marker at the end is shown as
164 * its bold name once the name is complete, and hidden before that.
165 */
166 streaming?: boolean;
167 /** Underline the project kinds with raw ANSI. On by default. */
168 ansi?: boolean;
169}
170
171/**
172 * Replace markers with styled names carrying a superscript number. Numbers
173 * continue from the highest one in `known`, and a name that is already known
174 * keeps its number.
175 *
176 * Afterwards every plain mention of a hinted name, or of a term or concept in
177 * `glossary`, gets the same treatment, so a word is never numbered in one
178 * sentence and bare in the next. Project kinds (why, where, how, gotcha) are
179 * only numbered when marked, since their names are often ordinary words.
180 * Code, links and existing hints are left alone.
181 */
182export function rewrite(text: string, options: RewriteOptions = {}): Rewrite {
183 const known = options.known ?? new Map<string, Hint>();
184 const ansi = options.ansi ?? true;
185 const hints: Hint[] = [];
186 let next = 1;
187 for (const h of known.values()) if (h.index >= next) next = h.index + 1;
188
189 // Hold back an unfinished trailing marker so it never shows raw.
190 let tail = "";
191 if (options.streaming) {
192 const open = OPEN_MARKER.exec(text);
193 if (open && !text.slice(open.index).includes("]]")) {
194 const name = open[2]?.trim();
195 // Name complete once the "|" has arrived; before that show nothing.
196 const kind = open[1] && isKind(open[1]) ? open[1] : "term";
197 tail = open[3] !== undefined && name ? styleName(kind, name, ansi) : "";
198 text = text.slice(0, open.index);
199 }
200 }
201 const byKey = new Map<string, KnownTerm>();
202 for (const g of options.glossary ?? []) {
203 if (g.kind === "term" || g.kind === "concept") byKey.set(normalize(g.term), g);
204 }
205
206 /** Give a name the next number and remember it. */
207 const add = (key: string, kind: Kind, term: string, definition?: string): Hint => {
208 const hint: Hint = { index: next++, kind, term, definition };
209 known.set(key, hint);
210 hints.push(hint);
211 return hint;
212 };
213
214 const transform = (chunk: string): string =>
215 chunk.replace(MARKER, (_whole, prefix: string | undefined, rawTerm: string, rawDef?: string) => {
216 const marker = parseMarker(prefix, rawTerm, rawDef);
217 if (!marker) return "";
218 const key = normalize(marker.term);
219 let hint = known.get(key);
220 if (!hint) {
221 hint = add(key, marker.kind, marker.term, marker.definition);
222 if (marker.related) hint.related = marker.related;
223 } else {
224 if (!hint.definition && marker.definition) hint.definition = marker.definition;
225 if (marker.related) hint.related = [...new Set([...(hint.related ?? []), ...marker.related])];
226 }
227 return `${styleName(hint.kind, marker.term, ansi)}${superscript(hint.index)}`;
228 });
229
230 // Plain mentions of known or glossary names, outside protected spans.
231 const highlight = (chunk: string): string => {
232 const pattern = termPattern([...known.keys(), ...byKey.keys()]);
233 if (!pattern) return chunk;
234 const mark = (plain: string): string =>
235 plain.replace(pattern, (word: string) => {
236 // Undo the plural we allowed so the lookup key matches.
237 const key = normalize(word);
238 const base =
239 [key, key.replace(/s$/, ""), key.replace(/es$/, "")].find((k) => known.has(k) || byKey.has(k)) ?? key;
240 let hint = known.get(base);
241 if (!hint) {
242 const g = byKey.get(base);
243 if (!g) return word;
244 hint = add(base, g.kind, g.term, g.definition);
245 }
246 return `${styleName(hint.kind, word, ansi)}${superscript(hint.index)}`;
247 });
248 let out = "";
249 let last = 0;
250 for (const m of chunk.matchAll(PROTECTED)) {
251 out += mark(chunk.slice(last, m.index!));
252 out += m[0];
253 last = m.index! + m[0].length;
254 }
255 return out + mark(chunk.slice(last));
256 };
257
258 // Walk the text, leaving fenced blocks untouched.
259 let out = "";
260 let last = 0;
261 for (const fence of text.matchAll(FENCE)) {
262 const start = fence.index!;
263 out += highlight(transform(text.slice(last, start)));
264 out += fence[0];
265 last = start + fence[0].length;
266 }
267 out += highlight(transform(text.slice(last)));
268
269 return { text: out + tail, hints };
270}
271
272/**
273 * Read hints back out of an already rendered reply, for example when a session
274 * is resumed and the last reply has to be re-indexed. Definitions and kinds are
275 * not in the text any more; the caller fills them from the glossary.
276 */
277export function recoverHints(text: string): Hint[] {
278 const found = new Map<number, Hint>();
279 for (const m of text.matchAll(/\*{2,3}([^*\n]+?)\*{2,3}([⁰¹²³⁴⁵⁶⁷⁸⁹]+)/g)) {
280 const index = fromSuperscript(m[2]!);
281 if (index > 0 && !found.has(index)) found.set(index, { index, kind: "term", term: stripAnsi(m[1]!).trim() });
282 }
283 return [...found.values()].sort((a, b) => a.index - b.index);
284}
285src/prompt.ts 23 lines1/**
2 * The system-prompt section that asks the model to mark jargon. Kept short:
3 * every token here is paid on every turn.
4 */
5export const PROMPT_SECTION = `## Jargon markers
6
7The user has ADHD and forgets terminology, both programming concepts and names specific to this project. Help them by marking terms:
8
9- The first time a reply uses a term with a specific technical meaning, write it as [[term|definition]]. The definition is one plain sentence, at most 15 words, with no jargon inside it. Example: [[closure|a function that remembers the variables around it after that code has finished]].
10- Mark: programming concepts, even basic ones (variable, inheritance, recursion), design patterns, tools and libraries, acronyms, and names specific to this project (modules, services, internal nicknames).
11- Other things worth remembering take a kind prefix, same shape, same limits:
12 [[concept: name|how it works, two short sentences with a tiny example, under 40 words]] for a mechanism rather than a word;
13 [[why: name|the reason]] when a design or tool choice is explained;
14 [[where: name|path or module, and what it owns]] when you say where something lives;
15 [[how: name|the steps or command]] for a procedure the user will need again;
16 [[gotcha: name|what goes wrong and how to avoid it]] for a trap worth remembering.
17- Do not mark: ordinary English, generic verbs (run, save, build), file paths, or a word whose meaning is obvious from the sentence it sits in.
18- Aim for roughly 2 to 6 marked terms in a typical reply. A long explanation may need 9; a one-line answer may need none. Never mark inside code blocks, inline code or commands.
19- When a marked entry is closely tied to another one you have marked before, add a link segment: [[closure|def|see: scope, callback]]. One or two links, only when the connection helps understanding.
20- Mark each term on its first use only; later mentions are numbered automatically. If the term was already defined in an earlier reply, write [[term]] with no definition.
21- The reader sees only the name; the definition is hidden behind a number. So the sentence must still make sense with the marker replaced by its name. Wrong: "[[gotcha: goroutines|they do not wait for each other]]." Right: "Goroutines do not wait for each other, so [[gotcha: goroutine cleanup|use a WaitGroup or the program exits before they finish]] matters." Never let a marker be the whole sentence.
22- Earlier replies show marked terms as **term** followed by a superscript digit. That is display formatting added later; keep writing [[term|definition]] yourself.`;
23src/cards.ts 30 lines1/**
2 * Which flashcards to ask: the entries most likely to have slipped. Kept free
3 * of any terminal code so both the pi overlay and the Claude Code pane use it.
4 */
5
6import type { Entry } from "./glossary.ts";
7
8const DAY = 86_400_000;
9
10/** Higher means more worth asking. Exported for tests. */
11export function priority(e: Entry, now: Date): number {
12 const daysSinceSeen = Math.min(60, (now.getTime() - Date.parse(e.lastSeen)) / DAY);
13 const daysSinceAsked = e.quiz.lastAsked ? Math.min(60, (now.getTime() - Date.parse(e.quiz.lastAsked)) / DAY) : 60;
14 let score = e.lookups * 3 + e.quiz.missed * 4 + daysSinceSeen * 0.3 + (e.quiz.asked === 0 ? 3 : 0);
15 // Asked very recently: let it rest.
16 if (daysSinceAsked < 0.5) score -= 20;
17 // Answered right more than missed: ease off.
18 score -= Math.max(0, e.quiz.asked - e.quiz.missed) * 1.5;
19 return score;
20}
21
22export function pickCards(entries: Entry[], n: number, now = new Date()): Entry[] {
23 return entries
24 .filter((e) => e.definition)
25 .map((e) => ({ e, score: priority(e, now) }))
26 .sort((a, b) => b.score - a.score)
27 .slice(0, n)
28 .map(({ e }) => e);
29}
30types/index.d.ts 23 lines1/** What the glossary pane and the quiz pane draw from. */
2export type VocabQuiz = {
3 /** Storage keys of the cards, in order. */
4 keys: string[];
5 at: number;
6 isRevealed: boolean;
7 missed: string[];
8};
9
10declare module "claude-code" {
11 interface PluginState {
12 vocab: {
13 /** What is typed in the glossary's search field. */
14 query: string;
15 /** Storage key of the entry the detail panel shows. */
16 selected: string | null;
17 /** Bumped whenever the glossary changes, so open panes redraw. */
18 rev: number;
19 quiz: VocabQuiz | null;
20 };
21 }
22}
23