SLOPSHOPPER

typing

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…

newpanecommandtoast
v0.1.1MITupdated 2026-10-13MDmubarak786/claude-mods/mods/typing
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · typing
│ ┃ Typing ✕ › fix the failing auth test and add an audit log call │ ┃ TYPING line 21/24 · best 0 wpm · 0 runs │ ┃ Type the line below, then press Enter. The ⏺ Read(src/auth.ts) │ ┃ clock starts at your first key. ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ const debounced = useMemo(() => ⎿ Added 2 lines, removed 1 line │ ┃ debounce(save, 300), [save]) ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ type here ⏎ score │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ ╭──────────────────────────────────────────╮ │ ┃ │ How to play │ ✻ Worked for 42s · done 4:20 PM │ ┃ │ 1. Click the field, or Tab to it, and │ │ ┃ │ type the line above exactly, spaces and │ › /typing │ ┃ │ all. │ │ ┃ │ 2. Right characters turn green, wrong │ │ ┃ │ ones red; Backspace fixes them. The │ │ ┃ │ clock runs from your first key. │ │ ┃ │ 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. │ │ ┃ │ While the field has the focus every key │ │ ┃ │ goes to it: press Esc to use n, r or h. │ │ ┃ │ h hides this. │ │ ┃ ╰──────────────────────────────────────────╯ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Typing
TYPING line 21/24 · best 0 wpm · 0 runs Type the line below, then press Enter. The clock starts at your first key. const debounced = useMemo(() => debounce(save, 300), [save]) type here ⏎ score ╭──────────────────────────────────────────────────────────╮ │ How to play │ │ 1. Click the field, or Tab to it, and type the line │ │ above exactly, spaces and all. │ │ 2. Right characters turn green, wrong ones red; │ │ Backspace fixes them. The clock runs from your first │ │ key. │ │ 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. │ │ While the field has the focus every key goes to it: │ │ press Esc to use n, r or h. h hides this. │ ╰──────────────────────────────────────────────────────────╯ n: Next line r: Same line again h: Hide help Esc close
README

typing

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.

A typing race in a pane: a typo turns red and is fixed, then Enter scores the line.

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.

Install

/plugin marketplace add MDmubarak786/claude-mods
/plugin install typing@modhub

Try it for one session without installing:

claude --plugin-dir ./mods/typing

How to play

  1. Run /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.
  2. Type the line exactly, spaces and all. The clock starts at your first key. Right characters turn green, wrong ones red; Backspace fixes them.
  3. Press Enter once you've typed the whole line. Words per minute counts the characters you got right, five to a word; accuracy is right characters over the line's length, or over what you typed if that's longer. A fragment can't be scored, so a quick five letters never sets the record. The status line says if it's your best.
  4. n brings the next line, r the same one again. Twenty-four lines rotate, never the same one twice in a row.
  5. Esc closes the pane. Scored runs are saved; a half-typed line starts over when you reopen the pane, after a restart, or after /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.

CommandWhat it does
/typingOpen the pane. Where no pane can be drawn, print the line and your record.
/typing newA new line.
/typing statsRuns, best words per minute, and the last ten runs.

What it touches

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.
  • No tool calls are observed, no files, processes, model calls, or network. The lines you type are the built-in list, never anything from your session.

Tested with

  • Claude Code 2.1.295, 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.
  • Played in the Desktop app, 2.1.293, through hot reloading.

Limitations

  • Words per minute is on correct characters only, so a run full of mistakes scores low twice over. That's the point.
  • The field is one line; a line of code is never longer than 72 characters, but a very narrow pane wraps it.
  • Lines are a fixed list of twenty-four. Add yours in hooks/game.ts and open a pull request.

License

MIT, see the repository root.

Source 3 files
hooks/register.ts 253 lines
1// 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}
253
hooks/game.ts 116 lines
1// 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}
116
types/index.d.ts 24 lines
1export 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