SLOPSHOPPER

nihongo

Japanese tutor in a pane: weighted kana drill, N5 word cards, and a sentence builder that corrects you against this week's grammar

newpanebandcommandstatusprompt
v0.1.0MITupdated 2026-10-26Ishu-dev/claude-code-mods/mods/nihongo
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · nihongo
│ ┃ 日本語 ✕ › fix the failing auth test and add an audit log call │ ┃ 日本語 streak 0d │ ┃ ⏺ Read(src/auth.ts) │ ┃ [ Kana drill ] [ Word cards ] [ Sentence bui ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ Kana: type the romaji reading. Katakana and ⎿ Added 2 lines, removed 1 line │ ┃ look-alike pairs come up more often, and ⏺ Bash(bun test) │ ┃ anything you miss comes back sooner. ⎿ 3 pass, 1 fail │ ┃ │ ┃ Sentences: translate an English line into ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ kana or kanji. Romaji is refused, as the │ ┃ plan says. ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ [ Word band above the prompt: on ] › /nihongo │ ⎿ nihongo: Nihongo opened. Pick kana, words or sentences. │ │ 日本語 飲みます のみます to drink ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ nihongo: 日本語 0d

Draws

Band
日本語 飲みます のみます to drink
Pane · 日本語
日本語 streak 0d [ Kana drill ] [ Word cards ] [ Sentence builder ] Kana: type the romaji reading. Katakana and look-alike pairs come up more often, and anything you miss comes back sooner. Sentences: translate an English line into kana or kanji. Romaji is refused, as the plan says. [ Word band above the prompt: on ]
README

Claude Code mods

Thirteen mods I wrote for Claude Code in October 2026, the week mods were announced. A mod is a plugin of TypeScript hooks that runs inside Claude Code: it can draw panes and bands in the terminal, add slash commands, give Claude new tools, and block or react to tool calls. I built these to see how far the system goes, and a few of them I now use every day.

The one I use most is interview-coach: it asks a placement interview question aloud, records my spoken answer, transcribes it on the Mac with Whisper, and scores pace, filler words and structure against my own CV stories.

The mods

ModHow you use itWhat it does
interview-coach/coachSpoken mock interview in a side pane. Asks a question aloud, records from the mic, transcribes locally (Whisper large-v3-turbo via whisper.cpp), then shows speaking time against the target, words per minute, filler words, time spent on each step of the answer framework, and two stronger example answers drawn from your profile. Keeps a WPM trend across sessions.
nihongo/nihongoJapanese tutor pane: kana drill that weights look-alike pairs and brings misses back sooner, 44 N5 word cards, and a sentence builder where Claude Haiku sets a line from the week's grammar and corrects your kana answer. A word band above the prompt shows a new word each turn.
joka-pond/pondAnimated ASCII pond that earns XP for every tool Claude uses and climbs a consulting ladder from Intern to Managing Partner. Storms when a tool fails, keeps a daily streak.
aurora/visuals, /auroraTruecolor pixel art: an aurora band above the prompt whose colours follow the time of day, plus a gallery (plasma, ocean sunset, warp, a 4-4-6 breathing lotus).
avatar/buddyA pixel companion docked beside the chat. Its mood follows Claude: thinking, happy after a reply, worried after a failure, sleepy after midnight.
chartClaude calls itGives Claude a tool that draws bar, line, area and donut charts inline in the chat, with axes, labels and legends.
heatmapClaude calls itInline truecolor heatmaps with heat, green and diverging palettes.
diagramClaude calls itFlowcharts drawn in the terminal: coloured boxes by role, laid out top to bottom with labelled arrows.
animateClaude calls itPlays short truecolor animations (fireworks, rain, aurora and others) inside a chat row at about 15 fps.
pixelClaude calls itDraws a pixel-art image in the chat from a JSON grid of colours.
change-ledger/changesPane listing every file Claude created or edited this session.
em-dash-guardautomaticBlocks any write or edit that adds an em dash, my house style for documents.
hello-mod/hiStarter mod: an instant reply with no model call, a guard that blocks rm -rf, and a toast at the end of each turn.

statusline/statusline-command.sh is my status line: model, effort, context used, 5-hour and weekly usage with time left, folder and git branch, colour-coded green, yellow and red.

Try one

claude --plugin-dir ./mods/chart

Pass --plugin-dir once per mod you want. claude plugin validate ./mods/chart checks a mod before loading it.

interview-coach needs ffmpeg, whisper-cpp (brew install ffmpeg whisper-cpp) and a Whisper model file. Set the paths at the top of mods/interview-coach/hooks/register.tsx, and copy profile.example.md to the profile path with your own experiences.

What I learned building them

  • The $ context object can't be stored or passed around. Long-running work has to start from a worker in session.start; buttons only queue jobs for it.
  • Redrawing a pane on a timer rebuilds its buttons and swallows clicks. Animations repaint pixels with $.ui.blit instead.
  • A tool registered in session.start becomes callable only from the next prompt.
  • Plugin tool results must be strings, so structured results go back as JSON text.
  • ffmpeg's -progress flag must come before -i, or progress output never arrives.
Source 2 files
hooks/register.tsx 549 lines
1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { Card, Mode, Tally, Ui, Word } from '../types'
5
6const PANE = 'nihongo'
7const VOICE = 'Kyoko'
8const IST_MS = 19800000
9
10const pairs = (s: string) =>
11  s.split(',').map(p => {
12    const [c, r] = p.trim().split(' ')
13    return { c, r }
14  })
15
16const HIRA = pairs(
17  'あ a,い i,う u,え e,お o,か ka,き ki,く ku,け ke,こ ko,さ sa,し shi,す su,せ se,そ so,た ta,ち chi,つ tsu,て te,と to,' +
18    'な na,に ni,ぬ nu,ね ne,の no,は ha,ひ hi,ふ fu,へ he,ほ ho,ま ma,み mi,む mu,め me,も mo,や ya,ゆ yu,よ yo,' +
19    'ら ra,り ri,る ru,れ re,ろ ro,わ wa,を o,ん n',
20)
21const KATA = pairs(
22  'ア a,イ i,ウ u,エ e,オ o,カ ka,キ ki,ク ku,ケ ke,コ ko,サ sa,シ shi,ス su,セ se,ソ so,タ ta,チ chi,ツ tsu,テ te,ト to,' +
23    'ナ na,ニ ni,ヌ nu,ネ ne,ノ no,ハ ha,ヒ hi,フ fu,ヘ he,ホ ho,マ ma,ミ mi,ム mu,メ me,モ mo,ヤ ya,ユ yu,ヨ yo,' +
24    'ラ ra,リ ri,ル ru,レ re,ロ ro,ワ wa,ヲ o,ン n',
25)
26const HARD_HIRA = 'きさぬめねれわるろはほまそんあお'
27const HARD_KATA = 'シツソンノメヌクワケタチテスラフレルアマ'
28
29// From the confusion-pair table in the plan
30const HOOKS: Record<string, string> = {
31  シ: 'strokes come from the left and sweep up (a smile at the bottom)',
32  ツ: 'strokes come from the top and sweep down (tsunami falling)',
33  ソ: 'one short stroke on top, long stroke sweeps down',
34  ン: 'strokes start low on the left and sweep up',
35  ノ: 'one stroke',
36  メ: 'two strokes crossing',
37  ヌ: 'two strokes, the cross sits lower',
38  ス: 'sharp corner at the top right',
39  ラ: 'has the extra top stroke',
40  ル: 'two strokes',
41  き: 'two crossbars',
42  さ: 'one crossbar',
43  ぬ: 'the loop sits at the tail',
44  め: 'no loop at the tail',
45  ね: 'loops at the end',
46  れ: 'hooks to the right',
47  わ: 'curves without a hook',
48  る: 'loops at the end',
49  ろ: 'open at the end',
50  は: 'no extra horizontal',
51  ほ: 'has the extra horizontal',
52  ま: 'no vertical stem on the left',
53}
54
55const ALT: Record<string, string> = { si: 'shi', ti: 'chi', tu: 'tsu', hu: 'fu', wo: 'o' }
56
57const WORDS: Word[] = `私|わたし|I
58学生|がくせい|student
59先生|せんせい|teacher
60友達|ともだち|friend
61本|ほん|book
62水|みず|water
63食べます|たべます|to eat
64飲みます|のみます|to drink
65行きます|いきます|to go
66来ます|きます|to come
67見ます|みます|to see, to watch
68聞きます|ききます|to listen, to ask
69読みます|よみます|to read
70書きます|かきます|to write
71話します|はなします|to speak
72学校|がっこう|school
73家|うち|home
74車|くるま|car
75電車|でんしゃ|train
76大きい|おおきい|big
77小さい|ちいさい|small
78新しい|あたらしい|new
79古い|ふるい|old
80高い|たかい|expensive, tall
81安い|やすい|cheap
82おいしい|おいしい|delicious
83静か|しずか|quiet
84きれい|きれい|pretty, clean
85元気|げんき|well, healthy
86好き|すき|to like
87人|ひと|person
88犬|いぬ|dog
89猫|ねこ|cat
90机|つくえ|desk
91部屋|へや|room
92上|うえ|above
93下|した|below
94中|なか|inside
95隣|となり|next to
96前|まえ|in front
97後ろ|うしろ|behind
98今日|きょう|today
99明日|あした|tomorrow
100毎日|まいにち|every day`
101  .split('\n')
102  .map(l => {
103    const [jp, reading, en] = l.split('|')
104    return { jp, reading, en }
105  })
106
107const GRAMMAR = [
108  { from: '2026-09-07', week: 2, name: 'Copula and noun sentences', points: 'X は Y です, じゃないです, でした, じゃなかったです, か, の, これ/それ/あれ, この/その/あの, も, ね, よ, self-introduction' },
109  { from: '2026-09-14', week: 3, name: 'Verbs, part 1', points: 'ます/ません/ました/ませんでした, particles を に へ で, ませんか, ましょう' },
110  { from: '2026-09-21', week: 4, name: 'Adjectives', points: 'い-adjectives and な-adjectives with past and negative, とても, あまり + negative, あります vs います, うえ/した/なか/まえ/うしろ/となり' },
111  { from: '2026-09-28', week: 5, name: 'Consolidation of weeks 2 to 4', points: '' },
112  { from: '2026-10-19', week: 6, name: 'The て form', points: 'て form of all verb classes, てください, ています, てもいいです, てはいけません, から (because), が (but)' },
113  { from: '2026-10-26', week: 7, name: 'Past tense and desire', points: 'past ました forms, たい, ほしい, に行きます, counters 人 つ 本 枚 回' },
114  { from: '2026-11-02', week: 8, name: 'Plain form', points: 'plain present/past/negative of all verb classes, と思います, と言っていました' },
115  { from: '2026-11-09', week: 9, name: 'Modifying nouns', points: 'relative clauses, のが好きです, ことができます, たり〜たりします' },
116  { from: '2026-11-16', week: 10, name: 'Giving, receiving, obligation', points: 'あげる/くれる/もらう, なければいけません, なくてもいいです' },
117  { from: '2026-11-30', week: 11, name: 'Comparison and quantity', points: 'より, のほうが, いちばん, ぐらい, だけ, しか〜ない, adverbs from adjectives' },
118]
119
120const TOPICS = ['campus', 'food', 'family', 'train and travel', 'friends', 'the weekend', 'the classroom', 'shopping', 'weather', 'a daily routine']
121
122const isHard = (c: string) => HARD_HIRA.includes(c) || HARD_KATA.includes(c)
123
124const weighted = (list: { card: Card; w: number }[], skip: string | undefined): Card => {
125  const pool = list.filter(x => x.card.key !== skip)
126  const total = pool.reduce((n, x) => n + x.w, 0)
127  let roll = Math.random() * total
128  for (const x of pool) {
129    roll -= x.w
130    if (roll <= 0) return x.card
131  }
132  return pool[pool.length - 1].card
133}
134
135const mastery = (t: Tally | undefined) => {
136  const tot = t ? t.r + t.w : 0
137  const rate = t && tot > 0 ? t.w / tot : 0.3
138  return (tot < 3 ? 1.5 : 1) * (1 + 4 * rate)
139}
140
141const pickKana = (stats: Record<string, Tally>, skip: string | undefined): Card =>
142  weighted(
143    [...HIRA.map(k => ({ k, kata: false })), ...KATA.map(k => ({ k, kata: true }))].map(({ k, kata }) => {
144      const key = `k:${k.c}`
145      return {
146        card: { key, front: k.c, back: k.r, speak: k.c, extra: HOOKS[k.c] ?? '' },
147        w: (kata ? 1.6 : 1) * (isHard(k.c) ? 2 : 1) * mastery(stats[key]),
148      }
149    }),
150    skip,
151  )
152
153const pickWord = (stats: Record<string, Tally>, skip: string | undefined): Card =>
154  weighted(
155    WORDS.map(wd => {
156      const key = `w:${wd.jp}`
157      return {
158        card: { key, front: wd.jp, back: `${wd.reading}   ${wd.en}`, speak: wd.reading, extra: '' },
159        w: mastery(stats[key]),
160      }
161    }),
162    skip,
163  )
164
165const randomWord = (): Word => WORDS[Math.floor(Math.random() * WORDS.length)]
166
167const dayOf = (ms: number) => new Date(ms + IST_MS).toISOString().slice(0, 10)
168const before = (iso: string) => new Date(Date.parse(`${iso}T00:00:00Z`) - 86400000).toISOString().slice(0, 10)
169
170const streakOf = (days: string[], today: string) => {
171  const seen = new Set(days)
172  let cursor = seen.has(today) ? today : before(today)
173  let n = 0
174  while (seen.has(cursor)) {
175    n += 1
176    cursor = before(cursor)
177  }
178  return n
179}
180
181const unlocked = (today: string) => GRAMMAR.filter(g => g.from <= today)
182
183const parseJson = (text: string): any => {
184  const start = text.indexOf('{')
185  const end = text.lastIndexOf('}')
186  if (start < 0 || end <= start) return null
187  try {
188    return JSON.parse(text.slice(start, end + 1))
189  } catch {
190    return null
191  }
192}
193
194const isRomaji = (s: string) => /^[\x00-\x7f]*$/.test(s)
195
196const blank: Ui = {
197  mode: 'home', card: null, n: 0, revealed: false, result: 'idle',
198  feedback: '', fix: '', busy: false, right: 0, wrong: 0, error: '',
199}
200
201const ui = atom({ plugin: 'nihongo', key: 'ui' } as const, blank)
202const band = atom({ plugin: 'nihongo', key: 'band' } as const, null)
203const isBandOn = atom({ plugin: 'nihongo', key: 'isBandOn' } as const, true)
204const stats = atom({ plugin: 'nihongo', key: 'stats' } as const, {})
205const days = atom({ plugin: 'nihongo', key: 'days' } as const, [])
206
207type Job = { kind: 'gen' } | { kind: 'grade'; answer: string } | { kind: 'speak'; text: string }
208
209export const register: Register = on => {
210  // Buttons and inputs only queue a job; a worker started in session.start runs it,
211  // because $ cannot be stored and a handler's dispatch is short-lived.
212  const queue: Job[] = []
213  let isBusy = false
214
215  on('session.start', async ($, e, next) => {
216    const set = (patch: Partial<Ui>) => update($, ui, u => ({ ...u, ...patch }))
217
218    const pointsFor = async () => {
219      const today = dayOf(await $.clock.now())
220      const open = unlocked(today)
221      const current = open[open.length - 1]
222      const all = open.map(g => g.points).filter(p => p !== '').join('; ')
223      const focus = current && current.points !== '' ? current.points : all
224      return { all, focus, name: current ? `Week ${current.week}: ${current.name}` : 'Week 2' }
225    }
226
227    const gen = async () => {
228      await set({ busy: true, card: null, result: 'idle', feedback: '', fix: '', error: '' })
229      const { all, focus } = await pointsFor()
230      const topic = TOPICS[Math.floor(Math.random() * TOPICS.length)]
231      const r = await $.model.complete({
232        model: 'haiku',
233        timeoutMs: 30000,
234        effort: 'low',
235        maxTokens: 250,
236        prompt:
237          'You write translation drills for a beginner learning Japanese (JLPT N5). ' +
238          `Grammar the learner has seen so far: ${all}. Drill mostly this: ${focus}. Topic: ${topic}. ` +
239          'Write ONE short English sentence (max 10 words) for the learner to translate into Japanese, using only that grammar and basic N5 vocabulary. ' +
240          'Return ONLY JSON: {"en":"the English sentence","target":"a natural model answer in Japanese","point":"the grammar point it drills, in a few words"}. No em dashes.',
241      })
242      const j = r.isAnswered ? parseJson(r.text) : null
243      if (!j || typeof j.en !== 'string' || typeof j.target !== 'string') {
244        return set({ busy: false, error: `Could not get a sentence (${r.isAnswered ? 'bad reply' : r.reason}). Press Start to retry.` })
245      }
246      await update($, ui, u => ({
247        ...u, busy: false, n: u.n + 1,
248        card: { key: 's', front: j.en, back: j.target, speak: j.target, extra: String(j.point ?? '') },
249      }))
250    }
251
252    const grade = async (answer: string) => {
253      const cur = await read($, ui)
254      if (!cur.card) return
255      await set({ busy: true })
256      const { all } = await pointsFor()
257      const r = await $.model.complete({
258        model: 'haiku',
259        timeoutMs: 30000,
260        effort: 'low',
261        maxTokens: 300,
262        prompt:
263          'You are a strict but kind Japanese tutor for a beginner (JLPT N5). ' +
264          `Grammar seen so far: ${all}. English prompt: "${cur.card.front}". Model answer: "${cur.card.back}". Learner wrote: "${answer}". ` +
265          'Judge the learner sentence. ok is true if it is grammatical and carries the meaning, even if it differs from the model answer; kana versus kanji differences are fine. ' +
266          'If not ok, name the exact rule broken in under 25 words (for example: を marks the object of the verb). ' +
267          'Return ONLY JSON: {"ok":true|false,"corrected":"the corrected sentence, or the learner sentence if ok","rule":"the rule broken, or one line on what was good"}. No em dashes.',
268      })
269      const j = r.isAnswered ? parseJson(r.text) : null
270      if (!j) return set({ busy: false, error: `Could not grade that (${r.isAnswered ? 'bad reply' : r.reason}). Try again.` })
271      const ok = j.ok === true
272      const today = dayOf(await $.clock.now())
273      await update($, stats, s => {
274        const t = s['s'] ?? { r: 0, w: 0 }
275        return { ...s, s: { r: t.r + (ok ? 1 : 0), w: t.w + (ok ? 0 : 1) } }
276      })
277      await update($, days, d => (d.includes(today) ? d : [...d, today].slice(-400)))
278      await $.store.set('stats', await read($, stats))
279      await $.store.set('days', await read($, days))
280      await set({
281        busy: false,
282        result: ok ? 'right' : 'wrong',
283        feedback: String(j.rule ?? ''),
284        fix: String(j.corrected ?? ''),
285        right: cur.right + (ok ? 1 : 0),
286        wrong: cur.wrong + (ok ? 0 : 1),
287      })
288      void $.audio.speak(String(j.corrected ?? cur.card.back), { voice: VOICE }).catch(() => {})
289    }
290
291    $.clock.every(300, () => {
292      if (isBusy || queue.length === 0) return
293      const job = queue.shift() as Job
294      isBusy = true
295      const run =
296        job.kind === 'gen' ? gen() :
297        job.kind === 'grade' ? grade(job.answer) :
298        $.audio.speak(job.text, { voice: VOICE }).then(() => undefined)
299      void run
300        .catch(err => set({ busy: false, error: `Something broke: ${String(err)}` }))
301        .finally(() => { isBusy = false })
302    })
303
304    await $.command.register({ name: 'nihongo', description: 'Open the Japanese tutor: kana drill, word cards, sentence builder' })
305    const savedStats = ((await $.store.get('stats')) as Record<string, Tally> | undefined) ?? {}
306    const savedDays = ((await $.store.get('days')) as string[] | undefined) ?? []
307    await update($, stats, () => savedStats)
308    await update($, days, () => savedDays)
309    await update($, ui, () => blank)
310    await update($, band, () => randomWord())
311    const streak = streakOf(savedDays, dayOf(await $.clock.now()))
312    $.ui.status(`日本語 ${streak}d`)
313    return next(e)
314  })
315
316  on('command.run', { command: 'nihongo' }, async $ => {
317    await $.ui.open({ id: PANE, title: '日本語' })
318    return { text: 'Nihongo opened. Pick kana, words or sentences.' }
319  })
320
321  on('prompt.submit', async ($, e, next) => {
322    await update($, band, () => randomWord())
323    return next(e)
324  })
325
326  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
327    const word = await read($, band)
328    if (e.props.hasSurvey || word === null || !(await read($, isBandOn))) return next(e)
329    const { Box, Text } = $.ui.resolve(e)
330    return (
331      <Box>
332        <Text color="cyan">日本語 </Text>
333        <Text bold>{word.jp}</Text>
334        <Text dimColor>{word.jp === word.reading ? '' : `  ${word.reading}`}  {word.en}</Text>
335      </Box>
336    )
337  })
338
339  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
340    const { Box, Text, Button, Input } = $.ui.resolve(e)
341    const u = await read($, ui)
342    const st = await read($, stats)
343    const dd = await read($, days)
344    const today = dayOf(await $.clock.now())
345    const open = unlocked(today)
346    const current = open[open.length - 1]
347    const streak = streakOf(dd, today)
348    const total = u.right + u.wrong
349
350    const set = (patch: Partial<Ui>) => update($, ui, x => ({ ...x, ...patch }))
351
352    const record = async (key: string, ok: boolean) => {
353      await update($, stats, s => {
354        const t = s[key] ?? { r: 0, w: 0 }
355        return { ...s, [key]: { r: t.r + (ok ? 1 : 0), w: t.w + (ok ? 0 : 1) } }
356      })
357      await update($, days, d => (d.includes(today) ? d : [...d, today].slice(-400)))
358      await $.store.set('stats', await read($, stats))
359      await $.store.set('days', await read($, days))
360    }
361
362    const draw = async (mode: Mode, patch: Partial<Ui> = {}) => {
363      const s = await read($, stats)
364      const skip = (await read($, ui)).card?.key
365      const card = mode === 'kana' ? pickKana(s, skip) : pickWord(s, skip)
366      await update($, ui, x => ({ ...x, ...patch, mode, card, n: x.n + 1, revealed: false, result: 'idle', fix: '', error: '' }))
367    }
368
369    const go = (mode: Mode) => {
370      if (mode === 'sentence') {
371        queue.push({ kind: 'gen' })
372        return set({ ...blank, mode, busy: true })
373      }
374      return draw(mode, { ...blank, mode, feedback: '' })
375    }
376
377    const answerKana = async (value: string) => {
378      const card = u.card
379      const given = ALT[value.trim().toLowerCase()] ?? value.trim().toLowerCase()
380      if (!card || given === '') return
381      const ok = given === card.back
382      await record(card.key, ok)
383      if (ok) {
384        await draw('kana', { right: u.right + 1, feedback: `✓ ${card.front} = ${card.back}` })
385      } else {
386        await set({
387          result: 'wrong', revealed: true, wrong: u.wrong + 1,
388          feedback: `✗ ${card.front} is "${card.back}", you wrote "${given}"`,
389          fix: card.extra,
390        })
391      }
392    }
393
394    const answerSentence = async (value: string) => {
395      const text = value.trim()
396      if (text === '') return
397      if (isRomaji(text)) {
398        await set({ feedback: 'No romaji. Type it in kana (switch the Mac to Japanese input with ctrl+space, then try again).' })
399        return
400      }
401      queue.push({ kind: 'grade', answer: text })
402    }
403
404    const mark = async (ok: boolean) => {
405      if (!u.card) return
406      await record(u.card.key, ok)
407      await draw('words', {
408        right: u.right + (ok ? 1 : 0), wrong: u.wrong + (ok ? 0 : 1),
409        feedback: `${ok ? '✓' : '✗'} ${u.card.front}  ${u.card.back}`,
410      })
411    }
412
413    const listen = (text: string) => queue.push({ kind: 'speak', text })
414
415    // Weakest characters by miss rate, at least 2 attempts
416    const weak = Object.entries(st)
417      .filter(([k, t]) => k.startsWith('k:') && t.r + t.w >= 2 && t.w > 0)
418      .sort((a, b) => b[1].w / (b[1].r + b[1].w) - a[1].w / (a[1].r + a[1].w))
419      .slice(0, 8)
420      .map(([k]) => k.slice(2))
421      .join(' ')
422
423    const header = (
424      <Box flexDirection="column">
425        <Text>
426          <Text color="cyan" bold>日本語</Text>
427          <Text dimColor>  streak {streak}d</Text>
428          {current ? <Text dimColor>  |  Week {current.week}: {current.name}</Text> : null}
429        </Text>
430        {u.mode !== 'home' && <Text dimColor>This session: {u.right} right, {u.wrong} missed{total > 0 ? ` (${Math.round((u.right / total) * 100)}%)` : ''}</Text>}
431      </Box>
432    )
433
434    if (u.mode === 'home') {
435      return (
436        <Box flexDirection="column" gap={1}>
437          {header}
438          <Box gap={1}>
439            <Button key="kana" hotkey="1" label="Kana drill" onPress={() => void go('kana')} />
440            <Button key="words" hotkey="2" label="Word cards" onPress={() => void go('words')} />
441            <Button key="sent" hotkey="3" label="Sentence builder" onPress={() => void go('sentence')} />
442          </Box>
443          <Text dimColor>Kana: type the romaji reading. Katakana and look-alike pairs come up more often, and anything you miss comes back sooner.</Text>
444          <Text dimColor>Sentences: translate an English line into kana or kanji. Romaji is refused, as the plan says.</Text>
445          {weak !== '' && <Text color="yellow">Weakest kana: {weak}</Text>}
446          <Button key="band" label={`Word band above the prompt: ${(await read($, isBandOn)) ? 'on' : 'off'}`} onPress={() => void update($, isBandOn, v => !v)} />
447        </Box>
448      )
449    }
450
451    const back = <Button key="back" hotkey="b" label="Back" onPress={() => void set({ ...blank })} />
452    const flash = u.feedback !== '' && u.result !== 'wrong' && u.mode !== 'sentence'
453      ? <Text color={u.feedback.startsWith('✓') ? 'green' : 'red'}>{u.feedback}</Text>
454      : null
455
456    if (u.mode === 'kana' && u.card) {
457      return (
458        <Box flexDirection="column" gap={1}>
459          {header}
460          {flash}
461          <Text bold color="cyan">   {u.card.front}</Text>
462          {u.result === 'wrong' ? (
463            <Box flexDirection="column">
464              <Text color="red">{u.feedback}</Text>
465              {u.fix !== '' && <Text dimColor>Hook: {u.fix}</Text>}
466              <Box gap={1}>
467                <Button key="next" hotkey="n" variant="primary" label="Next" onPress={() => void draw('kana')} />
468                <Button key="hear" hotkey="l" label="Listen" onPress={() => listen(u.card!.speak)} />
469                {back}
470              </Box>
471            </Box>
472          ) : (
473            <Box flexDirection="column">
474              <Input key={`kana-${u.n}`} autoFocus label="Reading: " placeholder="e.g. ka" onSubmit={v => void answerKana(v)} />
475              <Box gap={1}>
476                <Button key="hear" hotkey="l" label="Listen" onPress={() => listen(u.card!.speak)} />
477                <Button key="skip" hotkey="s" label="Skip" onPress={() => void draw('kana')} />
478                {back}
479              </Box>
480            </Box>
481          )}
482        </Box>
483      )
484    }
485
486    if (u.mode === 'words' && u.card) {
487      return (
488        <Box flexDirection="column" gap={1}>
489          {header}
490          {flash}
491          <Text bold color="cyan">   {u.card.front}</Text>
492          {u.revealed ? <Text>   {u.card.back}</Text> : <Text dimColor>   What does it mean, and how is it read?</Text>}
493          <Box gap={1}>
494            {!u.revealed && <Button key="show" hotkey="s" variant="primary" label="Show answer" onPress={() => void set({ revealed: true })} />}
495            {u.revealed && <Button key="got" hotkey="g" variant="primary" label="Got it" onPress={() => void mark(true)} />}
496            {u.revealed && <Button key="miss" hotkey="m" label="Missed" onPress={() => void mark(false)} />}
497            <Button key="hear" hotkey="l" label="Listen" onPress={() => listen(u.card!.speak)} />
498            {back}
499          </Box>
500        </Box>
501      )
502    }
503
504    if (u.mode === 'sentence') {
505      const card = u.card
506      return (
507        <Box flexDirection="column" gap={1}>
508          {header}
509          {u.busy && <Text dimColor>Thinking...</Text>}
510          {u.error !== '' && <Text color="red">{u.error}</Text>}
511          {card && (
512            <Box flexDirection="column">
513              <Text bold color="cyan">{card.front}</Text>
514              {card.extra !== '' && <Text dimColor>Drills: {card.extra}</Text>}
515            </Box>
516          )}
517          {card && u.result === 'idle' && !u.busy && (
518            <Box flexDirection="column">
519              {u.feedback !== '' && <Text color="yellow">{u.feedback}</Text>}
520              <Input key={`sent-${u.n}`} autoFocus label="日本語: " placeholder="type in kana or kanji" onSubmit={v => void answerSentence(v)} />
521            </Box>
522          )}
523          {card && u.result !== 'idle' && (
524            <Box flexDirection="column">
525              <Text color={u.result === 'right' ? 'green' : 'red'}>{u.result === 'right' ? '✓ Good' : '✗ Not quite'}   {u.fix}</Text>
526              <Text>{u.feedback}</Text>
527              <Text dimColor>Model answer: {card.back}</Text>
528            </Box>
529          )}
530          <Box gap={1}>
531            {(card === null && !u.busy) && <Button key="start" hotkey="s" variant="primary" label="Start" onPress={() => void go('sentence')} />}
532            {card && u.result !== 'idle' && !u.busy && <Button key="next" hotkey="n" variant="primary" label="Next" onPress={() => void go('sentence')} />}
533            {card && !u.busy && <Button key="hear" hotkey="l" label="Listen" onPress={() => listen(card.back)} />}
534            {card && u.result === 'idle' && !u.busy && <Button key="skip" hotkey="k" label="Skip" onPress={() => void go('sentence')} />}
535            {back}
536          </Box>
537        </Box>
538      )
539    }
540
541    return (
542      <Box flexDirection="column" gap={1}>
543        {header}
544        {back}
545      </Box>
546    )
547  })
548}
549
types/index.d.ts 39 lines
1export type Mode = 'home' | 'kana' | 'words' | 'sentence'
2
3export type Card = {
4  key: string
5  front: string
6  back: string
7  speak: string
8  extra: string
9}
10
11export type Ui = {
12  mode: Mode
13  card: Card | null
14  n: number
15  revealed: boolean
16  result: 'idle' | 'right' | 'wrong'
17  feedback: string
18  fix: string
19  busy: boolean
20  right: number
21  wrong: number
22  error: string
23}
24
25export type Word = { jp: string; reading: string; en: string }
26export type Tally = { r: number; w: number }
27
28declare module 'claude-code' {
29  interface PluginState {
30    nihongo: {
31      ui: Ui
32      band: Word | null
33      isBandOn: boolean
34      stats: Record<string, Tally>
35      days: string[]
36    }
37  }
38}
39