Listen to Claude's replies while the sentence being read is highlighted. Local voices (Kokoro, or your system's own), several languages, auto-read, and a…

<img src="docs/banner-light.png" width="100%" alt="Read-along, a mod for Claude Code: Claude's replies, read aloud, with the sentence you're hearing highlighted. A terminal shows a reply with one sentence highlighted and a player bar under it, while a small orange robot with headphones lies on the terminal saying hello in Korean, Japanese, Spanish and English.">
A mod for Claude Code: listen to Claude's replies while the sentence being read is highlighted in the terminal, so you can follow along with your eyes and ears together. Try the demo on its web page.

<sub>No sound in a GIF; the web page has the recordings with the voice.</sub>
Read-along is for anyone who would rather hear long replies than read them: people with dyslexia, low vision, ADHD or eye strain, people reading in a second language, and anyone who wants to look away from the screen while Claude explains something. It is not a screen reader and doesn't replace one.
Kokoro and Pocket TTS voices, made with read-along's own setup on a laptop CPU. Each link opens a short MP3; the web page plays them in place, with their text.
| Voice | Language | Sample |
|---|---|---|
kokoro:af_heart | English (US) | listen · at 2× speed |
kokoro:am_fenrir | English (US) | listen |
kokoro:bf_emma | English (UK) | listen |
kokoro:jf_alpha | Japanese | listen |
kokoro:ef_dora | Spanish | listen |
kokoro:ff_siwis | French | listen |
pocket:alba | English (Pocket TTS) | listen |
pocket:marius | English (Pocket TTS) | listen |
pocket:juergen | German (Pocket TTS) | listen |
python3. The speech service needs only the standard library.ffplay (comes with ffmpeg) or SoX's play.| Engine | Where | Quality | Setup |
|---|---|---|---|
| Kokoro | everywhere | natural, neural | /read-along setup (about 1 GB, CPU is fine) |
| macOS voices | macOS | good (Enhanced and Premium voices are better) | built in |
| Windows voices | WSL | good with the newer "natural" voices | built in |
| eSpeak NG | Linux, WSL | robotic, but about 100 languages | sudo apt install espeak-ng |
| Pocket TTS (optional) | everywhere | natural, neural; 27 voices, and your own | /read-along setup (about 1.3 GB, CPU is fine) |
Other languages use the system voices or eSpeak NG (its languages).
Kokoro, by hexgrad, is the default: an 82-million-parameter model that speaks English (US and UK), Japanese, Chinese, Spanish, French, Hindi, Italian and Portuguese (its voices). Setup installs kokoro-onnx, which runs on the CPU at about 3 times real time, and auto picks Kokoro whenever it has a voice for the language.
Pocket TTS, by Kyutai, is optional. It adds 27 voices (pocket:alba, pocket:marius, …) and can speak in a voice of your own, cloned from a short recording. It reads the main language when it has a model for it (English, French, German, Italian, Portuguese, Spanish or Dutch), and English otherwise, at about 3 times real time on 4 CPU threads. auto never picks it, so you choose its voices yourself, in the settings or with /read-along voice pocket:alba.
Install it with /read-along setup (Claude asks whether you want it), or yourself with python3 <plugin folder>/bin/setup.py pocket. It goes into a Python of its own in the data folder, with PyTorch for the CPU: about 1.3 GB with its English model. Its model and built-in voices download from Hugging Face the first time each is used. Already have Pocket TTS? Point read-along at its Python instead: setup.py pocket --python /path/to/python.
A voice of your own. Put a recording in the voices folder (~/.local/share/claude-read-along/voices; on macOS, under ~/Library/Application Support/claude-read-along), one file per voice, and run /read-along restart: me.wav becomes the voice pocket:me, listed under "Your voices" in the settings. About 10 to 30 seconds of clear speech works well. A .safetensors voice state made by pocket-tts export-voice works too, and loads at once. Use setup.py pocket --voices FOLDER to keep your voices somewhere else.
Cloning a voice from a recording uses Kyutai's gated model:
hf auth login (or set HF_TOKEN).Without that, Pocket TTS loads a model that can't clone: the built-in voices and saved voice states still work, and setup says what's missing.
Clone only your own voice, or a voice whose owner has agreed. Kyutai's terms forbid cloning or impersonating anyone without their explicit consent, and passing generated speech off as a real recording (Prohibited use). Your recordings are read only on your machine and never uploaded.
setup.py pocket-off stops read-along using Pocket TTS; delete the data folder's pocket folder to free the space.
At the Claude Code prompt:
/plugin install read-along --marketplace tomfluff/claude-read-along
Answer y to add the marketplace, then pick the user scope.
Next, check what your machine has:
/read-along doctor
To get the voices, run /read-along setup. It puts a request in the prompt box; press Enter and Claude walks you through it with the mod's setup skill: Kokoro, and Pocket TTS if you want it too, asking before it installs anything. To do it yourself instead, run python3 <plugin folder>/bin/setup.py kokoro (add --japanese for Japanese), and optionally python3 <plugin folder>/bin/setup.py pocket. Both go into their own folder, ~/.local/share/claude-read-along (on macOS, ~/Library/Application Support/claude-read-along), and nowhere else.
To see what the mod hooks and calls before you install it, clone the repository and run claude plugin validate . in it.
p pause, s stop, b back, n next, r read the last reply, o settings./read-along opens the settings: a voice per language, speed, volume, auto-read and the player's place. Changes last for the session; save as defaults keeps them.| Command | |
|---|---|
/read-along read | read the last reply |
/read-along stop, pause | stop, or pause and resume |
/read-along speed 1.5 | set the speed (0.5 to 4) |
/read-along voice kokoro:af_heart | set the main voice; voice ja kokoro:jf_alpha for Japanese |
/read-along voices | list the installed voices |
/read-along auto replies all | auto-read: off, final (each turn's last reply) or all |
/read-along auto mine on | also read your own messages |
/read-along auto thinking on | with auto-read all, also read Claude's thinking where it's shown as updates |
/read-along words on | say numbers and symbols as words (optional; asks Claude Haiku, see below) |
/read-along save | keep this session's choices as the defaults |
/read-along reset | put this session's choices back to your saved defaults (asks first; also "reset…" in the settings) |
/read-along reset defaults | put the saved settings back to the plugin's own (says what changes, then asks) |
/read-along doctor | what works on this machine and what to install |
/read-along setup | ask Claude to set up the Kokoro voices |
/read-along restart | restart the speech service |
All in /config under read-along.
| Setting | Default | |
|---|---|---|
| Voices | auto | A voice per language; see below. |
| Speed | 1.25 | 0.5 to 4. |
| Volume | 100 | Percent, 0 to 200. |
| Auto-read Claude's replies | off | off, final, all. |
| Auto-read my messages | off | |
| Read thinking updates | off | With auto-read all, also read Claude's thinking. Turn it on where your Claude Code shows thinking as updates in the transcript; Claude Code doesn't tell read-along whether it's shown. |
| Say numbers and symbols as words | off | Optional: asks Claude Haiku how to say a message's numbers and symbols before reading it. See below. |
| Read code blocks | off | Off says "code block" instead. |
| Highlight | reverse | Swaps text and background colours, which works on any theme. Or a colour: yellow, #f1fa8c/#282a36 (background/text). |
| Message buttons | hover | hover, glyph (only over a ♪ at the message's right; no flashing while scrolling), always, off. |
| Player | band | band above the prompt, hint (dim text under the prompt), message (under the message being read), band+message. |
| Icons | text | nerd for Nerd Font media icons. |
| Lower other apps while reading | off | WSL only for now: turns music and browsers down while speaking. |
A voice is written engine:name:
kokoro:af_heart, kokoro:jf_alpha: Kokoro (the first letter is the language, the second the gender)mac:Samantha, mac:Ava (Premium): macOSwin:Microsoft Aria: Windows, from WSLespeak:en-us, espeak:he: eSpeak NGpocket:alba, pocket:me: Pocket TTS, its own voices and yours (Pocket TTS)auto: the best voice installed for the languageThe Voices setting gives each language group a list. Latin-script text is read by main, and every other script by its own group (ja, zh, ko, he, ar, ru, hi, el, th). The first voice in a list is the one used; the voice button steps through the main list:
main=kokoro:af_heart, kokoro:bf_emma, mac:Samantha; ja=kokoro:jf_alpha; he=mac:Carmit
If your Latin-script text isn't English, tell auto which language it is: python3 <plugin folder>/bin/setup.py language es.
Voices read some written forms badly: v0.9.1, 2.5x, ~/.claude, ->, API. With Say numbers and symbols as words on (in the settings, or /read-along words on), read-along first asks Claude Haiku how a person would say each such span in a message, and the voice says that. The message on screen stays as written, and the highlight still follows it.
It's optional and off by default, because it changes what leaves your machine:
It uses the haiku alias, so it follows the newest Haiku model.
The mod runs a small speech service on your machine. It is reached over a Unix socket that only your user can open, and it sends nothing over the network. The network is used only to get the voices: by setup, where Python packages come from PyPI and Kokoro's model from the kokoro-onnx releases, checked against a pinned SHA-256; and, if you install Pocket TTS, by Pocket TTS fetching its model and built-in voices from Hugging Face the first time each is used. What you read is never sent anywhere, and neither are your voice recordings, with one optional exception: with numbers as words on, each message read goes once to Claude Haiku through your Claude Code session.
/read-along doctor, then look at the service's log: /tmp/read-along-<your uid>.log./read-along restart.enabledPlugins in ~/.claude/settings.json, and a mod that draws its own line there without passing the band on hides the mods after it. Read-along notices this, says so once, and shows the player on the line under the prompt meanwhile; /read-along doctor reports it too. To get the band back, move "read-along@claude-read-along": true above that mod in enabledPlugins and run /reload-plugins, or set player to hint to keep it under the prompt./read-along read, pause, stop and speed 2 still work from both, and the voice always plays on the computer running the session. /read-along doctor in the terminal lists, under "Surfaces", each app following the session and what it has asked read-along to draw.flowchart LR
reply["Claude's reply"] --> mod["read-along<br/>(in Claude Code)"]
mod -- "text" --> service["speech service<br/>(on your machine)"]
service -- "a sentence" --> engine["voice<br/>Kokoro · macOS · Windows · eSpeak · Pocket TTS"]
engine -- "audio" --> player["ffplay or SoX"] --> speakers(["your speakers"])
service -. "the sentence being heard" .-> mod
plugin/hooks/register.tsx is the Claude Code side: the buttons, the highlight, the player and the commands. plugin/bin/speakd.py is the speech service, shared by every Claude Code session so only one voice talks at a time. It splits a message into sentences, makes each one with the engines in plugin/bin/engines.py, and plays them through ffplay or SoX, sped up without changing pitch. plugin/bin/setup.py installs Kokoro.
MIT licence; see LICENSE. Read-along uses, without bundling: Kokoro-82M and misaki (Apache-2.0), kokoro-onnx (MIT), eSpeak NG (GPL-3.0, run as a separate program), optionally Pocket TTS by Kyutai (code MIT, model weights CC BY 4.0, run in its own Python; its built-in voices come from kyutai/tts-voices, each under its own licence, some non-commercial), and FFmpeg or SoX for playback.
hooks/register.tsx 1565 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderElement, RenderInput, RenderSurface } from 'claude-code'
3
4import type { ReadAlongCursor, ReadAlongInfo, ReadAlongSettings, ReadAlongStatus, ReadAlongVoice } from '../types'
5
6// Read-along: buttons on every message, a player while reading, auto-read,
7// and the sentence being read highlighted in its message.
8// The speech itself is bin/speakd.py, one local service per user that every
9// session shares (so only one voice talks at a time), reached over HTTP on a
10// Unix socket. A voice is `<engine>:<name>` (kokoro:af_heart, win:Microsoft
11// Aria, mac:Samantha, espeak:en-us, pocket:me) or `auto`, the best one installed.
12
13const now = atom({ plugin: 'read-along', key: 'now' } as const, null)
14const current = atom({ plugin: 'read-along', key: 'prefs' } as const, null)
15const cursor = atom({ plugin: 'read-along', key: 'cursor' } as const, null)
16const machine = atom({ plugin: 'read-along', key: 'info' } as const, null)
17const browsing = atom({ plugin: 'read-along', key: 'browsing' } as const, null)
18const installed = atom({ plugin: 'read-along', key: 'voices' } as const, null)
19const bandCheck = atom({ plugin: 'read-along', key: 'bandCheck' } as const, 0)
20const confirmReset = atom({ plugin: 'read-along', key: 'confirmReset' } as const, false)
21
22const PANE = 'read-along'
23const SPEED_STEP = 0.25
24const VOLUME_STEP = 10
25/**
26 * The player's status icon, by state. `nerd` uses Nerd Font icons (speaker,
27 * pause, hourglass); `text` uses plain symbols, which every terminal has.
28 */
29const ICONS = {
30 nerd: { idle: '\u{F057E}', playing: '\u{F057E}', paused: '\u{F03E4}', loading: '\u{F051F}' },
31 text: { idle: '♫', playing: '♫', paused: '‖', loading: '…' },
32} as const
33const icons = () => (str('icons', 'text') === 'nerd' ? 'nerd' : 'text')
34const icon = (state: 'idle' | 'playing' | 'paused' | 'loading') => ICONS[icons()][state]
35/** Button glyphs, Nerd Font media controls or their plain-text stand-ins. */
36const BUTTONS = {
37 nerd: { play: '\u{F040A}', pause: '\u{F03E4}', stop: '\u{F04DB}', back: '\u{F04AE}', forward: '\u{F04AD}', slower: '\u{F0374}', faster: '\u{F0415}', restart: '\u{F0450}' },
38 text: { play: '▶', pause: '‖', stop: '■', back: '«', forward: '»', slower: '−', faster: '+', restart: '⟲' },
39} as const
40const btn = (name: keyof (typeof BUTTONS)['text']) => BUTTONS[icons()][name]
41const stateOf = (status: ReadAlongStatus) => (status.state === 'paused' ? 'paused' : status.state === 'loading' ? 'loading' : 'playing')
42const DUCK_LEVELS = ['off', '50%', '30%', '15%']
43/** Other apps' volume while reading, as a fraction of their own: `30%` → 0.3, `off` → 1. */
44const duckLevel = (duckOthers: string) => (duckOthers === 'off' ? 1 : Math.max(0, Math.min(1, parseFloat(duckOthers) / 100 || 1)))
45
46type $ = EngineInterface
47type Options = Readonly<Record<string, string | number | boolean | readonly string[]>>
48type MessageEvent = RenderInput<'AssistantMessage' | 'UserMessage'>
49
50/**
51 * Voice groups: Latin-script text goes to `main`, every other script to its
52 * own group, so a Japanese sentence in an English reply gets a Japanese voice.
53 */
54const GROUPS: Record<string, string> = {
55 main: 'Main',
56 ja: 'Japanese',
57 zh: 'Chinese',
58 ko: 'Korean',
59 he: 'Hebrew',
60 ar: 'Arabic',
61 ru: 'Cyrillic',
62 hi: 'Hindi',
63 el: 'Greek',
64 th: 'Thai',
65}
66
67let opt: Options = {}
68const str = (k: string, d: string) => (typeof opt[k] === 'string' ? (opt[k] as string) : d)
69const num = (k: string, d: number) => (typeof opt[k] === 'number' ? (opt[k] as number) : d)
70const bool = (k: string, d: boolean) => (typeof opt[k] === 'boolean' ? (opt[k] as boolean) : d)
71
72/**
73 * The `voices` setting: `main=kokoro:af_heart, win:Microsoft Aria; ja=auto`.
74 * A list with no `group=` is the main one. Each group's first voice is the
75 * one used; the voice button steps through the main list.
76 */
77export const parseVoices = (text: string): Record<string, string[]> => {
78 const out: Record<string, string[]> = {}
79 for (const part of text.split(';')) {
80 const m = part.match(/^\s*([a-z]{2,4})\s*=(.*)$/)
81 const group = m?.[1] ?? 'main'
82 const voices = (m?.[2] ?? part)
83 .split(',')
84 .map(v => v.trim())
85 .filter(Boolean)
86 if (voices.length) out[group] = voices
87 }
88 if (!out.main) out.main = ['auto']
89 return out
90}
91
92export const formatVoices = (lists: Record<string, string[]>) =>
93 Object.entries(lists)
94 .filter(([, voices]) => voices.length)
95 .map(([group, voices]) => `${group}=${voices.join(', ')}`)
96 .join('; ')
97
98const favourites = () => parseVoices(str('voices', 'auto'))
99
100const defaults = (): ReadAlongSettings => ({
101 voices: Object.fromEntries(Object.entries(favourites()).map(([g, list]) => [g, list[0] ?? 'auto'])),
102 speed: num('speed', 1.25),
103 volume: num('volume', 100),
104 duckOthers: str('duckOthers', 'off'),
105 autoReadReplies: str('autoReadReplies', 'off'),
106 autoReadMine: bool('autoReadMine', false),
107 readThinking: bool('readThinking', false),
108 rewriteForSpeech: bool('rewriteForSpeech', false),
109})
110/** The message buttons' form on a surface. The phone has no pointer to hover with, so it shows them. */
111const buttons = (surface: RenderSurface = 'terminal') => {
112 const chosen = str('buttons', 'hover')
113 return surface === 'mobile' && (chosen === 'hover' || chosen === 'glyph') ? 'always' : chosen
114}
115/** `player`: band | hint | message | band+message. */
116const playerAt = () => str('player', 'band')
117/** Whether a read's progress shows under its message. The phone draws no band, so there it always does. */
118const showsUnderMessage = (surface: RenderSurface = 'terminal') =>
119 surface === 'mobile' || playerAt() === 'message' || playerAt() === 'band+message'
120
121/**
122 * What each surface has asked this mod to draw since it loaded, and the remote
123 * clients that attached: \`/read-along doctor\` shows them, so a surface that
124 * shows no buttons can be told apart from one that never asks.
125 */
126const drawn: Record<string, Record<string, number>> = {}
127const attached = new Set<string>()
128const saw = (e: { surface: RenderSurface; component: string }) => {
129 const bySurface = (drawn[e.surface] ??= {})
130 bySurface[e.component] = (bySurface[e.component] ?? 0) + 1
131}
132/** The bottom player's form: a band above the prompt, a tail on the hint line, or none. */
133const bottomKind = () => (playerAt() === 'message' ? 'none' : playerAt() === 'hint' ? 'hint' : 'band')
134
135/**
136 * Whether another mod hides the player band. The band is one tree the mods
137 * draw in turn, in \`enabledPlugins\` order, and a mod listed before
138 * read-along that draws its own band without passing it on keeps read-along's
139 * hook from running at all, so read-along can't see it from inside the hook.
140 * Instead, after the session starts and after each turn, the clock redraws
141 * the band and the hint line (bumping \`bandCheck\`, which only those two
142 * read: \`$.ui.invalidate\` would redraw every message's buttons too, the
143 * transcript jitter again) and watches which of read-along's hooks run: the
144 * hint line under the prompt without the band means the band is taken. The player then shows on
145 * the hint line meanwhile, and a toast says so, once a session.
146 */
147const band = {
148 /** undefined until checked; then whether the last check found the band taken. */
149 isHidden: undefined as boolean | undefined,
150 /** The clock tick to check at (0: none due) and the one the check began at. */
151 dueTick: 0,
152 startTick: 0,
153 sawBand: false,
154 sawHint: false,
155 isToasted: false,
156}
157const checkBandSoon = () => {
158 band.dueTick = ticks + 8 // about 3 s on, so the other mods have drawn
159}
160const hidesBand = () => bottomKind() === 'band' && band.isHidden === true
161
162/** One step of the check, from the clock. */
163async function checkBand($: $) {
164 if (band.startTick === 0) {
165 if (!band.dueTick || ticks < band.dueTick || bottomKind() !== 'band') return
166 band.dueTick = 0
167 band.startTick = ticks
168 band.sawBand = band.sawHint = false
169 await update($, bandCheck, n => n + 1)
170 return
171 }
172 if (ticks - band.startTick < 4) return // about 1.5 s for the redraw
173 band.startTick = 0
174 if (!band.sawHint) return // no prompt drawn in the terminal: nothing to tell
175 const isHidden = !band.sawBand
176 const isChanged = isHidden !== band.isHidden
177 band.isHidden = isHidden
178 if (isChanged) await update($, bandCheck, n => n + 1) // the hint line takes the player, or gives it back
179 if (isHidden && !band.isToasted) {
180 band.isToasted = true
181 $.ui.toast(
182 'Read-along: another mod hides the player above the prompt, so it shows under the prompt for now. Move read-along up in enabledPlugins (~/.claude/settings.json) to get it back; /read-along doctor says more.',
183 { timeoutMs: 12000 },
184 )
185 }
186}
187
188let socketPath = ''
189let isEnsured = false
190let lastError: string | null | undefined
191
192// ------------------------------------------------------------ the service
193
194async function sock($: $) {
195 if (!socketPath) socketPath = `/tmp/read-along-${(await $.env.get('USER')) ?? 'user'}.sock`
196 return socketPath
197}
198
199const speakd = ($: $) => `${$.plugin.root}/bin/speakd.py`
200
201async function ensure($: $) {
202 if (isEnsured) return
203 const run = await $.process.run(['python3', '-I', speakd($), '--ensure', '--socket', await sock($)], { timeoutMs: 20000 })
204 if (run.exitCode !== 0) throw new Error(`speech service didn't start: ${run.stdout || run.stderr}`)
205 isEnsured = true
206}
207
208async function call<T = ReadAlongStatus>($: $, path: string, body?: unknown): Promise<T> {
209 const go = async () => {
210 const res = await $.http.fetch(`http://speakd${path}`, {
211 method: body === undefined ? 'GET' : 'POST',
212 headers: { 'Content-Type': 'application/json' },
213 body: body === undefined ? undefined : JSON.stringify(body),
214 socketPath: await sock($),
215 })
216 return JSON.parse(res.text) as T
217 }
218 await ensure($)
219 try {
220 return await go()
221 } catch {
222 isEnsured = false // the service exited (idle timeout): start it again once
223 await ensure($)
224 return go()
225 }
226}
227
228async function settings($: $): Promise<ReadAlongSettings> {
229 const base = defaults()
230 const s: Partial<ReadAlongSettings> = (await read($, current)) ?? {}
231 return { ...base, ...s, voices: { ...base.voices, ...(s.voices ?? {}) } }
232}
233
234function setSettings($: $, change: Partial<ReadAlongSettings>) {
235 return update($, current, s => ({ ...defaults(), ...(s ?? {}), ...change }))
236}
237
238/** What the machine offers (engines, ducking), fetched once a session. */
239async function loadInfo($: $) {
240 try {
241 const info = await call<ReadAlongInfo>($, '/info')
242 await update($, machine, () => info)
243 } catch {
244 // the first read reports it
245 }
246}
247
248/**
249 * Every installed voice, for the settings pane's lists: asked again each time
250 * a list opens, since voices come and go (an engine set up, a Pocket TTS
251 * voice file added), and written only when they changed.
252 */
253async function loadVoices($: $) {
254 try {
255 const { voices } = await call<{ voices: ReadAlongVoice[] }>($, '/voices')
256 if (JSON.stringify(await read($, installed)) !== JSON.stringify(voices)) await update($, installed, () => voices)
257 } catch {
258 // the pane says it couldn't list them
259 }
260}
261
262/**
263 * Writes the service's status into state, and only what changed: every write
264 * redraws its readers, and every message row reads \`cursor\`, so an
265 * unchanged write (the idle check every 3 s) redrew the whole transcript and
266 * made it jitter when it met a message being sent.
267 */
268async function show($: $, status: ReadAlongStatus) {
269 const isIdle = status.state === 'idle' && !status.queue
270 const nextNow = isIdle ? null : status
271 if (JSON.stringify(await read($, now)) !== JSON.stringify(nextNow)) await update($, now, () => nextNow)
272 const nextCursor: ReadAlongCursor | null = isIdle || !status.key ? null : { key: status.key, text: status.text ?? '' }
273 const at = await read($, cursor)
274 if (at?.key !== nextCursor?.key || at?.text !== nextCursor?.text) await update($, cursor, () => nextCursor)
275 if (status.error && status.error !== lastError) $.ui.toast(`Read-along: ${status.error}`)
276 lastError = status.error
277}
278
279/**
280 * The clock that keeps the player and the highlight current. It runs from
281 * session.start for the session's life: a timer started inside another
282 * dispatch (a press, an append) loses its \`$\` once that dispatch ends,
283 * which froze the player mid-read. While a read runs it asks the service every
284 * 400 ms; otherwise every 3 s, so a read another session started shows too.
285 * It never starts the service: one that is not running just means no read.
286 */
287let isActive = false
288let ticks = 0
289let ticker: { cancel: () => void } | undefined
290
291function poll() {
292 isActive = true
293}
294
295async function tick($: $) {
296 ticks += 1
297 await checkBand($)
298 if (!isActive && ticks % 8 !== 0) return
299 try {
300 const res = await $.http.fetch('http://speakd/status', { socketPath: await sock($) })
301 const status = JSON.parse(res.text) as ReadAlongStatus
302 await show($, status)
303 isActive = !(status.state === 'idle' && !status.queue)
304 } catch {
305 isActive = false
306 if ((await read($, now)) !== null) await update($, now, () => null)
307 if ((await read($, cursor)) !== null) await update($, cursor, () => null)
308 }
309}
310
311const SAY_SYSTEM = [
312 'You prepare text for a text-to-speech voice. List every span in the text that a voice would read wrongly or awkwardly as written:',
313 'numbers, versions, dates, times, money, percentages, units, ranges, symbols and operators, abbreviations, acronyms read as letters, file names and paths, code identifiers.',
314 'For each, give the exact text as written (without markdown backticks or asterisks) and how a person would say it aloud, in the language of the sentence around it.',
315 'Leave ordinary words alone. Answer with a JSON array of [written, spoken] pairs and nothing else; [] when there is nothing to change.',
316 'Example: [["v0.9.1", "version zero point nine point one"], ["2.5x", "two and a half times"], ["~/.claude", "the home dot claude folder"]]',
317].join(' ')
318
319/** Text worth asking about: digits, symbols, capitals in a row, or a dot inside a word. */
320const NEEDS_WORDS = /[0-9%$€£¥#@&\/\\_~^<>=+|]|\b[A-Z]{2,}\b|\w\.\w/
321
322/** The pairs from Haiku's answer: a JSON array of [written, spoken], each written form found in the text. */
323export const parseSayPairs = (answer: string, text: string): [string, string][] => {
324 const json = answer.slice(answer.indexOf('['), answer.lastIndexOf(']') + 1)
325 try {
326 const raw: unknown = JSON.parse(json)
327 if (!Array.isArray(raw)) return []
328 return raw
329 .filter((p): p is [string, string] => Array.isArray(p) && p.length === 2 && typeof p[0] === 'string' && typeof p[1] === 'string')
330 .map(([written, said]): [string, string] => [written.replace(/[`*]/g, '').trim(), said.trim()])
331 .filter(([written, said]) => written && said && written.length <= 200 && said.length <= 400 && text.includes(written))
332 .slice(0, 200)
333 } catch {
334 return []
335 }
336}
337
338let hasSaidSayFailed = false
339
340/** How to say a message's numbers and symbols, asked of Claude Haiku once per message. */
341const sayCache = new Map<string, [string, string][]>()
342async function sayPairs($: $, text: string, key: string): Promise<[string, string][]> {
343 if (!NEEDS_WORDS.test(text)) return []
344 const known = sayCache.get(key)
345 if (known) return known
346 // `haiku` is the alias for the latest Haiku, so this follows new releases.
347 const r = await $.model.complete({ model: 'haiku', system: SAY_SYSTEM, prompt: text, maxTokens: 4000, effort: 'low', timeoutMs: 10000 })
348 const pairs = r.isAnswered ? parseSayPairs(r.text, text) : []
349 if (r.isAnswered) {
350 if (sayCache.size > 100) sayCache.delete(sayCache.keys().next().value ?? '')
351 sayCache.set(key, pairs)
352 }
353 return pairs
354}
355
356async function speak($: $, text: string, key: string, label: string, mode: 'now' | 'queue') {
357 if (!text.trim()) return
358 const s = await settings($)
359 // Read as written when Haiku fails or is slow: the words matter more than their form.
360 const say = s.rewriteForSpeech
361 ? await sayPairs($, text, key).catch(err => {
362 if (!hasSaidSayFailed) $.ui.toast(`Read-along: couldn't ask Claude Haiku how to say numbers (${String(err)}); reading as written.`)
363 hasSaidSayFailed = true
364 return []
365 })
366 : []
367 try {
368 const status = await call($, '/speak', {
369 say,
370 text,
371 key,
372 label,
373 mode,
374 speed: s.speed,
375 volume: s.volume / 100,
376 duck: duckLevel(s.duckOthers),
377 voices: s.voices,
378 readCode: bool('readCode', false),
379 })
380 await show($, status)
381 poll()
382 } catch (err) {
383 $.ui.toast(`Read-along: ${String(err)}`)
384 }
385}
386
387async function control($: $, action: string) {
388 try {
389 await show($, await call($, '/control', { action }))
390 poll()
391 } catch (err) {
392 $.ui.toast(`Read-along: ${String(err)}`)
393 }
394}
395
396async function setSpeed($: $, speed: number) {
397 speed = Math.round(Math.max(0.5, Math.min(4, speed)) * 100) / 100
398 await setSettings($, { speed })
399 if (await read($, now)) await show($, await call($, '/set', { speed }))
400}
401
402async function setVolume($: $, volume: number) {
403 volume = Math.round(Math.max(0, Math.min(200, volume)))
404 await setSettings($, { volume })
405 if (await read($, now)) await show($, await call($, '/set', { volume: volume / 100 }))
406}
407
408async function setDuck($: $, duckOthers: string) {
409 await setSettings($, { duckOthers })
410 if (await read($, now)) await show($, await call($, '/set', { duck: duckLevel(duckOthers) }))
411}
412
413/**
414 * Reads Claude's last reply. The buttons call this directly: a mod's own
415 * \`$.command.run\` never reaches its own command.run hook.
416 */
417async function readLast($: $) {
418 const last = (await $.session.messages()).filter(m => m.role === 'assistant' && m.text.trim()).pop()
419 if (last) await speak($, last.text, messageKey('a', last.text), 'reply', 'now')
420 return last !== undefined
421}
422
423/** The voice button: the next voice in the main list. */
424async function nextVoice($: $) {
425 const s = await settings($)
426 const list = favourites().main ?? ['auto']
427 const at = list.indexOf(s.voices.main ?? 'auto')
428 await setVoice($, 'main', list[(at + 1) % list.length] ?? 'auto')
429}
430
431async function setVoice($: $, group: string, voice: string) {
432 const s = await settings($)
433 const voices = { ...s.voices, [group]: voice }
434 await setSettings($, { voices })
435 if (await read($, now)) await show($, await call($, '/set', { voices: { [group]: voice } }))
436}
437
438async function saveDefaults($: $) {
439 const s = await settings($)
440 // Each group's voice in use goes first in its list, the others stay after it.
441 const lists = favourites()
442 for (const [group, voice] of Object.entries(s.voices)) {
443 lists[group] = [voice, ...(lists[group] ?? []).filter(v => v !== voice)]
444 }
445 const fields: [string, string | number | boolean][] = [
446 ['voices', formatVoices(lists)],
447 ['speed', s.speed],
448 ['volume', s.volume],
449 ['duckOthers', s.duckOthers],
450 ['autoReadReplies', s.autoReadReplies],
451 ['autoReadMine', s.autoReadMine],
452 ['readThinking', s.readThinking],
453 ['rewriteForSpeech', s.rewriteForSpeech],
454 ]
455 const refused: string[] = []
456 for (const [field, value] of fields) {
457 const { deny } = await $.config.set({ key: `read-along.${field}`, value })
458 if (deny) refused.push(`${field} (${deny})`)
459 }
460 return refused.length ? `Not saved: ${refused.join(', ')}` : 'Saved as the defaults for new sessions.'
461}
462
463/** This session's choices back to the saved defaults; a read in progress takes them at once. */
464async function resetSession($: $) {
465 await update($, current, () => null)
466 const s = await settings($)
467 if (await read($, now)) await show($, await call($, '/set', { voices: s.voices, speed: s.speed, volume: s.volume / 100, duck: duckLevel(s.duckOthers) }))
468 return 'This session is back to your saved defaults.'
469}
470
471type SavedValue = string | number | boolean
472
473/** The plugin's own defaults for its saved settings, from its manifest's userConfig. */
474export const factoryDefaults = (manifest: string): [string, SavedValue][] =>
475 Object.entries((JSON.parse(manifest) as { userConfig?: Record<string, { default?: unknown }> }).userConfig ?? {}).flatMap(([key, field]) =>
476 ['string', 'number', 'boolean'].includes(typeof field.default) ? [[key, field.default as SavedValue] as [string, SavedValue]] : [],
477 )
478
479/**
480 * /read-along reset defaults: every saved setting back to the plugin's own
481 * default, and this session with them. Without `isConfirmed` it only says
482 * what would change and how to confirm.
483 */
484async function resetDefaults($: $, isConfirmed: boolean) {
485 const all = factoryDefaults(await $.fs.read(`${$.plugin.root}/.claude-plugin/plugin.json`))
486 const changes = all.filter(([key, value]) => opt[key] !== undefined && opt[key] !== value)
487 if (!changes.length) return 'Your saved settings are already the defaults.'
488 if (!isConfirmed) {
489 return [
490 "This puts read-along's saved settings back to the plugin's defaults, and this session with them:",
491 ...changes.map(([key, value]) => ` ${key}: ${String(opt[key])} → ${String(value)}`),
492 '',
493 'To do it: /read-along reset defaults yes',
494 ].join('\n')
495 }
496 const refused: string[] = []
497 for (const [key, value] of changes) {
498 const { deny } = await $.config.set({ key: `read-along.${key}`, value })
499 if (deny) refused.push(`${key} (${deny})`)
500 }
501 await resetSession($)
502 return refused.length ? `Not reset: ${refused.join(', ')}` : 'Saved settings are back to the defaults, and so is this session.'
503}
504
505/** /read-along doctor: what works on this machine and what to install. */
506async function doctor($: $) {
507 const run = await $.process.run(['python3', '-I', speakd($), '--doctor', '--socket', await sock($)], { timeoutMs: 60000 })
508 return [(run.stdout || run.stderr || 'The doctor printed nothing.').trim(), '', await surfacesReport($)].join('\n')
509}
510
511/** Where this session draws, and what each surface has asked read-along to draw. */
512async function surfacesReport($: $) {
513 const surfaces = await $.session.surfaces()
514 const lines = ['Surfaces (where this session is drawn):']
515 for (const surface of new Set([...surfaces, ...Object.keys(drawn)])) {
516 const asks = Object.entries(drawn[surface] ?? {}).map(([component, n]) => `${component} ${n}`)
517 lines.push(` ${surface}: ${asks.length ? asks.join(', ') : 'has asked for nothing yet'}`)
518 }
519 if (attached.size) lines.push(` remote clients: ${[...attached].join(', ')}`)
520 if (bottomKind() === 'band') {
521 lines.push(
522 band.isHidden === undefined
523 ? 'Player above the prompt: not checked yet (it is checked after each turn).'
524 : band.isHidden
525 ? 'Player above the prompt: hidden by another mod, so it shows on the line under the prompt. A mod listed before read-along in enabledPlugins (~/.claude/settings.json) draws there without passing the band on; move read-along above it, or set player to hint.'
526 : 'Player above the prompt: shown.',
527 )
528 }
529 return lines.join('\n')
530}
531
532async function restart($: $) {
533 await $.process.run(['python3', '-I', speakd($), '--stop', '--socket', await sock($)], { timeoutMs: 15000 })
534 isEnsured = false
535 await update($, installed, () => null)
536 await loadInfo($)
537}
538
539const SETUP_PROMPT =
540 "Set up read-along for me: check this machine with /read-along doctor, then help me install the voices with the plugin's setup skill."
541
542// ------------------------------------------------------------ text helpers
543
544const clock = (seconds = 0) => {
545 const s = Math.max(0, Math.round(seconds))
546 return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`
547}
548
549const bar = (fraction: number, width: number) => {
550 const filled = Math.round(Math.max(0, Math.min(1, fraction)) * width)
551 return '━'.repeat(filled) + '─'.repeat(width - filled)
552}
553
554const KOKORO_ACCENTS: Record<string, string> = { a: 'US', b: 'UK', j: 'JP', z: 'ZH', e: 'ES', f: 'FR', h: 'HI', i: 'IT', p: 'PT' }
555const ENGINE_NAMES: Record<string, string> = { kokoro: 'Kokoro', win: 'Windows', mac: 'macOS', espeak: 'eSpeak', pocket: 'Pocket TTS' }
556
557/**
558 * A voice's short name, for buttons and the player: kokoro:af_heart → heart,
559 * win:Microsoft Aria Native → Aria, mac:Samantha → Samantha. `isLong` adds
560 * the engine: heart (Kokoro US). Names from before engines were named (a bare
561 * af_heart, a bare Windows name) still read right.
562 */
563export const voiceName = (voice = '', isLong = false): string => {
564 if (!voice || voice === 'auto') return isLong ? 'auto (best installed)' : 'auto'
565 const [, engine = '', name = ''] = voice.match(/^(kokoro|win|mac|espeak|pocket):(.*)$/) ?? ['', voice.includes(' ') ? 'win' : 'kokoro', voice]
566 if (engine === 'kokoro') {
567 const short = name.replace(/^[a-z]{2}_/, '')
568 return isLong ? `${short} (Kokoro ${KOKORO_ACCENTS[name[0] ?? ''] ?? ''})`.replace(' )', ')') : short
569 }
570 const short = engine === 'win' ? name.replace(/^Microsoft /, '').replace(/ (Native|Desktop|Online)$/, '') : name
571 return isLong ? `${short} (${ENGINE_NAMES[engine]})` : short
572}
573
574/** A voice's name in a list already headed by its engine: heart (US), Aria, alba. */
575export const voiceLabel = (voice: string): string => {
576 const engine = ENGINE_NAMES[voice.split(':')[0] ?? ''] ?? ''
577 return voiceName(voice, true).replace(` (${engine})`, '').replace(`(${engine} `, '(')
578}
579
580/** Voices in rows, one per engine, the person's own Pocket TTS voices first. */
581export const voiceRows = (voices: ReadAlongVoice[]): [string, ReadAlongVoice[]][] => {
582 const rows = new Map<string, ReadAlongVoice[]>()
583 for (const v of [...voices.filter(v => v.own), ...voices.filter(v => !v.own)]) {
584 const label = v.own ? 'Your voices' : (ENGINE_NAMES[v.engine] ?? v.engine)
585 rows.set(label, [...(rows.get(label) ?? []), v])
586 }
587 return [...rows]
588}
589
590const hash = (text: string) => {
591 let h = 5381
592 for (let i = 0; i < text.length; i++) h = ((h * 33) ^ text.charCodeAt(i)) >>> 0
593 return h.toString(36)
594}
595
596/** The handle a message's row and its read share: who wrote it and its text. */
597export const messageKey = (who: 'a' | 'u', text: string) => `${who}:${hash(text.trim())}`
598
599/**
600 * The service hears a sentence with the markdown taken out, so the mod finds
601 * it again word by word: link targets and URLs are skipped, and so are the
602 * words the service puts in for symbols (→ is "to", a URL is "link").
603 */
604const SPOKEN_ONLY = new Set(['to', 'from', 'link', 'code', 'block'])
605
606const words = (text: string, skip: [number, number][] = []) =>
607 [...text.matchAll(/[\p{L}\p{N}]+/gu)]
608 .map(m => ({ word: m[0].toLowerCase(), start: m.index ?? 0, end: (m.index ?? 0) + m[0].length }))
609 .filter(w => !SPOKEN_ONLY.has(w.word) && !skip.some(([a, b]) => w.start >= a && w.start < b))
610
611/** Where the sentence being heard sits in the message's markdown, if it can be found. */
612const findSentence = (raw: string, sentence: string) => {
613 const skip = [...raw.matchAll(/\]\([^)]*\)|https?:\/\/\S+/g)].map(m => [m.index ?? 0, (m.index ?? 0) + m[0].length] as [number, number])
614 const rawWords = words(raw, skip)
615 const target = words(sentence).map(w => w.word)
616 if (target.join('').length < 2) return undefined
617 const first = rawWords.findIndex((_, i) => target.every((w, k) => rawWords[i + k]?.word === w))
618 if (first < 0) return undefined
619 const start = rawWords[first]?.start ?? 0
620 let end = rawWords[first + target.length - 1]?.end ?? start
621 while (end < raw.length && /[.!?。!?]/.test(raw[end] ?? '')) end++
622 return { start, end }
623}
624
625/**
626 * Fallback where the message can't be redrawn (a table, a code block): the
627 * sentence bold. A table is Claude Code's own drawing, which takes no
628 * background, so the row being read gets its cells in bold instead.
629 */
630export const markSentence = (raw: string, sentence: string) => {
631 const at = findSentence(raw, sentence)
632 if (!at) return undefined
633 const lineStart = raw.lastIndexOf('\n', at.start - 1) + 1
634 const lineEnd = raw.indexOf('\n', at.start) < 0 ? raw.length : raw.indexOf('\n', at.start)
635 const line = raw.slice(lineStart, lineEnd)
636 if (/^\s*\|/.test(line)) {
637 if (/^\s*\|?[\s:|-]+$/.test(line)) return undefined // the separator row
638 const row = line.replace(/(\|)([^|]*?)(?=\|)/g, (cell, bar: string, body: string) => {
639 const text = body.trim()
640 return text && !/^\*\*.*\*\*$/.test(text) ? `${bar}${body.replace(text, `**${text}**`)}` : cell
641 })
642 return `${raw.slice(0, lineStart)}${row}${raw.slice(lineEnd)}`
643 }
644 const span = raw.slice(at.start, at.end)
645 if (/[\n`*_[\]|]/.test(span)) return undefined
646 return `${raw.slice(0, at.start)}**${span}**${raw.slice(at.end)}`
647}
648
649/**
650 * The line being read, with its inline markdown drawn as Markdown would but
651 * without colour: bold, italics, struck-through text and links (underlined)
652 * keep their look, inline code loses only its backticks. Each piece knows
653 * where it sits in the message, so the highlight covers just the sentence
654 * heard. One colour on purpose: the highlight's.
655 */
656export type InlineStyle = { bold?: true; italic?: true; strikethrough?: true; underline?: true }
657/** `href`: a bare web address, drawn as an explicit link (see drawRead). */
658export type InlineRun = { text: string; style: InlineStyle; isRead: boolean; href?: string }
659
660export function inlineRuns(raw: string, from: number, to: number, read: { start: number; end: number }): InlineRun[] {
661 const cells: { ch: string; at: number; style: InlineStyle; href?: string }[] = []
662 const put = (text: string, at: number, style: InlineStyle, href?: string) => {
663 let k = 0
664 for (const ch of text) {
665 cells.push({ ch, at: at + k, style, ...(href ? { href } : {}) })
666 k += ch.length // positions count UTF-16 units, as the sentence's do
667 }
668 }
669 const walk = (src: string, base: number, style: InlineStyle) => {
670 let i = 0
671 while (i < src.length) {
672 const rest = src.slice(i)
673 const c = src[i] ?? ''
674 let m: RegExpMatchArray | null
675 if (c === '\\' && /[\\`*_[\]()~#>!-]/.test(src[i + 1] ?? '')) {
676 put(src[i + 1] ?? '', base + i + 1, style)
677 i += 2
678 } else if ((m = rest.match(/^`([^`]+)`/))) {
679 put(m[1] ?? '', base + i + 1, style)
680 i += m[0].length
681 } else if ((m = rest.match(/^\[([^\]]+)\]\([^)\s]*(?:\s+"[^"]*")?\)/))) {
682 walk(m[1] ?? '', base + i + 1, { ...style, underline: true })
683 i += m[0].length
684 } else if ((m = rest.match(/^<(https?:\/\/[^>\s]+)>/))) {
685 put(m[1] ?? '', base + i + 1, style, m[1])
686 i += m[0].length
687 } else if (!/[\p{L}\p{N}]/u.test(src[i - 1] ?? '') && (m = rest.match(/^https?:\/\/[^\s<>]*[^\s<>.,;:!?'")\]]/u))) {
688 put(m[0], base + i, style, m[0])
689 i += m[0].length
690 } else if ((m = rest.match(/^(\*\*|__)(?=\S)([\s\S]*?\S)\1/))) {
691 walk(m[2] ?? '', base + i + 2, { ...style, bold: true })
692 i += m[0].length
693 } else if ((m = rest.match(/^~~(?=\S)([\s\S]*?\S)~~/))) {
694 walk(m[1] ?? '', base + i + 2, { ...style, strikethrough: true })
695 i += m[0].length
696 } else if ((m = rest.match(/^\*(?=\S)([\s\S]*?\S)\*/))) {
697 walk(m[1] ?? '', base + i + 1, { ...style, italic: true })
698 i += m[0].length
699 } else if (c === '_' && !/[\p{L}\p{N}]/u.test(src[i - 1] ?? '') && (m = rest.match(/^_(?=\S)([\s\S]*?\S)_(?![\p{L}\p{N}])/u))) {
700 walk(m[1] ?? '', base + i + 1, { ...style, italic: true })
701 i += m[0].length
702 } else {
703 put(c === '\n' ? ' ' : c, base + i, style) // a soft line break reads as a space, as in Markdown
704 i += 1
705 }
706 }
707 }
708 walk(raw.slice(from, to), from, {})
709 const runs: InlineRun[] = []
710 for (const { ch, at, style, href } of cells) {
711 const isRead = at >= read.start && at < read.end
712 const last = runs[runs.length - 1]
713 if (last && last.isRead === isRead && last.href === href && JSON.stringify(last.style) === JSON.stringify(style)) last.text += ch
714 else runs.push({ text: ch, style, isRead, ...(href ? { href } : {}) })
715 }
716 return runs
717}
718
719/**
720 * The `highlight` setting as Text props: `reverse` swaps the text and
721 * background colours, which reads on any theme; else a background colour,
722 * optionally with a text colour after a slash (`#7d1010/#ffffff`).
723 */
724export const highlightProps = (value: string): { inverse?: boolean; backgroundColor?: string; color?: string } => {
725 const v = value.trim()
726 if (!v || v === 'reverse') return { inverse: true }
727 const [bg = '', fg = ''] = v.split('/').map(s => s.trim())
728 return fg ? { backgroundColor: bg, color: fg } : { backgroundColor: bg }
729}
730
731/**
732 * The text after the line being read, cut so its indent survives. A markdown
733 * block of its own starts every list at the left edge, so the indented lines
734 * that follow (deeper items of the same list) come apart: each with the lines
735 * under it, dedented, to be drawn at its indent. `rest` is the text from the
736 * first unindented line on; each `gap` is 1 where a blank line came before.
737 */
738export function splitAfter(text: string): { parts: { indent: number; text: string; gap: number }[]; rest: string; restGap: number } {
739 const lines = text.replace(/^\n/, '').split('\n')
740 const blank = (l: string) => !/\S/.test(l)
741 const indentOf = (l: string) => (l.match(/^[ \t]*/)?.[0] ?? '').replace(/\t/g, ' ').length
742 const dedent = (l: string, n: number) => {
743 let k = 0
744 for (let col = 0; k < l.length && col < n && (l[k] === ' ' || l[k] === '\t'); k++) col += l[k] === '\t' ? 4 : 1
745 return l.slice(k)
746 }
747 const parts: { indent: number; text: string; gap: number }[] = []
748 let i = 0
749 for (;;) {
750 let blanks = 0
751 while (i < lines.length && blank(lines[i] ?? '')) {
752 i++
753 blanks++
754 }
755 if (i >= lines.length) return { parts, rest: '', restGap: 0 }
756 const indent = indentOf(lines[i] ?? '')
757 if (indent === 0) return { parts, rest: lines.slice(i).join('\n'), restGap: blanks ? 1 : 0 }
758 // The item and the lines under it: deeper ones, and blank ones before deeper ones.
759 let j = i + 1
760 while (j < lines.length) {
761 if (!blank(lines[j] ?? '')) {
762 if (indentOf(lines[j] ?? '') <= indent) break
763 j++
764 continue
765 }
766 let k = j
767 while (k < lines.length && blank(lines[k] ?? '')) k++
768 if (k >= lines.length || indentOf(lines[k] ?? '') <= indent) break
769 j = k
770 }
771 parts.push({ indent, text: lines.slice(i, j).map(l => dedent(l, indent)).join('\n'), gap: blanks ? 1 : 0 })
772 i = j
773 }
774}
775
776/**
777 * The message while one of its sentences is heard, drawn by the mod: the
778 * lines before and after stay markdown, and the line being read is text with
779 * the sentence highlighted. Markdown has no background colour, so this is
780 * the one way to paint one. Undefined where the line is in a table or a code
781 * block, which only draw right whole.
782 */
783function drawRead($: $, e: MessageEvent, raw: string, at: { start: number; end: number }, lead: string): RenderElement | undefined {
784 const lineStart = raw.lastIndexOf('\n', at.start - 1) + 1
785 const lineEnd = raw.indexOf('\n', at.end) < 0 ? raw.length : raw.indexOf('\n', at.end)
786 const line = raw.slice(lineStart, lineEnd)
787 const fences = (raw.slice(0, lineStart).match(/^\s*```/gm) ?? []).length
788 if (fences % 2 === 1) return undefined
789 if (/^\s*\|/.test(line)) return drawTable($, e, raw, at, lead)
790
791 const { Box, Text, Markdown, Link } = $.ui.resolve(e)
792 const before = raw.slice(0, lineStart).replace(/\s+$/, '')
793 const after = splitAfter(raw.slice(lineEnd))
794 const gapBefore = /\n\s*\n\s*$/.test(raw.slice(0, lineStart)) ? 1 : 0
795
796 // The line's own markup: a list marker becomes a bullet, a heading goes bold.
797 const marker = line.match(/^(\s*)([-*+]|\d+[.)])\s+/)
798 const heading = line.match(/^#{1,6}\s+/)
799 const quote = line.match(/^>\s?/)
800 const skipped = (marker?.[0] ?? heading?.[0] ?? quote?.[0] ?? '').length
801 // The marker as Claude Code draws it (`-` for any bullet), in a column of its
802 // own so a wrapped line hangs under the text, not under the marker.
803 const prefix = marker ? `${marker[1]}${/\d/.test(marker[2] ?? '') ? marker[2] : '-'} ` : quote ? '│ ' : ''
804 const runs = inlineRuns(raw, lineStart + skipped, lineEnd, at)
805 const hl = highlightProps(str('highlight', 'reverse'))
806
807 // marginTop: the engine draws each message with a blank row above it; without
808 // it, a message being read sat flush against the one before until reading ended.
809 return (
810 <Box flexDirection="row" marginTop={1}>
811 <Text>{lead}</Text>
812 <Box flexDirection="column" flexGrow={1} flexShrink={1}>
813 {before ? [<Markdown text={before} />] : []}
814 <Box marginTop={before ? gapBefore : 0}>
815 {prefix
816 ? [
817 <Box flexShrink={0}>
818 <Text>{prefix}</Text>
819 </Box>,
820 ]
821 : []}
822 <Box flexGrow={1} flexShrink={1}>
823 <Text bold={heading !== null}>
824 {runs.map(r => {
825 // A web address is drawn as a link holding the highlighted text:
826 // left as plain text, the terminal made it a link of its own
827 // and dropped the background.
828 const piece = (
829 <Text {...r.style} {...(r.isRead ? hl : {})}>
830 {r.text}
831 </Text>
832 )
833 return r.href ? <Link href={r.href}>{piece}</Link> : piece
834 })}
835 </Text>
836 </Box>
837 </Box>
838 {after.parts.map(p => (
839 <Box marginTop={p.gap} paddingLeft={p.indent}>
840 <Markdown text={p.text} />
841 </Box>
842 ))}
843 {after.rest
844 ? [
845 <Box marginTop={after.restGap}>
846 <Markdown text={after.rest} />
847 </Box>,
848 ]
849 : []}
850 </Box>
851 </Box>
852 )
853}
854
855/** Cells a string takes in the terminal: wide CJK and emoji two, marks none. */
856const cellWidth = (text: string) => {
857 let w = 0
858 for (const ch of text) {
859 if (/\p{M}/u.test(ch)) continue
860 w += /[ᄀ-ᅟ⺀-가-힣豈-︰-﹏-⦆¢-₩]|\p{Extended_Pictographic}/u.test(ch) ? 2 : 1
861 }
862 return w
863}
864
865export type TableLayout = {
866 widths: number[]
867 aligns: ('left' | 'right' | 'center')[]
868 /** Each row's cells as inline runs; the separator row is left out. */
869 rows: { cells: InlineRun[][]; isHeader: boolean; isRead: boolean }[]
870}
871
872/**
873 * A markdown table laid out to draw: its rows (header first), each cell's
874 * inline runs, the columns' widths and alignments. `readLine` is the index,
875 * among `lines`, of the row being read. Undefined if it isn't a table.
876 */
877export function layoutTable(lines: string[], readLine: number): TableLayout | undefined {
878 const split = (line: string) =>
879 line
880 .trim()
881 .replace(/^\|/, '')
882 .replace(/(?<!\\)\|$/, '')
883 .split(/(?<!\\)\|/)
884 .map(c => c.trim().replace(/\\\|/g, '|'))
885 const sep = lines.findIndex(l => /^\s*\|?(\s*:?-+:?\s*\|)*\s*:?-+:?\s*\|?\s*$/.test(l))
886 if (sep !== 1) return undefined
887 const aligns = split(lines[1] ?? '').map(c => (c.startsWith(':') && c.endsWith(':') ? 'center' : c.endsWith(':') ? 'right' : 'left') as TableLayout['aligns'][number])
888 const rows = lines
889 .map((line, i) => ({ line, i }))
890 .filter(({ i }) => i !== sep)
891 .map(({ line, i }) => {
892 const isRead = i === readLine
893 const cells = split(line).map(c => inlineRuns(c, 0, c.length, isRead ? { start: 0, end: c.length } : { start: 0, end: 0 }))
894 return { cells, isHeader: i === 0, isRead }
895 })
896 const columns = Math.max(...rows.map(r => r.cells.length))
897 const widths = Array.from({ length: columns }, (_, c) => Math.max(1, ...rows.map(r => cellWidth((r.cells[c] ?? []).map(x => x.text).join('')))))
898 return { widths, aligns: Array.from({ length: columns }, (_, c) => aligns[c] ?? 'left'), rows }
899}
900
901/**
902 * A message whose sentence being heard is a table row, drawn by the mod: the
903 * table in Claude Code's box-drawing style (a rule between every row), the row
904 * being read on the highlight's background, the text around it markdown. Claude Code draws its
905 * own tables, which take no background. Undefined where the table doesn't fit
906 * the width, which leaves Claude Code's table with the row in bold.
907 */
908function drawTable($: $, e: MessageEvent, raw: string, at: { start: number; end: number }, lead: string): RenderElement | undefined {
909 // The table: the run of `|` lines around the sentence.
910 const starts = [0, ...[...raw.matchAll(/\n/g)].map(m => (m.index ?? 0) + 1)]
911 const lineOf = (pos: number) => starts.findLastIndex(st => st <= pos)
912 const lineText = (i: number) => raw.slice(starts[i] ?? raw.length, (starts[i + 1] ?? raw.length + 1) - 1)
913 const isRow = (i: number) => i >= 0 && i < starts.length && /^\s*\|/.test(lineText(i))
914 const at0 = lineOf(at.start)
915 let first = at0
916 let last = at0
917 while (isRow(first - 1)) first--
918 while (isRow(last + 1)) last++
919 const lines = Array.from({ length: last - first + 1 }, (_, k) => lineText(first + k))
920 const table = layoutTable(lines, at0 - first)
921 if (!table) return undefined
922 const total = table.widths.reduce((a, w) => a + w + 3, 1)
923 if (total > (e.viewport?.columns ?? 100) - 4) return undefined
924
925 const { Box, Text, Markdown } = $.ui.resolve(e)
926 const hl = highlightProps(str('highlight', 'reverse'))
927 const tableStart = starts[first] ?? 0
928 const tableEnd = (starts[last + 1] ?? raw.length + 1) - 1
929 const before = raw.slice(0, tableStart).replace(/\s+$/, '')
930 const after = raw.slice(tableEnd).replace(/^\s+/, '')
931 const rule = (l: string, m: string, r: string) => <Text>{l + table.widths.map(w => '─'.repeat(w + 2)).join(m) + r}</Text>
932 const row = (r: TableLayout['rows'][number]) => (
933 <Text>
934 {'│'}
935 {table.widths.map((w, c) => {
936 const runs = r.cells[c] ?? []
937 const gap = w - cellWidth(runs.map(x => x.text).join(''))
938 const align = table.aligns[c] ?? 'left'
939 const left = align === 'right' ? gap : align === 'center' ? Math.floor(gap / 2) : 0
940 const paint = r.isRead ? hl : {}
941 const bold = r.isHeader ? { bold: true as const } : {}
942 return (
943 <Text>
944 <Text {...paint}>{' '.repeat(left + 1)}</Text>
945 {runs.map(x => (
946 <Text {...x.style} {...bold} {...paint}>
947 {x.text}
948 </Text>
949 ))}
950 <Text {...paint}>{' '.repeat(gap - left + 1)}</Text>
951 {'│'}
952 </Text>
953 )
954 })}
955 </Text>
956 )
957 return (
958 <Box flexDirection="row" marginTop={1}>
959 <Text>{lead}</Text>
960 <Box flexDirection="column" flexGrow={1} flexShrink={1}>
961 {before ? [<Markdown text={before} />] : []}
962 <Box flexDirection="column" marginTop={before ? 1 : 0}>
963 {rule('┌', '┬', '┐')}
964 {table.rows.flatMap((r, i) => (i < table.rows.length - 1 ? [row(r), rule('├', '┼', '┤')] : [row(r)]))}
965 {rule('└', '┴', '┘')}
966 </Box>
967 {after
968 ? [
969 <Box marginTop={1}>
970 <Markdown text={after} />
971 </Box>,
972 ]
973 : []}
974 </Box>
975 </Box>
976 )
977}
978
979// ------------------------------------------------------------ drawing
980//
981// Symbols are text-only ones (‖ « » ▶ ■): ⏸ ⏮ ⏭ default to emoji and a
982// terminal may draw them as colour icons.
983//
984// A child left out is an empty list, never `cond && <X/>`: the `false` that
985// leaves behind still takes a row on the terminal.
986
987/**
988 * What a message carries besides its text: \`below\`, a row under it (the
989 * progress while it's read, with \`player: message\`; the buttons, with
990 * \`buttons: always\`); \`overlay\`, the buttons over its top-right corner while
991 * the pointer is on it (\`buttons: hover\`); and \`gutter\`, a dim ♪ at its
992 * right that shows them while the pointer is on the ♪ alone (\`buttons: glyph\`).
993 */
994type MessageExtras = { below: RenderElement | null; gutter: RenderElement | null; overlay: RenderElement | null }
995
996/** The main voice as heard: the one chosen, or what `auto` picks on this machine. */
997async function mainVoice($: $, s: ReadAlongSettings) {
998 const chosen = s.voices.main ?? 'auto'
999 return chosen === 'auto' ? ((await read($, machine))?.auto ?? 'auto') : chosen
1000}
1001
1002async function messageRow($: $, e: MessageEvent, text: string, key: string, label: string): Promise<MessageExtras> {
1003 const { Box, Text, Button } = $.ui.resolve(e)
1004 const s = await settings($)
1005 const at = await read($, cursor)
1006 const id = e.requestId
1007 const isThis = at !== null && at.key === key
1008 const status = isThis ? await read($, now) : null // only the message being read follows the clock
1009 const isPlaying = status !== null && (status.state === 'playing' || status.state === 'loading')
1010 const form = buttons(e.surface)
1011
1012 const controls = (
1013 <Box key={`${id}-controls`} columnGap={1}>
1014 <Button key={`${id}-play`} label={isPlaying ? btn('pause') : btn('play')} onPress={() => (isThis ? control($, 'toggle') : speak($, text, key, label, 'now'))} />
1015 <Button key={`${id}-restart`} label={btn('restart')} onPress={() => (isThis ? control($, 'restart') : speak($, text, key, label, 'now'))} />
1016 {isThis ? [<Button key={`${id}-stop`} label={btn('stop')} onPress={() => control($, 'stop')} />] : []}
1017 <Button key={`${id}-slower`} label={btn('slower')} onPress={() => setSpeed($, s.speed - SPEED_STEP)} />
1018 <Text dimColor>{s.speed}×</Text>
1019 <Button key={`${id}-faster`} label={btn('faster')} onPress={() => setSpeed($, s.speed + SPEED_STEP)} />
1020 <Button key={`${id}-voice`} label={voiceName(await mainVoice($, s))} onPress={() => nextVoice($)} />
1021 </Box>
1022 )
1023
1024 // `hover`: the buttons over the message's top-right corner while the pointer
1025 // is anywhere on the message. They can flash as messages scroll under a
1026 // resting pointer; `glyph` avoids that by hovering a ♪ alone.
1027 const overlay =
1028 form === 'hover' ? (
1029 <Box position="absolute" top={0} right={0} display="none" hover={{ display: 'flex' }} backgroundColor="black">
1030 {controls}
1031 </Box>
1032 ) : null
1033 const gutter =
1034 form === 'glyph' ? (
1035 <Box key={`ra-${id}-gutter`} width={2} flexShrink={0}>
1036 <Text color={isThis ? 'cyan' : undefined} dimColor={!isThis}>
1037 {' ♪'}
1038 </Text>
1039 <Box position="absolute" top={0} right={2} display="none" hover={{ display: 'flex' }} backgroundColor="black">
1040 {controls}
1041 </Box>
1042 </Box>
1043 ) : null
1044
1045 if (status !== null && showsUnderMessage(e.surface)) {
1046 const width = Math.max(8, Math.min(24, (e.viewport?.columns ?? 80) - 60))
1047 const fraction = status.total ? (status.position ?? 0) / status.total : 0
1048 const progress = (
1049 <Box columnGap={1}>
1050 <Text color="cyan">
1051 {icon(stateOf(status))} {clock(status.position)} {bar(fraction, width)} {clock(status.total)}
1052 </Text>
1053 {controls}
1054 </Box>
1055 )
1056 return { below: progress, gutter, overlay }
1057 }
1058 return { below: form === 'always' ? controls : null, gutter, overlay }
1059}
1060
1061function wrap($: $, e: MessageEvent, inner: RenderElement, { below, gutter, overlay }: MessageExtras): RenderElement {
1062 if (below === null && gutter === null && overlay === null) return inner
1063 const { Box } = $.ui.resolve(e)
1064 if (overlay !== null) {
1065 // Keyed, so the whole message is the hover's scope.
1066 return (
1067 <Box key={`ra-${e.requestId}`} flexDirection="column">
1068 {inner}
1069 {below ? [below] : []}
1070 {overlay}
1071 </Box>
1072 )
1073 }
1074 return (
1075 <Box flexDirection="row">
1076 <Box flexDirection="column" flexGrow={1} flexShrink={1}>
1077 {inner}
1078 {below ? [below] : []}
1079 </Box>
1080 {gutter ? [gutter] : []}
1081 </Box>
1082 )
1083}
1084
1085/**
1086 * A message as it draws while one of its sentences is heard: the mod's own
1087 * drawing with the sentence highlighted, else the engine's with it in bold.
1088 */
1089async function drawMessage($: $, e: MessageEvent, raw: string, key: string, lead: string, next: (e: MessageEvent) => Promise<RenderElement>) {
1090 const at = await read($, cursor)
1091 if (at === null || at.key !== key || !at.text) return next(e)
1092 const where = findSentence(raw, at.text)
1093 const own = where && drawRead($, e, raw, where, lead)
1094 if (own) return own
1095 const marked = markSentence(raw, at.text)
1096 return next(marked ? ({ ...e, props: { ...e.props, text: marked } } as MessageEvent) : e)
1097}
1098
1099/** The player's one row: idle settings, or the read in progress with its controls. */
1100// Indented one column with two spaces after the icon, as token-weather's line
1101// is, so the two rows' icons and first words line up.
1102async function playerRow($: $, e: RenderInput<'AbovePrompt'>): Promise<RenderElement> {
1103 const { Box, Text, Button } = $.ui.resolve(e)
1104 const status = await read($, now)
1105 const s = await settings($)
1106 const cols = e.props.bodyColumns ?? e.viewport?.columns ?? 80
1107
1108 if (status === null) {
1109 const auto = s.autoReadReplies === 'off' && !s.autoReadMine ? 'auto-read off' : `auto-read ${[s.autoReadReplies !== 'off' ? `replies (${s.autoReadReplies})` : '', s.autoReadMine ? 'mine' : ''].filter(Boolean).join(' + ')}`
1110 return (
1111 <Box columnGap={1} flexWrap="nowrap" paddingX={1}>
1112 <Text wrap="truncate-end">
1113 <Text color="magenta" bold>
1114 {icon('idle')}
1115 {' '}
1116 {voiceName(await mainVoice($, s))}
1117 </Text>
1118 <Text dimColor>
1119 {' '}
1120 {s.speed}× · {auto}
1121 </Text>
1122 </Text>
1123 <Button key="last" hotkey="r" label="▶ last reply" onPress={() => readLast($)} />
1124 <Button key="settings" hotkey="o" label="settings" onPress={() => $.ui.open({ id: PANE, title: 'Read-along' })} />
1125 </Box>
1126 )
1127 }
1128
1129 const isPaused = status.state === 'paused'
1130 const fraction = status.total ? (status.position ?? 0) / status.total : 0
1131 const where = status.sentences ? ` ${(status.sentence ?? 0) + 1}/${status.sentences}` : ''
1132 const queued = status.queue ? ` +${status.queue}` : ''
1133 const voice = voiceName(status.voice ?? (await mainVoice($, s)))
1134 // Laid out like token-weather's line: icon and voice name in bold colour
1135 // (where "Showers" sits), then the details. The name is the voice button.
1136 // Everything but the bar has a fixed width; the bar takes what is left, so the row never wraps.
1137 const fixed = 1 + 3 + voice.length + 2 + 12 + where.length + queued.length + 6 * 6 + 2
1138 const width = Math.max(4, Math.min(40, cols - fixed))
1139 return (
1140 <Box flexWrap="nowrap" paddingX={1}>
1141 <Text color="cyan" bold>
1142 {icon(stateOf(status))}
1143 {' '}
1144 </Text>
1145 <Button key="voice" plain label={voice} onPress={() => nextVoice($)}>
1146 <Text color="cyan" bold>
1147 {voice}
1148 </Text>
1149 </Button>
1150 <Box columnGap={1} marginLeft={2} flexWrap="nowrap">
1151 <Text color="cyan" wrap="truncate-end">
1152 {clock(status.position)} {bar(fraction, width)} {clock(status.total)}
1153 <Text dimColor>
1154 {where}
1155 {queued}
1156 </Text>
1157 </Text>
1158 <Button key="toggle" hotkey="p" label={isPaused ? btn('play') : btn('pause')} onPress={() => control($, 'toggle')} />
1159 <Button key="stop" hotkey="s" label={btn('stop')} onPress={() => control($, 'stop')} />
1160 <Button key="back" hotkey="b" label={btn('back')} onPress={() => control($, 'back')} />
1161 <Button key="forward" hotkey="n" label={btn('forward')} onPress={() => control($, status.queue ? 'skip' : 'forward')} />
1162 <Button key="slower" label={btn('slower')} onPress={() => setSpeed($, s.speed - SPEED_STEP)} />
1163 <Button key="faster" label={btn('faster')} onPress={() => setSpeed($, s.speed + SPEED_STEP)} />
1164 </Box>
1165 </Box>
1166 )
1167}
1168
1169/** The installed voices a group can use: its languages', `main` taking the main language's. */
1170function voicesFor(all: ReadAlongVoice[], group: string, info: ReadAlongInfo | null) {
1171 const langs = group === 'main' ? [info?.mainLanguage ?? 'en'] : (info?.groupLangs?.[group] ?? [group])
1172 return all.filter(v => langs.includes(v.lang.toLowerCase().split(/[-_]/)[0] ?? ''))
1173}
1174
1175const HELP = [
1176 '/read-along the settings',
1177 '/read-along read read the last reply',
1178 '/read-along stop | pause',
1179 '/read-along speed 1.5',
1180 '/read-along voice NAME the main voice; voice ja NAME for Japanese, and so on',
1181 '/read-along voices [GROUP] list the installed voices',
1182 '/read-along auto replies off|final|all, auto mine on|off, auto thinking on|off',
1183 '/read-along words on|off say numbers and symbols as words (asks Claude Haiku first)',
1184 "/read-along save make this session's choices the defaults",
1185 "/read-along reset this session's choices back to the saved defaults (asks first)",
1186 "/read-along reset defaults the saved settings back to the plugin's own (asks first)",
1187 '/read-along doctor what works on this machine, and what to install',
1188 '/read-along setup ask Claude to install the voices',
1189 '/read-along restart restart the speech service',
1190].join('\n')
1191
1192export const register: Register = (on, options) => {
1193 opt = options as Options
1194
1195 // ------------------------------------------------------------ events
1196
1197 on('session.start', async ($, e, next) => {
1198 await $.command.register({
1199 name: 'read-along',
1200 description: 'Read-along: open its settings, or read | stop | pause | speed N | voice NAME | voices | auto … | words on|off | save | reset | doctor | setup | restart | help',types/index.d.ts 81 lines1/** What the speech service reports about the read in progress. */
2export type ReadAlongStatus = {
3 state: 'idle' | 'loading' | 'playing' | 'paused'
4 /** The mod's handle on the message being read (see `messageKey`). */
5 key?: string
6 label?: string
7 sentence?: number
8 sentences?: number
9 /** The sentence being heard now. */
10 text?: string
11 /** Seconds heard and estimated total, both at the current speed. */
12 position?: number
13 total?: number
14 speed?: number
15 volume?: number
16 /** The voice speaking the sentence heard now, as `<engine>:<name>`. */
17 voice?: string
18 queue: number
19 error?: string | null
20}
21
22/**
23 * The settings in force this session; /read-along save makes them the
24 * defaults. `voices` holds one voice per group (`main`, `ja`, `he`, …), each
25 * `<engine>:<name>` (kokoro:af_heart, win:Microsoft Aria) or `auto`.
26 */
27export type ReadAlongSettings = {
28 voices: Record<string, string>
29 speed: number
30 /** Percent, 0 to 200. */
31 volume: number
32 /** Other apps' volume while reading: off, or a percent such as 30%. */
33 duckOthers: string
34 autoReadReplies: string
35 autoReadMine: boolean
36 /** With auto-read `all`, also read the thinking blocks (shown as updates in some setups). */
37 readThinking: boolean
38 /** Ask Claude Haiku how to say a message's numbers and symbols before reading it. */
39 rewriteForSpeech: boolean
40}
41
42/** Which message is being read, and the sentence heard now. */
43export type ReadAlongCursor = { key: string; text: string }
44
45/** What this machine offers, from the speech service. */
46export type ReadAlongInfo = {
47 platform: 'wsl' | 'linux' | 'macos' | 'windows'
48 engines: { name: string; label: string; ok: boolean }[]
49 player: string | null
50 /** Whether other apps can be turned down while reading (WSL only for now). */
51 canDuck: boolean
52 /** The voice `auto` picks for the main group here. */
53 auto: string | null
54 /** The language of Latin-script text. */
55 mainLanguage: string
56 /** The languages each non-main group covers. */
57 groupLangs: Record<string, string[]>
58}
59
60/** An installed voice. */
61/** `own`: a Pocket TTS voice from the person's own folder. */
62export type ReadAlongVoice = { id: string; name: string; lang: string; engine: string; own?: boolean }
63
64declare module 'claude-code' {
65 interface PluginState {
66 'read-along': {
67 now: ReadAlongStatus | null
68 prefs: ReadAlongSettings | null
69 cursor: ReadAlongCursor | null
70 info: ReadAlongInfo | null
71 /** The voice group whose full list the settings pane shows. */
72 browsing: string | null
73 voices: ReadAlongVoice[] | null
74 /** The settings pane is asking whether to reset this session's choices. */
75 confirmReset: boolean
76 /** Bumped to redraw only the band and the hint line, for the check that another mod hides the band. */
77 bandCheck: number
78 }
79 }
80}
81