SLOPSHOPPER

typing-test

Typing test above the Claude Code prompt: retype a line of code as fast as you can. Plays while Claude works, pauses when the turn ends, keeps your best score…

newbandcommandtoast
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · typing-test
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /typing ⎿ typing-test: $Typing test · click the board to play · Esc returns to the prompt · /typing stop closes [ close ] ▣ client module ./boards/typing.tsx ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
[ close ] ▣ client module ./boards/typing.tsx ⟨Claude Code's own drawing⟩
README

typing-test

Typing test above the Claude Code prompt, for the minutes Claude spends working: retype a line of code as fast as you can. The game pauses and its status line says so when Claude finishes the turn, so you never miss a reply. Playing costs no tokens: the mod answers every key and click itself.

A single game split out of sezaakgun/cc-arcade (MIT, by Seza Akgün). Want all nine games, a picker and the pet in one plugin? Install games/cc-arcade instead.

Play

  1. /typing opens the board above the prompt (/typing stop or the close button closes it).
  2. Click the board to give it the keyboard; Esc gives the keyboard back to the prompt.
  3. type the line shown. Correct characters turn green; a wrong one shows on red until you backspace it. Tab skips to another line, Enter starts the next one after a finished line.

Your best score is kept in the plugin's store across sessions.

Install

npx claude-code-templates@latest --mod games/typing-test
claude

It is written to .claude/skills/typing-test/, which Claude Code auto-loads as typing-test@skills-dir. For one session with hot reload: claude --plugin-dir .claude/skills/typing-test.

Requirements

  • Claude Code 2.1.287 or later (mods are on by default from that version).
  • An interactive terminal that reports the mouse. Nothing draws in claude -p, the desktop app or mobile.
  • A terminal font with box-drawing and block characters.

Clones of the genre, not affiliated with or endorsed by the original publishers.

Source 5 files
hooks/register.tsx 92 lines
1/* @jsx h */
2import type { Register } from 'claude-code'
3import { isBetter } from './games/best.ts'
4
5// Typing test above the Claude Code prompt, split out of cc-arcade (https://github.com/sezaakgun/cc-arcade,
6// MIT, by Seza Akgün) as a single-game mod. `/typing` opens it, `/typing stop` closes it. The board in
7// ./boards is a surface module that runs on the drawing thread; this module mounts it, keeps the best
8// score in $.store and pauses the game when Claude finishes a turn. Playing costs no tokens.
9
10const GAME = 'typing' // what the board posts its score under
11const TITLE = 'Typing test'
12const LOWER_IS_BETTER = false
13const UNIT = ' wpm'
14
15let open = false
16// bumped at each finished turn; the open game pauses when it sees a new value
17let turnsDone = 0
18let best = 0
19
20export const register: Register = on => {
21  on('session.start', async ($, e, next) => {
22    const r = await next(e)
23    // a store that cannot be read costs the best score, never the game
24    const score = Number(await $.store.get('best').catch(() => undefined))
25    if (score) best = score
26    await $.command.register({
27      name: 'typing',
28      description: `$Typing test above the prompt, while Claude works (stop closes)`,
29      argumentHint: '[stop]',
30      immediate: true,
31    }).catch(err => $.ui.log(`typing-test: /typing not registered: ${err}`))
32    return r
33  })
34
35  on('command.run', { command: 'typing' }, async ($, e) => {
36    const arg = e.args.trim().toLowerCase()
37    if (arg === 'stop' || arg === 'close') {
38      open = false
39      $.ui.invalidate('ui.render')
40      return { text: `$Typing test closed` }
41    }
42    open = true
43    $.ui.invalidate('ui.render')
44    const score = best ? ` · best ${best}$ wpm` : ''
45    return { text: `$Typing test · click the board to play · Esc returns to the prompt · /typing stop closes${score}` }
46  })
47
48  on('turn.complete', async ($, e, next) => {
49    const r = await next(e)
50    // a subagent's turn is not the one the person is waiting for
51    if (e.agentId) return r
52    if (open) {
53      turnsDone++
54      $.ui.invalidate('ui.render')
55    }
56    return r
57  })
58
59  // a finished round, posted by the board: keep the best score and hand it back with the next props
60  on('ui.message', async ($, e, next) => {
61    const data = e.data as { game?: unknown; score?: unknown } | null
62    if (data?.game !== GAME || typeof data.score !== 'number') return next(e)
63    if (isBetter(data.score, best || undefined, LOWER_IS_BETTER)) {
64      best = data.score
65      await $.store.set('best', best).catch(err => $.ui.log(`typing-test: store write failed: ${err}`))
66      $.ui.toast(`$Typing test: new best ${best}$ wpm`)
67    }
68    return { props: { best, done: turnsDone, safe: false } }
69  })
70
71  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
72    // the board needs a terminal's keys and mouse; the desktop and mobile surfaces draw their own band
73    if (!open || e.props.hasSurvey || e.surface !== 'terminal') return next(e)
74    const { Box, Button, Client } = $.ui.resolve(e)
75    // the band's own width: the transcript column's while a Pane is docked beside it
76    const cols = e.props.bodyColumns || (e.viewport?.columns ?? 80)
77    const close = () => {
78      open = false
79      $.ui.invalidate('ui.render')
80    }
81    return (
82      <Box flexDirection="column">
83        <Box flexDirection="row" columnGap={1}>
84          <Button key="typing-test:close" label="close" onPress={close} />
85        </Box>
86        <Client key="board:typing" module="./boards/typing.tsx" width={cols} props={{ best, done: turnsDone, safe: false }} />
87        {await next(e)}
88      </Box>
89    )
90  })
91}
92
hooks/games/best.ts 10 lines
1// Which score counts as a new best. Minesweeper's best is its fastest clear, so lower wins there;
2// everywhere else higher wins. A score of zero or less never counts: a Minesweeper board whose
3// first click clears it takes 0 s and would otherwise lock the best forever, and a Pong match you
4// lost posts a negative margin.
5export function isBetter(score: number, prev: number | undefined, lowerIsBetter = false): boolean {
6  if (!Number.isFinite(score) || score <= 0) return false
7  if (prev === undefined || prev <= 0) return true
8  return lowerIsBetter ? score < prev : score > prev
9}
10
hooks/boards/typing.tsx 62 lines
1/* @jsx h */
2import type { ClientSurface } from 'claude-code'
3import { base, claudeDone, statusLine, type Base, type Props } from './common.tsx'
4import { accuracy, newTyping, press, wpm, type TypingGame } from '../games/typing.ts'
5
6type State = Base & { game: TypingGame; ms: number; started: boolean }
7type Run = [text: string, color: string, bg?: string]
8
9export default function Typing(props: Props, surface: ClientSurface<State>) {
10  const { Box, Text } = surface.elements
11  const fresh = (s: State): State => ({ ...s, banner: false, playing: false, started: false, ms: 0, game: newTyping(Math.random, Math.max(20, surface.columns - 4)) })
12
13  if (surface.state === undefined) {
14    surface.setState({ ...base(props), game: newTyping(), ms: 0, started: false })
15    // the clock runs from the first key to the last correct one, and stops while Claude's banner shows
16    surface.every(100, () => {
17      const s = surface.state
18      if (s?.started && s.playing && !s.game.done) surface.setState({ ...s, ms: s.ms + 100 })
19    })
20    surface.onKey(({ key, ctrl, meta }) => {
21      const s = surface.state
22      if (!s || ctrl || meta) return
23      // Tab skips to another line; Enter after a finished line starts the next
24      if (key === 'tab' || (key === 'return' && s.game.done)) return surface.setState(fresh(s))
25      // the space bar may arrive as the character or by name
26      const game = press(s.game, key === 'space' ? ' ' : key)
27      if (game === s.game) return
28      if (game.done) surface.post({ game: 'typing', score: wpm(game.text.length, s.ms) })
29      surface.setState({ ...s, banner: false, playing: true, started: true, game })
30    })
31  }
32  claudeDone(props, surface)
33
34  const s = surface.state
35  const g = s?.game
36  if (!s || !g) return <Text dimColor>typing</Text>
37
38  // typed characters green when right, the expected character on red when wrong, the cursor
39  // inverted, the rest dim; one span per run of the same style
40  const runs: Run[] = []
41  for (let i = 0; i < g.text.length; i++) {
42    const style: Run = i < g.typed.length
43      ? g.typed[i] === g.text[i] ? [g.text[i], 'green'] : [g.text[i], 'whiteBright', 'red']
44      : i === g.typed.length ? [g.text[i], 'black', 'white'] : [g.text[i], 'gray']
45    const last = runs[runs.length - 1]
46    if (last && last[1] === style[1] && last[2] === style[2]) last[0] += style[0]
47    else runs.push(style)
48  }
49  const seconds = (s.ms / 1000).toFixed(1)
50  const text = g.done ? `typing · ${wpm(g.text.length, s.ms)} wpm · ${accuracy(g)}% accuracy · ${seconds}s · Enter for another line`
51    : !s.started ? 'typing · click here and type the line · Tab skips to another'
52    : `typing · ${wpm(g.typed.length, s.ms)} wpm · ${accuracy(g)}% · ${seconds}s · Tab skips`
53  return (
54    <Box flexDirection="column">
55      <Box borderStyle="round" borderColor={g.done ? 'green' : 'gray'} paddingX={1}>
56        <Text>{runs.map(([t, c, b]) => (b ? <Text color={c} backgroundColor={b}>{t}</Text> : <Text color={c}>{t}</Text>))}</Text>
57      </Box>
58      {statusLine(Text, s.banner, `${text} · best ${props?.best ?? 0} wpm`)}
59    </Box>
60  )
61}
62
hooks/boards/common.tsx 61 lines
1/* @jsx h */
2import type { ClientElements, ClientSurface } from 'claude-code'
3
4// Shared by every board. A board is a surface module: it runs on the drawing thread with its own
5// frame clock, keys (after a click gives it focus; Esc gives the prompt back) and mouse.
6//
7// Never name a local `h` in a board file: every JSX tag compiles to a call of `h`.
8
9export type Props = { best?: number; done?: number; safe?: boolean } | undefined
10export type Base = { playing: boolean; banner: boolean; seenDone: number }
11export type Paint = readonly [glyph: string, color?: string, bg?: string]
12type TextTag = ClientElements['Text']
13
14export const base = (props: Props): Base => ({ playing: false, banner: false, seenDone: props?.done ?? 0 })
15
16// the hooks module bumps props.done when Claude finishes a turn: pause and say so, once per turn
17export function claudeDone<S extends Base>(props: Props, surface: ClientSurface<S>) {
18  const s = surface.state
19  const done = props?.done ?? 0
20  if (s && done !== s.seenDone) surface.setState({ ...s, seenDone: done, banner: true, playing: false })
21}
22
23export function statusLine(Text: TextTag, banner: boolean, text: string) {
24  return banner
25    ? <Text color="yellow" bold wrap="truncate-end">{`● Claude is done · ${text}`}</Text>
26    : <Text dimColor wrap="truncate-end">{text}</Text>
27}
28
29export type Run = [text: string, color?: string, bg?: string]
30
31// one run per stretch of cells sharing both colors. A drawing the engine cannot fit in its node
32// budget is worse than a coarser one, so maxRuns folds neighbours together, pair by pair, until
33// the row fits: a merged pair keeps the left one's colors and every glyph.
34export function runs(cols: number, y: number, at: (x: number, y: number) => Paint, maxRuns = Infinity): Run[] {
35  let out: Run[] = []
36  for (let x = 0; x < cols; x++) {
37    const [glyph, color, bg] = at(x, y)
38    const last = out[out.length - 1]
39    if (last && last[1] === color && last[2] === bg) last[0] += glyph
40    else out.push([glyph, color, bg])
41  }
42  while (out.length > Math.max(1, maxRuns)) {
43    const folded: Run[] = []
44    for (let i = 0; i < out.length; i += 2) {
45      const [t, c, b] = out[i]
46      const next = out[i + 1]
47      folded.push(next ? [t + next[0], c, b] : [t, c, b])
48    }
49    out = folded
50  }
51  return out
52}
53
54// a board as rows of two-character cells (a terminal cell is about twice as tall as wide, so two
55// make a square), one styled span per run of the same colors
56export function grid(Text: TextTag, cols: number, rowCount: number, at: (x: number, y: number) => Paint, maxRuns = Infinity) {
57  return Array.from({ length: Math.max(0, rowCount) }, (_, y) => (
58    <Text>{runs(cols, y, at, maxRuns).map(([t, c, b]) => (b ? <Text color={c} backgroundColor={b}>{t}</Text> : <Text color={c}>{t}</Text>))}</Text>
59  ))
60}
61
hooks/games/typing.ts 39 lines
1// Typing test: pure logic, drawn by the Typing module in ../surface.tsx. Lines of code and prose to
2// retype; speed is words per minute at five characters a word.
3
4export const LINES = [
5  'const total = items.reduce((sum, item) => sum + item.price, 0)',
6  'if (!response.ok) throw new Error(`request failed: ${response.status}`)',
7  'git commit -m "fix: retry the upload when the connection drops"',
8  'for (const [key, value] of Object.entries(config)) console.log(key, value)',
9  'The quick brown fox jumps over the lazy dog while the build runs.',
10  'export function clamp(n: number, lo: number, hi: number) { return Math.min(hi, Math.max(lo, n)) }',
11  'SELECT name, count(*) FROM orders GROUP BY name ORDER BY 2 DESC LIMIT 10;',
12  'Small, well-named functions are easier to read than clever ones.',
13  'docker run --rm -it -v "$PWD":/app -w /app node:22 npm test',
14  'def mean(xs): return sum(xs) / len(xs) if xs else 0.0',
15]
16
17export type TypingGame = { text: string; typed: string; errors: number; done: boolean }
18
19export const newTyping = (rand = Math.random, maxLength = Infinity): TypingGame => {
20  const fits = LINES.filter(l => l.length <= maxLength)
21  const pool = fits.length ? fits : LINES
22  return { text: pool[Math.floor(rand() * pool.length)], typed: '', errors: 0, done: false }
23}
24
25// one key: a printable character is typed (a wrong one counts as an error and stays until
26// backspaced), backspace removes the last character; the line is done when typed exactly
27export function press(g: TypingGame, key: string): TypingGame {
28  if (g.done) return g
29  if (key === 'backspace' || key === 'delete') return { ...g, typed: g.typed.slice(0, -1) }
30  if ([...key].length !== 1 || key < ' ') return g
31  const typed = g.typed + key
32  if (typed.length > g.text.length) return g
33  const errors = g.errors + (key === g.text[typed.length - 1] ? 0 : 1)
34  return { ...g, typed, errors, done: typed === g.text }
35}
36
37export const wpm = (chars: number, ms: number) => (ms > 0 ? Math.round((chars / 5) / (ms / 60_000)) : 0)
38export const accuracy = (g: TypingGame) => (g.typed.length ? Math.max(0, Math.round(100 * (1 - g.errors / (g.typed.length + g.errors)))) : 100)
39