Hold-or-toggle hotkey and Escape to cancel (#38) - #55
Merged
Merged
Conversation
This was referenced Sep 23, 2026
Owner
Author
Live test (integration build, M1, installed to /Applications)The installed app was started with
Not tested live: the Carbon path (no Accessibility), and a separate toggle chord (the unit tests cover it). |
`PLADDER_SETTINGS_PATH` points a copy launched from a worktree at a settings file of its own. Every recorder commit is saved at once, so a test copy that shared the live file would rewrite the chords of the copy in daily use. With the override set the legacy SpeakUp migration is skipped too, so the test copy starts from the defaults. Development only: no UI, and nothing changes for a normal launch. CLAUDE.md's "Working on this Mac" section says how to launch such a copy. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
`HotkeyMonitorEvent` gains an optional `instant`, stamped by the tap with the one clock read it already makes per event, and by Carbon in its handler. The next commit times a hold from press to release to decide between push-to-talk and a latched toggle; timing it from when the coordinator dequeues the event would be wrong, because the press waits for the microphone to start and the release queued behind it would look that much longer. A slow Bluetooth microphone would turn every tap into a hold. Optional, so the existing constructors and the chord set's tests are untouched; a source that does not stamp, a test fake, is read as now. No behaviour change. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A second recordable chord, "Toggle key", off by default. One press starts a recording, the next press of any chord stops and pastes it, so a two-minute dictation no longer needs a key held for two minutes. Set to the same combination as the push-to-talk key it makes that key hybrid: a release sooner than 400 ms after the press latches, a later one stops as before. A different combination is a chord of its own with its plain behaviour. A toggle chord equal to the stored chord follows the Accessibility stand-in, so a hybrid key stays hybrid. `HotkeyGestureTracker`, a clockless value type in Core, decides hold, toggle or hybrid from the role-tagged presses and releases and their instants; the coordinator carries out its start, stop and discard, and publishes `isLatched`. A press the state machine refuses resets it, so a tap while the engine loads cannot leave a latch behind; every end of a recording resets it, which is what makes the 120 s cap end a latched recording exactly as it ends a held one. Bounce: a press of a chord within 50 ms of its release is the keyboard, not the user, and never starts, stops or latches anything. The first one seen turns on a 50 ms settle before every stopping release for the rest of the app's run, and a bounce inside the settle resumes the hold. Until then nothing waits, so a healthy keyboard never pays for it; the event `keyboardBounceObserved` is logged once in the hotkey category. No separate press debounce: both monitors already report alternating presses and releases per chord. Settings: a "Toggle key" row under the key, with a warning when the chord cannot be detected without Accessibility (no stand-in of its own) or macOS owns it. The recorder now clears a field with Delete where that is allowed, which the toggle key and the send key are; the send key's empty chord always meant off but could not be set from the UI. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A recording that carries on after its chord was let go must not look like one that stops when the key comes up, or the user walks away from an open microphone. The red dot squares off into a stop sign while the coordinator's `isLatched` is set: in Compact and Live the dot morphs in place (one rounded rectangle whose corners animate, so it does not swap), and the Minimal disc shows the square beside a four-bar wave instead of the dot-then-bars intro, so the level stays visible. The Menu style has no pill, so the status line is its cue: "Recording — press <key> to stop", naming the toggle key when it is a chord of its own and the key or its stand-in when it is hybrid. The menu bar glyph is unchanged. Settings replicas keep the round dot. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
While a recording is on, a plain Escape drops it without transcribing or pasting, and the app plays the stop sound so the user hears the microphone go off. Between recordings Escape is never taken: the coordinator tells the monitor through a new `setCancelKeyEnabled(_:)` when a recording starts and when it ends, however it ends. On the tap `HotkeyChordSet` catches Escape before the chord trackers see it, so the interrupted-press rule never adds a `.cancelled`, and swallows its repeats and key-up even if the cancel key was turned off in between. Escape with an engaged chord's own modifiers still counts, so it works while Option+Space is held; Cmd+Option+Escape (Force Quit) passes through, and a chord or send key that contains Escape keeps it. Under Secure Event Input the tap sees no key-downs, so a modifier-only chord's recording cannot be cancelled there; it ends as before. Carbon registers Escape as a hot key of its own in the last ID slot when a recording starts and unregisters it when it ends, always from the main queue and never inline, so the release path never waits on the window server. Its mask is empty, so only a bare Escape cancels without Accessibility. `HotkeyEvent.escape` carries it on the stream; it belongs to no chord, so the monitors tag it `.dictate` and the coordinator ignores the role. The coordinator's `escapePressed()` emits `recordingDiscarded` and then cancels as an interrupted press does. On the release path the only new statement is the cancel-key flag, before `recordingStopped`. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
CLAUDE.md: decisions rows for the toggle key (hybrid rule, 400 ms, adaptive 50 ms bounce settle, why off by default, the Carbon and stand-in rules, the lone-modifier caveat) and for Escape (taken only while recording, how the tap and Carbon differ, the Secure Event Input gap); the send-key and cap rows say what they do for a latched recording; a pluggability line for adding a hotkey role. README: an FAQ entry for toggling. BENCHMARKS.md: what a "keyboard bounce observed" line means for the release-to-paste number, and where a latched recording's measurement starts. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A polish chord equal to the toggle chord is not registered, and a short polish press stops and polishes rather than latching like a toggle press. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
dinooo13
force-pushed
the
feature/38-toggle-escape
branch
from
September 23, 2026 20:47
a8dc250 to
50b5667
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #38.
Rebased onto
mainafter #54 and #56 merged. The conflicts were resolved as on the integration branch (see "Merge with #54" below), and the recording cap ismain's 10 minutes. Every commit builds on its own.Commits
What
Settings.toggleHotkey, empty (off) by default, and one settings row, "Toggle key". A chord of its own latches when released, however long it was held. The same chord as the key makes that key hybrid: a release under 400 ms latches, a longer hold stops at release. A latched recording ends on the next press of any chord, on Escape, or at the 120 s cap. A toggle chord equal to the stored chord follows the Accessibility stand-in, so a hybrid key stays hybrid. Without Accessibility the toggle chord is registered with Carbon like the key. It has no stand-in of its own, and the row says why when Carbon cannot register it.HotkeyGestureTracker(Core, a value type with no clock of its own) turns role-tagged press, release and cancel events into start, stop and discard actions. The instants come in with the events. The only timer it needs is requested throughOutcome.settleand answered withtimerFired(token:). The coordinator carries out the actions and publishesisLatched. A start the state machine refuses resets the tracker. Every way a recording ends also resets it, which is how the cap ends a latched recording exactly as it does today.keyboardBounceObservedand logged in thehotkeycategory. There is no separate 30 ms press debounce, because both monitors already emit alternating press and release events.HotkeyMonitor.setCancelKeyEnabled(_:)is turned on when a recording starts and off however it ends. On the tap,HotkeyChordSetcatches Escape before the chord trackers see it. It swallows the key-down, the repeats and the key-up, and Escape with an engaged chord's own modifiers still counts. Cmd+Option+Esc passes through, and so does a chord or send key that contains Escape. Carbon registers a bare Escape in hot key slot 7 while a recording is on, always throughDispatchQueue.main.async.escapePressed()emitsrecordingDiscarded, which plays the stop sound, then cancels. Nothing is transcribed, pasted or timed.PLADDER_SETTINGS_PATHis a development-only override of the settings file. It also skips the SpeakUp migration. It is documented in CLAUDE.md.Start/Stopp-Taste, and so on). The key coverage check passed: every new literal is inLocalizable.xcstringsand markedtranslated.Tests
263 tests in 22 suites in PladderCoreTests, 55 more than the 208 on e5af597. All other targets are unchanged. Three consecutive runs were green.
HotkeyGestureTrackerTests(22 tests): hold, toggle and hybrid; a release exactly at the threshold; any chord ends a latch; the closing press's release is ignored; another chord's press or interruption is ignored; interruption discards; bounce turns deferral on; settle; bounce resumes the hold;submitsurvives a bounce; a latch is never deferred; a bounce never ends a latch;resetkeeps deferral.CancelKeyTests(8 tests,HotkeyChordSet): Escape only while enabled; the key-up is swallowed after disabling; foreign modifiers pass through; the chord's own modifiers still cancel; Escape never reaches the trackers, so no.cancelled; a chord or send key containing Escape is not the cancel key;resetkeeps the cancel key.HotkeyCodingTests: a missing or emptytoggleHotkeymeans off; round trip.ToggleHotkeyTests(16 tests, coordinator): hybrid tap latches and the next tap inserts; hybrid hold; a hold is timed by the event's own instants, not the dequeue time; a plain toggle chord; push-to-talk stays plain beside it; either chord ends a latch; the chords the monitor is started with (second chord, empty, hybrid stand-in); the cap in toggle mode; an interrupted hybrid press is still silent; a toggle-key change while recording stops the mic; a refused start does not latch; deferral and bounce during settle; the bounce is announced once.EscapeTests(6 tests): Escape discards, pastes nothing and announcesrecordingStarted, recordingDiscarded; the release after Escape does nothing; Escape while idle does nothing; Escape while latched; output device restored; the cancel key is on only while recording (dictation, interrupted press, cap →[true, false] × 3).No existing test assertion changed.
FakeHotkeygainedsend(_:),escape()andcancelKeyEnabledas new lines;HotkeyChordSetTests.swiftis untouched.Deviations from the plan and addendum
.dictate,start(chords:),HotkeyMonitorEvent, andHotkeyChordSet(which carries the Escape rule). The plan's commit 2 ("roles, bindings and a tagged event stream") was already done by e5af597, so that slot holds the timestamp commit below.HotkeyMonitorEvent.instant, stamped by the tap and by Carbon). The plan read.nowin the coordinator's loop. That loop awaitshotkeyPressed, which waits forcapture.start(), so a release queued behind the press would be timed late. With a slow Bluetooth microphone every tap would count as a hold. The field is optional, so On-device LLM polish on a second hotkey via Apple Foundation Models #36's constructors and tests are unchanged, and a fake that sends no instant is read as now.HotkeyEvent.escape, tagged.dictate; the coordinator ignores the role. This was the smallest change toHotkeyMonitorEvent: the struct stays as it is, and there is one new enum case.escape()and no.cancelaction in the gesture tracker. The coordinator handles.escapedirectly throughescapePressed(), andcancelRecordingresets the tracker. That also covers a recording started from a directhotkeyPressed()call.reset(), although the plan cleared it.hotkeyReleasedresets the tracker on every stop, and clearing the last release there would let a bounce straight after a stop start a new recording.VStack, the same pattern On-device LLM polish on a second hotkey via Apple Foundation Models #36's plan uses. This keeps the German valid and makes the merge mechanical. Rewording the old key was dropped.main.cycleRole(On-device LLM polish on a second hotkey via Apple Foundation Models #36) is still set and cleared but no longer filters the stream: the gesture tracker's own role checks do that job.Critical path
Nothing between
recordingStoppedandinsertedchanges:finish(), the pipeline and the output are untouched (git diff e5af597 -- Sources/PladderCore/DictationCoordinator.swifthas no hunk afteronEvent(.recordingStopped)).hotkeyReleasedgains a singleendGesture()call after its guard and beforerecordingStopped. That call cancels the settle task, resets the tracker, clears the latch flag, and flips the cancel-key flag. On the tap the flag flip is a lock-protected Bool. On Carbon it is a lock plus aDispatchQueue.main.async, soUnregisterEventHotKeynever runs inline. The one wait users can feel is the adaptive 50 ms settle. It sits beforerecordingStoppedand only happens after a bounce has been seen. It is off by default, and BENCHMARKS.md says how to recognise it in the log.pladder-cli benchmeasures the engine, not the coordinator, so it cannot see this change. For completeness, on the integration build of #36, #37 and #38 (M1, same procedure asmain): 10s 0.254 s (main 0.248), 30s 0.436 (0.434), 60s 0.596 (0.601), 2m 0.932 (1.009), 5m 2.017 (2.079), 10m 3.760 (3.803). Every difference is within noise.Live-test recipe
./scripts/bundle.shin the worktree. Never use--install, which runspkill -x Pladder.cat ~/Library/Application\ Support/Pladder/settings.json. Before the first launch, write the test copy's own file with chords that differ from the live ones. A copy that starts from the defaults listens for Option+Space and would fire alongside the copy in use. For example (Control+Shift+D for both; copy theengineIDfrom the live file):mkdir -p /tmp/pladder-38 && printf '{"engineID":"<live engineID>","hotkey":{"keyCodes":[2,56,59]},"toggleHotkey":{"keyCodes":[2,56,59]}}' > /tmp/pladder-38/settings.jsonPLADDER_SETTINGS_PATH=/tmp/pladder-38/settings.json "$PWD/dist/Pladder.app/Contents/MacOS/Pladder" &release-to-pasteline. Then hold, press Escape while holding, and let go: the same, and the release does nothing. While idle, Escape must still close a Spotlight window.pkill -f "$PWD/dist/Pladder.app", thenCODESIGN_IDENTITY=- ./scripts/bundle.shand relaunch as in step 3 without granting Accessibility. Repeat steps 5 to 7: after each stop the pill shows "Copied — press ⌘V", Escape cancels only while recording, and Spotlight gets it back afterwards.pkill -f "$PWD/dist/Pladder.app"; rm -rf /tmp/pladder-38.The bounce path cannot be reproduced on demand. The tracker tests cover it, and the
keyboard bounce observedlog line is how a field report will be confirmed. No copy was launched from this worktree: with default settings it would listen for Option+Space next to the copy in use.For the reviewer
DictationCoordinator.startHotkey/act/endGesture, and the start/stop/cancel-key ordering inhotkeyPressed(enable before the await;endGesture()in thecatch).CarbonHotkeyMonitor.syncCancelKey: generation and staleness handling, teardown instop(), and thehandlebranch for slot 7.HotkeyChordSet.cancelKeyDown: which modifiers are allowed (collapsed sides of the engaged chords only).Merge notes for #36
The expected conflicts are all adjacent lines and mechanical:
startHotkey: the chords line (var chordsplus the toggle line here,.polishthere).cancelRecording/hotkeyPressed:endGesture()and the cancel-key enable here,willPolishthere.Settingsfields,CodingKeysand decoder.SettingsView: rows, and the footerVStack, where both branches add a secondFootnoteText.Eventcases,AppModel.handle,EventLog.Localizable.xcstrings: sorted keys, so keep both blocks.HotkeyRoleis nowdictate, polish, toggle, so Carbon IDs are 0, 1 and 2 for the roles and 7 for Escape. The polish role has no mode instartGestureand therefore holds;act(.start(role))passes the role tohotkeyPressed(role:), sowillPolishrouting works as is. Whichever branch merges second should add a settings warning for a toggle chord equal to the polish chord: two roles on one chord would both fire.Merge with #54
The conflicts are adjacent lines. On the integration branch:
startHotkeyregisters dictate, toggle and polish, and drops a polish chord that is already another role's chord. The polish role's gesture mode is.hold. The polish row getsallowsEmpty(Delete clears) and a warning when it equals the toggle key. The footer carries three literals.🤖 Generated with Claude Code