Repository navigation
Conversation
keyboard.badge.exclamationmark is not an SF Symbol. It is absent from CoreGlyphs' name_availability.plist entirely, so NSImage(systemSymbolName:) returns nil for it on every macOS, and the compose path falls back to an empty NSImage - leaving the menu bar showing the status dot on its own with no keyboard behind it. It only affects .permissionDenied and .deviceMissing, which are the two states where the icon most needs to be findable, so the app appears to vanish from the menu bar at exactly the moment something is wrong. The healthy states use keyboard (10.15) and keyboard.fill (12.0), which is why this stays hidden in normal use. Move the symbol names to WLKit so a test can assert they all resolve on the running system, use keyboard.badge.ellipsis for the problem states (macOS 11.0, under the package's 13.0 floor), and fall back to the plain keyboard rather than to a blank image so an unresolvable name can never blank the icon again.
IOHIDDeviceOpen answers kIOReturnNotPermitted (0xE00002E2) both when the Input Monitoring grant is missing and when the pad is already held or has wedged, so the error code cannot be what decides between them. The check here only recognised the older 0xE00002C1, so on current systems a permissions failure fell through to the generic branch and the menu said "Looking for the pad" - which sends you hunting for a cable when the answer is a checkbox. The 0xE00002C1 reading is also a leftover from before d2e7c86 ("open the HID device shared instead of seized"), where that code meant a refused seize rather than a refused grant. Consult IOHIDCheckAccess, which is the only caller that actually knows the state of the grant, and keep believing an error that names a permission outright in case the check is stale. Each case now names what to do next rather than printing a bare error code.
… to a shortcut or off
Two related additions to config.json:
- A top-level "dial" string ("effort" | "agent" | "tab" | "space") turns
the knob from a fixed effort-ladder control into a Herdr navigator:
step the focused agent, cycle tabs in the focused workspace, or step
through workspaces, all direction-aware and wrapping. Effort stays the
default, unchanged. New HerdrClient wrappers for workspace.list and
workspace.focus, and adjacentAgent/adjacentWorkspace/adjacentTab as the
pure, tested seams (adjacentTab generalizes the existing nextTab to take
a direction; nextTab itself becomes a one-line wrapper so its callers
and tests are untouched).
- Every spare key — stack (6), tabs (7), land (8), the macro keys (9, 12),
and the wide voice key (10+11) — can now be bound in "keys" to a
keyboard shortcut instead of only a text macro:
"6": { "shortcut": "cmd+shift+5" }
ShortcutSpec parses the "+"-joined spec (modifiers in any order, one
base key) into a keycode and CGEventFlags with no AppKit dependency, so
it's unit-testable; ShortcutController (app layer) posts it as a real
CGEvent, the same technique VoiceController already uses for the wide
key's right-command tap. A bad spec doesn't get silently dropped — it
surfaces as a panel error the next time that key is pressed.
Binding any of these previously-fixed keys means a config value now
needs to be able to say "no, really, nothing" distinctly from "left
unmentioned, keep the default" — an unmentioned stack key keeps
floating the GitButler window, but a key explicitly turned off should
go dark. KeyAction.off (reached via `false`, or an empty string/
shortcut) is that explicit, present-but-inert binding, named to avoid
colliding with Optional<KeyAction>.none in a switch.
BridgeController.handleKeyPress and refresh()'s per-key lighting both
route through KeyBindings.action(for:) first now, falling back to each
key's built-in job only when nothing is bound. MenuPanelView's key
buttons now call bridge.handleKeyPress(index) directly instead of
duplicating the dispatch logic, so a click and a physical press can
never disagree about what a key does.
Developed test-first throughout. New: ShortcutSpecTests (parsing),
HerdrDialCycleTests (agent/workspace direction and wrap), tab-cycle
direction cases, and KeyBindings coverage for shortcuts, off, and the
now-overridable stack/tabs/land keys. Full suite: 126 tests, 3 skipped
(need physical hardware), 0 failures. Verified end to end on a Creator
Micro 2 over both USB and Bluetooth LE: dial set to "space" correctly
cycled Herdr workspaces, and 10+11 bound to "ctrl+t" and 12 bound to
"return" both fired as real system keystrokes.
Config docs (README, KeyBindings' header comment) updated for both.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RqHC39HxzGPUxiG1xtXip3
…t just a flag Real hardware reports a modifier key's own press as a distinct HID event — a keyboard never sends "T with the control flag set" without also sending control's own keydown. Stamping the flag on the base key alone (what this did until now) satisfies a listener that only reads event.flags on the base key, but not one that also tracks each modifier's own press independently, which is exactly what lets an app offer "tap a bare modifier alone" as its own trigger type — as OpenSuperWhisper's hotkey settings do. That mismatch is the likely cause of the pad's ctrl+t binding working inconsistently against it. ShortcutController now presses each configured modifier in a fixed order, building the CGEventFlags up one key at a time as a real chord's reports would arrive, fires the base key with the full set held, then releases the modifiers in reverse. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RqHC39HxzGPUxiG1xtXip3
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.
What
Two additions to
config.json:"dial"string repurposes the knob from a fixed effort ladder into a Herdr navigator —"agent"steps focus through agents,"tab"cycles tabs in the focused workspace,"space"steps through workspaces."effort"stays the default.{"shortcut": "cmd+shift+5"}), synthesised as a realCGEventthe same way the wide key's own right-command tap already works. Any of these can also be explicitly turned off (false) — distinct from being left unmentioned, which keeps the key's built-in job.Why
Wanted the dial to step through Herdr workspaces instead of effort, and wanted the stack/tabs/land keys free to bind to system-wide hotkeys (screenshot, app shortcuts) instead of GitButler actions I don't use.
Note on #8
This overlaps in spirit with #8's chord binding, but is a different mechanism: #8 sends keystrokes into the focused Herdr pane's pty (
pane.send_keys); this sends real system-wideCGEvents to whatever's frontmost, independent of Herdr. #8's own write-up flags the system-wide case as future work — this is that. Happy to reconcile config vocabulary ("chords"vs"keys": {"shortcut": ...}) if a unified scheme is preferred.A note on shortcut fidelity
A configured shortcut's modifiers are synthesised as their own keydown/keyup events (not just a flag riding on the base key), matching how real hardware reports a chord — needed for apps whose hotkey engine tracks each modifier's own press independently (some support "tap a bare modifier alone" as its own trigger type, which requires this). Verified against a real third-party global-hotkey app, not just a synthetic listener.
Testing
Developed test-first. New:
ShortcutSpecTests(parsing — modifiers, aliases, case, named keys, malformed input),HerdrDialCycleTests(agent/workspace stepping, direction, wrap), plus newHerdrTabCycleTestsdirection cases andKeyBindingsTestscoverage for shortcuts,.off, and the now-overridable stack/tabs/land keys.Full suite: 133 tests, 3 skipped (need physical hardware), 0 failures (includes the 7 tests #9 brought in — this branch is rebased on top of it).
Verified end to end on a Creator Micro 2, both USB and Bluetooth LE:
"dial": "space"correctly cycled Herdr workspaces10+11bound to"ctrl+t"and12bound to"return"both fired as real system keystrokes🤖 Generated with Claude Code