From b6e9a3907b68c96e03e4ac8f08aac25d66f60e9e Mon Sep 17 00:00:00 2001 From: Fabian Meyer <44942030+dinooo13@users.noreply.github.com> Date: Mon, 28 Sep 2026 20:12:29 +0200 Subject: [PATCH] Hotkey: V is the default send key With Option+Space held in the left hand, Right Option needed the right hand, which is the one on the mouse. The index finger of the hand holding the chord rests on V, so a prompt can be dictated and sent one-handed. A letter works as a send key: the tracker swallows it, its repeats and its key-up only while the chord is held, and arms the release rather than interrupting it. Accessibility is not a reason to prefer a modifier here: without it Carbon ignores the send key altogether. Saved settings keep the send key they store; only new installs get V. Co-Authored-By: Claude Opus 5.5 (1M context) --- CLAUDE.md | 2 +- Sources/PladderCore/Models/Settings.swift | 7 ++++--- Sources/PladderCore/Protocols/HotkeyMonitor.swift | 7 ++++++- .../DictationCoordinatorTests.swift | 2 +- Tests/PladderCoreTests/HotkeyTests.swift | 15 +++++++++++++++ 5 files changed, 27 insertions(+), 6 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 0ff8841..5b4e914 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -45,7 +45,7 @@ swift run -c release pladder-cli polish-set docs/polish-set.json --model s1-mini | Interrupted press | A non-chord key within 1 s of the chord press cancels the recording without transcribing | Anyone who records a lone Command key shares it with Cmd+C, Cmd+V and Cmd+Tab; the overlay waits 150 ms before showing so those never flash it. Option+Space shares no modifier with them | | Secure Event Input | `IsSecureEventInputEnabled()` polled with the grant; sustained 3 s and a chord Carbon can register → Carbon monitor until it clears | A password field or Terminal's Secure Keyboard Entry stops taps receiving key events; modifier-only chords are unaffected and stay on the tap | | Without Accessibility | Carbon `RegisterEventHotKey` plus clipboard-only output | A standard account cannot grant Accessibility without an admin. Carbon needs no permission but wants exactly one regular key and collapses left and right, so modifier-only chords are refused in the recorder; the transcript is left on the clipboard and the overlay says "press ⌘V". A stored chord Carbon cannot register, a lone Right Command say, is stood in for by the default Option+Space and the menu names it; the stored chord returns with the grant. `CopySymbolicHotKeys` only feeds the warning that an enabled macOS shortcut owns the recorded chord. `AppModel` polls the grant every two seconds and swaps the monitor in both directions | -| Send key | Press Right Option (configurable) while the hotkey is held and Return is posted 50 ms after Cmd+V | Sends a chat message or runs a command without a second trip to the keyboard; the Return is posted from a detached task so it stays off the release-to-paste path. Only for a held recording: a latched one ends on the closing press, before any send key could arm | +| Send key | Press V (configurable) while the hotkey is held and Return is posted 50 ms after Cmd+V | Sends a chat message or runs a command without a second trip to the keyboard; the Return is posted from a detached task so it stays off the release-to-paste path. V because the index finger of the hand holding Option+Space rests on it, so the other hand stays on the mouse; Right Option, the default before, needed both hands. A letter is fine here: it is swallowed only while the chord is held. Needs Accessibility like the paste: Carbon registers only the chord, so there Option+V reaches the app. Only for a held recording: a latched one ends on the closing press, before any send key could arm | | Polish toggle | A Processing-tab toggle (Experimental section), off by default, no key of its own. When on, every dictation runs the usual pipeline, then Apple's on-device model (FoundationModels, `PladderRefine`) with a fixed cleanup prompt, then pastes | Self-corrections, spoken punctuation, number words and lists are beyond the deterministic processors, and the model runs on device with nothing to download. It costs one to three seconds and sits on the normal hotkey's path, which is why it is marked experimental and off: the branch is one Bool read; the session is prewarmed at key-down; transcripts under four words skip it; anything the model cannot do (Apple Intelligence off, refusal, the 8 s timeout) pastes the text as dictated. Logged as its own `polished release-to-paste` line | | Learned corrections | After a paste the field is watched through Accessibility for up to 60 s; a word the user corrects that passes a token diff, a phonetic gate (Soundex or edit distance ≤ 2) and a yes/no review by the on-device model becomes one menu line, "Learned “x” → “y”? Add / Dismiss" | Nothing runs before Cmd+V is posted: the hook is in `AppModel.handle(.inserted)`, the watcher lives on its own thread and reads only the pasted range plus a margin, the review runs on a detached task. Present only with Accessibility and Apple Intelligence, absent otherwise, no setting, no change to the menu bar glyph. Dismissed pairs go to `dismissed-corrections.json`, not settings, so a bug there can never cost the dictionary. Pure case changes are never proposed. Terminals and TUIs expose a screen buffer, not a field, so nothing is learned there | | Toggle key | Another recordable chord, off by default. A chord of its own latches at release however long the press; equal to the push-to-talk chord it makes that key hybrid: a tap under 400 ms latches, a longer hold stops at release. The next press of any chord, Escape or the 10 min cap ends a latched recording. The overlay is the same as for a held recording, in and out; only the menu's status line says which key stops it | Two-minute dictations should not need a key held for two minutes. Handy and VoiceInk default to hybrid on one key; here it is opt-in, because a stray tap would otherwise leave the microphone open until the cap pastes two minutes of room noise. Hold, toggle and hybrid are decided in `HotkeyGestureTracker`, a clockless value type timed by the instant each monitor stamps on its events, so both monitors behave alike and a press that waits for the microphone cannot make the next release look longer. A same-chord press within 50 ms of its release is a bounce (some Bluetooth keyboards do this mid-hold): it never acts, and the first one seen turns on a 50 ms settle before every stopping release for the rest of the run, so only a keyboard that needs it pays for it and the release path is otherwise untouched. No separate press debounce: both monitors already report alternating presses and releases. Without Accessibility the toggle chord registers with Carbon like the key; one Carbon cannot register has no stand-in, except that a hybrid chord follows the key's. With a lone modifier as a hybrid key, the Command of a later Cmd+C ends a latched recording | diff --git a/Sources/PladderCore/Models/Settings.swift b/Sources/PladderCore/Models/Settings.swift index 3915fd1..1411570 100644 --- a/Sources/PladderCore/Models/Settings.swift +++ b/Sources/PladderCore/Models/Settings.swift @@ -49,7 +49,8 @@ public struct Settings: Codable, Sendable, Equatable { public var engineID: EngineID public var hotkey: Hotkey /// Pressed at any point while `hotkey` is held, this makes the dictation - /// end with Return, which sends a chat message or runs a command. Empty + /// end with Return, which sends a chat message or runs a command. V by + /// default, within reach of the hand holding the default hotkey. Empty /// turns it off. public var submitKey: Hotkey /// Every dictation runs through the on-device model before it is pasted. @@ -86,7 +87,7 @@ public struct Settings: Codable, Sendable, Equatable { public init( engineID: EngineID, hotkey: Hotkey = .optionSpace, - submitKey: Hotkey = .rightOption, + submitKey: Hotkey = .keyV, polishDictations: Bool = false, polishModel: PolishModel = .appleIntelligence, toggleHotkey: Hotkey = Hotkey(keyCodes: []), @@ -137,7 +138,7 @@ public struct Settings: Codable, Sendable, Equatable { hotkey = decodedHotkey.flatMap { $0.keyCodes.isEmpty ? nil : $0 } ?? .optionSpace // Unlike the hotkey, an empty submit key is meaningful: it is how the // feature is switched off. - submitKey = try c.decodeIfPresent(Hotkey.self, forKey: .submitKey) ?? .rightOption + submitKey = try c.decodeIfPresent(Hotkey.self, forKey: .submitKey) ?? .keyV // Once a chord of its own, the polish is now a Processing toggle. // A stored chord migrates to `true`, so the feature the user asked // for turns on with the update; nothing is written under the old key. diff --git a/Sources/PladderCore/Protocols/HotkeyMonitor.swift b/Sources/PladderCore/Protocols/HotkeyMonitor.swift index dcba398..c87f220 100644 --- a/Sources/PladderCore/Protocols/HotkeyMonitor.swift +++ b/Sources/PladderCore/Protocols/HotkeyMonitor.swift @@ -213,8 +213,13 @@ public struct Hotkey: Codable, Sendable, Hashable { /// Right Command (kVK_RightCommand = 0x36). The default before /// Option+Space; still the usual modifier-only choice. public static let rightCommand = Hotkey(0x36) - /// Right Option (kVK_RightOption = 0x3D). + /// Right Option (kVK_RightOption = 0x3D). The send key before V. public static let rightOption = Hotkey(0x3D) + /// V (kVK_ANSI_V = 0x09). The default send key: the index finger of the + /// hand holding Option+Space rests on it, so the other hand can stay on + /// the mouse. A key position, so it is in the same place on every layout; + /// the recorder names it after what it types there. + public static let keyV = Hotkey(0x09) /// Function key (kVK_Function = 0x3F). Requires the Fn key not be bound /// elsewhere in System Settings > Keyboard. public static let function = Hotkey(0x3F) diff --git a/Tests/PladderCoreTests/DictationCoordinatorTests.swift b/Tests/PladderCoreTests/DictationCoordinatorTests.swift index 5c74ca2..85cf3bd 100644 --- a/Tests/PladderCoreTests/DictationCoordinatorTests.swift +++ b/Tests/PladderCoreTests/DictationCoordinatorTests.swift @@ -1838,7 +1838,7 @@ final class EventLog: @unchecked Sendable { #expect(decoded.engineID == EchoEngine.engineID) #expect(decoded.dictionary.count == 1) #expect(decoded.hotkey == .optionSpace) - #expect(decoded.submitKey == .rightOption) + #expect(decoded.submitKey == .keyV) #expect(!decoded.polishDictations) #expect(decoded.polishModel == .appleIntelligence) #expect(decoded.appendTrailingSpace == true) diff --git a/Tests/PladderCoreTests/HotkeyTests.swift b/Tests/PladderCoreTests/HotkeyTests.swift index 3d621e5..29ff959 100644 --- a/Tests/PladderCoreTests/HotkeyTests.swift +++ b/Tests/PladderCoreTests/HotkeyTests.swift @@ -11,6 +11,7 @@ private let leftShift: UInt16 = 0x38 private let space: UInt16 = 0x31 private let keyA: UInt16 = 0x00 private let keyC: UInt16 = 0x08 +private let keyV: UInt16 = 0x09 @Suite struct HotkeyChordTrackerTests { @Test func loneModifierPressesAndReleases() { @@ -166,6 +167,20 @@ private let keyC: UInt16 = 0x08 #expect(t.flagsChanged(modifiers: []) == .init(event: .released(submit: true))) } + @Test func defaultSubmitKeyWorksWithTheHandHoldingTheChord() { + // Option+Space held, V tapped at once: V arms the release rather than + // interrupting it, and is swallowed so the app never sees Option+V. + var t = HotkeyChordTracker(hotkey: .optionSpace, submitKey: .keyV) + #expect(t.flagsChanged(modifiers: [leftOption]) == .init()) + #expect(t.keyDown(space, modifiers: [leftOption]) == .init(event: .pressed, swallow: true)) + #expect(t.keyDown(keyV, modifiers: [leftOption]) == .init(swallow: true)) + #expect(t.keyUp(keyV, modifiers: [leftOption]) == .init(swallow: true)) + #expect(t.keyUp(space, modifiers: [leftOption]) == .init(event: .released(submit: true), swallow: true)) + // Afterwards V types again. + #expect(t.keyDown(keyV, modifiers: []) == .init()) + #expect(t.keyUp(keyV, modifiers: []) == .init()) + } + @Test func submitKeyPassesThroughWhenNotEngaged() { var t = HotkeyChordTracker(hotkey: .rightCommand, submitKey: Hotkey(returnKey)) #expect(t.keyDown(returnKey, modifiers: []) == .init())