SLOPSHOPPER

lingo-pane

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

newpanebandspinnerguardcommand
v0.1.0MITupdated 2026-10-05asdrubalivan/lingo-pane
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · lingo-pane
│ ┃ lingo-pane ✕ › fix the failing auth╭────────────────────────────────────────────╮ │ ┃ ○ Ctrl+X Tab to come back │ lingo-pane │ │ ┃ ✓ Claude done ⏺ Read(src/auth.ts) │ lingo-pane: setup pending. Run /lingo │ │ ┃ ⎿ Read 6 lines │ setup (or press 2 in the band above the │ │ ┃ lingo-pane setup ⏺ Update(src/auth.ts) │ prompt). │ │ ┃ Step 1 of 7 ⎿ Added 2 lines, re╰────────────────────────────────────────────╯ │ ┃ Type, Enter keeps the text, Tab moves to the ⏺ Bash(bun test) │ ┃ next field or button. ⎿ 3 pass, 1 fail │ ┃ Which languages? The explanations are in │ ┃ your native language. ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ Native language: : Spanish │ ┃ Target language: : English ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ Next › /lingo │ │ lingo-pane: setup pending. 2: Start setup 3: Later ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
lingo-pane: setup pending. 2: Start setup 3: Later
Pane · lingo-pane
○ Ctrl+X Tab to come back ✓ Claude done lingo-pane setup Step 1 of 7 Type, Enter keeps the text, Tab moves to the next field or button. Which languages? The explanations are in your native language. Native language: : Spanish Target language: : English Next
README

lingo-pane

English · Español · Français

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.

What it does

  • While Claude works, a split opens beside the transcript (fullscreen terminals from 110 columns; 40 % of the width by default) with a short lesson: a conversation, a role-play or a reading with the tutor, in micro-units of three replies. On narrower terminals a 1: open lesson button above the prompt opens it instead.
  • The tutor corrects you the socratic way (it points at the mistake and lets you try again); Enter on an empty line asks for help. Your mistakes become flashcards, reviewed Pimsleur-style and shown in the spinner line.
  • /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.
  • Everything that varies between learners is a strategy you can swap: where your content lives, how reviews are scheduled, how you are corrected and how your activity is logged.

Requirements

  • Claude Code 2.1.287 or later (mods are early access).
  • Terminal. Planned to work inside herdr.
  • A Claude subscription. The tutor makes its own model calls with your session's credentials, so no API key is needed, but it uses your plan's limits.

Privacy

Mods run outside the sandbox and can see your prompts and tool calls. What lingo-pane does with that:

  • Reads: when a turn starts and ends, whether a tool call needs your permission, and the terminal's size, to open and close the split at the right time. It does not keep your prompts or Claude's replies, unless you turn on the option below.
  • Stores (in Claude Code's plugin store on your machine): your setup, your progress in the built-in pack, the cards made from your mistakes and a short log of finished units.
  • Sends to Claude (with your session's credentials, on your plan): the tutor's requests, which hold your languages, level and interests, your lines in the lesson and the mistakes due for review. With the optional "material from your own session" setting on (off by default), also short excerpts of your last prompt and of Claude's last reply, with anything that looks like a key, a password or an email address removed first.
  • Sends to Google only when you press 🔊 listen: the phrase on screen, opened in Google Translate in your browser.

Nothing else leaves your machine.

Source 23 files
hooks/register.tsx 1591 lines
1import { 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 lines
1// 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]
43
src/lesson.ts 148 lines
1// 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}
148
src/correction/socratic.ts 22 lines
1import 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}
22
src/command.ts 52 lines
1// 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}
52
src/labels.ts 131 lines
1// 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
131
src/themes.ts 106 lines
1// 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)
106
src/microcards.ts 66 lines
1// 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}
66
src/setup.ts 345 lines
1// 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}
345
src/wait-machine.ts 132 lines
1// 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}
132
src/split.ts 45 lines
1// 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}
45
src/activities.ts 111 lines
1// 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