SLOPSHOPPER

read-aloud

Reads Claude's replies and your prompts aloud on request, with the open-source Kokoro TTS model.

newspinnerrowscommandtoastprompt
★ 1v0.3.1no licenseupdated 2026-10-09tanujarun/it-speaks
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · read-aloud
⟨Claude Code's own drawing⟩ ╭────────────────────────────────────────────╮ 🔊 read │ read-aloud │ │ read-aloud: no voice model yet. Run │ ⏺ Read(src/auth.ts) │ /read-aloud setup │ ⎿ Read 6 lines ╰────────────────────────────────────────────╯ ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ⟨Claude Code's own drawing⟩ 🔊 read ✻ Worked for 42s · done 4:20 PM › /read-aloud ⎿ read-aloud: read-aloud is on. ⎿ read-aloud: triggers: a read trigger under each reply and prompt ⎿ read-aloud: read unasked, replies: off (voice af_heart) ⎿ read-aloud: read unasked, prompts: off (voice am_michael) ⎿ read-aloud: speed 1, volume 100%, limit 2000 ⎿ read-aloud: voice model: not installed. Run /read-aloud setup ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Your message
⟨Claude Code's own drawing⟩ 🔊 read
Claude's reply
⟨Claude Code's own drawing⟩ 🔊 read
README

It Speaks

A Claude Code mod (read-aloud) that gives Claude a voice. It puts a small 🔊 read trigger under each of Claude's replies and each of your prompts: press it and that one is read aloud, press it again to stop. Nothing is read unless you ask. The voice is Kokoro-82M, an open-source (Apache-2.0) text-to-speech model that runs locally on the CPU, so nothing you or Claude write leaves the machine to be spoken.

Built on Claude Code's function hooks, which are early access and may change between releases; written against Claude Code 2.1.295. Developed and used on Windows 11. The speech process is plain Python and is written to run on macOS and Linux too, but it has not been run there.

Install

At the prompt of a Claude Code terminal session:

/plugin install read-aloud --marketplace tanujarun/it-speaks

Then install the voice (next section).

Setup

The voice model and its Python runtime live outside the mod, in ~/.claude/read-aloud (about 340 MB of downloads). Install them once, either from a session:

/read-aloud setup

or from a shell, with Python 3.10 or newer:

python tts/setup.py

python tts/setup.py check says what is in place. Deleting ~/.claude/read-aloud removes all of it.

Updating

/read-aloud update

does three things and says what each came to:

  • Packages. Upgrades the voice runtime's Python packages to their newest release, and names each change in the form <package> <old> -> <new>.
  • Model. Checks each model file against the SHA-256 that tts/models.json names, downloads one that is missing or different, and removes a file an earlier version of the mod installed and this one no longer uses.
  • The mod. Compares this copy's version with the one published here and says when it is behind. The mod itself is updated by Claude Code: claude plugin update, then /reload-plugins.

The speech process is stopped for the update and started again after it. If packages fail to install with "access is denied", another Claude Code session is speaking with them: /hush there, or close it, and run the update again. From a shell the same is python tts/setup.py update.

A new model release reaches people as a new version of the mod: models.json names the packages, each file's address and its checksum, so changing the model is an edit to that one file, and /read-aloud update after the mod updates brings an install in line with it.

Reading what you pick

  • The trigger. Click 🔊 read under a reply or a prompt. It turns into ■ stop while that row plays. The trigger is drawn where a click can reach a transcript row: the fullscreen terminal ("tui": "fullscreen") and the desktop app. In a terminal that prints into scrollback it is left out.
  • /read-aloud last reads Claude's last reply, on any surface.
  • /read-aloud selection reads the text you selected with the mouse.
  • /hush stops the speech. So does submitting a prompt or interrupting a turn.
  • The indicator. While something is being read, the right end of the prompt's footer (the bottom right corner) says reading aloud · /hush stops it, beside the mode labels Claude Code shows there, and loading the voice while the model loads for the first utterance. Nothing is pinned to the status line under the prompt.

Code blocks and tables are named ("Code block.") rather than read out.

With the skins mod

Where skins is installed and a skin is on, the trigger wears it: ► read with the glyph in the skin's accent and the word in its muted colour, ■ stop in its error colour, and > / x under the skin's ASCII icons. It follows a change of skin as it happens. The built-in skins' colours are kept in hooks/skin-paint.ts; a built-in added to skins after this was written leaves the trigger in its plain look.

With a mod that draws the same rows

The trigger wraps whatever draws the row beneath it. A mod that draws a prompt or a reply itself (a skin) and is listed before this one in enabledPlugins (~/.claude/settings.json) never hands the row down, so no trigger shows there. List read-aloud@... first in enabledPlugins: plugins nest in that order, first outermost. Then start a new session.

Commands

CommandWhat it does
/hushStops the speech now
/read-aloudWhat is on, and the voices in use
/read-aloud on / offEverything on or off
/read-aloud triggers on / offThe read trigger under each reply and prompt
/read-aloud lastClaude's last reply
/read-aloud selectionThe text selected with the mouse
/read-aloud say <text>Says the text
/read-aloud voice <name>The voice for Claude (/read-aloud voices lists them)
/read-aloud prompt-voice <name>The voice for your prompts
/read-aloud speed <0.5-2>Speaking rate
/read-aloud volume <0-200>Percent
/read-aloud updateUpgrades the voice packages and model; says if the mod is behind

To have it read without being asked:

CommandWhat it does
/read-aloud replies all / final / offEverything Claude says as it lands, the final answer alone, or nothing (the default)
/read-aloud prompts on / offRead your prompt back as you submit it (off by default)
/read-aloud limit <characters>Longest reply read unasked; 0 reads all of it

Settings are kept across sessions.

How it works

hooks/register.tsx starts one small Python process per session (tts/daemon.py) and hands it text through a spool folder of JSON files; the process loads the model at the first utterance and lets it go after ten quiet minutes. Subagents' output and non-interactive runs (claude -p) are never read.

claude plugin test . runs the mod's tests; tts/selftest.py, run with the runtime's Python, says a sentence through the real model.

Source 4 files
hooks/register.tsx 809 lines
1import { atom, memberOf, read, update } from 'claude-code'
2import type { ElementTable, EngineInterface, Register, RenderElement } from 'claude-code'
3
4import { paintOf } from './skin-paint'
5import type { Paint } from './skin-paint'
6
7import { toSpeech } from './speech-text'
8import type { Saying } from '../types'
9
10// Speech is made by a process of the mod's own (tts/daemon.py: Kokoro-82M, an
11// open-source model, through kokoro-onnx), started once per session and told
12// what to say through a spool folder of JSON files. Its runtime lives outside
13// the mod, under HOME_FOLDER, where tts/setup.py installs it.
14
15type Replies = 'all' | 'final' | 'off'
16
17type Settings = {
18  isOn: boolean
19  /** `all`: each thing Claude says as it lands; `final`: the turn's answer. */
20  replies: Replies
21  /** Whether a prompt is read back as it is submitted. */
22  prompts: boolean
23  /** Whether each reply and prompt in the transcript carries a read trigger. */
24  triggers: boolean
25  voice: string
26  promptVoice: string
27  speed: number
28  /** Percent, 100 the model's own level. */
29  volume: number
30  /** The most characters one reply is read for; 0 for the whole of it. */
31  limit: number
32}
33
34type Daemon = {
35  spool: string
36  sequence: number
37  voices: readonly string[]
38  /** Settles when the speech process has exited. */
39  ended: Promise<void>
40}
41
42type DaemonEvent = { event?: string; id?: unknown; voices?: unknown; message?: unknown }
43
44// Nothing is read unasked: each row carries a trigger, and the person picks.
45const DEFAULTS: Settings = {
46  isOn: true,
47  replies: 'off',
48  prompts: false,
49  triggers: true,
50  voice: 'af_heart',
51  promptVoice: 'am_michael',
52  speed: 1,
53  volume: 100,
54  limit: 2000,
55}
56
57const PROMPT_LIMIT = 600
58const READ = '\u{1F50A} read'
59const STOP = '■ stop'
60
61// What the footer's indicator says while the speech process works.
62const INDICATOR: Readonly<Record<Saying, string | undefined>> = {
63  idle: undefined,
64  loading: 'loading the voice',
65  speaking: 'reading aloud · /hush stops it',
66}
67
68// What the speech process is doing now; the footer's indicator draws from it.
69const saying = atom({ plugin: 'read-aloud', key: 'saying' } as const, 'idle' as Saying)
70
71// One member per transcript row: true on the row whose text is being read.
72const playing = atom({ plugin: 'read-aloud', key: 'playing' } as const, false)
73const SETUP_TIMEOUT_MS = 600_000
74
75const HELP = [
76  '/read-aloud                      what is on, and the voices in use',
77  '/read-aloud on | off             everything on or off',
78  '/read-aloud triggers on | off    the read trigger under each reply and prompt',
79  '/read-aloud last                 the last reply (again: the same)',
80  '/read-aloud selection            the text selected with the mouse',
81  '/read-aloud replies all | final | off',
82  '                                 read unasked: each thing Claude says, or the answer alone',
83  '/read-aloud prompts on | off     read your prompt back as you submit it',
84  '/read-aloud voice <name>         the voice for Claude (voices: list them)',
85  '/read-aloud prompt-voice <name>  the voice for your prompts',
86  '/read-aloud speed <0.5-2>        speaking rate',
87  '/read-aloud volume <0-200>       percent',
88  '/read-aloud limit <characters>   longest reply read unasked; 0 reads all of it',
89  '/read-aloud say <text>           says the text',
90  '/read-aloud setup                installs the voice model (about 340 MB)',
91  '/read-aloud update               upgrades the voice packages and model, and says if the mod is behind',
92  '/hush                            stops the speech now',
93].join('\n')
94
95const clamp = (value: number, low: number, high: number): number =>
96  Math.min(high, Math.max(low, value))
97
98const readSettings = (stored: unknown): Settings => {
99  const held = (typeof stored === 'object' && stored !== null ? stored : {}) as Partial<Settings>
100  const text = (value: unknown, fallback: string): string =>
101    typeof value === 'string' && value !== '' ? value : fallback
102  const number = (value: unknown, fallback: number): number =>
103    typeof value === 'number' && Number.isFinite(value) ? value : fallback
104
105  return {
106    isOn: typeof held.isOn === 'boolean' ? held.isOn : DEFAULTS.isOn,
107    replies:
108      held.replies === 'all' || held.replies === 'final' || held.replies === 'off'
109        ? held.replies
110        : DEFAULTS.replies,
111    prompts: typeof held.prompts === 'boolean' ? held.prompts : DEFAULTS.prompts,
112    triggers: typeof held.triggers === 'boolean' ? held.triggers : DEFAULTS.triggers,
113    voice: text(held.voice, DEFAULTS.voice),
114    promptVoice: text(held.promptVoice, DEFAULTS.promptVoice),
115    speed: clamp(number(held.speed, DEFAULTS.speed), 0.5, 2),
116    volume: clamp(number(held.volume, DEFAULTS.volume), 0, 200),
117    limit: Math.max(0, Math.round(number(held.limit, DEFAULTS.limit))),
118  }
119}
120
121const squeeze = (text: string): string => text.replace(/\s+/g, ' ').trim()
122
123let settings = DEFAULTS
124let isInteractive = false
125let daemon: Daemon | undefined
126let starting: Promise<Daemon | undefined> | undefined
127let hasToldMissing = false
128let spokenThisTurn: string[] = []
129let lastReply = ''
130let utterance = 0
131// The row a trigger asked for, and the utterance that reads it: `awaited`
132// until the speech process says it speaks, so the idle a stop reports just
133// before it is not taken for this one's end.
134let playingRow: string | undefined
135let awaited: string | undefined
136
137const setSaying = ($: EngineInterface, now: Saying): void => {
138  void update($, saying, () => now).catch(() => {})
139}
140
141const setPlaying = async ($: EngineInterface, row: string | undefined): Promise<void> => {
142  const before = playingRow
143  playingRow = row
144  if (before !== undefined && before !== row) {
145    await update($, memberOf(playing, { requestId: before }), () => false)
146  }
147  if (row !== undefined) {
148    await update($, memberOf(playing, { requestId: row }), () => true)
149  }
150}
151
152const loadSettings = async ($: EngineInterface): Promise<Settings> => {
153  settings = readSettings(await $.store.get('settings'))
154
155  return settings
156}
157
158const saveSettings = async ($: EngineInterface, change: Partial<Settings>): Promise<void> => {
159  settings = readSettings({ ...(await loadSettings($)), ...change })
160  await $.store.set('settings', settings)
161}
162
163const homeFolder = async ($: EngineInterface): Promise<string> => {
164  const own = await $.env.get('READ_ALOUD_HOME')
165  const user = (await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME')) ?? '.'
166
167  return (own ?? `${user}/.claude/read-aloud`).replace(/\\/g, '/')
168}
169
170/** The runtime's Python, or undefined while tts/setup.py has not run. */
171const findPython = async ($: EngineInterface, home: string): Promise<string | undefined> => {
172  // setup.py records a finished install; one from before it did has the model.
173  const isInstalled =
174    (await $.fs.exists(`${home}/installed.json`)) || (await $.fs.exists(`${home}/models/kokoro-v1.0.onnx`))
175  if (!isInstalled) {
176    return undefined
177  }
178  for (const python of [`${home}/venv/Scripts/python.exe`, `${home}/venv/bin/python`]) {
179    if (await $.fs.exists(python)) {
180      return python
181    }
182  }
183
184  return undefined
185}
186
187const onDaemonEvent = ($: EngineInterface, mine: Daemon, line: string): void => {
188  let said: DaemonEvent
189  try {
190    said = JSON.parse(line) as DaemonEvent
191  } catch {
192    return
193  }
194
195  if (said.event === 'ready' && Array.isArray(said.voices)) {
196    mine.voices = said.voices.filter((voice): voice is string => typeof voice === 'string')
197  } else if (said.event === 'loading') {
198    setSaying($, 'loading')
199  } else if (said.event === 'speaking') {
200    setSaying($, 'speaking')
201    if (said.id === awaited) {
202      awaited = undefined
203    }
204  } else if (said.event === 'idle') {
205    setSaying($, 'idle')
206    if (awaited === undefined) {
207      void setPlaying($, undefined)
208    }
209  } else if (said.event === 'error') {
210    $.ui.log(`read-aloud: ${String(said.message)}`, { to: 'debug' })
211    if (said.id === awaited) {
212      awaited = undefined
213    }
214  }
215}
216
217/** Reads the daemon's lines for as long as it lives; the loop is its life. */
218const follow = async ($: EngineInterface, mine: Daemon, argv: readonly string[]): Promise<void> => {
219  let held = ''
220  try {
221    for await (const { stream, text } of $.process.spawn({ argv })) {
222      if (stream === 'stderr') {
223        $.ui.log(`read-aloud: ${text.trim()}`, { to: 'debug' })
224        continue
225      }
226      held += text
227      const lines = held.split('\n')
228      held = lines.pop() ?? ''
229      for (const line of lines) {
230        onDaemonEvent($, mine, line)
231      }
232    }
233  } catch (error) {
234    $.ui.log(`read-aloud: the speech process did not run: ${String(error)}`, { to: 'debug' })
235  } finally {
236    if (daemon === mine) {
237      daemon = undefined
238      setSaying($, 'idle')
239    }
240  }
241}
242
243const start = async ($: EngineInterface): Promise<Daemon | undefined> => {
244  const home = await homeFolder($)
245  const python = await findPython($, home)
246
247  if (python === undefined) {
248    if (!hasToldMissing) {
249      hasToldMissing = true
250      $.ui.toast('read-aloud: no voice model yet. Run /read-aloud setup', { timeoutMs: 10_000 })
251    }
252
253    return undefined
254  }
255
256  const name = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`
257  const mine: Daemon = { spool: `${home}/spool/${name}`, sequence: 0, voices: [], ended: Promise.resolve() }
258  const script = `${$.plugin.root.replace(/\\/g, '/')}/tts/daemon.py`
259
260  daemon = mine
261  mine.ended = follow($, mine, [python, '-B', script, '--spool', mine.spool, '--models', `${home}/models`])
262
263  return mine
264}
265
266const running = async ($: EngineInterface): Promise<Daemon | undefined> => {
267  if (daemon !== undefined) {
268    return daemon
269  }
270  starting ??= start($).finally(() => {
271    starting = undefined
272  })
273
274  return starting
275}
276
277const send = async ($: EngineInterface, command: Record<string, unknown>): Promise<boolean> => {
278  const to = await running($)
279  if (to === undefined) {
280    return false
281  }
282  to.sequence += 1
283  const file = `${to.spool}/${String(to.sequence).padStart(6, '0')}.json`
284  await $.fs.write(file, JSON.stringify(command))
285
286  return true
287}
288
289/** Queues `text` to be spoken; answers the utterance's id, or undefined. */
290const say = async ($: EngineInterface, text: string, voice: string): Promise<string | undefined> => {
291  if (text === '') {
292    return undefined
293  }
294  utterance += 1
295  const id = `u${utterance}`
296  const isSent = await send($, {
297    op: 'speak',
298    id,
299    text,
300    voice,
301    speed: settings.speed,
302    gain: settings.volume / 100,
303  })
304
305  return isSent ? id : undefined
306}
307
308/** Silences the speech, when a process is there to silence. */
309const hush = async ($: EngineInterface): Promise<void> => {
310  awaited = undefined
311  if (daemon !== undefined) {
312    await send($, { op: 'stop' })
313  }
314  await setPlaying($, undefined)
315}
316
317type Who = 'voice' | 'promptVoice'
318
319/** A press on a row's trigger: reads that row whole, or stops it if it plays. */
320const toggle = async ($: EngineInterface, row: string, markdown: string, who: Who): Promise<void> => {
321  try {
322    const wasPlaying = playingRow === row
323    await loadSettings($)
324    await hush($)
325    if (wasPlaying) {
326      return
327    }
328    const id = await say($, toSpeech(markdown), settings[who])
329    if (id === undefined) {
330      return
331    }
332    awaited = id
333    await setPlaying($, row)
334  } catch (error) {
335    $.ui.log(`read-aloud: ${String(error)}`, { to: 'debug' })
336  }
337}
338
339// The skins mod's state, read as any plugin may read another's. Read while a
340// row is drawn, it draws the row again when the person changes their skin.
341// Its contract is not this mod's to import, so each value is read as unknown.
342type Held = { value: unknown }
343
344const skinPaint = async ($: EngineInterface): Promise<Paint | undefined> => {
345  try {
346    const prefs: Held = await $.state.get({ plugin: 'skins', key: 'prefs' } as never)
347    const custom: Held = await $.state.get({ plugin: 'skins', key: 'custom' } as never)
348    const isLight: Held = await $.state.get({ plugin: 'skins', key: 'isLight' } as never)
349
350    return paintOf(prefs.value, custom.value, isLight.value)
351  } catch {
352    return undefined
353  }
354}
355
356type Ui = Pick<ElementTable, 'Box' | 'Button' | 'Text'>
357
358/** The row as drawn beneath, and under it the trigger, in the skin's colours. */
359const withTrigger = (
360  { Box, Button, Text }: Ui,
361  drawn: RenderElement,
362  isPlaying: boolean,
363  paint: Paint | undefined,
364  onPress: () => void,
365): RenderElement => (
366  <Box flexDirection="column">
367    {drawn}
368    <Box paddingLeft={2}>
369      {paint === undefined ? (
370        <Button key="read-aloud" plain dimColor label={isPlaying ? STOP : READ} onPress={onPress} />
371      ) : (
372        <Button key="read-aloud" plain label={isPlaying ? 'stop' : 'read'} onPress={onPress}>
373          <Text color={isPlaying ? paint.stop : paint.accent}>
374            {isPlaying ? (paint.isAscii ? 'x' : '■') : paint.isAscii ? '>' : '►'}
375          </Text>
376          <Text color={paint.muted}>{isPlaying ? ' stop' : ' read'}</Text>
377        </Button>
378      )}
379    </Box>
380  </Box>
381)
382
383/** Whether a press can reach a transcript row: not in a terminal's scrollback. */
384const canPress = (surface: string, isFullscreen: boolean | undefined): boolean =>
385  surface !== 'terminal' || isFullscreen !== false
386
387const sayReply = async ($: EngineInterface, markdown: string): Promise<void> => {
388  spokenThisTurn.push(squeeze(markdown))
389  await say($, toSpeech(markdown, settings.limit), settings.voice)
390}
391
392// A hook here never fails the event it rides on: speech is an extra.
393const quietly = async ($: EngineInterface, work: () => Promise<void>): Promise<void> => {
394  try {
395    await work()
396  } catch (error) {
397    $.ui.log(`read-aloud: ${String(error)}`, { to: 'debug' })
398  }
399}
400
401const describe = async ($: EngineInterface): Promise<string> => {
402  const home = await homeFolder($)
403  const isInstalled = (await findPython($, home)) !== undefined
404  const replies = { all: 'everything Claude says', final: 'the final answer', off: 'off' }
405
406  return [
407    `read-aloud is ${settings.isOn ? 'on' : 'off'}.`,
408    `  triggers: ${settings.triggers ? 'a read trigger under each reply and prompt' : 'off'}`,
409    `  read unasked, replies: ${replies[settings.replies]} (voice ${settings.voice})`,
410    `  read unasked, prompts: ${settings.prompts ? 'as you submit them' : 'off'} (voice ${settings.promptVoice})`,
411    `  speed ${settings.speed}, volume ${settings.volume}%, limit ${settings.limit || 'none'}`,
412    isInstalled
413      ? `  voice model: Kokoro-82M in ${home}`
414      : '  voice model: not installed. Run /read-aloud setup',
415    '  /read-aloud help lists the commands; /hush stops the speech.',
416  ].join('\n')
417}
418
419/** Ends the speech process and waits for it, so no file of its is in use. */
420const stopDaemon = async ($: EngineInterface): Promise<void> => {
421  const mine = daemon
422  if (mine === undefined) {
423    return
424  }
425  awaited = undefined
426  await send($, { op: 'quit' })
427  // Three seconds at most; where no clock answers, its exit alone ends the wait.
428  const patience = $.clock.sleep(3000).catch(() => new Promise<void>(() => {}))
429  await Promise.race([mine.ended, patience])
430  await setPlaying($, undefined)
431}
432
433type SetupRun = { isDone: boolean; report: string }
434
435/** Runs tts/setup.py with the machine's Python: `all` installs, `update` the same on an install. */
436const runSetup = async ($: EngineInterface, step: 'all' | 'update'): Promise<SetupRun> => {
437  const script = `${$.plugin.root.replace(/\\/g, '/')}/tts/setup.py`
438  const home = await homeFolder($)
439
440  await stopDaemon($)
441  for (const python of ['python', 'py', 'python3']) {
442    const ran = await $.process
443      .run([python, '-B', script, step], {
444        timeoutMs: SETUP_TIMEOUT_MS,
445        env: { READ_ALOUD_HOME: home },
446      })
447      .catch(() => undefined)
448    if (ran === undefined) {
449      continue
450    }
451    // Every line but a download's progress, which only its last one is worth.
452    const lines = `${ran.stdout}\n${ran.stderr}`.split('\n').map(line => line.trim())
453    const report = lines.filter(line => line !== '' && !/ \d+ MB( of \d+)?$/.test(line)).slice(-10).join('\n')
454
455    hasToldMissing = false
456    await running($)
457
458    return { isDone: ran.exitCode === 0, report }
459  }
460
461  return { isDone: false, report: 'No Python found. Install Python 3.10 or newer and run it again.' }
462}
463
464const setUp = async ($: EngineInterface): Promise<string> => {
465  const { isDone, report } = await runSetup($, 'all')
466
467  return `read-aloud: ${isDone ? 'the voice is installed.' : 'setup failed.'}\n${report}`
468}
469
470const isNewer = (theirs: string, ours: string): boolean => {
471  const [a, b] = [theirs, ours].map(version => version.split('.').map(part => Number.parseInt(part, 10) || 0))
472  for (let at = 0; at < 3; at += 1) {
473    const difference = (a?.[at] ?? 0) - (b?.[at] ?? 0)
474    if (difference !== 0) {
475      return difference > 0
476    }
477  }
478
479  return false
480}
481
482/**
483 * One line on the mod itself: its version here against the one its GitHub
484 * repository (the manifest's `homepage`) publishes. Empty when it cannot tell.
485 */
486const modNews = async ($: EngineInterface): Promise<string> => {
487  try {
488    const own = JSON.parse(await $.fs.read(`${$.plugin.root}/.claude-plugin/plugin.json`)) as Record<string, unknown>
489    const home = typeof own.homepage === 'string' ? own.homepage : ''
490    if (typeof own.version !== 'string' || !home.startsWith('https://github.com/')) {
491      return ''
492    }
493    const raw = home.replace('https://github.com/', 'https://raw.githubusercontent.com/').replace(/\/$/, '')
494    const published = await $.http.fetch(`${raw}/HEAD/.claude-plugin/plugin.json`)
495    const theirs = published.ok ? (JSON.parse(published.text) as Record<string, unknown>).version : undefined
496    if (typeof theirs !== 'string') {
497      return ''
498    }
499
500    return isNewer(theirs, own.version)
501      ? `mod: version ${theirs} is out and this is ${own.version}. Update it with: claude plugin update, then /reload-plugins`
502      : `mod: version ${own.version} is the newest published.`
503  } catch {
504    return ''
505  }
506}
507
508const updateRuntime = async ($: EngineInterface): Promise<string> => {
509  const home = await homeFolder($)
510  if ((await findPython($, home)) === undefined) {
511    return 'read-aloud: the voice is not installed yet. Run /read-aloud setup'
512  }
513  const { isDone, report } = await runSetup($, 'update')
514  const news = await modNews($)
515
516  return [`read-aloud: ${isDone ? 'the voice is up to date.' : 'the update failed.'}`, report, news]
517    .filter(part => part !== '')
518    .join('\n')
519}
520
521const setVoice = async ($: EngineInterface, key: 'voice' | 'promptVoice', name: string): Promise<string> => {
522  const voices = (await running($))?.voices ?? []
523
524  if (name === '') {
525    return `Name a voice. ${voices.length > 0 ? `Voices: ${voices.join(', ')}` : ''}`.trim()
526  }
527  if (voices.length > 0 && !voices.includes(name)) {
528    return `No voice named ${name}. Voices: ${voices.join(', ')}`
529  }
530  await saveSettings($, { [key]: name })
531  await hush($)
532  await say($, `This is the voice ${name.replace(/^[a-z]{2}_/, '')}.`, name)
533
534  return `${key === 'voice' ? "Claude's" : 'Your prompt'} voice is now ${name}.`
535}
536
537const runCommand = async ($: EngineInterface, args: string): Promise<string> => {
538  const [verb = '', ...rest] = args.trim().split(/\s+/)
539  const value = rest.join(' ')
540  const isYes = value === 'on' || value === ''
541
542  await loadSettings($)
543
544  switch (verb) {
545    case '':
546    case 'status':
547      return describe($)
548    case 'help':
549      return HELP
550    case 'on':
551      await saveSettings($, { isOn: true })
552      await running($)
553      $.ui.invalidate('ui.render')
554
555      return describe($)
556    case 'off':
557      await saveSettings($, { isOn: false })
558      await hush($)
559      $.ui.invalidate('ui.render')
560
561      return 'read-aloud is off.'
562    case 'replies': {
563      if (value !== 'all' && value !== 'final' && value !== 'off') {
564        return 'Say which: /read-aloud replies all | final | off'
565      }
566      await saveSettings($, { replies: value })
567
568      return describe($)
569    }
570    case 'prompts':
571      if (value !== 'on' && value !== 'off' && value !== '') {
572        return 'Say which: /read-aloud prompts on | off'
573      }
574      await saveSettings($, { prompts: isYes })
575
576      return isYes ? 'Your prompts are read back to you.' : 'Your prompts are no longer read back.'
577    case 'triggers':
578      if (value !== 'on' && value !== 'off' && value !== '') {
579        return 'Say which: /read-aloud triggers on | off'
580      }
581      await saveSettings($, { triggers: isYes })
582      $.ui.invalidate('ui.render')
583
584      return isYes ? 'Each reply and prompt carries a read trigger.' : 'The read triggers are hidden.'
585    case 'voice':
586      return setVoice($, 'voice', value)
587    case 'prompt-voice':
588      return setVoice($, 'promptVoice', value)
589    case 'voices': {
590      const voices = (await running($))?.voices ?? []
591
592      return voices.length > 0
593        ? `Voices (a: American, b: British; f: female, m: male):\n${voices.join(', ')}`
594        : 'The voices are listed once the speech process has started. Try again in a moment.'
595    }
596    case 'speed': {
597      const speed = Number(value)
598      if (!Number.isFinite(speed) || speed < 0.5 || speed > 2) {
599        return 'Give a rate from 0.5 to 2: /read-aloud speed 1.2'
600      }
601      await saveSettings($, { speed })
602
603      return `Speed is ${speed}.`
604    }
605    case 'volume': {
606      const volume = Number(value.replace('%', ''))
607      if (!Number.isFinite(volume) || volume < 0 || volume > 200) {
608        return 'Give a percent from 0 to 200: /read-aloud volume 80'
609      }
610      await saveSettings($, { volume })
611
612      return `Volume is ${volume}%.`
613    }
614    case 'limit': {
615      const limit = Number(value)
616      if (!Number.isInteger(limit) || limit < 0) {
617        return 'Give a number of characters, 0 for no limit: /read-aloud limit 2000'
618      }
619      await saveSettings($, { limit })
620
621      return limit === 0 ? 'Replies are read whole.' : `Replies are read up to ${limit} characters.`
622    }
623    case 'last':
624    case 'again':
625      if (lastReply === '') {
626        return 'Claude has said nothing yet in this session.'
627      }
628      await hush($)
629
630      return (await say($, toSpeech(lastReply), settings.voice)) !== undefined
631        ? 'Reading the last reply.'
632        : 'The voice is not installed. Run /read-aloud setup'
633    case 'selection': {
634      const selected = await $.ui.selection()
635      if (selected === undefined || selected.text.trim() === '') {
636        return 'Nothing is selected. Select text with the mouse, then run /read-aloud selection'
637      }
638      await hush($)
639
640      return (await say($, toSpeech(selected.text), settings.voice)) !== undefined
641        ? 'Reading the selection.'
642        : 'The voice is not installed. Run /read-aloud setup'
643    }
644    case 'say':
645      return (await say($, toSpeech(value), settings.voice)) !== undefined
646        ? 'Saying it.'
647        : 'Nothing to say, or the voice is not installed (/read-aloud setup).'
648    case 'setup':
649      return setUp($)
650    case 'update':
651      return updateRuntime($)
652    default:
653      return `No such setting: ${verb}\n${HELP}`
654  }
655}
656
657export const register: Register = on => {
658  on('session.start', async ($, e, next) => {
659    isInteractive = e.isInteractive
660    await $.command.register({
661      name: 'read-aloud',
662      description: 'Read Claude aloud: triggers, last, selection, voice, speed (bare: status)',
663      argumentHint: '[on|off|triggers|last|selection|replies|prompts|voice|speed|volume|say|setup|update|help]',
664      immediate: true,
665    })
666    await $.command.register({
667      name: 'hush',
668      description: 'Stop the speech that is playing',
669      immediate: true,
670    })
671    await quietly($, async () => {
672      await loadSettings($)
673      if (isInteractive && settings.isOn) {
674        await running($)
675      }
676    })
677
678    return next(e)
679  })
680
681  on('prompt.submit', async ($, e, next) => {
682    const isTyped = e.origin.kind === 'composer' && !e.text.trimStart().startsWith('/')
683
684    if (isInteractive && isTyped) {
685      await quietly($, async () => {
686        await loadSettings($)
687        // A new prompt makes whatever was being read stale.
688        await hush($)
689        if (settings.isOn && settings.prompts) {
690          await say($, toSpeech(e.text, PROMPT_LIMIT), settings.promptVoice)
691        }
692      })
693    }
694
695    return next(e)
696  })
697
698  on('turn.start', ($, e, next) => {
699    spokenThisTurn = []
700
701    return next(e)
702  })
703
704  on('session.append', { door: 'response' }, async ($, e, next) => {
705    const stored = await next(e)
706    const isMainReply = e.agentId === undefined && e.message.type === 'assistant'
707
708    if (isInteractive && isMainReply) {
709      await quietly($, async () => {
710        for (const block of e.message.content) {
711          if (block.type === 'text' && typeof block.text === 'string' && block.text.trim() !== '') {
712            lastReply = block.text
713            if (settings.isOn && settings.replies === 'all') {
714              await sayReply($, block.text)
715            }
716          }
717        }
718      })
719    }
720
721    return stored
722  })
723
724  on('turn.complete', async ($, e, next) => {
725    if (isInteractive && e.agentId === undefined) {
726      await quietly($, async () => {
727        const answer = squeeze(e.answer)
728        const isSaid = spokenThisTurn.some(said => said.includes(answer) || answer.includes(said))
729
730        if (answer !== '') {
731          lastReply = e.answer
732        }
733        if (e.isAborted) {
734          await hush($)
735        } else if (settings.isOn && settings.replies !== 'off' && answer !== '' && !isSaid) {
736          await sayReply($, e.answer)
737        }
738      })
739    }
740
741    return next(e)
742  })
743
744  // The trigger: the row as it is drawn beneath, and a small button under it
745  // that reads that row, or stops it while it is the one being read.
746  on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
747    const drawn = await next(e)
748    const isShown = settings.isOn && settings.triggers && canPress(e.surface, e.viewport?.isFullscreen)
749
750    if (!isShown || toSpeech(e.props.text) === '') {
751      return drawn
752    }
753
754    const isPlaying = await read($, memberOf(playing, e))
755    const row = e.requestId
756    const text = e.props.text
757
758    return withTrigger($.ui.resolve(e), drawn, isPlaying, await skinPaint($), () => {
759      void toggle($, row, text, 'voice')
760    })
761  })
762
763  on('ui.render', { component: 'UserMessage' }, async ($, e, next) => {
764    const drawn = await next(e)
765    const isTyped = e.props.origin.kind === 'composer' && !e.props.text.trimStart().startsWith('/')
766    const isShown = settings.isOn && settings.triggers && canPress(e.surface, e.viewport?.isFullscreen)
767
768    if (!isShown || !isTyped || toSpeech(e.props.text) === '') {
769      return drawn
770    }
771
772    const isPlaying = await read($, memberOf(playing, e))
773    const row = e.requestId
774    const text = e.props.text
775
776    return withTrigger($.ui.resolve(e), drawn, isPlaying, await skinPaint($), () => {
777      void toggle($, row, text, 'promptVoice')
778    })
779  })
780
781  // The indicator: one more of the dim labels at the right of the prompt's
782  // footer, the bottom right corner, for as long as a row is being read.
783  on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
784    const label = INDICATOR[await read($, saying)]
785
786    return label === undefined
787      ? next(e)
788      : next({ ...e, props: { ...e.props, modes: [...e.props.modes, label] } })
789  })
790
791  on('command.run', { command: 'read-aloud' }, async ($, e) => {
792    try {
793      return { text: await runCommand($, e.args) }
794    } catch (error) {
795      return { text: `read-aloud: ${String(error)}` }
796    }
797  })
798
799  on('command.run', { command: 'hush' }, async $ => {
800    try {
801      await hush($)
802    } catch (error) {
803      return { text: `read-aloud: ${String(error)}` }
804    }
805
806    return { text: 'Hushed.' }
807  })
808}
809
hooks/skin-paint.ts 98 lines
1// The trigger wears the active skin of the skins mod
2// (github.com/hellosverre/claude-skins) when that mod is installed: its accent
3// on the glyph, its muted colour on the word, its error colour on stop. The
4// skin is read from that mod's own state (`prefs`, `custom`, `isLight`), which
5// names a skin but holds only the colours of the ones a person made, so the
6// three slots of its built-in skins are kept here. A skin this file does not
7// know leaves the trigger as it is without a skin.
8
9export type Paint = {
10  accent: string
11  muted: string
12  stop: string
13  /** The skin's icon set: glyphs every font has, when it is `ascii`. */
14  isAscii: boolean
15}
16
17type Slots = { user: string; muted: string; err: string }
18
19const BUILT_IN: Readonly<Record<string, Slots>> = {
20  noir: { user: '#f5f5f5', muted: '#8c8c8c', err: '#f87171' },
21  'tokyo-night': { user: '#7aa2f7', muted: '#737aa2', err: '#f7768e' },
22  dracula: { user: '#bd93f9', muted: '#6272a4', err: '#ff5555' },
23  nord: { user: '#88c0d0', muted: '#7b88a1', err: '#bf616a' },
24  gruvbox: { user: '#fabd2f', muted: '#928374', err: '#fb4934' },
25  catppuccin: { user: '#cba6f7', muted: '#7f849c', err: '#f38ba8' },
26  mono: { user: '#e0e0e0', muted: '#7a7a7a', err: '#ffffff' },
27}
28
29// The one built-in that carries its own palette for a light background.
30const LIGHT: Readonly<Record<string, Slots>> = {
31  noir: { user: '#111111', muted: '#6b6b6b', err: '#c42b2b' },
32}
33
34const HEX = /^#[0-9a-f]{6}$/i
35
36const isRecord = (value: unknown): value is Record<string, unknown> =>
37  typeof value === 'object' && value !== null
38
39/** `amount` of the way from `hex` to black, as the skins mod deepens a colour. */
40const deepen = (hex: string, amount: number): string =>
41  `#${[1, 3, 5]
42    .map(at => Math.round(parseInt(hex.slice(at, at + 2), 16) * (1 - amount)))
43    .map(value => Math.max(0, Math.min(255, value)).toString(16).padStart(2, '0'))
44    .join('')}`
45
46// For a light background the skins mod derives a palette: colours deepened
47// until they read on white, secondary text a fixed grey.
48const toLight = (slots: Slots): Slots => ({
49  user: deepen(slots.user, 0.45),
50  muted: '#6b6b6b',
51  err: deepen(slots.err, 0.25),
52})
53
54const slotsOf = (name: string, custom: unknown): Slots | undefined => {
55  const own = BUILT_IN[name]
56  if (own !== undefined) {
57    return own
58  }
59
60  const made = isRecord(custom) ? custom[name] : undefined
61  if (!isRecord(made)) {
62    return undefined
63  }
64
65  // A made skin is its base with the slots it changed laid over it.
66  const base = (typeof made.base === 'string' ? BUILT_IN[made.base] : undefined) ?? BUILT_IN.noir
67  const palette = isRecord(made.palette) ? made.palette : {}
68  const slot = (key: keyof Slots, fallback: string): string => {
69    const value = palette[key]
70
71    return typeof value === 'string' && HEX.test(value) ? value : fallback
72  }
73
74  return base === undefined
75    ? undefined
76    : { user: slot('user', base.user), muted: slot('muted', base.muted), err: slot('err', base.err) }
77}
78
79/**
80 * What the trigger wears, from the skins mod's `prefs`, `custom` and `isLight`
81 * as its state holds them; undefined with no skins mod, its skin off, or a
82 * skin unknown here.
83 */
84export const paintOf = (prefs: unknown, custom: unknown, isLight: unknown): Paint | undefined => {
85  if (!isRecord(prefs) || typeof prefs.skin !== 'string' || prefs.skin === 'off') {
86    return undefined
87  }
88
89  const dark = slotsOf(prefs.skin, custom)
90  if (dark === undefined) {
91    return undefined
92  }
93
94  const slots = isLight === true ? (LIGHT[prefs.skin] ?? toLight(dark)) : dark
95
96  return { accent: slots.user, muted: slots.muted, stop: slots.err, isAscii: prefs.icons === 'ascii' }
97}
98
hooks/speech-text.ts 111 lines
1// Markdown as Claude writes it, turned into what is worth hearing: the prose
2// kept, the marks dropped, and what cannot be read aloud (code, tables, links)
3// named in a word.
4
5const TERMINAL = /[.!?:;,…]$/
6
7const basename = (path: string): string => {
8  const cut = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\'))
9
10  return cut < 0 ? path : path.slice(cut + 1)
11}
12
13// A path of two folders or more is its file's name; `name.ts:42` is a line.
14const sayPaths = (text: string): string =>
15  text
16    .replace(/(?:[A-Za-z]:)?(?:[\\/][\w.@~-]+){2,}[\\/]?|(?:[\w.@~-]+[\\/]){2,}[\w.@~-]*/g, path =>
17      basename(path.replace(/[\\/]$/, '')),
18    )
19    .replace(/(\.[A-Za-z]{1,5}):(\d+)(?::\d+)?\b/g, '$1 line $2')
20
21// A heading or a list item is a sentence of its own: its mark goes, and
22// `closed` gives it the stop that makes the voice pause.
23const isOwnSentence = (line: string): boolean =>
24  /^\s{0,3}#{1,6}\s/.test(line) || /^\s*(?:[-*+]|\d+[.)])\s+/.test(line)
25
26const unmarked = (line: string): string =>
27  line
28    .replace(/^\s{0,3}#{1,6}\s+/, '')
29    .replace(/^\s*>+\s?/, '')
30    .replace(/^\s*(?:[-*+]|\d+[.)])\s+(?:\[[ xX]\]\s+)?/, '')
31
32const closed = (said: string): string => (said !== '' && !TERMINAL.test(said) ? `${said}.` : said)
33
34const sayInline = (text: string): string =>
35  sayPaths(
36    text
37      .replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1')
38      .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
39      .replace(/<https?:\/\/[^>\s]+>|https?:\/\/[^\s)>\]]*[^\s)>\].,;:!?]/g, 'link')
40      .replace(/<\/?[A-Za-z][^>]*>/g, ' ')
41      .replace(/`([^`]*)`/g, '$1'),
42  )
43    .replace(/\*+|~~/g, '')
44    .replace(/(^|[^\w])_{1,2}([^_\n]+?)_{1,2}(?=[^\w]|$)/g, '$1$2')
45    .replace(/\s*(?:->|=>|→|⇒)\s*/g, ' to ')
46    .replace(/[\p{Extended_Pictographic}️‍]/gu, '')
47
48/** Cuts at the last sentence that fits, and says that more is on screen. */
49const hold = (text: string, limit: number): string => {
50  if (limit <= 0 || text.length <= limit) {
51    return text
52  }
53
54  const head = text.slice(0, limit)
55  const end = Math.max(
56    head.lastIndexOf('. '),
57    head.lastIndexOf('.\n'),
58    head.lastIndexOf('? '),
59    head.lastIndexOf('! '),
60    head.lastIndexOf('\n'),
61  )
62  const kept = end > limit / 2 ? head.slice(0, end + 1) : head.slice(0, head.lastIndexOf(' '))
63
64  return `${kept.trim()}\nThe rest is on screen.`
65}
66
67/**
68 * What to say for `markdown`; `""` when nothing of it is prose. `limit` holds
69 * the spoken text to that many characters (0: no limit).
70 */
71export const toSpeech = (markdown: string, limit = 0): string => {
72  const lines: string[] = []
73  let isInFence = false
74  let isInTable = false
75
76  for (const line of markdown.replace(/\r\n?/g, '\n').split('\n')) {
77    if (/^\s*(?:```|~~~)/.test(line)) {
78      if (!isInFence) {
79        lines.push('Code block.')
80      }
81      isInFence = !isInFence
82      continue
83    }
84    if (isInFence) {
85      continue
86    }
87
88    const isTableRow = /^\s*\|.*\|\s*$/.test(line)
89    if (isTableRow) {
90      if (!isInTable) {
91        lines.push('Table.')
92      }
93      isInTable = true
94      continue
95    }
96    isInTable = false
97
98    if (/^\s*(?:[-*_]\s*){3,}$/.test(line)) {
99      continue
100    }
101
102    const prose = sayInline(unmarked(line)).replace(/[ \t]+/g, ' ').trim()
103    const said = isOwnSentence(line) ? closed(prose) : prose
104    if (said !== '' && /[\p{L}\p{N}]/u.test(said)) {
105      lines.push(said)
106    }
107  }
108
109  return hold(lines.join('\n'), limit)
110}
111
types/index.d.ts 12 lines
1/** Whether a transcript row's text is the one being read aloud now. */
2export type IsPlaying = boolean
3
4/** What the speech process is doing, as the footer's indicator says it. */
5export type Saying = 'idle' | 'loading' | 'speaking'
6
7declare module 'claude-code' {
8  interface PluginState {
9    'read-aloud': { playing: StateFamily<IsPlaying>; saying: Saying }
10  }
11}
12