SLOPSHOPPER

read-aloud

Speak the last reply aloud on demand, in a macOS Personal Voice, with pause, skip and speed above the prompt.

newbandcommandprocesstimeraudio
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · read-aloud
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /read-aloud ⎿ read-aloud: EmberSpeak is not installed: spoke 170 characters with the system voice instead. ⟨Claude Code's own drawing⟩ ♪ speaking ⏹ ⏸ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⟨Claude Code's own drawing⟩ ♪ speaking ⏹ ⏸
README

See Stack Mods for Claude Code ⚡

Claude Code License: MIT YouTube: See Stack Website: seestack.dev

Curated developer mods for Claude Code by See Stack.

Upgrade your terminal with live context window telemetry, compaction headroom alerts, and personal voice narration with media transport controls.


⚡ Quick Install (One Command)

Add the See Stack Marketplace to your Claude Code installation:

claude plugin marketplace add see-stack/claude-code-mods

Then install any of the mods:

# 1. Interactive Context Bar with Headroom Tracking
claude plugin install context-bar@seestack-mods

# 2. Text-to-Speech Voice Player with Transport Scrubber & Queue
claude plugin install read-aloud@seestack-mods

To update mods anytime:

claude plugin update context-bar@seestack-mods
claude plugin update read-aloud@seestack-mods

📋 Prerequisites & What You Need to Enable

Before installing, make sure your environment meets these requirements:

1. Claude Code Version (v2.1.287 or higher)

Native plugin and mod support was introduced in Claude Code v2.1.287. Check your version:

claude --version

If you are on an older build, upgrade to the latest version:

npm install -g @anthropic-ai/claude-code

2. Verify Plugins Are Enabled

Once installed, verify that Claude Code recognizes the mods:

claude plugin list

If a mod shows as disabled, enable it with:

claude plugin enable context-bar@seestack-mods
claude plugin enable read-aloud@seestack-mods

3. Voice Synthesis (read-aloud mod only)

  • macOS: Works out of the box with macOS speech synthesis (say / AVSpeechSynthesizer).
  • Supports personal voices (e.g. Ember, Samantha, Daniel). Configure your preferred voice in System Settings > Accessibility > Spoken Content.

🛠 Featured Mods

1. context-bar

A live stacked context window audit HUD placed directly above your prompt with an interactive collapsible accordion dropdown so you never burn 40,000 tokens on stale logs or get surprised by auto-compaction.

Context Bar Accordion Details View

Key Features:
  • Interactive Accordion Dropdown: Click anywhere on the header to reveal the full itemized audit (detail), or hit [ Minimize ] to collapse back to a single compact line.
  • Inline Drilldowns: Click ▶ directly on memory files or skills to expand their itemized breakdown without repeating headers.
  • Auto-Compaction Headroom: Real-time tracking of safety headroom before auto-compaction triggers (e.g. compacts at 167k [38%]).
  • Visual Token Meter: Color-coded segments for system prompt, system tools, memory files, skills, messages, and free space.
  • Slash Command: Type /context-bar inside any Claude Code session to toggle visibility.
┌──────────────────────────────────────────────────────────────┐
│ ◆ context            75k of 200k · compacts at 167k  38% ▾   │
│ █■■■███████████████████████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ │
│ detail                                                       │
│ ▶ ■ system prompt    █·························   2k    1%   │
│ ▶ ■ system tools     ██████████················  19k  9.5%   │
│ ▶ ■ memory files     █························· 3.1k  1.5%   │
│ ▶ ■ skills           █························· 2.6k  1.3%   │
│ ▶ ■ messages         ██████████████████········  49k   24%   │
│   ─ free space       ──────────────────────────  92k   46%   │
│   ░ autocompact buf… ░░░░░░░░··················  33k   17%   │
│                                                              │
│ slash commands  42 of 42 · 1.4k                              │
│ window  claude-sonnet-5-5 · compacts at 167k                 │
│                                                              │
│ [ Minimize ]                                                 │
└──────────────────────────────────────────────────────────────┘

2. read-aloud

Brings a high-quality audio deck and Text-to-Speech into Claude Code with full media transport controls.

Read Aloud Demo

Key Features:
  • Media Deck: Play/Pause, sentence scrubbing, reply queuing, and speed multiplier.
  • Terracotta Progress Bar: Visual scrub bar matching your context meter.
  • Speech Queue: Seamlessly queues replies without cutting off sentences mid-speech.
  • Clean Formatting: Automatically strips markdown headers, raw code blocks, and table noise before speaking so the voice remains natural and conversational.
  • Slash Command: Type /read-aloud or click the read button above your prompt.
Keyboard Transport Controls:
KeyActionDescription
pPause / ResumeInstantly toggles speech playback
sStopHalts audio and clears speech buffer
◀ / ▶Sentence StepSteps one sentence backward or forward
⏮ / ⏭Reply StepJumps across queued assistant replies
rSpeed MultiplierCycles playback speed: 1× ➔ 1.25× ➔ 1.5× ➔ 2×

🔧 Useful Commands Inside Claude Code

Slash Command / KeyWhat it does
/context-barToggles the Context Bar HUD above prompt
/read-aloudSpeaks the latest response out loud
/pluginsOpens the interactive Claude Code plugin manager
/reload-pluginsHot-reloads all mods without restarting your terminal session

💻 Local Development & Customization

To inspect, tweak, or test these mods locally:

git clone https://github.com/see-stack/claude-code-mods.git
cd claude-code-mods

# Load mods in development mode:
claude --plugin-dir ./mods/context-bar
claude --plugin-dir ./mods/read-aloud

Reload changes instantly in your active session with /reload-plugins.


🌐 Ecosystem & Community

  • 📺 YouTube: See Stack (@SeeStack) — In-depth AI agent architecture breakdowns and tutorials.
  • 🌐 Documentation & Vaults: seestack.dev
  • 💬 Built for developers building real AI agent workflows.
  • 📄 Licensed under the MIT License.
Source 2 files
hooks/register.tsx 921 lines
1// Read Aloud: speaks the reply on screen when asked, in the voice the Stop hook
2// already reads in, with transport.
3//
4// The hook this follows (claude-config/hooks/speak-output) finds the last turn by
5// tailing the transcript after every reply. This one is handed the reply instead:
6// `turn.complete` carries the turn's final visible text, so there is no transcript
7// to tail, no flush to wait out, and nothing to walk back through when the band
8// wants to know whether there is anything waiting to be read.
9//
10// Speech goes through EmberSpeak.app rather than $.audio.speak, because Ember is a
11// Personal Voice: macOS offers those to AVSpeechSynthesizer alone, under an
12// application identity, which is the whole reason that bundle exists. The built-in
13// $.audio.speak (the platform's own `say`) is the fallback for when the bundle is
14// gone - it cannot speak Ember, so it is handed Nora for a Norwegian reply and no
15// voice at all otherwise, and it has no transport at all.
16//
17// Transport lives in two halves, because a running process can be told nothing but
18// signals. Pause and resume are signals, and stop kills the process; speed and
19// position are settled at launch, so the app is relaunched from the offset its
20// progress file reports. That file is the only channel back from the app: one
21// line, "<offset> <state>", in UTF-16 units of the whole text - which is what the
22// mod seeks by, and what the app's own range callbacks count in too.
23//
24// A reading begins only when it is asked for, and flows as long as it has
25// anything left to read: a reply that arrives while it is talking takes a place
26// at the end of it rather than cutting in, and the reading stops flowing when the
27// last reply has been read. Nothing here reaches for a turn's reply on its own -
28// the Stop hook in settings.json is muted (CLAUDE_SPEAK_OFF) so that this mod is
29// the only voice, which is what makes a list of replies possible in the first
30// place.
31//
32// /read-aloud          speak the last reply, or add it to the reading
33// /read-aloud stop     silence it: what was after this reply is dropped
34// /read-aloud prev     the reply before this one (the `prev` button)
35// /read-aloud forward  the reply after it - `skip`, from before the button
36// /read-aloud clear    drop what is after this reply, leave this one talking
37// /read-aloud back|next      a sentence back or on, inside this reply
38// /read-aloud pause|resume|faster|slower
39//
40// The band above the prompt draws one row: the transport while something is
41// playing, an offer to read while a reply is waiting, and nothing at all when
42// there is neither. The transport names the voice, then draws one run of glyphs
43// with the progress bar in the middle of them: the reply before this one, stop, a
44// sentence back, the bar - filled and empty in the same two glyphs the context
45// band above it draws with, and measuring the reply being read - a sentence on,
46// play or pause, the rate, the reply after it, and how far into the reading it is.
47// It measures what it is about to draw rather than trusting a threshold - the
48// engine writes a plain Button's hotkey itself, and a glyph may draw two cells -
49// and what does not fit beside the heading takes a second row, which is what a
50// 54-column terminal needs. A reading that has ended leaves its list and the reply before
51// the last one on the ready row, since that is when going back is wanted.
52import { atom, read, update } from 'claude-code'
53import type { EngineInterface, PluginOptions, Register, SessionMessage } from 'claude-code'
54
55import type { Mine, Run, Speech } from '../types'
56
57type Config = {
58  voice: string
59  norwegianVoice: string
60  /** The multipliers the rate button walks, in order. */
61  rates: number[]
62  maxChars: number
63  speaker: string
64}
65
66/** The manifest's options as the engine hands them over: whatever is set over the
67 *  defaults, with anything that makes no sense falling back rather than refusing
68 *  to load - a bad rate list should cost the rate button, not the whole mod. */
69export function readOptions(options: PluginOptions): Config {
70  const text = (value: unknown, fallback: string) =>
71    typeof value === 'string' && value.trim() !== '' ? value.trim() : fallback
72  const rates = String(text(options.rates, DEFAULTS.rates))
73    .split(',')
74    .map(part => Number(part.trim()))
75    .filter(rate => Number.isFinite(rate) && rate > 0)
76  const asked = Number(options.maxChars)
77  return {
78    voice: text(options.voice, DEFAULTS.voice),
79    norwegianVoice: text(options.norwegianVoice, DEFAULTS.norwegianVoice),
80    rates: rates.length > 0 ? rates : [1],
81    maxChars: Number.isFinite(asked) ? Math.min(20000, Math.max(200, Math.round(asked))) : DEFAULTS.maxChars,
82    speaker: text(options.speaker, DEFAULTS.speaker),
83  }
84}
85
86// What the manifest's userConfig offers, with the value used when nothing is set.
87// Every one of these can be changed in /config, which stores what is chosen in
88// settings.json under pluginConfigs.
89const DEFAULTS = {
90  voice: 'Ember',
91  norwegianVoice: 'Nora',
92  rates: '1,1.25,1.5,2,0.75',
93  maxChars: 6000,
94  speaker: '~/Control-Panel/tools/ember-speak/EmberSpeak.app/Contents/MacOS/EmberSpeak',
95}
96// Both under the home directory read at session.start: a hooks module has no
97// environment of its own, and this keeps its runtime files where the hook keeps
98// its own (~/Control-Panel/local-config).
99const SCRATCH = '/Control-Panel/local-config/hooks/read-aloud/speak.txt' // the text being read
100const PROGRESS = '/Control-Panel/local-config/hooks/read-aloud/speak.state' // where the app is
101// $.audio.speak speaks the first 4096 characters and says so; cut it here instead,
102// so the truncation is ours and lands at a word.
103const MAX_CHARS_FALLBACK = 4096
104const POLL_MS = 1000
105const IDLE_EVERY = 5 // idle, look every fifth tick rather than every tick
106const GRACE_MS = 3000 // a launch is not up this soon: never read that as "done"
107const KILL_SETTLE_MS = 150 // a killed speaker needs a moment to let go of the audio
108const GAP = 2 // cells between the bar and the first control on its row
109// The transport as glyphs rather than words: at 54 columns the words for seven
110// controls do not fit on one row, and a plain Button whose label is one glyph
111// draws as that glyph and nothing else. Bare, so the engine prints no hotkey in
112// front of them - the words are still there as /read-aloud arguments.
113const GLYPH = { play: '▶', pause: '⏸', stop: '⏹', prev: '⏮', forward: '⏭', back: '‹', next: '›' } as const
114// What the media glyphs are counted at, and what they may draw in: counting one
115// cell for a glyph a font gives two would overflow the row it was chosen to fit.
116const GLYPH_CELLS = 2
117const RUN_MAX = 6 // replies a reading holds; past this the oldest falls off the front
118const BAR = 12 // cells in the progress bar
119// The context band's own two: the accent it marks this conversation with, and the
120// grey it leaves empty room in. Filled and empty cells are its glyphs too.
121const ACCENT = '#d97757'
122const FREE = '#808080'
123
124// What the person has set, read again whenever the module loads.
125let config = readOptions({})
126
127// Held by the host, so the band survives a hot reload of this file.
128const speech = atom({ plugin: 'read-aloud', key: 'speech' } as const, null as Speech | null)
129
130let home = ''
131let ticks = 0
132
133export const register: Register = (on, options) => {
134  config = readOptions(options)
135  on('session.start', async ($, e, next) => {
136    const r = await next(e)
137    // A name Claude Code already has is refused: start anyway.
138    await $.command
139      .register({ name: 'read-aloud', description: 'Speak the last reply aloud', argumentHint: '[stop|prev|forward|back|next|clear|pause|resume|faster|slower]' })
140      .catch(() => {})
141    home = await homeDir($)
142    $.clock.every(POLL_MS, () => void poll($))
143    return r
144  })
145
146  // The reply, kept for the band to offer and for the command to read out. A
147  // subagent's turn is not this conversation's, and a turn that answered nothing
148  // leaves whatever was waiting before it still waiting.
149  on('turn.complete', async ($, e, next) => {
150    const r = await next(e)
151    if (e.agentId) return r
152    const answer = e.reason === 'answer' ? speakable(e.answer, config.maxChars) : ''
153    if (answer === '') return r
154    const s = await read($, speech)
155    const run = s?.run
156    // Inside a reading the reply takes a place at its end, and stops being what
157    // the band offers: it is not waiting to be asked for any more, it is going to
158    // be read. Speech with no list behind it - the fallback voice - leaves it on
159    // offer instead, since there is no reading for it to join.
160    if (run && flowing(s)) {
161      await merge($, { run: joinRun(run, answer), answer: undefined })
162      return r
163    }
164    // A reply arriving after a reading has ended ends that reading: the band is
165    // back to offering one reply, and `prev` no longer walks through the old one.
166    await merge($, { answer, run: undefined })
167    return r
168  })
169
170  on('command.run', { command: 'read-aloud' }, async ($, e) => {
171    switch (e.args.trim().toLowerCase()) {
172      case 'stop':
173        await stop($)
174        return { text: 'Speech stopped.' }
175      case 'pause':
176        return { text: (await setPaused($, true)) ? 'Paused.' : 'Nothing is playing.' }
177      case 'resume':
178        return { text: (await setPaused($, false)) ? 'Resumed.' : 'Nothing is paused.' }
179      // Replies: prev and forward walk the reading, as the buttons of those names
180      // do. skip is forward's other name, from before there was a button.
181      case 'prev':
182        return { text: await prevReply($) }
183      case 'forward':
184      case 'skip':
185        return { text: await forwardReply($) }
186      case 'clear':
187        return { text: await clearAhead($) }
188      // Sentences, inside whichever reply is playing.
189      case 'back':
190        return { text: await seek($, -1) }
191      case 'next':
192        return { text: await seek($, 1) }
193      case 'faster':
194        return { text: await cycleRate($, 1) }
195      case 'slower':
196        return { text: await cycleRate($, -1) }
197    }
198    return { text: await readLast($) }
199  })
200
201  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
202    const rest = await next(e) // what other mods and Claude Code draw here stays
203    const s = await read($, speech)
204    const playing = s?.isSpeaking === true
205    const waiting = s?.answer ?? ''
206    if (e.props.hasSurvey || (!playing && waiting === '')) return rest
207    const { Box, Text, Button } = $.ui.resolve(e)
208
209    if (!playing) {
210      // A reading that has ended leaves its list behind, so the reply before the
211      // one on offer is still one press away rather than gone.
212      const behind = (s?.run?.at ?? 0) > 0
213      return (
214        <Box flexDirection="column">
215          {rest}
216          <Box flexDirection="row" paddingX={1}>
217            <Text color={ACCENT}>{'♪ '}</Text>
218            <Text dimColor>{`${looksNorwegian(waiting) ? config.norwegianVoice : config.voice} · ready`}</Text>
219            <Text>{'  '}</Text>
220            {/* The same two glyphs the transport uses, for the same two things:
221                play this reply, or go back to the one before it. */}
222            <Button key="read" plain label={GLYPH.play} onPress={() => readLast($)} />
223            {behind && <Text>{' '}</Text>}
224            {behind && <Button key="prev" plain label={GLYPH.prev} onPress={() => prevReply($)} />}
225          </Box>
226        </Box>
227      )
228    }
229
230    const mine = s.mine
231    const run = s.run
232    const filled = mine ? fill(share(mine), BAR) : 0
233    // What the head says, and how wide it is. Speech with no text behind it - the
234    // fallback voice - has no bar and no count, which is why this is a number and
235    // not a constant.
236    const voice = mine ? mine.voice : s.isPaused ? 'speaking · paused' : 'speaking'
237    const count = run && run.items.length > 1 ? `· ${run.at + 1} of ${run.items.length}` : ''
238    const head = 2 + voice.length
239
240    // The controls, and the sets of them to try in turn: the sentences inside one
241    // reply go before the rate, and both before the reply pair the row is for.
242    // Pause and stop are the floor - pause is the only control that reaches speech
243    // with no text behind it, and stop is the only way out.
244    //
245    // The bar is one of the row's items rather than something the head draws, so
246    // that it sits in the middle of the glyphs - between the sentence steps it is
247    // measuring the distance within - and so that the row is measured with it.
248    // Its onPress is never called: no Button is made of it, it draws two Texts.
249    const bar: Control = { key: 'bar', label: '', cells: BAR, onPress: () => {} }
250    // How far into the reading it is, as the row's last item rather than something
251    // the heading carries: it belongs with the transport it counts.
252    const counter: Control = { key: 'count', label: count, cells: count.length, onPress: () => {} }
253    const pause: Control = {
254      key: 'pause',
255      label: s.isPaused ? GLYPH.play : GLYPH.pause,
256      cells: GLYPH_CELLS,
257      onPress: () => setPaused($, !s.isPaused),
258    }
259    const prev: Control = { key: 'prev', label: GLYPH.prev, cells: GLYPH_CELLS, onPress: () => prevReply($) }
260    const stopIt: Control = { key: 'stop', label: GLYPH.stop, cells: GLYPH_CELLS, onPress: () => stop($) }
261    // Not `next`: that is the hook's own third argument.
262    const stepBack: Control = { key: 'back', label: GLYPH.back, onPress: () => seek($, -1) }
263    const stepOn: Control = { key: 'next', label: GLYPH.next, onPress: () => seek($, 1) }
264    // The rate keeps its number: a glyph for it would say nothing about which rate,
265    // and the number is what the button is for.
266    const rate: Control = { key: 'rate', label: `${mine?.rate ?? 1}×`, onPress: () => cycleRate($, 1) }
267    const forward: Control = { key: 'forward', label: GLYPH.forward, cells: GLYPH_CELLS, onPress: () => forwardReply($) }
268
269    // Left to right, the order the row draws: back a reply and stop it, the two
270    // sentence steps with the bar between them, then the toggle, the rate, and on
271    // to the next reply.
272    const controls: Control[] = [
273      ...(run ? [prev] : []),
274      stopIt,
275      ...(mine ? [stepBack, bar, stepOn] : []),
276      pause,
277      ...(mine ? [rate] : []),
278      ...(run ? [forward] : []),
279      ...(count === '' ? [] : [counter]),
280    ]
281    const withoutSteps = controls.filter(c => c.key !== 'back' && c.key !== 'next')
282    const withoutRate = withoutSteps.filter(c => c.key !== 'rate')
283    const floor = controls.filter(c => c.key === 'pause' || c.key === 'stop' || c.key === 'bar')
284    const sets = [controls, withoutSteps, withoutRate, floor]
285    // Measured, never guessed: a plain Button is drawn by the engine as "p: pause",
286    // so a row of five is about ten cells wider than its labels look. Beside the bar
287    // if that fits what the row is for, and otherwise the bar keeps its own row and
288    // the controls take the next one - but only when that buys more of them, since a
289    // narrow terminal pays for the second row in prompt space.
290    const room = (e.props.bodyColumns ?? 0) - 2 // the row's own padding, one cell either side
291    const beside = widestFit(sets, room - head - GAP)
292    const below = widestFit(sets, room)
293    const split = below.length > beside.length
294    const chosen = split ? below : beside
295    const buttons = chosen.flatMap((c, i) => {
296      const gap = i === 0 ? [] : [<Text key={`gap-${c.key}`}>{' '}</Text>]
297      // The bar is the one item that is drawn rather than pressed: where the app
298      // says it is, in the accent, and the room left in the grey.
299      if (c.key === 'bar') {
300        return [
301          ...gap,
302          <Text key="bar-played" color={ACCENT}>{'█'.repeat(filled)}</Text>,
303          <Text key="bar-left" color={FREE}>{'─'.repeat(BAR - filled)}</Text>,
304        ]
305      }
306      if (c.key === 'count') return [...gap, <Text key="count" dimColor>{count}</Text>]
307      return [...gap, <Button key={c.key} plain hotkey={c.hotkey} label={c.label} onPress={c.onPress} />]
308    })
309
310    // Drawn after `rest` rather than before it, so this row is the band's last
311    // one whichever way the engine nests the two: outermost, it follows what the
312    // other mods drew; innermost, this whole box is already placed beneath them.
313    return (
314      <Box flexDirection="column">
315        {rest}
316        <Box flexDirection="row" paddingX={1}>
317          <Text color={ACCENT}>{'♪ '}</Text>
318          {/* Which voice, and how far into the reading: the bar itself is in the
319              middle of the glyphs, so the row repeats no percentage. */}
320          <Text dimColor>{voice}</Text>
321          {count !== '' && <Text dimColor>{count}</Text>}
322          {!split && chosen.length > 0 && <Text>{'  '}</Text>}
323          {!split && buttons}
324        </Box>
325        {split && <Box flexDirection="row" paddingX={1}>{buttons}</Box>}
326      </Box>
327    )
328  })
329}
330
331/** How far into the text the app says it is, as a percentage. */
332function share(mine: Mine) {
333  return mine.chars > 0 ? (mine.offset / mine.chars) * 100 : 0
334}
335
336/** How many cells of a `width`-wide bar a percentage fills, clamped: the app can
337 *  report an offset at or past the end of the text it was handed. */
338export function fill(percent: number, width: number) {
339  return Math.round((Math.max(0, Math.min(100, percent)) / 100) * width)
340}
341
342/** One control of the transport, as the row draws it. */
343export type Control = {
344  key: string
345  label: string
346  /** The cells the label draws in, where that is not its length: a glyph may draw
347   *  two, and a row measured by `length` would then be one that overflows. */
348  cells?: number
349  hotkey?: string
350  onPress: () => void | Promise<unknown>
351}
352
353/** How wide a row of controls draws. A plain Button carrying a hotkey is written
354 *  by the engine itself, as "p: pause" - the hotkey, a colon, the label - so the
355 *  widths are not the labels' own, and a row that looks as if it fits may not. */
356export function controlsWidth(controls: readonly Control[]) {
357  const labels = controls.reduce((n, c) => n + (c.hotkey ? c.hotkey.length + 2 : 0) + (c.cells ?? c.label.length), 0)
358  return labels + Math.max(0, controls.length - 1) // the single space between two of them
359}
360
361/** The first of `sets` that fits in `columns` - widest first - or nothing when
362 *  none of them does, which is a row too narrow for any control at all. */
363export function widestFit(sets: readonly (readonly Control[])[], columns: number) {
364  return sets.find(set => controlsWidth(set) <= columns) ?? []
365}
366
367// One place the state changes, so the band is asked to draw again from one place.
368async function merge($: EngineInterface, patch: Partial<Speech>) {
369  await update($, speech, prev => ({
370    isSpeaking: false,
371    isPaused: false,
372    startedAt: 0,
373    ...prev,
374    ...patch,
375  })).catch(() => {})
376  $.ui.invalidate('ui.render')
377}
378
379// ---- the reading -----------------------------------------------------------
380//
381// A reading is a list of replies and a cursor into it, not a queue: `prev` needs
382// somewhere to go back to, and the reply it goes back to has to still be there.
383// Whatever is after the cursor is still to be read, in order; whatever is before
384// it has been read and can be read again.
385
386/** Whether a reading is going on by itself: something is playing, or a reply is
387 *  still to be read after the one at the cursor. Inside one a new reply joins the
388 *  list; outside one nothing is read until it is asked for, which is what keeps
389 *  this mod from speaking over a session the person is reading themselves. */
390export function flowing(s: Speech | null) {
391  const run = s?.run
392  if (s?.isSpeaking === true) return true
393  return run ? run.at + 1 < run.items.length : false
394}
395
396/** Where a step lands in a reading, or null at either end of it. */
397export function step(run: Run, direction: 1 | -1) {
398  const at = run.at + direction
399  return at >= 0 && at < run.items.length ? at : null
400}
401
402/** A reading with one more reply at its end, and the cursor moved back by however
403 *  many fell off the front to keep the list at `max`. */
404export function joinRun(run: Run, text: string, max = RUN_MAX): Run {
405  const items = [...run.items, text].slice(-max)
406  return { items, at: Math.max(0, run.at - (run.items.length + 1 - items.length)) }
407}
408
409/** Moves the cursor back one reply and reads it. The reply it is leaving stays in
410 *  the list, so `forward` walks straight back to it. */
411async function prevReply($: EngineInterface) {
412  const s = await read($, speech)
413  const run = s?.run
414  const at = run ? step(run, -1) : null
415  if (!run || at === null) return 'This is the first reply of the reading.'
416  return (await playAt($, s, run, at)) ? 'Reading the reply before this one.' : 'Could not reach EmberSpeak.'
417}
418
419/** The other way: on to the reply after this one, whether it is waiting mid-reading
420 *  or reached from the row that a finished reading leaves behind. */
421async function forwardReply($: EngineInterface) {
422  const s = await read($, speech)
423  const run = s?.run
424  const at = run ? step(run, 1) : null
425  if (!run || at === null) return 'This is the last reply of the reading.'
426  return (await playAt($, s, run, at)) ? 'Reading the next reply.' : 'Could not reach EmberSpeak.'
427}
428
429/** Drops everything after the cursor and leaves what is playing alone: the reading
430 *  then ends with the reply that is talking instead of going on to the rest. */
431async function clearAhead($: EngineInterface) {
432  const run = (await read($, speech))?.run
433  const n = run ? run.items.length - run.at - 1 : 0
434  if (!run || n === 0) return 'Nothing is waiting to be read.'
435  await merge($, { run: { items: run.items.slice(0, run.at + 1), at: run.at } })
436  return `Cleared ${n} ${n === 1 ? 'reply' : 'replies'} from the reading.`
437}
438
439// Puts the cursor on `at` and starts reading that reply, at the rate the reading
440// is already at - a rate chosen mid-reply is a choice about the reading, not about
441// that one reply.
442//
443// The handoff is stamped as a launch: between here and the app being up, the poll
444// has to read the state as "starting", not as "finished, go on to the next one".
445//
446// When the app cannot be reached the reading is dropped rather than left standing:
447// a list nothing is playing is a reading that never ends, and every reply after it
448// would be filed behind a voice that is not coming.
449async function playAt($: EngineInterface, s: Speech | null, run: Run, at: number) {
450  const text = run.items[at]
451  if (text === undefined) return false
452  await merge($, { run: { items: run.items, at }, isSpeaking: false, isPaused: false, startedAt: Date.now(), mine: undefined })
453  const voice = await voiceFor($, text)
454  if (await start($, text, voice, s?.mine?.rate ?? 1, 0)) return true
455  await merge($, { run: undefined, isSpeaking: false, isPaused: false, startedAt: 0, mine: undefined })
456  $.ui.log('read-aloud: could not start the next reply')
457  return false
458}
459
460// The end of a reading's last reply: the list stays and the cursor stays on it, so
461// `prev` still walks back through it, and the reply just read goes back to being
462// what the band offers. Nothing after the cursor means nothing starts on its own.
463async function drained($: EngineInterface, run: Run) {
464  await merge($, {
465    isSpeaking: false,
466    isPaused: false,
467    startedAt: 0,
468    mine: undefined,
469    run,
470    answer: run.items[run.at],
471  })
472}
473
474async function homeDir($: EngineInterface) {
475  try {
476    const { exitCode, stdout } = await $.process.run(['/usr/bin/printenv', 'HOME'], { timeoutMs: 5000 })
477    return exitCode === 0 ? stdout.trim() : ''
478  } catch {
479    return ''
480  }
481}
482
483function path(under: string) {
484  return home + under
485}
486
487// A configured path, with ~ standing for the home read at session.start.
488function expand(configured: string) {
489  return configured.startsWith('~/') ? home + configured.slice(1) : configured
490}
491
492// ---- reading it ------------------------------------------------------------
493
494// The reply waiting to be read: what the last turn answered, or - when the session
495// was resumed or this mod reloaded and holds none - the last reply in the
496// conversation, which is what it would have been.
497async function waiting($: EngineInterface) {
498  const s = await read($, speech)
499  if (s?.answer) return s.answer
500  return speakable(lastReply(await $.session.messages().catch(() => [])), config.maxChars)
501}
502
503async function readLast($: EngineInterface) {
504  const text = await waiting($)
505  if (text === '') return 'Nothing to read: nothing has been answered in this conversation yet.'
506  const s = await read($, speech)
507  // Asked for inside a reading, the reply takes a place at its end rather than
508  // cutting in. The reply already talking - or already in the list - is not added
509  // a second time.
510  const run = s?.run
511  if (run && flowing(s)) {
512    if (text === (await speakingText($)) || run.items.includes(text)) return 'Already reading that one.'
513    const joined = joinRun(run, text)
514    await merge($, { run: joined, answer: undefined })
515    const waiting = joined.items.length - joined.at - 1
516    return `Queued. ${waiting} ${waiting === 1 ? 'reply' : 'replies'} to come.`
517  }
518  // Nothing is playing, so this is a reading of one reply, from the start. The
519  // cursor is the list's one entry; an answer already on offer stops being one.
520  const voice = await voiceFor($, text)
521  if (await start($, text, voice, 1, 0)) {
522    await merge($, { run: { items: [text], at: 0 }, answer: undefined })
523    return `Speaking ${text.length} characters with ${voice}.`
524  }
525  return await fallback($, text, voice)
526}
527
528// ---- transport ------------------------------------------------------------
529
530// Pause and resume of the app's own voice, which keeps the synthesizer's place:
531// the process is not frozen, so it stops at a word and picks up from there. It
532// reaches whatever EmberSpeak is speaking, the hook's replies included - usefully
533// so, since the hook reads every turn and this is the only way to stop it talking.
534async function setPaused($: EngineInterface, paused: boolean) {
535  const s = await read($, speech)
536  if (!s?.isSpeaking) return false
537  const signal = paused ? '-USR1' : '-USR2'
538  try {
539    await $.process.run(['/usr/bin/pkill', signal, '-x', 'EmberSpeak'], { timeoutMs: 5000 })
540  } catch {
541    return false
542  }
543  await merge($, { isPaused: paused })
544  return true
545}
546
547// Speed and position cannot be changed in a running app, so the reply is read
548// again from where it had got to, at the new rate. The progress file is what
549// makes that exact: the app reports the offset every word.
550async function cycleRate($: EngineInterface, direction: 1 | -1) {
551  const s = await read($, speech)
552  const mine = s?.mine
553  if (!mine) return 'Nothing is playing.'
554  const at = config.rates.indexOf(mine.rate)
555  const next = config.rates[(at + direction + config.rates.length) % config.rates.length]!
556  const text = await speakingText($)
557  if (!(await start($, text, mine.voice, next, mine.offset))) return 'Could not reach EmberSpeak.'
558  return `Reading again at ${next}×.`
559}
560
561async function seek($: EngineInterface, direction: 1 | -1) {
562  const s = await read($, speech)
563  const mine = s?.mine
564  if (!mine) return 'Nothing is playing.'
565  const text = await speakingText($)
566  const offset = sentenceStart(text, mine.offset, direction)
567  if (offset >= text.length) {
568    // Past the end of this reply there is no sentence left to step to: it is over,
569    // and the reply after it either starts now or the reading ends here.
570    const run = s?.run
571    const next = run ? step(run, 1) : null
572    if (run && next !== null && (await playAt($, s, run, next))) return 'Reading the next reply.'
573    await stop($)
574    return 'End of the reply.'
575  }
576  await start($, text, mine.voice, mine.rate, offset)
577  return direction === 1 ? 'Skipped a sentence forward.' : 'Skipped a sentence back.'
578}
579
580// The text being read, from the file the app is reading it out of - so a seek or
581// a rate change hands back exactly what is playing, not a fresh reading of the
582// conversation.
583async function speakingText($: EngineInterface) {
584  if (!home) return ''
585  return await $.fs.read(path(SCRATCH)).catch(() => '')
586}
587
588// Stopping ends the reading, not the list. What was after the cursor is dropped,
589// so nothing starts up again on its own; the reply it stopped on goes back to
590// being what the band offers, and `prev` still has somewhere to walk back to.
591async function stop($: EngineInterface) {
592  const run = (await read($, speech))?.run
593  await kill($)
594  const patch: Partial<Speech> = { isSpeaking: false, isPaused: false, startedAt: 0, mine: undefined }
595  if (run) {
596    patch.run = { items: run.items.slice(0, run.at + 1), at: run.at }
597    patch.answer = run.items[run.at]
598  } else {
599    patch.run = undefined
600  }
601  await merge($, patch)
602}
603
604// Kills by name, which is the only handle there is: neither $.process.run nor
605// $.process.spawn reports a pid. It stops the hook's speech too, on purpose - the
606// two of them reading at once is the thing to avoid.
607async function kill($: EngineInterface) {
608  try {
609    await $.process.run(['/usr/bin/pkill', '-x', 'EmberSpeak'], { timeoutMs: 5000 })
610  } catch {
611    // Nothing to kill, or no pkill: whatever follows still runs.
612  }
613}
614
615// ---- the app ---------------------------------------------------------------
616
617// Writes `text` and reads it from the start, replacing anything already playing.
618async function start($: EngineInterface, text: string, voice: string, rate: number, offset: number) {
619  if (!home || text === '') return false
620  await kill($)
621  try {
622    await $.fs.write(path(SCRATCH), text)
623  } catch {
624    return false
625  }
626  // A killed speaker takes a moment to let go of the audio device, and the one
627  // launched into that moment is the one that comes out silent.
628  await $.clock.sleep(KILL_SETTLE_MS).catch(() => {})
629  return await launch($, voice, rate, offset, text.length)
630}
631
632// Launches the app detached, and returns as soon as it is away.
633//
634// $.process.run waits for its child, and EmberSpeak holds itself open for as long
635// as the speech lasts - six minutes for a long reply - so the launch has to go
636// through a shell that backgrounds it. Both streams and the child's stdin are
637// redirected inside that command: run() reads the pipes until they close, and a
638// grandchild still holding them would keep it waiting exactly as an awaited child
639// does. The `-x` guard is what reports the bundle missing, which is the one case
640// the built-in voice is used instead.
641async function launch($: EngineInterface, voice: string, rate: number, offset: number, chars: number) {
642  const speaker = expand(config.speaker)
643  const scratch = path(SCRATCH)
644  const progress = path(PROGRESS)
645  const cmd = [
646    `if [ -x ${q(speaker)} ]; then`,
647    `nohup ${q(speaker)} -v ${q(voice)} -r ${rate} -s ${offset} -p ${q(progress)} -f ${q(scratch)}`,
648    `</dev/null >/dev/null 2>&1 & else exit 3; fi`,
649  ].join(' ')
650  try {
651    const { exitCode } = await $.process.run(['/bin/sh', '-c', cmd], { timeoutMs: 10_000 })
652    if (exitCode !== 0) return false
653  } catch {
654    return false
655  }
656  await merge($, {
657    isSpeaking: true,
658    isPaused: false,
659    startedAt: Date.now(),
660    mine: { voice, chars, rate, offset },
661  })
662  return true
663}
664
665// The platform's own synthesizer, for when EmberSpeak is not there. Not awaited: it
666// resolves when the utterance ends, which can be minutes, and nothing here waits on
667// that. Ember is a Personal Voice that `say` cannot speak, so an English reply is
668// left to the system default voice rather than naming one macOS would substitute.
669// It has no transport, so it is not marked as this mod's own.
670async function fallback($: EngineInterface, text: string, voice: string) {
671  const spoken = speakable(text, MAX_CHARS_FALLBACK)
672  void $.audio
673    .speak(spoken, voice === config.voice ? {} : { voice })
674    .catch((err: unknown) => $.ui.log(`read-aloud: could not speak: ${String(err)}`))
675  await merge($, { isSpeaking: true, isPaused: false, startedAt: Date.now(), mine: undefined })
676  const spokenWith = voice === config.voice ? 'the system voice' : voice
677  return `EmberSpeak is not installed: spoke ${spoken.length} characters with ${spokenWith} instead.`
678}
679
680async function voiceFor($: EngineInterface, text: string) {
681  if (!looksNorwegian(text)) return config.voice
682  return (await listsVoiceNow($, config.norwegianVoice)) ? config.norwegianVoice : config.voice
683}
684
685// Asks `say` what it has, the way the hook does: EmberSpeak resolves a name it
686// cannot find to the system default rather than failing, which would read Norwegian
687// in an English voice. Only ever asked about a Norwegian reply.
688async function listsVoiceNow($: EngineInterface, name: string) {
689  try {
690    const { stdout } = await $.process.run(['/usr/bin/say', '-v', '?'], { timeoutMs: 5000 })
691    return listsVoice(stdout, name)
692  } catch {
693    return false
694  }
695}
696
697// ---- watching it -----------------------------------------------------------
698
699async function poll($: EngineInterface) {
700  const s = await read($, speech)
701  const was = s?.isSpeaking === true
702  // While something is playing, look every tick, so the position keeps up and the
703  // band leaves as soon as the voice does; idle, every fifth, so a quiet session is
704  // not spawning a process a second to learn nothing.
705  if (ticks++ % (was ? 1 : IDLE_EVERY) !== 0) return
706  const playing = await isPlaying($)
707  // The progress file belongs to this mod's own launches. Speech the hook started
708  // has none, and what is left in the file from the last reading is not about it:
709  // read then, a stale "done" would take the band down while a voice is talking.
710  const progress = playing && s?.mine ? await readProgress($) : null
711  // A speaker that has said everything is finished, whether or not its process has
712  // gone yet.
713  const live = playing && progress?.state !== 'done'
714  const moved = progress !== null && progress.offset !== s?.mine?.offset
715  // With no progress file there is nothing to say what the voice is doing, so the
716  // pause this mod was asked for stands until this mod is asked to lift it.
717  const paused = progress ? progress.state === 'paused' : s?.isPaused === true
718  // A reply still to be read keeps the poll interested even with nothing playing:
719  // that is the handoff between two of them, and the case where the speaker never
720  // came up at all.
721  const run = s?.run
722  const next = run ? step(run, 1) : null
723  if (live === was && !moved && paused === (s?.isPaused === true) && next === null) return
724  // Right after a launch EmberSpeak is not up yet - and the handoff to the next
725  // reply is a launch too - so a reading taken in that window would take the band
726  // down and start the reply after the one being started.
727  if (!live && Date.now() - (s?.startedAt ?? 0) < GRACE_MS) return
728  if (!live) {
729    // The end of a reply is where the next one begins: the app has stopped and a
730    // reply is still to be read, so the reading goes on without being asked for it
731    // again. This is also how a list whose speaker never came up is read or
732    // dropped, rather than left to hold every reply after it.
733    if (run && next !== null) {
734      await playAt($, s, run, next)
735      return
736    }
737    // Nothing after the cursor is the last reply read: the list stays for `prev`,
738    // and the reply just read goes back to being what the band offers.
739    if (run && run.items.length > 0) {
740      await drained($, run)
741      return
742    }
743    await merge($, { isSpeaking: false, isPaused: false, startedAt: 0, mine: undefined, run: undefined })
744    return
745  }
746  await merge($, {
747    isSpeaking: true,
748    isPaused: paused,
749    startedAt: s?.mine ? (s.startedAt ?? Date.now()) : Date.now(),
750    mine: s?.mine ? { ...s.mine, offset: progress?.offset ?? s.mine.offset } : undefined,
751  })
752}
753
754/** The app's progress file: where it is, and whether it is speaking. */
755export function parseProgress(raw: string) {
756  const [offset, state] = raw.trim().split(/\s+/)
757  const at = Number(offset)
758  if (!Number.isFinite(at)) return null
759  if (state !== 'speaking' && state !== 'paused' && state !== 'done') return null
760  return { offset: at, state }
761}
762
763async function readProgress($: EngineInterface) {
764  if (!home) return null
765  const raw = await $.fs.read(path(PROGRESS)).catch(() => '')
766  return parseProgress(raw)
767}
768
769async function isPlaying($: EngineInterface) {
770  try {
771    const { exitCode } = await $.process.run(['/usr/bin/pgrep', '-x', 'EmberSpeak'], { timeoutMs: 5000 })
772    return exitCode === 0
773  } catch {
774    return false
775  }
776}
777
778// ---- text ------------------------------------------------------------------
779
780/** True when a `say -v '?'` listing carries a voice by that name. */
781export function listsVoice(listing: string, name: string) {
782  const wanted = name.trim().toLowerCase()
783  if (wanted === '') return false
784  return listing
785    .split('\n')
786    .some(line => (line.trim().split(/\s{2,}/)[0] ?? '').trim().toLowerCase() === wanted)
787}
788
789/** The assistant text of the last turn: what is on screen as the last reply. */
790export function lastReply(messages: readonly SessionMessage[]) {
791  const turn: string[] = []
792  let started = false
793  for (let i = messages.length - 1; i >= 0; i--) {
794    const m = messages[i]!
795    // A user row is the command's own record, a notice, or a tool result: the turn
796    // has not begun until some assistant text has been read, and a row before that
797    // is walked past rather than stopped at.
798    if (m.role === 'user') {
799      if (started) break
800      continue
801    }
802    // An assistant row with no text is a step that only called tools. It joins
803    // nothing and marks nothing: taken as the turn's start, it would make the walk
804    // stop at the next tool result and read out an empty reply.
805    const text = m.text.trim()
806    if (text === '') continue
807    turn.unshift(text)
808    started = true
809  }
810  return turn.join('\n').trim()
811}
812
813/** Where the sentence one step away from `offset` begins, in UTF-16 units. */
814export function sentenceStart(text: string, offset: number, direction: 1 | -1) {
815  const starts = [0]
816  const ends = /[.!?\n]+[ \t]*/g
817  let m: RegExpExecArray | null
818  while ((m = ends.exec(text)) !== null) starts.push(m.index + m[0].length)
819  let i = 0
820  while (i + 1 < starts.length && starts[i + 1]! <= offset) i++
821  if (direction === 1) return i + 1 < starts.length ? starts[i + 1]! : text.length
822  // Back: the start of the sentence being read, and the one before it when the
823  // offset is already sitting on that start.
824  return offset > starts[i]! ? starts[i]! : starts[i - 1] ?? 0
825}
826
827/** Norwegian rather than English, by word vote; English wins ties and near-ties. */
828export function looksNorwegian(text: string) {
829  const lowered = text.toLowerCase()
830  const words = lowered.match(/[a-zæøå]+/g) ?? []
831  if (words.length < 3) return false
832  let nb = 0
833  let en = 0
834  for (const w of words) {
835    if (NORWEGIAN_WORDS.has(w)) nb++
836    else if (ENGLISH_WORDS.has(w)) en++
837  }
838  // æ and ø are Norwegian/Danish only, so they count for more than the words.
839  // å is shared with Swedish and Danish, so on its own it proves nothing.
840  const marks = (lowered.match(/[æø]/g) ?? []).length
841  return 2 * nb + 3 * marks > 2 * en && nb + marks >= 2
842}
843
844/** Strips the markdown that reads badly out loud, and caps the length. */
845export function speakable(text: string, max: number) {
846  return truncate(
847    text
848      .replace(/```[\s\S]*?```/g, ' ')
849      .replace(/~~~[\s\S]*?~~~/g, ' ')
850      .replace(/^[ \t]*\|.*\|[ \t]*$/gm, tableRow)
851      .replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1')
852      .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
853      .replace(/^ {0,3}#{1,6}[ \t]*/gm, '')
854      .replace(/^ {0,3}>[ \t]?/gm, '')
855      .replace(/^ {0,3}[-*+][ \t]+/gm, '')
856      .replace(/^[ \t]*([-*_])\1{2,}[ \t]*$/gm, '')
857      .replace(/\*\*|__|`|~~/g, '')
858      .replace(/(?<!\w)[*_](\S[^*_]*?)[*_](?!\w)/g, '$1')
859      .replace(/[ \t]+/g, ' ')
860      .replace(/\n{2,}/g, '\n')
861      .trim(),
862    max,
863  )
864}
865
866// A table row as it is read out: its cells, and nothing at all for a rule.
867function tableRow(row: string) {
868  const cells = row
869    .trim()
870    .replace(/^\|/, '')
871    .replace(/\|$/, '')
872    .split('|')
873    .map(c => c.trim())
874  if (cells.every(c => /^[-: ]*$/.test(c))) return ''
875  return cells.filter(c => c !== '').join(', ')
876}
877
878function truncate(text: string, max: number) {
879  if (text.length <= max) return text
880  const cut = text.slice(0, max)
881  const at = cut.lastIndexOf(' ')
882  return `${at > 0 ? cut.slice(0, at) : cut}. Output truncated.`
883}
884
885// One argument, quoted for the shell this app is launched through. Only ever our
886// own paths, voice names and numbers: the reply text travels through the scratch
887// file, so nothing a reply contains can reach a command line.
888function q(argument: string | number) {
889  return `'${String(argument).replace(/'/g, `'\\''`)}'`
890}
891
892// Function words that are Norwegian and not English. Every entry has to be a word
893// English does not also use, because a false positive here means an English reply
894// read out in Norwegian, which is worse than the reverse.
895const NORWEGIAN_WORDS = new Set(
896  `er og å et en så ikke jeg det som til på med av den han hun fra om når hva
897    hvordan hvorfor også skal kan har hadde blir ble vært gjør gjorde ser får
898    fikk kommer kom tok gir ga sier sa vet tror tenker trenger bruker brukte
899    lager lagde finner fant sjekker kjører virker fungerer feil ting dag tid sted
900    fil filen filer kode jobb måte litt mer mest alle noen ingen ingenting
901    der nå da jo nok kanskje selv sammen tilbake igjen fortsatt allerede
902    snart nesten godt dårlig stor liten ny gammel første siste neste samme annen
903    hver hvilken hvem hvor denne dette disse vil må være oss dere dem seg
904    sitt våre kun helt veldig mye
905    endret endrer endre endring stemme språk norsk norske prøv prøve prøver
906    tekst teksten linje linjen svar svare svarte ord ordet navn navnet verdi
907    verdien lese leser skrive skriver skrevet bruk bruke brukt lage laget
908    kjør kjøre kjørt bygge bygget feilen eksempel eksempler mål målet hjelp
909    hjelpe hjelper sjekk sjekke vise viser viste gå går gikk finne
910    fordi derfor hvis etter før mens ganske rundt gjennom mellom opp videre
911    egen eget egne hele halv ganger tusen hundre klokka klokken uke uken
912    måned år`.split(/\s+/),
913)
914
915const ENGLISH_WORDS = new Set(
916  `the is are was were and of to in that it you we they this with for on have
917    has had be been not but from at as by or so if then than there their them
918    what when where which who how why all any some more most very just only also
919    into out up down over under`.split(/\s+/),
920)
921
types/index.d.ts 44 lines
1/** Speech this mod started, and everything transport needs to know about it. */
2export type Mine = {
3  voice: string
4  /** The whole text that was launched, in characters. */
5  chars: number
6  /** The multiplier the app was launched at. */
7  rate: number
8  /** Where the app says it is, in UTF-16 units of that text. */
9  offset: number
10}
11
12/** What is playing right now, as the band above the prompt reads it. */
13export type Speech = {
14  isSpeaking: boolean
15  isPaused: boolean
16  /** Milliseconds since the epoch; 0 when nothing is playing. */
17  startedAt: number
18  /** Absent for speech this mod did not start: the Stop hook's, read off pgrep.
19   *  There is no text or position behind it, so the band offers no transport. */
20  mine?: Mine
21  /** The last turn's answer, already stripped and capped: what the band offers to
22   *  read while nothing is playing. Absent until a turn has answered, and absent
23   *  while a reading has the reply in its list instead. */
24  answer?: string
25  /** The reading this mod is in the middle of. Kept after its last reply has been
26   *  read, so `prev` still has somewhere to walk back to. */
27  run?: Run
28}
29
30/** A reading: the replies it holds, oldest first, and where it has got to.
31 *  A reply that arrives while it is flowing takes a place at the end. */
32export type Run = {
33  items: string[]
34  /** Which reply is playing, or - with nothing playing - which one it stopped on.
35   *  Everything after the cursor is still to be read, in order. */
36  at: number
37}
38
39declare module 'claude-code' {
40  interface PluginState {
41    'read-aloud': { speech: Speech | null }
42  }
43}
44