SLOPSHOPPER

blind-claude

Makes Claude Code friendlier for blind and screen reader users: clean output, text to speech, voice choice and sound cues.

newpanespinnerrowsguardcommand
v0.1.0no licenseupdated 2026-10-24blindpro/blind-claude/plugin
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · blind-claude
│ ┃ blind-dialog ✕ › fix the failing auth test and add an audit log call │ ┃ Nothing to answer. │ ⏺ 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. │ │ Done, took 42 seconds. │ │ › /speak │ ⎿ blind-claude: Speech level, now some. How much is spoken: │ ⎿ blind-claude: 1. all: replies, every tool and its result. │ ⎿ blind-claude: 2. most: replies and actions such as edits and com │ ⎿ blind-claude: 3. some: the final answer only, current. │ ⎿ blind-claude: 4. none: nothing. │ ⎿ blind-claude: 0. Back. │ │ Working. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · blind-dialog
Nothing to answer.
Spinner
Working.
Turn line
Done, took 42 seconds.
README

Blind Claude

Blind Claude is a mod for Claude Code that makes it easier to use with a screen reader or without sight at all.

Claude Code is built for people who look at the terminal. It draws boxes, bullets, spinners, check marks, emoji and markdown tables. A screen reader reads all of that out loud, one strange symbol at a time, or skips it in ways that lose meaning. Blind Claude fixes that.

What it does

  1. Cleans the output. Decorative symbols, emoji, box drawing, markdown markup and spinner frames are removed or turned into plain words. Tables become sentences a screen reader can follow.
  2. Speaks the output. Claude Code can read its work aloud through text to speech. You pick how much it says with four levels: all, most, some, or none.
  3. Lets you pick the voice. List the voices installed on your computer, try them, and choose one. Speed and volume can be changed too.
  4. Makes the terminal calmer. No animated spinners, plain status messages, short sound cues for important moments, and a layout that reads in a sensible order.

Status

Version 0.1.0, early and in active development. Working now:

  1. Symbol cleaning of Claude's replies, and a system prompt section that asks Claude to write plainly.
  2. Speech at four levels through NVDA, or the Windows voices when NVDA is not running, with stop and repeat.
  3. Listing, testing and choosing a voice, plus rate and volume.
  4. Sound cues for four events: done, start, attention and error. Five done sounds come built in, and you can add your own for any event.
  5. A still "Working." line in place of the animated spinner, and "Done, took 12 seconds." at the end of a turn.
  6. Edits shown in words, and /changes to read everything Claude changed in Notepad++ or Notepad.
  7. Permission prompts and Claude's questions in a plain dialog: the question first, one choice per line, spoken as you arrow through them, Enter to choose.

Not built yet: plain rows for other tools, notifications, and pause and resume. See the roadmap.

Installing

  1. Install Claude Code first.
  2. In this folder, double click install.cmd, or run .\install.ps1 in PowerShell.
  3. The installer checks the mod, adds this folder as a plugin source, installs the mod for your Windows user, and says out loud when it is done.
  4. Start a new Claude Code session and type /blind to open the settings menu.

The installed mod reads from this folder, so any change here applies in the next session. Running the installer again updates an existing install.

If NVDA is running, Blind Claude speaks through it, in your NVDA voice and settings, using the NVDA controller client that comes with the mod. When NVDA is not running it uses the Windows voice. /speak nvda uses NVDA only, and /speak windows the Windows voice only.

To install straight from GitHub without downloading this folder, type these two commands in Claude Code, then start a new session:

  1. /plugin marketplace add blindpro/blind-claude
  2. /plugin install blind-claude@blind-claude

To try the mod for one session without installing it, run claude --plugin-dir plugin from this folder.

Uninstalling

Double click uninstall.cmd, or run .\uninstall.ps1. Your settings and your own sounds are kept unless you ask otherwise:

  1. .\uninstall.ps1 -RemoveSettings also deletes your saved settings.
  2. .\uninstall.ps1 -RemoveSounds also deletes the sounds you added.
  3. Both scripts take -Quiet to skip speaking the result.

Checking and testing

  1. claude plugin validate plugin checks the mod the way Claude Code loads it.
  2. claude plugin test plugin runs the tests.

Documentation

  1. Architecture: how the mod plugs into Claude Code.
  2. Clean output: which symbols are removed or replaced, and how.
  3. Speech: the four speech levels, speech engines, stopping and repeating speech.
  4. Voices: listing, testing and choosing a voice.
  5. Terminal experience: spinners, status, sound cues and layout.
  6. Commands and settings: every slash command and setting.
  7. Roadmap: the order the work will be done in.
  8. Accessible diffs: what changed and where, in the transcript and in the change report.
  9. Permission prompts and questions: the plain dialog for permissions and Claude's questions.
  10. Writing accessible docs: rules for anything written in this project.

Quick look at the commands

These work now, apart from the ones marked as planned in the commands document.

  1. /speak all, /speak most, /speak some, /speak none: choose how much is spoken.
  2. /speak stop: stop talking right now.
  3. /speak repeat: say the last reply again.
  4. /speak auto, /speak nvda, /speak windows: who speaks. Auto uses NVDA while it is running.
  5. /usage and /context: answered in plain sentences instead of bars and grids, while cleaning is on.
  6. /voices: list the installed voices and choose one by number.
  7. /voices 3 or /voices Zira: choose a voice.
  8. /rate and /volume: speed and loudness.
  9. /clean on, /clean off or /clean words: symbol cleaning.
  10. /sounds: sound cues. /sounds help lists the sound commands.
  11. /changes: read what Claude changed in Notepad++ or Notepad. /changes help lists the rest.
  12. /blind: open the settings menu. Type a number and press Enter to pick a setting, then a number for its choice. 0 goes back. /blind status reads every setting in one list, /blind help lists every command, and /blind restart starts speech again if it has stopped.

Platform

Blind Claude is developed and tested first on Windows 11 with NVDA. macOS and Linux support is planned but secondary. See the speech document for details on each platform.

Requirements

  1. Claude Code with support for plugins that use function hooks.
  2. Windows 10 or 11 for the first release. PowerShell is used to reach NVDA and the Windows speech voices.
  3. NVDA is optional. Without it, the Windows voices speak.
Source 10 files
hooks/register.tsx 1355 lines
1// Blind Claude: wires every feature to Claude Code's events.
2//
3// Everything that calls the engine ($) lives in this file, because the
4// engine handle may not be passed into imported files. The imported files
5// hold the pure logic: cleaning text, describing tools, settings.
6
7import { atom, read, update } from 'claude-code'
8import type { EngineInterface, Register } from 'claude-code'
9
10import type { Dialog, FileChange } from '../types'
11import { cleanForDisplay, cleanForSpeech } from './clean'
12import {
13  applyEdit,
14  changeFromResult,
15  diffTexts,
16  filesOf,
17  firstChangedLine,
18  parseGitDiff,
19  relativePath,
20  reportHtml,
21  reportText,
22  rowText,
23  summary,
24} from './diff'
25import type { ReportOptions } from './diff'
26import { ENGINE_ONLY_TOOLS, QUESTION_KEY, allowKey, answerOf, dialogSpeech, focusOrder, parseQuestions, permissionDialog, questionDialog, questionSpeech, wrapFocus } from './prompts'
27import type { Question } from './prompts'
28import { RESET_MESSAGE, choose, parseChoice, screenText } from './menu'
29import type { MenuContext, Screen } from './menu'
30import {
31  CLEAN_MODES,
32  SOUND_EVENTS,
33  DEFAULTS,
34  ENGINES,
35  LEVELS,
36  SETTINGS_KEY,
37  clamp,
38  describeSettings,
39  editorName,
40  sanitize,
41} from './settings'
42import type { CleanMode, Level, Settings, SoundEvent, SpeechEngine } from './settings'
43import { SOUNDS_HELP, chooseSound, findSound, isPlayable, isSoundEvent, listText, overviewText, sortSounds } from './sounds'
44import type { SoundFile } from './sounds'
45import {
46  MAX_SERVER_STARTS,
47  SERVER_START_WINDOW_MS,
48  describeExit,
49  describeToolCall,
50  describeToolResult,
51  findVoice,
52  formatDuration,
53  mayStartServer,
54  parseVoices,
55  speaksAlerts,
56  speaksFinalAnswer,
57  speaksReplyBlocks,
58  speaksToolCall,
59  speaksToolResult,
60  spoolName,
61} from './speech'
62import type { Voice } from './speech'
63import { contextText, usageText } from './usage'
64
65type Engine = EngineInterface
66
67
68const PLAIN_WRITING = [
69  'The person using this session is blind and uses a screen reader and text to speech.',
70  'Write so your replies read well aloud:',
71  '- Do not use emoji, arrows, check marks, box drawing or other decorative symbols.',
72  '- Do not use markdown tables. Use a list, or one sentence per row, instead.',
73  '- Use bold and italics rarely, and never for decoration.',
74  '- Do not draw diagrams, trees or charts with characters. Describe them in words.',
75  '- Use numbered lists for steps.',
76  '- When pointing at code, name the file and line in words, such as "file clean.ts, line 40".',
77].join('\n')
78
79const HELP = [
80  'Blind Claude commands:',
81  '/speak all, most, some or none: choose how much is spoken.',
82  '/speak auto, nvda or windows: who speaks. auto uses NVDA while it is running, otherwise the Windows voice.',
83  '/speak stop: stop speaking now.',
84  '/speak repeat: say the last reply again.',
85  '/voices: list voices. /voices and a number or name: choose one. /voices test and a number or name: hear a sample.',
86  '/rate and a number from -10 to 10, or faster or slower: set the speed.',
87  '/volume and a number from 0 to 100: set the volume.',
88  '/clean on, off or words: symbol cleaning.',
89  '/sounds: sound cues for done, start, attention and error. /sounds help for details.',
90  '/blind: open the settings menu. Type a number and press Enter to choose; 0 goes back.',
91  '/speak, /voices, /rate, /volume, /clean or /sounds on their own open that part of the menu.',
92  '/blind status: current settings in one list. /blind reset: back to defaults.',
93  '/blind restart: start the speech server again if speech has stopped.',
94  '/usage and /context are answered in plain sentences while symbol cleaning is on.',
95  '/changes: open what Claude changed in the last turn in Notepad++ or Notepad. /changes help for more.',
96  "/blind prompts on or off: permission prompts and Claude's questions in Blind Claude's dialog, or Claude Code's own.",
97].join('\n')
98
99const CHANGES_HELP = [
100  'Change report commands:',
101  '/changes: open what Claude changed in the last turn that changed files.',
102  '/changes session: every change in this session.',
103  '/changes and a number: one file from the last turn, such as /changes 2.',
104  '/changes open and a number: open that file itself, at its first change.',
105  '/changes git: every change since the last commit, from git.',
106  '/changes preview: the change waiting for your permission.',
107  '/changes auto on or off: open the report by itself after each turn that changes files.',
108  '/changes before on or off: open the change before you approve an edit.',
109  '/changes editor and auto, notepad++, notepad or the path of a program: which editor opens it.',
110  '/changes view text or browser: a text file, or a web page with a heading for each file and change.',
111  '/changes context and a number from 0 to 5: unchanged lines shown around each change.',
112  '/changes rows full or summary: what edits show in the transcript.',
113  '/changes settings: the changes menu.',
114].join('\n')
115
116// The pane the accessible dialog is drawn in.
117const DIALOG_PANE = 'blind-dialog'
118
119// Session state the engine keeps across a reload of the mod: every change
120// made this session, the turn count, and what the dialog pane shows.
121const changesState = atom({ plugin: 'blind-claude', key: 'changes' } as const, [] as FileChange[], { shape: 'changes-1' })
122const turnState = atom({ plugin: 'blind-claude', key: 'turn' } as const, 0)
123const dialogState = atom({ plugin: 'blind-claude', key: 'dialog' } as const, null as Dialog | null, { shape: 'dialog-1' })
124
125type Command = Record<string, unknown>
126
127// The Windows speech server. While it starts, commands wait in `queue` and
128// are sent the moment it is ready, so nothing said during startup is lost.
129type Server = {
130  spool: string
131  state: 'starting' | 'ready' | 'stopped'
132  seq: number
133  queue: Command[]
134  ready: Promise<boolean>
135  settle: (isReady: boolean) => void
136}
137
138// Module state. The engine may load the module afresh in the middle of a
139// session (a reload, or its hooks worker replaced), which starts all of
140// these over: settings are then read back from the store, and the speech
141// server is started again the next time something is spoken.
142let current: Settings = { ...DEFAULTS }
143let hasSettings = false
144let lastReply = ''
145let bridge: Server | null = null
146let isWindows: boolean | null = null
147// When the speech server was started, to stop a server that cannot run
148// from being started over and over.
149let serverStarts: number[] = []
150let voicesWaiter: ((json: string) => void) | null = null
151// The engine the speech server last said it speaks with: nvda, windows or none.
152let engineNow = ''
153let engineWaiter: (() => void) | null = null
154// The settings menu screen open now, or null when the menu is closed.
155let menu: Screen | null = null
156// The session's working folder, so reports can show paths inside it short.
157let cwd = ''
158// The change each pending Edit or Write would make, by its call, worked out
159// before it runs, and the latest one, for /changes preview.
160const previews = new Map<string, FileChange>()
161let lastPreview: FileChange | null = null
162// What "allow for the rest of this session" allowed (see allowKey).
163const allowedThisSession = new Set<string>()
164// The file the open dialog's answer is written into, and whether its pane is open.
165let answerPath: string | null = null
166let isDialogOpen = false
167// The stop the dialog's focus is on, and whether the pane has a text box,
168// so the focus can wrap past Claude Code's own stops (see wrapFocus).
169let dialogFocus: string | null = null
170let dialogHasInput = false
171// One dialog at a time: the next waits for the one before to be answered.
172let dialogQueue: Promise<unknown> = Promise.resolve()
173
174async function loadSettings($: Engine): Promise<void> {
175  if (hasSettings) return
176  current = { ...DEFAULTS, ...sanitize(await $.store.get(SETTINGS_KEY)) }
177  hasSettings = true
178}
179
180async function saveSettings($: Engine, change: Partial<Settings>): Promise<void> {
181  await loadSettings($)
182  current = { ...current, ...change }
183  await $.store.set(SETTINGS_KEY, current)
184}
185
186function voiceConfig(): Command[] {
187  const config = { op: 'config', engine: current.engine, voice: current.voice ?? '', rate: current.rate, volume: current.volume }
188
189  return current.voice === null ? [{ op: 'default-voice' }, config] : [config]
190}
191
192async function writeCommand($: Engine, server: Server, command: Command): Promise<boolean> {
193  const name = spoolName(await $.clock.now(), server.seq++)
194  try {
195    await $.fs.write(`${server.spool}\\${name}`, JSON.stringify(command))
196    return true
197  } catch (error) {
198    $.ui.log(`Blind Claude could not reach the speech server: ${String(error)}`, { to: 'debug' })
199    return false
200  }
201}
202
203// The running or starting speech server, started now if it is not running.
204// Null where there is none: not Windows, or it keeps stopping.
205async function ensureServer($: Engine): Promise<Server | null> {
206  if (isWindows === null) isWindows = (await $.env.get('OS')) === 'Windows_NT'
207  if (!isWindows) return null
208  if (bridge !== null && bridge.state !== 'stopped') return bridge
209  if (!mayStartServer(serverStarts, await $.clock.now())) return null
210
211  return startSpeechServer($)
212}
213
214// Sends one command to the Windows speech server, starting it if need be.
215// False when there is no server to send it to.
216async function sendToServer($: Engine, command: Command): Promise<boolean> {
217  const server = await ensureServer($)
218  if (server === null) return false
219  if (server.state === 'ready') return writeCommand($, server, command)
220  server.queue.push(command)
221  if (server.queue.length > 50) server.queue.shift()
222
223  return true
224}
225
226async function sendVoiceConfig($: Engine): Promise<void> {
227  for (const command of voiceConfig()) await sendToServer($, command)
228}
229
230// Speaks text through the speech server, or Claude Code's own speech where
231// the server is not running. Never waits for the speech to finish.
232async function speak($: Engine, text: string, options: { interrupt?: boolean; isRaw?: boolean } = {}): Promise<void> {
233  const spoken = options.isRaw ? text : cleanForSpeech(text)
234  if (spoken.trim() === '') return
235  const isSent = await sendToServer($, { op: 'speak', text: spoken, interrupt: options.interrupt === true })
236  if (isSent) return
237  void $.audio.speak(spoken, current.voice ? { voice: current.voice } : undefined).catch(() => undefined)
238}
239
240async function stopSpeech($: Engine): Promise<void> {
241  await sendToServer($, { op: 'stop' })
242}
243
244async function resetSettings($: Engine): Promise<void> {
245  current = { ...DEFAULTS }
246  await $.store.set(SETTINGS_KEY, current)
247  await sendVoiceConfig($)
248  $.ui.invalidate('ui.render')
249}
250
251// Where the person's own sounds live, outside the plugin folder so they
252// survive updates and reinstalls.
253async function customSoundRoot($: Engine): Promise<string | null> {
254  const base = (await $.env.get('LOCALAPPDATA')) ?? (await $.env.get('APPDATA'))
255
256  return base ? `${base}\\BlindClaude\\sounds` : null
257}
258
259async function listFolder($: Engine, folder: string, isCustom: boolean): Promise<SoundFile[]> {
260  try {
261    const entries = await $.fs.list(folder)
262    return entries
263      .filter(entry => entry.kind === 'file' && isPlayable(entry.name))
264      .map(entry => ({ name: entry.name, path: `${folder}\\${entry.name}`, isCustom }))
265  } catch {
266    return []
267  }
268}
269
270async function soundFiles($: Engine, event: SoundEvent): Promise<SoundFile[]> {
271  const builtIn = await listFolder($, `${$.plugin.root}\\sounds\\${event}`, false)
272  const root = await customSoundRoot($)
273  const custom = root === null ? [] : await listFolder($, `${root}\\${event}`, true)
274
275  return sortSounds([...builtIn, ...custom])
276}
277
278async function playFile($: Engine, file: SoundFile): Promise<void> {
279  const isSent = await sendToServer($, { op: 'play', path: file.path })
280  if (isSent || file.isCustom) return
281  const relative = file.path.slice($.plugin.root.length + 1).replace(/\\/g, '/')
282  void $.audio.play({ asset: relative }).catch(() => undefined)
283}
284
285async function playSound($: Engine, event: SoundEvent): Promise<void> {
286  const choice = current.soundEvents[event]
287  if (!current.sounds || !choice.isOn) return
288  const file = chooseSound(await soundFiles($, event), choice.pick, Math.random())
289  if (file) await playFile($, file)
290}
291
292async function runSoundScript($: Engine, args: string[]): Promise<string> {
293  const script = `${$.plugin.root}\\scripts\\sound-files.ps1`
294  const ran = await $.process.run(['powershell.exe', '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', script, ...args], { timeoutMs: 60_000 })
295
296  return ran.stdout.trim().split('\n').pop()?.trim() ?? ''
297}
298
299async function requestVoices($: Engine): Promise<Voice[] | null> {
300  const server = await ensureServer($)
301  if (server === null) return null
302  // A server that is still starting gets a few seconds to finish.
303  if (server.state === 'starting') {
304    const isReady = await Promise.race([server.ready, $.clock.sleep(4000).then(() => false)])
305    if (!isReady) return null
306  }
307  const answer = new Promise<string>(resolve => {
308    voicesWaiter = resolve
309  })
310  if (!(await sendToServer($, { op: 'voices' }))) return null
311  const timeout = $.clock.sleep(4000).then(() => null)
312  const json = await Promise.race([answer, timeout])
313  voicesWaiter = null
314
315  return json === null ? null : parseVoices(json)
316}
317
318// Starts the speech server. It runs until the session ends or the module is
319// unloaded. If it stops on its own it is started again straight away, up to
320// MAX_SERVER_STARTS times in a few minutes; past that the person is told,
321// and /blind restart tries again.
322async function startSpeechServer($: Engine): Promise<Server | null> {
323  const temp = (await $.env.get('TEMP')) ?? (await $.env.get('TMP'))
324  if (!temp) return null
325  const now = await $.clock.now()
326  serverStarts = [...serverStarts.filter(at => now - at < SERVER_START_WINDOW_MS), now]
327  let settle: (isReady: boolean) => void = () => undefined
328  const ready = new Promise<boolean>(resolve => {
329    settle = resolve
330  })
331  engineNow = ''
332  const spool = `${temp}\\blind-claude\\${Math.floor(now)}`
333  const server: Server = { spool, state: 'starting', seq: 0, queue: voiceConfig(), ready, settle }
334  bridge = server
335  const script = `${$.plugin.root}\\scripts\\speech-server.ps1`
336
337  void (async () => {
338    let pending = ''
339    let errors = ''
340    let stopReason = ''
341    let code: number | null = null
342    try {
343      const child = $.process.spawn({
344        argv: ['powershell.exe', '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', script, '-Spool', spool],
345      })
346      for await (const chunk of child) {
347        if (chunk.stream === 'stderr') errors = (errors + chunk.text).slice(-2000)
348        if (chunk.stream !== 'stdout') continue
349        pending += chunk.text
350        let end = pending.indexOf('\n')
351        while (end >= 0) {
352          const line = pending.slice(0, end).trim()
353          pending = pending.slice(end + 1)
354          end = pending.indexOf('\n')
355          if (line === 'READY') {
356            server.state = 'ready'
357            const queued = server.queue
358            server.queue = []
359            for (const command of queued) await writeCommand($, server, command)
360            server.settle(true)
361          } else if (line.startsWith('ENGINE ')) {
362            engineNow = line.slice('ENGINE '.length)
363            engineWaiter?.()
364          } else if (line.startsWith('VOICES ')) {
365            voicesWaiter?.(line.slice('VOICES '.length))
366          } else if (line.startsWith('STOP ')) {
367            stopReason = line.slice('STOP '.length)
368          } else if (line.startsWith('ERROR ')) {
369            $.ui.log(`Blind Claude speech: ${line.slice('ERROR '.length)}`)
370          }
371        }
372      }
373      code = (await child.result).code
374    } catch (error) {
375      errors = `${errors}\n${String(error)}`
376    }
377    server.state = 'stopped'
378    server.settle(false)
379    // A newer server replaced this one, so its end is expected.
380    if (bridge !== server) return
381    const reason = describeExit(code, stopReason, errors)
382    if (mayStartServer(serverStarts, await $.clock.now())) {
383      $.ui.log(`Blind Claude speech server stopped (${reason}). Starting it again.`, { to: 'debug' })
384      await startSpeechServer($)
385    } else {
386      $.ui.log(`Blind Claude speech server stopped (${reason}). It stopped ${MAX_SERVER_STARTS} times in a few minutes, so speech is off for now. Type /blind restart to try again.`)
387    }
388  })()
389
390  return server
391}
392
393// NVDA may have started or closed since the server last spoke, so ask it
394// which engine it would use now, waiting a moment for the answer.
395async function refreshEngine($: Engine): Promise<void> {
396  if (bridge?.state !== 'ready') return
397  const answer = new Promise<void>(resolve => {
398    engineWaiter = resolve
399  })
400  if (await writeCommand($, bridge, { op: 'engine' })) await Promise.race([answer, $.clock.sleep(1500)])
401  engineWaiter = null
402}
403
404function engineName(): string {
405  if (isWindows === false) return 'Claude Code built in speech'
406  if (bridge?.state === 'ready') {
407    if (engineNow === 'nvda') return 'NVDA'
408    if (engineNow === 'windows') return 'Windows voice'
409    if (engineNow === 'none') return current.engine === 'nvda' ? 'NVDA, not running, so nothing is spoken' : 'none available'
410    return 'speech server'
411  }
412  if (bridge?.state === 'starting') return 'speech server, starting'
413
414  return 'speech server stopped. Type /blind restart to start it'
415}
416
417// Fetches only what the screen needs: voices for the voice list (and for
418// opening it from the main screen), sound files for one event.
419async function menuContext($: Engine, screen: Screen, needsVoices = false): Promise<MenuContext> {
420  const voices = screen.kind === 'voice' || needsVoices ? await requestVoices($) : null
421  const files = screen.kind === 'event' ? await soundFiles($, screen.event) : []
422  if (screen.kind === 'main') await refreshEngine($)
423
424  return { settings: current, engine: engineName(), voices, files }
425}
426
427// Opens a menu screen: its text, after an optional message, shown and spoken.
428async function showScreen($: Engine, screen: Screen, message?: string): Promise<string> {
429  menu = screen
430  const body = screenText(screen, await menuContext($, screen))
431  const text = message ? `${message}\n${body}` : body
432  if (current.level !== 'none') await speak($, text, { interrupt: true, isRaw: true })
433
434  return text
435}
436
437async function applyChange($: Engine, change: Partial<Settings>): Promise<void> {
438  await saveSettings($, change)
439  if ('engine' in change || 'voice' in change || 'rate' in change || 'volume' in change) await sendVoiceConfig($)
440  if ('clean' in change) $.ui.invalidate('ui.render')
441  if (change.level === 'none') await stopSpeech($)
442}
443
444// Answers a number typed while the menu is open.
445async function answerMenu($: Engine, screen: Screen, choice: number): Promise<string> {
446  const answer = choose(screen, choice, await menuContext($, screen, screen.kind === 'main' && choice === 3))
447  if (answer.kind === 'close') {
448    menu = null
449    if (current.level !== 'none') await speak($, 'Menu closed.', { interrupt: true, isRaw: true })
450    return 'Settings menu closed.'
451  }
452  if (answer.kind === 'open') return showScreen($, answer.screen, answer.message)
453  if (answer.kind === 'changes-report') {
454    const changes = await lastTurnChanges($)
455    const message = changes.length === 0 ? 'Claude has not changed any files in this session yet.' : await openReport($, changes, 'Blind Claude change report for the last turn that changed files.')
456    return showScreen($, screen, message)
457  }
458  if (answer.kind === 'reset') {
459    await resetSettings($)
460    return showScreen($, answer.screen, RESET_MESSAGE)
461  }
462  await applyChange($, answer.change)
463  if (answer.play) await playFile($, answer.play)
464
465  return showScreen($, answer.screen, answer.message)
466}
467
468// Runs open-changes.ps1. Answers the editor's name and the path opened,
469// or the error in words.
470async function openInEditor($: Engine, path: string, options: { editor: string; line?: number; isReport?: boolean }): Promise<{ editor: string; path: string } | { error: string }> {
471  const script = `${$.plugin.root}\\scripts\\open-changes.ps1`
472  const args = ['-Path', path, '-Editor', options.editor, ...(options.line ? ['-Line', String(options.line)] : []), ...(options.isReport ? ['-Report'] : [])]
473  try {
474    const ran = await $.process.run(['powershell.exe', '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', script, ...args], { timeoutMs: 30_000 })
475    const line = ran.stdout.trim().split('\n').pop()?.trim() ?? ''
476    if (line.startsWith('OK ')) {
477      const [editor = 'the editor', opened = path] = line.slice(3).split('|')
478      return { editor, path: opened }
479    }
480    return { error: line.replace(/^ERROR /, '') || ran.stderr.trim() || 'unknown error' }
481  } catch (error) {
482    return { error: String(error) }
483  }
484}
485
486// Writes a change report and opens it. Answers what to tell the person.
487async function openReport($: Engine, changes: readonly FileChange[], title: string, footer?: string[]): Promise<string> {
488  const base = (await $.env.get('LOCALAPPDATA')) ?? (await $.env.get('APPDATA'))
489  if (!base) return 'Could not find your local app data folder to save the change report in.'
490  const isPage = current.changes.view === 'browser'
491  const options: ReportOptions = { title, cwd, context: current.changes.context, ...(footer ? { footer } : {}) }
492  const path = `${base}\\BlindClaude\\changes\\report-${Math.floor(await $.clock.now())}.${isPage ? 'html' : 'txt'}`
493  await $.fs.write(path, isPage ? reportHtml(changes, options) : reportText(changes, options))
494  const opened = await openInEditor($, path, { editor: isPage ? 'browser' : current.changes.editor, isReport: true })
495  if ('error' in opened) return `Could not open the change report: ${opened.error}. It is saved at ${path}.`
496  const files = filesOf(changes).length
497
498  return `The change report opened in ${opened.editor}. It covers ${files === 1 ? '1 file' : `${files} files`}. If you do not hear it, press Alt Tab to reach it.`
499}
500
501// Writes a change report of every change since the last commit, from git,
502// and opens it. Answers what to tell the person.
503async function gitReport($: Engine): Promise<string> {
504  const diff = async (args: string[]) => {
505    try {
506      return await $.process.run(['git', ...args], { timeoutMs: 60_000 })
507    } catch {
508      return null
509    }
510  }
511  let ran = await diff(['diff', 'HEAD', '--no-color', '--no-ext-diff'])
512  if (ran === null || ran.exitCode !== 0) ran = await diff(['diff', '--no-color', '--no-ext-diff'])
513  if (ran === null || ran.exitCode !== 0) return 'This folder is not a git repository, or git is not installed.'
514  const changes = parseGitDiff(ran.stdout)
515  const untracked = await diff(['ls-files', '--others', '--exclude-standard'])
516  const newFiles = (untracked?.stdout ?? '').split('\n').map(l => l.trim()).filter(l => l !== '')
517  const footer = newFiles.length > 0 ? [`New files git does not track yet, not shown above: ${newFiles.slice(0, 50).join(', ')}${newFiles.length > 50 ? `, and ${newFiles.length - 50} more` : ''}.`] : undefined
518  if (changes.length === 0) return newFiles.length > 0 ? `No changes to tracked files since the last commit. ${footer![0]}` : 'No changes since the last commit.'
519
520  return openReport($, changes, 'Blind Claude change report: every change since the last commit, from git.', footer)
521}
522
523async function sessionChanges($: Engine): Promise<FileChange[]> {
524  return (await read($, changesState)) ?? []
525}
526
527// The changes of the last turn that changed files.
528async function lastTurnChanges($: Engine): Promise<FileChange[]> {
529  const all = await sessionChanges($)
530  const last = all[all.length - 1]
531
532  return last === undefined ? [] : all.filter(c => c.turn === last.turn)
533}
534
535function inputField(input: unknown, key: string): string | undefined {
536  if (typeof input !== 'object' || input === null) return undefined
537  const value = (input as Record<string, unknown>)[key]
538
539  return typeof value === 'string' ? value : undefined
540}
541
542// Works out the change an Edit or Write will make, before it runs, so the
543// permission dialog can say it and /changes preview can show it.
544async function preparePreview($: Engine, tool: string, input: unknown, id: string | undefined): Promise<FileChange | null> {
545  const path = inputField(input, 'file_path')
546  if (!path) return null
547  let before: string | null = null
548  try {
549    before = await $.fs.read(path)
550  } catch {
551    before = null
552  }
553  const turn = (await read($, turnState)) ?? 0
554  let preview: FileChange | null = null
555  if (tool === 'Edit') {
556    if (before === null) return null
557    const after = applyEdit(before, inputField(input, 'old_string') ?? '', inputField(input, 'new_string') ?? '', (input as { replace_all?: unknown }).replace_all === true)
558    preview = after === null ? null : diffTexts(path, before, after, turn)
559  } else if (tool === 'Write') {
560    preview = diffTexts(path, before, inputField(input, 'content') ?? '', turn)
561  }
562  if (preview === null) return null
563  lastPreview = preview
564  if (id !== undefined) previews.set(id, preview)
565
566  return preview
567}
568
569// Undoes the escaping wait-answer.ps1 puts on a typed answer's newlines.
570function decodeAnswer(text: string): string {
571  return text.replace(/\\(\\|n)/g, (_, c: string) => (c === 'n' ? '\n' : '\\'))
572}
573
574function sendAnswer($: Engine, answer: string): void {
575  if (answerPath === null) return
576  void $.fs.write(answerPath, `${answer}\nEND`).catch(() => undefined)
577}
578
579async function closeDialog($: Engine): Promise<void> {
580  answerPath = null
581  if (!isDialogOpen) return
582  isDialogOpen = false
583  await update($, dialogState, () => null)
584  await $.ui.close({ id: DIALOG_PANE })
585}
586
587// Shows `dialog` in the dialog pane, opening the pane if it is not open,
588// and waits for one answer: a choice's key, "text:" and what was typed, or
589// "cancel". Null when the pane cannot be shown, such as on a narrow terminal.
590async function askDialog($: Engine, dialog: Dialog, signal: AbortSignal, isNew: boolean): Promise<string | null> {
591  const temp = (await $.env.get('TEMP')) ?? (await $.env.get('TMP'))
592  if (!temp) return null
593  const path = `${temp}\\blind-claude\\answers\\${dialog.id.replace(/[^\w-]/g, '_')}.txt`
594  answerPath = path
595  await update($, dialogState, () => dialog)
596  if (!isDialogOpen) {
597    const rows = 1 + dialog.lines.length + dialog.options.length + (dialog.input ? 2 : 0) + 2
598    const opened = await $.ui.open({ id: DIALOG_PANE, title: 'Blind Claude', focus: true, closeOnEscape: true, holdToasts: true, rows })
599    isDialogOpen = true
600    if (!opened.isPlaced) {
601      $.ui.log(`Blind Claude could not show its dialog (${opened.reason}), so Claude Code's own is shown.`, { to: 'debug' })
602      await closeDialog($)
603      return null
604    }
605  }
606  if (isNew) {
607    dialogFocus = QUESTION_KEY
608    await playSound($, 'attention')
609    if (current.level !== 'none') await speak($, dialogSpeech(dialog), { interrupt: true, isRaw: true })
610  }
611  const script = `${$.plugin.root}\\scripts\\wait-answer.ps1`
612  const onAbort = () => sendAnswer($, 'cancel')
613  signal.addEventListener('abort', onAbort)
614  try {
615    while (true) {
616      const ran = await $.process.run(['powershell.exe', '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', script, '-Path', path], { timeoutMs: 600_000 })
617      const line = ran.stdout.split('\n').map(l => l.trim()).find(l => l.startsWith('ANSWER '))
618      if (line !== undefined) return decodeAnswer(line.slice('ANSWER '.length))
619      if (signal.aborted) return 'cancel'
620    }
621  } finally {
622    signal.removeEventListener('abort', onAbort)
623  }
624}
625
626// Runs `work` once every dialog before it has been answered.
627function oneDialogAtATime<T>(work: () => Promise<T>): Promise<T> {
628  const run = dialogQueue.then(work, work)
629  dialogQueue = run.catch(() => undefined)
630
631  return run
632}
633
634type PermissionAnswer = { decision: 'allow' } | { decision: 'deny'; reason: string } | null
635
636// Asks for permission in the dialog. Null leaves it to Claude Code's own prompt.
637async function askPermission($: Engine, id: string, tool: string, input: unknown, reason: string | undefined, preview: FileChange | undefined, signal: AbortSignal): Promise<PermissionAnswer> {
638  const dialog = permissionDialog(`permission-${id}`, tool, cleanInput(input), reason, preview ? summary(preview) : undefined)
639  let isNew = true
640  try {
641    while (true) {
642      const answer = await askDialog($, dialog, signal, isNew)
643      isNew = false
644      if (answer === null || answer === 'engine') return null
645      if (answer === 'read' && preview) {
646        await openReport($, [preview], 'Blind Claude: the change Claude wants to make. It has not been made yet.')
647        continue
648      }
649      if (answer === 'yes') return { decision: 'allow' }
650      if (answer === 'always') {
651        allowedThisSession.add(allowKey(tool, input))
652        return { decision: 'allow' }
653      }
654      if (answer.startsWith('text:')) return { decision: 'deny', reason: `The user declined this tool call, and said: ${answer.slice(5)}` }
655      return { decision: 'deny', reason: "The user declined this tool call in Blind Claude's permission dialog." }
656    }
657  } finally {
658    await closeDialog($)
659  }
660}
661
662// Claude's questions, one at a time. Answers the AskUserQuestion result's
663// answers, "engine" for Claude Code's own dialog, or "cancel".
664async function askQuestions($: Engine, id: string, questions: readonly Question[], shown: readonly Question[], signal: AbortSignal): Promise<Record<string, string> | 'engine' | 'cancel'> {
665  const answers: Record<string, string> = {}
666  try {
667    for (let index = 0; index < questions.length; index++) {
668      const question = questions[index]!
669      const chosen = new Set<number>()
670      let isNew = true
671      while (true) {
672        const answer = await askDialog($, questionDialog(`question-${id}-${index}`, shown, index, chosen), signal, isNew)
673        isNew = false
674        if (answer === null || answer === 'engine') return 'engine'
675        if (answer === 'cancel') return 'cancel'
676        if (answer.startsWith('text:')) {
677          answers[question.question] = answer.slice(5)
678          break
679        }
680        if (answer === 'done') {
681          answers[question.question] = answerOf(question, [...chosen].sort((a, b) => a - b))
682          break
683        }
684        const pick = /^opt-(\d+)$/.exec(answer)
685        if (!pick) continue
686        const n = Number(pick[1])
687        if (!question.multiSelect) {
688          answers[question.question] = answerOf(question, [n])
689          break
690        }
691        if (chosen.has(n)) chosen.delete(n)
692        else chosen.add(n)
693        const label = shown[index]?.options[n]?.label ?? ''
694        await speak($, `${chosen.has(n) ? 'Chosen' : 'Not chosen'}, ${label}.`, { interrupt: true, isRaw: true })
695      }
696    }
697  } finally {
698    await closeDialog($)
699  }
700
701  return answers
702}
703
704// Cleans the symbols out of every string a dialog will show.
705function cleanInput(input: unknown): unknown {
706  if (typeof input === 'string') return cleanForDisplay(input, 'on')
707  if (Array.isArray(input)) return input.map(cleanInput)
708  if (typeof input === 'object' && input !== null) {
709    return Object.fromEntries(Object.entries(input as Record<string, unknown>).map(([k, v]) => [k, k === 'command' || k.endsWith('_string') || k === 'content' ? v : cleanInput(v)]))
710  }
711
712  return input
713}
714
715function word(args: string): [string, string] {
716  const trimmed = args.trim()
717  const space = trimmed.search(/\s/)
718
719  return space < 0 ? [trimmed.toLowerCase(), ''] : [trimmed.slice(0, space).toLowerCase(), trimmed.slice(space + 1).trim()]
720}
721
722const commandFailed = () => ({ text: 'That Blind Claude command failed. Type /blind help for the list of commands.' })
723
724export const register: Register = on => {
725  on('session.start', async ($, e, next) => {
726    const started = await next(e)
727    cwd = started.cwd
728    hasSettings = false
729    await loadSettings($)
730
731    const commands = [
732      { name: 'speak', description: 'Blind Claude: set how much is spoken and who speaks, or stop and repeat speech', argumentHint: '[all|most|some|none|stop|repeat|auto|nvda|windows]' },
733      { name: 'voices', description: 'Blind Claude: list, test and choose a speech voice', argumentHint: '[number|name|test <voice>|default]' },
734      { name: 'rate', description: 'Blind Claude: set the speech speed', argumentHint: '[-10 to 10|faster|slower]' },
735      { name: 'volume', description: 'Blind Claude: set the speech volume', argumentHint: '[0 to 100]' },
736      { name: 'clean', description: 'Blind Claude: turn symbol cleaning on, off, or words', argumentHint: '[on|off|words]' },
737      { name: 'sounds', description: 'Blind Claude: list, play, pick, add and remove sound cues', argumentHint: '[help|status|on|off|list|play|pick|add|remove|folder]' },
738      { name: 'blind', description: 'Blind Claude: open the settings menu, show status or help, reset, or restart speech', argumentHint: '[help|status|reset|restart|prompts on|prompts off]' },
739      { name: 'changes', description: 'Blind Claude: read what Claude changed, in Notepad++ or Notepad', argumentHint: '[help|session|<number>|open <number>|git|preview|auto|before|editor|view|context|rows]' },
740    ]
741    // Claude Code refuses a name one of its own commands uses, and some exist
742    // only in interactive sessions. One refusal must not stop the others.
743    for (const command of commands) {
744      try {
745        await $.command.register({ ...command, immediate: true })
746      } catch (error) {
747        $.ui.log(`Blind Claude could not add /${command.name}: ${String(error)}`)
748      }
749    }
750
751    // Started now so the first reply is not kept waiting for it.
752    await ensureServer($)
753
754    return started
755  })
756
757  // Clean output, layer one: ask Claude to write plainly.
758  on('prompt.compose', async ($, e, next) => {
759    await loadSettings($)
760    const composed = await next(e)
761    if (current.clean === 'off') return composed
762
763    return { sections: [...composed.sections, { id: 'blind-claude:plain-writing', text: PLAIN_WRITING, scope: 'session' as const }] }
764  })
765
766  // Clean output, layer two: clean each reply before it is drawn.
767  on('ui.render', { component: 'AssistantMessage' }, ($, e, next) => {
768    if (current.clean === 'off') return next(e)
769
770    return next({ ...e, props: { ...e.props, text: cleanForDisplay(e.props.text, current.clean) } })
771  })
772
773  // The same cleaning for what slash commands print, built in ones such as
774  // /cost and /context included, and for the notices under the logo.
775  on('ui.render', { component: 'CommandOutput' }, ($, e, next) => {
776    if (current.clean === 'off') return next(e)
777
778    return next({ ...e, props: { ...e.props, text: cleanForDisplay(e.props.text, current.clean) } })
779  })
780
781  on('ui.render', { component: 'InfoNotice' }, ($, e, next) => {
782    if (current.clean === 'off') return next(e)
783
784    return next({ ...e, props: { ...e.props, text: cleanForDisplay(e.props.text, current.clean) } })
785  })
786
787  // /usage and /context open panels of bars and grids that the cleaning
788  // above never sees, so while cleaning is on they answer in sentences.
789  on('command.run', { command: 'usage' }, async ($, e, next) => {
790    await loadSettings($)
791    if (current.clean === 'off') return next(e)
792
793    return { text: usageText(await $.session.usage(), await $.clock.now()) }
794  }).catch(($, e, next) => next(e))
795
796  on('command.run', { command: 'context' }, async ($, e, next) => {
797    await loadSettings($)
798    if (current.clean === 'off') return next(e)
799
800    return { text: contextText(await $.session.usage({ breakdown: 'summary' })) }
801  }).catch(($, e, next) => next(e))
802
803  // A still status line instead of the animated spinner.
804  on('ui.render', { component: 'Spinner' }, ($, e, next) => {
805    if (current.clean === 'off') return next(e)
806    const { Text } = $.ui.resolve(e)
807
808    return <Text>Working.</Text>
809  })
810
811  on('ui.render', { component: 'TurnDuration' }, ($, e, next) => {
812    if (current.clean === 'off') return next(e)
813    const { Text } = $.ui.resolve(e)
814
815    return <Text dimColor>Done, took {formatDuration(e.props.durationMs)}.</Text>
816  })
817
818  // A new message stops any speech still playing. While the settings menu
819  // is open, a number typed at the prompt answers the menu and never reaches
820  // Claude; anything else closes the menu and goes to Claude as usual.
821  on('prompt.submit', async ($, e, next) => {
822    await loadSettings($)
823    await stopSpeech($)
824    if (menu !== null) {
825      const isPerson = e.origin.kind === 'composer' || e.origin.kind === 'bridge'
826      const choice = isPerson && e.attachments === undefined ? parseChoice(e.text) : null
827      if (choice !== null) {
828        try {
829          return { drop: await answerMenu($, menu, choice) }
830        } catch (error) {
831          $.ui.log(`Blind Claude menu failed: ${String(error)}`, { to: 'debug' })
832          return { drop: 'That menu choice failed. Type /blind to open the menu again.' }
833        }
834      }
835      if (isPerson) menu = null
836    }
837
838    return next(e)
839  }).catch(($, e, next) => next(e))
840
841  // Reply text as it arrives, at the "all" and "most" levels.
842  on('session.append', async ($, e, next) => {
843    await loadSettings($)
844    const appended = await next(e)
845    const isMainReply = e.door === 'response' && e.agentId === undefined && e.message.type === 'assistant'
846    if (isMainReply && speaksReplyBlocks(current.level)) {
847      const text = e.message.content
848        .map(block => (block.type === 'text' && typeof block.text === 'string' ? block.text : ''))
849        .join('\n')
850        .trim()
851      if (text !== '') await speak($, text)
852    }
853
854    return appended
855  }).catch(($, e, next) => next(e))
856
857  // Claude's questions in the accessible dialog. The answer is given in
858  // place of the tool, so Claude Code's own dialog never opens; choosing
859  // "Show Claude Code's own question instead" lets it open.
860  on('tool.call', { tool: 'AskUserQuestion' }, async ($, e, next) => {
861    await loadSettings($)
862    if (!current.prompts) return next(e)
863    const questions = parseQuestions(e)
864    const shown = parseQuestions(cleanInput(e))
865    if (questions === null || shown === null) return next(e)
866    const answer = await oneDialogAtATime(() => askQuestions($, e.tool_use_id ?? 'question', questions, shown, next.signal))
867    if (answer === 'engine') return next(e)
868    if (answer === 'cancel') return { deny: 'The user dismissed the question without answering.' }
869
870    return { result: { questions: e.questions, answers: answer } }
871  }).catch(($, e, next) => next(e))
872
873  on('tool.call', async ($, e, next) => {
874    await loadSettings($)
875    const isMain = e.agentId === undefined
876    const isFileTool = e.tool === 'Edit' || e.tool === 'Write'
877    if (isFileTool) {
878      const preview = await preparePreview($, e.tool, e, e.tool_use_id)
879      if (preview && e.tool_use_id !== undefined) {
880        try {
881          $.ui.notice(e.tool_use_id, `Blind Claude: ${summary(preview)} Type /changes preview to read it.`)
882        } catch {
883          // No dialog is open for this call.
884        }
885      }
886    }
887    if (isMain && speaksToolCall(current.level, e.tool)) await speak($, describeToolCall(e.tool, e), { isRaw: true })
888    const ran = await next(e)
889    if (e.tool_use_id !== undefined) previews.delete(e.tool_use_id)
890    const turn = (await read($, turnState)) ?? 0
891    const change = isFileTool && ran.deny === undefined && ran.isError !== true ? changeFromResult(e.tool, ran.result, turn, !isMain) : null
892    if (change) await update($, changesState, list => [...(list ?? []), change].slice(-500))
893    if (isMain && ran.deny === undefined) {
894      if (change && (current.level === 'all' || current.level === 'most')) await speak($, summary(change, true), { isRaw: true })
895      else if (speaksToolResult(current.level)) await speak($, describeToolResult(e.tool, ran.isError === true), { isRaw: true })
896    }
897
898    return ran
899  }).catch(($, e, next) => next(e))
900
901  // Permission prompts in the accessible dialog. Claude Code decides first
902  // whether to ask; only a question meant for the person is taken over.
903  on('tool.check', async ($, e, next) => {
904    const verdict = await next(e)
905    await loadSettings($)
906    const id = e.tool_use_id
907    if (verdict.decision !== 'ask' || e.approver !== 'person' || id === undefined || ENGINE_ONLY_TOOLS.has(e.tool)) return verdict
908    if (allowedThisSession.has(allowKey(e.tool, e.input))) return { decision: 'allow', reason: 'Allowed for the rest of this session in Blind Claude.' }
909    const preview = previews.get(id)
910    if (preview && current.changes.isPreview) await openReport($, [preview], 'Blind Claude: the change Claude wants to make. It has not been made yet.')
911    if (!current.prompts) return verdict
912    const answer = await oneDialogAtATime(() => askPermission($, id, e.tool, e.input, verdict.reason, preview, next.signal))
913
914    return answer === null ? verdict : answer
915  }).catch(($, e, next) => next(e))
916
917  // The dialog: its question at the top, then one choice per line. Up and
918  // down arrow move between them, Enter or the number chooses.
919  on('ui.render', { component: 'Pane', requestId: DIALOG_PANE }, async ($, e) => {
920    const elements = $.ui.resolve(e)
921    const { Box, Text, Button } = elements
922    // Mobile has no text box, so there the choices are all there is.
923    const Input = 'Input' in elements ? elements.Input : null
924    const dialog = await read($, dialogState)
925    if (!dialog) return <Text>Nothing to answer.</Text>
926    dialogHasInput = Input !== null
927
928    return (
929      <Box flexDirection="column">
930        <Button key={QUESTION_KEY} label={dialog.title} plain autoFocus onPress={() => speak($, dialogSpeech(dialog), { interrupt: true, isRaw: true })} />
931        {dialog.lines.map(line => (
932          <Text>{line}</Text>
933        ))}
934        {dialog.options.map((option, i) =>
935          i < 9 ? (
936            <Button key={option.key} label={option.label} hotkey={String(i + 1)} plain onPress={() => sendAnswer($, option.key)} />
937          ) : (
938            <Button key={option.key} label={option.label} plain onPress={() => sendAnswer($, option.key)} />
939          ),
940        )}
941        {dialog.input && Input ? (
942          <Input key="typed" label={dialog.input.label} submitLabel="send" onSubmit={(value: string) => (value.trim() === '' ? undefined : sendAnswer($, `text:${value}`))} />
943        ) : null}
944      </Box>
945    )
946  })
947
948  // Says the stop the person moves to, so the arrows can be followed by ear.
949  // Claude Code's own stops in the pane, such as its close mark, are passed
950  // over: the focus wraps around the dialog instead, and Escape still closes.
951  on('ui.focus', async ($, e, next) => {
952    if (e.requestId !== DIALOG_PANE) return next(e)
953    const dialog = await read($, dialogState)
954    const element = e.element === undefined && e.origin.kind === 'person' && dialog ? wrapFocus(focusOrder(dialog, dialogHasInput), dialogFocus) : e.element
955    const moved = await next(element === e.element ? e : { ...e, element })
956    if (moved.deny !== undefined || element === undefined) return moved
957    dialogFocus = element
958    if (e.origin.kind !== 'person' && e.element !== undefined) return moved
959    const option = dialog?.options.find(o => o.key === element)
960    if (option) await speak($, option.spoken, { interrupt: true, isRaw: true })
961    else if (element === QUESTION_KEY && dialog) await speak($, questionSpeech(dialog), { interrupt: true, isRaw: true })
962    else if (element === 'typed' && dialog?.input) await speak($, dialog.input.label, { interrupt: true, isRaw: true })
963
964    return moved
965  }).catch(($, e, next) => next(e))
966
967  // Escape closes the dialog: a permission is then refused and a question
968  // left unanswered, as in Claude Code's own.
969  on('ui.close', async ($, e, next) => {
970    if (e.id === DIALOG_PANE && e.origin.kind === 'person') {
971      isDialogOpen = false
972      sendAnswer($, 'cancel')
973    }
974
975    return next(e)
976  }).catch(($, e, next) => next(e))
977
978  // Edits in the transcript: a summary and the changed lines in words, in
979  // place of the colored diff. Control O still shows Claude Code's own.
980  on('ui.render', { component: 'ToolResult' }, ($, e, next) => {
981    const { tool, output, isErrored, isExpanded } = e.props
982    if (current.clean === 'off' || isErrored || isExpanded === true || (tool !== 'Edit' && tool !== 'Write')) return next(e)
983    const change = changeFromResult(tool, output, 0, false)
984    if (change === null) return next(e)
985    const { Text } = $.ui.resolve(e)
986
987    return <Text>{rowText(change, current.changes.isSummaryOnly)}</Text>
988  })
989
990  on('turn.start', async ($, e, next) => {
991    await loadSettings($)
992    const started = await next(e)
993    if (e.agentId === undefined) await update($, turnState, n => (n ?? 0) + 1)
994    await playSound($, 'start')
995
996    return started
997  }).catch(($, e, next) => next(e))
998
999  // Permission requests and questions from Claude Code.
1000  on('classic.Notification', async ($, e, next) => {
1001    await loadSettings($)
1002    if (!/idle/i.test(e.notification_type)) {
1003      await playSound($, 'attention')
1004      if (speaksAlerts(current.level)) await speak($, e.message, { interrupt: false })
1005    }
1006
1007    return next(e)
1008  }).catch(($, e, next) => next(e))
1009
1010  on('turn.complete', async ($, e, next) => {
1011    await loadSettings($)
1012    const result = await next(e)
1013    if (e.agentId !== undefined) return result
1014
1015    if (e.reason === 'answer') {
1016      if (e.answer.trim() !== '') lastReply = e.answer
1017      await playSound($, 'done')
1018      const turn = (await read($, turnState)) ?? 0
1019      const changed = filesOf((await sessionChanges($)).filter(c => c.turn === turn)).length
1020      if (changed > 0 && speaksFinalAnswer(current.level)) {
1021        await speak($, `Changed ${changed === 1 ? '1 file' : `${changed} files`}. Type /changes to read ${changed === 1 ? 'it' : 'them'}.`, { isRaw: true })
1022      }
1023      if (speaksFinalAnswer(current.level)) await speak($, e.answer)
1024      if (changed > 0 && current.changes.isAuto) await openReport($, await lastTurnChanges($), 'Blind Claude change report for the last turn.')
1025    } else if (e.reason === 'error') {
1026      await playSound($, 'error')
1027      if (speaksAlerts(current.level)) await speak($, 'Claude stopped because of an error.', { isRaw: true })
1028    } else if (e.reason === 'refusal' && speaksAlerts(current.level)) {
1029      await speak($, 'Claude declined to continue.', { isRaw: true })
1030    }
1031
1032    return result
1033  }).catch(($, e, next) => next(e))
1034
1035  on('command.run', { command: 'speak' }, async ($, e) => {
1036    await loadSettings($)
1037    const [choice] = word(e.args)
1038    if (choice === '') return { text: await showScreen($, { kind: 'level' }) }
1039    if (choice === 'stop') {
1040      await stopSpeech($)
1041      return { text: 'Speech stopped.' }
1042    }
1043    if (choice === 'repeat') {
1044      if (lastReply === '') return { text: 'Nothing to repeat yet.' }
1045      await speak($, lastReply, { interrupt: true })
1046      return { text: 'Repeating the last reply.' }
1047    }
1048    if (ENGINES.includes(choice as SpeechEngine)) {
1049      await saveSettings($, { engine: choice as SpeechEngine })
1050      await sendVoiceConfig($)
1051      await refreshEngine($)
1052      await speak($, `Speech engine ${choice}.`, { interrupt: true, isRaw: true })
1053      return { text: `Speech engine set to ${choice}. Speaking with: ${engineName()}.` }
1054    }
1055    if (LEVELS.includes(choice as Level)) {
1056      await saveSettings($, { level: choice as Level })
1057      if (choice === 'none') await stopSpeech($)
1058      else await speak($, `Speech level ${choice}.`, { interrupt: true, isRaw: true })
1059      return { text: `Speech level set to ${choice}.` }
1060    }
1061
1062    return { text: `Unknown option "${choice}". Use all, most, some, none, stop, repeat, auto, nvda or windows.` }
1063  }).catch(commandFailed)
1064
1065  on('command.run', { command: 'voices' }, async ($, e) => {
1066    await loadSettings($)
1067    const [first, rest] = word(e.args)
1068    if (first === 'default') {
1069      await saveSettings($, { voice: null })
1070      await sendVoiceConfig($)
1071      await speak($, 'Using the default voice.', { interrupt: true, isRaw: true })
1072      return { text: 'Using the system default voice.' }
1073    }
1074
1075    const voices = await requestVoices($)
1076    if (voices === null) {
1077      return { text: 'The voice list is only available on Windows while the speech server is running. Try again in a moment.' }
1078    }
1079    if (first === '') return { text: await showScreen($, { kind: 'voice' }) }
1080
1081    const isTest = first === 'test'
1082    const query = isTest ? rest : e.args.trim()
1083    if (query === '') return { text: 'Say which voice to test, by number or name.' }
1084    const matches = findVoice(voices, query)
1085    if (matches.length === 0) return { text: `No voice matches "${query}". Type /voices to list them.` }
1086    if (matches.length > 1) return { text: `More than one voice matches: ${matches.map(v => v.name).join(', ')}. Be more specific.` }
1087    const voice = matches[0]!
1088
1089    if (isTest) {
1090      await sendToServer($, { op: 'config', voice: voice.name })
1091      await speak($, `This is ${voice.name}.`, { interrupt: true, isRaw: true })
1092      await sendVoiceConfig($)
1093      return { text: `Played a sample of ${voice.name}.` }
1094    }
1095    await saveSettings($, { voice: voice.name })
1096    await sendVoiceConfig($)
1097    await speak($, `Voice changed to ${voice.name}.`, { interrupt: true, isRaw: true })
1098
1099    return { text: `Voice changed to ${voice.name}.` }
1100  }).catch(commandFailed)
1101
1102  on('command.run', { command: 'rate' }, async ($, e) => {
1103    await loadSettings($)
1104    const [choice] = word(e.args)
1105    if (choice === '') return { text: await showScreen($, { kind: 'rate' }) }
1106    let rate = current.rate
1107    if (choice === 'faster') rate += 1
1108    else if (choice === 'slower') rate -= 1
1109    else if (/^-?\d+$/.test(choice)) rate = Number(choice)
1110    else return { text: 'Use a number from -10 to 10, or faster, or slower.' }
1111    await saveSettings($, { rate: clamp(rate, -10, 10) })
1112    await sendVoiceConfig($)
1113    await speak($, `Rate ${current.rate}.`, { interrupt: true, isRaw: true })
1114
1115    return { text: `Speech rate set to ${current.rate}.` }
1116  }).catch(commandFailed)
1117
1118  on('command.run', { command: 'volume' }, async ($, e) => {
1119    await loadSettings($)
1120    const [choice] = word(e.args)
1121    if (choice === '') return { text: await showScreen($, { kind: 'volume' }) }
1122    if (!/^\d+$/.test(choice)) return { text: 'Use a number from 0 to 100.' }
1123    await saveSettings($, { volume: clamp(Number(choice), 0, 100) })
1124    await sendVoiceConfig($)
1125    await speak($, `Volume ${current.volume}.`, { interrupt: true, isRaw: true })
1126
1127    return { text: `Speech volume set to ${current.volume}.` }
1128  }).catch(commandFailed)
1129
1130  on('command.run', { command: 'clean' }, async ($, e) => {
1131    await loadSettings($)
1132    const [choice] = word(e.args)
1133    if (choice === '') return { text: await showScreen($, { kind: 'clean' }) }
1134    if (!CLEAN_MODES.includes(choice as CleanMode)) return { text: 'Use on, off or words.' }
1135    await saveSettings($, { clean: choice as CleanMode })
1136    $.ui.invalidate('ui.render')
1137
1138    return { text: `Symbol cleaning set to ${choice}.` }
1139  }).catch(commandFailed)
1140
1141  on('command.run', { command: 'sounds' }, async ($, e) => {
1142    await loadSettings($)
1143    const parts = e.args.trim().split(/\s+/).filter(part => part !== '')
1144    const [first = '', second = '', ...rest] = parts
1145    const action = first.toLowerCase()
1146    const eventWord = second.toLowerCase()
1147
1148    const saveEvent = async (event: SoundEvent, change: Partial<Settings['soundEvents'][SoundEvent]>) => {
1149      await saveSettings($, { soundEvents: { ...current.soundEvents, [event]: { ...current.soundEvents[event], ...change } } })
1150    }
1151    const ambiguous = (files: readonly SoundFile[]) => `More than one sound matches: ${files.map(f => f.name).join(', ')}. Be more specific.`
1152
1153    if (action === '') return { text: await showScreen($, { kind: 'sounds' }) }
1154    if (action === 'status') {
1155      const counts = { done: 0, start: 0, attention: 0, error: 0 }
1156      for (const event of SOUND_EVENTS) counts[event] = (await soundFiles($, event)).length
1157      return { text: overviewText(current, counts) }
1158    }
1159    if (action === 'help') return { text: SOUNDS_HELP }
1160    if (action === 'on' || action === 'off') {
1161      await saveSettings($, { sounds: action === 'on' })
1162      return { text: `All sounds turned ${action}.` }
1163    }
1164    if (action === 'test') {
1165      const file = chooseSound(await soundFiles($, 'done'), current.soundEvents.done.pick, Math.random())
1166      if (!file) return { text: 'There are no done sounds.' }
1167      await playFile($, file)
1168      return { text: `Played ${file.name}.` }
1169    }
1170    if (action === 'folder') {
1171      const root = await customSoundRoot($)
1172      return { text: root ? `Your own sounds are kept in ${root}, one folder per event. WAV files placed there are used right away.` : 'Could not find your local app data folder.' }
1173    }
1174
1175    // "/sounds done on" turns one event on or off.
1176    if (isSoundEvent(action) && (eventWord === 'on' || eventWord === 'off')) {
1177      await saveEvent(action, { isOn: eventWord === 'on' })
1178      return { text: `${action} sound turned ${eventWord}.` }
1179    }
1180
1181    if (!isSoundEvent(eventWord)) {
1182      return { text: `Name an event: done, start, attention or error. For example, /sounds ${action} done. Type /sounds help for all sound commands.` }
1183    }
1184    const event = eventWord
1185    const files = await soundFiles($, event)
1186    const query = rest.join(' ')
1187
1188    if (action === 'list') return { text: listText(event, files, current.soundEvents[event]) }
1189
1190    if (action === 'play') {
1191      const chosen = query === '' ? chooseSound(files, current.soundEvents[event].pick, Math.random()) : undefined
1192      const matches = query === '' ? (chosen ? [chosen] : []) : findSound(files, query)
1193      if (matches.length === 0) return { text: files.length === 0 ? `No ${event} sounds yet.` : `No ${event} sound matches "${query}".` }
1194      if (matches.length > 1) return { text: ambiguous(matches) }
1195      await playFile($, matches[0]!)
1196      return { text: `Played ${matches[0]!.name}.` }
1197    }
1198
1199    if (action === 'pick') {
1200      if (query === '' || query.toLowerCase() === 'random') {
hooks/clean.ts 282 lines
1// Text cleaning for the screen and for speech.
2// Pure functions: text in, text out, no engine calls, so they are easy to test.
3
4import type { CleanMode } from './settings'
5
6// Symbols that carry meaning become words. Order matters: longer first.
7const WORDS: readonly [RegExp, string][] = [
8  [/[\u2705\u2714\u2713\u2611]\uFE0F?/gu, ' done '],
9  [/[\u274C\u274E\u2716\u2717\u2718]\uFE0F?/gu, ' failed '],
10  [/\u26A0\uFE0F?/gu, ' warning '],
11  [/\u2139\uFE0F?/gu, ' note '],
12  [/\u23BF/gu, 'Result:'],
13  [/\s*(?:\u2192|\u21D2|\u27F6|\u27F9|\u279C|\u2794|\u27A1\uFE0F?|\u21AA)\s*/gu, ' to '],
14  [/\s*(?:\u2190|\u21D0|\u27F5|\u2B05\uFE0F?)\s*/gu, ' from '],
15  [/\s*(?:\u2194|\u21D4|\u27F7)\s*/gu, ' and '],
16  [/\u2026/gu, '...'],
17  [/\s*\u2265\s*/gu, ' greater than or equal to '],
18  [/\s*\u2264\s*/gu, ' less than or equal to '],
19  [/\s*\u2260\s*/gu, ' not equal to '],
20  [/\u00D7/gu, ' times '],
21  [/(?<=\d)\s*\u2013\s*(?=\d)/gu, ' to '],
22  [/\s*[\u2014\u2013]\s*/gu, ', '],
23  [/[\u201C\u201D]/gu, '"'],
24  [/[\u2018\u2019]/gu, "'"],
25]
26
27// A few common emoji, named for the "words" clean mode. Anything not
28// listed here is removed like in the "on" mode.
29const EMOJI_NAMES: Record<string, string> = {
30  '\u{1F680}': 'rocket',
31  '\u{1F389}': 'party',
32  '\u{1F525}': 'fire',
33  '\u{1F4A1}': 'idea',
34  '\u{1F41B}': 'bug',
35  '\u{1F4DD}': 'note',
36  '\u{1F527}': 'wrench',
37  '\u{1F528}': 'hammer',
38  '\u{1F44D}': 'thumbs up',
39  '\u{1F44E}': 'thumbs down',
40  '\u{1F600}': 'smiling face',
41  '\u{1F642}': 'smiling face',
42  '\u{1F60A}': 'smiling face',
43  '\u{1F622}': 'sad face',
44  '\u{1F914}': 'thinking face',
45  '\u2728': 'sparkles',
46  '\u{1F4E6}': 'package',
47  '\u{1F50D}': 'search',
48  '\u{1F512}': 'locked',
49  '\u{1F513}': 'unlocked',
50  '\u{1F6A7}': 'under construction',
51  '\u{1F6A8}': 'alert',
52  '\u{1F4C1}': 'folder',
53  '\u{1F4C4}': 'file',
54  '\u2B50': 'star',
55  '\u2764': 'heart',
56}
57
58// Removed outright, after the word swaps above.
59const EMOJI = /(?:\p{Regional_Indicator}|\p{Extended_Pictographic})(?:[\u{1F3FB}-\u{1F3FF}]|\uFE0F|\u20E3)*(?:\u200D(?:\p{Extended_Pictographic})(?:[\u{1F3FB}-\u{1F3FF}]|\uFE0F)*)*/gu
60const LEFTOVER_JOINERS = /[\uFE0E\uFE0F\u200D\u20E3\u{1F3FB}-\u{1F3FF}]/gu
61const BOX_AND_BLOCKS = /[\u2500-\u259F]/gu
62const BRAILLE = /[\u2800-\u28FF]/gu
63const DINGBATS = /[\u2700-\u27BF\u2190-\u21FF\u2B00-\u2BFF]/gu
64const SHAPES = /[\u25A0-\u25FF\u2022\u2023\u2043\u2219\u00B7]/gu
65
66// Decorative bullets at the start of a line become a plain dash.
67const BULLET = /^(\s*)(?:[*+\u2022\u25CF\u25CB\u25E6\u25AA\u25AB\u25A0\u25A1\u25B6\u25BA\u2023\u2043\u2219\u23FA]|-(?=\s))\s+/gmu
68
69type Segment = { kind: 'text' | 'code'; text: string; lang: string }
70
71// Splits markdown into prose and fenced code blocks.
72export function splitFences(text: string): Segment[] {
73  const out: Segment[] = []
74  const lines = text.split('\n')
75  let buffer: string[] = []
76  let fence: { marker: string; lang: string } | null = null
77
78  const flush = (kind: 'text' | 'code', lang = '') => {
79    if (buffer.length > 0 || kind === 'code') out.push({ kind, text: buffer.join('\n'), lang })
80    buffer = []
81  }
82
83  for (const line of lines) {
84    const open = /^\s*(`{3,}|~{3,})\s*([\w+#.-]*)/.exec(line)
85    if (fence === null && open) {
86      flush('text')
87      fence = { marker: open[1]!, lang: open[2] ?? '' }
88      continue
89    }
90    if (fence !== null && line.trim().startsWith(fence.marker[0]!.repeat(fence.marker.length)) && line.trim().replace(/[`~]/g, '') === '') {
91      flush('code', fence.lang)
92      fence = null
93      continue
94    }
95    buffer.push(line)
96  }
97  if (fence !== null) flush('code', fence.lang)
98  else flush('text')
99
100  return out
101}
102
103// Hides inline code from the prose rules, then puts it back.
104function protectInline(text: string, fn: (prose: string) => string, restore: (code: string) => string): string {
105  const saved: string[] = []
106  const hidden = text.replace(/`[^`\n]+`/g, code => {
107    saved.push(code)
108    return `\u0000${saved.length - 1}\u0000`
109  })
110
111  return fn(hidden).replace(/\u0000(\d+)\u0000/g, (_, i: string) => restore(saved[Number(i)] ?? ''))
112}
113
114function isTableRow(line: string): boolean {
115  return /^\s*\|.*\|\s*$/.test(line)
116}
117
118function isTableRule(line: string): boolean {
119  return /^\s*\|?(\s*:?-{2,}:?\s*\|)+\s*:?-{0,}:?\s*\|?\s*$/.test(line)
120}
121
122function cells(line: string): string[] {
123  return line.trim().replace(/^\|/, '').replace(/\|$/, '').split('|').map(c => c.trim())
124}
125
126function endSentence(text: string): string {
127  return /[.!?:]$/.test(text) ? text : `${text}.`
128}
129
130// Markdown tables become one line per row, read with their column names.
131export function linearizeTables(text: string): string {
132  const lines = text.split('\n')
133  const out: string[] = []
134  let i = 0
135  while (i < lines.length) {
136    const line = lines[i]!
137    const next = lines[i + 1]
138    if (isTableRow(line) && next !== undefined && isTableRule(next)) {
139      const header = cells(line)
140      const rows: string[][] = []
141      i += 2
142      while (i < lines.length && isTableRow(lines[i]!)) {
143        rows.push(cells(lines[i]!))
144        i++
145      }
146      const rowWord = rows.length === 1 ? 'row' : 'rows'
147      const colWord = header.length === 1 ? 'column' : 'columns'
148      out.push(`Table with ${rows.length} ${rowWord} and ${header.length} ${colWord}: ${header.join(', ')}.`)
149      rows.forEach((row, n) => {
150        const parts = row.map((value, c) => {
151          const name = header[c] ?? `Column ${c + 1}`
152          return endSentence(`${name}: ${value === '' ? 'empty' : value}`)
153        })
154        out.push(`Row ${n + 1}. ${parts.join(' ')}`)
155      })
156      continue
157    }
158    out.push(line)
159    i++
160  }
161
162  return out.join('\n')
163}
164
165function stripMarkdown(text: string): string {
166  return text
167    .replace(/^[ \t]{0,3}#{1,6}[ \t]+(.*?)[ \t]*#*[ \t]*$/gm, '$1')
168    .replace(/^[ \t]*([-*_])([ \t]*\1){2,}[ \t]*$/gm, '')
169    .replace(/^([ \t]*)>[ \t]?/gm, '$1')
170    .replace(/\*\*(.+?)\*\*/g, '$1')
171    .replace(/__(.+?)__/g, '$1')
172    .replace(/~~(.+?)~~/g, '$1')
173    .replace(/(^|[^\w*])\*(?!\s)([^*\n]+?)\*(?![\w*])/g, '$1$2')
174    .replace(/(^|[^\w])_(?!\s)([^_\n]+?)_(?![\w])/g, '$1$2')
175}
176
177function swapSymbols(text: string, mode: CleanMode): string {
178  let out = text
179  for (const [pattern, word] of WORDS) out = out.replace(pattern, word)
180  if (mode === 'words') {
181    out = out.replace(EMOJI, emoji => {
182      const name = EMOJI_NAMES[emoji.replace(/[\uFE0F\u200D]/g, '')]
183      return name ? ` ${name} ` : ' '
184    })
185  }
186
187  return out
188    .replace(EMOJI, ' ')
189    .replace(LEFTOVER_JOINERS, '')
190    .replace(BOX_AND_BLOCKS, ' ')
191    .replace(BRAILLE, '')
192    .replace(DINGBATS, ' ')
193    .replace(SHAPES, ' ')
194}
195
196// Swaps symbols line by line, keeping each line's own indent, so a removed
197// symbol at the start of a line does not leave a fake indent behind.
198function swapAndTidy(text: string, mode: CleanMode): string {
199  return text
200    .replace(BULLET, '$1- ')
201    .split('\n')
202    .map(line => {
203      const indent = /^[ \t]*/.exec(line)![0]
204      const body = swapSymbols(line.slice(indent.length), mode)
205        .trim()
206        .replace(/[ \t]{2,}/g, ' ')
207        .replace(/ +([,.;:!?])/g, '$1')
208        .replace(/^,\s*/, '')
209        .replace(/,(\s*,)+/g, ',')
210        .trimEnd()
211      return body === '' ? '' : indent + body
212    })
213    .join('\n')
214    .replace(/\n{3,}/g, '\n\n')
215}
216
217function cleanProse(text: string, mode: CleanMode): string {
218  return protectInline(
219    text,
220    prose => swapAndTidy(stripMarkdown(linearizeTables(prose)), mode),
221    code => code,
222  )
223}
224
225// Cleans text for the screen. Code blocks and inline code are left exactly as written.
226export function cleanForDisplay(text: string, mode: CleanMode = 'on'): string {
227  if (mode === 'off') return text
228
229  return splitFences(text)
230    .map(seg => (seg.kind === 'code' ? fenceBack(seg) : cleanProse(seg.text, mode)))
231    .join('\n')
232    .replace(/^\n+|\n+$/g, '')
233}
234
235function fenceBack(seg: Segment): string {
236  return '```' + seg.lang + '\n' + seg.text + '\n```'
237}
238
239function describeCode(seg: Segment): string {
240  const count = seg.text === '' ? 0 : seg.text.split('\n').length
241  const lines = count === 1 ? '1 line' : `${count} lines`
242  const lang = seg.lang ? `${seg.lang}, ` : ''
243
244  return `Code block, ${lang}${lines}.`
245}
246
247const ABBREVIATIONS: readonly [RegExp, string][] = [
248  [/\be\.g\.,?/gi, 'for example,'],
249  [/\bi\.e\.,?/gi, 'that is,'],
250  [/\betc\./gi, 'and so on.'],
251  [/\bvs\.?(?=\s)/gi, 'versus'],
252]
253
254function speakPaths(text: string): string {
255  return text
256    .replace(/\[([^\]\n]+)\]\((?:[^)\s]+)\)/g, '$1')
257    .replace(/\bhttps?:\/\/(?:www\.)?([^\s/)>\]]+)[^\s)>\]]*/g, 'link to $1')
258    .replace(/(?:[A-Za-z]:[\\/])?(?:[\w.@~-]+[\\/])+([\w@-][\w.@-]*\.\w+)/g, '$1')
259}
260
261// Cleans text for the voice. Goes further than display: code blocks are
262// described instead of read, paths become file names, links become site names.
263export function cleanForSpeech(text: string): string {
264  const spoken = splitFences(text)
265    .map(seg => {
266      if (seg.kind === 'code') return describeCode(seg)
267      let prose = cleanProse(seg.text, 'words')
268      prose = prose.replace(/`([^`\n]+)`/g, '$1')
269      prose = speakPaths(prose)
270      for (const [pattern, word] of ABBREVIATIONS) prose = prose.replace(pattern, word)
271      return prose.replace(/^(\s*)- /gm, '$1')
272    })
273    .join('\n')
274
275  return spoken
276    .replace(/([!?.,;:])\1+/g, '$1')
277    .replace(/[*_#|`~<>{}[\]\\^]+/g, ' ')
278    .replace(/[ \t]{2,}/g, ' ')
279    .replace(/\n\s*\n+/g, '\n')
280    .trim()
281}
282
hooks/diff.ts 572 lines
1// Accessible diffs: what changed in a file, and where, in words.
2// Pure functions: tool results and diff text in, sentences out, no engine
3// calls. register.tsx collects the changes, writes the reports and opens them.
4
5import type { DiffHunk, FileChange } from '../types'
6
7// A new file longer than this is not listed line by line.
8export const MAX_NEW_FILE_LINES = 200
9// A transcript row lists the lines themselves up to this many changes.
10export const ROW_LINES = 6
11
12export type Entry =
13  | { kind: 'context'; line: number; text: string }
14  | { kind: 'added'; line: number; text: string }
15  | { kind: 'removed'; line: number; text: string }
16  | { kind: 'changed'; line: number; before: string; after: string }
17
18export type Counts = { added: number; removed: number; changed: number }
19
20function baseName(path: string): string {
21  const parts = path.split(/[\\/]/)
22
23  return parts[parts.length - 1] || path
24}
25
26function plural(n: number, one: string, many = `${one}s`): string {
27  return n === 1 ? `1 ${one}` : `${n} ${many}`
28}
29
30function splitLines(text: string): string[] {
31  const lines = text.replace(/\r\n/g, '\n').split('\n')
32  if (lines.length > 0 && lines[lines.length - 1] === '') lines.pop()
33
34  return lines
35}
36
37function words(text: string): string[] {
38  return text.match(/\w+/g) ?? []
39}
40
41// Two lines look alike when at least half the words of the shorter one are
42// in the other.
43function isAlike(a: string, b: string): boolean {
44  const left = words(a)
45  const right = new Set(words(b))
46  if (left.length === 0 || right.size === 0) return a.trim() === b.trim()
47  const shared = left.filter(w => right.has(w)).length
48
49  return shared * 2 >= Math.min(left.length, right.size)
50}
51
52// Walks a hunk's lines. A run of removed lines followed by the same number
53// of added lines is a set of changed lines, which is how most edits look.
54export function hunkEntries(hunk: DiffHunk): Entry[] {
55  const out: Entry[] = []
56  let oldLine = hunk.oldStart
57  let newLine = hunk.newStart
58  let removed: { line: number; text: string }[] = []
59  let added: { line: number; text: string }[] = []
60
61  // Runs of the same length are paired line by line. Otherwise lines are
62  // paired from the top while they look alike, and the rest are listed.
63  const flush = () => {
64    const isEven = removed.length === added.length
65    let paired = 0
66    while (paired < removed.length && paired < added.length && (isEven || isAlike(removed[paired]!.text, added[paired]!.text))) paired++
67    for (let i = 0; i < paired; i++) out.push({ kind: 'changed', line: added[i]!.line, before: removed[i]!.text, after: added[i]!.text })
68    for (const r of removed.slice(paired)) out.push({ kind: 'removed', ...r })
69    for (const a of added.slice(paired)) out.push({ kind: 'added', ...a })
70    removed = []
71    added = []
72  }
73
74  for (const raw of hunk.lines) {
75    const mark = raw[0]
76    const text = raw.slice(1).replace(/\r$/, '')
77    if (mark === '\\') continue
78    if (mark === '-') {
79      if (added.length > 0) flush()
80      removed.push({ line: oldLine++, text })
81    } else if (mark === '+') {
82      added.push({ line: newLine++, text })
83    } else {
84      flush()
85      out.push({ kind: 'context', line: newLine, text })
86      oldLine++
87      newLine++
88    }
89  }
90  flush()
91
92  return out
93}
94
95// Keeps only the unchanged lines within `around` lines of a change.
96export function trimContext(entries: readonly Entry[], around: number): Entry[] {
97  const changes = entries.map((e, i) => (e.kind === 'context' ? -1 : i)).filter(i => i >= 0)
98
99  return entries.filter((e, i) => e.kind !== 'context' || changes.some(c => Math.abs(c - i) <= around))
100}
101
102export function countEntries(entries: readonly Entry[]): Counts {
103  const counts = { added: 0, removed: 0, changed: 0 }
104  for (const e of entries) if (e.kind !== 'context') counts[e.kind]++
105
106  return counts
107}
108
109export function countChanges(changes: readonly FileChange[]): Counts {
110  const total = { added: 0, removed: 0, changed: 0 }
111  for (const change of changes) {
112    for (const hunk of change.hunks) {
113      const c = countEntries(hunkEntries(hunk))
114      total.added += c.added
115      total.removed += c.removed
116      total.changed += c.changed
117    }
118  }
119
120  return total
121}
122
123export function countText(c: Counts): string {
124  const parts: string[] = []
125  if (c.added > 0) parts.push(`${plural(c.added, 'line')} added`)
126  if (c.removed > 0) parts.push(parts.length === 0 ? `${plural(c.removed, 'line')} removed` : `${c.removed} removed`)
127  if (c.changed > 0) parts.push(parts.length === 0 ? `${plural(c.changed, 'line')} changed` : `${c.changed} changed`)
128
129  return parts.length === 0 ? 'no lines changed' : parts.join(', ')
130}
131
132// The lines of the new file the change covers, such as "lines 40 to 52".
133function lineRange(hunks: readonly DiffHunk[]): string {
134  const lines = hunks.flatMap(h => hunkEntries(h).filter(e => e.kind !== 'context').map(e => e.line))
135  if (lines.length === 0) return ''
136  const low = Math.min(...lines)
137  const high = Math.max(...lines)
138
139  return low === high ? `line ${low}` : `lines ${low} to ${high}`
140}
141
142function newFileLines(change: FileChange): number {
143  return change.hunks.reduce((n, h) => n + h.newLines, 0)
144}
145
146// One sentence for a change. `isSpoken` leaves out the line numbers.
147export function summary(change: FileChange, isSpoken = false): string {
148  const name = baseName(change.path)
149  const by = change.isAgent ? ' by a helper agent' : ''
150  switch (change.kind) {
151    case 'create':
152      return `Created ${name}${by}, ${plural(newFileLines(change), 'line')}.`
153    case 'emptied':
154      return `Emptied ${name}${by}.`
155    case 'deleted':
156      return `Deleted ${name}${by}.`
157    case 'rewrite':
158      return `Rewrote ${name}${by}. The old version was too large to compare.`
159    case 'edit': {
160      const range = isSpoken ? '' : lineRange(change.hunks)
161      const counts = countText(countChanges([change]))
162      return `Edited ${name}${by}${range ? `, ${range}` : ''}: ${counts}.`
163    }
164  }
165}
166
167function indentWords(line: string): string {
168  const indent = /^[ \t]*/.exec(line)![0]
169  const tabs = indent.split('').filter(c => c === '\t').length
170  const spaces = indent.length - tabs
171  if (tabs === 0 && spaces === 0) return 'no indent'
172  const parts = [tabs > 0 ? plural(tabs, 'tab') : '', spaces > 0 ? plural(spaces, 'space') : ''].filter(p => p !== '')
173
174  return parts.join(' and ')
175}
176
177// Names the words that differ between two short lines, or null when the
178// lines share too little for that to help.
179export function wordsChanged(before: string, after: string): string | null {
180  if (before.length > 160 || after.length > 160) return null
181  const split = (s: string) => s.match(/\w+|\s+|[^\w\s]+/g) ?? []
182  const a = split(before)
183  const b = split(after)
184  let start = 0
185  while (start < a.length && start < b.length && a[start] === b[start]) start++
186  let end = 0
187  while (end < a.length - start && end < b.length - start && a[a.length - 1 - end] === b[b.length - 1 - end]) end++
188  const shared = [...a.slice(0, start), ...a.slice(a.length - end)].join('').trim()
189  if (shared === '') return null
190  const gone = a.slice(start, a.length - end).join('').trim()
191  const came = b.slice(start, b.length - end).join('').trim()
192  if (gone.length > 60 || came.length > 60) return null
193  if (gone === '' && came === '') return null
194  if (gone === '') return `Words added: "${came}".`
195  if (came === '') return `Words removed: "${gone}".`
196
197  return `Words changed: "${gone}" became "${came}".`
198}
199
200// The lines that say one entry. The code is kept exactly as written.
201export function describeEntry(entry: Entry): string[] {
202  switch (entry.kind) {
203    case 'context':
204      return [entry.text.trim() === '' ? `Unchanged line ${entry.line}, empty.` : `Unchanged line ${entry.line}: ${entry.text}`]
205    case 'added':
206      return [entry.text.trim() === '' ? `Added line ${entry.line}, empty.` : `Added line ${entry.line}: ${entry.text}`]
207    case 'removed':
208      return [entry.text.trim() === '' ? `Removed, was line ${entry.line}, empty.` : `Removed, was line ${entry.line}: ${entry.text}`]
209    case 'changed': {
210      const { line, before, after } = entry
211      if (before.trimEnd() === after.trimEnd()) return [`Changed line ${line}, spaces at the end of the line only.`]
212      if (before.trim() === after.trim() && before.trim() !== '') {
213        return [`Changed line ${line}, indent only, ${indentWords(before)} became ${indentWords(after)}: ${after.trim()}`]
214      }
215      const words = wordsChanged(before, after)
216      return [`Changed line ${line}.`, `Before: ${before}`, `After: ${after}`, ...(words ? [words] : [])]
217    }
218  }
219}
220
221const SCOPES: readonly [RegExp, string][] = [
222  [/^\s*(?:export\s+)?(?:default\s+)?(?:async\s+)?function\s*\*?\s*([A-Za-z_$][\w$-]*)/i, 'function'],
223  [/^\s*(?:export\s+)?(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*(?::[^=]+)?=\s*(?:async\s+)?(?:\([^)]*\)|[A-Za-z_$][\w$]*)\s*(?::[^=]+)?=>/, 'function'],
224  [/^\s*(?:async\s+)?def\s+([A-Za-z_]\w*)/, 'function'],
225  [/^\s*(?:pub\s+)?fn\s+([A-Za-z_]\w*)/, 'function'],
226  [/^\s*func\s+(?:\([^)]*\)\s*)?([A-Za-z_]\w*)/, 'function'],
227  [/^\s*(?:export\s+)?(?:default\s+)?(?:abstract\s+)?class\s+([A-Za-z_$][\w$]*)/, 'class'],
228]
229
230// The function, class or heading a line sits in, read from the lines of
231// the file before it: the nearest one indented less than the line.
232export function findScope(lines: readonly string[], index: number): string | undefined {
233  const target = lines[index] ?? ''
234  const indent = /^[ \t]*/.exec(target)![0].length
235  for (let i = Math.min(index, lines.length - 1); i >= 0 && i > index - 400; i--) {
236    const line = lines[i]!
237    const heading = /^#{1,6}\s+(.+?)\s*#*\s*$/.exec(line)
238    if (heading) return `section ${heading[1]}`
239    const own = /^[ \t]*/.exec(line)![0].length
240    if (i !== index && own >= indent) continue
241    for (const [pattern, word] of SCOPES) {
242      const match = pattern.exec(line)
243      if (match) return `${word} ${match[1]}`
244    }
245  }
246
247  return undefined
248}
249
250// The scope from the text git prints after a hunk's @@ marks.
251export function scopeFromHeader(text: string): string | undefined {
252  for (const [pattern, word] of SCOPES) {
253    const match = pattern.exec(text)
254    if (match) return `${word} ${match[1]}`
255  }
256  const heading = /^#{1,6}\s+(.+)$/.exec(text.trim())
257
258  return heading ? `section ${heading[1]}` : undefined
259}
260
261function isHunk(value: unknown): value is DiffHunk {
262  if (typeof value !== 'object' || value === null) return false
263  const h = value as Record<string, unknown>
264
265  return ['oldStart', 'oldLines', 'newStart', 'newLines'].every(k => typeof h[k] === 'number') && Array.isArray(h.lines) && h.lines.every(l => typeof l === 'string')
266}
267
268function withScopes(hunks: DiffHunk[], original: string | null): DiffHunk[] {
269  if (original === null) return hunks
270  const lines = splitLines(original)
271
272  return hunks.map(h => {
273    const entries = hunkEntries(h)
274    const first = h.lines.findIndex(l => l[0] === '-' || l[0] === '+')
275    const contextBefore = first < 0 ? 0 : first
276    const index = h.oldStart - 1 + contextBefore
277    const scope = entries.length > 0 ? findScope(lines, Math.max(0, Math.min(index, lines.length - 1))) : undefined
278    return scope ? { ...h, scope } : h
279  })
280}
281
282function createHunks(content: string): { hunks: DiffHunk[]; note?: string } {
283  const lines = splitLines(content)
284  const shown = lines.slice(0, MAX_NEW_FILE_LINES)
285  const hunk: DiffHunk = { oldStart: 0, oldLines: 0, newStart: 1, newLines: lines.length, lines: shown.map(l => `+${l}`) }
286  const note = lines.length > shown.length ? `Only the first ${MAX_NEW_FILE_LINES} of its ${lines.length} lines are listed. Open the file to read the rest.` : undefined
287
288  return note ? { hunks: [hunk], note } : { hunks: [hunk] }
289}
290
291// A change from the Edit or Write tool's result record, or null when the
292// record is not one or nothing changed.
293export function changeFromResult(tool: string, result: unknown, turn: number, isAgent: boolean): FileChange | null {
294  if (tool !== 'Edit' && tool !== 'Write') return null
295  if (typeof result !== 'object' || result === null) return null
296  const r = result as Record<string, unknown>
297  if (typeof r.filePath !== 'string') return null
298  const path = r.filePath
299  const patch = Array.isArray(r.structuredPatch) ? r.structuredPatch.filter(isHunk) : []
300  const original = typeof r.originalFile === 'string' ? r.originalFile : null
301  const base = { path, turn, isAgent }
302
303  if (tool === 'Write') {
304    const content = typeof r.content === 'string' ? r.content : ''
305    if (r.type === 'create') return { ...base, kind: 'create', ...createHunks(content) }
306    if (content === '' && original !== '') return { ...base, kind: 'emptied', hunks: [] }
307    if (patch.length === 0) return original === null ? { ...base, kind: 'rewrite', hunks: [] } : null
308  }
309  if (patch.length === 0) return null
310
311  return { ...base, kind: 'edit', hunks: withScopes(patch, original) }
312}
313
314// The change an edit would make, worked out before it runs, for a preview.
315// One hunk from the first to the last line that differs.
316export function diffTexts(path: string, before: string | null, after: string, turn = 0): FileChange | null {
317  if (before === null) return { path, turn, isAgent: false, kind: 'create', ...createHunks(after) }
318  if (after === '' && before !== '') return { path, turn, isAgent: false, kind: 'emptied', hunks: [] }
319  const a = splitLines(before)
320  const b = splitLines(after)
321  let start = 0
322  while (start < a.length && start < b.length && a[start] === b[start]) start++
323  let end = 0
324  while (end < a.length - start && end < b.length - start && a[a.length - 1 - end] === b[b.length - 1 - end]) end++
325  if (start === a.length && start === b.length) return null
326  const around = 3
327  const from = Math.max(0, start - around)
328  const tail = Math.min(end, around)
329  const lines = [
330    ...a.slice(from, start).map(l => ` ${l}`),
331    ...a.slice(start, a.length - end).map(l => `-${l}`),
332    ...b.slice(start, b.length - end).map(l => `+${l}`),
333    ...a.slice(a.length - end, a.length - end + tail).map(l => ` ${l}`),
334  ]
335  const oldLines = a.length - end + tail - from
336  const newLines = b.length - end + tail - from
337  const hunk: DiffHunk = { oldStart: from + 1, oldLines, newStart: from + 1, newLines, lines }
338
339  return { path, turn, isAgent: false, kind: 'edit', hunks: withScopes([hunk], before) }
340}
341
342// What an Edit call would leave in the file, or null when its text is not there.
343export function applyEdit(content: string, oldString: string, newString: string, replaceAll: boolean): string | null {
344  if (oldString === '') return null
345  const index = content.indexOf(oldString)
346  if (index < 0) return null
347  if (replaceAll) return content.split(oldString).join(newString)
348
349  return content.slice(0, index) + newString + content.slice(index + oldString.length)
350}
351
352// Reads `git diff` output into changes.
353export function parseGitDiff(text: string): FileChange[] {
354  const out: FileChange[] = []
355  let file: FileChange | null = null
356  let hunk: DiffHunk | null = null
357  let oldPath = ''
358  for (const raw of text.replace(/\r\n/g, '\n').split('\n')) {
359    if (raw.startsWith('diff --git ')) {
360      const match = /^diff --git a\/(.+) b\/(.+)$/.exec(raw)
361      file = { path: match?.[2] ?? raw.slice(11), kind: 'edit', hunks: [], turn: 0, isAgent: false }
362      out.push(file)
363      hunk = null
364      oldPath = match?.[1] ?? ''
365      continue
366    }
367    if (file === null) continue
368    if (hunk === null) {
369      if (raw.startsWith('new file mode')) file.kind = 'create'
370      else if (raw.startsWith('deleted file mode')) file.kind = 'deleted'
371      else if (raw.startsWith('Binary files')) file.note = 'A binary file. Its contents are not shown.'
372      else if (raw.startsWith('rename from ')) file.note = `Renamed from ${raw.slice(12)}.`
373      else if (raw.startsWith('+++ ') && raw !== '+++ /dev/null') file.path = raw.slice(4).replace(/^b\//, '')
374      else if (raw === '+++ /dev/null') file.path = oldPath
375    }
376    const header = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@ ?(.*)$/.exec(raw)
377    if (header) {
378      const scope = scopeFromHeader(header[5] ?? '')
379      hunk = {
380        oldStart: Number(header[1]),
381        oldLines: header[2] === undefined ? 1 : Number(header[2]),
382        newStart: Number(header[3]),
383        newLines: header[4] === undefined ? 1 : Number(header[4]),
384        lines: [],
385        ...(scope ? { scope } : {}),
386      }
387      file.hunks.push(hunk)
388      continue
389    }
390    if (hunk !== null && /^[ +\-\\]/.test(raw)) hunk.lines.push(raw)
391  }
392
393  // A deleted file is said in one line, not listed.
394  return out.map(f => (f.kind === 'deleted' ? { ...f, hunks: [] } : f))
395}
396
397// The worded lines of one change, for the transcript or a report.
398export function changeLines(change: FileChange, around: number): string[] {
399  return change.hunks.flatMap(h => trimContext(hunkEntries(h), around).flatMap(describeEntry))
400}
401
402// The text drawn in the transcript under an edit.
403export function rowText(change: FileChange, isSummaryOnly = false): string {
404  const head = summary(change)
405  if (isSummaryOnly || change.kind === 'emptied' || change.kind === 'rewrite' || change.kind === 'deleted') return head
406  const lines = changeLines(change, 0)
407  const changed = change.hunks.reduce((n, h) => n + hunkEntries(h).filter(e => e.kind !== 'context').length, 0)
408  if (changed > ROW_LINES || change.note) return `${head}\nType /changes to read all of it.`
409
410  return [head, ...lines].join('\n')
411}
412
413type FileGroup = { path: string; changes: FileChange[] }
414
415function groupByFile(changes: readonly FileChange[]): FileGroup[] {
416  const groups: FileGroup[] = []
417  for (const change of changes) {
418    const key = change.path.toLowerCase()
419    const group = groups.find(g => g.path.toLowerCase() === key)
420    if (group) group.changes.push(change)
421    else groups.push({ path: change.path, changes: [change] })
422  }
423
424  return groups
425}
426
427export function relativePath(path: string, cwd: string): string {
428  if (cwd === '') return path
429  const root = cwd.replace(/[\\/]+$/, '')
430  const isInside = path.toLowerCase().startsWith(`${root.toLowerCase()}\\`) || path.toLowerCase().startsWith(`${root.toLowerCase()}/`)
431
432  return isInside ? path.slice(root.length + 1) : path
433}
434
435function fileSummary(group: FileGroup): string {
436  const only = group.changes[0]!
437  if (group.changes.length === 1 && only.kind !== 'edit') return summary(only)
438  const counts = countText(countChanges(group.changes))
439  const created = group.changes.some(c => c.kind === 'create') ? 'New file. ' : ''
440  const how = group.changes.length > 1 ? `Changed in ${plural(group.changes.length, 'edit')}` : 'Edited'
441
442  return `${created}${how}: ${counts}.`
443}
444
445export type ReportOptions = { title: string; cwd: string; context: number; footer?: string[] }
446
447function changeCount(groups: readonly FileGroup[]): string {
448  return groups.map((g, i) => `${i + 1}, ${baseName(g.path)}, ${plural(g.changes.reduce((n, c) => n + Math.max(1, c.hunks.length), 0), 'change')}`).join('. ')
449}
450
451// The change report, as plain text: one line per thing said.
452export function reportText(changes: readonly FileChange[], options: ReportOptions): string {
453  const groups = groupByFile(changes)
454  const lines = [options.title]
455  lines.push(`${plural(groups.length, 'file')} changed: ${countText(countChanges(changes))}.`)
456  lines.push(`Files: ${changeCount(groups)}.`)
457  if (groups.some(g => g.changes.length > 1)) {
458    lines.push('Changes are listed in the order they were made. Line numbers are as they were at the time of each change.')
459  }
460  lines.push('Each file starts with a line beginning "File", and each change with a line beginning "Change".')
461
462  groups.forEach((group, fileIndex) => {
463    lines.push('')
464    const agent = group.changes.some(c => c.isAgent) ? ' Some of it was changed by a helper agent.' : ''
465    lines.push(`File ${fileIndex + 1} of ${groups.length}: ${relativePath(group.path, options.cwd)}. ${fileSummary(group)}${agent}`)
466    const hunks = group.changes.flatMap(c => c.hunks.map(h => ({ change: c, hunk: h })))
467    for (const change of group.changes) {
468      if (change.kind === 'emptied' || change.kind === 'rewrite' || change.kind === 'deleted') lines.push(summary(change))
469    }
470    hunks.forEach(({ change, hunk }, i) => {
471      const first = hunkEntries(hunk).find(e => e.kind !== 'context')
472      const near = first ? `, near line ${first.line}` : ''
473      const scope = hunk.scope ? `, in ${hunk.scope}` : ''
474      const what = change.kind === 'create' ? ' New file' : ''
475      lines.push(`Change ${i + 1} of ${hunks.length} in ${baseName(group.path)}${near}${scope}.${what ? `${what}, ${plural(hunk.newLines, 'line')}.` : ''}`)
476      const entries = trimContext(hunkEntries(hunk), options.context)
477      lines.push(...entries.flatMap(describeEntry))
478      lines.push(`End of change ${i + 1}.`)
479    })
480    for (const change of group.changes) if (change.note) lines.push(change.note)
481  })
482  if (options.footer) lines.push('', ...options.footer)
483
484  return lines.join('\n') + '\n'
485}
486
487function escapeHtml(text: string): string {
488  return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
489}
490
491function entryHtml(entry: Entry): string {
492  const code = (text: string) => `<code>${escapeHtml(text)}</code>`
493  switch (entry.kind) {
494    case 'context':
495      return `<li class="same">Unchanged line ${entry.line}: ${code(entry.text)}</li>`
496    case 'added':
497      return `<li>Added line ${entry.line}: <ins>${code(entry.text)}</ins></li>`
498    case 'removed':
499      return `<li>Removed, was line ${entry.line}: <del>${code(entry.text)}</del></li>`
500    case 'changed': {
501      const [head, ...rest] = describeEntry(entry)
502      if (rest.length === 0) return `<li>${escapeHtml(head!)}</li>`
503      const words = rest[2] ? `<br>${escapeHtml(rest[2])}` : ''
504      return `<li>${escapeHtml(head!)}<br>Before: <del>${code(entry.before)}</del><br>After: <ins>${code(entry.after)}</ins>${words}</li>`
505    }
506  }
507}
508
509// The same report as a web page: each file a heading level 2 and each
510// change a heading level 3, so a screen reader can jump between them.
511export function reportHtml(changes: readonly FileChange[], options: ReportOptions): string {
512  const groups = groupByFile(changes)
513  const body: string[] = [
514    `<h1>${escapeHtml(options.title)}</h1>`,
515    `<p>${escapeHtml(`${plural(groups.length, 'file')} changed: ${countText(countChanges(changes))}.`)}</p>`,
516    '<nav aria-label="Files"><ol>',
517    ...groups.map((g, i) => `<li><a href="#file-${i + 1}">${escapeHtml(relativePath(g.path, options.cwd))}</a></li>`),
518    '</ol></nav>',
519  ]
520  groups.forEach((group, fileIndex) => {
521    body.push(`<section><h2 id="file-${fileIndex + 1}">File ${fileIndex + 1} of ${groups.length}: ${escapeHtml(relativePath(group.path, options.cwd))}</h2>`)
522    body.push(`<p>${escapeHtml(fileSummary(group))}</p>`)
523    for (const change of group.changes) {
524      if (change.kind === 'emptied' || change.kind === 'rewrite' || change.kind === 'deleted') body.push(`<p>${escapeHtml(summary(change))}</p>`)
525    }
526    const hunks = group.changes.flatMap(c => c.hunks)
527    hunks.forEach((hunk, i) => {
528      const first = hunkEntries(hunk).find(e => e.kind !== 'context')
529      const near = first ? `, near line ${first.line}` : ''
530      const scope = hunk.scope ? `, in ${hunk.scope}` : ''
531      body.push(`<h3>Change ${i + 1} of ${hunks.length}${near}${escapeHtml(scope)}</h3><ul>`)
532      body.push(...trimContext(hunkEntries(hunk), options.context).map(entryHtml))
533      body.push('</ul>')
534    })
535    for (const change of group.changes) if (change.note) body.push(`<p>${escapeHtml(change.note)}</p>`)
536    body.push('</section>')
537  })
538  if (options.footer) body.push(...options.footer.map(line => `<p>${escapeHtml(line)}</p>`))
539
540  return [
541    '<!doctype html>',
542    '<html lang="en"><head><meta charset="utf-8">',
543    `<title>${escapeHtml(options.title)}</title>`,
544    '<style>',
545    ':root { --bg: #ffffff; --fg: #1a1a1a; --ins: #dff5e1; --del: #fbe3e3; color-scheme: light dark; }',
546    '@media (prefers-color-scheme: dark) { :root { --bg: #161616; --fg: #ececec; --ins: #173a1e; --del: #451a1a; } }',
547    'body { background: var(--bg); color: var(--fg); font: 16px/1.5 system-ui, sans-serif; max-width: 70rem; margin: 0 auto; padding: 1rem; }',
548    'code { white-space: pre-wrap; font-family: Consolas, monospace; }',
549    'ins { background: var(--ins); text-decoration: none; } del { background: var(--del); }',
550    'li { margin: 0.25rem 0; } .same { opacity: 0.8; }',
551    '</style></head><body><main>',
552    ...body,
553    '</main></body></html>',
554    '',
555  ].join('\n')
556}
557
558// The files changed, in the order first changed, each with its last change.
559export function filesOf(changes: readonly FileChange[]): FileChange[] {
560  return groupByFile(changes).map(g => g.changes[g.changes.length - 1]!)
561}
562
563// The first line a change touches in the new file, or 1.
564export function firstChangedLine(change: FileChange): number {
565  for (const hunk of change.hunks) {
566    const first = hunkEntries(hunk).find(e => e.kind !== 'context')
567    if (first) return Math.max(1, first.line)
568  }
569
570  return 1
571}
572
hooks/prompts.ts 214 lines
1// The accessible dialog for permission prompts and Claude's questions:
2// the question at the top in plain words, then one choice per line.
3// Pure functions: register.tsx opens the pane, waits for the answer and
4// speaks the choice the focus is on.
5
6import type { Dialog, DialogOption } from '../types'
7
8import { fileName } from './speech'
9
10// Tools whose own dialog must be answered by the person in Claude Code:
11// a hook's allow does not dismiss it, so these are left alone.
12export const ENGINE_ONLY_TOOLS = new Set(['AskUserQuestion', 'ExitPlanMode', 'EnterPlanMode'])
13
14const RESERVED = new Set(['tool', 'tool_use_id', 'consent', 'agentId', 'description'])
15
16// What can be typed in the box under the choices.
17export const INPUT_PERMISSION = 'Or type what Claude should do instead, and press Enter:'
18export const INPUT_QUESTION = 'Or type your own answer, and press Enter:'
19
20function field(input: unknown, key: string): string | undefined {
21  if (typeof input !== 'object' || input === null) return undefined
22  const value = (input as Record<string, unknown>)[key]
23
24  return typeof value === 'string' && value.trim() !== '' ? value : undefined
25}
26
27function short(text: string, max = 200): string {
28  const one = text.replace(/\s+/g, ' ').trim()
29
30  return one.length > max ? `${one.slice(0, max)}...` : one
31}
32
33function valueText(value: unknown): string {
34  if (typeof value === 'string') return short(value, 120)
35  if (typeof value === 'number' || typeof value === 'boolean') return String(value)
36  try {
37    return short(JSON.stringify(value), 120)
38  } catch {
39    return 'a value'
40  }
41}
42
43// A command of several lines, one line each, so none is hidden.
44function commandLines(command: string): string[] {
45  const lines = command.replace(/\r\n/g, '\n').split('\n').filter(l => l.trim() !== '')
46  if (lines.length <= 1) return [`Command: ${command.trim()}`]
47  const shown = lines.slice(0, 8).map((l, i) => `Command line ${i + 1}: ${l}`)
48
49  return lines.length > 8 ? [...shown, `And ${lines.length - 8} more lines.`] : shown
50}
51
52function inputLines(input: unknown): string[] {
53  if (typeof input !== 'object' || input === null) return []
54  const entries = Object.entries(input as Record<string, unknown>).filter(([k, v]) => !RESERVED.has(k) && v !== undefined)
55  const lines = entries.slice(0, 6).map(([k, v]) => `${k.replace(/_/g, ' ')}: ${valueText(v)}`)
56
57  return entries.length > 6 ? [...lines, `And ${entries.length - 6} more settings.`] : lines
58}
59
60// The question at the top of a permission dialog, in plain sentences.
61export function describeCall(tool: string, input: unknown, change?: string): string[] {
62  const description = field(input, 'description')
63  const what = description ? [`What it does: ${short(description)}`] : []
64  switch (tool) {
65    case 'Bash':
66    case 'PowerShell': {
67      const shell = tool === 'PowerShell' ? 'PowerShell command' : 'command'
68      return [`Claude wants to run a ${shell}.`, ...what, ...commandLines(field(input, 'command') ?? '')]
69    }
70    case 'Edit':
71    case 'Write':
72    case 'NotebookEdit': {
73      const path = field(input, 'file_path') ?? field(input, 'notebook_path') ?? ''
74      const verb = tool === 'Write' ? 'write' : 'edit'
75      return [`Claude wants to ${verb} ${fileName(path)}.`, `File: ${path}`, ...(change ? [change] : [])]
76    }
77    case 'WebFetch':
78      return ['Claude wants to fetch a web page.', `Address: ${field(input, 'url') ?? 'unknown'}`]
79    case 'WebSearch':
80      return ['Claude wants to search the web.', `Search: ${field(input, 'query') ?? 'unknown'}`]
81    case 'Agent':
82    case 'Task':
83      return [`Claude wants to start a helper agent: ${short(description ?? field(input, 'prompt') ?? 'a task', 120)}.`]
84    default: {
85      const mcp = /^mcp__(.+?)__(.+)$/.exec(tool)
86      const head = mcp
87        ? `Claude wants to use ${mcp[2]!.replace(/_/g, ' ')} from ${mcp[1]!.replace(/_/g, ' ')}.`
88        : `Claude wants to use the ${tool} tool.`
89      return [head, ...what, ...inputLines(input)]
90    }
91  }
92}
93
94// What "don't ask again" remembers for the rest of the session.
95export function allowKey(tool: string, input: unknown): string {
96  if (tool === 'Edit' || tool === 'Write' || tool === 'NotebookEdit') return 'edits'
97  if (tool === 'Bash' || tool === 'PowerShell') return `${tool}:${(field(input, 'command') ?? '').trim()}`
98
99  return tool
100}
101
102function allowAgainLabel(tool: string): string {
103  if (tool === 'Edit' || tool === 'Write' || tool === 'NotebookEdit') return 'Yes, and allow all file edits for the rest of this session'
104  if (tool === 'Bash' || tool === 'PowerShell') return 'Yes, and allow this exact command for the rest of this session'
105  const mcp = /^mcp__(.+?)__(.+)$/.exec(tool)
106
107  return `Yes, and allow ${mcp ? mcp[2]!.replace(/_/g, ' ') : tool} for the rest of this session`
108}
109
110function numbered(options: readonly { key: string; label: string }[]): DialogOption[] {
111  return options.map((o, i) => ({ ...o, spoken: `${o.label}. ${i + 1} of ${options.length}.` }))
112}
113
114export function permissionDialog(id: string, tool: string, input: unknown, reason: string | undefined, change: string | undefined): Dialog {
115  const why = reason && reason.trim() !== '' ? [`Why it asks: ${short(reason)}`] : []
116  const options = [
117    { key: 'yes', label: 'Yes' },
118    { key: 'always', label: allowAgainLabel(tool) },
119    ...(change ? [{ key: 'read', label: 'Read the change in your editor first' }] : []),
120    { key: 'no', label: 'No' },
121    { key: 'engine', label: "Show Claude Code's own prompt instead" },
122  ]
123
124  return {
125    id,
126    title: 'Permission needed.',
127    lines: [...describeCall(tool, input, change), ...why, 'Allow it?'],
128    options: numbered(options),
129    input: { label: INPUT_PERMISSION },
130  }
131}
132
133export type Question = {
134  question: string
135  header: string
136  options: { label: string; description?: string }[]
137  multiSelect: boolean
138}
139
140// The questions of an AskUserQuestion call, or null when they are not in
141// the shape the dialog can show.
142export function parseQuestions(input: unknown): Question[] | null {
143  if (typeof input !== 'object' || input === null) return null
144  const list = (input as Record<string, unknown>).questions
145  if (!Array.isArray(list) || list.length === 0) return null
146  const out: Question[] = []
147  for (const item of list) {
148    if (typeof item !== 'object' || item === null) return null
149    const q = item as Record<string, unknown>
150    if (typeof q.question !== 'string' || !Array.isArray(q.options) || q.options.length === 0) return null
151    const options = q.options.map(o => (typeof o === 'object' && o !== null ? (o as Record<string, unknown>) : {}))
152    if (options.some(o => typeof o.label !== 'string')) return null
153    out.push({
154      question: q.question,
155      header: typeof q.header === 'string' ? q.header : '',
156      options: options.map(o => ({ label: String(o.label), ...(typeof o.description === 'string' && o.description !== '' ? { description: o.description } : {}) })),
157      multiSelect: q.multiSelect === true,
158    })
159  }
160
161  return out
162}
163
164export function questionDialog(id: string, questions: readonly Question[], index: number, selected: ReadonlySet<number>): Dialog {
165  const q = questions[index]!
166  const header = q.header ? `, ${q.header}` : ''
167  // The question is in the title, so the stop at the top shows and says it.
168  const title = `${questions.length > 1 ? `Question ${index + 1} of ${questions.length}${header}.` : `Claude has a question${header}.`} ${q.question}`
169  const label = (o: Question['options'][number]) => (o.description ? `${o.label}: ${o.description}` : o.label)
170  const options = q.multiSelect
171    ? [
172        ...q.options.map((o, i) => ({ key: `opt-${i}`, label: `${selected.has(i) ? 'Chosen' : 'Not chosen'}, ${label(o)}` })),
173        { key: 'done', label: selected.size === 0 ? 'Done, with nothing chosen' : `Done, send ${selected.size === 1 ? 'this choice' : `these ${selected.size} choices`}` },
174      ]
175    : q.options.map((o, i) => ({ key: `opt-${i}`, label: label(o) }))
176  options.push({ key: 'engine', label: "Show Claude Code's own question instead" })
177  const hint = q.multiSelect ? 'Choose as many as apply. Pressing a choice turns it on or off.' : ''
178
179  return { id, title, lines: hint ? [hint] : [], options: numbered(options), input: { label: INPUT_QUESTION } }
180}
181
182// What is spoken when a dialog opens: the question and every choice.
183export function dialogSpeech(dialog: Dialog): string {
184  const choices = dialog.options.map((o, i) => `${i + 1}, ${o.label}.`).join(' ')
185
186  return `${dialog.title} ${dialog.lines.join(' ')} ${dialog.options.length} choices: ${choices} Use up and down arrow and Enter, or press a number. Up arrow from the first choice reads the question again.`
187}
188
189// The answer to one question, as AskUserQuestion's result keeps it.
190export function answerOf(question: Question, chosen: readonly number[]): string {
191  return chosen.map(i => question.options[i]?.label ?? '').filter(l => l !== '').join(', ')
192}
193
194// The key of the line at the top of the dialog that holds the question, so
195// the arrows can reach it and Enter on it says the whole dialog again.
196export const QUESTION_KEY = 'question'
197
198// What is spoken when the focus is on the question.
199export function questionSpeech(dialog: Dialog): string {
200  return `${dialog.title} ${dialog.lines.join(' ')} Press Enter to hear the choices again.`
201}
202
203// The dialog's stops in the order the arrows move through them.
204export function focusOrder(dialog: Dialog, hasInput: boolean): string[] {
205  return [QUESTION_KEY, ...dialog.options.map(o => o.key), ...(dialog.input && hasInput ? ['typed'] : [])]
206}
207
208// Where the focus goes instead of one of Claude Code's own stops in the pane,
209// such as its close mark: off the last stop it wraps to the top, off any
210// other it wraps to the bottom.
211export function wrapFocus(order: readonly string[], last: string | null): string {
212  return last === order[order.length - 1] ? order[0]! : order[order.length - 1]!
213}
214
hooks/menu.ts 343 lines
1// The numbered settings menu: what each screen shows, and what a typed
2// number does there. register.tsx keeps which screen is open, applies the
3// changes and plays or speaks what an answer asks for.
4
5import { CLEAN_MODES, ENGINES, LEVELS, SOUND_EVENTS, clamp, editorName } from './settings'
6import type { CleanMode, Settings, SoundEvent, SpeechEngine } from './settings'
7import { EVENT_WORDS } from './sounds'
8import type { SoundFile } from './sounds'
9import type { Voice } from './speech'
10
11export type Screen =
12  | { kind: 'main' }
13  | { kind: 'level' }
14  | { kind: 'engine' }
15  | { kind: 'voice' }
16  | { kind: 'rate' }
17  | { kind: 'volume' }
18  | { kind: 'clean' }
19  | { kind: 'sounds' }
20  | { kind: 'all-sounds' }
21  | { kind: 'event'; event: SoundEvent }
22  | { kind: 'changes' }
23  | { kind: 'prompts' }
24  | { kind: 'reset' }
25
26// What some screens need beyond the settings: the installed voices (null
27// where the speech server cannot list them) and an event's sound files.
28export type MenuContext = {
29  settings: Settings
30  engine: string
31  voices: readonly Voice[] | null
32  files: readonly SoundFile[]
33}
34
35export type MenuAnswer =
36  | { kind: 'open'; screen: Screen; message?: string }
37  | { kind: 'set'; change: Partial<Settings>; message: string; screen: Screen; play?: SoundFile }
38  | { kind: 'reset'; screen: Screen }
39  | { kind: 'changes-report' }
40  | { kind: 'close' }
41
42const LEVEL_WORDS: Record<Settings['level'], string> = {
43  all: 'replies, every tool and its result',
44  most: 'replies and actions such as edits and commands',
45  some: 'the final answer only',
46  none: 'nothing',
47}
48
49const ENGINE_WORDS: Record<SpeechEngine, string> = {
50  auto: 'NVDA while it is running, otherwise the Windows voice',
51  nvda: 'NVDA only, in its own voice and settings',
52  windows: 'the Windows voice only',
53}
54
55const CLEAN_WORDS: Record<CleanMode, string> = {
56  on: 'remove symbols and emoji from what is shown',
57  off: 'show output unchanged',
58  words: 'remove symbols, and replace emoji with their names',
59}
60
61// The editors the Changes screen steps through, in order.
62const EDITORS = ['auto', 'notepad++', 'notepad']
63
64const RATE_STEP = 1
65const VOLUME_STEP = 10
66
67// A menu choice: a whole number on its own, nothing else.
68export function parseChoice(text: string): number | null {
69  const trimmed = text.trim()
70
71  return /^\d{1,3}$/.test(trimmed) ? Number(trimmed) : null
72}
73
74function onOff(isOn: boolean): string {
75  return isOn ? 'on' : 'off'
76}
77
78function current(label: string, isCurrent: boolean): string {
79  return isCurrent ? `${label}, current` : label
80}
81
82function eventSummary(settings: Settings, event: SoundEvent): string {
83  const choice = settings.soundEvents[event]
84  const pick = choice.pick === 'random' ? 'random sound' : choice.pick
85
86  return `${onOff(choice.isOn)}, ${pick}`
87}
88
89// The voices offered: the installed ones, numbered as /voices numbers them,
90// then the system default last.
91function voiceItems(ctx: MenuContext): string[] {
92  const voices = ctx.voices ?? []
93  const chosen = ctx.settings.voice
94  const items = voices.map(v => current(v.culture ? `${v.name}, ${v.culture}` : v.name, v.name === chosen))
95
96  return [...items, current('System default', chosen === null)]
97}
98
99function screenParts(screen: Screen, ctx: MenuContext): { title: string; items: string[] } {
100  const s = ctx.settings
101  switch (screen.kind) {
102    case 'main':
103      return {
104        title: `Blind Claude settings. Speech engine: ${ctx.engine}.`,
105        items: [
106          `Speech level, ${s.level}`,
107          `Speech engine, ${s.engine}`,
108          `Voice, ${s.voice ?? 'system default'}`,
109          `Rate, ${s.rate}`,
110          `Volume, ${s.volume}`,
111          `Symbol cleaning, ${s.clean}`,
112          `Sounds, ${onOff(s.sounds)}`,
113          `Changes, opened in ${editorName(s.changes.editor)}`,
114          `Permission prompts and questions, ${s.prompts ? "Blind Claude's dialog" : "Claude Code's own"}`,
115          'Reset all settings to defaults',
116        ],
117      }
118    case 'level':
119      return {
120        title: `Speech level, now ${s.level}. How much is spoken:`,
121        items: LEVELS.map(level => current(`${level}: ${LEVEL_WORDS[level]}`, level === s.level)),
122      }
123    case 'engine':
124      return {
125        title: `Speech engine, now ${s.engine}. Voice, rate and volume apply to the Windows voice; NVDA uses its own.`,
126        items: ENGINES.map(engine => current(`${engine}: ${ENGINE_WORDS[engine]}`, engine === s.engine)),
127      }
128    case 'voice':
129      return { title: `Voice, now ${s.voice ?? 'system default'}.`, items: voiceItems(ctx) }
130    case 'rate':
131      return {
132        title: `Rate, now ${s.rate}, from -10 to 10.`,
133        items: ['Faster', 'Slower', current('Normal, 0', s.rate === 0)],
134      }
135    case 'volume':
136      return {
137        title: `Volume, now ${s.volume}, from 0 to 100.`,
138        items: ['Louder', 'Quieter', current('Full, 100', s.volume === 100)],
139      }
140    case 'clean':
141      return {
142        title: `Symbol cleaning, now ${s.clean}.`,
143        items: CLEAN_MODES.map(mode => current(`${mode}: ${CLEAN_WORDS[mode]}`, mode === s.clean)),
144      }
145    case 'sounds':
146      return {
147        title: 'Sounds.',
148        items: [
149          `All sounds, ${onOff(s.sounds)}`,
150          ...SOUND_EVENTS.map(event => `${event} sound, ${EVENT_WORDS[event]}: ${eventSummary(s, event)}`),
151        ],
152      }
153    case 'all-sounds':
154      return { title: `All sounds, now ${onOff(s.sounds)}.`, items: [current('On', s.sounds), current('Off', !s.sounds)] }
155    case 'event': {
156      const choice = s.soundEvents[screen.event]
157      const files = ctx.files.map(f => {
158        const mine = f.isCustom ? ', added by you' : ''
159        return current(`${f.name}${mine}`, choice.pick.toLowerCase() === f.name.toLowerCase())
160      })
161      return {
162        title: `${screen.event} sound, ${EVENT_WORDS[screen.event]}. Now ${eventSummary(s, screen.event)}. Choosing a sound plays it.`,
163        items: [current('On', choice.isOn), current('Off', !choice.isOn), current('Random sound each time', choice.pick === 'random'), ...files],
164      }
165    }
166    case 'changes': {
167      const c = s.changes
168      return {
169        title: 'Changes: the report of what Claude changed in your files.',
170        items: [
171          'Open the change report for the last turn',
172          `Open it by itself after each turn that changes files, ${onOff(c.isAuto)}`,
173          `Open the change before you approve an edit, ${onOff(c.isPreview)}`,
174          `Editor, ${editorName(c.editor)}. Choosing this moves to the next editor`,
175          `View, ${c.view === 'browser' ? 'web page' : 'text'}. Choosing this switches between text and web page`,
176          `Unchanged lines around each change, ${c.context}. Choosing this adds one, up to 5, then goes back to 0`,
177          `Edits in the transcript, ${c.isSummaryOnly ? 'summary only' : 'summary and changed lines'}`,
178        ],
179      }
180    }
181    case 'prompts':
182      return {
183        title: `Permission prompts and questions, now ${s.prompts ? "Blind Claude's dialog" : "Claude Code's own"}.`,
184        items: [
185          current("Blind Claude's dialog: the question first, then one choice per line, spoken as you move", s.prompts),
186          current("Claude Code's own prompts", !s.prompts),
187        ],
188      }
189    case 'reset':
190      return { title: 'Reset every Blind Claude setting to its default?', items: ['Yes, reset', 'No'] }
191  }
192}
193
194export function screenText(screen: Screen, ctx: MenuContext): string {
195  const { title, items } = screenParts(screen, ctx)
196  const isMain = screen.kind === 'main'
197  const zero = isMain ? '0. Close the menu.' : '0. Back.'
198  const footer = isMain
199    ? 'Type a number and press Enter. Anything else you type goes to Claude as usual.'
200    : 'Type a number and press Enter.'
201
202  return [title, ...items.map((item, i) => `${i + 1}. ${item}.`), zero, footer].join('\n')
203}
204
205// Where 0 leads from each screen.
206function parent(screen: Screen): Screen | null {
207  if (screen.kind === 'main') return null
208  if (screen.kind === 'all-sounds' || screen.kind === 'event') return { kind: 'sounds' }
209
210  return { kind: 'main' }
211}
212
213const MAIN_SCREENS: readonly Screen[] = [
214  { kind: 'level' },
215  { kind: 'engine' },
216  { kind: 'voice' },
217  { kind: 'rate' },
218  { kind: 'volume' },
219  { kind: 'clean' },
220  { kind: 'sounds' },
221  { kind: 'changes' },
222  { kind: 'prompts' },
223  { kind: 'reset' },
224]
225
226// What typing `choice` on `screen` does. An option that is not there leaves
227// the screen open and says so.
228export function choose(screen: Screen, choice: number, ctx: MenuContext): MenuAnswer {
229  const s = ctx.settings
230  const main: Screen = { kind: 'main' }
231  const sounds: Screen = { kind: 'sounds' }
232  if (choice === 0) {
233    const back = parent(screen)
234    return back === null ? { kind: 'close' } : { kind: 'open', screen: back }
235  }
236  const count = screenParts(screen, ctx).items.length
237  if (choice > count) return { kind: 'open', screen, message: `There is no option ${choice}.` }
238  const index = choice - 1
239
240  switch (screen.kind) {
241    case 'main': {
242      const next = MAIN_SCREENS[index]!
243      if (next.kind === 'voice' && ctx.voices === null) {
244        return {
245          kind: 'open',
246          screen,
247          message: 'The voice list is only available on Windows while the speech server is running. Try again in a moment.',
248        }
249      }
250      return { kind: 'open', screen: next }
251    }
252    case 'level': {
253      const level = LEVELS[index]!
254      return { kind: 'set', change: { level }, message: `Speech level set to ${level}.`, screen: main }
255    }
256    case 'engine': {
257      const engine = ENGINES[index]!
258      return { kind: 'set', change: { engine }, message: `Speech engine set to ${engine}.`, screen: main }
259    }
260    case 'voice': {
261      const voice = (ctx.voices ?? [])[index]
262      if (voice === undefined) return { kind: 'set', change: { voice: null }, message: 'Using the system default voice.', screen: main }
263      return { kind: 'set', change: { voice: voice.name }, message: `Voice changed to ${voice.name}.`, screen: main }
264    }
265    case 'rate': {
266      const rate = index === 0 ? clamp(s.rate + RATE_STEP, -10, 10) : index === 1 ? clamp(s.rate - RATE_STEP, -10, 10) : 0
267      return { kind: 'set', change: { rate }, message: `Rate ${rate}.`, screen }
268    }
269    case 'volume': {
270      const volume = index === 0 ? clamp(s.volume + VOLUME_STEP, 0, 100) : index === 1 ? clamp(s.volume - VOLUME_STEP, 0, 100) : 100
271      return { kind: 'set', change: { volume }, message: `Volume ${volume}.`, screen }
272    }
273    case 'clean': {
274      const clean = CLEAN_MODES[index]!
275      return { kind: 'set', change: { clean }, message: `Symbol cleaning set to ${clean}.`, screen: main }
276    }
277    case 'sounds':
278      return index === 0 ? { kind: 'open', screen: { kind: 'all-sounds' } } : { kind: 'open', screen: { kind: 'event', event: SOUND_EVENTS[index - 1]! } }
279    case 'all-sounds': {
280      const isOn = index === 0
281      return { kind: 'set', change: { sounds: isOn }, message: `All sounds turned ${onOff(isOn)}.`, screen: sounds }
282    }
283    case 'event': {
284      const event = screen.event
285      const was = s.soundEvents[event]
286      const setEvent = (change: Partial<typeof was>) => ({ soundEvents: { ...s.soundEvents, [event]: { ...was, ...change } } })
287      if (index < 2) {
288        const isOn = index === 0
289        return { kind: 'set', change: setEvent({ isOn }), message: `${event} sound turned ${onOff(isOn)}.`, screen: sounds }
290      }
291      if (index === 2) {
292        return { kind: 'set', change: setEvent({ pick: 'random' }), message: `${event} will play a random sound each time.`, screen: sounds }
293      }
294      const file = ctx.files[index - 3]!
295      return { kind: 'set', change: setEvent({ pick: file.name }), message: `${event} will always play ${file.name}.`, screen: sounds, play: file }
296    }
297    case 'changes': {
298      const c = s.changes
299      const set = (change: Partial<Settings['changes']>, message: string): MenuAnswer => ({
300        kind: 'set',
301        change: { changes: { ...c, ...change } },
302        message,
303        screen,
304      })
305      switch (index) {
306        case 0:
307          return { kind: 'changes-report' }
308        case 1:
309          return set({ isAuto: !c.isAuto }, `Opening after each turn turned ${onOff(!c.isAuto)}.`)
310        case 2:
311          return set({ isPreview: !c.isPreview }, `Opening before you approve an edit turned ${onOff(!c.isPreview)}.`)
312        case 3: {
313          const editor = EDITORS[(EDITORS.indexOf(c.editor) + 1) % EDITORS.length]!
314          return set({ editor }, `Editor set to ${editorName(editor)}.`)
315        }
316        case 4: {
317          const view = c.view === 'browser' ? 'text' : 'browser'
318          return set({ view }, `View set to ${view === 'browser' ? 'web page' : 'text'}.`)
319        }
320        case 5: {
321          const context = c.context >= 5 ? 0 : c.context + 1
322          return set({ context }, `${context} unchanged lines around each change.`)
323        }
324        default:
325          return set({ isSummaryOnly: !c.isSummaryOnly }, c.isSummaryOnly ? 'Edits in the transcript list their changed lines.' : 'Edits in the transcript show a summary only.')
326      }
327    }
328    case 'prompts': {
329      const prompts = index === 0
330      return {
331        kind: 'set',
332        change: { prompts },
333        message: prompts ? "Permission prompts and questions use Blind Claude's dialog." : "Permission prompts and questions use Claude Code's own.",
334        screen: main,
335      }
336    }
337    case 'reset':
338      return index === 0 ? { kind: 'reset', screen: main } : { kind: 'open', screen: main }
339  }
340}
341
342export const RESET_MESSAGE = 'All Blind Claude settings are back to their defaults.'
343
hooks/settings.ts 135 lines
1export type Level = 'all' | 'most' | 'some' | 'none'
2
3// The moments a sound can mark. "start": Claude starts working.
4// "attention": Claude needs permission or has a question.
5export const SOUND_EVENTS = ['done', 'start', 'attention', 'error'] as const
6export type SoundEvent = (typeof SOUND_EVENTS)[number]
7
8// "random" plays a random sound for the event; otherwise the file name to play.
9export type SoundChoice = { isOn: boolean; pick: string }
10export type CleanMode = 'on' | 'off' | 'words'
11
12// Who speaks. "auto": NVDA while it runs, else the Windows voices.
13export type SpeechEngine = 'auto' | 'nvda' | 'windows'
14
15// The change report: which editor opens it, as text or a web page, how
16// many unchanged lines are shown around a change, whether it opens by
17// itself after a turn or before an edit is approved, and whether edits in
18// the transcript list their lines or only a summary.
19export type ChangesView = 'text' | 'browser'
20export type ChangeSettings = {
21  editor: string
22  view: ChangesView
23  context: number
24  isAuto: boolean
25  isPreview: boolean
26  isSummaryOnly: boolean
27}
28
29export type Settings = {
30  level: Level
31  engine: SpeechEngine
32  voice: string | null
33  rate: number
34  volume: number
35  clean: CleanMode
36  sounds: boolean
37  soundEvents: Record<SoundEvent, SoundChoice>
38  changes: ChangeSettings
39  // Permission prompts and Claude's questions in Blind Claude's own dialog.
40  prompts: boolean
41}
42
43export const LEVELS: readonly Level[] = ['all', 'most', 'some', 'none']
44export const CLEAN_MODES: readonly CleanMode[] = ['on', 'off', 'words']
45export const ENGINES: readonly SpeechEngine[] = ['auto', 'nvda', 'windows']
46
47export const DEFAULTS: Settings = {
48  level: 'some',
49  engine: 'auto',
50  voice: null,
51  rate: 0,
52  volume: 100,
53  clean: 'on',
54  sounds: true,
55  soundEvents: {
56    done: { isOn: true, pick: 'random' },
57    start: { isOn: true, pick: 'random' },
58    attention: { isOn: true, pick: 'random' },
59    error: { isOn: true, pick: 'random' },
60  },
61  changes: { editor: 'auto', view: 'text', context: 2, isAuto: false, isPreview: false, isSummaryOnly: false },
62  prompts: true,
63}
64
65export const SETTINGS_KEY = 'settings'
66
67// Accepts only known keys with valid values from whatever the store held.
68export function sanitize(value: unknown): Partial<Settings> {
69  if (typeof value !== 'object' || value === null) return {}
70  const v = value as Record<string, unknown>
71  const out: Partial<Settings> = {}
72  if (LEVELS.includes(v.level as Level)) out.level = v.level as Level
73  if (ENGINES.includes(v.engine as SpeechEngine)) out.engine = v.engine as SpeechEngine
74  if (typeof v.voice === 'string' || v.voice === null) out.voice = v.voice as string | null
75  if (typeof v.rate === 'number') out.rate = clamp(v.rate, -10, 10)
76  if (typeof v.volume === 'number') out.volume = clamp(v.volume, 0, 100)
77  if (CLEAN_MODES.includes(v.clean as CleanMode)) out.clean = v.clean as CleanMode
78  if (typeof v.sounds === 'boolean') out.sounds = v.sounds
79  if (typeof v.soundEvents === 'object' && v.soundEvents !== null) {
80    const saved = v.soundEvents as Record<string, unknown>
81    const events = { ...DEFAULTS.soundEvents }
82    for (const event of SOUND_EVENTS) {
83      const one = saved[event]
84      if (typeof one !== 'object' || one === null) continue
85      const { isOn, pick } = one as Record<string, unknown>
86      events[event] = {
87        isOn: typeof isOn === 'boolean' ? isOn : events[event].isOn,
88        pick: typeof pick === 'string' && pick !== '' ? pick : events[event].pick,
89      }
90    }
91    out.soundEvents = events
92  }
93  if (typeof v.changes === 'object' && v.changes !== null) {
94    const c = v.changes as Record<string, unknown>
95    const d = DEFAULTS.changes
96    out.changes = {
97      editor: typeof c.editor === 'string' && c.editor !== '' ? c.editor : d.editor,
98      view: c.view === 'browser' || c.view === 'text' ? c.view : d.view,
99      context: typeof c.context === 'number' ? clamp(c.context, 0, 5) : d.context,
100      isAuto: typeof c.isAuto === 'boolean' ? c.isAuto : d.isAuto,
101      isPreview: typeof c.isPreview === 'boolean' ? c.isPreview : d.isPreview,
102      isSummaryOnly: typeof c.isSummaryOnly === 'boolean' ? c.isSummaryOnly : d.isSummaryOnly,
103    }
104  }
105  if (typeof v.prompts === 'boolean') out.prompts = v.prompts
106
107  return out
108}
109
110export function clamp(n: number, low: number, high: number): number {
111  return Math.min(high, Math.max(low, Math.round(n)))
112}
113
114export function describeSettings(s: Settings): string {
115  return [
116    `Speech level: ${s.level}.`,
117    `Speech engine setting: ${s.engine}.`,
118    `Voice: ${s.voice ?? 'system default'}.`,
119    `Rate: ${s.rate}.`,
120    `Volume: ${s.volume}.`,
121    `Symbol cleaning: ${s.clean}.`,
122    `Sounds: ${s.sounds ? 'on' : 'off'}.`,
123    `Change reports: ${editorName(s.changes.editor)}, ${s.changes.view === 'browser' ? 'as a web page' : 'as text'}, ${s.changes.isAuto ? 'opened after each turn' : 'opened with /changes'}.`,
124    `Permission prompts and questions: ${s.prompts ? "Blind Claude's dialog" : "Claude Code's own"}.`,
125  ].join('\n')
126}
127
128export function editorName(editor: string): string {
129  if (editor === 'auto') return 'Notepad++ if installed, else Notepad'
130  if (editor === 'notepad++') return 'Notepad++'
131  if (editor === 'notepad') return 'Notepad'
132
133  return editor
134}
135
hooks/sounds.ts 100 lines
1// Pure helpers for sound cues: picking a file, and the text the /sounds
2// command shows. register.tsx finds the files and plays them.
3
4import { SOUND_EVENTS } from './settings'
5import type { Settings, SoundChoice, SoundEvent } from './settings'
6
7export type SoundFile = { name: string; path: string; isCustom: boolean }
8
9export const EVENT_WORDS: Record<SoundEvent, string> = {
10  done: 'when Claude finishes',
11  start: 'when Claude starts working',
12  attention: 'when Claude needs permission or has a question',
13  error: 'when a turn stops with an error',
14}
15
16export function isSoundEvent(word: string): word is SoundEvent {
17  return (SOUND_EVENTS as readonly string[]).includes(word)
18}
19
20export function isPlayable(name: string): boolean {
21  return /\.wav$/i.test(name)
22}
23
24// Built in sounds first, then the person's own, each group by name.
25export function sortSounds(files: readonly SoundFile[]): SoundFile[] {
26  return [...files].sort((a, b) => Number(a.isCustom) - Number(b.isCustom) || a.name.localeCompare(b.name))
27}
28
29// The file to play for an event, or undefined when there is none.
30// A picked file that has gone missing falls back to a random one.
31export function chooseSound(files: readonly SoundFile[], pick: string, random: number): SoundFile | undefined {
32  if (files.length === 0) return undefined
33  if (pick !== 'random') {
34    const picked = files.find(f => f.name.toLowerCase() === pick.toLowerCase())
35    if (picked) return picked
36  }
37
38  return files[Math.min(files.length - 1, Math.floor(random * files.length))]
39}
40
41// Finds a sound by its number in the list (from 1) or by part of its name.
42export function findSound(files: readonly SoundFile[], query: string): SoundFile[] {
43  const q = query.trim().toLowerCase()
44  if (/^\d+$/.test(q)) {
45    const one = files[Number(q) - 1]
46    return one ? [one] : []
47  }
48  const exact = files.filter(f => f.name.toLowerCase() === q)
49  if (exact.length > 0) return exact
50
51  return files.filter(f => f.name.toLowerCase().includes(q))
52}
53
54function choiceWords(choice: SoundChoice): string {
55  const state = choice.isOn ? 'on' : 'off'
56  const pick = choice.pick === 'random' ? 'random sound' : `always ${choice.pick}`
57
58  return `${state}, ${pick}`
59}
60
61export function overviewText(settings: Settings, counts: Record<SoundEvent, number>): string {
62  const lines = SOUND_EVENTS.map(event => {
63    const count = counts[event]
64    const files = count === 0 ? 'no sounds yet' : count === 1 ? '1 sound' : `${count} sounds`
65    return `${event}, ${EVENT_WORDS[event]}: ${choiceWords(settings.soundEvents[event])}, ${files}.`
66  })
67
68  return [
69    `All sounds are ${settings.sounds ? 'on' : 'off'}.`,
70    ...lines,
71    'Type /sounds help for the sound commands.',
72  ].join('\n')
73}
74
75export function listText(event: SoundEvent, files: readonly SoundFile[], choice: SoundChoice): string {
76  if (files.length === 0) {
77    return `No ${event} sounds yet. Add one with /sounds add ${event} followed by the path to a sound file.`
78  }
79  const lines = files.map((f, i) => {
80    const mine = f.isCustom ? ', added by you' : ''
81    const chosen = choice.pick.toLowerCase() === f.name.toLowerCase() ? '. Chosen' : ''
82    return `${i + 1}. ${f.name}${mine}${chosen}.`
83  })
84
85  return [`${event} sounds, ${choiceWords(choice)}:`, ...lines].join('\n')
86}
87
88export const SOUNDS_HELP = [
89  'Sound commands. Events are done, start, attention and error.',
90  '/sounds: overview of every event.',
91  '/sounds on or off: all sounds on or off.',
92  '/sounds done on or off: one event on or off.',
93  '/sounds list done: list the sounds for an event.',
94  '/sounds play done 2: play a sound. Leave out the number for the one that would play.',
95  '/sounds pick done 2: always use that sound. /sounds pick done random: a random one each time.',
96  '/sounds add done C:\\path\\to\\sound.ogg: add your own sound. WAV works always; other formats need ffmpeg.',
97  '/sounds remove done 6: remove a sound you added.',
98  '/sounds folder: where your own sounds are kept.',
99].join('\n')
100
hooks/speech.ts 196 lines
1// Pure helpers for speech: what to say about tools, and which events each
2// speech level speaks. No engine calls here; register.tsx does the talking.
3
4import type { Level } from './settings'
5
6// Tools that change something or reach outside. At the "most" level only
7// these are announced; reads and searches are left out to keep it short.
8const ACTION_TOOLS = new Set([
9  'Bash',
10  'PowerShell',
11  'Edit',
12  'MultiEdit',
13  'Write',
14  'NotebookEdit',
15  'Agent',
16  'Task',
17  'WebFetch',
18  'WebSearch',
19])
20
21export function fileName(path: unknown): string {
22  if (typeof path !== 'string' || path === '') return 'a file'
23  const parts = path.split(/[\\/]/)
24
25  return parts[parts.length - 1] || path
26}
27
28function field(input: unknown, key: string): string | undefined {
29  if (typeof input !== 'object' || input === null) return undefined
30  const value = (input as Record<string, unknown>)[key]
31
32  return typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined
33}
34
35function short(text: string, max = 80): string {
36  const one = text.replace(/\s+/g, ' ').trim()
37
38  return one.length > max ? `${one.slice(0, max)}...` : one
39}
40
41function host(url: string | undefined): string {
42  if (!url) return 'a web page'
43  const match = /^[a-z]+:\/\/(?:www\.)?([^/:?#]+)/i.exec(url)
44
45  return match?.[1] ?? 'a web page'
46}
47
48// A plain sentence for a tool call, such as "Editing register.ts".
49export function describeToolCall(tool: string, input: unknown): string {
50  switch (tool) {
51    case 'Bash':
52    case 'PowerShell': {
53      const description = field(input, 'description')
54      const command = field(input, 'command')
55      return description ? `Running command: ${short(description)}` : `Running command: ${short(command ?? 'unknown')}`
56    }
57    case 'Read':
58      return `Reading ${fileName(field(input, 'file_path'))}`
59    case 'Edit':
60    case 'MultiEdit':
61      return `Editing ${fileName(field(input, 'file_path'))}`
62    case 'Write':
63      return `Writing ${fileName(field(input, 'file_path'))}`
64    case 'NotebookEdit':
65      return `Editing notebook ${fileName(field(input, 'notebook_path'))}`
66    case 'Glob':
67      return `Finding files matching ${short(field(input, 'pattern') ?? 'a pattern', 40)}`
68    case 'Grep':
69      return `Searching for ${short(field(input, 'pattern') ?? 'text', 40)}`
70    case 'WebFetch':
71      return `Fetching ${host(field(input, 'url'))}`
72    case 'WebSearch':
73      return `Searching the web for ${short(field(input, 'query') ?? 'something', 60)}`
74    case 'Agent':
75    case 'Task':
76      return `Starting a helper agent: ${short(field(input, 'description') ?? 'task', 60)}`
77    case 'TodoWrite':
78    case 'TaskCreate':
79    case 'TaskUpdate':
80      return 'Updating the task list'
81    case 'Skill':
82      return `Using skill ${field(input, 'skill') ?? ''}`.trim()
83    case 'AskUserQuestion':
84      return 'Claude has a question for you'
85    default: {
86      const mcp = /^mcp__(.+?)__(.+)$/.exec(tool)
87      if (mcp) return `Using ${mcp[2]!.replace(/_/g, ' ')} from ${mcp[1]!.replace(/_/g, ' ')}`
88      return `Using tool ${tool}`
89    }
90  }
91}
92
93// What to say once a tool call has finished, at the "all" level.
94export function describeToolResult(tool: string, isError: boolean): string {
95  const name = tool.startsWith('mcp__') ? 'Tool' : tool
96
97  return isError ? `${name} failed.` : `${name} finished.`
98}
99
100export function speaksToolCall(level: Level, tool: string): boolean {
101  if (level === 'all') return true
102  if (level === 'most') return ACTION_TOOLS.has(tool)
103
104  return false
105}
106
107export function speaksToolResult(level: Level): boolean {
108  return level === 'all'
109}
110
111// "all" and "most" speak reply text as each block arrives.
112export function speaksReplyBlocks(level: Level): boolean {
113  return level === 'all' || level === 'most'
114}
115
116// "some" speaks the final answer once the turn ends.
117export function speaksFinalAnswer(level: Level): boolean {
118  return level === 'some'
119}
120
121// Errors and permission requests are spoken at every level but "none".
122export function speaksAlerts(level: Level): boolean {
123  return level !== 'none'
124}
125
126// File name for one command in the speech server's spool folder.
127// Sorting by name must give the order the commands were sent in.
128export function spoolName(now: number, seq: number): string {
129  return `${String(Math.floor(now)).padStart(15, '0')}-${String(seq).padStart(6, '0')}.json`
130}
131
132// The speech server is started again whenever it is needed, but not more
133// than this many times in this window, so a server that cannot run does not
134// start over and over.
135export const MAX_SERVER_STARTS = 5
136export const SERVER_START_WINDOW_MS = 5 * 60_000
137
138// Whether the server may be started now, given when it was started before.
139export function mayStartServer(starts: readonly number[], now: number): boolean {
140  return starts.filter(at => now - at < SERVER_START_WINDOW_MS).length < MAX_SERVER_STARTS
141}
142
143// Why the speech server ended, in words: the reason it gave itself on a
144// STOP line, else the last line it wrote to standard error, else its exit code.
145export function describeExit(code: number | null, stopReason: string, errors: string): string {
146  if (stopReason !== '') return stopReason
147  const lastError = errors.trim().split('\n').pop()?.trim() ?? ''
148  if (lastError !== '') return lastError
149  if (code === null) return 'it was ended from outside'
150  if (code === 0) return 'it exited without saying why'
151
152  return `it ended with code ${code}, probably closed by Windows or another program`
153}
154
155export type Voice = { name: string; culture: string; gender: string; current: boolean }
156
157export function parseVoices(json: string): Voice[] {
158  try {
159    const value: unknown = JSON.parse(json)
160    const list = Array.isArray(value) ? value : [value]
161    return list
162      .filter((v): v is Record<string, unknown> => typeof v === 'object' && v !== null && typeof (v as { name?: unknown }).name === 'string')
163      .map(v => ({
164        name: String(v.name),
165        culture: typeof v.culture === 'string' ? v.culture : '',
166        gender: typeof v.gender === 'string' ? v.gender : '',
167        current: v.current === true,
168      }))
169  } catch {
170    return []
171  }
172}
173
174// Finds a voice by its number in the list (from 1) or by part of its name.
175export function findVoice(voices: readonly Voice[], query: string): Voice[] {
176  const q = query.trim().toLowerCase()
177  if (/^\d+$/.test(q)) {
178    const one = voices[Number(q) - 1]
179    return one ? [one] : []
180  }
181  const exact = voices.filter(v => v.name.toLowerCase() === q)
182  if (exact.length > 0) return exact
183
184  return voices.filter(v => v.name.toLowerCase().includes(q))
185}
186
187export function formatDuration(ms: number): string {
188  const seconds = Math.max(0, Math.round(ms / 1000))
189  if (seconds < 60) return seconds === 1 ? '1 second' : `${seconds} seconds`
190  const minutes = Math.floor(seconds / 60)
191  const rest = seconds % 60
192  const m = minutes === 1 ? '1 minute' : `${minutes} minutes`
193
194  return rest === 0 ? m : `${m} ${rest === 1 ? '1 second' : `${rest} seconds`}`
195}
196
hooks/usage.ts 83 lines
1// Plain text for /usage and /context. Claude Code draws both as panels of
2// bars, grids and symbols; these say the same figures in sentences.
3// Pure functions: register.tsx fetches the figures and passes them in.
4
5import type { SessionContextBreakdown, SessionUsage } from 'claude-code'
6
7import { formatDuration } from './speech'
8
9const WINDOW_NAMES: Record<string, string> = {
10  five_hour: 'Current session, the 5 hour limit',
11  seven_day: 'Current week, all models',
12  seven_day_opus: 'Current week, Opus',
13  seven_day_sonnet: 'Current week, Sonnet',
14  spend_limit: 'Spending limit',
15}
16
17function windowName(kind: string): string {
18  return WINDOW_NAMES[kind] ?? kind.replace(/_/g, ' ')
19}
20
21// "in 2 hours 5 minutes", to the minute, from an ISO time.
22export function resetsIn(iso: string | undefined, now: number): string {
23  if (iso === undefined) return ''
24  const at = Date.parse(iso)
25  if (Number.isNaN(at)) return ''
26  const minutes = Math.max(1, Math.round((at - now) / 60_000))
27  if (minutes < 60) return `resets in ${formatDuration(minutes * 60_000)}`
28  const hours = Math.floor(minutes / 60)
29  if (hours >= 48) return `resets in ${Math.round(hours / 24)} days`
30  const rest = minutes % 60
31  const h = hours === 1 ? '1 hour' : `${hours} hours`
32
33  return rest === 0 ? `resets in ${h}` : `resets in ${h} ${formatDuration(rest * 60_000)}`
34}
35
36export function formatTokens(tokens: number): string {
37  if (tokens >= 1_000_000) return `${Number((tokens / 1_000_000).toFixed(1))} million`
38  if (tokens >= 1_000) return `${Number((tokens / 1_000).toFixed(1))} thousand`
39
40  return String(Math.round(tokens))
41}
42
43function contextLine(usage: SessionUsage): string {
44  const { tokens, window, percent } = usage.context
45  if (tokens === undefined) return `Context: nothing used yet, of ${formatTokens(window)} tokens.`
46
47  return `Context: ${percent ?? Math.round((tokens / window) * 100)} percent used, ${formatTokens(tokens)} of ${formatTokens(window)} tokens.`
48}
49
50export function usageText(usage: SessionUsage, now: number): string {
51  const lines = ['Usage.']
52  if (usage.rateLimits.length === 0) lines.push('No plan limits have been reported yet. They show after the first reply on a subscription.')
53  for (const limit of usage.rateLimits) {
54    const reset = resetsIn(limit.resetsAt, now)
55    lines.push(`${windowName(limit.kind)}: ${limit.percentUsed} percent used${reset ? `, ${reset}` : ''}.`)
56  }
57  if (usage.cost !== undefined) lines.push(`This session has cost ${usage.cost.usd.toFixed(2)} US dollars.`)
58  lines.push(contextLine(usage))
59
60  return lines.join('\n')
61}
62
63export function contextText(usage: SessionUsage): string {
64  const breakdown: SessionContextBreakdown | undefined = usage.context.breakdown
65  if (breakdown === undefined) return contextLine(usage)
66  const lines = [
67    `Context for ${breakdown.model}: ${breakdown.percentage} percent used, ${formatTokens(breakdown.totalTokens)} of ${formatTokens(breakdown.rawMaxTokens)} tokens.`,
68  ]
69  for (const category of breakdown.categories) {
70    if (category.tokens <= 0) continue
71    const share = Math.round((category.tokens / breakdown.rawMaxTokens) * 100)
72    const note = category.isDeferred ? ', loaded only when needed' : ''
73    lines.push(`${category.name}: ${formatTokens(category.tokens)} tokens, ${share} percent${note}.`)
74  }
75  const memory = breakdown.memoryFiles.length
76  if (memory > 0) lines.push(`${memory === 1 ? '1 memory file' : `${memory} memory files`} loaded.`)
77  if (breakdown.isAutoCompactEnabled && breakdown.autoCompactThreshold !== undefined) {
78    lines.push(`Claude Code compacts the conversation at ${formatTokens(breakdown.autoCompactThreshold)} tokens.`)
79  }
80
81  return lines.join('\n')
82}
83
types/index.d.ts 50 lines
1// Blind Claude's session state: the values the engine keeps for the
2// session, so a reload of the mod in the middle of a session loses nothing.
3
4// One hunk of a diff, as Claude Code's structuredPatch gives it: each line
5// starts with a space, a plus or a minus. `scope` names the function or
6// section the change is in, when one was found.
7export type DiffHunk = {
8  oldStart: number
9  oldLines: number
10  newStart: number
11  newLines: number
12  lines: string[]
13  scope?: string
14}
15
16// One change to one file: an edit, a new file, a file emptied, a file
17// rewritten too large to compare, or a file deleted.
18export type FileChange = {
19  path: string
20  kind: 'edit' | 'create' | 'emptied' | 'rewrite' | 'deleted'
21  hunks: DiffHunk[]
22  // The turn it was made in, counted from 1 in this session.
23  turn: number
24  // Made by a helper agent rather than Claude itself.
25  isAgent: boolean
26  // Something the report should say about it, such as lines left out.
27  note?: string
28}
29
30// What the accessible dialog shows: the question, the choices, and an
31// optional box to type an answer of one's own.
32export type DialogOption = { key: string; label: string; spoken: string }
33export type Dialog = {
34  id: string
35  title: string
36  lines: string[]
37  options: DialogOption[]
38  input?: { label: string }
39}
40
41declare module 'claude-code' {
42  interface PluginState {
43    'blind-claude': {
44      changes: Shaped<FileChange[]>
45      turn: number
46      dialog: Shaped<Dialog | null>
47    }
48  }
49}
50