SLOPSHOPPER

monkey

A monkeytype-style typing test above the prompt: /monkey. Words (10/25/50/100) or time (15/30/60/120 s) mode, WPM and accuracy, a best score per mode.

newbandcommand
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · monkey
› 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 › /monkey ⎿ monkey: Typing test above the prompt: click it, then type. ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ ▣ client module ./board.tsx │ │ [ Next ] [ Close ] · [ words ] [ time ] · [ 10 ] [ 25 ] [ 50 ] [ 100 ] · [ caret: rainbow ] │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │ ▣ client module ./board.tsx │ │ [ Next ] [ Close ] · [ words ] [ time ] · [ 10 ] [ 25 ] [ 50 ] [ 100 ] · [ caret: rainbow ] │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
README

monkey

A monkeytype-style typing test that opens above the Claude Code prompt, so you can type while Claude works. Each run shows WPM and accuracy, and the board keeps your best score per mode.

A scripted run of /monkey: 25 words typed, then a new-record banner

Install

/plugin marketplace add KamilMarzynski/claude-code-mods
/plugin install monkey@kamil-mods

Then run /reload-plugins or start a new session.

Use

  1. Run /monkey. The board opens above the prompt.
  2. Click the board, then type. The clock starts on the first key.
  3. When you finish, the board shows WPM, accuracy and time. A new best score gets a rainbow banner.
KeyAction
TabNew words (after a run: next run)
EnterNext run, after a run
BackspaceDelete one character
Ctrl+W, Option+BackspaceDelete one word
EscBack to the prompt (the board stays)

The buttons under the words:

ButtonAction
Next / CloseDeal a new run / hide the board
words · 10 25 50 100Type a fixed number of words
time · 15 30 60 120Type for a fixed number of seconds
caretCycle the caret color: rainbow or one of the 8 session colors

Typing: done words are bright, the caret marks the next letter

Time mode: wrong letters are red, the clock counts down

Finished run with a new record

Limits

  • Each finished run (mode, wpm, accuracy, seconds, time stamp) goes to the local plugin store, which keeps the last 5000 runs, your mode and your caret color. The mod sends nothing over the network.
  • Terminal only. The board does not show in the VS Code extension or on mobile.
  • You must click the board before you type. Claude Code has no key that moves focus into it.
  • The caret colors use the session color theme keys of Claude Code. A Claude Code update can change them.
Source 4 files
hooks/register.tsx 142 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { BoardProps, Finished, Run, Prefs } from '../types'
5import { CARETS, DEFAULT_PREFS, SIZES, tagOf } from './options.ts'
6
7const MAX_RUNS = 5000
8
9const round = atom({ plugin: 'monkey', key: 'round' } as const, null)
10const best = atom({ plugin: 'monkey', key: 'best' } as const, null)
11const record = atom({ plugin: 'monkey', key: 'record' } as const, null)
12const prefs = atom({ plugin: 'monkey', key: 'prefs' } as const, DEFAULT_PREFS)
13
14const isFinished = (data: unknown): data is Finished => {
15  const d = data as Partial<Finished> | null
16
17  return (
18    typeof d?.id === 'string' &&
19    typeof d.mode === 'string' &&
20    typeof d.wpm === 'number' &&
21    typeof d.accuracy === 'number' &&
22    typeof d.seconds === 'number'
23  )
24}
25
26const prefsFrom = (stored: unknown): Prefs => {
27  const s = { ...DEFAULT_PREFS, ...(stored as Partial<Prefs> | undefined) }
28
29  return {
30    kind: s.kind === 'time' ? 'time' : 'words',
31    words: (SIZES.words as readonly number[]).includes(s.words) ? s.words : DEFAULT_PREFS.words,
32    time: (SIZES.time as readonly number[]).includes(s.time) ? s.time : DEFAULT_PREFS.time,
33    caret: (CARETS as readonly string[]).includes(s.caret) ? s.caret : DEFAULT_PREFS.caret,
34  }
35}
36
37const bestOf = (runs: Run[], mode: string): number | null =>
38  runs.reduce<number | null>((top, run) => (run.mode === mode && run.wpm > (top ?? -1) ? run.wpm : top), null)
39
40const runsIn = async ($: EngineInterface) => ((await $.store.get('runs')) ?? []) as Run[]
41
42const save = async ($: EngineInterface, next: Prefs) => {
43  await $.store.set('settings', next)
44  await update($, prefs, () => next)
45}
46
47const startRound = async ($: EngineInterface, next: Prefs) => {
48  const top = bestOf(await runsIn($), tagOf(next))
49  await save($, next)
50  await update($, best, () => top)
51  await update($, record, () => null)
52  await update($, round, current => (current ?? 0) + 1)
53}
54
55export const register: Register = on => {
56  on('session.start', async ($, e, next) => {
57    await $.command.register({
58      name: 'monkey',
59      description: 'Typing test above the prompt: words or time mode, then your WPM',
60    })
61
62    return next(e)
63  })
64
65  on('command.run', { command: 'monkey' }, async $ => {
66    await startRound($, prefsFrom(await $.store.get('settings')))
67
68    return { text: 'Typing test above the prompt: click it, then type.' }
69  })
70
71  on('ui.message', async ($, e, next) => {
72    if (e.component !== 'AbovePrompt' || !isFinished(e.data)) {
73      return next(e)
74    }
75
76    const { id, ...result } = e.data
77    const runs = await runsIn($)
78    const previous = bestOf(runs, result.mode)
79    await $.store.set('runs', [...runs, { ...result, at: await $.clock.now() }].slice(-MAX_RUNS))
80
81    if (result.mode === tagOf(await read($, prefs)) && result.wpm > (previous ?? 0)) {
82      await update($, best, () => result.wpm)
83      await update($, record, () => ({ id, previous }))
84    }
85
86    return {}
87  })
88
89  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
90    const current = await read($, round)
91
92    if (current === null || e.props.hasSurvey || e.surface === 'mobile' || e.surface === 'vscode') {
93      return next(e)
94    }
95
96    const { Box, Button, Client, Text } = $.ui.resolve(e)
97    const chosen = await read($, prefs)
98    const size = chosen.kind === 'words' ? chosen.words : chosen.time
99    const props: BoardProps = {
100      kind: chosen.kind,
101      size,
102      caret: chosen.caret,
103      best: await read($, best),
104      record: await read($, record),
105    }
106    const choose = (change: Partial<Prefs>) => () => startRound($, { ...chosen, ...change })
107    const nextCaret = CARETS[(CARETS.indexOf(chosen.caret as (typeof CARETS)[number]) + 1) % CARETS.length] ?? DEFAULT_PREFS.caret
108
109    return (
110      <Box borderStyle="round" paddingX={1} flexDirection="column">
111        <Client key={`board-${current}`} module="./board.tsx" width="100%" props={props} />
112        <Box key="controls" gap={1}>
113          <Button key="next" label="Next" onPress={() => update($, round, () => current + 1)} />
114          <Button key="close" label="Close" onPress={() => update($, round, () => null)} />
115          <Text dimColor>·</Text>
116          {(['words', 'time'] as const).map(kind => (
117            <Button
118              key={`kind-${kind}`}
119              label={kind}
120              variant={kind === chosen.kind ? 'primary' : undefined}
121              dimColor={kind !== chosen.kind}
122              onPress={choose({ kind })}
123            />
124          ))}
125          <Text dimColor>·</Text>
126          {SIZES[chosen.kind].map(option => (
127            <Button
128              key={`size-${option}`}
129              label={String(option)}
130              variant={option === size ? 'primary' : undefined}
131              dimColor={option !== size}
132              onPress={choose(chosen.kind === 'words' ? { words: option } : { time: option })}
133            />
134          ))}
135          <Text dimColor>·</Text>
136          <Button key="caret" label={`caret: ${chosen.caret}`} onPress={() => save($, { ...chosen, caret: nextCaret })} />
137        </Box>
138      </Box>
139    )
140  })
141}
142
hooks/options.ts 47 lines
1import type { Kind, Prefs } from '../types'
2
3export const SIZES = { words: [10, 25, 50, 100], time: [15, 30, 60, 120] } as const
4
5export const CARETS = ['rainbow', 'red', 'blue', 'green', 'yellow', 'purple', 'orange', 'pink', 'cyan'] as const
6
7export const DEFAULT_PREFS: Prefs = { kind: 'words', words: 25, time: 30, caret: 'rainbow' }
8
9export const caretColor = (caret: string) => `${caret}_FOR_SUBAGENTS_ONLY`
10
11export const tagOf = ({ kind, words, time }: Prefs) => `${kind}:${kind === 'words' ? words : time}`
12
13export const LINE = 25
14const FASTEST_CHARS_PER_MINUTE = 200 * 5
15
16const WORDS = (
17  'the be of and a to in he have it that for they with as not on she at by this we you do but from or which ' +
18  'one would all will there say who make when can more if no man out other so what time up go about than into ' +
19  'could state only new year some take come these know see use get like then first any work now may such give ' +
20  'over think most even find day also after way many must look before great back through long where much should ' +
21  'well people down own just because good each those feel seem how high too place little world very still nation ' +
22  'hand old life tell write become here show house both between need mean call develop under last right move thing ' +
23  'general school never same another begin while number part turn real leave might want point form off child few ' +
24  'small since against ask late home interest large person end open public follow during present without again ' +
25  'hold govern around possible head consider word program problem however lead system set order eye plan run keep ' +
26  'face fact group play stand increase early course change help line'
27).split(' ')
28
29const randomWord = () => WORDS[Math.floor(Math.random() * WORDS.length)] ?? 'the'
30
31export const deal = (kind: Kind, size: number): string[] => {
32  if (kind === 'words') {
33    return Array.from({ length: size }, randomWord)
34  }
35
36  const words: string[] = []
37  let chars = 0
38
39  while (chars < (size / 60) * FASTEST_CHARS_PER_MINUTE) {
40    const word = randomWord()
41    words.push(word)
42    chars += word.length + 1
43  }
44
45  return [...words, ...Array.from({ length: LINE }, randomWord)]
46}
47
hooks/board.tsx 239 lines
1import type { ClientKeyEvent, ClientSurface } from 'claude-code'
2
3import type { BoardProps, Kind } from '../types'
4import { LINE, caretColor, deal } from './options.ts'
5
6const RAINBOW = ['#eb5f57', '#f0883e', '#f5c242', '#7cc860', '#4fb0c6', '#5b8def', '#a46fd8', '#d66cb8']
7
8type State = {
9  id: string
10  kind: Kind
11  size: number
12  words: string[]
13  typed: string
14  ms: number
15  frame: number
16  isStarted: boolean
17  isDone: boolean
18  isRecord: boolean
19}
20type Run = { text: string; color?: string; dimColor?: true; bold?: true; underline?: true; strikethrough?: true }
21
22const fresh = (kind: Kind, size: number): State => ({
23  id: Math.random().toString(36).slice(2),
24  kind,
25  size,
26  words: deal(kind, size),
27  typed: '',
28  ms: 0,
29  frame: 0,
30  isStarted: false,
31  isDone: false,
32  isRecord: false,
33})
34
35const isFinished = (words: string[], typed: string): boolean => {
36  const typedWords = typed.split(' ')
37
38  return (
39    typedWords.length > words.length ||
40    (typedWords.length === words.length && (typedWords.at(-1) ?? '').length >= (words.at(-1) ?? '').length)
41  )
42}
43
44const typedAfter = (typed: string, { key, ctrl, meta }: ClientKeyEvent): string | undefined => {
45  const isErase = key === 'backspace' || key === 'delete'
46
47  if ((isErase && meta) || (key === 'w' && ctrl)) {
48    return typed.replace(/\S*\s?$/, '')
49  }
50
51  if (isErase) {
52    return typed.slice(0, -1)
53  }
54
55  const char = key === 'space' ? ' ' : key
56
57  return ctrl || meta || [...char].length !== 1 || char < ' ' ? undefined : typed + char
58}
59
60const score = ({ words, typed, ms }: State) => {
61  const typedWords = typed.split(' ')
62  let correctChars = 0
63  let typedChars = 0
64  let rightChars = 0
65
66  words.forEach((word, i) => {
67    const attempt = typedWords[i] ?? ''
68
69    if (attempt === word) {
70      correctChars += word.length + (i < words.length - 1 ? 1 : 0)
71    }
72
73    typedChars += attempt.length
74    rightChars += [...attempt].filter((char, j) => char === word[j]).length
75  })
76
77  return {
78    wpm: Math.round(correctChars / 5 / (Math.max(ms, 1) / 60000)),
79    accuracy: typedChars === 0 ? 0 : Math.round((rightChars / typedChars) * 100),
80  }
81}
82
83const runsOf = ({ words, typed, isDone }: State, from: number, to: number, caretStyle: { color?: string }): Run[] => {
84  const typedWords = typed.split(' ')
85  const active = typedWords.length - 1
86  const caret = { ...caretStyle, bold: true, underline: true } as const
87  const runs: Run[] = []
88  const push = (run: Run) => {
89    const last = runs.at(-1)
90    const isSameStyle =
91      last !== undefined &&
92      last.color === run.color &&
93      last.dimColor === run.dimColor &&
94      last.bold === run.bold &&
95      last.underline === run.underline &&
96      last.strikethrough === run.strikethrough
97
98    if (isSameStyle) {
99      last.text += run.text
100    } else {
101      runs.push(run)
102    }
103  }
104
105  words.slice(from, to).forEach((word, offset) => {
106    const i = from + offset
107    const attempt = typedWords[i]
108
109    ;[...word].forEach((char, j) => {
110      if (attempt === undefined || j >= attempt.length) {
111        const isCaret = i === active && j === (attempt ?? '').length && !isDone
112
113        return push(isCaret ? { text: char, ...caret } : { text: char, dimColor: true })
114      }
115
116      push(attempt[j] === char ? { text: char } : { text: char, color: 'red' })
117    })
118
119    if (attempt !== undefined && attempt.length > word.length) {
120      push({ text: attempt.slice(word.length), color: 'red', strikethrough: true })
121    }
122
123    if (i < words.length - 1) {
124      push(i === active && attempt === word && !isDone ? { text: ' ', ...caret } : { text: ' ', dimColor: true })
125    }
126  })
127
128  return runs
129}
130
131export default function Board(props: BoardProps | undefined, surface: ClientSurface<State>) {
132  const { Box, Text } = surface.elements
133  const finish = (s: State) =>
134    surface.post({ id: s.id, mode: `${s.kind}:${s.size}`, ...score(s), seconds: s.ms / 1000 })
135
136  if (surface.state === undefined) {
137    surface.setState(fresh(props?.kind ?? 'words', props?.size ?? LINE))
138    surface.every(100, () => {
139      const s = surface.state
140
141      if (s === undefined || (s.isDone && !s.isRecord)) {
142        return
143      }
144
145      const ms = s.isStarted && !s.isDone ? s.ms + 100 : s.ms
146      const isTimeUp = s.kind === 'time' && !s.isDone && ms >= s.size * 1000
147      const next = { ...s, frame: s.frame + 1, ms, isDone: s.isDone || isTimeUp }
148      surface.setState(next)
149
150      if (isTimeUp) {
151        finish(next)
152      }
153    })
154    surface.onKey(event => {
155      const s = surface.state
156
157      if (s === undefined) {
158        return
159      }
160
161      if (event.key === 'tab' || (event.key === 'return' && s.isDone)) {
162        return surface.setState(fresh(s.kind, s.size))
163      }
164
165      const typed = s.isDone ? undefined : typedAfter(s.typed, event)
166
167      if (typed !== undefined && typed !== s.typed) {
168        const next = { ...s, typed, isStarted: true, isDone: s.kind === 'words' && isFinished(s.words, typed) }
169        surface.setState(next)
170
171        if (next.isDone) {
172          finish(next)
173        }
174      }
175    })
176  }
177
178  const s = surface.state
179
180  if (s === undefined) {
181    return <Text dimColor>monkey</Text>
182  }
183
184  const { wpm, accuracy } = score(s)
185  const seconds = (s.ms / 1000).toFixed(1)
186  const record = s.isDone && props?.record?.id === s.id ? props.record : null
187  const best = props?.best ?? null
188
189  if (record !== null && !s.isRecord) {
190    surface.setState({ ...s, isRecord: true })
191  }
192
193  const wave = (text: string) =>
194    [...text].map((char, i) => (
195      <Text color={RAINBOW[(((i - s.frame) % RAINBOW.length) + RAINBOW.length) % RAINBOW.length]}>{char}</Text>
196    ))
197
198  const caret = props?.caret ?? 'rainbow'
199  const caretStyle = { color: caret === 'rainbow' ? RAINBOW[s.frame % RAINBOW.length] : caretColor(caret) }
200  const done = Math.min(s.typed.split(' ').length - 1, s.words.length)
201  const start = Math.floor(Math.min(done, s.words.length - 1) / LINE) * LINE
202  const lines = [start, start + LINE].filter(from => from < s.words.length)
203  const progress =
204    s.kind === 'words' ? `${done}/${s.size}` : `${Math.max(0, Math.ceil(s.size - s.ms / 1000))}s left`
205  const status = s.isDone
206    ? `${accuracy}% acc · ${seconds}s · Enter or Tab: next · Esc: back to prompt`
207    : s.isStarted
208      ? `${progress} · Tab: new words`
209      : `${progress} · click here, then type · Tab: new words · Esc: back to prompt`
210
211  return (
212    <Box flexDirection="column">
213      {lines.map(from => (
214        <Text wrap="wrap">
215          {s.isRecord
216            ? wave(s.words.slice(from, from + LINE).join(' '))
217            : runsOf(s, from, from + LINE, caretStyle).map(({ text, ...style }) => <Text {...style}>{text}</Text>)}
218        </Text>
219      ))}
220      {record !== null && (
221        <Text bold>
222          {wave(`★ New record! ${wpm} WPM${record.previous === null ? '' : `, up from ${record.previous}`} ★`)}
223        </Text>
224      )}
225      <Box>
226        {s.isDone && record === null && (
227          <Text bold color="yellow">
228            {wpm} WPM{'  '}
229          </Text>
230        )}
231        <Text dimColor>
232          {status}
233          {best === null ? '' : ` · best ${best} WPM`}
234        </Text>
235      </Box>
236    </Box>
237  )
238}
239
types/index.d.ts 20 lines
1export type Round = number | null
2
3export type Kind = 'words' | 'time'
4
5export type Prefs = { kind: Kind; words: number; time: number; caret: string }
6
7export type Finished = { id: string; mode: string; wpm: number; accuracy: number; seconds: number }
8
9export type Run = Omit<Finished, 'id'> & { at: number }
10
11export type NewRecord = { id: string; previous: number | null }
12
13export type BoardProps = { kind: Kind; size: number; caret: string; best: number | null; record: NewRecord | null }
14
15declare module 'claude-code' {
16  interface PluginState {
17    monkey: { round: Round; best: number | null; record: NewRecord | null; prefs: Prefs }
18  }
19}
20