Sound for Claude Code on Linux: mechanical key sounds as you type, and a chime when you send a prompt, when the reply lands and when a turn fails.

Sound for Claude Code on Linux. Keys click as you type, and a short chime plays when you send a prompt, when the reply lands and when a turn fails.
| When | Sound |
|---|---|
| You press a key in the prompt box | That key's press, then its release |
| Claude writes reply text | Off unless you turn it on with /cues claude on: keys at a typist's uneven pace, about one every 110 ms, with a short pause after each word. Tool calls make no sound |
| You send a prompt | Enter, and the send chime |
| Claude's reply is done | The reply chime |
| A turn ends in an error or a refusal | The error chime |
Shortcuts with Ctrl or Alt, pasted text and sub-agents' replies make no sound. A turn you stop plays no chime.
pw-cat) or ALSA (aplay). PipeWire is used when both are there.With either missing the mod stays silent and says why in one toast.
| Command | What it does |
|---|---|
/cues | Shows the settings, lists the key switches, and says so when the player is not running |
/cues on, /cues off | Turns every sound on or off (unmute and mute do the same) |
/cues volume | Says what the keys volume and the chimes volume are |
/cues volume keys 40, /cues volume chimes 70 | Sets one of the two volumes, 0 to 100. The scale follows the ear: 50 sounds about half as loud as 100 |
/cues volume 40 | Sets both volumes at once |
/cues topre | Picks a key switch, turns sound on and plays one key of it |
/cues keys off | Turns your own key sounds off (on turns them back on) |
/cues claude on | Turns on key sounds for what Claude writes. They are off by default, since Claude writes a lot |
/cues send off | Turns the send chime off |
/cues claude topre | Gives Claude's keys a switch of their own, and turns them on. /cues claude same goes back to your switch |
/cues reply off | Turns the reply chime off |
/cues reply bell, /cues reply notes | Picks the reply chime: a small bell (the default) or two low rising notes |
/cues error off | Turns the error chime off |
/cues chimes off | Turns all three chimes off at once |
Settings are remembered across sessions.
mxblue, mxbrown (the default), mxblack, holypanda, topre, cream, alpaca, turquoise, blackink, redink, bluealps, boxnavy, buckling.
Every sound is a 16-bit PCM .wav file under sounds/, read when a session starts. Any sample rate works; stereo is mixed down to one channel. Each switch and each chime is brought to the same loudness when it loads, so a quiet recording needs no editing.
| Path | Sound |
|---|---|
sounds/keys/<switch>/press/r0.wav to r4.wav | A key on the number row (r0) down to the bottom row (r4) |
sounds/keys/<switch>/press/space.wav, enter.wav, backspace.wav | Optional. Without one, the key plays a row sound |
sounds/keys/<switch>/release/key.wav | Any key coming back up |
sounds/keys/<switch>/release/space.wav, enter.wav, backspace.wav | Optional |
sounds/chimes/send.wav, reply.wav, error.wav | Optional. Each replaces the built-in chime of that name. A reply.wav plays whichever reply sound is picked |
The chimes are made from sine notes when the player starts, so they ship as code (src/audio/synth.ts), not as files.
The hooks cannot play sound themselves on Linux, so each session starts one small player, bin/cuesd.ts. It loads every clip into memory, brings each one to the same loudness so no switch or chime is louder than the next, mixes the ones that are playing, and writes the mix to pw-cat a few milliseconds ahead of the clock. Its PipeWire stream is named cues and starts at full level each session, so the volume to set is /cues volume. The hooks ask for a clip with one request on a Unix socket in $XDG_RUNTIME_DIR and never wait for the answer, so no key and no turn is held up by a sound. The player stops with the session.
PipeWire drops the start of a sound handed to a stream that ran dry, and a key click is mostly its start. So the player keeps the stream fed with silence for 3 seconds after each sound, and puts 45 ms of silence ahead of the first sound after a longer pause. After those 3 seconds it writes nothing and uses no CPU.
The key recordings in sounds/keys are from kbsim by Thomas Lai, under the MIT License in sounds/keys/LICENSE. They were converted to wav and trimmed of leading silence.
hooks/register.ts 162 lines1import type { Hook, Register } from 'claude-code'
2
3import { runCommand } from '../src/command'
4import { clipForChar, clipForKey, pressClips, releaseClips } from '../src/keys'
5import type { KeyClip } from '../src/keys'
6import { playUrl } from '../src/request'
7import { DEFAULTS, gainFor, restore } from '../src/settings'
8import type { Settings, Switch } from '../src/settings'
9import { Typist, keyDelay } from '../src/typist'
10
11type Engine = Parameters<Hook<'session.start'>>[0]
12
13const READY = 'cuesd ready'
14const SETTINGS_KEY = 'settings'
15// A key comes back up this long after it went down.
16const RELEASE_MS = 70
17// Every clip is equally loud as it loads, so these set the balance: a key coming back up is softer
18// than it going down, and Claude's keys sit under the user's.
19const RELEASE_GAIN = 0.5
20const CLAUDE_GAIN = 0.75
21const CHIME_GAIN = 1
22// Each key press is a little louder or softer than the last, by up to this share.
23const GAIN_SPREAD = 0.1
24
25type Chime = 'send' | 'reply' | 'error'
26
27type State = {
28 settings: Settings
29 socketPath: string
30 // True once the player said it can play, until it exits.
31 isReady: boolean
32 typist: Typist
33 typing: { cancel: () => void } | null
34}
35
36// Asks the player for a clip and does not wait: no sound is worth holding up a key or a turn.
37// `volume` is the setting the clip plays at, 0 to 1.
38const send = ($: Engine, s: State, clips: readonly string[], gain: number, volume: number): void => {
39 if (!s.isReady || !s.settings.isOn) return
40 void $.http.fetch(playUrl(clips, gain * gainFor(volume)), { socketPath: s.socketPath }).catch(() => undefined)
41}
42
43const pressKey = ($: Engine, s: State, board: Switch, clip: KeyClip, gain: number): void => {
44 const level = gain * (1 - GAIN_SPREAD + Math.random() * 2 * GAIN_SPREAD)
45 send($, s, pressClips(board, clip), level, s.settings.volume)
46 $.clock.after(RELEASE_MS, () => send($, s, releaseClips(board, clip), level * RELEASE_GAIN, s.settings.volume))
47}
48
49const pressClaudeKey = ($: Engine, s: State, clip: KeyClip): void =>
50 pressKey($, s, s.settings.claudeSwitch ?? s.settings.switch, clip, CLAUDE_GAIN)
51
52// A `chimes/reply.wav` of the user's plays in place of the reply sound that was picked.
53const chime = ($: Engine, s: State, name: Chime): void => {
54 if (!s.settings[name]) return
55 send($, s, name === 'reply' ? ['chimes/reply', `chimes/${s.settings.replySound}`] : [`chimes/${name}`], CHIME_GAIN, s.settings.chimeVolume)
56}
57
58// Types the next waiting character of Claude's text, then waits a typist's uneven moment for the one after.
59const typeNext = ($: Engine, s: State): void => {
60 const char = s.typist.next()
61 if (char === null) {
62 s.typing = null
63 return
64 }
65 pressClaudeKey($, s, clipForChar(char))
66 s.typing = $.clock.after(keyDelay(char, Math.random()), () => typeNext($, s))
67}
68
69const stopTyping = (s: State): void => {
70 s.typist.clear()
71 s.typing?.cancel()
72 s.typing = null
73}
74
75const queueTyping = ($: Engine, s: State, text: string): void => {
76 if (!s.isReady || !s.settings.isOn || !s.settings.claude) return
77 s.typist.push(text)
78 s.typing ??= $.clock.after(keyDelay('', Math.random()), () => typeNext($, s))
79}
80
81// Runs the player for as long as the session lives. A player that cannot start or that stops leaves
82// the session silent, with one toast that says why.
83const runPlayer = async ($: Engine, s: State): Promise<void> => {
84 let complaint = ''
85 try {
86 const argv = ['bun', `${$.plugin.root}/bin/cuesd.ts`, `${$.plugin.root}/sounds`, s.socketPath]
87 for await (const piece of $.process.spawn({ argv })) {
88 if (!('text' in piece)) continue
89 if (piece.stream === 'stderr') complaint = piece.text.trim()
90 else if (piece.text.includes(READY)) s.isReady = true
91 }
92 } catch {
93 complaint = 'cuesd: could not run bun. Install it from bun.sh.'
94 }
95 s.isReady = false
96 if (complaint !== '') await $.ui.toast(`cues: no sound. ${complaint}`)
97}
98
99export const register: Register = on => {
100 const s: State = { settings: DEFAULTS, socketPath: '', isReady: false, typist: new Typist(), typing: null }
101
102 on('session.start', async ($, e, next) => {
103 const started = await next(e)
104 s.settings = restore(await $.store.get(SETTINGS_KEY))
105 await $.command.register({ name: 'cues', description: 'Sounds: on or off, volume, key switch', argumentHint: 'on | off | volume [keys|chimes] 0-100 | <switch> | keys|claude|send|reply|error|chimes on|off' })
106 if (!e.isInteractive) return started
107 const runDir = (await $.env.get('XDG_RUNTIME_DIR')) ?? '/tmp'
108 // The random part keeps a reloaded mod's new player off the path of the old one, which removes its
109 // socket file as it shuts down.
110 s.socketPath = `${runDir}/cues-${(await $.session.id()).slice(0, 8)}-${crypto.randomUUID().slice(0, 8)}.sock`
111 void runPlayer($, s)
112 return started
113 })
114
115 on('prompt.edit', ($, e, next) => {
116 if (s.settings.keys && e.key !== undefined && e.key.ctrl !== true && e.key.meta !== true) pressKey($, s, s.settings.switch, clipForKey(e.key.key), 1)
117 return next(e)
118 })
119
120 on('prompt.submit', ($, e, next) => {
121 if (e.origin.kind === 'composer') {
122 if (s.settings.keys) pressKey($, s, s.settings.switch, 'enter', 1)
123 chime($, s, 'send')
124 }
125 return next(e)
126 })
127
128 on('turn.step', async function* ($, e, next) {
129 for await (const chunk of next(e)) {
130 if (chunk.kind === 'text') queueTyping($, s, chunk.text)
131 yield chunk
132 }
133 })
134
135 on('turn.complete', ($, e, next) => {
136 if (e.agentId === undefined) {
137 stopTyping(s)
138 if (e.reason === 'answer') chime($, s, 'reply')
139 else if (e.reason === 'error' || e.reason === 'refusal') chime($, s, 'error')
140 }
141 return next(e)
142 })
143
144 on('command.run', { command: 'cues' }, async ($, e) => {
145 const out = runCommand(e.args, s.settings, s.isReady)
146 if (out.settings !== undefined) {
147 const hasNewSwitch = out.settings.switch !== s.settings.switch
148 const hasNewReply = out.settings.replySound !== s.settings.replySound
149 const hasNewClaudeSwitch = out.settings.claudeSwitch !== s.settings.claudeSwitch
150 const hasNewKeysVolumeOnly = out.settings.volume !== s.settings.volume && out.settings.chimeVolume === s.settings.chimeVolume
151 s.settings = out.settings
152 await $.store.set(SETTINGS_KEY, out.settings)
153 if (!out.settings.claude || !out.settings.isOn) s.typist.clear()
154 // The change is heard at once: a key of the new switch, the new reply sound, or a chime at the new volume.
155 if (hasNewSwitch || hasNewKeysVolumeOnly) pressKey($, s, s.settings.switch, 'r2', 1)
156 else if (hasNewClaudeSwitch) pressClaudeKey($, s, 'r2')
157 else chime($, s, hasNewReply ? 'reply' : 'send')
158 }
159 return { text: out.text }
160 })
161}
162src/command.ts 83 lines1import { REPLY_SOUNDS, SWITCHES, isReplySound, isSwitch } from './settings'
2import type { Settings, Switch } from './settings'
3
4// What `/cues <args>` answers, and the settings to save when it changed them.
5export type CommandResult = { text: string; settings?: Settings }
6
7// Each sound that turns on and off alone, by the word that names it in the command.
8const SOUNDS = {
9 keys: 'Your key sounds',
10 claude: "Claude's key sounds",
11 send: 'Send chime',
12 reply: 'Reply chime',
13 error: 'Error chime',
14} as const
15type Sound = keyof typeof SOUNDS
16
17const USAGE = [
18 '/cues on | off (or unmute | mute)',
19 '/cues volume 0-100 (keys and chimes at once)',
20 '/cues volume keys | chimes 0-100',
21 '/cues <switch>',
22 '/cues claude <switch> | same (a switch for what Claude writes)',
23 '/cues keys | claude | send | reply | error on | off',
24 '/cues chimes on | off (send, reply and error at once)',
25 '/cues reply bell | notes',
26].join('\n')
27
28const isSound = (word: string): word is Sound => Object.hasOwn(SOUNDS, word)
29const onOff = (isOn: boolean) => (isOn ? 'on' : 'off')
30const percent = (volume: number) => Math.round(volume * 100)
31
32// The two volumes, by the word that names each in the command.
33const VOLUMES = { keys: { field: 'volume', label: 'Keys' }, chimes: { field: 'chimeVolume', label: 'Chimes' } } as const
34const isVolume = (word: string): word is keyof typeof VOLUMES => Object.hasOwn(VOLUMES, word)
35// A volume as typed, 0 to 1, or null when it is not a whole number. Over 100 is 100.
36const parseVolume = (text: string): number | null => (/^\d+$/.test(text) ? Math.min(100, Number(text)) / 100 : null)
37
38const SWITCH_NAMES = `Switches: ${Object.keys(SWITCHES).join(', ')}`
39const ON_WORDS: ReadonlySet<string> = new Set(['on', 'unmute'])
40const OFF_WORDS: ReadonlySet<string> = new Set(['off', 'mute'])
41
42const status = (s: Settings, isPlayerUp: boolean): string => {
43 const state = `${s.isOn ? 'On' : 'Off'} · keys volume ${percent(s.volume)} · chimes volume ${percent(s.chimeVolume)} · ${SWITCHES[s.switch]}`
44 const head = isPlayerUp ? state : `${state} · no sound: the player is not running`
45 const toggles = (Object.keys(SOUNDS) as Sound[]).map(name => `${name} ${onOff(s[name])}`).join(' · ')
46 const groups = `${toggles}\nreply sound: ${s.replySound}\nclaude's switch: ${s.claudeSwitch ?? 'same as yours'}`
47 const switches = (Object.keys(SWITCHES) as Switch[]).map(k => `${k === s.switch ? '▸' : ' '} ${k.padEnd(10)} ${SWITCHES[k]}`)
48 return [head, groups, '', ...switches, '', USAGE].join('\n')
49}
50
51// `isPlayerUp` is whether the sound player answered that it can play; the state line says when it did not.
52export const runCommand = (args: string, s: Settings, isPlayerUp = true): CommandResult => {
53 const [word = '', value = '', third = ''] = args.trim().toLowerCase().split(/\s+/)
54 if (word === '') return { text: status(s, isPlayerUp) }
55 if (ON_WORDS.has(word)) return { text: 'Sounds on.', settings: { ...s, isOn: true } }
56 if (OFF_WORDS.has(word)) return { text: 'Sounds off. /cues on brings them back.', settings: { ...s, isOn: false } }
57 if (word === 'volume' && value === '') {
58 return { text: `Keys volume is ${percent(s.volume)}, chimes volume is ${percent(s.chimeVolume)}. /cues volume keys|chimes 0-100 sets one, /cues volume 0-100 sets both.` }
59 }
60 if (word === 'volume' && isVolume(value)) {
61 const { field, label } = VOLUMES[value]
62 if (third === '') return { text: `${label} volume is ${percent(s[field])}. /cues volume ${value} 0-100 sets it.` }
63 const volume = parseVolume(third)
64 if (volume !== null) return { text: `${label} volume ${percent(volume)}.`, settings: { ...s, [field]: volume } }
65 }
66 const both = word === 'volume' ? parseVolume(value) : null
67 if (both !== null) return { text: `Volume ${percent(both)}.`, settings: { ...s, volume: both, chimeVolume: both } }
68 if (isSwitch(word)) return { text: `Keys now sound like ${SWITCHES[word]}.`, settings: { ...s, switch: word, keys: true, isOn: true } }
69 if (word === 'claude' && isSwitch(value)) {
70 return { text: `Claude's keys now sound like ${SWITCHES[value]}.`, settings: { ...s, claude: true, isOn: true, claudeSwitch: value } }
71 }
72 if (word === 'claude' && value === 'same') return { text: "Claude's keys now sound like yours.", settings: { ...s, claudeSwitch: null } }
73 if (word === 'reply' && isReplySound(value)) {
74 return { text: `Reply chime is now ${REPLY_SOUNDS[value]}.`, settings: { ...s, reply: true, replySound: value } }
75 }
76 if (value === 'on' || value === 'off') {
77 const isOn = value === 'on'
78 if (isSound(word)) return { text: `${SOUNDS[word]} ${value}.`, settings: { ...s, [word]: isOn } }
79 if (word === 'chimes') return { text: `Chimes ${value}.`, settings: { ...s, send: isOn, reply: isOn, error: isOn } }
80 }
81 return { text: `/cues does not know "${args.trim()}".\n\n${USAGE}\n\n${SWITCH_NAMES}` }
82}
83src/keys.ts 38 lines1export type KeyClip = 'r0' | 'r1' | 'r2' | 'r3' | 'r4' | 'space' | 'enter' | 'backspace'
2
3const ROWS = ['1234567890-=', 'qwertyuiop[]\\', "asdfghjkl;'", 'zxcvbnm,./'] as const
4const SHIFTED_DIGITS = '!@#$%^&*()_+'
5const ROW_CLIPS = ['r0', 'r1', 'r2', 'r3'] as const
6// What a switch with no recording of a wide key plays for it.
7const WIDE_FALLBACK = { space: 'r4', enter: 'r2', backspace: 'r2' } as const
8
9const isWide = (clip: KeyClip): clip is keyof typeof WIDE_FALLBACK => Object.hasOwn(WIDE_FALLBACK, clip)
10
11export const clipForChar = (char: string): KeyClip => {
12 if (char === ' ' || char === '\t') return 'space'
13 if (char === '\n' || char === '\r') return 'enter'
14 if (SHIFTED_DIGITS.includes(char)) return 'r0'
15 const row = ROWS.findIndex(keys => keys.includes(char.toLowerCase()))
16 return ROW_CLIPS[row] ?? 'r4'
17}
18
19// `key` is a key name as `prompt.edit` gives it: one character, or a word such as `backspace`.
20export const clipForKey = (key: string): KeyClip => {
21 const name = key.toLowerCase()
22 if (name === 'space') return 'space'
23 if (name === 'return' || name === 'enter') return 'enter'
24 if (name === 'backspace' || name === 'delete') return 'backspace'
25 return name.length === 1 ? clipForChar(name) : 'r4'
26}
27
28// The clips to try for a press, first one that exists wins.
29export const pressClips = (switchName: string, clip: KeyClip): string[] => {
30 const base = `keys/${switchName}/press`
31 return isWide(clip) ? [`${base}/${clip}`, `${base}/${WIDE_FALLBACK[clip]}`] : [`${base}/${clip}`]
32}
33
34export const releaseClips = (switchName: string, clip: KeyClip): string[] => {
35 const base = `keys/${switchName}/release`
36 return isWide(clip) ? [`${base}/${clip}`, `${base}/key`] : [`${base}/key`]
37}
38src/request.ts 23 lines1// The one request the player takes: `GET /play?clips=<a>,<b>&gain=<n>`. It plays the first clip it
2// has. A clip is a sound file's path under the sounds folder, without `.wav`.
3export type PlayRequest = { clips: string[]; gain: number }
4
5const HOST = 'http://cues'
6const MAX_GAIN = 2
7const CLIP = /^[a-z0-9_-]+(\/[a-z0-9_-]+)*$/
8
9export const playUrl = (clips: readonly string[], gain: number): string => `${HOST}/play?clips=${clips.join(',')}&gain=${gain.toFixed(2)}`
10
11// `target` is a request line's path and query. Null for anything that is not a well-formed play request.
12export const parsePlay = (target: string): PlayRequest | null => {
13 const [path, query = ''] = target.split('?', 2)
14 if (path !== '/play') return null
15 const params = new Map(query.split('&').map(pair => pair.split('=', 2) as [string, string | undefined]))
16 const clips = (params.get('clips') ?? '').split(',')
17 if (!clips.every(clip => CLIP.test(clip))) return null
18 const raw = params.get('gain')
19 const gain = raw === undefined ? 1 : Number(raw)
20 if (raw === '' || Number.isNaN(gain)) return null
21 return { clips, gain: Math.min(MAX_GAIN, Math.max(0, gain)) }
22}
23src/settings.ts 74 lines1// The key switches there are recordings of, by folder name under `sounds/keys`.
2export const SWITCHES = {
3 mxblue: 'Cherry MX Blue',
4 mxbrown: 'Cherry MX Brown',
5 mxblack: 'Cherry MX Black',
6 holypanda: 'Holy Panda',
7 topre: 'Topre',
8 cream: 'NovelKeys Cream',
9 alpaca: 'Alpaca',
10 turquoise: 'Turquoise Tealios',
11 blackink: 'Gateron Black Ink',
12 redink: 'Gateron Red Ink',
13 bluealps: 'Blue Alps',
14 boxnavy: 'Box Navy',
15 buckling: 'Buckling Spring',
16} as const
17
18export type Switch = keyof typeof SWITCHES
19
20export const isSwitch = (name: string): name is Switch => Object.hasOwn(SWITCHES, name)
21
22// The sounds the reply chime can be, by clip name under `chimes/`.
23export const REPLY_SOUNDS = { bell: 'the bell', notes: 'the two low notes' } as const
24export type ReplySound = keyof typeof REPLY_SOUNDS
25
26export const isReplySound = (name: string): name is ReplySound => Object.hasOwn(REPLY_SOUNDS, name)
27
28export type Settings = {
29 isOn: boolean
30 // How loud the keys are, and how loud the chimes are, each 0 to 1.
31 volume: number
32 chimeVolume: number
33 switch: Switch
34 // Key sounds for what the user types, and for what Claude writes.
35 keys: boolean
36 claude: boolean
37 // The switch Claude's keys sound like; null is the user's own switch.
38 claudeSwitch: Switch | null
39 // The chimes, by name: a prompt sent, a reply landed, a turn failed.
40 send: boolean
41 reply: boolean
42 error: boolean
43 replySound: ReplySound
44}
45
46export const DEFAULTS: Settings = { isOn: true, volume: 0.7, chimeVolume: 0.7, switch: 'mxbrown', keys: true, claude: false, claudeSwitch: null, send: true, reply: true, error: true, replySound: 'bell' }
47
48// The level a volume setting plays at, both 0 to 1. Loudness is heard roughly as the square of the
49// level, so the setting is squared: each step of the setting then sounds like an even step.
50export const gainFor = (volume: number): number => volume * volume
51
52const flag = (value: unknown, fallback: boolean): boolean => (typeof value === 'boolean' ? value : fallback)
53const level = (value: unknown, fallback: number): number => (typeof value === 'number' && value >= 0 && value <= 1 ? value : fallback)
54
55// Settings as they were saved, each field checked: one that is missing or unusable is the default.
56export const restore = (saved: unknown): Settings => {
57 const s = (typeof saved === 'object' && saved !== null ? saved : {}) as Record<string, unknown>
58 const volume = level(s.volume, DEFAULTS.volume)
59 return {
60 isOn: flag(s.isOn, DEFAULTS.isOn),
61 volume,
62 // Settings saved with one volume for everything keep sounding as they did.
63 chimeVolume: level(s.chimeVolume, volume),
64 switch: typeof s.switch === 'string' && isSwitch(s.switch) ? s.switch : DEFAULTS.switch,
65 keys: flag(s.keys, DEFAULTS.keys),
66 claude: flag(s.claude, DEFAULTS.claude),
67 claudeSwitch: typeof s.claudeSwitch === 'string' && isSwitch(s.claudeSwitch) ? s.claudeSwitch : DEFAULTS.claudeSwitch,
68 send: flag(s.send, DEFAULTS.send),
69 reply: flag(s.reply, DEFAULTS.reply),
70 error: flag(s.error, DEFAULTS.error),
71 replySound: typeof s.replySound === 'string' && isReplySound(s.replySound) ? s.replySound : DEFAULTS.replySound,
72 }
73}
74src/typist.ts 41 lines1const DEFAULT_MAX = 30
2// The middle wait between two keys, and the share of it a wait may be shorter or longer by.
3const KEY_MS = 110
4const KEY_SPREAD = 0.3
5const WORD_PAUSE_MS = 90
6
7// How long to wait, in ms, after typing `typed` before the next key. `roll` is a random number from 0 to 1.
8export const keyDelay = (typed: string, roll: number): number =>
9 Math.round(KEY_MS * (1 - KEY_SPREAD + 2 * KEY_SPREAD * roll)) + (/\s/.test(typed) ? WORD_PAUSE_MS : 0)
10
11// The text Claude has written but that has not been "typed" yet, handed out one keystroke at a time.
12export class Typist {
13 #backlog = ''
14 readonly #max: number
15
16 // Past `max` waiting characters the oldest are dropped, so the typing stays near what is on screen.
17 constructor(max = DEFAULT_MAX) {
18 this.#max = max
19 }
20
21 get isEmpty(): boolean {
22 return this.#backlog === ''
23 }
24
25 push(text: string): void {
26 this.#backlog = (this.#backlog + text).slice(-this.#max)
27 }
28
29 clear(): void {
30 this.#backlog = ''
31 }
32
33 // The next character to type, or null. A run of blanks comes back as its first blank only.
34 next(): string | null {
35 const char = this.#backlog.charAt(0)
36 if (char === '') return null
37 this.#backlog = /\s/.test(char) ? this.#backlog.replace(/^\s+/, '') : this.#backlog.slice(1)
38 return char
39 }
40}
41