Types out the code the agent writes, live, in a side pane with keyboard sounds

A Claude Code mod that makes it feel like you are typing while the agent codes.
When the agent calls Write, Edit, MultiEdit, NotebookEdit or Bash, Ghost Typist catches the code in the model's stream as it is generated and types it out in a side pane at a human pace, with a cursor and keyboard sounds.
It only shows things: the agent never waits for the typing.

Full-resolution video: docs/guide.mp4. The recording is silent; the keyboard sounds play in your terminal.
/plugin install ghost-typist --marketplace gaupoit/ghost-typist
The pane opens on its own when the agent starts writing, on a terminal at least 144 columns wide. On a narrower one, type /typist to open it.
| Command | What it does |
|---|---|
/typist | Open the pane |
/typist on / off | Turn typing on or off for this session |
/typist mode hacker | Hacker Typer mode: you type it (see below) |
/typist mode watch | Back to typing by itself |
/typist speed 140 | Base speed in words per minute |
/typist sound clicky | clicky, thock or off |
/typist status | Show the current settings |
Defaults are in /config (mode, speed, sound, which tools to type, auto-open).
/typist mode hacker opens the pane with the keyboard in it. Every letter or digit key you press types the next 3 to 5 characters of the agent's code (indentation comes for free). Stop pressing and the code waits for you. When the agent starts its next call, the one you were on finishes by itself, so you never fall behind. Esc returns to the prompt; click the pane or press ctrl+x tab to come back.
turn.step hook passes every chunk of the model's stream on unchanged, and copies the tool call's argument pieces (incomplete JSON) into a queue.content, new_string, new_source or command as it arrives, escapes and all.afplay; Linux terminals stay silent).Subagent calls are not typed. For Edit, only the new text is typed.
claude --plugin-dir .
claude plugin validate .
claude plugin test .
The clips in sounds/ are synthesised (scripts/gen_sounds.py). The guide video is recorded with VHS: vhs docs/guide.tape.
MIT
hooks/register.tsx 378 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { GhostJob, GhostSettings } from '../types'
5import { readToolText, TOOL_FIELDS } from './extract'
6import { advance, DEFAULT_PACE, measuredWpm, revealForKeys } from './typist'
7import type { Progress } from './typist'
8
9const PANE = 'ghost-typist'
10// Hacker mode listens through hidden Buttons, one per letter and digit: a
11// text field would keep every key typed into it on screen.
12const KEY_PREFIX = 'key-'
13const HOTKEYS = 'abcdefghijklmnopqrstuvwxyz0123456789'.split('')
14const TICK_MS = 50
15const SOUNDS = ['clicky', 'thock', 'off'] as const
16type Sound = (typeof SOUNDS)[number]
17const MODES = ['watch', 'hacker'] as const
18type Mode = (typeof MODES)[number]
19// In hacker mode the clicks stop this long after the last key.
20const KEY_SOUND_MS = 250
21
22const jobAtom = atom({ plugin: 'ghost-typist', key: 'job' } as const, null)
23const queuedAtom = atom({ plugin: 'ghost-typist', key: 'queued' } as const, 0)
24const settingsAtom = atom({ plugin: 'ghost-typist', key: 'settings' } as const, {
25 isEnabled: true,
26})
27
28// A tool call being typed out. `raw` is its arguments' JSON as it streams in.
29type Pending = {
30 id: string
31 tool: string
32 raw: string
33 parsedLength: number
34 text: string
35 path?: string
36 isStreamDone: boolean
37 progress: Progress
38 typingMs: number
39}
40
41const basename = (path: string) => path.split('/').at(-1) ?? path
42
43type Defaults = { wpm: number; sound: Sound; mode: Mode; tools: Set<string>; autoOpen: boolean }
44
45let defaults: Defaults = {
46 wpm: DEFAULT_PACE.wpm,
47 sound: 'clicky',
48 mode: 'watch',
49 tools: new Set(Object.keys(TOOL_FIELDS)),
50 autoOpen: true,
51}
52const queue: Pending[] = []
53let ticker: Timer | undefined
54let isTicking = false
55let sound: AbortController | undefined
56let soundOff: Timer | undefined
57let hasOpened = false
58
59const settingsOf = (s: GhostSettings) => ({
60 isEnabled: s.isEnabled,
61 wpm: s.wpm ?? defaults.wpm,
62 sound: s.sound ?? defaults.sound,
63 mode: s.mode ?? defaults.mode,
64})
65
66function setSound($: EngineInterface, isTyping: boolean, kind: Sound) {
67 if (isTyping && sound === undefined && kind !== 'off') {
68 sound = new AbortController()
69 $.audio
70 .play({ asset: `sounds/${kind}.wav` }, { shouldLoop: true, gain: 0.5, signal: sound.signal })
71 .catch(() => undefined)
72 } else if (!isTyping && sound !== undefined) {
73 sound.abort()
74 sound = undefined
75 }
76}
77
78function stop($: EngineInterface) {
79 ticker?.cancel()
80 ticker = undefined
81 setSound($, false, 'off')
82 $.ui.status(undefined)
83}
84
85function parse(job: Pending) {
86 if (job.raw.length === job.parsedLength) return
87 const read = readToolText(job.tool, job.raw)
88 job.text = job.tool === 'Bash' ? `$ ${read.text}` : read.text
89 job.path = read.path ?? job.path
90 job.parsedLength = job.raw.length
91}
92
93// Shows the job's progress, and moves on once it is fully typed.
94async function publish($: EngineInterface, job: Pending, mode: Mode) {
95 const isFinished = job.isStreamDone && job.progress.shown >= job.text.length
96 const name = job.path ? basename(job.path) : job.tool
97 $.ui.status(
98 isFinished ? undefined : mode === 'hacker' ? `⌨ hacker mode · type to write ${name}` : `⌨ typing ${name}`,
99 )
100
101 const snapshot: GhostJob = {
102 id: job.id,
103 tool: job.tool,
104 path: job.path,
105 text: job.text.slice(0, job.progress.shown),
106 total: job.text.length,
107 isStreamDone: job.isStreamDone,
108 wpm: measuredWpm(job.progress.shown, job.typingMs),
109 }
110 await update($, jobAtom, () => snapshot)
111 await update($, queuedAtom, () => Math.max(0, queue.length - 1))
112
113 // The last finished job stays on screen until the next one starts.
114 if (isFinished && queue[0] === job) {
115 queue.shift()
116 if (queue.length === 0) stop($)
117 }
118}
119
120async function tick($: EngineInterface) {
121 const job = queue[0]
122 if (job === undefined) return stop($)
123 parse(job)
124
125 const settings = settingsOf(await read($, settingsAtom))
126 // Hacker mode waits for keys, unless another call is waiting behind this one.
127 const isAuto = settings.mode === 'watch' || queue.length > 1
128 if (isAuto) {
129 const pace = {
130 ...DEFAULT_PACE,
131 wpm: settings.wpm,
132 finishWithinMs: queue.length > 1 ? 500 : DEFAULT_PACE.finishWithinMs,
133 }
134 const before = job.progress.shown
135 job.progress = advance(job.text, job.progress, TICK_MS, job.isStreamDone, pace, Math.random)
136 if (job.progress.shown > before) job.typingMs += TICK_MS
137 }
138 if (settings.mode === 'watch') {
139 setSound($, job.progress.shown < job.text.length, settings.sound)
140 }
141
142 await publish($, job, settings.mode)
143}
144
145// Hacker mode: the person pressed `presses` keys in the pane.
146async function pressKeys($: EngineInterface, presses: number) {
147 const job = queue[0]
148 if (job === undefined) return
149 parse(job)
150 const settings = settingsOf(await read($, settingsAtom))
151
152 const before = job.progress.shown
153 job.progress = { ...job.progress, shown: revealForKeys(job.text, before, presses, Math.random) }
154 // Count about one keystroke's time per press, so the footer shows a real WPM.
155 job.typingMs += presses * 120
156
157 setSound($, job.progress.shown > before, settings.sound)
158 soundOff?.cancel()
159 soundOff = $.clock.after(KEY_SOUND_MS, () => setSound($, false, 'off'))
160
161 await publish($, job, settings.mode)
162}
163
164function ensureTicker($: EngineInterface) {
165 if (ticker !== undefined) return
166 ticker = $.clock.every(TICK_MS, () => {
167 if (isTicking) return
168 isTicking = true
169 tick($)
170 .catch(() => undefined)
171 .finally(() => {
172 isTicking = false
173 })
174 })
175}
176
177
178export const register: Register = (on, options) => {
179 defaults = {
180 wpm: Number(options.wpm ?? DEFAULT_PACE.wpm),
181 sound: (SOUNDS.includes(options.sound as Sound) ? options.sound : 'clicky') as Sound,
182 tools: new Set(
183 String(options.tools ?? Object.keys(TOOL_FIELDS).join(','))
184 .split(',')
185 .map(t => t.trim())
186 .filter(t => t in TOOL_FIELDS),
187 ),
188 mode: (MODES.includes(options.mode as Mode) ? options.mode : 'watch') as Mode,
189 autoOpen: options.autoOpen !== false,
190 }
191
192 on('session.start', async ($, e, next) => {
193 await $.command.register({
194 name: 'typist',
195 description: 'Ghost Typist: open the pane, or on | off | mode watch|hacker | speed <wpm> | sound clicky|thock|off',
196 })
197
198 return next(e)
199 })
200
201 on('command.run', { command: 'typist' }, async ($, e) => {
202 const [word = '', value = ''] = e.args.trim().split(/\s+/)
203 const current = settingsOf(await read($, settingsAtom))
204
205 if (word === '' || word === 'open') {
206 await $.ui.open({ id: PANE, title: 'Ghost Typist' })
207 return { text: 'Ghost Typist pane opened.' }
208 }
209 if (word === 'on' || word === 'off') {
210 await update($, settingsAtom, s => ({ ...s, isEnabled: word === 'on' }))
211 if (word === 'off') stop($)
212 return { text: `Ghost Typist is ${word}.` }
213 }
214 if (word === 'speed') {
215 const wpm = Number(value)
216 if (!Number.isFinite(wpm) || wpm < 10 || wpm > 2000) {
217 return { text: `Speed must be a number of WPM from 10 to 2000 (now ${current.wpm}).` }
218 }
219 await update($, settingsAtom, s => ({ ...s, wpm }))
220 return { text: `Ghost Typist types at ${wpm} WPM.` }
221 }
222 if (word === 'mode') {
223 if (!MODES.includes(value as Mode)) {
224 return { text: `Mode is watch or hacker (now ${current.mode}).` }
225 }
226 await update($, settingsAtom, s => ({ ...s, mode: value as Mode }))
227 if (value === 'hacker') {
228 // Asked for, so the pane may take the keyboard.
229 await $.ui.open({ id: PANE, title: 'Ghost Typist', focus: true })
230 return { text: 'Hacker mode: each letter key you press in the pane types the agent\'s code. Esc returns to the prompt.' }
231 }
232 return { text: 'Watch mode: Ghost Typist types by itself.' }
233 }
234 if (word === 'sound') {
235 if (!SOUNDS.includes(value as Sound)) {
236 return { text: `Sound is one of ${SOUNDS.join(', ')} (now ${current.sound}).` }
237 }
238 await update($, settingsAtom, s => ({ ...s, sound: value as Sound }))
239 if (value === 'off') setSound($, false, 'off')
240 return { text: `Ghost Typist sound: ${value}.` }
241 }
242
243 return {
244 text: `Ghost Typist is ${current.isEnabled ? 'on' : 'off'} · ${current.mode} mode · ${current.wpm} WPM · sound ${current.sound}. Usage: /typist [open | on | off | mode watch|hacker | speed <wpm> | sound clicky|thock|off]`,
245 }
246 })
247
248 // Watches the model's stream and copies tool arguments into the queue.
249 // Every chunk is passed on unchanged.
250 on('turn.step', async function* ($, e, next) {
251 const stream = next(e)
252 const settings = await read($, settingsAtom)
253 if (!settings.isEnabled || e.agentId !== undefined) return yield* stream
254
255 const byIndex = new Map<number, Pending>()
256 const finishAll = () => {
257 for (const job of byIndex.values()) job.isStreamDone = true
258 }
259 try {
260 for await (const chunk of stream) {
261 if (chunk.kind === 'tool') {
262 finishAll()
263 if (defaults.tools.has(chunk.name)) {
264 const job: Pending = {
265 id: chunk.id,
266 tool: chunk.name,
267 raw: '',
268 parsedLength: -1,
269 text: '',
270 isStreamDone: false,
271 progress: { shown: 0, carry: 0 },
272 typingMs: 0,
273 }
274 byIndex.set(chunk.index, job)
275 queue.push(job)
276 if (defaults.autoOpen && !hasOpened) {
277 hasOpened = true
278 void $.ui.open({ id: PANE, title: 'Ghost Typist' })
279 }
280 ensureTicker($)
281 }
282 } else if (chunk.kind === 'input') {
283 const job = byIndex.get(chunk.index)
284 if (job !== undefined) job.raw += chunk.json
285 }
286 yield chunk
287 }
288 } finally {
289 finishAll()
290 }
291
292 return await stream.result
293 })
294
295 // Hacker mode: every edit of the pane's key field is a key press.
296 // Answered here: each key redraws the pane, so by the time the press
297 // would reach its Button the handler it was drawn with is gone.
298 on('ui.press', async ($, e, next) => {
299 if (e.plugin !== 'ghost-typist' || e.requestId !== PANE || !e.element.startsWith(KEY_PREFIX)) {
300 return next(e)
301 }
302 await pressKeys($, 1)
303
304 return { element: e.element }
305 })
306
307 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
308 const elements = $.ui.resolve(e)
309 const { Box, Text, Code } = elements
310 const job = await read($, jobAtom)
311 const queued = await read($, queuedAtom)
312 const { mode } = settingsOf(await read($, settingsAtom))
313 // Surfaces without buttons stay in watch mode.
314 const Button = mode === 'hacker' && 'Button' in elements ? elements.Button : undefined
315 const keyField =
316 Button === undefined ? null : (
317 <Box flexDirection="column">
318 <Text dimColor>
319 {e.props.isFocused ? 'mash any letter key…' : 'focus the pane (click, or ctrl+x tab) and mash letters'}
320 </Text>
321 <Box display="none">
322 {HOTKEYS.map(ch => (
323 <Button key={`${KEY_PREFIX}${ch}`} hotkey={ch} label={ch} onPress={() => undefined} />
324 ))}
325 </Box>
326 </Box>
327 )
328
329 if (job === null) {
330 return (
331 <Box flexDirection="column">
332 <Text bold>Ghost Typist</Text>
333 <Text dimColor>Waiting for the agent to write some code…</Text>
334 {keyField}
335 </Box>
336 )
337 }
338
339 const columns = Math.max(20, e.props.bodyColumns - 6)
340 const room = Math.max(3, e.props.scroll.bodyRows - (keyField === null ? 4 : 6))
341 const isTyping = !job.isStreamDone || job.text.length < job.total
342 // Keep the cursor in view: the tail of the text that fits, counting wrapped rows.
343 const lines = job.text.split('\n')
344 let used = 0
345 let first = lines.length
346 while (first > 0) {
347 const rows = Math.max(1, Math.ceil(lines[first - 1]!.length / columns))
348 if (used + rows > room) break
349 used += rows
350 first -= 1
351 }
352 const source = lines.slice(first).join('\n') + (isTyping ? '▌' : '')
353 const pct = job.total === 0 ? 0 : Math.round((job.text.length / job.total) * 100)
354
355 return (
356 <Box flexDirection="column">
357 <Box flexDirection="row" gap={1}>
358 <Text bold color="claude">
359 {job.tool}
360 </Text>
361 <Text>{job.path ?? ''}</Text>
362 </Box>
363 <Code
364 source={source}
365 path={job.path}
366 language={job.tool === 'Bash' ? 'bash' : undefined}
367 startLine={job.path !== undefined && job.tool === 'Write' ? first + 1 : undefined}
368 />
369 <Text dimColor>
370 {isTyping ? (Button === undefined ? 'typing' : 'hacker mode') : 'done'} · {job.wpm} WPM ·{' '}
371 {pct}%{queued > 0 ? ` · ${queued} queued` : ''}
372 </Text>
373 {keyField}
374 </Box>
375 )
376 })
377}
378hooks/extract.ts 113 lines1// Reads string fields out of a tool call's arguments while they are still
2// streaming, so the JSON is usually incomplete: `{"file_path":"a.ts","content":"imp`.
3
4export type Field = { value: string; isComplete: boolean }
5
6const ESCAPES: Record<string, string> = {
7 '"': '"',
8 '\\': '\\',
9 '/': '/',
10 b: '\b',
11 f: '\f',
12 n: '\n',
13 r: '\r',
14 t: '\t',
15}
16
17// Decodes the JSON string whose opening quote is at `start`. Stops cleanly
18// at the end of the input, dropping an escape that is cut in half.
19const readString = (raw: string, start: number): Field & { end: number } => {
20 let value = ''
21 let i = start + 1
22 while (i < raw.length) {
23 const ch = raw[i]!
24 if (ch === '"') return { value, isComplete: true, end: i + 1 }
25 if (ch !== '\\') {
26 value += ch
27 i += 1
28 continue
29 }
30 const esc = raw[i + 1]
31 if (esc === undefined) break
32 if (esc === 'u') {
33 const hex = raw.slice(i + 2, i + 6)
34 if (hex.length < 4) break
35 value += String.fromCharCode(parseInt(hex, 16))
36 i += 6
37 continue
38 }
39 value += ESCAPES[esc] ?? esc
40 i += 2
41 }
42
43 return { value, isComplete: false, end: raw.length }
44}
45
46// Every string value of `key`, at any depth, in order (MultiEdit's
47// `edits[].new_string` gives several). The last may be incomplete.
48export const extractFields = (raw: string, key: string): Field[] => {
49 const found: Field[] = []
50 const stack: Array<'{' | '['> = []
51 let isKeyNext = false
52 let lastKey: string | undefined
53 let i = 0
54 while (i < raw.length) {
55 const ch = raw[i]!
56 if (ch === '"') {
57 const str = readString(raw, i)
58 if (isKeyNext && stack.at(-1) === '{') {
59 lastKey = str.value
60 isKeyNext = false
61 } else if (lastKey === key) {
62 found.push({ value: str.value, isComplete: str.isComplete })
63 }
64 i = str.end
65 continue
66 }
67 if (ch === '{' || ch === '[') {
68 stack.push(ch)
69 isKeyNext = ch === '{'
70 } else if (ch === '}' || ch === ']') {
71 stack.pop()
72 isKeyNext = false
73 } else if (ch === ',') {
74 isKeyNext = stack.at(-1) === '{'
75 } else if (ch === ':') {
76 isKeyNext = false
77 }
78 i += 1
79 }
80
81 return found
82}
83
84export const extractField = (raw: string, key: string): Field | undefined =>
85 extractFields(raw, key)[0]
86
87// What each tool types, and where its target path lives.
88export const TOOL_FIELDS: Record<string, { text: string; path?: string }> = {
89 Write: { text: 'content', path: 'file_path' },
90 Edit: { text: 'new_string', path: 'file_path' },
91 MultiEdit: { text: 'new_string', path: 'file_path' },
92 NotebookEdit: { text: 'new_source', path: 'notebook_path' },
93 Bash: { text: 'command' },
94}
95
96export const EDIT_SEPARATOR = '\n\n⋯\n\n'
97
98// The text a tool call has typed so far, and whether its arguments are done.
99export const readToolText = (
100 tool: string,
101 raw: string,
102): { text: string; path?: string } => {
103 const spec = TOOL_FIELDS[tool]
104 if (spec === undefined) return { text: '' }
105 const parts = extractFields(raw, spec.text).map(f => f.value)
106 const path = spec.path === undefined ? undefined : extractField(raw, spec.path)
107
108 return {
109 text: parts.join(EDIT_SEPARATOR),
110 path: path?.isComplete ? path.value : undefined,
111 }
112}
113hooks/typist.ts 92 lines1// Pacing: how many characters a human-ish typist gets through in `dtMs`.
2// Pure, so tests drive it with a fixed random source.
3
4export type Pace = {
5 wpm: number
6 // How far behind the model the typist may fall before speeding up.
7 maxLagChars: number
8 // Once the model has finished the call, the rest is typed within this.
9 finishWithinMs: number
10}
11
12export const DEFAULT_PACE: Pace = { wpm: 110, maxLagChars: 600, finishWithinMs: 8000 }
13
14// Cost of one character, in units of a plain keystroke.
15export const charCost = (ch: string, prev: string | undefined, rand: number): number => {
16 const jitter = 0.6 + rand * 0.8
17 // Auto-indent: leading whitespace after a newline costs almost nothing.
18 if ((ch === ' ' || ch === '\t') && (prev === '\n' || prev === ' ' || prev === '\t')) {
19 return 0.08
20 }
21 if (ch === '\n') return 3.5 * jitter
22 if (';{}()'.includes(ch)) return 1.8 * jitter
23 if (ch === ' ') return 1.3 * jitter
24
25 return jitter
26}
27
28export type Progress = {
29 shown: number
30 // Keystroke budget carried over between ticks.
31 carry: number
32 // Time spent typing since the model finished the call.
33 sinceDoneMs?: number
34}
35
36export const advance = (
37 text: string,
38 progress: Progress,
39 dtMs: number,
40 isStreamDone: boolean,
41 pace: Pace,
42 random: () => number,
43): Progress => {
44 const left = text.length - progress.shown
45 if (left <= 0) return { shown: text.length, carry: 0 }
46
47 const keysPerMs = (pace.wpm * 5) / 60_000
48 const speedUp = Math.max(1, left / pace.maxLagChars)
49 let budget = progress.carry + dtMs * keysPerMs * speedUp
50 let shown = progress.shown
51 while (shown < text.length) {
52 const cost = charCost(text[shown]!, text[shown - 1], random())
53 if (cost > budget) break
54 budget -= cost
55 shown += 1
56 }
57
58 // Once the call is complete, spread what is left over the time remaining
59 // in the window, so it ends on time whatever the speed.
60 const sinceDoneMs = isStreamDone ? (progress.sinceDoneMs ?? 0) + dtMs : undefined
61 if (sinceDoneMs !== undefined) {
62 const remainingMs = Math.max(dtMs, pace.finishWithinMs - sinceDoneMs + dtMs)
63 shown = Math.max(shown, progress.shown + Math.ceil((left * dtMs) / remainingMs))
64 }
65 shown = Math.min(shown, text.length)
66
67 return { shown, carry: shown === text.length ? 0 : budget, sinceDoneMs }
68}
69
70// Words per minute actually reached, for the footer.
71export const measuredWpm = (chars: number, ms: number): number =>
72 ms <= 0 ? 0 : Math.round(chars / 5 / (ms / 60_000))
73
74// Hacker Typer mode: each key the person presses reveals a few characters,
75// and indentation after a newline comes along for free.
76export const revealForKeys = (
77 text: string,
78 shown: number,
79 presses: number,
80 random: () => number,
81): number => {
82 let next = shown
83 for (let i = 0; i < presses && next < text.length; i += 1) {
84 next += 3 + Math.floor(random() * 3)
85 if (text[next - 1] === '\n') {
86 while (next < text.length && /[ \t]/.test(text[next]!)) next += 1
87 }
88 }
89
90 return Math.min(next, text.length)
91}
92types/index.d.ts 29 lines1export type GhostJob = {
2 id: string
3 tool: string
4 path?: string
5 // The part typed out so far.
6 text: string
7 // Characters received from the model so far.
8 total: number
9 isStreamDone: boolean
10 wpm: number
11}
12
13export type GhostSettings = {
14 isEnabled: boolean
15 wpm?: number
16 sound?: 'clicky' | 'thock' | 'off'
17 mode?: 'watch' | 'hacker'
18}
19
20declare module 'claude-code' {
21 interface PluginState {
22 'ghost-typist': {
23 job: GhostJob | null
24 queued: number
25 settings: GhostSettings
26 }
27 }
28}
29