SLOPSHOPPER

read-along

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…

newpanebandspinnerrowscommand
v0.9.2MITupdated 2026-10-13tomfluff/claude-read-along/plugin
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · read-along
│ ┃ Read-along ✕ ⟨Claude Code's own drawing⟩ │ ┃ Not reading. │ ┃ ⏺ Read(src/auth.ts) │ ┃ Voice ⎿ Read 6 lines │ ┃ [ auto (best installed) ] [ more… ] ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ Other languages: [ Japanese ] [ Chinese ] [ ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ Speed [ − ] 1.25× [ + ] │ ┃ ⟨Claude Code's own drawing⟩ │ ┃ Volume [ − ] 100% [ + ] │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ Auto-read replies [ off ] [ final ] [ all ] │ ┃ › /read-along │ ┃ Auto-read my messages [ off ] [ on ] ⎿ read-along: Read-along settings opened. │ ┃ │ ┃ Read thinking updates [ off ] [ on ] with au │ ┃ │ ┃ Say numbers and symbols as words [ off ] [ o │ ┃ │ ┃ Optional, off by default. When on, each │ ┃ message is sent once to Claude Haiku through │ ┃ this session before it is read, to learn how │ ┃ to say its numbers, versions and symbols. │ ┃ That uses a little of your Claude usage and │ ┃ adds a second or two before reading starts. ♫ auto 1.25× · auto-read off [ ▶ last reply ] [ settings ] ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
♫ auto 1.25× · auto-read off [ ▶ last reply ] [ settings ] ⟨Claude Code's own drawing⟩
Pane · Read-along
Not reading. Voice [ auto (best installed) ] [ more… ] Other languages: [ Japanese ] [ Chinese ] [ Korean ] [ Hebre Speed [ − ] 1.25× [ + ] Volume [ − ] 100% [ + ] Auto-read replies [ off ] [ final ] [ all ] Auto-read my messages [ off ] [ on ] Read thinking updates [ off ] [ on ] with auto-read all Say numbers and symbols as words [ off ] [ on ] Optional, off by default. When on, each message is sent once to Claude Haiku through this session before it is read, to learn how to say its numbers, versions and symbols. That uses a little of your Claude usage and adds a second or two before reading starts. Speech itself is still made on this machine. Player [ band ] [ hint ] [ message ] [ band+message ] [ ▶ read last reply ] [ ■ stop ] [ save as defaults ] [ reset… ] Choices here last for this session until you save them. Voice lists, the highlight, icons and the message buttons are in /config under read-along. /read-along reset defaults puts the saved settings back to the plugin's own. /read-along doctor checks this machine.
Your message
⟨Claude Code's own drawing⟩
Claude's reply
⟨Claude Code's own drawing⟩
README

Read-along for Claude Code

<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.">

Tests: passing or failing, on Linux and macOS Needs Claude Code 2.1.287 or later Runs on WSL, Linux and macOS Voices run locally; nothing is sent anywhere Licence: MIT

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.

Read-along in Claude Code: hovering a reply shows its play, restart, speed and voice buttons; pressing play reads it aloud, each sentence highlighted as it's heard, with the player above the prompt showing the progress.

<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.

  • Play any message. Hover a message for its play, speed and voice buttons.
  • Follow the highlight. The sentence being spoken is highlighted in the message.
  • A player above the prompt. Pause, stop, skip back or forward, and change speed and voice.
  • Auto-read. Have each reply read when it arrives, or every update Claude writes between tool calls too.
  • Several languages in one reply. Japanese, Chinese, Korean, Hebrew, Arabic, Cyrillic, Hindi, Greek and Thai sentences each get their own voice.
  • Local and private. Speech is made on your machine. Nothing you read is sent anywhere, unless you turn on the optional numbers as words setting.
  • Fast without chipmunk voices. Speeds up to 4× keep the voice's pitch.

Hear it

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.

VoiceLanguageSample
kokoro:af_heartEnglish (US)listen · at 2× speed
kokoro:am_fenrirEnglish (US)listen
kokoro:bf_emmaEnglish (UK)listen
kokoro:jf_alphaJapaneselisten
kokoro:ef_doraSpanishlisten
kokoro:ff_siwisFrenchlisten
pocket:albaEnglish (Pocket TTS)listen
pocket:mariusEnglish (Pocket TTS)listen
pocket:juergenGerman (Pocket TTS)listen

What you need

  • Claude Code 2.1.287 or later, in the terminal. Read-along is a mod, a plugin that draws in Claude Code's interface. Mods are on by default from that version. They also draw in the JetBrains plugin and the desktop app's Code tab, but not in the desktop app's WSL sessions.
  • WSL, Linux or macOS. Native Windows isn't supported yet; use it through WSL (#5). So far read-along has been used day to day on WSL only. On Linux and macOS its tests, the doctor and a spoken sentence pass in CI, but nobody has tried it there by hand yet: reports are welcome in #2 (Linux) and #1 (macOS).
  • Python 3.9 or newer as python3. The speech service needs only the standard library.
  • An audio player: ffplay (comes with ffmpeg) or SoX's play.
  • At least one voice engine:
EngineWhereQualitySetup
Kokoroeverywherenatural, neural/read-along setup (about 1 GB, CPU is fine)
macOS voicesmacOSgood (Enhanced and Premium voices are better)built in
Windows voicesWSLgood with the newer "natural" voicesbuilt in
eSpeak NGLinux, WSLrobotic, but about 100 languagessudo apt install espeak-ng
Pocket TTS (optional)everywherenatural, 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

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

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:

  1. Make a free account at Hugging Face.
  2. Accept Kyutai's terms on kyutai/pocket-tts.
  3. Sign in on your machine: 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.

Install

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.

Using it

  • Hover a message for its buttons: play or pause, restart, slower, faster, and the voice (click to step to the next voice).
  • The player sits above the prompt. While idle it shows the voice and speed, plus buttons to read the last reply and open the settings. While reading it shows progress, pause, stop, back, forward and speed. Its buttons have hotkeys: 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 readread the last reply
/read-along stop, pausestop, or pause and resume
/read-along speed 1.5set the speed (0.5 to 4)
/read-along voice kokoro:af_heartset the main voice; voice ja kokoro:jf_alpha for Japanese
/read-along voiceslist the installed voices
/read-along auto replies allauto-read: off, final (each turn's last reply) or all
/read-along auto mine onalso read your own messages
/read-along auto thinking onwith auto-read all, also read Claude's thinking where it's shown as updates
/read-along words onsay numbers and symbols as words (optional; asks Claude Haiku, see below)
/read-along savekeep this session's choices as the defaults
/read-along resetput this session's choices back to your saved defaults (asks first; also "reset…" in the settings)
/read-along reset defaultsput the saved settings back to the plugin's own (says what changes, then asks)
/read-along doctorwhat works on this machine and what to install
/read-along setupask Claude to set up the Kokoro voices
/read-along restartrestart the speech service

Settings

All in /config under read-along.

SettingDefault
VoicesautoA voice per language; see below.
Speed1.250.5 to 4.
Volume100Percent, 0 to 200.
Auto-read Claude's repliesoffoff, final, all.
Auto-read my messagesoff
Read thinking updatesoffWith 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 wordsoffOptional: asks Claude Haiku how to say a message's numbers and symbols before reading it. See below.
Read code blocksoffOff says "code block" instead.
HighlightreverseSwaps text and background colours, which works on any theme. Or a colour: yellow, #f1fa8c/#282a36 (background/text).
Message buttonshoverhover, glyph (only over a ♪ at the message's right; no flashing while scrolling), always, off.
Playerbandband above the prompt, hint (dim text under the prompt), message (under the message being read), band+message.
Iconstextnerd for Nerd Font media icons.
Lower other apps while readingoffWSL only for now: turns music and browsers down while speaking.

Voices

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): macOS
  • win:Microsoft Aria: Windows, from WSL
  • espeak:en-us, espeak:he: eSpeak NG
  • pocket:alba, pocket:me: Pocket TTS, its own voices and yours (Pocket TTS)
  • auto: the best voice installed for the language

The 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.

Numbers and symbols as words

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:

  • Each message read is sent once to Claude Haiku, through your own Claude Code session. That text came from Claude in the first place, but with this on, "nothing you read is sent anywhere" no longer holds.
  • It uses a little of your Claude usage, and reading starts a second or two later. A message with no digits, symbols or abbreviations isn't sent.
  • If Haiku fails or takes over 10 seconds, the message is read as written.

It uses the haiku alias, so it follows the newest Haiku model.

Privacy

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.

Troubleshooting

  • Silent? Run /read-along doctor, then look at the service's log: /tmp/read-along-<your uid>.log.
  • Changed something (installed a voice, ran setup)? Run /read-along restart.
  • Crackling on WSL? The service already uses a 300 ms audio buffer there, which WSLg needs. Check that nothing else is holding the audio device.
  • No player above the prompt? The band above the prompt is shared: mods draw it in turn, in the order of 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.
  • No buttons in the phone or desktop app (Remote Control)? Neither shows read-along's buttons when it follows a terminal session over Remote Control (#6); the desktop app draws no mods there at all as of Claude Code 2.1.296. Typed commands such as /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.
  • The first read is slow. Kokoro loads its model on the first read of a session (a few seconds), then keeps it until the service has been idle for 30 minutes.

How it works

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.

Credits and licence

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.

Source 2 files
hooks/register.tsx 1565 lines
1import { 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 lines
1/** 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