Skip to content

Add dual-trigger dictation with raw and cleaned modes - #105

Open
alexkroman wants to merge 3 commits into
mainfrom
claude/slack-session-ekjvcq
Open

Add dual-trigger dictation with raw and cleaned modes#105
alexkroman wants to merge 3 commits into
mainfrom
claude/slack-session-ekjvcq

Conversation

@alexkroman

Copy link
Copy Markdown
Collaborator

What & why

Blurt now supports two dictation trigger keys instead of one:

  • A cleaned key that pastes the server-side LLM cleanup rewrite (polished text)
  • A raw key that pastes the verbatim transcript (exactly as spoken)

This required threading the DictationMode choice through the entire pipeline:

Engine changes:

  • Replaced DictationKeyRouter (single trigger) with DualTriggerRouter (two triggers sharing one gate)
    • Routes flag changes from both keys into a shared DictationKeyGate
    • Tracks ownership: whichever key opens the idle gate owns that session; the other key is ignored until idle
    • Reports which mode (DictationMode.raw or .cleaned) started each session via Outcome
  • Added DictationMode enum to represent the two transcript types
  • Added DictationTriggerPair value type to keep the two keys distinct (swaps on collision rather than rejecting)
  • Added RawTriggerKeyStore to persist the raw trigger's keycode (defaults to right ⌥, distinct from cleaned's right ⌘)
  • Replaced EnhancedTranscriptsStore (boolean on/off) with CleanupPromptStore (editable instruction text)
    • The cleaned mode's request includes the llm block with this instruction; raw omits it entirely
    • Empty instruction sends {} to use AssemblyAI's default cleanup rewrite
  • Updated DictationSession to accept and thread DictationMode through the pipeline
  • Updated AssemblyAITranscriber.transcribe to accept a cleanup flag that controls whether the llm block is included

App changes:

  • Updated DictationKeyTap to drive both triggers and report the owning mode to onStart
  • Updated HotkeyStepView to show two pickers (one per mode) instead of one
  • Updated SettingsWindowRoot to show the cleanup instruction editor instead of the enhanced-transcripts toggle
  • Updated AppCoordinator to pass the mode from the tap through to the session

Tests:

  • Removed DictationKeyRouterTests; added DualTriggerRouterTests covering ownership, edge dedup, and combo detection
  • Added DictationModeTests, DictationTriggerPairTests, RawTriggerKeyStoreTests, CleanupPromptStoreTests
  • Added DictationSessionModeTests pinning that the mode threads through to the transcriber's cleanup flag
  • Updated AssemblyAITranscriberTests to verify the llm block is present for cleaned and absent for raw

How it was tested

  • scripts/check.sh passes
  • All new unit tests pass (dual-trigger routing, mode threading, trigger pair distinctness, cleanup instruction persistence)
  • Existing tests updated to pass the new cleanup parameter to transcriber calls

https://claude.ai/code/session_01EuDVZMSmFoRjkewQCX7bjg

claude added 2 commits July 30, 2026 02:20
- 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.
Copilot AI review requested due to automatic review settings July 30, 2026 02:34

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 DualTriggerRouter and adds stores/value-types to support two distinct trigger keys.
  • Extends TranscriberProtocol and AssemblyAITranscriber with a per-call cleanup flag 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 DictationModecleansUp 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

  • instruction is currently internal, but the app module sets it via CleanupPromptStore().instruction = … (see SettingsWindowRoot). This should be public so 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.

Comment thread Sources/BlurtEngine/Config/CleanupPromptStore.swift Outdated
Fixes the cross-module app-build failure: SettingsWindowRoot writes through
CleanupPromptStore().instruction, which requires public init and property.
Copilot AI review requested due to automatic review settings July 30, 2026 02:41

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 into DualTriggerRouter, 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) {

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants