Learn a language live in your terminal: a lesson split that opens while Claude works, conversation, role-play and reading with a tutor.

A Claude Code mod that teaches you a language live, right in your terminal: a lesson pane, micro-lessons while Claude works, and a tutor that corrects you the way you prefer.
Status: early, private. It works end to end in the terminal but is still being tried out. Design and what is built:
docs/decisions.md.
1: open lesson button above the prompt opens it instead./lingo opens or closes it by hand, /lingo setup runs the guided setup (languages, level, interests, split width, tutor model, theme), /lingo theme <name> switches colors.Mods run outside the sandbox and can see your prompts and tool calls. What lingo-pane does with that:
🔊 listen: the phrase on screen, opened in Google Translate in your browser.Nothing else leaves your machine.
hooks/register.tsx 1591 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import { DEMO_CARDS, DEMO_LESSON_COUNT, DEMO_PACK_TITLE } from '../src/content/demo-english-a1'
5import type { LessonQueue } from '../src/lesson'
6import {
7 IDLE_PRACTICE,
8 PROGRESS_STORE_KEY,
9 advanceLesson,
10 afterAnswer,
11 buildQueue,
12 isCorrectAnswer,
13 isFinished,
14 leaksAnswer,
15 nextCard,
16 parseProgress,
17 recordAttempt,
18 reveal,
19 startPractice,
20 tutorPrompt,
21} from '../src/lesson'
22import { socratic } from '../src/correction/socratic'
23import { badThemeText, parseLingoArgs, unknownSubcommandText } from '../src/command'
24import { LABELS } from '../src/labels'
25import { THEMES, themeByName } from '../src/themes'
26import { microLesson, spinnerSuffix } from '../src/microcards'
27import {
28 CLOSED_WIZARD,
29 DEFAULT_SPLIT_SHARE,
30 LEVELS,
31 SETUP_STORE_KEY,
32 SPLIT_SHARES,
33 STEPS,
34 STRATEGY_AXES,
35 TUTOR_MODELS,
36 buildSetup,
37 draftFromConfig,
38 draftFromSetup,
39 parseInterests,
40 parseSetup,
41 resolveSetup,
42 stepProblem,
43 withTheme,
44 wizardTransition,
45} from '../src/setup'
46import type { WizardEvent } from '../src/setup'
47import {
48 CLOSE_DELAY_MS,
49 INITIAL_WAIT,
50 SHOW_DELAY_MS,
51 closeOnTurnComplete,
52 isClaudeWorking,
53 isKeptOpen,
54 isLessonVisible,
55 isOfferVisible,
56 transition,
57} from '../src/wait-machine'
58import type { WaitEvent } from '../src/wait-machine'
59import { canSeatSplit, claudeStateLine, inlineRows, splitColumns } from '../src/split'
60import type { Viewport } from '../src/split'
61import type { LingoActivity, LingoLesson, LingoPractice, LingoReading, LingoSetup, LingoTutorState, LingoUnit, LingoUnitSummary } from '../types'
62import {
63 ACTIVITIES,
64 ACTIVITY_STORE_KEY,
65 activityLabel,
66 levelHint,
67 parseActivityLog,
68 recordUnit,
69 suggestActivity,
70} from '../src/activities'
71import {
72 IDLE_LESSON,
73 UNIT_REPLIES,
74 askedForHelp,
75 isAwaitingLearner,
76 markedParts,
77 newUnit,
78 summaryText,
79 withHelp,
80 withLearnerLine,
81 withTutorTurn,
82 withoutTutor,
83} from '../src/conversation'
84import {
85 TUTOR_MAX_TOKENS,
86 TUTOR_TIMEOUT_MS,
87 helpPrompt,
88 openingPrompt,
89 parseTutorTurn,
90 replyPrompt,
91 tutorSystem,
92} from '../src/tutor'
93import type { TutorContext } from '../src/tutor'
94import { listenUrl } from '../src/listen'
95import { MAX_ANSWER_EXCERPT, MAX_PROMPT_EXCERPT, NO_CONTEXT, contextForTutor, excerpt } from '../src/context'
96import { generatedOpeningPrompt, parseScenario, pickScenario } from '../src/roleplay'
97import {
98 READING_MAX_TOKENS,
99 answerReading,
100 parseReading,
101 readingPrompt,
102 readingSystem,
103 showReadingAnswer,
104} from '../src/reading'
105import {
106 MAX_WOVEN,
107 MISTAKES_STORE_KEY,
108 addCorrections,
109 dueMistakes,
110 isFormCard,
111 mistakeAsCard,
112 parseMistakes,
113 pickForTurn,
114 recordMistakeReview,
115 spinnerLine,
116 wovenOutcome,
117} from '../src/mistakes'
118
119const PANE = 'lingo'
120const PANE_TITLE = 'lingo-pane'
121
122// Waiting-state trigger and the split's standing, kept in `$.state` so the
123// Spinner, the band and the pane redraw when it changes (contract: types/index.d.ts).
124const wait = atom({ plugin: 'lingo-pane', key: 'wait' } as const, INITIAL_WAIT)
125
126// Guided setup (contract: types/index.d.ts). The saved setup lives in `$.store`
127// (the source of truth); `setupCache` mirrors it so the band and the Spinner
128// redraw when it changes, and `$.state` resets on /clear, hence the fallback to
129// the store whenever the mirror is not loaded.
130const setupWizard = atom({ plugin: 'lingo-pane', key: 'setupWizard' } as const, CLOSED_WIZARD)
131const setupCache = atom({ plugin: 'lingo-pane', key: 'setupCache' } as const, { isLoaded: false, setup: null })
132const setupBand = atom({ plugin: 'lingo-pane', key: 'setupBand' } as const, { isHidden: false })
133
134// The practice session in the pane (contract: types/index.d.ts). Progress lives
135// in `$.store` under `progress`; this is only where the learner is right now.
136const practice = atom({ plugin: 'lingo-pane', key: 'practice' } as const, IDLE_PRACTICE)
137
138// The lesson in the split: the activity on screen and its micro-unit (contract: types/index.d.ts).
139const lesson = atom({ plugin: 'lingo-pane', key: 'lesson' } as const, IDLE_LESSON)
140
141// The Spinner's micro-card for the turn, worked out once when the turn's delay ends.
142const spinnerCard = atom({ plugin: 'lingo-pane', key: 'spinnerCard' } as const, { turnId: null, text: null })
143
144// The contextual mode's material (opt-in): the last prompt and Claude's last reply, cut short and redacted.
145const workContext = atom({ plugin: 'lingo-pane', key: 'workContext' } as const, NO_CONTEXT)
146
147// What the `switch ▸` menu offers.
148const AVAILABLE_ACTIVITIES: readonly LingoActivity[] = ['conversation', 'roleplay', 'reading', 'review']
149
150// The review's tutor hint (the flashcard practice kept from the first lesson).
151const HINT_TIMEOUT_MS = 20000
152
153// The setup in force: the mirror once loaded, the store otherwise.
154async function currentSetup($: EngineInterface): Promise<LingoSetup | null> {
155 const cache = await read($, setupCache)
156 return resolveSetup(cache, cache.isLoaded ? undefined : await $.store.get(SETUP_STORE_KEY))
157}
158
159function dispatchWait($: EngineInterface, event: WaitEvent) {
160 return update($, wait, s => transition(s, event))
161}
162
163async function closeSplit($: EngineInterface) {
164 await $.ui.close({ id: PANE })
165 await dispatchWait($, { type: 'pane-closed' })
166}
167
168// Any key typed in the split's field or any press there: the split is in use.
169async function touch($: EngineInterface) {
170 const state = await read($, wait)
171 if (state.pane === 'open' && !state.isTouched) await dispatchWait($, { type: 'touched' })
172}
173
174const nowIso = async ($: EngineInterface) => new Date(await $.clock.now()).toISOString()
175
176// Moving the ring is never worth failing a press over: a pane drawn without the keyboard denies it.
177async function focusKey($: EngineInterface, key: string) {
178 await $.ui.focus({ requestId: PANE, key }).catch(() => undefined)
179}
180
181// What the tutor may read of the learner's session: only with the opt-in.
182async function sessionMaterial($: EngineInterface, setup: LingoSetup): Promise<string | null> {
183 return setup.isContextual ? contextForTutor(await read($, workContext)) : null
184}
185
186// What the tutor knows about this learner and this unit.
187function tutorContext(setup: LingoSetup, unit: LingoUnit | null, material: string | null = null): TutorContext {
188 return {
189 targetLanguage: setup.targetLanguage,
190 nativeLanguage: setup.nativeLanguage,
191 level: setup.level,
192 interests: setup.interests,
193 activity: unit?.activity ?? 'conversation',
194 scenario: unit?.scenario ?? null,
195 weave: unit?.woven ?? [],
196 workContext: material,
197 }
198}
199
200// One call on the learner's own plan, with the model chosen in the setup.
201async function callTutor($: EngineInterface, setup: LingoSetup, system: string, prompt: string, maxTokens = TUTOR_MAX_TOKENS) {
202 return $.model.complete({
203 model: setup.tutorModel,
204 system,
205 prompt,
206 effort: 'low',
207 maxTokens,
208 timeoutMs: TUTOR_TIMEOUT_MS,
209 })
210}
211
212// A change to the unit on screen, only if it is still the same unit.
213async function updateUnit($: EngineInterface, id: string, change: (unit: LingoUnit) => LingoUnit) {
214 await update($, lesson, l => (l.unit === null || l.unit.id !== id ? l : { ...l, unit: change(l.unit) }))
215}
216
217// A new micro-unit of conversation or role-play: the tutor opens it. A role-play
218// takes the next scenario of the learner's level, or one the tutor makes up from
219// their interests in the same call.
220async function openTalkUnit($: EngineInterface, setup: LingoSetup, activity: LingoUnit['activity']) {
221 // The tutor weaves in a couple of the mistakes due at this unit.
222 const log = parseActivityLog(await $.store.get(ACTIVITY_STORE_KEY))
223 const due = dueMistakes(parseMistakes(await $.store.get(MISTAKES_STORE_KEY)), log.units + 1).filter(isFormCard)
224 const woven = due.slice(0, MAX_WOVEN).map(c => ({ id: c.id, wrong: c.wrong, right: c.right }))
225 const pick = activity === 'roleplay' ? pickScenario(setup.level, setup.interests, log.done.roleplay ?? 0) : null
226 const scenario = pick?.kind === 'fixed' ? pick.scenario : null
227 const unit = newUnit(`${activity}-${await $.clock.now()}`, activity, scenario, woven)
228 await update($, lesson, l => ({ ...l, activity, unit, isMenuOpen: false }))
229 await focusKey($, 'reply')
230 const ctx = tutorContext(setup, unit, await sessionMaterial($, setup))
231 const isMadeUp = pick?.kind === 'generated'
232 const reply = await callTutor($, setup, tutorSystem(ctx), isMadeUp ? generatedOpeningPrompt(ctx) : openingPrompt(ctx))
233 const turn = reply.isAnswered ? parseTutorTurn(reply.text) : null
234 const madeUp = isMadeUp && reply.isAnswered ? parseScenario(reply.text) : null
235 await updateUnit($, unit.id, u => {
236 if (u.pending !== 'opening') return u
237 const opened = turn === null ? withoutTutor(u, LABELS.tutorSilentOpening) : withTutorTurn(u, turn)
238 return madeUp === null ? opened : { ...opened, scenario: madeUp }
239 })
240}
241
242// A reading: the tutor writes a short text at the learner's level and 2-3 questions.
243async function openReading($: EngineInterface, setup: LingoSetup) {
244 const id = `reading-${await $.clock.now()}`
245 const waiting: LingoReading = {
246 id,
247 title: '',
248 text: '',
249 questions: [],
250 index: 0,
251 misses: 0,
252 isPending: true,
253 notice: null,
254 summary: null,
255 }
256 await update($, lesson, (l): LingoLesson => ({ ...l, activity: 'reading', reading: waiting, isMenuOpen: false }))
257 const ctx = tutorContext(setup, null, await sessionMaterial($, setup))
258 const reply = await callTutor($, setup, readingSystem(ctx), readingPrompt(ctx), READING_MAX_TOKENS)
259 const written = reply.isAnswered ? parseReading(id, reply.text) : null
260 await update($, lesson, (l): LingoLesson =>
261 l.reading === null || l.reading.id !== id
262 ? l
263 : { ...l, reading: written ?? { ...l.reading, isPending: false, notice: LABELS.readingSilent } },
264 )
265 if (written !== null) await focusKey($, 'reading-answer')
266}
267
268// The learner's answer to the question on screen; an empty Enter shows it and makes it a card.
269async function answerQuestion($: EngineInterface, value: string) {
270 const before = (await read($, lesson)).reading
271 const question = before?.questions[before.index]
272 if (before === null || question === undefined || before.summary !== null || before.isPending) return
273 const isShown = value.trim() === ''
274 const after = isShown ? showReadingAnswer(before) : answerReading(before, value)
275 await update($, lesson, (l): LingoLesson => (l.reading?.id === before.id ? { ...l, reading: after } : l))
276 if (isShown) {
277 const unitNumber = parseActivityLog(await $.store.get(ACTIVITY_STORE_KEY)).units + 1
278 const card = { wrong: '', right: question.answer, note: before.title, sentence: question.question }
279 const mistakes = parseMistakes(await $.store.get(MISTAKES_STORE_KEY))
280 await $.store.set(MISTAKES_STORE_KEY, addCorrections(mistakes, [card], unitNumber, await nowIso($)))
281 }
282 if (after.summary !== null) await finishActivity($, 'reading', after.summary)
283}
284
285// The review: the built-in pack's Pimsleur queue, started at once (zero clicks).
286async function startReview($: EngineInterface) {
287 await update($, lesson, (l): LingoLesson => ({ ...l, activity: 'review', isMenuOpen: false }))
288 const session = await read($, practice)
289 if (session.lesson !== null && session.status !== 'done') return
290 const queue = await reviewQueue($)
291 if (queue.recall.length + queue.fresh.length === 0) return
292 const latest = parseProgress(await $.store.get(PROGRESS_STORE_KEY))
293 await update($, practice, () => startPractice(queue, latest.currentLesson))
294 await focusKey($, 'answer-0')
295}
296
297// The review: the mistakes due at the next unit first, then the built-in pack's Pimsleur lesson.
298async function reviewQueue($: EngineInterface): Promise<LessonQueue> {
299 const log = parseActivityLog(await $.store.get(ACTIVITY_STORE_KEY))
300 const due = dueMistakes(parseMistakes(await $.store.get(MISTAKES_STORE_KEY)), log.units + 1).map(c => c.id)
301 const latest = parseProgress(await $.store.get(PROGRESS_STORE_KEY))
302 const pack = isFinished(latest, DEMO_LESSON_COUNT)
303 ? { recall: [], fresh: [] }
304 : buildQueue(DEMO_CARDS, latest, new Date(await $.clock.now()))
305 return { recall: [...due, ...pack.recall], fresh: pack.fresh }
306}
307
308async function startActivity($: EngineInterface, setup: LingoSetup, activity: LingoActivity) {
309 if (activity === 'review') return startReview($)
310 if (activity === 'reading') return openReading($, setup)
311 // A talk unit left half done for another one closes here: its mistakes are kept.
312 const left = (await read($, lesson)).unit
313 if (left !== null && left.summary === null) {
314 await saveUnitMistakes($, left, parseActivityLog(await $.store.get(ACTIVITY_STORE_KEY)).units + 1)
315 }
316 return openTalkUnit($, setup, activity)
317}
318
319// The split opens straight into the unfinished activity, or into the suggested one.
320async function ensureLesson($: EngineInterface, setup: LingoSetup) {
321 const current = await read($, lesson)
322 if (current.activity === 'review') {
323 const session = await read($, practice)
324 if (session.lesson !== null && session.status !== 'done') return
325 } else if (current.activity === 'reading') {
326 const reading = current.reading
327 if (reading !== null && reading.summary === null && !(reading.isPending && reading.text === '')) return
328 } else if (current.unit !== null && current.unit.summary === null) {
329 // A call lost with a reload leaves the unit waiting forever: give it back.
330 if (current.unit.pending !== null && current.unit.lines.length === 0) {
331 return openTalkUnit($, setup, current.unit.activity)
332 }
333 return
334 }
335 const log = parseActivityLog(await $.store.get(ACTIVITY_STORE_KEY))
336 return startActivity($, setup, suggestActivity(log, AVAILABLE_ACTIVITIES))
337}
338
339// A unit's mistakes become cards (numbered by the unit), and the woven ones count as reviewed.
340async function saveUnitMistakes($: EngineInterface, unit: LingoUnit, unitNumber: number) {
341 if (unit.corrections.length === 0 && unit.woven.length === 0) return
342 const at = await nowIso($)
343 // Re-read before writing: the store is shared between sessions.
344 let mistakes = parseMistakes(await $.store.get(MISTAKES_STORE_KEY))
345 for (const card of unit.woven) {
346 const outcome = wovenOutcome(unit.lines, card)
347 if (outcome !== null) mistakes = recordMistakeReview(mistakes, card.id, at, outcome)
348 }
349 await $.store.set(MISTAKES_STORE_KEY, addCorrections(mistakes, unit.corrections, unitNumber, at))
350}
351
352// The summary of the activity on screen when its unit is over; null while one runs.
353function finishedSummary(current: LingoLesson): { activity: LingoActivity; summary: LingoUnitSummary } | null {
354 if (current.activity === 'reading') {
355 return current.reading?.summary == null ? null : { activity: 'reading', summary: current.reading.summary }
356 }
357 if (current.activity === 'review' || current.unit === null || current.unit.summary === null) return null
358 return { activity: current.unit.activity, summary: current.unit.summary }
359}
360
361// A finished unit of any activity: logged; with Claude idle, a split in use
362// closes and says how it went, else the next activity is one press away.
363async function finishActivity($: EngineInterface, activity: LingoActivity, summary: LingoUnitSummary) {
364 const log = recordUnit(parseActivityLog(await $.store.get(ACTIVITY_STORE_KEY)), activity, summary, await nowIso($))
365 await $.store.set(ACTIVITY_STORE_KEY, log)
366 const state = await read($, wait)
367 if (!isClaudeWorking(state) && isKeptOpen(state)) {
368 await closeSplit($)
369 $.ui.toast(`${LABELS.modName}: ${summaryText(summary, activity)}`)
370 return log
371 }
372 await focusKey($, 'next-unit')
373 return log
374}
375
376// A finished talk unit: its mistakes become cards, numbered by it.
377async function finishUnit($: EngineInterface, unit: LingoUnit) {
378 if (unit.summary === null) return
379 const units = parseActivityLog(await $.store.get(ACTIVITY_STORE_KEY)).units + 1
380 await saveUnitMistakes($, unit, units)
381 await finishActivity($, unit.activity, unit.summary)
382}
383
384// The learner's line: shown at once, then the tutor's answer.
385async function sendReply($: EngineInterface, setup: LingoSetup, text: string) {
386 const before = (await read($, lesson)).unit
387 if (before === null) return
388 const asked = withLearnerLine(before, text)
389 if (asked === before) return
390 await updateUnit($, before.id, () => asked)
391 const ctx = tutorContext(setup, asked, await sessionMaterial($, setup))
392 const reply = await callTutor($, setup, tutorSystem(ctx), replyPrompt(ctx, asked, asked.replies >= UNIT_REPLIES))
393 const turn = reply.isAnswered ? parseTutorTurn(reply.text) : null
394 await updateUnit($, before.id, u =>
395 u.pending !== 'reply' ? u : turn === null ? withoutTutor(u, LABELS.tutorSilentReply) : withTutorTurn(u, turn),
396 )
397 const after = (await read($, lesson)).unit
398 if (after !== null && after.id === before.id && after.summary !== null) await finishUnit($, after)
399}
400
401// An empty Enter: a scaffold, never the answer.
402async function sendHelp($: EngineInterface, setup: LingoSetup) {
403 const before = (await read($, lesson)).unit
404 if (before === null) return
405 const asked = askedForHelp(before)
406 if (asked === before) return
407 await updateUnit($, before.id, () => asked)
408 const ctx = tutorContext(setup, asked, await sessionMaterial($, setup))
409 const reply = await callTutor($, setup, tutorSystem(ctx), helpPrompt(ctx, asked))
410 const text = reply.isAnswered ? reply.text.trim() : ''
411 await updateUnit($, before.id, u => (u.pending !== 'help' ? u : text === '' ? withoutTutor(u, LABELS.tutorSilentHelp) : withHelp(u, text)))
412}
413
414export const register: Register = (on, options) => {
415 const target = String(options.targetLanguage)
416 const native = String(options.nativeLanguage)
417
418 // Timer handles live in module variables: a hot reload drops them together
419 // with the timers themselves (the engine cancels pending waits on reload).
420 let delayTimer: { cancel: () => void } | null = null
421 let closeTimer: { cancel: () => void } | null = null
422 // Redraws the split once a second while Claude works, for the elapsed time.
423 let ticker: { cancel: () => void } | null = null
424 // Bumped on Enter in a wizard field so the field is drawn again with its text.
425 let inputRev = 0
426 // The last size a render event reported: `turn.start` carries none, and a
427 // render hook may not write `$.state`. The terminal's wins over a remote one.
428 let terminalViewport: Viewport | null = null
429 let otherViewport: Viewport | null = null
430 const noteViewport = (e: { surface: string; viewport?: Viewport }) => {
431 if (e.viewport === undefined) return
432 if (e.surface === 'terminal') terminalViewport = e.viewport
433 else otherViewport = e.viewport
434 }
435 const viewport = () => terminalViewport ?? otherViewport
436
437 // What the split asks for: the keyboard (granted only over an empty prompt),
438 // its share of the width when docked, about 40 % of the height inline.
439 const openArgs = (setup: LingoSetup | null, terminalColumns?: number) => {
440 const seen = viewport()
441 const columns = terminalColumns ?? seen?.columns
442 return {
443 id: PANE,
444 title: PANE_TITLE,
445 focus: true as const,
446 ...(columns === undefined ? {} : { columns: splitColumns(columns, setup?.splitShare ?? DEFAULT_SPLIT_SHARE) }),
447 ...(seen === null ? {} : { rows: inlineRows(seen.rows) }),
448 }
449 }
450
451 on('session.start', async ($, e, next) => {
452 await $.command.register({
453 name: 'lingo',
454 description: 'Open the lesson pane; "/lingo setup" runs the guided setup; "/lingo theme <name>" switches colors',
455 argumentHint: '[setup | theme <name>]',
456 // Typed while Claude works it runs at once: that is when the split is useful.
457 immediate: true,
458 })
459
460 // Load what is saved into the mirror; a missing or unreadable value is pending.
461 const setup = parseSetup(await $.store.get(SETUP_STORE_KEY))
462 await update($, setupCache, () => ({ isLoaded: true, setup }))
463 if (setup === null) $.ui.toast(LABELS.setupPendingToast)
464
465 return next(e)
466 })
467
468 // /clear, /resume and /branch start a new session without `session.start`
469 // (the process goes on) and reset `$.state`: reload the mirror from the store
470 // and remind again, since the band's "Later" was forgotten with the state.
471 on('classic.SessionStart', async ($, e, next) => {
472 if (e.source === 'startup' || e.source === 'compact') return next(e)
473
474 const setup = parseSetup(await $.store.get(SETUP_STORE_KEY))
475 await update($, setupCache, () => ({ isLoaded: true, setup }))
476 if (setup === null) $.ui.toast(LABELS.setupPendingToast)
477
478 return next(e)
479 })
480
481 on('command.run', { command: 'lingo' }, async ($, e) => {
482 const parsed = parseLingoArgs(e.args)
483 if (parsed.kind === 'unknown') return { text: unknownSubcommandText(parsed.name) }
484 if (parsed.kind === 'bad-theme') return { text: badThemeText(parsed.name) }
485
486 const setup = await currentSetup($)
487
488 if (parsed.kind === 'theme') {
489 // Re-read the store before writing: another session may have saved since.
490 const latest = parseSetup(await $.store.get(SETUP_STORE_KEY))
491 if (latest === null) return { text: LABELS.themeNeedsSetup }
492 const changed = withTheme(latest, parsed.theme)
493 await $.store.set(SETUP_STORE_KEY, changed)
494 await update($, setupCache, () => ({ isLoaded: true, setup: changed }))
495 return { text: LABELS.themeSet(themeByName(parsed.theme).label) }
496 }
497
498 if (parsed.kind === 'setup') {
499 // Always the wizard, never a toggle: re-running it resumes where it was.
500 await update($, setupWizard, s => wizardTransition(s, { type: 'open' }, draftFromConfig(native, target)))
501 const opened = await $.ui.open(openArgs(setup, e.presentation?.columns))
502 if (opened.isPlaced) await dispatchWait($, { type: 'pane-placed', opener: 'person' })
503 return {}
504 }
505
506 // A second /lingo closes a pane that is up and drawn (the person's toggle).
507 const panes = await $.ui.panes()
508 if (panes.some(p => p.id === PANE && p.isPlaced)) {
509 await closeSplit($)
510 return {}
511 }
512
513 // Asked by the person, so it is placed at any width (docked from 110 columns in fullscreen).
514 const opened = await $.ui.open(openArgs(setup, e.presentation?.columns))
515 if (opened.isPlaced) await dispatchWait($, { type: 'pane-placed', opener: 'person' })
516 // Straight into the unfinished activity or the suggested one.
517 if (setup !== null) await ensureLesson($, setup)
518
519 return {}
520 })
521
522 // --- Busy detection -------------------------------------------------------
523
524 // `turn.start` is raised for the main loop only (a subagent's run raises none).
525 on('turn.start', async ($, e, next) => {
526 delayTimer?.cancel()
527 closeTimer?.cancel()
528 await dispatchWait($, { type: 'turn-start', turnId: e.turnId, at: await $.clock.now() })
529
530 ticker?.cancel()
531 ticker = $.clock.every(1000, async () => {
532 if ((await read($, wait)).pane === 'open') $.ui.invalidate('ui.render')
533 })
534
535 const turnId = e.turnId
536 delayTimer = $.clock.after(SHOW_DELAY_MS, async () => {
537 await dispatchWait($, { type: 'delay-elapsed', turnId })
538
539 // The Spinner's card for this turn: one of the learner's own mistakes, due ones first.
540 const setup = await currentSetup($)
541 if (setup !== null) {
542 const log = parseActivityLog(await $.store.get(ACTIVITY_STORE_KEY))
543 const mistake = pickForTurn(parseMistakes(await $.store.get(MISTAKES_STORE_KEY)), log.units + 1, turnId)
544 await update($, spinnerCard, () => ({ turnId, text: mistake === null ? null : spinnerLine(mistake) }))
545 }
546
547 // Nothing opens while the setup is pending, once retired for a permission
548 // ask, or over a split that is already open.
549 const state = await read($, wait)
550 if (setup === null || state.phase !== 'showing' || state.pane !== 'none') return
551 if ((await $.ui.panes()).some(p => p.id === PANE)) return
552
553 // Only a docked split opens by itself: in fullscreen, from 110 columns.
554 const seen = viewport()
555 if (seen === null || !canSeatSplit(seen)) {
556 await dispatchWait($, { type: 'pane-not-placed' })
557 return
558 }
559
560 // Unasked, the engine seats it from 144 columns (110 once the person opened it).
561 const opened = await $.ui.open(openArgs(setup))
562 if (opened.isPlaced) {
563 await dispatchWait($, { type: 'pane-placed', opener: 'mod' })
564 await ensureLesson($, setup)
565 } else {
566 // It would sit undrawn: drop it and offer a button above the prompt.
567 await $.ui.close({ id: PANE })
568 await dispatchWait($, { type: 'pane-not-placed' })
569 }
570 })
571
572 return next(e)
573 })
574
575 on('turn.complete', async ($, e, next) => {
576 // A subagent's turn also reaches this hook; only the main loop's counts.
577 if (e.agentId !== undefined) return next(e)
578
579 // The contextual opt-in keeps a short, redacted excerpt of Claude's reply; off, nothing.
580 const setup = await currentSetup($)
581 if (setup?.isContextual === true && e.answer.trim() !== '') {
582 const answer = excerpt(e.answer, MAX_ANSWER_EXCERPT)
583 await update($, workContext, c => ({ ...c, answer }))
584 }
585
586 const turnId = e.turnId
587 const before = await read($, wait)
588 const complete = { type: 'turn-complete', turnId, isAborted: e.isAborted } as const
589 const isCounting = transition(before, complete).phase === 'closing'
590 const closing = closeOnTurnComplete(before, complete)
591 delayTimer?.cancel()
592 closeTimer?.cancel()
593 ticker?.cancel()
594 await dispatchWait($, complete)
595 // The split's top line turns to "done".
596 $.ui.invalidate('ui.render')
597
598 if (closing === 'now') await closeSplit($)
599 // A split in use whose micro-unit already ended while Claude worked: with
600 // Claude idle now, it goes, and says how the unit went.
601 const atRest = finishedSummary(await read($, lesson))
602 if (closing === 'keep' && isKeptOpen(before) && atRest !== null) {
603 await closeSplit($)
604 $.ui.toast(`${LABELS.modName}: ${summaryText(atRest.summary, atRest.activity)}`)
605 }
606 if (isCounting) {
607 closeTimer = $.clock.after(CLOSE_DELAY_MS, async () => {
608 // Untouched until the end of the countdown: it goes; touched meanwhile, it stays.
609 const state = await read($, wait)
610 await dispatchWait($, { type: 'countdown-elapsed', turnId })
611 if (closing === 'after-countdown' && closeOnTurnComplete(state, complete) !== 'keep') await closeSplit($)
612 })
613 }
614
615 return next(e)
616 })
617
618 // The learner's next prompt closes a split they were using; an untouched one
619 // the mod opened goes with its turn instead.
620 on('prompt.submit', async ($, e, next) => {
621 // A slash command (`/lingo` itself) is not the learner's next prompt.
622 const isCommand = e.text.trimStart().startsWith('/')
623 if (!isCommand && isKeptOpen(await read($, wait))) await closeSplit($)
624 // The contextual opt-in keeps a short, redacted excerpt of the prompt; off, nothing.
625 const setup = await currentSetup($)
626 if (setup?.isContextual === true && !isCommand) {
627 const prompt = excerpt(e.text, MAX_PROMPT_EXCERPT)
628 await update($, workContext, c => ({ ...c, prompt }))
629 }
630 return next(e)
631 })
632
633 // --- Do not cover what the person must answer ----------------------------
634
635 // A real tool call the mode decider will put to the person (a permission ask).
636 // The split stays, dimmed and without its field, so no key meant for the
637 // dialog lands in it.
638 on('tool.check', async ($, e, next) => {
639 const verdict = await next(e)
640 if (verdict.decision !== 'ask' || e.tool_use_id === undefined) return verdict
641
642 delayTimer?.cancel()
643 await dispatchWait($, { type: 'needs-user' })
644
645 return verdict
646 })
647
648 on('tool.call', async ($, e, next) => {
649 if (String(e.tool) !== 'AskUserQuestion') {
650 // A tool is running, so whatever asked before has been answered.
651 await dispatchWait($, { type: 'user-answered' })
652 return next(e)
653 }
654
655 delayTimer?.cancel()
656 await dispatchWait($, { type: 'needs-user' })
657
658 // The call resolves once the question has been answered.
659 const answered = await next(e)
660 await dispatchWait($, { type: 'user-answered' })
661
662 return answered
663 })
664
665 on('ui.close', { id: PANE }, async ($, e, next) => {
666 // The person's mark or key, the mod's own close, or an unload: the split is gone.
667 await dispatchWait($, { type: 'pane-closed' })
668 return next(e)
669 })
670
671 // --- What is drawn --------------------------------------------------------
672
673 // One micro-lesson per turn in the Spinner's suffix: no pane, any terminal.
674 on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
675 noteViewport(e)
676 const state = await read($, wait)
677 if (!isLessonVisible(state) || state.turnId === null) return next(e)
678
679 // Nothing to teach until the setup says which languages and level.
680 const setup = await currentSetup($)
681 if (setup === null) return next(e)
682
683 // The learner's own mistake when there is one; the built-in cards otherwise.
684 const card = await read($, spinnerCard)
685 const line = card.turnId === state.turnId && card.text !== null ? card.text : microLesson(setup.targetLanguage, setup.nativeLanguage, state.turnId)
686 const suffix = spinnerSuffix(line)
687
688 return next({ ...e, props: { ...e.props, suffix } })
689 })
690
691 // The band above the prompt: the setup reminder while setup is pending, and
692 // "open lesson" while Claude works and the split cannot seat by itself.
693 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
694 noteViewport(e)
695 if (e.props.hasSurvey) return next(e)
696
697 const state = await read($, wait)
698 const setup = await currentSetup($)
699 const band = await read($, setupBand)
700 const isSetupShown = setup === null && !band.isHidden
701 const isOfferShown = setup !== null && isOfferVisible(state)
702 if (!isSetupShown && !isOfferShown) return next(e)
703
704 const { Box, Button, Text } = $.ui.resolve(e)
705
706 return (
707 <Box flexDirection="column">
708 {isSetupShown && (
709 <Box columnGap={1}>
710 <Text dimColor>{LABELS.bandSetupPending}</Text>
711 <Button
712 key="setup-start"
713 label={LABELS.bandStartSetup}
714 hotkey="2"
715 plain
716 onPress={async () => {
717 await update($, setupWizard, s => wizardTransition(s, { type: 'open' }, draftFromConfig(native, target)))
718 // Opened by a press, so it is placed at any width.
719 const opened = await $.ui.open(openArgs(setup))
720 if (opened.isPlaced) await dispatchWait($, { type: 'pane-placed', opener: 'person' })
721 }}
722 />
723 <Button
724 key="setup-later"
725 label={LABELS.bandLater}
726 hotkey="3"
727 plain
728 onPress={() => update($, setupBand, () => ({ isHidden: true }))}
729 />
730 </Box>
731 )}
732 {isOfferShown && (
733 <Box columnGap={1}>
734 <Text dimColor>{LABELS.modName}</Text>
735 <Button
736 key="open-lingo"
737 label={LABELS.bandOpenLesson}
738 hotkey="1"
739 plain
740 onPress={async () => {
741 await dispatchWait($, { type: 'offer-taken' })
742 // Opened by a press: placed at any width, inline when it cannot dock.
743 const opened = await $.ui.open(openArgs(setup))
744 if (opened.isPlaced) await dispatchWait($, { type: 'pane-placed', opener: 'person' })
745 if (setup !== null) await ensureLesson($, setup)
746 }}
747 />
748 </Box>
749 )}
750 </Box>
751 )
752 })
753
754 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
755 noteViewport(e)
756 const setup = await currentSetup($)
757 const wizard = await read($, setupWizard)
758 const state = await read($, wait)
759
760 if (e.surface === 'mobile') {
761 // The mobile app draws no Input yet, so the wizard cannot run there.
762 const { Box, Text } = $.ui.resolve(e)
763 return (
764 <Box flexDirection="column">
765 <Text bold>{LABELS.modName}</Text>
766 <Text dimColor>
767 {setup === null ? LABELS.setupNeedsInput : LABELS.learning(setup.targetLanguage, setup.nativeLanguage)}
768 </Text>
769 </Box>
770 )
771 }
772
773 const { Box, Button, Input, Link, Text } = $.ui.resolve(e)
774
775 // On top of the split: whether the keys are here (Esc hands them back and the
776 // split stays), and what Claude is doing.
777 const isFocused = e.props.isFocused
778 const top = (
779 <Box flexDirection="column" marginBottom={1}>
780 <Text bold={isFocused} dimColor={!isFocused}>
781 {isFocused ? LABELS.keysHere : LABELS.keysAway}
782 </Text>
783 <Text dimColor>{claudeStateLine(state, state.startedAt === null ? 0 : await $.clock.now())}</Text>
784 </Box>
785 )
786
787 // A permission ask or a question: the split waits, dimmed and with no field,
788 // so a key meant for the dialog never lands in it.
789 if (state.phase === 'paused') {
790 return (
791 <Box flexDirection="column">
792 {top}
793 <Text dimColor>{LABELS.waitingForYou}</Text>
794 </Box>
795 )
796 }
797
798 // Setup pending, or being redone: the wizard.
799 const drawWizard = () => {
800 // What an untouched draft stands for: what is saved, else the userConfig.
801 const defaults = setup === null ? draftFromConfig(native, target) : draftFromSetup(setup)
802 const draft = wizard.draft ?? defaults
803 const step = wizard.step
804 const number = STEPS.indexOf(step) + 1
805 const problem = stepProblem(step, draft)
806 const dispatch = async (event: WizardEvent) => {
807 await touch($)
808 await update($, setupWizard, s => wizardTransition(s, event, defaults))
809 }
810 // Enter empties a field on screen; a new key redraws it with the draft.
811 const keepText = () => {
812 inputRev += 1
813 $.ui.invalidate('ui.render')
814 }
815
816 const confirm = async () => {
817 const latest = await read($, setupWizard)
818 const built = buildSetup(latest.draft ?? defaults, new Date(await $.clock.now()).toISOString())
819 if (built === null) return
820 await $.store.set(SETUP_STORE_KEY, built)
821 await update($, setupCache, () => ({ isLoaded: true, setup: built }))
822 await update($, setupWizard, () => CLOSED_WIZARD)
823 $.ui.toast(LABELS.setupSaved)
824 // Straight into the lesson, zero clicks.
825 await ensureLesson($, built)
826 }
827
828 // One row of choice buttons; the picked one is primary. No hotkeys: Tab and Enter.
829 const choices = <T extends string | number>(
830 prefix: string,
831 options: readonly { id: T; label: string }[],
832 picked: T,
833 pick: (id: T) => unknown,
834 ) => (
835 <Box flexWrap="wrap" columnGap={1}>
836 {options.map(option => (
837 <Button
838 key={`${prefix}-${option.id}`}
839 label={option.label}
840 variant={picked === option.id ? 'primary' : 'secondary'}
841 onPress={() => pick(option.id)}
842 />
843 ))}
844 </Box>
845 )
846 const preview = themeByName(draft.theme).colors
847
848 return (
849 <Box flexDirection="column">
850 <Text bold>{LABELS.setupTitle}</Text>
851 <Text dimColor>{LABELS.setupStep(number, STEPS.length)}</Text>
852 <Text dimColor>
853 {step === 'languages' || step === 'interests'
854 ? LABELS.setupHintTyping
855 : step === 'level'
856 ? LABELS.setupHintLevel
857 : LABELS.setupHintButtons}
858 </Text>
859
860 {step === 'languages' && (
861 <Box flexDirection="column">
862 <Text>{LABELS.setupLanguagesAsk}</Text>
863 <Input
864 key={`native-${inputRev}`}
865 label={LABELS.setupNative}
866 value={draft.nativeLanguage}
867 placeholder={LABELS.setupNativePlaceholder}
868 autoFocus
869 onInput={value => dispatch({ type: 'set-field', field: 'nativeLanguage', value })}
870 onSubmit={value => {
871 dispatch({ type: 'set-field', field: 'nativeLanguage', value: value === '' ? draft.nativeLanguage : value })
872 keepText()
873 }}
874 />
875 <Input
876 key={`target-${inputRev}`}
877 label={LABELS.setupTarget}
878 value={draft.targetLanguage}
879 placeholder={LABELS.setupTargetPlaceholder}
880 onInput={value => dispatch({ type: 'set-field', field: 'targetLanguage', value })}
881 onSubmit={value => {
882 dispatch({ type: 'set-field', field: 'targetLanguage', value: value === '' ? draft.targetLanguage : value })
883 keepText()
884 }}
885 />
886 </Box>
887 )}
888
889 {step === 'level' && (
890 <Box flexDirection="column">
891 <Text>{LABELS.setupLevelAsk(draft.targetLanguage.trim())}</Text>
892 <Box flexWrap="wrap" columnGap={1}>
893 {LEVELS.map((level, index) => (
894 <Button
895 key={`level-${level}`}
896 label={level}
897 hotkey={String(index + 1)}
898 plain
899 variant={draft.level === level ? 'primary' : 'secondary'}
900 onPress={() => dispatch({ type: 'set-level', level })}
901 />
902 ))}
903 </Box>
904 <Text dimColor>{draft.level === null ? LABELS.setupLevelNone : LABELS.setupLevelPicked(draft.level)}</Text>
905 </Box>
906 )}
907
908 {step === 'interests' && (
909 <Box flexDirection="column">
910 <Text>{LABELS.setupInterestsAsk}</Text>
911 <Text dimColor>{LABELS.setupInterestsNote}</Text>
912 <Input
913 key={`interests-${inputRev}`}
914 label={LABELS.setupInterests}
915 value={draft.interests}
916 placeholder={LABELS.setupInterestsPlaceholder}
917 autoFocus
918 onInput={value => dispatch({ type: 'set-interests', value })}
919 onSubmit={value => {
920 dispatch({ type: 'set-interests', value: value === '' ? draft.interests : value })
921 keepText()
922 }}
923 />
924 </Box>
925 )}
926
927 {step === 'placement' && (
928 <Box flexDirection="column">
929 <Text>{LABELS.setupPlacementTitle}</Text>
930 <Text dimColor>{LABELS.setupPlacementLater}</Text>
931 </Box>
932 )}
933
934 {step === 'strategies' && (
935 <Box flexDirection="column">
936 <Text>{LABELS.setupStrategiesAsk}</Text>
937 {STRATEGY_AXES.map(info => (
938 <Box key={`axis-${info.axis}`} flexDirection="column">
939 <Text bold>{info.title}</Text>
940 <Box flexWrap="wrap" columnGap={1}>
941 {info.options.map(option =>
942 option.isImplemented ? (
943 <Button
944 key={`${info.axis}-${option.id}`}
945 label={option.label}
946 variant={draft.strategies[info.axis] === option.id ? 'primary' : 'secondary'}
947 onPress={() => dispatch({ type: 'set-strategy', axis: info.axis, id: option.id })}
948 />
949 ) : (
950 <Text key={`soon-${info.axis}-${option.id}`} dimColor>
951 {LABELS.setupComingLater(option.label)}
952 </Text>
953 ),
954 )}
955 </Box>
956 </Box>
957 ))}
958 </Box>
959 )}
960
961 {step === 'preferences' && (
962 <Box flexDirection="column">
963 <Text>{LABELS.setupPreferencesAsk}</Text>
964 <Text bold>{LABELS.setupShareTitle}</Text>
965 {choices('share', SPLIT_SHARES.map(share => ({ id: share, label: LABELS.setupShare(share) })), draft.splitShare, share =>
966 dispatch({ type: 'set-share', share }),
967 )}
968 <Text bold>{LABELS.setupTutorTitle}</Text>
969 {choices('tutor', TUTOR_MODELS, draft.tutorModel, model => dispatch({ type: 'set-tutor', model }))}
970 <Text dimColor>{LABELS.setupTutorNote}</Text>
971 <Text bold>{LABELS.setupThemeTitle}</Text>
972 {choices('theme', THEMES.map(t => ({ id: t.name, label: t.label })), draft.theme, theme =>
973 dispatch({ type: 'set-theme', theme }),
974 )}
975 <Box key="theme-preview" columnGap={1} flexWrap="wrap">
976 <Text color={preview.conversation}>conversation</Text>
977 <Text color={preview.roleplay}>role-play</Text>
978 <Text color={preview.reading}>reading</Text>
979 <Text color={preview.tutor}>tutor</Text>
980 <Text color={preview.you}>you</Text>
981 </Box>
982 <Text bold>{LABELS.setupContextTitle}</Text>
983 {choices(
984 'context',
985 [
986 { id: 'off', label: LABELS.setupContextOff },
987 { id: 'on', label: LABELS.setupContextOn },
988 ],
989 draft.isContextual ? 'on' : 'off',
990 id => dispatch({ type: 'set-contextual', isOn: id === 'on' }),
991 )}
992 <Text dimColor>{LABELS.setupContextNote}</Text>
993 </Box>
994 )}
995
996 {step === 'summary' && (
997 <Box flexDirection="column">
998 <Text>{LABELS.setupSummary}</Text>
999 <Text>{LABELS.setupSummaryLanguages(draft.targetLanguage.trim(), draft.nativeLanguage.trim(), draft.level ?? '?')}</Text>
1000 <Text dimColor>{LABELS.setupSummaryInterests(parseInterests(draft.interests))}</Text>
1001 <Text dimColor>
1002 {LABELS.setupSummaryLook(
1003 draft.splitShare,
1004 TUTOR_MODELS.find(m => m.id === draft.tutorModel)?.label ?? draft.tutorModel,
1005 themeByName(draft.theme).label,
1006 )}
1007 </Text>
1008 <Text dimColor>{LABELS.setupSummaryContext(draft.isContextual)}</Text>
1009 {STRATEGY_AXES.map(info => (
1010 <Text key={`summary-${info.axis}`} dimColor>
1011 {info.title}: {info.options.find(o => o.id === draft.strategies[info.axis])?.label ?? draft.strategies[info.axis]}
1012 </Text>
1013 ))}
1014 </Box>
1015 )}
1016
1017 <Box marginTop={1} columnGap={1} flexWrap="wrap">
1018 {step !== 'languages' && <Button key="back" label={LABELS.back} plain onPress={() => dispatch({ type: 'back' })} />}
1019 {step !== 'placement' && step !== 'summary' && problem === null && (
1020 <Button key="next" label={LABELS.next} plain onPress={() => dispatch({ type: 'next' })} />
1021 )}
1022 {(step === 'placement' || step === 'strategies') && (
1023 <Button key="skip" label={LABELS.skip} plain onPress={() => dispatch({ type: 'skip' })} />
1024 )}
1025 {step === 'summary' && <Button key="confirm" label={LABELS.confirm} variant="primary" autoFocus onPress={confirm} />}
1026 </Box>
1027 {problem !== null && <Text dimColor>{problem}</Text>}
1028 </Box>
1029 )
1030 }
1031
1032 if (setup === null || wizard.isOpen) {
1033 return (
1034 <Box flexDirection="column">
1035 {top}
1036 {drawWizard()}
1037 </Box>
1038 )
1039 }
1040
1041 // The lesson, in the theme's colors; dim while the keys are elsewhere.
1042 const theme = themeByName(setup.theme).colors
1043 const dim = !isFocused
1044 const current = await read($, lesson)
1045 const activityColor = theme[current.activity]
1046 const chip = (
1047 <Box columnGap={1}>
1048 <Text backgroundColor={activityColor} color={theme.bg} bold dimColor={dim}>
1049 {` ${activityLabel(current.activity)} · ${setup.level} `}
1050 </Text>
1051 {current.activity === 'roleplay' && current.unit?.scenario != null && (
1052 <Text color={theme.roleplay} dimColor={dim}>
1053 {current.unit.scenario.id === null ? LABELS.madeUpScenario(current.unit.scenario.title) : current.unit.scenario.title}
1054 </Text>
1055 )}
1056 {current.activity === 'reading' && current.reading !== null && current.reading.title !== '' && (
1057 <Text color={theme.reading} dimColor={dim}>
1058 {current.reading.title}
1059 </Text>
1060 )}
1061 </Box>
1062 )
1063 // Where a role-play happens and who plays whom, under the chip.
1064 const scene =
1065 current.activity === 'roleplay' && current.unit?.scenario != null ? (
1066 <Text color={theme.dim} dimColor={dim} wrap="wrap">
1067 {LABELS.scene(current.unit.scenario.situation, current.unit.scenario.learnerRole)}
1068 </Text>
1069 ) : null
1070
1071 const openMenu = async () => {
1072 await touch($)
1073 await update($, lesson, l => ({ ...l, isMenuOpen: true }))
1074 await focusKey($, `switch-${current.activity}`)
1075 }
1076 const closeMenu = async () => {
1077 await touch($)
1078 await update($, lesson, l => ({ ...l, isMenuOpen: false }))
1079 const field =
1080 current.activity === 'review'
1081 ? `answer-${(await read($, practice)).index}`
1082 : current.activity === 'reading'
1083 ? 'reading-answer'
1084 : 'reply'
1085 await focusKey($, field)
1086 }
1087 // An activity left half done is resumed where it was; otherwise it starts.
1088 const choose = async (activity: LingoActivity) => {
1089 await touch($)
1090 const latest = await read($, lesson)
1091 const session = await read($, practice)
1092 const isRunning =
1093 activity === 'review'
1094 ? session.lesson !== null && session.status !== 'done'
1095 : activity === 'reading'
1096 ? latest.reading !== null && latest.reading.summary === null && latest.reading.text !== ''
1097 : latest.unit !== null && latest.unit.activity === activity && latest.unit.summary === null
1098 if (!isRunning) return startActivity($, setup, activity)
1099 await update($, lesson, (l): LingoLesson => ({ ...l, activity, isMenuOpen: false }))
1100 await focusKey($, activity === 'review' ? `answer-${session.index}` : activity === 'reading' ? 'reading-answer' : 'reply')
1101 }
1102 const switchButton = <Button key="switch" label={LABELS.switchActivity} plain dimColor={dim} onPress={openMenu} />
1103
1104 if (current.isMenuOpen) {
1105 const menu = ACTIVITIES.filter(a => AVAILABLE_ACTIVITIES.includes(a.id))
1106 return (
1107 <Box flexDirection="column">
1108 {top}
1109 {chip}
1110 <Box flexDirection="column" marginTop={1} borderStyle="single" borderColor={theme.ring} paddingX={1}>
1111 {menu.map((a, index) => (
1112 <Button
1113 key={`switch-${a.id}`}
1114 label={a.id === current.activity ? `${a.label} ${LABELS.current}` : a.label}
1115 hotkey={String(index + 1)}
1116 plain
1117 onPress={() => choose(a.id)}
1118 />
1119 ))}
1120 <Button key="switch-back" label={LABELS.backToField} plain dimColor onPress={closeMenu} />
1121 </Box>
1122 <Text color={theme.dim}>{LABELS.menuHint(menu.length)}</Text>
1123 </Box>
1124 )
1125 }
1126
1127 const log = parseActivityLog(await $.store.get(ACTIVITY_STORE_KEY))
1128 const suggested = suggestActivity(log, AVAILABLE_ACTIVITIES)
1129 const nextButton = (
1130 <Button
1131 key="next-unit"
1132 label={LABELS.nextActivity(activityLabel(suggested))}
1133 variant="primary"
1134 autoFocus
1135 onPress={async () => {
1136 await touch($)
1137 await startActivity($, setup, suggested)
1138 }}
1139 />
1140 )
1141
1142 // A reading: the text, then one question at a time. An empty Enter shows the
1143 // answer and makes a card of the question.
1144 if (current.activity === 'reading') {
1145 const reading = current.reading
1146 const question = reading?.questions[reading.index]
1147 return (
1148 <Box flexDirection="column">
1149 {top}
1150 {chip}
1151 {reading === null || reading.isPending ? (
1152 <Text color={theme.dim} dimColor={dim}>
1153 {LABELS.readingWriting}
1154 </Text>
1155 ) : (
1156 <Box flexDirection="column" marginTop={1}>
1157 {reading.text !== '' && (
1158 <Text color={theme.fg} dimColor={dim} wrap="wrap">
1159 {reading.text}
1160 </Text>
1161 )}
1162 {reading.text !== '' && (
1163 <Text color={theme.dim} dimColor={dim}>
1164 {LABELS.readingGenerated}
1165 </Text>
1166 )}
1167 {reading.questions.slice(0, reading.index).map((q, at) => (
1168 <Text key={`answered-${at}`} color={q.outcome === 'right' ? theme.you : theme.err} dimColor={dim}>
1169 {LABELS.readingAnswered(at + 1, q.question, q.answer, q.outcome === 'right')}
1170 </Text>
1171 ))}
1172 {question !== undefined && reading.summary === null && (
1173 <Box flexDirection="column" marginTop={1}>
1174 <Text color={theme.fg} bold dimColor={dim}>
1175 {LABELS.readingQuestion(reading.index + 1, reading.questions.length, question.question)}
1176 </Text>
1177 <Box borderStyle="round" borderColor={isFocused ? theme.ring : theme.dim} paddingX={1}>
1178 <Input
1179 key="reading-answer"
1180 placeholder={LABELS.readingPlaceholder}
1181 submitLabel={LABELS.reviewSubmit}
1182 autoFocus
1183 onInput={() => touch($)}
1184 onSubmit={async value => {
1185 await touch($)
1186 await answerQuestion($, value)
1187 }}
1188 />
1189 </Box>
1190 {reading.misses > 0 && (
1191 <Text color={theme.err} dimColor={dim}>
1192 {LABELS.readingNotQuite}
1193 </Text>
1194 )}
1195 </Box>
1196 )}
1197 {reading.notice !== null && (
1198 <Text color={theme.err} dimColor={dim}>
1199 {reading.notice}
1200 </Text>src/content/demo-english-a1.ts 43 lines1// The `en-a1` demo pack: English A1 for Spanish speakers, 6 lessons of 5 cards.
2// A module, not a JSON file, because the mod runs without Node or fs and cannot
3// import JSON (and tests cannot read files either, so the old JSON was removed).
4
5import type { Card } from '../strategies'
6
7export const DEMO_PACK_ID = "demo-english-a1"
8export const DEMO_PACK_TITLE = "English A1 demo (for Spanish speakers)"
9export const DEMO_LESSON_COUNT = 6
10
11export const DEMO_CARDS: readonly Card[] = [
12 { id: "en-a1-01-01", lesson: 1, prompt: "Hola", answer: "Hello", tags: ["en-a1", "greetings", "leccion-01"] },
13 { id: "en-a1-01-02", lesson: 1, prompt: "Buenos días", answer: "Good morning", tags: ["en-a1", "greetings", "leccion-01"] },
14 { id: "en-a1-01-03", lesson: 1, prompt: "Buenas noches", answer: "Good night", tags: ["en-a1", "greetings", "leccion-01"] },
15 { id: "en-a1-01-04", lesson: 1, prompt: "Adiós", answer: "Goodbye", tags: ["en-a1", "greetings", "leccion-01"] },
16 { id: "en-a1-01-05", lesson: 1, prompt: "¿Cómo estás?", answer: "How are you?", tags: ["en-a1", "greetings", "leccion-01"] },
17 { id: "en-a1-02-01", lesson: 2, prompt: "Me llamo Ana", answer: "My name is Ana", tags: ["en-a1", "introductions", "leccion-02"] },
18 { id: "en-a1-02-02", lesson: 2, prompt: "¿Cómo te llamas?", answer: "What's your name?", tags: ["en-a1", "introductions", "leccion-02"] },
19 { id: "en-a1-02-03", lesson: 2, prompt: "Mucho gusto", answer: "Nice to meet you", tags: ["en-a1", "introductions", "leccion-02"] },
20 { id: "en-a1-02-04", lesson: 2, prompt: "Soy de Venezuela", answer: "I'm from Venezuela", tags: ["en-a1", "introductions", "leccion-02"] },
21 { id: "en-a1-02-05", lesson: 2, prompt: "¿De dónde eres?", answer: "Where are you from?", tags: ["en-a1", "introductions", "leccion-02"] },
22 { id: "en-a1-03-01", lesson: 3, prompt: "uno", answer: "one", tags: ["en-a1", "numbers", "leccion-03"] },
23 { id: "en-a1-03-02", lesson: 3, prompt: "dos", answer: "two", tags: ["en-a1", "numbers", "leccion-03"] },
24 { id: "en-a1-03-03", lesson: 3, prompt: "tres", answer: "three", tags: ["en-a1", "numbers", "leccion-03"] },
25 { id: "en-a1-03-04", lesson: 3, prompt: "cuatro", answer: "four", tags: ["en-a1", "numbers", "leccion-03"] },
26 { id: "en-a1-03-05", lesson: 3, prompt: "cinco", answer: "five", tags: ["en-a1", "numbers", "leccion-03"] },
27 { id: "en-a1-04-01", lesson: 4, prompt: "madre", answer: "mother", tags: ["en-a1", "family", "leccion-04"] },
28 { id: "en-a1-04-02", lesson: 4, prompt: "padre", answer: "father", tags: ["en-a1", "family", "leccion-04"] },
29 { id: "en-a1-04-03", lesson: 4, prompt: "hermano", answer: "brother", tags: ["en-a1", "family", "leccion-04"] },
30 { id: "en-a1-04-04", lesson: 4, prompt: "hermana", answer: "sister", tags: ["en-a1", "family", "leccion-04"] },
31 { id: "en-a1-04-05", lesson: 4, prompt: "amigo", answer: "friend", tags: ["en-a1", "family", "leccion-04"] },
32 { id: "en-a1-05-01", lesson: 5, prompt: "agua", answer: "water", tags: ["en-a1", "food", "leccion-05"] },
33 { id: "en-a1-05-02", lesson: 5, prompt: "pan", answer: "bread", tags: ["en-a1", "food", "leccion-05"] },
34 { id: "en-a1-05-03", lesson: 5, prompt: "manzana", answer: "apple", tags: ["en-a1", "food", "leccion-05"] },
35 { id: "en-a1-05-04", lesson: 5, prompt: "café", answer: "coffee", tags: ["en-a1", "food", "leccion-05"] },
36 { id: "en-a1-05-05", lesson: 5, prompt: "Tengo hambre", answer: "I'm hungry", tags: ["en-a1", "food", "leccion-05"] },
37 { id: "en-a1-06-01", lesson: 6, prompt: "Por favor", answer: "Please", tags: ["en-a1", "polite-phrases", "leccion-06"] },
38 { id: "en-a1-06-02", lesson: 6, prompt: "Gracias", answer: "Thank you", tags: ["en-a1", "polite-phrases", "leccion-06"] },
39 { id: "en-a1-06-03", lesson: 6, prompt: "De nada", answer: "You're welcome", tags: ["en-a1", "polite-phrases", "leccion-06"] },
40 { id: "en-a1-06-04", lesson: 6, prompt: "Lo siento", answer: "I'm sorry", tags: ["en-a1", "polite-phrases", "leccion-06"] },
41 { id: "en-a1-06-05", lesson: 6, prompt: "No entiendo", answer: "I don't understand", tags: ["en-a1", "polite-phrases", "leccion-06"] },
42]
43src/lesson.ts 148 lines1// Pure logic of the practice session: answer matching, hints, progress and the
2// queue. No `$`, no I/O; inputs are never mutated.
3
4import { dueCards } from './review/pimsleur'
5import type { Card, Progress } from './strategies'
6import type { LingoPractice, LingoProgress } from '../types'
7
8export const PROGRESS_STORE_KEY = 'progress'
9export const PROGRESS_VERSION = 1
10/** Past attempts kept per card (the store is small and shared). */
11export const MAX_REVIEWS_PER_CARD = 20
12
13export const FRESH_PROGRESS: LingoProgress = { version: PROGRESS_VERSION, currentLesson: 1, cards: {} }
14
15// --- Answers ----------------------------------------------------------------
16
17/** Lowercase, no punctuation, one space between words, no edge spaces. */
18export function normalizeAnswer(text: string): string {
19 return text
20 .toLowerCase()
21 .replace(/[^\p{L}\p{N}\s]/gu, '')
22 .replace(/\s+/g, ' ')
23 .trim()
24}
25
26export function isCorrectAnswer(input: string, answer: string): boolean {
27 const given = normalizeAnswer(input)
28 return given !== '' && given === normalizeAnswer(answer)
29}
30
31/** A deterministic hint: the first letter and the size, never the answer. */
32export function hintFor(answer: string): string {
33 const words = normalizeAnswer(answer).split(' ')
34 const letters = words.join('').length
35 const first = (normalizeAnswer(answer)[0] ?? '?').toUpperCase()
36 const count = (n: number, one: string, many: string) => `${n} ${n === 1 ? one : many}`
37 return `Starts with "${first}", ${count(words.length, 'word', 'words')}, ${count(letters, 'letter', 'letters')}.`
38}
39
40/** True when a tutor reply contains the answer, so it must not be shown. */
41export function leaksAnswer(reply: string, answer: string): boolean {
42 const target = normalizeAnswer(answer)
43 return target !== '' && ` ${normalizeAnswer(reply)} `.includes(` ${target} `)
44}
45
46/** The prompt handed to `$.model.complete`; the answer is for the tutor only. */
47export function tutorPrompt(card: Card, failed: string, targetLanguage: string, nativeLanguage: string): string {
48 return [
49 `Exercise: the learner must translate "${card.prompt}" into ${targetLanguage}.`,
50 `Reference answer (for you only; never write it, spell it out or translate it): "${card.answer}"`,
51 `The learner answered: "${failed}"`,
52 `Reply in ${nativeLanguage} with one short guiding question or hint, at most two lines, without giving the answer.`,
53 ].join('\n')
54}
55
56// --- Progress ---------------------------------------------------------------
57
58const isObject = (value: unknown): value is Record<string, unknown> =>
59 typeof value === 'object' && value !== null && !Array.isArray(value)
60
61/** Whatever `$.store` held under `progress`, as progress; a fresh one when it is not valid. */
62export function parseProgress(raw: unknown): LingoProgress {
63 if (!isObject(raw) || raw.version !== PROGRESS_VERSION) return FRESH_PROGRESS
64 const { currentLesson, cards } = raw
65 if (typeof currentLesson !== 'number' || !Number.isInteger(currentLesson) || currentLesson < 1) return FRESH_PROGRESS
66 if (!isObject(cards)) return FRESH_PROGRESS
67
68 const clean: LingoProgress['cards'] = {}
69 for (const [id, entry] of Object.entries(cards)) {
70 if (!isObject(entry) || !Array.isArray(entry.reviews)) continue
71 const reviews = entry.reviews
72 .filter(isObject)
73 .filter(r => typeof r.at === 'string' && typeof r.isCorrect === 'boolean')
74 .map(r => ({ at: r.at as string, isCorrect: r.isCorrect as boolean }))
75 clean[id] = { reviews: reviews.slice(-MAX_REVIEWS_PER_CARD) }
76 }
77 return { version: PROGRESS_VERSION, currentLesson, cards: clean }
78}
79
80export function recordAttempt(progress: LingoProgress, cardId: string, at: string, isCorrect: boolean): LingoProgress {
81 const before = progress.cards[cardId]?.reviews ?? []
82 const reviews = [...before, { at, isCorrect }].slice(-MAX_REVIEWS_PER_CARD)
83 return { ...progress, cards: { ...progress.cards, [cardId]: { reviews } } }
84}
85
86/** After finishing the current lesson: the next one, or "finished" (lessons + 1). */
87export function advanceLesson(progress: LingoProgress, lessonCount: number): LingoProgress {
88 return { ...progress, currentLesson: Math.min(progress.currentLesson + 1, lessonCount + 1) }
89}
90
91export const isFinished = (progress: LingoProgress, lessonCount: number): boolean =>
92 progress.currentLesson > lessonCount
93
94function toList(progress: LingoProgress): Progress[] {
95 return Object.entries(progress.cards).map(([cardId, entry]) => ({ cardId, reviews: entry.reviews }))
96}
97
98// --- Queue ------------------------------------------------------------------
99
100export type LessonQueue = { recall: string[]; fresh: string[] }
101
102/** Pimsleur recall block (n-1, n-3, n-7), then the new cards of the current lesson. */
103export function buildQueue(cards: readonly Card[], progress: LingoProgress, now: Date): LessonQueue {
104 const all = [...cards]
105 const recall = dueCards(all, toList(progress), { now, currentLesson: progress.currentLesson }).map(c => c.id)
106 const fresh = all
107 .filter(c => c.lesson === progress.currentLesson)
108 .map(c => c.id)
109 .sort()
110 return { recall, fresh }
111}
112
113export const queueIds = (queue: LessonQueue): string[] => [...queue.recall, ...queue.fresh]
114
115// --- Session state ----------------------------------------------------------
116
117export const IDLE_PRACTICE: LingoPractice = {
118 lesson: null,
119 queue: [],
120 recallCount: 0,
121 index: 0,
122 status: 'asking',
123 misses: 0,
124 hint: null,
125 lastAnswer: null,
126 tutor: { kind: 'idle' },
127}
128
129export function startPractice(queue: LessonQueue, lesson: number): LingoPractice {
130 const ids = queueIds(queue)
131 return { ...IDLE_PRACTICE, lesson, queue: ids, recallCount: queue.recall.length, status: ids.length === 0 ? 'done' : 'asking' }
132}
133
134/** After a right or wrong answer: the new state of the card on screen. */
135export function afterAnswer(session: LingoPractice, isCorrect: boolean, answer: string, given: string): LingoPractice {
136 if (isCorrect) return { ...session, status: 'correct', hint: null, tutor: { kind: 'idle' } }
137 return { ...session, misses: session.misses + 1, hint: hintFor(answer), lastAnswer: given, tutor: { kind: 'idle' } }
138}
139
140export const reveal = (session: LingoPractice): LingoPractice => ({ ...session, status: 'revealed', hint: null })
141
142/** Next card, or `done` after the last one. */
143export function nextCard(session: LingoPractice): LingoPractice {
144 const index = session.index + 1
145 if (index >= session.queue.length) return { ...session, index, status: 'done', misses: 0, hint: null, lastAnswer: null, tutor: { kind: 'idle' } }
146 return { ...session, index, status: 'asking', misses: 0, hint: null, lastAnswer: null, tutor: { kind: 'idle' } }
147}
148src/correction/socratic.ts 22 lines1import type { CorrectionStyle } from '../strategies'
2
3// Socratic correction: hints and questions before answers. Derived from the
4// author's own course preferences (guide with a question before showing the
5// correct form; gloss new vocabulary inline without giving away the tested
6// point; ask the learner to justify each option; never assert grammar from
7// memory when unsure).
8export const socratic: CorrectionStyle = {
9 id: 'socratic',
10 instruction({ targetLanguage, nativeLanguage }) {
11 return [
12 `You are a patient ${targetLanguage} tutor for a ${nativeLanguage} speaker. Explain in ${nativeLanguage}; keep examples in ${targetLanguage}.`,
13 'When the learner answers wrong, do not give the correct answer first. Ask one short guiding question that points at the mistake. Give a stronger hint only if they miss again, and the answer last.',
14 'When the learner answers right, confirm briefly and, if useful, ask why it is right in one sentence.',
15 'New vocabulary in an exercise is fine, but gloss it inline in parentheses, and never let the gloss give away what the item is testing.',
16 'For multiple choice, ask the learner to justify each option before confirming.',
17 'If you are not sure a rule or usage is correct, say so instead of stating it as fact.',
18 'Keep every reply to a few lines.',
19 ].join('\n')
20 },
21}
22src/command.ts 52 lines1// What `/lingo` was asked to do, parsed from the text after the command name.
2// Pure: hooks/register.tsx turns the result into panes and answers.
3
4import { THEMES, parseThemeName } from './themes'
5import type { LingoThemeName } from '../types'
6
7export type LingoSubcommand =
8 | { kind: 'open' }
9 | { kind: 'setup' }
10 | { kind: 'theme'; theme: LingoThemeName }
11 /** `/lingo theme` with no name or one that does not exist. */
12 | { kind: 'bad-theme'; name: string }
13 | { kind: 'unknown'; name: string }
14
15/** The subcommands that exist, as `[name, what it does]`, for the help text. */
16export const SUBCOMMANDS: readonly (readonly [string, string])[] = [
17 ['(none)', 'open or close the lesson pane'],
18 ['setup', 'open the guided setup (languages, level, interests, strategies, look)'],
19 ['theme <name>', `switch the color theme (${THEMES.map(t => t.name).join(', ')})`],
20]
21
22/**
23 * `args` is everything after `/lingo` as typed ("" when nothing). Only the
24 * first word counts, case-insensitively; words after a known subcommand are
25 * ignored rather than refused, save the theme's name.
26 */
27export function parseLingoArgs(args: string): LingoSubcommand {
28 const [first = '', second = ''] = args.trim().split(/\s+/)
29 if (first === '') return { kind: 'open' }
30 const name = first.toLowerCase()
31 if (name === 'setup') return { kind: 'setup' }
32 if (name === 'theme') {
33 const theme = parseThemeName(second)
34 return theme === null ? { kind: 'bad-theme', name: second } : { kind: 'theme', theme }
35 }
36 return { kind: 'unknown', name: first }
37}
38
39/** The answer to a subcommand that does not exist: lists the ones that do. */
40export function unknownSubcommandText(name: string): string {
41 const lines = SUBCOMMANDS.map(([sub, what]) => ` /lingo ${sub === '(none)' ? '' : sub}`.trimEnd() + ` ${what}`)
42 return [`lingo-pane: unknown subcommand "${name}". Available:`, ...lines].join('\n')
43}
44
45/** The answer to `/lingo theme` without a known name: the themes that exist. */
46export function badThemeText(name: string): string {
47 const list = THEMES.map(t => `${t.name} (${t.label})`).join(', ')
48 return name === ''
49 ? `lingo-pane: name a theme: /lingo theme <name>. Themes: ${list}.`
50 : `lingo-pane: there is no theme "${name}". Themes: ${list}.`
51}
52src/labels.ts 131 lines1// Every label the mod draws, in English, in one table so it can be translated
2// later. Catalogue entries (strategy options, tutor models, themes) keep their
3// labels next to their ids; prompts sent to the model are not labels.
4
5export const LABELS = {
6 modName: 'lingo-pane',
7
8 // --- Setup ------------------------------------------------------------------
9 setupTitle: 'lingo-pane setup',
10 setupStep: (n: number, of: number) => `Step ${n} of ${of}`,
11 setupHintTyping: 'Type, Enter keeps the text, Tab moves to the next field or button.',
12 setupHintLevel: 'Tab moves between buttons and Enter presses, or type the digit shown.',
13 setupHintButtons: 'Tab moves between buttons and Enter presses.',
14 setupLanguagesAsk: 'Which languages? The explanations are in your native language.',
15 setupNative: 'Native language: ',
16 setupTarget: 'Target language: ',
17 setupNativePlaceholder: 'e.g. Spanish',
18 setupTargetPlaceholder: 'e.g. English',
19 setupLevelAsk: (target: string) => `Your level in ${target} (CEFR):`,
20 setupLevelNone: 'None picked yet.',
21 setupLevelPicked: (level: string) => `Picked: ${level}`,
22 setupInterestsAsk: 'What do you like to talk about? Role-play scenarios are made from it.',
23 setupInterestsNote: 'Optional, comma-separated, up to 5.',
24 setupInterests: 'Interests: ',
25 setupInterestsPlaceholder: 'e.g. chess, cooking, startups',
26 setupPlacementTitle: 'Placement test (optional)',
27 setupPlacementLater: 'placement test: coming later',
28 setupStrategiesAsk: 'How should it work? Defaults are fine; "coming later" ones are not built yet.',
29 setupComingLater: (label: string) => `${label} (coming later)`,
30 setupPreferencesAsk: 'How the lesson looks and who teaches. Defaults are fine.',
31 setupShareTitle: 'Split width while Claude works (share of the terminal)',
32 setupShare: (share: number) => `${share} %`,
33 setupTutorTitle: 'Tutor model',
34 setupTutorNote: 'Every tutor reply uses your Claude plan.',
35 setupThemeTitle: 'Theme',
36 setupContextTitle: 'Material from your own session (optional)',
37 setupContextOff: 'Off',
38 setupContextOn: 'On',
39 setupContextNote:
40 "On: short excerpts of your last prompt and of Claude's last reply (secrets and emails redacted) go to the tutor, on your Claude plan, as topic ideas. Off by default.",
41 setupSummaryContext: (isOn: boolean) => `Material from your session: ${isOn ? 'on' : 'off'}`,
42 setupSummary: 'Summary',
43 setupSummaryLanguages: (target: string, native: string, level: string) => `${target} from ${native}, level ${level}`,
44 setupSummaryInterests: (interests: readonly string[]) =>
45 `Interests: ${interests.length === 0 ? '(none)' : interests.join(', ')}`,
46 setupSummaryLook: (share: number, tutor: string, theme: string) => `Split ${share} % · tutor ${tutor} · theme ${theme}`,
47 setupSaved: 'lingo-pane: setup saved',
48 setupPendingToast: 'lingo-pane: setup pending. Run /lingo setup (or press 2 in the band above the prompt).',
49 setupNeedsInput: 'Setup needs a terminal or the desktop app.',
50 learning: (target: string, native: string) => `Learning ${target} from ${native}.`,
51
52 back: 'Back',
53 next: 'Next',
54 skip: 'Skip',
55 confirm: 'Confirm',
56
57 // --- The split -------------------------------------------------------------------
58 keysHere: '● keys here · Esc back to the prompt',
59 keysAway: '○ Ctrl+X Tab to come back',
60 waitingForYou: 'Claude needs you: answer below. The lesson waits here.',
61
62 // --- The lesson ----------------------------------------------------------------
63 tutor: 'tutor ',
64 you: 'you ',
65 help: 'help ',
66 tutorThinking: 'the tutor is thinking…',
67 tutorSilentOpening: 'The tutor did not answer. Press "switch ▸" to try again or pick another activity.',
68 tutorSilentReply: 'The tutor did not answer; your line is back to you: send it again.',
69 tutorSilentHelp: 'The tutor did not answer. Try Enter on the empty field again.',
70 replyPlaceholder: (target: string) => `reply in ${target}`,
71 replySubmit: 'reply',
72 listen: '🔊 listen',
73 switchActivity: 'switch ▸',
74 talkHint: '⏎ empty = help · Tab = listen and switch',
75 talkHintWaiting: 'the tutor is answering; send your next line once it has',
76 nextActivity: (label: string) => `next: ${label} ▸`,
77 levelHint: (direction: 'up' | 'down', level: string) =>
78 direction === 'up'
79 ? `Tutor: these went smoothly. You could try ${level}; /lingo setup changes it.`
80 : `Tutor: these were hard. ${level} might suit you better for now; /lingo setup changes it.`,
81 madeUpScenario: (title: string) => `${title} (made up from your interests)`,
82 scene: (situation: string, learnerRole: string) => `${situation} You: ${learnerRole}`,
83 readingWriting: 'the tutor is writing a short text for you…',
84 readingSilent: 'The tutor did not write the text. Try again with "next" or pick another activity.',
85 readingGenerated: 'Written by the tutor for your level; it can make mistakes.',
86 readingQuestion: (n: number, of: number, question: string) => `Q${n} of ${of}: ${question}`,
87 readingPlaceholder: 'a word or three',
88 readingNotQuite: 'Not quite. Look again, or Enter on the empty field shows it.',
89 readingAnswered: (n: number, question: string, answer: string, isRight: boolean) =>
90 `${isRight ? '✓' : '·'} Q${n} ${question} ${answer}`,
91 readingHint: '⏎ empty = show the answer (it becomes a card) · Tab = listen and switch',
92 current: '(current)',
93 backToField: 'back',
94 menuHint: (count: number) => `press 1-${count} · Tab back`,
95
96 // --- Review (the built-in pack) ---------------------------------------------------
97 reviewPackNote: (title: string) => `The built-in pack is ${title}; it is used for now.`,
98 reviewDemoFinished: (count: number) => `Demo finished: all ${count} lessons are done. Come back to review, or wait for the lesson generator.`,
99 reviewLessonIntro: (lesson: number, count: number, recall: number, fresh: number) =>
100 `Lesson ${lesson} of ${count}: ${recall} to recall, ${fresh} new.`,
101 reviewMistakesIntro: (count: number) => `Your mistakes: ${count} to review.`,
102 reviewMistakeOf: (card: number, of: number) => `Your mistake - card ${card} of ${of}: type the right words for the marked ones`,
103 reviewDone: 'Review done',
104 reviewStart: 'Start lesson',
105 reviewLessonDone: (lesson: number) => `Lesson ${lesson} done`,
106 reviewDemoLast: 'Demo finished: that was the last lesson.',
107 reviewContinue: 'Continue',
108 reviewCardOf: (lesson: number, card: number, of: number, isRecall: boolean) =>
109 `Lesson ${lesson} - card ${card} of ${of} (${isRecall ? 'recall' : 'new'})`,
110 reviewIn: (target: string) => `In ${target}: `,
111 reviewPlaceholder: 'type your answer',
112 reviewSubmit: 'answer',
113 reviewNotYet: (hint: string) => `Not yet. ${hint}`,
114 reviewHintFromTutor: 'Hint from tutor',
115 reviewHint: '⏎ empty = show the answer · Tab = hint from tutor, switch',
116 reviewCorrect: (answer: string) => `Correct: ${answer}`,
117 reviewAnswer: (answer: string) => `Answer: ${answer}`,
118 hintUnavailable: 'The tutor is not available right now. Use the hint above or try again.',
119 hintLeaked: 'The tutor reply would have given the answer away, so it was dropped. Try again or look at the hint.',
120
121 // --- Band above the prompt -----------------------------------------------------
122 bandSetupPending: 'lingo-pane: setup pending. ',
123 bandStartSetup: 'Start setup',
124 bandLater: 'Later',
125 bandOpenLesson: 'open lesson',
126
127 // --- Theme command -------------------------------------------------------------
128 themeSet: (label: string) => `lingo-pane: theme set to ${label}.`,
129 themeNeedsSetup: 'lingo-pane: finish the setup first (/lingo setup).',
130} as const
131src/themes.ts 106 lines1// The lesson pane's color themes: raw hex colors handed to `color`,
2// `backgroundColor` and `borderColor` on Text and Box. Each activity has its
3// own color, and the tutor and the learner are told apart at a glance. The
4// values come from the UI mockup of the 2026-10-02 grilling.
5
6import type { LingoThemeName } from '../types'
7
8export type ThemeColors = {
9 /** The pane's background. */
10 bg: string
11 /** Ordinary text. */
12 fg: string
13 /** Hints and secondary lines. */
14 dim: string
15 /** Activity colors. */
16 conversation: string
17 roleplay: string
18 reading: string
19 review: string
20 /** Who speaks. */
21 tutor: string
22 you: string
23 /** A mistake. */
24 err: string
25 /** The frame around the answer field. */
26 ring: string
27}
28
29export type Theme = { name: LingoThemeName; label: string; colors: ThemeColors }
30
31export const DEFAULT_THEME: LingoThemeName = 'atardecer'
32
33// The mockup gives no color to `review` (it shows three activities); it borrows
34// the learner's own color, the warmest one that is not an activity's.
35export const THEMES: readonly Theme[] = [
36 {
37 name: 'atardecer',
38 label: 'Atardecer',
39 colors: {
40 bg: '#17202b',
41 fg: '#e8e3da',
42 dim: '#7e8a99',
43 conversation: '#ff8a65',
44 roleplay: '#b39ddb',
45 reading: '#4db6ac',
46 review: '#ffcc80',
47 tutor: '#90caf9',
48 you: '#ffcc80',
49 err: '#ef9a9a',
50 ring: '#ff8a65',
51 },
52 },
53 {
54 name: 'tropico',
55 label: 'Trópico',
56 colors: {
57 bg: '#1b1a22',
58 fg: '#ece6dc',
59 dim: '#8a8494',
60 conversation: '#ffb347',
61 roleplay: '#ff6f91',
62 reading: '#5fd38d',
63 review: '#ffd59e',
64 tutor: '#4fc3f7',
65 you: '#ffd59e',
66 err: '#ff6f91',
67 ring: '#ffb347',
68 },
69 },
70 {
71 name: 'pastel',
72 label: 'Pastel',
73 colors: {
74 bg: '#1e1e2e',
75 fg: '#cdd6f4',
76 dim: '#7f849c',
77 conversation: '#cba6f7',
78 roleplay: '#fab387',
79 reading: '#a6e3a1',
80 review: '#f5c2e7',
81 tutor: '#89dceb',
82 you: '#f5c2e7',
83 err: '#f38ba8',
84 ring: '#cba6f7',
85 },
86 },
87]
88
89/** The theme by name; the default for anything unknown. */
90export function themeByName(name: string): Theme {
91 return THEMES.find(t => t.name === name) ?? (THEMES[0] as Theme)
92}
93
94/** "Trópico", "tropico", " PASTEL " -> the theme's name; null when none matches. */
95export function parseThemeName(text: string): LingoThemeName | null {
96 const plain = text
97 .trim()
98 .toLowerCase()
99 .normalize('NFD')
100 .replace(/\p{M}/gu, '')
101 return THEMES.find(t => t.name === plain)?.name ?? null
102}
103
104export const isThemeName = (value: unknown): value is LingoThemeName =>
105 typeof value === 'string' && THEMES.some(t => t.name === value)
106src/microcards.ts 66 lines1// Provisional micro-lesson content: a short embedded list of English -> Spanish
2// cards. The real source will be a ContentStore (see strategies.ts); until
3// then nothing here pretends to cover other languages.
4
5export type MicroCard = { word: string; gloss: string }
6
7export const ENGLISH_TO_SPANISH: readonly MicroCard[] = [
8 { word: 'deadline', gloss: 'fecha límite' },
9 { word: 'workaround', gloss: 'solución provisional' },
10 { word: 'reliable', gloss: 'confiable' },
11 { word: 'to figure out', gloss: 'averiguar, descifrar' },
12 { word: 'to look into', gloss: 'investigar, revisar' },
13 { word: 'to roll back', gloss: 'revertir' },
14 { word: 'to rely on', gloss: 'depender de, confiar en' },
15 { word: 'to carry out', gloss: 'llevar a cabo' },
16]
17
18const ENGLISH = ['english', 'inglés', 'ingles']
19const SPANISH = ['spanish', 'español', 'espanol']
20
21const normalize = (language: string): string => language.trim().toLowerCase()
22
23/**
24 * The cards for a language pair, or the reason there are none. Only the pair
25 * the embedded list really covers is answered; nothing is translated on the fly.
26 */
27export function cardsFor(
28 target: string,
29 native: string,
30): { kind: 'cards'; cards: readonly MicroCard[] } | { kind: 'unsupported'; message: string } {
31 if (!ENGLISH.includes(normalize(target))) {
32 return { kind: 'unsupported', message: `no built-in cards for ${target.trim()} yet` }
33 }
34 if (!SPANISH.includes(normalize(native))) {
35 return { kind: 'unsupported', message: `no built-in cards explained in ${native.trim()} yet` }
36 }
37 return { kind: 'cards', cards: ENGLISH_TO_SPANISH }
38}
39
40/** A stable index in [0, count) from a seed (FNV-1a), so one turn keeps one card. */
41export function pickIndex(seed: string, count: number): number {
42 if (count <= 0) return 0
43 let hash = 0x811c9dc5
44 for (let i = 0; i < seed.length; i += 1) {
45 hash ^= seed.charCodeAt(i)
46 hash = Math.imul(hash, 0x01000193) >>> 0
47 }
48 return hash % count
49}
50
51/** The one line a turn shows: a card's word and gloss, or the "no cards" note. */
52export function microLesson(target: string, native: string, turnId: string): string {
53 const found = cardsFor(target, native)
54 if (found.kind === 'unsupported') return found.message
55 const card = found.cards[pickIndex(turnId, found.cards.length)]
56 return card === undefined ? '' : `${card.word} = ${card.gloss}`
57}
58
59/**
60 * The Spinner `suffix` carrying the lesson. The engine draws a rewritten
61 * suffix as given, so the ellipsis it would have drawn is kept in front.
62 */
63export function spinnerSuffix(lesson: string): string {
64 return lesson === '' ? '…' : `… · ${lesson}`
65}
66src/setup.ts 345 lines1// The guided setup as pure logic: the wizard's steps, the strategy catalogue,
2// validation, and reading the saved setup back from `$.store`. The pane that
3// draws it and the `$.store` / `$.state` calls live in hooks/register.tsx.
4
5import { DEFAULT_THEME, isThemeName } from './themes'
6import type {
7 LingoLevel,
8 LingoSetup,
9 LingoSetupDraft,
10 LingoSetupStep,
11 LingoSetupWizard,
12 LingoStrategyChoices,
13 LingoThemeName,
14 LingoTutorModel,
15} from '../types'
16
17/** `$.store` key holding the saved setup. */
18export const SETUP_STORE_KEY = 'setup'
19export const SETUP_VERSION = 1
20export const MAX_LANGUAGE_LENGTH = 40
21
22export const LEVELS: readonly LingoLevel[] = ['A1', 'A2', 'B1', 'B2', 'C1', 'C2']
23export const STEPS: readonly LingoSetupStep[] = [
24 'languages',
25 'level',
26 'interests',
27 'placement',
28 'strategies',
29 'preferences',
30 'summary',
31]
32
33/** The split's share of the terminal's width, in percent: the buttons, and the range a stored value may take. */
34export const SPLIT_SHARES: readonly number[] = [33, 40, 45, 50]
35export const DEFAULT_SPLIT_SHARE = 40
36export const MIN_SPLIT_SHARE = 33
37export const MAX_SPLIT_SHARE = 50
38
39/** Interests: a few short topics, typed comma-separated. */
40export const MAX_INTERESTS = 5
41export const MAX_INTEREST_LENGTH = 40
42export const MAX_INTERESTS_TEXT = 200
43
44export const DEFAULT_TUTOR_MODEL: LingoTutorModel = 'sonnet'
45export const TUTOR_MODELS: readonly { id: LingoTutorModel; label: string }[] = [
46 { id: 'sonnet', label: 'Sonnet' },
47 { id: 'haiku', label: 'Haiku (faster, lighter)' },
48 { id: 'opus', label: 'Opus (slower, uses more of your plan)' },
49]
50
51export type StrategyAxis = keyof LingoStrategyChoices
52export type StrategyOption = { id: string; label: string; isImplemented: boolean }
53export type StrategyAxisInfo = { axis: StrategyAxis; title: string; options: readonly StrategyOption[] }
54
55/**
56 * Every option the design names per axis. Only the implemented ones can be
57 * chosen; the rest are listed as "coming later". Ids match the built-ins
58 * (src/correction/socratic.ts, src/review/pimsleur.ts).
59 */
60export const STRATEGY_AXES: readonly StrategyAxisInfo[] = [
61 {
62 axis: 'correctionStyle',
63 title: 'Correction style',
64 options: [
65 { id: 'socratic', label: 'Socratic (hints before answers)', isImplemented: true },
66 { id: 'direct', label: 'Direct', isImplemented: false },
67 { id: 'progressive-hints', label: 'Progressive hints', isImplemented: false },
68 ],
69 },
70 {
71 axis: 'reviewAlgorithm',
72 title: 'Review algorithm',
73 options: [
74 { id: 'pimsleur', label: 'Pimsleur (n-1, n-3, n-7)', isImplemented: true },
75 { id: 'sm2', label: 'SM-2', isImplemented: false },
76 { id: 'fsrs', label: 'FSRS', isImplemented: false },
77 { id: 'leitner', label: 'Leitner', isImplemented: false },
78 ],
79 },
80 {
81 axis: 'contentStore',
82 title: 'Content store',
83 options: [
84 { id: 'local', label: 'Local (in $.store)', isImplemented: true },
85 { id: 'folder', label: 'Markdown/JSON folder', isImplemented: false },
86 { id: 'anki', label: 'Anki', isImplemented: false },
87 { id: 'obsidian', label: 'Obsidian', isImplemented: false },
88 ],
89 },
90 {
91 axis: 'activityLog',
92 title: 'Activity log',
93 options: [
94 { id: 'local', label: 'Local (in $.store)', isImplemented: true },
95 { id: 'markdown', label: 'Markdown file', isImplemented: false },
96 { id: 'none', label: 'None', isImplemented: false },
97 ],
98 },
99]
100
101export const DEFAULT_STRATEGIES: LingoStrategyChoices = {
102 contentStore: 'local',
103 reviewAlgorithm: 'pimsleur',
104 correctionStyle: 'socratic',
105 activityLog: 'local',
106}
107
108export const CLOSED_WIZARD: LingoSetupWizard = { isOpen: false, step: 'languages', draft: null }
109
110const isImplemented = (axis: StrategyAxis, id: string): boolean =>
111 STRATEGY_AXES.find(info => info.axis === axis)?.options.some(o => o.id === id && o.isImplemented) ?? false
112
113const sameLanguage = (a: string, b: string): boolean => a.trim().toLowerCase() === b.trim().toLowerCase()
114
115// --- Drafts -----------------------------------------------------------------
116
117const PREFERENCE_DEFAULTS = {
118 splitShare: DEFAULT_SPLIT_SHARE,
119 tutorModel: DEFAULT_TUTOR_MODEL,
120 theme: DEFAULT_THEME,
121 // The contextual mode is opt-in.
122 isContextual: false,
123} as const
124
125/** The assistant's starting point: the `userConfig` languages, no level yet. */
126export function draftFromConfig(nativeLanguage: string, targetLanguage: string): LingoSetupDraft {
127 return {
128 nativeLanguage,
129 targetLanguage,
130 level: null,
131 strategies: { ...DEFAULT_STRATEGIES },
132 interests: '',
133 ...PREFERENCE_DEFAULTS,
134 }
135}
136
137/** Re-running `/lingo setup` starts from what is saved. */
138export function draftFromSetup(setup: LingoSetup): LingoSetupDraft {
139 return {
140 nativeLanguage: setup.nativeLanguage,
141 targetLanguage: setup.targetLanguage,
142 level: setup.level,
143 strategies: { ...setup.strategies },
144 interests: setup.interests.join(', '),
145 splitShare: setup.splitShare,
146 tutorModel: setup.tutorModel,
147 theme: setup.theme,
148 isContextual: setup.isContextual,
149 }
150}
151
152/** "chess, cooking,, Chess , jazz" -> ["chess", "cooking", "jazz"]: trimmed, capped, no repeats. */
153export function parseInterests(text: string): string[] {
154 const seen = new Set<string>()
155 const result: string[] = []
156 for (const part of text.split(',')) {
157 const interest = part.trim().slice(0, MAX_INTEREST_LENGTH).trim()
158 const folded = interest.toLowerCase()
159 if (interest === '' || seen.has(folded)) continue
160 seen.add(folded)
161 result.push(interest)
162 if (result.length === MAX_INTERESTS) break
163 }
164 return result
165}
166
167export const isSplitShare = (value: unknown): value is number =>
168 typeof value === 'number' && Number.isInteger(value) && value >= MIN_SPLIT_SHARE && value <= MAX_SPLIT_SHARE
169
170export const isTutorModel = (value: unknown): value is LingoTutorModel =>
171 typeof value === 'string' && TUTOR_MODELS.some(m => m.id === value)
172
173/** Why a step cannot be left yet, or null when it can. */
174export function stepProblem(step: LingoSetupStep, draft: LingoSetupDraft): string | null {
175 if (step === 'languages') {
176 if (draft.nativeLanguage.trim() === '' || draft.targetLanguage.trim() === '') {
177 return 'Fill in both languages.'
178 }
179 if (sameLanguage(draft.nativeLanguage, draft.targetLanguage)) {
180 return 'The two languages must differ.'
181 }
182 }
183 if (step === 'level' && draft.level === null) return 'Pick a level.'
184 return null
185}
186
187export const canAdvance = (step: LingoSetupStep, draft: LingoSetupDraft): boolean =>
188 stepProblem(step, draft) === null
189
190// --- The wizard -------------------------------------------------------------
191
192export type WizardEvent =
193 | { type: 'open' }
194 | { type: 'close' }
195 | { type: 'set-field'; field: 'nativeLanguage' | 'targetLanguage'; value: string }
196 | { type: 'set-level'; level: LingoLevel }
197 | { type: 'set-strategy'; axis: StrategyAxis; id: string }
198 | { type: 'set-interests'; value: string }
199 | { type: 'set-share'; share: number }
200 | { type: 'set-tutor'; model: LingoTutorModel }
201 | { type: 'set-theme'; theme: LingoThemeName }
202 | { type: 'set-contextual'; isOn: boolean }
203 | { type: 'next' }
204 | { type: 'back' }
205 /** Placement: leave the optional test. Strategies: take the defaults and go on. */
206 | { type: 'skip' }
207
208const stepAt = (index: number): LingoSetupStep => STEPS[Math.max(0, Math.min(STEPS.length - 1, index))] ?? 'languages'
209
210/**
211 * The next wizard state. `defaults` is what a draft that was never touched
212 * stands for (the `userConfig` languages, or the saved setup being edited).
213 * Opening a wizard that is already open resumes it where it was.
214 */
215export function wizardTransition(
216 state: LingoSetupWizard,
217 event: WizardEvent,
218 defaults: LingoSetupDraft,
219): LingoSetupWizard {
220 const draft = state.draft ?? defaults
221 switch (event.type) {
222 case 'open':
223 return state.isOpen ? state : { isOpen: true, step: 'languages', draft: null }
224 case 'close':
225 return CLOSED_WIZARD
226 case 'set-field':
227 return { ...state, draft: { ...draft, [event.field]: event.value.slice(0, MAX_LANGUAGE_LENGTH) } }
228 case 'set-level':
229 return LEVELS.includes(event.level) ? { ...state, draft: { ...draft, level: event.level } } : state
230 case 'set-strategy':
231 // A "coming later" option cannot be chosen.
232 return isImplemented(event.axis, event.id)
233 ? { ...state, draft: { ...draft, strategies: { ...draft.strategies, [event.axis]: event.id } } }
234 : state
235 case 'set-interests':
236 return { ...state, draft: { ...draft, interests: event.value.slice(0, MAX_INTERESTS_TEXT) } }
237 case 'set-share':
238 return isSplitShare(event.share) ? { ...state, draft: { ...draft, splitShare: event.share } } : state
239 case 'set-tutor':
240 return isTutorModel(event.model) ? { ...state, draft: { ...draft, tutorModel: event.model } } : state
241 case 'set-theme':
242 return isThemeName(event.theme) ? { ...state, draft: { ...draft, theme: event.theme } } : state
243 case 'set-contextual':
244 return { ...state, draft: { ...draft, isContextual: event.isOn === true } }
245 case 'next':
246 return canAdvance(state.step, draft) && state.step !== 'summary'
247 ? { ...state, draft, step: stepAt(STEPS.indexOf(state.step) + 1) }
248 : state
249 case 'back':
250 return { ...state, draft, step: stepAt(STEPS.indexOf(state.step) - 1) }
251 case 'skip':
252 if (state.step === 'placement') return { ...state, draft, step: 'strategies' }
253 if (state.step === 'strategies') {
254 return { ...state, draft: { ...draft, strategies: { ...DEFAULT_STRATEGIES } }, step: 'preferences' }
255 }
256 return state
257 }
258}
259
260// --- Saving and reading back ------------------------------------------------
261
262/** The setup to save from a finished draft, or null when it is not valid. */
263export function buildSetup(draft: LingoSetupDraft, completedAt: string): LingoSetup | null {
264 if (!canAdvance('languages', draft) || draft.level === null) return null
265 const { strategies } = draft
266 const isValid = (Object.keys(DEFAULT_STRATEGIES) as StrategyAxis[]).every(axis =>
267 isImplemented(axis, strategies[axis]),
268 )
269 if (!isValid || !isSplitShare(draft.splitShare) || !isTutorModel(draft.tutorModel) || !isThemeName(draft.theme)) {
270 return null
271 }
272 return {
273 version: SETUP_VERSION,
274 targetLanguage: draft.targetLanguage.trim(),
275 nativeLanguage: draft.nativeLanguage.trim(),
276 level: draft.level,
277 strategies: { ...strategies },
278 interests: parseInterests(draft.interests),
279 splitShare: draft.splitShare,
280 tutorModel: draft.tutorModel,
281 theme: draft.theme,
282 isContextual: draft.isContextual,
283 completedAt,
284 }
285}
286
287/** The saved setup with another theme (`/lingo theme <name>`). */
288export const withTheme = (setup: LingoSetup, theme: LingoThemeName): LingoSetup => ({ ...setup, theme })
289
290const isObject = (value: unknown): value is Record<string, unknown> =>
291 typeof value === 'object' && value !== null && !Array.isArray(value)
292
293const isText = (value: unknown): value is string => typeof value === 'string' && value.trim() !== ''
294
295/**
296 * Whatever `$.store` held under `setup`, as a setup, or null when it is not one
297 * this version understands: missing, corrupt, another version, an unknown
298 * strategy id. Null means setup is pending. The fields added after the first
299 * setups were saved (interests, split share, tutor model, theme, the contextual
300 * opt-in) take their defaults when missing or unreadable, so an earlier setup
301 * stays done.
302 */
303export function parseSetup(raw: unknown): LingoSetup | null {
304 if (!isObject(raw) || raw.version !== SETUP_VERSION) return null
305 const { targetLanguage, nativeLanguage, level, strategies, completedAt, interests, splitShare, tutorModel, theme, isContextual } = raw
306 if (!isText(targetLanguage) || !isText(nativeLanguage) || !isText(completedAt)) return null
307 if (typeof level !== 'string' || !LEVELS.includes(level as LingoLevel)) return null
308 if (!isObject(strategies)) return null
309
310 const chosen = {} as Record<StrategyAxis, string>
311 for (const axis of Object.keys(DEFAULT_STRATEGIES) as StrategyAxis[]) {
312 const id = strategies[axis]
313 if (typeof id !== 'string' || !isImplemented(axis, id)) return null
314 chosen[axis] = id
315 }
316
317 return {
318 version: SETUP_VERSION,
319 targetLanguage,
320 nativeLanguage,
321 level: level as LingoLevel,
322 strategies: chosen,
323 interests: Array.isArray(interests)
324 ? parseInterests(interests.filter((i): i is string => typeof i === 'string').join(','))
325 : [],
326 splitShare: isSplitShare(splitShare) ? splitShare : DEFAULT_SPLIT_SHARE,
327 tutorModel: isTutorModel(tutorModel) ? tutorModel : DEFAULT_TUTOR_MODEL,
328 theme: isThemeName(theme) ? theme : DEFAULT_THEME,
329 // Only an explicit true turns the opt-in on.
330 isContextual: isContextual === true,
331 completedAt,
332 }
333}
334
335/**
336 * The setup in force: the one mirrored in `$.state` once loaded, else what the
337 * store held (`$.state` resets on /clear). Null means pending.
338 */
339export function resolveSetup(
340 cache: { isLoaded: boolean; setup: LingoSetup | null },
341 stored: unknown,
342): LingoSetup | null {
343 return cache.isLoaded ? cache.setup : parseSetup(stored)
344}
345src/wait-machine.ts 132 lines1// The waiting-state trigger as a pure state machine: what to show while Claude
2// is busy, and when the docked split opens and closes. Timers, `$.ui.open` and
3// the rest live in hooks/register.tsx; this file only decides the next state
4// from the current one and an event.
5
6import type { LingoPaneOpener, LingoWaitState } from '../types'
7
8/** Claude must be busy this long before anything is shown. */
9export const SHOW_DELAY_MS = 2000
10/** After the turn completes the lesson lingers this long, then goes. */
11export const CLOSE_DELAY_MS = 3000
12
13export const INITIAL_WAIT: LingoWaitState = {
14 phase: 'idle',
15 turnId: null,
16 startedAt: null,
17 pane: 'none',
18 opener: null,
19 isTouched: false,
20}
21
22export type WaitEvent =
23 | { type: 'turn-start'; turnId: string; at: number }
24 | { type: 'delay-elapsed'; turnId: string }
25 | { type: 'turn-complete'; turnId: string; isAborted: boolean }
26 | { type: 'countdown-elapsed'; turnId: string }
27 /** A permission ask or an AskUserQuestion: the learner is needed elsewhere. */
28 | { type: 'needs-user' }
29 /** A later tool call ran: whatever asked has been answered. */
30 | { type: 'user-answered' }
31 /** The split is on screen, opened by the mod itself while waiting or by the person. */
32 | { type: 'pane-placed'; opener: LingoPaneOpener }
33 /** The split cannot seat (main screen, too narrow, or not placed): the band offers it. */
34 | { type: 'pane-not-placed' }
35 /** The split is gone (the mod closed it, or the person did). */
36 | { type: 'pane-closed' }
37 /** The person pressed the offer button and opened the split themselves. */
38 | { type: 'offer-taken' }
39 /** A key typed in the split's field or one of its buttons pressed. Getting focus does not count. */
40 | { type: 'touched' }
41
42const PANE_CLOSED = { pane: 'none', opener: null, isTouched: false } as const
43
44/** Idle again; a split that is open stays (the hook decides whether to close it). */
45const idle = (state: LingoWaitState): LingoWaitState => ({
46 ...state,
47 phase: 'idle',
48 turnId: null,
49 startedAt: null,
50 pane: state.pane === 'offered' ? 'none' : state.pane,
51})
52
53export function transition(state: LingoWaitState, event: WaitEvent): LingoWaitState {
54 switch (event.type) {
55 case 'turn-start':
56 // A new turn re-evaluates the offer; a split that is open stays as it is.
57 return {
58 ...(state.pane === 'open' ? state : { ...state, ...PANE_CLOSED }),
59 phase: 'armed',
60 turnId: event.turnId,
61 startedAt: event.at,
62 }
63 case 'delay-elapsed':
64 return state.phase === 'armed' && state.turnId === event.turnId
65 ? { ...state, phase: 'showing' }
66 : state
67 case 'needs-user':
68 return state.phase === 'armed' || state.phase === 'showing'
69 ? { ...state, phase: 'paused' }
70 : state
71 case 'user-answered':
72 return state.phase === 'paused' ? { ...state, phase: 'showing' } : state
73 case 'turn-complete':
74 if (state.turnId !== event.turnId || state.phase === 'idle' || state.phase === 'closing') {
75 return state
76 }
77 // Only a lesson that was on screen gets the short countdown; an interrupted
78 // turn, or one that finished before anything showed (or while retired),
79 // goes idle at once.
80 return event.isAborted || state.phase !== 'showing' ? idle(state) : { ...state, phase: 'closing' }
81 case 'countdown-elapsed':
82 return state.phase === 'closing' && state.turnId === event.turnId ? idle(state) : state
83 case 'pane-placed':
84 return { ...state, pane: 'open', opener: event.opener, isTouched: false }
85 case 'pane-not-placed':
86 return { ...state, ...PANE_CLOSED, pane: 'offered' }
87 case 'pane-closed':
88 case 'offer-taken':
89 return { ...state, ...PANE_CLOSED }
90 case 'touched':
91 return state.pane === 'open' ? { ...state, isTouched: true } : state
92 }
93}
94
95/** The micro-lesson belongs in the spinner while the trigger is showing it. */
96export function isLessonVisible(state: LingoWaitState): boolean {
97 return (state.phase === 'showing' || state.phase === 'closing') && state.turnId !== null
98}
99
100/** The "open lesson" button belongs above the prompt only while showing. */
101export function isOfferVisible(state: LingoWaitState): boolean {
102 return state.phase === 'showing' && state.pane === 'offered'
103}
104
105/** Claude is working (or waiting on the person mid-turn). */
106export function isClaudeWorking(state: LingoWaitState): boolean {
107 return state.phase === 'armed' || state.phase === 'showing' || state.phase === 'paused'
108}
109
110/** The mod opened the split by itself and nobody has used it: it goes when the turn does. */
111export function isUntouchedAuto(state: LingoWaitState): boolean {
112 return state.pane === 'open' && state.opener === 'mod' && !state.isTouched
113}
114
115/**
116 * A split the learner is using: touched, or opened by the person (`/lingo`, the
117 * band). It stays after the turn and closes when its micro-unit ends with Claude
118 * idle, when the learner submits their next prompt, or by hand.
119 */
120export function isKeptOpen(state: LingoWaitState): boolean {
121 return state.pane === 'open' && (state.isTouched || state.opener === 'person')
122}
123
124/** When a completed turn closes the split: now, after the short countdown, or not at all. */
125export function closeOnTurnComplete(
126 state: LingoWaitState,
127 event: { turnId: string; isAborted: boolean },
128): 'now' | 'after-countdown' | 'keep' {
129 if (!isUntouchedAuto(state) || state.turnId !== event.turnId) return 'keep'
130 return transition(state, { type: 'turn-complete', ...event }).phase === 'closing' ? 'after-countdown' : 'now'
131}
132src/split.ts 45 lines1// Sizing and seating of the docked split, and the line that tells a learner
2// studying in it what Claude is doing. Pure: hooks/register.tsx reads the
3// viewport off the render events and calls `$.ui.open` with these numbers.
4
5import type { LingoWaitState } from '../types'
6
7/** The engine docks a pane only in fullscreen and from this many columns (144 for one it opens unasked the first time). */
8export const DOCK_MIN_COLUMNS = 110
9/** An inline pane (no room for the split) is opened about this share of the terminal's height. */
10export const INLINE_ROWS_SHARE = 0.4
11export const MIN_INLINE_ROWS = 8
12
13export type Viewport = { columns: number; rows: number; isFullscreen?: boolean }
14
15/** Whether the split may open by itself: fullscreen and wide enough to dock. */
16export function canSeatSplit(viewport: Viewport | null): boolean {
17 return viewport !== null && viewport.isFullscreen === true && viewport.columns >= DOCK_MIN_COLUMNS
18}
19
20/** The columns the docked split asks for: the share of the terminal's width. */
21export function splitColumns(terminalColumns: number, sharePercent: number): number {
22 return Math.max(1, Math.round((terminalColumns * sharePercent) / 100))
23}
24
25/** The rows an inline pane asks for: about 40 % of the terminal's height. */
26export function inlineRows(terminalRows: number): number {
27 return Math.max(MIN_INLINE_ROWS, Math.round(terminalRows * INLINE_ROWS_SHARE))
28}
29
30/** 41s, 2m 05s. */
31export function formatElapsed(ms: number): string {
32 const seconds = Math.max(0, Math.floor(ms / 1000))
33 if (seconds < 60) return `${seconds}s`
34 return `${Math.floor(seconds / 60)}m ${String(seconds % 60).padStart(2, '0')}s`
35}
36
37/** The top line of the split: what Claude is doing right now. */
38export function claudeStateLine(state: LingoWaitState, nowMs: number): string {
39 if (state.phase === 'paused') return '! Claude needs you · answer below'
40 if (state.phase === 'armed' || state.phase === 'showing') {
41 return state.startedAt === null ? '✻ Claude working' : `✻ Claude working · ${formatElapsed(nowMs - state.startedAt)}`
42 }
43 return '✓ Claude done'
44}
45src/activities.ts 111 lines1// The split's activities: which exist, which one the tutor suggests next (it
2// rotates, favouring the one done least recently, and never suggests review),
3// the log of finished micro-units kept in `$.store`, and the level hint (the
4// tutor suggests moving up or down; it never changes the level by itself).
5// Pure: hooks/register.tsx reads and writes the store.
6
7import { LEVELS } from './setup'
8import type { LingoActivity, LingoActivityLog, LingoLevel, LingoUnitSummary } from '../types'
9
10export const ACTIVITY_STORE_KEY = 'activity'
11export const ACTIVITY_LOG_VERSION = 1
12/** Finished units the log keeps. */
13export const MAX_RECENT = 20
14/** Talk units the level hint looks back on. */
15export const LEVEL_WINDOW = 5
16
17/** The `switch ▸` menu, in order (digits 1-4). */
18export const ACTIVITIES: readonly { id: LingoActivity; label: string }[] = [
19 { id: 'conversation', label: 'conversation' },
20 { id: 'roleplay', label: 'role-play' },
21 { id: 'reading', label: 'reading' },
22 { id: 'review', label: 'review' },
23]
24
25/** What the tutor rotates through. */
26export const SUGGESTED: readonly LingoActivity[] = ['conversation', 'roleplay', 'reading']
27
28export const activityLabel = (activity: LingoActivity): string =>
29 ACTIVITIES.find(a => a.id === activity)?.label ?? activity
30
31export const FRESH_LOG: LingoActivityLog = { version: ACTIVITY_LOG_VERSION, units: 0, lastDoneAt: {}, done: {}, recent: [] }
32
33const isObject = (value: unknown): value is Record<string, unknown> =>
34 typeof value === 'object' && value !== null && !Array.isArray(value)
35
36const isActivity = (value: unknown): value is LingoActivity => ACTIVITIES.some(a => a.id === value)
37
38const isCount = (value: unknown): value is number => typeof value === 'number' && Number.isInteger(value) && value >= 0
39
40/** Whatever `$.store` held under `activity`; a fresh log when it is not one. */
41export function parseActivityLog(raw: unknown): LingoActivityLog {
42 if (!isObject(raw) || raw.version !== ACTIVITY_LOG_VERSION || !isCount(raw.units)) return FRESH_LOG
43 const lastDoneAt: LingoActivityLog['lastDoneAt'] = {}
44 if (isObject(raw.lastDoneAt)) {
45 for (const [activity, at] of Object.entries(raw.lastDoneAt)) {
46 if (isActivity(activity) && typeof at === 'string') lastDoneAt[activity] = at
47 }
48 }
49 const done: LingoActivityLog['done'] = {}
50 if (isObject(raw.done)) {
51 for (const [activity, count] of Object.entries(raw.done)) {
52 if (isActivity(activity) && isCount(count)) done[activity] = count
53 }
54 }
55 const recent = (Array.isArray(raw.recent) ? raw.recent : [])
56 .filter(isObject)
57 .filter(r => isActivity(r.activity) && typeof r.at === 'string' && isCount(r.sentences) && isCount(r.corrections))
58 .map(r => ({
59 activity: r.activity as LingoActivity,
60 at: r.at as string,
61 sentences: r.sentences as number,
62 corrections: r.corrections as number,
63 }))
64 .slice(-MAX_RECENT)
65 return { version: ACTIVITY_LOG_VERSION, units: raw.units, lastDoneAt, done, recent }
66}
67
68/** A finished unit: counted, dated, and kept among the recent ones. */
69export function recordUnit(
70 log: LingoActivityLog,
71 activity: LingoActivity,
72 summary: LingoUnitSummary,
73 at: string,
74): LingoActivityLog {
75 return {
76 ...log,
77 units: log.units + 1,
78 lastDoneAt: { ...log.lastDoneAt, [activity]: at },
79 done: { ...log.done, [activity]: (log.done[activity] ?? 0) + 1 },
80 recent: [...log.recent, { activity, at, ...summary }].slice(-MAX_RECENT),
81 }
82}
83
84/** The activity the split opens into: never done first, else the one done least recently. */
85export function suggestActivity(log: LingoActivityLog, available: readonly LingoActivity[]): LingoActivity {
86 const candidates = SUGGESTED.filter(a => available.includes(a))
87 const never = candidates.find(a => log.lastDoneAt[a] === undefined)
88 if (never !== undefined) return never
89 const sorted = [...candidates].sort((a, b) => (log.lastDoneAt[a] ?? '').localeCompare(log.lastDoneAt[b] ?? ''))
90 return sorted[0] ?? 'conversation'
91}
92
93export type LevelHint = { direction: 'up' | 'down'; level: LingoLevel }
94
95/**
96 * After enough talk: no mistakes at all suggests the next level; most lines
97 * corrected suggests the one below. Only ever a suggestion.
98 */
99export function levelHint(log: LingoActivityLog, level: LingoLevel): LevelHint | null {
100 const talk = log.recent.filter(r => r.activity === 'conversation' || r.activity === 'roleplay').slice(-LEVEL_WINDOW)
101 if (talk.length < LEVEL_WINDOW) return null
102 const sentences = talk.reduce((n, r) => n + r.sentences, 0)
103 const corrections = talk.reduce((n, r) => n + r.corrections, 0)
104 const index = LEVELS.indexOf(level)
105 const above = LEVELS[index + 1]
106 const below = LEVELS[index - 1]
107 if (corrections === 0 && above !== undefined) return { direction: 'up', level: above }
108 if (sentences > 0 && corrections / sentences >= 0.6 && below !== undefined) return { direction: 'down', level: below }
109 return null
110}
111