SLOPSHOPPER

hark

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

panebandcommandtoaststatusprompt
v0.1.0no licenseupdated 2026-10-24MoizAsif616/claude-code-utilities/mods/cli/hark
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · hark
│ ┃ Speech models ✕ › fix the failing auth test and add an audit log call │ ┃ Speech models │ ┃ Model Download RAM ⏺ Read(src/auth.ts) │ ┃ Accuracy ⎿ Read 6 lines │ ┃ ↑↓ select · Enter choose · Esc close ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /hark │ ⎿ hark: Voice input could not start: Voice input needs Python 3.9 │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Speech models
Speech models Model Download RAM Accuracy ↑↓ select · Enter choose · Esc close
README

claude-code-utilities

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:

FolderWhat goes there
mods/cliMods for the Claude Code CLI (the terminal) only
mods/desktopMods for the Claude Code desktop app only (none yet)
mods/bothMods that work in both, with a behaviour for each
ModRuns inIts job
mods/both/statusdeckCLI and desktopLive stats: a status row under the prompt in the CLI, a status band above the prompt in the desktop app
mods/cli/prettierCLIThe layout and colours of the whole chat: prompts, replies, code, tool rows, the spinner
mods/cli/harkCLIVoice input: speak your prompt, transcribed on your own computer

The two kinds load differently:

  • CLI mods are copied into ~/.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.
  • Mods for both (and for the desktop) are installed as plugins: the desktop app doesn't read your shell's profile, so 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.

Install

Windows (PowerShell)

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.

Linux (Ubuntu)

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.

macOS

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.

Load order

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.

statusdeck

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.

In the CLI

The row under the prompt, with every colour in it:

  • Mode, model and effort at the left. A click on the model or the effort opens Claude Code's picker for it.
  • Usage pills at the right: the 5-hour and 7-day rate-limit windows and the context fill, each coloured from blue (little used) through green and amber to red (nearly full), with the time until a window resets.
  • Cache warmth: ⚡ and a countdown while the prompt cache is warm, ❄ once it has gone cold.
  • While Claude works: the turn's running time and live output tokens after the effort.
  • Always one line: when the terminal is too narrow, pills drop in this order: 7d, ctx, 5h, then the cache. They come back as room returns.
  • Pane chips above the prompt when the panes mod is installed.

In the desktop app

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.

  • Clawd: a small pixel mascot showing what the session is doing: idle, working, done (for 5 s), needs you, stopped, or asleep after 5 minutes idle.
  • Turn pill: the elapsed time and output tokens while a turn runs.
  • Git pill: the branch and the changed-file count, inside a git repository only.
  • Context gauge: a small disc that fills in 10% steps, its colour changing at each step. Hover it for the tokens used out of the window size.
  • Cache timer: how long the prompt cache stays warm, from green to red; a red ❄ when cold.
  • Context panel (the ☰ 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 time and tokens line under each answer is hidden: the turn pill shows the same.

The desktop band is adapted from the deskdeck mod; the 5-hour and 7-day pills are left out of it.

Option

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.

prettier

The layout and colours of the chat. What it changes:

  • Your prompts: a pill with a rounded purple border on the right, one space of padding on each side, at most 70% of the terminal wide, with the time you sent it under it. A long prompt shows its first 6 lines, then ▸ 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.
  • Replies and tool-call rows: held to the left 85% of the terminal.
  • Colours in replies: orange headings and bold, lavender links, purple list and quote markers, and tables framed in light orange lines, with orange headers, striped rows, a little padding in each cell, column lines that run the full height of a wrapped row, and no column wider than 30% of the reply.
  • Colours on tool-call rows: a status dot (purple when done, light purple while running, red when it failed, grey when interrupted), the tool's name in purple and what it worked on in grey. The command or path is shown whole, never cut.
  • Code blocks in replies: each fenced block is a charcoal panel with a thin violet rounded border. Its top row holds the language on a lighter chip at the left (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.
  • Edits: +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.
  • Reads, searches and commands: a run of them folds to one row, ▸ 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.
  • Failures: a failed tool's error in a red frame, ✗ 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.
  • Rows you didn't type: background tasks finishing and messages from other agents, sessions or channels show as a quiet card on the left (⚙ Background task · completed · 2m 2s), not as your pill.
  • End of a reply: 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.
  • Spinner: drawn whole by prettier, a purple animated ⠋ and Claude Code's word, or the tool running now: ⠋ ▸ running Bash….

Opening folded rows

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.

Commands

CommandSettings (defaults)
/prettierlists the commands; on, off (layout and colours together), reset (every setting back to its default)
/prettier-promptwidth 70 (% of the terminal), lines 6 (shown before folding), time on
/prettier-responsewidth 85 (% of the terminal; also the most a code panel or red frame takes), fold on (fold runs of reads, searches and commands)
/prettier-codewidth 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.

Known limits

  • Tool output and progress text that Claude Code draws itself run the full terminal width: Claude Code refuses a mod that puts its own drawing inside a fixed width.
  • Times and tool lists only appear for prompts and replies made after the mod loaded, not for resumed sessions.
  • The dim ● <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.

hark

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.

  • The row above the input: while voice input is on, the model's name on the left and a red ● 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.
  • Recording: click ● 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.
  • Clean text: "uh", "um", "er" and "hmm" are dropped; punctuation and capitals are kept.
  • Speech detection: a small detector (Silero, 640 KB) tells your voice from silence and room noise, so background sound isn't transcribed. If it can't be loaded, hark goes by loudness.
  • Remembers on/off: if voice input was on when you closed Claude Code, it loads again in the next session.
  • Nothing left running: turning it off or closing Claude Code unloads the model from memory, and on Linux and macOS so does Claude Code crashing.

Models

/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.

  • Only one model is kept on disk. Once a new model has loaded, the previous one is deleted to free the space. A green ✓ marks the model in use.
  • Speed shows the catalog's estimate until you have used a model, then the wait measured on your computer from ■ Stop to the text appearing, for example 0.4 s.
  • All of them run on a CPU; no graphics card is needed:
ModelLanguagesDownloadNotes
Moonshine Tiny / BaseEnglish30 / 111 MBSmallest and quickest; Base is a good first choice
Kroko LiveEnglish57 MBWords appear as you say them; no punctuation
Parakeet 110MEnglish108 MBVery accurate for its size
Parakeet 0.6B v2 / v3English / 25 Europeanabout 485 MBBest accuracy that is still fast on a CPU
SenseVoice SmallChinese, Cantonese, English, Japanese, Korean163 MBFor East Asian languages
Whisper Small, Large v3, Large v3 Turbo990.5 to 3 GBMost languages; the large ones are slow on a CPU
Distil-Whisper LargeEnglish1.5 GBNear large-model accuracy, faster

Commands

CommandWhat it does
/harkTurns voice input on or off; /hark on and /hark off also work. With no model yet, it opens the list
/hark-modelsChoose, download, switch or delete a speech model

What it needs

  • Python 3.9 or newer on your 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.
  • A microphone. On Linux also PortAudio: sudo apt install libportaudio2 (Fedora: sudo dnf install portaudio, Arch: sudo pacman -S portaudio). Windows and macOS have it built in.
  • Disk: about 450 MB for the environment, plus the model you pick.

Everything hark downloads lives in ~/.claude/hark. Delete that folder to remove it all.

Update

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.

Notes

  • The types/ and tests/ folders inside each mod are for development and aren't needed to run the mods.
  • statusdeck (CLI and desktop app) and prettier are tested on Windows. The code has nothing Windows-specific, so they should work on Linux and macOS, but I haven't verified that.
  • hark is tested on Linux (Ubuntu). Its code handles Windows and macOS paths, but it hasn't been tried there.
Source 2 files
hooks/register.tsx 835 lines
1import { 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}
835
types/index.d.ts 50 lines
1export 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