SLOPSHOPPER

cues

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.

newcommandtoastpromptprocessnetwork
v0.1.0MITupdated 2026-10-12CoderDayton/vii-mods/plugins/cues
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · cues
› 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 › /cues ⎿ cues: On · keys volume 70 · chimes volume 70 · Cherry MX Brown · no sound: the player is not running ⎿ cues: keys on · claude off · send on · reply on · error on ⎿ cues: reply sound: bell ⎿ cues: claude's switch: same as yours ⎿ cues: ⎿ cues: mxblue Cherry MX Blue ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

cues

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.

Sounds

WhenSound
You press a key in the prompt boxThat key's press, then its release
Claude writes reply textOff 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 promptEnter, and the send chime
Claude's reply is doneThe reply chime
A turn ends in an error or a refusalThe error chime

Shortcuts with Ctrl or Alt, pasted text and sub-agents' replies make no sound. A turn you stop plays no chime.

Needs

  • Linux with PipeWire (pw-cat) or ALSA (aplay). PipeWire is used when both are there.
  • Bun 1.4 or newer, to run the player.

With either missing the mod stays silent and says why in one toast.

Commands

CommandWhat it does
/cuesShows the settings, lists the key switches, and says so when the player is not running
/cues on, /cues offTurns every sound on or off (unmute and mute do the same)
/cues volumeSays what the keys volume and the chimes volume are
/cues volume keys 40, /cues volume chimes 70Sets one of the two volumes, 0 to 100. The scale follows the ear: 50 sounds about half as loud as 100
/cues volume 40Sets both volumes at once
/cues toprePicks a key switch, turns sound on and plays one key of it
/cues keys offTurns your own key sounds off (on turns them back on)
/cues claude onTurns on key sounds for what Claude writes. They are off by default, since Claude writes a lot
/cues send offTurns the send chime off
/cues claude topreGives Claude's keys a switch of their own, and turns them on. /cues claude same goes back to your switch
/cues reply offTurns the reply chime off
/cues reply bell, /cues reply notesPicks the reply chime: a small bell (the default) or two low rising notes
/cues error offTurns the error chime off
/cues chimes offTurns all three chimes off at once

Settings are remembered across sessions.

Key switches

mxblue, mxbrown (the default), mxblack, holypanda, topre, cream, alpaca, turquoise, blackink, redink, bluealps, boxnavy, buckling.

Your own sounds

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.

PathSound
sounds/keys/<switch>/press/r0.wav to r4.wavA key on the number row (r0) down to the bottom row (r4)
sounds/keys/<switch>/press/space.wav, enter.wav, backspace.wavOptional. Without one, the key plays a row sound
sounds/keys/<switch>/release/key.wavAny key coming back up
sounds/keys/<switch>/release/space.wav, enter.wav, backspace.wavOptional
sounds/chimes/send.wav, reply.wav, error.wavOptional. 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.

How it works

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.

Limits

  • Linux only. On macOS and Windows the mod loads and stays silent.
  • The first key after a pause of more than 3 seconds sounds about 45 ms later than the ones that follow.
  • A key held down repeats its sound only as fast as the prompt box reports the repeats.
  • Claude's typing is capped at the newest 30 characters still waiting, so the clicks stay close to the text on screen. It stops when the reply lands.

Credits

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.

Source 6 files
hooks/register.ts 162 lines
1import 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}
162
src/command.ts 83 lines
1import { 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}
83
src/keys.ts 38 lines
1export 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}
38
src/request.ts 23 lines
1// 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}
23
src/settings.ts 74 lines
1// 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}
74
src/typist.ts 41 lines
1const 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