Click ⏸︎ narrator off in the footer, or /narrator, and after each turn Claude tells you out loud what it did, in your language, with the best free voice your…

A Claude Code mod: click ⏸︎ narrator off at the bottom right of the prompt, or type /narrator, and after each turn Claude tells you out loud what it did, in your language, with the best free voice your system has.
❯ _
? for shortcuts ⏵ narrator on
The switch reads like the footer's mode labels (⏵⏵ auto mode on), in quiet colors: ⏵ narrator on in green, ⏸︎ narrator off in gray. A click switches it (in fullscreen, where the terminal reports clicks); /narrator does it anywhere.
While it speaks the switch reads ⏹︎ narrating in blue, a lighter blue sweeping across it from left to right, and a click stops it, leaving the narrator on. When narrations of other windows wait their turn behind it, it reads ⏭︎ narrating, and a click skips to the next one.
With a language set (see Settings) the switch shows it after its words: ⏵ narrator on · ES, ⏸︎ narrator off · ES, ⏹︎ narrating · ES.
Where the footer is short the switch gives up on or off, then the language, then narrator (or narrating), and stays a ⏵, ⏸︎, ⏹︎ or ⏭︎ in its color, so the row never breaks in two. Beside Sub Meter, the meter shrinks first.
When a turn ends, Haiku (at low effort, so it is quick) reads your request, the tools Claude used and its final message, and writes one or two short spoken sentences in first person, said to you ("I added the narrator and the tests pass"), ending with Claude's question when it asked you one ("Do I open a PR?"), in the language you wrote in, or the one you set (see Settings). Haiku also names that language, and the mod picks the best voice installed for it; where there is none, the English one. When the request's words tell its language, Haiku is told to speak it, and a narration in another language is not said: the answer itself is read instead. A short answer already says it, so it is read out as it is, with no Haiku call: a path by its file's name (hooks/register.tsx: "register") and no commit hashes, in the language its words or your request tell, else the one the last narration was in (on Windows and Linux, Haiku narrates it while neither tells; macOS tells the language from the text). With a language set, a short answer is read as it is only when its words or your request tell it is in that language; any other, Haiku says in it. /recap is spoken too.
| System | Voice |
|---|---|
| macOS | AVSpeechSynthesizer: Siri's natural voices first, then premium, enhanced and compact ones, in your region (say cannot reach Siri's) |
| Windows | Windows.Media.SpeechSynthesis, the OneCore voices Narrator uses; the desktop SAPI voices where WinRT does not load |
| WSL | Windows' voices, through powershell.exe; Linux's where that does not run |
| Linux | spd-say (speech-dispatcher, with whatever module it is set up with: espeak-ng, RHVoice, Piper...), else espeak-ng or espeak |
A new prompt cuts the narration short. It stays on in your next sessions until you switch it off. A session nobody is at (claude -p, as a script or a bot that runs Claude Code uses) is not narrated.
/narrator switch on or off
/narrator on|off
/narrator stop quiet now, stay on
/narrator again say the last narration again
/narrator read read the last answer in full
/narrator voices list the installed voices, by language
/narrator test [voice] say a short sample with a voice
/narrator again says the last narration again, as it was, with no model call. /narrator read reads Claude's last answer in full instead of its summary, as plain text (no code blocks, a path by its file's name), even with the narrator off; a long one takes a while, and /narrator stop, a click on the switch or a new prompt cuts it short.
/narrator voices lists the voices your system has, one line per language (es-ES: Eddy, Mónica); on macOS it leaves out the novelty ones (Bahh, Bells...). On Linux it shows speech-dispatcher's table, whose NAME column holds the voices of its output module, and eSpeak's.
/narrator test says a short English sample with the voice in /config (or, if it is empty, the one the system picks), and tells you which voice spoke. Pass a voice to try it without changing /config: /narrator test Microsoft Helena. A name several languages share (Eddy) can take its language, as /narrator voices shows it: /narrator test Eddy (es-MX). If the system has no voice by that name, the preview says so and names the one it used instead.
sudo apt install speech-dispatcher espeak-ng; for natural voices, set speech-dispatcher up with RHVoice or Piper./plugin marketplace add midudev/better-claude
/plugin install narrator@better-claude
In /config:
Mónica, Microsoft Helena, or Eddy (es-MX) for one language's) to always speak with. Empty: the best one for the language. /narrator voices lists them and /narrator test tries one.es, en, pt) to always narrate in, whatever language you write in; a region is dropped (es-MX: es, the voice's region still comes from the system). It is checked against the runtime's language list: an unknown value (zz, a typo, any other text) is ignored, and the narrator goes back to automatic. The switch then shows it (⏵ narrator on · ES). Empty: the language you write in, and the switch shows none.0.5 to 2: 1.3 by default, a little quicker; 1 is the voice's own. The engine's voice, the last resort, keeps its own pace.0: Haiku narrates every answer.haiku by default.my-tts --voice Mónica runs my-tts --voice Mónica "<narration>"; it can also read NARRATOR_TEXT and NARRATOR_LANG, the narration and its language. It waits its turn in the line like any voice, and a new prompt ends it. The Voice and Speed settings are the program's to read (NARRATOR_VOICE, NARRATOR_SPEED). If it fails, the engine's own voice says the narration. On Windows it is left aside. Empty: the system's voice.Every window shares one line, a folder in your temp directory: when two sessions finish together, their narrations are said one after the other, in the order they were ready. On Windows the line is Windows' own, and WSL's windows share it. One that would wait behind three others, or for two minutes, is dropped. A window that closes never holds the line. A new prompt, /narrator stop or switching it off cuts a narration short, also while it waits its turn; a click on ⏭︎ narrating cuts the one being said and lets the next speak.
One hooks module (hooks/register.tsx): a ui.render hook on the footer's SessionMode draws the switch as a Button, as wide as the terminal's width leaves room for beside the mode label and the hint (which a hook on PromptHint measures), turn.start and tool.call note the request and each tool call of the main loop, and turn.complete (and command.run on /recap) asks the model for the narration and hands it to a speaker (hooks/speaker.ts): JavaScript for Automation on macOS, PowerShell on Windows, a shell wrapper on Linux. Each writes a . while it speaks, so the mod can end it the moment you send a new prompt, and waiting <n> when the number of narrations waiting behind it changes, which the switch shows. Where none works, the engine's own $.audio.speak.
claude plugin validate . # from this folder
claude plugin test . # runs hooks/register.test.tsx against the engine
MIT © Miguel Ángel Durán (midudev)
hooks/register.tsx 725 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderNode, RenderViewport, Timer } from 'claude-code'
3
4import { LINUX, MACOS, MACOS_VOICES, QUEUE, WINDOWS, WINDOWS_VOICES, dispatcherRate, espeakWords, powershell } from './speaker'
5import { cut, finalQuestion, guessLanguage, parseNarration, plain, withQuestion } from './text'
6
7type Platform = 'macos' | 'windows' | 'linux' | 'other'
8/** A program that speaks, and what it is told through its environment. */
9type Command = { argv: string[]; env: Record<string, string>; program: string; isFallback?: boolean }
10/** How a speaker spoke: the voice it used, and whether that is not the one asked for (none matched); or that the line dropped it. */
11type Spoke = { voice: string; isFallback: boolean; isSkipped?: boolean }
12/**
13 * One narration: the model call that writes it, then the program that speaks it, until silenced;
14 * once that speaks, how many narrations of other windows wait their turn behind it.
15 */
16type Narration = { isActive: boolean; abort: AbortController; isSpeaking?: boolean; waiting?: number }
17/** The synthesizer found on Linux, the languages it has voices for and their names, lowercased (empty: it did not say). */
18type LinuxSpeech = { tool?: 'spd-say' | 'espeak-ng' | 'espeak'; languages: Set<string>; names: Set<string> }
19
20// The switch at the right of the prompt footer, in the style of the mode labels (⏵⏵ auto mode on),
21// each in a quiet color, with the words it gives up first where the row is short. While it speaks,
22// a click goes on to the next narration in the line, or, with none waiting, stops it. U+FE0E keeps
23// the pause, skip and stop signs text glyphs, never emojis.
24const SWITCH_ON = { icon: '\u23F5', words: ['narrator on', 'narrator'], color: 'success' } as const
25const SWITCH_OFF = { icon: '\u23F8\uFE0E', words: ['narrator off', 'narrator'], color: 'inactive' } as const
26const SWITCH_SKIP = { icon: '\u23ED\uFE0E', words: ['narrating'], color: 'permission' } as const
27const SWITCH_STOP = { icon: '\u23F9\uFE0E', words: ['narrating'], color: 'permission' } as const
28// While it speaks a lighter blue, the theme's own shimmer of it, sweeps the switch from left to
29// right: three cells wide, a cell a tenth of a second (the footer redraws ten times a second at
30// most), and a pause of four steps between sweeps.
31const SHIMMER = 'permissionShimmer'
32const SWEEP_MS = 100
33const SWEEP_WIDTH = 3
34const SWEEP_PAUSE = 4
35// The terminal footer's row: two columns of padding at each end, the mode label at the left
36// (`⏵⏵ accept edits on ·`, the longest but bypass's) and the hint after it, two columns at least
37// between that and what is drawn here.
38const FOOTER_PADDING = 4
39const FOOTER_GAP = 2
40const MODE_LABEL = 20
41const SEPARATOR = ' · '
42// Room left, when nothing of another plugin is beneath, for one that may wrap the switch and draw
43// before it: Sub Meter's shortest meter (`week 100%`) and its separator.
44const AROUND = 9 + SEPARATOR.length
45// A little quicker than the voice's normal pace, unless the settings say otherwise.
46const SPEED = 1.3
47const MAX_ACTIONS = 40
48const PREVIEW = 'Hello! This is a voice preview for Narrator.'
49// The tool inputs that say best what a call did, in order of preference.
50const DETAILS = ['description', 'file_path', 'notebook_path', 'path', 'pattern', 'url', 'query', 'skill', 'command', 'prompt']
51const LANGUAGE_CODE = /^[a-z]{2,3}(?:[-_][a-z0-9]{2,8})*$/i
52const DISPLAY_NAMES = new Intl.DisplayNames(['en'], { type: 'language', fallback: 'none' })
53// What /recap prints when it wrote no recap: nothing to say out loud.
54const NO_RECAP = /^(Nothing to recap yet\b|Recap cancelled\.|Couldn't generate a recap\.|\/recap only runs\b|API Error:)/
55
56/** The English name of a language code, or undefined where the runtime's language data does not know it. */
57function languageName (code: string) {
58 try {
59 return DISPLAY_NAMES.of(code)
60 } catch {
61 return undefined
62 }
63}
64
65/** The language the narrator is told to speak: the one set, else the one the user wrote in. */
66function spoken (language: string) {
67 if (!language) return "Speak the language the user wrote the request in; if that is unclear, the language of Claude's final message."
68 return `Always speak ${languageName(language) ?? language} (${language}), whatever language the request or Claude's final message is in.`
69}
70
71const NARRATOR = (language: string) => `You are the voice of Claude Code, a coding assistant. After each of its turns you speak to the user, out loud, as Claude: in the first person, to them. You are Claude talking to the person, not a narrator talking about them.
72
73You get the user's request, the actions Claude took and Claude's final message. Say what a colleague would say in passing: what was done and how it turned out, plus anything the user has to do or decide now. Leave out how it was done. If Claude only answered a question, give the gist of the answer.
74
75Talk to the user, never about them: address them in the second person ("you"), in the language you speak, never as "the user". And you are Claude: say "I", never "Claude" or "the assistant". When you are given Claude's closing question, it is read right after your words, as Claude wrote it: do not ask it, report it or mention it. Otherwise, if Claude's final message asks the user something, end with that question, asked to them directly as a question ("Do I run the quick test?"), not reported ("I ask whether to run it") and with no label before it.
76
77${spoken(language)}
78
79Rules:
80- A speech synthesizer reads it aloud: plain sentences only. No markdown, lists, emojis or code, and no file paths, URLs, hashes or version strings read out character by character: name files and commands the way a person says them ("the register file", "the tests").
81- Short: one sentence, two only for a large change; never more than 30 words, plus Claude's question when it asked one.
82- No preamble ("Sure", "In summary"), no question of your own, nothing Claude did not say or do.
83
84Reply in exactly this form: the first line holds only the ISO 639-1 code of the language you speak (es, en, pt, fr...), and the narration starts on the second line.`
85
86const enabled = atom({ plugin: 'narrator', key: 'isEnabled' } as const, false)
87const speech = atom({ plugin: 'narrator', key: 'speech' } as const, null)
88const sweepStep = atom({ plugin: 'narrator', key: 'sweepStep' } as const, 0)
89
90/** An answer this short is read out as it is: no model call to write a narration of it. */
91const SHORT_ANSWER = 200
92
93let settings = { voice: '', model: 'haiku', speed: SPEED, shortAnswer: SHORT_ANSWER, language: '', command: '' }
94// The main loop's turn so far: the prompt that started it and each tool call it made.
95let request = ''
96let actions: string[] = []
97let current: Narration | undefined
98let platform: Promise<Platform> | undefined
99let wsl: Promise<boolean> | undefined
100let linuxSpeech: Promise<LinuxSpeech> | undefined
101let hasWarned = false
102// The hint as last drawn on the terminal, in columns.
103let hintWidth = 0
104let lastHint = ''
105// The language the last narration was said in: a short answer whose words do not tell is read in it.
106let spokenLanguage = ''
107// The last narration said, for `/narrator again`, and the main loop's last answer with its request, for `/narrator read`.
108let lastSaid: { text: string; language: string } | undefined
109let lastAnswer: { answer: string; request: string } | undefined
110// The sweep across the switch, running while it speaks.
111let sweep: Timer | undefined
112let isNarratorEnabled = false
113let lastCellsText = ''
114let lastCells: string[] = []
115
116/**
117 * A language setting as its ISO 639 code (`es-MX` is `es`), checked against the language data the
118 * runtime ships (`Intl.DisplayNames`), so a typo or any other text is not a language: empty, automatic.
119 */
120export function languageOf (value: unknown) {
121 const code = String(value ?? '').trim()
122 const language = LANGUAGE_CODE.test(code) ? code.split(/[-_]/)[0]!.toLowerCase() : ''
123 return language && languageName(language) ? language : ''
124}
125
126const message = (error: unknown) => (error instanceof Error ? error.message : String(error))
127
128/** One tool call as a line the narrator reads: its tool, then the input that says most. */
129function describe (e: object) {
130 const input = e as Record<string, unknown>
131 const detail = DETAILS.map(key => input[key]).find(value => typeof value === 'string' && value.trim() !== '')
132 return detail === undefined ? String(input.tool) : `${String(input.tool)}: ${cut(String(detail).replace(/\s+/g, ' '), 200)}`
133}
134
135/** What the narrator is told about the turn, and Claude's closing question, which is read after it as it is. */
136function brief (answer: string, question: string) {
137 const did = actions.length > 0 ? actions.map(action => `- ${action}`).join('\n') : '(no tools: Claude only answered)'
138 return [
139 `The user's request:\n<request>\n${cut(request, 2000) || '(none: Claude went on by itself)'}\n</request>`,
140 `What Claude did, in order:\n${did}`,
141 `Claude's final message to the user:\n<message>\n${cut(answer, 6000)}\n</message>`,
142 ...(question ? [`Claude's closing question, read right after your words as it is (do not ask, report or mention it):\n<question>\n${question}\n</question>`] : [])
143 ].join('\n\n')
144}
145
146/** The system the session runs on, asked once. */
147function platformOf ($: EngineInterface) {
148 platform ??= (async (): Promise<Platform> => {
149 if ((await $.env.get('OS')) === 'Windows_NT') return 'windows'
150 const name = (await $.process.run(['uname', '-s']).catch(() => undefined))?.stdout.trim()
151 return name === 'Darwin' ? 'macos' : name === 'Linux' ? 'linux' : 'other'
152 })()
153 return platform
154}
155
156/** Whether the session runs under WSL: Linux, on a Windows desktop whose voices are better. */
157function wslOf ($: EngineInterface) {
158 wsl ??= (async () => (await platformOf($)) === 'linux' && (await $.env.get('WSL_DISTRO_NAME')) !== undefined)()
159 return wsl
160}
161
162/** The language codes listed in a synthesizer's voice table, by their main part (`es` for es-419). */
163function languagesIn (table: string) {
164 const languages = new Set<string>()
165 for (const line of table.split('\n').slice(1)) {
166 const code = line.trim().split(/\s+/).slice(1).find(word => LANGUAGE_CODE.test(word))
167 if (code) languages.add(code.split(/[-_]/)[0]!.toLowerCase())
168 }
169 return languages
170}
171
172/** The voice names in `spd-say -L`'s table, lowercased: what comes before each row's language. */
173function namesIn (table: string) {
174 const names = new Set<string>()
175 for (const line of table.split('\n').slice(1)) {
176 const words = line.trim().split(/\s+/)
177 const at = words.findIndex((word, i) => i > 0 && LANGUAGE_CODE.test(word))
178 if (at > 0) names.add(words.slice(0, at).join(' ').toLowerCase())
179 }
180 return names
181}
182
183/** Which synthesizer Linux has, speech-dispatcher first, and its languages, asked once. */
184function linuxSpeechOf ($: EngineInterface) {
185 linuxSpeech ??= (async (): Promise<LinuxSpeech> => {
186 const found = (await $.process.run(['sh', '-c', 'command -v spd-say espeak-ng espeak']).catch(() => undefined))?.stdout ?? ''
187 const paths = found.split('\n').map(path => path.trim())
188 const tool = (['spd-say', 'espeak-ng', 'espeak'] as const).find(name => paths.some(path => path.endsWith(`/${name}`)))
189 if (!tool) return { languages: new Set(), names: new Set() }
190 const listed = await $.process.run(tool === 'spd-say' ? ['spd-say', '-L'] : [tool, '--voices'], { timeoutMs: 5000 }).catch(() => undefined)
191 const table = listed?.stdout ?? ''
192 return { tool, languages: languagesIn(table), names: tool === 'spd-say' ? namesIn(table) : new Set() }
193 })()
194 return linuxSpeech
195}
196
197/** A speaker that first waits its turn in the line every window shares (PowerShell waits in its own). */
198const inLine = (command: Command): Command => ({ ...command, argv: ['sh', '-c', QUEUE, 'narrator-line', ...command.argv] })
199
200/** The programs that can say `text` here, best first; `ticket`, when it was ready, is its place in the line. */
201async function speakersFor ($: EngineInterface, text: string, language: string, voice: string, ticket: string): Promise<Command[]> {
202 const env = { NARRATOR_TEXT: text, NARRATOR_LANG: language, NARRATOR_VOICE: voice, NARRATOR_SPEED: String(settings.speed), NARRATOR_TICKET: ticket }
203 const system = await platformOf($)
204 // A speak command of the person's own, run by the shell with the narration as its last argument.
205 if (settings.command && system !== 'windows') {
206 const argv = ['sh', '-c', LINUX, 'narrator', 'sh', '-c', `exec ${settings.command} "$1"`, 'narrator-command', text]
207 return [inLine({ argv, env: { ...env, NARRATOR_SPEAKER: settings.command }, program: settings.command })]
208 }
209 if (system === 'macos') return [inLine({ argv: ['osascript', '-l', 'JavaScript', '-e', MACOS], env, program: 'osascript' })]
210 if (system === 'windows') return [{ argv: powershell(WINDOWS), env, program: 'powershell' }]
211 if (system !== 'linux') return []
212
213 const speakers: Command[] = []
214 if (await wslOf($)) {
215 const inherited = await $.env.get('WSLENV')
216 const crossing = [inherited, 'NARRATOR_TEXT', 'NARRATOR_LANG', 'NARRATOR_VOICE', 'NARRATOR_SPEED', 'NARRATOR_TICKET'].filter(Boolean).join(':')
217 speakers.push({ argv: powershell(WINDOWS, 'powershell.exe'), env: { ...env, WSLENV: crossing }, program: 'powershell.exe' })
218 }
219 const { tool, languages, names } = await linuxSpeechOf($)
220 // A language the synthesizer has no voice for is read by its English voice.
221 const lang = language && (languages.size === 0 || languages.has(language) ? language : 'en')
222 // At speed 1 no rate is given: the synthesizer keeps the one it is set up with.
223 const isPaced = settings.speed !== 1
224 if (tool === 'spd-say') {
225 const options = [...(lang ? ['-l', lang] : []), ...(voice ? ['-y', voice] : []), ...(isPaced ? ['-r', String(dispatcherRate(settings.speed))] : [])]
226 // speech-dispatcher speaks a voice it does not have with its default one, and says nothing.
227 const isFallback = voice !== '' && names.size > 0 && !names.has(voice.toLowerCase())
228 speakers.push(inLine({ argv: ['sh', '-c', LINUX, 'narrator', 'spd-say', '-w', ...options, '--', text], env: { ...env, NARRATOR_STOP: 'spd-say -S' }, program: 'spd-say', isFallback }))
229 } else if (tool) {
230 const selectedVoice = voice || lang
231 const options = [...(selectedVoice ? ['-v', selectedVoice] : []), ...(isPaced ? ['-s', String(espeakWords(settings.speed))] : [])]
232 speakers.push(inLine({ argv: ['sh', '-c', LINUX, 'narrator', tool, ...options, '--', text], env, program: tool }))
233 }
234 return speakers
235}
236
237async function commandOutput ($: EngineInterface, argv: string[]) {
238 const result = await $.process.run(argv, { timeoutMs: 10_000 })
239 if (result.exitCode !== 0) throw new Error(result.stderr.trim() || `exit code ${result.exitCode}`)
240 return result.stdout.trim()
241}
242
243/** `name<TAB>language` rows as one line per language: `es-ES: Eddy, Mónica`. A voice installed in two qualities is listed once: its name picks the best. */
244function formatNamedVoices (output: string) {
245 const byLanguage = new Map<string, Set<string>>()
246 for (const line of output.split(/\r?\n/).map(row => row.trim()).filter(Boolean)) {
247 const [name = '', language = '?'] = line.split('\t')
248 byLanguage.set(language, (byLanguage.get(language) ?? new Set()).add(name))
249 }
250 return [...byLanguage].sort(([a], [b]) => a.localeCompare(b)).map(([language, names]) => `${language}: ${[...names].join(', ')}`)
251}
252
253/** Lists voices exposed by this system's speech synthesizers. */
254async function listVoices ($: EngineInterface) {
255 const system = await platformOf($)
256 if (system === 'macos') {
257 const output = await commandOutput($, ['osascript', '-l', 'JavaScript', '-e', MACOS_VOICES])
258 const voices = formatNamedVoices(output)
259 return voices.length ? `Installed voices:\n${voices.join('\n')}` : 'Narrator: no installed voices found.'
260 }
261 if (system === 'windows') {
262 const output = await commandOutput($, powershell(WINDOWS_VOICES))
263 const voices = formatNamedVoices(output)
264 return voices.length ? `Installed voices:\n${voices.join('\n')}` : 'Narrator: no installed voices found.'
265 }
266 if (system !== 'linux') return 'Narrator: voice discovery is not supported on this system.'
267
268 const sections: string[] = []
269 const failures: string[] = []
270 if (await wslOf($)) {
271 try {
272 const output = await commandOutput($, powershell(WINDOWS_VOICES, 'powershell.exe'))
273 const voices = formatNamedVoices(output)
274 if (voices.length) sections.push(`Windows voices:\n${voices.join('\n')}`)
275 } catch (error) {
276 failures.push(`Windows: ${message(error)}`)
277 }
278 }
279 const found = await commandOutput($, ['sh', '-c', 'for tool in spd-say espeak-ng espeak; do command -v "$tool" || true; done'])
280 const paths = found.split('\n').map(path => path.trim())
281 const has = (name: string) => paths.some(path => path.endsWith(`/${name}`))
282 if (has('spd-say')) {
283 try {
284 const output = await commandOutput($, ['spd-say', '-L'])
285 if (output) sections.push(`Speech Dispatcher voices (its output module's; pass a NAME to /narrator test):\n${output}`)
286 } catch (error) {
287 failures.push(`Speech Dispatcher: ${message(error)}`)
288 }
289 }
290 const espeak = has('espeak-ng') ? 'espeak-ng' : has('espeak') ? 'espeak' : undefined
291 if (espeak) {
292 try {
293 const output = await commandOutput($, [espeak, '--voices'])
294 if (output) sections.push(`${espeak} voices:\n${output}`)
295 } catch (error) {
296 failures.push(`${espeak}: ${message(error)}`)
297 }
298 }
299 if (!sections.length && failures.length) return `Narrator: could not list voices. ${failures.join('; ')}`
300 if (!sections.length) return 'Narrator: no installed voices found.'
301 if (failures.length) sections.push(`Some voices could not be listed: ${failures.join('; ')}`)
302 return sections.join('\n\n')
303}
304
305/** Shows on the switch whether this session's narration is speaking, and how many wait behind it; while it does, sweeps it. */
306async function showSpeech ($: EngineInterface) {
307 const speaking = current?.isSpeaking ? { waiting: current.waiting ?? 0 } : null
308 await update($, speech, () => speaking)
309 if (speaking && !sweep) sweep = $.clock.every(SWEEP_MS, () => void update($, sweepStep, step => step + 1))
310 if (!speaking && sweep) {
311 sweep.cancel()
312 sweep = undefined
313 await update($, sweepStep, () => 0)
314 }
315}
316
317/** Follows what a speaker says of itself: `voice` once it speaks, `waiting <n>` while others wait their turn. */
318async function follow ($: EngineInterface, narration: Narration, said: string) {
319 const isSpeaking = narration.isSpeaking === true || /^voice /m.test(said)
320 const told = [...said.matchAll(/^waiting (\d+)\r?$/gm)].at(-1)?.[1]
321 const waiting = told === undefined ? narration.waiting ?? 0 : Number(told)
322 if (isSpeaking === (narration.isSpeaking === true) && waiting === (narration.waiting ?? 0)) return
323 narration.isSpeaking = isSpeaking
324 narration.waiting = waiting
325 await showSpeech($)
326}
327
328/** Runs one speaker till it ends or the narration is silenced; how it spoke, if it did. */
329async function play ($: EngineInterface, narration: Narration, command: Command): Promise<Spoke | undefined> {
330 let said = ''
331 let failure = ''
332 try {
333 for await (const { text } of $.process.spawn(command)) {
334 said = (said + text.replace(/^\.\r?$/gm, '')).slice(-4000)
335 if (!narration.isActive) break
336 await follow($, narration, said)
337 }
338 } catch (error) {
339 failure = message(error)
340 } finally {
341 if (narration.isSpeaking) {
342 narration.isSpeaking = false
343 await showSpeech($)
344 }
345 }
346 const voice = /^voice (.+?)\r?$/m.exec(said)?.[1]
347 // Behind others for too long: the line dropped it, and nothing else should say it now.
348 if (/^skipped\r?$/m.test(said)) {
349 $.ui.log('narrator: not said, the line of narrations from other sessions was too long', { to: 'debug' })
350 return { voice: '', isFallback: false, isSkipped: true }
351 }
352 failure ||= /^error.*$/m.exec(said)?.[0] ?? (voice ? '' : `${command.program} said nothing`)
353 // Under WSL the Windows side outlives its end of the pipe: end it by its id.
354 const pid = /^pid (\d+)/m.exec(said)?.[1]
355 if (!narration.isActive && pid && command.program === 'powershell.exe') {
356 await $.process.run(['taskkill.exe', '/PID', pid, '/T', '/F']).catch(() => undefined)
357 }
358 if (!narration.isActive) return undefined
359 $.ui.log(failure ? `narrator: ${command.program}: ${failure}` : `narrator: spoke with ${voice}`, { to: 'debug' })
360 return failure === '' ? { voice: voice!, isFallback: command.isFallback === true || /^no voice /m.test(said) } : undefined
361}
362
363/** Says `text` with the best speaker that works here, the engine's own synthesizer last; how, if it did. */
364async function speak ($: EngineInterface, narration: Narration, text: string, language: string, voice = settings.voice): Promise<Spoke | undefined> {
365 // Every window shares one line, so two sessions that finish together are said one after the other.
366 const ticket = String(await $.clock.now()).padStart(13, '0')
367 for (const command of await speakersFor($, text, language, voice, ticket)) {
368 const spoke = await play($, narration, command)
369 if (spoke || !narration.isActive) return spoke
370 }
371 try {
372 await $.audio.speak(text, voice ? { voice } : undefined)
373 return { voice: voice || "Claude Code's own voice", isFallback: false }
374 } catch (error) {
375 $.ui.log(`narrator: ${message(error)}`, { to: 'debug' })
376 if (hasWarned) return undefined
377 hasWarned = true
378 const system = await platformOf($)
379 $.ui.toast(system === 'linux'
380 ? 'Narrator: no voice to speak with. Install speech-dispatcher (spd-say) or espeak-ng.'
381 : `Narrator: no voice to speak with (${message(error)}).`)
382 return undefined
383 }
384}
385
386/** Says a short sample with `voice` (empty: the one picked for English), and what came of it. */
387async function testVoice ($: EngineInterface, voice: string) {
388 silence()
389 const narration: Narration = { isActive: true, abort: new AbortController() }
390 current = narration
391 try {
392 const spoke = await speak($, narration, PREVIEW, 'en', voice)
393 if (!narration.isActive) return 'Narrator: preview stopped.'
394 if (!spoke) return 'Narrator: could not play the voice preview.'
395 if (spoke.isSkipped) return 'Narrator: not previewed, other sessions are speaking. Try again in a moment.'
396 if (voice && spoke.isFallback) return `Narrator: there is no voice "${voice}", so the preview used ${spoke.voice}. /narrator voices lists the installed ones.`
397 return `Narrator: previewed ${voice || spoke.voice}.`
398 } catch (error) {
399 $.ui.log(`narrator: voice preview: ${message(error)}`, { to: 'debug' })
400 return `Narrator: could not play the voice preview. ${message(error)}`
401 } finally {
402 narration.isActive = false
403 if (current === narration) current = undefined
404 }
405}
406
407/** Stops what is being said, and the narration being written. */
408function silence () {
409 if (!current) return
410 current.isActive = false
411 current.abort.abort()
412 current = undefined
413}
414
415/** Tells, out loud, what the turn that ended with `answer` did. */
416async function narrate ($: EngineInterface, answer: string) {
417 silence()
418 const narration: Narration = { isActive: true, abort: new AbortController() }
419 current = narration
420 try {
421 // A short answer already says it: read it out, with no model call to retell it, once its language
422 // is known (macOS tells it from the text). With a language set, only when its words or the request
423 // tell it is in that language: any other, the model says in it.
424 const direct = plain(answer)
425 const ownLanguage = guessLanguage(answer) || guessLanguage(`${request}\n${answer}`)
426 const directLanguage = ownLanguage || spokenLanguage
427 const isReadable = settings.language ? ownLanguage === settings.language : directLanguage !== '' || (await platformOf($)) === 'macos'
428 if (direct !== '' && direct.length <= settings.shortAnswer && isReadable) {
429 spokenLanguage = directLanguage
430 lastSaid = { text: direct, language: directLanguage }
431 await speak($, narration, direct, directLanguage)
432 return
433 }
434 // The language to speak: the setting, else the request's when its words tell it. The model is told
435 // it in so many words: left to tell it from a request in one language and tool calls in another, it
436 // sometimes narrates in the wrong one.
437 const asked = settings.language || guessLanguage(request)
438 // Claude's closing question is said as Claude wrote it, after the narration: the model, asked to
439 // pass it on, sometimes reports it ("I ask whether to do it") or drops it. Only when the answer is in
440 // the language to speak: a question in another one is left to the model to ask in it.
441 const answerLanguage = guessLanguage(answer)
442 const question = asked !== '' && answerLanguage !== '' && answerLanguage !== asked ? '' : finalQuestion(answer)
443 const reply = await $.model.complete(
444 // A short summary: no deep thinking, so it is said right after the turn.
445 { model: settings.model, system: NARRATOR(asked), prompt: brief(answer, question), maxTokens: 150, effort: 'low', timeoutMs: 30_000 },
446 { signal: narration.abort.signal }
447 ).catch(() => undefined)
448 if (!narration.isActive) return
449 const told = reply?.isAnswered ? parseNarration(reply.text) : undefined
450 // A narration in another language than the request's is not said, whatever code the model wrote:
451 // its words tell. The setting is left to the model, as the answer may be in neither.
452 const toldLanguage = told ? guessLanguage(told.text) || told.language : ''
453 const isOtherLanguage = !settings.language && asked !== '' && toldLanguage !== '' && toldLanguage !== asked
454 if (isOtherLanguage) $.ui.log(`narrator: the model narrated in ${toldLanguage}, not ${asked}: reading the answer instead`, { to: 'debug' })
455 // No narration written, or not in the request's language: the answer itself, as far as it reads aloud.
456 const { text, language } = told?.text && !isOtherLanguage ? told : { text: cut(direct, 250), language: directLanguage }
457 const said = withQuestion(text, question)
458 if (!said) return
459 // The voice follows the words, not the code the model wrote: a narration in English is never read
460 // by a Spanish voice.
461 const saidLanguage = guessLanguage(said) || language
462 if (saidLanguage) spokenLanguage = saidLanguage
463 lastSaid = { text: said, language: saidLanguage }
464 await speak($, narration, said, saidLanguage)
465 } catch (error) {
466 $.ui.log(`narrator: ${message(error)}`, { to: 'debug' })
467 } finally {
468 if (current === narration) current = undefined
469 }
470}
471
472/** Says `text` again, as it is, in place of whatever is being said: the last narration, or an answer read in full. */
473async function retell ($: EngineInterface, text: string, language: string) {
474 silence()
475 const narration: Narration = { isActive: true, abort: new AbortController() }
476 current = narration
477 try {
478 await speak($, narration, text, language)
479 } catch (error) {
480 $.ui.log(`narrator: ${message(error)}`, { to: 'debug' })
481 } finally {
482 if (current === narration) current = undefined
483 }
484}
485
486/** `/narrator again`: the last narration, said again with no model call. */
487function sayAgain ($: EngineInterface) {
488 if (!lastSaid) return 'Narrator: nothing said yet.'
489 void retell($, lastSaid.text, lastSaid.language)
490 return 'Narrator: saying it again.'
491}
492
493/** `/narrator read`: the last answer in full, as plain text, in the language its words or its request tell. */
494function readAnswer ($: EngineInterface) {
495 if (!lastAnswer) return 'Narrator: no answer to read yet.'
496 const { answer, request: asked } = lastAnswer
497 const text = plain(answer)
498 if (!text) return 'Narrator: the last answer has nothing to read aloud.'
499 void retell($, text, guessLanguage(answer) || guessLanguage(`${asked}\n${answer}`) || spokenLanguage || settings.language)
500 return 'Narrator: reading the last answer.'
501}
502
503/** A click on the switch: while it speaks, on to the next narration in the line or quiet; else on or off. */
504function press ($: EngineInterface) {
505 if (current?.isSpeaking) return silence()
506 void setEnabled($)
507}
508
509/** Switches the narrator on or off (no `value`: the other way), and keeps it for the next sessions. */
510async function setEnabled ($: EngineInterface, value?: boolean) {
511 let isEnabled = false
512 await update($, enabled, now => (isEnabled = value ?? !now))
513 isNarratorEnabled = isEnabled
514 if (!isEnabled) silence()
515 await $.store.set('enabled', isEnabled)
516 return isEnabled
517}
518
519/** Whether a drawing beneath shows nothing: no text, no control (the engine's own node draws `modes`, counted apart). */
520function isBlank (node: RenderNode | undefined): boolean {
521 if (node === undefined || node === null) return true
522 if (typeof node === 'string') return node.trim() === ''
523 if (node.type === 'engine') return true
524 return (node.type === 'Box' || node.type === 'Text') && (node.children ?? []).every(isBlank)
525}
526
527/** Columns a text takes: one per character, none for a variation selector. */
528const widthOf = (text: string) => [...text.replace(/[\uFE0E\uFE0F]/g, '')].length
529
530/** A text's cells: one per character, a variation selector kept with the one before it. */
531function cellsOf (text: string) {
532 if (text === lastCellsText) return lastCells
533 const cells: string[] = []
534 for (const char of text) {
535 if (/[\uFE0E\uFE0F]/.test(char) && cells.length > 0) cells[cells.length - 1] += char
536 else cells.push(char)
537 }
538 lastCellsText = text
539 lastCells = cells
540 return cells
541}
542
543/** A label cut where the sweep is at `step`: before it, under it (empty between sweeps) and after it. */
544function sweptAt (label: string, step: number) {
545 const cells = cellsOf(label)
546 const at = (step % (cells.length + SWEEP_WIDTH + SWEEP_PAUSE)) - SWEEP_WIDTH
547 const start = Math.min(Math.max(at, 0), cells.length)
548 const end = Math.min(Math.max(at + SWEEP_WIDTH, 0), cells.length)
549 return [cells.slice(0, start), cells.slice(start, end), cells.slice(end)].map(part => part.join(''))
550}
551
552/** Columns a drawing beneath takes on one row; the engine's own node draws `modes`, joined by ` & `. */
553function widthOfNode (node: RenderNode | undefined, modes: readonly string[]): number {
554 if (node === undefined || node === null) return 0
555 if (typeof node === 'string') return widthOf(node)
556 if (node.type === 'engine') return widthOf(modes.join(' & '))
557 return (node.children ?? []).reduce((sum, child) => sum + widthOfNode(child, modes), 0)
558}
559
560/** Whether a drawing beneath holds another plugin's: the engine stamps each hook's root with its plugin. */
561function holdsPlugin (node: RenderNode | undefined): boolean {
562 if (node === undefined || node === null || typeof node === 'string' || node.type === 'engine') return false
563 return 'part' in node || (node.children ?? []).some(holdsPlugin)
564}
565
566/** Columns the footer leaves at its right end on the terminal, beside the mode label and the hint; elsewhere, no limit. */
567function roomOf (e: { surface: string; viewport?: RenderViewport }) {
568 if (e.surface !== 'terminal' || e.viewport === undefined) return Infinity
569 return e.viewport.columns - FOOTER_PADDING - FOOTER_GAP - MODE_LABEL - (hintWidth > 0 ? 1 + hintWidth : 0)
570}
571
572export const register: Register = (on, options) => {
573 // Whether someone is at the session. Until session.start says otherwise (a mod loaded in the middle
574 // of a session does not see it), someone is.
575 let isInteractive = true
576 isNarratorEnabled = false
577 lastHint = ''
578 const speed = Number(options.speed)
579 const shortAnswer = Number(options.shortAnswer ?? SHORT_ANSWER)
580 settings = {
581 voice: String(options.voice ?? '').trim(),
582 model: String(options.model || 'haiku'),
583 speed: speed > 0 ? speed : SPEED,
584 shortAnswer: shortAnswer >= 0 ? shortAnswer : SHORT_ANSWER,
585 language: languageOf(options.language),
586 command: String(options.command ?? '').trim()
587 }
588
589 on('session.start', async ($, e, next) => {
590 // A session nobody is at (`claude -p`: a script, a bot that runs Claude Code) is not narrated.
591 isInteractive = e.isInteractive
592 const asked = String(options.language ?? '').trim()
593 if (asked && !settings.language) $.ui.log(`narrator: "${cut(asked, 40)}" is not a language code (es, en, pt...): speaking the language you write in`, { to: 'debug' })
594 await $.command.register({
595 name: 'narrator',
596 description: 'Claude tells you out loud what it did after each turn · no argument: switch on or off',
597 argumentHint: '[on | off | stop | again | read | voices | test [voice]]',
598 immediate: true
599 })
600 const isEnabled = (await $.store.get('enabled')) === true
601 isNarratorEnabled = isEnabled
602 await update($, enabled, () => isEnabled)
603 return next(e)
604 })
605
606 on('session.end', ($, e, next) => {
607 silence()
608 return next(e)
609 })
610
611 on('command.run', { command: 'narrator' }, async ($, e) => {
612 const arg = e.args.trim().toLowerCase()
613 if (arg === 'voices') {
614 try {
615 return { text: await listVoices($) }
616 } catch (error) {
617 return { text: `Narrator: could not list voices. ${message(error)}` }
618 }
619 }
620 const test = /^test(?:\s+([\s\S]+))?$/i.exec(e.args.trim())
621 if (test) {
622 const named = (test[1] ?? '').trim().replace(/^(['"])(.*)\1$/, '$2').trim()
623 return { text: await testVoice($, named || settings.voice) }
624 }
625 if (arg === 'stop') {
626 silence()
627 return { text: 'Narrator: quiet.' }
628 }
629 if (arg === 'again') return { text: sayAgain($) }
630 if (arg === 'read') return { text: readAnswer($) }
631 if (arg !== '' && arg !== 'on' && arg !== 'off') return { text: 'Usage: /narrator [on | off | stop | again | read | voices | test [voice]]' }
632 const isEnabled = await setEnabled($, arg === '' ? undefined : arg === 'on')
633 return { text: isEnabled ? 'Narrator on: Claude will tell you out loud what it did after each turn.' : 'Narrator off.' }
634 })
635
636 // A new prompt means the person has moved on: stop talking.
637 on('prompt.submit', ($, e, next) => {
638 silence()
639 return next(e)
640 }).catch(($, e, next) => next(e))
641
642 on('turn.start', ($, e, next) => {
643 if (e.agentId === undefined) {
644 if (e.text) request = e.text
645 actions = []
646 }
647 return next(e)
648 })
649
650 on('tool.call', async ($, e, next) => {
651 const result = await next(e)
652 if (isInteractive && isNarratorEnabled && e.agentId === undefined && actions.length < MAX_ACTIONS) {
653 actions.push(describe(e) + (result.deny !== undefined ? ' (denied)' : result.isError ? ' (failed)' : ''))
654 }
655 return result
656 }).catch(($, e, next) => next(e))
657
658 on('turn.complete', async ($, e, next) => {
659 const result = await next(e)
660 // Kept on or off, so `/narrator read` can read it whenever it is asked.
661 if (e.agentId === undefined && e.reason === 'answer' && e.answer.trim()) lastAnswer = { answer: e.answer, request }
662 if (isInteractive && isNarratorEnabled && e.agentId === undefined && e.reason === 'answer' && e.answer.trim()) void narrate($, e.answer)
663 return result
664 })
665
666 on('command.run', { command: 'recap' }, async ($, e, next) => {
667 silence()
668 const result = await next(e)
669 if (isInteractive && isNarratorEnabled && result.text?.trim() && !NO_RECAP.test(result.text)) {
670 actions = []
671 void narrate($, result.text)
672 }
673 return result
674 }).catch(($, e, next) => next(e))
675
676 // The switch, last in the footer's right end: a click turns the narrator on or off, and while it
677 // speaks, skips to the next narration in the line or stops it.
678 // A language that is set shows after the words (`⏵ narrator on · ES`); automatic shows none.
679 // Where the row is short it gives up "on" or "off", then the language, then "narrator" (or
680 // "narrating"): its sign and color still tell.
681 on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
682 const [isEnabled, speaking] = await Promise.all([read($, enabled), read($, speech)])
683 const step = speaking ? await read($, sweepStep) : 0
684 // Beneath: the engine's mode labels, or another plugin's drawing there.
685 const beneath = await next(e)
686 const hasBeneath = e.props.modes.length > 0 || !isBlank(beneath)
687 const { Box, Button, Text } = $.ui.resolve(e)
688 const { icon, words, color } = speaking ? (speaking.waiting > 0 ? SWITCH_SKIP : SWITCH_STOP) : isEnabled ? SWITCH_ON : SWITCH_OFF
689 const room = roomOf(e) -
690 (hasBeneath ? widthOfNode(beneath, e.props.modes) + SEPARATOR.length : 0) -
691 (holdsPlugin(beneath) ? 0 : AROUND)
692 const tag = settings.language ? `${SEPARATOR}${settings.language.toUpperCase()}` : ''
693 const labels = [...words.map(word => `${icon} ${word}${tag}`), `${icon} ${words.at(-1)}`]
694 const label = labels.find(text => widthOf(text) <= room) ?? icon
695 const [before, under, after] = speaking ? sweptAt(label, step) : [label, '', '']
696
697 return (
698 <Box flexDirection="row">
699 {hasBeneath ? beneath : null}
700 {hasBeneath ? <Text dimColor>{SEPARATOR}</Text> : null}
701 <Button key="narrator" plain onPress={() => press($)}>
702 <Text color={color}>
703 {before || null}
704 {under ? <Text color={SHIMMER}>{under}</Text> : null}
705 {after || null}
706 </Text>
707 </Button>
708 </Box>
709 )
710 })
711
712 // The hint line beside the mode label: its width tells the switch how much room is left.
713 on('ui.render', { component: 'PromptHint' }, ($, e, next) => {
714 if (e.surface === 'terminal' && e.props.hint !== lastHint) {
715 lastHint = e.props.hint
716 const width = widthOf(e.props.hint)
717 if (width !== hintWidth) {
718 hintWidth = width
719 $.ui.invalidate('ui.render')
720 }
721 }
722 return next(e)
723 })
724}
725hooks/speaker.ts 422 lines1// The programs that speak a narration: one a system, each the best free voice that system has. Each
2// reads NARRATOR_TEXT, what to say; NARRATOR_LANG, its language as an ISO 639-1 code (empty: the
3// program tells, or uses its default); and NARRATOR_VOICE, a voice's name (empty: the best one for
4// the language, English's when the language has none; `Name (es-ES)` picks a name's voice in one
5// language); NARRATOR_SPEED, how fast (1: the voice's normal pace); and NARRATOR_TICKET, when it was
6// ready, its place in the line every window's narrations wait in (see QUEUE). Each writes
7// `no voice <name>` when it has no voice by that name, `voice <name>` once it speaks, or a line
8// starting `error`, then a `.` every tenth of a second while it speaks, so the one reading it is
9// woken to end it, and `waiting <n>` each time the number of narrations waiting behind it in the
10// line changes; while it waits in the line, a `.` every 0.3 s, and `skipped` if the line drops it.
11
12/**
13 * macOS: JavaScript for Automation, run by `osascript`, with AVSpeechSynthesizer, which reaches the
14 * Siri voices (`say` does not). An empty NARRATOR_LANG is told from the text.
15 */
16export const MACOS = String.raw`
17ObjC.import('AVFoundation')
18ObjC.import('NaturalLanguage')
19
20function run () {
21 const env = $.NSProcessInfo.processInfo.environment
22 const read = key => { const value = env.objectForKey(key); return value.isNil() ? '' : ObjC.unwrap(value) }
23 const say = text => $.NSFileHandle.fileHandleWithStandardOutput.writeData($(text + '\n').dataUsingEncoding($.NSUTF8StringEncoding))
24 const text = read('NARRATOR_TEXT')
25 const wanted = read('NARRATOR_VOICE').toLowerCase()
26 const speed = Number(read('NARRATOR_SPEED')) || 1
27 if (!text) return
28
29 const found = $.NLLanguageRecognizer.dominantLanguageForString(text)
30 const language = read('NARRATOR_LANG').toLowerCase().split('-')[0] || (found.isNil() ? 'en' : ObjC.unwrap(found))
31
32 const all = $.AVSpeechSynthesisVoice.speechVoices
33 const voices = []
34 for (let i = 0; i < all.count; i++) {
35 const voice = all.objectAtIndex(i)
36 voices.push({ voice, id: ObjC.unwrap(voice.identifier), name: ObjC.unwrap(voice.name), lang: ObjC.unwrap(voice.language), quality: Number(voice.quality) })
37 }
38
39 // The regions to prefer for a language: the person's own (es-MX before es-ES), then the usual one.
40 const regionsFor = language => {
41 const regions = []
42 const preferred = $.NSLocale.preferredLanguages
43 for (let i = 0; i < preferred.count; i++) {
44 const [lang, region] = ObjC.unwrap(preferred.objectAtIndex(i)).split('-')
45 if (lang === language && region) regions.push(region)
46 }
47 const country = $.NSLocale.currentLocale.countryCode
48 if (!country.isNil()) regions.push(ObjC.unwrap(country))
49 regions.push({ de: 'DE', en: 'US', es: 'ES', fr: 'FR', it: 'IT', ja: 'JP', pt: 'BR', zh: 'CN' }[language] || '')
50 return regions
51 }
52 // Siri's natural voices first, then premium over enhanced over compact, then the preferred region.
53 // The novelty voices (Bahh, Grandpa...) come last.
54 const best = language => {
55 const regions = regionsFor(language)
56 const near = v => { const at = regions.indexOf(v.lang.split('-')[1]); return at < 0 ? 0 : 50 - at * 10 }
57 const score = v =>
58 (v.id.includes('.siri.') ? 1000 : 0) + v.quality * 100 + near(v) - (v.id.includes('.super-compact.') ? 1 : 0) -
59 (/\.(speech\.synthesis|eloquence)\./.test(v.id) ? 10000 : 0)
60 return voices.filter(v => v.lang.split('-')[0] === language).sort((a, b) => score(b) - score(a))[0]
61 }
62 // A name several languages share (Eddy) reads in the narration's language, else its best voice.
63 const isNamed = v => [v.id, v.name, v.name + ' (' + v.lang + ')'].some(name => name.toLowerCase() === wanted)
64 const inLanguage = v => (v.lang.split('-')[0] === language ? 1 : 0)
65 const named = wanted && voices.filter(isNamed).sort((a, b) => inLanguage(b) - inLanguage(a) || b.quality - a.quality)[0]
66 if (wanted && !named) say('no voice ' + read('NARRATOR_VOICE'))
67 const picked = named || best(language) || best('en')
68
69 const synth = $.AVSpeechSynthesizer.alloc.init
70 const utterance = $.AVSpeechUtterance.speechUtteranceWithString(text)
71 if (picked) utterance.voice = picked.voice
72 // The voice's normal pace is a rate of 0.5; measured, each 0.1 above it speaks 0.6 times faster and
73 // each 0.1 below 0.088 slower, the same for Siri's voices and the compact ones.
74 utterance.rate = Math.min(Math.max(speed >= 1 ? 0.5 + (speed - 1) / 6 : 0.5 - (1 - speed) / 0.88, 0), 1)
75 say('voice ' + (picked ? picked.id : 'default'))
76 synth.speakUtterance(utterance)
77
78 const loop = $.NSRunLoop.currentRunLoop
79 let started = false
80 for (let i = 0; i < 6000; i++) {
81 loop.runUntilDate($.NSDate.dateWithTimeIntervalSinceNow(0.1))
82 say('.')
83 if (synth.speaking) started = true
84 else if (started || i > 30) break
85 }
86 if (!started) say('error: the synthesizer did not speak')
87}
88`
89
90/** The voices AVSpeechSynthesizer can use, with their language codes, but the novelty ones (Bahh, Bells...). */
91export const MACOS_VOICES = String.raw`
92ObjC.import('AVFoundation')
93const all = $.AVSpeechSynthesisVoice.speechVoices
94const rows = []
95for (let i = 0; i < all.count; i++) {
96 const voice = all.objectAtIndex(i)
97 if (ObjC.unwrap(voice.identifier).includes('.speech.synthesis.')) continue
98 rows.push(ObjC.unwrap(voice.name) + '\t' + ObjC.unwrap(voice.language))
99}
100rows.sort()
101$.NSFileHandle.fileHandleWithStandardOutput.writeData($(rows.join('\n')).dataUsingEncoding($.NSUTF8StringEncoding))
102`
103
104/**
105 * Windows (and WSL, through `powershell.exe`): the OneCore voices of Windows.Media.SpeechSynthesis,
106 * the ones Narrator uses, played from memory; where WinRT cannot load, the desktop SAPI voices of
107 * System.Speech. It also writes `pid <n>`, so WSL, whose end of the pipe cannot end it, can. It
108 * waits in a line of its own, Windows', which the windows of WSL share.
109 */
110export const WINDOWS = String.raw`
111$ErrorActionPreference = 'Stop'
112$text = $env:NARRATOR_TEXT
113$language = "$env:NARRATOR_LANG".ToLower().Split('-')[0]
114$wanted = "$env:NARRATOR_VOICE"
115# Read with a dot whatever the region's decimal mark.
116$speed = 0.0
117$null = [double]::TryParse("$env:NARRATOR_SPEED", [Globalization.NumberStyles]::Float, [Globalization.CultureInfo]::InvariantCulture, [ref]$speed)
118if ($speed -le 0) { $speed = 1.0 }
119if (-not $text) { exit 0 }
120$region = (Get-Culture).Name
121function Say($line) { [Console]::Out.WriteLine($line); [Console]::Out.Flush() }
122Say "pid $PID"
123
124# Among voices given as Name, Language and Item: the one named (or listed as "Name (es-ES)"); else
125# the language's, the person's region first; else English's, en-US first.
126function Pick($voices) {
127 if ($wanted) {
128 $named = @($voices | Where-Object { "$($_.Name) ($($_.Language))" -eq $wanted -or $_.Name -eq $wanted -or $_.Name -like "*$wanted*" })
129 if ($named.Count -gt 0) { return $named[0] }
130 Say "no voice $wanted"
131 }
132 foreach ($lang in @($language, 'en')) {
133 if (-not $lang) { continue }
134 $matching = @($voices | Where-Object { $_.Language.ToLower().Split('-')[0] -eq $lang })
135 if ($matching.Count -eq 0) { continue }
136 $preferred = if ($lang -eq $language) { $region } else { 'en-US' }
137 $near = @($matching | Where-Object { $_.Language -eq $preferred })
138 if ($near.Count -gt 0) { return $near[0] }
139 return $matching[0]
140 }
141 return $null
142}
143
144$script:isSpeaking = $false
145
146function Speak-OneCore {
147 Add-Type -AssemblyName System.Runtime.WindowsRuntime
148 $null = [Windows.Media.SpeechSynthesis.SpeechSynthesizer, Windows.Media.SpeechSynthesis, ContentType = WindowsRuntime]
149 $synth = New-Object Windows.Media.SpeechSynthesis.SpeechSynthesizer
150 $voices = @([Windows.Media.SpeechSynthesis.SpeechSynthesizer]::AllVoices | ForEach-Object { [pscustomobject]@{ Name = $_.DisplayName; Language = $_.Language; Item = $_ } })
151 $picked = Pick $voices
152 if ($picked) { $synth.Voice = $picked.Item }
153 # A multiple of the voice's pace, from 0.5 to 6 (Windows 10 1803 on).
154 if ($synth.Options) { $synth.Options.SpeakingRate = [Math]::Min([Math]::Max($speed, 0.5), 6) }
155 $asTask = [System.WindowsRuntimeSystemExtensions].GetMethods() | Where-Object { $_.Name -eq 'AsTask' -and $_.GetParameters().Count -eq 1 -and $_.GetParameters()[0].ParameterType.Name -like 'IAsyncOperation*' } | Select-Object -First 1
156 $task = $asTask.MakeGenericMethod([Windows.Media.SpeechSynthesis.SpeechSynthesisStream]).Invoke($null, @($synth.SynthesizeTextToStreamAsync($text)))
157 $null = $task.Wait(-1)
158 $wav = New-Object System.IO.MemoryStream
159 [System.IO.WindowsRuntimeStreamExtensions]::AsStreamForRead($task.Result.GetInputStreamAt(0)).CopyTo($wav)
160 $bytes = $wav.ToArray()
161 # How long it plays: the data after the 44-byte header, at the header's bytes per second.
162 $seconds = ($bytes.Length - 44) / [Math]::Max([BitConverter]::ToInt32($bytes, 28), 1)
163 $wav.Position = 0
164 $player = New-Object System.Media.SoundPlayer($wav)
165 $player.Load()
166 $name = if ($picked) { $picked.Name } else { $synth.Voice.DisplayName }
167 $script:isSpeaking = $true
168 Say "voice $name"
169 $player.Play()
170 $end = (Get-Date).AddSeconds($seconds + 0.3)
171 while ((Get-Date) -lt $end) { Start-Sleep -Milliseconds 100; Tick }
172}
173
174function Speak-Sapi {
175 Add-Type -AssemblyName System.Speech
176 $synth = New-Object System.Speech.Synthesis.SpeechSynthesizer
177 $voices = @($synth.GetInstalledVoices() | Where-Object { $_.Enabled } | ForEach-Object { [pscustomobject]@{ Name = $_.VoiceInfo.Name; Language = $_.VoiceInfo.Culture.Name; Item = $_ } })
178 $picked = Pick $voices
179 if ($picked) { $synth.SelectVoice($picked.Name) }
180 # From -10 to 10, each end about three times slower or faster than the voice's pace.
181 $synth.Rate = [int][Math]::Max(-10, [Math]::Min(10, [Math]::Round(10 * [Math]::Log($speed) / [Math]::Log(3))))
182 $script:isSpeaking = $true
183 Say "voice $($synth.Voice.Name)"
184 $spoken = $synth.SpeakAsync($text)
185 while (-not $spoken.IsCompleted) { Start-Sleep -Milliseconds 100; Tick }
186}
187
188# The line every window waits in, as QUEUE's on macOS and Linux: a ticket (NARRATOR_TICKET, then
189# this process's id) in a folder of the user's own, the oldest live one first, and a mutex for the
190# one that speaks, which Windows lets go of when its process ends. False: dropped, not to be said.
191$script:ticket = $null
192$script:seat = $null
193$script:line = $null
194function Enter-Line {
195 if ("$env:NARRATOR_TICKET" -notmatch '^\d+$') { return $true }
196 $dir = Join-Path ([IO.Path]::GetTempPath()) 'claude-narrator-queue'
197 $mine = "$($env:NARRATOR_TICKET)-$PID"
198 $script:line = $dir
199 try {
200 $null = New-Item -ItemType Directory -Force -Path $dir
201 $script:ticket = Join-Path $dir $mine
202 $null = New-Item -ItemType File -Force -Path $script:ticket
203 $script:seat = New-Object System.Threading.Mutex($false, 'Local\claude-narrator-line')
204 } catch { return $true }
205 $start = Get-Date
206 while ($true) {
207 $ahead = 0
208 try {
209 foreach ($file in @(Get-ChildItem -LiteralPath $dir -File | Sort-Object Name)) {
210 if ($file.Name -eq $mine) { break }
211 $owner = $null
212 if ($file.Name -match '^\d+-(\d+)$') { $owner = Get-Process -Id ([int]$Matches[1]) -ErrorAction SilentlyContinue }
213 # Gone: not a ticket, its process has ended, or one that started after it was written took its id.
214 $isGone = -not $owner
215 if (-not $isGone) { try { $isGone = $owner.StartTime -gt $file.CreationTime } catch { $isGone = $true } }
216 if ($isGone) { Remove-Item -LiteralPath $file.FullName -Force -ErrorAction SilentlyContinue; continue }
217 $ahead++
218 }
219 } catch { return $true }
220 if ($ahead -eq 0) {
221 try { if ($script:seat.WaitOne(0)) { return $true } } catch [System.Threading.AbandonedMutexException] { return $true }
222 }
223 if ($ahead -ge 3 -or ((Get-Date) - $start).TotalSeconds -ge 120) { Exit-Line; return $false }
224 Say '.'
225 Start-Sleep -Milliseconds 300
226 }
227}
228function Exit-Line {
229 if ($script:seat) { try { $script:seat.ReleaseMutex() } catch {} }
230 if ($script:ticket) { Remove-Item -LiteralPath $script:ticket -Force -ErrorAction SilentlyContinue }
231}
232
233# A dot while it speaks, every tenth of a second; every third, how many narrations wait their turn,
234# each time that changes.
235$script:ticks = 0
236$script:told = 0
237function Tick {
238 Say '.'
239 $script:ticks++
240 if (-not $script:ticket -or $script:ticks % 3) { return }
241 $mine = Split-Path -Leaf $script:ticket
242 $waiting = 0
243 try {
244 foreach ($file in @(Get-ChildItem -LiteralPath $script:line -File)) {
245 if ($file.Name -eq $mine -or $file.Name -notmatch '^\d+-(\d+)$') { continue }
246 $owner = Get-Process -Id ([int]$Matches[1]) -ErrorAction SilentlyContinue
247 try { if ($owner -and $owner.StartTime -le $file.CreationTime) { $waiting++ } } catch {}
248 }
249 } catch { return }
250 if ($waiting -ne $script:told) { Say "waiting $waiting"; $script:told = $waiting }
251}
252
253if (-not (Enter-Line)) { Say 'skipped'; exit 0 }
254try {
255 try {
256 Speak-OneCore
257 } catch {
258 if ($script:isSpeaking) { exit 0 }
259 try { Speak-Sapi } catch { if (-not $script:isSpeaking) { Say "error: $($_.Exception.Message)" } }
260 }
261} finally {
262 Exit-Line
263}
264`
265
266/** Lists the Windows voices Narrator can select, including desktop SAPI voices where available. */
267export const WINDOWS_VOICES = String.raw`
268$ErrorActionPreference = 'Stop'
269$voices = @()
270# WinRT and SAPI are independent catalogs; list whichever APIs are available.
271try {
272 Add-Type -AssemblyName System.Runtime.WindowsRuntime
273 $null = [Windows.Media.SpeechSynthesis.SpeechSynthesizer, Windows.Media.SpeechSynthesis, ContentType = WindowsRuntime]
274 $voices += [Windows.Media.SpeechSynthesis.SpeechSynthesizer]::AllVoices | ForEach-Object { [pscustomobject]@{ Name = $_.DisplayName; Language = $_.Language } }
275} catch {}
276try {
277 Add-Type -AssemblyName System.Speech
278 $synth = New-Object System.Speech.Synthesis.SpeechSynthesizer
279 $voices += $synth.GetInstalledVoices() | Where-Object { $_.Enabled } | ForEach-Object { [pscustomobject]@{ Name = $_.VoiceInfo.Name; Language = $_.VoiceInfo.Culture.Name } }
280} catch {}
281$voices | Sort-Object Language, Name -Unique | ForEach-Object { $_.Name + [char]9 + $_.Language }
282`
283
284/**
285 * Linux: runs the synthesizer given as its arguments (spd-say, which speaks with whatever voice
286 * speech-dispatcher is set up with, or espeak-ng), writing a `.` while it runs. Ended, or once
287 * nobody reads, it ends the synthesizer and runs NARRATOR_STOP, which silences speech-dispatcher.
288 * It also runs a speak command of the person's own, on macOS too: NARRATOR_SPEAKER then names it.
289 */
290export const LINUX = String.raw`
291"$@" &
292pid=$!
293stop () {
294 pkill -TERM -P "$pid" 2>/dev/null
295 kill "$pid" 2>/dev/null
296 if [ -n "$NARRATOR_STOP" ]; then $NARRATOR_STOP >/dev/null 2>&1; fi
297 exit 0
298}
299trap stop TERM INT HUP PIPE
300speaker=$NARRATOR_SPEAKER
301[ -n "$speaker" ] || speaker=$1
302echo "voice $speaker"
303while kill -0 "$pid" 2>/dev/null; do
304 echo . 2>/dev/null || stop
305 sleep 0.1
306done
307wait "$pid" || echo "error: $speaker failed"
308`
309
310const clamp = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max)
311
312/**
313 * speech-dispatcher's rate for a speed, from -100 to 100, as its espeak-ng module reads it: 0 is 170
314 * words a minute, -100 is 80 and 100 is 449.
315 */
316export const dispatcherRate = (speed: number) => Math.round(clamp(((speed - 1) * 170 * 100) / (speed >= 1 ? 279 : 90), -100, 100))
317
318/** espeak's words a minute for a speed: 175 at its normal pace, within the 80 to 450 it takes. */
319export const espeakWords = (speed: number) => Math.round(clamp(175 * speed, 80, 450))
320
321const encodedCache = new Map<string, string>()
322
323/** A PowerShell script as `-EncodedCommand` takes it: UTF-16LE, base64. */
324export function encodedCommand (script: string) {
325 const cached = encodedCache.get(script)
326 if (cached !== undefined) return cached
327 const bytes = new Uint8Array(script.length * 2)
328 for (let i = 0; i < script.length; i++) {
329 const code = script.charCodeAt(i)
330 bytes[i * 2] = code & 0xff
331 bytes[i * 2 + 1] = code >> 8
332 }
333 const encoded = bytes.toBase64()
334 encodedCache.set(script, encoded)
335 return encoded
336}
337
338/** How PowerShell runs one of these scripts: no profile, no prompts, no script policy in the way. */
339export const powershell = (script: string, program = 'powershell') => [program, '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-EncodedCommand', encodedCommand(script)]
340
341/**
342 * The line every speaker waits in on macOS and Linux, shared by every window: it runs the speaker
343 * given as its arguments once its turn comes. Each narration takes a ticket (NARRATOR_TICKET, then
344 * this shell's id) in a folder of the user's own, and speaks once its ticket is the oldest of the
345 * live ones and it holds the seat, a link no two can make at once, so one speaks at a time even when
346 * a ticket came in late. A ticket whose process is gone, or older than a quarter of an hour (its id
347 * may have been reused), is thrown away, so a window that was closed never holds the line; the shell
348 * takes its ticket and the seat away as the speech ends, or as it is ended. A narration that would
349 * wait behind three others, or for two minutes, is not said: it prints `skipped`. While its speaker
350 * speaks it prints `waiting <n>` each time the number of narrations waiting their turn changes.
351 */
352export const QUEUE = String.raw`
353case $NARRATOR_TICKET in '' | *[!0-9]*) exec "$@" ;; esac
354dir=$TMPDIR
355[ -n "$dir" ] || dir=/tmp
356dir="$dir/claude-narrator-queue-$(id -u)"
357here=$PWD
358mkdir -p "$dir" 2>/dev/null && cd "$dir" 2>/dev/null || exec "$@"
359mine=$NARRATOR_TICKET-$$
360: > "$mine" 2>/dev/null || { cd "$here"; exec "$@"; }
361pid=
362leave () {
363 if [ -n "$pid" ]; then kill "$pid" 2>/dev/null; fi
364 rm -f "$dir/$mine"
365 if [ "$(readlink "$dir/seat" 2>/dev/null)" = "$$" ]; then rm -f "$dir/seat"; fi
366 exit 0
367}
368trap leave TERM INT HUP PIPE
369# A ticket's two parts: the time it was ready, and its process.
370parts () { IFS=-; set -- $1; IFS=' '; born=$1; owner=$2; }
371start=$(date +%s)
372while :; do
373 now=$(date +%s)
374 ahead=0
375 for f in *-*; do
376 [ -e "$f" ] || continue
377 [ "$f" = "$mine" ] && break
378 # Gone: not a ticket, its process has ended, or older than any speech lasts (a reused process id).
379 case $f in *[!0-9-]* | -* | *- | *-*-*) rm -f "$f"; continue ;; esac
380 parts "$f"
381 if ! kill -0 "$owner" 2>/dev/null || [ $((now - born / 1000)) -gt 900 ]; then rm -f "$f"; continue; fi
382 ahead=$((ahead + 1))
383 done
384 # First in line, it takes the seat: one speaks at a time, even when a ticket came in late.
385 if [ "$ahead" -eq 0 ]; then
386 holder=$(readlink seat 2>/dev/null)
387 if [ -n "$holder" ] && ! kill -0 "$holder" 2>/dev/null; then rm -f seat; fi
388 ln -s "$$" seat 2>/dev/null && break
389 fi
390 if [ "$ahead" -ge 3 ] || [ $((now - start)) -ge 120 ]; then
391 rm -f "$mine"
392 echo skipped
393 exit 0
394 fi
395 echo . 2>/dev/null || leave
396 sleep 0.3
397done
398cd "$here" 2>/dev/null
399"$@" &
400pid=$!
401# While it speaks, how many narrations wait their turn, each time that changes.
402cd "$dir" 2>/dev/null || { wait "$pid"; pid=; leave; }
403told=0
404while kill -0 "$pid" 2>/dev/null; do
405 now=$(date +%s)
406 waiting=0
407 for f in *-*; do
408 case $f in "$mine" | *[!0-9-]* | -* | *- | *-*-*) continue ;; esac
409 parts "$f"
410 if kill -0 "$owner" 2>/dev/null && [ $((now - born / 1000)) -le 900 ]; then waiting=$((waiting + 1)); fi
411 done
412 if [ "$waiting" -ne "$told" ]; then
413 echo "waiting $waiting" 2>/dev/null || leave
414 told=$waiting
415 fi
416 sleep 0.3
417done
418wait "$pid"
419pid=
420leave
421`
422hooks/text.ts 127 lines1// What the narrator says, as text a synthesizer reads well, and the language it is in.
2
3// The first line of the narrator's reply: the language's code, maybe quoted or bracketed, after a
4// label ("Language: es") or beside a stray number ("1. es").
5const LANGUAGE_LINE = /^[^\p{L}\n]*(?:(?:language|lang|idioma)[^\p{L}\n]+)?([a-z]{2,3})(?:[-_][a-z0-9]{2,8})?[^\p{L}\n]*\n([\s\S]*)$/iu
6// A first line with no letters at all ("1", "--"): no narration, and never to be read out.
7const BARE_LINE = /^[^\p{L}\n]*\n([\s\S]*)$/u
8// The code before a colon, on the narration's own line ("es: He añadido…").
9const INLINE_CODE = /^[^\p{L}\n]*([a-z]{2,3})(?:[-_][a-z0-9]{2,8})?\s*:\s+(\S[\s\S]*)$/iu
10const DISPLAY_NAMES = new Intl.DisplayNames(['en'], { type: 'language', fallback: 'none' })
11// Common words, to tell the language of a text no model narrated.
12const WORDS: Record<string, Set<string>> = {
13 en: new Set(['the', 'and', 'is', 'to', 'of', 'it', 'you', 'that', 'with', 'for', 'this', 'are']),
14 es: new Set(['el', 'la', 'que', 'de', 'y', 'en', 'los', 'las', 'es', 'para', 'por', 'una', 'del', 'he']),
15 pt: new Set(['o', 'que', 'não', 'em', 'um', 'uma', 'os', 'você', 'está', 'do', 'da', 'foi']),
16 fr: new Set(['le', 'la', 'les', 'et', 'est', 'des', 'une', 'pour', 'que', 'dans', 'vous', 'pas']),
17 de: new Set(['der', 'die', 'das', 'und', 'ist', 'nicht', 'ich', 'mit', 'für', 'sie', 'den', 'ein']),
18 it: new Set(['il', 'che', 'di', 'è', 'per', 'una', 'con', 'non', 'sono', 'gli', 'della', 'ho'])
19}
20
21// A commit's hash, which a synthesizer reads letter by letter: hex digits, a number and a letter among them.
22const HASH = /\b(?=[0-9a-f]*\d)(?=[0-9a-f]*[a-f])[0-9a-f]{7,40}\b/g
23const EXTENSION = /(.)(?:\.[a-z][a-z0-9]{0,4})+$/i
24
25/** How a person names a file or folder: by its last part, without the extension (`hooks/register.tsx`: register). */
26const fileName = (path: string) => (path.split(/[\\/]/).filter(Boolean).pop() ?? '').replace(EXTENSION, '$1')
27
28/** Whether a word with a slash in it is a path, not `and/or` or a date: from the root, home or here, or with an extension or two slashes, its last part a name. */
29const isPath = (word: string) =>
30 /[a-z]/i.test(fileName(word)) && (/^(?:~|\.{1,2})?\//.test(word) || EXTENSION.test(word) || word.split('/').length > 2)
31
32/** `text` cut to `size` characters, at a sentence's end when there is one in its second half. */
33export function cut (text: string, size: number) {
34 if (text.length <= size) return text
35 const head = text.slice(0, size)
36 const end = Math.max(head.lastIndexOf('. '), head.lastIndexOf('! '), head.lastIndexOf('? '))
37 return end > size / 2 ? head.slice(0, end + 1) : `${head.trimEnd()}…`
38}
39
40/**
41 * Markdown as a synthesizer should read it: no code blocks, marks, links' addresses or bullets, a
42 * path said by its file's name, and no hashes.
43 */
44export function plain (markdown: string) {
45 return markdown
46 .replace(/```[\s\S]*?(```|$)/g, ' ')
47 // Code that is a path or a file is said by its name; any other, as it is.
48 .replace(/`([^`]*)`/g, (code, inside: string) => (/^[^\s/\\]*([/\\][^\s/\\]*)+$|^[\w-]+\.[a-z][a-z0-9]{0,4}$/i.test(inside) ? fileName(inside) : inside))
49 .replace(/!?\[([^\]]*)\]\([^)]*\)/g, '$1')
50 .replace(/https?:\/\/\S+/g, ' ')
51 .replace(/^\s{0,3}(#{1,6}|[-*+•]|\d+[.)]|>)\s+/gm, '')
52 .replace(/[\w.@~-]*\/[\w.@~/-]*[\w@~/-]/g, word => (isPath(word) ? fileName(word) : word))
53 .replace(HASH, ' ')
54 .replace(/\(\s*\)/g, ' ')
55 .replace(/[*_~|#>]+/g, ' ')
56 .replace(/\s+/g, ' ')
57 .replace(/ ([.,;:!??؟])/g, '$1')
58 .replace(/^[\s-]+/, '')
59 .trim()
60}
61
62/** The language of `text` by its most common words, or '' when too few say. */
63export function guessLanguage (text: string) {
64 const words = text.toLowerCase().match(/\p{L}+/gu) ?? []
65 let best = ''
66 let most = 2
67 for (const [language, common] of Object.entries(WORDS)) {
68 let count = 0
69 for (const word of words) if (common.has(word)) count++
70 if (count > most) [best, most] = [language, count]
71 }
72 return best
73}
74
75/** The sentences of a text: each ends at a full stop, an exclamation or a question mark. */
76const sentences = (text: string) => text.split(/(?<=[.!?…؟])\s+|(?<=[。!?])\s*/).filter(Boolean)
77const isQuestion = (sentence: string) => /[??؟]$/.test(sentence)
78
79/**
80 * Claude's closing question to the user, as it reads aloud ("¿Lo hago?", "Want me to open a PR?"): the
81 * questions that end its last line. Empty when the answer does not end with one, or with a long one.
82 */
83export function finalQuestion (answer: string) {
84 const last = plain(answer.trim().split('\n').filter(line => line.trim()).pop() ?? '')
85 const asked: string[] = []
86 for (const sentence of sentences(last).reverse()) {
87 if (!isQuestion(sentence)) break
88 asked.unshift(sentence)
89 }
90 const question = asked.join(' ')
91 return question.length <= 200 ? question : ''
92}
93
94/** The narration, then Claude's closing question as Claude wrote it: once, in place of any question the narration ends with. */
95export function withQuestion (narration: string, question: string) {
96 if (!narration || !question || narration.includes(question)) return narration
97 const cleaned = narration.replace(/(?:[;:]\s*[^.!?;:]*|,\s*¿[^?]*)[??؟]$/, '.')
98 const said = sentences(cleaned)
99 while (said.length > 0 && isQuestion(said.at(-1)!)) said.pop()
100 return said.length > 0 ? `${said.join(' ')} ${question}` : question
101}
102
103/** Whether `code` names a language the runtime knows ("es", "en"), not any two letters ("ok"). */
104function isLanguageCode (code: string) {
105 try {
106 return DISPLAY_NAMES.of(code.toLowerCase()) !== undefined
107 } catch {
108 return false
109 }
110}
111
112/**
113 * The narrator's reply as its language and what to say. The code line is never said: also when the
114 * model writes it in another shape (a label, a stray number, after a colon), or writes a line with no
115 * letters in its place. A reply without the code is guessed.
116 */
117export function parseNarration (reply: string) {
118 const trimmed = reply.trim()
119 const found = LANGUAGE_LINE.exec(trimmed + '\n')
120 if (found && isLanguageCode(found[1]!)) return { language: found[1]!.toLowerCase(), text: plain(found[2]!) }
121 const inline = INLINE_CODE.exec(trimmed)
122 if (inline && isLanguageCode(inline[1]!)) return { language: inline[1]!.toLowerCase(), text: plain(inline[2]!) }
123 const bare = BARE_LINE.exec(trimmed + '\n')
124 const text = plain(bare ? bare[1]! : trimmed)
125 return { language: guessLanguage(text), text }
126}
127types/index.d.ts 13 lines1declare module 'claude-code' {
2 interface PluginState {
3 narrator: {
4 /** Whether Claude tells, out loud, what it did after each turn. */
5 isEnabled: boolean
6 /** What this session is saying, null when it is not: `waiting`, how many narrations wait their turn behind it. */
7 speech: { waiting: number } | null
8 /** How far the sweep across the switch has gone while it speaks, a step a tenth of a second. */
9 sweepStep: number
10 }
11 }
12}
13