A German vocabulary game above the prompt while Claude works, with words from the session's topic

Claude Code mods by Shahriar.
Add the marketplace once:
/plugin marketplace add ShahriarBijoy/claude-mods
Then install the mods you want, and reload:
/plugin install stack@shahriar-mods
/plugin install wartezeit@shahriar-mods
/reload-plugins
To update installed mods later, run /plugin marketplace update shahriar-mods, then /reload-plugins.
Mods are an early-access Claude Code feature, so you need a recent Claude Code build (developed on 2.1.286).
A progress band above the prompt with one row per tracked thing:
It starts collapsed to one summary line. Focus the band with ctrl+x tab and press e to expand or collapse it, or use the commands:
/stack: toggle/stack expand and /stack collapse/stack hide <row> and /stack show <row|all>Rows are tasks, subagents, session, weekly, limits and costs.
Note: to backfill costs from before you installed it, stack runs npx --yes ccusage claude monthly --json on your machine at session start, at most every 12 hours. That downloads and runs the ccusage package. If the run fails, /stack reports why, and only months from the install onward are tracked.
A Duolingo-style German vocabulary game in the band above the prompt. It only shows while Claude is working; answer with 1–4 from the empty prompt.
claude -p run on Sonnet 5.5 writes a deck of about 22 words for it, using your Claude login. Until then, and when a run fails, you get a bundled deck of everyday words. A topic seen before reuses its saved deck without a new run.Commands:
/wartezeit: the current deck and mode/wartezeit mode <mixed|vocab|meaning|article|sentence>/wartezeit stats: streak, accuracy per card type, your 10 weakest words, and words learned per topicNote: each new topic costs one Sonnet 5.5 run against your Claude subscription usage (about $0.04, 25 seconds). The run is started with WARTEZEIT_CHILD=1, which makes the mod inside it do nothing.
hooks/register.tsx 434 lines1import { atom, read, update } from 'claude-code'
2import type { Elements, EngineInterface, Register, Timer } from 'claude-code'
3
4import type { WzCard, WzDeck, WzKind, WzMode, WzSignals, WzSummary } from '../types'
5import { addSignal, claudeArgv, deckFrom, hasEnoughSignals, parseClaudeOutput, topicKey } from './deck'
6import {
7 DAILY_GOAL,
8 XP_PER_CORRECT,
9 buildQuestion,
10 cardId,
11 chooseKind,
12 dayKey,
13 emptyKindStats,
14 leitner,
15 pickNext,
16 statsReport,
17 streakOf,
18} from './game'
19import type { Day, KindStats, Progress } from './game'
20import { GENERAL_DECK } from './general'
21import {
22 AMBER,
23 ORANGE,
24 PURPLE,
25 PURPLE_BG,
26 WHITE,
27 cardSvg,
28 feedbackOf,
29 feedbackSvg,
30 fit,
31 goalBar,
32 headerSvg,
33 seenOf,
34 sourceText,
35 tileTone,
36} from './view'
37import type { View } from './view'
38
39const deck = atom({ plugin: 'wartezeit', key: 'deck' } as const, GENERAL_DECK)
40const deckStatus = atom({ plugin: 'wartezeit', key: 'deckStatus' } as const, 'general')
41const signals = atom({ plugin: 'wartezeit', key: 'signals' } as const, { prompts: [], files: [], commands: [] })
42const question = atom({ plugin: 'wartezeit', key: 'question' } as const, null)
43const answer = atom({ plugin: 'wartezeit', key: 'answer' } as const, null)
44const activity = atom({ plugin: 'wartezeit', key: 'activity' } as const, 'Thinking')
45const frame = atom({ plugin: 'wartezeit', key: 'frame' } as const, 0)
46const index = atom({ plugin: 'wartezeit', key: 'index' } as const, 0)
47const mode = atom({ plugin: 'wartezeit', key: 'mode' } as const, 'mixed')
48const summary = atom({ plugin: 'wartezeit', key: 'summary' } as const, { today: 0, streak: 0, xp: 0, seen: [] })
49const answeredThisTurn = atom({ plugin: 'wartezeit', key: 'answeredThisTurn' } as const, 0)
50
51type $ = EngineInterface
52
53const MODES: WzMode[] = ['mixed', 'vocab', 'meaning', 'article', 'sentence']
54const ADVANCE_MS = 2000
55const SPINNER_MS = 150
56const SPINNER = ['·', '✢', '✳', '✶', '✻', '✽', '✻', '✶', '✳', '✢']
57const GENERATE_TIMEOUT_MS = 180_000
58
59// The store: what the learner has done, across sessions.
60const PROGRESS = 'progress'
61const DAYS = 'days'
62const KINDS = 'kinds'
63const XP = 'xp'
64const MODE = 'mode'
65const deckKey = (key: string) => `deck:${key}`
66
67// The deck's own `claude -p` runs this module too; there it stays out of the way, or every
68// deck it generates would start another.
69let childCheck: Promise<boolean> | undefined
70const isChild = ($: $): Promise<boolean> => (childCheck ??= $.env.get('WARTEZEIT_CHILD').then(value => value === '1'))
71
72let sessionId: string | undefined
73let spinner: Timer | undefined
74let advance: Timer | undefined
75
76const sessionOf = async ($: $): Promise<string> => (sessionId ??= await $.session.id())
77
78const storeRecord = async <T,>($: $, key: string): Promise<Record<string, T>> => {
79 const value = await $.store.get(key)
80 return value && typeof value === 'object' ? (value as Record<string, T>) : {}
81}
82
83// Learning ------------------------------------------------------------------------
84
85const refreshSummary = async ($: $) => {
86 const now = await $.clock.now()
87 const progress = await storeRecord<Progress>($, PROGRESS)
88 const days = await storeRecord<Day>($, DAYS)
89 const xp = Number(await $.store.get(XP)) || 0
90 const next: WzSummary = { today: days[dayKey(now)]?.correct ?? 0, streak: streakOf(days, now), xp, seen: Object.keys(progress) }
91 await update($, summary, () => next)
92}
93
94const poolOf = (current: WzDeck): WzCard[] => {
95 const byId = new Map<string, WzCard>()
96 for (const card of [...current.cards, ...GENERAL_DECK.cards]) if (!byId.has(cardId(card))) byId.set(cardId(card), card)
97 return [...byId.values()]
98}
99
100const nextQuestion = async ($: $) => {
101 advance?.cancel()
102 advance = undefined
103 const current = await read($, deck)
104 const pool = poolOf(current)
105 const progress = await storeRecord<Progress>($, PROGRESS)
106 const at = await read($, index)
107 const chosenMode = await read($, mode)
108 const picked = pickNext({
109 deck: current,
110 pool,
111 progress,
112 session: await sessionOf($),
113 now: await $.clock.now(),
114 index: at,
115 mode: chosenMode,
116 rng: Math.random,
117 })
118 if (!picked) return
119 const kind = chooseKind(picked.card, chosenMode, pool, Math.random)
120 const next = buildQuestion(picked.card, kind, pool, Math.random, {
121 isNew: progress[cardId(picked.card)] === undefined,
122 source: picked.source,
123 })
124 await update($, question, () => next)
125 await update($, answer, () => null)
126 await update($, index, n => n + 1)
127}
128
129const answerQuestion = async ($: $, picked: number) => {
130 const asked = await read($, question)
131 if (!asked || (await read($, answer)) !== null) return
132 const isCorrect = picked === asked.correct
133 await update($, answer, () => ({ picked, isCorrect }))
134 await update($, answeredThisTurn, n => n + 1)
135
136 const now = await $.clock.now()
137 const id = cardId(asked.card)
138 const progress = await storeRecord<Progress>($, PROGRESS)
139 const topic = (await read($, deck)).topic
140 progress[id] = leitner(progress[id], asked.card, isCorrect, { now, topic, session: await sessionOf($) })
141 await $.store.set(PROGRESS, progress)
142
143 const days = await storeRecord<Day>($, DAYS)
144 const today = days[dayKey(now)] ?? { correct: 0, answered: 0 }
145 days[dayKey(now)] = { correct: today.correct + (isCorrect ? 1 : 0), answered: today.answered + 1 }
146 await $.store.set(DAYS, days)
147
148 const kinds = { ...emptyKindStats(), ...(await storeRecord<KindStats[WzKind]>($, KINDS)) } as KindStats
149 kinds[asked.kind] = { right: kinds[asked.kind].right + (isCorrect ? 1 : 0), wrong: kinds[asked.kind].wrong + (isCorrect ? 0 : 1) }
150 await $.store.set(KINDS, kinds)
151 if (isCorrect) await $.store.set(XP, (Number(await $.store.get(XP)) || 0) + XP_PER_CORRECT)
152
153 await refreshSummary($)
154 advance?.cancel()
155 advance = $.clock.after(ADVANCE_MS, () => void nextQuestion($))
156}
157
158// Topic and deck ------------------------------------------------------------------
159
160const generateDeck = async ($: $) => {
161 const gathered = await read($, signals)
162 const key = topicKey(gathered)
163 const cached = (await $.store.get(deckKey(key))) as WzDeck | undefined
164 if (cached?.cards?.length) {
165 await update($, deck, () => cached)
166 await update($, deckStatus, () => 'cached')
167 return
168 }
169 try {
170 const ran = await $.process.run(claudeArgv(gathered), { env: { WARTEZEIT_CHILD: '1' }, timeoutMs: GENERATE_TIMEOUT_MS })
171 if (ran.exitCode !== 0) throw new Error(`claude exited ${ran.exitCode}: ${ran.stderr.trim().split('\n').pop() ?? ''}`)
172 const { deck: made, dropped } = deckFrom(parseClaudeOutput(ran.stdout), key)
173 if (dropped.length > 0) $.ui.log(`wartezeit: dropped ${dropped.length} cards: ${dropped.join('; ')}`, { to: 'debug' })
174 if (!made) throw new Error(`too few valid cards (${dropped.length} dropped)`)
175 await $.store.set(deckKey(key), made)
176 await update($, deck, () => made)
177 await update($, deckStatus, () => 'ready')
178 } catch (error) {
179 const reason = error instanceof Error ? error.message : String(error)
180 $.ui.log(`wartezeit: deck generation failed: ${reason}`, { to: 'debug' })
181 await update($, deckStatus, () => `failed: ${reason.slice(0, 120)}`)
182 }
183}
184
185const noteSignal = async ($: $, kind: keyof WzSignals, value: string) => {
186 await update($, signals, current => addSignal(current, kind, value))
187 if ((await read($, deckStatus)) !== 'general' || !hasEnoughSignals(await read($, signals))) return
188 await update($, deckStatus, () => 'generating')
189 $.clock.after(0, () => void generateDeck($))
190}
191
192// What Claude is doing, for the footer.
193const describeTool = (tool: string, args: Record<string, unknown>): string => {
194 const pick = (key: string) => (typeof args[key] === 'string' ? (args[key] as string) : '')
195 const file = (pick('file_path') || pick('notebook_path') || pick('path')).split(/[\\/]/).pop() ?? ''
196 const short = (text: string) => (text.length > 40 ? `${text.slice(0, 39)}…` : text)
197 if (tool === 'Read') return `Reading ${file}`
198 if (tool === 'Edit' || tool === 'MultiEdit' || tool === 'NotebookEdit') return `Editing ${file}`
199 if (tool === 'Write') return `Writing ${file}`
200 if (tool === 'Bash' || tool === 'PowerShell') return `Running ${short(pick('command').split('\n')[0] ?? '')}`
201 if (tool === 'Grep') return `Searching for ${short(pick('pattern'))}`
202 if (tool === 'Glob') return `Finding ${short(pick('pattern'))}`
203 if (tool === 'WebFetch' || tool === 'WebSearch') return 'Reading the web'
204 if (tool === 'Agent' || tool === 'Task') return 'Working with a subagent'
205 return `Using ${tool.replace(/^mcp__.+?__/, '')}`
206}
207
208// Drawing ---------------------------------------------------------------------------
209
210type AnyElements = Elements['terminal'] | Elements['desktop']
211
212const press = ($: $, option: number) => () => answerQuestion($, option)
213
214const drawTerminal = ($: $, elements: Elements['terminal'], view: View) => {
215 const { Box, Text, Button } = elements
216 const feedback = feedbackOf(view)
217 const width = view.columns - 4
218 const bar = goalBar(view.stats.today, 12)
219
220 const header = (
221 <Box key="header" flexDirection="row" gap={2}>
222 <Text color={WHITE} backgroundColor={PURPLE_BG} bold>{` ${fit(view.current.topic, 24)} `}</Text>
223 <Text>
224 <Text color={PURPLE}>{bar.filled}</Text>
225 <Text dimColor>{bar.empty}</Text>
226 <Text>{` ${view.stats.today}/${DAILY_GOAL}`}</Text>
227 </Text>
228 <Text color={AMBER} bold>{`▲ ${view.stats.streak}`}</Text>
229 <Text color={PURPLE} bold>{`✦ ${view.stats.xp} XP`}</Text>
230 </Box>
231 )
232 const source = <Text key="source" dimColor wrap="truncate">{fit(sourceText(view), width)}</Text>
233 const label = (
234 <Box key="label" flexDirection="row" gap={1}>
235 <Text dimColor>{view.asked.label}</Text>
236 {view.asked.isNew && <Text color={WHITE} backgroundColor={PURPLE} bold> new </Text>}
237 </Box>
238 )
239 const prompt = <Text key="prompt" bold wrap="truncate">{fit(view.asked.prompt, width)}</Text>
240 const tiles = (
241 <Box key="tiles" flexDirection="row" flexWrap="wrap" columnGap={1}>
242 {view.asked.options.map((option, i) => {
243 const tone = tileTone(view, i)
244 return (
245 <Box key={`tile:${i}`} backgroundColor={tone.bg} paddingX={1}>
246 {view.given ? (
247 <Text color={tone.fg} backgroundColor={tone.bg}>{`${i + 1}: ${option}`}</Text>
248 ) : (
249 <Button key={`answer:${i}`} hotkey={String(i + 1)} plain label={option} onPress={press($, i)} />
250 )}
251 </Box>
252 )
253 })}
254 </Box>
255 )
256 const headline = feedback && <Text key="feedback" color={feedback.tone} bold>{feedback.headline}</Text>
257 const example = feedback && (
258 <Text key="example" dimColor wrap="truncate">
259 {fit(`${view.asked.card.example_de} — ${view.asked.card.example_en}`, width)}
260 </Text>
261 )
262 const seen = seenOf(view)
263 const footer = (
264 <Box key="footer" flexDirection="row" justifyContent="space-between" width={width}>
265 <Text>
266 <Text color={ORANGE}>{view.spin} </Text>
267 <Text dimColor>{fit(view.doing, Math.max(8, width - seen.length - 4))}</Text>
268 </Text>
269 <Text dimColor>{seen}</Text>
270 </Box>
271 )
272
273 // The card and its answers always show; the rest joins while the band has rows for it.
274 const tileRows = Math.ceil(view.asked.options.reduce((cells, option) => cells + option.length + 6, 0) / Math.max(20, width))
275 let room = view.maxRows - 2 - tileRows
276 const take = (rows: number) => (room >= rows ? ((room -= rows), true) : false)
277 const showFooter = take(1)
278 const showHeadline = !!headline && take(1)
279 const showHeader = take(1)
280 const showExample = !!example && take(1)
281 const showSource = take(1)
282
283 return (
284 <Box flexDirection="column">
285 {showHeader && header}
286 {showSource && source}
287 {label}
288 {prompt}
289 {tiles}
290 {showHeadline && headline}
291 {showExample && example}
292 {showFooter && footer}
293 </Box>
294 )
295}
296
297const drawDesktop = ($: $, elements: Elements['desktop'], view: View) => {
298 const { Box, Text, Button, Svg } = elements
299 const width = Math.min(860, Math.max(360, Math.round(view.columns * 7.6) - 60))
300 const feedback = feedbackSvg(view, width)
301 return (
302 <Box flexDirection="column" gap={1}>
303 <Svg key="header" source={headerSvg(view, width)} alt={`${view.current.topic} · ${view.stats.today}/${DAILY_GOAL} today · streak ${view.stats.streak} · ${view.stats.xp} XP`} width={width} height={30} />
304 <Text key="source" dimColor wrap="truncate">{sourceText(view)}</Text>
305 <Svg key="card" source={cardSvg(view, width)} alt={`${view.asked.label}: ${view.asked.prompt}${view.asked.isNew ? ' (new)' : ''}`} width={width} height={58} />
306 <Box key="tiles" flexDirection="row" flexWrap="wrap" gap={1}>
307 {view.asked.options.map((option, i) => {
308 const tone = tileTone(view, i)
309 return (
310 <Box key={`tile:${i}`} backgroundColor={tone.bg} paddingX={1}>
311 {view.given ? (
312 <Text color={tone.fg}>{`${i + 1} ${option}`}</Text>
313 ) : (
314 <Button key={`answer:${i}`} hotkey={String(i + 1)} label={`${i + 1} ${option}`} onPress={press($, i)} />
315 )}
316 </Box>
317 )
318 })}
319 </Box>
320 {feedback && <Svg key="feedback" source={feedback} alt={`${feedbackOf(view)?.headline}. ${view.asked.card.example_de}`} width={width} height={50} />}
321 <Text key="footer" dimColor>{`${view.spin} ${view.doing} · ${seenOf(view)}`}</Text>
322 </Box>
323 )
324}
325
326// Hooks -----------------------------------------------------------------------------
327
328const stopSpinner = () => {
329 spinner?.cancel()
330 spinner = undefined
331}
332
333export const register: Register = on => {
334 on('session.start', async ($, e, next) => {
335 const started = await next(e)
336 if (await isChild($)) return started
337 await $.command.register({
338 name: 'wartezeit',
339 description: 'German vocabulary while Claude works: stats, or mode <mixed|vocab|meaning|article|sentence>',
340 argumentHint: '[stats | mode <mixed|vocab|meaning|article|sentence>]',
341 })
342 const saved = await $.store.get(MODE)
343 if (typeof saved === 'string' && (MODES as string[]).includes(saved)) await update($, mode, () => saved as WzMode)
344 await refreshSummary($)
345 return started
346 })
347
348 on('prompt.submit', async ($, e, next) => {
349 if ((await isChild($)) || e.text.trimStart().startsWith('/')) return next(e)
350 await update($, answeredThisTurn, () => 0)
351 await update($, activity, () => 'Thinking')
352 await noteSignal($, 'prompts', e.text)
353 if ((await read($, question)) === null || (await read($, answer)) !== null) await nextQuestion($)
354 stopSpinner()
355 spinner = $.clock.every(SPINNER_MS, () => void update($, frame, n => (n + 1) % SPINNER.length))
356 return next(e)
357 })
358
359 on('tool.call', async ($, e, next) => {
360 if (await isChild($)) return next(e)
361 const tool: string = e.tool
362 const args = e as unknown as Record<string, unknown>
363 const path = [args.file_path, args.notebook_path, args.path].find(value => typeof value === 'string') as string | undefined
364 if (path) await noteSignal($, 'files', path)
365 if ((tool === 'Bash' || tool === 'PowerShell') && typeof args.command === 'string') await noteSignal($, 'commands', args.command)
366 if (e.agentId) return next(e)
367 await update($, activity, () => describeTool(tool, args))
368 const ran = await next(e)
369 await update($, activity, () => 'Thinking')
370 return ran
371 })
372
373 on('turn.complete', async ($, e, next) => {
374 const done = await next(e)
375 if ((await isChild($)) || e.agentId) return done
376 stopSpinner()
377 const answered = await read($, answeredThisTurn)
378 if (answered > 0 || e.durationMs >= 5000) {
379 const { today, streak } = await read($, summary)
380 $.ui.toast(`Claude is done · ${today}/${DAILY_GOAL} today · streak ${streak}`)
381 }
382 return done
383 })
384
385 on('command.run', { command: 'wartezeit' }, async ($, e) => {
386 const [verb = '', choice = ''] = e.args.trim().toLowerCase().split(/\s+/)
387 if (verb === 'mode') {
388 if (!(MODES as string[]).includes(choice)) return { text: `Modes: ${MODES.join(', ')}. Now: ${await read($, mode)}.` }
389 await update($, mode, () => choice as WzMode)
390 await $.store.set(MODE, choice)
391 if ((await read($, answer)) === null) await nextQuestion($)
392 return { text: `wartezeit mode: ${choice}.` }
393 }
394 if (verb === 'stats') {
395 const report = statsReport({
396 progress: await storeRecord<Progress>($, PROGRESS),
397 days: await storeRecord<Day>($, DAYS),
398 kinds: { ...emptyKindStats(), ...(await storeRecord<KindStats[WzKind]>($, KINDS)) } as KindStats,
399 xp: Number(await $.store.get(XP)) || 0,
400 now: await $.clock.now(),
401 })
402 return { text: report }
403 }
404 const current = await read($, deck)
405 return {
406 text: `wartezeit: ${current.topic} (${current.cards.length} words, ${await read($, deckStatus)}) · mode ${await read($, mode)}. Commands: /wartezeit stats, /wartezeit mode <${MODES.join('|')}>.`,
407 }
408 })
409
410 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
411 if ((await isChild($)) || e.props.hasSurvey || !e.props.isWorking) return next(e)
412 if (e.surface !== 'terminal' && e.surface !== 'desktop') return next(e)
413 const asked = await read($, question)
414 if (!asked) return next(e)
415
416 const view: View = {
417 current: await read($, deck),
418 status: await read($, deckStatus),
419 gathered: await read($, signals),
420 stats: await read($, summary),
421 asked,
422 given: await read($, answer),
423 doing: await read($, activity),
424 spin: SPINNER[(await read($, frame)) % SPINNER.length] ?? '✻',
425 columns: e.props.bodyColumns,
426 maxRows: e.props.maxRows,
427 }
428 const elements: AnyElements = $.ui.resolve(e)
429 return e.surface === 'desktop'
430 ? drawDesktop($, elements as Elements['desktop'], view)
431 : drawTerminal($, elements as Elements['terminal'], view)
432 })
433}
434hooks/deck.ts 227 lines1import type { WzCard, WzDeck, WzSignals } from '../types'
2import { cardId } from './game'
3import { genderOf } from './nouns'
4
5// Signals -----------------------------------------------------------------------
6
7const STOP = new Set(
8 `the and for with this that from into onto make makes please can could would should will you your
9 me my our we they them their it its is are was were be been have has had not but also just then
10 than when what where which while why how all any some more most very much many few each every
11 here there about after before again need needs want wants like using use used get got set let lets
12 add adds fix fixes change changes update updates new old file files code line lines thing things
13 way still even only really maybe sure okay ok yes now today help work works working done do does
14 did one two three first last next same other another something anything everything
15 src lib dist build index main app apps test tests spec specs hooks utils util common core types
16 node_modules json yaml yml tsx jsx mjs cjs md txt users home tmp temp appdata local roaming`
17 .split(/\s+/)
18 .filter(Boolean),
19)
20
21const words = (text: string): string[] =>
22 text
23 .replace(/([a-z])([A-Z])/g, '$1 $2')
24 .toLowerCase()
25 .split(/[^a-zäöüß]+/)
26 .filter(word => word.length >= 4 && !STOP.has(word))
27
28const baseName = (path: string): string => path.split(/[\\/]/).filter(Boolean).pop() ?? path
29
30const pathWords = (path: string): string[] => {
31 const parts = path.split(/[\\/]/).filter(Boolean).slice(-3)
32 return parts.flatMap(part => words(part.replace(/\.[a-z0-9]+$/i, '')))
33}
34
35const commandName = (command: string): string => {
36 const [program = '', sub = ''] = command.trim().split(/\s+/)
37 const name = baseName(program).replace(/\.(exe|cmd|bat)$/i, '')
38 return /^[a-z][\w-]*$/i.test(sub) && ['git', 'npm', 'pnpm', 'yarn', 'bun', 'npx', 'docker', 'kubectl', 'cargo', 'go', 'gh'].includes(name)
39 ? `${name} ${sub}`
40 : name
41}
42
43export const addSignal = (signals: WzSignals, kind: keyof WzSignals, value: string): WzSignals => {
44 const clean = kind === 'commands' ? commandName(value) : value.trim()
45 if (!clean || signals[kind].includes(clean)) return signals
46 return { ...signals, [kind]: [...signals[kind], clean].slice(-40) }
47}
48
49// A topic needs a prompt with some words in it and a couple of files or commands, or a good
50// run of files and commands alone.
51export const hasEnoughSignals = (signals: WzSignals): boolean => {
52 const tools = signals.files.length + signals.commands.length
53 const promptWords = signals.prompts.reduce((count, prompt) => count + words(prompt).length, 0)
54 return (promptWords >= 3 && tools >= 2) || tools >= 6
55}
56
57// The three strongest words across the signals, in alphabetical order: the same work in a
58// later session lands on the same key, and its cached deck, without asking a model.
59export const topicKey = (signals: WzSignals): string => {
60 const scores = new Map<string, number>()
61 const add = (word: string, weight: number) => scores.set(word, (scores.get(word) ?? 0) + weight)
62 for (const prompt of signals.prompts) for (const word of words(prompt)) add(word, 2)
63 for (const file of signals.files) for (const word of pathWords(file)) add(word, 1)
64 for (const command of signals.commands) for (const word of words(command)) add(word, 1)
65 const top = [...scores].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).slice(0, 3).map(([word]) => word)
66 return top.length > 0 ? top.sort().join('-') : 'general'
67}
68
69const clip = (text: string, length: number): string => {
70 const flat = text.replace(/\s+/g, ' ').trim()
71 return flat.length > length ? `${flat.slice(0, length - 1)}…` : flat
72}
73
74const listed = (items: string[], max: number): string =>
75 items.length > max ? `${items.slice(0, max).join(', ')} +${items.length - max}` : items.join(', ')
76
77// The line under the header naming what the topic was read from.
78export const sourceLine = (signals: WzSignals): string => {
79 const parts: string[] = []
80 const prompt = signals.prompts[signals.prompts.length - 1]
81 if (prompt) parts.push(`prompt "${clip(prompt, 32)}"`)
82 if (signals.files.length > 0) parts.push(`files ${listed([...new Set(signals.files.map(baseName))].reverse(), 3)}`)
83 if (signals.commands.length > 0) parts.push(`commands ${listed([...signals.commands].reverse(), 3)}`)
84 return parts.join(' · ')
85}
86
87// The model call ------------------------------------------------------------------
88
89export const SYSTEM_PROMPT =
90 'You write German vocabulary decks for an English-speaking software developer learning German at A1 to B1 level. ' +
91 'Answer only through the structured output tool, in valid German with correct grammatical gender.'
92
93const CARD_SCHEMA = {
94 type: 'object',
95 properties: {
96 type: { enum: ['noun', 'verb', 'adjective'] },
97 de: { type: 'string' },
98 article: { enum: ['der', 'die', 'das'] },
99 en: { type: 'string' },
100 example_de: { type: 'string' },
101 example_en: { type: 'string' },
102 cloze: { type: 'string' },
103 },
104 required: ['type', 'de', 'en', 'example_de', 'example_en', 'cloze'],
105} as const
106
107export const DECK_SCHEMA = {
108 type: 'object',
109 properties: {
110 topic: { type: 'string' },
111 cards: { type: 'array', items: CARD_SCHEMA },
112 },
113 required: ['topic', 'cards'],
114} as const
115
116export const deckPrompt = (signals: WzSignals): string =>
117 [
118 'Here is what a developer is working on right now:',
119 ...signals.prompts.slice(-3).map(prompt => `- request: ${clip(prompt, 300)}`),
120 signals.files.length > 0 ? `- files: ${[...new Set(signals.files.map(baseName))].slice(-15).join(', ')}` : '',
121 signals.commands.length > 0 ? `- commands: ${signals.commands.slice(-8).join(', ')}` : '',
122 '',
123 'Make a German vocabulary deck about this topic:',
124 '- topic: a short English label for the topic, at most 24 characters, like "Login & sessions".',
125 '- About 14 domain words of the topic and about 8 everyday words around it (verbs, adjectives, common nouns), A1 to B1.',
126 '- de is the dictionary form: a noun in the singular without its article (put the article in "article"), a verb in the infinitive, an adjective in its base form.',
127 '- example_de is a short German sentence (at most 9 words) that contains de exactly as written; example_en translates it.',
128 '- cloze is example_de with de replaced by ___ . For a noun, keep its article in the sentence right before the gap and make the noun the subject in the singular. For a verb, put the infinitive after a modal verb. For an adjective, use it after "ist" or "sind".',
129 ]
130 .filter(line => line !== '')
131 .join('\n')
132
133// Sonnet 5.5 over Haiku: on the same session it wrote a deck in 25 s for $0.04 with one card
134// dropped, where Haiku took 109 s and $0.07 and lost four to the gender check.
135export const DECK_MODEL = 'claude-sonnet-5-5'
136
137export const claudeArgv = (signals: WzSignals): string[] => [
138 'claude',
139 '-p',
140 '--model',
141 DECK_MODEL,
142 '--output-format',
143 'json',
144 '--tools',
145 '',
146 '--no-session-persistence',
147 '--strict-mcp-config',
148 '--disable-slash-commands',
149 '--system-prompt',
150 SYSTEM_PROMPT,
151 '--json-schema',
152 JSON.stringify(DECK_SCHEMA),
153 deckPrompt(signals),
154]
155
156type RawDeck = { topic?: unknown; cards?: unknown }
157
158// `claude -p --output-format json` prints the run's messages, the last being the result,
159// with `--json-schema`'s answer as `structured_output` (an older build printed the result
160// object alone, its answer as JSON text in `result`).
161export const parseClaudeOutput = (stdout: string): RawDeck => {
162 const parsed: unknown = JSON.parse(stdout)
163 const messages = Array.isArray(parsed) ? parsed : [parsed]
164 const result = messages.find(message => (message as { type?: unknown })?.type === 'result') as
165 | { is_error?: boolean; result?: unknown; structured_output?: unknown }
166 | undefined
167 if (!result) throw new Error('no result message in the claude output')
168 if (result.is_error) throw new Error(`claude reported an error: ${String(result.result).slice(0, 200)}`)
169 if (result.structured_output && typeof result.structured_output === 'object') return result.structured_output as RawDeck
170 if (typeof result.result === 'string') return JSON.parse(result.result.slice(result.result.indexOf('{'))) as RawDeck
171 throw new Error('the claude result has no deck')
172}
173
174const text = (value: unknown): string => (typeof value === 'string' ? value.trim() : '')
175
176export type Validation = { cards: WzCard[]; dropped: string[] }
177
178// Every card the model wrote, kept only when it is whole: a noun's article must agree with
179// the bundled gender list, and a cloze must be its example with exactly the word cut out
180// (else the card stays, without a sentence question).
181export const validateCards = (raw: unknown): Validation => {
182 const cards: WzCard[] = []
183 const dropped: string[] = []
184 const seen = new Set<string>()
185 for (const entry of Array.isArray(raw) ? raw : []) {
186 const item = entry as Record<string, unknown>
187 const type = item.type
188 const de = text(item.de)
189 const en = text(item.en)
190 const example_de = text(item.example_de)
191 const example_en = text(item.example_en)
192 if ((type !== 'noun' && type !== 'verb' && type !== 'adjective') || !de || !en || !example_de || !example_en) {
193 dropped.push(`${de || '?'}: incomplete`)
194 continue
195 }
196 const card: WzCard = { type, de, en, example_de, example_en }
197 if (type === 'noun') {
198 const article = text(item.article)
199 const listed = genderOf(de)
200 if (de.includes(' ') || (article !== 'der' && article !== 'die' && article !== 'das')) {
201 dropped.push(`${de}: no article`)
202 continue
203 }
204 if (listed !== article) {
205 dropped.push(listed ? `${article} ${de}: the list says ${listed}` : `${article} ${de}: not in the gender list`)
206 continue
207 }
208 card.article = article
209 }
210 const cloze = text(item.cloze)
211 if (cloze.split('___').length === 2 && cloze.replace('___', de) === example_de) card.cloze = cloze
212 if (seen.has(cardId(card))) continue
213 seen.add(cardId(card))
214 cards.push(card)
215 }
216 return { cards, dropped }
217}
218
219// A deck needs enough words for a game; fewer and the session keeps the general deck.
220export const MIN_DECK = 8
221
222export const deckFrom = (raw: RawDeck, key: string): { deck: WzDeck | null; dropped: string[] } => {
223 const { cards, dropped } = validateCards(raw.cards)
224 const topic = clip(text(raw.topic) || key.replace(/-/g, ' '), 24)
225 return { deck: cards.length >= MIN_DECK ? { key, topic, cards } : null, dropped }
226}
227hooks/game.ts 282 lines1import type { WzArticle, WzCard, WzDeck, WzKind, WzMode, WzQuestion, WzSource } from '../types'
2
3export const DAILY_GOAL = 20
4export const XP_PER_CORRECT = 10
5const DAY_MS = 24 * 60 * 60 * 1000
6const ARTICLES: WzArticle[] = ['der', 'die', 'das']
7
8export type Rng = () => number
9
10// A small seeded generator, so a test can replay the choices a session made.
11export const seededRng = (seed: number): Rng => {
12 let state = seed >>> 0
13 return () => {
14 state = (state + 0x6d2b79f5) >>> 0
15 let t = state
16 t = Math.imul(t ^ (t >>> 15), t | 1)
17 t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
18 return ((t ^ (t >>> 14)) >>> 0) / 4294967296
19 }
20}
21
22const shuffle = <T>(items: readonly T[], rng: Rng): T[] => {
23 const out = [...items]
24 for (let i = out.length - 1; i > 0; i--) {
25 const j = Math.floor(rng() * (i + 1))
26 ;[out[i], out[j]] = [out[j] as T, out[i] as T]
27 }
28 return out
29}
30
31const pickSome = <T>(items: readonly T[], count: number, rng: Rng): T[] => shuffle(items, rng).slice(0, count)
32
33export const cardId = (card: WzCard): string => `${card.type}:${card.de}`
34
35export const withArticle = (card: WzCard): string => (card.type === 'noun' && card.article ? `${card.article} ${card.de}` : card.de)
36
37// Cards -----------------------------------------------------------------------
38
39// The other words a sentence gap could be confused with. A noun's distractors all have a
40// different gender, so with the article the sentence keeps, only the answer is grammatical.
41export const sentenceDistractors = (card: WzCard, pool: readonly WzCard[]): WzCard[] =>
42 pool.filter(
43 other =>
44 other.type === card.type &&
45 other.de !== card.de &&
46 (card.type !== 'noun' || (other.article !== undefined && other.article !== card.article)),
47 )
48
49export const kindsFor = (card: WzCard, pool: readonly WzCard[]): WzKind[] => {
50 const kinds: WzKind[] = ['vocab', 'meaning']
51 if (card.type === 'noun' && card.article) kinds.push('article')
52 if (card.cloze && sentenceDistractors(card, pool).length >= 2) kinds.push('sentence')
53 return kinds
54}
55
56const supports = (card: WzCard, mode: WzMode, pool: readonly WzCard[]): boolean =>
57 mode === 'mixed' || kindsFor(card, pool).includes(mode)
58
59const LABELS: Record<WzKind, string> = {
60 vocab: 'Translate into German',
61 meaning: 'What does this mean?',
62 article: 'der, die or das?',
63 sentence: 'Fill the gap',
64}
65
66// One question about `card`: its options shuffled, the correct one's index kept. `pool` is
67// where the other options come from (the session deck, then the general one).
68export const buildQuestion = (
69 card: WzCard,
70 kind: WzKind,
71 pool: readonly WzCard[],
72 rng: Rng,
73 meta: { isNew: boolean; source: WzSource },
74): WzQuestion => {
75 const resolved: WzKind = (kind === 'sentence' && !card.cloze) || (kind === 'article' && card.type !== 'noun') ? 'vocab' : kind
76 return questionOf(card, resolved, pool, rng, meta)
77}
78
79const questionOf = (
80 card: WzCard,
81 kind: WzKind,
82 pool: readonly WzCard[],
83 rng: Rng,
84 meta: { isNew: boolean; source: WzSource },
85): WzQuestion => {
86 const others = pool.filter(other => cardId(other) !== cardId(card))
87 let correct: string
88 let options: string[]
89 let prompt: string
90
91 if (kind === 'article') {
92 prompt = card.de
93 correct = card.article ?? 'der'
94 options = [...ARTICLES]
95 } else if (kind === 'meaning') {
96 prompt = withArticle(card)
97 correct = card.en
98 options = [correct, ...pickSome([...new Set(others.map(other => other.en))].filter(en => en !== card.en), 3, rng)]
99 } else if (kind === 'sentence' && card.cloze) {
100 prompt = card.cloze
101 correct = card.de
102 options = [correct, ...pickSome(sentenceDistractors(card, pool).map(other => other.de), 3, rng)]
103 } else {
104 prompt = card.en
105 correct = withArticle(card)
106 // A noun's tile set always holds the same noun with a wrong article: the trap to learn from.
107 const wrongArticle = card.type === 'noun' && card.article ? [`${pickSome(ARTICLES.filter(a => a !== card.article), 1, rng)[0]} ${card.de}`] : []
108 const fill = pickSome(others.map(withArticle).filter(text => text !== correct), 3 - wrongArticle.length, rng)
109 options = [correct, ...wrongArticle, ...fill]
110 }
111
112 const tiles = kind === 'article' ? options : shuffle(options, rng)
113 return {
114 card,
115 kind,
116 label: LABELS[kind],
117 prompt,
118 options: tiles,
119 correct: tiles.indexOf(correct),
120 isNew: meta.isNew,
121 source: meta.source,
122 }
123}
124
125export const chooseKind = (card: WzCard, mode: WzMode, pool: readonly WzCard[], rng: Rng): WzKind => {
126 const kinds = kindsFor(card, pool)
127 if (mode !== 'mixed' && kinds.includes(mode)) return mode
128 return kinds[Math.floor(rng() * kinds.length)] ?? 'vocab'
129}
130
131// Spaced repetition --------------------------------------------------------------
132
133export type Progress = {
134 card: WzCard
135 topic: string
136 session: string
137 box: number
138 due: number
139 right: number
140 wrong: number
141 lastSeen: number
142}
143
144// Leitner boxes 1–5: a right answer moves a card up a box, a wrong one back to the first.
145// Days until a card in each box is due again.
146export const BOX_DAYS = [0, 0, 1, 3, 7, 16] as const
147
148export const leitner = (
149 before: Progress | undefined,
150 card: WzCard,
151 isCorrect: boolean,
152 context: { now: number; topic: string; session: string },
153): Progress => {
154 const box = isCorrect ? Math.min(5, (before?.box ?? 1) + 1) : 1
155 return {
156 card,
157 topic: before?.topic ?? context.topic,
158 session: before?.session ?? context.session,
159 box,
160 due: context.now + (BOX_DAYS[box] ?? 0) * DAY_MS,
161 right: (before?.right ?? 0) + (isCorrect ? 1 : 0),
162 wrong: (before?.wrong ?? 0) + (isCorrect ? 0 : 1),
163 lastSeen: context.now,
164 }
165}
166
167// Two of every five questions are review slots: 60% new words, 40% due reviews.
168export const isReviewSlot = (index: number): boolean => index % 5 === 1 || index % 5 === 3
169
170export type Pick = { card: WzCard; source: WzSource }
171
172// The next card: in a review slot, the most overdue card from an earlier session; otherwise
173// a word of this session's deck not seen before. Each falls back to the other, and with
174// neither left, the deck's card seen longest ago.
175export const pickNext = (input: {
176 deck: WzDeck
177 pool: readonly WzCard[]
178 progress: Readonly<Record<string, Progress>>
179 session: string
180 now: number
181 index: number
182 mode: WzMode
183 rng: Rng
184}): Pick | null => {
185 const { deck, pool, progress, session, now, index, mode, rng } = input
186 const inDeck = deck.cards.filter(card => supports(card, mode, pool))
187 const fresh = inDeck.filter(card => progress[cardId(card)] === undefined)
188 const reviews = Object.values(progress)
189 .filter(entry => entry.session !== session && entry.due <= now && supports(entry.card, mode, pool))
190 .sort((a, b) => a.due - b.due)
191
192 const review = reviews[0]
193 const newCard = fresh.length > 0 ? fresh[Math.floor(rng() * fresh.length)] : undefined
194 if (isReviewSlot(index) && review) return { card: review.card, source: 'review' }
195 if (newCard) return { card: newCard, source: 'new' }
196 if (review) return { card: review.card, source: 'review' }
197
198 const practice = [...inDeck].sort((a, b) => (progress[cardId(a)]?.lastSeen ?? 0) - (progress[cardId(b)]?.lastSeen ?? 0))[0]
199 return practice ? { card: practice, source: 'practice' } : null
200}
201
202// Days, streaks and stats ---------------------------------------------------------
203
204export type Day = { correct: number; answered: number }
205export type KindStats = Record<WzKind, { right: number; wrong: number }>
206
207export const dayKey = (ms: number): string => {
208 const d = new Date(ms)
209 return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
210}
211
212// Days in a row with at least one right answer, ending today, or yesterday while today has
213// none yet: the streak is not broken until the day is over.
214export const streakOf = (days: Readonly<Record<string, Day>>, now: number): number => {
215 const practised = (ms: number) => (days[dayKey(ms)]?.correct ?? 0) > 0
216 let at = practised(now) ? now : now - DAY_MS
217 let streak = 0
218 while (practised(at)) {
219 streak++
220 at -= DAY_MS
221 }
222 return streak
223}
224
225export const emptyKindStats = (): KindStats => ({
226 vocab: { right: 0, wrong: 0 },
227 meaning: { right: 0, wrong: 0 },
228 article: { right: 0, wrong: 0 },
229 sentence: { right: 0, wrong: 0 },
230})
231
232const percent = (right: number, wrong: number): string =>
233 right + wrong === 0 ? '–' : `${Math.round((100 * right) / (right + wrong))}%`
234
235// A card counts as learned from the third box on: answered right at least twice in a row.
236const LEARNED_BOX = 3
237
238export const statsReport = (input: {
239 progress: Readonly<Record<string, Progress>>
240 days: Readonly<Record<string, Day>>
241 kinds: KindStats
242 xp: number
243 now: number
244}): string => {
245 const { progress, days, kinds, xp, now } = input
246 const entries = Object.values(progress)
247 const today = days[dayKey(now)]?.correct ?? 0
248 const lines = [
249 `Streak: ${streakOf(days, now)} days · today ${today}/${DAILY_GOAL} · ${xp} XP · ${entries.length} words seen`,
250 '',
251 'Accuracy by card type:',
252 ...(Object.keys(kinds) as WzKind[]).map(kind => {
253 const { right, wrong } = kinds[kind]
254 return ` ${kind.padEnd(9)} ${percent(right, wrong).padStart(4)} (${right} of ${right + wrong})`
255 }),
256 ]
257
258 const weakest = entries
259 .filter(entry => entry.wrong > 0)
260 .sort((a, b) => a.right / (a.right + a.wrong) - b.right / (b.right + b.wrong) || b.wrong - a.wrong)
261 .slice(0, 10)
262 lines.push('', 'Weakest words:')
263 if (weakest.length === 0) lines.push(' none yet')
264 for (const entry of weakest) {
265 lines.push(` ${withArticle(entry.card).padEnd(26)} ${percent(entry.right, entry.wrong).padStart(4)} ${entry.card.en}`)
266 }
267
268 const topics = new Map<string, { learned: number; seen: number }>()
269 for (const entry of entries) {
270 const topic = topics.get(entry.topic) ?? { learned: 0, seen: 0 }
271 topic.seen++
272 if (entry.box >= LEARNED_BOX) topic.learned++
273 topics.set(entry.topic, topic)
274 }
275 lines.push('', 'Words learned per topic:')
276 if (topics.size === 0) lines.push(' none yet')
277 for (const [topic, { learned, seen }] of [...topics].sort((a, b) => b[1].learned - a[1].learned)) {
278 lines.push(` ${topic.padEnd(26)} ${learned} learned of ${seen} seen`)
279 }
280 return lines.join('\n')
281}
282hooks/general.ts 59 lines1import type { WzCard, WzDeck } from '../types'
2
3const noun = (article: WzCard['article'], de: string, en: string, example_de: string, example_en: string): WzCard => ({
4 type: 'noun',
5 article,
6 de,
7 en,
8 example_de,
9 example_en,
10 cloze: example_de.replace(de, '___'),
11})
12
13const word = (type: 'verb' | 'adjective', de: string, en: string, example_de: string, example_en: string): WzCard => ({
14 type,
15 de,
16 en,
17 example_de,
18 example_en,
19 cloze: example_de.replace(de, '___'),
20})
21
22// Everyday words around working at a computer, A1–A2: the deck a session starts with, until
23// words from its own topic arrive. A verb's gap takes the infinitive (after a modal), an
24// adjective's the base form (after "ist"), so the answer is exactly the dictionary form.
25export const GENERAL_DECK: WzDeck = {
26 key: 'general',
27 topic: 'Everyday German',
28 cards: [
29 noun('die', 'Arbeit', 'work', 'Die Arbeit macht heute Spaß.', 'Work is fun today.'),
30 noun('der', 'Kaffee', 'coffee', 'Der Kaffee ist noch heiß.', 'The coffee is still hot.'),
31 noun('die', 'Zeit', 'time', 'Die Zeit vergeht schnell.', 'Time flies.'),
32 noun('das', 'Wochenende', 'weekend', 'Das Wochenende war sehr ruhig.', 'The weekend was very quiet.'),
33 noun('die', 'Pause', 'break', 'Die Pause dauert zehn Minuten.', 'The break lasts ten minutes.'),
34 noun('der', 'Bildschirm', 'screen', 'Der Bildschirm ist zu hell.', 'The screen is too bright.'),
35 noun('die', 'Nachricht', 'message', 'Die Nachricht ist gerade angekommen.', 'The message has just arrived.'),
36 noun('das', 'Ergebnis', 'result', 'Das Ergebnis sieht gut aus.', 'The result looks good.'),
37 noun('der', 'Fehler', 'mistake, error', 'Der Fehler tritt nur manchmal auf.', 'The error only happens sometimes.'),
38 noun('die', 'Aufgabe', 'task', 'Die Aufgabe ist schon erledigt.', 'The task is already done.'),
39 noun('der', 'Termin', 'appointment', 'Der Termin ist morgen um neun.', 'The appointment is tomorrow at nine.'),
40 noun('das', 'Problem', 'problem', 'Das Problem ist jetzt gelöst.', 'The problem is solved now.'),
41 noun('das', 'Fenster', 'window', 'Das Fenster ist offen.', 'The window is open.'),
42 noun('die', 'Tastatur', 'keyboard', 'Die Tastatur ist ganz neu.', 'The keyboard is brand new.'),
43 word('verb', 'warten', 'to wait', 'Ich muss kurz warten.', 'I have to wait a moment.'),
44 word('verb', 'arbeiten', 'to work', 'Wir wollen heute lange arbeiten.', 'We want to work late today.'),
45 word('verb', 'lernen', 'to learn', 'Ich möchte Deutsch lernen.', 'I would like to learn German.'),
46 word('verb', 'beginnen', 'to begin', 'Wir können jetzt beginnen.', 'We can begin now.'),
47 word('verb', 'schreiben', 'to write', 'Ich muss eine E-Mail schreiben.', 'I have to write an email.'),
48 word('verb', 'finden', 'to find', 'Ich kann den Schlüssel nicht finden.', "I can't find the key."),
49 word('verb', 'speichern', 'to save', 'Du solltest die Datei speichern.', 'You should save the file.'),
50 word('adjective', 'schnell', 'fast', 'Der Computer ist sehr schnell.', 'The computer is very fast.'),
51 word('adjective', 'fertig', 'finished, ready', 'Das Projekt ist fast fertig.', 'The project is almost finished.'),
52 word('adjective', 'wichtig', 'important', 'Diese Frage ist wichtig.', 'This question is important.'),
53 word('adjective', 'einfach', 'simple, easy', 'Die Lösung ist ganz einfach.', 'The solution is quite simple.'),
54 word('adjective', 'müde', 'tired', 'Ich bin heute müde.', 'I am tired today.'),
55 word('adjective', 'bereit', 'ready', 'Alles ist bereit.', 'Everything is ready.'),
56 word('adjective', 'langsam', 'slow', 'Das Internet ist heute langsam.', 'The internet is slow today.'),
57 ],
58}
59hooks/view.ts 118 lines1import type { WzAnswer, WzDeck, WzQuestion, WzSignals, WzSummary } from '../types'
2import { sourceLine } from './deck'
3import { DAILY_GOAL, cardId, withArticle } from './game'
4
5export const PURPLE = '#8B7CF6'
6export const PURPLE_BG = '#3B3466'
7export const AMBER = '#F5B841'
8export const GREEN = '#3FB950'
9export const GREEN_BG = '#2EA043'
10export const ORANGE = '#F0883E'
11export const ORANGE_BG = '#D9732B'
12export const TILE_BG = '#2F2C42'
13export const TILE_FG = '#ECEAF5'
14export const WHITE = '#FFFFFF'
15
16export type View = {
17 current: WzDeck
18 status: string
19 gathered: WzSignals
20 stats: WzSummary
21 asked: WzQuestion
22 given: WzAnswer | null
23 doing: string
24 spin: string
25 columns: number
26 maxRows: number
27}
28
29export const seenOf = (view: View) => {
30 const seen = new Set(view.stats.seen)
31 return `${view.current.cards.filter(card => seen.has(cardId(card))).length} of ${view.current.cards.length} words seen`
32}
33
34export const sourceText = (view: View): string => {
35 const from = sourceLine(view.gathered)
36 if (view.status === 'generating') return `Finding words for this session${from ? ` · from ${from}` : ''}`
37 if (view.status.startsWith('failed')) return `Everyday words · session words failed (${view.status.slice(8)})`
38 if (view.status === 'general') return 'Everyday words · session words arrive once the topic is clear'
39 return `From ${from || 'this session'}`
40}
41
42export const feedbackOf = (view: View) => {
43 if (!view.given) return null
44 const right = view.asked.options[view.asked.correct] ?? ''
45 const full = view.asked.kind === 'article' || view.asked.kind === 'sentence' ? withArticle(view.asked.card) : right
46 return view.given.isCorrect ? { tone: GREEN, headline: `✓ Richtig! ${full}` } : { tone: ORANGE, headline: `✗ Fast! It's ${full}` }
47}
48
49export const tileTone = (view: View, option: number) => {
50 if (!view.given) return { bg: TILE_BG, fg: TILE_FG }
51 if (option === view.asked.correct) return { bg: GREEN_BG, fg: WHITE }
52 if (option === view.given.picked) return { bg: ORANGE_BG, fg: WHITE }
53 return { bg: TILE_BG, fg: TILE_FG }
54}
55
56export const fit = (text: string, cells: number) => (text.length > cells ? `${text.slice(0, Math.max(0, cells - 1))}…` : text)
57
58export const goalBar = (today: number, cells: number) => {
59 const filled = Math.round((Math.min(today, DAILY_GOAL) / DAILY_GOAL) * cells)
60 return { filled: '━'.repeat(filled), empty: '─'.repeat(cells - filled) }
61}
62
63const escapeXml = (text: string) => text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
64
65export const FONT = "font-family=\"ui-sans-serif, 'Segoe UI', system-ui, -apple-system, sans-serif\""
66
67export const headerSvg = (view: View, width: number) => {
68 const chip = fit(view.current.topic, 26)
69 const chipW = Math.round(chip.length * 7.4 + 24)
70 const barX = chipW + 18
71 const barW = Math.max(80, Math.min(220, width - barX - 230))
72 const fill = (Math.min(view.stats.today, DAILY_GOAL) / DAILY_GOAL) * barW
73 const after = barX + barW + 12
74 return [
75 `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="30" viewBox="0 0 ${width} 30" ${FONT}>`,
76 `<style>.t{fill:#ECE9E2}.m{fill:#9A968E}.track{fill:#3A3835}@media (prefers-color-scheme: light){.t{fill:#2A2925}.m{fill:#77736B}.track{fill:#E6E2DA}}</style>`,
77 `<rect x="0" y="3" width="${chipW}" height="24" rx="12" fill="${PURPLE}" fill-opacity="0.2"/>`,
78 `<text x="${chipW / 2}" y="15" text-anchor="middle" dominant-baseline="central" font-size="13" font-weight="600" fill="${PURPLE}">${escapeXml(chip)}</text>`,
79 `<rect class="track" x="${barX}" y="12" width="${barW}" height="6" rx="3"/>`,
80 fill > 0 ? `<rect x="${barX}" y="12" width="${Math.max(6, fill).toFixed(1)}" height="6" rx="3" fill="${PURPLE}"/>` : '',
81 `<text class="m" x="${after}" y="15" dominant-baseline="central" font-size="12.5">${view.stats.today}/${DAILY_GOAL} today</text>`,
82 `<text x="${after + 96}" y="15" dominant-baseline="central" font-size="13" font-weight="600" fill="${AMBER}">▲ ${view.stats.streak}</text>`,
83 `<text x="${after + 140}" y="15" dominant-baseline="central" font-size="13" font-weight="600" fill="${PURPLE}">✦ ${view.stats.xp} XP</text>`,
84 '</svg>',
85 ].join('')
86}
87
88export const cardSvg = (view: View, width: number) => {
89 const prompt = fit(view.asked.prompt, Math.floor(width / 12))
90 const badgeX = view.asked.label.length * 5.9 + 8
91 return [
92 `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="58" viewBox="0 0 ${width} 58" ${FONT}>`,
93 `<style>.t{fill:#F0EDE6}.m{fill:#9A968E}@media (prefers-color-scheme: light){.t{fill:#2A2925}.m{fill:#77736B}}</style>`,
94 `<text class="m" x="0" y="11" dominant-baseline="central" font-size="12">${escapeXml(view.asked.label)}</text>`,
95 view.asked.isNew
96 ? `<rect x="${badgeX}" y="3" width="34" height="16" rx="8" fill="${PURPLE}"/><text x="${badgeX + 17}" y="11" text-anchor="middle" dominant-baseline="central" font-size="10.5" font-weight="700" fill="${WHITE}">new</text>`
97 : '',
98 `<text class="t" x="0" y="40" dominant-baseline="central" font-size="22" font-weight="600">${escapeXml(prompt)}</text>`,
99 '</svg>',
100 ].join('')
101}
102
103export const feedbackSvg = (view: View, width: number) => {
104 const feedback = feedbackOf(view)
105 if (!feedback) return null
106 const example = fit(`${view.asked.card.example_de} — ${view.asked.card.example_en}`, Math.floor((width - 28) / 6.6))
107 return [
108 `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="50" viewBox="0 0 ${width} 50" ${FONT}>`,
109 `<style>.m{fill:#B8B4AC}@media (prefers-color-scheme: light){.m{fill:#5A5650}}</style>`,
110 `<rect x="0" y="0" width="${width}" height="50" rx="10" fill="${feedback.tone}" fill-opacity="0.14"/>`,
111 `<rect x="0" y="0" width="4" height="50" rx="2" fill="${feedback.tone}"/>`,
112 `<text x="14" y="16" dominant-baseline="central" font-size="14" font-weight="700" fill="${feedback.tone}">${escapeXml(feedback.headline)}</text>`,
113 `<text class="m" x="14" y="35" dominant-baseline="central" font-size="12.5">${escapeXml(example)}</text>`,
114 '</svg>',
115 ].join('')
116}
117
118hooks/nouns.ts 121 lines1import type { WzArticle } from '../types'
2
3// German noun genders the decks are checked against: everyday A1–B1 nouns and the nouns of
4// work and software. A noun the model writes with another article, or one the list cannot
5// place, is dropped from the deck, so a card never teaches a wrong article. Nouns whose
6// gender depends on the meaning (der/die See, der/das Teil) are left out on purpose.
7const DER = `
8Abend Abfall Ablauf Absatz Abschluss Abschnitt Absender Abstand Akku Alarm Algorithmus
9Alltag Anbieter Anfang Anhang Anruf Anschluss Ansatz Antrag Anwalt Apfel April Arm Arzt Ast
10Aufbau Aufruf Aufwand Augenblick August Ausdruck Ausgang Ausweis Auszug Autor Bahnhof Ball
11Bauch Baum Baustein Bedarf Befehl Beitrag Benutzer Bereich Berg Bericht Beruf Besuch Betrieb
12Beweis Bezug Bildschirm Bleistift Blick Block Boden Branch Brief Browser Bruder Buchstabe Bug
13Bus Button Cache Chef Code Compiler Computer Container Cursor Dank Datensatz Deckel
14Dezember Dialog Dienst Dienstag Donnerstag Drucker Durst Eimer Eindruck Eingang Eintrag
15Einfluss Empfänger Entwickler Entwurf Erfolg Export Fahrer Fahrplan Fall Februar Fehler
16Feierabend Feiertag Feind Fernseher Film Filter Finger Fisch Flughafen Fluss Fokus Fortschritt
17Freitag Freund Frühling Fuß Gang Garten Gast Gedanke Geburtstag Gegenstand Geldbeutel Gewinn
18Gipfel Glaube Gott Grund Gruß Hacker Hafen Haken Hals Hammer Handel Herbst Herr
19Hintergrund Himmel Hinweis Hof Hund Hunger Hut Import Inhaber Inhalt Januar Job Juli Junge
20Juni Kaffee Kalender Kampf Kanal Käse Kauf Kellner Kern Knopf Koch Koffer Kollege Kommentar
21Kompromiss Kontakt Kopf Kopfhörer Körper Kreis Krieg Kuchen Kühlschrank Kunde Kurs Kuss Laden
22 Lauf Lehrer Leser Link Löffel Lohn Löwe Mai Mann Mantel Markt März Mittag Mittwoch Modus
23Monat Mond Monitor Montag Morgen Motor Mund Nachbar Nachteil Name Nebel November Nutzer Ofen
24Oktober Onkel Ordner Ort Osten Park Partner Pass Pfad Pfeil Pilot Plan Planet Platz Port
25Preis Prompt Prototyp Prozess Prozessor Puffer Pullover Punkt Quellcode Rahmen Rand Raum
26Rechner Regen Reis Rest Rhythmus Ring Rock Rücken Ruf Saft Salat Samstag Sand Satz Schaden
27Schalter Schatten Schein Schirm Schlaf Schlüssel Schmerz Schnee Schrank Schreibtisch Schritt
28Schuh Schüler Schutz Schwerpunkt September Server Sessel Sieg Sinn Sohn Sommer Sonntag Spaß
29Speicher Spiegel Spieler Sport Sprung Staat Stand Stapel Start Stecker Stein Stern Stift Stil
30Stock Strand Streit Strom Student Stuhl Sturm Tag Tanz Tarif Tee Teller Termin Test Text
31Thread Tisch Titel Tod Ton Topf Traum Treffer Trend Tropfen Turm Typ Unterricht Unterschied
32Urlaub Vater Verein Verkehr Verlauf Verlust Versuch Vertrag Vogel Vorgang Vorschlag Vorteil
33Wagen Wald Wechsel Weg Wein Wert Westen Wettbewerb Widerspruch Wind Winter Wunsch Wurm Zahn
34Zähler Zaun Zeiger Zeitpunkt Zeitraum Zettel Zucker Zufall Zug Zugang Zugriff Zusammenhang
35Zustand Zweck Zweifel
36`
37
38const DIE = `
39Abfrage Abhängigkeit Ablage Absicht Abteilung Adresse Agentur Aktion Ampel Analyse Änderung
40Anforderung Anfrage Angst Anlage Anleitung Anmeldung Anpassung Anrede Anschrift Ansicht Antwort
41Anwendung Anzahl Anzeige Apotheke App Arbeit Art Aufgabe Aufmerksamkeit Ausbildung Ausgabe
42Ausnahme Auswahl Authentifizierung Bahn Banane Bank Batterie Bearbeitung Bedeutung Bedingung
43Behörde Berechtigung Beschreibung Bestätigung Bestellung Betreuung Bewerbung Bezahlung
44Beziehung Bibliothek Bildung Birne Bitte Blume Bluse Brille Brücke Butter Cloud Datei
45Datenbank Deadline Decke Dokumentation Domain Dose Dusche Ebene Ecke Ehe Eigenschaft
46Einführung Eingabe Einladung Einstellung Empfehlung Energie Ente Entscheidung Entwicklung
47Erfahrung Erinnerung Erklärung Erlaubnis Ernährung Erweiterung Familie Farbe Fassung Firma
48Flasche Form Formel Frage Frau Freigabe Freiheit Freude Freundin Funktion Gabel Garage Gebühr
49Geduld Gefahr Gegend Geschichte Gesellschaft Gesundheit Gewohnheit Gitarre Grafik Grenze Größe
50Gruppe Haltestelle Hand Handlung Hardware Hilfe Hitze Hochzeit Hoffnung Hose Hütte Idee
51Information Insel Instanz Jacke Jugend Kamera Kantine Karte Kartoffel Kasse Kategorie Katze
52Kennung Kerze Kette Kirche Kirsche Kiste Klasse Kleidung Komponente Konferenz Konfiguration
53Konsole Kontrolle Kopie Kraft Krankheit Kritik Küche Kugel Kuh Kultur Lampe Landschaft Länge
54Leiste Leistung Leitung Lieferung Linie Liste Lizenz Lösung Luft Lust Macht Mahlzeit Mail E-Mail
55Mannschaft Marke Mauer Maus Medizin Meinung Meldung Melone Menge Methode Miete Milch Minute
56Mitte Mitteilung Mode Möglichkeit Mücke Muschel Musik Mutter Nachricht Nacht Nadel Nase Natur
57Notiz Nudel Nummer Nutzung Oberfläche Optimierung Ordnung Packung Pause Person Pfanne Pflanze
58Pflicht Pipeline Pizza Planung Plattform Politik Polizei Post Praxis Presse Programmierung
59Prüfsumme Prüfung Puppe Qualität Rechnung Regel Reihe Reihenfolge Reise Richtung Rolle Rose
60Rückgabe Rückmeldung Sache Säge Sahne Schachtel Schaltfläche Schere Schicht Schlange Schleife
61Schnittstelle Schnur Schokolade Schrift Schule Schulter Schwester Seife Seite Sekunde
62Sicherheit Sicherung Sitzung Socke Software Sonne Sorge Spalte Speisekarte Spitze Sprache
63Sprechstunde Stadt Stelle Steuerung Stimme Stimmung Störung Strafe Straße Strecke
64Struktur Stufe Stunde Suche Suchmaschine Suppe Tabelle Tafel Tante Tasche Taste Tastatur
65Tasse Tat Technik Teilnahme Temperatur Tochter Toilette Tomate Torte Treppe Tür Tüte Übersicht
66Übersetzung Überweisung Übung Uhr Umfrage Umgebung Umsetzung Universität Unterhaltung
67Unterlage Unterstützung Ursache Variable Vase Veränderung Verantwortung Verbindung Vereinbarung
68Verfügung Verschlüsselung Version Verwaltung Verwendung Verzögerung Voraussetzung
69Vorbereitung Vorlage Vorschau Wahl Wahrheit Wand Wanne Ware Wärme Warnung Warteschlange
70Wartung Wäsche Webseite Weiterleitung Welt Werbung Werkstatt Wiederherstellung Wiederholung
71Wiese Wirkung Wirtschaft Wissenschaft Woche Wohnung Wolke Wurst Zahl Zahnbürste Zeile Zeit
72Zeitung Zentrale Ziege Zitrone Zukunft Zunge Zusammenarbeit Zustimmung Zwiebel
73`
74
75const DAS = `
76Abendessen Abenteuer Abo Abonnement Alter Amt Angebot Archiv Argument Array Atom Attribut Auge
77Ausland Auto Backend Bad Beispiel Bein Bett Bier Bild Blatt Blut Boot Brett Brot Buch
78Budget Büro Café Dach Dashboard Datum Design Display Ding Dokument Dorf Duplikat Ei Eis
79Element Ende Ereignis Ergebnis Erlebnis Essen Etikett Event Exemplar Fach Fahrrad Feature
80Feedback Fenster Fest Feuer Fleisch Flugzeug Format Formular Foto Framework Frontend Frühstück
81Fundament Gebäude Gebiet Gefühl Geheimnis Gehirn Gelände Geld Gemüse Gepäck Gerät
82Gericht Gerücht Gesetz Gesicht Gespräch Getränk Gewicht Gewissen Gewitter Glas Gleis Glück
83Gold Gras Guthaben Haar Handy Haus Hemd Herz Hindernis Hotel Icon Instrument Interesse
84Interface Internet Inventar Jahr Kabel Kapitel Kennwort Kind Kino Kissen Kleid Klima
85Konto Kontingent Konzept Konzert Krankenhaus Kriterium Lager Land Laufwerk Layout Leben
86Licht Lied Limit Lob Loch Mädchen Medikament Meer Meeting Menü Merkmal Messer Mittagessen
87Mittel Modell Modul Museum Muster Netz Netzwerk Objekt Obst Ohr Öl Opfer Paar Paket Papier
88Passwort Pferd Pflaster Plugin Prinzip Problem Produkt Profil Programm Projekt Protokoll
89Publikum Rad Rathaus Rätsel Recht Regal Register Repository Rezept Rind Risiko Salz
90Schema Schicksal Schiff Schild Schloss Schwein Semester Setup Signal Skript Sofa Spiel
91Spielzeug Stück Studium Symbol System Tablett Talent Taxi Team Telefon Thema Thermometer
92Ticket Tier Tool Training Tuch Unternehmen Unwetter Update Verb Verbot Verfahren Verhalten
93Verhältnis Vermögen Verständnis Verzeichnis Video Volk Vorbild Vorhaben Wachstum Wasser
94Werkzeug Wetter Wissen Wochenende Wort Wörterbuch Zeichen Zelt Zentrum Zertifikat Zeugnis
95Ziel Zimmer Zitat Zubehör Zuhause
96`
97
98const GENDERS = new Map<string, WzArticle>()
99for (const [article, words] of [['der', DER], ['die', DIE], ['das', DAS]] as const) {
100 for (const word of words.split(/\s+/).filter(Boolean)) GENDERS.set(word.toLowerCase(), article)
101}
102
103// A compound's gender is its last noun's (die Datenbankverbindung: die Verbindung), so a noun
104// not in the list takes the gender of the longest listed noun it ends with. Listed nouns
105// shorter than four letters never stand for a compound's head (Ei, Tag, Uhr would match far
106// too much); compounds of those are listed whole.
107const MIN_HEAD = 4
108
109export const genderOf = (noun: string): WzArticle | undefined => {
110 const word = noun.trim().toLowerCase()
111 const exact = GENDERS.get(word)
112 if (exact) return exact
113 for (let start = 1; start <= word.length - MIN_HEAD; start++) {
114 const head = GENDERS.get(word.slice(start))
115 if (head) return head
116 }
117 return undefined
118}
119
120export const knownNounCount = (): number => GENDERS.size
121types/index.d.ts 60 lines1export type WzArticle = 'der' | 'die' | 'das'
2
3export type WzCardType = 'noun' | 'verb' | 'adjective'
4
5// `de` is the dictionary form: a noun without its article, a verb's infinitive, an
6// adjective's base form. `cloze` is `example_de` with `de` replaced by "___".
7export type WzCard = {
8 type: WzCardType
9 de: string
10 article?: WzArticle
11 en: string
12 example_de: string
13 example_en: string
14 cloze?: string
15}
16
17export type WzDeck = { key: string; topic: string; cards: WzCard[] }
18
19export type WzKind = 'vocab' | 'meaning' | 'article' | 'sentence'
20
21export type WzMode = 'mixed' | WzKind
22
23export type WzSource = 'new' | 'review' | 'practice'
24
25export type WzQuestion = {
26 card: WzCard
27 kind: WzKind
28 label: string
29 prompt: string
30 options: string[]
31 correct: number
32 isNew: boolean
33 source: WzSource
34}
35
36export type WzAnswer = { picked: number; isCorrect: boolean }
37
38export type WzSignals = { prompts: string[]; files: string[]; commands: string[] }
39
40// What the band shows of the learner's record, kept in step with the store.
41export type WzSummary = { today: number; streak: number; xp: number; seen: string[] }
42
43declare module 'claude-code' {
44 interface PluginState {
45 wartezeit: {
46 deck: WzDeck
47 deckStatus: string
48 signals: WzSignals
49 question: WzQuestion | null
50 answer: WzAnswer | null
51 activity: string
52 frame: number
53 index: number
54 mode: WzMode
55 summary: WzSummary
56 answeredThisTurn: number
57 }
58 }
59}
60