Skip to content

In toggle mode, tap any one shortcut key (e.g. Fn or Space from Fn+Space) to end dictation — not the full combo#225

Open
pianoandglass wants to merge 1 commit into
zachlatta:mainfrom
pianoandglass:feat/toggle-stop-on-any-key-pr
Open

In toggle mode, tap any one shortcut key (e.g. Fn or Space from Fn+Space) to end dictation — not the full combo#225
pianoandglass wants to merge 1 commit into
zachlatta:mainfrom
pianoandglass:feat/toggle-stop-on-any-key-pr

Conversation

@pianoandglass

@pianoandglass pianoandglass commented Jun 6, 2026

Copy link
Copy Markdown

Description

In Tap to Toggle mode, the dictation shortcut is a combination of two or more keys (e.g. Fn + Space, or Fn + Control + Space) that starts dictation with a single tap — no holding required. This PR changes how you end that dictation.

Before → After

  • Before: with a Fn + Space shortcut, ending dictation required pressing the whole Fn + Space combo again, or clicking the on-screen Stop button.
  • After: with the same Fn + Space shortcut, tapping either Fn or Space on its own ends the dictation and sends it for transcription. This works for any multi-key combination (e.g. Fn + Control + Space → any one of the three keys ends it). Pressing Space to stop does not type a stray space into your text field.

Changed Files

File Changes
Sources/AppState.swift Implemented the .consumeOnly action to swallow trailing shortcut keys so they don't leak into the active text field.
Sources/ShortcutCore/DictationShortcutSessionController.swift Added the ignoreToggleUntilComponentDeactivation flag to correctly track and consume stop-sequence events. Implemented the .consumeOnly action to prevent trailing keys from leaking.
Sources/ShortcutCore/ShortcutMatcher.swift Added logic to detect individual toggle component inputs and emit stateless toggleComponentInputReceived events. Refactored modifier extraction to use safe if let binding instead of force unwrapping.
Sources/ShortcutCore/ShortcutModels.swift Added the toggleComponentInputReceived event to the ShortcutEvent enumeration.
Sources/HotkeyManager.swift Updated the onShortcutEvent handler to evaluate multiple emitted events per loop and correctly flag when a key press must be consumed by the OS.
Sources/SetupTestHotkeyHarness.swift Updated the event closure signature to handle and consume the new .consumeOnly action.
Sources/SetupView.swift Added support for the .consumeOnly action in the shortcut action switch statement.

Technical Details: Consuming Trailing Keys and Edge Cases

Handling global multi-key shortcuts requires carefully consuming keyboard events so they do not accidentally leak into the active text editor. The logic handles the following edge cases:

  • Consuming the Space Key (Trailing Keys): When a user stops dictation by releasing a sequence (like Fn + Space), they might release Fn before Space. This triggers a toggleComponentInputReceived event. The session controller intercepts this stateless event and returns .consumeOnly, which tells macOS to completely swallow the keystroke, preventing a stray space from being typed into the text box.
  • Component-Level Deactivation: To prevent the keyboard from getting permanently locked, the system tracks when all components of the shortcut are physically released (toggleComponentDeactivated). It only resets the ignore state when the user completely lifts their fingers off the keys, preventing accidental double-triggers.

Summary by CodeRabbit

Release Notes

  • New Features

    • Improved hotkey behavior with clearer “consumed vs. passed through” handling.
    • Added finer-grained toggle shortcut lifecycle tracking, including component-level activation, input, and deactivation.
  • Bug Fixes

    • Refined event consumption to prevent unintended keyboard input propagation.
    • Improved dictation toggle responsiveness, including safer handling during rapid start/stop and state transitions.

@coderabbitai

coderabbitai Bot commented Jun 6, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Important

Review skipped

No new commits to review since the last review.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: f5a11494-379d-4c79-af76-ca07f6004819

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

This PR changes shortcut callbacks to return whether an input was consumed and adds toggle component lifecycle events. Hotkey handling, session control, and test harness wiring are updated to use the new boolean flow and the new consume-only action.

Changes

Toggle component events and hotkey consumption pipeline

Layer / File(s) Summary
Toggle component lifecycle events and ShortcutMatcher state tracking
Sources/ShortcutCore/ShortcutModels.swift, Sources/ShortcutCore/ShortcutMatcher.swift
ShortcutEvent adds toggle component lifecycle cases. ShortcutInputState gains toggle component state and classification helpers. ShortcutMatcher emits toggle component input, activation, and deactivation events while tracking partial component activity.
HotkeyManager event consumption callback refactor
Sources/HotkeyManager.swift
onShortcutEvent changes to return Bool. handleInputEvent uses callback returns plus matcher consumption decisions to determine whether the input event is consumed.
AppState synchronous hotkey handling
Sources/AppState.swift
startHotkeyMonitoring returns the shortcut handler result directly. handleShortcutEvent now returns Bool and consumes copy-again, consume-only, and stop actions.
DictationShortcutSessionController consume-only action and toggle ignore window
Sources/ShortcutCore/DictationShortcutSessionController.swift
DictationShortcutAction adds consumeOnly. The controller adds ignoreToggleUntilComponentDeactivation, suppresses selected toggle events while the flag is set, and expands idle, hold, and toggle event handling.
Test harness and UI consume-only handling
Sources/SetupTestHotkeyHarness.swift, Sources/SetupView.swift
The test hotkey harness returns Bool from onShortcutEvent and conditionally consumes stop and consumeOnly actions. SetupView adds an explicit consumeOnly branch.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

  • zachlatta/freeflow#87: Also changes hotkey handling so shortcut callbacks return a value used to decide whether the input event is consumed.
  • zachlatta/freeflow#98: Also updates the shortcut pipeline to use boolean callback returns for consumption decisions.

Poem

🐰 Click, tap, boing—events now say,
“Consumed or not?” in a brighter way.
Toggle ears up, then down they go,
Little bools make the shortcut flow.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 64.71% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main behavior change: ending dictation by pressing any one toggle shortcut key instead of the full combo.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added the size/m label Jun 6, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🧹 Nitpick comments (2)
Sources/AppState.swift (1)

2805-2812: ⚡ Quick win

Move guard let self before the sleep to avoid redundant delay.

Currently, if self is nil at line 2808, the code uses the fallback value, sleeps for 100ms, and only then returns nil at line 2813. Moving the guard earlier would exit immediately without the unnecessary delay.

♻️ Suggested refactor
 contextCaptureTask = Task { [weak self] in
+    guard let self else { return nil }
     do {
-        // Delay context collection to let the OS accessibility tree update after a consecutive paste.
-        // Fetch the delay from AppState using safe weak reference fallback
-        let delay = self?.contextCaptureDelayNanoseconds ?? 100_000_000
-        try await Task.sleep(nanoseconds: delay)
+        try await Task.sleep(nanoseconds: self.contextCaptureDelayNanoseconds)
     } catch {
         return nil
     }
-    guard let self else { return nil }
     let context = await self.contextService.collectContext()
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Sources/AppState.swift` around lines 2805 - 2812, Move the nil-check for the
weak self before sleeping to avoid an unnecessary delay when self is gone: add a
guard let self = self else { return nil } at the start of this block, then read
self.contextCaptureDelayNanoseconds and call try await Task.sleep(nanoseconds:
delay); ensure you reference the same symbol names
(contextCaptureDelayNanoseconds and Task.sleep) and remove reliance on the
fallback literal before the guard so the method returns immediately when self is
nil.
Sources/HotkeyManager.swift (1)

62-66: 💤 Low value

Consider short-circuiting the callback loop for efficiency.

The loop invokes onShortcutEvent for every emitted event without short-circuiting once shouldConsume is set to true. If callbacks have side effects or if consumption can be determined early, consider breaking after the first true return.

However, if you need to invoke the callback for all events for observability or other reasons, this is fine as-is.

⚡ Optional optimization if early-exit is desired
 var shouldConsume = false
 for event in result.emittedEvents {
     if onShortcutEvent?(event) == true {
         shouldConsume = true
+        break
     }
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Sources/HotkeyManager.swift` around lines 62 - 66, The loop over
result.emittedEvents calls the onShortcutEvent callback for every event even
after shouldConsume is set true; modify the loop in HotkeyManager to
short-circuit by breaking out once onShortcutEvent(event) returns true and
shouldConsume is set, so stop invoking further callbacks and set shouldConsume
accordingly (refer to onShortcutEvent, result.emittedEvents, and shouldConsume).
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@Sources/AppState.swift`:
- Around line 2805-2812: Move the nil-check for the weak self before sleeping to
avoid an unnecessary delay when self is gone: add a guard let self = self else {
return nil } at the start of this block, then read
self.contextCaptureDelayNanoseconds and call try await Task.sleep(nanoseconds:
delay); ensure you reference the same symbol names
(contextCaptureDelayNanoseconds and Task.sleep) and remove reliance on the
fallback literal before the guard so the method returns immediately when self is
nil.

In `@Sources/HotkeyManager.swift`:
- Around line 62-66: The loop over result.emittedEvents calls the
onShortcutEvent callback for every event even after shouldConsume is set true;
modify the loop in HotkeyManager to short-circuit by breaking out once
onShortcutEvent(event) returns true and shouldConsume is set, so stop invoking
further callbacks and set shouldConsume accordingly (refer to onShortcutEvent,
result.emittedEvents, and shouldConsume).

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 7890ff29-d1fb-4fe5-92f7-de19c22b3a0a

📥 Commits

Reviewing files that changed from the base of the PR and between 1100c72 and 6237ef1.

📒 Files selected for processing (7)
  • Sources/AppState.swift
  • Sources/HotkeyManager.swift
  • Sources/SetupTestHotkeyHarness.swift
  • Sources/SetupView.swift
  • Sources/ShortcutCore/DictationShortcutSessionController.swift
  • Sources/ShortcutCore/ShortcutMatcher.swift
  • Sources/ShortcutCore/ShortcutModels.swift

…tap to toggle mode

Modified files:
- Sources/AppState.swift: Implement .consumeOnly to block trailing characters, and refactor handleShortcutEvent into a @discardableResult Bool so consumed key events stop propagating.
- Sources/ShortcutCore/DictationShortcutSessionController.swift: Add ignoreToggleUntilComponentDeactivation to consume stop-sequence events.
- Sources/ShortcutCore/ShortcutMatcher.swift: Detect toggle component inputs, emit stateless events, and safely unwrap modifiers.
- Sources/ShortcutCore/ShortcutModels.swift: Add toggleComponentInputReceived event.
- Sources/HotkeyManager.swift: Update onShortcutEvent to loop through multiple emitted events.
- Sources/SetupTestHotkeyHarness.swift: Handle .consumeOnly.
- Sources/SetupView.swift: Handle .consumeOnly.
@pianoandglass
pianoandglass force-pushed the feat/toggle-stop-on-any-key-pr branch from 6237ef1 to f985f31 Compare June 24, 2026 05:07
@pianoandglass pianoandglass changed the title feat: complete dictation when pressing any of the configured keys in tap to toggle mode In toggle mode, tap any one shortcut key (e.g. Fn or Space from Fn+Space) to end dictation — not the full combo Jun 24, 2026
@pianoandglass

Copy link
Copy Markdown
Author

Rebased onto the latest main — this PR is now conflict-free and cleanly mergeable against current upstream.

It's intentionally self-contained: scoped to compile standalone against main with a minimal footprint on shared files, so it does not depend on — or conflict with — my other open PRs. Each can be reviewed and merged independently, in any order.

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jun 24, 2026

Copy link
Copy Markdown
Contributor

@pianoandglass Thanks for the rebase confirmation! I'll kick off a fresh review of the PR now.

✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant