Typing race in a pane. A line of code appears, you type it; every character colors right or wrong as you go, and Enter scores words per minute and accuracy…

A typing race on lines of code, in a pane beside the conversation. A line appears, you type it into the field below; every character colors right or wrong as you go, and Enter scores words per minute and accuracy. The last ten runs and your best are kept.
It's a game you play, not a watcher: it never calls a model, never reads what Claude is doing, and costs no tokens. Thirty seconds of warm-up while a long turn runs.

Recorded in a terminal. The Desktop app draws the same pane, with each key shown as a badge.
TYPING line 7/24 · best 64 wpm · 12 runs
2 wrong so far. Backspace fixes it.
const total = items.reduce((sum, item) => sum + item.price, 0)
> const total = items.reduce((sum, itme score
Last runs
58 wpm · 97% · 11.2 s
64 wpm · 100% · 9.8 s
n: Next line r: Same line again h: Help Esc closes
The line is colored, which the text above can't show: characters typed right turn green, wrong ones sit on red, the next one to type is underlined, and what's left is dim. Colors follow your theme.
/plugin marketplace add MDmubarak786/claude-mods
/plugin install typing@modhub
Try it for one session without installing:
claude --plugin-dir ./mods/typing
/typing. The pane opens with a line of code and a field under it, focused. Until your first scored run, a short how-to sits under the field; h hides or shows it any time./clear.While the field has the focus every key goes to it, so press Esc once to use n, r or h, or click them. After a run is scored the field goes away and Enter on the focused Next button brings the next line.
| Command | What it does |
|---|---|
/typing | Open the pane. Where no pane can be drawn, print the line and your record. |
/typing new | A new line. |
/typing stats | Runs, best words per minute, and the last ten runs. |
From claude plugin validate ./mods/typing:
hooks: session.start, classic.SessionStart{source=clear|resume|fork}, command.run{command=typing}, ui.render{component=Pane}
calls: $.clock.now, $.command.register, $.session.surfaces, $.state.get, $.state.set, $.store.get (via load), $.store.set (via save), $.ui.focus (via restart), $.ui.invalidate, $.ui.log, $.ui.open, $.ui.resolve, $.ui.toast
$.store keeps the current line, the last ten runs, and the record. Keystrokes are drawn, not saved; a scored run is. If the store can't be read at start, the mod plays in memory and writes nothing over the saved record.$.clock picks each line and times the run from the first key to Enter. There is no timer: nothing happens between your keys.$.ui.focus puts the keys back in the field after n or r, so the next line can be typed at once.claude plugin validate --strict and claude plugin test passing: the snippet list (plain ASCII, one line, at most 72 columns), picking a line, marking characters, the clock from the first key, scoring and the history, mounted pane tests typing into the field and submitting on the mock clock, the commands, an unreadable store, and a narrow pane.hooks/game.ts and open a pull request.MIT, see the repository root.
hooks/register.ts 253 lines1// typing: a typing race on lines of code, in a pane.
2//
3// /typing open the race
4// /typing new a new line
5// /typing stats runs, best words per minute, the last ten runs
6//
7// In the pane: a line of code is shown over a field; type it. Each character
8// colors right or wrong as you go, and Enter scores the run. n brings the next
9// line, r retypes the same one, h shows the how-to. While the field has the
10// focus every key goes to it; Esc hands the keys back to the pane. Nothing
11// here calls a model, reads what Claude is doing, or leaves the machine.
12
13import { atom, read, update } from 'claude-code'
14import type { Game, Run } from '../types'
15import { SNIPPETS, finish, isGame, marks, newGame, target, type } from './game'
16
17const PANE = 'typing'
18const EMPTY: Game = { seed: 0, snippet: 0, typed: '', startedAt: null, result: null, history: [], stats: { runs: 0, bestWpm: 0 } }
19const game = atom({ plugin: 'typing', key: 'game' } as const, EMPTY)
20
21// The how-to block: shown until the first key, or until h has set it either way.
22let help: boolean | null = null
23// The field holds its own text and is never handed any: setting an Input's
24// value replaces what the person has typed. A new line gets a new field
25// instead, by a new key, so it starts empty without a value being drawn.
26let round = 0
27const fieldKey = () => 'typed-' + round
28// False after the store could not be read: then nothing is written over the
29// saved record until a later load reads it.
30let storeOk = true
31
32async function save($, g: Game) {
33 if (!storeOk || !g.seed) return
34 try {
35 await $.store.set('game', g)
36 } catch {
37 // The drawing still updates; the store catches up on the next change.
38 }
39}
40
41// Apply a change through update's read-modify-write and mirror it to the
42// store. Keystrokes pass `persist: false`: the run is saved when it is scored.
43async function change($, fn: (cur: Game) => Game, persist = true): Promise<Game> {
44 let out: Game | null = null
45 await update($, game, (cur) => {
46 out = fn(cur)
47 return out
48 })
49 if (out && persist) await save($, out)
50 return out ?? (await read($, game))
51}
52
53async function load($) {
54 let saved: unknown = undefined
55 let readable = true
56 try {
57 saved = await $.store.get('game')
58 } catch {
59 readable = false
60 }
61 storeOk = readable
62 if (isGame(saved)) {
63 // A half-typed line from before a restart starts over; the record stays.
64 const s = saved as Game
65 await update($, game, () => (s.result ? s : { ...s, typed: '', startedAt: null }))
66 return
67 }
68 if (!readable) {
69 const cur = await read($, game)
70 if (!cur.seed) await update($, game, () => newGame(Date.now(), null))
71 $.ui.log('typing could not read its saved runs; playing without saving')
72 return
73 }
74 const fresh = newGame(await $.clock.now(), null)
75 await update($, game, () => fresh)
76 await save($, fresh)
77}
78
79async function startNew($, same = false) {
80 round++
81 const now = await $.clock.now()
82 return change($, (cur) => (same ? { ...newGame(cur.seed, cur), snippet: cur.snippet } : newGame(now, cur)))
83}
84
85// A new line, with the keys handed to the field so typing can start at once.
86async function restart($, same: boolean) {
87 await startNew($, same)
88 try {
89 await $.ui.focus({ requestId: PANE, key: fieldKey() })
90 } catch {
91 // The field is drawn with autoFocus anyway; a refused focus is harmless.
92 }
93}
94
95function runLine(r: Run): string {
96 return r.wpm + ' wpm · ' + r.accuracy + '% · ' + (r.ms / 1000).toFixed(1) + ' s'
97}
98
99function summary(g: Game): string {
100 if (!g.seed) return 'No line yet.'
101 const state = g.result ? 'Last run ' + runLine(g.result) + '.' : g.typed ? g.typed.length + ' of ' + target(g).length + ' typed.' : 'Not started.'
102 return 'Typing: "' + target(g) + '". ' + state + ' Best ' + g.stats.bestWpm + ' wpm over ' + g.stats.runs + ' runs. /typing opens the pane.'
103}
104
105export function register(on) {
106 on('session.start', async ($, e, next) => {
107 help = null
108 try {
109 await load($)
110 } catch (error) {
111 $.ui.log('typing could not load its runs: ' + error)
112 }
113 try {
114 await $.command.register({ name: 'typing', description: 'A typing race on lines of code, in a pane', argumentHint: '[new | stats]', immediate: true })
115 } catch (error) {
116 $.ui.log('could not register /typing: ' + error)
117 }
118 return next(e)
119 })
120
121 // /clear and /resume reset $.state; the record lives on in the store.
122 on('classic.SessionStart', { source: ['clear', 'resume', 'fork'] }, async ($, e, next) => {
123 await load($)
124 return next(e)
125 }).catch(async ($, e, next) => next(e))
126
127 on('command.run', { command: 'typing' }, async ($, e) => {
128 const args = e.args.trim().toLowerCase()
129 if (args === 'new') return { text: 'New line. ' + summary(await startNew($)) }
130 if (args === 'stats') {
131 const g = await read($, game)
132 const last = g.history.length ? ' Last: ' + g.history.map(runLine).join(' / ') : ''
133 return { text: 'Runs ' + g.stats.runs + ' · best ' + g.stats.bestWpm + ' wpm.' + last }
134 }
135 if (args) return { text: 'Usage: /typing, /typing new, or /typing stats' }
136 // Under claude -p nothing draws, and ui.open still reports the pane as placed:
137 // the surfaces list is the reliable sign.
138 let surfaces = []
139 try {
140 surfaces = await $.session.surfaces()
141 } catch {
142 surfaces = []
143 }
144 if (!surfaces.length) return { text: summary(await read($, game)) }
145 // A newly opened pane draws an empty field, so a half-typed line starts over
146 // with it; a scored run is left as it is.
147 round++
148 await change($, (cur) => (cur.result || !cur.typed ? cur : { ...cur, typed: '', startedAt: null }), false)
149 const placed = await $.ui.open({ id: PANE, title: 'Typing', focus: true, closeOnEscape: true, rows: 26 })
150 if (!placed.isPlaced) return { text: summary(await read($, game)) }
151 return {}
152 }).catch(async () => ({ text: 'typing: the command failed.' }))
153
154 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
155 if (e.requestId !== PANE) return next(e)
156 const g = await read($, game)
157 const { Box, Text, Button, Input } = $.ui.resolve(e)
158 if (!g.seed) return Text({ children: ['No line loaded. /typing new starts one.'] })
159 const columns = e.props.bodyColumns || 60
160 const wide = columns >= 50
161 const showHelp = help ?? (wide && !g.typed && !g.result && g.stats.runs === 0)
162 const t = target(g)
163 const m = marks(g)
164 const wrong = m.filter((x) => x === 'wrong').length
165
166 const status = g.result ? { text: g.result.wpm + ' words per minute, ' + g.result.accuracy + '% accurate, in ' + (g.result.ms / 1000).toFixed(1) + ' s' + (g.result.wpm >= g.stats.bestWpm && g.result.wpm > 0 ? '. Your best!' : '.'), color: 'success', bold: true }
167 : !g.typed ? { text: 'Type the line below, then press Enter. The clock starts at your first key.', color: 'suggestion' }
168 : g.typed.length > t.length ? { text: (g.typed.length - t.length) + ' past the end. Backspace, then Enter.', color: 'warning' }
169 : wrong ? { text: wrong + ' wrong so far. Backspace fixes it.', color: 'warning' }
170 : { text: g.typed.length + ' of ' + t.length + ', all right so far.', color: 'suggestion' }
171
172 const header = Box({ flexDirection: 'column', children: [
173 Box({ flexDirection: 'row', columnGap: 2, flexWrap: 'wrap', children: [
174 Text({ bold: true, children: ['TYPING'] }),
175 Text({ dimColor: true, children: ['line ' + (g.snippet + 1) + '/' + SNIPPETS.length + ' · best ' + g.stats.bestWpm + ' wpm · ' + g.stats.runs + ' runs'] }),
176 ] }),
177 Text({ color: status.color, bold: status.bold, wrap: 'wrap', children: [status.text] }),
178 ] })
179
180 // The target line, each character colored by how it was typed: consecutive
181 // characters in the same state share one Text to keep the tree small. The
182 // next character to type is underlined.
183 const segments: { mark: string; text: string; cursor: boolean }[] = []
184 for (let i = 0; i < t.length; i++) {
185 const mark = m[i]
186 const cursor = !g.result && i === g.typed.length
187 const last = segments[segments.length - 1]
188 if (last && last.mark === mark && !cursor && !last.cursor) last.text += t[i]
189 else segments.push({ mark, text: t[i], cursor })
190 }
191 const line = Text({ wrap: 'wrap', children: segments.map((s) =>
192 Text({
193 color: s.mark === 'right' ? 'success' : s.mark === 'wrong' ? 'inverseText' : undefined,
194 backgroundColor: s.mark === 'wrong' ? 'error' : undefined,
195 dimColor: s.mark === 'todo' && !s.cursor,
196 underline: s.cursor,
197 bold: s.cursor,
198 children: [s.text],
199 }),
200 ) })
201 const extra = g.typed.length > t.length ? Text({ color: 'error', children: [(g.typed.length - t.length) + ' extra character' + (g.typed.length - t.length === 1 ? '' : 's') + ' past the end'] }) : null
202
203 const field = g.result ? null : Input({
204 key: fieldKey(),
205 placeholder: 'type here',
206 submitLabel: 'score',
207 autoFocus: true,
208 onInput: async (value) => {
209 const now = await $.clock.now()
210 await change($, (cur) => type(cur, value, now), false)
211 },
212 onSubmit: async (value) => {
213 const now = await $.clock.now()
214 let error: string | undefined
215 await change($, (cur) => {
216 const r = finish(type(cur, value, now), now)
217 error = r.error
218 return r.game
219 })
220 if (error) $.ui.toast(error)
221 },
222 })
223
224 const history = g.history.length
225 ? Box({ flexDirection: 'column', children: [
226 Text({ dimColor: true, children: ['Last runs'] }),
227 ...g.history.slice(0, wide ? 5 : 3).map((r, i) => Text({ dimColor: i > 0, children: [runLine(r)] })),
228 ] })
229 : null
230
231 const howTo = Box({ flexDirection: 'column', borderStyle: 'round', borderColor: 'suggestion', paddingX: 1, children: [
232 Text({ bold: true, children: ['How to play'] }),
233 Text({ wrap: 'wrap', children: ['1. Click the field, or Tab to it, and type the line above exactly, spaces and all.'] }),
234 Text({ wrap: 'wrap', children: ['2. Right characters turn green, wrong ones red; Backspace fixes them. The clock runs from your first key.'] }),
235 Text({ wrap: 'wrap', children: ['3. Press Enter to score: words per minute on what you got right, and accuracy. n brings the next line, r the same one again.'] }),
236 Text({ dimColor: true, wrap: 'wrap', children: ['While the field has the focus every key goes to it: press Esc to use n, r or h. h hides this.'] }),
237 ] })
238
239 const footer = Box({ flexDirection: 'row', columnGap: 3, flexWrap: 'wrap', children: [
240 Button({ key: 'next', label: 'Next line', hotkey: 'n', plain: true, autoFocus: g.result ? true : undefined, onPress: () => restart($, false) }),
241 Button({ key: 'again', label: 'Same line again', hotkey: 'r', plain: true, onPress: () => restart($, true) }),
242 Button({ key: 'help', label: showHelp ? 'Hide help' : 'Help', hotkey: 'h', plain: true, dimColor: true, onPress: () => {
243 help = !showHelp
244 $.ui.invalidate('ui.render')
245 } }),
246 Text({ dimColor: true, children: ['Esc closes'] }),
247 ] })
248
249 const parts = [header, line, extra, field, history, showHelp ? howTo : null, footer].filter(Boolean)
250 return Box({ flexDirection: 'column', rowGap: 1, children: parts })
251 })
252}
253hooks/game.ts 116 lines1// The rules of the typing race, pure: no `$` here, so the tests and the hooks
2// module share one implementation.
3
4import type { Game, Run, Stats } from '../types'
5
6export const HISTORY = 10
7
8// Lines of code to type: plain ASCII, one line each, nothing longer than 72 columns.
9export const SNIPPETS: string[] = [
10 'const total = items.reduce((sum, item) => sum + item.price, 0)',
11 'for (let i = 0; i < rows.length; i++) rows[i].visited = false',
12 'if (!user || user.role !== "admin") return res.status(403).end()',
13 'export default function App() { return <Main title="Home" /> }',
14 'const [count, setCount] = useState<number>(0)',
15 'await fs.promises.writeFile(path, JSON.stringify(data, null, 2))',
16 'def fib(n): return n if n < 2 else fib(n - 1) + fib(n - 2)',
17 'with open("config.yml") as f: config = yaml.safe_load(f)',
18 'result = [x * x for x in range(10) if x % 2 == 0]',
19 'class Point: def __init__(self, x, y): self.x, self.y = x, y',
20 'SELECT name, count(*) FROM orders GROUP BY name ORDER BY 2 DESC',
21 'git commit -am "Fix the off-by-one in the pager" && git push',
22 'docker run --rm -it -p 8080:80 -v "$PWD":/app nginx:alpine',
23 'curl -s https://api.example.com/v1/users | jq ".[] | .email"',
24 'let xs: Vec<u32> = (1..=10).filter(|x| x % 3 == 0).collect();',
25 'func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {',
26 'public static void main(String[] args) { System.out.println("hi"); }',
27 'let mut guard = mutex.lock().unwrap(); *guard += 1;',
28 'match value { Some(v) => v, None => return Err(Error::Missing) }',
29 'type Props = { title: string; onClose?: () => void }',
30 'const debounced = useMemo(() => debounce(save, 300), [save])',
31 'kubectl get pods -n prod -o wide | grep -v Running',
32 'ssh -i ~/.ssh/id_ed25519 deploy@10.0.0.4 "sudo systemctl restart api"',
33 'printf "%-20s %8.2f\\n" "$name" "$amount" >> report.txt',
34]
35
36function hash(s: string): number {
37 let h = 2166136261
38 for (let i = 0; i < s.length; i++) {
39 h ^= s.charCodeAt(i)
40 h = Math.imul(h, 16777619)
41 }
42 return h >>> 0
43}
44
45// The snippet for a seed, never the one just typed.
46export function pickSnippet(seed: number, avoid: number): number {
47 const n = SNIPPETS.length
48 let i = hash(seed + ':snippet') % n
49 if (i === avoid) i = (i + 1) % n
50 return i
51}
52
53export function newGame(seed: number, prev: Game | null): Game {
54 return { seed, snippet: pickSnippet(seed, prev ? prev.snippet : -1), typed: '', startedAt: null, result: null, history: prev?.history ?? [], stats: prev?.stats ?? { runs: 0, bestWpm: 0 } }
55}
56
57export function target(g: Game): string {
58 return SNIPPETS[g.snippet] ?? ''
59}
60
61// Each character of the target as typed so far: right, wrong, or not yet.
62export type Mark = 'right' | 'wrong' | 'todo'
63export function marks(g: Game): Mark[] {
64 const t = target(g)
65 return t.split('').map((ch, i) => (i >= g.typed.length ? 'todo' : g.typed[i] === ch ? 'right' : 'wrong'))
66}
67
68// A keystroke. The clock starts at the first character.
69export function type(g: Game, value: string, now: number): Game {
70 if (g.result) return g
71 const startedAt = g.startedAt ?? (value.length ? now : null)
72 return { ...g, typed: value, startedAt }
73}
74
75// Words per minute on the standard five-character word, counting only the
76// characters typed right; accuracy is right characters over the longer of the
77// target and what was typed, so extra characters past the end count against you.
78export function score(g: Game, now: number): Run | null {
79 if (!g.startedAt || !g.typed.length) return null
80 const t = target(g)
81 const right = marks(g).filter((m) => m === 'right').length
82 const ms = Math.max(1, now - g.startedAt)
83 const wpm = Math.round((right / 5) / (ms / 60000))
84 const accuracy = Math.round((right / Math.max(t.length, g.typed.length)) * 100)
85 return { wpm, accuracy, ms, chars: t.length, at: now }
86}
87
88export type FinishResult = { game: Game; error?: string }
89
90export function finish(g: Game, now: number): FinishResult {
91 if (g.result) return { game: g, error: 'This run is scored. Press n for the next line.' }
92 if (!g.typed.length) return { game: g, error: 'Type the line first.' }
93 // A run is scored on the whole line, so a fast fragment can't set the record.
94 if (g.typed.length < target(g).length) return { game: g, error: 'Finish the line first: ' + (target(g).length - g.typed.length) + ' to go.' }
95 const run = score(g, now)
96 if (!run) return { game: g, error: 'Type the line first.' }
97 const history = [run, ...g.history].slice(0, HISTORY)
98 const stats: Stats = { runs: g.stats.runs + 1, bestWpm: Math.max(g.stats.bestWpm, run.wpm) }
99 return { game: { ...g, result: run, history, stats } }
100}
101
102export function isRun(x: unknown): x is Run {
103 if (!x || typeof x !== 'object') return false
104 const r = x as Record<string, unknown>
105 return ['wpm', 'accuracy', 'ms', 'chars', 'at'].every((k) => typeof r[k] === 'number')
106}
107
108export function isGame(x: unknown): x is Game {
109 if (!x || typeof x !== 'object') return false
110 const g = x as Record<string, unknown>
111 const stats = g.stats as Record<string, unknown> | undefined
112 return typeof g.seed === 'number' && Number.isInteger(g.snippet) && (g.snippet as number) >= 0 && (g.snippet as number) < SNIPPETS.length &&
113 typeof g.typed === 'string' && (g.startedAt === null || typeof g.startedAt === 'number') && (g.result === null || isRun(g.result)) &&
114 Array.isArray(g.history) && g.history.every(isRun) && !!stats && typeof stats === 'object' && ['runs', 'bestWpm'].every((k) => Number.isInteger(stats[k]))
115}
116types/index.d.ts 24 lines1export type Run = { wpm: number; accuracy: number; ms: number; chars: number; at: number }
2export type Stats = { runs: number; bestWpm: number }
3export type Game = {
4 /** Seed that picked the snippet; 0 until a game exists. */
5 seed: number
6 /** Index into the built-in snippet list. */
7 snippet: number
8 /** What has been typed so far. */
9 typed: string
10 /** When the first character was typed, in ms; null before that. */
11 startedAt: number | null
12 /** The score of the finished run; null while typing. */
13 result: Run | null
14 /** The last ten runs, newest first. */
15 history: Run[]
16 stats: Stats
17}
18
19declare module 'claude-code' {
20 interface PluginState {
21 typing: { game: Game }
22 }
23}
24