SLOPSHOPPER

jargon

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

newbandrowscommandmodel
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · jargon
› fix the failing auth test and add an audit log call ⏺ 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 › /jargon ⎿ jargon: Level intermediate: 123 built-in terms highlight by themselves; /jargon <term> defines any other, /jargon leve ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

jargon

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.

  • Hover a term in the list to see its definition card.
  • Click a term (or its link) to pin the card above the prompt. x dismisses it.

Commands

  • /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:
  • beginner highlights all of them;
  • intermediate (the default) skips the basic ones, like API, git and JSON;
  • advanced highlights only the advanced ones, like idempotent and CORS.

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.

Cost and storage

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.

Source 7 files
hooks/register.tsx 526 lines
1import { 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}
526
hooks/bundled.ts 262 lines
1import 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}
262
hooks/cache.ts 55 lines
1import 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}
55
hooks/card.tsx 96 lines
1import 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}
96
hooks/glossary.ts 48 lines
1import 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}
48
hooks/text.ts 271 lines
1export 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 [].`
271
types/index.d.ts 31 lines
1export 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