SLOPSHOPPER

turntype

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

newpanebandguardcommandtoast
★ 7v1.0.0GPL-3.0-or-laterupdated 2026-10-11Danovich/turntype
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · turntype
│ ┃ turntype ✕ › fix the failing auth test and add an audit log call │ ┃ not when will on he up about in this me go a │ ┃ you of ⏺ Read(src/auth.ts) │ ┃ there my we he be who about this up by who ⎿ Read 6 lines │ ┃ we this ⏺ Update(src/auth.ts) │ ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌… ⎿ Added 2 lines, removed 1 line │ ┃ ctrl+x tab to type ⏎ finish ⏺ Bash(bun test) │ ┃ ◍ english ❘ ∿ flow ◷ time (30) ☰ words ( ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /type │ │ press 1: back to typing or ctrl+x tab ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
press 1: back to typing or ctrl+x tab
Pane · turntype
not when will on he up about in this me go a you of there my we he be who about this up by who we this ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ctrl+x tab to type ⏎ finish ◍ english ❘ ∿ flow ◷ time (30) ☰ words (25)
README

<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.

Install

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.

How it plays

1. Claude starts working

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 seeIt means
2/5 min today ⋅ 4-day streakYou've typed 2 of your 5 daily minutes, and today counts toward the streak
0/5 min today ⋅ keep your 4-day streakNo test yet today. One test keeps the streak alive
daily goal met (5 min) ⋅ 4-day streakDone for today. Typing more is still allowed
nothingYour first day, with the goal off

2. Press 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.

3. Type

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:

LookMeaning
dimstill to type
whitetyped right
reda mistake: a wrong letter, or one you skipped
yellow, underlinedwhere 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
  • wpm turns green when you're at or above your personal best for this test, and yellow when you're under it. It waits 5 seconds before judging, because the first seconds swing wildly.
  • Words mode counts down as 7/25.
  • Time mode counts down as 0:18 left.

A few rules keep the score honest:

  • A space ends a word, and a finished word stays finished. Backspace fixes the word you're on, not the one before it.
  • Extra spaces before a word are ignored. So are letters typed past a word's end, because the next key there is the space.
  • A typo costs accuracy even after you fix it. Accuracy counts the keys you pressed, not the text you ended with.
  • Pasting is not typing. If one change of the field adds more than a few characters at once, the test can't set a personal best.

4. Take a break whenever

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.

5. Claude finishes

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

6. The result

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.

  • The personal best reads one of:
  • ✦ 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 list
  • personal best 98 wpm: not this time
  • not a personal best try: ended early: the test didn't qualify (why)
  • The sparkline is your last 10 results for this exact test. ⇡ +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.

When a permission prompt shows up mid-test

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.

Where it doesn't run

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.

Modes

ModeThe test ends when⏎ (Enter)
∿ flow (default)Claude is done and you finish the word. Started with Claude idle: only Enter ends itEnds it, and it counts as complete
◷ time 15 / 30 / 60 / 120The typing time runs out (pauses don't count)Ends it early
☰ words 10 / 25 / 50 / 100You 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.

What counts as a fair test

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:

  • nothing was pasted
  • it wasn't ended early with Enter (flow is the exception, because Enter is its normal end)
  • a flow test lasted at least 15 seconds
  • accuracy was at least 75%

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.

Your progress

Daily goal and streak

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).

Adaptive words

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.

Word lists

ListWordsSource
english200 common wordsturntype's own
english_1k (default)the 1,000 most commonMonkeytype
english_5kthe 5,000 most commonMonkeytype
english_10kabout 10,000Monkeytype

Pick one with ◍ in the toolbar.

Add your own

Drop a JSON file in word-lists/ and it shows up in the toolbar by its file name:

{ "words": ["und", "der", "die", "das", "..."] }
  • The name is the file name: lowercase letters, digits, _ and -, so german.json shows as german.
  • At least 20 words. A word has no spaces and at most 24 characters. Repeats are removed. Symbols are fine, so a list of useState and => works for practicing code.
  • Broken files show in the toolbar until you pick one. Then turntype says so, keeps your current list and drops the file from the toolbar.

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.

Keys

KeyWhereWhat it does
1Empty promptOpens the game, continues a test, or takes you back after a dialog
/typePromptOpens the game
ctrl+x tabAnywhereGives the keys back to the game
⏎ EnterGame fieldEnds the test
TabGame field, before the first keyMoves to the toolbar
EscGame paneHides the pane. A test in progress waits in the band
⏎ or a / x / dResult card, after its first secondAgain / close / details

Settings

Set these in /config, under turntype:

SettingOptionsDefault
Adaptive wordson, offon
Daily goal (minutes)off, 3, 5, 10, 15, 305

The mode, count and word list are set in the game's toolbar and remembered between sessions. The colors follow your Claude Code theme.

Your data

Everything stays on your machine, in the mod's own store (~/.claude/plugins/store/):

  • your toolbar picks
  • personal bests, per test
  • your last 200 fair results
  • daily totals for the last 400 days
  • letter-pair statistics

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.

Notes and limitations

turntype is a mod living inside someone else's app, so a few things work around Claude Code rather than with it.

  • Not tested on the Desktop app. turntype is built for the Desktop app as well as the terminal, and its automated tests draw both. But nobody has played it in the real Desktop app yet, and all the live testing ran in the terminal. If you try it there, say how it went.
  • Permission prompts take the keys. Claude Code gives the keys to the dialog, then hands them to the prompt when the dialog closes. turntype doesn't hear about it directly, so it checks every 0.4 seconds and asks for the keys back. Wait a beat after answering the dialog: a key typed in that moment can be lost, or land in the prompt, and Claude Code won't move the keys away from a prompt with text in it. Delete it and the game takes them back. turntype stops trying after 60 seconds, or when Claude's turn ends. If it gives up, press 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.
  • Give the invitation a moment. Claude Code arms the 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.
  • Very fast bursts can look like a paste. turntype sees the field's text change, not single keys. When one change adds more than 3 characters, the test counts as pasted and can't set a personal best. If your terminal ever delivers four or more keys in one change, a real test gets flagged too.
  • Flow waits for you. A flow test ends on the first space after Claude finishes. If you hid the pane, the test waits in the band until you come back and finish the word, or press Enter.
  • A finished word is final. Backspace can't reach a word you've already ended with a space. This is on purpose, but it surprises people coming from editors.
  • New word lists need a reload. turntype reads the list names when the session starts. After adding a file to word-lists/, run /reload-plugins or start a new session.
  • Updating mid-test can drop the test. If a new turntype version changes how a test is stored, a test in progress during the update is dropped. Your saved results are safe.
  • The terminal and the Desktop app only. The VS Code chat panel and claude -p have nowhere to draw the pane.
  • A field stays on screen. Claude Code only hands a mod's pane the keys through a text field, so the word you're typing shows in a small field under the words. Typing straight into the words, as on Monkeytype, would need a click before every test.
  • Fullscreen puts the game in a sidebar. In fullscreen mode, from 110 columns wide, Claude Code docks every pane beside the transcript, floor to ceiling, and a mod can't ask for the spot above the prompt instead. Narrower, or outside fullscreen, the game is a five-row block above the prompt. Resizing across that line mid-test moves the pane and briefly takes the keys from it, and turntype takes them back.
  • Tested on one Claude Code version. The mods API is new and can change. turntype is tested with Claude Code 2.1.296, and a later release can break things until the mod catches up.

Develop

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

Credits and license

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.

Source 20 files
hooks/register.tsx 887 lines
1import { 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}
887
hooks/engine/lifecycle.ts 84 lines
1import 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}
84
hooks/engine/modes.ts 68 lines
1import 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'
68
hooks/engine/progress.ts 331 lines
1import 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}
331
hooks/engine/field.ts 86 lines
1import 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}
86
hooks/engine/round.ts 312 lines
1import 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}
312
hooks/engine/settings.ts 74 lines
1import 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}
74
hooks/engine/timing.ts 25 lines
1import 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)
25
hooks/engine/word-list.ts 107 lines
1import { 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}
107
hooks/view/band.tsx 85 lines
1import 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}
85
hooks/view/game.tsx 186 lines
1import 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}
186
hooks/view/palette.ts 30 lines
1import 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