Voice input for the prompt: local speech-to-text models, a record button above the input, live transcription

Mods for Claude Code. Each mod has one job and works on its own: none imports, reads or calls another. They are sorted by where they run:
| Folder | What goes there |
|---|---|
mods/cli | Mods for the Claude Code CLI (the terminal) only |
mods/desktop | Mods for the Claude Code desktop app only (none yet) |
mods/both | Mods that work in both, with a behaviour for each |
| Mod | Runs in | Its job |
|---|---|---|
mods/both/statusdeck | CLI and desktop | Live stats: a status row under the prompt in the CLI, a status band above the prompt in the desktop app |
mods/cli/prettier | CLI | The layout and colours of the whole chat: prompts, replies, code, tool rows, the spinner |
mods/cli/hark | CLI | Voice input: speak your prompt, transcribed on your own computer |
The two kinds load differently:
~/.claude/mods and named in the CLAUDE_CODE_PLUGIN_DIRS environment variable, set in your shell's profile. Only a claude started from that shell sees it, so the desktop app never loads them.CLAUDE_CODE_PLUGIN_DIRS can't reach it. mods/both is a plugin marketplace; install from it once and the mod loads in the CLI and the desktop app alike.Restart Claude Code (and the desktop app) after installing.
git clone https://github.com/MoizAsif616/claude-code-utilities.git
# CLI mods: copied to ~/.claude/mods and named in your PowerShell profile (terminal only)
New-Item -ItemType Directory -Force "$env:USERPROFILE\.claude\mods" | Out-Null
Copy-Item -Recurse -Force .\claude-code-utilities\mods\cli\* "$env:USERPROFILE\.claude\mods\"
Add-Content $PROFILE '$env:CLAUDE_CODE_PLUGIN_DIRS = "$env:USERPROFILE\.claude\mods\prettier;$env:USERPROFILE\.claude\mods\hark"'
# Mods for both: installed as plugins from the mods/both marketplace
claude plugin marketplace add .\claude-code-utilities\mods\both
claude plugin install statusdeck@claude-code-utilities --scope user
Open a new PowerShell window before starting claude, and check the variable:
echo $env:CLAUDE_CODE_PLUGIN_DIRS # must start with ...\mods\prettier
Don't set CLAUDE_CODE_PLUGIN_DIRS as a Windows user environment variable: every program sees those, the desktop app included, so the CLI mods would load there too.
git clone https://github.com/MoizAsif616/claude-code-utilities.git
mkdir -p ~/.claude/mods
cp -r claude-code-utilities/mods/cli/* ~/.claude/mods/
echo 'export CLAUDE_CODE_PLUGIN_DIRS="$HOME/.claude/mods/prettier:$HOME/.claude/mods/hark"' >> ~/.bashrc
claude plugin marketplace add ./claude-code-utilities/mods/both
claude plugin install statusdeck@claude-code-utilities --scope user
Open a new shell (or source ~/.bashrc) before starting claude.
git clone https://github.com/MoizAsif616/claude-code-utilities.git
mkdir -p ~/.claude/mods
cp -R claude-code-utilities/mods/cli/* ~/.claude/mods/
echo 'export CLAUDE_CODE_PLUGIN_DIRS="$HOME/.claude/mods/prettier:$HOME/.claude/mods/hark"' >> ~/.zshrc
claude plugin marketplace add ./claude-code-utilities/mods/both
claude plugin install statusdeck@claude-code-utilities --scope user
Open a new shell (or source ~/.zshrc) before starting claude.
A plugin installed from a folder is read from that folder: keep the clone where it is, and an edit there reaches Claude Code after /reload-plugins.
The three mods never draw the same thing, so their order doesn't matter (;-separated on Windows, : on Linux and macOS in CLAUDE_CODE_PLUGIN_DIRS). The install commands list prettier first.
The one row hark shares is the one above the prompt: statusdeck draws its panes chips there in the CLI (only with the panes mod), and whichever of the two loads first shows when both have something to draw. hark also doesn't affect prettier's times: it keeps each prompt as it was finally sent, so the time under a prompt you sent while recording is right.
One mod with two looks: a status row under the prompt in the CLI, and a status band above the prompt in the desktop app. Each draws only where it belongs.
The row under the prompt, with every colour in it:
⚡ and a countdown while the prompt cache is warm, ❄ once it has gone cold.A band right above the prompt, in the app's theme colours, so it reads in light and dark mode. The bar under the prompt is left to the app: it draws a mod's content there only as plain text.
❄ when cold.☰ at the right end, or /statusdeck-context): check or uncheck skills and MCP tools. After Apply, whatever is unchecked is hidden from Claude and refused if Claude calls it anyway. The desktop app's own ccd_* tools can't be turned off. Pending changes apply on their own at the next /clear or /compact.The desktop band is adapted from the deskdeck mod; the 5-hour and 7-day pills are left out of it.
The cache lifetime (1h on a Claude subscription, 5m with an API key) is a plugin option: /plugin configure statusdeck@claude-code-utilities in a terminal session.
statusdeck doesn't draw the spinner; prettier does.
The layout and colours of the chat. What it changes:
▸ 14 more lines. Beside the time, 14:32 ⧉ (same grey): a click on ⧉ copies that prompt's text and nothing else, and it reads a green ✓ for 3.5 seconds.python, or code when unlabeled) and ⧉ Copy at the right, which copies only the code and reads Copied! for 3.5 seconds. A panel is as wide as its longest line, never wider than the reply.+12 −3 (green and red) after an edit's row. While a reply is being written each diff shows whole at 75% of the terminal; once it is done, each edited or created file is one row, ▸ Edit hooks/app.ts +12 −3, that opens to its diff on a click.▸ Ran 2 commands · 1.2s. A click opens it: each call on a filled block with the whole command and its output (up to 40 lines). A click on any block, or on the row, closes it.✗ Bash failed, as wide as the error and no wider than the reply. Once the reply is done the call is one row, ▸ Edit app.ts · ✗ failed (✗ failed in red), that opens to its frame on a click. Declining a permission prompt is not a failure.⚙ Background task · completed · 2m 2s), not as your pill.39s · ▸ 2 tools, how long it took (dim) and how many tools it used (purple). A click on the count lists each call: ✓ Bash npm test · 1.2s.⠋ and Claude Code's word, or the tool running now: ⠋ ▸ running Bash….Everything compact toggles on a click: ▸ is closed, ▾ is open. Clicks only reach mods in Claude Code's fullscreen (alternate-screen) layout. On the normal screen these rows stay compact, edit diffs and errors stay whole (there would be no way to reopen them), the Copy buttons are hidden, and a folded prompt or message says ctrl+o. ctrl+o is Claude Code's own transcript view: it shows tool calls, outputs and long prompts in full; press it again to go back.
| Command | Settings (defaults) |
|---|---|
/prettier | lists the commands; on, off (layout and colours together), reset (every setting back to its default) |
/prettier-prompt | width 70 (% of the terminal), lines 6 (shown before folding), time on |
/prettier-response | width 85 (% of the terminal; also the most a code panel or red frame takes), fold on (fold runs of reads, searches and commands) |
/prettier-code | width 75 (% of the terminal, for diffs) |
Each command on its own lists its settings and what they do. Change one or several at once: /prettier-prompt width 60 lines 4. Settings are kept across sessions. A wrong name or value says what's allowed.
● <mod>: ... lines Claude Code prints when a mod loads, reloads or fails are Claude Code's own; no mod can restyle them.More detail is in mods/cli/prettier/README.md.
Voice input for the prompt. You speak, and the words appear in the input box as you talk. Speech is turned into text by a model running on your own computer: no account, no API key, and your voice never leaves the machine. The only network use is downloading a model once.
● Record button on the right. While recording: a ■ Stop button, a timer and level bars that move with your voice and stay flat in silence.● Record and talk; the text fills the input live, after anything you had already typed. Click ■ Stop to keep the text and edit it, or press Enter to stop and send in one go. With nothing said, the input is left as it was./hark-models opens a list of speech models with their languages, download size, RAM, speed and accuracy. Pick one, confirm, and it downloads (in the background if you like, with a red ✕ to cancel). Voice input switches to it as soon as it has loaded.
✓ marks the model in use.■ Stop to the text appearing, for example 0.4 s.| Model | Languages | Download | Notes |
|---|---|---|---|
| Moonshine Tiny / Base | English | 30 / 111 MB | Smallest and quickest; Base is a good first choice |
| Kroko Live | English | 57 MB | Words appear as you say them; no punctuation |
| Parakeet 110M | English | 108 MB | Very accurate for its size |
| Parakeet 0.6B v2 / v3 | English / 25 European | about 485 MB | Best accuracy that is still fast on a CPU |
| SenseVoice Small | Chinese, Cantonese, English, Japanese, Korean | 163 MB | For East Asian languages |
| Whisper Small, Large v3, Large v3 Turbo | 99 | 0.5 to 3 GB | Most languages; the large ones are slow on a CPU |
| Distil-Whisper Large | English | 1.5 GB | Near large-model accuracy, faster |
| Command | What it does |
|---|---|
/hark | Turns voice input on or off; /hark on and /hark off also work. With no model yet, it opens the list |
/hark-models | Choose, download, switch or delete a speech model |
PATH (python3, python or py). hark makes its own Python environment in ~/.claude/hark and installs what it needs there, nothing system-wide. It uses uv when installed; otherwise Python's own venv, which on Ubuntu/Debian needs sudo apt install python3-venv.sudo apt install libportaudio2 (Fedora: sudo dnf install portaudio, Arch: sudo pacman -S portaudio). Windows and macOS have it built in.Everything hark downloads lives in ~/.claude/hark. Delete that folder to remove it all.
Pull the latest changes, then copy the CLI mods again and update statusdeck:
cd claude-code-utilities && git pull
cp -R mods/cli/* ~/.claude/mods/
claude plugin update statusdeck@claude-code-utilities
On Windows, run git pull and repeat the Copy-Item line from the install section. The profile line only needs adding once. In a running session, /reload-plugins picks up the new statusdeck.
types/ and tests/ folders inside each mod are for development and aren't needed to run the mods.hooks/register.tsx 835 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, HookStream, ProcessSpawnChunk, ProcessSpawnResult, PromptFilled, Register } from 'claude-code'
3
4import type { HarkDownload, HarkModel, HarkPhase, HarkPickerView } from '../types'
5
6const phase = atom({ plugin: 'hark', key: 'phase' } as const, 'off' as HarkPhase)
7const modelName = atom({ plugin: 'hark', key: 'modelName' } as const, '')
8const levels = atom({ plugin: 'hark', key: 'levels' } as const, [] as number[])
9const seconds = atom({ plugin: 'hark', key: 'seconds' } as const, 0)
10const download = atom({ plugin: 'hark', key: 'download' } as const, null as HarkDownload | null)
11const models = atom({ plugin: 'hark', key: 'models' } as const, [] as HarkModel[])
12const active = atom({ plugin: 'hark', key: 'active' } as const, null as string | null)
13const view = atom({ plugin: 'hark', key: 'view' } as const, { kind: 'list' } as HarkPickerView)
14const isPickerOpen = atom({ plugin: 'hark', key: 'isPickerOpen' } as const, false)
15// Per model, the median wait from Stop to text measured on this machine, in ms.
16const speeds = atom({ plugin: 'hark', key: 'speeds' } as const, {} as Record<string, number>)
17
18// Palette shared with statusdeck and prettier.
19const PILL_BG = '#202C34'
20const PILL_LABEL = '#D4DCE2'
21const PILL_TIMER = '#A7B3BD'
22const ORANGE = '#F97316'
23const PURPLE_LIGHT = '#C4B5FD'
24const GREEN = '#22C55E'
25const RED = '#FF6B6B'
26const RED_DEEP = '#DC2626'
27const WHITE = '#FFFFFF'
28const QUIET = '#52525B'
29
30const PICKER = 'hark-models'
31const PLACEHOLDER = '🎙 listening…'
32const BARS = 14
33// Sounds people make between words, dropped from what hark heard.
34const FILLER = /^(u+h+|u+h*m+|e+r|e+r+m+|h+m+|m{2,})$/i
35const WELCOME =
36 'hark is installed: speak your prompts instead of typing them.\n' +
37 ' /hark turn voice input on or off\n' +
38 ' /hark-models choose, download or delete a speech model'
39
40type Listing = {
41 models: HarkModel[]
42 active: string | null
43 venv_python: string
44 has_venv: boolean
45}
46
47type EngineEvent =
48 | { event: 'listening'; port: number }
49 | { event: 'ready'; port: number; name: string; load_ms: number }
50 | { event: 'partial'; text: string }
51 | { event: 'level'; rms: number; seconds: number }
52 | { event: 'error'; text: string }
53 | { event: 'step'; text: string }
54 | { event: 'progress'; done: number; total: number }
55 | { event: 'done'; pruned?: string[]; freed_mb?: number }
56 | ({ event: 'list' } & Listing)
57
58// What the running session holds; a reload starts it over, as it does the
59// engine child, and session.start puts the state atoms back to match.
60const live: {
61 python?: string[]
62 venvPython?: string
63 engine?: HookStream<ProcessSpawnChunk, ProcessSpawnResult>
64 installer?: HookStream<ProcessSpawnChunk, ProcessSpawnResult>
65 // The engine's port from its start, to stop it even while it loads.
66 enginePort?: number
67 // Its port once the model has loaded and it can record.
68 port?: number
69 // The model the engine runs.
70 engineModel?: string
71 draft: string
72 base: string
73 isWaitingToTurnOn: boolean
74 // The person cancelled the running download; its loop ends at the next piece.
75 isInstallCancelled: boolean
76 // A model switch that came while recording, done once the recording ends.
77 needsReload: boolean
78 // The last prompt box write; each new one waits for it, so they land in order.
79 writing: Promise<unknown>
80} = {
81 draft: '',
82 base: '',
83 isWaitingToTurnOn: false,
84 isInstallCancelled: false,
85 needsReload: false,
86 writing: Promise.resolve(),
87}
88
89function script($: EngineInterface): string {
90 return `${$.plugin.root}/engine/hark.py`
91}
92
93async function findPython($: EngineInterface): Promise<string[] | undefined> {
94 if (live.python) return live.python
95 for (const argv of [['python3'], ['python'], ['py', '-3']]) {
96 const ran = await $.process.run([...argv, '--version']).catch(() => undefined)
97 if (ran?.exitCode === 0 && /Python 3\.(9|1\d)/.test(ran.stdout + ran.stderr)) {
98 live.python = argv
99 return argv
100 }
101 }
102 return undefined
103}
104
105function parseLines(text: string): EngineEvent[] {
106 return text
107 .split('\n')
108 .filter(line => line.trim().startsWith('{'))
109 .map(line => JSON.parse(line) as EngineEvent)
110}
111
112/** Runs one management command (list, use, remove) and returns its last event. */
113async function manage($: EngineInterface, args: string[]): Promise<EngineEvent> {
114 const py = await findPython($)
115 if (!py) return { event: 'error', text: 'Voice input needs Python 3.9 or newer, and none was found on PATH.' }
116 const ran = await $.process.run([...py, script($), ...args], { timeoutMs: 180_000 })
117 const events = parseLines(ran.stdout)
118 return events.at(-1) ?? { event: 'error', text: ran.stderr.trim().split('\n').slice(-3).join('\n') || 'no answer' }
119}
120
121/** Reads the catalog and what is installed into the state the picker draws. */
122async function refreshModels($: EngineInterface): Promise<Listing | string> {
123 const answer = await manage($, ['list'])
124 if (answer.event !== 'list') return answer.event === 'error' ? answer.text : 'unexpected answer'
125 live.venvPython = answer.venv_python
126 await update($, models, () => answer.models)
127 await update($, active, () => answer.active)
128 return answer
129}
130
131/** Writes the prompt box after every earlier write, so a late partial never lands over the final text. */
132function fillPrompt($: EngineInterface, text: string): Promise<PromptFilled> {
133 const write = live.writing.then(() => $.prompt.fill({ text }))
134 live.writing = write.catch(() => undefined)
135 return write
136}
137
138async function post(path: string, $: EngineInterface, port = live.port): Promise<Record<string, unknown>> {
139 if (port === undefined) return { error: 'voice input is off' }
140 const answer = await $.http
141 .fetch(`http://127.0.0.1:${port}${path}`, { method: 'POST', body: '{}' })
142 .catch(error => ({ ok: false, text: JSON.stringify({ error: String(error) }) }))
143 try {
144 return JSON.parse(answer.text) as Record<string, unknown>
145 } catch {
146 return { error: answer.text }
147 }
148}
149
150// --- On and off ----------------------------------------------------------------
151
152/** Starts the engine on the active model; with none, opens the picker unless `isQuiet`. */
153async function turnOn($: EngineInterface, isQuiet = false): Promise<string> {
154 if ((await read($, phase)) !== 'off') return `Voice input is already on (${await read($, modelName)}).`
155 const found = await refreshModels($)
156 if (typeof found === 'string') return `Voice input could not start: ${found}`
157 const model = found.models.find(one => one.id === found.active)
158 if (!model || !found.has_venv) {
159 if (isQuiet) return ''
160 live.isWaitingToTurnOn = true
161 await openPicker($)
162 return 'No speech model is installed yet. Pick one to download; voice input turns on when it is ready.'
163 }
164 live.isWaitingToTurnOn = false
165 live.needsReload = false
166 await update($, modelName, () => model.name)
167 await update($, phase, () => 'loading')
168 void runEngine($, found.venv_python, model.id)
169 return `Voice input is on: loading ${model.name}.`
170}
171
172async function runEngine($: EngineInterface, venvPython: string, id: string) {
173 const stream = $.process.spawn({ argv: [venvPython, script($), 'serve', id] })
174 live.engine = stream
175 live.engineModel = id
176 let pending = ''
177 let lastError = ''
178 try {
179 for await (const chunk of stream) {
180 // A newer engine took over while this one was still loading: leaving
181 // the loop kills this one before it can answer for the new model.
182 if (live.engine !== stream) break
183 if (chunk.stream === 'stderr') {
184 const lines = chunk.text.trim().split('\n').filter(line => !/resampler|sample_rate|^\s*$/.test(line))
185 if (lines.length > 0) lastError = lines.at(-1) ?? lastError
186 continue
187 }
188 pending += chunk.text
189 const cut = pending.lastIndexOf('\n')
190 if (cut < 0) continue
191 const complete = pending.slice(0, cut)
192 pending = pending.slice(cut + 1)
193 for (const event of parseLines(complete)) {
194 if (live.engine !== stream) {
195 if (event.event === 'listening' || event.event === 'ready') await post('/quit', $, event.port)
196 break
197 }
198 if (event.event === 'error') lastError = event.text
199 else await onEngineEvent($, event, id)
200 }
201 }
202 } catch (error) {
203 lastError = String(error)
204 }
205 if (live.engine !== stream) return
206 live.engine = undefined
207 live.enginePort = undefined
208 live.port = undefined
209 const was = await read($, phase)
210 await update($, phase, () => 'off')
211 if (was !== 'off') {
212 if (was === 'recording') await fillPrompt($, live.draft)
213 $.ui.toast(`Voice input stopped: ${lastError || 'the engine exited'}`)
214 }
215}
216
217async function onEngineEvent($: EngineInterface, event: EngineEvent, id: string) {
218 if (event.event === 'listening') {
219 live.enginePort = event.port
220 } else if (event.event === 'ready') {
221 live.port = event.port
222 await update($, phase, () => 'ready')
223 void pruneOthers($, id)
224 } else if (event.event === 'level') {
225 await update($, levels, now => [...now, event.rms].slice(-BARS))
226 await update($, seconds, () => event.seconds)
227 } else if (event.event === 'partial') {
228 if ((await read($, phase)) === 'recording') {
229 await fillPrompt($, live.base + (dropFillers(event.text) || PLACEHOLDER))
230 }
231 }
232}
233
234/** Once `id` has loaded, deletes every other downloaded model, so only the one in use takes disk space. */
235async function pruneOthers($: EngineInterface, id: string) {
236 // A download still running loads its model when done, and prunes then.
237 if (await read($, download)) return
238 const known = await read($, models)
239 const answer = await manage($, ['prune', id])
240 if (answer.event === 'error') {
241 $.ui.toast(`Could not delete the old models: ${answer.text}`)
242 return
243 }
244 if (answer.event !== 'done' || !answer.pruned?.length) return
245 await refreshModels($)
246 const names = answer.pruned.map(one => known.find(model => model.id === one)?.name ?? one)
247 $.ui.toast(`Deleted ${names.join(', ')} to free ${size(answer.freed_mb ?? 0)}.`)
248}
249
250async function turnOff($: EngineInterface): Promise<string> {
251 const was = await read($, phase)
252 if (was === 'off') return 'Voice input is already off.'
253 if (was === 'recording') {
254 await post('/cancel', $)
255 await fillPrompt($, live.draft)
256 }
257 // The engine answers /quit from its start, so this stops one still loading too.
258 await post('/quit', $, live.enginePort)
259 const stream = live.engine
260 live.engine = undefined
261 live.enginePort = undefined
262 live.port = undefined
263 // Ending the loop kills the child; nothing to wait for.
264 void stream?.return(undefined as never).catch(() => undefined)
265 await update($, phase, () => 'off')
266 return 'Voice input is off; the model is unloaded.'
267}
268
269/** Restarts the engine on the active model when voice input is on; mid-recording, once it ends. */
270async function reload($: EngineInterface) {
271 const now = await read($, phase)
272 if (now === 'recording' || now === 'finishing') {
273 live.needsReload = true
274 return
275 }
276 live.needsReload = false
277 if (now === 'off') return
278 await turnOff($)
279 await turnOn($)
280}
281
282// --- Recording -------------------------------------------------------------------
283
284async function startRecording($: EngineInterface) {
285 if ((await read($, phase)) !== 'ready' || live.port === undefined) {
286 await openPicker($)
287 return
288 }
289 const box = await $.prompt.read()
290 live.draft = box.text
291 live.base = live.draft.trim() === '' ? '' : `${live.draft.trimEnd()} `
292 const started = await post('/start', $)
293 if (started.error) {
294 $.ui.toast(`Could not open the microphone: ${String(started.error)}`)
295 return
296 }
297 await update($, levels, () => [])
298 await update($, seconds, () => 0)
299 await update($, phase, () => 'recording')
300 await fillPrompt($, live.base + PLACEHOLDER)
301}
302
303/** Stops the recording and resolves the whole prompt: the draft plus what was said. */
304async function finishRecording($: EngineInterface): Promise<{ text: string; heard: string }> {
305 await update($, phase, () => 'finishing')
306 const stopped = await post('/stop', $)
307 await update($, phase, () => (live.port === undefined ? 'off' : 'ready'))
308 if (live.needsReload) void reload($)
309 const heard = typeof stopped.text === 'string' ? dropFillers(stopped.text.trim()) : ''
310 if (stopped.error) $.ui.toast(`Transcription failed: ${String(stopped.error)}`)
311 if (heard && typeof stopped.ms === 'number' && live.engineModel) void recordSpeed($, live.engineModel, stopped.ms)
312 return { text: heard ? live.base + heard : live.draft, heard }
313}
314
315/** Drops "uh", "um" and their kind, keeping the sentence's punctuation and capitals whole. */
316function dropFillers(text: string): string {
317 const kept: string[] = []
318 let isCapitalNext = false
319 for (const word of text.split(/\s+/).filter(Boolean)) {
320 const bare = word.replace(/^[^\p{L}]+|[^\p{L}]+$/gu, '')
321 if (!FILLER.test(bare)) {
322 kept.push(isCapitalNext ? word.replace(/\p{L}/u, letter => letter.toUpperCase()) : word)
323 isCapitalNext = false
324 continue
325 }
326 const before = kept.at(-1)
327 const ending = word.slice(word.indexOf(bare) + bare.length)
328 const isSentenceStart = before === undefined || /[.!?…]$/.test(before)
329 if (before !== undefined) {
330 // "it, uh." ends the sentence at "it."; "want, um, the" reads "want the".
331 if (/[.!?…]/.test(ending) && !/[.!?…]$/.test(before)) kept[kept.length - 1] = before.replace(/[,;:]+$/, '') + ending.replace(/[^.!?…]/g, '')
332 else if (ending.startsWith(',') && before.endsWith(',')) kept[kept.length - 1] = before.slice(0, -1)
333 }
334 if (isSentenceStart && /^\p{Lu}/u.test(bare)) isCapitalNext = true
335 }
336 return kept.join(' ')
337}
338
339/** Keeps the last ten waits from Stop to text for a model, and their median for the picker. */
340async function recordSpeed($: EngineInterface, id: string, ms: number) {
341 try {
342 const all = ((await $.store.get('speed')) ?? {}) as Record<string, number[]>
343 const recent = [...(all[id] ?? []), ms].slice(-10)
344 await $.store.set('speed', { ...all, [id]: recent })
345 await update($, speeds, now => ({ ...now, [id]: median(recent) }))
346 } catch {
347 // A speed that is not kept only leaves the picker showing the catalog's word.
348 }
349}
350
351/** Remembers whether voice input is on, so the next session starts the same way. */
352async function rememberOn($: EngineInterface, isOn: boolean) {
353 try {
354 await $.store.set('isOn', isOn)
355 } catch {
356 // Not kept: the next session starts with voice off, as it did before.
357 }
358}
359
360async function stopByButton($: EngineInterface) {
361 if ((await read($, phase)) !== 'recording') return
362 const done = await finishRecording($)
363 const filled = await fillPrompt($, done.text)
364 // Whatever the box made of that write, the placeholder never stays behind.
365 if (filled.text.includes(PLACEHOLDER)) await fillPrompt($, filled.text.replace(PLACEHOLDER, '').trimEnd())
366 if (!done.heard) $.ui.toast("Didn't catch any speech.")
367}
368
369// --- The model picker --------------------------------------------------------------
370
371async function openPicker($: EngineInterface) {
372 await refreshModels($)
373 await update($, view, () => ({ kind: 'list' }) as HarkPickerView)
374 await update($, isPickerOpen, () => true)
375 const count = (await read($, models)).length
376 await $.ui.open({ id: PICKER, title: 'Speech models', focus: true, closeOnEscape: true, holdToasts: true, rows: count + 5 })
377}
378
379async function closePicker($: EngineInterface) {
380 await update($, isPickerOpen, () => false)
381 await $.ui.close({ id: PICKER })
382}
383
384async function show($: EngineInterface, next: HarkPickerView, focusKey: string) {
385 await update($, view, () => next)
386 await $.ui.focus({ requestId: PICKER, key: focusKey }).catch(() => undefined)
387}
388
389async function choose($: EngineInterface, model: HarkModel) {
390 const loading = await read($, download)
391 if (loading?.id === model.id) return show($, { kind: 'progress', id: model.id }, 'background')
392 if (!model.installed) return show($, { kind: 'install', id: model.id }, 'download')
393 return show($, { kind: 'installed', id: model.id }, (await read($, active)) === model.id ? 'back' : 'use')
394}
395
396async function useModel($: EngineInterface, model: HarkModel) {
397 const answer = await manage($, ['use', model.id])
398 if (answer.event === 'error') {
399 $.ui.toast(answer.text)
400 return
401 }
402 await refreshModels($)
403 await show($, { kind: 'list' }, `m-${model.id}`)
404 await reload($)
405}
406
407async function deleteModel($: EngineInterface, model: HarkModel) {
408 if ((await read($, active)) === model.id && (await read($, phase)) !== 'off') await turnOff($)
409 const answer = await manage($, ['remove', model.id])
410 if (answer.event === 'error') $.ui.toast(answer.text)
411 await refreshModels($)
412 await show($, { kind: 'list' }, `m-${model.id}`)
413}
414
415async function startInstall($: EngineInterface, model: HarkModel, isBackground: boolean) {
416 if (await read($, download)) {
417 $.ui.toast('Another model is still downloading; try again when it is done.')
418 return
419 }
420 if (live.installer) {
421 $.ui.toast('The cancelled download is still stopping; try again in a moment.')
422 return
423 }
424 const py = await findPython($)
425 if (!py) {
426 $.ui.toast('Voice input needs Python 3.9 or newer, and none was found on PATH.')
427 return
428 }
429 await update($, download, () => ({ id: model.id, name: model.name, step: 'Starting', done: 0, total: 0 }))
430 if (isBackground) await closePicker($)
431 else await show($, { kind: 'progress', id: model.id }, 'background')
432 void runInstall($, py, model)
433}
434
435async function runInstall($: EngineInterface, py: string[], model: HarkModel) {
436 const stream = $.process.spawn({ argv: [...py, script($), 'install', model.id] })
437 live.installer = stream
438 let pending = ''
439 let outcome = ''
440 try {
441 for await (const chunk of stream) {
442 // Leaving the loop kills the installer.
443 if (live.isInstallCancelled) break
444 if (chunk.stream === 'stderr') continue
445 pending += chunk.text
446 const cut = pending.lastIndexOf('\n')
447 if (cut < 0) continue
448 const complete = pending.slice(0, cut)
449 pending = pending.slice(cut + 1)
450 for (const event of parseLines(complete)) {
451 if (event.event === 'step') {
452 await update($, download, now => (now ? { ...now, step: event.text } : now))
453 } else if (event.event === 'progress') {
454 await update($, download, now => (now ? { ...now, done: event.done, total: event.total } : now))
455 } else if (event.event === 'done') {
456 outcome = 'done'
457 } else if (event.event === 'error') {
458 outcome = event.text
459 }
460 }
461 }
462 } catch (error) {
463 outcome = outcome || String(error)
464 }
465 if (live.installer === stream) live.installer = undefined
466 const isCancelled = live.isInstallCancelled && outcome !== 'done'
467 live.isInstallCancelled = false
468 if (isCancelled) {
469 await manage($, ['discard', model.id])
470 await refreshModels($)
471 return
472 }
473 await update($, download, () => null)
474 await refreshModels($)
475 const current = await read($, view)
476 const isWatching = (await read($, isPickerOpen)) && current.kind === 'progress' && current.id === model.id
477 if (outcome !== 'done') {
478 $.ui.toast(`Downloading ${model.name} failed: ${outcome || 'the download stopped'}`)
479 if (isWatching) await show($, { kind: 'install', id: model.id }, 'download')
480 return
481 }
482 if (isWatching) await show($, { kind: 'list' }, `m-${model.id}`)
483 if (live.isWaitingToTurnOn) {
484 if (isWatching) await closePicker($)
485 await turnOn($)
486 await rememberOn($, true)
487 } else {
488 $.ui.toast(`${model.name} is ready and in use.`)
489 await reload($)
490 }
491}
492
493/** Stops the running download at once; runInstall throws away what it fetched. */
494async function cancelInstall($: EngineInterface) {
495 const loading = await read($, download)
496 if (!loading || !live.installer) return
497 live.isInstallCancelled = true
498 void live.installer.return(undefined as never).catch(() => undefined)
499 await update($, download, () => null)
500 $.ui.toast(`Cancelled downloading ${loading.name}.`)
501 const current = await read($, view)
502 if ((await read($, isPickerOpen)) && current.kind === 'progress') {
503 await show($, { kind: 'install', id: current.id }, 'download')
504 }
505}
506
507// --- Hooks -------------------------------------------------------------------------
508
509export const register: Register = on => {
510 on('session.start', async ($, e, next) => {
511 await update($, phase, () => 'off')
512 await update($, download, () => null)
513 await update($, isPickerOpen, () => false)
514 // Clears the status line entry an earlier version of hark left behind.
515 $.ui.status(undefined)
516 await $.command.register({
517 name: 'hark',
518 description: 'Turn voice input on or off',
519 immediate: true,
520 })
521 await $.command.register({ name: 'hark-models', description: 'Choose, download or delete a speech model' })
522 const started = await next(e)
523 // Once per machine: say what hark is and the two commands that drive it.
524 try {
525 if ((await $.store.get('welcomed')) !== true) {
526 const shown = await $.session
527 .append({ message: { type: 'system', content: [{ type: 'text', text: WELCOME }] } })
528 .then(() => true)
529 .catch(() => false)
530 if (!shown) $.ui.toast(WELCOME.split('\n')[0] ?? 'hark is installed')
531 await $.store.set('welcomed', true)
532 }
533 } catch {
534 // A missing welcome must not keep hark from starting.
535 }
536 try {
537 const measured = ((await $.store.get('speed')) ?? {}) as Record<string, number[]>
538 await update($, speeds, () => Object.fromEntries(Object.entries(measured).map(([id, waits]) => [id, median(waits)])))
539 // Voice input was on when the last session ended: load it again, quietly.
540 if ((await $.store.get('isOn')) === true) void turnOn($, true)
541 } catch {
542 // Without the store, hark starts off, as it always did.
543 }
544 return started
545 })
546
547 on('session.end', async ($, e, next) => {
548 await post('/quit', $, live.enginePort)
549 return next(e)
550 })
551
552 on('command.run', { command: 'hark' }, async ($, e) => {
553 const wanted = e.args.trim().toLowerCase()
554 const isOn = (await read($, phase)) !== 'off'
555 if (wanted === 'on' || (wanted === '' && !isOn)) {
556 const text = await turnOn($)
557 await rememberOn($, (await read($, phase)) !== 'off')
558 return { text }
559 }
560 const text = await turnOff($)
561 await rememberOn($, false)
562 return { text }
563 })
564
565 on('command.run', { command: 'hark-models' }, async $ => {
566 await openPicker($)
567 return {}
568 })
569
570 on('ui.close', async ($, e, next) => {
571 if (e.id === PICKER) {
572 await update($, isPickerOpen, () => false)
573 await update($, view, () => ({ kind: 'list' }) as HarkPickerView)
574 }
575 return next(e)
576 })
577
578 // Enter while recording: stop, transcribe what is left, and send the prompt.
579 on('prompt.submit', async ($, e, next) => {
580 const isOurs = e.command === undefined && e.isMeta === undefined && e.origin.kind === 'composer'
581 if (!isOurs || (await read($, phase)) !== 'recording') return next(e)
582 const done = await finishRecording($)
583 if (!done.heard && done.text.trim() === '') return { drop: "Didn't catch any speech, so nothing was sent." }
584 return next({ ...e, text: done.text })
585 }).catch(($, e, next) =>
586 // If finishing failed, never send the placeholder as part of the prompt.
587 next.called ? next(e) : next({ ...e, text: e.text.replace(PLACEHOLDER, '').trim() || e.text }),
588 )
589
590 // The voice line above the prompt: "hark: <model>" and the live bars on the
591 // left, the Record or Stop button at the right end.
592 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
593 if (e.props.hasSurvey) return next(e)
594 const { Box, Button, Text } = $.ui.resolve(e)
595 const now = await read($, phase)
596 const loading = await read($, download)
597 const showsDownload = loading !== null && !(await read($, isPickerOpen))
598 if (now === 'off' && !showsDownload) return next(e)
599
600 const left = [<Text key="label" color={PILL_LABEL}>hark: </Text>]
601 if (now === 'off') {
602 left.push(<Text key="name" color={PILL_TIMER}>voice off</Text>)
603 } else {
604 // The name opens the picker, as /hark-models does.
605 left.push(
606 <Button key="name" plain hover={{ backgroundColor: PILL_BG }} onPress={() => openPicker($)}>
607 <Text color={PILL_LABEL} bold hover={{ backgroundColor: PILL_BG, color: WHITE }}>{await read($, modelName)}</Text>
608 </Button>,
609 )
610 }
611 if (now === 'loading') left.push(<Text key="loading" color={PILL_TIMER}> loading…</Text>)
612 if (now === 'recording') {
613 left.push(<Text key="gap"> </Text>)
614 for (const [index, rms] of padLevels(await read($, levels)).entries()) {
615 left.push(<Text key={`bar-${index}`} color={ORANGE}>{barGlyph(rms)}</Text>)
616 }
617 left.push(<Text key="clock" color={PILL_TIMER}> {clock(await read($, seconds))}</Text>)
618 }
619 if (showsDownload && loading) {
620 left.push(<Text key="download" color={PILL_TIMER}> ⬇ {loading.name} {bar(loading)} </Text>)
621 left.push(
622 <Button key="cancel-download" plain hover={{ backgroundColor: RED_DEEP, color: WHITE }} onPress={() => cancelInstall($)}>
623 <Text color={RED} bold hover={{ backgroundColor: RED_DEEP, color: WHITE }}>{' ✕ '}</Text>
624 </Button>,
625 )
626 }
627
628 let right
629 if (now === 'ready' || now === 'finishing') {
630 right = (
631 <Box key="record-box">
632 <Button key="record" plain hover={{ backgroundColor: PILL_BG, color: RED }} onPress={() => startRecording($)}>
633 <Text backgroundColor={PILL_BG} color={RED} bold hover={{ backgroundColor: PILL_BG, color: RED }}>{' ● Record '}</Text>
634 </Button>
635 </Box>
636 )
637 } else if (now === 'recording') {
638 right = (
639 <Box key="stop-box">
640 <Button key="stop" plain hover={{ backgroundColor: RED_DEEP, color: WHITE }} onPress={() => stopByButton($)}>
641 <Text backgroundColor={RED_DEEP} color={WHITE} bold hover={{ backgroundColor: RED_DEEP, color: WHITE }}>{' ■ Stop '}</Text>
642 </Button>
643 </Box>
644 )
645 }
646
647 return (
648 <Box width={e.props.bodyColumns} justifyContent="space-between">
649 <Box key="left">{left}</Box>
650 {right ?? <Box key="right" />}
651 </Box>
652 )
653 })
654
655 // The model picker: a list driven by the arrows, and a question per model.
656 on('ui.render', { component: 'Pane', requestId: PICKER }, async ($, e) => {
657 const { Box, Button, Text } = $.ui.resolve(e)
658 const list = await read($, models)
659 const inUse = await read($, active)
660 const current = await read($, view)
661 const loading = await read($, download)
662 const width = Math.max(40, e.props.bodyColumns)
663 const footer = (hint: string) => <Text key="footer" color={QUIET}>{hint}</Text>
664
665 if (current.kind === 'list') {
666 const nameWidth = 24
667 const fixed = 2 + nameWidth + 9 + 11 + 11
668 const speedWidth = width - fixed >= 11 ? 11 : 0
669 const languageWidth = width - fixed - speedWidth >= 13 ? 13 : 0
670 const measured = await read($, speeds)
671 const focusKey = `m-${inUse ?? list.find(one => one.recommended)?.id ?? list[0]?.id}`
672 return (
673 <Box flexDirection="column">
674 <Text key="head" bold>Speech models</Text>
675 <Text key="columns" color={QUIET}>
676 {' ' + fit('Model', nameWidth) + fit('Languages', languageWidth) + fit('Download', 9) + fit('RAM', 11) + fit('Speed', speedWidth) + 'Accuracy'}
677 </Text>
678 {list.map(model => (
679 <Button key={`m-${model.id}`} plain autoFocus={`m-${model.id}` === focusKey ? true : undefined} onPress={() => choose($, model)}>
680 <Text color={GREEN}>{model.id === inUse ? '✓ ' : ' '}</Text>
681 <Text bold={model.id === inUse}>{fit(model.name, nameWidth)}</Text>
682 <Text color={PILL_TIMER}>{fit(shortLanguages(model), languageWidth)}</Text>
683 <Text>{fit(size(model.size_mb), 9)}</Text>
684 <Text color={PILL_TIMER}>{fit(`~${size(model.ram_mb)}`, 11)}</Text>
685 {measured[model.id] === undefined ? (
686 <Text color={QUIET}>{fit(model.speed.replace(' on CPU', ''), speedWidth)}</Text>
687 ) : (
688 <Text color={PILL_LABEL}>{fit(waitText(measured[model.id]), speedWidth)}</Text>
689 )}
690 <Text color={accuracyColor(model.accuracy)}>{loading?.id === model.id ? `⬇ ${percent(loading)}` : model.accuracy}</Text>
691 </Button>
692 ))}
693 {footer('↑↓ select · Enter choose · Esc close')}
694 </Box>
695 )
696 }
697
698 const model = list.find(one => one.id === current.id)
699 if (!model) return footer('That model is gone. Esc close')
700 const wait = (await read($, speeds))[model.id]
701 const facts =
702 `${size(model.size_mb)} download · about ${size(model.ram_mb)} RAM · ${allLanguages(model)} · ${model.accuracy} accuracy` +
703 (wait === undefined ? '' : ` · text ${waitText(wait)} after Stop on this machine`)
704 const back = <Button key="back" label="Back" onPress={() => show($, { kind: 'list' }, `m-${model.id}`)} />
705
706 if (current.kind === 'install') {
707 return (
708 <Box flexDirection="column">
709 <Text key="ask" bold>Download {model.name}?</Text>
710 <Text key="facts" color={PILL_TIMER}>{facts}</Text>
711 <Box key="actions" marginTop={1}>
712 <Button key="download" label="Download" variant="primary" autoFocus onPress={() => startInstall($, model, false)} />
713 <Button key="bg" label="Download in background" onPress={() => startInstall($, model, true)} />
714 {back}
715 </Box>
716 {footer('←→ select · Enter choose · Esc close')}
717 </Box>
718 )
719 }
720 if (current.kind === 'progress') {
721 return (
722 <Box flexDirection="column">
723 <Text key="ask" bold>Downloading {model.name}</Text>
724 <Text key="step" color={PILL_TIMER}>{loading?.id === model.id ? loading.step : 'Finishing'}</Text>
725 {loading?.id === model.id && <Text key="bar" color={PURPLE_LIGHT}>{bar(loading)}</Text>}
726 <Box key="actions" marginTop={1}>
727 <Button key="background" label="Continue in background" autoFocus onPress={() => closePicker($)} />
728 <Button key="cancel" plain hover={{ backgroundColor: RED_DEEP, color: WHITE }} onPress={() => cancelInstall($)}>
729 <Text color={RED} bold hover={{ backgroundColor: RED_DEEP, color: WHITE }}>{' ✕ Cancel download '}</Text>
730 </Button>
731 </Box>
732 {footer('←→ select · Enter choose · Esc keeps it downloading in the background')}
733 </Box>
734 )
735 }
736 if (current.kind === 'delete') {
737 return (
738 <Box flexDirection="column">
739 <Text key="ask" bold>Delete {model.name}?</Text>
740 <Text key="facts" color={PILL_TIMER}>Frees {size(model.size_mb)}. You can download it again later.</Text>
741 <Box key="actions" marginTop={1}>
742 <Button key="delete" label="Delete" autoFocus onPress={() => deleteModel($, model)} />
743 <Button key="cancel" label="Cancel" onPress={() => show($, { kind: 'installed', id: model.id }, 'back')} />
744 </Box>
745 {footer('←→ select · Enter choose · Esc close')}
746 </Box>
747 )
748 }
749 const isInUse = model.id === inUse
750 return (
751 <Box flexDirection="column">
752 <Text key="ask" bold>{model.name} {isInUse ? <Text color={GREEN}>✓ in use</Text> : <Text color={PILL_TIMER}>installed</Text>}</Text>
753 <Text key="facts" color={PILL_TIMER}>{facts}</Text>
754 <Box key="actions" marginTop={1}>
755 {!isInUse && <Button key="use" label="Use this model" variant="primary" autoFocus onPress={() => useModel($, model)} />}
756 <Button key="delete" label="Delete" onPress={() => show($, { kind: 'delete', id: model.id }, 'cancel')} />
757 {back}
758 </Box>
759 {footer('←→ select · Enter choose · Esc close')}
760 </Box>
761 )
762 })
763}
764
765function fit(text: string, width: number): string {
766 if (width <= 0) return ''
767 if (text.length >= width) return `${text.slice(0, width - 2)}… `
768 return text.padEnd(width, ' ')
769}
770
771/** Up to three languages by name; past that the main one and a count: "English +98". */
772function shortLanguages(model: HarkModel): string {
773 const count = model.language_count ?? model.languages.length
774 if (count <= 3) return model.languages.join(', ')
775 return `${model.languages[0]} +${count - 1}`
776}
777
778/** Every language by name where the catalog names them all, else "English and 98 more". */
779function allLanguages(model: HarkModel): string {
780 const count = model.language_count ?? model.languages.length
781 if (count === model.languages.length) return model.languages.join(', ')
782 return `${model.languages.join(', ')} and ${count - model.languages.length} more`
783}
784
785/** A wait in ms as the picker shows it: "0.4 s". */
786function waitText(ms: number): string {
787 return `${(ms / 1000).toFixed(1)} s`
788}
789
790function median(values: number[]): number {
791 const sorted = [...values].sort((a, b) => a - b)
792 const middle = Math.floor(sorted.length / 2)
793 return sorted.length % 2 === 1 ? (sorted[middle] ?? 0) : ((sorted[middle - 1] ?? 0) + (sorted[middle] ?? 0)) / 2
794}
795
796function size(mb: number): string {
797 return mb >= 1000 ? `${(mb / 1000).toFixed(1)} GB` : `${mb} MB`
798}
799
800function percent(loading: HarkDownload): string {
801 return loading.total > 0 ? `${Math.floor((loading.done / loading.total) * 100)}%` : '…'
802}
803
804function bar(loading: HarkDownload): string {
805 if (loading.total <= 0) return loading.step
806 const fraction = loading.done / loading.total
807 const filled = Math.max(0, Math.min(12, Math.round(fraction * 12)))
808 return `${'█'.repeat(filled)}${'░'.repeat(12 - filled)} ${percent(loading)}`
809}
810
811function accuracyColor(accuracy: string): string {
812 if (accuracy === 'best' || accuracy === 'excellent') return GREEN
813 if (accuracy === 'very good') return '#9FDFB5'
814 if (accuracy === 'good') return PILL_LABEL
815 return PILL_TIMER
816}
817
818function clock(total: number): string {
819 const whole = Math.floor(total)
820 return `${Math.floor(whole / 60)}:${String(whole % 60).padStart(2, '0')}`
821}
822
823function padLevels(heard: number[]): number[] {
824 return [...Array<number>(Math.max(0, BARS - heard.length)).fill(0), ...heard]
825}
826
827function loudness(rms: number): number {
828 return Math.min(1, Math.sqrt(rms * 14))
829}
830
831function barGlyph(rms: number): string {
832 const steps = '▁▂▃▄▅▆▇█'
833 return steps[Math.min(7, Math.round(loudness(rms) * 7))] ?? '▁'
834}
835types/index.d.ts 50 lines1export type HarkPhase = 'off' | 'loading' | 'ready' | 'recording' | 'finishing'
2
3export type HarkModel = {
4 id: string
5 name: string
6 // The main language first; `language_count` counts all, when more than are named.
7 languages: string[]
8 language_count?: number
9 size_mb: number
10 ram_mb: number
11 accuracy: string
12 // The catalog's word for how fast it runs: "very fast", "slow on CPU".
13 speed: string
14 installed: boolean
15 recommended?: boolean
16}
17
18export type HarkDownload = {
19 id: string
20 name: string
21 step: string
22 done: number
23 total: number
24}
25
26// What the model picker shows: the list, or one model's question.
27export type HarkPickerView =
28 | { kind: 'list' }
29 | { kind: 'install'; id: string }
30 | { kind: 'installed'; id: string }
31 | { kind: 'delete'; id: string }
32 | { kind: 'progress'; id: string }
33
34declare module 'claude-code' {
35 interface PluginState {
36 hark: {
37 phase: HarkPhase
38 modelName: string
39 levels: number[]
40 seconds: number
41 download: HarkDownload | null
42 models: HarkModel[]
43 active: string | null
44 view: HarkPickerView
45 isPickerOpen: boolean
46 speeds: Record<string, number>
47 }
48 }
49}
50