SLOPSHOPPER

claudelingo

Learn a language in the dead time while Claude is working. A spaced-repetition quiz in the band above your prompt, answered with a digit.

newbandcommandtoaststatusprompt
★ 3v0.3.0MITupdated 2026-09-17AI-Experts-LLC/claudelingo
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · claudelingo
› 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 › /lingo ⎿ claudelingo: No language chosen yet — press a digit in the band above your prompt. ⟨Claude Code's own drawing⟩ ,___, Which language do you want to learn? (o.-) 1: Spanish 2: French 3: Italian /)_) press a digit · /lingo lang <code> for any other ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⟨Claude Code's own drawing⟩ ,___, Which language do you want to learn? (o.-) 1: Spanish 2: French 3: Italian /)_) press a digit · /lingo lang <code> for any other
README

claudelingo

Learn a language in the dead time while Claude is working.

claudelingo puts a vocabulary quiz in the band above your Claude Code prompt. When Claude starts thinking, a card appears. Press the digit beside your answer. When Claude needs you back, the card gets out of the way.

 ,___,  What does "tiempo" mean?
 (o.o)  1: time   2: weather   3: house   4: always
 /)_)   5: skip   6: explain   box 1/5

Spanish, French and Italian are built in, roughly the 310 most common words in each, which covers most of what you hear in a day. Any other language can be generated on demand.

Install

curl -fsSL https://raw.githubusercontent.com/AI-Experts-LLC/claudelingo/main/install.sh | sh

Then start Claude Code as usual with claude. The first time, the band asks which language you want to learn. Press a digit.

Requirements: Claude Code 2.1.271 or newer, git, and python3 (both come with the Xcode Command Line Tools on macOS). Check your version with claude --version.

claudelingo is a mod: a Claude Code plugin built on function hooks, which are early access. That is why it needs a recent Claude Code, and why the installer has to switch the feature on.

What the installer does

  1. Clones this repository into ~/.claude/skills/claudelingo, where Claude Code loads it as a plugin. Run the same command again to update.
  2. Adds CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 to the env block of ~/.claude/settings.json, which turns function hooks on. Before changing anything it saves a copy beside the file, settings.json.claudelingo-backup (later runs add -2, -3, and never overwrite the first), and it prints exactly what it changed. The file keeps its permissions, and if it's a symlink (from a dotfiles repo, say), the change is written to the file it points to. If the file can't be written, it's left alone and you get a launcher command instead.
  3. If you used the older version of claudelingo, it removes that version's status line and hooks from the same file, so you don't end up with two. It only removes entries that run the claudelingo program itself; anything of yours that merely mentions it is kept. Your old progress is imported the first time you start Claude.

Prefer not to have your settings touched? Use this instead:

curl -fsSL https://raw.githubusercontent.com/AI-Experts-LLC/claudelingo/main/install.sh | sh -s -- --no-settings

With that option, settings.json is left alone and you start Claude Code with claude-lingo instead of claude.

Uninstall

curl -fsSL https://raw.githubusercontent.com/AI-Experts-LLC/claudelingo/main/install.sh | sh -s -- --uninstall

This removes the plugin and puts the function-hooks setting back the way it was before you installed. Your decks are kept, so reinstalling picks up where you left off. Old-version entries the installer removed aren't restored; they're in settings.json.claudelingo-backup if you want them.

Using it

Cards while Claude works

While a turn is running, the band puts a card up. When the turn ends, the card comes down. You never have to go looking for it, and it never sits between you and a question Claude is asking you.

A quiz whenever you want one

Press 1 on the idle band, or type /lingo quiz, for a five-card quiz right where you are, whether or not Claude is busy:

 ,___,  What does "el" mean?
 (o.o)  1: the (f. pl.)  2: a (f.)  3: the (m.)  4: the (f.)
 /)_)   5: skip   6: explain   2/5

It counts down, keeps going if you send Claude a prompt in the meantime, and ends with a score:

 ,___,  Quiz done — 4 of 5 right
 (o.o)  wrong ones come back sooner; right ones come back later.
 /)_)   1: again   5: done

Keys

Every key is a digit, because a digit works from an empty prompt without clicking anything first. One keystroke per answer.

Key
1–4answer a multiple-choice card
1acknowledge a new word, move past a result, or start a quiz from the idle band
5skip a card. No penalty; it comes back in ten minutes
6explain: ask Claude for a memory hook for this word
type + Enterspell a word out, on cards that ask you to

Commands

/lingo quiz [n]a quiz now: five cards, or n (up to 50)
/lingo statswhere you stand: words met, mastered, streak, accuracy
/lingo langthe languages you have; /lingo lang fr switches
/lingo pack Portuguese ptgenerate a pack for a language that isn't built in
/lingo practisekeep asking while Claude is idle, with no fixed length
/lingo off / /lingo onhide the band, or bring it back
/lingo reseterase the current language's progress, after asking

Each language keeps its own deck, so switching never costs you progress.

No API key needed. Memory hooks and generated packs run through your existing Claude Code login and usage.

How it teaches

Every word climbs the same ladder, and only moves up when you get it right:

  1. New word. You're shown the word, its meaning and where it ranks in the frequency list. Nothing is asked yet.
  2. Meaning. You see the word and choose from four English meanings.
  3. Translate. You see the English and choose from four words in the language.
  4. Fill the gap. The word is blanked out of a real sentence.
  5. Spell it. You see the English and type the word. Accents and capitalisation are forgiven; spelling isn't.

Scheduling uses spaced repetition. A word still being learned comes back after 1 minute, 10 minutes and an hour. Once it sticks, the gaps grow to 1, 3, 7, 16 and 35 days. A wrong answer drops the word one level and sends it back through the short gaps, without wiping its history.

At most 8 words are in progress at once and at most 20 new words a day, so a long afternoon builds a deck you remember rather than a flood you forget.

Your data

Everything is stored locally, in a file Claude Code keeps for the plugin under ~/.claude/plugins/store/. Nothing is sent anywhere except the model requests behind explain and /lingo pack. Your deck follows you between projects on the same machine, but not between machines.

Cards, answers and scores never go into your conversation with Claude, so the quiz doesn't use up Claude's context.

Coming from the older claudelingo

Earlier versions of claudelingo were a separate terminal program with a tmux pane and a status line. The first time you start Claude after installing this version, it imports your old decks from ~/.claudelingo: box levels, due dates, streak and accuracy. It copies them and leaves the originals in place. If a language already has a deck in the new version, that deck is kept rather than overwritten.

The installer removes the old version's status line and hooks from settings.json for you. The old program itself can then be deleted:

rm -rf ~/.claudelingo/src && rm -f "$(command -v claudelingo)"

Codex support did not carry over. The old version could quiz you while Codex worked, but a mod is a Claude Code plugin, so the new version only runs inside Claude Code.

Troubleshooting

Nothing appears above the prompt.

  • Check claude --version is 2.1.271 or newer. The stable release channel can lag behind the version this needs.
  • Restart Claude Code after installing; plugins load at startup.
  • Type /lingo stats. If the command isn't found, the plugin didn't load. Re-run the installer and read its output.
  • If you installed with --no-settings, start Claude with claude-lingo, not claude.

Two bands, or /lingo behaving strangely. Another copy of claudelingo may be installed. Re-run the installer, which removes known older copies.

The band disappears in a narrow terminal. Below 30 columns it shrinks to a single row, and in very short terminals it steps aside entirely. It can only take key presses when it fits.

Early access

Function hooks may change between Claude Code releases without notice. If an update breaks claudelingo, re-run the installer to get the latest version, and open an issue if that doesn't fix it.

Contributing

git clone https://github.com/AI-Experts-LLC/claudelingo
cd claudelingo
npm install
npm run check     # typecheck, plugin validation, unit tests, installer tests

To run your working copy inside Claude Code:

CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude --plugin-dir .

Layout

hooks/register.tsthe mod's entry point: every hook it registers
hooks/views/the band. row.ts fits each row into the terminal width
hooks/srs.tsthe spaced-repetition scheduler
hooks/deck.tsreading and writing decks, settings and packs
hooks/migrate.tsimporting decks from the older claudelingo
hooks/packgen.tsgenerating a word pack for a new language
hooks/packs/the built-in Spanish, French and Italian word lists
install.shthe installer
types/claude-code.d.tsthe function-hooks API, as written by Claude Code's /plugin-types

Tests

  • tests/*.spec.ts run under vitest: the scheduler, the band's layout at every width, the word packs, deck storage and the migration.
  • tests/installer.sh runs the installer against throwaway home directories.
  • tests/*.test.ts use Claude Code's own plugin test kit and run with npm run test:kit. The claude plugin test command isn't available in current releases yet, so these don't run today.

Two rules worth knowing before you change anything:

  • The band must always be exactly three rows tall. A band taller than the space it's given scrolls, and a scrolling band ignores digit key presses. That would silently disable every answer. The tests measure rendered width in terminal columns at many sizes, not just the number of rows in the layout.
  • A failed read must never lead to a write. If a deck, setting or pack can't be read, nothing is written in its place. A read failure isn't the same as an empty deck, and treating it as one would overwrite real progress.

A useful habit: when you add a test, break the code it covers and check the test fails.

Licence

MIT

Source 20 files
hooks/register.ts 1100 lines
1import type { CommandSpec, EngineInterface, On, RenderSurface, Timer } from 'claude-code'
2
3import {
4  DEFAULT_SETTINGS,
5  generatedCodes,
6  loadPack,
7  loadProgress,
8  loadSettings,
9  messageOf,
10  saveProgress,
11  saveSettings,
12} from './deck'
13import type { Store, Trouble } from './deck'
14import { memoryHook } from './enrich'
15import { migrate, migrationNotice } from './migrate'
16import {
17  BAND_ROWS,
18  COMMAND_NAME,
19  PACKS_KEY,
20  PACK_WORDS,
21  QUIZ_LENGTH,
22  QUIZ_MAX,
23  PLUGIN_NAME,
24  REDRAW_MS,
25  TICK_MS,
26  packKey,
27} from './names'
28import { BUNDLED_CHOICES, BUNDLED_CODES, materialize } from './pack'
29import type { PackChoice } from './pack'
30import { generatePack } from './packgen'
31import {
32  applyAnswer,
33  buildCard,
34  deferItem,
35  emptyProgress,
36  isCorrect,
37  makeRng,
38  selectNext,
39  stats,
40} from './srs'
41import { tickAt } from './ticker'
42import { standing } from './ui/tiers'
43import type { BandState, Card, Pack, Progress, Settings, Verdict } from './types'
44import { bandView, withBand } from './views/band'
45
46/**
47 * claudelingo: a vocabulary quiz in the band above the prompt.
48 *
49 * Until function hooks there was no way to put anything interactive inside
50 * Claude Code, so claudelingo used to be a CLI that reached *around* it: five
51 * hook events writing a `status.json` it read back to guess whether the agent
52 * was working, a Codex transcript tailer for the edge Codex gave it no hook
53 * for, three separate surfaces because none of them could both draw and listen,
54 * a lock file per language so two panes could not erase each other's decks, and
55 * a `claude -p` subprocess whenever it needed a model.
56 *
57 * All of it is gone. `e.props.isWorking` answers the first, a `Button` in the
58 * band answers the second, `$.store` the third, `$.model.complete` the last.
59 * What survived is what claudelingo always actually was: the scheduler, the
60 * words, and the owl.
61 *
62 * `migrate.ts` reads the old install's decks in, once, so nobody pays for that
63 * history with their progress.
64 */
65
66/** The slice of `$` this mod uses, bound once at `session.start`. */
67interface Host extends Store {
68  now: () => Promise<number>
69  every: (ms: number, fn: () => void) => Timer
70  complete: EngineInterface['model']['complete']
71  invalidate: () => void
72  toast: (text: string) => void
73  log: (text: string) => void
74  status: (text: string | undefined) => void
75  registerCommand: (spec: CommandSpec) => Promise<unknown>
76  ask: EngineInterface['ui']['ask']
77  exists: (path: string) => Promise<boolean>
78  readFile: (path: string) => Promise<string>
79  listDir: (path: string) => Promise<readonly { name: string }[]>
80  /**
81   * The home directory, where the old CLI kept its decks.
82   *
83   * Bound as its own member rather than a general `env.get(name)`: `$.env.get`
84   * takes a literal name so that the variables a hooks module reads can be
85   * listed without running it, and a wrapper taking a parameter defeats that.
86   */
87  home: () => Promise<string | undefined>
88}
89
90const COMMAND: CommandSpec = {
91  name: COMMAND_NAME,
92  description: 'claudelingo: your standing, your language, and the band above the prompt',
93  argumentHint: '[quiz [n] | stats | lang <code> | practise | on | off | pack <Language> <code> | reset]',
94  // A turn being in flight is exactly when the band is busiest, and every one
95  // of these is about the band rather than about the conversation. Waiting for
96  // the turn to end would answer questions about a screen that has moved on.
97  immediate: true,
98}
99
100/**
101 * Whether this surface can draw the band: every one but the mobile app, which
102 * has no `Input` and so could not put up a `recall` card.
103 */
104const isDrawable = <E extends Record<'surface', RenderSurface>>(
105  e: E,
106): e is Exclude<E, Record<'surface', 'mobile'>> => e.surface !== 'mobile'
107
108export function register(on: On) {
109  let host: Host | null = null
110
111  let settings: Settings = { ...DEFAULT_SETTINGS }
112  let pack: Pack | null = null
113  let progress: Progress = emptyProgress('')
114
115  /** Save is off because the deck could not be read. See `deck.ts`. */
116  let readOnly = false
117
118  /** The same, for settings: a failed read must not authorise writing over them. */
119  let settingsReadOnly = false
120
121  /**
122   * Which request a memory hook belongs to.
123   *
124   * Bumped whenever the card changes. A reply that arrives after the card has
125   * gone carries a stale token and is dropped — without it, the mnemonic for a
126   * word you skipped lands as the aside under the next word's verdict, which is
127   * confidently worded and wrong.
128   */
129  let hookToken = 0
130
131  /**
132   * What is wrong, in the words the person reads.
133   *
134   * Kept per cause, so one clearing never hides another — and *shown* by severity rather than by arrival, which is the part
135   * a Map alone does not give you. A store that has stopped accepting answers
136   * matters more than a memory hook that could not be fetched, whichever
137   * happened first.
138   *
139   * It is pinned with `$.ui.status`, not drawn in the band. The band is three
140   * rows and the third is the controls; an error that took that row would
141   * delete the very keys needed to clear it, which is a trap rather than a
142   * message. `$.ui.status` is the engine's own affordance for a line that stays
143   * until it is replaced, and it costs the band nothing.
144   */
145  const troubles = new Map<string, string>()
146
147  /** Worst first. Anything unlisted sorts last, in insertion order. */
148  const SEVERITY = ['settings', 'deck', 'save', 'migrate', 'pack', 'lang', 'grade', 'hook']
149
150  let state: BandState = {
151    card: null,
152    verdict: null,
153    practising: false,
154    quiz: null,
155    hook: null,
156    fetchingHook: false,
157    typed: '',
158  }
159
160  /** The last frame drawn, so the timer only redraws what has actually moved. */
161  let frame = ''
162
163  let ticker: Timer | null = null
164
165  /**
166   * Record or clear one cause, and put the worst of them under the prompt.
167   *
168   * Every caller that can fail records here, and every caller that can succeed
169   * clears the same cause on its way through — a stale error is worse than
170   * none, because it hides the next real one behind it.
171   */
172  function note(cause: string, trouble: Trouble) {
173    const had = troubleText()
174
175    if (trouble) troubles.set(cause, trouble.text)
176    else troubles.delete(cause)
177
178    const now = troubleText()
179
180    if (now !== had) host?.status(now ?? undefined)
181  }
182
183  function troubleText(): string | null {
184    if (troubles.size === 0) return null
185
186    const ranked = [...troubles.keys()].sort((a, b) => {
187      const rank = (cause: string) => {
188        const at = SEVERITY.indexOf(cause)
189
190        return at === -1 ? SEVERITY.length : at
191      }
192
193      return rank(a) - rank(b)
194    })
195
196    const worst = ranked[0] as string
197    const rest = troubles.size - 1
198
199    return `claudelingo: ${troubles.get(worst)}${rest > 0 ? ` (+${rest} more, /lingo stats)` : ''}`
200  }
201
202  /**
203   * Redraw, but only when the picture has changed.
204   *
205   * The band animates on a clock — the ticker reveals a meaning halfway through
206   * each word and the owl blinks — and the engine's own redraws go quiet
207   * exactly while a turn is running, which is when the band is meant to be
208   * teaching. So it asks for its own. A plain `every(1s) -> invalidate()`
209   * would work and would also cost a dispatch a second for the life of every
210   * session, most of them repainting an identical band; this signature is what
211   * makes that a fair trade.
212   */
213  function signatureAt(now: number): string {
214    if (!settings.on) return 'off'
215
216    const slot = Math.floor(now / TICK_MS)
217    const revealed = (now % TICK_MS) / TICK_MS >= 0.5
218    const blink = Math.floor(now / 2000) % 4
219
220    return [
221      settings.lang,
222      state.card?.word.id ?? '-',
223      state.verdict ? (state.verdict.correct ? 'y' : 'n') : '-',
224      state.hook ? 'h' : '-',
225      state.fetchingHook ? 'f' : '-',
226      state.typed,
227      state.practising ? 'p' : '-',
228      troubleText() ?? '-',
229      // The ticker's own frame only matters while it is the thing on screen.
230      state.card || state.verdict ? '' : `${slot}${revealed ? 'r' : ''}${blink}`,
231    ].join('|')
232  }
233
234  function startTicker(engine: Host) {
235    if (ticker) return
236
237    ticker = engine.every(REDRAW_MS, () => {
238      void engine
239        .now()
240        .then((now) => {
241          const next = signatureAt(now)
242
243          if (next === frame) return
244
245          frame = next
246          engine.invalidate()
247        })
248        .catch(() => undefined)
249    })
250  }
251
252  /**
253   * Reload the deck for a language.
254   *
255   * Returns whether it opened, because the callers have already written
256   * `settings.lang` by the time they call: one that failed silently would leave
257   * the band quizzing the previous language while the settings named another,
258   * and the next session opening a language with no pack.
259   */
260  async function openLanguage(engine: Host, code: string): Promise<boolean> {
261    const loaded = await loadPack(engine, code)
262
263    if (loaded.pack === null) {
264      note('pack', { text: `${loaded.reason} — /lingo lang to see what there is` })
265
266      return false
267    }
268
269    const deck = await loadProgress(engine, code)
270
271    pack = loaded.pack
272    progress = deck.progress
273    readOnly = deck.readOnly
274    hookToken += 1
275    note('pack', null)
276    note('lang', null)
277    note('deck', deck.trouble)
278
279    // `fetchingHook` goes with the token. Leaving it set drops the in-flight
280    // reply correctly but strands the next card's explain button on "asking…",
281    // where pressing it does nothing.
282    state = { ...state, card: null, verdict: null, hook: null, fetchingHook: false, typed: '' }
283
284    return true
285  }
286
287  /** A quiz run that still has cards to put up. */
288  const quizRunning = () => state.quiz !== null && state.quiz.done < state.quiz.total
289
290  /** A quiz run that has used all its cards: the score is what is on screen. */
291  const quizFinished = () => state.quiz !== null && state.quiz.done >= state.quiz.total
292
293  /**
294   * Whether the band should be asking questions at this moment.
295   *
296   * A run you started outranks whether a turn happens to be in flight: you
297   * asked, so it asks, and it keeps asking until its cards are used up.
298   */
299  const isQuizzing = (isWorking: boolean) =>
300    settings.on && (isWorking || state.practising || settings.alwaysOn || quizRunning())
301
302  /** Start a run of `total` cards, replacing whatever is on the band. */
303  function startQuiz(engine: Host, total: number) {
304    state = {
305      ...state,
306      quiz: { total, done: 0, correct: 0, taught: 0 },
307      card: null,
308      verdict: null,
309      hook: null,
310      fetchingHook: false,
311      typed: '',
312    }
313
314    hookToken += 1
315    engine.invalidate()
316  }
317
318  /**
319   * Put a card up if one is due and there is room for it.
320   *
321   * Called from the render hook: the deck and the clock are both already in
322   * hand there, and a card chosen anywhere else would be chosen for a band that
323   * may since have been told to stand down.
324   *
325   * `isWorking` is the gate, and it is the whole of the old hooks-plus-status-
326   * file-plus-transcript-tailing apparatus reduced to a boolean the engine
327   * hands over. Idle means no card: a vocabulary question is
328   * the wrong thing to be looking at when Claude is waiting on you.
329   */
330  function pump(now: number, isWorking: boolean): void {
331    if (!pack || state.card || state.verdict) return
332
333    // A finished run holds the band until its score is put away. Without this a
334    // turn running in the background would deal card six over the top of it.
335    if (quizFinished()) return
336
337    if (!isQuizzing(isWorking)) return
338
339    const picked = selectNext(pack, progress, settings, now)
340
341    if (!picked) return
342
343    state = {
344      ...state,
345      card: buildCard(pack, picked.word, picked.item, makeRng(now)),
346      hook: null,
347      typed: '',
348    }
349  }
350
351  async function persist(engine: Host): Promise<void> {
352    if (readOnly) return
353
354    note('save', await saveProgress(engine, progress))
355  }
356
357  /**
358   * Write the settings, unless they were never successfully read.
359   *
360   * Saving defaults over settings we could not see would lose the language,
361   * the model and an `/lingo off` in one go.
362   */
363  async function saveSettingsIfAllowed(engine: Host): Promise<void> {
364    if (settingsReadOnly) return
365
366    note('settings', await saveSettings(engine, settings))
367  }
368
369  /**
370   * Fold an answer in, show how it went, and schedule what comes next.
371   *
372   * The card comes off the band *before* the first await. Held keys repeat and
373   * fingers double-tap, and two presses either side of `await engine.now()`
374   * would both capture the same card: `applyAnswer` twice, a doubled streak and
375   * a two-box promotion for one answer.
376   */
377  async function grade(engine: Host, card: Card, response: { choice?: number; text?: string }) {
378    if (state.card !== card) return
379
380    state = { ...state, card: null, hook: null, fetchingHook: false, typed: '' }
381    hookToken += 1
382
383    const now = await engine.now()
384    const correct = isCorrect(card, response)
385
386    progress = applyAnswer(progress, card.word, card, correct, now)
387
388    const verdict: Verdict | null =
389      card.kind === 'teach'
390        ? null
391        : {
392            correct,
393            answer: card.choices[card.answerIndex] ?? card.accepted[0] ?? card.word.term,
394            word: card.word,
395          }
396
397    const run = state.quiz
398
399    state = {
400      ...state,
401      verdict,
402      quiz: run
403        ? {
404            ...run,
405            done: run.done + 1,
406            // A `teach` card is shown, not asked, so it counts towards the run
407            // but never towards the score — five new words is not nought out of
408            // five.
409            taught: run.taught + (card.kind === 'teach' ? 1 : 0),
410            correct: run.correct + (card.kind !== 'teach' && correct ? 1 : 0),
411          }
412        : null,
413    }
414
415    note('grade', null)
416
417    await persist(engine)
418    engine.invalidate()
419  }
420
421  const actions = (engine: Host) => ({
422    answer: (index: number) => {
423      const card = state.card
424
425      if (!card) return
426
427      void grade(engine, card, { choice: index }).catch((error: unknown) => {
428        note('grade', { text: messageOf(error) })
429        engine.invalidate()
430      })
431    },
432
433    type: (text: string) => {
434      state = { ...state, typed: text }
435    },
436
437    spell: (text: string) => {
438      const card = state.card
439
440      if (!card) return
441
442      void grade(engine, card, { text }).catch((error: unknown) => {
443        note('grade', { text: messageOf(error) })
444        engine.invalidate()
445      })
446    },
447
448    next: () => {
449      const card = state.card
450
451      // A `teach` card is an introduction, not a question: acknowledging it is
452      // what schedules the word, so it goes through the grader like any other.
453      if (card && card.kind === 'teach') {
454        void grade(engine, card, {}).catch((error: unknown) => {
455          note('grade', { text: messageOf(error) })
456          engine.invalidate()
457        })
458
459        return
460      }
461
462      state = { ...state, card: null, verdict: null, hook: null, fetchingHook: false, typed: '' }
463      hookToken += 1
464      engine.invalidate()
465    },
466
467    skip: () => {
468      const card = state.card
469
470      if (!card) return
471
472      void engine
473        .now()
474        .then(async (now) => {
475          // A skip costs nothing but a delay: punishing it would poison the box
476          // levels, and it has to work on a word not yet taught, or `selectNext`
477          // hands straight back the card just skipped.
478          progress = {
479            ...progress,
480            items: {
481              ...progress.items,
482              [card.word.id]: deferItem(progress.items[card.word.id], card.word.id, now),
483            },
484          }
485
486          state = {
487            ...state,
488            card: null,
489            verdict: null,
490            hook: null,
491            fetchingHook: false,
492            typed: '',
493          }
494
495          hookToken += 1
496          note('grade', null)
497
498          await persist(engine)
499          engine.invalidate()
500        })
501        .catch((error: unknown) => {
502          // Silence here left the card on screen with nothing said: press,
503          // nothing happens, press again, nothing happens.
504          note('grade', { text: `could not skip: ${messageOf(error)}` })
505          engine.invalidate()
506        })
507    },
508
509    explain: () => {
510      const word = state.card?.word ?? state.verdict?.word
511
512      if (!word || !pack || state.fetchingHook || !settings.enrich) return
513
514      // The card this hook is for. Anything that changes the card bumps the
515      // token, so a slow reply for a word that has gone is dropped rather than
516      // drawn under whatever is on screen now.
517      const token = hookToken
518
519      state = { ...state, fetchingHook: true }
520      engine.invalidate()
521
522      void memoryHook(engine, engine, word, settings.model, pack.englishName)
523        .then((hook) => {
524          if (token !== hookToken) return
525
526          state = { ...state, hook: hook.text, fetchingHook: false }
527          note('hook', hook.text === null ? { text: hook.reason } : null)
528          engine.invalidate()
529        })
530        .catch((error: unknown) => {
531          if (token !== hookToken) return
532
533          state = { ...state, fetchingHook: false }
534          note('hook', { text: `could not fetch a hook: ${messageOf(error)}` })
535          engine.invalidate()
536        })
537    },
538
539    practise: () => {
540      state = { ...state, practising: true }
541      engine.invalidate()
542    },
543
544    quiz: () => startQuiz(engine, QUIZ_LENGTH),
545
546    again: () => startQuiz(engine, state.quiz?.total ?? QUIZ_LENGTH),
547
548    done: () => {
549      state = { ...state, quiz: null, card: null, verdict: null, typed: '' }
550      engine.invalidate()
551    },
552
553    chooseLang: (code: string) => {
554      void (async () => {
555        const previous = settings.lang
556
557        settings = { ...settings, lang: code }
558
559        if (await openLanguage(engine, code)) {
560          await saveSettingsIfAllowed(engine)
561        } else {
562          // The pack would not open, so do not leave the settings naming it —
563          // the next session would start on a language with nothing to study.
564          settings = { ...settings, lang: previous }
565        }
566
567        engine.invalidate()
568      })().catch((error: unknown) => {
569        note('lang', { text: messageOf(error) })
570        engine.invalidate()
571      })
572    },
573  })
574
575  /** What the picker offers: bundled, then anything generated into the store. */
576  async function choicesFor(engine: Host): Promise<PackChoice[]> {
577    const generated = await generatedCodes(engine)
578
579    const extra = await Promise.all(
580      generated.codes.map(async (code) => {
581        const loaded = await loadPack(engine, code)
582
583        return loaded.pack
584          ? { code, englishName: loaded.pack.englishName, words: loaded.pack.words.length }
585          : null
586      }),
587    )
588
589    return [...BUNDLED_CHOICES, ...extra.filter((choice): choice is PackChoice => choice !== null)]
590  }
591
592  /**
593   * Read the old CLI install's decks in, once, and say what happened.
594   *
595   * Never throws and never blocks the session: an import that cannot be done is
596   * a notice, not a failure to start.
597   */
598  async function importOldDecks(engine: Host): Promise<void> {
599    const home = (await engine.home().catch(() => undefined)) ?? ''
600
601    if (!home) return
602
603    const result = await migrate(
604      engine,
605      { exists: engine.exists, read: engine.readFile, list: engine.listDir },
606      home,
607    ).catch(() => null)
608
609    if (!result) return
610
611    note('migrate', result.trouble)
612
613    const notice = migrationNotice(result)
614
615    if (notice) engine.log(notice)
616
617    // Pick up where the old install left off, but never over a choice made here.
618    if (!settings.lang && result.lang) {
619      settings = { ...settings, lang: result.lang }
620      await saveSettingsIfAllowed(engine)
621    }
622  }
623
624  on('session.start', async ($, e, next) => {
625    const engine: Host = {
626      now: () => $.clock.now(),
627      every: (ms, fn) => $.clock.every(ms, fn),
628      get: (key) => $.store.get(key),
629      set: (key, value) => $.store.set(key, value),
630      complete: (request) => $.model.complete(request),
631      invalidate: () => $.ui.invalidate('ui.render'),
632      toast: (text) => $.ui.toast(text),
633      log: (text) => $.ui.log(text),
634      status: (text) => $.ui.status(text),
635      registerCommand: (spec) => $.command.register(spec),
636      ask: (question, options) => $.ui.ask(question, options),
637      exists: (path) => $.fs.exists(path),
638      readFile: (path) => $.fs.read(path),
639      listDir: (path) => $.fs.list(path),
640      home: () => $.env.get('HOME'),
641    }
642
643    // Bound before anything that can fail. Assigning it last meant a single
644    // rejection above left `host` null for the session: no band, ever, and
645    // `/lingo` falling through to nothing, with no message of its own.
646    host = engine
647    startTicker(engine)
648
649    try {
650      await engine.registerCommand(COMMAND)
651    } catch (error) {
652      // A command name someone else holds costs the command, not the band.
653      engine.log(`claudelingo: /${COMMAND_NAME} is taken (${messageOf(error)})`)
654    }
655
656    const loaded = await loadSettings(engine)
657
658    settings = loaded.settings
659    settingsReadOnly = loaded.readOnly
660    note('settings', loaded.trouble)
661
662    // Before opening anything: a deck coming over from the CLI should be the
663    // one that opens, not an empty one created beside it.
664    await importOldDecks(engine)
665
666    if (settings.lang) await openLanguage(engine, settings.lang)
667
668    return next(e)
669  }).catch(($, e, next) => {
670    // A hook that throws is skipped, and a skipped `session.start` would leave
671    // the band bound to nothing. Whatever failed, the session carries on.
672    host?.log(`claudelingo: could not start (${messageOf(next.error)})`)
673
674    return next(e)
675  })
676
677  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
678    const engine = host
679    const beneath = await next(e)
680
681    if (!engine) return beneath
682
683    // Every path that draws nothing still stamps the frame. Leaving it stale
684    // had the ticker fire an invalidate a second for as long as a survey held
685    // the band — the dispatch-per-second the signature exists to avoid.
686    const standDown = async () => {
687      frame = signatureAt(await engine.now().catch(() => 0))
688
689      return beneath
690    }
691
692    if (!settings.on) return standDown()
693
694    // A survey holds the band and is the person's to answer; the band yields.
695    if (e.props.hasSurvey) return standDown()
696
697    // A band taller than the rows it is given scrolls in a window — and a
698    // scrolling band arms none of its Buttons' hotkeys, which is the whole
699    // interaction. Below the height it needs it draws nothing at all rather
700    // than something that cannot be answered.
701    if (e.props.maxRows < BAND_ROWS) return standDown()
702
703    // `mobile` has no `Input`, so a `recall` card could not draw there; the
704    // band stays off that surface rather than shipping a card kind that fails.
705    if (!isDrawable(e)) return standDown()
706
707    const { Box, Text, Button, Input } = $.ui.resolve(e)
708    const ui = { Box, Text, Button, Input }
709    const now = await engine.now()
710
711    const choices = settings.lang ? null : await choicesFor(engine)
712
713    if (settings.lang) pump(now, e.props.isWorking)
714
715    frame = signatureAt(now)
716
717    const band = bandView(
718      { ui, actions: actions(engine), columns: e.props.bodyColumns },
719      {
720        pack,
721        tick: pack ? tickAt(pack, progress, now) : null,
722        card: state.card,
723        item: state.card ? progress.items[state.card.word.id] : undefined,
724        verdict: state.verdict,
725        hook: state.hook,
726        fetchingHook: state.fetchingHook,
727        typed: state.typed,
728        streak: progress.streak,
729        isWorking: e.props.isWorking,
730        practising: state.practising || settings.alwaysOn,
731        quiz: state.quiz,
732        choices,
733        enrich: settings.enrich,
734        now,
735      },
736    )
737
738    return withBand(ui, beneath, band)
739  })
740
741  /**
742   * The band stands down when Claude needs you.
743   *
744   * `isWorking` already says whether a turn is running, so nothing here has to
745   * track that. What this does is drop a card that is on screen at the moment
746   * the turn ends, because the card was put up for the dead time and the dead
747   * time is over: leaving it would have you answering vocabulary while Claude
748   * waits. Practise mode is the person saying otherwise, so it survives.
749   */
750  on('turn.complete', ($, e, next) => {
751    const showing = state.card !== null || state.verdict !== null
752
753    // A run you asked for outlives the turn: it is bounded, so it ends itself.
754    if (host && !state.practising && !settings.alwaysOn && !state.quiz && showing) {
755      // The verdict goes with the card. Leaving it would pin "correct — el =
756      // the" across the whole idle stretch, and `pump` refuses a new card while
757      // one stands, so the band would be frozen on it until the next prompt.
758      state = { ...state, card: null, verdict: null, hook: null, fetchingHook: false, typed: '' }
759      hookToken += 1
760      host.invalidate()
761    }
762
763    return next(e)
764  })
765
766  /**
767   * A new prompt is a new stretch of dead time, and the end of a practice run.
768   *
769   * Practise is "quiz me now, even though Claude is idle" — once Claude is not
770   * idle any more, the ordinary rule is the better one, and leaving it latched
771   * would quiz through the next Notification too.
772   */
773  on('prompt.submit', ($, e, next) => {
774    // Practising is "keep asking while Claude is idle", and Claude is about to
775    // stop being idle, so the ordinary rule takes over. A quiz run is a fixed
776    // number of cards you asked for, so it carries on across the prompt and
777    // finishes where it said it would.
778    state = { ...state, practising: false, verdict: state.quiz ? state.verdict : null }
779
780    return next(e)
781  })
782
783  on('command.run', { command: COMMAND_NAME }, async ($, e, next) => {
784    const engine = host
785
786    if (!engine) return next(e)
787
788    const [verb = '', ...rest] = e.args.trim().split(/\s+/).filter(Boolean)
789    const argument = rest.join(' ')
790
791    switch (verb.toLowerCase()) {
792      case '':
793      case 'stats':
794        return { text: await statsText(engine) }
795
796      case 'lang':
797      case 'language':
798        return { text: await langText(engine, argument) }
799
800      case 'quiz': {
801        // `Number('')` is 0, and 0 is finite — so a bare `/lingo quiz` asked
802        // for a one-card quiz until this told the two apart.
803        const asked = argument.trim() === '' ? Number.NaN : Number(argument)
804
805        const total = Number.isFinite(asked)
806          ? Math.max(1, Math.min(QUIZ_MAX, Math.floor(asked)))
807          : QUIZ_LENGTH
808
809        if (!pack) return { text: 'No language chosen yet — press a digit in the band.' }
810
811        startQuiz(engine, total)
812
813        return {
814          text:
815            `${total} ${total === 1 ? 'card' : 'cards'}, above your prompt. ` +
816            'Press the digit beside your answer.',
817        }
818      }
819
820      case 'practise':
821      case 'practice':
822        state = { ...state, practising: true }
823        engine.invalidate()
824
825        return { text: 'Practising: the band will keep asking while Claude is idle.' }
826
827      case 'on':
828      case 'off': {
829        settings = { ...settings, on: verb.toLowerCase() === 'on' }
830        await saveSettingsIfAllowed(engine)
831        engine.invalidate()
832
833        return {
834          text: settings.on
835            ? 'The band is back above your prompt.'
836            : 'The band is off. `/lingo on` brings it back.',
837        }
838      }
839
840      case 'pack':
841        return { text: await packText(engine, argument) }
842
843      case 'reset':
844        return { text: await resetText(engine) }
845
846      default:
847        return {
848          text:
849            `Unknown: \`/${COMMAND_NAME} ${verb}\`.\n\n` +
850            `\`/${COMMAND_NAME} quiz [n]\` · \`stats\` · \`lang [code]\` · \`practise\` · ` +
851            `\`on\`/\`off\` · \`pack <Language> <code>\` · \`reset\``,
852        }
853    }
854  })
855
856  /** Everything outstanding, for the pinned line that can only show one. */
857  function troubleList(): string[] {
858    if (troubles.size === 0) return []
859
860    return ['', '**Outstanding:**', ...[...troubles].map(([cause, text]) => `- ${cause}: ${text}`)]
861  }
862
863  async function statsText(engine: Host): Promise<string> {
864    // Still list the troubles: the pinned line promises they are here, and the
865    // no-language case is exactly when a failed read is why.
866    if (!pack) {
867      return [
868        'No language chosen yet — press a digit in the band above your prompt.',
869        ...troubleList(),
870      ].join('\n')
871    }
872
873    const now = await engine.now()
874    const counts = stats(pack, progress, now)
875    const where = standing(counts.learned)
876    const accuracy = counts.accuracy ? `${Math.round(counts.accuracy * 100)}%` : '—'
877
878    const ahead = where.next
879      ? `${where.toGo} more for "${where.next.name}"`
880      : 'the top of the list'
881
882    return [
883      `**${pack.englishName}** — ${where.tier.name}, ${ahead}`,
884      '',
885      `words met    ${counts.learned} of ${counts.total}`,
886      `mastered     ${counts.mastered}`,
887      `due now      ${counts.due}`,
888      `streak       ${counts.streak}${counts.bestStreak > counts.streak ? ` (best ${counts.bestStreak})` : ''}`,
889      `accuracy     ${accuracy}`,
890      readOnly ? '\n_Running read-only: the deck could not be read, so nothing is being saved._' : '',
891      settingsReadOnly ? '_Your settings could not be read, so changes are not being saved._' : '',
892      ...troubleList(),
893    ]
894      .filter(Boolean)
895      .join('\n')
896  }
897
898  async function langText(engine: Host, code: string): Promise<string> {
899    const choices = await choicesFor(engine)
900
901    if (!code) {
902      const rows = choices.map(
903        (choice) =>
904          `- \`${choice.code}\` ${choice.englishName} — ${choice.words} words` +
905          (choice.code === settings.lang ? '  ← studying' : ''),
906      )
907
908      return [
909        'Languages you have:',
910        '',
911        ...rows,
912        '',
913        `\`/${COMMAND_NAME} lang <code>\` switches. ` +
914          `\`/${COMMAND_NAME} pack <Language> <code>\` builds a new one.`,
915      ].join('\n')
916    }
917
918    if (!choices.some((choice) => choice.code === code)) {
919      return (
920        `No pack for \`${code}\`. You have: ${choices.map((c) => c.code).join(', ')}.\n\n` +
921        `\`/${COMMAND_NAME} pack <Language>\` builds one.`
922      )
923    }
924
925    const previous = settings.lang
926
927    settings = { ...settings, lang: code }
928
929    if (!(await openLanguage(engine, code))) {
930      settings = { ...settings, lang: previous }
931      engine.invalidate()
932
933      return `Could not open \`${code}\`: ${troubles.get('pack') ?? 'the pack would not load'}`
934    }
935
936    await saveSettingsIfAllowed(engine)
937    engine.invalidate()
938
939    return `Studying ${pack?.englishName ?? code}. Your other decks are kept as they were.`
940  }
941
942  /**
943   * Build a pack for a language the mod does not ship.
944   *
945   * The generator is in `packgen.ts`, which knows nothing about how the model
946   * is reached. What this adds is the transport and three checks that all exist
947   * for one reason. Progress is keyed by code *and* by rank, so any
948   * pack that lands on a code some deck already uses re-attaches every box
949   * level earned there to whatever word now sits at each rank.
950   */
951  async function packText(engine: Host, argument: string): Promise<string> {
952    const [language = '', given] = argument.split(/\s+/).filter(Boolean)
953
954    if (!language) {
955      return `Which language? \`/${COMMAND_NAME} pack Portuguese\`, or ` +
956        `\`/${COMMAND_NAME} pack Portuguese pt\` to choose the code.`
957    }
958
959    // The code names the pack *and* the deck, so it is worth giving: the
960    // fallback is the first two letters, and "Portuguese" that way is `po`,
961    // which is nobody's idea of Portuguese and collides with Polish besides.
962    // The fallback stays — refusing without one would be unhelpful — but every
963    // message below names the code that was actually used.
964    const code = (given ?? language.slice(0, 2)).toLowerCase()
965
966    if (!/^[a-z]{2}$/.test(code)) {
967      return `\`${code}\` is not a two-letter code. Try \`/${COMMAND_NAME} pack ${language} pt\`.`
968    }
969
970    if (BUNDLED_CODES.includes(code)) {
971      return (
972        `\`${code}\` is a language claudelingo already ships, and progress is keyed by ` +
973        `code, so a generated pack would collide with it. Nothing was written. ` +
974        `Pick another code: \`/${COMMAND_NAME} pack ${language} xx\`.`
975      )
976    }
977
978    const known = await generatedCodes(engine)
979
980    // A failed read is not an empty index. Writing one back would erase every
981    // pack already generated — the bodies survive under their own keys, but
982    // nothing would ever look at them again.
983    if (known.failed) {
984      note('pack', { text: 'could not read the pack index — not generating over it' })
985
986      return 'Could not read your list of generated packs, so nothing was built: writing a new one would have erased it.'
987    }
988
989    if (known.codes.includes(code)) {
990      const answer = await engine
991        .ask(
992          `You already have a pack under "${code}". Replace it?`,
993          ['Keep it', 'Replace it'],
994        )
995        .catch(() => 'Keep it')
996
997      if (answer !== 'Replace it') return `Kept your existing \`${code}\` pack.`
998    }
999
1000    engine.toast(`claudelingo: building a ${language} pack…`)
1001
1002    const notes: string[] = []
1003
1004    try {
1005      const raw = await generatePack(
1006        (prompt, { system }) =>
1007          engine.complete({ model: settings.model, prompt, system, maxTokens: 8000 }),
1008        language,
1009        code,
1010        PACK_WORDS,
1011        {
1012          // The generator reports a chunk that failed twice and a run that
1013          // stopped short. Dropping those reported a 100-word pack as a
1014          // success when two thirds of it had been lost.
1015          onProgress: (done, total, note) => {
1016            if (note) notes.push(note)
1017            engine.status(`claudelingo: ${language} pack — ${done}/${total}`)
1018          },
1019        },
1020      )
1021
1022      // Validate before storing: a half-valid pack in the store would fail on
1023      // every later load instead of once, here, with a reason.
1024      const built = materialize(raw)
1025
1026      await engine.set(packKey(code), raw)
1027
1028      try {
1029        await engine.set(PACKS_KEY, [...new Set([...known.codes, code])])
1030      } catch (error) {
1031        // The pack is written but invisible: say so, rather than reporting a
1032        // failure for something that is sitting in the store.
1033        note('pack', { text: `the ${code} pack is saved but not listed: ${messageOf(error)}` })
1034
1035        return (
1036          `Built **${built.englishName}** (${built.words.length} words) but could not add it ` +
1037          `to your list of packs, so it will not appear in \`/${COMMAND_NAME} lang\`. ` +
1038          `Re-run to try again.`
1039        )
1040      }
1041
1042      note('pack', null)
1043
1044      // The generator's own last note already explains itself; quoting it under
1045      // a heading that repeats it reads as a stutter.
1046      const short =
1047        built.words.length < PACK_WORDS
1048          ? `\n\nAsked for ${PACK_WORDS}, ${
1049              notes.at(-1) ?? `stopped at ${built.words.length}: the ranks asked for are used up`
1050            }. A short pack of real words beats a full one padded out.`
1051          : ''
1052
1053      return (
1054        `Built **${built.englishName}** as \`${code}\` — ${built.words.length} words.${short}\n\n` +
1055        `\`/${COMMAND_NAME} lang ${code}\` to start on it.`
1056      )
1057    } catch (error) {
1058      return `Could not build a ${language} pack: ${messageOf(error)}`
1059    } finally {
1060      engine.status(troubleText() ?? undefined)
1061    }
1062  }
1063
1064  async function resetText(engine: Host): Promise<string> {
1065    if (!pack) return 'Nothing to reset yet.'
1066
1067    const name = pack.englishName
1068
1069    const answer = await engine
1070      .ask(`Erase your ${name} progress? This cannot be undone.`, ['Keep it', 'Erase it'])
1071      .catch(() => 'Keep it')
1072
1073    if (answer !== 'Erase it') return `Kept your ${name} deck.`
1074
1075    if (readOnly) {
1076      // The dialog said "this cannot be undone", and under read-only nothing is
1077      // written at all: the stored deck would come back at the next session.
1078      return (
1079        `Did **not** erase your ${name} deck: it could not be read, so the mod is ` +
1080        `running read-only and writes nothing. Nothing has changed.`
1081      )
1082    }
1083
1084    progress = emptyProgress(pack.code)
1085    state = { ...state, card: null, verdict: null, hook: null, fetchingHook: false, typed: '' }
1086    hookToken += 1
1087
1088    const trouble = await saveProgress(engine, progress)
1089
1090    note('save', trouble)
1091    engine.invalidate()
1092
1093    if (trouble) return `Could not erase your ${name} deck: ${trouble.text}`
1094
1095    return `Erased your ${name} deck. Everything starts again from word one.`
1096  }
1097}
1098
1099export const PLUGIN = PLUGIN_NAME
1100
hooks/deck.ts 284 lines
1/**
2 * The deck, in `$.store`.
3 *
4 * claudelingo used to keep this under `~/.claudelingo`, and most of that
5 * hard-won care was about the filesystem rather than about learning: an atomic
6 * write per answer, a lock file so two panes could not each hold the whole deck
7 * and have the second erase the first, a corrupt deck moved aside with a
8 * timestamp, a read error told apart from a parse error so a permission problem
9 * on a networked home did not replace a deck that was almost certainly fine.
10 *
11 * None of that is needed here. `$.store` is one store per plugin, owned by the
12 * engine, serialised by it, and there is exactly one band. What remains is the
13 * part that was never about files: a value read back that is not a deck.
14 *
15 * Two failures, still told apart, because the right answer differs:
16 *
17 * - The read **failed** (the host refused, the store is unreachable). That says
18 *   nothing about what is in it, so the band runs read-only and saves nothing
19 *   rather than writing a fresh deck over one it could not see.
20 * - The read **succeeded** and the value is not a deck (an older schema, a
21 *   hand-edited store). Overwriting it would silently discard whatever it was,
22 *   so it is moved aside under `<key>:quarantine` first, and the band says
23 *   where it went.
24 */
25
26import { emptyProgress } from './srs'
27import { PACKS_KEY, SETTINGS_KEY, packKey, progressKey } from './names'
28import type { Pack, Progress, RawPack, Settings } from './types'
29import { BUNDLED_CODES, bundledPack, materialize } from './pack'
30
31/** The slice of `$` this module touches. */
32export interface Store {
33  get: (key: string) => Promise<unknown>
34  set: (key: string, value: unknown) => Promise<void>
35}
36
37export const DEFAULT_SETTINGS: Settings = {
38  lang: '',
39  maxLearning: 8,
40  newPerDay: 20,
41  alwaysOn: false,
42  enrich: true,
43  model: 'haiku',
44  on: true,
45}
46
47function isRecord(value: unknown): value is Record<string, unknown> {
48  return typeof value === 'object' && value !== null && !Array.isArray(value)
49}
50
51/**
52 * Settings, with anything unreadable falling back to the default for that
53 * field alone.
54 *
55 * Per field rather than per object: a store written by a later version with one
56 * key this one does not understand should cost that key, not the language you
57 * chose six months ago.
58 */
59export function readSettings(value: unknown): Settings {
60  if (!isRecord(value)) return { ...DEFAULT_SETTINGS }
61
62  const pick = <K extends keyof Settings>(key: K, ok: (v: unknown) => boolean): Settings[K] =>
63    ok(value[key]) ? (value[key] as Settings[K]) : DEFAULT_SETTINGS[key]
64
65  const isPositive = (v: unknown) => typeof v === 'number' && Number.isFinite(v) && v > 0
66
67  return {
68    lang: pick('lang', (v) => typeof v === 'string'),
69    maxLearning: pick('maxLearning', isPositive),
70    newPerDay: pick('newPerDay', isPositive),
71    alwaysOn: pick('alwaysOn', (v) => typeof v === 'boolean'),
72    enrich: pick('enrich', (v) => typeof v === 'boolean'),
73    model: pick('model', (v) => typeof v === 'string' && v.length > 0),
74    on: pick('on', (v) => typeof v === 'boolean'),
75  }
76}
77
78/**
79 * Is this a deck?
80 *
81 * Deliberately shallow: the version and the item map. A single malformed item
82 * inside an otherwise good deck is not worth quarantining a year of progress
83 * over, and every reader of an item already copes with a missing field.
84 */
85export function isProgress(value: unknown, lang: string): value is Progress {
86  return (
87    isRecord(value) &&
88    value.version === 1 &&
89    value.lang === lang &&
90    isRecord(value.items)
91  )
92}
93
94/** What went wrong, in the words the band puts on screen. */
95export type Trouble = { text: string } | null
96
97export interface LoadedProgress {
98  progress: Progress
99  /** Save is off: the store could not be read, so it must not be written. */
100  readOnly: boolean
101  trouble: Trouble
102}
103
104/**
105 * The deck for a language, and whether it may be written back.
106 *
107 * Never throws: the band draws whatever this returns, and a card is better than
108 * a blank band with an exception behind it.
109 */
110export async function loadProgress(store: Store, lang: string): Promise<LoadedProgress> {
111  const key = progressKey(lang)
112
113  let value: unknown
114
115  try {
116    value = await store.get(key)
117  } catch (error) {
118    return {
119      progress: emptyProgress(lang),
120      readOnly: true,
121      trouble: {
122        text: `could not read your deck (${messageOf(error)}) — not saving, so nothing is lost`,
123      },
124    }
125  }
126
127  if (value === undefined) {
128    return { progress: emptyProgress(lang), readOnly: false, trouble: null }
129  }
130
131  if (isProgress(value, lang)) {
132    return { progress: value, readOnly: false, trouble: null }
133  }
134
135  // Something is there and it is not a deck. Keep it before writing over it.
136  try {
137    await store.set(`${key}:quarantine`, value)
138
139    return {
140      progress: emptyProgress(lang),
141      readOnly: false,
142      trouble: { text: `your ${lang} deck could not be read; it is kept at ${key}:quarantine` },
143    }
144  } catch {
145    // The copy failed, so the original is all there is: do not overwrite it.
146    return {
147      progress: emptyProgress(lang),
148      readOnly: true,
149      trouble: { text: `your ${lang} deck could not be read or copied aside — not saving` },
150    }
151  }
152}
153
154export async function saveProgress(store: Store, progress: Progress): Promise<Trouble> {
155  try {
156    await store.set(progressKey(progress.lang), progress)
157
158    return null
159  } catch (error) {
160    return { text: `could not save: ${messageOf(error)}` }
161  }
162}
163
164export interface LoadedSettings {
165  settings: Settings
166  /** Save is off: the settings could not be read, so they must not be written. */
167  readOnly: boolean
168  trouble: Trouble
169}
170
171/**
172 * Settings, and whether they may be written back.
173 *
174 * The same rule as the deck, and it matters more here than it looks. Handing
175 * back the defaults on a failed read means `lang: ''` — the language picker, as
176 * though you had never chosen — and `on: true`, the band returning for someone
177 * who ran `/lingo off`. The first press would then save those defaults over the
178 * real settings. A read that failed authorises no write.
179 */
180export async function loadSettings(store: Store): Promise<LoadedSettings> {
181  try {
182    return {
183      settings: readSettings(await store.get(SETTINGS_KEY)),
184      readOnly: false,
185      trouble: null,
186    }
187  } catch (error) {
188    return {
189      settings: { ...DEFAULT_SETTINGS },
190      readOnly: true,
191      trouble: {
192        text: `could not read your settings (${messageOf(error)}) — not saving over them`,
193      },
194    }
195  }
196}
197
198export async function saveSettings(store: Store, settings: Settings): Promise<Trouble> {
199  try {
200    await store.set(SETTINGS_KEY, settings)
201
202    return null
203  } catch (error) {
204    return { text: `could not save settings: ${messageOf(error)}` }
205  }
206}
207
208/**
209 * The pack for a code: bundled first, then one generated into the store.
210 *
211 * Bundled first, because progress is keyed by code *and* by rank: a generated
212 * `es` would re-attach box levels earned on Spanish to whatever word now sits
213 * at each rank. `/lingo pack` refuses to write one; this order is the belt to
214 * that's braces.
215 */
216export type LoadedPack =
217  | { pack: Pack; reason?: undefined }
218  | { pack: null; reason: string }
219
220export async function loadPack(store: Store, code: string): Promise<LoadedPack> {
221  const bundled = bundledPack(code)
222
223  if (bundled) return { pack: materialize(bundled) }
224
225  let value: unknown
226
227  try {
228    value = await store.get(packKey(code))
229  } catch (error) {
230    // Not "there is no such pack": the store did not answer. Saying the pack is
231    // missing would send someone off to spend ten minutes regenerating one that
232    // is sitting right there.
233    return { pack: null, reason: `could not read the ${code} pack: ${messageOf(error)}` }
234  }
235
236  if (!isRecord(value)) {
237    return { pack: null, reason: `no word pack for "${code}"` }
238  }
239
240  try {
241    return { pack: materialize(value as unknown as RawPack) }
242  } catch (error) {
243    // `materialize` knows exactly what is wrong (`duplicate term "casa"`), and
244    // that is the only thing that tells someone how to fix it by hand.
245    return { pack: null, reason: `the ${code} pack could not be read: ${messageOf(error)}` }
246  }
247}
248
249export interface LoadedCodes {
250  codes: string[]
251  /** The read failed, so this list is not evidence that there are no packs. */
252  failed: boolean
253}
254
255/**
256 * The codes of packs generated into the store, bundled ones excluded.
257 *
258 * `failed` is the whole point of the shape. `/lingo pack` rewrites this index
259 * by reading it and writing it back, and an empty list from a failed read would
260 * make that write erase every pack the user has ever generated — the bodies
261 * survive under their own keys, but nothing would ever look at them again.
262 * A read that failed authorises no write, here as everywhere else.
263 */
264export async function generatedCodes(store: Store): Promise<LoadedCodes> {
265  try {
266    const value = await store.get(PACKS_KEY)
267
268    if (!Array.isArray(value)) return { codes: [], failed: false }
269
270    return {
271      codes: value.filter(
272        (code): code is string => typeof code === 'string' && !BUNDLED_CODES.includes(code),
273      ),
274      failed: false,
275    }
276  } catch {
277    return { codes: [], failed: true }
278  }
279}
280
281export function messageOf(error: unknown): string {
282  return error instanceof Error ? error.message : String(error)
283}
284
hooks/enrich.ts 115 lines
1/**
2 * Memory hooks, through the session's own model.
3 *
4 * Press the explain key on a card and you get a cognate, an etymology or a
5 * vivid image — the thing that turns a word you have looked up four times into
6 * one you know.
7 *
8 * claudelingo used to do this by spawning `claude -p` and reading its stdout,
9 * which meant finding the binary on PATH, saying so once at startup when it was
10 * not there, and hiding the key when it was missing. A hooks module has no
11 * processes and needs none: `$.model.complete` runs on the session's own client
12 * and credentials. No API key, no second bill, and nothing to detect.
13 *
14 * The reply is cached in `$.store` by word id, so a word is only ever paid for
15 * once, on any machine that store follows.
16 */
17
18import { hookKey } from './names'
19import type { Store } from './deck'
20import type { Word } from './types'
21
22/** The slice of `$` this module touches. */
23export interface Completer {
24  complete: (request: { model: string; prompt: string; system?: string; maxTokens?: number }) => Promise<string>
25}
26
27const SYSTEM =
28  'You help someone remember a foreign word. Answer with one sentence of at most ' +
29  '20 words: a cognate, an etymology, or a vivid image linking the word to its ' +
30  'meaning. No preamble, no quotes, no bullet points, plain text only.'
31
32/**
33 * One line, whatever the model sent.
34 *
35 * The band is three rows and has promised to stay three rows, so a reply with a
36 * newline in it would break the layout of the conversation above. Control
37 * characters go for the reason they go in `pack.ts`: this is model output
38 * arriving in a render tree.
39 */
40export function oneLine(text: string, limit = 160): string {
41  const flat = text
42    .replace(/\p{Cc}/gu, ' ')
43    .replace(/\s+/g, ' ')
44    .trim()
45
46  return flat.length > limit ? `${flat.slice(0, limit - 1).trimEnd()}…` : flat
47}
48
49/** A hook, or why there isn't one. */
50export type Hook = { text: string; reason?: undefined } | { text: null; reason: string }
51
52/**
53 * A memory hook for a word: from the cache, or from the model and then cached.
54 *
55 * Never throws. The hook is a bonus on a card that is already on screen and
56 * already answerable; a model that is slow, refusing or unreachable should cost
57 * the hook and nothing else.
58 *
59 * It carries the reason rather than a bare null, because the reasons are not
60 * interchangeable and the caller cannot guess between them. `settings.model`
61 * takes any non-empty string, so a typo'd or retired model id reads as "could
62 * not reach the model" on every word for ever, while the message that would fix
63 * it in one go — `no such model: haiku-3` — is the thing being discarded.
64 */
65export async function memoryHook(
66  store: Store,
67  model: Completer,
68  word: Word,
69  modelId: string,
70  englishName: string,
71): Promise<Hook> {
72  const key = hookKey(word.id)
73
74  try {
75    const cached = await store.get(key)
76
77    if (typeof cached === 'string' && cached.length > 0) return { text: cached }
78  } catch {
79    // An unreadable cache is a cache miss, not a failure.
80  }
81
82  let reply: string
83
84  try {
85    reply = await model.complete({
86      model: modelId,
87      system: SYSTEM,
88      prompt:
89        `${englishName} word: "${word.term}" (${word.pos}) meaning "${word.gloss}". ` +
90        'How do I remember it?',
91      maxTokens: 120,
92    })
93  } catch (error) {
94    return {
95      text: null,
96      reason: `could not reach ${modelId} for a hook: ${
97        error instanceof Error ? error.message : String(error)
98      }`,
99    }
100  }
101
102  const hook = oneLine(typeof reply === 'string' ? reply : '')
103
104  if (!hook) return { text: null, reason: `${modelId} had nothing to say about "${word.term}"` }
105
106  try {
107    await store.set(key, hook)
108  } catch {
109    // A cache that cannot be written still leaves a usable hook on screen; the
110    // same broken store is already being reported by the deck's own save.
111  }
112
113  return { text: hook }
114}
115
hooks/migrate.ts 314 lines
1/**
2 * Bringing a deck over from the version of claudelingo that was a CLI.
3 *
4 * That version kept everything in `~/.claudelingo`: `settings.json` for the
5 * language, `progress-<code>.json` per deck. This one keeps it in `$.store`.
6 * Someone who has been using the old one has box levels, due dates and a streak
7 * in those files, and deleting the CLI without reading them would quietly throw
8 * that away — the deck *is* the product, and a year of it is not something to
9 * ask anyone to rebuild.
10 *
11 * So the first session reads them, once, and says what it found.
12 *
13 * The file format is the same on both sides, which is not luck: the mod took
14 * the CLI's `Progress` type unchanged. That makes this a copy rather than a
15 * conversion, and the only real work is deciding what to do about collisions.
16 *
17 * Three rules, all of them the same rule:
18 *
19 * - **A deck already here wins.** Anything you have answered in the band is
20 *   newer than a file last written before you installed this, and merging two
21 *   schedules for one word means inventing an answer nobody gave.
22 * - **A read that failed is not an empty deck.** That holds for the directory,
23 *   for each deck file, and for the store read that checks whether a deck is
24 *   already here — a failure at any of the three says so and changes nothing,
25 *   because each of them otherwise reads as "there was nothing there" and
26 *   authorises a write over something never seen.
27 * - **It runs once, but only once it is finished.** The marker is written when
28 *   every deck has been imported, skipped, or found not to be a deck — so a
29 *   machine with no old install pays one `exists` check per session and nothing
30 *   more, while a deck that could not be reached today is tried again tomorrow.
31 *   Marking early is what turns a transient failure into permanent loss.
32 */
33
34import { isProgress } from './deck'
35import type { Store, Trouble } from './deck'
36import { MIGRATED_KEY, progressKey } from './names'
37import type { Progress, Settings } from './types'
38
39/** The slice of `$` this needs: the host's filesystem, read-only. */
40export interface Files {
41  exists: (path: string) => Promise<boolean>
42  read: (path: string) => Promise<string>
43  list: (path: string) => Promise<readonly { name: string }[]>
44}
45
46export interface Migration {
47  /** Decks brought over, by language code. */
48  imported: string[]
49  /** Decks left alone because one was already here. */
50  skipped: string[]
51  /** The language the old install was studying, if it said. */
52  lang: string | null
53  trouble: Trouble
54}
55
56const NOTHING: Migration = { imported: [], skipped: [], lang: null, trouble: null }
57
58/** `progress-es.json` -> `es`. */
59function codeOf(name: string): string | null {
60  const match = /^progress-([A-Za-z-]{2,10})\.json$/.exec(name)
61
62  return match?.[1] ?? null
63}
64
65/**
66 * Read the old install's decks into the store, once.
67 *
68 * `home` is the directory `~/.claudelingo` sits in; it is passed rather than
69 * looked up so a test can point it somewhere harmless.
70 */
71export async function migrate(
72  store: Store,
73  files: Files,
74  home: string,
75): Promise<Migration> {
76  // Already done, or deliberately not to be done again.
77  try {
78    if ((await store.get(MIGRATED_KEY)) !== undefined) return NOTHING
79  } catch {
80    // An unreadable store is not the place to start writing decks into.
81    return NOTHING
82  }
83
84  const dir = `${home}/.claudelingo`
85
86  if (!(await files.exists(dir).catch(() => false))) {
87    await mark(store)
88
89    return NOTHING
90  }
91
92  const result: Migration = { imported: [], skipped: [], lang: null, trouble: null }
93
94  /** Decks that could not be settled either way, so the marker is withheld. */
95  const unresolved: string[] = []
96
97  try {
98    const entries = await files.list(dir)
99
100    for (const { name } of entries) {
101      const code = codeOf(name)
102
103      if (code === null) continue
104
105      const read = await readProgress(files, `${dir}/${name}`, code)
106
107      if (read.kind === 'unreadable') {
108        // The file is there and we could not open it. Skipping quietly and
109        // marking the import done would strand that deck for ever, including
110        // after the permission that caused it is fixed.
111        unresolved.push(`${code} (${read.reason})`)
112
113        continue
114      }
115
116      // Present but not a deck: an older schema, or a file that only looks like
117      // one. Nothing to import and nothing that will change, so it is not a
118      // reason to come back.
119      if (read.kind === 'invalid') continue
120
121      const progress = read.progress
122
123      // Anything already answered here is newer than a file written before this
124      // was installed; two schedules for one word cannot be merged honestly.
125      //
126      // `.catch(() => undefined)` here would be the whole point of this module
127      // thrown away: a store that failed to answer would read as "no deck here"
128      // and the import would write over one it never saw. A read that failed
129      // authorises no write, which is the rule `deck.ts` is built on.
130      let held: unknown
131
132      try {
133        held = await store.get(progressKey(code))
134      } catch (error) {
135        unresolved.push(`${code} (could not check for an existing deck: ${messageOf(error)})`)
136
137        continue
138      }
139
140      if (held !== undefined) {
141        result.skipped.push(code)
142
143        continue
144      }
145
146      try {
147        await store.set(progressKey(code), progress)
148        result.imported.push(code)
149      } catch (error) {
150        unresolved.push(`${code} (could not be saved: ${messageOf(error)})`)
151      }
152    }
153
154    const settings = await readLang(files, `${dir}/settings.json`)
155
156    result.lang = settings.lang
157
158    if (settings.reason !== undefined) unresolved.push(settings.reason)
159  } catch (error) {
160    // Say so and try again next session: recording success here would mean
161    // never looking at those files again.
162    return {
163      ...result,
164      trouble: {
165        text: `could not read your old claudelingo deck (${
166          error instanceof Error ? error.message : String(error)
167        }) — it is still in ${dir}`,
168      },
169    }
170  }
171
172  // Only once everything is either imported, skipped or known not to be a deck.
173  // Marking with something still unresolved is what turns a transient failure
174  // into permanent loss: the files stay on disk and nothing ever reads them.
175  if (unresolved.length > 0) {
176    return {
177      ...result,
178      trouble: {
179        text:
180          `could not bring over ${unresolved.join(', ')} from ${dir} — ` +
181          'it is still there, and this will try again next session',
182      },
183    }
184  }
185
186  // A marker that would not write means this runs again next session; saying
187  // "kept the es deck already here" every session from now on is noise, not
188  // news, so an unmarked run that moved nothing says nothing at all.
189  const marked = await mark(store)
190
191  if (!marked && result.imported.length === 0) return { ...result, skipped: [] }
192
193  return result
194}
195
196/**
197 * Record that the import is finished, and say whether that stuck.
198 *
199 * A marker that cannot be written is not a correctness problem — the next
200 * session finds every deck already present and imports nothing — but it does
201 * mean the notice would be repeated for ever. The caller uses this to stay
202 * quiet about decks it did not actually move.
203 */
204async function mark(store: Store): Promise<boolean> {
205  try {
206    await store.set(MIGRATED_KEY, new Date().toISOString())
207
208    return true
209  } catch {
210    return false
211  }
212}
213
214/**
215 * One deck file: readable and a deck, readable and not a deck, or unreadable.
216 *
217 * Three outcomes rather than two, because the middle one is final and the last
218 * one is not. A file whose contents are not a deck will never become one; a
219 * file that would not open today may open tomorrow, and the difference decides
220 * whether it is safe to stop looking.
221 */
222type ReadDeck =
223  | { kind: 'deck'; progress: Progress }
224  | { kind: 'invalid' }
225  | { kind: 'unreadable'; reason: string }
226
227async function readProgress(files: Files, path: string, code: string): Promise<ReadDeck> {
228  let text: string
229
230  try {
231    text = await files.read(path)
232  } catch (error) {
233    return { kind: 'unreadable', reason: `could not be read: ${messageOf(error)}` }
234  }
235
236  let parsed: unknown
237
238  try {
239    parsed = JSON.parse(text)
240  } catch {
241    // Parsed and rejected: the contents are not a deck and never will be.
242    return { kind: 'invalid' }
243  }
244
245  // The filename claims a language and the deck carries one. If they disagree
246  // the file is not what it says it is, and importing it would attach one
247  // language's box levels to another's word ids.
248  return isProgress(parsed, code) ? { kind: 'deck', progress: parsed } : { kind: 'invalid' }
249}
250
251function messageOf(error: unknown): string {
252  return error instanceof Error ? error.message : String(error)
253}
254
255/**
256 * The language the old install was on, so this one opens where you left off.
257 *
258 * Told apart the same way the decks are: a settings file that would not open is
259 * not a settings file that named nothing. The difference is small here — the
260 * decks are imported either way and the picker asks once — but conflating them
261 * would write the marker and lose the answer for good, which is the failure
262 * this module exists to avoid.
263 */
264async function readLang(
265  files: Files,
266  path: string,
267): Promise<{ lang: string | null; reason?: string }> {
268  // Absent is a perfectly ordinary answer: it means no language was ever set.
269  if (!(await files.exists(path))) return { lang: null }
270
271  let text: string
272
273  try {
274    text = await files.read(path)
275  } catch (error) {
276    return { lang: null, reason: `settings.json could not be read: ${messageOf(error)}` }
277  }
278
279  try {
280    const parsed: unknown = JSON.parse(text)
281
282    if (typeof parsed !== 'object' || parsed === null) return { lang: null }
283
284    const lang = (parsed as Partial<Settings>).lang
285
286    return { lang: typeof lang === 'string' && lang.length > 0 ? lang : null }
287  } catch {
288    return { lang: null }
289  }
290}
291
292/** What to tell someone whose deck has just moved. */
293export function migrationNotice(result: Migration): string | null {
294  const { imported, skipped } = result
295
296  if (imported.length === 0 && skipped.length === 0) return null
297
298  const parts: string[] = []
299
300  if (imported.length > 0) {
301    parts.push(
302      `brought ${imported.length === 1 ? 'your' : ''} ${imported.join(', ')} ${
303        imported.length === 1 ? 'deck' : 'decks'
304      } over from the old claudelingo`.replace('  ', ' '),
305    )
306  }
307
308  if (skipped.length > 0) {
309    parts.push(`kept the ${skipped.join(', ')} deck already here`)
310  }
311
312  return `claudelingo: ${parts.join('; ')}.`
313}
314
hooks/names.ts 127 lines
1/**
2 * The fixed names: what the band is called, and what it keeps where.
3 *
4 * Store keys are versioned in their own right (`v1`) rather than under one
5 * schema number, because the deck and the settings change for different
6 * reasons and a settings migration should not orphan a year of progress.
7 */
8
9/** The plugin's name, as `ui.press` and `ui.focus` carry it. */
10export const PLUGIN_NAME = 'claudelingo'
11
12/** The slash command the band's third row names. */
13export const COMMAND_NAME = 'lingo'
14
15/** Settings: the language, the caps, whether the band draws at all. */
16export const SETTINGS_KEY = 'claudelingo:settings:v1'
17
18/** One deck per language, so switching language never touches the other. */
19export const progressKey = (lang: string) => `claudelingo:progress:v1:${lang}`
20
21/** A memory hook, cached by word id: a word is only ever paid for once. */
22export const hookKey = (wordId: string) => `claudelingo:hook:v1:${wordId}`
23
24/** A pack generated for a language the mod does not bundle. */
25export const packKey = (code: string) => `claudelingo:pack:v1:${code}`
26
27/** The index of generated packs, so the picker can offer them. */
28export const PACKS_KEY = 'claudelingo:packs:v1'
29
30/**
31 * When the old CLI's decks were read in, so it only ever happens once.
32 *
33 * Set whatever the outcome, including "there was nothing there" — otherwise a
34 * machine that never had the old install pays a directory read every session
35 * for ever.
36 */
37export const MIGRATED_KEY = 'claudelingo:migrated:v1'
38
39/* ── Button keys ──────────────────────────────────────────────────────────
40 *
41 * `e.element` at `ui.press` is one of these. They are addresses, not labels:
42 * a test presses `answer:2` without knowing what the option says.
43 */
44
45/** One per multiple-choice option, `answer:0` upwards. */
46export const answerKey = (index: number) => `answer:${index}`
47/** One per language the picker offers. */
48export const langKey = (code: string) => `lang:${code}`
49
50export const KEYS = {
51  /** Acknowledges a `teach` card, or moves past a verdict. */
52  next: 'next',
53  /** Drops the card and delays it ten minutes, without penalty. */
54  skip: 'skip',
55  /** Asks the model for a memory hook. */
56  explain: 'explain',
57  /** Quizzes while Claude is idle. */
58  practise: 'practise',
59  /** Where a `recall` card's typing goes. */
60  spell: 'spell',
61  /** Starts a quiz run, from the idle band. */
62  quiz: 'quiz',
63  /** Runs the same quiz again, from the score. */
64  again: 'again',
65  /** Puts the score away. */
66  done: 'done',
67} as const
68
69/**
70 * Hotkeys are digits throughout, and that is a constraint rather than a taste.
71 *
72 * In the band a bare digit presses from an empty composer, with nothing
73 * focused: that is the whole reason this mod exists, and it is what lets an
74 * answer cost one keystroke. A letter presses only once one of the band's
75 * Buttons already has the focus, which is a chord and a hunt. So every control
76 * that has to work from where your hands already are gets a digit, and the
77 * digits after the options are the controls.
78 */
79export const CONTROL_HOTKEYS = {
80  /** Never collides: options take at most 1-4, and `teach` offers no options. */
81  next: '1',
82  skip: '5',
83  explain: '6',
84  practise: '1',
85  quiz: '1',
86  again: '1',
87  done: '5',
88} as const
89
90/**
91 * Cards in a quiz you asked for, and the most you may ask for.
92 *
93 * Five is about a minute, which is the length of a pause worth filling. The cap
94 * is there because a run keeps asking until it is done: a thousand-card quiz
95 * would be a band you cannot get rid of.
96 */
97export const QUIZ_LENGTH = 5
98export const QUIZ_MAX = 50
99
100/** Words a generated pack asks for. */
101export const PACK_WORDS = 300
102
103/** Rows the band draws. Fixed, so the conversation above it never jumps. */
104export const BAND_ROWS = 3
105
106/** Below this many columns the owl gutter costs more than it gives. */
107export const OWL_MIN_COLUMNS = 46
108
109/** Below this, three rows cannot say anything useful: draw one instead. */
110export const BAND_MIN_COLUMNS = 30
111
112/** How long one word holds the idle ticker before the next takes over. */
113export const TICK_MS = 8000
114
115/** Fraction of that spent hidden, before the meaning is revealed. */
116export const HIDDEN_FRACTION = 0.5
117
118/**
119 * How often the band redraws itself while it is drilling.
120 *
121 * The ticker reveals on a clock, and the engine redraws on its own events —
122 * which go quiet exactly while a turn is running, which is when the band is
123 * supposed to be teaching. So it asks for its own redraws, at a rate the
124 * engine's ten-a-second ceiling never has to fold.
125 */
126export const REDRAW_MS = 1000
127
hooks/pack.ts 97 lines
1/**
2 * Turning a written pack into words the band can ask about.
3 *
4 * One rule here matters more than the rest: control characters are stripped. A
5 * gloss is drawn as a `Text` child in a band that has promised to be three rows
6 * tall, so a newline breaks the promise the conversation above it relies on,
7 * and a raw escape is data the model wrote arriving in a render tree. Both are
8 * cut here, once, rather than at each of the places that draw a word.
9 */
10
11import { canCloze } from './cloze'
12import { BUNDLED } from './packs'
13import type { Pack, RawPack, Word } from './types'
14
15export class PackError extends Error {}
16
17function clean(value: string): string {
18  return value
19    .replace(/\p{Cc}/gu, ' ')
20    .replace(/\s+/g, ' ')
21    .trim()
22}
23
24export function materialize(raw: RawPack): Pack {
25  if (!raw || typeof raw.code !== 'string' || !Array.isArray(raw.words)) {
26    throw new PackError('pack is missing `code` or `words`')
27  }
28
29  const seen = new Set<string>()
30
31  const words: Word[] = raw.words.map((entry, index) => {
32    const [rawTerm, rawGloss, rawPos, rawNote, rawExample] = entry
33    const term = clean(rawTerm ?? '')
34    const gloss = clean(rawGloss ?? '')
35    const pos = clean(rawPos ?? '')
36
37    if (!term || !gloss || !pos) {
38      throw new PackError(`pack ${raw.code}: entry ${index + 1} needs [term, gloss, pos]`)
39    }
40
41    if (seen.has(term)) {
42      throw new PackError(`pack ${raw.code}: duplicate term "${term}"`)
43    }
44
45    seen.add(term)
46
47    const word: Word = { id: `${raw.code}:${index + 1}`, rank: index + 1, term, gloss, pos }
48    const note = clean(rawNote ?? '')
49
50    if (note) word.note = note
51
52    // `sentence | translation`. A sentence that cannot be blanked is dropped
53    // rather than kept — `canCloze` is the same question the card builder asks,
54    // so the two cannot disagree about whether a sentence is usable.
55    const [text, translation] = clean(rawExample ?? '').split('|')
56
57    if (text && translation && canCloze(text, term)) {
58      word.example = { text: text.trim(), translation: translation.trim() }
59    }
60
61    return word
62  })
63
64  return {
65    code: clean(raw.code),
66    name: clean(raw.name || raw.code),
67    englishName: clean(raw.englishName || raw.name || raw.code),
68    words,
69  }
70}
71
72/** The codes built into the mod: what a generated pack may not be called. */
73export const BUNDLED_CODES: readonly string[] = BUNDLED.map((pack) => pack.code)
74
75/** A bundled pack by code, or undefined. */
76export function bundledPack(code: string): RawPack | undefined {
77  return BUNDLED.find((pack) => pack.code === code)
78}
79
80/**
81 * What the picker offers: the bundled languages, then anything generated.
82 *
83 * Name and code only — materialising every pack to count its words would parse
84 * a thousand entries to draw one row.
85 */
86export interface PackChoice {
87  code: string
88  englishName: string
89  words: number
90}
91
92export const BUNDLED_CHOICES: readonly PackChoice[] = BUNDLED.map((pack) => ({
93  code: pack.code,
94  englishName: pack.englishName,
95  words: pack.words.length,
96}))
97
hooks/packgen.ts 194 lines
1import { canCloze } from "./cloze";
2import type { RawPack } from "./types";
3
4/**
5 * Building a frequency pack for a language claudelingo does not ship.
6 *
7 * This module holds the prompts, the chunking and every rule about what comes
8 * back; it does not know how the model is reached. That is the `Asker` handed
9 * in — `$.model.complete` in the band, a fake in the tests — which is what lets
10 * the hardest code here be driven without a model at all.
11 *
12 * Every comment below records a real run that went wrong: a truncated reply
13 * losing 693 words of French, boundaries overlapping until a 1000-word pack
14 * held 600 distinct ones, and a model that, asked to fill a quota past the
15 * point where it knows the frequency order, starts reciting the dictionary
16 * alphabetically.
17 */
18
19/**
20 * How the model is reached.
21 *
22 * One prompt, one system prompt, the reply's text. Everything either transport
23 * needs beyond that — timeouts, models, process plumbing — it closes over.
24 */
25export type Asker = (prompt: string, options: { system: string }) => Promise<string>;
26
27export class EnrichError extends Error {}
28
29const PACK_SYSTEM =
30  "You are a corpus linguist building a beginner vocabulary pack. " +
31  "Order words by descending corpus frequency, one dictionary form per entry, " +
32  "no duplicate terms and no two entries sharing an English gloss. " +
33  "`pos` is one of: noun, verb, adj, adv, prep, conj, pron, art, num, interj. " +
34  "`gloss` is a short English translation; `note` carries gender or an " +
35  "irregularity when it matters. `example` is a short natural sentence that " +
36  "*contains the entry's term verbatim*, then ` | `, then its English " +
37  "translation — six to twelve words, using only vocabulary at least as common " +
38  "as the entry itself. Reply with JSON only — no prose, no code fence.";
39
40/** How many words to ask for in one request. */
41const PACK_CHUNK = 100;
42
43interface GeneratedEntry {
44  term?: string;
45  gloss?: string;
46  pos?: string;
47  note?: string;
48  example?: string;
49}
50
51/** One request: the words ranked `from`..`from + size - 1`. */
52async function packChunk(
53  ask: Asker,
54  language: string,
55  from: number,
56  size: number,
57  known: string[],
58): Promise<GeneratedEntry[]> {
59  // The already-taken terms go in the prompt because the model cannot see the
60  // earlier chunks: without them the boundaries overlap heavily and a 1000-word
61  // pack comes back with 600 distinct words.
62  const avoid = known.length
63    ? ` Do not repeat any of these, which are already in the pack: ${known.join(", ")}.`
64    : "";
65  const raw = await ask(
66    `Produce words ranked ${from} to ${from + size - 1} by frequency in ${language}, ` +
67      `as JSON of the form {"words":[{"term":"","gloss":"","pos":"","note":"","example":""}]}. ` +
68      `Exactly ${size} entries, continuing the frequency order — not the commonest words ` +
69      `again.${avoid} Omit "note" when it does not apply.`,
70    { system: PACK_SYSTEM },
71  );
72  const body = raw
73    .replace(/^```(?:json)?\s*/i, "")
74    .replace(/```\s*$/, "")
75    .trim();
76  let parsed: { words?: GeneratedEntry[] };
77  try {
78    parsed = JSON.parse(body) as { words?: GeneratedEntry[] };
79  } catch {
80    throw new EnrichError(`Claude did not return valid JSON for words ${from}-${from + size - 1}`);
81  }
82  if (!Array.isArray(parsed.words)) {
83    throw new EnrichError(`Claude returned no words for ${from}-${from + size - 1}`);
84  }
85  return parsed.words;
86}
87
88/**
89 * Build a frequency pack for a language claudelingo does not ship.
90 *
91 * Asked in chunks, because one request for a thousand entries with a sentence
92 * each is a very long reply: it truncates, and a truncated JSON body is a whole
93 * pack lost rather than one chunk. Each chunk is told what the earlier ones
94 * produced, or the boundaries overlap and the duplicates eat the count.
95 */
96export async function generatePack(
97  ask: Asker,
98  language: string,
99  code: string,
100  count: number,
101  options: {
102    onProgress?: (done: number, total: number, note?: string) => void;
103    /** Words already gathered, to continue from instead of regenerating. */
104    existing?: RawPack["words"];
105  } = {},
106): Promise<RawPack> {
107  const { onProgress, existing } = options;
108  const seen = new Set<string>();
109  const words: RawPack["words"] = [];
110
111  // Carry on from a pack already on disk rather than paying for it twice. A run
112  // that dies at word 800 should cost its next attempt 200 words, not 1000.
113  for (const row of existing ?? []) {
114    const term = (row[0] ?? "").replace(/\s+/g, " ").trim().toLowerCase();
115    if (!term || seen.has(term)) continue;
116    seen.add(term);
117    words.push(row);
118  }
119
120  // Deliberately no top-up past the requested ranks.
121  //
122  // Chunks overlap, so asking for `count` ranks yields fewer than `count` words
123  // — and the obvious fix, "keep asking until the target is met", is how a real
124  // run ended up with `padernete` and `achufladamente` in it. Past the point
125  // where the model actually knows the frequency order it starts reciting the
126  // dictionary alphabetically to fill the quota. A short pack of real words
127  // beats a full one padded with sludge, so it stops at the end of the range and
128  // says what it got.
129  const maxChunks = Math.ceil(count / PACK_CHUNK);
130  let from = Math.floor(words.length / PACK_CHUNK) * PACK_CHUNK + 1;
131  let barren = 0;
132
133  for (let chunk = 0; chunk < maxChunks && words.length < count && barren < 3; chunk++) {
134    const size = Math.min(PACK_CHUNK, Math.max(20, count - words.length));
135    const before = words.length;
136    let entries: GeneratedEntry[];
137    try {
138      // Only the most recent terms: the whole list would grow the prompt without
139      // bound, and it is the boundary that overlaps, not the beginning.
140      entries = await packChunk(ask, language, from, size, [...seen].slice(-120));
141    } catch (error) {
142      // One bad chunk must not cost the run. A reply that does not parse is
143      // usually a truncation, so retry once at half the size; if that fails too,
144      // stop and keep what we have — throwing here discarded 693 words of
145      // French and 673 of Italian, about half an hour of somebody's quota.
146      onProgress?.(words.length, count, `chunk ${from}: ${(error as Error).message}; retrying smaller`);
147      try {
148        entries = await packChunk(
149          ask,
150          language,
151          from,
152          Math.max(20, Math.floor(size / 2)),
153          [...seen].slice(-120),
154        );
155      } catch (retryError) {
156        onProgress?.(words.length, count, `chunk ${from} failed twice: ${(retryError as Error).message}`);
157        break;
158      }
159    }
160    for (const entry of entries) {
161      if (!entry?.term || !entry.gloss || !entry.pos) continue;
162      // Deduped on the *cleaned* term, which is what the loader compares. On the
163      // raw string "casa" and "  casa  " both survive generation, and `savePack`
164      // then throws `duplicate term`, losing a run that can take ten minutes.
165      const key = entry.term.replace(/\s+/g, " ").trim().toLowerCase();
166      if (!key || seen.has(key)) continue;
167      seen.add(key);
168      // A sentence that does not contain its own word cannot be clozed, and one
169      // without a translation cannot be shown. The loader drops both anyway;
170      // writing them to disk would just be junk in a file people read.
171      const example = (entry.example ?? "").trim();
172      const [text, translation] = example.split("|");
173      const usable = !!text && !!translation?.trim() && canCloze(text, entry.term);
174
175      const row: string[] = [entry.term, entry.gloss, entry.pos];
176      if (usable) row.push(entry.note ?? "", example);
177      else if (entry.note) row.push(entry.note);
178      words.push(row as RawPack["words"][number]);
179      if (words.length >= count) break;
180    }
181    onProgress?.(words.length, count);
182    // A chunk that adds nothing new means the model has run out of words it has
183    // not already given us; three in a row and there is no point asking again.
184    barren = words.length > before ? 0 : barren + 1;
185    from += PACK_CHUNK;
186  }
187
188  if (!words.length) throw new EnrichError("Claude returned no words");
189  if (words.length < count) {
190    onProgress?.(words.length, count, `stopped at ${words.length}: the ranks asked for are used up`);
191  }
192  return { code, name: language, englishName: language, words };
193}
194
hooks/srs.ts 357 lines
1import { blankTerm } from "./cloze";
2import type { Card, CardKind, ItemProgress, Pack, Progress, Settings, Word } from "./types";
3
4const MINUTE = 60_000;
5const DAY = 24 * 60 * MINUTE;
6
7/**
8 * Short-term steps walked while an item is still `learning`. Graduating to `review`
9 * takes one correct answer per step, so a word has to survive three separate waits
10 * before it starts costing calendar days.
11 */
12export const LEARNING_STEPS = [1 * MINUTE, 10 * MINUTE, 60 * MINUTE];
13
14/** Days until the next review, indexed by Leitner box. Box 0 is unused. */
15export const REVIEW_DAYS = [0, 1, 3, 7, 16, 35];
16
17export const MAX_BOX = 5;
18
19/**
20 * The teaching ramp: a word is shown before it is ever asked, recognised before it
21 * has to be produced, and only typed out once it is genuinely familiar.
22 */
23/**
24 * What to ask at a given box, and whether the word can carry a sentence.
25 *
26 * A cloze sits between recognising a word and producing it cold: the sentence
27 * gives you the grammar and the company the word keeps, which is most of what
28 * "knowing" it means, and it is the first time the word is asked for in context
29 * rather than in isolation. Words without an example sentence skip straight on.
30 */
31export function cardKindForBox(box: number, hasExample = false): CardKind {
32  if (box <= 2) return "recognize";
33  if (box === 3) return "reverse";
34  if (box === 4) return hasExample ? "cloze" : "reverse";
35  return "recall";
36}
37
38export function emptyProgress(lang: string): Progress {
39  return {
40    version: 1,
41    lang,
42    items: {},
43    streak: 0,
44    bestStreak: 0,
45    totalAnswered: 0,
46    totalCorrect: 0,
47    introducedByDay: {},
48  };
49}
50
51export function dayKey(now: number): string {
52  const d = new Date(now);
53  const pad = (n: number) => String(n).padStart(2, "0");
54  return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
55}
56
57/** Deterministic PRNG so a seeded run always produces the same quiz. */
58export function makeRng(seed: number): () => number {
59  let a = seed >>> 0;
60  return () => {
61    a = (a + 0x6d2b79f5) >>> 0;
62    let t = a;
63    t = Math.imul(t ^ (t >>> 15), t | 1);
64    t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
65    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
66  };
67}
68
69export function shuffle<T>(items: T[], rng: () => number): T[] {
70  const out = items.slice();
71  for (let i = out.length - 1; i > 0; i--) {
72    const j = Math.floor(rng() * (i + 1));
73    const a = out[i] as T;
74    const b = out[j] as T;
75    out[i] = b;
76    out[j] = a;
77  }
78  return out;
79}
80
81function countLearning(progress: Progress): number {
82  return Object.values(progress.items).filter((i) => i.stage === "learning").length;
83}
84
85/**
86 * Decide what to show next.
87 *
88 * Order of preference: anything already due (most overdue first, weakest first on a
89 * tie), then a brand-new word if both the learning-queue and daily caps allow it.
90 * Returns null when the user is genuinely caught up.
91 */
92export function selectNext(
93  pack: Pack,
94  progress: Progress,
95  settings: Settings,
96  now: number,
97): { word: Word; item: ItemProgress | null } | null {
98  const due = Object.values(progress.items)
99    .filter((item) => item.due <= now)
100    .sort((a, b) => a.due - b.due || a.box - b.box || a.id.localeCompare(b.id));
101
102  const byId = new Map(pack.words.map((w) => [w.id, w] as const));
103  for (const item of due) {
104    const word = byId.get(item.id);
105    // An item whose word vanished (pack trimmed, language switched) is not fatal.
106    if (word) return { word, item };
107  }
108
109  if (countLearning(progress) >= settings.maxLearning) return null;
110  if ((progress.introducedByDay[dayKey(now)] ?? 0) >= settings.newPerDay) return null;
111
112  const next = pack.words.find((w) => !progress.items[w.id]);
113  return next ? { word: next, item: null } : null;
114}
115
116/** When the next item becomes eligible, or null if there is nothing scheduled. */
117/**
118 * A skip costs nothing but a delay — punishing it would poison the box levels.
119 *
120 * A word with no progress row yet gets one in the `new` stage, because without
121 * it `selectNext` hands back the very card that was just skipped.
122 */
123export const SKIP_DELAY_MS = 10 * MINUTE;
124
125export function deferItem(
126  existing: ItemProgress | undefined,
127  id: string,
128  now: number,
129): ItemProgress {
130  if (existing) return { ...existing, due: now + SKIP_DELAY_MS };
131  return {
132    id,
133    stage: "new",
134    box: 0,
135    step: 0,
136    due: now + SKIP_DELAY_MS,
137    lastSeen: 0,
138    seen: 0,
139    correct: 0,
140    lapses: 0,
141  };
142}
143
144export function nextDueAt(progress: Progress): number | null {
145  const times = Object.values(progress.items).map((i) => i.due);
146  return times.length ? Math.min(...times) : null;
147}
148
149function pickDistractors(pack: Pack, word: Word, rng: () => number, count: number): Word[] {
150  const isCandidate = (w: Word) => w.id !== word.id && w.gloss !== word.gloss && w.term !== word.term;
151  // Nearby-rank words of the same part of speech make the hardest, fairest choices;
152  // widen the net only as far as needed to fill the row.
153  const tiers = [
154    pack.words.filter((w) => isCandidate(w) && w.pos === word.pos && Math.abs(w.rank - word.rank) <= 60),
155    pack.words.filter((w) => isCandidate(w) && w.pos === word.pos),
156    pack.words.filter(isCandidate),
157  ];
158
159  const picked: Word[] = [];
160  const used = new Set<string>();
161  for (const tier of tiers) {
162    for (const candidate of shuffle(tier, rng)) {
163      if (picked.length >= count) break;
164      if (used.has(candidate.id)) continue;
165      used.add(candidate.id);
166      picked.push(candidate);
167    }
168    if (picked.length >= count) break;
169  }
170  return picked;
171}
172
173/** Strip case, accents, and surrounding punctuation so "Qué" matches "que". */
174export function normalize(text: string): string {
175  return text
176    .normalize("NFD")
177    .replace(/[\u0300-\u036f]/g, "")
178    .toLowerCase()
179    .replace(/[^\p{L}\p{N}\s'-]/gu, "")
180    .trim()
181    .replace(/\s+/g, " ");
182}
183
184export function buildCard(pack: Pack, word: Word, item: ItemProgress | null, rng: () => number): Card {
185  const kind: CardKind =
186    item === null || item.stage === "new"
187      ? "teach"
188      : cardKindForBox(item.box, Boolean(word.example));
189
190  if (kind === "teach") {
191    return { kind, word, prompt: word.term, choices: [], answerIndex: -1, accepted: [word.term] };
192  }
193
194  if (kind === "recall") {
195    return {
196      kind,
197      word,
198      prompt: word.gloss,
199      choices: [],
200      answerIndex: -1,
201      accepted: [word.term, ...word.term.split(/\s*,\s*/)],
202    };
203  }
204
205  const distractors = pickDistractors(pack, word, rng, 3);
206  const label = (w: Word) => (kind === "recognize" ? w.gloss : w.term);
207  if (kind === "cloze" && word.example) {
208    const blanked = blankTerm(word.example.text, word.term);
209    const options = shuffle([word, ...distractors], rng);
210    return {
211      kind,
212      word,
213      prompt: blanked,
214      choices: options.map((w) => w.term),
215      answerIndex: options.findIndex((w) => w.id === word.id),
216      accepted: [word.term],
217    };
218  }
219  const options = shuffle([word, ...distractors], rng);
220  return {
221    kind,
222    word,
223    prompt: kind === "recognize" ? word.term : word.gloss,
224    choices: options.map(label),
225    answerIndex: options.findIndex((w) => w.id === word.id),
226    accepted: [label(word)],
227  };
228}
229
230export function isCorrect(card: Card, response: { choice?: number; text?: string }): boolean {
231  if (card.kind === "teach") return true;
232  const typed = normalize(response.text ?? "");
233  if (card.choices.length) {
234    // A picked option is the answer, whatever else came along with it. Letting
235    // the text win here threw away a correct `--choice` whenever both were
236    // passed — box demoted, lapse recorded, streak gone, for the right answer.
237    if (response.choice !== undefined) return response.choice === card.answerIndex;
238    // Someone who types the right answer instead of its number has got it right.
239    if (typed.length > 0) {
240      const right = card.choices[card.answerIndex];
241      return (
242        (right !== undefined && normalize(right) === typed) ||
243        card.accepted.some((a) => normalize(a) === typed)
244      );
245    }
246    return false;
247  }
248  return typed.length > 0 && card.accepted.some((a) => normalize(a) === typed);
249}
250
251function freshItem(id: string, now: number): ItemProgress {
252  return { id, stage: "new", box: 0, step: 0, due: now, lastSeen: 0, seen: 0, correct: 0, lapses: 0 };
253}
254
255/**
256 * Fold one answer into progress. Returns a new object; the caller owns persistence.
257 *
258 * A wrong answer never destroys history — it drops the item one box and sends it back
259 * through the short learning steps, which is what makes the box level meaningful.
260 */
261export function applyAnswer(
262  progress: Progress,
263  word: Word,
264  card: Card,
265  correct: boolean,
266  now: number,
267): Progress {
268  const items = { ...progress.items };
269  const previous = items[word.id] ?? freshItem(word.id, now);
270  const item: ItemProgress = { ...previous, lastSeen: now };
271  const introducedByDay = { ...progress.introducedByDay };
272
273  if (card.kind === "teach") {
274    const key = dayKey(now);
275    introducedByDay[key] = (introducedByDay[key] ?? 0) + 1;
276    // Only today's count is ever read; keeping every day since install would grow
277    // the progress file by one key a day for the life of the deck.
278    for (const day of Object.keys(introducedByDay)) {
279      if (day !== key) delete introducedByDay[day];
280    }
281    item.stage = "learning";
282    item.box = 1;
283    item.step = 0;
284    item.due = now + (LEARNING_STEPS[0] as number);
285    items[word.id] = item;
286    // A teach card is an introduction, not a question: it must not move the streak
287    // or the accuracy numbers.
288    return { ...progress, items, introducedByDay };
289  }
290
291  item.seen += 1;
292  if (correct) item.correct += 1;
293
294  if (correct) {
295    if (item.stage === "learning") {
296      const step = item.step + 1;
297      if (step < LEARNING_STEPS.length) {
298        item.step = step;
299        item.due = now + (LEARNING_STEPS[step] as number);
300      } else {
301        item.stage = "review";
302        item.step = 0;
303        item.box = Math.min(MAX_BOX, item.box + 1);
304        item.due = now + (REVIEW_DAYS[item.box] as number) * DAY;
305      }
306    } else {
307      item.box = Math.min(MAX_BOX, item.box + 1);
308      item.due = now + (REVIEW_DAYS[item.box] as number) * DAY;
309    }
310  } else {
311    if (item.stage === "review") item.lapses += 1;
312    item.stage = "learning";
313    item.box = Math.max(1, item.box - 1);
314    item.step = 0;
315    item.due = now + (LEARNING_STEPS[0] as number);
316  }
317
318  items[word.id] = item;
319  const streak = correct ? progress.streak + 1 : 0;
320  return {
321    ...progress,
322    items,
323    introducedByDay,
324    streak,
325    bestStreak: Math.max(progress.bestStreak, streak),
326    totalAnswered: progress.totalAnswered + 1,
327    totalCorrect: progress.totalCorrect + (correct ? 1 : 0),
328  };
329}
330
331export interface Stats {
332  learned: number;
333  total: number;
334  learning: number;
335  review: number;
336  mastered: number;
337  due: number;
338  streak: number;
339  bestStreak: number;
340  accuracy: number;
341}
342
343export function stats(pack: Pack, progress: Progress, now: number): Stats {
344  const items = Object.values(progress.items);
345  return {
346    learned: items.length,
347    total: pack.words.length,
348    learning: items.filter((i) => i.stage === "learning").length,
349    review: items.filter((i) => i.stage === "review").length,
350    mastered: items.filter((i) => i.box >= MAX_BOX).length,
351    due: items.filter((i) => i.due <= now).length,
352    streak: progress.streak,
353    bestStreak: progress.bestStreak,
354    accuracy: progress.totalAnswered ? progress.totalCorrect / progress.totalAnswered : 0,
355  };
356}
357
hooks/ticker.ts 56 lines
1/**
2 * The idle drill: a word, a pause, its meaning, the next word.
3 *
4 * With nothing due and no turn running there is nothing to quiz, but there is
5 * still a band. So it tickers: it walks the language's frequency list in order,
6 * shows a word alone for a moment, then reveals the gloss. Trying and then
7 * seeing is most of what makes a word stick, and it is the whole of what a
8 * surface can offer when it has no question to ask.
9 *
10 * Nothing here is graded and nothing is written. The quiz is elsewhere.
11 *
12 * It is a pure function of the clock: a render hook is called afresh on every
13 * draw and may be called
14 * twice for one moment (a resize, another plugin's invalidate), so a ticker
15 * that advanced itself per call would jump about. Given the same millisecond
16 * this returns the same frame.
17 */
18
19import { HIDDEN_FRACTION, TICK_MS } from './names'
20import type { Pack, Progress, Word } from './types'
21
22export interface Tick {
23  word: Word | null
24  /** True once the meaning is showing. */
25  revealed: boolean
26  learned: number
27  total: number
28  /** Where this word sits in the frequency list: #1 is the commonest. */
29  rank: number
30}
31
32export function tickAt(pack: Pack, progress: Progress, now: number): Tick {
33  const words = pack.words
34  // `now` is wall-clock in practice, but a non-finite or negative value must
35  // not index off the end of the list.
36  const slot = Number.isFinite(now) ? Math.abs(Math.floor(now / TICK_MS)) : 0
37  const index = words.length ? slot % words.length : 0
38  const word = words[index] ?? null
39
40  return {
41    word,
42    revealed: Number.isFinite(now) && (Math.abs(now) % TICK_MS) / TICK_MS >= HIDDEN_FRACTION,
43    learned: Object.keys(progress.items).length,
44    total: words.length,
45    rank: word ? index + 1 : 0,
46  }
47}
48
49/** A ten-cell progress bar. */
50export function bar(fraction: number, cells: number): string {
51  const safe = Number.isFinite(fraction) ? fraction : 0
52  const filled = Math.max(0, Math.min(cells, Math.round(safe * cells)))
53
54  return '█'.repeat(filled) + '░'.repeat(cells - filled)
55}
56
hooks/ui/tiers.ts 52 lines
1/**
2 * Where you are, in words rather than numbers.
3 *
4 * A count alone — "83 words" — says nothing about whether that is a lot. These
5 * thresholds are the honest shape of learning a language's frequency list: the
6 * first hundred words are most of what you hear in a day, the first thousand is
7 * most of a conversation, and the gaps get wider because the words get rarer.
8 */
9export interface Tier {
10  name: string;
11  /** Words needed to have reached it. */
12  at: number;
13}
14
15export const TIERS: Tier[] = [
16  { name: "just arrived", at: 0 },
17  { name: "first words", at: 10 },
18  { name: "finding your feet", at: 50 },
19  { name: "getting by", at: 100 },
20  { name: "holding a conversation", at: 250 },
21  { name: "comfortable", at: 500 },
22  { name: "most of a day's speech", at: 1000 },
23];
24
25export interface Standing {
26  tier: Tier;
27  /** The one above, or null at the top. */
28  next: Tier | null;
29  /** Words still needed for `next`, or 0 at the top. */
30  toGo: number;
31  /** How far through the current tier, 0..1. */
32  progress: number;
33}
34
35export function standing(learned: number): Standing {
36  const count = Number.isFinite(learned) ? Math.max(0, Math.floor(learned)) : 0;
37  let index = 0;
38  for (let i = 0; i < TIERS.length; i++) {
39    if (count >= (TIERS[i] as Tier).at) index = i;
40  }
41  const tier = TIERS[index] as Tier;
42  const next = index + 1 < TIERS.length ? (TIERS[index + 1] as Tier) : null;
43  if (!next) return { tier, next: null, toGo: 0, progress: 1 };
44  const span = next.at - tier.at;
45  return {
46    tier,
47    next,
48    toGo: Math.max(0, next.at - count),
49    progress: span > 0 ? Math.min(1, (count - tier.at) / span) : 1,
50  };
51}
52
hooks/types.ts 182 lines
1/**
2 * The shapes the band draws and the store holds.
3 *
4 * What claudelingo needed when it was a CLI, less everything it no longer has
5 * to invent for itself. `AgentStatus` is the whole of what went: it used to
6 * reconstruct "is the agent working?" from hook events written to a file and
7 * read back, guess a stale `busy` after fifteen minutes, and tail Codex
8 * transcripts for the edge Codex gave it no hook for. The band is handed
9 * `isWorking` on every draw, so none of that exists.
10 */
11
12/** A single vocabulary entry, materialised from the compact pack format. */
13export interface Word {
14  /** Stable id, e.g. `es:42`. Survives pack edits as long as rank order is stable. */
15  id: string
16  /** 1-based frequency rank within its pack. */
17  rank: number
18  /** The word in the target language. */
19  term: string
20  /** English gloss. May list several senses, comma separated. */
21  gloss: string
22  /** Coarse part of speech, used to pick plausible distractors. */
23  pos: string
24  /** A sentence using the word, and its translation. Absent in older packs. */
25  example?: { text: string; translation: string }
26  /** Optional extra: noun gender, an irregular form, a usage caveat. */
27  note?: string
28}
29
30export interface Pack {
31  /** ISO 639-1 code. */
32  code: string
33  /** Name in the target language, e.g. "Español". */
34  name: string
35  /** Name in English, e.g. "Spanish". */
36  englishName: string
37  words: Word[]
38}
39
40/** The compact form a pack is written in: arrays keep it small and diffable. */
41export interface RawPack {
42  code: string
43  name: string
44  englishName: string
45  /**
46   * `[term, gloss, pos, note?, example?]`, ordered most-frequent first.
47   *
48   * `example` is a short sentence using the word, with its English translation
49   * after a `|`. It is what a cloze card blanks out, and it is optional: packs
50   * written before sentences existed stay valid.
51   */
52  words: Array<
53    | [string, string, string]
54    | [string, string, string, string]
55    | [string, string, string, string, string]
56  >
57}
58
59/** Where an item sits in the teach -> recognise -> produce progression. */
60export type Stage = 'new' | 'learning' | 'review'
61
62/** What we are about to ask. `teach` is a no-fail introduction, not a question. */
63export type CardKind = 'teach' | 'recognize' | 'reverse' | 'recall' | 'cloze'
64
65export interface ItemProgress {
66  /** Word id. */
67  id: string
68  stage: Stage
69  /** Leitner box, 0-5. Drives which card kind is used and the review interval. */
70  box: number
71  /** Index into LEARNING_STEPS while `stage === "learning"`. */
72  step: number
73  /** Epoch ms when this item next becomes eligible. */
74  due: number
75  /** Epoch ms of the last answer, or 0 if never answered. */
76  lastSeen: number
77  seen: number
78  correct: number
79  lapses: number
80}
81
82export interface Progress {
83  version: 1
84  lang: string
85  items: Record<string, ItemProgress>
86  /** Consecutive correct answers, across sessions. */
87  streak: number
88  bestStreak: number
89  totalAnswered: number
90  totalCorrect: number
91  /** `YYYY-MM-DD` (local) -> words introduced that day, for the new-word cap. */
92  introducedByDay: Record<string, number>
93}
94
95export interface Card {
96  kind: CardKind
97  word: Word
98  /** Text shown as the question. */
99  prompt: string
100  /** Choice labels for `recognize` / `reverse` / `cloze`. Empty otherwise. */
101  choices: string[]
102  /** Index into `choices` of the correct answer, or -1 when there are none. */
103  answerIndex: number
104  /** Accepted literal answers for `recall`. */
105  accepted: string[]
106}
107
108export interface Settings {
109  /** ISO 639-1 code of the language being studied, or "" before the first pick. */
110  lang: string
111  /** Max items allowed in `learning` at once. Keeps the queue from flooding. */
112  maxLearning: number
113  /** Max brand-new words introduced per calendar day. */
114  newPerDay: number
115  /** Quiz even when no turn is running. */
116  alwaysOn: boolean
117  /** Ask $.model for memory hooks. */
118  enrich: boolean
119  /** Model used for memory hooks and pack generation. */
120  model: string
121  /** Draw the band at all. `/lingo off` clears it without uninstalling. */
122  on: boolean
123}
124
125/**
126 * What the band is showing, between draws.
127 *
128 * A render hook is called afresh each time and must be able to draw the whole
129 * picture from state it kept itself, so this is the band's memory: the card on
130 * screen, the answer just given, and whether the person asked to practise while
131 * Claude is idle.
132 */
133export interface BandState {
134  /** The card on screen, or null when the band is drilling or caught up. */
135  card: Card | null
136  /**
137   * The answer just given, held for one card so the band can say how it went.
138   *
139   * Cleared by the press that moves on, so "correct" never outlives the card it
140   * describes.
141   */
142  verdict: Verdict | null
143  /** Practise pressed: quiz on regardless of whether a turn is running. */
144  practising: boolean
145  /** The quiz you asked for, running or just finished. */
146  quiz: QuizRun | null
147  /** A memory hook fetched for the card on screen. */
148  hook: string | null
149  /** A fetch in flight, so the band draws a wait rather than firing twice. */
150  fetchingHook: boolean
151  /** What the person has typed into a `recall` card's field. */
152  typed: string
153}
154
155/**
156 * A quiz you asked for: a bounded run of cards, right where you are.
157 *
158 * The band already puts a card up while Claude is working, which is the point
159 * of the thing — but that is the band deciding, and it ends when the turn does.
160 * A run is you deciding: it starts on a press, it keeps asking whether or not a
161 * turn is in flight, it counts, and it stops with a score rather than trailing
162 * off. Those are different enough to be separate state.
163 */
164export interface QuizRun {
165  /** Cards this run will put up. */
166  total: number
167  /** Cards dealt with so far, introductions included. */
168  done: number
169  /** Of the ones that could be got wrong, how many were right. */
170  correct: number
171  /** How many were introductions — shown, not asked, so not scored. */
172  taught: number
173}
174
175export interface Verdict {
176  correct: boolean
177  /** The answer, spelled out, for a card that was got wrong. */
178  answer: string
179  /** The word it was about, so the band can offer a hook for it. */
180  word: Word
181}
182
hooks/views/band.tsx 575 lines
1/* @jsxRuntime classic */
2/* @jsx h */
3/* @jsxFrag Fragment */
4import type {
5  BoxProps,
6  ButtonProps,
7  ElementConstructor,
8  InputProps,
9  RenderElement,
10  RenderNode,
11  TextProps,
12} from 'claude-code'
13
14import {
15  BAND_MIN_COLUMNS,
16  CONTROL_HOTKEYS,
17  KEYS,
18  OWL_MIN_COLUMNS,
19  answerKey,
20  langKey,
21} from '../names'
22import { MASCOT_WIDTH, owl, remark } from '../ui/mascot'
23import type { Mood } from '../ui/mascot'
24import { MAX_BOX } from '../srs'
25import { bar } from '../ticker'
26import type { Tick } from '../ticker'
27import type { Card, ItemProgress, Pack, QuizRun, Verdict } from '../types'
28import type { PackChoice } from '../pack'
29import { buttonOverhead, fit, fitRow } from './row'
30import type { Part } from './row'
31
32/**
33 * The band above the prompt: three rows, and every one of them pressable.
34 *
35 * This is the whole reason claudelingo is a mod. It used to draw these three
36 * rows as a status line, and its README was honest about the ceiling:
37 * "Claude Code draws its own terminal UI and does not host third-party widgets,
38 * so there is no way to put an interactive box inside it", and so "because it
39 * cannot take input, the bottom row is always the controls" — a row naming the
40 * slash commands you must type to reach the thing on screen. `/lingo 2` to
41 * answer a question you are already looking at.
42 *
43 * Here the options are `Button`s. A bare digit in an empty composer presses
44 * one. The third row stops being a list of commands to type and becomes what
45 * the pane's bottom row always was: the keys that work right now.
46 *
47 * Three rules hold the layout together:
48 *
49 * - **Exactly three rows**, always, so the conversation above never jumps as a
50 *   card comes and goes — and measured in *rendered cells*, not in children.
51 *   `Text` wraps by default, so a row of parts that each fit the body width but
52 *   together exceed it is two rows on screen while still being one child in the
53 *   tree. Every composite row goes through `fitRow`, which budgets it as a row.
54 * - **Never taller than `maxRows`.** A band that overflows scrolls in a window
55 *   and — the part that would be fatal — "a bare digit arms none of its
56 *   Buttons' hotkeys". An overflowing band is a band you cannot answer, which
57 *   is why the budgeting above is a correctness rule and not a cosmetic one.
58 * - **The controls row is never given up.** Whatever else is happening, the
59 *   keys that work stay on screen. Errors do not live here at all: they are
60 *   pinned under the prompt with `$.ui.status`, the engine's own affordance for
61 *   exactly that, which costs the band no rows and cannot be truncated away.
62 */
63
64/**
65 * The elements this view draws with, as `$.ui.resolve(e)` hands them over.
66 *
67 * Named rather than picked off `Elements['terminal']` because the same four are
68 * on `desktop`, and the band is worth drawing there too. `mobile` has no
69 * `Input`, so it has no `recall` card — `isDrawable` in `register.ts` keeps the
70 * band off it rather than letting one card kind fail to build a tree.
71 */
72export type BandUi = {
73  Box: ElementConstructor<BoxProps>
74  Text: ElementConstructor<TextProps>
75  Button: ElementConstructor<ButtonProps>
76  Input: ElementConstructor<InputProps>
77}
78
79/** What a press does. The view names them; `register.ts` supplies them. */
80export interface BandActions {
81  answer: (index: number) => void
82  quiz: () => void
83  again: () => void
84  done: () => void
85  spell: (text: string) => void
86  type: (text: string) => void
87  next: () => void
88  skip: () => void
89  explain: () => void
90  practise: () => void
91  chooseLang: (code: string) => void
92}
93
94export interface BandModel {
95  pack: Pack | null
96  tick: Tick | null
97  card: Card | null
98  item: ItemProgress | undefined
99  verdict: Verdict | null
100  hook: string | null
101  fetchingHook: boolean
102  typed: string
103  streak: number
104  /** A turn is running: the band may quiz. */
105  isWorking: boolean
106  /** Quizzing regardless, because practise was pressed or always-on is set. */
107  practising: boolean
108  /** The quiz you asked for: its progress while running, its score when done. */
109  quiz: QuizRun | null
110  /** The languages the picker offers, when nothing has been chosen yet. */
111  choices: readonly PackChoice[] | null
112  /** Can the model be asked for a memory hook? */
113  enrich: boolean
114  /**
115   * The time, as `$.clock.now()` gave it.
116   *
117   * Passed in rather than read here: the clock is an event through the host, so
118   * a test can hold it still, and a render hook that read the wall clock itself
119   * would draw a different owl on two draws of the same moment.
120   */
121  now: number
122}
123
124export interface BandKit {
125  ui: BandUi
126  actions: BandActions
127  /** Cells across the band (`props.bodyColumns`, not the viewport's). */
128  columns: number
129}
130
131/** One control in the third row: a Button, and what pressing it does. */
132interface Control {
133  key: string
134  hotkey: string
135  part: Part
136  onPress: () => void
137}
138
139/**
140 * The owl's mood, from what is on screen.
141 *
142 * The one piece of claudelingo's character that survived the move unchanged,
143 * because it was never about the surface: asleep while Claude is idle, watching while a
144 * card is up, pleased when you get one right, startled when you do not.
145 */
146function moodOf(model: BandModel): Mood {
147  if (model.verdict) return model.verdict.correct ? (model.streak >= 5 ? 'proud' : 'happy') : 'oops'
148  if (model.card) return 'watching'
149  if (model.choices) return 'asking'
150  if (!model.isWorking && !model.practising) return 'asleep'
151
152  return model.tick?.revealed ? 'happy' : 'watching'
153}
154
155/** The three body rows for one draw, before the gutter goes beside them. */
156function rowsOf(kit: BandKit, model: BandModel, columns: number): RenderElement[] {
157  const { Text, Box, Button, Input } = kit.ui
158  const { actions } = kit
159
160  /** A row on its own: one Text, fitted to the width and never wrapped. */
161  const line = (text: string, props: Record<string, unknown> = {}) => (
162    <Text wrap="truncate-end" {...props}>
163      {fit(text, columns)}
164    </Text>
165  )
166
167  const dim = (text: string) => line(text, { dimColor: true })
168
169  const named = (hotkey: string, label: string): Part => ({
170    label,
171    overhead: buttonOverhead(hotkey),
172  })
173
174  /**
175   * The controls row, budgeted as a row.
176   *
177   * `notes` are dim asides — a box level, the commands that do what the band
178   * cannot — and they shrink first. The buttons keep their names whole, because
179   * a control whose label has been eaten is a control nobody can find.
180   */
181  const controls = (buttons: Control[], notes: string[] = []) => {
182    const parts: Part[] = [
183      ...buttons.map((button) => ({ ...button.part, fixed: true })),
184      ...notes.map((label) => ({ label })),
185    ]
186
187    const labels = fitRow(parts, columns)
188
189    return (
190      <Box flexDirection="row" gap={2}>
191        {buttons.map((button, index) => (
192          <Button
193            key={button.key}
194            hotkey={button.hotkey}
195            plain
196            dimColor={button.key !== KEYS.next}
197            label={labels[index] ?? button.part.label}
198            onPress={button.onPress}
199          />
200        ))}
201        {notes.map((_note, index) => (
202          <Text dimColor wrap="truncate-end">
203            {labels[buttons.length + index] ?? ''}
204          </Text>
205        ))}
206      </Box>
207    )
208  }
209
210  const skipButton: Control = {
211    key: KEYS.skip,
212    hotkey: CONTROL_HOTKEYS.skip,
213    part: named(CONTROL_HOTKEYS.skip, 'skip'),
214    onPress: actions.skip,
215  }
216
217  const explainButton: Control = {
218    key: KEYS.explain,
219    hotkey: CONTROL_HOTKEYS.explain,
220    part: named(CONTROL_HOTKEYS.explain, model.fetchingHook ? 'asking…' : 'explain'),
221    onPress: actions.explain,
222  }
223
224  /** Explain is only offered where the model may actually be asked. */
225  const withExplain = (buttons: Control[]): Control[] =>
226    model.enrich ? [...buttons, explainButton] : buttons
227
228  // ── Nothing chosen yet ───────────────────────────────────────────────────
229  //
230  // The CLI asks this in a pane, over three screens. Here it is one row of
231  // buttons, and the answer is one digit.
232  if (model.choices) {
233    const offered = model.choices.slice(0, 4)
234
235    const labels = fitRow(
236      offered.map((choice, index) => ({
237        label: choice.englishName,
238        overhead: buttonOverhead(String(index + 1)),
239      })),
240      columns,
241    )
242
243    return [
244      line('Which language do you want to learn?', { bold: true }),
245      <Box flexDirection="row" gap={2}>
246        {offered.map((choice, index) => (
247          <Button
248            key={langKey(choice.code)}
249            hotkey={String(index + 1)}
250            plain
251            label={labels[index] ?? choice.englishName}
252            onPress={() => actions.chooseLang(choice.code)}
253          />
254        ))}
255      </Box>,
256      dim('press a digit · /lingo lang <code> for any other'),
257    ]
258  }
259
260  // ── A quiz you asked for, finished ───────────────────────────────────────
261  //
262  // After the verdict for its last card, not instead of it: you get told how
263  // that one went, and the score arrives when you move past it.
264  const run = model.quiz
265
266  if (run && run.done >= run.total && !model.verdict) {
267    const asked = run.total - run.taught
268
269    const score =
270      asked > 0
271        ? `${run.correct} of ${asked} right`
272        : `${run.taught} new ${run.taught === 1 ? 'word' : 'words'}`
273
274    const extra = asked > 0 && run.taught > 0 ? `, ${run.taught} new` : ''
275
276    return [
277      line(`Quiz done \u2014 ${score}${extra}`, {
278        bold: true,
279        color: asked === 0 || run.correct === asked ? 'success' : undefined,
280      }),
281      dim(asideFor(asked, run)),
282      controls([
283        {
284          key: KEYS.again,
285          hotkey: CONTROL_HOTKEYS.again,
286          part: named(CONTROL_HOTKEYS.again, 'again'),
287          onPress: actions.again,
288        },
289        {
290          key: KEYS.done,
291          hotkey: CONTROL_HOTKEYS.done,
292          part: named(CONTROL_HOTKEYS.done, 'done'),
293          onPress: actions.done,
294        },
295      ]),
296    ]
297  }
298
299  /**
300   * `2/5` while a run is going, so you can see the end coming.
301   *
302   * It replaces the box level rather than sitting beside it: two ratios in one
303   * row (`2/5  box 1/5`) read as one thing gone wrong rather than two things
304   * going right, and during a run the position in the run is what you want.
305   */
306  const running = run !== null && run.done < run.total
307  const progress = running ? [`${run.done + 1}/${run.total}`] : []
308  const counter = (box: string) => (running ? progress : [box])
309
310  // ── Just answered ────────────────────────────────────────────────────────
311  if (model.verdict) {
312    const { correct, answer, word } = model.verdict
313
314    const said = correct
315      ? `correct — ${word.term} = ${word.gloss}`
316      : `not quite — ${word.term} = ${answer}`
317
318    const aside =
319      model.hook || remark(moodOf(model), model.streak) || word.note || word.example?.text || ''
320
321    return [
322      line(said, { bold: true, color: correct ? 'success' : 'error' }),
323      dim(aside),
324      controls(
325        withExplain([
326          {
327            key: KEYS.next,
328            hotkey: CONTROL_HOTKEYS.next,
329            part: named(CONTROL_HOTKEYS.next, 'next'),
330            onPress: actions.next,
331          },
332        ]),
333        progress,
334      ),
335    ]
336  }
337
338  // ── A card is up ─────────────────────────────────────────────────────────
339  if (model.card && model.pack) {
340    const card = model.card
341    const pack = model.pack
342    const box = model.item ? `box ${model.item.box}/${MAX_BOX}` : 'new'
343
344    if (card.kind === 'teach') {
345      // Term and rank share the first row, and both are budgeted: a generated
346      // pack can hold a term far longer than anything the bundled ones do.
347      const [term = '', rank = ''] = fitRow(
348        [{ label: card.word.term }, { label: `#${card.word.rank}`, fixed: true }],
349        columns,
350      )
351
352      const note = card.word.note ? ` (${card.word.note})` : ''
353
354      return [
355        <Box flexDirection="row" gap={2}>
356          <Text bold color="suggestion" wrap="truncate-end">
357            {term}
358          </Text>
359          <Text dimColor wrap="truncate-end">
360            {rank}
361          </Text>
362        </Box>,
363        line(`${card.word.gloss}  ${card.word.pos}${note}`),
364        controls(
365          withExplain([
366            {
367              key: KEYS.next,
368              hotkey: CONTROL_HOTKEYS.next,
369              part: named(CONTROL_HOTKEYS.next, 'got it'),
370              onPress: actions.next,
371            },
372            skipButton,
373          ]),
374          progress,
375        ),
376      ]
377    }
378
379    if (card.kind === 'recall') {
380      return [
381        line(`Spell the ${pack.englishName} for "${card.prompt}"`, { bold: true }),
382        <Input
383          key={KEYS.spell}
384          placeholder="type it, then Enter"
385          value={model.typed}
386          submitLabel="answer"
387          onInput={actions.type}
388          onSubmit={actions.spell}
389        />,
390        controls(withExplain([skipButton]), counter(box)),
391      ]
392    }
393
394    // recognize / reverse / cloze: four options, one digit each, sharing the
395    // row — so four long glosses shorten together rather than the fourth
396    // falling off the end and taking the band's height with it.
397    const labels = fitRow(
398      card.choices.map((choice, index) => ({
399        label: choice,
400        overhead: buttonOverhead(String(index + 1)),
401      })),
402      columns,
403    )
404
405    return [
406      line(questionRow(card, pack.englishName), { bold: true }),
407      <Box flexDirection="row" gap={2}>
408        {card.choices.map((choice, index) => (
409          <Button
410            key={answerKey(index)}
411            hotkey={String(index + 1)}
412            plain
413            label={labels[index] ?? choice}
414            onPress={() => actions.answer(index)}
415          />
416        ))}
417      </Box>,
418      controls(withExplain([skipButton]), counter(box)),
419    ]
420  }
421
422  // ── Caught up, or standing down ──────────────────────────────────────────
423  //
424  // The ticker: a word alone, a moment to
425  // reach for it, then the meaning. Nothing here is graded and nothing written.
426  // The idle band's one button starts a quiz. It used to say "practise" and
427  // turn on an endless mode, which is a worse thing to offer: it never says how
428  // long it will go on for and it never tells you how you did. `/lingo practise`
429  // still does that for anyone who wants it.
430  const quizButton: Control = {
431    key: KEYS.quiz,
432    hotkey: CONTROL_HOTKEYS.quiz,
433    part: named(CONTROL_HOTKEYS.quiz, 'quiz'),
434    onPress: actions.quiz,
435  }
436
437  const tick = model.tick
438
439  if (!tick?.word) {
440    return [
441      line(model.pack ? `${model.pack.englishName} · all caught up` : 'claudelingo'),
442      dim(`${bar(1, 10)}  nothing due`),
443      controls([quizButton], ['/lingo stats']),
444    ]
445  }
446
447  const counts = `${tick.learned}/${tick.total}${model.streak > 0 ? ` · streak ${model.streak}` : ''}`
448
449  // `«term» = gloss` is one row of three parts. The separator is fixed and
450  // carries its own cells, so the two words share exactly what is left.
451  const [term = '', , gloss = ''] = fitRow(
452    [
453      { label: `«${tick.word.term}»` },
454      { label: ' = ', fixed: true, overhead: -4 },
455      { label: tick.revealed ? tick.word.gloss : '?' },
456    ],
457    columns,
458  )
459
460  return [
461    <Box flexDirection="row">
462      <Text color="suggestion" wrap="truncate-end">
463        {term}
464      </Text>
465      <Text dimColor>{' = '}</Text>
466      <Text bold={tick.revealed} dimColor={!tick.revealed} wrap="truncate-end">
467        {gloss}
468      </Text>
469    </Box>,
470    dim(`${bar(tick.total ? tick.learned / tick.total : 0, 10)}  ${counts} · #${tick.rank}`),
471    controls([quizButton], ['/lingo stats', '/lingo lang']),
472  ]
473}
474
475/**
476 * Does this row hold something a person can press?
477 *
478 * A render tree is plain data, so this is a walk rather than a flag every
479 * branch has to remember to carry — one added later is covered without being
480 * told to be. `children` sits beside `props` on an element, not inside it
481 * (`StyledElement`); reading it from `props` finds nothing and quietly reports
482 * every row as unpressable.
483 */
484function isPressable(node: RenderNode): boolean {
485  if (typeof node === 'string') return false
486  if (node.type === 'Button' || node.type === 'Input') return true
487  if (!('children' in node) || node.children === undefined) return false
488
489  return node.children.some(isPressable)
490}
491
492/** What to say under a score, which depends on what kind of run it was. */
493function asideFor(asked: number, run: QuizRun): string {
494  if (asked === 0) return 'shown, not tested — you will be asked about them shortly.'
495  if (run.correct === asked) return 'every one. The next ones will be harder.'
496
497  return 'wrong ones come back sooner; right ones come back later.'
498}
499
500/** How a card is asked, in words. `teach` has its own layout above. */
501function questionRow(card: Card, englishName: string): string {
502  switch (card.kind) {
503    case 'recognize':
504      return `What does "${card.prompt}" mean?`
505    case 'reverse':
506      return `How do you say "${card.prompt}" in ${englishName}?`
507    case 'cloze':
508      return `Fill the gap:  ${card.prompt}`
509    default:
510      return card.prompt
511  }
512}
513
514/**
515 * The band, drawn.
516 *
517 * Below `BAND_MIN_COLUMNS` three rows cannot say anything useful, so it gives
518 * up the layout and keeps the interaction: the controls row, which is the one
519 * row that can still be pressed.
520 */
521export function bandView(kit: BandKit, model: BandModel): RenderElement {
522  const { Box, Text } = kit.ui
523  const gutter = kit.columns >= OWL_MIN_COLUMNS
524  const columns = Math.max(1, kit.columns - (gutter ? MASCOT_WIDTH + 2 : 0))
525
526  const body = rowsOf(kit, model, columns)
527
528  if (kit.columns < BAND_MIN_COLUMNS) {
529    // One row, and it must be one that can be pressed: a question nobody can
530    // answer is worth less than the keys that answer it. Usually that is the
531    // controls, but on the picker it is the languages themselves — keeping the
532    // hint under them would leave a first-run user with nothing to press.
533    const pressable = body.filter(isPressable)
534
535    return <Box flexDirection="column">{(pressable.length > 0 ? pressable : body).slice(-1)}</Box>
536  }
537
538  if (!gutter) {
539    return <Box flexDirection="column">{body}</Box>
540  }
541
542  const face = owl(moodOf(model), Math.floor(model.now / 2000))
543
544  return (
545    <Box flexDirection="row" gap={2}>
546      <Box flexDirection="column">
547        {face.map((row) => (
548          <Text dimColor>{row}</Text>
549        ))}
550      </Box>
551      <Box flexDirection="column" flexGrow={1}>
552        {body}
553      </Box>
554    </Box>
555  )
556}
557
558/**
559 * The band under whatever else the engine and other plugins put there.
560 *
561 * Under, not over: the engine's own notices are about the session and this is
562 * about vocabulary, so the thing you might need to act on stays nearest the
563 * conversation and the quiz sits closest to the prompt you answer it from.
564 */
565export function withBand(ui: BandUi, beneath: RenderElement, band: RenderElement): RenderElement {
566  const { Box } = ui
567
568  return (
569    <Box flexDirection="column">
570      {beneath}
571      {band}
572    </Box>
573  )
574}
575