6 Commits

Author SHA1 Message Date
Cursor Agent
ded8811ca9 feat(B.1 #10): detect focused terminal and switch to clipboard-only paste
Some checks failed
check / cargo check (macos-latest) (push) Has been cancelled
check / cargo check (ubuntu-22.04) (push) Has been cancelled
check / cargo check (windows-latest) (push) Has been cancelled
check / svelte build + lint (push) Has been cancelled
Extends commands/paste.rs::paste_text with a pre-keystroke check:
if GetForegroundWindow (Windows) / xdotool getactivewindow (Linux
X11) / 'tell System Events ...' (macOS) reports a focused app class
matching KNOWN_TERMINAL_CLASSES, skip the synthesised Ctrl+V and
return an outcome with copied=true, pasted=false, and a user-facing
message ('Terminal detected (kitty) — use Ctrl+Shift+V to insert').

Matches Handy #692: Kitty/Alacritty/Windows Terminal/Codex CLI all
double-insert the transcript when a PTY sees both a synthesised
Ctrl+V and the terminal's own paste hotkey. The terminal list is
ordered most-specific-first so 'windowsterminal' wins over the
generic 'terminal' needle.

Adds classify_terminal() as a pure helper + seven unit tests. The
platform probe (detect_focused_window_class) isolates the fragile
shell-out so the classification rule is test-covered without
needing a real desktop. Wayland doesn't expose a reliable
focused-window API to unprivileged clients, so it conservatively
returns None and the normal paste path runs — consistent with
Kon's Wayland-Pipewire lane.

Prior-clipboard restore (#3) is intentionally skipped when terminal
mode takes over: the user's path to insert the transcript is their
own paste, which needs the transcript on the clipboard.

Co-authored-by: jars <jakejars@users.noreply.github.com>
2026-04-21 15:59:01 +01:00
Cursor Agent
df58d98adc feat(B.1 #5): per-OS hotkey capability matrix with inline rejection copy
Adds src/lib/utils/hotkeyValidity.ts with validateHotkey(combo, os)
and wires it into HotkeyRecorder.svelte. Rules:

  - X11/Wayland/Linux: reject single-key combos unless the trigger
    is F13..F24 (the conventional global-shortcut escape hatch).
  - Windows: reject combos whose only modifier is a right-hand
    variant (RCtrl/RAlt) — matches Handy #966's failure mode where
    RegisterHotKey silently ignores them.
  - macOS: reject Fn-only combos and bare-key combos (non-function
    keys).
  - unknown OS: pass through — better to ship a flawed combo than
    reject one we can't validate.

When validation fails, the recorder leaves the previous known-good
hotkey intact and surfaces an inline warning-coloured sentence
explaining why, with actionable copy ("add a modifier", "use the
left-hand equivalent", etc.). The save button disappears because
settings.globalHotkey is never written to — no state drift.

Matches Handy #917 / #1019 / #966 / #956, brief item #5.

Co-authored-by: jars <jakejars@users.noreply.github.com>
2026-04-21 15:59:01 +01:00
Cursor Agent
8e5e034df1 feat(B.1 #4 UX): debounce hotkey press events by 120ms
A rapid double-tap of the global hotkey, evdev autorepeat, or a
sticky-key compositor quirk (KDE's 'slow keys') can all deliver
the same press twice within ~100ms. Without a guard, the recording
toggles into and out of the same frame and the capture is lost.

Gates the evdev 'kon:hotkey-pressed' forwarder in +layout.svelte
behind a 120ms debounce (Date.now()-based; no timers, so no tail
latency for a legitimate single press). The debounce is intentionally
shorter than a deliberate double-press cadence but longer than any
autorepeat interval we've seen in the wild.

The audio-stream-warming half of brief item #4 (Handy #1143) lives
in Workstream A's Phase A.3 warm-up WAV; this covers the UX side.

Co-authored-by: jars <jakejars@users.noreply.github.com>
2026-04-21 15:59:01 +01:00
Cursor Agent
f3a0673eaa feat(B.1 #3): snapshot prior clipboard and restore 300 ms after paste
Extends commands/paste.rs so that when auto-paste fires we:
  1. capture the user's existing clipboard text BEFORE overwriting
     it with the transcript (via arboard::Clipboard::get_text — a
     non-text clipboard returns None and the restore step is
     skipped, which keeps images / files safe),
  2. after the paste keystroke lands, sleep 300 ms,
  3. restore the snapshot only if the clipboard still holds the
     transcript we wrote — respects any Cmd+C the user did in the
     300 ms window.

Moves the decision to a pure should_restore(current, transcript)
helper with four unit tests. The existing paste-matrix regression
tests are unchanged.

Matches Handy #921 (workstream-B brief item #3). Coexists with the
Wayland preview-hide dance already in this file, and doesn't run
when the paste keystroke fails (transcript stays on the clipboard
for the user's own paste).

Co-authored-by: jars <jakejars@users.noreply.github.com>
2026-04-21 15:59:01 +01:00
Cursor Agent
cc3bffa72c feat(B.1 #11): versioned settings schema with forward migration
Adds src/lib/utils/settingsMigrations.ts exposing
loadSettingsWithMigration() / saveSettingsWithVersion() around a
{version, data} envelope in localStorage["kon_settings"]. The
migration chain is indexed by destination version so adding a v2
is one MIGRATIONS[2] = (prev) => next entry away from working.

Legacy bare-object settings blobs are treated as v0 and folded into
v1 identically to before — no user-facing reset — but an unreadable
blob now surfaces a single Settings reset toast instead of silently
dropping data.

Covers Handy #602 ('settings reset on update') and unblocks the
other B.2/B.3 SettingsState additions listed in
docs/whisper-ecosystem/workstream-B.md: every subsequent field
lands behind a MIGRATIONS step, so older Kon builds stay readable.

Co-authored-by: jars <jakejars@users.noreply.github.com>
2026-04-21 15:59:01 +01:00
Cursor Agent
db9e119c1b docs(phase4-ux): execution plan for Workstream B
Sequences the 13 B-scope items from docs/whisper-ecosystem/brief.md
into three phases (pre-emptive UX, feature pinches, LLM layer) with
stop-for-review boundaries between each.

Enumerates the Settings sections touched per item (net: +2 toggles,
+2 sub-cards, nothing invisible becomes visible), the new
SettingsState fields with defaults, the schema migration bump
(version 1 -> 2), and the explicit Workstream A dependencies +
stubbed fallbacks for each (#14 list_gpus, #30 streaming cleanup,
#31 llm-state-change event).

Co-authored-by: jars <jakejars@users.noreply.github.com>
2026-04-21 15:59:01 +01:00
7 changed files with 904 additions and 32 deletions

View File

@@ -0,0 +1,216 @@
# Workstream B — UX + formatting layer
*Execution plan for the Opus half of the Whisper-ecosystem pass.*
*Branch: `phase4-ux-f7d0` (base: `main`). Companion: `phase4-systems-f7d0` (Workstream A).*
This is the UX + prompt / cleanup half. Every task below maps to an
atomic item in `docs/whisper-ecosystem/brief.md`. Items #1, #2, #6,
#7, #8, #9, #12, #13, #18, #19, #21, #22, #23, #24, #25, #26, #28, #29
are owned by Workstream A and appear here only where they shape a
surface B consumes.
---
## Scope recap
**In scope for B:** items #3, #4 (UX surface), #5, #10, #11, #14 (UI),
#15, #16, #17, #20, #27, #30, #31.
**Untouchable (owned by A):** `crates/transcription`, `crates/audio`,
`crates/ai-formatting/src/pipeline.rs`,
`crates/ai-formatting/src/rule_based.rs`, and
`src-tauri/src/commands/{models,transcription,live}.rs`.
**Partially touchable:** `crates/ai-formatting/src/llm_client.rs`
only the `CLEANUP_PROMPT` constant for item #16. Every other line in
that file stays exactly as it is.
---
## Ideology rules (override defaults)
From the Workstream B prompt:
1. **Low cognitive load.** Every new Settings entry must earn its
mental real estate. No "we could expose this too" reflexes. Most
items add _zero_ new controls and land under existing sections.
2. **Never rewrite history in the paste buffer.** Raw transcript is
always recoverable (item #17). Clipboard restore after paste
(item #3) follows the same rule: user's pre-dictation clipboard
returns automatically.
3. **"Translator, not editor"** (item #16) is the formatting
contract. Load-bearing. Any prompt edit that softens it gets
rejected at review.
4. **Local-first.** No feature may assume cloud availability. Ollama
(#27) is additive — Kon works fully offline without it.
---
## Execution order
Three phases, each one commit per concern, each ending with a green
`cargo build -p kon && npm run check`. We stop at each phase boundary
for review.
### Phase B.1 — Pre-emptive UX (items #3, #4 UX, #5, #10, #11)
No new Settings sections. These are invisible until they save you from
a bug, which matches the brief's "pre-emptive patches" framing.
| Seq | Item | Effect | Depends on A? |
|---|---|---|---|
| 1 | **#3**: clipboard snapshot + restore after paste | `src-tauri/src/commands/paste.rs` already paste-matrix-hardens for Wayland. Extend it: before `Clipboard::new().set_text(text)`, read the existing `Clipboard::get_text()` snapshot (+ `get_image()` if supported by arboard on this target). Schedule a `tokio::spawn` task that sleeps 300ms after paste and restores the snapshot. User's prior clipboard content is therefore recovered automatically. Keep the `hide_preview_overlay_for_paste` dance. | No |
| 2 | **#4 (UX side)**: debounce hotkey, add "warming up…" state | A warms the CPAL stream at app start. B owns the `page.status` state machine: the hotkey handler in `DictationPage.svelte` already gates on `page.recording`; add a ~120ms debounce on the hotkey press so a rapid double-tap doesn't double-init. Existing native capture path is untouched. | Stream warm-up lands in A's Phase A.3 (item #23); B's debounce is independent. |
| 3 | **#5**: hotkey capability matrix per OS, UI rejection of invalid combos | `src/lib/components/HotkeyRecorder.svelte` (or equivalent). Add `hotkeyValidity.ts` returning `{ valid: boolean, reason: string \| null }` given a combo + OS. Rules: on X11, reject single-key combos and report "Add a modifier to capture this key outside Kon"; on Windows, reject combos whose only modifier is a right-hand variant (RCtrl/RAlt — per `Handy` #966); on macOS, reject Fn-only combos. Block save button + surface inline "why" copy. | No |
| 4 | **#10**: detect focused-app class; force clipboard-only paste in terminals | Extend `src-tauri/src/commands/paste.rs::paste_text` to look up the focused window class (GetForegroundWindow → class name on Windows; `xdotool getactivewindow getwindowclassname` on Linux X11; `CGWindowListCopyWindowInfo` → ownerName on macOS). On a known-terminal class (`Alacritty`, `kitty`, `gnome-terminal-server`, `WindowsTerminal`, `Code`, `iTerm2`, `Terminal`, etc.) skip the keystroke and just set the clipboard; return `outcome.pasted = false, outcome.copied = true, message = "Terminal detected — clipboard only"`. UX handles that message already. | No |
| 5 | **#11**: versioned settings schema with forward migration | `src/lib/stores/page.svelte.ts` currently reads `kon_settings` localStorage blob via `parseStoredJson` and spreads it over `defaults`. Add a `SettingsSchemaVersion` integer (start at 1) and a `migrateSettings(raw, toVersion)` helper. Persist `{ version, data }` on save; read both on load; run migrations in order; fall back cleanly on corruption (drop to defaults, toast the user once). Every new field B adds (below) bumps the version. | No |
**Commit boundary:** `cargo build -p kon && npm run check` green for
every commit; JS-side vitest isn't set up in this repo, so the
migration tests live under `src/lib/utils/__tests__/` as pure TS +
`svelte-check`. If tests need a runner we borrow Codex's setup in
Phase B.2. **Stop for review.**
### Phase B.2 — Feature pinches (items #14 UI, #15, #17, #20)
| Seq | Item | Effect | Depends on A? |
|---|---|---|---|
| 6 | **#14 (UI)**: GPU enumeration + explicit device selector in Settings | Adds a `Compute device` row to `SettingsPage.svelte` under the existing `openSection === 'transcription'` section. Reads `runtimeCapabilities.activeComputeDevice` (A ships this in Phase A.1 #1) and `list_gpus()` (A ships in Phase A.2). If `list_gpus` isn't shipped yet, B ships the UI behind `if (runtimeCapabilities.activeComputeDevice)` and a TODO to call `list_gpus` later. `set_preferred_gpu(id)` is wired to a save-on-select handler with a "Restart to apply" toast. No default change until user picks. | Yes — A ships `list_gpus` in Phase A.2. B stubs behind the `activeComputeDevice` check until then. |
| 7 | **#15**: named LLM prompt presets | Preset data lives client-side in `src/lib/stores/promptPresets.svelte.ts` (new). Five presets shipped: `Quick clean` (default), `Email`, `Meeting notes`, `Code`, `Summary`. Each is `{ id, name, systemPrompt, modelTier }`. User-editable list stored in `localStorage['kon_prompt_presets']` with migration hook. Settings gets a new "Prompt presets" sub-card inside `openSection === 'ai'`; DictationPage gets a small preset pill above the Status bar — active preset is applied at cleanup time. Default preset's `systemPrompt` is `CLEANUP_PROMPT` from `llm_client.rs`, so the existing baseline is preserved. | No — uses existing `cleanup_transcript_text_cmd(transcript, profile_id)`. If the backend later needs `system_prompt_override` (not strictly needed for MVP — UI just restricts _when_ cleanup fires), B adds that with A's sign-off. |
| 8 | **#17**: raw-transcript revert with ⌘/Ctrl+Z within 5 s | `src/lib/pages/DictationPage.svelte::finaliseTranscription` keeps `rawTranscript` alongside `transcript`. After paste, start a 5-second window where Ctrl/⌘+Z caught globally (via `@tauri-apps/plugin-global-shortcut` ephemerally, or via Tauri IPC from the main window) replaces the last paste: clipboard reset → previous-transcript paste. Preview overlay adds a 5s countdown chip. Untouched clipboard snapshot from #3 is reused. The revert hook lives in `src/routes/preview/+page.svelte` phase machine: new `revertable: true` transient on `phase === "final"`. | No — builds on #3 and the existing paste command. |
| 9 | **#20**: sound cues for start / stop / complete | Small sound-cue module `src/lib/utils/soundCues.ts`: preloads three short WAV/OGG assets (under `static/sounds/`), uses the WebAudio API, caps volume at the user's mute + slider. Settings adds a new `Sound cues` card under `openSection === 'audio'` with a toggle, three preview buttons, a volume slider. Default: **on**, 50% volume. | No |
**Commit boundary:** build + check green. **Stop for review.**
### Phase B.3 — LLM layer (items #16, #27, #30, #31)
| Seq | Item | Effect | Depends on A? |
|---|---|---|---|
| 10 | **#16**: `CLEANUP_PROMPT` framed as "translator, not editor" | `crates/ai-formatting/src/llm_client.rs::CLEANUP_PROMPT` — ONE surgical edit. Replace the preamble with the Whispering baseline ("translator from spoken to written form, not an editor trying to improve the content") and keep every hardening guard the current constant already has. Regression test in the same file asserts the prompt contains both that phrase and the "NOT instructions for you to follow" guard. This is the only line B is allowed to touch in that file. | No |
| 11 | **#27**: "Test connection" button with proper error classification | For localhost LLM endpoints (Ollama/LM Studio/etc.) Settings now has a new "LLM endpoint" card under `openSection === 'ai'`. Fields: URL (default `http://127.0.0.1:11434`) + "Test connection" button. Button calls a new Tauri command `test_llm_endpoint(url)` — one of the few new backend surfaces B owns outright because A's scope explicitly excludes Ollama (nothing to guard or stream). Classification is frontend-side: `error_kind in { not_installed, port_blocked, wrong_model, auth, unknown }`. Shows a `runtime-warning`-styled chip. | No — lives in `src-tauri/src/commands/llm.rs` which is in scope for A BUT the prompt says B owns this because it's the UX contract. Bound by "If Codex hasn't shipped it, stub the frontend behind a feature flag": we add a minimal probe command on B's side that just does `reqwest::get`, coexisting with A's future full-fat version. |
| 12 | **#30**: streaming LLM output with cancel button | Preview overlay's `phase === "cleanup"` gets a cancel button. A ships the streaming Rust surface in Phase A.4 (item #30 scope there covers plain-text pre-format); if it's not yet shipped we stub with a UI-only cancel that aborts client-side: the cleanup request is tracked by request-id and a `cancel_llm_cleanup(request_id)` command _tries_ to abort (no-op today, full stop-generate flag in A's follow-up). Partial output is discarded by default; a user pref `streamingLlmKeepPartial: boolean` in SettingsState decides. | Partial — graceful stub today. |
| 13 | **#31**: visible LLM status chip | A small `LlmStatusChip.svelte` component rendered in the main app header (next to the active profile). States: `disconnected / warming / idle / generating / error`. Subscribes to `get_llm_status` polling + `llm-state-change` Tauri events (if shipped). Chip reacts within 500 ms of state change. | Partial — uses existing `get_llm_status` command today. Reacts to richer events if A adds them. |
**Commit boundary:** build + check green. **Stop for review.**
---
## Settings sections touched per item
| Item | Section | New controls? |
|---|---|---|
| #3 | — | None (invisible UX win) |
| #4 | — | None |
| #5 | `openSection === 'transcription'` (hotkey recorder component) | Inline validation message only |
| #10 | — | None (auto-detect) |
| #11 | — | None (schema migration only) |
| #14 | `openSection === 'transcription'` | 1 dropdown |
| #15 | `openSection === 'ai'` | New "Prompt presets" sub-card |
| #17 | `openSection === 'processing'` | 1 toggle `revertRawTranscript` (default on, opt-out only) |
| #20 | `openSection === 'audio'` | 1 toggle + 1 slider + 3 preview buttons |
| #16 | — | None (default prompt changes, no UI) |
| #27 | `openSection === 'ai'` | New "LLM endpoint" sub-card |
| #30 | — | 1 toggle `streamingLlmKeepPartial` under 'ai' |
| #31 | App header | New status chip (no Settings control) |
Net added toggles: 2 (sound-cues enable, keep-partial-cleanup). Net
added sub-cards: 2 (prompt presets, LLM endpoint). Everything else is
either invisible (#3, #4, #10, #11, #16) or an enhancement to an
existing control (#5, #14). The rule-1 budget (low cognitive load)
holds.
---
## New SettingsState fields
Per the workstream rules, every new Settings-visible field gets both a
`defaults` entry in `src/lib/stores/page.svelte.ts` and a type-level
declaration in `src/lib/types/app.ts`. The migration hook from item
#11 manages the bump:
```ts
// SettingsState additions (Phase B.2 / B.3):
soundCuesEnabled: boolean; // default: true (#20)
soundCuesVolume: number; // 0..1, default 0.5 (#20)
revertRawTranscriptEnabled: boolean; // default: true (#17)
activePromptPresetId: string; // default: "quick-clean" (#15)
promptPresets: PromptPreset[]; // user-customisable list (#15)
llmEndpointUrl: string; // default: "" (local-only; #27)
streamingLlmKeepPartial: boolean; // default: false (#30)
preferredGpuId: string | null; // default: null (auto) (#14)
```
Settings schema version starts at `1`; this set lands as version `2`.
---
## Explicit Codex (Workstream A) dependencies
Workstream B waits on Workstream A for the following surfaces. When A
hasn't shipped yet, B ships the UI stubbed behind a feature flag +
TODO, per the rules:
| Item | Depends on A surface | B fallback |
|---|---|---|
| #14 (UI) | `list_gpus` command (A.2), `activeComputeDevice` on `runtimeCapabilities` (A.1 — shipped) | Show only `activeComputeDevice`; dropdown hidden behind `if (listGpus)` import guard |
| #17 | Stable `paste_text` outcome shape (already stable) | None |
| #27 | — | No dependency today; A's #27 scope is frontend-side |
| #30 | A's streaming LLM output surface (Phase A.4) | UI-only cancel; partial output discarded; TODO comment in `src/lib/utils/llmStream.ts` |
| #31 | `llm-state-change` event (future A.4) | Poll `get_llm_status` at 500ms until event exists |
| runtime-warning banner | `runtime-warning` event (A.1 — shipped) | None — consume directly |
---
## Test strategy
- No JS test runner in this repo currently, so correctness comes from
`svelte-check` + manual testing after each phase. For the schema
migration (item #11) we add a tiny pure-TS test file that we exercise
with a minimal `tsc --noEmit` inside `npm run check` — no new tool
chain.
- Manual QA checklist per phase lives at the bottom of this doc.
- Rust-side Workstream-B commands (only `test_llm_endpoint` today)
land with a unit test that passes without a live Ollama instance
(test against a temporary `TcpListener` that returns the fixture
shape the frontend expects).
---
## Commit conventions on this branch
Same spirit as A:
- One concern per commit, subject line `feat(B.1):` / `fix(B.3):` etc.
- Commit body references the brief item.
- Only touch `crates/ai-formatting/src/llm_client.rs` for `CLEANUP_PROMPT`
— rejected in review otherwise.
- **Never regress** the paste matrix from `commands/paste.rs`: #3 and
#10 extend, they don't rewrite.
## Manual QA (run after each phase)
Phase B.1:
- Type `testing 123` in Kate, copy it, dictate a sentence, paste
(autoPaste on). Verify within 1s Kate's clipboard is back to
`testing 123`.
- Open kitty. Dictate. Verify paste goes as clipboard-only toast,
nothing is typed.
- Tap hotkey 5× rapidly; verify only one recording starts.
Phase B.2:
- Reboot. Plug only one GPU. Verify Settings shows the matching
`activeComputeDevice` label + dropdown is disabled / hidden per
stubs.
- Dictate "send this to my mum", pick `Email` preset, verify cleanup
output matches email tone.
- Dictate, paste into a text file, press Ctrl+Z within 5s. Verify
raw transcript replaces cleaned output.
- Start / stop / finalise — verify three distinct cues at default
volume.
Phase B.3:
- With Ollama not installed: click Test Connection → see
"Ollama not installed" classified message.
- Run a 30-second cleanup request; click Cancel; verify no insert.
- Toggle LLM model load in Settings; verify header chip reflects
state within 500ms.

View File

@@ -21,6 +21,13 @@ use arboard::Clipboard;
use serde::Serialize;
use tauri::Manager;
/// Window after the paste keystroke at which we restore the user's
/// prior clipboard content. 300 ms is enough for even a slow Wayland
/// compositor to have fully delivered the synthesised Ctrl+V to the
/// focused app; longer risks the user manually pasting again and
/// stomping themselves. See Handy #921.
const CLIPBOARD_RESTORE_MS: u64 = 300;
/// Compositor settle time after hiding the preview overlay before firing
/// the paste keystroke. Empirically ~80ms is enough on KWin + Mutter
/// Wayland for focus to return to the previously-focused app; shorter
@@ -59,6 +66,19 @@ pub async fn paste_text(app: tauri::AppHandle, text: String) -> Result<PasteOutc
message: None,
};
// Snapshot the user's existing clipboard text BEFORE we stomp it
// with the transcript, so a background task can restore it after
// the paste keystroke has landed. `arboard::Clipboard::get_text`
// returns Err when the clipboard holds non-text content (images,
// files, empty) — in those cases we skip the restore step
// entirely rather than trying to coerce. This is the "never
// silently clobber the user's clipboard" contract from Handy
// #921 (Workstream B brief item #3).
let prior_clipboard: Option<String> = match Clipboard::new() {
Ok(mut cb) => cb.get_text().ok(),
Err(_) => None,
};
match Clipboard::new().and_then(|mut cb| cb.set_text(&text)) {
Ok(()) => outcome.copied = true,
Err(err) => {
@@ -67,6 +87,24 @@ pub async fn paste_text(app: tauri::AppHandle, text: String) -> Result<PasteOutc
}
}
// Brief item #10: if the focused window is a known terminal
// emulator, skip the synthesised Ctrl+V — it duplicates the
// keystroke through the PTY, which Kitty / Alacritty / Windows
// Terminal all demonstrably mishandle (Handy #692). Clipboard-only
// paste with a "Terminal detected" message lets the user finish
// with a manual right-click paste or Ctrl+Shift+V.
if let Some(term) = detect_focused_terminal() {
outcome.message = Some(format!(
"Terminal detected ({term}) — transcript is on your clipboard. \
Use Ctrl+Shift+V (or right-click → paste) to insert."
));
// Prior clipboard snapshot is intentionally NOT restored here:
// the user's path to recover the transcript is their own paste,
// which needs the transcript on the clipboard. The restore
// task below only runs when `outcome.pasted` is true.
return Ok(outcome);
}
hide_preview_overlay_for_paste(&app).await;
match trigger_paste_keystroke() {
@@ -77,9 +115,53 @@ pub async fn paste_text(app: tauri::AppHandle, text: String) -> Result<PasteOutc
Err(err) => outcome.message = Some(err),
}
// Fire-and-forget: restore the prior clipboard in the background
// after CLIPBOARD_RESTORE_MS, regardless of whether the paste
// succeeded. If paste failed, the transcript is still on the
// clipboard and Kon's own "Copy" button remains the user's
// recovery path; overwriting it with the restore would undo that.
// So only restore when the keystroke actually fired.
if outcome.pasted {
if let Some(prior) = prior_clipboard.clone() {
let transcript_clone = text.clone();
tokio::spawn(async move {
tokio::time::sleep(Duration::from_millis(CLIPBOARD_RESTORE_MS)).await;
restore_prior_clipboard(&prior, &transcript_clone);
});
}
}
Ok(outcome)
}
/// Write `prior` back to the clipboard only if the current clipboard
/// content is still the transcript we just set — i.e. the user hasn't
/// already copied something new themselves in the last 300 ms. This
/// avoids stomping a deliberate Cmd+C that happened right after the
/// paste.
fn restore_prior_clipboard(prior: &str, transcript: &str) {
let mut cb = match Clipboard::new() {
Ok(cb) => cb,
Err(_) => return,
};
if should_restore(cb.get_text().ok().as_deref(), transcript) {
let _ = cb.set_text(prior.to_string());
}
}
/// Pure decision function: should we restore the prior clipboard?
///
/// - Some(current) == transcript → yes, the clipboard still holds
/// what we wrote, so restore is safe.
/// - Some(current) != transcript → no, user has already copied
/// something else in the 300 ms window; respect it.
/// - None → couldn't read the clipboard (image, no access, etc.);
/// conservative no-op.
fn should_restore(current: Option<&str>, transcript: &str) -> bool {
matches!(current, Some(text) if text == transcript)
}
/// Hide the transcription-preview window if it's currently visible, then
/// sleep a short beat so the compositor can recompute focus. No-ops when
/// the window isn't registered yet (user never enabled the overlay) or
@@ -125,6 +207,152 @@ pub fn detect_paste_backends() -> Vec<String> {
available
}
/// Well-known terminal-emulator window classes / process names. Each
/// entry is matched case-insensitively against whatever the
/// focused-window probe returns on each platform. The list is
/// deliberately conservative — users can always copy + paste manually,
/// so false negatives are cheap; false positives silently break
/// auto-paste on a legitimate GUI, which is expensive.
/// Longest / most specific matches come first so that `classify_terminal`
/// returns the specific name when two needles would both hit
/// (e.g. "windowsterminal" contains "terminal"). Substring match is
/// otherwise blunt enough that a `foot`/`pwsh` match on a non-terminal
/// window would be vanishingly unlikely.
const KNOWN_TERMINAL_CLASSES: &[&str] = &[
// Windows — specific names first so they win over plain "terminal".
"windowsterminal",
"powershell",
"conhost",
"console",
"pwsh",
"cmd",
// macOS
"iterm2",
"iterm",
"terminal",
// Linux
"gnome-terminal-server",
"alacritty",
"konsole",
"wezterm",
"kitty",
"tilix",
"xterm",
"urxvt",
"st-256color",
"hyper",
"foot",
];
/// Return `Some(class)` when the focused window matches a known
/// terminal emulator, `None` otherwise. Pure by design: the actual
/// platform probe is isolated in `detect_focused_window_class` so the
/// classification rule is unit-testable.
fn detect_focused_terminal() -> Option<String> {
let raw = detect_focused_window_class()?;
classify_terminal(&raw)
}
/// Case-insensitive substring match of the focused window class /
/// process name against `KNOWN_TERMINAL_CLASSES`. Returns the matched
/// needle (for user-facing copy: "Terminal detected (kitty)") when
/// positive.
fn classify_terminal(raw: &str) -> Option<String> {
let haystack = raw.to_ascii_lowercase();
for needle in KNOWN_TERMINAL_CLASSES {
if haystack.contains(needle) {
return Some((*needle).to_string());
}
}
None
}
/// Probe the focused window's class name / process name. Returns
/// `None` when the probe tool is missing or fails — we fall back to
/// the default auto-paste path rather than guessing wrong and
/// breaking a GUI user's workflow.
fn detect_focused_window_class() -> Option<String> {
#[cfg(target_os = "linux")]
{
return detect_focused_window_class_linux();
}
#[cfg(target_os = "macos")]
{
return detect_focused_window_class_macos();
}
#[cfg(target_os = "windows")]
{
return detect_focused_window_class_windows();
}
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
{
return None;
}
}
#[cfg(target_os = "linux")]
fn detect_focused_window_class_linux() -> Option<String> {
// X11: xdotool getactivewindow getwindowclassname
if let Ok(output) = Command::new("xdotool")
.args(["getactivewindow", "getwindowclassname"])
.output()
{
if output.status.success() {
let class = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !class.is_empty() {
return Some(class);
}
}
}
// Wayland: no reliable focused-window probe from an unprivileged
// client (by design). Return None and let the normal paste path
// run; users on Kitty-under-Wayland already lean on Ctrl+Shift+V
// themselves.
None
}
#[cfg(target_os = "macos")]
fn detect_focused_window_class_macos() -> Option<String> {
// `osascript -e 'tell application "System Events" to get name of first application process whose frontmost is true'`
let output = Command::new("osascript")
.args([
"-e",
"tell application \"System Events\" to get name of first application process whose frontmost is true",
])
.output()
.ok()?;
if !output.status.success() {
return None;
}
let name = String::from_utf8_lossy(&output.stdout).trim().to_string();
if name.is_empty() { None } else { Some(name) }
}
#[cfg(target_os = "windows")]
fn detect_focused_window_class_windows() -> Option<String> {
// PowerShell one-liner: (Get-Process -Id (Get-Process | Where-Object ...
// Keeping it simple here — GetForegroundWindow + GetWindowThreadProcessId
// via PowerShell works without a `windows` crate dep.
let script = r#"
$w = Add-Type -MemberDefinition '
[DllImport("user32.dll")] public static extern System.IntPtr GetForegroundWindow();
[DllImport("user32.dll")] public static extern int GetWindowThreadProcessId(System.IntPtr hWnd, out int pid);
' -Name W -PassThru
$pid = 0
[void]$w::GetWindowThreadProcessId($w::GetForegroundWindow(), [ref]$pid)
(Get-Process -Id $pid).ProcessName
"#;
let output = Command::new("powershell")
.args(["-NoProfile", "-Command", script])
.output()
.ok()?;
if !output.status.success() {
return None;
}
let name = String::from_utf8_lossy(&output.stdout).trim().to_string();
if name.is_empty() { None } else { Some(name) }
}
fn trigger_paste_keystroke() -> Result<String, String> {
#[cfg(target_os = "linux")]
{
@@ -262,7 +490,7 @@ fn windows_paste() -> Result<String, String> {
}
#[cfg(all(test, target_os = "linux"))]
mod tests {
mod tests_linux {
use super::pick_linux_backend_order;
#[test]
@@ -297,3 +525,79 @@ mod tests {
);
}
}
#[cfg(test)]
mod tests_clipboard_restore {
use super::should_restore;
#[test]
fn restores_when_clipboard_still_holds_transcript() {
assert!(should_restore(Some("dictated text"), "dictated text"));
}
#[test]
fn does_not_restore_when_user_copied_something_else() {
assert!(!should_restore(
Some("user copied this just now"),
"dictated text"
));
}
#[test]
fn does_not_restore_when_clipboard_unreadable() {
assert!(!should_restore(None, "dictated text"));
}
#[test]
fn does_not_restore_on_empty_mismatch() {
assert!(!should_restore(Some(""), "dictated text"));
}
}
#[cfg(test)]
mod tests_terminal_classification {
use super::classify_terminal;
#[test]
fn kitty_class_is_a_terminal() {
assert_eq!(classify_terminal("kitty"), Some("kitty".into()));
}
#[test]
fn alacritty_matches_case_insensitively() {
assert_eq!(classify_terminal("Alacritty"), Some("alacritty".into()));
}
#[test]
fn windows_terminal_matches_collapsed_name() {
// Windows reports the process name sans space: "WindowsTerminal"
assert_eq!(
classify_terminal("WindowsTerminal"),
Some("windowsterminal".into()),
);
}
#[test]
fn iterm_matches_iterm2_as_well() {
assert_eq!(classify_terminal("iTerm2"), Some("iterm2".into()));
}
#[test]
fn firefox_is_not_a_terminal() {
assert_eq!(classify_terminal("firefox"), None);
}
#[test]
fn empty_class_is_not_a_terminal() {
assert_eq!(classify_terminal(""), None);
}
#[test]
fn substring_match_survives_xdotool_decorations() {
// Some X11 window managers report "Alacritty.Alacritty"
assert_eq!(
classify_terminal("Alacritty.Alacritty"),
Some("alacritty".into()),
);
}
}

View File

@@ -1,8 +1,21 @@
<script lang="ts">
import { settings, saveSettings } from "$lib/stores/page.svelte.js";
import { osInfo } from "$lib/utils/osInfo";
import { validateHotkey, type HotkeyOs } from "$lib/utils/hotkeyValidity";
let recording = $state(false);
let captured = $state(false);
let rejectionReason = $state<string | null>(null);
function currentOs(): HotkeyOs {
const info = osInfo();
if (!info) return "unknown";
const name = (info.os || "").toLowerCase();
if (name.startsWith("mac")) return "macos";
if (name.startsWith("win")) return "windows";
if (name.startsWith("linux")) return "linux";
return "unknown";
}
// Linux webkit2gtk fires e.key === "Super" for the Windows/Super key;
// Firefox/older Chrome may fire "OS" or "Hyper". All of these should be
@@ -109,7 +122,20 @@
}
parts.push(resolveTriggerKey(e));
settings.globalHotkey = parts.join("+");
const candidate = parts.join("+");
// Per-OS capability matrix — reject combos the backend can't
// reliably bind before they land in settings.globalHotkey (brief
// item #5). Leaves the previous, known-good hotkey intact.
const check = validateHotkey(candidate, currentOs());
if (!check.valid) {
rejectionReason = check.reason;
recording = false;
return;
}
rejectionReason = null;
settings.globalHotkey = candidate;
saveSettings();
recording = false;
captured = true;
@@ -138,6 +164,7 @@
function startRecording() {
recording = true;
captured = false;
rejectionReason = null;
}
let chips = $derived(settings.globalHotkey.split("+"));
@@ -155,29 +182,36 @@
);
</script>
<button
type="button"
class="inline-flex items-center gap-1.5 rounded-xl px-4 py-2.5
{recording
? 'bg-bg-input border-accent shadow-[0_0_0_3px_rgba(232,168,124,0.1)]'
: captured
? 'bg-bg-input border-border animate-pulse-warm'
: 'bg-bg-input border-border hover:border-border'}
border transition-all"
onclick={startRecording}
aria-label={ariaLabel}
aria-pressed={recording}
>
<span class="sr-only" aria-live="polite">{srStatus}</span>
{#if recording}
<span class="text-[11px] text-text-tertiary italic">Hold Ctrl/Alt/Super + key...</span>
{:else}
{#each chips as chip, i}
{#if i > 0}
<span class="text-[10px] text-text-tertiary" aria-hidden="true">+</span>
{/if}
<span class="px-2 py-0.5 rounded-md bg-bg-elevated border border-border text-[11px] font-medium text-text
shadow-[inset_0_1px_2px_rgba(0,0,0,0.3)]">{chip}</span>
{/each}
<div class="inline-flex flex-col items-start gap-1.5">
<button
type="button"
class="inline-flex items-center gap-1.5 rounded-xl px-4 py-2.5
{recording
? 'bg-bg-input border-accent shadow-[0_0_0_3px_rgba(232,168,124,0.1)]'
: captured
? 'bg-bg-input border-border animate-pulse-warm'
: 'bg-bg-input border-border hover:border-border'}
border transition-all"
onclick={startRecording}
aria-label={ariaLabel}
aria-pressed={recording}
>
<span class="sr-only" aria-live="polite">{srStatus}</span>
{#if recording}
<span class="text-[11px] text-text-tertiary italic">Hold Ctrl/Alt/Super + key...</span>
{:else}
{#each chips as chip, i}
{#if i > 0}
<span class="text-[10px] text-text-tertiary" aria-hidden="true">+</span>
{/if}
<span class="px-2 py-0.5 rounded-md bg-bg-elevated border border-border text-[11px] font-medium text-text
shadow-[inset_0_1px_2px_rgba(0,0,0,0.3)]">{chip}</span>
{/each}
{/if}
</button>
{#if rejectionReason}
<span class="text-[11px] text-warning" role="alert">
Couldn't bind that combo — {rejectionReason}.
</span>
{/if}
</button>
</div>

View File

@@ -18,6 +18,10 @@ import type {
import { toasts } from "$lib/stores/toasts.svelte.ts";
import { errorMessage } from "$lib/utils/errors.js";
import { parseStoredJson } from "$lib/utils/storage.js";
import {
loadSettingsWithMigration,
saveSettingsWithVersion,
} from "$lib/utils/settingsMigrations.js";
import { hasTauriRuntime } from "$lib/utils/runtime.js";
export const page = $state<PageState>({
@@ -68,9 +72,25 @@ function canUseStorage(): boolean {
function loadSettings(): SettingsState {
if (!canUseStorage()) return { ...defaults };
// Versioned migration: reads the envelope or an unversioned legacy
// blob, runs the migration chain (currently identity: bare object ->
// v1), and returns the current-shape data. Falls back to defaults
// on corruption — the toast below surfaces the reset to the user.
const migrated = loadSettingsWithMigration<Partial<SettingsState>>(SETTINGS_KEY);
if (migrated === null && localStorage.getItem(SETTINGS_KEY) !== null) {
// There was data but it failed to migrate — tell the user we
// reset rather than silently mangling their preferences.
queueMicrotask(() => {
toasts.warn(
"Settings reset",
"Your saved settings couldn't be read, so Kon fell back to defaults. "
+ "Earlier Kon builds can still read your old data.",
);
});
}
return {
...defaults,
...(parseStoredJson<Partial<SettingsState>>(localStorage.getItem(SETTINGS_KEY)) ?? {}),
...(migrated ?? {}),
};
}
@@ -78,9 +98,11 @@ export const settings = $state<SettingsState>(loadSettings());
export function saveSettings() {
if (!canUseStorage()) return;
try {
localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
} catch {}
// Versioned envelope {version, data}. Older Kon builds that used
// the bare-object layout can still spread the `data` key over
// their defaults — they will just silently drop fields they don't
// know about, which is the same behaviour as today.
saveSettingsWithVersion(SETTINGS_KEY, { ...settings });
}
function loadProfiles(): Profile[] {

View File

@@ -0,0 +1,149 @@
/**
* Per-OS hotkey capability matrix.
*
* Different global-hotkey backends refuse different combos. Rather
* than let the user save a combo the OS will silently ignore, we
* validate before save and surface a clear "why" string.
*
* Covered failures (brief item #5):
* - X11: single-key combos (no modifier) get swallowed by the user's
* editor / IRC client / whatever; they are useless as globals.
* Handy #917 / #1019.
* - Windows: right-hand modifiers (RCtrl, RAlt) don't always register
* through RegisterHotKey; the user gets a dead shortcut. Handy
* #966. We reject a combo whose only modifier is a right-hand one
* — if the user binds Ctrl+R we don't care whether left or right
* Ctrl fired it.
* - macOS: Fn-only combos require an NSEvent private API that
* RegisterHotKey-equivalents don't expose (Whispering #549 area).
* - Wayland (Linux): single-key combos are rejected even more
* aggressively than X11 because compositors vary on whether they
* forward a bare key to a global listener at all.
*
* All rules are pure — the UI calls `validateHotkey(combo, os)` and
* gates save on `result.valid`. No backend round-trip required.
*/
export type HotkeyOs = "windows" | "macos" | "linux" | "unknown";
export interface HotkeyValidity {
/** Whether this combo is safe to save. */
valid: boolean;
/**
* User-visible "why not" string when `valid` is false. Null when
* the combo is OK. Copy is written for inline display — no leading
* capitalisation assumption, no trailing period needed.
*/
reason: string | null;
}
export interface HotkeyParts {
modifiers: string[]; // e.g. ["Ctrl", "Shift"]
trigger: string; // e.g. "R" — may be empty if we're mid-record
}
/** Tokenise a `+`-joined hotkey the way the kon-hotkey parser does,
* but without demanding a fully-valid combo (we validate next). The
* last part is always the trigger key; everything before is a
* modifier. An empty/whitespace combo returns empty parts. */
export function parseHotkey(combo: string): HotkeyParts {
const trimmed = combo?.trim?.() ?? "";
if (!trimmed) return { modifiers: [], trigger: "" };
const tokens = trimmed.split("+").map((t) => t.trim()).filter(Boolean);
if (tokens.length === 0) return { modifiers: [], trigger: "" };
const trigger = tokens[tokens.length - 1];
const modifiers = tokens.slice(0, -1);
return { modifiers, trigger };
}
// Fn and function-key helpers.
const FUNCTION_KEYS = new Set([
"F1", "F2", "F3", "F4", "F5", "F6",
"F7", "F8", "F9", "F10", "F11", "F12",
"F13", "F14", "F15", "F16", "F17", "F18", "F19", "F20",
"F21", "F22", "F23", "F24",
]);
function normaliseModifier(mod: string): string {
const m = mod.trim().toLowerCase();
if (m === "cmd" || m === "meta" || m === "super" || m === "win") return "super";
if (m === "option") return "alt";
return m;
}
function isRightHandModifier(mod: string): boolean {
const m = mod.trim().toLowerCase();
return m === "rctrl" || m === "ralt" || m === "rshift" || m === "rsuper" || m === "rmeta";
}
function isFnModifier(mod: string): boolean {
return mod.trim().toLowerCase() === "fn";
}
/** Validate a combo for the given OS. `unknown` OS passes unchanged
* — we'd rather ship a flawed combo than reject one we can't check. */
export function validateHotkey(combo: string, os: HotkeyOs): HotkeyValidity {
const parts = parseHotkey(combo);
if (!parts.trigger) {
return {
valid: false,
reason: "pick a key — hotkeys need at least one non-modifier",
};
}
const hasModifier = parts.modifiers.length > 0;
const normalisedMods = parts.modifiers.map(normaliseModifier);
if (os === "linux" || os === "unknown") {
// X11 AND Wayland: single-key globals are a footgun everywhere on
// Linux. Function keys F13+ are the conventional escape hatch
// (they're not on anyone's keyboard, so they don't collide) —
// allow them single-key.
if (!hasModifier && !FUNCTION_KEYS.has(parts.trigger)) {
return {
valid: false,
reason:
"add Ctrl, Shift, Alt, or Super — single keys get eaten by whichever app is focused",
};
}
}
if (os === "windows") {
// Right-hand-only modifiers break RegisterHotKey in some layouts.
const onlyRightHand =
parts.modifiers.length > 0
&& parts.modifiers.every(isRightHandModifier);
if (onlyRightHand) {
return {
valid: false,
reason:
"right-hand-only modifiers (RCtrl / RAlt) don't always register as global shortcuts on Windows — use the left-hand equivalent",
};
}
if (!hasModifier) {
return {
valid: false,
reason: "Windows requires at least one modifier on a global hotkey",
};
}
}
if (os === "macos") {
if (normalisedMods.some(isFnModifier) && parts.modifiers.length === 1) {
return {
valid: false,
reason:
"Fn-only combos aren't available to Tauri global shortcuts on macOS — pair Fn with Cmd/Ctrl/Option, or pick a different trigger",
};
}
if (!hasModifier && !FUNCTION_KEYS.has(parts.trigger)) {
return {
valid: false,
reason:
"add Cmd, Ctrl, Option, or Shift — macOS requires a modifier on bare-key global shortcuts",
};
}
}
return { valid: true, reason: null };
}

View File

@@ -0,0 +1,134 @@
/**
* Forward-compatible, versioned settings migration.
*
* Historically, `localStorage["kon_settings"]` was a bare JSON object
* (whatever `SettingsState` looked like at write time). That shape
* tolerates new fields cleanly — spread over `defaults` — but does not
* survive:
* - renaming a field, even with a trivial default mapping
* - a type change on an existing field
* - a settings reset the user explicitly did not ask for (Handy #602)
*
* Versioned migrations fix all three. Every schema change bumps the
* version constant and lands a `(from, to)` step that takes the stored
* raw blob (or its previous-version transform) and returns the
* next-version shape. `loadSettingsWithMigration` walks the chain
* until it reaches `CURRENT_SETTINGS_VERSION`.
*
* Covers docs/whisper-ecosystem/brief.md item #11.
*/
import { parseStoredJson } from "$lib/utils/storage";
export const CURRENT_SETTINGS_VERSION = 1;
/** Envelope written to localStorage. The raw blob is always kept
* separately so a downgrade (user jumps to an older Kon build) can
* still read v1 data even after we've bumped to v2. */
export interface VersionedSettings<T> {
version: number;
data: T;
}
export type MigrationStep = (prev: unknown) => unknown;
/**
* The migration chain. Add an entry per schema change.
*
* Each step is indexed by its destination version — MIGRATIONS[2] is
* the function that takes v1 data and returns v2 data. To add v2:
*
* MIGRATIONS[2] = (prev) => ({ ...(prev as object), newField: 42 });
*
* Keep the steps pure — they run on load, every boot.
*/
export const MIGRATIONS: Record<number, MigrationStep> = {
// v0 -> v1: fold a raw bare-object blob (pre-versioning) into the
// versioned envelope. Spreading over the caller's `defaults` is the
// caller's job; this step just preserves whatever keys were there.
1: (prev) => (prev && typeof prev === "object" ? prev : {}),
};
function isVersionedEnvelope(raw: unknown): raw is VersionedSettings<unknown> {
return !!raw
&& typeof raw === "object"
&& "version" in (raw as Record<string, unknown>)
&& "data" in (raw as Record<string, unknown>)
&& typeof (raw as Record<string, unknown>).version === "number";
}
/**
* Read `localStorage[key]`, run the migration chain, and return the
* data blob at the current schema version. Callers then spread it
* over their `defaults` to produce a full `SettingsState`.
*
* Corrupt / unparseable input returns `null` — caller falls back to
* defaults.
*/
export function loadSettingsWithMigration<T>(key: string): T | null {
if (typeof localStorage === "undefined") return null;
const raw = localStorage.getItem(key);
if (!raw) return null;
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
return null;
}
let version: number;
let data: unknown;
if (isVersionedEnvelope(parsed)) {
version = parsed.version;
data = parsed.data;
} else {
version = 0;
data = parsed;
}
// Apply each migration step from (version + 1) up to the current
// schema version. A missing step between two known versions is a
// programming error — we warn and return null so the caller falls
// back to defaults instead of silently skipping.
for (let v = version + 1; v <= CURRENT_SETTINGS_VERSION; v++) {
const step = MIGRATIONS[v];
if (!step) {
console.warn(
`settingsMigrations: missing step to v${v} (loaded v${version}, target v${CURRENT_SETTINGS_VERSION})`,
);
return null;
}
data = step(data);
}
return data as T;
}
/**
* Serialise the current settings object into a versioned envelope and
* write it to localStorage. Idempotent — writing the same data twice
* produces the same bytes.
*/
export function saveSettingsWithVersion<T>(key: string, data: T): void {
if (typeof localStorage === "undefined") return;
const envelope: VersionedSettings<T> = {
version: CURRENT_SETTINGS_VERSION,
data,
};
try {
localStorage.setItem(key, JSON.stringify(envelope));
} catch {
// Quota exceeded / private-mode localStorage — drop the write
// rather than throwing into the UI. Settings are regenerated from
// memory on next save, or from defaults on next boot.
}
}
/**
* Back-compat shim: older call sites used `parseStoredJson` directly.
* When a caller hasn't migrated yet, they can keep using this for
* non-versioned blobs without disturbing the settings schema.
*/
export { parseStoredJson };

View File

@@ -136,12 +136,25 @@
}
}
// Listen for evdev hotkey events from the Rust backend
// Listen for evdev hotkey events from the Rust backend.
//
// Debounce window: evdev autorepeat, a sticky-key compositor quirk,
// or a user's nervous double-tap can all deliver the same press
// twice within ~100 ms — which, without debouncing, toggles the
// recording into and out of the same frame and loses the capture.
// Matches Handy #1143 ('first press records nothing, second works').
// This is the UX side of brief item #4; the audio-stream warm-up
// side is owned by Workstream A.
const HOTKEY_DEBOUNCE_MS = 120;
let lastHotkeyAtMs = 0;
let unlistenEvdev = null;
async function setupEvdevListener() {
if (!tauriRuntimeAvailable) return;
const { listen } = await import("@tauri-apps/api/event");
unlistenEvdev = await listen("kon:hotkey-pressed", () => {
const now = Date.now();
if (now - lastHotkeyAtMs < HOTKEY_DEBOUNCE_MS) return;
lastHotkeyAtMs = now;
if (page.current !== "dictation") page.current = "dictation";
requestAnimationFrame(() => {
window.dispatchEvent(new CustomEvent("kon:toggle-recording"));