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.

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.
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.
~/.claude/skills/claudelingo, where Claude Code loads it as a plugin. Run the same command again to update.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.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.
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.
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.
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
Every key is a digit, because a digit works from an empty prompt without clicking anything first. One keystroke per answer.
| Key | |
|---|---|
1–4 | answer a multiple-choice card |
1 | acknowledge a new word, move past a result, or start a quiz from the idle band |
5 | skip a card. No penalty; it comes back in ten minutes |
6 | explain: ask Claude for a memory hook for this word |
type + Enter | spell a word out, on cards that ask you to |
/lingo quiz [n] | a quiz now: five cards, or n (up to 50) |
/lingo stats | where you stand: words met, mastered, streak, accuracy |
/lingo lang | the languages you have; /lingo lang fr switches |
/lingo pack Portuguese pt | generate a pack for a language that isn't built in |
/lingo practise | keep asking while Claude is idle, with no fixed length |
/lingo off / /lingo on | hide the band, or bring it back |
/lingo reset | erase 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.
Every word climbs the same ladder, and only moves up when you get it right:
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.
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.
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.
Nothing appears above the prompt.
claude --version is 2.1.271 or newer. The stable release channel can lag behind the version this needs./lingo stats. If the command isn't found, the plugin didn't load. Re-run the installer and read its output.--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.
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.
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.ts | the mod's entry point: every hook it registers |
hooks/views/ | the band. row.ts fits each row into the terminal width |
hooks/srs.ts | the spaced-repetition scheduler |
hooks/deck.ts | reading and writing decks, settings and packs |
hooks/migrate.ts | importing decks from the older claudelingo |
hooks/packgen.ts | generating a word pack for a new language |
hooks/packs/ | the built-in Spanish, French and Italian word lists |
install.sh | the installer |
types/claude-code.d.ts | the 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:
A useful habit: when you add a test, break the code it covers and check the test fails.
MIT
hooks/register.ts 1100 lines1import 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
1100hooks/deck.ts 284 lines1/**
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}
284hooks/enrich.ts 115 lines1/**
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}
115hooks/migrate.ts 314 lines1/**
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}
314hooks/names.ts 127 lines1/**
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
127hooks/pack.ts 97 lines1/**
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}))
97hooks/packgen.ts 194 lines1import { 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}
194hooks/srs.ts 357 lines1import { 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}
357hooks/ticker.ts 56 lines1/**
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}
56hooks/ui/tiers.ts 52 lines1/**
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}
52hooks/types.ts 182 lines1/**
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}
182hooks/views/band.tsx 575 lines1/* @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