SLOPSHOPPER

vocab

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

newpanerowscommandstatusprompt
v0.1.0MITupdated 2026-10-12zenodea/agent-vocab
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · vocab
│ ┃ Glossary ✕ › fix the failing auth test and add an audit log call │ ┃ a number, or a word to search │ ┃ Nothing explained yet. ⏺ 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 │ │ › /vocab │ ⎿ vocab: Glossary opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Glossary
a number, or a word to search Nothing explained yet.
Pane · vocab-quiz
No quiz running. /vocab quiz starts one.
README

vocab

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.

piClaude CodeCodex
Entry pointsrc/vocab.ts (pi extension)hooks/register.tsx (Claude Code mod)codex/vocab.ts (Codex hooks plus a vocab command)
Open the glossaryalt+w or /vocab/vocab (opens a pane)vocab in another terminal pane
Look up hint 3alt+w, then 3/vocab 3vocab 3
Searchtype in the overlay/vocab closure, or type in the panevocab closure
Quiz, export, toggle, forget/vocab quiz, export, toggle, forgetsame commandsvocab quiz, export, toggle, forget
Glossary livesin 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 markerthe mod, from the model's markerthe model itself (Codex cannot rewrite a reply)
Project kinds (why, where, how)bold, underlinedbold (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.

Install

pi

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

Claude Code

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.

Codex

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.

pi keys

KeyDoes
alt+wOpen or close the glossary. Typing a number picks that hint. Typing a word filters the whole glossary. esc closes.
alt+shift+wLook 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.

KeyDoes
↑ ↓Move one entry.
ctrl+d ctrl+uHalf a page down or up. pageDown pageUp a full page, home end to the ends.
ctrl+eAsk the agent to explain the selected entry again, plainly, with an example from this project.
tabFollow the first link of the selected entry (see: ...).

Claude Code pane

/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

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.

Commands

The same in every host. In Codex they are the vocab command's arguments (vocab quiz, not /vocab quiz).

CommandDoes
/vocabOpen 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 toggleStop 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.

Kinds

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.

KindAnswersIn the replyGlyph
termwhat is this calledbold
concepthow does this workbold italic◇
whywhy did we do it this waybold, underlined in pi?
wherewhere does this livebold, underlined in pi@
howhow do I do this againbold, underlined in pi›
gotchawhat bit us beforebold, 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.

How it works

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.

SteppiClaude CodeCodex
Prompt sectionbefore_agent_startprompt.composeSessionStart hook (codex/notes.ts has the Codex wording)
Live displaymessage_updateui.render on AssistantMessagenone needed: the model writes the final form
Stored replymessage_endsession.append (main conversation only, not subagents)Stop hook reads [vocab-N] lines
Numbers after a resumeread back from the session's repliesthe same, on session.startkept 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.

Develop

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.

Source 7 files
hooks/register.tsx 414 lines
1/**
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};
414
src/export.ts 39 lines
1/**
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);
39
src/glossary.ts 190 lines
1/**
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}
190
src/markers.ts 285 lines
1/**
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}
285
src/prompt.ts 23 lines
1/**
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.`;
23
src/cards.ts 30 lines
1/**
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}
30
types/index.d.ts 23 lines
1/** 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