Add dual-trigger dictation with raw and cleaned modes - #105
Conversation
- Two lone-modifier hotkeys: a raw (verbatim) key and a cleaned-up key. The mode is latched at key-press and threaded through the pipeline to the transcribe request. - New engine types: DictationMode, DualTriggerRouter (single gate + per-key mode ownership), RawTriggerKeyStore, DictationTriggerPair (distinctness via swap), CleanupPromptStore. - Cleanup is a single server-side dictation-API request: the cleaned key sends the llm block; the raw key omits it. The cleanup prompt is now user-editable and sent as llm.instruction (empty selects the server default, byte-identical to before). - Settings: two hotkey pickers with swap-on-collision; the enhanced-transcripts toggle is replaced by a cleanup-prompt editor. - Removed EnhancedTranscriptsStore and the now-unused DictationKeyRouter; updated AGENTS.md, project-guardrails, and engine docs.
- Route the Settings cleanup-prompt write through CleanupPromptStore's setter (instead of binding @AppStorage directly), so the setter + characterCap are app-reachable (unblocks periphery --strict) and cap-truncation/blank-removal are restored. Setter now stores the raw (capped) value; getter still trims. - Set activeMode only after the terminal-phase guard in performPress, so a press rejected while a prior dictation is still transcribing can't clobber the in-flight session's mode. Adds a gated-transcriber regression test. - HIG: full-width cleanup TextField (@ViewBuilder label + labelsHidden); rename the hotkey section header "Shortcut" -> "Shortcuts". - Add the missing symmetric DictationTriggerPair free-key assign test.
There was a problem hiding this comment.
Pull request overview
Adds dual-trigger dictation to Blurt by introducing a DictationMode (raw vs cleaned) and threading that choice from the hotkey event tap through DictationSession into AssemblyAITranscriber, where it controls whether the request includes the server-side llm cleanup rewrite block.
Changes:
- Replaces the single-trigger router with
DualTriggerRouterand adds stores/value-types to support two distinct trigger keys. - Extends
TranscriberProtocolandAssemblyAITranscriberwith a per-callcleanupflag and replaces the enhanced-transcripts toggle with an editable cleanup instruction (CleanupPromptStore). - Updates app UI (two hotkey pickers + cleanup prompt editor) and adds/updates engine tests to pin mode threading and request shape.
Reviewed changes
Copilot reviewed 35 out of 35 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| Tests/BlurtEngineTests/Stubs/StubTranscriber.swift | Updates stub transcriber to the new cleanup parameter. |
| Tests/BlurtEngineTests/RawTriggerKeyStoreTests.swift | Adds coverage for raw trigger key persistence and fallback behavior. |
| Tests/BlurtEngineTests/PersistedSettingsTests.swift | Updates reset roster expectations for new defaults keys (raw key + cleanup prompt). |
| Tests/BlurtEngineTests/EnhancedTranscriptsStoreTests.swift | Removes tests for the deleted enhanced-transcripts toggle store. |
| Tests/BlurtEngineTests/DualTriggerRouterTests.swift | Adds coverage for dual-trigger routing (ownership, edge dedup, reset/rebind). |
| Tests/BlurtEngineTests/DictationTriggerPairTests.swift | Pins swap-on-collision behavior to keep triggers distinct. |
| Tests/BlurtEngineTests/DictationSessionSubmitTests.swift | Updates submit commands to carry DictationMode. |
| Tests/BlurtEngineTests/DictationSessionModeTests.swift | Adds tests ensuring mode threads to transcriber cleanup and survives race guards. |
| Tests/BlurtEngineTests/DictationModeTests.swift | Pins DictationMode → cleansUp mapping. |
| Tests/BlurtEngineTests/DictationKeyRouterTests.swift | Removes tests for the deleted single-trigger router. |
| Tests/BlurtEngineTests/CleanupPromptStoreTests.swift | Adds persistence/normalization/cap tests for the cleanup instruction store. |
| Tests/BlurtEngineTests/CancelRaceTests.swift | Updates gated transcriber stub to accept cleanup. |
| Tests/BlurtEngineTests/AssemblyAITranscriberTests.swift | Updates request-shape assertions for cleanup + cleanup instruction behavior. |
| Sources/BlurtEngine/STT/TranscriberProtocol.swift | Extends protocol surface with cleanup: Bool. |
| Sources/BlurtEngine/STT/AssemblyAITranscriber.swift | Implements per-call cleanup selection and attaches optional llm.instruction. |
| Sources/BlurtEngine/Pipeline/DictationSession+Pipeline.swift | Threads DictationMode into the transcriber cleanup flag. |
| Sources/BlurtEngine/Pipeline/DictationSession+Commands.swift | Updates command enum so .press carries DictationMode. |
| Sources/BlurtEngine/Pipeline/DictationSession.swift | Captures activeMode at press-time and guards against clobbering mid-flight. |
| Sources/BlurtEngine/Hotkey/RawTriggerKeyStore.swift | Adds separate defaults-backed store for the raw trigger key. |
| Sources/BlurtEngine/Hotkey/DualTriggerRouter.swift | Adds a two-key router over a shared DictationKeyGate, reporting owning mode. |
| Sources/BlurtEngine/Hotkey/DictationTriggerPair.swift | Adds a value type to keep raw/cleaned keys distinct via swapping. |
| Sources/BlurtEngine/Hotkey/DictationMode.swift | Introduces .raw vs .cleaned mode used across hotkey → pipeline → STT. |
| Sources/BlurtEngine/Hotkey/DictationKeyRouter.swift | Deletes the old single-trigger router implementation. |
| Sources/BlurtEngine/Config/PersistedSettings.swift | Adds raw trigger + cleanup prompt keys to the reset roster. |
| Sources/BlurtEngine/Config/EnhancedTranscriptsStore.swift | Deletes the enhanced-transcripts toggle store. |
| Sources/BlurtEngine/Config/CleanupPromptStore.swift | Adds defaults-backed cleanup instruction store (currently has access control issues). |
| BLURTENGINE.md | Updates public engine documentation to reflect mode + cleanup flag. |
| App/Blurt/Shared/UITestIdentifiers.swift | Updates identifiers for dual hotkey pickers and cleanup prompt field. |
| App/Blurt/Blurt/Wizard/Steps/HotkeyStepView.swift | Adds two pickers and collision-swapping persistence via DictationTriggerPair. |
| App/Blurt/Blurt/Wizard/SettingsWindowRoot.swift | Replaces toggle with a cleanup instruction editor bound to CleanupPromptStore. |
| App/Blurt/Blurt/UITestSupport.swift | Updates UI-test transcriber stub signature for cleanup. |
| App/Blurt/Blurt/Hotkey/DictationKeyTap.swift | Swaps tap wiring to DualTriggerRouter and forwards owning mode to callback. |
| App/Blurt/Blurt/AppCoordinator.swift | Threads mode from key tap into the session .press(mode) command. |
| AGENTS.md | Updates repo-level architecture docs to reflect dual-trigger + per-press cleanup. |
| .claude/skills/project-guardrails/SKILL.md | Updates guardrails doc to reflect per-press server-side cleanup and dual triggers. |
Comments suppressed due to low confidence (1)
Sources/BlurtEngine/Config/CleanupPromptStore.swift:42
instructionis currently internal, but the app module sets it viaCleanupPromptStore().instruction = …(seeSettingsWindowRoot). This should bepublicso the settings UI can compile and so the store matches the access level pattern used by other settings stores (e.g.TriggerKeyStore.triggerKey).
var instruction: String? {
get { defaults.string(forKey: Self.defaultsKey).trimmedNonEmpty() }
nonmutating set {
guard newValue.trimmedNonEmpty() != nil, let value = newValue else {
defaults.removeObject(forKey: Self.defaultsKey)
return
}
defaults.set(String(value.prefix(Self.characterCap)), forKey: Self.defaultsKey)
}
}
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Fixes the cross-module app-build failure: SettingsWindowRoot writes through CleanupPromptStore().instruction, which requires public init and property.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 35 out of 35 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (1)
App/Blurt/Blurt/Hotkey/DictationKeyTap.swift:170
refreshBinding()reads the two persisted trigger keys and feeds them straight intoDualTriggerRouter, but it never enforces that they’re distinct. This can happen for real users on upgrade (e.g. they previously set the single trigger to right ⌥; the new raw key defaults to right ⌥ too), leaving both keycodes equal — in that case the router will treat the shared key as the raw trigger and the cleaned trigger will never start.
Consider resolving collisions here (preserving the existing cleaned key and picking a distinct raw fallback) and persisting the resolved pair back to UserDefaults so both the tap and Settings UI converge immediately.
func refreshBinding() {
let rawKey = RawTriggerKeyStore().triggerKey
let cleanedKey = TriggerKeyStore().triggerKey
rawFlag = Self.flag(for: rawKey)
cleanedFlag = Self.flag(for: cleanedKey)
if router.rebind(rawKeyCode: rawKey.keyCode, cleanedKeyCode: cleanedKey.keyCode) {
What & why
Blurt now supports two dictation trigger keys instead of one:
This required threading the
DictationModechoice through the entire pipeline:Engine changes:
DictationKeyRouter(single trigger) withDualTriggerRouter(two triggers sharing one gate)DictationKeyGateDictationMode.rawor.cleaned) started each session viaOutcomeDictationModeenum to represent the two transcript typesDictationTriggerPairvalue type to keep the two keys distinct (swaps on collision rather than rejecting)RawTriggerKeyStoreto persist the raw trigger's keycode (defaults to right ⌥, distinct from cleaned's right ⌘)EnhancedTranscriptsStore(boolean on/off) withCleanupPromptStore(editable instruction text)llmblock with this instruction; raw omits it entirely{}to use AssemblyAI's default cleanup rewriteDictationSessionto accept and threadDictationModethrough the pipelineAssemblyAITranscriber.transcribeto accept acleanupflag that controls whether thellmblock is includedApp changes:
DictationKeyTapto drive both triggers and report the owning mode toonStartHotkeyStepViewto show two pickers (one per mode) instead of oneSettingsWindowRootto show the cleanup instruction editor instead of the enhanced-transcripts toggleAppCoordinatorto pass the mode from the tap through to the sessionTests:
DictationKeyRouterTests; addedDualTriggerRouterTestscovering ownership, edge dedup, and combo detectionDictationModeTests,DictationTriggerPairTests,RawTriggerKeyStoreTests,CleanupPromptStoreTestsDictationSessionModeTestspinning that the mode threads through to the transcriber'scleanupflagAssemblyAITranscriberTeststo verify thellmblock is present for cleaned and absent for rawHow it was tested
scripts/check.shpassescleanupparameter to transcriber callshttps://claude.ai/code/session_01EuDVZMSmFoRjkewQCX7bjg