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

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.
Version 0.1.0, early and in active development. Working now:
/changes to read everything Claude changed in Notepad++ or Notepad.Not built yet: plain rows for other tools, notifications, and pause and resume. See the roadmap.
install.cmd, or run .\install.ps1 in PowerShell./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:
/plugin marketplace add blindpro/blind-claude/plugin install blind-claude@blind-claudeTo try the mod for one session without installing it, run claude --plugin-dir plugin from this folder.
Double click uninstall.cmd, or run .\uninstall.ps1. Your settings and your own sounds are kept unless you ask otherwise:
.\uninstall.ps1 -RemoveSettings also deletes your saved settings..\uninstall.ps1 -RemoveSounds also deletes the sounds you added.-Quiet to skip speaking the result.claude plugin validate plugin checks the mod the way Claude Code loads it.claude plugin test plugin runs the tests.These work now, apart from the ones marked as planned in the commands document.
/speak all, /speak most, /speak some, /speak none: choose how much is spoken./speak stop: stop talking right now./speak repeat: say the last reply again./speak auto, /speak nvda, /speak windows: who speaks. Auto uses NVDA while it is running./usage and /context: answered in plain sentences instead of bars and grids, while cleaning is on./voices: list the installed voices and choose one by number./voices 3 or /voices Zira: choose a voice./rate and /volume: speed and loudness./clean on, /clean off or /clean words: symbol cleaning./sounds: sound cues. /sounds help lists the sound commands./changes: read what Claude changed in Notepad++ or Notepad. /changes help lists the rest./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.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.
hooks/register.tsx 1355 lines1// 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 lines1// 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}
282hooks/diff.ts 572 lines1// 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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
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}
572hooks/prompts.ts 214 lines1// 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}
214hooks/menu.ts 343 lines1// 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.'
343hooks/settings.ts 135 lines1export 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}
135hooks/sounds.ts 100 lines1// 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')
100hooks/speech.ts 196 lines1// 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}
196hooks/usage.ts 83 lines1// 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}
83types/index.d.ts 50 lines1// 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