Skip to content

feat: hold-to-talk shortcut action (push-to-talk dictation) - #433

Open
theoephraim wants to merge 2 commits into
AprilNEA:masterfrom
theoephraim:feat/hold-shortcut-action
Open

feat: hold-to-talk shortcut action (push-to-talk dictation)#433
theoephraim wants to merge 2 commits into
AprilNEA:masterfrom
theoephraim:feat/hold-shortcut-action

Conversation

@theoephraim

@theoephraim theoephraim commented Jul 20, 2026

Copy link
Copy Markdown

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 HoldShortcut action so a mouse button can hold a key combo down for
as 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

  • openlogi-core: new Action::HoldShortcut(KeyCombo), carrying the same
    recorded chord as CustomShortcut but with press/release semantics.
    Appended at the end of the enum so serde wire positions and goldens are
    unchanged. PROTOCOL_VERSION 10 → 11 (Action crosses the IPC boundary).
  • openlogi-inject: phase-aware press_hold / release_hold primitives
    built 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.
  • openlogi-agent-core: wire the release edge through to 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
    CaptureInterrupted force-releases every held chord. Button arm extracted
    into on_button to stay under the pedantic line limit.
  • openlogi-gui: label + render the new action; locale strings across all
    languages.

Like CustomShortcut, this is hand-authored TOML for now — the recorder UI
that would offer a Hold toggle doesn't exist yet, so the GUI only labels and
renders it. A HoldShortcut reaching 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)
  • Wire-format goldens unchanged; PROTOCOL_VERSION bump covered by
    wire_format test
  • Not runtime-tested on hardware — no Logitech device available in this
    environment. Needs a real press/release verification on macOS/Linux/Windows.

@greptile-apps

greptile-apps Bot commented Jul 20, 2026

Copy link
Copy Markdown

Greptile Summary

This PR adds a HoldShortcut(KeyCombo) action to enable push-to-talk–style dictation: the synthesised chord is held down for the physical duration of the button press and released on button-up, instead of firing a one-shot tap. The protocol version is bumped to 11 to reflect the new Action variant, and all locale files get a "Hold %{chord}" string.

  • openlogi-core: New Action::HoldShortcut enum variant appended (preserving existing serde wire positions), plus held_combo() helper; protocol version 10 → 11.
  • openlogi-inject: New press_hold / release_hold public API backed by platform-specific key_phase helpers that emit exactly one keyboard edge (modifier-then-key for down, key-then-modifier for up) per call; HoldShortcut in the no-release-edge execute path (gesture button) degrades to a tap on all three platforms.
  • openlogi-agent-core: HeldChords thread-local bookkeeping in hook_runtime.rs stores the chord on press and resolves it on release from stored state (surviving mid-hold rebinds); CaptureInterrupted drains and force-releases all held chords to prevent stuck keys.

Confidence Score: 4/5

Safe 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

Filename Overview
crates/openlogi-agent-core/src/hook_runtime.rs Adds HeldChords thread-local and on_button refactor. Core press/release logic is correct and well-tested; CaptureInterrupted drains held chords. Known gap: clean Hook::stop() path does not drain HELD_CHORDS before the callback thread exits, which can leave a chord stuck system-wide (flagged in a previous review round).
crates/openlogi-inject/src/inject/windows.rs Adds key_phase for Windows SendInput: down=mods-then-key, up=key-then-mods. key_input second param (key_up: bool) semantics are correct — false=down, true=up — matching post_key. combo_modifiers extracted for reuse, deduplication of VK_CONTROL preserved.
crates/openlogi-inject/src/inject/linux.rs Adds key_phase emitting a single SYN frame per edge; ordering matches press_key. HoldShortcut in execute degrades to press_key tap correctly. key_code==0 guard in execute is redundant (hold_phase already guards it) but harmless.
crates/openlogi-inject/src/inject/macos.rs post_key refactored into post_key_phase (single-edge) + post_key (tap). combo_flags extracted correctly. HoldShortcut in execute degrades to tap for the no-release-edge path. Clean extraction with no logic changes to the existing tap path.
crates/openlogi-core/src/binding.rs HoldShortcut appended last in the Action enum (serde positions preserved), held_combo() helper added, Category::Editing assignment and catalog exclusion match CustomShortcut treatment. TOML roundtrip test confirms correct serde routing.
crates/openlogi-agent-core/src/ipc.rs PROTOCOL_VERSION correctly bumped 10 to 11 with matching comment; wire_format golden test updated to 11.

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
Loading
%%{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
Loading

Reviews (2): Last reviewed commit: "feat(agent): hold push-to-talk chords fo..." | Re-trigger Greptile

Comment thread crates/openlogi-agent-core/src/hook_runtime.rs
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.
@theoephraim
theoephraim force-pushed the feat/hold-shortcut-action branch from e46a128 to c8e1cb0 Compare July 20, 2026 19:46
@davidbudnick davidbudnick added type: feature New feature request platform: all Cross-platform issue labels Aug 2, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

platform: all Cross-platform issue type: feature New feature request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants