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

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.
| Mod | How you use it | What it does |
|---|---|---|
| interview-coach | /coach | Spoken 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 | /nihongo | Japanese 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 | /pond | Animated 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, /aurora | Truecolor 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 | /buddy | A pixel companion docked beside the chat. Its mood follows Claude: thinking, happy after a reply, worried after a failure, sleepy after midnight. |
| chart | Claude calls it | Gives Claude a tool that draws bar, line, area and donut charts inline in the chat, with axes, labels and legends. |
| heatmap | Claude calls it | Inline truecolor heatmaps with heat, green and diverging palettes. |
| diagram | Claude calls it | Flowcharts drawn in the terminal: coloured boxes by role, laid out top to bottom with labelled arrows. |
| animate | Claude calls it | Plays short truecolor animations (fireworks, rain, aurora and others) inside a chat row at about 15 fps. |
| pixel | Claude calls it | Draws a pixel-art image in the chat from a JSON grid of colours. |
| change-ledger | /changes | Pane listing every file Claude created or edited this session. |
| em-dash-guard | automatic | Blocks any write or edit that adds an em dash, my house style for documents. |
| hello-mod | /hi | Starter 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.
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.
$ 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.$.ui.blit instead.session.start becomes callable only from the next prompt.-progress flag must come before -i, or progress output never arrives.hooks/register.tsx 549 lines1import { 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}
549types/index.d.ts 39 lines1export 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