feat: hold-to-talk shortcut action (push-to-talk dictation) - #433
feat: hold-to-talk shortcut action (push-to-talk dictation)#433theoephraim wants to merge 2 commits into
Conversation
Greptile SummaryThis PR adds a
Confidence Score: 4/5Safe to merge for non-production testing; a chord held at the moment the app quits normally can stay stuck system-wide until that key is manually pressed and released. The implementation is carefully structured — rebind-safe, auto-repeat-safe, and CaptureInterrupted force-releases every held chord. One gap remains from the previous review: the normal shutdown path (Hook::drop / stop()) does not drain HELD_CHORDS before the callback thread exits, so a chord held at quit time stays physically down on the OS until the user manually clears it. That path is not addressed in this PR. crates/openlogi-agent-core/src/hook_runtime.rs — the shutdown drain is the one path still missing cleanup for held chords. Important Files Changed
Sequence Diagram%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
participant HW as Physical Button
participant Hook as OS Hook Thread
participant HC as HeldChords (thread-local)
participant Inject as openlogi-inject
Note over HW,Inject: Happy path - button held and released normally
HW->>Hook: "Button DOWN (pressed=true)"
Hook->>HC: press(button, combo)
HC-->>Hook: Some(combo) first press only
Hook->>Inject: "press_hold(&combo)"
Inject-->>HW: key-down + modifiers posted to OS
HW->>Hook: "Button UP (pressed=false)"
Hook->>HC: release(button)
HC-->>Hook: Some(combo) stored chord
Hook->>Inject: "release_hold(&combo)"
Inject-->>HW: key-up + modifiers posted to OS
Note over HW,Inject: Auto-repeat guard
HW->>Hook: Button DOWN again (auto-repeat)
Hook->>HC: press(button, combo)
HC-->>Hook: None already held no double press
Note over HW,Inject: CaptureInterrupted (OS kills tap)
HW->>Hook: CaptureInterrupted
Hook->>HC: drain()
HC-->>Hook: all held combos
Hook->>Inject: release_hold x N
Inject-->>HW: force key-up for every stranded chord
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
participant HW as Physical Button
participant Hook as OS Hook Thread
participant HC as HeldChords (thread-local)
participant Inject as openlogi-inject
Note over HW,Inject: Happy path - button held and released normally
HW->>Hook: "Button DOWN (pressed=true)"
Hook->>HC: press(button, combo)
HC-->>Hook: Some(combo) first press only
Hook->>Inject: "press_hold(&combo)"
Inject-->>HW: key-down + modifiers posted to OS
HW->>Hook: "Button UP (pressed=false)"
Hook->>HC: release(button)
HC-->>Hook: Some(combo) stored chord
Hook->>Inject: "release_hold(&combo)"
Inject-->>HW: key-up + modifiers posted to OS
Note over HW,Inject: Auto-repeat guard
HW->>Hook: Button DOWN again (auto-repeat)
Hook->>HC: press(button, combo)
HC-->>Hook: None already held no double press
Note over HW,Inject: CaptureInterrupted (OS kills tap)
HW->>Hook: CaptureInterrupted
Hook->>HC: drain()
HC-->>Hook: all held combos
Hook->>Inject: release_hold x N
Inject-->>HW: force key-up for every stranded chord
Reviews (2): Last reviewed commit: "feat(agent): hold push-to-talk chords fo..." | Re-trigger Greptile |
Every action was a one-shot tap fired on button-down, so there was no way to bind a key that stays held — the shape push-to-talk needs. Append Action::HoldShortcut(KeyCombo), carrying the same recorded chord as CustomShortcut but with press/release semantics. Appended at the end: serde encodes the declaration index, so existing variants keep their wire positions and the goldens are unchanged. PROTOCOL_VERSION 8 -> 9 since Action crosses the IPC boundary. openlogi-inject gains phase-aware primitives (press_hold/release_hold) built from the existing per-platform helpers. Down order is modifiers-then-key and up order is the reverse: a chord whose modifiers lifted before its key would surface as a bare keypress. A HoldShortcut reaching execute() came from a dispatch path with no release edge (the HID++ gesture button, which reports an instantaneous click), so it degrades to a tap rather than jamming the chord down. Like CustomShortcut, this is hand-authored TOML for now — the recorder UI that would offer a Hold toggle does not exist yet, so the GUI only labels and renders it.
The hook already delivered both edges on all three platforms; the single-action path dropped the release with an `if pressed` guard. Wire the release edge up to Action::HoldShortcut: press synthesises the key-down, release the matching key-up. Held chords live in a thread-local keyed by button, matching HOLD's thread-per-device reasoning — press and release for a device arrive on the same thread, so the pair always balances. A stuck key-down jams that key for every app on the system, so the unbalanced paths get the care: - The release resolves from stored state *before* the binding maps are read. A rebind or unbind mid-hold would otherwise resolve to a different action (or none, returning PassThrough early) and strand the chord that actually went down. - A repeated button-down does not stack a second key-down the single release could never balance. - CaptureInterrupted force-releases every held chord: the button-ups are never coming. Extract the button arm into on_button. It was already at the pedantic line limit and the hold paths pushed it over; the callback was doing too much to read. Not runtime-tested on hardware — no Logitech device available here.
e46a128 to
c8e1cb0
Compare
feel free to toss this implementation... But was a first go at making it possible to hold a button to do something.
Summary
Adds a
HoldShortcutaction so a mouse button can hold a key combo down foras long as the button is physically held, instead of firing a one-shot tap.
The motivating use case is push-to-talk dictation: bind a thumb button to
the dictation hotkey and hold it to talk, exactly like a walkie-talkie —
press-and-hold to dictate, release to stop. Every existing action fired on
button-down only, so there was no way to bind a key that stays held for the
duration of the press.
Changes
Action::HoldShortcut(KeyCombo), carrying the samerecorded chord as
CustomShortcutbut with press/release semantics.Appended at the end of the enum so serde wire positions and goldens are
unchanged.
PROTOCOL_VERSION10 → 11 (Action crosses the IPC boundary).press_hold/release_holdprimitivesbuilt from the existing per-platform helpers. Down order is
modifiers-then-key, up order is the reverse, so a chord never surfaces as a
bare keypress while its modifiers are mid-transition.
HoldShortcut—press synthesises key-down, release the matching key-up. Held chords live in
a thread-local keyed by button (press/release for a device arrive on the
same thread, so the pair always balances). Unbalanced paths are handled so a
stuck key-down can't jam a key system-wide: the release resolves from stored
state before binding maps are read (survives a mid-hold rebind/unbind), a
repeated button-down won't stack a second key-down, and
CaptureInterruptedforce-releases every held chord. Button arm extractedinto
on_buttonto stay under the pedantic line limit.languages.
Like
CustomShortcut, this is hand-authored TOML for now — the recorder UIthat would offer a Hold toggle doesn't exist yet, so the GUI only labels and
renders it. A
HoldShortcutreaching a dispatch path with no release edge(the HID++ gesture button, which reports an instantaneous click) degrades to
a tap rather than jamming the chord down.
Testing
devenv tasks run openlogi:check(fmt + clippy -D warnings + workspace tests)PROTOCOL_VERSIONbump covered bywire_formattestenvironment. Needs a real press/release verification on macOS/Linux/Windows.