SLOPSHOPPER

chime

Plays a sound when Claude finishes, needs your input, or hits an error. Ten original sounds, your own files, volume, quiet hours and snooze.

newpaneguardcommandtoaststatus
v0.1.0MITupdated 2026-10-09oguz-hd/claude-code-chime/plugins/chime
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · chime
│ ┃ Chime ✕ › fix the failing auth╭────────────────────────────────────────────╮ │ ┃ Chime │ chime │ │ ┃ Sounds for when Claude finishes or needs ⏺ Read(src/auth.ts) │ Chime could not play a sound: sound file │ │ ┃ you. ⎿ Read 6 lines │ not found: /plugins/chime/sounds/tada.wav │ │ ┃ ⏺ Update(src/auth.ts) ╰────────────────────────────────────────────╯ │ ┃ 1: Done Ta-da ⎿ Added 2 lines, removed 1 line │ ┃ 2: Waiting Doorbell ⏺ Bash(bun test) │ ┃ 3: Error Sad trombone ⎿ 3 pass, 1 fail │ ┃ 4: Idle off │ ┃ 5: Volume 60 ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ 6: Chime on │ ┃ 7: Snooze off ✻ Worked for 42s · done 4:20 PM │ ┃ 8: Test play every sound │ ┃ › /chime │ ┃ ↑↓ move · Enter open · 1-8 jump · Esc close │ ┃ · /chime help for more │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Chime
Chime Sounds for when Claude finishes or needs you. 1: Done Ta-da 2: Waiting Doorbell 3: Error Sad trombone 4: Idle off 5: Volume 60 6: Chime on 7: Snooze off 8: Test play every sound ↑↓ move · Enter open · 1-8 jump · Esc close · /chime help for more
README

Chime for Claude Code

Chime plays a sound when Claude Code finishes a turn, stops to wait for you, or hits an error. Start a long task, switch to another window, and come back when you hear it.

  • Ten original sounds (ta-da, coin, level up, doorbell, microwave, boing, rubber duck, robot, slide whistle, sad trombone), synthesized from scratch and free to use (CC0).
  • Your own sound files for any event.
  • A picker pane to try each sound and keep it, plus a /chime command for everything else.
  • Volume, snooze, quiet hours, and a minimum turn length so quick replies stay silent.
  • Windows, macOS and Linux, with nothing to install beyond Claude Code.

Install

Chime is a Claude Code mod: a plugin built on function hooks. In Claude Code, run:

/plugin marketplace add oguz-hd/claude-code-chime
/plugin install chime@claude-code-chime

Restart Claude Code. Type /chime test to hear the current sounds.

Mods use Claude Code's function hooks, which are in early access and may change between releases. If /chime is not listed after a restart, update Claude Code (claude update) and check /plugin for an error next to Chime.

When it plays

EventDefault soundPlays when
DoneTa-daClaude finishes and hands the turn back to you
WaitingDoorbellClaude needs a permission, asks you a question (AskUserQuestion), or wants a plan approved
ErrorSad tromboneThe turn ends on an API error, such as a rate limit or an overloaded server
IdleoffThe prompt has sat unanswered for a while (Claude Code's idle reminder)

Chime stays silent:

  • after a turn that a Stop hook sent back to work (it waits for the real end of the turn);
  • for subagents (only the main conversation counts);
  • in non-interactive runs (claude -p, the SDK);
  • in sessions reached over SSH, where the sound would play on the remote machine (turn it on with /chime ssh on);
  • while snoozed, during quiet hours, or when turned off;
  • when two events arrive within 1.5 seconds of each other, which plays one sound instead of two.

Changing sounds

Type /chime to open the picker. It works from the keyboard like Claude Code's own menus:

KeyDoes
↑ ↓move between rows
Enteropen a row, or keep the highlighted choice
1–9, 0jump straight to a numbered row
bback to the main screen
Escclose the picker

In a sound list, resting on a sound plays it, so you can browse by ear and press Enter on the one you like. The volume list does the same at each level.

Chime
Sounds for when Claude finishes or needs you.

1: Done      Ta-da
2: Waiting   Doorbell
3: Error     Sad trombone
4: Idle      off
5: Volume    60
6: Chime     on
7: Snooze    off
8: Test      play every sound

↑↓ move · Enter open · 1-8 jump · Esc close · /chime help for more

Or use the command directly:

/chime done coin                  bundled sound
/chime waiting ~/sounds/ping.wav  your own file
/chime error off                  silence one event
/chime volume 40                  0-100
/chime snooze 45m                 also 2h, 1h30m, off
/chime quiet 22:00-08:00          no sounds at night (local time); off to clear
/chime min 20                     "done" only after turns of 20 s or more
/chime test                       play every event's sound
/chime status                     show the settings
/chime help                       everything else

Settings are kept per user across sessions and projects.

Your own sounds

Give an absolute path or one starting with ~/.

  • Windows plays .wav files only (8-, 16-, 24- or 32-bit PCM, or 32-bit float).
  • macOS plays anything afplay does: .wav, .mp3, .m4a, .aiff and more.
  • Linux plays what your player supports; .wav works everywhere.

Short sounds (under two seconds) work best.

How it plays sound

PlatformPlayerVolume
WindowsPowerShell and System.Media.SoundPlayer, part of every WindowsChime scales the samples itself and caches the result in %TEMP%\claude-chime
macOSafplayafplay -v
Linuxthe first that works of pw-play, paplay, ffplay, aplaythe player's own volume flag (aplay has none)
WSLthe Linux players (WSLg provides PulseAudio), else Windows through powershell.exeas above

If no player works, Chime shows the reason once as a notice and keeps quiet.

Troubleshooting

  • No sound and no notice. Check /chime status: Chime may be off, snoozed, in quiet hours or in an SSH session. Then run /chime test.
  • "no working audio player" on Linux. Install PipeWire (pw-play) or PulseAudio utilities (paplay), for example sudo apt install pulseaudio-utils.
  • "Windows plays .wav files only". Convert the file, for example ffmpeg -i ping.mp3 ping.wav.
  • Sounds play twice. You probably still have a Stop or Notification sound hook in ~/.claude/settings.json from an earlier setup. Remove it; Chime covers both.
  • Every quick reply chimes. Use /chime min 15 so only longer turns play the done sound.

Development

plugins/chime/
  .claude-plugin/plugin.json   manifest
  hooks/hooks.json             names the hooks module
  hooks/register.tsx           hooks, /chime command, picker pane, players
  hooks/settings.ts            sound catalog, defaults, parsing
  hooks/wav.ts                 WAV scaling and other pure helpers
  types/index.d.ts             the plugin's state contract
  sounds/*.wav                 the bundled sounds
tools/render_sounds.py         renders sounds/*.wav (numpy)

Load your working copy into one session with claude --plugin-dir plugins/chime, or let Claude Code hot-reload it as a mod.

Check your changes:

claude plugin validate plugins/chime   # what the engine will load and refuse
claude plugin validate .               # the marketplace manifest

To type-check, run /plugin-types plugins/chime/.claude/types in Claude Code once, then npx -p typescript tsc -p plugins/chime.

To change or add a sound, edit tools/render_sounds.py, then pip install numpy && python tools/render_sounds.py. Add the new id to BUNDLED in hooks/settings.ts.

License

Code: MIT. Sounds: CC0 1.0 (public domain). See LICENSE.

Source 4 files
hooks/register.tsx 709 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { ChimeEvent, ChimeSettings, ChimeSound } from '../types'
5import {
6  BUNDLED,
7  DEFAULTS,
8  EVENTS,
9  EVENT_INFO,
10  clampVolume,
11  describeDuration,
12  isBundled,
13  isFilePath,
14  isInQuietHours,
15  normalize,
16  parseDuration,
17  parseQuietHours,
18  soundLabel,
19} from './settings'
20import { POWERSHELL, PS_PLAY, PS_WRITE_AND_PLAY, fnv1a, fromBase64, posixPlayers, scaleWav, toBase64 } from './wav'
21
22type Engine = EngineInterface
23type Platform = 'windows' | 'mac' | 'linux' | 'wsl'
24type PlayResult = { isPlayed: true } | { isPlayed: false; reason: string }
25
26const PANE = 'chime'
27const COMMAND = 'chime'
28/** Two announcements closer than this are one: a permission prompt can raise
29 *  both a tool hook and a Notification, and a turn can end with both. */
30const DEBOUNCE_MS = 1500
31
32const settingsAtom = atom({ plugin: 'chime', key: 'settings' } as const, DEFAULTS)
33const snoozeAtom = atom({ plugin: 'chime', key: 'snoozeUntil' } as const, 0)
34/** The picker's screen: `main`, an event's sound list, `volume` or `snooze`. */
35const screenAtom = atom({ plugin: 'chime', key: 'screen' } as const, 'main')
36
37const VOLUME_STEPS = [10, 20, 30, 40, 50, 60, 70, 80, 90, 100] as const
38/** How long the ring must rest on a sound before it plays, so arrowing past is quiet. */
39const PREVIEW_DELAY_MS = 250
40
41// Module state: a hot reload resets these, which is harmless.
42let isInteractive = true
43let turnStartedAt = 0
44let lastPlayedAt = 0
45const reportedFailures = new Set<string>()
46let previewTimer: Timer | undefined
47let lastPreview = { sound: '', volume: -1, at: 0 }
48
49// ------------------------------------------------------------------ settings
50
51async function saveSettings($: Engine, change: (s: ChimeSettings) => ChimeSettings): Promise<ChimeSettings> {
52  const next = await update($, settingsAtom, s => normalize(change(s)))
53  await $.store.set('settings', next)
54  await refreshStatus($)
55  return next
56}
57
58async function setSnooze($: Engine, until: number): Promise<void> {
59  await update($, snoozeAtom, () => until)
60  await $.store.set('snoozeUntil', until)
61  await refreshStatus($)
62  if (until > 0) {
63    const left = until - (await $.clock.now())
64    if (left > 0) $.clock.after(left + 500, () => void refreshStatus($))
65  }
66}
67
68const clockTime = (ms: number) => new Date(ms).toTimeString().slice(0, 5)
69
70/** The status line says something only when Chime is not simply on. */
71async function refreshStatus($: Engine): Promise<void> {
72  const s = await read($, settingsAtom)
73  const snoozeUntil = await read($, snoozeAtom)
74  const now = await $.clock.now()
75  if (!s.isEnabled) $.ui.status('chime off')
76  else if (snoozeUntil > now) $.ui.status(`chime snoozed until ${clockTime(snoozeUntil)}`)
77  else if (isInQuietHours(s.quietHours, new Date(now))) $.ui.status('chime quiet hours')
78  else $.ui.status(undefined)
79}
80
81// ------------------------------------------------------------------ playing
82
83/** Plays the sound set for `event`, unless something says to stay silent. */
84async function announce($: Engine, event: ChimeEvent): Promise<void> {
85  const s = await read($, settingsAtom)
86  const now = await $.clock.now()
87  if (!isInteractive || !s.isEnabled) return
88  if ((await read($, snoozeAtom)) > now) return
89  if (isInQuietHours(s.quietHours, new Date(now))) return
90  if (now - lastPlayedAt < DEBOUNCE_MS) return
91  if (!s.isPlayingOverSsh && (await isOverSsh($))) return
92  lastPlayedAt = now
93  await playSound($, s.sounds[event], s.volume)
94}
95
96async function playSound($: Engine, sound: string, volume: number): Promise<void> {
97  const path = await resolveSound($, sound)
98  if (!path) return
99  const result = await play($, path, volume)
100  if (!result.isPlayed && !reportedFailures.has(result.reason)) {
101    reportedFailures.add(result.reason)
102    $.ui.toast(`Chime could not play a sound: ${result.reason}`)
103  }
104}
105
106/** Fire and forget: a hook never waits for a sound to finish. */
107const later = (work: Promise<unknown>) => void work.catch(() => undefined)
108
109// ------------------------------------------------------------------ platform and players
110
111let platform: Promise<Platform> | undefined
112
113function detectPlatform($: Engine): Promise<Platform> {
114  platform ??= (async (): Promise<Platform> => {
115    if ((await $.env.get('OS')) === 'Windows_NT') return 'windows'
116    if ((await $.env.get('WSL_DISTRO_NAME')) !== undefined) return 'wsl'
117    if (await $.fs.exists('/usr/bin/afplay')) return 'mac'
118    return 'linux'
119  })()
120  return platform
121}
122
123/** True when the session was reached over SSH, so a sound would play on the remote machine. */
124async function isOverSsh($: Engine): Promise<boolean> {
125  return (
126    (await $.env.get('SSH_CONNECTION')) !== undefined ||
127    (await $.env.get('SSH_TTY')) !== undefined ||
128    (await $.env.get('SSH_CLIENT')) !== undefined
129  )
130}
131
132async function homeDir($: Engine): Promise<string> {
133  return (await $.env.get('HOME')) ?? (await $.env.get('USERPROFILE')) ?? ''
134}
135
136async function tempDir($: Engine): Promise<string> {
137  return (await $.env.get('TEMP')) ?? (await $.env.get('TMP')) ?? (await $.env.get('TMPDIR')) ?? '/tmp'
138}
139
140/** The file a sound setting points at, or null for `off`. */
141async function resolveSound($: Engine, sound: ChimeSound): Promise<string | null> {
142  if (sound === 'off') return null
143  if (isBundled(sound)) return `${$.plugin.root}/sounds/${sound}.wav`
144  if (/^~[\\/]/.test(sound)) return `${await homeDir($)}${sound.slice(1)}`
145  return sound
146}
147
148
149/**
150 * Plays `path` at `volume` (0-100) and resolves once the sound has finished.
151 * Never rejects: a failure comes back as `{ isPlayed: false, reason }`.
152 */
153async function play($: Engine, path: string, volume: number): Promise<PlayResult> {
154  const gain = Math.min(1, Math.max(0, volume / 100))
155  if (gain === 0) return { isPlayed: true }
156  try {
157    if (!(await $.fs.exists(path))) return { isPlayed: false, reason: `sound file not found: ${path}` }
158    switch (await detectPlatform($)) {
159      case 'windows':
160        return await playOnWindows($, path, gain)
161      case 'mac':
162        return await runPlayer($, ['afplay', '-v', gain.toFixed(2), path])
163      case 'linux':
164        return await playOnPosix($, path, gain)
165      case 'wsl': {
166        const posix = await playOnPosix($, path, gain)
167        return posix.isPlayed ? posix : await playOnWindowsFromWsl($, path)
168      }
169    }
170  } catch (error) {
171    return { isPlayed: false, reason: error instanceof Error ? error.message : String(error) }
172  }
173}
174
175async function runPlayer($: Engine, argv: string[], stdin?: string): Promise<PlayResult> {
176  const { exitCode, stderr } = await $.process.run(argv, { stdin, timeoutMs: 20_000 })
177  if (exitCode === 0) return { isPlayed: true }
178  const detail = stderr.trim().split(/\r?\n/)[0] ?? ''
179  return { isPlayed: false, reason: `${argv[0]} exited with ${exitCode}${detail ? `: ${detail}` : ''}` }
180}
181
182async function playOnWindows($: Engine, path: string, gain: number): Promise<PlayResult> {
183  if (!/\.wav$/i.test(path)) {
184    return { isPlayed: false, reason: `Windows plays .wav files only; convert ${path} to .wav` }
185  }
186  if (gain >= 0.999) return runPlayer($, [...POWERSHELL, PS_PLAY, path])
187
188  const stat = await $.fs.stat(path)
189  const key = fnv1a(`${path}|${stat.size}|${stat.mtimeMs}|${gain.toFixed(3)}`)
190  const cached = `${await tempDir($)}\\claude-chime\\${key}.wav`
191  if (await $.fs.exists(cached)) return runPlayer($, [...POWERSHELL, PS_PLAY, cached])
192
193  const { base64 } = await $.fs.read(path, { as: 'bytes' })
194  const scaled = scaleWav(fromBase64(base64), gain)
195  if (!scaled) return runPlayer($, [...POWERSHELL, PS_PLAY, path]) // a WAV encoding we cannot scale
196  return runPlayer($, [...POWERSHELL, PS_WRITE_AND_PLAY, cached], toBase64(scaled))
197}
198
199let posixPlayer: number | undefined
200
201async function playOnPosix($: Engine, path: string, gain: number): Promise<PlayResult> {
202  const players = posixPlayers(path, gain)
203  const order = posixPlayer === undefined ? players.map((_, i) => i) : [posixPlayer]
204  const failures: string[] = []
205  for (const i of order) {
206    const argv = players[i]
207    if (!argv) continue
208    try {
209      const result = await runPlayer($, argv)
210      if (result.isPlayed) {
211        posixPlayer = i
212        return result
213      }
214      failures.push(result.reason)
215    } catch {
216      failures.push(`${argv[0]} not found`)
217    }
218  }
219  if (posixPlayer !== undefined) {
220    posixPlayer = undefined // the remembered player broke; search again next time
221    return playOnPosix($, path, gain)
222  }
223  return {
224    isPlayed: false,
225    reason: `no working audio player (tried pw-play, paplay, ffplay, aplay): ${failures.join('; ')}`,
226  }
227}
228
229async function playOnWindowsFromWsl($: Engine, path: string): Promise<PlayResult> {
230  if (!/\.wav$/i.test(path)) return { isPlayed: false, reason: 'no Linux audio player, and Windows plays .wav only' }
231  const { exitCode, stdout } = await $.process.run(['wslpath', '-w', path])
232  if (exitCode !== 0) return { isPlayed: false, reason: 'wslpath could not translate the sound path' }
233  return runPlayer($, [...POWERSHELL, PS_PLAY, stdout.trim()])
234}
235
236// ------------------------------------------------------------------ picker helpers
237
238const pad = (text: string, width: number) => (text.length >= width ? `${text} ` : text.padEnd(width))
239
240const nearestStep = (volume: number) => Math.min(100, Math.max(10, Math.round(volume / 10) * 10))
241
242/** The ring's landing key in a sound list, for the sound an event has now. */
243const focusKeyForSound = (sound: string) =>
244  sound === 'off' ? 'sound:off' : isBundled(sound) ? `sound:${sound}` : `sound:${BUNDLED[0]?.id ?? 'tada'}`
245
246/** What the volume screen plays: the done sound, or the first event's that is not silent. */
247function previewSoundFor(s: ChimeSettings): string {
248  const sound = [s.sounds.done, s.sounds.waiting, s.sounds.error, s.sounds.idle].find(x => x !== 'off')
249  return sound ?? 'tada'
250}
251
252function cancelPreview(): void {
253  previewTimer?.cancel()
254  previewTimer = undefined
255}
256
257function schedulePreview($: Engine, key: string): void {
258  cancelPreview()
259  previewTimer = $.clock.after(PREVIEW_DELAY_MS, () => later(runPreview($, key)))
260}
261
262async function runPreview($: Engine, key: string): Promise<void> {
263  const s = await read($, settingsAtom)
264  let sound: string
265  let volume = s.volume
266  if (key.startsWith('sound:')) sound = key.slice('sound:'.length)
267  else if (key.startsWith('vol:')) {
268    sound = previewSoundFor(s)
269    volume = Number(key.slice('vol:'.length))
270  } else return
271  if (sound === 'off') return
272  lastPreview = { sound, volume, at: Date.now() }
273  await playSound($, sound, volume)
274}
275
276/** True when the browse preview just played this, so Enter need not play it again. */
277const wasJustPreviewed = (sound: string, volume: number) =>
278  lastPreview.sound === sound && lastPreview.volume === volume && Date.now() - lastPreview.at < 3000
279
280async function testSounds($: Engine, only?: ChimeEvent): Promise<void> {
281  const s = await read($, settingsAtom)
282  for (const event of only ? [only] : EVENTS) {
283    if (s.sounds[event] === 'off') continue
284    await playSound($, s.sounds[event], s.volume)
285    await $.clock.sleep(400)
286  }
287}
288
289// ------------------------------------------------------------------ hooks
290
291export const register: Register = on => {
292  on('session.start', async ($, e, next) => {
293    isInteractive = e.isInteractive
294    const stored = await $.store.get('settings')
295    await update($, settingsAtom, () => normalize(stored))
296    const snoozeUntil = Number((await $.store.get('snoozeUntil')) ?? 0)
297    await update($, snoozeAtom, () => (Number.isFinite(snoozeUntil) ? snoozeUntil : 0))
298    await $.command.register({
299      name: COMMAND,
300      description: 'Sounds for when Claude finishes or needs you: pick sounds, volume, snooze',
301      argumentHint: '[status|test|volume N|done SOUND|on|off|snooze 1h|help]',
302      immediate: true,
303    })
304    await refreshStatus($)
305    return next(e)
306  })
307
308  // The turn's start, for the minimum-length rule. The earliest start since
309  // the last stop counts, so a subagent's turn starting mid-way changes nothing.
310  on('turn.start', async ($, e, next) => {
311    if (turnStartedAt === 0) turnStartedAt = await $.clock.now()
312    return next(e)
313  })
314
315  // Done: the main agent stopped and no Stop hook sent it back to work.
316  on('classic.Stop', async ($, e, next) => {
317    const result = await next(e)
318    if (result.block !== undefined) return result
319
320    const startedAt = turnStartedAt
321    turnStartedAt = 0
322    const s = await read($, settingsAtom)
323    const seconds = startedAt ? ((await $.clock.now()) - startedAt) / 1000 : Infinity
324    if (seconds >= s.minTurnSeconds) later(announce($, 'done'))
325    return result
326  })
327
328  // Error: the turn ended on an API error instead of a reply.
329  on('classic.StopFailure', async ($, e, next) => {
330    turnStartedAt = 0
331    later(announce($, 'error'))
332    return next(e)
333  })
334
335  // Waiting / idle: Claude Code's own notifications.
336  on('classic.Notification', async ($, e, next) => {
337    const type = e.notification_type
338    if (type === 'idle_prompt') later(announce($, 'idle'))
339    else if (type !== 'auth_success') later(announce($, 'waiting'))
340    return next(e)
341  })
342
343  // Waiting, also for the dialogs that raise no notification of their own.
344  on('tool.call', { tool: 'AskUserQuestion' }, async ($, e, next) => {
345    later(announce($, 'waiting'))
346    return next(e)
347  })
348  on('tool.call', { tool: 'ExitPlanMode' }, async ($, e, next) => {
349    later(announce($, 'waiting'))
350    return next(e)
351  })
352
353  on('command.run', { command: COMMAND }, async ($, e) => {
354    const text = await runCommand($, e.args)
355    return text ? { text } : {}
356  })
357
358  // Moving the ring onto a sound or a volume previews it, as a theme picker
359  // previews themes: the person browses by ear and presses Enter to keep one.
360  on('ui.focus', { component: 'Pane', requestId: PANE }, async ($, e, next) => {
361    const moved = await next(e)
362    if (moved.deny === undefined && e.origin.kind === 'person' && e.element) {
363      schedulePreview($, e.element)
364    }
365    return moved
366  })
367
368  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
369    const { Box, Button, Text } = $.ui.resolve(e)
370    const s = await read($, settingsAtom)
371    const screen = await read($, screenAtom)
372    const snoozeUntil = await read($, snoozeAtom)
373    const now = await $.clock.now()
374    const isSnoozed = snoozeUntil > now
375
376    /** Switch screens and put the ring on `key` in the new one. */
377    const go = (to: string, key: string) => async () => {
378      cancelPreview()
379      await update($, screenAtom, () => to)
380      // Best effort: the ring cannot move while the pane does not hold the keys.
381      await $.ui.focus({ requestId: PANE, key }).catch(() => undefined)
382    }
383    const footer = (keys: string) => (
384      <Box marginTop={1}>
385        <Text dimColor>{keys}</Text>
386      </Box>
387    )
388
389    // ---- a sound list for one event
390    if (EVENTS.includes(screen as ChimeEvent)) {
391      const event = screen as ChimeEvent
392      const current = s.sounds[event]
393      const keep = (sound: string) => async () => {
394        cancelPreview()
395        await saveSettings($, x => ({ ...x, sounds: { ...x.sounds, [event]: sound } }))
396        if (!wasJustPreviewed(sound, s.volume)) later(playSound($, sound, s.volume))
397        await go('main', `event:${event}`)()
398      }
399      return (
400        <Box flexDirection="column">
401          <Text bold>{EVENT_INFO[event].label} sound</Text>
402          <Text dimColor>Plays when {EVENT_INFO[event].when}.</Text>
403          <Box flexDirection="column" marginTop={1}>
404            {BUNDLED.map((sound, i) => (
405              <Button
406                key={`sound:${sound.id}`}
407                plain
408                hotkey={String((i + 1) % 10)}
409                autoFocus={current === sound.id ? true : undefined}
410                label={`${pad(sound.label + (current === sound.id ? ' ✓' : ''), 16)}${sound.note}`}
411                onPress={keep(sound.id)}
412              />
413            ))}
414            <Button
415              key="sound:off"
416              plain
417              hotkey="s"
418              label={`${pad('Silent' + (current === 'off' ? ' ✓' : ''), 16)}no sound for this event`}
419              onPress={keep('off')}
420            />
421          </Box>
422          {isFilePath(current) && (
423            <Box marginTop={1}>
424              <Text>Using your file: {current}</Text>
425            </Box>
426          )}
427          <Box marginTop={1} flexDirection="column">
428            <Button key="back" plain hotkey="b" label="Back" onPress={go('main', `event:${event}`)} />
429          </Box>
430          {footer(`↑↓ listen · Enter keep · b back · Esc close · own file: /chime ${event} ~/sound.wav`)}
431        </Box>
432      )
433    }
434
435    // ---- volume
436    if (screen === 'volume') {
437      const keep = (volume: number) => async () => {
438        cancelPreview()
439        const saved = await saveSettings($, x => ({ ...x, volume }))
440        const sample = previewSoundFor(saved)
441        if (!wasJustPreviewed(sample, volume)) later(playSound($, sample, volume))
442        await go('main', 'open:volume')()
443      }
444      return (
445        <Box flexDirection="column">
446          <Text bold>Volume</Text>
447          <Text dimColor>Move to hear each level; your done sound plays at it.</Text>
448          <Box flexDirection="column" marginTop={1}>
449            {VOLUME_STEPS.map((v, i) => (
450              <Button
451                key={`vol:${v}`}
452                plain
453                hotkey={String((i + 1) % 10)}
454                autoFocus={nearestStep(s.volume) === v ? true : undefined}
455                label={`${pad(String(v), 5)}${'█'.repeat(v / 10)}${'░'.repeat(10 - v / 10)}${s.volume === v ? '  ✓' : ''}`}
456                onPress={keep(v)}
457              />
458            ))}
459          </Box>
460          <Box marginTop={1} flexDirection="column">
461            <Button key="back" plain hotkey="b" label="Back" onPress={go('main', 'open:volume')} />
462          </Box>
463          {footer('↑↓ listen · Enter keep · b back · Esc close')}
464        </Box>
465      )
466    }
467
468    // ---- snooze
469    if (screen === 'snooze') {
470      const choose = (until: () => number) => async () => {
471        await setSnooze($, until())
472        await go('main', 'open:snooze')()
473      }
474      const tomorrowAt8 = () => {
475        const d = new Date(now)
476        d.setDate(d.getDate() + 1)
477        d.setHours(8, 0, 0, 0)
478        return d.getTime()
479      }
480      const options: { key: string; label: string; until: () => number }[] = [
481        { key: 'snooze:15', label: 'For 15 minutes', until: () => now + 15 * 60_000 },
482        { key: 'snooze:30', label: 'For 30 minutes', until: () => now + 30 * 60_000 },
483        { key: 'snooze:60', label: 'For 1 hour', until: () => now + 60 * 60_000 },
484        { key: 'snooze:120', label: 'For 2 hours', until: () => now + 120 * 60_000 },
485        { key: 'snooze:tomorrow', label: 'Until tomorrow 08:00', until: tomorrowAt8 },
486      ]
487      return (
488        <Box flexDirection="column">
489          <Text bold>Snooze</Text>
490          <Text dimColor>{isSnoozed ? `Silent until ${clockTime(snoozeUntil)}.` : 'Stay silent for a while.'}</Text>
491          <Box flexDirection="column" marginTop={1}>
492            {options.map((o, i) => (
493              <Button
494                key={o.key}
495                plain
496                hotkey={String(i + 1)}
497                autoFocus={i === 2 ? true : undefined}
498                label={o.label}
499                onPress={choose(o.until)}
500              />
501            ))}
502            {isSnoozed && <Button key="snooze:off" plain hotkey="w" label="Wake up now" onPress={choose(() => 0)} />}
503          </Box>
504          <Box marginTop={1} flexDirection="column">
505            <Button key="back" plain hotkey="b" label="Back" onPress={go('main', 'open:snooze')} />
506          </Box>
507          {footer('↑↓ move · Enter choose · b back · Esc close')}
508        </Box>
509      )
510    }
511
512    // ---- main
513    const notes: string[] = []
514    if (s.quietHours) notes.push(`Quiet hours ${s.quietHours}`)
515    if (s.minTurnSeconds) notes.push(`"Done" after turns of ${s.minTurnSeconds} s or more`)
516    if (!s.isPlayingOverSsh && (await isOverSsh($))) notes.push('SSH session: silent (/chime ssh on)')
517    return (
518      <Box flexDirection="column">
519        <Text bold>Chime</Text>
520        <Text dimColor>Sounds for when Claude finishes or needs you.</Text>
521        <Box flexDirection="column" marginTop={1}>
522          {EVENTS.map((event, i) => (
523            <Button
524              key={`event:${event}`}
525              plain
526              hotkey={String(i + 1)}
527              autoFocus={i === 0 ? true : undefined}
528              label={`${pad(EVENT_INFO[event].label, 10)}${soundLabel(s.sounds[event])}`}
529              onPress={go(event, focusKeyForSound(s.sounds[event]))}
530            />
531          ))}
532          <Button
533            key="open:volume"
534            plain
535            hotkey="5"
536            label={`${pad('Volume', 10)}${s.volume}`}
537            onPress={go('volume', `vol:${nearestStep(s.volume)}`)}
538          />
539          <Button
540            key="toggle"
541            plain
542            hotkey="6"
543            label={`${pad('Chime', 10)}${s.isEnabled ? 'on' : 'off'}`}
544            onPress={() => saveSettings($, x => ({ ...x, isEnabled: !x.isEnabled }))}
545          />
546          <Button
547            key="open:snooze"
548            plain
549            hotkey="7"
550            label={`${pad('Snooze', 10)}${isSnoozed ? `until ${clockTime(snoozeUntil)}` : 'off'}`}
551            onPress={go('snooze', 'snooze:60')}
552          />
553          <Button
554            key="test"
555            plain
556            hotkey="8"
557            label={`${pad('Test', 10)}play every sound`}
558            onPress={() => later(testSounds($))}
559          />
560        </Box>
561        {notes.length > 0 && (
562          <Box marginTop={1}>
563            <Text dimColor>{notes.join(' · ')}</Text>
564          </Box>
565        )}
566        {footer('↑↓ move · Enter open · 1-8 jump · Esc close · /chime help for more')}
567      </Box>
568    )
569  })
570}
571
572// ------------------------------------------------------------------ /chime
573
574const HELP = `Chime plays a sound when Claude finishes, needs you, or fails.
575
576  /chime                      open the sound picker
577  /chime status               show the settings
578  /chime test [event]         play one event's sound, or all of them
579  /chime list                 list the bundled sounds
580  /chime play SOUND           preview a sound
581  /chime EVENT SOUND          set a sound: EVENT is done, waiting, error or idle;
582                              SOUND is a bundled name, off, or a file path
583  /chime volume 0-100         set the volume
584  /chime on | off             turn Chime on or off
585  /chime snooze 30m | 2h | off   stay silent for a while
586  /chime quiet 22:00-08:00 | off   no sounds during these hours (local time)
587  /chime min SECONDS          only chime "done" after turns at least this long
588  /chime ssh on | off         also play in sessions reached over SSH
589  /chime reset                back to the defaults`
590
591async function runCommand($: Engine, args: string): Promise<string> {
592  const [verb = '', ...rest] = args.trim().split(/\s+/).filter(Boolean)
593  const value = args.trim().slice(verb.length).trim()
594  const word = verb.toLowerCase()
595
596  if (word === '') {
597    cancelPreview()
598    await update($, screenAtom, () => 'main')
599    await $.ui.open({ id: PANE, title: 'Chime', focus: true, closeOnEscape: true, holdToasts: true, rows: 18 })
600    return ''
601  }
602  if (word === 'help') return HELP
603  if (word === 'status') return statusText($)
604
605  if (word === 'list') {
606    return ['Bundled sounds:', ...BUNDLED.map(b => `  ${b.id.padEnd(14)} ${b.note}`), '  off            silence'].join('\n')
607  }
608
609  if (word === 'play') {
610    if (!isBundled(value) && !isFilePath(value)) return `Unknown sound "${value}". Try /chime list.`
611    const s = await read($, settingsAtom)
612    later(playSound($, value, s.volume))
613    return `Playing ${soundLabel(value)} at volume ${s.volume}.`
614  }
615
616  if (word === 'test') {
617    const s = await read($, settingsAtom)
618    const only = EVENTS.includes(value as ChimeEvent) ? (value as ChimeEvent) : undefined
619    const events = only ? [only] : EVENTS
620    later(testSounds($, only))
621    return events.map(ev => `${EVENT_INFO[ev].label}: ${soundLabel(s.sounds[ev])}`).join(', ')
622  }
623
624  if (EVENTS.includes(word as ChimeEvent)) {
625    const event = word as ChimeEvent
626    if (!value) return `${EVENT_INFO[event].label} plays ${soundLabel((await read($, settingsAtom)).sounds[event])}.`
627    const sound = isBundled(value.toLowerCase()) || value.toLowerCase() === 'off' ? value.toLowerCase() : value
628    if (sound !== 'off' && !isBundled(sound)) {
629      if (!isFilePath(sound)) return `Unknown sound "${value}". Use a name from /chime list, off, or an absolute file path.`
630      const path = await resolveSound($, sound)
631      if (!path || !(await $.fs.exists(path))) return `No file at ${sound}.`
632    }
633    const s = await saveSettings($, x => ({ ...x, sounds: { ...x.sounds, [event]: sound } }))
634    later(playSound($, sound, s.volume))
635    return `${EVENT_INFO[event].label} now plays ${soundLabel(sound)}.`
636  }
637
638  if (word === 'volume' || word === 'vol') {
639    const n = Number(value)
640    if (!value || !Number.isFinite(n)) return `Volume is ${(await read($, settingsAtom)).volume}. Set it with /chime volume 0-100.`
641    const s = await saveSettings($, x => ({ ...x, volume: clampVolume(n) }))
642    later(playSound($, s.sounds.done === 'off' ? 'tada' : s.sounds.done, s.volume))
643    return `Volume set to ${s.volume}.`
644  }
645
646  if (word === 'on' || word === 'off') {
647    await saveSettings($, x => ({ ...x, isEnabled: word === 'on' }))
648    return word === 'on' ? 'Chime is on.' : 'Chime is off. Turn it back on with /chime on.'
649  }
650
651  if (word === 'snooze') {
652    if (value === 'off' || value === '0') {
653      await setSnooze($, 0)
654      return 'Snooze cleared.'
655    }
656    const ms = parseDuration(value || '1h')
657    if (!ms) return 'Give a duration such as 30m, 2h or 1h30m.'
658    await setSnooze($, (await $.clock.now()) + ms)
659    return `Snoozed for ${describeDuration(ms)}.`
660  }
661
662  if (word === 'quiet') {
663    if (value === 'off' || value === '') {
664      await saveSettings($, x => ({ ...x, quietHours: '' }))
665      return 'Quiet hours cleared.'
666    }
667    if (!parseQuietHours(value)) return 'Give the hours as HH:MM-HH:MM, for example 22:00-08:00.'
668    await saveSettings($, x => ({ ...x, quietHours: value.replace(/\s+/g, '') }))
669    return `No sounds from ${value.replace(/\s+/g, '').replace('-', ' to ')} (local time).`
670  }
671
672  if (word === 'min') {
673    const n = Number(value)
674    if (!value || !Number.isFinite(n) || n < 0) return 'Give a number of seconds, for example /chime min 20 (0 = every turn).'
675    const s = await saveSettings($, x => ({ ...x, minTurnSeconds: Math.round(n) }))
676    return s.minTurnSeconds ? `"Done" now plays only after turns of ${s.minTurnSeconds} s or more.` : '"Done" plays after every turn.'
677  }
678
679  if (word === 'ssh') {
680    if (value !== 'on' && value !== 'off') return 'Use /chime ssh on or /chime ssh off.'
681    await saveSettings($, x => ({ ...x, isPlayingOverSsh: value === 'on' }))
682    return value === 'on' ? 'Chime also plays in SSH sessions (on the remote machine).' : 'Chime stays silent in SSH sessions.'
683  }
684
685  if (word === 'reset') {
686    await saveSettings($, () => DEFAULTS)
687    await setSnooze($, 0)
688    return 'Chime is back to its defaults.'
689  }
690
691  return `Unknown option "${verb}".\n\n${HELP}`
692}
693
694async function statusText($: Engine): Promise<string> {
695  const s = await read($, settingsAtom)
696  const snoozeUntil = await read($, snoozeAtom)
697  const now = await $.clock.now()
698  const lines = [
699    `Chime is ${s.isEnabled ? 'on' : 'off'}, volume ${s.volume}.`,
700    ...EVENTS.map(ev => `  ${EVENT_INFO[ev].label.padEnd(8)} ${soundLabel(s.sounds[ev])}`),
701  ]
702  if (snoozeUntil > now) lines.push(`Snoozed until ${clockTime(snoozeUntil)}.`)
703  if (s.quietHours) lines.push(`Quiet hours: ${s.quietHours}.`)
704  if (s.minTurnSeconds) lines.push(`"Done" only after turns of ${s.minTurnSeconds} s or more.`)
705  if (await isOverSsh($)) lines.push(s.isPlayingOverSsh ? 'SSH session: playing on the remote machine.' : 'SSH session: silent (/chime ssh on to change).')
706  if (!isInteractive) lines.push('Not an interactive session: silent.')
707  return lines.join('\n')
708}
709
hooks/settings.ts 105 lines
1import type { ChimeEvent, ChimeSettings, ChimeSound } from '../types'
2
3export const EVENTS: readonly ChimeEvent[] = ['done', 'waiting', 'error', 'idle']
4
5export const EVENT_INFO: Record<ChimeEvent, { label: string; when: string }> = {
6  done: { label: 'Done', when: 'Claude finished and handed the turn back to you' },
7  waiting: { label: 'Waiting', when: 'Claude needs a permission, an answer or a plan approval' },
8  error: { label: 'Error', when: 'the turn ended on an API error (rate limit, overload, auth)' },
9  idle: { label: 'Idle', when: 'the prompt has sat unanswered for a while (a reminder)' },
10}
11
12/** The bundled sounds, in the order the picker lists them. */
13export const BUNDLED: readonly { id: string; label: string; note: string }[] = [
14  { id: 'tada', label: 'Ta-da', note: 'a short pickup note, then a bright chord' },
15  { id: 'coin', label: 'Coin', note: '8-bit coin pickup' },
16  { id: 'level-up', label: 'Level up', note: 'rising 8-bit arpeggio' },
17  { id: 'doorbell', label: 'Doorbell', note: 'ding-dong' },
18  { id: 'microwave', label: 'Microwave', note: 'beep beep beep' },
19  { id: 'boing', label: 'Boing', note: 'cartoon spring' },
20  { id: 'rubber-duck', label: 'Rubber duck', note: 'two squeaks' },
21  { id: 'robot', label: 'Robot', note: 'beep-boop chirps' },
22  { id: 'slide-whistle', label: 'Slide whistle', note: 'whistle sliding up' },
23  { id: 'sad-trombone', label: 'Sad trombone', note: 'wah wah wah waaah' },
24]
25
26export const DEFAULTS: ChimeSettings = {
27  isEnabled: true,
28  volume: 60,
29  sounds: { done: 'tada', waiting: 'doorbell', error: 'sad-trombone', idle: 'off' },
30  minTurnSeconds: 0,
31  quietHours: '',
32  isPlayingOverSsh: false,
33}
34
35export const isBundled = (sound: ChimeSound) => BUNDLED.some(b => b.id === sound)
36
37/** True for `/x`, `~/x`, `C:\x`, `C:/x` and `\\server\share`. */
38export const isFilePath = (sound: ChimeSound) =>
39  /^(\/|~[\\/]|[A-Za-z]:[\\/]|\\\\)/.test(sound)
40
41export const soundLabel = (sound: ChimeSound) =>
42  sound === 'off' ? 'off' : (BUNDLED.find(b => b.id === sound)?.label ?? sound)
43
44export const clampVolume = (n: number) => Math.round(Math.min(100, Math.max(0, n)))
45
46/**
47 * Fills in what a stored value lacks and drops what no longer fits, so a
48 * settings file from an older or newer version still loads.
49 */
50export function normalize(raw: unknown): ChimeSettings {
51  const s = (raw && typeof raw === 'object' ? raw : {}) as Partial<ChimeSettings>
52  const sounds = { ...DEFAULTS.sounds }
53  for (const event of EVENTS) {
54    const value = s.sounds?.[event]
55    if (typeof value === 'string' && (value === 'off' || isBundled(value) || isFilePath(value))) {
56      sounds[event] = value
57    }
58  }
59  return {
60    isEnabled: typeof s.isEnabled === 'boolean' ? s.isEnabled : DEFAULTS.isEnabled,
61    volume: typeof s.volume === 'number' && Number.isFinite(s.volume) ? clampVolume(s.volume) : DEFAULTS.volume,
62    sounds,
63    minTurnSeconds:
64      typeof s.minTurnSeconds === 'number' && s.minTurnSeconds >= 0 ? Math.round(s.minTurnSeconds) : 0,
65    quietHours: typeof s.quietHours === 'string' && parseQuietHours(s.quietHours) ? s.quietHours : '',
66    isPlayingOverSsh: s.isPlayingOverSsh === true,
67  }
68}
69
70/** `22:00-07:30` to minutes after midnight, or null when malformed. */
71export function parseQuietHours(text: string): { from: number; to: number } | null {
72  const m = /^\s*(\d{1,2}):(\d{2})\s*-\s*(\d{1,2}):(\d{2})\s*$/.exec(text)
73  if (!m) return null
74  const [h1 = 0, m1 = 0, h2 = 0, m2 = 0] = m.slice(1).map(Number)
75  if (h1 > 23 || h2 > 23 || m1 > 59 || m2 > 59) return null
76  return { from: h1 * 60 + m1, to: h2 * 60 + m2 }
77}
78
79/** Whether `date` (local time) falls inside the range; a range may wrap midnight. */
80export function isInQuietHours(text: string, date: Date): boolean {
81  const range = parseQuietHours(text)
82  if (!range || range.from === range.to) return false
83  const now = date.getHours() * 60 + date.getMinutes()
84  return range.from < range.to
85    ? now >= range.from && now < range.to
86    : now >= range.from || now < range.to
87}
88
89/** `30`, `30m`, `2h`, `1h30m` to milliseconds, or null. A bare number is minutes. */
90export function parseDuration(text: string): number | null {
91  const t = text.trim().toLowerCase()
92  if (/^\d+$/.test(t)) return Number(t) * 60_000
93  const m = /^(?:(\d+)h)?\s*(?:(\d+)m(?:in)?)?$/.exec(t)
94  if (!m || (!m[1] && !m[2])) return null
95  return (Number(m[1] ?? 0) * 60 + Number(m[2] ?? 0)) * 60_000
96}
97
98export function describeDuration(ms: number): string {
99  const minutes = Math.max(1, Math.round(ms / 60_000))
100  if (minutes < 60) return `${minutes} min`
101  const h = Math.floor(minutes / 60)
102  const m = minutes % 60
103  return m ? `${h} h ${m} min` : `${h} h`
104}
105
hooks/wav.ts 105 lines
1// Pure helpers for playing sounds: no engine calls here (`$` stays in register.tsx).
2
3// Windows: System.Media.SoundPlayer ships with every Windows and needs nothing
4// installed, but it plays .wav only and has no volume control. So the volume is
5// applied to the samples, and the scaled copy is cached in %TEMP%.
6
7export const POWERSHELL = ['powershell.exe', '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command']
8export const PS_PLAY = '& { (New-Object Media.SoundPlayer $args[0]).PlaySync() }'
9export const PS_WRITE_AND_PLAY =
10  '& { $o = $args[0]; [void][IO.Directory]::CreateDirectory([IO.Path]::GetDirectoryName($o)); ' +
11  '[IO.File]::WriteAllBytes($o, [Convert]::FromBase64String([Console]::In.ReadToEnd())); ' +
12  '(New-Object Media.SoundPlayer $o).PlaySync() }'
13
14// Linux (and WSL with WSLg): the first player that is installed and works wins.
15export const posixPlayers = (path: string, gain: number): string[][] => [
16  ['pw-play', `--volume=${gain.toFixed(2)}`, path],
17  ['paplay', `--volume=${Math.round(gain * 65536)}`, path],
18  ['ffplay', '-nodisp', '-autoexit', '-loglevel', 'quiet', '-volume', String(Math.round(gain * 100)), path],
19  ['aplay', '-q', path], // no volume control
20]
21
22// --------------------------------------------------------------------- WAV
23
24/**
25 * Returns a copy of a RIFF/WAVE file with every sample multiplied by `gain`,
26 * or null for an encoding it does not handle (compressed WAV). Handles 8-, 16-,
27 * 24- and 32-bit PCM and 32-bit float, including WAVE_FORMAT_EXTENSIBLE.
28 */
29export function scaleWav(bytes: Uint8Array, gain: number): Uint8Array | null {
30  const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
31  const tag = (at: number) => String.fromCharCode(...bytes.subarray(at, at + 4))
32  if (bytes.length < 12 || tag(0) !== 'RIFF' || tag(8) !== 'WAVE') return null
33
34  let format = 0
35  let bits = 0
36  let dataStart = -1
37  let dataEnd = -1
38  for (let at = 12; at + 8 <= bytes.length; ) {
39    const id = tag(at)
40    const size = view.getUint32(at + 4, true)
41    const body = at + 8
42    if (id === 'fmt ') {
43      format = view.getUint16(body, true)
44      bits = view.getUint16(body + 14, true)
45      if (format === 0xfffe && size >= 26) format = view.getUint16(body + 24, true)
46    } else if (id === 'data') {
47      dataStart = body
48      dataEnd = Math.min(bytes.length, body + size)
49    }
50    at = body + size + (size % 2)
51  }
52  if (dataStart < 0 || bits === 0) return null
53
54  const out = bytes.slice()
55  const o = new DataView(out.buffer)
56  const clamp = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, Math.round(v)))
57  if (format === 1 && bits === 16) {
58    for (let i = dataStart; i + 1 < dataEnd; i += 2) o.setInt16(i, clamp(o.getInt16(i, true) * gain, -32768, 32767), true)
59  } else if (format === 1 && bits === 8) {
60    for (let i = dataStart; i < dataEnd; i++) o.setUint8(i, clamp((o.getUint8(i) - 128) * gain + 128, 0, 255))
61  } else if (format === 1 && bits === 24) {
62    for (let i = dataStart; i + 2 < dataEnd; i += 3) {
63      let v = o.getUint8(i) | (o.getUint8(i + 1) << 8) | (o.getUint8(i + 2) << 16)
64      if (v & 0x800000) v -= 0x1000000
65      v = clamp(v * gain, -0x800000, 0x7fffff)
66      o.setUint8(i, v & 0xff)
67      o.setUint8(i + 1, (v >> 8) & 0xff)
68      o.setUint8(i + 2, (v >> 16) & 0xff)
69    }
70  } else if (format === 1 && bits === 32) {
71    for (let i = dataStart; i + 3 < dataEnd; i += 4) o.setInt32(i, clamp(o.getInt32(i, true) * gain, -2147483648, 2147483647), true)
72  } else if (format === 3 && bits === 32) {
73    for (let i = dataStart; i + 3 < dataEnd; i += 4) o.setFloat32(i, o.getFloat32(i, true) * gain, true)
74  } else {
75    return null
76  }
77  return out
78}
79
80// --------------------------------------------------------------------- helpers
81
82export function fnv1a(text: string): string {
83  let h = 0x811c9dc5
84  for (let i = 0; i < text.length; i++) {
85    h ^= text.charCodeAt(i)
86    h = Math.imul(h, 0x01000193)
87  }
88  return (h >>> 0).toString(16).padStart(8, '0')
89}
90
91export function fromBase64(base64: string): Uint8Array {
92  const binary = atob(base64)
93  const bytes = new Uint8Array(binary.length)
94  for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i)
95  return bytes
96}
97
98export function toBase64(bytes: Uint8Array): string {
99  let binary = ''
100  for (let i = 0; i < bytes.length; i += 0x8000) {
101    binary += String.fromCharCode(...bytes.subarray(i, i + 0x8000))
102  }
103  return btoa(binary)
104}
105
types/index.d.ts 35 lines
1/** A moment Chime can announce. */
2export type ChimeEvent = 'done' | 'waiting' | 'error' | 'idle'
3
4/**
5 * What plays for one event: a bundled sound's id (`tada`), `off`, or the
6 * absolute path of an audio file of the user's own.
7 */
8export type ChimeSound = string
9
10export type ChimeSettings = {
11  /** Master switch. */
12  isEnabled: boolean
13  /** 0 to 100. */
14  volume: number
15  sounds: Record<ChimeEvent, ChimeSound>
16  /** The done sound plays only for turns at least this long (0 = always). */
17  minTurnSeconds: number
18  /** `HH:MM-HH:MM` in local time, or empty for none. */
19  quietHours: string
20  /** Whether to play in a session reached over SSH (the sound plays on the remote machine). */
21  isPlayingOverSsh: boolean
22}
23
24declare module 'claude-code' {
25  interface PluginState {
26    chime: {
27      settings: ChimeSettings
28      /** The picker's screen: `main`, an event's sound list, `volume` or `snooze`. */
29      screen: string
30      /** Epoch ms until which Chime stays silent; 0 when not snoozed. */
31      snoozeUntil: number
32    }
33  }
34}
35