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>
15 KiB
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:
- 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.
- 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.
- "Translator, not editor" (item #16) is the formatting contract. Load-bearing. Any prompt edit that softens it gets rejected at review.
- 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:
// 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 minimaltsc --noEmitinsidenpm 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_endpointtoday) land with a unit test that passes without a live Ollama instance (test against a temporaryTcpListenerthat 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.rsforCLEANUP_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 123in Kate, copy it, dictate a sentence, paste (autoPaste on). Verify within 1s Kate's clipboard is back totesting 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
activeComputeDevicelabel + dropdown is disabled / hidden per stubs. - Dictate "send this to my mum", pick
Emailpreset, 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.