Highlights jargon in Claude's replies; hover or click a term for a plain-English definition

Highlights jargon in Claude's replies and explains it in plain English.
jargon ships with about 190 common dev terms (idempotent, mutex, race condition, CORS, …). Any of them in a reply is linked (idempotent ⓘ) and listed under it, with no model call. For any other word, /jargon <term> asks Haiku once, and the term highlights from then on.
/jargon <term>: show a term's card. A term jargon doesn't know yet costs one small Haiku call, sent with the part of the newest reply that mentions it. Quotes and backticks around the term are dropped; a term is at most 60 characters. With highlights off, the definition is printed instead./jargon level beginner|intermediate|advanced: how much you already know. Each built-in term is tagged basic, intermediate or advanced:Terms you looked up with /jargon <term> always highlight, at any level. The level is kept across sessions; /jargon level alone shows it.
/jargon: list the terms Haiku has defined so far, and the current level./jargon off and /jargon on: turn the highlights off or on.on, off, level and level <one word> are commands, so they can't be looked up; a longer phrase such as /jargon level of indirection can.
Acronyms (REST, PR, CI) are only linked when written in capitals, so "the rest of the file" stays plain. Terms are only linked where they appear in prose: a name used just inside code or backticks is your own.
Highlighting costs nothing: jargon only calls Haiku when you look up a term it doesn't know. Built-in terms live in hooks/bundled.ts; the terms Haiku defined for you, and the ones you looked up, are kept in ~/.claude/jargon/cache.json. Definitions an older version saved by itself stay there unhighlighted until you look one up, which then costs nothing. Each save merges with what is already in the file, so sessions open side by side keep each other's terms.
hooks/register.tsx 526 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { JargonEntry, JargonGlossary, JargonLevel, JargonNotes, JargonPin } from '../types'
5import { BUNDLED, DEFAULT_LEVEL, LEVELS, bundledFor, isLevel } from './bundled'
6import { emptyCache, parseCache } from './cache'
7import type { CacheFile } from './cache'
8import { card, cardLeft, cardWidth, chipOffsets } from './card'
9import { MAX_GLOSSARY, MAX_REPLIES, keepLast, liveLookups, merge, withoutBundled } from './glossary'
10import {
11 DEFINE_SYSTEM,
12 LINK_ROOT,
13 MAX_TERM_CHARS,
14 cleanTerm,
15 excerpt,
16 hasTerm,
17 linkTerms,
18 parseDefinition,
19 proseOf,
20 replyKey,
21 slug,
22 termMatcher,
23} from './text'
24
25const glossary = atom({ plugin: 'jargon', key: 'glossary' } as const, {} as JargonGlossary)
26const notes = atom({ plugin: 'jargon', key: 'notes' } as const, {} as JargonNotes)
27const pinned = atom({ plugin: 'jargon', key: 'pinned' } as const, null as JargonPin | null)
28const isOn = atom({ plugin: 'jargon', key: 'isOn' } as const, true)
29const level = atom({ plugin: 'jargon', key: 'level' } as const, DEFAULT_LEVEL as JargonLevel)
30const lookedUp = atom({ plugin: 'jargon', key: 'lookedUp' } as const, [] as string[])
31
32const STORE_KEY = 'glossary'
33const BACKUP_KEY = 'cache'
34const LEVEL_KEY = 'level'
35const MAX_RECENT = 20
36const MAX_RETIRED = 500
37const MAX_TERMS = 8
38
39type Term = JargonEntry & { slug: string }
40
41// The cache file is the module's own: read at session.start (a reload too),
42// written after each lookup, merged with what is on disk so sessions side by
43// side keep each other's terms. `$.store` keeps a copy so a file left broken
44// never costs the glossary. `saved` is the file's Haiku count as last read;
45// `asked` counts this session's calls not yet written.
46let saved = 0
47let asked = 0
48let cachePath = ''
49let writing: Promise<void> = Promise.resolve()
50
51// Bumped after every write to what decides the highlights (the glossary, the
52// lookups, the level), so the pattern and the memos below never go stale. Each
53// write names its atom at the call, as the engine's scan requires, so the bump
54// follows each one.
55let termsVersion = 0
56
57// One combined pattern per terms version, and each reply's terms under it.
58let matcher: { version: number; find: (text: string) => string[] } | null = null
59
60// The transcript's width beside a docked pane, as the band last measured it
61// (its body plus the engine's five for `[-]`); `viewport.columns` is the whole
62// screen's. A render hook may not write state, so the band leaves it here.
63let transcriptColumns: number | undefined
64
65// The replies in the order they were first drawn, newest last, by message
66// id: where `/jargon <term>` finds the reply a term came from. Kept here for
67// the same reason; a reload losing it costs only that context. `retired`
68// holds the ids that left the list, so an old reply redrawn stays out.
69let recent: { id: string; text: string }[] = []
70let retired = new Set<string>()
71
72// The glossary replies are matched against, as built for `termsVersion`.
73let visible: { version: number; terms: JargonGlossary } | null = null
74
75const CHIP_LEAD = 'Terms'.length
76const CHIP_GAP = 2
77const termsMemo = new Map<string, Term[]>()
78
79function debug($: EngineInterface, line: string): void {
80 $.ui.log(`jargon: ${line}`, { to: 'debug' })
81}
82
83/**
84 * Writes the glossary, lookups and Haiku count, merged with the file as it is
85 * now. Two saves from two sessions landing in the same instant can still lose
86 * one's changes.
87 */
88function save($: EngineInterface): Promise<void> {
89 writing = writing
90 .then(async () => {
91 const sent = asked
92 const file = merge(await lastSaved($), {
93 glossary: await read($, glossary),
94 lookedUp: await read($, lookedUp),
95 asked: sent,
96 })
97 await $.store.set(BACKUP_KEY, file)
98 if (cachePath) await $.fs.write(cachePath, JSON.stringify(file, null, 2))
99 // Take in what other sessions saved, keeping what this one found meanwhile.
100 let defined: JargonGlossary = {}
101 await update($, glossary, now => {
102 defined = keepLast(withoutBundled({ ...file.glossary, ...now }), MAX_GLOSSARY)
103 return defined
104 })
105 termsVersion += 1
106 await update($, lookedUp, now => liveLookups([...file.lookedUp, ...now], defined))
107 termsVersion += 1
108 saved = file.asked
109 asked -= sent
110 })
111 .catch(err => debug($, `could not save the cache: ${String(err)}`))
112
113 return writing
114}
115
116/** The cache as last written by any session: the file, else the store's copy (no HOME, or a broken file). */
117async function lastSaved($: EngineInterface): Promise<CacheFile | null> {
118 if (cachePath && (await $.fs.exists(cachePath))) {
119 const fromFile = parseCache(String(await $.fs.read(cachePath)))
120 if (fromFile !== null) return fromFile
121 }
122 const backup = await $.store.get(BACKUP_KEY)
123
124 return backup === undefined ? null : parseCache(JSON.stringify(backup))
125}
126
127async function loadCache($: EngineInterface): Promise<CacheFile> {
128 const backup = await $.store.get(BACKUP_KEY)
129 const fromStore = backup === undefined ? null : parseCache(JSON.stringify(backup))
130 if (cachePath && (await $.fs.exists(cachePath))) {
131 const raw = String(await $.fs.read(cachePath))
132 const fromFile = parseCache(raw)
133 if (fromFile !== null) return fromFile
134
135 // Unreadable: keep it for the person, carry on from the copy.
136 const aside = `${cachePath}.broken-${Date.now()}`
137 await $.fs.write(aside, raw)
138 $.ui.log(`jargon: ${cachePath} was unreadable; moved it to ${aside}`)
139 }
140 if (fromStore !== null) return fromStore
141
142 // Glossaries saved before the cache file existed.
143 const old = await $.store.get(STORE_KEY)
144 if (typeof old === 'object' && old !== null && !Array.isArray(old)) {
145 return parseCache(JSON.stringify({ glossary: old })) ?? emptyCache()
146 }
147
148 return emptyCache()
149}
150
151/**
152 * The terms a reply shows, at most MAX_TERMS: the terms defined from it first,
153 * then the ones the person looked up, then the rest in order of appearance, so
154 * a looked-up term is never crowded out by common words earlier in the reply.
155 */
156function termsFor(
157 text: string,
158 known: JargonGlossary,
159 looked: readonly string[],
160 own: Record<string, string>,
161): Term[] {
162 if (matcher?.version !== termsVersion) {
163 const slugs = Object.keys(known)
164 matcher = { version: termsVersion, find: termMatcher(slugs.map(s => [s, known[s]?.term ?? ''])) }
165 termsMemo.clear()
166 }
167 const memoKey = `${replyKey(text)}:${Object.keys(own).join(',')}`
168 const memo = termsMemo.get(memoKey)
169 if (memo !== undefined) return memo
170
171 // Only prose counts: a name used just in code is the person's own.
172 const prose = proseOf(text)
173 const out: Term[] = []
174 const taken = new Set<string>()
175 const add = (s: string) => {
176 const entry = known[s]
177 if (entry === undefined || taken.has(s) || out.length >= MAX_TERMS) return
178 taken.add(s)
179 out.push({ ...entry, slug: s })
180 }
181 const found = matcher.find(prose)
182 const isLooked = new Set(looked)
183 // The matcher found `found` in the prose already; only the reply's own notes need checking.
184 for (const s of Object.keys(own)) {
185 const entry = known[s]
186 if (entry !== undefined && hasTerm(prose, entry.term)) add(s)
187 }
188 for (const s of found) if (isLooked.has(s)) add(s)
189 for (const s of found) add(s)
190
191 if (termsMemo.size > 300) termsMemo.clear()
192 termsMemo.set(memoKey, out)
193
194 return out
195}
196
197/**
198 * What highlights at `lvl`: the bundled terms it shows, and every term the
199 * person looked up, bundled or Haiku's. Other saved definitions (0.1.x
200 * picked them by itself) stay out of sight but answer a lookup for free.
201 */
202function visibleTerms(lvl: JargonLevel, defined: JargonGlossary, looked: readonly string[]): JargonGlossary {
203 if (visible?.version === termsVersion) return visible.terms
204 const terms: JargonGlossary = { ...bundledFor(lvl) }
205 for (const s of looked) {
206 const entry = defined[s] ?? BUNDLED[s]
207 if (entry !== undefined) terms[s] = entry
208 }
209 visible = { version: termsVersion, terms }
210
211 return terms
212}
213
214/**
215 * Notes a reply as drawn. A reply that streams redraws under its id with
216 * longer text, which replaces what was kept; a redraw of one that has left
217 * the list adds nothing.
218 */
219function remember(id: string, text: string): void {
220 const mine = recent.filter(r => r.id === id)
221 if (mine.some(r => r.text.startsWith(text))) return
222 const grown = recent.findIndex(r => r.id === id && text.startsWith(r.text))
223 if (grown >= 0) {
224 recent = recent.map((r, i) => (i === grown ? { id, text } : r))
225 return
226 }
227 if (mine.length === 0 && retired.has(id)) return
228 recent = [...recent, { id, text }]
229 while (recent.length > MAX_RECENT) {
230 retired.add(recent[0]!.id)
231 recent = recent.slice(1)
232 }
233 if (retired.size > MAX_RETIRED) retired = new Set([...retired].slice(-MAX_RETIRED))
234}
235
236/** The newest reply that holds `term`, if any. */
237function replyWith(term: string): string | undefined {
238 for (let i = recent.length - 1; i >= 0; i--) {
239 if (hasTerm(recent[i]!.text, term)) return recent[i]!.text
240 }
241
242 return undefined
243}
244
245/** Records a lookup: the term then highlights at every level, ranked first. */
246async function markLookedUp($: EngineInterface, s: string): Promise<void> {
247 await update($, lookedUp, list => (list.includes(s) ? list : [...list, s]))
248 termsVersion += 1
249 await save($)
250}
251
252/** Pins the card, or with highlights off answers with the definition itself. */
253async function show($: EngineInterface, s: string, entry: JargonEntry, hash: string, verb: string): Promise<string> {
254 if (!(await read($, isOn))) return `**${entry.term}**: ${entry.definition}`
255 await update($, pinned, () => ({ slug: s, hash }))
256
257 return `${verb} ${entry.term}.`
258}
259
260/**
261 * Shows the card for `term`, asking Haiku only when jargon doesn't know it
262 * yet. Either way the term is looked up, so it highlights from then on.
263 */
264async function define($: EngineInterface, term: string): Promise<string> {
265 const s = slug(term)
266 const reply = replyWith(term)
267 const hash = reply === undefined ? '' : replyKey(reply)
268 const entry = (await read($, glossary))[s] ?? BUNDLED[s]
269 if (entry !== undefined) {
270 await markLookedUp($, s)
271
272 return show($, s, entry, hash, 'Pinned')
273 }
274
275 const result = await $.model.complete({
276 model: 'haiku',
277 effort: 'low',
278 maxTokens: 400,
279 timeoutMs: 20000,
280 system: DEFINE_SYSTEM,
281 prompt: reply === undefined ? `Define: ${term}` : `<reply>\n${excerpt(reply, term)}\n</reply>\n\nDefine: ${term}`,
282 })
283 if (!result.isAnswered) {
284 debug($, `Haiku gave no answer (${result.reason})`)
285
286 return `Couldn't define ${term}.`
287 }
288 asked += 1
289
290 const found = parseDefinition(result.text)
291 if (found === null) {
292 debug($, `Haiku answered no definition of ${term}`)
293 await save($)
294
295 return `Couldn't define ${term}.`
296 }
297
298 // Kept under the person's own spelling: what they will see in replies.
299 const added: JargonEntry = { term, kind: found.kind, definition: found.definition }
300 await update($, glossary, all => {
301 const next = { ...all }
302 delete next[s]
303 next[s] = added
304
305 return keepLast(next, MAX_GLOSSARY)
306 })
307 termsVersion += 1
308 if (reply !== undefined && found.context !== '') {
309 await update($, notes, all => {
310 const next = { ...all }
311 const own = { ...next[hash], [s]: found.context }
312 delete next[hash]
313 next[hash] = own
314
315 return keepLast(next, MAX_REPLIES)
316 })
317 }
318 await markLookedUp($, s)
319
320 return show($, s, added, hash, 'Defined')
321}
322
323export const register: Register = on => {
324 on('session.start', async ($, e, next) => {
325 let cache = emptyCache()
326 try {
327 const home = await $.env.get('HOME')
328 cachePath = home ? `${home}/.claude/jargon/cache.json` : ''
329 cache = await loadCache($)
330 } catch (err) {
331 $.ui.log(`jargon: could not read the cache, starting empty: ${String(err)}`)
332 }
333 saved = cache.asked
334 asked = 0
335 // Caches from 0.1.x hold Haiku's picks of built-in terms too: the built-in
336 // entry and its tier take over, so the level applies to them.
337 const own = withoutBundled(cache.glossary)
338 const dropped = Object.keys(cache.glossary).length - Object.keys(own).length
339 // A reload keeps the atoms, which a 0.1.x module may have filled: clean them too.
340 let defined: JargonGlossary = {}
341 await update($, glossary, known => {
342 defined = withoutBundled({ ...own, ...known })
343 return defined
344 })
345 termsVersion += 1
346 await update($, lookedUp, list => liveLookups([...cache.lookedUp, ...list], defined))
347 termsVersion += 1
348 if (dropped > 0) {
349 debug($, `dropped ${dropped} saved terms that are built in`)
350 await save($)
351 }
352 const kept = await $.store.get(LEVEL_KEY)
353 if (isLevel(kept)) {
354 await update($, level, () => kept)
355 termsVersion += 1
356 }
357 await $.command.register({
358 name: 'jargon',
359 description: 'Jargon highlights: define a term, set your level, turn them on or off, or list the terms Haiku defined',
360 argumentHint: '[term|level <beginner|intermediate|advanced>|on|off]',
361 })
362
363 return next(e)
364 })
365
366 on('command.run', { command: 'jargon' }, async ($, e) => {
367 const arg = e.args.trim()
368 const word = arg.toLowerCase()
369 // `level` alone or with one word is the setting (one word that is no level
370 // is a typo, answered for free); `level of indirection` is a term.
371 const [first, ...rest] = word.split(/\s+/)
372 if (first === 'level' && rest.length <= 1) {
373 const name = rest[0]
374 if (name === undefined) {
375 return { text: `Level: ${await read($, level)}. Choose with /jargon level ${LEVELS.join('|')}.` }
376 }
377 if (!isLevel(name)) return { text: `No level "${name}". Levels: ${LEVELS.join(', ')}.` }
378 await update($, level, () => name)
379 termsVersion += 1
380 await $.store.set(LEVEL_KEY, name)
381
382 return { text: `Level: ${name}. ${Object.keys(bundledFor(name)).length} built-in terms highlight.` }
383 }
384 if (word === 'on' || word === 'off') {
385 await update($, isOn, () => word === 'on')
386 if (word === 'off') await update($, pinned, () => null)
387
388 return { text: word === 'on' ? 'Jargon highlights on.' : 'Jargon highlights off.' }
389 }
390 if (arg !== '') {
391 const term = cleanTerm(arg)
392 if (term.length > MAX_TERM_CHARS) {
393 return { text: `That's longer than a term: ${MAX_TERM_CHARS} characters at most.` }
394 }
395
396 return { text: await define($, term) }
397 }
398
399 const current = await read($, level)
400 const builtIn =
401 `Level ${current}: ${Object.keys(bundledFor(current)).length} built-in terms highlight by themselves; ` +
402 '/jargon <term> defines any other, /jargon level changes the level.'
403 const defined = Object.values(await read($, glossary))
404 if (defined.length === 0) return { text: builtIn }
405
406 const lines = defined
407 .slice(-40)
408 .reverse()
409 .map(t => `**${t.term}**: ${t.definition}`)
410 const calls = saved + asked
411 const count = `Haiku asked ${calls} ${calls === 1 ? 'time' : 'times'}. `
412 const where = cachePath ? `Cache: ${cachePath}` : 'Cache: kept in the plugin store (no HOME)'
413
414 return { text: [...lines, '', builtIn, `${count}${where}`].join('\n') }
415 })
416
417 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
418 if (e.props.isSummary) return next(e)
419 const text = e.props.text
420 remember(e.requestId, text)
421 if (!(await read($, isOn))) return next(e)
422
423 const key = replyKey(text)
424 const own = (await read($, notes))[key] ?? {}
425 const looked = await read($, lookedUp)
426 const known = visibleTerms(await read($, level), await read($, glossary), looked)
427 const terms = termsFor(text, known, looked, own)
428 if (terms.length === 0) return next(e)
429
430 const { Box, Text, Button, Markdown } = $.ui.resolve(e)
431 const linked = linkTerms(
432 text,
433 terms.map(t => t.term),
434 )
435 const pin = (s: string) => update($, pinned, () => ({ slug: s, hash: key }))
436 const isTerminal = e.surface === 'terminal'
437 const columns = e.viewport?.columns
438 // The chip row's width: the transcript's, less the bullet's gutter.
439 const rowWidth = Math.min(columns ?? 80, transcriptColumns ?? Infinity) - (isTerminal ? 2 : 0)
440 const width = Math.min(cardWidth(columns), rowWidth)
441 // A chip near the right edge opens its card leftwards, so it is never
442 // squeezed against the edge.
443 const offsets = chipOffsets(
444 terms.map(t => t.term),
445 rowWidth,
446 CHIP_LEAD,
447 CHIP_GAP,
448 )
449
450 const body = (
451 <Box flexDirection="column">
452 <Markdown
453 key="reply"
454 text={linked.text}
455 pressableLinks={linked.links}
456 onLinkPress={link => {
457 if (link.href.startsWith(LINK_ROOT)) void pin(link.href.slice(LINK_ROOT.length))
458 }}
459 />
460 <Box flexDirection="row" flexWrap="wrap" columnGap={CHIP_GAP}>
461 <Text dimColor>Terms</Text>
462 {terms.map((t, i) => (
463 <Box key={`t-${t.slug}`}>
464 <Button
465 key={`chip-${t.slug}`}
466 plain
467 label={t.term}
468 hover={{ color: 'claude', underline: true }}
469 onPress={() => void pin(t.slug)}
470 />
471 <Box
472 position="absolute"
473 bottom={1}
474 left={cardLeft(offsets[i] ?? 0, width, rowWidth)}
475 display="none"
476 backgroundColor="userMessageBackground"
477 hover={{ display: 'flex' }}
478 >
479 {card({ Box, Text }, t, own[t.slug], width)}
480 </Box>
481 </Box>
482 ))}
483 </Box>
484 </Box>
485 )
486
487 if (!isTerminal) return body
488
489 return (
490 <Box flexDirection="row">
491 <Box width={2} flexShrink={0}>
492 <Text>{e.props.isFirstOfReply ? '⏺' : ' '}</Text>
493 </Box>
494 {body}
495 </Box>
496 )
497 })
498
499 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
500 if (e.surface === 'terminal') transcriptColumns = e.props.bodyColumns + 5
501 const pin = await read($, pinned)
502 if (pin === null || e.props.hasSurvey || !(await read($, isOn))) return next(e)
503
504 const entry = (await read($, glossary))[pin.slug] ?? BUNDLED[pin.slug]
505 if (entry === undefined) return next(e)
506
507 const note = (await read($, notes))[pin.hash]?.[pin.slug]
508 const { Box, Text, Button } = $.ui.resolve(e)
509
510 return (
511 <Box flexDirection="row" columnGap={2}>
512 {card({ Box, Text }, entry, note, cardWidth(e.props.bodyColumns - 12))}
513 <Button
514 key="dismiss"
515 role="dismiss"
516 plain
517 hotkey="x"
518 label="Dismiss"
519 dimColor
520 onPress={() => void update($, pinned, () => null)}
521 />
522 </Box>
523 )
524 })
525}
526hooks/bundled.ts 262 lines1import type { JargonGlossary, JargonLevel } from '../types'
2import { slug } from './text'
3
4/** How far into programming a reader is before they stop needing a term explained. */
5export type Tier = 'basic' | 'intermediate' | 'advanced'
6
7// Terms that ship with the mod: they highlight with no model call. Same bar as
8// a Haiku definition: jargon a newcomer may not know, defined in at most 18
9// plain words without the term itself. Words whose everyday meaning is the
10// usual one (path, port, branch, state) are left out; those used both ways
11// (commit, shell, queue) are 'basic', so only a beginner sees them linked.
12// The tier decides which reader levels see the term (`TIERS_SHOWN`).
13const TERMS: ReadonlyArray<readonly [term: string, kind: string, definition: string, tier: Tier]> = [
14 // Concurrency and async
15 ['idempotent', 'adjective, APIs', 'Doing it twice has the same effect as doing it once.', 'advanced'],
16 ['mutex', 'noun, concurrency', 'A lock that lets only one task touch something at a time.', 'advanced'],
17 ['race condition', 'noun, concurrency', 'A bug where the outcome depends on which of two tasks finishes first.', 'advanced'],
18 ['deadlock', 'noun, concurrency', 'Two tasks each waiting on the other, so neither can ever continue.', 'advanced'],
19 ['atomic', 'adjective, concurrency', 'Happens all at once or not at all, never left half-finished.', 'advanced'],
20 ['concurrency', 'noun, programming', 'Several tasks making progress during the same period, taking turns or truly in parallel.', 'intermediate'],
21 ['async', 'adjective, programming', 'Starts work that finishes later, so the program can keep going meanwhile.', 'basic'],
22 ['await', 'keyword, JavaScript', 'Pauses this function until a pending result arrives, without freezing the program.', 'basic'],
23 ['callback', 'noun, programming', 'A function handed to other code, which calls it when something happens.', 'basic'],
24 ['event loop', 'noun, JavaScript', "The runtime's cycle that picks up finished work and runs the code waiting on it.", 'advanced'],
25
26 // Data and types
27 ['hash', 'noun, computing', 'A short fingerprint computed from data; the same input always gives the same one.', 'basic'],
28 ['hash map', 'noun, data structures', 'A lookup table that finds a value by its key almost instantly.', 'intermediate'],
29 ['queue', 'noun, data structures', 'A line of items handled in arrival order: first in, first out.', 'basic'],
30 ['recursion', 'noun, programming', 'A function solving a problem by calling itself on smaller pieces of it.', 'basic'],
31 ['serialization', 'noun, data', 'Turning in-memory data into text or bytes that can be saved or sent.', 'advanced'],
32 ['JSON', 'noun, data format', 'A plain-text format for structured data, built from lists and key-value pairs.', 'basic'],
33 ['YAML', 'noun, data format', 'A plain-text settings format that uses indentation to show structure.', 'basic'],
34 ['schema', 'noun, data', 'The agreed shape of some data: which fields exist and what types they hold.', 'intermediate'],
35 ['enum', 'noun, programming', 'A type whose value must be one of a fixed, named set of options.', 'intermediate'],
36 ['immutable', 'adjective, programming', 'Cannot be changed after it is created; changes make a new copy instead.', 'advanced'],
37 ['null', 'noun, programming', 'A value meaning "nothing here"; using it as a real value often crashes.', 'basic'],
38 ['boolean', 'noun, programming', 'A value that is either true or false.', 'basic'],
39 ['regex', 'noun, text processing', 'A compact pattern language for finding or matching text.', 'intermediate'],
40 ['regular expression', 'noun, text processing', 'A compact pattern language for finding or matching text.', 'intermediate'],
41 ['UUID', 'noun, identifiers', 'A long random ID that is practically guaranteed never to repeat anywhere.', 'intermediate'],
42 ['timestamp', 'noun, data', 'A recorded date and time marking when something happened.', 'basic'],
43 ['UTF-8', 'noun, text encoding', 'The standard way to store text from every language as bytes.', 'intermediate'],
44 ['base64', 'noun, encoding', 'A way to write any binary data using only letters, digits and two symbols.', 'intermediate'],
45 ['truthy', 'adjective, JavaScript', 'Counted as true in a condition, even when not literally true.', 'advanced'],
46 ['falsy', 'adjective, JavaScript', 'Counted as false in a condition, like 0, an empty string or null.', 'advanced'],
47
48 // Languages and code
49 ['TypeScript', 'noun, language', 'JavaScript with added type labels that catch mistakes before the code runs.', 'basic'],
50 ['type annotation', 'noun, programming', 'A label saying what kind of value a variable or argument holds.', 'intermediate'],
51 ['generics', 'noun, programming', 'Code written once that works for many types, with the type filled in later.', 'advanced'],
52 ['closure', 'noun, programming', 'A function that remembers the variables from the place it was created.', 'advanced'],
53 ['polymorphism', 'noun, programming', 'One call working on different kinds of objects, each responding its own way.', 'advanced'],
54 ['namespace', 'noun, programming', 'A named container that stops names clashing with the same names elsewhere.', 'intermediate'],
55 ['side effect', 'noun, programming', 'Anything a function changes besides returning a value, like writing a file.', 'advanced'],
56 ['pure function', 'noun, programming', 'A function whose result depends only on its inputs and that changes nothing else.', 'advanced'],
57 ['dependency injection', 'noun, design pattern', 'Handing a component the things it needs instead of letting it build them itself.', 'advanced'],
58 ['singleton', 'noun, design pattern', 'A class allowed only one shared instance in the whole program.', 'advanced'],
59 ['abstraction', 'noun, software design', 'Hiding details behind a simpler surface so the rest of the code can ignore them.', 'intermediate'],
60 ['coupling', 'noun, software design', "How much one part of the code depends on another part's details.", 'advanced'],
61 ['refactor', 'verb, programming', 'Restructure code without changing what it does.', 'basic'],
62 ['compile', 'verb, programming', 'Turn source code into a form the computer can run directly.', 'basic'],
63 ['transpile', 'verb, build tools', 'Convert source code into another language or version, such as TypeScript into JavaScript.', 'advanced'],
64 ['runtime', 'noun, programming', 'The environment that runs a program, or the period while it is running.', 'basic'],
65 ['boilerplate', 'noun, programming', 'Repetitive setup code needed every time but carrying little meaning.', 'intermediate'],
66 ['dead code', 'noun, programming', 'Code that never runs or whose result is never used.', 'intermediate'],
67 ['technical debt', 'noun, software practice', 'Shortcuts taken now that slow down future changes until someone cleans them up.', 'intermediate'],
68 ['edge case', 'noun, testing', 'An unusual input or situation at the limits of what the code expects.', 'basic'],
69 ['off-by-one', 'adjective, bugs', 'A mistake where a count or position is one too many or one too few.', 'intermediate'],
70 ['memory leak', 'noun, performance', 'Memory a program keeps holding after it stops needing it, so usage keeps growing.', 'intermediate'],
71 ['garbage collection', 'noun, runtimes', 'The runtime automatically freeing memory that nothing uses any more.', 'advanced'],
72 ['segfault', 'noun, crashes', 'A crash caused by a program touching memory it is not allowed to use.', 'advanced'],
73 ['stack trace', 'noun, debugging', 'The list of function calls that were running when an error happened.', 'basic'],
74 ['Big O', 'noun, algorithms', "Shorthand for how an algorithm's time or memory grows as its input grows.", 'advanced'],
75 ['memoization', 'noun, performance', "Saving a function's results so repeat calls with the same input are instant.", 'advanced'],
76 ['lazy loading', 'noun, performance', 'Loading something only when it is first needed, not up front.', 'intermediate'],
77 ['debounce', 'verb, UI programming', 'Wait until rapid repeated events stop, then act once.', 'advanced'],
78 ['throttle', 'verb, performance', 'Limit how often something may run, for example at most once per second.', 'advanced'],
79
80 // Command line and operating system
81 ['CLI', 'noun, tooling', 'A program you use by typing commands rather than clicking.', 'basic'],
82 ['shell', 'noun, command line', 'The program that reads the commands you type and runs them.', 'basic'],
83 ['environment variable', 'noun, operating systems', 'A named setting the system passes to programs, often holding secrets or config.', 'basic'],
84 ['stdin', 'noun, operating systems', 'The standard channel a program reads its input from.', 'intermediate'],
85 ['stdout', 'noun, operating systems', 'The standard channel a program writes its normal output to.', 'intermediate'],
86 ['stderr', 'noun, operating systems', 'The standard channel a program writes its error messages to.', 'intermediate'],
87 ['exit code', 'noun, command line', 'The number a program returns when it ends; zero usually means success.', 'intermediate'],
88 ['sudo', 'command, Unix', 'Runs the command that follows with administrator rights.', 'basic'],
89 ['chmod', 'command, Unix', 'Changes who may read, write or run a file.', 'intermediate'],
90 ['grep', 'command, Unix', 'Searches files for lines matching a text pattern.', 'basic'],
91 ['glob', 'noun, command line', 'A wildcard pattern like *.ts that matches many file names at once.', 'intermediate'],
92 ['symlink', 'noun, file systems', 'A file that points to another file or folder, like a shortcut.', 'intermediate'],
93 ['dotfile', 'noun, configuration', 'A settings file whose name starts with a dot, hidden by default.', 'intermediate'],
94 ['shebang', 'noun, scripting', 'The first line of a script, starting with #!, naming the program that runs it.', 'advanced'],
95 ['daemon', 'noun, operating systems', 'A program that runs quietly in the background, usually started with the system.', 'advanced'],
96 ['PID', 'noun, operating systems', 'The number the system gives each running program.', 'intermediate'],
97 ['cron', 'noun, scheduling', 'A Unix tool that runs commands on a repeating schedule.', 'intermediate'],
98 ['SSH', 'noun, networking', 'A secure way to log in to another computer and run commands there.', 'intermediate'],
99
100 // Version control
101 ['git', 'tool, version control', 'Records the history of changes to files so work can be shared and undone.', 'basic'],
102 ['repository', 'noun, version control', 'A project folder whose full history is tracked by version control.', 'basic'],
103 ['repo', 'noun, version control', 'Short for repository: a project folder whose full history is tracked.', 'basic'],
104 ['monorepo', 'noun, project layout', 'One repository holding many related projects or packages together.', 'intermediate'],
105 ['commit', 'noun, version control', "A saved snapshot of changes in a project's history, with a message.", 'basic'],
106 ['diff', 'noun, version control', 'The line-by-line differences between two versions of something.', 'basic'],
107 ['rebase', 'verb, version control', 'Replay your commits on top of another branch so history reads as one line.', 'intermediate'],
108 ['cherry-pick', 'verb, version control', 'Copy one specific commit from one branch onto another.', 'intermediate'],
109 ['stash', 'verb, version control', 'Set uncommitted changes aside for later, leaving a clean working copy.', 'intermediate'],
110 ['merge conflict', 'noun, version control', 'Two changes edited the same lines, so a person must choose between them.', 'intermediate'],
111 ['pull request', 'noun, collaboration', 'A request to review some changes and merge them into the main code.', 'basic'],
112 ['PR', 'noun, collaboration', 'A pull request: a proposed change waiting for review before it is merged.', 'basic'],
113 ['fork', 'noun, collaboration', "Your own copy of someone else's repository, which you can change freely.", 'basic'],
114 ['.gitignore', 'noun, version control', 'A file listing which files git should never track.', 'basic'],
115
116 // Web and networking
117 ['API', 'noun, software', 'A defined way for one program to ask another for data or actions.', 'basic'],
118 ['endpoint', 'noun, web APIs', 'One specific URL of an API that accepts a certain kind of request.', 'basic'],
119 ['REST', 'noun, web APIs', 'A common style of web API built on URLs and standard request verbs.', 'intermediate'],
120 ['GraphQL', 'noun, web APIs', 'A query language letting a client ask an API for exactly the fields it needs.', 'intermediate'],
121 ['webhook', 'noun, web APIs', 'A URL another service calls automatically to tell you something happened.', 'intermediate'],
122 ['HTTP', 'noun, web', 'The protocol browsers and servers use to send requests and responses.', 'basic'],
123 ['HTTPS', 'noun, web', "The encrypted version of the web's request protocol.", 'basic'],
124 ['status code', 'noun, web', 'The number a web server sends back to say how a request went, like 404.', 'basic'],
125 ['payload', 'noun, networking', 'The actual data carried in a request or message, apart from its headers.', 'intermediate'],
126 ['WebSocket', 'noun, web', 'A connection that stays open so server and browser can message each other anytime.', 'intermediate'],
127 ['CORS', 'noun, web security', "Browser rules deciding which other websites a page's scripts may fetch data from.", 'advanced'],
128 ['DNS', 'noun, networking', "The internet's phone book, turning names like example.com into server addresses.", 'intermediate'],
129 ['localhost', 'noun, networking', 'A name that always means this same computer.', 'basic'],
130 ['latency', 'noun, performance', 'The delay between asking for something and getting the answer.', 'intermediate'],
131 ['throughput', 'noun, performance', 'How much work a system finishes per unit of time.', 'intermediate'],
132 ['rate limit', 'noun, APIs', 'A cap on how many requests a service accepts in a given time.', 'intermediate'],
133 ['pagination', 'noun, APIs', 'Splitting a long list of results into pages fetched one at a time.', 'intermediate'],
134 ['exponential backoff', 'noun, networking', 'Waiting longer after each failed retry, so a struggling service is not hammered.', 'advanced'],
135 ['proxy', 'noun, networking', 'A go-between server that forwards requests on behalf of someone else.', 'intermediate'],
136 ['CDN', 'noun, web infrastructure', 'A network of servers keeping copies of files close to users, for speed.', 'intermediate'],
137 ['load balancer', 'noun, infrastructure', 'A server that spreads incoming requests across several copies of an app.', 'intermediate'],
138 ['frontend', 'noun, web', "The part of an app that runs in the user's browser or device.", 'basic'],
139 ['backend', 'noun, web', 'The server side of an app: the data, logic and storage users never see.', 'basic'],
140 ['middleware', 'noun, web servers', 'Code that runs between a request arriving and its handler, adding a shared step.', 'intermediate'],
141 ['DOM', 'noun, web', "The browser's live tree of a page's elements, which scripts can change.", 'intermediate'],
142 ['cookie', 'noun, web', 'A small piece of data a website stores in your browser to remember you.', 'basic'],
143 ['polyfill', 'noun, web', 'Code that adds a missing feature to older browsers or runtimes.', 'advanced'],
144
145 // Security
146 ['authentication', 'noun, security', 'Checking who someone is, for example with a password.', 'basic'],
147 ['authorization', 'noun, security', 'Checking what a logged-in person is allowed to do.', 'basic'],
148 ['OAuth', 'noun, authentication', 'A standard way to let an app act for you without giving it your password.', 'intermediate'],
149 ['JWT', 'noun, authentication', 'A signed token carrying proof of who you are from one request to the next.', 'intermediate'],
150 ['encryption', 'noun, security', 'Scrambling data so only someone with the right key can read it.', 'basic'],
151 ['sanitize', 'verb, security', 'Clean untrusted input so it cannot be mistaken for code or commands.', 'intermediate'],
152 ['SQL injection', 'noun, security', 'An attack that sneaks database commands into input a program trusts.', 'intermediate'],
153 ['XSS', 'noun, web security', "An attack that gets a website to run someone else's script in visitors' browsers.", 'advanced'],
154 ['CSRF', 'noun, web security', 'An attack tricking a logged-in browser into sending a request its user never meant.', 'advanced'],
155
156 // Databases and caching
157 ['SQL', 'noun, databases', 'The standard language for reading and changing data in relational databases.', 'basic'],
158 ['NoSQL', 'noun, databases', 'Databases that store data without fixed tables, such as documents or key-value pairs.', 'intermediate'],
159 ['Postgres', 'noun, database', 'A popular open-source relational database.', 'intermediate'],
160 ['PostgreSQL', 'noun, database', 'A popular open-source relational database.', 'intermediate'],
161 ['SQLite', 'noun, database', 'A small database kept in a single file, needing no separate server.', 'intermediate'],
162 ['Redis', 'tool, databases', 'A very fast in-memory store, often used for caches and queues.', 'intermediate'],
163 ['ORM', 'noun, databases', 'A library that lets code work with database rows as ordinary objects.', 'intermediate'],
164 ['migration', 'noun, databases', "A versioned script that changes a database's structure, such as adding a column.", 'basic'],
165 ['transaction', 'noun, databases', 'A group of database changes that all succeed together or all fail together.', 'basic'],
166 ['primary key', 'noun, databases', 'The column whose value uniquely identifies each row in a table.', 'intermediate'],
167 ['foreign key', 'noun, databases', 'A column pointing to a row in another table, linking the two.', 'intermediate'],
168 ['N+1 query', 'noun, databases', 'A slowdown from running one extra database query per item instead of one for all.', 'advanced'],
169 ['cache', 'noun, performance', 'A store of recent results kept close by so they need not be fetched again.', 'basic'],
170 ['cache invalidation', 'noun, performance', 'Deciding when saved results are out of date and must be thrown away.', 'advanced'],
171
172 // Tooling, builds and releases
173 ['npm', 'tool, JavaScript', 'The JavaScript package manager: it installs libraries and runs project scripts.', 'basic'],
174 ['package manager', 'noun, tooling', 'A tool that downloads and updates the libraries a project depends on.', 'basic'],
175 ['dependency', 'noun, software', 'A library or tool your project needs in order to work.', 'basic'],
176 ['lockfile', 'noun, tooling', 'A file pinning the exact version of every installed library, for repeatable installs.', 'intermediate'],
177 ['semver', 'noun, versioning', 'Version numbers as major.minor.patch, where a major bump signals breaking changes.', 'intermediate'],
178 ['breaking change', 'noun, versioning', 'A change that makes existing code relying on it stop working.', 'intermediate'],
179 ['bundler', 'noun, build tools', 'A tool that combines many source files into a few files for the browser.', 'intermediate'],
180 ['tree shaking', 'noun, build tools', 'Dropping code the app never uses from the final bundle.', 'advanced'],
181 ['minify', 'verb, build tools', 'Shrink code by removing spaces and shortening names, without changing behaviour.', 'intermediate'],
182 ['source map', 'noun, debugging', 'A file mapping built code back to the original source, for readable errors.', 'advanced'],
183 ['linter', 'noun, tooling', 'A tool that scans code for likely bugs and style problems without running it.', 'basic'],
184 ['lint', 'verb, tooling', 'Scan code automatically for likely bugs and style problems.', 'basic'],
185 ['SDK', 'noun, tooling', 'A kit of libraries and tools for building on a particular platform or service.', 'basic'],
186 ['IDE', 'noun, tooling', 'A code editor bundled with tools like debugging, search and refactoring.', 'basic'],
187 ['REPL', 'noun, tooling', 'An interactive prompt that runs each line of code as you type it.', 'intermediate'],
188 ['debugger', 'noun, tooling', 'A tool that pauses a running program so you can inspect it step by step.', 'basic'],
189 ['breakpoint', 'noun, debugging', 'A marked line where the debugger pauses the program.', 'basic'],
190 ['hot reload', 'noun, development', 'Applying code changes to a running app instantly, without restarting it.', 'intermediate'],
191 ['CI', 'noun, DevOps', 'A service that automatically builds and tests every change pushed to a project.', 'basic'],
192 ['CI/CD', 'noun, DevOps', 'Automatic testing of every change, and automatic release of the ones that pass.', 'intermediate'],
193 ['pipeline', 'noun, DevOps', 'A series of automated steps, such as build, test and release, run in order.', 'basic'],
194 ['deploy', 'verb, DevOps', 'Put a new version of software onto the servers where people use it.', 'basic'],
195 ['staging', 'noun, DevOps', 'A copy of the live system for trying releases before real users see them.', 'basic'],
196 ['rollback', 'noun, DevOps', 'Undoing a release by going back to the previous working version.', 'intermediate'],
197 ['feature flag', 'noun, software practice', 'A switch that turns a feature on or off without releasing new code.', 'intermediate'],
198 ['observability', 'noun, operations', 'How well logs, metrics and traces show what a running system is doing.', 'advanced'],
199 ['Docker', 'tool, containers', 'Packages an app with everything it needs so it runs the same anywhere.', 'intermediate'],
200 ['container', 'noun, infrastructure', 'A lightweight, isolated package that runs an app with its own files and settings.', 'basic'],
201 ['Kubernetes', 'tool, infrastructure', 'A system that runs and manages many containers across many machines.', 'intermediate'],
202 ['virtual machine', 'noun, infrastructure', 'A whole computer simulated in software, running on another computer.', 'intermediate'],
203 ['VM', 'noun, infrastructure', 'A virtual machine: a whole computer simulated in software on another computer.', 'intermediate'],
204 ['serverless', 'adjective, cloud', 'Code a cloud provider runs on demand, without you managing any servers.', 'intermediate'],
205
206 // Testing
207 ['unit test', 'noun, testing', 'A small automated test that checks one piece of code on its own.', 'basic'],
208 ['integration test', 'noun, testing', 'A test that checks several parts of a system working together for real.', 'intermediate'],
209 ['end-to-end test', 'noun, testing', 'A test that drives the whole app the way a real user would.', 'intermediate'],
210 ['mock', 'noun, testing', 'A fake stand-in for a real component, so a test runs in isolation.', 'basic'],
211 ['fixture', 'noun, testing', 'Fixed sample data or setup that tests reuse.', 'basic'],
212 ['assertion', 'noun, testing', 'A check in a test that fails loudly when a value is not what was expected.', 'intermediate'],
213 ['flaky', 'adjective, testing', 'Passes or fails at random without any change to the code.', 'intermediate'],
214 ['regression', 'noun, testing', 'Something that used to work and broke after a later change.', 'basic'],
215 ['coverage', 'noun, testing', 'The share of the code that the tests actually run.', 'basic'],
216 ['TDD', 'noun, testing practice', 'Writing a failing test first, then just enough code to make it pass.', 'intermediate'],
217
218 // AI
219 ['LLM', 'noun, AI', 'A large language model: an AI trained on vast amounts of text to read and write.', 'basic'],
220 ['context window', 'noun, AI', 'How much text a language model can take into account at once.', 'advanced'],
221 ['prompt injection', 'noun, AI security', 'Hidden instructions in text that trick an AI into doing something unintended.', 'advanced'],
222 ['MCP', 'noun, AI tooling', 'A standard way to connect AI assistants to outside tools and data sources.', 'advanced'],
223 ['embedding', 'noun, AI', "A list of numbers standing for a text's meaning, so similar texts get similar lists.", 'advanced'],
224 ['RAG', 'noun, AI', 'Looking up relevant documents and handing them to a model before it answers.', 'advanced'],
225]
226
227/** The bundled terms, slug to entry. Never written to the cache: a cached entry of the same slug wins. */
228export const BUNDLED: JargonGlossary = Object.fromEntries(
229 TERMS.map(([term, kind, definition]) => [slug(term), { term, kind, definition }]),
230)
231
232/** Each bundled term's tier, by slug. */
233export const TIERS: Readonly<Record<string, Tier>> = Object.fromEntries(
234 TERMS.map(([term, , , tier]) => [slug(term), tier]),
235)
236
237export const LEVELS: readonly JargonLevel[] = ['beginner', 'intermediate', 'advanced']
238export const DEFAULT_LEVEL: JargonLevel = 'intermediate'
239
240/** A beginner sees every bundled term; each level up hides the tier below it. */
241export const TIERS_SHOWN: Readonly<Record<JargonLevel, readonly Tier[]>> = {
242 beginner: ['basic', 'intermediate', 'advanced'],
243 intermediate: ['intermediate', 'advanced'],
244 advanced: ['advanced'],
245}
246
247const BY_LEVEL = Object.fromEntries(
248 LEVELS.map(level => [
249 level,
250 Object.fromEntries(Object.entries(BUNDLED).filter(([s]) => TIERS_SHOWN[level].includes(TIERS[s]!))),
251 ]),
252) as Record<JargonLevel, JargonGlossary>
253
254/** The bundled terms a reader at `level` sees highlighted. */
255export function bundledFor(level: JargonLevel): JargonGlossary {
256 return BY_LEVEL[level]
257}
258
259export function isLevel(value: unknown): value is JargonLevel {
260 return typeof value === 'string' && (LEVELS as readonly string[]).includes(value)
261}
262hooks/cache.ts 55 lines1import type { JargonGlossary } from '../types'
2
3export const CACHE_VERSION = 1
4
5/**
6 * What `~/.claude/jargon/cache.json` holds: every definition Haiku wrote, the
7 * slugs the person looked up, and how many times Haiku was asked.
8 */
9export type CacheFile = {
10 version: typeof CACHE_VERSION
11 glossary: JargonGlossary
12 lookedUp: string[]
13 asked: number
14}
15
16export function emptyCache(): CacheFile {
17 return { version: CACHE_VERSION, glossary: {}, lookedUp: [], asked: 0 }
18}
19
20/**
21 * Reads the file's text, keeping every well-formed entry; null when the text
22 * is no JSON object at all (a write cut short), so the caller can keep it.
23 * Fields older versions wrote (`seen`, `skipped`) are dropped.
24 */
25export function parseCache(raw: string): CacheFile | null {
26 let data: unknown
27 try {
28 data = JSON.parse(raw)
29 } catch {
30 return null
31 }
32 if (typeof data !== 'object' || data === null || Array.isArray(data)) return null
33
34 const o = data as Record<string, unknown>
35 const glossary: JargonGlossary = {}
36 if (typeof o.glossary === 'object' && o.glossary !== null) {
37 for (const [slug, v] of Object.entries(o.glossary as Record<string, unknown>)) {
38 if (typeof v !== 'object' || v === null) continue
39 const e = v as Record<string, unknown>
40 if (typeof e.term !== 'string' || typeof e.definition !== 'string') continue
41 glossary[slug] = {
42 term: e.term,
43 kind: typeof e.kind === 'string' ? e.kind : '',
44 definition: e.definition,
45 }
46 }
47 }
48 const lookedUp = Array.isArray(o.lookedUp)
49 ? o.lookedUp.filter((s): s is string => typeof s === 'string')
50 : []
51 const asked = typeof o.asked === 'number' && Number.isFinite(o.asked) ? o.asked : 0
52
53 return { version: CACHE_VERSION, glossary, lookedUp, asked }
54}
55hooks/card.tsx 96 lines1import type { Elements } from 'claude-code'
2
3import type { JargonEntry } from '../types'
4
5type Kit = Pick<Elements['terminal'], 'Box' | 'Text'>
6
7export const CARD_MAX_WIDTH = 64
8
9/**
10 * A margin note: one accent bar down the left, the term in the accent and
11 * bold, its kind dim beside it, the definition at full strength, and the
12 * reply's own use of it dim beneath. No frame, no heading.
13 */
14export function card(
15 { Box, Text }: Kit,
16 entry: JargonEntry,
17 note: string | undefined,
18 width: number,
19) {
20 return (
21 <Box
22 flexDirection="column"
23 width={width}
24 borderStyle="quote"
25 borderColor="claude"
26 paddingLeft={1}
27 >
28 <Text wrap="wrap">
29 <Text bold color="claude">
30 {entry.term}
31 </Text>
32 {entry.kind ? (
33 <Text dimColor italic>
34 {' '}
35 {entry.kind}
36 </Text>
37 ) : null}
38 </Text>
39 <Text wrap="wrap">{entry.definition}</Text>
40 {note ? (
41 <Text dimColor wrap="wrap">
42 Here: {note}
43 </Text>
44 ) : null}
45 </Box>
46 )
47}
48
49export function cardWidth(columns: number | undefined): number {
50 return Math.max(24, Math.min(CARD_MAX_WIDTH, (columns ?? 80) - 4))
51}
52
53const WIDE =
54 /[\p{Extended_Pictographic}\p{Script=Han}\p{Script=Hangul}\p{Script=Hiragana}\p{Script=Katakana}]/u
55
56/** Cells a label takes: wide (CJK, emoji) characters count two. */
57export function cellWidth(text: string): number {
58 let n = 0
59 for (const ch of text) n += WIDE.test(ch) ? 2 : 1
60
61 return n
62}
63
64/**
65 * Where each chip starts in a row laid out as `flexWrap="wrap"` with
66 * `columnGap`: the lead label first, then the chips, a chip that does not fit
67 * starting the next line at 0.
68 */
69export function chipOffsets(
70 labels: string[],
71 rowWidth: number,
72 leadWidth: number,
73 gap: number,
74): number[] {
75 let cursor = leadWidth
76
77 return labels.map(label => {
78 const w = cellWidth(label)
79 const x = cursor === 0 || cursor + gap + w > rowWidth ? 0 : cursor + gap
80 cursor = x + w
81
82 return x
83 })
84}
85
86/**
87 * The card's `left` against its chip at `x`: 0 while it fits to the right,
88 * else shifted left so its right edge meets the row's, never past the row's
89 * left edge.
90 */
91export function cardLeft(x: number, width: number, rowWidth: number): number {
92 if (x + width <= rowWidth) return 0
93
94 return Math.max(-x, rowWidth - x - width)
95}
96hooks/glossary.ts 48 lines1import type { JargonGlossary } from '../types'
2import { BUNDLED } from './bundled'
3import { emptyCache } from './cache'
4import type { CacheFile } from './cache'
5
6// What the glossary may hold, and how it merges. Plain values in and out:
7// register.tsx does the reading and writing.
8
9export const MAX_GLOSSARY = 500
10export const MAX_REPLIES = 60
11
12export function keepLast<T>(record: Record<string, T>, max: number): Record<string, T> {
13 const keys = Object.keys(record)
14 if (keys.length <= max) return record
15
16 return Object.fromEntries(keys.slice(-max).map(k => [k, record[k] as T]))
17}
18
19/** The glossary without built-in terms: those ship with the mod and are never saved. */
20export function withoutBundled(glossary: JargonGlossary): JargonGlossary {
21 return Object.fromEntries(Object.entries(glossary).filter(([s]) => !(s in BUNDLED)))
22}
23
24/** The lookups that still name a term: a Haiku definition the cap dropped takes its lookup with it. */
25export function liveLookups(slugs: Iterable<string>, glossary: JargonGlossary): string[] {
26 return [...new Set(slugs)].filter(s => s in glossary || s in BUNDLED)
27}
28
29/**
30 * The file to write: what is on disk now (another session may have saved
31 * since this one read it) with this session's definitions, lookups and calls
32 * added. This session's entry wins on the same slug.
33 */
34export function merge(
35 disk: CacheFile | null,
36 mine: { glossary: JargonGlossary; lookedUp: readonly string[]; asked: number },
37): CacheFile {
38 const base = disk ?? emptyCache()
39 const glossary = keepLast(withoutBundled({ ...base.glossary, ...mine.glossary }), MAX_GLOSSARY)
40
41 return {
42 ...emptyCache(),
43 glossary,
44 lookedUp: liveLookups([...base.lookedUp, ...mine.lookedUp], glossary),
45 asked: base.asked + mine.asked,
46 }
47}
48hooks/text.ts 271 lines1export const LINK_ROOT = 'https://jargon.invalid/'
2/** Drawn after each linked term, inside the link, so the mark presses with the word. */
3export const MARK = 'ⓘ'
4
5/** The longest term `/jargon <term>` takes, as the old extraction clipped terms. */
6export const MAX_TERM_CHARS = 60
7/** How much of a reply goes to Haiku as context, around the term. */
8export const MAX_EXCERPT_CHARS = 1200
9
10/** What Haiku answers for one term: the entry's text and how the reply uses it. */
11export type Definition = { kind: string; definition: string; context: string }
12
13export function slug(term: string): string {
14 const s = term
15 .toLowerCase()
16 .replace(/\+/g, 'plus')
17 .replace(/#/g, 'sharp')
18 .replace(/[^a-z0-9]+/g, '-')
19 .replace(/^-+|-+$/g, '')
20 .slice(0, 48)
21
22 return s || `t${hash(term)}`
23}
24
25/** FNV-1a over the trimmed text, as hex: the key a reply's notes sit under. */
26export function hash(text: string): string {
27 let h = 0x811c9dc5
28 const t = text.trim()
29 for (let i = 0; i < t.length; i++) {
30 h ^= t.charCodeAt(i)
31 h = Math.imul(h, 0x01000193)
32 }
33
34 return (h >>> 0).toString(16)
35}
36
37function escape(s: string): string {
38 return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
39}
40
41/**
42 * True when every letter is a capital and there are at least two (REST, PR,
43 * CI/CD, UTF-8): such a term matches only in capitals, so "the rest of" never
44 * reads as REST.
45 */
46export function isAcronym(term: string): boolean {
47 const letters = term.replace(/[^A-Za-z]/g, '')
48
49 return letters.length >= 2 && letters === letters.toUpperCase()
50}
51
52/** Matches the term as a whole word: in any case, or in capitals for an acronym. */
53export function termPattern(term: string): RegExp {
54 return new RegExp(`(?<![\\w-])${escape(term)}(?![\\w-])`, isAcronym(term) ? '' : 'i')
55}
56
57export function hasTerm(text: string, term: string): boolean {
58 return term.trim() !== '' && termPattern(term).test(text)
59}
60
61/**
62 * Two patterns for a whole glossary, one for acronyms (capitals only) and one
63 * for the rest (any case): answers the slugs of the terms a text holds, in
64 * the order they appear, a longer term winning over one inside it. Built once
65 * per glossary, not per draw.
66 */
67export function termMatcher(
68 entries: ReadonlyArray<readonly [slug: string, term: string]>,
69): (text: string) => string[] {
70 const anyCase = new Map<string, string>()
71 const capitals = new Map<string, string>()
72 for (const [s, term] of entries) {
73 if (term.trim() === '') continue
74 if (isAcronym(term)) capitals.set(term, s)
75 else anyCase.set(term.toLowerCase(), s)
76 }
77 const patternOf = (spellings: Map<string, string>, flags: string) => {
78 if (spellings.size === 0) return null
79 const alternatives = [...spellings.keys()].sort((a, b) => b.length - a.length).map(escape)
80
81 return new RegExp(`(?<![\\w-])(?:${alternatives.join('|')})(?![\\w-])`, flags)
82 }
83 const patterns = [
84 { pattern: patternOf(anyCase, 'gi'), slugOf: (m: string) => anyCase.get(m.toLowerCase()) },
85 { pattern: patternOf(capitals, 'g'), slugOf: (m: string) => capitals.get(m) },
86 ]
87
88 return text => {
89 const hits: { at: number; end: number; slug: string }[] = []
90 for (const { pattern, slugOf } of patterns) {
91 if (pattern === null) continue
92 for (const m of text.matchAll(pattern)) {
93 const s = slugOf(m[0])
94 const at = m.index ?? 0
95 if (s !== undefined) hits.push({ at, end: at + m[0].length, slug: s })
96 }
97 }
98 hits.sort((a, b) => a.at - b.at || b.end - a.end)
99
100 const out = new Set<string>()
101 let reached = 0
102 for (const hit of hits) {
103 if (hit.at < reached) continue
104 reached = hit.end
105 out.add(hit.slug)
106 }
107
108 return [...out]
109 }
110}
111
112// Code (fenced, indented, inline with one or two backticks), links and bare
113// URLs: never rewritten.
114const PROTECTED = new RegExp(
115 [
116 '```[\\s\\S]*?(?:```|$)',
117 '~~~[\\s\\S]*?(?:~~~|$)',
118 '(?:^|\\n)(?:(?: {4}|\\t)[^\\n]*(?:\\n|$))+',
119 '``[^\\n]*?``',
120 '`[^`\\n]*`',
121 '!?\\[[^\\]]*\\]\\([^)]*\\)',
122 '<https?:[^>\\s]*>',
123 'https?:\\/\\/\\S+',
124 ].join('|'),
125 'g',
126)
127
128type Segment = { text: string; isProse: boolean }
129
130function segments(text: string): Segment[] {
131 const out: Segment[] = []
132 let at = 0
133 for (const m of text.matchAll(PROTECTED)) {
134 const start = m.index ?? 0
135 if (start > at) out.push({ text: text.slice(at, start), isProse: true })
136 out.push({ text: m[0], isProse: false })
137 at = start + m[0].length
138 }
139 if (at < text.length) out.push({ text: text.slice(at), isProse: true })
140
141 return out
142}
143
144/**
145 * What the person typed after `/jargon`, as a term: wrapping quotes,
146 * backticks and emphasis taken off, spaces collapsed.
147 */
148export function cleanTerm(raw: string): string {
149 let t = raw.trim().replace(/\s+/g, ' ')
150 for (;;) {
151 const m = /^(["'`*_]+)(.*?)\1$/.exec(t)
152 if (m === null || m[2]!.trim() === '') return t
153 t = m[2]!.trim()
154 }
155}
156
157/**
158 * The part of a reply Haiku needs to see a term in use: the reply whole when
159 * short, else MAX_EXCERPT_CHARS around the term's first mention.
160 */
161export function excerpt(reply: string, term: string): string {
162 const text = reply.replace(/<context>[\s\S]*?<\/context>/g, '').trim()
163 if (text.length <= MAX_EXCERPT_CHARS) return text
164 const at = termPattern(term).exec(text)?.index ?? 0
165 const start = Math.max(0, Math.min(at - MAX_EXCERPT_CHARS / 2, text.length - MAX_EXCERPT_CHARS))
166 const cut = text.slice(start, start + MAX_EXCERPT_CHARS)
167
168 return `${start > 0 ? '…' : ''}${cut}${start + MAX_EXCERPT_CHARS < text.length ? '…' : ''}`
169}
170
171/** The text with code and links taken out: what a reader reads as prose. */
172export function proseOf(text: string): string {
173 return segments(text)
174 .filter(p => p.isProse)
175 .map(p => p.text)
176 .join(' ')
177}
178
179/**
180 * The key a reply's notes sit under: the same for the text as stored and as
181 * the terminal draws it, which leaves out `<context>` blocks.
182 */
183export function replyKey(text: string): string {
184 return hash(text.replace(/<context>[\s\S]*?<\/context>/g, ''))
185}
186
187/**
188 * Turns the first prose occurrence of each term into a markdown link to
189 * `LINK_ROOT + slug`. Longer terms go first so "race condition" wins over
190 * "race". Answers the text and the hrefs it wrote.
191 */
192export function linkTerms(
193 text: string,
194 terms: readonly string[],
195): { text: string; links: string[] } {
196 let parts = segments(text)
197 const links: string[] = []
198 const ordered = [...terms].sort((a, b) => b.length - a.length)
199
200 for (const term of ordered) {
201 const pattern = termPattern(term)
202 const i = parts.findIndex(p => p.isProse && pattern.test(p.text))
203 const part = parts[i]
204 if (part === undefined) continue
205
206 const m = pattern.exec(part.text)
207 if (m === null) continue
208 const start = m.index
209 const href = LINK_ROOT + slug(term)
210 const label = m[0].replace(/([\[\]\\])/g, '\\$1')
211 parts = [
212 ...parts.slice(0, i),
213 { text: part.text.slice(0, start), isProse: true },
214 { text: `[${label} ${MARK}](${href})`, isProse: false },
215 { text: part.text.slice(start + m[0].length), isProse: true },
216 ...parts.slice(i + 1),
217 ]
218 links.push(href)
219 }
220
221 return { text: parts.map(p => p.text).join(''), links }
222}
223
224function clip(value: unknown, max: number): string {
225 if (typeof value !== 'string') return ''
226 const s = value.replace(/\s+/g, ' ').trim()
227
228 return s.length > max ? `${s.slice(0, max - 1).trimEnd()}…` : s
229}
230
231/**
232 * Reads the model's JSON answer: the first item that carries a definition.
233 * An answer that is no JSON array (cut off, wrapped in prose) or an empty one
234 * is null. The term itself is the caller's: what the person asked about.
235 */
236export function parseDefinition(raw: string): Definition | null {
237 const start = raw.indexOf('[')
238 const end = raw.lastIndexOf(']')
239 if (start < 0 || end <= start) return null
240
241 let data: unknown
242 try {
243 data = JSON.parse(raw.slice(start, end + 1))
244 } catch {
245 return null
246 }
247 if (!Array.isArray(data)) return null
248
249 for (const item of data) {
250 if (typeof item !== 'object' || item === null) continue
251 const o = item as Record<string, unknown>
252 const definition = clip(o.definition, 200)
253 if (!definition) continue
254
255 return { kind: clip(o.kind, 40), definition, context: clip(o.context, 160) }
256 }
257
258 return null
259}
260
261export const DEFINE_SYSTEM = `You explain one technical term in plain English for a reader who is new to programming.
262
263Answer with a JSON array holding one item, no prose:
264[{"kind": "...", "definition": "...", "context": "..."}]
265
266- kind: two or three words naming what it is and its field, like "adjective, APIs" or "tool, version control".
267- definition: plain English, at most 18 words, without using the term itself.
268- context: when a reply is given, at most 16 words on what the term means or does in it; otherwise "".
269
270If it is not something you can define, answer [].`
271types/index.d.ts 31 lines1export type JargonEntry = {
2 term: string
3 kind: string
4 definition: string
5}
6
7/** Slug to entry, oldest first. */
8export type JargonGlossary = Record<string, JargonEntry>
9
10/** Hash of a reply's text to its terms' notes, slug to note. */
11export type JargonNotes = Record<string, Record<string, string>>
12
13export type JargonPin = { slug: string; hash: string }
14
15/** How much the reader already knows: decides which bundled terms highlight. */
16export type JargonLevel = 'beginner' | 'intermediate' | 'advanced'
17
18declare module 'claude-code' {
19 interface PluginState {
20 jargon: {
21 glossary: JargonGlossary
22 notes: JargonNotes
23 pinned: JargonPin | null
24 isOn: boolean
25 level: JargonLevel
26 /** Slugs the person asked about with `/jargon <term>`: shown at every level, ranked first. */
27 lookedUp: string[]
28 }
29 }
30}
31