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.

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.

/plugin marketplace add KamilMarzynski/claude-code-mods
/plugin install monkey@kamil-mods
Then run /reload-plugins or start a new session.
/monkey. The board opens above the prompt.| Key | Action |
|---|---|
| Tab | New words (after a run: next run) |
| Enter | Next run, after a run |
| Backspace | Delete one character |
| Ctrl+W, Option+Backspace | Delete one word |
| Esc | Back to the prompt (the board stays) |
The buttons under the words:
| Button | Action |
|---|---|
| Next / Close | Deal a new run / hide the board |
| words · 10 25 50 100 | Type a fixed number of words |
| time · 15 30 60 120 | Type for a fixed number of seconds |
| caret | Cycle the caret color: rainbow or one of the 8 session colors |



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.hooks/register.tsx 142 lines1import { 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}
142hooks/options.ts 47 lines1import 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}
47hooks/board.tsx 239 lines1import 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}
239types/index.d.ts 20 lines1export 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