A typing challenge while Claude works: press 1 in an empty prompt, or run /type.

<h1 align="center">turntype</h1>
<b>A typing test for the seconds (and minutes) you spend watching Claude think.</b><br> A Claude Code mod.
<img src="docs/demo.gif" alt="turntype: the invitation appears while Claude works, a flow test runs, the result card shows a new personal best" width="760">
<a href="#install">Install</a> · <a href="#how-it-plays">How it plays</a> · <a href="#modes">Modes</a> · <a href="#your-progress">Progress</a> · <a href="#word-lists">Word lists</a> · <a href="#keys">Keys</a> · <a href="#settings">Settings</a>
I made this mod for fun, and it's inspired by Monkeytype. The goal is to help me in my never-ending race to type faster. This project is not related to Monkeytype in any shape or form. It isn't made, endorsed or reviewed by the Monkeytype team. It borrows three of their word lists under their GPL-3.0 license (details below), and that's the whole connection.
Here's the idea. You send Claude a prompt, then you wait. You could scroll Twitter, or you could press 1 and type the fastest 30 seconds of your day.
You need Claude Code 2.1.296 or later, in the terminal. The Desktop app should work too, but nobody has tested it there yet (notes).
Type this at the Claude Code prompt:
/plugin install turntype --marketplace Danovich/turntype
Answer y to add the marketplace, then press Enter for the user scope. turntype is active right away, in this session and every one after.
To try it for one session without installing it:
git clone https://github.com/Danovich/turntype
claude --plugin-dir ./turntype
The mods API is young and can change between Claude Code releases. If something looks off after an update, open an issue and say which version you're on.
Once a turn has run for 3 seconds, a line shows above the prompt. Short turns never show it, so it stays out of your way when Claude answers fast.
press 1: type while you wait ⋅ 2/5 min today ⋅ 4-day streak
The note after the dot is today's practice and your streak:
| You see | It means |
|---|---|
2/5 min today ⋅ 4-day streak | You've typed 2 of your 5 daily minutes, and today counts toward the streak |
0/5 min today ⋅ keep your 4-day streak | No test yet today. One test keeps the streak alive |
daily goal met (5 min) ⋅ 4-day streak | Done for today. Typing more is still allowed |
| nothing | Your first day, with the goal off |
1 (or run /type)Press 1 in an empty prompt. If you've started writing a prompt, 1 is just a 1. You can also run /type at any time, Claude busy or not.
The game opens in a small pane above the prompt and takes the keys:
the quick brown fox jumps over the lazy dog and then some
words that keep on coming while Claude reads your code
╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌
█start typing ⏎ finish your weak pairs: th er
◍ english_1k ❘ ∿ flow ◷ time (30) ☰ words (25)
Before the first key you can change the test in the toolbar on the last row. Press Tab to reach it, then Enter, or click. See Modes and Word lists. When adaptive words are on and you have weak letter pairs, they show next to the field.
The clock starts on your first key. Two lines of words show what to type: the line you're on, and the next one. When you finish a line, the next one moves up. The colors show how it's going:
| Look | Meaning |
|---|---|
| dim | still to type |
| white | typed right |
| red | a mistake: a wrong letter, or one you skipped |
| yellow, underlined | where your next key lands (on the space once a word is typed out) |
The field under the rule holds only the word you're on. The row under the field keeps score live. It has a row of its own, so it stays still while the word grows:
while claude writes the code you can type a few words
words that keep on coming while Claude reads your code
╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌
y█ ⏎ finish
0:12 52 wpm 7 words esc: hide
7/25.0:18 left.A few rules keep the score honest:
Stop typing for more than 2 seconds and the clock pauses:
paused ⋅ type to resume
The pause doesn't count against your wpm, and that holds for a time test too: a 30-second test means 30 seconds of typing, not 30 seconds on the wall clock. The next key picks up where you left off.
Press Esc to hide the pane. A test in progress waits in the band:
press 1: continue ⋅ 12 words
Press 1 to jump back in. A test you never typed in stays while Claude is still working, and goes away when the turn ends.
In flow mode the test lasts as long as Claude does. When the turn ends, the status says so:
∘ Claude is done ⋅ finish the word
Finish the word you're on, type the space, and the test ends. In time and words mode Claude finishing changes nothing. The test runs to its own end, or you press ⏎ to stop early.
If Claude was already done when you started (or you opened the game with /type while Claude was idle), a flow test has nothing to wait for. It says so, and runs until you press ⏎:
∘ Claude is done ⋅ ⏎ finish
62 wpm 96% accuracy ✦ new personal best (+4)
▂▃▅▄▆█ ⇡ +5 vs week today 3 tests ⋅ 4-day streak
╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌
█ ⏎ again x: close d: details
◍ english_1k ❘ ∿ flow ◷ time (30) ☰ words (25)
The card is the same five rows as the game, so the pane never jumps.
The card ignores keys for its first second. A test often ends mid-word, and the keys you already sent shouldn't start a new test or close the card. After that second, the small field on the card takes every key: ⏎ or a starts again, x closes, d shows the details, and any other key does nothing. None of them leak into your prompt.
✦ new personal best (+4): you beat it, by this much✦ first personal best for this test: your first fair result for this mode, count and listpersonal best 98 wpm: not this timenot a personal best try: ended early: the test didn't qualify (why)⇡ +5 vs week compares this result with your average for the same test over the last 7 days.d for details swaps the trend row and the rule for two rows of detail: raw wpm, consistency, the test length, the words and today's total time, then correct, wrong, extra and missed characters. Details stay open for the next card until you press d again.⏎ or a for again starts a fresh test. Change the toolbar first if you want a different one.x to close.A new personal best also pops a toast, and so does each milestone the first time you reach it:
✦ new personal best: 61 wpm ⋅ time 30 ⋅ first 60 wpm test
The milestones are 40, 50, 60, 70, 80, 90, 100, 120 and 150 wpm, and 3, 7, 30 and 100-day streaks.
Claude sometimes asks before it runs a tool, and the dialog takes the keys from the game. Once you answer it, turntype takes the keys back by itself, and your test keeps going.
It leaves the keys with you if you've started typing in the prompt, because you're clearly busy. The band then shows the way back:
press 1: back to typing ⋅ 12 words or ctrl+x tab
ctrl+x tab gives the keys back to the game from anywhere. The field shows the same hint when it doesn't have them.
The game needs somewhere to draw: the terminal, or the Claude Code Desktop app (untested so far). In the VS Code chat panel or with claude -p, /type tells you so and does nothing else.
| Mode | The test ends when | ⏎ (Enter) |
|---|---|---|
∿ flow (default) | Claude is done and you finish the word. Started with Claude idle: only Enter ends it | Ends it, and it counts as complete |
◷ time 15 / 30 / 60 / 120 | The typing time runs out (pauses don't count) | Ends it early |
☰ words 10 / 25 / 50 / 100 | You type the last word (no space needed) | Ends it early |
Flow is the reason this mod exists: you type exactly as long as Claude works. Time and words are the classic tests, for when you want a number you can compare.
To change modes, use the toolbar under the field before your first key, or under the result card. Press a counted mode to see its counts (◷ time 15 30 60 120). The count in use is yellow, and a pick closes the list and puts you back in the field. Your picks are saved for next time.
Changed your mind while in the toolbar? Just start typing. The first letter goes to the field and starts the test, never into your prompt.
Each mode, count and word list keeps its own personal best. time 15 never competes with words 100, and english_10k never competes with english. A test can set a personal best only if:
A test that misses one of these still counts toward today's minutes, your streak and your letter-pair practice. It just can't take the crown.
Every day with at least one test extends your streak. Days follow your own midnight, not UTC. The daily goal counts minutes of active typing, 5 by default. You can set it from 3 to 30 minutes, or turn it off and keep just the streak (Settings).
turntype notices the letter pairs you miss. If you keep hitting r when you meant th, then th is a weak pair. It counts the miss only while the word is still on track, so a single slip doesn't drag down every letter after it.
A pair has to show up at least 15 times, and you have to miss it often enough that it isn't noise. After that, about a third of the words in each new test contain one of your three weakest pairs. Older misses fade a little with each test, so a pair you've fixed drops out on its own. Your current weak pairs show next to the field before you start.
Adaptive words are on by default. Turn them off in Settings for plain random words.
| List | Words | Source |
|---|---|---|
english | 200 common words | turntype's own |
english_1k (default) | the 1,000 most common | Monkeytype |
english_5k | the 5,000 most common | Monkeytype |
english_10k | about 10,000 | Monkeytype |
Pick one with ◍ in the toolbar.
Drop a JSON file in word-lists/ and it shows up in the toolbar by its file name:
{ "words": ["und", "der", "die", "das", "..."] }
_ and -, so german.json shows as german.useState and => works for practicing code.turntype reads the list names when the session starts. It reads each list's words the first time you use that list, then keeps them.
| Key | Where | What it does |
|---|---|---|
1 | Empty prompt | Opens the game, continues a test, or takes you back after a dialog |
/type | Prompt | Opens the game |
ctrl+x tab | Anywhere | Gives the keys back to the game |
⏎ Enter | Game field | Ends the test |
Tab | Game field, before the first key | Moves to the toolbar |
Esc | Game pane | Hides the pane. A test in progress waits in the band |
⏎ or a / x / d | Result card, after its first second | Again / close / details |
Set these in /config, under turntype:
| Setting | Options | Default |
|---|---|---|
| Adaptive words | on, off | on |
| Daily goal (minutes) | off, 3, 5, 10, 15, 30 | 5 |
The mode, count and word list are set in the game's toolbar and remembered between sessions. The colors follow your Claude Code theme.
Everything stays on your machine, in the mod's own store (~/.claude/plugins/store/):
turntype sends nothing anywhere. If you run several Claude Code sessions at once, they share the store, and two tests finishing at the same moment both get saved.
turntype is a mod living inside someone else's app, so a few things work around Claude Code rather than with it.
1 in an empty prompt or ctrl+x tab.1 only works in an empty prompt. With text in the prompt, 1 types a 1, and the game won't take the keys from your half-written message. Use ctrl+x tab or clear the prompt.1 key a fraction of a second after the invitation appears. A 1 pressed in that instant types a 1 into the prompt. Delete it and press 1 again.word-lists/, run /reload-plugins or start a new session.claude -p have nowhere to draw the pane.claude plugin validate --strict . # manifest, hooks and calls
claude plugin test . # engine, flow, probe and cost tests
tsc -p . # types (Claude Code writes them into .claude-plugin/types/)
hooks/register.tsx is the shell, the only code that talks to Claude Code.hooks/engine/ holds the game rules as pure functions.hooks/view/ draws the pane and the band.tests/cost.test.ts makes sure typing and the clock tick cost no extra redraws or host calls. A typing game that lags is worse than none.The test kit has no real keyboard focus, and it can't press Esc or move Date.now(). So there's a live probe too. It drives turntype in a real Claude Code session in an iTerm2 window: it types, resizes and reads the screen, and saves screenshots of the moments worth a look. It needs macOS, iTerm2, and Screen Recording allowed for iTerm. It plays on a clean store and puts yours back after, so your stats stay untouched. Leave its window alone while it runs.
python3 -I tests/live/iterm-probe.py # about 80 checks, no model calls
python3 -I tests/live/iterm-probe.py --turns # plus two short model turns: Claude done, a permission dialog
python3 -I tests/live/iterm-probe.py --only words_test esc_and_back # chosen cases alone
turntype is free software under the GNU General Public License v3.0 or later.
The word lists english_1k, english_5k and english_10k are copied unchanged from Monkeytype (frontend/static/languages/), © the Monkeytype contributors, under GPL-3.0. Thanks to them for the lists, and for the years of typing tests that inspired this one. The english list is turntype's own.
Monkeytype is a trademark of its owners. turntype is an independent fan project, not affiliated with or endorsed by Monkeytype.
hooks/register.tsx 887 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, PaneOpenArgs, Register, Timer, UiOpenResult } from 'claude-code'
3
4import type { TypingDayStanding, TypingGame, TypingPicks, TypingRound, TypingRoundFinished, TypingRoundReady, TypingSettings } from '../types'
5import { bandKind, isActive, keepsRound, readableGame, roundAfter } from './engine/lifecycle'
6import type { RoundNeed } from './engine/lifecycle'
7import { FALLBACK_LIST_NAME } from './engine/modes'
8import {
9 configKey,
10 dayStanding,
11 LEGACY_PROGRESS_KEY,
12 milestonesReached,
13 PART_PARSERS,
14 parseLegacyProgress,
15 personalBestWpms,
16 PROGRESS_PARTS,
17 recordResult,
18 todayPractice,
19 weakBigrams,
20} from './engine/progress'
21import type { ProgressPart, StoredProgress } from './engine/progress'
22import { applyFieldInput, drawnOf, fieldKey, freshField, gameWith } from './engine/field'
23import type { FieldAsDrawn } from './engine/field'
24import { createRound, expireRound, finishByPlayer, MAX_CHARACTERS_PER_INPUT } from './engine/round'
25import { DEFAULT_SETTINGS, parseDailyGoalMinutes, parseSavedPicks, parseTypingSettings, PICKED_SETTINGS_KEY, picksOf, settingsInUse } from './engine/settings'
26import { isIdle, isTimeUp } from './engine/timing'
27import { FALLBACK_WORD_LIST, isWordListName, parseWordList } from './engine/word-list'
28import type { WordList } from './engine/word-list'
29import { backToTypingBand, invitationBand, resultBand } from './view/band'
30import { gameView } from './view/game'
31import { paletteFor, THEME_PALETTE } from './view/palette'
32import type { Palette } from './view/palette'
33import type { OpenOptions, PickerActions, PickerProps } from './view/picker'
34import { resultCard } from './view/result-card'
35import { resultToast } from './view/shared'
36
37/*
38 * The shell: everything that reaches outside through `$` (state, the store, files, timers, the
39 * pane and its keys), applying the pure rules of `engine/` and drawing with `view/`. One file by
40 * the platform's rule: `$` and the state atoms never cross an import. Sections, in the order each
41 * builds on the one before: state, resources, settings, progress, rounds, pane and focus, session,
42 * toolbar, hooks.
43 */
44
45type Dollar = EngineInterface
46
47// ─── State ───────────────────────────────────────────────────────────────────────────────────
48// The session's values in $.state, declared in types/index.d.ts. A write redraws what read them.
49
50const NO_PICKS: TypingPicks | null = null
51const picksAtom = atom({ plugin: 'turntype', key: 'picks' } as const, NO_PICKS)
52const NO_GAME: TypingGame | null = null
53const gameAtom = atom({ plugin: 'turntype', key: 'game' } as const, NO_GAME)
54const clockTickAtom = atom({ plugin: 'turntype', key: 'clockTick' } as const, 0)
55const isGameOpenAtom = atom({ plugin: 'turntype', key: 'isGameOpen' } as const, false)
56const isPaneFocusedAtom = atom({ plugin: 'turntype', key: 'isPaneFocused' } as const, true)
57const isDetailsShownAtom = atom({ plugin: 'turntype', key: 'isDetailsShown' } as const, false)
58const cardFieldAtom = atom({ plugin: 'turntype', key: 'cardField' } as const, 0)
59const NO_OPEN_OPTIONS: OpenOptions = null
60const openOptionsAtom = atom({ plugin: 'turntype', key: 'openOptions' } as const, NO_OPEN_OPTIONS)
61const NO_PERSONAL_BESTS: Readonly<Record<string, number>> = {}
62const personalBestsAtom = atom({ plugin: 'turntype', key: 'personalBests' } as const, NO_PERSONAL_BESTS)
63const NO_BIGRAMS: readonly string[] = []
64const focusBigramsAtom = atom({ plugin: 'turntype', key: 'focusBigrams' } as const, NO_BIGRAMS)
65const NO_DAY_STANDING: TypingDayStanding | null = null
66const dayStandingAtom = atom({ plugin: 'turntype', key: 'dayStanding' } as const, NO_DAY_STANDING)
67
68// ─── Resources ───────────────────────────────────────────────────────────────────────────────
69// The word lists (files of the mod) and the colors of the Claude Code theme. Module variables, not
70// $.state: a key and a redraw read them with no host call, and $.state would copy every list across
71// the host boundary on each read. A reload reads them again.
72
73const WORD_LISTS_FOLDER = 'word-lists'
74const JSON_EXTENSION = '.json'
75const THEME_CONFIG_KEY = 'theme'
76
77// The lists the toolbar names: every `*.json` in word-lists/, by file name, read at start. English
78// is always one: without its file the built-in words stand in.
79let listNames: readonly string[] = [FALLBACK_LIST_NAME]
80// The lists read so far. A file is read the first time its list is needed (the one in use at start,
81// or a pick), then kept: a start reads one file, not every list.
82let wordLists: ReadonlyMap<string, WordList> = new Map()
83// A new theme shows from the next redraw.
84let palette: Palette = THEME_PALETTE
85
86/** The list by name, once read; one not read (or gone) gets English, its file's or the built-in one. */
87const wordListFor = (name: string): WordList => wordLists.get(name) ?? wordLists.get(FALLBACK_LIST_NAME) ?? FALLBACK_WORD_LIST
88
89const listFolder = (dollar: Dollar): string => `${dollar.plugin.root}/${WORD_LISTS_FOLDER}`
90
91/** Names the lists in word-lists/, from the folder alone: no file is read. Without the folder, English alone. */
92const loadListNames = async (dollar: Dollar): Promise<void> => {
93 try {
94 const names = (await dollar.fs.list(listFolder(dollar)))
95 .filter(entry => entry.kind === 'file' && entry.name.endsWith(JSON_EXTENSION))
96 .map(entry => entry.name.slice(0, -JSON_EXTENSION.length))
97 .filter(isWordListName)
98 listNames = [...new Set([FALLBACK_LIST_NAME, ...names])]
99 } catch {
100 listNames = [FALLBACK_LIST_NAME]
101 }
102 // A reload reads the files again: one changed since is read as it is now.
103 wordLists = new Map()
104}
105
106/**
107 * Reads list `name` if it is not read yet. False when it cannot be had: no such list, or a file that
108 * is not JSON text or not a valid list, which then leaves the toolbar. English always can: without
109 * its file, the built-in list.
110 */
111const ensureList = async (dollar: Dollar, name: string): Promise<boolean> => {
112 if (wordLists.has(name)) {
113 return true
114 }
115 const list = listNames.includes(name) ? await readWordList(dollar, name) : undefined
116 if (list !== undefined) {
117 wordLists = new Map([...wordLists, [name, list]])
118
119 return true
120 }
121 if (name === FALLBACK_LIST_NAME) {
122 // Read once, broken or missing: the built-in English stands in from now on.
123 wordLists = new Map([...wordLists, [name, FALLBACK_WORD_LIST]])
124
125 return true
126 }
127 listNames = listNames.filter(listName => listName !== name)
128
129 return false
130}
131
132/** Reads one list file; a file that is not JSON text or not a valid list is none. */
133const readWordList = async (dollar: Dollar, name: string): Promise<WordList | undefined> => {
134 try {
135 const content = await dollar.fs.read(`${listFolder(dollar)}/${name}${JSON_EXTENSION}`)
136
137 return typeof content === 'string' ? parseWordList(name, JSON.parse(content)) : undefined
138 } catch {
139 return undefined
140 }
141}
142
143/** Reads the Claude Code theme from /config; without it (an old host, a test) the theme keys stay. */
144const loadPalette = async (dollar: Dollar): Promise<void> => {
145 try {
146 const theme = (await dollar.config.list()).find(row => row.key === THEME_CONFIG_KEY)?.value
147 palette = paletteFor(typeof theme === 'string' ? theme : '')
148 } catch {
149 palette = THEME_PALETTE
150 }
151}
152
153// ─── Settings ────────────────────────────────────────────────────────────────────────────────
154// Two owners: the defaults (the mode table, adaptive from /config) and the picks made in the game
155// (the store, copied into state). The settings in use are derived from them, never stored a third time.
156
157// Adaptive comes from /config: a change there reloads the module, and register reads it again.
158let defaultSettings: TypingSettings = DEFAULT_SETTINGS
159// The daily goal in minutes, from /config the same way; undefined: off.
160let dailyGoalMinutes: number | undefined
161
162/** The settings in use: the picks over the defaults. */
163const readSettings = async (dollar: Dollar): Promise<TypingSettings> =>
164 settingsInUse(defaultSettings, await read(dollar, picksAtom), name => listNames.includes(name))
165
166/** Copies the saved picks into state, where a drawing reads them. At start and after /clear. */
167const loadPicks = async (dollar: Dollar): Promise<void> => {
168 try {
169 const picks = parseSavedPicks(await dollar.store.get(PICKED_SETTINGS_KEY), defaultSettings)
170 await update(dollar, picksAtom, () => picks)
171 } catch {
172 // No store: the defaults alone.
173 }
174}
175
176/** A pick: in use at once, and saved for the next session and every other one (one store write; nothing reloads). */
177const savePicks = async (dollar: Dollar, chosen: TypingSettings): Promise<void> => {
178 await update(dollar, picksAtom, () => picksOf(chosen))
179 try {
180 await dollar.store.set(PICKED_SETTINGS_KEY, picksOf(chosen))
181 } catch {
182 // Not saved: the pick still holds for this session.
183 }
184}
185
186// ─── Progress ────────────────────────────────────────────────────────────────────────────────
187// The results in the store, one key per part, shared by every session on the machine. A save reads
188// every part right before writing it and merges the test in, so a test another session saved is kept.
189
190/** Keeps in state what a key, a new round or the band reads: the personal bests, the weak letter pairs, today. */
191const publishProgress = async (dollar: Dollar, progress: StoredProgress): Promise<void> => {
192 await Promise.all([
193 update(dollar, personalBestsAtom, () => personalBestWpms(progress)),
194 update(dollar, focusBigramsAtom, () => weakBigrams(progress)),
195 update(dollar, dayStandingAtom, () => dayStanding(progress.daily, Date.now())),
196 ])
197}
198
199/** Reads the saved parts, all at once; a missing or broken one is empty. */
200const readParts = async (dollar: Dollar): Promise<StoredProgress> => {
201 const [personalBests, history, daily, bigrams] = await Promise.all(PROGRESS_PARTS.map(part => dollar.store.get(part)))
202
203 return {
204 personalBests: PART_PARSERS.personalBests(personalBests),
205 history: PART_PARSERS.history(history),
206 daily: PART_PARSERS.daily(daily),
207 bigrams: PART_PARSERS.bigrams(bigrams),
208 }
209}
210
211/**
212 * Reads the saved parts at load. The one record of versions before 0.8 is split into parts no
213 * session has written yet, then deleted: once, and it never replaces a newer part.
214 */
215const loadParts = async (dollar: Dollar): Promise<StoredProgress> => {
216 const [legacyValue, saved] = await Promise.all([dollar.store.get(LEGACY_PROGRESS_KEY), readParts(dollar)])
217
218 if (legacyValue === undefined) {
219 return saved
220 }
221 const legacy = parseLegacyProgress(legacyValue)
222 const savedKeys = new Set(await dollar.store.keys())
223 const missing = PROGRESS_PARTS.filter(part => !savedKeys.has(part))
224 await Promise.all(missing.map(part => dollar.store.set(part, legacy[part])))
225 await dollar.store.delete(LEGACY_PROGRESS_KEY)
226
227 const kept = <P extends ProgressPart>(part: P): StoredProgress[P] => (missing.includes(part) ? legacy[part] : saved[part])
228
229 return { personalBests: kept('personalBests'), history: kept('history'), daily: kept('daily'), bigrams: kept('bigrams') }
230}
231
232/** Copies the saved progress into state. At start and after /clear. */
233const loadProgress = async (dollar: Dollar): Promise<void> => {
234 try {
235 await publishProgress(dollar, await loadParts(dollar))
236 } catch {
237 // No store (an old host, a test without one): play on without personal bests.
238 }
239}
240
241/**
242 * Saves a finished test, then attaches its summary to the round for the result card. Each part is
243 * read once, all at once, right before the save: the summary compares with that, and the test is
244 * merged into it, never written over it, so a test another session saved meanwhile is kept. Then
245 * all four are written at once. When the store fails the card shows the result alone.
246 */
247const recordProgress = async (dollar: Dollar, round: TypingRoundFinished): Promise<void> => {
248 try {
249 const now = Date.now()
250 const before = await readParts(dollar)
251 const { progress: after, summary } = recordResult(before, round, now)
252 await Promise.all(PROGRESS_PARTS.map(part => dollar.store.set(part, after[part])))
253 await Promise.all([
254 publishProgress(dollar, after),
255 update(dollar, gameAtom, game =>
256 game?.round.phase === 'finished' && game.round.roundId === round.roundId ? { ...game, round: { ...game.round, progress: summary } } : game,
257 ),
258 ])
259 const toast = resultToast(round, summary, milestonesReached(before, after, now))
260 if (toast !== undefined) {
261 dollar.ui.toast(toast)
262 }
263 } catch {
264 // The result card stays without its progress line.
265 }
266}
267
268// ─── Rounds ──────────────────────────────────────────────────────────────────────────────────
269// Every change of the round goes through `replaceGame`: the engine's reducers applied to the round
270// and its field in state, the clock run while a test runs, a test saved the moment it finishes.
271
272/** The round in play, if any. */
273const readRound = async (dollar: Dollar): Promise<TypingRound | null> => (await read(dollar, gameAtom))?.round ?? null
274
275const TICK_MS = 1000
276
277// A hot reload cancels the timer with the old environment.
278let ticker: Timer | undefined
279
280const stopTicker = (): void => {
281 ticker?.cancel()
282 ticker = undefined
283}
284
285/**
286 * Each second of a running test: end a time test whose time is up, redraw the clock, and
287 * stop while the player is idle (the next key starts it again), so an idle game costs nothing.
288 * The round is only read: written only when the time ran out, through replaceRound, which saves it.
289 */
290const handleTick = async (dollar: Dollar): Promise<void> => {
291 const now = Date.now()
292 const round = await readRound(dollar)
293
294 if (round?.phase === 'running' && isTimeUp(round, now)) {
295 await replaceRound(dollar, current => (current === null ? null : expireRound(current, now)))
296
297 return
298 }
299 if (round?.phase !== 'running' || isIdle(round, now)) {
300 stopTicker()
301 }
302 await update(dollar, clockTickAtom, tick => tick + 1)
303}
304
305const syncTicker = (dollar: Dollar, round: TypingRound | null): void => {
306 if (round?.phase !== 'running') {
307 stopTicker()
308 } else if (ticker === undefined) {
309 ticker = dollar.clock.every(TICK_MS, () => void handleTick(dollar))
310 }
311}
312
313/**
314 * A test can end mid-word (time up, the last word): keys still on their way would press the result
315 * card's `a`, `x` and `d`. The card takes no press for this long after a test ends.
316 */
317const RESULT_LOCKOUT_MS = 1000
318
319// The clock time from which the result card takes presses. A hot reload starts it over: no lockout.
320let resultPressesFrom = 0
321
322/** A result card action, ignored while the card is new. `$.clock`, not Date.now(): a press is rare, and a test moves it. */
323const afterLockout = (dollar: Dollar, action: () => void) => (): void => {
324 void (async () => {
325 if ((await dollar.clock.now()) >= resultPressesFrom) {
326 action()
327 }
328 })()
329}
330
331/**
332 * The one place a round changes: it runs the clock, and saves a test the moment it finishes. The
333 * save follows the change to finished, seen inside the one update: a later update of a finished
334 * round (its summary attached, a turn's end) never saves it again. The finish starts the result
335 * card's lockout, closed at once and timed once the clock answers.
336 */
337const replaceGame = async (dollar: Dollar, change: (game: TypingGame | null) => TypingGame | null): Promise<void> => {
338 let wasFinished = false
339 const game = await update(dollar, gameAtom, current => {
340 wasFinished = current?.round.phase === 'finished'
341
342 return change(current)
343 })
344 const round = game?.round ?? null
345 syncTicker(dollar, round)
346
347 if (round?.phase === 'finished' && !wasFinished) {
348 resultPressesFrom = Number.POSITIVE_INFINITY
349 resultPressesFrom = (await dollar.clock.now()) + RESULT_LOCKOUT_MS
350 await recordProgress(dollar, round)
351 }
352}
353
354/** A change of the round alone: its field stays while it is the same round, a new round starts with its first field. */
355const replaceRound = (dollar: Dollar, change: (round: TypingRound | null) => TypingRound | null): Promise<void> =>
356 replaceGame(dollar, game => gameWith(game, change(game?.round ?? null)))
357
358// When the main agent's last turn ended (Date.now()), kept here as well as in the round. A key's
359// write still on its way can hide a running test from the turn's end, which then marks nothing; a
360// test that began before this moment, with no turn running since, is Claude done all the same.
361let claudeDoneAt: number | undefined
362
363/** The round with Claude done when it began before the last turn ended, whatever the state says. */
364const withClaudeDone = <R extends TypingRound>(round: R): R =>
365 round.phase === 'running' && !round.isClaudeDone && pendingTurnId === undefined && claudeDoneAt !== undefined && round.startedAt < claudeDoneAt
366 ? { ...round, isClaudeDone: true }
367 : round
368
369/** One change of a field, as it was drawn. Date.now() here, not $.clock.now(): one host call less on every key. */
370const handleInput = (dollar: Dollar, drawn: FieldAsDrawn, fieldText: string, lineWidth: number): void => {
371 const now = Date.now()
372
373 void replaceGame(dollar, game =>
374 game !== null && isActive(game.round)
375 ? applyFieldInput({ ...game, round: withClaudeDone(game.round) }, drawn, fieldText, now, {
376 words: wordListFor(game.round.wordList).words,
377 lineWidth,
378 })
379 : game,
380 )
381}
382
383/** Enter in the field: the player ends the test. */
384const handleFinish = (dollar: Dollar, roundId: number): void => {
385 void replaceRound(dollar, round => (round?.phase === 'running' && round.roundId === roundId ? finishByPlayer(round) : round))
386}
387
388const nextRoundId = (round: TypingRound | null): number => (round?.roundId ?? 0) + 1
389
390/**
391 * Makes rounds with `given` (else the settings in use), the weak letter pairs and the personal
392 * best, read once here: the change itself stays synchronous, and a round carries its personal best
393 * so a key reads nothing else.
394 */
395const roundMaker = async (dollar: Dollar, given?: TypingSettings) => {
396 const chosen = given ?? (await readSettings(dollar))
397 // Read before the change, which stays synchronous; a list that cannot be read gives English.
398 await ensureList(dollar, chosen.wordList)
399 const weak = await read(dollar, focusBigramsAtom)
400 const personalBests = await read(dollar, personalBestsAtom)
401
402 return (previous: TypingRound | null): TypingRoundReady => {
403 const round = createRound(nextRoundId(previous), chosen, weak, wordListFor(chosen.wordList))
404
405 return { ...round, pbWpm: personalBests[configKey(round)] }
406 }
407}
408
409/**
410 * A round for `need`: the one there when it serves (keepsRound), else a new one made with `given`
411 * or the settings in use. A round that serves needs nothing read or written; the check runs again
412 * inside the update, so a round that changed meanwhile is still judged as it is then.
413 */
414const ensureRound = async (dollar: Dollar, need: RoundNeed, given?: TypingSettings): Promise<void> => {
415 if (keepsRound(await readRound(dollar), need)) {
416 return
417 }
418 const makeRound = await roundMaker(dollar, given)
419 await replaceRound(dollar, round => (keepsRound(round, need) ? round : makeRound(round)))
420}
421
422// ─── Pane and focus ──────────────────────────────────────────────────────────────────────────
423// Opening and closing the game pane, the flags the band reads (open, focused), getting the keys
424// back after a permission dialog, and handing them to the field after a pick.
425
426const GAME_PANE_ID = 'turntype'
427/** Every phase draws exactly this many rows, so the pane never changes height. */
428const GAME_PANE_ROWS = 5
429const GAME_PANE: PaneOpenArgs = { id: GAME_PANE_ID, title: 'turntype', focus: true, closeOnEscape: true, rows: GAME_PANE_ROWS }
430/** After a permission ask: how often to look whether the dialog gave the keys back. */
431const REFOCUS_POLL_MS = 400
432/** A mode that allows the call shows no dialog: if the pane keeps the keys this long, stop looking. */
433const REFOCUS_DIALOG_WAIT_MS = 3000
434const REFOCUS_GIVE_UP_MS = 60_000
435
436// A hot reload cancels the timer with the old environment.
437let refocusTimer: Timer | undefined
438// Where the pane was seated when last drawn: a move between `inline` and `dock` (a terminal widened
439// or narrowed past 110 columns in fullscreen) takes the keys from it.
440let lastPlacement: string | undefined
441// The ring is on one of the pane's Buttons (the toolbar, the card's): a letter typed there is no
442// Button's, so Claude Code hands it to the prompt with the keys. Kept by the ui.focus hook.
443let isRingOnButton = false
444// The width the words were last drawn at: a key caught on its way to the prompt is applied at it.
445let lastLineWidth = 80
446
447const findGamePane = async (dollar: Dollar) => (await dollar.ui.panes()).find(pane => pane.id === GAME_PANE_ID)
448
449/** Writes the focus flag only on a change, so the band is not drawn again for nothing. */
450const setPaneFocused = async (dollar: Dollar, isFocused: boolean): Promise<void> => {
451 if ((await read(dollar, isPaneFocusedAtom)) !== isFocused) {
452 await update(dollar, isPaneFocusedAtom, () => isFocused)
453 }
454}
455
456/**
457 * Opens the game pane with the keyboard; keeps a round in progress, else starts one.
458 * Says whether the pane is drawn: where nothing draws (the VS Code chat panel, `claude -p`) it is not.
459 */
460const openGame = async (dollar: Dollar): Promise<UiOpenResult> => {
461 // Asked first, before any await: a press's open that comes after one counts as unasked, and
462 // after an Esc the pane then waits undrawn below 144 columns. The round follows at once.
463 const opening = dollar.ui.open(GAME_PANE)
464 await ensureRound(dollar, 'open')
465 await update(dollar, isGameOpenAtom, () => true)
466 const opened = await opening
467 // Focus is a request: text in the composer refuses it, and then the band keeps its way back.
468 await setPaneFocused(dollar, (await findGamePane(dollar))?.isFocused ?? false)
469
470 return opened
471}
472
473const stopRefocus = (): void => {
474 refocusTimer?.cancel()
475 refocusTimer = undefined
476}
477
478/**
479 * A permission dialog takes the keys from the game and hands them to the prompt when it closes, and
480 * so does the pane moving between inline and the dock. Watch for that loss, then ask for the keys
481 * back until the pane has them: the engine grants it only over an empty composer, so a prompt the
482 * person started to write keeps the keys.
483 */
484const refocusAfterAsk = (dollar: Dollar): void => {
485 stopRefocus()
486 // Counted in polls, the timer's own time: no second clock to disagree with it.
487 let waitedMs = 0
488 let hasLostFocus = false
489
490 const check = async (): Promise<void> => {
491 waitedMs += REFOCUS_POLL_MS
492 const pane = await findGamePane(dollar)
493
494 if (pane === undefined || waitedMs > REFOCUS_GIVE_UP_MS) {
495 stopRefocus()
496 } else if (pane.isFocused) {
497 if (hasLostFocus || waitedMs > REFOCUS_DIALOG_WAIT_MS) {
498 stopRefocus()
499 }
500 await setPaneFocused(dollar, true)
501 } else {
502 hasLostFocus = true
503 await setPaneFocused(dollar, false)
504 await dollar.ui.open(GAME_PANE)
505 }
506 }
507
508 refocusTimer = dollar.clock.every(REFOCUS_POLL_MS, () => void check())
509}
510
511// The mod's own $.ui.close does not reach its own ui.close hook, so reset the flag here too.
512const closeGame = async (dollar: Dollar): Promise<void> => {
513 isRingOnButton = false
514 stopRefocus()
515 await replaceRound(dollar, () => null)
516 await update(dollar, isGameOpenAtom, () => false)
517 await dollar.ui.close({ id: GAME_PANE_ID })
518}
519
520/**
521 * The new round's field takes the keys: the button just pressed is gone from the toolbar, and the
522 * next thing to do is type. Refused (the pane does not hold the keys), the focus stays as it is.
523 */
524const focusField = async (dollar: Dollar): Promise<void> => {
525 const game = await read(dollar, gameAtom)
526
527 if (game !== null && isActive(game.round)) {
528 try {
529 await dollar.ui.focus({ requestId: GAME_PANE_ID, key: fieldKey(game.field) })
530 } catch {
531 // No focus call (an old host): the pick still holds; Tab or a click reaches the field.
532 }
533 }
534}
535
536/** An element of the pane the keys type into: the game's field (`r1-f0`) or the card's (`card-...`). */
537const isFieldElement = (element: string | undefined): boolean => element !== undefined && /^(?:r\d+-f\d+|card-)/.test(element)
538
539/** Keys a person types: a few printable characters, as one edit or a burst. */
540const isTypedKeys = (text: string): boolean => text.length > 0 && text.length <= MAX_CHARACTERS_PER_INPUT && !/[\n\r\t]/.test(text)
541
542/**
543 * A key typed while the ring sat on a Button reached the prompt: it is the game's. Before the first
544 * key it starts the test in a fresh field holding it; on the result card it is a card key. Then the
545 * pane asks for the keys back (the composer is empty: the edit was not taken), the field first.
546 */
547const takeBackLeakedKeys = async (dollar: Dollar, text: string): Promise<void> => {
548 const game = await read(dollar, gameAtom)
549
550 if (game?.round.phase === 'finished') {
551 resultCardActions(dollar).onKey(text.slice(-1).toLowerCase())
552 } else if (game?.round.phase === 'ready') {
553 const now = Date.now()
554 await replaceGame(dollar, current => {
555 if (current === null || current.round.phase !== 'ready' || current.round.roundId !== game.round.roundId) {
556 return current
557 }
558 const typed = applyFieldInput(current, drawnOf(current.field), text, now, {
559 words: wordListFor(current.round.wordList).words,
560 lineWidth: lastLineWidth,
561 })
562
563 // The Input on screen never held these keys: a fresh one is drawn holding what the round took.
564 return typed.round === current.round ? typed : { ...typed, field: freshField(typed.field, typed.round) }
565 })
566 }
567 await dollar.ui.open(GAME_PANE)
568 await setPaneFocused(dollar, (await findGamePane(dollar))?.isFocused ?? false)
569}
570
571// ─── Session ─────────────────────────────────────────────────────────────────────────────────
572
573/**
574 * Copies what the store keeps into $.state and matches the pane flags to the engine's own record.
575 * Runs at session start, after a reload, and after /clear, /resume and /branch: those reset every
576 * $.state value to its default, but session.start does not fire again.
577 */
578const syncSession = async (dollar: Dollar): Promise<void> => {
579 const [pane] = await Promise.all([findGamePane(dollar), loadPicks(dollar), loadProgress(dollar)])
580 await Promise.all([
581 update(dollar, isGameOpenAtom, () => pane !== undefined),
582 setPaneFocused(dollar, pane?.isFocused ?? true),
583 // A reload keeps the round, but one made by a version with another round shape cannot be read.
584 update(dollar, gameAtom, readableGame),
585 ])
586
587 // A reload keeps the round; a reset leaves none, and an open pane would stay empty.
588 if (pane !== undefined && (await read(dollar, gameAtom)) === null) {
589 await closeGame(dollar)
590 }
591}
592
593// ─── Toolbar ─────────────────────────────────────────────────────────────────────────────────
594
595/** A pick in the pane: in use and saved at once, a round not yet started made again with it, the options closed, the keys to the field. */
596const chooseSettings = async (dollar: Dollar, chosen: TypingSettings): Promise<void> => {
597 // A list read for the first time here; one whose file is broken is said so, and the pick not made.
598 if (!(await ensureList(dollar, chosen.wordList))) {
599 dollar.ui.toast(`couldn't read the list ${chosen.wordList}: its file is not a word list`)
600 await update(dollar, openOptionsAtom, () => null)
601 // The list left the toolbar, a module variable: the pane draws again without it.
602 dollar.ui.invalidate('ui.render')
603
604 return
605 }
606 await savePicks(dollar, chosen)
607 await ensureRound(dollar, 'settings', chosen)
608 await update(dollar, openOptionsAtom, () => null)
609 await focusField(dollar)
610}
611
612/** The result card's actions, each through the lockout; a key typed on the card is one of them or nothing. */
613const resultCardActions = (dollar: Dollar) => {
614 const onAgain = afterLockout(dollar, () => void ensureRound(dollar, 'again'))
615 const onClose = afterLockout(dollar, () => void closeGame(dollar))
616 const onToggleDetails = afterLockout(dollar, () => void update(dollar, isDetailsShownAtom, isShown => !isShown))
617 const keyActions: Readonly<Record<string, () => void>> = { a: onAgain, x: onClose, d: onToggleDetails }
618
619 return {
620 // Every key empties the card's field (a new key); a, x and d act.
621 onKey: (key: string) => {
622 void update(dollar, cardFieldAtom, index => index + 1)
623 keyActions[key]?.()
624 },
625 onAgain,
626 onClose,
627 onToggleDetails,
628 }
629}
630
631/** The toolbar as the pane draws it: the settings in use, the lists, which group is open. */
632const pickerProps = async (dollar: Dollar): Promise<PickerProps> => ({
633 settings: await readSettings(dollar),
634 wordListNames: listNames,
635 palette,
636 openOptions: await read(dollar, openOptionsAtom),
637})
638
639const pickerActions = (dollar: Dollar): PickerActions => ({
640 onChoose: chosen => void chooseSettings(dollar, chosen),
641 onToggle: group => void update(dollar, openOptionsAtom, open => (open === group ? null : group)),
642 // A focused Button takes no letters: they would go to the prompt. Back to the field instead.
643 onBackToField: () => void update(dollar, openOptionsAtom, () => null).then(() => focusField(dollar)),
644})
645
646// ─── Hooks ───────────────────────────────────────────────────────────────────────────────────
647
648/** A turn shorter than this never shows the invitation. */
649const APPEAR_AFTER_MS = 3000
650
651// The main agent's turn in progress, if any: an untouched round stays while it runs.
652let pendingTurnId: string | undefined
653
654export const register: Register = (on, options) => {
655 defaultSettings = parseTypingSettings(options)
656 dailyGoalMinutes = parseDailyGoalMinutes(options)
657
658 on('session.start', async ($, e, next) => {
659 const result = await next(e)
660 // Everything at once: none of these waits for another, so a start (and every reload, as after a
661 // change in /config) takes one round of host calls, not one after another.
662 await Promise.all([
663 // A refused name throws: caught, so the rest still loads.
664 $.command.register({ name: 'type', description: 'Type while Claude works: open the turntype game', immediate: true }).catch(() => undefined),
665 loadListNames($),
666 loadPalette($),
667 syncSession($),
668 ])
669 // The one list file a start reads: the list in use, known once the picks are. A round made before
670 // (a /type at the very start) with another list, and not typed in yet, is made again with it.
671 const settings = await readSettings($)
672 await ensureList($, settings.wordList)
673 const round = await readRound($)
674 if (round?.phase === 'ready' && round.wordList !== settings.wordList) {
675 await ensureRound($, 'settings')
676 }
677 // Lists and colors are module variables, not state: a pane drawn before they were read (a
678 // reload draws the open pane at once) is drawn again with them.
679 $.ui.invalidate('ui.render')
680
681 return result
682 })
683
684 // A new theme in /config: the colors follow from the next redraw.
685 on('config.set', { key: THEME_CONFIG_KEY }, async ($, e, next) => {
686 const result = await next(e)
687 palette = paletteFor(typeof e.value === 'string' ? e.value : '')
688
689 return result
690 }).catch(($, e, next) => next(e))
691
692 // /clear, /resume and /branch reset $.state without a new session.start: load and match again.
693 on('classic.SessionStart', { source: ['clear', 'resume', 'fork'] }, async ($, e, next) => {
694 const result = await next(e)
695 await syncSession($)
696
697 return result
698 }).catch(($, e, next) => next(e))
699
700 // `/type` opens the game; its toolbar changes the settings. Where nothing draws, say so: an empty
701 // answer would look like a command that did nothing.
702 on('command.run', { command: 'type' }, async $ => {
703 const opened = await openGame($)
704
705 return opened.isPlaced ? {} : { text: `the game needs the terminal or the Desktop app (${opened.reason})` }
706 })
707
708 // Each .catch passes the event on: in a handler next is replay-safe, so a hook that failed after
709 // its next(e) keeps that result and nothing beneath runs twice.
710 on('ui.close', { id: GAME_PANE_ID }, async ($, e, next) => {
711 const result = await next(e)
712 stopRefocus()
713 isRingOnButton = false
714 await update($, isGameOpenAtom, () => false)
715 await replaceRound($, round => roundAfter(round, { kind: 'paneClosed', isTurnRunning: pendingTurnId !== undefined }))
716
717 return result
718 }).catch(($, e, next) => next(e))
719
720 // The field's autoFocus takes the ring whenever the pane takes the keyboard: a click, ctrl+x tab, an open.
721 on('ui.focus', { requestId: GAME_PANE_ID }, async ($, e, next) => {
722 const result = await next(e)
723
724 if (result.deny === undefined) {
725 isRingOnButton = !isFieldElement(e.element)
726 await setPaneFocused($, true)
727 }
728
729 return result
730 }).catch(($, e, next) => next(e))
731
732 // A permission dialog is about to take the keys from a game in play: get them back after it.
733 // The verdict is passed on as it came; a failure here only skips the refocus (the band still
734 // offers the way back), and the .catch hands on the verdict next(e) settled to.
735 on('tool.check', async ($, e, next) => {
736 const result = await next(e)
737
738 // A query (no dialog) is harmless here: the pane keeps the keys and the watch stops.
739 if (result.decision === 'ask' && isActive(await readRound($)) && (await findGamePane($))?.isFocused) {
740 refocusAfterAsk($)
741 }
742
743 return result
744 }).catch(($, e, next) => next(e))
745
746 on('turn.start', async ($, e, next) => {
747 const result = await next(e)
748 const turnId = e.turnId
749 pendingTurnId = turnId
750
751 // Short turns never show the invitation; a test in progress outlives the turn that started it.
752 $.clock.after(APPEAR_AFTER_MS, () => {
753 if (pendingTurnId === turnId) {
754 void ensureRound($, 'invitation')
755 }
756 })
757
758 return result
759 })
760
761 on('turn.complete', async ($, e, next) => {
762 const result = await next(e)
763
764 if (!e.agentId) {
765 pendingTurnId = undefined
766 claudeDoneAt = Date.now()
767 // The person writes the next prompt now: never take the keys from them.
768 stopRefocus()
769 const isGameOpen = await read($, isGameOpenAtom)
770 await replaceRound($, round => roundAfter(round, { kind: 'turnComplete', isGameOpen }))
771 }
772
773 return result
774 })
775
776 // A key that left a Button of the pane for the prompt is the game's: taken, and the keys go back.
777 // Every other edit of the prompt passes on after one module variable is read: no host call.
778 on('prompt.edit', ($, e, next) => {
779 if (!isRingOnButton || e.text !== '' || e.key?.ctrl || e.key?.meta || !isTypedKeys(e.inputText)) {
780 return next(e)
781 }
782 isRingOnButton = false
783 void takeBackLeakedKeys($, e.inputText)
784
785 return { text: e.text, cursor: e.cursor }
786 }).catch(($, e, next) => next(e))
787
788 // The band: a one-line invitation, or the way back to an open pane that lost the keys.
789 // Its digit hotkey also fires from an empty prompt.
790 // Hot path: while the game is open and holds the keys the band is empty, and returns before it
791 // reads the round, so it is not subscribed to it and a key never draws it again. The open flag
792 // is kept by openGame, closeGame, the ui.close hook and session.start, so no host call here.
793 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
794 if (e.props.hasSurvey) {
795 return next(e)
796 }
797 const isGameOpen = await read($, isGameOpenAtom)
798
799 if (isGameOpen && (await read($, isPaneFocusedAtom))) {
800 return next(e)
801 }
802 const round = await readRound($)
803 const kind = bandKind(round, isGameOpen)
804
805 if (round === null || kind === 'none') {
806 return next(e)
807 }
808 const elements = $.ui.resolve(e)
809 const openFromBand = () => void openGame($)
810
811 if (round.phase === 'finished') {
812 return resultBand(elements, round, palette, openFromBand)
813 }
814
815 if (kind === 'back') {
816 return backToTypingBand(elements, round, openFromBand)
817 }
818 // Only the invitation shows today: a test waiting to continue is not subscribed to it.
819 const standing = round.phase === 'ready' ? await read($, dayStandingAtom) : null
820 const daily = standing === null ? undefined : { practice: todayPractice(standing, Date.now()), goalMinutes: dailyGoalMinutes }
821
822 return invitationBand(elements, round, daily, openFromBand)
823 })
824
825 // The game pane: five rows in every phase. Words, the rule, the field and live stats, then the
826 // toolbar before the first key; or the result card.
827 on('ui.render', { component: 'Pane', requestId: GAME_PANE_ID }, async ($, e, next) => {
828 const game = await read($, gameAtom)
829 const elements = $.ui.resolve(e)
830
831 if (game === null || !('Input' in elements)) {
832 return next(e)
833 }
834 const { field } = game
835 const round = withClaudeDone(game.round)
836 const placement = e.props.placement
837 lastLineWidth = e.props.bodyColumns
838
839 // Moved inline ↔ dock mid-test: the keys go with the move, at once or a moment after this draw.
840 // Watch as after a dialog: they come back, or the watch stops once they stayed 3 s.
841 if (lastPlacement !== undefined && placement !== lastPlacement && isActive(round)) {
842 refocusAfterAsk($)
843 }
844 lastPlacement = placement
845
846 if (round.phase === 'finished') {
847 return resultCard(
848 elements,
849 round,
850 {
851 bodyColumns: e.props.bodyColumns,
852 fieldKey: `card-r${round.roundId}-${await read($, cardFieldAtom)}`,
853 isDetailsShown: await read($, isDetailsShownAtom),
854 palette,
855 picker: await pickerProps($),
856 },
857 { ...resultCardActions($), picker: pickerActions($) },
858 )
859 }
860
861 // Subscribes the pane to the ticker while a test runs.
862 if (round.phase === 'running') {
863 await read($, clockTickAtom)
864 }
865
866 return gameView(
867 elements,
868 round,
869 field,
870 {
871 bodyColumns: e.props.bodyColumns,
872 isFocused: e.props.isFocused,
873 isClaudeWorking: pendingTurnId !== undefined,
874 now: Date.now(),
875 palette,
876 // Only before the first key, where the toolbar is drawn: a key never reads the settings.
877 picker: round.phase === 'ready' ? await pickerProps($) : undefined,
878 },
879 {
880 onInput: (drawn, text, lineWidth) => handleInput($, drawn, text, lineWidth),
881 onFinish: roundId => handleFinish($, roundId),
882 picker: pickerActions($),
883 },
884 )
885 })
886}
887hooks/engine/lifecycle.ts 84 lines1import type { TypingGame, TypingRound, TypingRoundReady, TypingRoundRunning } from '../../types'
2
3import { ROUND_SHAPE } from './round'
4
5/**
6 * When a round lives and goes, as plain rules: the shell holds the facts (is the game open, is
7 * Claude working) and applies the answer. A test in progress always outlives the turn and the pane
8 * that started it; a round never typed in lives only while someone may still play it.
9 */
10
11export type ActiveRound = TypingRoundReady | TypingRoundRunning
12
13/** Before or during a test: what a key, the invitation or the way back can act on. */
14export const isActive = (round: TypingRound | null): round is ActiveRound => round?.phase === 'ready' || round?.phase === 'running'
15
16/** Something that happened around the game, with the facts the rules read. */
17export type RoundEvent =
18 /** Claude's turn ended (the main agent's, not a subagent's). */
19 | { readonly kind: 'turnComplete'; readonly isGameOpen: boolean }
20 /** The person closed the game pane (Esc, the close mark). */
21 | { readonly kind: 'paneClosed'; readonly isTurnRunning: boolean }
22
23/**
24 * The round after `event`. A turn's end: a test in progress learns Claude is done; an untouched
25 * round stays only in an open game. The pane closing: a test in progress waits in the band; an
26 * untouched round stays only while Claude still works; a finished one goes with its card.
27 */
28export const roundAfter = (round: TypingRound | null, event: RoundEvent): TypingRound | null => {
29 switch (round?.phase) {
30 case 'running':
31 return event.kind === 'turnComplete' ? { ...round, isClaudeDone: true } : round
32 case 'ready':
33 return (event.kind === 'turnComplete' ? event.isGameOpen : event.isTurnRunning) ? round : null
34 case 'finished':
35 return event.kind === 'turnComplete' ? round : null
36 case undefined:
37 return null
38 }
39}
40
41/** A game kept in state over a reload stays only when this version can read its round: the same shape. */
42export const readableGame = (game: TypingGame | null): TypingGame | null => (game?.round.shape === ROUND_SHAPE ? game : null)
43
44/**
45 * Why the shell wants a round: the invitation after a turn's delay, new settings, the game opened,
46 * or "again" on the result card.
47 */
48export type RoundNeed = 'invitation' | 'settings' | 'open' | 'again'
49
50/**
51 * Whether the round there now serves `need`, or a new one is made. A test in progress is never cut
52 * short; opening the game keeps an untouched round too; "again" always starts over.
53 */
54export const keepsRound = (round: TypingRound | null, need: RoundNeed): boolean => {
55 switch (need) {
56 case 'invitation':
57 case 'settings':
58 return round?.phase === 'running'
59 case 'open':
60 return isActive(round)
61 case 'again':
62 return false
63 }
64}
65
66/**
67 * What the band above the prompt shows. `back`: the game is open but lost the keys (a dialog, a
68 * click). `invite`: a round waits with the pane closed (the invitation, or a test to continue).
69 * `result`: a test finished while the pane was closed. The shell answers `none` itself, before
70 * reading the round, while the open game holds the keys: a key then never draws the band.
71 */
72export type BandKind = 'none' | 'back' | 'invite' | 'result'
73
74export const bandKind = (round: TypingRound | null, isGameOpen: boolean): BandKind => {
75 if (round === null) {
76 return 'none'
77 }
78 if (isGameOpen) {
79 return isActive(round) ? 'back' : 'none'
80 }
81
82 return round.phase === 'finished' ? 'result' : 'invite'
83}
84hooks/engine/modes.ts 68 lines1import type { TypingCountedMode, TypingMode } from '../../types'
2
3/**
4 * The one place that knows the game's modes and the settings a person picks: each mode's rules,
5 * its counts and their defaults, and the default mode and word list. Everything else derives from
6 * it: the defaults, the toolbar's groups, labels, personal-best keys, the end of a test. It imports no code: a type only, which the compiler holds to the table both ways.
7 */
8
9/** What ends a test of the mode on its own. */
10export type ModeEnd =
11 /** Claude is done and the player completes the word (flow: as long as the turn). */
12 | 'claudeDone'
13 /** The active time, the mode's count in seconds, runs out. */
14 | 'timeUp'
15 /** The last of the mode's count of words is typed. */
16 | 'lastWord'
17
18type CountSpec = {
19 /** The counts to pick from, smallest first: the toolbar's options and what `/type` takes. */
20 readonly presets: readonly number[]
21 readonly default: number
22}
23
24type ModeSpec = {
25 readonly endsWhen: ModeEnd
26 /** What Enter makes of a test before its end: complete (flow has no end of its own) or early. */
27 readonly enterEnds: 'complete' | 'early'
28 /** A shorter test cannot set a personal best; 0: any length is fair. */
29 readonly minFairMs: number
30}
31
32/** Every mode, in toolbar order; a mode with a count has its presets, one without has none. */
33type ModeTable = {
34 readonly [M in TypingMode]: ModeSpec & (M extends TypingCountedMode ? { readonly count: CountSpec } : { readonly count?: undefined })
35}
36
37export const MODES: ModeTable = {
38 flow: { endsWhen: 'claudeDone', enterEnds: 'complete', minFairMs: 15_000 },
39 time: { endsWhen: 'timeUp', enterEnds: 'early', minFairMs: 0, count: { presets: [15, 30, 60, 120], default: 30 } },
40 words: { endsWhen: 'lastWord', enterEnds: 'early', minFairMs: 0, count: { presets: [10, 25, 50, 100], default: 25 } },
41}
42
43export const DEFAULT_MODE: TypingMode = 'flow'
44
45/** The list a new player types: the 1,000 most common English words. */
46export const DEFAULT_WORD_LIST = 'english_1k'
47
48/** The list that is always there: `english.json`, or without it the built-in words. Any list that cannot be had falls back to it. */
49export const FALLBACK_LIST_NAME = 'english'
50
51/** The modes in toolbar order. */
52export const TYPING_MODES = Object.keys(MODES) as readonly TypingMode[]
53
54export const isTypingMode = (value: unknown): value is TypingMode => typeof value === 'string' && Object.hasOwn(MODES, value)
55
56export const isCountedMode = (mode: TypingMode): mode is TypingCountedMode => MODES[mode].count !== undefined
57
58/** The modes with a count, in toolbar order. */
59export const COUNTED_MODES = TYPING_MODES.filter(isCountedMode)
60
61/** Each counted mode's default count. */
62export const DEFAULT_COUNTS = Object.fromEntries(COUNTED_MODES.map(mode => [mode, MODES[mode].count.default])) as Readonly<
63 Record<TypingCountedMode, number>
64>
65
66/** Words keep coming as the player types, unless the mode ends on its last word. */
67export const isEndless = (mode: TypingMode): boolean => MODES[mode].endsWhen !== 'lastWord'
68hooks/engine/progress.ts 331 lines1import type { TypingDayStanding, TypingPbOutcome, TypingProgressSummary, TypingRound, TypingRoundFinished } from '../../types'
2
3import { MODES } from './modes'
4import { ROUND_ENDING } from './round'
5import { addBigramAttempts, mean } from './stats'
6
7/**
8 * The saved progress, one `$.store` key per part. Every session on the machine shares the store and
9 * a get then a set is not atomic, so a save reads each part again right before it writes it and
10 * merges this test into it: a test another session saved meanwhile is kept.
11 */
12export const PROGRESS_PARTS = ['personalBests', 'history', 'daily', 'bigrams'] as const
13
14export type ProgressPart = (typeof PROGRESS_PARTS)[number]
15
16/** The one record of versions before 0.8: read once, split into the parts, then deleted. */
17export const LEGACY_PROGRESS_KEY = 'progress'
18
19const LEGACY_VERSION = 1
20const HISTORY_LIMIT = 200
21const DAILY_LIMIT = 400
22const RECENT_COUNT = 10
23const TREND_DAYS = 7
24const DAY_MS = 86_400_000
25const MIN_ACCURACY = 75
26/** Each test the older letter-pair counts weigh this much less, so a fixed weakness fades out. */
27const BIGRAM_DECAY = 0.9
28const MIN_BIGRAM_ATTEMPTS = 15
29const MIN_BIGRAM_ERROR_RATE = 0.04
30const FOCUS_BIGRAM_COUNT = 3
31const SPARK_LEVELS = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'] as const
32
33type PersonalBest = { readonly wpm: number; readonly accuracy: number; readonly at: number }
34type HistoryEntry = { readonly key: string; readonly at: number; readonly wpm: number; readonly accuracy: number }
35type DailyTotal = { readonly tests: number; readonly activeMs: number }
36type BigramStat = { readonly attempts: number; readonly errors: number }
37
38export type StoredProgress = {
39 readonly personalBests: Readonly<Record<string, PersonalBest>>
40 /** Fair results only, oldest first. */
41 readonly history: readonly HistoryEntry[]
42 /** Per local day, `YYYY-MM-DD`: every test with at least one word. */
43 readonly daily: Readonly<Record<string, DailyTotal>>
44 /** Per letter pair: how often it was typed and missed, older tests weighing less. */
45 readonly bigrams: Readonly<Record<string, BigramStat>>
46}
47
48export const EMPTY_PROGRESS: StoredProgress = { personalBests: {}, history: [], daily: {}, bigrams: {} }
49
50const isRecord = (value: unknown): value is Record<string, unknown> =>
51 typeof value === 'object' && value !== null && !Array.isArray(value)
52
53const isFiniteNumber = (value: unknown): value is number => typeof value === 'number' && Number.isFinite(value)
54
55const isPersonalBest = (value: unknown): value is PersonalBest =>
56 isRecord(value) && isFiniteNumber(value['wpm']) && isFiniteNumber(value['accuracy']) && isFiniteNumber(value['at'])
57
58const isHistoryEntry = (value: unknown): value is HistoryEntry =>
59 isRecord(value) &&
60 typeof value['key'] === 'string' &&
61 isFiniteNumber(value['at']) &&
62 isFiniteNumber(value['wpm']) &&
63 isFiniteNumber(value['accuracy'])
64
65const isDailyTotal = (value: unknown): value is DailyTotal =>
66 isRecord(value) && isFiniteNumber(value['tests']) && isFiniteNumber(value['activeMs'])
67
68const isBigramStat = (value: unknown): value is BigramStat =>
69 isRecord(value) && isFiniteNumber(value['attempts']) && isFiniteNumber(value['errors'])
70
71const pickValidEntries = <T>(value: unknown, isValid: (entry: unknown) => entry is T): Record<string, T> =>
72 isRecord(value)
73 ? Object.fromEntries(Object.entries(value).filter((entry): entry is [string, T] => isValid(entry[1])))
74 : {}
75
76/** Reads each saved part; a missing or broken part starts empty, one odd entry is left out. */
77export const PART_PARSERS: { readonly [P in ProgressPart]: (value: unknown) => StoredProgress[P] } = {
78 personalBests: value => pickValidEntries(value, isPersonalBest),
79 history: value => (Array.isArray(value) ? value.filter(isHistoryEntry) : []),
80 daily: value => pickValidEntries(value, isDailyTotal),
81 bigrams: value => pickValidEntries(value, isBigramStat),
82}
83
84/** The record of versions before 0.8, split into its parts; another version or a broken value is empty. */
85export const parseLegacyProgress = (value: unknown): StoredProgress =>
86 isRecord(value) && value['version'] === LEGACY_VERSION
87 ? {
88 personalBests: PART_PARSERS.personalBests(value['personalBests']),
89 history: PART_PARSERS.history(value['history']),
90 daily: PART_PARSERS.daily(value['daily']),
91 bigrams: PART_PARSERS.bigrams(value['bigrams']),
92 }
93 : EMPTY_PROGRESS
94
95/**
96 * A personal best is kept per mode, count and word list (`time:30:english`, `flow:code`): a 15 s
97 * sprint never competes with 100 words, nor code with English.
98 */
99export const configKey = (round: TypingRound): string =>
100 [round.mode, ...(round.count === undefined ? [] : [round.count]), round.wordList].join(':')
101
102export const personalBestWpms = (progress: StoredProgress): Readonly<Record<string, number>> =>
103 Object.fromEntries(Object.entries(progress.personalBests).map(([key, best]) => [key, best.wpm]))
104
105/** Why a result cannot set a personal best, or undefined when it is a fair test. */
106const unfairReason = (round: TypingRoundFinished): string | undefined => {
107 if (round.hasPastedText) {
108 return 'text was pasted'
109 }
110 if (round.ending === ROUND_ENDING.early) {
111 return 'ended early'
112 }
113 const { minFairMs } = MODES[round.mode]
114 if (round.result.durationMs < minFairMs) {
115 return `under ${minFairMs / 1000} s`
116 }
117
118 return round.result.accuracy < MIN_ACCURACY ? `accuracy under ${MIN_ACCURACY}%` : undefined
119}
120
121/** The local calendar day: a streak follows the player's midnight, not UTC. */
122const dayKey = (date: Date): string =>
123 `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
124
125const previousDay = (date: Date): Date => new Date(date.getFullYear(), date.getMonth(), date.getDate() - 1)
126
127const countStreak = (daily: StoredProgress['daily'], day: Date): number =>
128 daily[dayKey(day)] === undefined ? 0 : 1 + countStreak(daily, previousDay(day))
129
130/** The practice of `now`'s day as saved, and the streak before it. */
131export const dayStanding = (daily: StoredProgress['daily'], now: number): TypingDayStanding => {
132 const today = new Date(now)
133 const total = daily[dayKey(today)] ?? { tests: 0, activeMs: 0 }
134
135 return { day: dayKey(today), tests: total.tests, activeMs: total.activeMs, streakBefore: countStreak(daily, previousDay(today)) }
136}
137
138export type TodayPractice = {
139 readonly activeMs: number
140 /** Days in a row with a test: today's included once played, until then the run that ended yesterday. */
141 readonly streakDays: number
142 readonly isPlayedToday: boolean
143}
144
145/**
146 * Today as of `now`, from a standing read that day or the day before (a session open past
147 * midnight): yesterday's standing gives a day with nothing typed yet, its streak still alive.
148 */
149export const todayPractice = (standing: TypingDayStanding, now: number): TodayPractice => {
150 const today = new Date(now)
151
152 if (standing.day === dayKey(today)) {
153 const isPlayedToday = standing.tests > 0
154
155 return { activeMs: standing.activeMs, streakDays: standing.streakBefore + (isPlayedToday ? 1 : 0), isPlayedToday }
156 }
157 const isFromYesterday = standing.day === dayKey(previousDay(today))
158
159 return { activeMs: 0, streakDays: isFromYesterday && standing.tests > 0 ? standing.streakBefore + 1 : 0, isPlayedToday: false }
160}
161
162const addToDaily = (daily: StoredProgress['daily'], today: string, durationMs: number): StoredProgress['daily'] => {
163 const before = daily[today] ?? { tests: 0, activeMs: 0 }
164 const entries = Object.entries({ ...daily, [today]: { tests: before.tests + 1, activeMs: before.activeMs + durationMs } })
165
166 return Object.fromEntries(
167 entries.toSorted(([firstDay], [secondDay]) => firstDay.localeCompare(secondDay)).slice(-DAILY_LIMIT),
168 )
169}
170
171const roundToHundredths = (value: number): number => Math.round(value * 100) / 100
172
173/** The part of the current word's target the player reached: its letters as far as typed. */
174const reachedInCurrentWord = (round: TypingRoundFinished): string =>
175 (round.targetWords[round.typedWords.length] ?? '').slice(0, round.currentWord.length)
176
177/** Weighs the old counts down and adds this test's; a pair that fades below one attempt is dropped. */
178const mergeBigrams = (bigrams: StoredProgress['bigrams'], round: TypingRoundFinished): StoredProgress['bigrams'] => {
179 // Every pair reached is one attempt: the words that left the window counted as they left, then the
180 // window's committed targets and the current word's so far.
181 const attempts = addBigramAttempts(round.droppedBigramAttempts, [
182 ...round.targetWords.slice(0, round.typedWords.length),
183 reachedInCurrentWord(round),
184 ])
185 const keys = new Set([...Object.keys(bigrams), ...Object.keys(attempts), ...Object.keys(round.bigramErrors)])
186
187 return Object.fromEntries(
188 [...keys]
189 .map((bigram): [string, BigramStat] => [
190 bigram,
191 {
192 attempts: roundToHundredths((bigrams[bigram]?.attempts ?? 0) * BIGRAM_DECAY + (attempts[bigram] ?? 0)),
193 errors: roundToHundredths((bigrams[bigram]?.errors ?? 0) * BIGRAM_DECAY + (round.bigramErrors[bigram] ?? 0)),
194 },
195 ])
196 .filter(([, stat]) => stat.attempts >= 1),
197 )
198}
199
200/**
201 * The player's weakest letter pairs, worst first: enough attempts to judge, and an error rate
202 * that is not noise. Smoothing (+5 attempts) keeps one slip on a rare pair from topping the list.
203 */
204export const weakBigrams = (progress: StoredProgress): readonly string[] =>
205 Object.entries(progress.bigrams)
206 .filter(([, stat]) => stat.attempts >= MIN_BIGRAM_ATTEMPTS)
207 .map(([bigram, stat]) => ({ bigram, errorRate: stat.errors / (stat.attempts + 5) }))
208 .filter(({ errorRate }) => errorRate >= MIN_BIGRAM_ERROR_RATE)
209 .toSorted((first, second) => second.errorRate - first.errorRate)
210 .slice(0, FOCUS_BIGRAM_COUNT)
211 .map(({ bigram }) => bigram)
212
213const judgePersonalBest = (wpm: number, previous: PersonalBest | undefined): TypingPbOutcome => {
214 if (previous === undefined) {
215 return { kind: 'first' }
216 }
217
218 return wpm > previous.wpm ? { kind: 'new', previousWpm: previous.wpm } : { kind: 'kept', pbWpm: previous.wpm }
219}
220
221/** How one finished test changes each part: applied to the parts as read, or to a fresher copy. */
222export type ProgressChanges = { readonly [P in ProgressPart]: (part: StoredProgress[P]) => StoredProgress[P] }
223
224/**
225 * The changes of a finished test that ended at `now`. Each one merges, never replaces: a personal
226 * best keeps the higher wpm, a result is added once, today's total adds this test. So applied to a
227 * part another session changed meanwhile, both tests stay.
228 */
229export const progressChanges = (round: TypingRoundFinished, now: number): ProgressChanges => {
230 const { result } = round
231 const key = configKey(round)
232 const isFair = unfairReason(round) === undefined
233 // Any test with a word is practice data and counts for today, fair or not.
234 const hasWords = result.wordCount > 0
235 const entry: HistoryEntry = { key, at: now, wpm: result.wpm, accuracy: result.accuracy }
236
237 return {
238 personalBests: bests =>
239 isFair && (bests[key]?.wpm ?? -1) < result.wpm ? { ...bests, [key]: { wpm: result.wpm, accuracy: result.accuracy, at: now } } : bests,
240 history: history =>
241 isFair && !history.some(past => past.key === key && past.at === now) ? [...history, entry].slice(-HISTORY_LIMIT) : history,
242 daily: daily => (hasWords ? addToDaily(daily, dayKey(new Date(now)), result.durationMs) : daily),
243 bigrams: bigrams => (hasWords ? mergeBigrams(bigrams, round) : bigrams),
244 }
245}
246
247export const applyProgressChanges = (progress: StoredProgress, changes: ProgressChanges): StoredProgress => ({
248 personalBests: changes.personalBests(progress.personalBests),
249 history: changes.history(progress.history),
250 daily: changes.daily(progress.daily),
251 bigrams: changes.bigrams(progress.bigrams),
252})
253
254export type RecordedProgress = {
255 readonly progress: StoredProgress
256 readonly summary: TypingProgressSummary
257}
258
259/** Adds a finished test to the saved progress and tells how it compares. `now` is when it ended. */
260export const recordResult = (progress: StoredProgress, round: TypingRoundFinished, now: number): RecordedProgress => {
261 const { result } = round
262 const key = configKey(round)
263 const recorded = applyProgressChanges(progress, progressChanges(round, now))
264 const standing = dayStanding(recorded.daily, now)
265 const previousBest = progress.personalBests[key]
266 const sameConfigHistory = progress.history.filter(entry => entry.key === key)
267 const dayTotals = {
268 todayTests: standing.tests,
269 todayActiveMs: standing.activeMs,
270 streakDays: todayPractice(standing, now).streakDays,
271 }
272 const reason = unfairReason(round)
273
274 if (reason !== undefined) {
275 return {
276 progress: recorded,
277 summary: {
278 pb: { kind: 'unfair', reason, pbWpm: previousBest?.wpm },
279 trendDelta: undefined,
280 recentWpm: sameConfigHistory.slice(-RECENT_COUNT).map(entry => entry.wpm),
281 ...dayTotals,
282 },
283 }
284 }
285
286 const weekWpm = sameConfigHistory.filter(past => past.at >= now - TREND_DAYS * DAY_MS).map(past => past.wpm)
287
288 return {
289 progress: recorded,
290 summary: {
291 pb: judgePersonalBest(result.wpm, previousBest),
292 trendDelta: weekWpm.length === 0 ? undefined : Math.round(result.wpm - mean(weekWpm)),
293 recentWpm: [...sameConfigHistory, { wpm: result.wpm }].slice(-RECENT_COUNT).map(past => past.wpm),
294 ...dayTotals,
295 },
296 }
297}
298
299/** Speeds worth a toast the first time a fair test reaches them: every 10 wpm early on, so a player improving sees one often. */
300const WPM_MILESTONES = [40, 50, 60, 70, 80, 90, 100, 120, 150] as const
301const STREAK_MILESTONES = [3, 7, 30, 100] as const
302
303const bestWpm = (progress: StoredProgress): number => Math.max(0, ...Object.values(progress.personalBests).map(best => best.wpm))
304
305const streakAt = (progress: StoredProgress, now: number): number => todayPractice(dayStanding(progress.daily, now), now).streakDays
306
307/** The highest milestone passed going from `before` to `after`: `before < m <= after`. */
308const highestPassed = (milestones: readonly number[], before: number, after: number): number | undefined =>
309 milestones.findLast(milestone => before < milestone && milestone <= after)
310
311/**
312 * What a test reached for the first time, from the saved progress before and after it: the
313 * highest new speed (any mode or list, fair tests only), and a streak length. No record of its own.
314 */
315export const milestonesReached = (before: StoredProgress, after: StoredProgress, now: number): readonly string[] => {
316 const wpm = highestPassed(WPM_MILESTONES, bestWpm(before), bestWpm(after))
317 const streak = highestPassed(STREAK_MILESTONES, streakAt(before, now), streakAt(after, now))
318
319 return [...(wpm === undefined ? [] : [`first ${wpm} wpm test`]), ...(streak === undefined ? [] : [`${streak}-day streak`])]
320}
321
322/** One block character per value, scaled from the lowest to the highest; a flat run sits in the middle. */
323export const sparkline = (values: readonly number[]): string => {
324 const lowest = Math.min(...values)
325 const range = Math.max(...values) - lowest
326
327 return values
328 .map(value => SPARK_LEVELS[range === 0 ? 3 : Math.round(((value - lowest) / range) * (SPARK_LEVELS.length - 1))])
329 .join('')
330}
331hooks/engine/field.ts 86 lines1import type { TypingField, TypingGame, TypingRound } from '../../types'
2
3import { applyTyped } from './round'
4import type { TypingContext } from './round'
5import { textFromWord } from './typed-text'
6import { droppedWords } from './window'
7
8/*
9 * The input field: the pane types into an Input, which keeps its own text, so each committed word
10 * gets a fresh field (a new key) and a field's text is read against where it started. The round
11 * knows none of this: it takes "the text from word N on" (applyTyped), and this file works out N
12 * and the next field. A surface that hands keys to the mod directly would need none of it.
13 */
14
15/** The field a round starts with: the first, empty, at the start of the text. */
16export const firstField = (round: TypingRound): TypingField => ({ roundId: round.roundId, index: 0, baseWord: 0, basePartial: 0, value: '' })
17
18/** The game for `round`: its field kept while the round is the same one, a first field for a new one. */
19export const gameWith = (game: TypingGame | null, round: TypingRound | null): TypingGame | null =>
20 round === null ? null : { round, field: game?.field.roundId === round.roundId ? game.field : firstField(round) }
21
22/** A field as the pane drew it: its round, its index (its key), and where its text started. */
23export type FieldAsDrawn = Pick<TypingField, 'roundId' | 'index' | 'baseWord' | 'basePartial'>
24
25/** The field's key in the pane: a new key is a new Input, drawn holding its value. */
26export const fieldKey = (field: Pick<TypingField, 'roundId' | 'index'>): string => `r${field.roundId}-f${field.index}`
27
28export const drawnOf = ({ roundId, index, baseWord, basePartial }: TypingField): FieldAsDrawn => ({ roundId, index, baseWord, basePartial })
29
30/** The committed words and the current word: none before the first key. */
31const typedOf = (round: TypingRound): readonly [readonly string[], string] => (round.phase === 'ready' ? [[], ''] : [round.typedWords, round.currentWord])
32
33/** The end of the typed text, as a field's base: every committed word, and the current word as far as typed. */
34const endOf = (round: TypingRound): Pick<TypingField, 'baseWord' | 'basePartial'> =>
35 round.phase === 'ready' ? { baseWord: 0, basePartial: 0 } : { baseWord: round.scored.words, basePartial: round.currentWord.length }
36
37/** A fresh field (a new key) holding the round's text after the field's base: the one drawn before showed keys left out. */
38export const freshField = (field: TypingField, round: TypingRound): TypingField => {
39 const [typedWords, currentWord] = typedOf(round)
40
41 return { ...field, index: field.index + 1, value: textFromWord(typedWords, currentWord, field.baseWord - droppedWords(round)).slice(field.basePartial) }
42}
43
44/**
45 * Applies one change of the field `drawn`, whose text follows its base as the field was drawn. Its
46 * base is a point in the typed text, a late field's too, so the text before it is read from the round.
47 * A late change (a field already replaced) is taken only while the newer field is still empty: it
48 * holds the keys typed before the redraw. After a committed word the next field starts empty at
49 * the end of the text; where the round left keys out, a fresh field holds what it took.
50 */
51export const applyFieldInput = (game: TypingGame, drawn: FieldAsDrawn, fieldText: string, now: number, context: TypingContext): TypingGame => {
52 const { round, field } = game
53
54 if (round.phase === 'finished' || drawn.roundId !== round.roundId) {
55 return game
56 }
57 const [typedWords, currentWord] = typedOf(round)
58 const dropped = droppedWords(round)
59 // The field's base in today's window, as a word and a number of characters from that word's start.
60 const shiftedBase = drawn.baseWord - dropped
61 const fromWord = Math.min(Math.max(0, shiftedBase), typedWords.length)
62 const previousTail = textFromWord(typedWords, currentWord, fromWord)
63 const basePartial = shiftedBase < 0 ? 0 : shiftedBase > typedWords.length ? previousTail.length : Math.min(drawn.basePartial, previousTail.length)
64 const isLate = drawn.index < field.index
65
66 if (isLate && textFromWord(typedWords, currentWord, field.baseWord - dropped).length !== field.basePartial) {
67 return game
68 }
69 const fieldTyped = previousTail.slice(0, basePartial) + fieldText
70 const change = applyTyped(round, fromWord, fieldTyped, now, context)
71
72 if (change === undefined) {
73 return game
74 }
75 const next = change.round
76 if (next.phase === 'finished') {
77 return { round: next, field }
78 }
79 const didCommit = next !== round && endOf(next).baseWord > endOf(round).baseWord
80 // A late field's keys join the base of the field after it; a committed word starts the next field.
81 const placed =
82 next !== round && (isLate || didCommit) ? { ...field, ...endOf(next), index: isLate ? field.index : field.index + 1, value: '' } : field
83
84 return { round: next, field: change.held === fieldTyped ? placed : freshField(placed, next) }
85}
86hooks/engine/round.ts 312 lines1import type {
2 TypingRound,
3 TypingRoundBase,
4 TypingRoundEnding,
5 TypingRoundFinished,
6 TypingRoundReady,
7 TypingRoundRunning,
8 TypingSettings,
9} from '../../types'
10
11import { isEndless, MODES } from './modes'
12import { countOf } from './settings'
13import { addScoredWords, addToSecond, calculateResult, EMPTY_SCORED } from './stats'
14import { activeElapsedMs, isTimeUp, pausedPartOfGap } from './timing'
15import { parseTyped, textFromWord } from './typed-text'
16import { trimWindow } from './window'
17import { FALLBACK_WORD_LIST, pickWords } from './word-list'
18import type { WordList } from './word-list'
19
20export const ROUND_ENDING = {
21 complete: 'complete',
22 timeUp: 'timeUp',
23 early: 'early',
24} as const satisfies Record<TypingRoundEnding, TypingRoundEnding>
25
26/** The version of the round's shape: raised whenever its fields change, so a round kept over a reload from another version is dropped. */
27export const ROUND_SHAPE = 5
28
29const EMPTY_SAMPLES = { count: 0, sum: 0, sumOfSquares: 0 } as const
30const FIRST_SECOND = { index: 0, keystrokes: 0 } as const
31
32const INITIAL_ENDLESS_WORDS = 60
33const LOOKAHEAD_WORDS = 20
34/** Two lines of `lineWidth` cells hold about `lineWidth / 3` words of six cells (a word and its space). */
35const CELLS_PER_WORD_AHEAD = 3
36
37/** Words to keep ahead of the current one: enough to fill the two lines on show at this width. */
38const wordsAheadFor = (lineWidth: number): number => Math.max(LOOKAHEAD_WORDS, Math.ceil(lineWidth / CELLS_PER_WORD_AHEAD))
39/**
40 * The most text one change of the field adds while a person types: one key, or a few the host
41 * coalesced in a fast burst. More is a paste, and a pasted test can never be a personal best.
42 */
43export const MAX_CHARACTERS_PER_INPUT = 3
44
45/**
46 * A new round with words from `wordList`; `weakBigrams` are the player's weak letter pairs, practiced
47 * when adaptive words are on. The round keeps only the list's name: a key then copies no word list.
48 */
49export const createRound = (
50 roundId: number,
51 settings: TypingSettings,
52 weakBigrams: readonly string[],
53 wordList: WordList = FALLBACK_WORD_LIST,
54): TypingRoundReady => {
55 const focusBigrams = settings.isAdaptive ? weakBigrams : []
56 const count = countOf(settings)
57
58 return {
59 shape: ROUND_SHAPE,
60 roundId,
61 mode: settings.mode,
62 count,
63 phase: 'ready',
64 // An endless test starts with enough words to fill the rows; one that ends on its last word has its count.
65 targetWords: pickWords(wordList.words, isEndless(settings.mode) ? INITIAL_ENDLESS_WORDS : (count ?? INITIAL_ENDLESS_WORDS), undefined, focusBigrams),
66 isClaudeDone: false,
67 focusBigrams,
68 pbWpm: undefined,
69 wordList: wordList.name,
70 }
71}
72
73type KeystrokeTally = {
74 readonly committedCount: number
75 readonly currentWord: string
76 readonly correct: number
77 readonly incorrect: number
78 readonly didCommit: boolean
79 /** Expected letter pairs whose second letter was missed, one entry per wrong key. */
80 readonly missedBigrams: readonly string[]
81}
82
83/** Scores only the appended characters, as each key was judged when it was pressed. */
84const tallyAppended = (committedCount: number, currentWord: string, appended: string, targetWords: readonly string[]): KeystrokeTally => {
85 const initial: KeystrokeTally = {
86 committedCount,
87 currentWord,
88 correct: 0,
89 incorrect: 0,
90 didCommit: false,
91 missedBigrams: [],
92 }
93
94 return [...appended].reduce<KeystrokeTally>((tally, character) => {
95 const targetWord = targetWords[tally.committedCount] ?? ''
96
97 if (character === ' ') {
98 if (tally.currentWord === '') {
99 return tally
100 }
101 const isCorrect = tally.currentWord === targetWord
102
103 return {
104 committedCount: tally.committedCount + 1,
105 currentWord: '',
106 correct: tally.correct + (isCorrect ? 1 : 0),
107 incorrect: tally.incorrect + (isCorrect ? 0 : 1),
108 didCommit: true,
109 missedBigrams: tally.missedBigrams,
110 }
111 }
112
113 const position = tally.currentWord.length
114 const isCorrect = character === targetWord[position]
115 // The pair the player meant: the target's letter before and the one missed. A word's first
116 // letter and letters past its end have no pair. Only while the word is still on track: after
117 // a slip every next letter is out of place, and would blame pairs the player did not miss.
118 const isOnTrack = targetWord.startsWith(tally.currentWord)
119 const missedBigram =
120 !isCorrect && isOnTrack && position > 0 && position < targetWord.length ? targetWord.slice(position - 1, position + 1) : undefined
121
122 return {
123 ...tally,
124 currentWord: tally.currentWord + character,
125 correct: tally.correct + (isCorrect ? 1 : 0),
126 incorrect: tally.incorrect + (isCorrect ? 0 : 1),
127 missedBigrams: missedBigram === undefined ? tally.missedBigrams : [...tally.missedBigrams, missedBigram],
128 }
129 }, initial)
130}
131
132const addBigramErrors = (
133 bigramErrors: Readonly<Record<string, number>>,
134 missedBigrams: readonly string[],
135): Readonly<Record<string, number>> =>
136 missedBigrams.length === 0
137 ? bigramErrors
138 : missedBigrams.reduce((counts, bigram) => ({ ...counts, [bigram]: (counts[bigram] ?? 0) + 1 }), bigramErrors)
139
140export const finishRound = (round: TypingRoundRunning, ending: TypingRoundEnding): TypingRoundFinished => ({
141 shape: round.shape,
142 roundId: round.roundId,
143 mode: round.mode,
144 count: round.count,
145 targetWords: round.targetWords,
146 isClaudeDone: round.isClaudeDone,
147 focusBigrams: round.focusBigrams,
148 pbWpm: round.pbWpm,
149 wordList: round.wordList,
150 phase: 'finished',
151 typedWords: round.typedWords,
152 currentWord: round.currentWord,
153 ending,
154 bigramErrors: round.bigramErrors,
155 hasPastedText: round.hasPastedText,
156 scored: round.scored,
157 droppedBigramAttempts: round.droppedBigramAttempts,
158 result: calculateResult(round, ending),
159 progress: undefined,
160})
161
162/** The player ends the test (Enter): complete in a mode with no end of its own (flow), else early. */
163export const finishByPlayer = (round: TypingRoundRunning): TypingRoundFinished =>
164 finishRound(round, MODES[round.mode].enterEnds === 'complete' ? ROUND_ENDING.complete : ROUND_ENDING.early)
165
166/** The last word typed: committed, or its last letter in place (no space needed after the last word). */
167const isLastWordTyped = (round: TypingRoundRunning, tally: KeystrokeTally): boolean => {
168 const lastTargetWord = round.targetWords.at(-1)
169
170 return (
171 tally.committedCount >= round.targetWords.length ||
172 (tally.committedCount === round.targetWords.length - 1 && tally.currentWord === lastTargetWord)
173 )
174}
175
176/**
177 * Typed text from a committed word on, with what is not typing left out: spaces before a word (only
178 * one space ends a word) and letters past the current word's end (the next key after a typed-out
179 * word is the space). `fromWord` says which target the text's first word is.
180 */
181const cleanTypedText = (text: string, targetWords: readonly string[], fromWord: number): string => {
182 const spaced = text.replace(/^ +/, '').replace(/ {2,}/g, ' ')
183 const { committedWords, currentWord } = parseTyped(spaced)
184 const overflow = currentWord.length - (targetWords[fromWord + committedWords.length] ?? '').length
185
186 return overflow > 0 ? spaced.slice(0, spaced.length - overflow) : spaced
187}
188
189/** `committed` (the words from `fromWord` on) still holds every committed word from there, as typed. */
190const keepsCommittedWords = (typedWords: readonly string[], fromWord: number, committed: readonly string[]): boolean =>
191 typedWords.slice(fromWord).every((word, index) => committed[index] === word)
192
193/** The words of `typedWords` before `fromWord`, then `committed`: the same array where nothing changed. */
194const joinWords = (typedWords: readonly string[], fromWord: number, committed: readonly string[]): readonly string[] =>
195 fromWord + committed.length === typedWords.length && committed.every((word, index) => word === typedWords[fromWord + index])
196 ? typedWords
197 : [...typedWords.slice(0, fromWord), ...committed]
198
199/** Ends a time test whose active time has run out; any other round is returned as it is. */
200export const expireRound = (round: TypingRound, now: number): TypingRound =>
201 round.phase === 'running' && isTimeUp(round, now) ? finishRound(round, ROUND_ENDING.timeUp) : round
202
203/** A change of the typed text as the round took it: the round after it, and the text it holds from `fromWord` on. */
204export type TypedChange = {
205 readonly round: TypingRound
206 /** `text` with the keys that are not typing left out: a field showing more holds keys the round did not take. */
207 readonly held: string
208}
209
210/**
211 * What a change needs beside the text: the round's word list (for the words added ahead in an
212 * endless test) and the width the pane draws the words at (where the words no longer drawn end).
213 */
214export type TypingContext = {
215 readonly words: readonly string[]
216 readonly lineWidth: number
217}
218
219/**
220 * Applies a change of the typed text from committed word `fromWord` (in the window) on: `text` is
221 * what the player has there now. Only that part is looked at, which is the current word alone but
222 * after a late field. Undefined when the change is not taken: it would take back a committed word.
223 * Deletions and edits count no keystroke, but they are activity: they end an idle gap.
224 */
225export const applyTyped = (
226 round: TypingRoundReady | TypingRoundRunning,
227 fromWord: number,
228 text: string,
229 now: number,
230 { words, lineWidth }: TypingContext,
231): TypedChange | undefined => {
232 const previousWords = round.phase === 'running' ? round.typedWords : []
233 const previousCurrent = round.phase === 'running' ? round.currentWord : ''
234 const previousTail = textFromWord(previousWords, previousCurrent, fromWord)
235
236 // The time ran out while the player was away: the test ends without this key.
237 if (round.phase === 'running' && isTimeUp(round, now)) {
238 return { round: finishRound(round, ROUND_ENDING.timeUp), held: previousTail }
239 }
240 // One rule for keys that are not typing (a space before a word, a letter past a word's end):
241 // they are left out of the text and the scores.
242 const tail = cleanTypedText(text, round.targetWords, fromWord)
243
244 if (tail === previousTail) {
245 return { round, held: tail }
246 }
247
248 const parsedTail = parseTyped(tail)
249 // A committed word is final: a late change that would take one back (its space deleted) is not taken.
250 if (!keepsCommittedWords(previousWords, fromWord, parsedTail.committedWords)) {
251 return undefined
252 }
253 const typedWords = joinWords(previousWords, fromWord, parsedTail.committedWords)
254 const { currentWord } = parsedTail
255 const isAppend = tail.startsWith(previousTail)
256 const appended = isAppend ? tail.slice(previousTail.length) : ''
257 const tally = tallyAppended(previousWords.length, previousCurrent, appended, round.targetWords)
258 const keystrokes = tally.correct + tally.incorrect
259
260 const started = round.phase === 'running' ? round : undefined
261 const startedAt = started?.startedAt ?? now
262 const pausedMs = (started?.pausedMs ?? 0) + (started ? pausedPartOfGap(now - started.lastInputAt) : 0)
263 const wordsAhead = wordsAheadFor(lineWidth)
264 const needsMoreWords = isEndless(round.mode) && tally.committedCount + wordsAhead > round.targetWords.length
265 const running: TypingRoundRunning = {
266 shape: round.shape,
267 roundId: round.roundId,
268 mode: round.mode,
269 count: round.count,
270 isClaudeDone: round.isClaudeDone,
271 focusBigrams: round.focusBigrams,
272 pbWpm: round.pbWpm,
273 wordList: round.wordList,
274 phase: 'running',
275 targetWords: needsMoreWords
276 ? [...round.targetWords, ...pickWords(words, wordsAhead, round.targetWords.at(-1), round.focusBigrams)]
277 : round.targetWords,
278 typedWords,
279 currentWord,
280 startedAt,
281 lastInputAt: now,
282 pausedMs,
283 correctKeystrokes: (started?.correctKeystrokes ?? 0) + tally.correct,
284 incorrectKeystrokes: (started?.incorrectKeystrokes ?? 0) + tally.incorrect,
285 // Each word committed by this change, scored once: it is final from here on.
286 scored: addScoredWords(
287 started?.scored ?? EMPTY_SCORED,
288 typedWords.slice(previousWords.length),
289 round.targetWords.slice(previousWords.length),
290 ),
291 bigramErrors: addBigramErrors(started?.bigramErrors ?? {}, tally.missedBigrams),
292 hasPastedText: (started?.hasPastedText ?? false) || appended.length > MAX_CHARACTERS_PER_INPUT,
293 droppedBigramAttempts: started?.droppedBigramAttempts ?? {},
294 secondTotals: started?.secondTotals ?? EMPTY_SAMPLES,
295 currentSecond: started?.currentSecond ?? FIRST_SECOND,
296 }
297 // Samples go to active seconds, so a pause adds no empty seconds to the consistency.
298 const sampled: TypingRoundRunning = {
299 ...running,
300 ...addToSecond(running.secondTotals, running.currentSecond, Math.floor(activeElapsedMs(running, now) / 1000), keystrokes),
301 }
302
303 // The mode's own end: its last word, or Claude done and the word completed.
304 const { endsWhen } = MODES[sampled.mode]
305 if ((endsWhen === 'lastWord' && isLastWordTyped(sampled, tally)) || (endsWhen === 'claudeDone' && sampled.isClaudeDone && tally.didCommit)) {
306 return { round: finishRound(sampled, ROUND_ENDING.complete), held: tail }
307 }
308
309 // A committed word is final: the time to drop the oldest words once there are enough.
310 return { round: tally.didCommit ? trimWindow(sampled, lineWidth) : sampled, held: tail }
311}
312hooks/engine/settings.ts 74 lines1import type { PluginOptions } from 'claude-code'
2
3import type { TypingCountedMode, TypingPicks, TypingSettings } from '../../types'
4import { COUNTED_MODES, DEFAULT_COUNTS, DEFAULT_MODE, DEFAULT_WORD_LIST, FALLBACK_LIST_NAME, isCountedMode, isTypingMode } from './modes'
5import { isWordListName } from './word-list'
6
7/** The settings before any pick: the defaults of `modes.ts`, adaptive words on. */
8export const DEFAULT_SETTINGS: TypingSettings = {
9 mode: DEFAULT_MODE,
10 counts: DEFAULT_COUNTS,
11 isAdaptive: true,
12 wordList: DEFAULT_WORD_LIST,
13}
14
15/** The store key of the picks: one value, so a pick is one write. */
16export const PICKED_SETTINGS_KEY = 'settings'
17
18/** What a pick saves: the picked fields alone. */
19export const picksOf = (settings: TypingSettings): TypingPicks => ({ mode: settings.mode, counts: settings.counts, wordList: settings.wordList })
20
21/**
22 * The settings in use: the picks over the defaults. A list that is not there (its file deleted from
23 * `word-lists/`) gives way to the default list, and that one to English, which always is: the toolbar
24 * and the personal bests never name a list the words do not come from. The pick stays saved: the
25 * list is back when its file is.
26 */
27export const settingsInUse = (defaults: TypingSettings, picks: TypingPicks | null, isKnownList: (name: string) => boolean): TypingSettings => {
28 const settings = picks === null ? defaults : { ...defaults, ...picks }
29 const wordList = [settings.wordList, defaults.wordList].find(isKnownList) ?? FALLBACK_LIST_NAME
30
31 return wordList === settings.wordList ? settings : { ...settings, wordList }
32}
33
34const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === 'object' && value !== null && !Array.isArray(value)
35
36const parseCount = (value: unknown, fallback: number): number => {
37 const count = Number(value)
38
39 return Number.isInteger(count) && count > 0 ? count : fallback
40}
41
42/** Picks saved before counts were kept per mode named each count by its field. */
43const LEGACY_COUNT_FIELDS: Readonly<Record<TypingCountedMode, string>> = { time: 'timeSeconds', words: 'wordCount' }
44
45/** Picks read back from the store: null when none are saved; a field missing or odd keeps its default. */
46export const parseSavedPicks = (saved: unknown, defaults: TypingSettings): TypingPicks | null => {
47 if (!isRecord(saved)) {
48 return null
49 }
50 const counts = isRecord(saved['counts']) ? saved['counts'] : {}
51
52 return {
53 mode: isTypingMode(saved['mode']) ? saved['mode'] : defaults.mode,
54 counts: Object.fromEntries(
55 COUNTED_MODES.map(mode => [mode, parseCount(counts[mode] ?? saved[LEGACY_COUNT_FIELDS[mode]], defaults.counts[mode])]),
56 ) as TypingPicks['counts'],
57 wordList: typeof saved['wordList'] === 'string' && isWordListName(saved['wordList']) ? saved['wordList'] : defaults.wordList,
58 }
59}
60
61/** The defaults before any pick, with adaptive words from /config: the one setting the game has no button for. */
62export const parseTypingSettings = (options: PluginOptions): TypingSettings => ({ ...DEFAULT_SETTINGS, isAdaptive: options['adaptive'] !== 'off' })
63
64/** The count a settings' mode is played with: its count, or none for a mode without one (flow). */
65export const countOf = (settings: TypingSettings): number | undefined =>
66 isCountedMode(settings.mode) ? settings.counts[settings.mode] : undefined
67
68/** The daily goal from /config, in minutes of typing; undefined when off. */
69export const parseDailyGoalMinutes = (options: PluginOptions): number | undefined => {
70 const minutes = Number(options['dailyGoal'])
71
72 return Number.isInteger(minutes) && minutes > 0 ? minutes : undefined
73}
74hooks/engine/timing.ts 25 lines1import type { TypingRoundBase, TypingRoundRunning } from '../../types'
2
3import { MODES } from './modes'
4
5/**
6 * One rule pauses every interruption (Esc, a closed pane, a permission prompt, a break):
7 * of any gap between two inputs, only this much counts as test time.
8 */
9export const IDLE_PAUSE_MS = 2000
10
11/** The part of a gap between inputs that is left out of the test. */
12export const pausedPartOfGap = (gapMs: number): number => Math.max(0, gapMs - IDLE_PAUSE_MS)
13
14export const isIdle = (round: TypingRoundRunning, now: number): boolean => now - round.lastInputAt > IDLE_PAUSE_MS
15
16/** Test time so far: wall time since the start, less the pauses; it stops while idle. */
17export const activeElapsedMs = (round: TypingRoundRunning, now: number): number =>
18 Math.min(now, round.lastInputAt + IDLE_PAUSE_MS) - round.startedAt - round.pausedMs
19
20/** The active time a test lasts: its count in seconds where the mode ends when time is up; else no limit. */
21export const timeLimitMs = (round: TypingRoundBase): number =>
22 MODES[round.mode].endsWhen === 'timeUp' ? (round.count ?? 0) * 1000 : Number.POSITIVE_INFINITY
23
24export const isTimeUp = (round: TypingRoundRunning, now: number): boolean => activeElapsedMs(round, now) >= timeLimitMs(round)
25hooks/engine/word-list.ts 107 lines1import { FALLBACK_LIST_NAME } from './modes'
2
3/**
4 * A word list: what a test picks its words from. The lists are JSON files in the mod's
5 * `word-lists/` folder, `{ "words": [...] }`, named by their file: to add a list, add a file.
6 */
7export type WordList = {
8 readonly name: string
9 readonly words: readonly string[]
10}
11
12
13/**
14 * The first 50 words of `english.json`, our own list: the game still works when no list file can be
15 * read. (The `english_1k`/`_5k`/`_10k` files are Monkeytype's, GPL-3.0: see the README.)
16 */
17export const FALLBACK_WORD_LIST: WordList = {
18 name: FALLBACK_LIST_NAME,
19 words: [
20 'the', 'be', 'to', 'of', 'and', 'a', 'in', 'that', 'have', 'it',
21 'for', 'not', 'on', 'with', 'he', 'as', 'you', 'do', 'at', 'this',
22 'but', 'his', 'by', 'from', 'they', 'we', 'say', 'her', 'she', 'or',
23 'an', 'will', 'my', 'one', 'all', 'would', 'there', 'their', 'what', 'so',
24 'up', 'out', 'if', 'about', 'who', 'get', 'which', 'go', 'me', 'when',
25 ],
26}
27
28/** A list name is its file name: lowercase letters, digits, underscores and dashes (`english_1k`). */
29const LIST_NAME_PATTERN = /^[a-z0-9_-]{1,32}$/
30/** Fewer words than this repeat too often to make a test. */
31const MIN_LIST_WORDS = 20
32const MAX_WORD_LENGTH = 24
33/** One word: no whitespace and no control characters; anything else (`=>`, `useState`) is fine. */
34const WORD_PATTERN = /^[^\s\p{Cc}]+$/u
35
36export const isWordListName = (name: string): boolean => LIST_NAME_PATTERN.test(name)
37
38/** The lists in toolbar order: by name, numbers in natural order (`english_1k`, `english_5k`, `english_10k`). */
39export const wordListOrder = (names: readonly string[]): readonly string[] =>
40 names.toSorted((first, second) => first.localeCompare(second, 'en', { numeric: true }))
41
42/**
43 * Reads a list file's content. Words that cannot be typed (empty, with a space, too long, not a
44 * string) and repeats are left out; a broken file or one with too few words is no list at all.
45 */
46export const parseWordList = (name: string, value: unknown): WordList | undefined => {
47 if (!isWordListName(name) || typeof value !== 'object' || value === null || !('words' in value) || !Array.isArray(value.words)) {
48 return undefined
49 }
50 const words = [
51 ...new Set(
52 value.words.filter((word): word is string => typeof word === 'string' && word.length <= MAX_WORD_LENGTH && WORD_PATTERN.test(word)),
53 ),
54 ]
55
56 return words.length >= MIN_LIST_WORDS ? { name, words } : undefined
57}
58
59/** The share of words picked for practice when there are weak letter pairs. */
60const PRACTICE_SHARE = 0.3
61
62const randomFrom = (words: readonly string[]): string => words[Math.floor(Math.random() * words.length)] ?? words[0] ?? 'the'
63
64/** Words that hold at least one of the letter pairs: O(list) once per pick call, not per word. */
65export const wordsWithBigrams = (words: readonly string[], bigrams: readonly string[]): readonly string[] =>
66 bigrams.length === 0 ? [] : words.filter(word => bigrams.some(bigram => word.includes(bigram)))
67
68// The practice words of the last list and letter pairs picked with: words are added ahead 20 at a
69// time, and the list and the pairs stay the same for a whole test, so the list is scanned once.
70let practiceCache: { readonly words: readonly string[]; readonly bigrams: string; readonly practice: readonly string[] } | undefined
71
72/** `wordsWithBigrams`, scanned once per list and pairs: the same answer, from a cache of one. */
73const practiceWordsOf = (words: readonly string[], bigrams: readonly string[]): readonly string[] => {
74 const key = bigrams.join(' ')
75 if (practiceCache?.words !== words || practiceCache.bigrams !== key) {
76 practiceCache = { words, bigrams: key, practice: wordsWithBigrams(words, bigrams) }
77 }
78
79 return practiceCache.practice
80}
81
82/**
83 * Picks `count` random words from `words`, never the same word twice in a row. With
84 * `focusBigrams`, about a third of them hold one of those pairs: practice for the player's weak keys.
85 */
86export const pickWords = (
87 words: readonly string[],
88 count: number,
89 previousWord?: string,
90 focusBigrams: readonly string[] = [],
91): readonly string[] => {
92 const source = words.length >= 2 ? words : FALLBACK_WORD_LIST.words
93 const practiceWords = practiceWordsOf(source, focusBigrams)
94 const picked: string[] = []
95
96 while (picked.length < count) {
97 const isPractice = practiceWords.length > 1 && Math.random() < PRACTICE_SHARE
98 const word = randomFrom(isPractice ? practiceWords : source)
99
100 if (word !== (picked.at(-1) ?? previousWord)) {
101 picked.push(word)
102 }
103 }
104
105 return picked
106}
107hooks/view/band.tsx 85 lines1import type { TypingRoundFinished } from '../../types'
2import type { TodayPractice } from '../engine/progress'
3import type { Palette } from './palette'
4import { progressLabel } from './shared'
5import type { ActiveRound, GameElements } from './shared'
6
7type BandElements = Pick<GameElements, 'Box' | 'Button' | 'Text'>
8
9/**
10 * `press 1: <label>`: what to press bright, what it does dim. A plain Button always draws as
11 * `<hotkey>: <label>`, so "press" goes before it. Its `1` also fires from an empty prompt.
12 */
13const pressOne = ({ Box, Button, Text }: BandElements, key: string, label: string, onPress: () => void) => (
14 <Box flexDirection="row" columnGap={1}>
15 <Text>press</Text>
16 <Button key={key} label={label} hotkey="1" plain dimColor onPress={onPress} />
17 </Box>
18)
19
20/** The pane is open but a dialog or a click took the keys: the way back. */
21export const backToTypingBand = (elements: BandElements, round: ActiveRound, onOpen: () => void) => {
22 const { Box, Text } = elements
23 const label = round.phase === 'running' ? `back to typing ⋅ ${progressLabel(round)}` : 'back to typing'
24
25 return (
26 <Box flexDirection="row" columnGap={2}>
27 {pressOne(elements, 'play', label, onOpen)}
28 <Text dimColor>or ctrl+x tab</Text>
29 </Box>
30 )
31}
32
33/** A finished test while the pane is closed: the result in one row, and another test. */
34export const resultBand = (elements: BandElements, round: TypingRoundFinished, palette: Palette, onOpen: () => void) => {
35 const { Box, Text } = elements
36
37 return (
38 <Box flexDirection="row" columnGap={2}>
39 <Text color={palette.warning} bold>{`${round.result.wpm} wpm`}</Text>
40 <Text dimColor>{`${round.result.accuracy}% accuracy`}</Text>
41 {round.progress?.pb.kind === 'new' && <Text color={palette.success} bold>✦ new personal best</Text>}
42 {pressOne(elements, 'again', 'again', onOpen)}
43 </Box>
44 )
45}
46
47/** Today's practice beside the invitation; `goalMinutes` undefined: the goal is off, the streak alone. */
48export type DailyGoal = { readonly practice: TodayPractice; readonly goalMinutes: number | undefined }
49
50const MINUTE_MS = 60_000
51/** A one-day run is no streak worth a word. */
52const MIN_STREAK_SHOWN = 2
53
54/** `2/5 min today ⋅ 4-day streak`, or `keep your 4-day streak` before today's first test; met: `daily goal met (5 min)`. */
55const dailyGoalText = ({ practice, goalMinutes }: DailyGoal): string | undefined => {
56 const minutes = Math.floor(practice.activeMs / MINUTE_MS)
57 const goal =
58 goalMinutes === undefined ? [] : [minutes >= goalMinutes ? `daily goal met (${goalMinutes} min)` : `${minutes}/${goalMinutes} min today`]
59 const streak = practice.streakDays < MIN_STREAK_SHOWN ? [] : [`${practice.isPlayedToday ? '' : 'keep your '}${practice.streakDays}-day streak`]
60 const parts = [...goal, ...streak]
61
62 return parts.length === 0 ? undefined : parts.join(' ⋅ ')
63}
64
65/**
66 * The invitation while Claude works, with today's goal and streak; or a test in progress waiting
67 * while the pane is closed, its progress alone.
68 */
69export const invitationBand = (elements: BandElements, round: ActiveRound, daily: DailyGoal | undefined, onOpen: () => void) => {
70 if (round.phase === 'running') {
71 return pressOne(elements, 'play', `continue ⋅ ${progressLabel(round)}`, onOpen)
72 }
73 // No mode label: the mode shows in the pane.
74 const invitation = pressOne(elements, 'play', 'type while you wait', onOpen)
75 const goalText = daily === undefined ? undefined : dailyGoalText(daily)
76 const { Box, Text } = elements
77
78 return goalText === undefined ? invitation : (
79 <Box flexDirection="row" columnGap={1}>
80 {invitation}
81 <Text dimColor>{`⋅ ${goalText}`}</Text>
82 </Box>
83 )
84}
85hooks/view/game.tsx 186 lines1import type { Color, TextProps } from 'claude-code'
2
3import type { TypingField, TypingRoundRunning } from '../../types'
4import { buildVisibleLines, LETTER_STYLE } from '../engine/layout'
5import type { LetterStyle } from '../engine/layout'
6import { MODES } from '../engine/modes'
7import { drawnOf, fieldKey } from '../engine/field'
8import type { FieldAsDrawn } from '../engine/field'
9import { liveWpm } from '../engine/stats'
10import { activeElapsedMs, isIdle, timeLimitMs } from '../engine/timing'
11import type { Palette } from './palette'
12import { settingsPicker } from './picker'
13import type { PickerActions, PickerProps } from './picker'
14import { formatClock, ONE_ROW, progressLabel, rule } from './shared'
15import type { ActiveRound, GameElements } from './shared'
16
17/**
18 * How each letter of the words rows is drawn: the target letter, styled by how it was typed. Text
19 * still to type is dim; letters typed right white; a mistake red, a wrong letter or one a finished
20 * word went without. The next character to type is yellow and underlined: the only underline.
21 */
22const letterTextPropsOf = (palette: Palette): Record<LetterStyle, TextProps> => ({
23 [LETTER_STYLE.correct]: {},
24 [LETTER_STYLE.incorrect]: { color: palette.error },
25 [LETTER_STYLE.missed]: { color: palette.error },
26 [LETTER_STYLE.untyped]: { dimColor: true },
27 [LETTER_STYLE.caret]: { color: palette.warning, underline: true },
28})
29
30// The props of the last palette drawn with: made once per theme, not on every draw.
31let letterPropsCache: { readonly palette: Palette; readonly props: Record<LetterStyle, TextProps> } | undefined
32
33const letterTextProps = (palette: Palette): Record<LetterStyle, TextProps> => {
34 if (letterPropsCache?.palette !== palette) {
35 letterPropsCache = { palette, props: letterTextPropsOf(palette) }
36 }
37
38 return letterPropsCache.props
39}
40
41/** Two lines of words in every phase, the current one and the next: the first key changes nothing above the field. */
42const WORD_LINE_COUNT = 2
43/** What Enter does, drawn by the field after its text: `⏎ finish`. */
44const SUBMIT_LABEL = 'finish'
45/**
46 * In the field before the first key, so the row reads `start typing ⏎ finish`, in that order. The
47 * leading space takes the cursor, which a focused field draws on the placeholder's first cell.
48 */
49const FOCUSED_PLACEHOLDER = ' start typing'
50const UNFOCUSED_PLACEHOLDER = 'ctrl+x tab to type'
51/** Live wpm swings in the first seconds, so the vs-PB color waits for this much active time. */
52const PB_MARK_AFTER_MS = 5000
53
54export type GameViewProps = {
55 /** The pane body's width: the word lines wrap to it. */
56 readonly bodyColumns: number
57 readonly isFocused: boolean
58 /** A turn of Claude's runs: a flow test begun without one has no end but Enter. */
59 readonly isClaudeWorking: boolean
60 readonly now: number
61 /** The colors for the person's Claude Code theme. */
62 readonly palette: Palette
63 /** The toolbar, drawn before the first key; none once the test runs, so a key reads no settings. */
64 readonly picker: PickerProps | undefined
65}
66
67export type GameActions = {
68 /** A change of the field drawn as `field`, with the width the words were drawn at. */
69 readonly onInput: (field: FieldAsDrawn, text: string, lineWidth: number) => void
70 readonly onFinish: (roundId: number) => void
71 readonly picker: PickerActions
72}
73
74/** Green while live wpm is at or over the personal best of this test, yellow under it; none at first. */
75const pbColor = (activeMs: number, wpm: number, pbWpm: number | undefined, palette: Palette): Color | undefined => {
76 if (pbWpm === undefined || activeMs < PB_MARK_AFTER_MS) {
77 return undefined
78 }
79
80 return wpm >= pbWpm ? palette.success : palette.warning
81}
82
83/** One stat of the status row at its full width: a narrow pane cuts the hint after it, never wraps a stat. */
84const stat = ({ Box, Text }: Pick<GameElements, 'Box' | 'Text'>, text: string, props: TextProps) => (
85 <Box flexShrink={0}>
86 <Text {...props}>{text}</Text>
87 </Box>
88)
89
90/**
91 * Last on the status row, so a narrow pane cuts it first: Esc, or that Claude is done and how to
92 * end. Flow ends with the word once Claude finishes during it; a flow test begun with Claude idle
93 * (the turn already over, or none) has no end of its own, so it says Enter ends it.
94 */
95const keyHint = ({ Text }: Pick<GameElements, 'Text'>, round: TypingRoundRunning, isClaudeWorking: boolean, palette: Palette) => {
96 const endsWithWord = MODES[round.mode].endsWhen === 'claudeDone'
97
98 if (round.isClaudeDone || (endsWithWord && !isClaudeWorking)) {
99 return (
100 <Text color={palette.success} wrap={ONE_ROW}>
101 {`∘ Claude is done ⋅ ${endsWithWord && round.isClaudeDone ? 'finish the word' : '⏎ finish'}`}
102 </Text>
103 )
104 }
105
106 return <Text dimColor wrap={ONE_ROW}>esc: hide</Text>
107}
108
109/** The clock and live wpm of a test in play: `0:12 52 wpm`, the wpm colored against the personal best. */
110const pace = (elements: Pick<GameElements, 'Box' | 'Text'>, round: TypingRoundRunning, now: number, palette: Palette) => {
111 const activeMs = activeElapsedMs(round, now)
112 const wpm = liveWpm(round, activeMs)
113 const clock =
114 MODES[round.mode].endsWhen === 'timeUp' ? `${formatClock(Math.max(0, timeLimitMs(round) - activeMs))} left` : formatClock(activeMs)
115
116 return [stat(elements, clock, { dimColor: true }), stat(elements, `${wpm} wpm`, { color: pbColor(activeMs, wpm, round.pbWpm, palette), bold: true })]
117}
118
119/** Before the first key, beside the field: the weak pairs this test practices, if any. */
120const weakPairs = ({ Text }: Pick<GameElements, 'Text'>, round: ActiveRound) =>
121 round.phase === 'ready' && round.focusBigrams.length > 0 ? <Text dimColor wrap={ONE_ROW}>{`your weak pairs: ${round.focusBigrams.join(' ')}`}</Text> : null
122
123/**
124 * The status under the field, on its own row so it never moves as the word grows: `0:12 52 wpm
125 * 7 words esc: hide`, or `paused ⋅ type to resume` in place of the pace.
126 */
127const liveStatus = ({ Box, Text }: Pick<GameElements, 'Box' | 'Text'>, round: TypingRoundRunning, props: GameViewProps) => {
128 const { now, palette } = props
129 const lead = isIdle(round, now) ? [stat({ Box, Text }, 'paused ⋅ type to resume', { dimColor: true })] : pace({ Box, Text }, round, now, palette)
130
131 return (
132 <Box flexDirection="row" columnGap={2}>
133 {lead}
134 {stat({ Box, Text }, progressLabel(round), { dimColor: true })}
135 {keyHint({ Text }, round, props.isClaudeWorking, palette)}
136 </Box>
137 )
138}
139
140/**
141 * The pane before and during a test, five rows: two word lines, a dashed rule, the field, then the
142 * toolbar before the first key or the status once the test runs. The field has its row to itself
143 * (the weak pairs beside it before the first key), so it grows and nothing else moves. It keeps
144 * its place in the tree and is drawn before every Button: the toolbar going at the first key must
145 * not take the focus ring from it.
146 */
147export const gameView = (elements: GameElements, round: ActiveRound, field: TypingField, props: GameViewProps, actions: GameActions) => {
148 const { Box, Button, Input, Text } = elements
149 const [typedWords, currentWord] = round.phase === 'running' ? [round.typedWords, round.currentWord] : [[], '']
150 const lines = buildVisibleLines(round.targetWords, typedWords, currentWord, props.bodyColumns, WORD_LINE_COUNT)
151 const drawn = drawnOf(field)
152 const letterProps = letterTextProps(props.palette)
153
154 return (
155 <Box flexDirection="column">
156 {lines.map(line => (
157 <Box flexDirection="row">
158 {line.length === 0 ? <Text> </Text> : line.map(run => <Text {...letterProps[run.style]}>{run.text}</Text>)}
159 </Box>
160 ))}
161 {rule({ Text }, props.bodyColumns)}
162 <Box flexDirection="row" columnGap={2}>
163 <Input
164 key={fieldKey(field)}
165 // In the field, before its own `⏎ finish`: what to do now, then what Enter does, until
166 // the first key; the empty field of each next word says nothing. Without the keys, the
167 // way back to them.
168 placeholder={props.isFocused ? (round.phase === 'ready' ? FOCUSED_PLACEHOLDER : undefined) : UNFOCUSED_PLACEHOLDER}
169 // Empty for a fresh field; the word as cut for one drawn after keys past its end.
170 value={field.value === '' ? undefined : field.value}
171 submitLabel={SUBMIT_LABEL}
172 autoFocus
173 onInput={text => actions.onInput(drawn, text, props.bodyColumns)}
174 onSubmit={() => actions.onFinish(round.roundId)}
175 />
176 {weakPairs({ Text }, round)}
177 </Box>
178 {round.phase === 'running'
179 ? liveStatus({ Box, Text }, round, props)
180 : props.picker === undefined
181 ? <Text> </Text>
182 : settingsPicker({ Box, Button, Text }, props.picker, actions.picker)}
183 </Box>
184 )
185}
186hooks/view/palette.ts 30 lines1import type { Color } from 'claude-code'
2
3/** The colors the views draw with: theme keys, so each follows the person's Claude Code theme. */
4export type Palette = {
5 /** What is selected or next: the toolbar pick, the letters to type, the big wpm, a trend down. */
6 readonly warning: Color
7 readonly error: Color
8 readonly success: Color
9 readonly suggestion: Color
10}
11
12export const THEME_PALETTE: Palette = {
13 warning: 'warning',
14 error: 'error',
15 success: 'success',
16 suggestion: 'suggestion',
17}
18
19/**
20 * Our own shades where a theme's key does not suit the game. In `dark` the theme's warning
21 * (#FFC107) leans orange next to the green and red: turntype's yellow is a clearer #FFCE1B.
22 * Other themes (light, colorblind, ANSI) keep their keys: their warning is tuned for them.
23 */
24const THEME_OVERRIDES: Readonly<Record<string, Partial<Palette>>> = {
25 dark: { warning: '#FFCE1B' },
26}
27
28/** The palette for a Claude Code theme by name (`dark`, `light`, `dark-daltonized`, ...). */
29export const paletteFor = (theme: string): Palette => ({ ...THEME_PALETTE, ...THEME_OVERRIDES[theme] })
30