From 1f14828504d2b8078b4d230ffc0f4bb4f04424d2 Mon Sep 17 00:00:00 2001 From: mmorrison Date: Tue, 1 Sep 2026 09:25:33 -0500 Subject: [PATCH 1/4] fix: draw the menu-bar icon when the badge symbol is missing 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. --- Sources/WLKit/IconSymbols.swift | 26 ++++++++++++++++++++ Sources/WLMicroManager/MenuBarIcon.swift | 15 ++++++++---- Tests/WLKitTests/IconSymbolsTests.swift | 30 ++++++++++++++++++++++++ 3 files changed, 66 insertions(+), 5 deletions(-) create mode 100644 Sources/WLKit/IconSymbols.swift create mode 100644 Tests/WLKitTests/IconSymbolsTests.swift diff --git a/Sources/WLKit/IconSymbols.swift b/Sources/WLKit/IconSymbols.swift new file mode 100644 index 0000000..ba965af --- /dev/null +++ b/Sources/WLKit/IconSymbols.swift @@ -0,0 +1,26 @@ +import Foundation + +/// The SF Symbols the menu-bar icon draws. +/// +/// Gathered here rather than written inline at the draw site so a test can +/// check they all still exist. A symbol name that is not on the running system +/// resolves to nil, and the drawing code has nowhere to go but a blank image, +/// so the icon quietly loses its glyph on exactly the machines where the symbol +/// was withdrawn or never shipped. +public enum IconSymbols { + + /// Running, nothing to report. + public static let idle = "keyboard" + + /// Agents are doing something worth a colour. + public static let active = "keyboard.fill" + + /// No pad, or no permission to reach it. + /// + /// Not `keyboard.badge.exclamationmark`, which reads better but is not on + /// every system - where it is absent the icon drew as a bare dot with no + /// keyboard at all, in the two states where you most need to see it. + public static let problem = "keyboard.badge.ellipsis" + + public static let all = [idle, active, problem] +} diff --git a/Sources/WLMicroManager/MenuBarIcon.swift b/Sources/WLMicroManager/MenuBarIcon.swift index 45e2a6e..2765539 100644 --- a/Sources/WLMicroManager/MenuBarIcon.swift +++ b/Sources/WLMicroManager/MenuBarIcon.swift @@ -36,10 +36,10 @@ enum MenuBarIcon { var symbolName: String { switch self { - case .off: return "keyboard" - case .permissionDenied, .deviceMissing: return "keyboard.badge.exclamationmark" - case .idle: return "keyboard" - case .allIdle, .working, .blocked: return "keyboard.fill" + case .off: return IconSymbols.idle + case .permissionDenied, .deviceMissing: return IconSymbols.problem + case .idle: return IconSymbols.idle + case .allIdle, .working, .blocked: return IconSymbols.active } } @@ -71,7 +71,12 @@ enum MenuBarIcon { static func image(for state: State) -> NSImage { let symbolSize = NSSize(width: 18, height: 18) let config = NSImage.SymbolConfiguration(pointSize: 14, weight: .regular) - let symbol = NSImage(systemSymbolName: state.symbolName, accessibilityDescription: state.help)? + // Falling back to the plain keyboard rather than to a blank image: a + // symbol the running system does not have would otherwise leave the + // menu bar with a status dot floating on its own, which reads as a + // broken app rather than as whatever the state actually is. + let symbol = (NSImage(systemSymbolName: state.symbolName, accessibilityDescription: state.help) + ?? NSImage(systemSymbolName: IconSymbols.idle, accessibilityDescription: state.help))? .withSymbolConfiguration(config) ?? NSImage(size: symbolSize) diff --git a/Tests/WLKitTests/IconSymbolsTests.swift b/Tests/WLKitTests/IconSymbolsTests.swift new file mode 100644 index 0000000..57f8e31 --- /dev/null +++ b/Tests/WLKitTests/IconSymbolsTests.swift @@ -0,0 +1,30 @@ +import XCTest +import AppKit +@testable import WLKit + +/// Guards the menu-bar glyph against a failure that is invisible until it is +/// embarrassing: `NSImage(systemSymbolName:)` returns nil for a name the running +/// system does not have, the drawing code falls back to an empty image, and the +/// icon becomes a bare status dot with no keyboard at all. +/// +/// Worse, the states that need the glyph most are the error ones, so the icon +/// vanishes precisely when someone is trying to work out what is wrong. +final class IconSymbolsTests: XCTestCase { + + func testEverySymbolTheIconDrawsExistsOnThisSystem() { + for name in IconSymbols.all { + XCTAssertNotNil( + NSImage(systemSymbolName: name, accessibilityDescription: nil), + "\(name) is not an SF Symbol on this system, so the icon would draw blank" + ) + } + } + + /// The glyph is how the icon is found in a crowded menu bar, so every state + /// keeps the keyboard rather than swapping to a generic warning mark. + func testEveryStateKeepsTheKeyboardShape() { + for name in IconSymbols.all { + XCTAssertTrue(name.hasPrefix("keyboard"), "\(name) would not read as this app") + } + } +} From ed47081bbda46bd9a51ab1068eac20aaf1482dd4 Mon Sep 17 00:00:00 2001 From: mmorrison Date: Tue, 1 Sep 2026 09:32:05 -0500 Subject: [PATCH 2/4] fix: tell a missing grant apart from an unreachable pad 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. --- Sources/WLKit/BridgeController.swift | 17 +++++-- Sources/WLKit/DeviceOpenFailure.swift | 33 +++++++++++++ Tests/WLKitTests/DeviceOpenFailureTests.swift | 48 +++++++++++++++++++ 3 files changed, 93 insertions(+), 5 deletions(-) create mode 100644 Sources/WLKit/DeviceOpenFailure.swift create mode 100644 Tests/WLKitTests/DeviceOpenFailureTests.swift diff --git a/Sources/WLKit/BridgeController.swift b/Sources/WLKit/BridgeController.swift index bf18e02..b7ce554 100644 --- a/Sources/WLKit/BridgeController.swift +++ b/Sources/WLKit/BridgeController.swift @@ -193,12 +193,19 @@ public final class BridgeController: ObservableObject { lastError = nil } catch { deviceConnected = false - let message = error.localizedDescription - if message.contains("0xE00002C1") || message.contains("Input Monitoring") { + // Ask the API that knows about the grant rather than reading the + // error code, which says "not permitted" for a wedged pad too. + let granted = IOHIDCheckAccess(kIOHIDRequestTypeListenEvent) == kIOHIDAccessTypeGranted + switch DeviceOpenFailure.classify(accessGranted: granted, message: error.localizedDescription) { + case .permissionMissing: permissionDenied = true - if !warnedPermission { warnedPermission = true; lastError = message } - } else { - lastError = message + if !warnedPermission { + warnedPermission = true + lastError = DeviceOpenFailure.permissionMissing.message + } + case .deviceUnavailable(let underlying): + permissionDenied = false + lastError = DeviceOpenFailure.deviceUnavailable(underlying).message } scheduleReopen() return diff --git a/Sources/WLKit/DeviceOpenFailure.swift b/Sources/WLKit/DeviceOpenFailure.swift new file mode 100644 index 0000000..a670d65 --- /dev/null +++ b/Sources/WLKit/DeviceOpenFailure.swift @@ -0,0 +1,33 @@ +import Foundation + +/// Why the pad would not open, in terms someone can act on. +/// +/// `IOHIDDeviceOpen` returns `kIOReturnNotPermitted` (0xE00002E2) for a missing +/// Input Monitoring grant *and* for a device that is already held or has got +/// itself wedged, so reading the code alone gets it wrong half the time. The +/// original check only recognised the older 0xE00002C1, so on current systems a +/// permissions failure fell through and the menu simply said it was looking for +/// the pad, which sends you hunting for a cable when the answer is a checkbox. +public enum DeviceOpenFailure: Equatable { + case permissionMissing + case deviceUnavailable(String) + + /// `accessGranted` comes from `IOHIDCheckAccess`, the only caller that + /// actually knows the state of the grant. An error that names a permission + /// outright is still believed, in case the check itself is stale. + public static func classify(accessGranted: Bool, message: String) -> DeviceOpenFailure { + if message.contains("0xE00002C1") || message.contains("Input Monitoring") { + return .permissionMissing + } + return accessGranted ? .deviceUnavailable(message) : .permissionMissing + } + + public var message: String { + switch self { + case .permissionMissing: + return "Input Monitoring is not granted. System Settings › Privacy & Security › Input Monitoring, then switch the manager off and on." + case .deviceUnavailable(let underlying): + return "The pad did not open. Something else may be holding it, or it needs a reconnect - unplug it, wait a moment, plug it back in. (\(underlying))" + } + } +} diff --git a/Tests/WLKitTests/DeviceOpenFailureTests.swift b/Tests/WLKitTests/DeviceOpenFailureTests.swift new file mode 100644 index 0000000..dd5a557 --- /dev/null +++ b/Tests/WLKitTests/DeviceOpenFailureTests.swift @@ -0,0 +1,48 @@ +import XCTest +@testable import WLKit + +/// Telling "you have not granted permission" apart from "the pad is not +/// reachable", which look identical from the error code alone. +/// +/// `IOHIDDeviceOpen` answers `kIOReturnNotPermitted` (0xE00002E2) both when the +/// Input Monitoring grant is missing and when the device is already held or +/// wedged, so the code cannot be the thing that decides. `IOHIDCheckAccess` is +/// the only source that actually knows about the grant, so it casts the vote. +final class DeviceOpenFailureTests: XCTestCase { + + private let notPermitted = "IOHIDDeviceOpen failed (0xE00002E2)" + + func testMissingGrantIsReportedAsAPermissionProblem() { + let failure = DeviceOpenFailure.classify(accessGranted: false, message: notPermitted) + XCTAssertEqual(failure, .permissionMissing) + } + + /// The case that sent us in circles: the same error code with the grant + /// already in place is a device problem, and calling it a permission + /// problem sends someone back to System Settings for nothing. + func testSameErrorWithTheGrantInPlaceIsADeviceProblem() { + let failure = DeviceOpenFailure.classify(accessGranted: true, message: notPermitted) + XCTAssertEqual(failure, .deviceUnavailable(notPermitted)) + } + + /// Whatever we say next has to name the thing to try, because the error + /// code on its own has already proved useless to a reader. + func testTheDeviceMessageSuggestsSomethingToDo() { + let advice = DeviceOpenFailure.deviceUnavailable(notPermitted).message + XCTAssertTrue(advice.contains("reconnect") || advice.contains("unplug"), + "a wedged pad is fixed by reseating it, so say so: \(advice)") + XCTAssertTrue(advice.contains(notPermitted), "keep the raw error for anyone diagnosing") + } + + func testThePermissionMessageNamesTheSettingToLookFor() { + let advice = DeviceOpenFailure.permissionMissing.message + XCTAssertTrue(advice.contains("Input Monitoring"), advice) + } + + /// Older systems phrase the denial differently, and that reading should + /// still win over a stale access check. + func testAnExplicitPermissionErrorIsBelievedRegardless() { + let legacy = "IOHIDDeviceOpen failed (0xE00002C1)" + XCTAssertEqual(DeviceOpenFailure.classify(accessGranted: true, message: legacy), .permissionMissing) + } +} From b37ab815d63cbb5d3f9f1d788fed81ef8ac44819 Mon Sep 17 00:00:00 2001 From: Peter Foti Date: Fri, 4 Sep 2026 10:22:46 -0400 Subject: [PATCH 3/4] feat: repurpose the dial for Herdr navigation, and bind any spare key to a shortcut or off MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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.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 Claude-Session: https://claude.ai/code/session_01RqHC39HxzGPUxiG1xtXip3 --- README.md | 43 +++++-- Sources/WLKit/BridgeController.swift | 117 ++++++++++++++---- Sources/WLKit/HerdrClient.swift | 80 ++++++++++-- Sources/WLKit/KeyBindings.swift | 108 ++++++++++++---- Sources/WLKit/OAIProtocol.swift | 6 + Sources/WLKit/ShortcutSpec.swift | 68 ++++++++++ Sources/WLMicroManager/MenuPanelView.swift | 41 +++--- Sources/WLMicroManager/MicroManagerApp.swift | 19 ++- .../WLMicroManager/ShortcutController.swift | 56 +++++++++ Tests/WLKitTests/HerdrDialCycleTests.swift | 101 +++++++++++++++ Tests/WLKitTests/HerdrTabCycleTests.swift | 31 +++++ Tests/WLKitTests/KeyBindingsTests.swift | 107 ++++++++++++++++ Tests/WLKitTests/ShortcutSpecTests.swift | 58 +++++++++ 13 files changed, 752 insertions(+), 83 deletions(-) create mode 100644 Sources/WLKit/ShortcutSpec.swift create mode 100644 Sources/WLMicroManager/ShortcutController.swift create mode 100644 Tests/WLKitTests/HerdrDialCycleTests.swift create mode 100644 Tests/WLKitTests/ShortcutSpecTests.swift diff --git a/README.md b/README.md index fc8db2c..15e300e 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ forward** — Herdr selects the pane but leaves the window where it was, so an agent key pressed from a browser used to move a cursor you could not see. Set `WL_TERMINAL_BUNDLE_ID` if your panes do not live in Ghostty. -**Row 3** is actions: +**Row 3** is actions — all four are overridable from `config.json`, stack/tabs/land included: | key | what it does | |---|---| @@ -62,8 +62,10 @@ agent key pressed from a browser used to move a cursor you could not see. Set Superwhisper — and one more macro key. -**The dial** tunes reasoning effort. **The joystick** switches model: it puts -the model list on screen, north and south move, east confirms, west cancels. +**The dial** tunes reasoning effort by default; the `"dial"` config key can +point it at Herdr navigation instead — stepping the focused agent, tab, or +workspace. **The joystick** switches model: it puts the model list on screen, +north and south move, east confirms, west cancels. ### The stack key @@ -148,18 +150,43 @@ the dial and joystick offer, drop a `config.json` into "keys": { "9": "Open PRs for all active GitButler branches", "12": "Run but pull", - "10+11": "Summarise what you are working on" + "10+11": "Summarise what you are working on", + "6": { "shortcut": "cmd+shift+5" }, + "7": { "shortcut": "f13" } }, + "dial": "effort", "claude": { "models": ["fable", "opus"], "efforts": ["low", "high"] }, "codex": { "models": ["gpt-5.6-sol", "gpt-5.6-codex"] } } ``` A bound string is injected into the focused agent's prompt, unsubmitted — you -still read it and press enter. `"10+11"` addresses the wide key as one; `"10"` -and `"11"` address its halves. Keys the file does not mention keep their -defaults, and an empty string unbinds a key outright. Binding the wide key to -text replaces its right-command tap. +still read it and press enter. A key can be bound to a keyboard shortcut +instead — `{ "shortcut": "cmd+shift+5" }` — which is synthesised system-wide +regardless of what Herdr is doing: launch an app's own hotkey, take a +screenshot, trigger Mission Control, anything a physical key combo can do. +Modifiers (`cmd`/`command`, `shift`, `opt`/`option`/`alt`, `ctrl`/`control`) go +in any order, `+`-joined, with exactly one base key — a letter, digit, named +punctuation key (`equal`, `minus`, `comma`, …), or a named key (`space`, +`tab`, `return`, `escape`, `delete`, `up`/`down`/`left`/`right`, `f1`–`f19`). +An unrecognised shortcut string does nothing and reports itself as an error +the next time you press that key. A synthesised shortcut needs the +**Accessibility** permission, same as the wide key's right-command tap. + +`"10+11"` addresses the wide key as one; `"10"` and `"11"` address its +halves. **Every spare key is overridable this way, including the stack (6), +tabs (7), and land (8) keys** — binding one replaces its built-in job +entirely. Keys the file does not mention keep their defaults: 9 and 12 type +their text macros, the wide key taps right-command, and 6/7/8 stay +stack/tabs/land. An empty string (or `{"shortcut": ""}`) unbinds a key +outright, back to nothing. + +`"dial"` sets what the knob does. `"effort"` (the default) climbs the +reasoning-effort ladder for the focused agent; `"agent"` steps focus through +the agents in sidebar order; `"tab"` cycles the tabs of the focused workspace; +`"space"` (or `"workspace"`) steps through workspaces. All four wrap at the +ends and follow the turn direction. An unrecognised value falls back to +`"effort"`. `codex.models` is your copy of what Codex's own `/model` menu offers, in its order — the joystick steers that menu rather than owning it, so there is nothing diff --git a/Sources/WLKit/BridgeController.swift b/Sources/WLKit/BridgeController.swift index b7ce554..2a6c9be 100644 --- a/Sources/WLKit/BridgeController.swift +++ b/Sources/WLKit/BridgeController.swift @@ -56,6 +56,9 @@ public final class BridgeController: ObservableObject { public var onLandKey: (() -> Void)? /// Called when either half of the wide voice key is pressed. public var onVoiceKey: (() -> Void)? + /// Called for a key bound to a shortcut, once it has parsed cleanly. + /// Posting the event is app-layer (needs AppKit and Accessibility). + public var onShortcut: ((ShortcutSpec) -> Void)? /// Called per dial detent: +1 clockwise, -1 counter-clockwise. public var onDial: ((Int) -> Void)? /// Called when the joystick enters a cardinal sector. @@ -339,22 +342,29 @@ public final class BridgeController: ObservableObject { reconcileStatusStreams(fetched) let state = StatusMapper.aggregate(fetched, config) - // Macro and voice keys share ids, so the binding decides each key's - // light: configured text wins, the wide key falls back to voice. - let flexKeys = (Pad.macroKeyIDs + Pad.voiceKeyIDs).map { key -> OAI.Thread in - if keyBindings.text(for: key) != nil { + // Every overridable key shares this light: a binding (text or + // shortcut) wins and paints the generic macro colour; an unbound key + // falls back to whatever it does by default. + let flexKeys = Pad.overridableKeyIDs.map { key -> OAI.Thread in + switch keyBindings.action(for: key) { + case .text, .shortcut: return StatusMapper.macroThread(id: key, config) + case .off: + return OAI.Thread(id: key, brightness: 0, effect: .off) + case nil: + switch key { + case Pad.stackKeyID: return StatusMapper.stackThread(open: stackPanelOpen, config) + case Pad.tabCycleKeyID: return StatusMapper.tabCycleThread(config) + case Pad.landKeyID: return StatusMapper.landThread(open: landPanelOpen, config) + default: + guard Pad.voiceKeyIDs.contains(key) else { + return OAI.Thread(id: key, brightness: 0, effect: .off) + } + return StatusMapper.voiceThread(id: key, active: voiceActive, config) + } } - if Pad.voiceKeyIDs.contains(key) { - return StatusMapper.voiceThread(id: key, active: voiceActive, config) - } - return OAI.Thread(id: key, brightness: 0, effect: .off) } - let threads = StatusMapper.threads(for: fetched, config) - + [StatusMapper.stackThread(open: stackPanelOpen, config), - StatusMapper.tabCycleThread(config), - StatusMapper.landThread(open: landPanelOpen, config)] - + flexKeys + let threads = StatusMapper.threads(for: fetched, config) + flexKeys // Fingerprint the whole rendered picture, not just the aggregate, so // one agent changing still repaints when the worst state has not. @@ -420,20 +430,21 @@ public final class BridgeController: ObservableObject { /// has to agree with `Pad`, so keep the dispatch in a single switch. public func handleKeyPress(_ index: Int) { if onKeyIntercept?(index) == true { return } + // A config binding wins over any of these keys' built-in role — this + // is how the config file may repurpose stack, tabs, land, the macro + // keys, or the wide voice key. + if Pad.overridableKeyIDs.contains(index), let action = keyBindings.action(for: index) { + perform(action) + return + } if index == Pad.stackKeyID { onStackKey?() } else if index == Pad.tabCycleKeyID { Task { await cycleTabs() } } else if index == Pad.landKeyID { onLandKey?() - } else if Pad.macroKeyIDs.contains(index) || Pad.voiceKeyIDs.contains(index) { - // A configured text macro wins over the key's built-in role, which - // is how the config file may repurpose the wide voice key. - if let text = keyBindings.text(for: index) { - Task { await injectPrompt(text) } - } else if Pad.voiceKeyIDs.contains(index) { - onVoiceKey?() - } + } else if Pad.voiceKeyIDs.contains(index) { + onVoiceKey?() } else if index == Pad.dialUpID || index == Pad.dialDownID { onDial?(index == Pad.dialUpID ? 1 : -1) } else if let direction = Pad.JoystickDirection(keyID: index) { @@ -443,6 +454,24 @@ public final class BridgeController: ObservableObject { } } + /// Runs a key's bound action. A shortcut is validated here rather than at + /// config-load time, so a typo surfaces as a panel error on the press that + /// hits it, not a silently dropped binding. + private func perform(_ action: KeyBindings.KeyAction) { + switch action { + case .off: + break // explicitly inert — beats the built-in job on purpose + case .text(let text): + Task { await injectPrompt(text) } + case .shortcut(let spec): + guard let parsed = ShortcutSpec.parse(spec) else { + lastError = "Unrecognised shortcut \"\(spec)\"." + return + } + onShortcut?(parsed) + } + } + /// Types a macro string into the focused agent's prompt, unsubmitted — /// the human still reads it and presses enter. public func injectPrompt(_ text: String) async { @@ -481,6 +510,52 @@ public final class BridgeController: ObservableObject { } } + /// The dial as a Herdr navigator. `step` is +1 / -1 from the encoder + /// detents; `.effort` is handled in the app layer and never reaches here. + public func cycleDial(_ step: Int, mode: KeyBindings.DialMode) async { + switch mode { + case .effort: break + case .agent: await cycleAgent(step) + case .tab: await cycleTabsBy(step) + case .space: await cycleWorkspace(step) + } + } + + private func cycleTabsBy(_ step: Int) async { + do { + try await HerdrClient.cycleTabs(step) + } catch { + lastError = error.localizedDescription + } + } + + /// Steps focus to the next/previous agent, in the same reading order the + /// keys use, and brings the terminal forward as an agent key would. + private func cycleAgent(_ step: Int) async { + do { + guard let next = HerdrClient.adjacentAgent( + in: try await HerdrClient.listAgents(), step: step + ), let target = next.focusTarget else { return } + try await HerdrClient.focusAgent(target) + raiseTerminal() + } catch { + lastError = error.localizedDescription + } + } + + /// Steps focus to the next/previous workspace in sidebar order. + private func cycleWorkspace(_ step: Int) async { + do { + guard let next = HerdrClient.adjacentWorkspace( + in: try await HerdrClient.listWorkspaces(), step: step + ) else { return } + try await HerdrClient.focusWorkspace(next.workspaceID) + raiseTerminal() + } catch { + lastError = error.localizedDescription + } + } + /// Repaints the stack key. Called by the app when the window opens or /// closes, so the key reflects what is actually on screen. public func setStackPanelOpen(_ open: Bool) async { diff --git a/Sources/WLKit/HerdrClient.swift b/Sources/WLKit/HerdrClient.swift index 29387e9..c59cfd1 100644 --- a/Sources/WLKit/HerdrClient.swift +++ b/Sources/WLKit/HerdrClient.swift @@ -100,6 +100,26 @@ public struct HerdrTab: Equatable, Sendable { } } +public struct HerdrWorkspace: Equatable, Sendable { + public var workspaceID: String + /// Display position in the sidebar, which is the order workspaces cycle in. + public var number: Int + public var focused: Bool + + init(json: [String: Any]) { + workspaceID = json["workspace_id"] as? String ?? "" + number = json["number"] as? Int ?? 0 + focused = json["focused"] as? Bool ?? false + } + + /// For tests. + public init(workspaceID: String, number: Int, focused: Bool = false) { + self.workspaceID = workspaceID + self.number = number + self.focused = focused + } +} + public enum HerdrError: LocalizedError { case cannotConnect(String, String) case timeout(String) @@ -205,6 +225,18 @@ public enum HerdrClient { _ = try await request("agent.focus", params: ["target": target]) } + /// Workspaces (Herdr's "spaces") in `number` order — the sidebar order. + public static func listWorkspaces() async throws -> [HerdrWorkspace] { + let result = try await request("workspace.list") + let raw = result["workspaces"] as? [[String: Any]] ?? [] + return raw.map(HerdrWorkspace.init(json:)).sorted { $0.number < $1.number } + } + + /// Focuses a workspace; Herdr restores that space's own active tab and pane. + public static func focusWorkspace(_ workspaceID: String) async throws { + _ = try await request("workspace.focus", params: ["workspace_id": workspaceID]) + } + public static func listTabs(workspaceID: String? = nil) async throws -> [HerdrTab] { var params: [String: Any] = [:] if let workspaceID { params["workspace_id"] = workspaceID } @@ -229,17 +261,17 @@ public enum HerdrClient { _ = try await request("pane.send_text", params: ["pane_id": paneID, "text": text]) } - /// Focuses the tab after the focused one in its workspace, wrapping at the - /// end. Tabs in other workspaces are left alone: cycling is a - /// within-window gesture, not a window switcher. - public static func cycleTabs() async throws { - guard let next = nextTab(in: try await listTabs()) else { return } + /// Focuses the tab `step` places from the focused one in its workspace, + /// wrapping at either end. Tabs in other workspaces are left alone: cycling + /// is a within-window gesture, not a window switcher. + public static func cycleTabs(_ step: Int = 1) async throws { + guard let next = adjacentTab(in: try await listTabs(), step: step) else { return } try await focusTab(next.tabID) } - /// The tab `cycleTabs` would focus, or nil when there is nothing to do — - /// no focused tab, or a workspace with a single tab. - public static func nextTab(in tabs: [HerdrTab]) -> HerdrTab? { + /// The tab `step` places from the focused one, wrapping, or nil when there + /// is nothing to do — no focused tab, or a workspace with a single tab. + public static func adjacentTab(in tabs: [HerdrTab], step: Int) -> HerdrTab? { guard let focused = tabs.first(where: \.focused) else { return nil } let siblings = tabs .filter { $0.workspaceID == focused.workspaceID } @@ -247,7 +279,37 @@ public enum HerdrClient { guard siblings.count > 1, let index = siblings.firstIndex(of: focused) else { return nil } - return siblings[(index + 1) % siblings.count] + return siblings[wrap(index + step, into: siblings.count)] + } + + /// The tab the tabs key focuses: one step forward. + public static func nextTab(in tabs: [HerdrTab]) -> HerdrTab? { + adjacentTab(in: tabs, step: 1) + } + + /// The agent `step` places from the focused one in `agent.list` order — + /// which is sidebar order and is never re-sorted — wrapping at either end. + /// Nil when nothing is focused or there is only one agent. + public static func adjacentAgent(in agents: [HerdrAgent], step: Int) -> HerdrAgent? { + guard agents.count > 1, + let index = agents.firstIndex(where: \.focused) + else { return nil } + return agents[wrap(index + step, into: agents.count)] + } + + /// The workspace `step` places from the focused one in `number` order, + /// wrapping. Nil when nothing is focused or there is only one workspace. + public static func adjacentWorkspace(in spaces: [HerdrWorkspace], step: Int) -> HerdrWorkspace? { + let ordered = spaces.sorted { $0.number < $1.number } + guard ordered.count > 1, + let index = ordered.firstIndex(where: \.focused) + else { return nil } + return ordered[wrap(index + step, into: ordered.count)] + } + + /// Positive modulo, so a step past either end wraps rather than trapping. + private static func wrap(_ index: Int, into count: Int) -> Int { + ((index % count) + count) % count } private static let counter = Counter() diff --git a/Sources/WLKit/KeyBindings.swift b/Sources/WLKit/KeyBindings.swift index fa49425..26f9db1 100644 --- a/Sources/WLKit/KeyBindings.swift +++ b/Sources/WLKit/KeyBindings.swift @@ -11,18 +11,58 @@ import Foundation /// "12": "Run but pull", /// "10+11": "Summarise what you are working on" /// }, +/// "dial": "effort", /// "claude": { "models": ["fable", "opus"], "efforts": ["low", "high"] }, /// "codex": { "models": ["gpt-5.6-sol", "gpt-5.6-codex"] } /// } /// /// A bound string is injected into the focused agent's prompt, unsubmitted. -/// `"10+11"` addresses the wide key as one; `"10"` and `"11"` address its -/// halves separately. Keys the file does not mention keep their defaults — -/// 9 and 12 have the defaults below, the wide key defaults to the voice key. -/// An empty string unbinds a key outright. +/// A key can also be bound to a system-wide keyboard shortcut instead — +/// `{"shortcut": "cmd+shift+5"}` — synthesised regardless of what Herdr is +/// doing. `"10+11"` addresses the wide key as one; `"10"` and `"11"` address +/// its halves separately. Keys the file does not mention keep their +/// defaults — 9 and 12 default to the text macros below, the wide key +/// defaults to the voice key, and 6/7/8 default to stack/tabs/land. Binding +/// any of those overrides its default; an empty string (or `{"shortcut":""}`) +/// unbinds a key outright. +/// +/// A top-level `"dial"` string repurposes the knob: `"effort"` (the default) +/// climbs the reasoning-effort ladder, `"agent"` steps the focused agent, +/// `"tab"` cycles tabs in the focused workspace, `"space"` (or `"workspace"`) +/// steps the focused workspace. Anything else falls back to `"effort"`. public struct KeyBindings: Sendable, Equatable { - public private(set) var texts: [Int: String] + /// What a dial detent does. `effort` is handled in the app layer; the rest + /// are Herdr navigation and run through `BridgeController.cycleDial`. + public enum DialMode: String, Sendable { + case effort, agent, tab, space + + init(config: String?) { + switch config?.lowercased() { + case "agent": self = .agent + case "tab": self = .tab + case "space", "workspace": self = .space + default: self = .effort + } + } + } + + /// What a bound key does. `.shortcut` carries the raw config string — + /// `ShortcutSpec.parse` validates it at dispatch time, so a typo shows up + /// as an error in the panel rather than silently dropping the binding. + /// + /// `.off` is deliberately not the same thing as a key going unmentioned: + /// an unmentioned stack/tabs/land/voice key keeps its built-in job, but + /// `.off` is a real, present binding that beats it — how you silence a + /// built-in you don't want. (Named `off`, not `none`, so it can never be + /// confused with `Optional.none` when matched against `KeyAction?`.) + public enum KeyAction: Equatable, Sendable { + case text(String) + case shortcut(String) + case off + } + + public private(set) var actions: [Int: KeyAction] /// The models the joystick cycles through in Claude Code, in order. /// Overridable with a top-level `"claude": {"models": [...]}` object. public private(set) var claudeModels: [String] @@ -35,28 +75,40 @@ public struct KeyBindings: Sendable, Equatable { /// the panel shows the steering guide alone. public private(set) var codexModels: [String] - public static let defaults: [Int: String] = [ - 9: "Open PRs for all active GitButler branches", - 12: "Run but pull", + /// What the knob does. Set with a top-level `"dial"` string. + public private(set) var dialMode: DialMode + + public static let defaults: [Int: KeyAction] = [ + 9: .text("Open PRs for all active GitButler branches"), + 12: .text("Run but pull"), ] public static let defaultClaudeModels = ["fable", "opus", "sonnet", "haiku"] public static let defaultClaudeEfforts = ["low", "medium", "high", "xhigh", "max"] public init( - texts: [Int: String] = KeyBindings.defaults, + actions: [Int: KeyAction] = KeyBindings.defaults, claudeModels: [String] = KeyBindings.defaultClaudeModels, claudeEfforts: [String] = KeyBindings.defaultClaudeEfforts, - codexModels: [String] = [] + codexModels: [String] = [], + dialMode: DialMode = .effort ) { - self.texts = texts + self.actions = actions self.claudeModels = claudeModels self.claudeEfforts = claudeEfforts self.codexModels = codexModels + self.dialMode = dialMode + } + + /// The action bound to a key, or nil when the key does whatever it does + /// by default (an agent key, or — unless overridden — stack/tabs/land/voice). + public func action(for key: Int) -> KeyAction? { + actions[key] } - /// The string bound to a key, or nil when the key does something else. + /// The text bound to a key, or nil when it is unbound or bound to a + /// shortcut instead. public func text(for key: Int) -> String? { - guard let text = texts[key], !text.isEmpty else { return nil } + guard case .text(let text) = actions[key] else { return nil } return text } @@ -79,16 +131,12 @@ public struct KeyBindings: Sendable, Equatable { guard let json = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] else { return KeyBindings() } - var texts = defaults + var actions = defaults if let keys = json["keys"] as? [String: Any] { for (name, value) in keys { - guard let text = value as? String else { continue } + guard let action = keyAction(from: value) else { continue } for key in keyIDs(for: name) { - if text.isEmpty { - texts.removeValue(forKey: key) - } else { - texts[key] = text - } + actions[key] = action } } } @@ -99,13 +147,29 @@ public struct KeyBindings: Sendable, Equatable { let codex = json["codex"] as? [String: Any] let codexModels = (codex?["models"] as? [String])?.filter { !$0.isEmpty } return KeyBindings( - texts: texts, + actions: actions, claudeModels: models?.isEmpty == false ? models! : defaultClaudeModels, claudeEfforts: efforts?.isEmpty == false ? efforts! : defaultClaudeEfforts, - codexModels: codexModels ?? [] + codexModels: codexModels ?? [], + dialMode: DialMode(config: json["dial"] as? String) ) } + /// A key's value is a bare string (a text macro, or `""` to turn it off), + /// an object with a `"shortcut"` string (same empty-string rule), or + /// `false` (turns it off outright — the unambiguous choice for a + /// stack/tabs/land key, where an empty string could read as "leave it + /// alone"). Anything else — `true`, a shortcut object missing its field, + /// a number — binds nothing, same as leaving the key unmentioned. + private static func keyAction(from value: Any) -> KeyAction? { + if let flag = value as? Bool { return flag ? nil : .off } + if let text = value as? String { return text.isEmpty ? .off : .text(text) } + if let object = value as? [String: Any], let shortcut = object["shortcut"] as? String { + return shortcut.isEmpty ? .off : .shortcut(shortcut) + } + return nil + } + private static func keyIDs(for name: String) -> [Int] { if name == "10+11" { return Pad.voiceKeyIDs } guard let id = Int(name) else { return [] } diff --git a/Sources/WLKit/OAIProtocol.swift b/Sources/WLKit/OAIProtocol.swift index a6329a1..a709404 100644 --- a/Sources/WLKit/OAIProtocol.swift +++ b/Sources/WLKit/OAIProtocol.swift @@ -192,6 +192,12 @@ public enum Pad { public static let boundKeyIDs = agentKeyIDs + [stackKeyID, tabCycleKeyID, landKeyID] + voiceKeyIDs + macroKeyIDs + /// Every key whose job `config.json` can replace: stack, tabs, land, the + /// wide key, and the two plain macro keys. Agent keys are never in this + /// list — Herdr's own agent list decides those. + public static let overridableKeyIDs = + [stackKeyID, tabCycleKeyID, landKeyID] + voiceKeyIDs + macroKeyIDs + // MARK: Dial and joystick /// The dial and joystick emit keycodes like the keys do, so binding them diff --git a/Sources/WLKit/ShortcutSpec.swift b/Sources/WLKit/ShortcutSpec.swift new file mode 100644 index 0000000..65ee25f --- /dev/null +++ b/Sources/WLKit/ShortcutSpec.swift @@ -0,0 +1,68 @@ +import Foundation + +/// A parsed keyboard shortcut, e.g. `"cmd+shift+5"` — the modifier flags and +/// virtual keycode `CGEvent` needs to synthesise it. Parsing lives here, with +/// no AppKit dependency, so it can be tested on its own; posting the event is +/// app-layer, the same split `VoiceController` uses for the wide key's tap. +public struct ShortcutSpec: Equatable, Sendable { + public var keyCode: UInt16 + public var modifiers: Modifiers + + public struct Modifiers: OptionSet, Sendable, Equatable { + public let rawValue: UInt8 + public init(rawValue: UInt8) { self.rawValue = rawValue } + public static let command = Modifiers(rawValue: 1 << 0) + public static let shift = Modifiers(rawValue: 1 << 1) + public static let option = Modifiers(rawValue: 1 << 2) + public static let control = Modifiers(rawValue: 1 << 3) + } + + /// Parses a `"+"`-separated spec like `"cmd+shift+5"` or `"ctrl+alt+u"`: + /// modifiers in any order, case-insensitive, exactly one base key. Nil for + /// anything that isn't exactly that — no base key, more than one, or a + /// name this table doesn't know. + public static func parse(_ spec: String) -> ShortcutSpec? { + let parts = spec + .lowercased() + .split(separator: "+") + .map { $0.trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty } + guard !parts.isEmpty else { return nil } + + var modifiers: Modifiers = [] + var keyCode: UInt16? + for part in parts { + switch part { + case "cmd", "command": modifiers.insert(.command) + case "shift": modifiers.insert(.shift) + case "opt", "option", "alt": modifiers.insert(.option) + case "ctrl", "control": modifiers.insert(.control) + default: + // A second base key, or an unrecognised name, both fail — + // reporting silence rather than guessing which one you meant. + guard keyCode == nil, let code = keyCodes[part] else { return nil } + keyCode = code + } + } + guard let keyCode else { return nil } + return ShortcutSpec(keyCode: keyCode, modifiers: modifiers) + } + + /// Standard US-ANSI virtual keycodes (the Carbon `kVK_*` table). Named + /// keys only need to cover what a macro key would plausibly bind — + /// letters, digits, punctuation, function keys, arrows. + private static let keyCodes: [String: UInt16] = [ + "a": 0, "s": 1, "d": 2, "f": 3, "h": 4, "g": 5, "z": 6, "x": 7, "c": 8, "v": 9, + "b": 11, "q": 12, "w": 13, "e": 14, "r": 15, "y": 16, "t": 17, + "1": 18, "2": 19, "3": 20, "4": 21, "6": 22, "5": 23, "equal": 24, "9": 25, "7": 26, + "minus": 27, "8": 28, "0": 29, "rightbracket": 30, "o": 31, "u": 32, + "leftbracket": 33, "i": 34, "p": 35, "return": 36, "l": 37, "j": 38, + "quote": 39, "k": 40, "semicolon": 41, "backslash": 42, "comma": 43, + "slash": 44, "n": 45, "m": 46, "period": 47, "tab": 48, "space": 49, + "grave": 50, "delete": 51, "escape": 53, + "f1": 122, "f2": 120, "f3": 99, "f4": 118, "f5": 96, "f6": 97, "f7": 98, + "f8": 100, "f9": 101, "f10": 109, "f11": 103, "f12": 111, + "f13": 105, "f14": 107, "f15": 113, "f16": 106, "f17": 64, "f18": 79, "f19": 80, + "left": 123, "right": 124, "down": 125, "up": 126, + ] +} diff --git a/Sources/WLMicroManager/MenuPanelView.swift b/Sources/WLMicroManager/MenuPanelView.swift index 78ed990..7bd6d7b 100644 --- a/Sources/WLMicroManager/MenuPanelView.swift +++ b/Sources/WLMicroManager/MenuPanelView.swift @@ -101,30 +101,22 @@ struct MenuPanelView: View { private func keyView(_ index: Int) -> some View { let color = bridge.keyColors[index] let isBound = Pad.boundKeyIDs.contains(index) - let isStackKey = index == Pad.stackKeyID - let isTabCycleKey = index == Pad.tabCycleKeyID - let isLandKey = index == Pad.landKeyID - let macroText = bridge.keyBindings.text(for: index) - let isVoiceKey = macroText == nil && Pad.voiceKeyIDs.contains(index) + // A binding overrides the key's built-in role — same rule + // `BridgeController.handleKeyPress` uses, so the two stay in sync. + let action = Pad.overridableKeyIDs.contains(index) ? bridge.keyBindings.action(for: index) : nil + let isStackKey = index == Pad.stackKeyID && action == nil + let isTabCycleKey = index == Pad.tabCycleKeyID && action == nil + let isLandKey = index == Pad.landKeyID && action == nil + let isVoiceKey = action == nil && Pad.voiceKeyIDs.contains(index) // Key index and agent slot are different orderings — the top row is // wired right to left — so the slot lookup goes through the pad map. let slot = Pad.agentSlot(for: index) let agent = slot.flatMap { $0 < bridge.agents.count ? bridge.agents[$0] : nil } return Button { - if isStackKey { - StackPanelController.shared.toggle() - } else if isTabCycleKey { - Task { await bridge.cycleTabs() } - } else if isLandKey { - LandPanelController.shared.handleLandKey() - } else if let macroText { - Task { await bridge.injectPrompt(macroText) } - } else if isVoiceKey { - VoiceController.shared.handleVoiceKey() - } else if let slot, agent != nil { - Task { await bridge.focusSlot(slot) } - } + // The same dispatch a physical press goes through, so a click + // here can never disagree with what the key actually does. + bridge.handleKeyPress(index) } label: { RoundedRectangle(cornerRadius: 5) .fill(color ?? Color.secondary.opacity(isBound ? 0.16 : 0.07)) @@ -136,10 +128,10 @@ struct MenuPanelView: View { } .buttonStyle(.plain) .disabled(agent == nil && !isStackKey && !isTabCycleKey && !isLandKey - && macroText == nil && !isVoiceKey) + && (action == nil || action == .off) && !isVoiceKey) .help(helpText(index, agent: agent, isStackKey: isStackKey, isTabCycleKey: isTabCycleKey, isLandKey: isLandKey, - macroText: macroText, isVoiceKey: isVoiceKey)) + action: action, isVoiceKey: isVoiceKey)) } private func helpText( @@ -148,13 +140,18 @@ struct MenuPanelView: View { isStackKey: Bool, isTabCycleKey: Bool, isLandKey: Bool, - macroText: String?, + action: KeyBindings.KeyAction?, isVoiceKey: Bool ) -> String { if isStackKey { return "GitButler stack for the focused agent" } if isTabCycleKey { return "Cycle tabs in the focused Herdr window" } if isLandKey { return "Land the focused agent's branches onto the target" } - if let macroText { return "Type: \(macroText)" } + switch action { + case .text(let text): return "Type: \(text)" + case .shortcut(let spec): return "Shortcut: \(spec)" + case .off: return "Unbound" + case nil: break + } if isVoiceKey { return "Right command — start or stop Superwhisper" } if let agent { return "\(agent.shortName) — \(agent.status)" } return "Key \(index)" diff --git a/Sources/WLMicroManager/MicroManagerApp.swift b/Sources/WLMicroManager/MicroManagerApp.swift index 45afbde..2b47129 100644 --- a/Sources/WLMicroManager/MicroManagerApp.swift +++ b/Sources/WLMicroManager/MicroManagerApp.swift @@ -43,6 +43,12 @@ struct MicroManagerApp: App { } bridge.onVoiceKey = { voice.handleVoiceKey() } + let shortcuts = ShortcutController.shared + shortcuts.onError = { [weak bridge] message in + bridge?.noteError(message) + } + bridge.onShortcut = { spec in shortcuts.post(spec) } + let tune = TuneController.shared tune.onError = { [weak bridge] message in bridge?.noteError(message) @@ -50,7 +56,18 @@ struct MicroManagerApp: App { tune.bindings = { [weak bridge] in bridge?.keyBindings ?? KeyBindings() } - bridge.onDial = { step in tune.handleDial(step) } + // `config.json`'s "dial" key decides the knob's job. It is + // re-read on every bridge start, and this closure reads it + // live, so an edit takes effect on the next off/on toggle. + bridge.onDial = { [weak bridge] step in + guard let bridge else { return } + let mode = bridge.keyBindings.dialMode + if mode == .effort { + tune.handleDial(step) + } else { + Task { await bridge.cycleDial(step, mode: mode) } + } + } bridge.onJoystick = { direction in tune.handleJoystick(direction) } // While a land confirmation is up, every key that is not // the land key means "cancel", nothing else. diff --git a/Sources/WLMicroManager/ShortcutController.swift b/Sources/WLMicroManager/ShortcutController.swift new file mode 100644 index 0000000..37860f6 --- /dev/null +++ b/Sources/WLMicroManager/ShortcutController.swift @@ -0,0 +1,56 @@ +import AppKit +import WLKit + +/// Posts a config-bound `ShortcutSpec` as a real system keystroke, so a spare +/// key can drive anything a hotkey can — a screenshot tool, Mission Control, +/// an app's own shortcut — regardless of what Herdr is doing. +/// +/// Same technique as `VoiceController`'s right-command tap: a synthesised +/// `CGEvent`, which needs the Accessibility grant on top of the pad's own +/// Input Monitoring grant. +@MainActor +final class ShortcutController { + + static let shared = ShortcutController() + + var onError: ((String) -> Void)? + + func post(_ spec: ShortcutSpec) { + let prompt = kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String + guard AXIsProcessTrustedWithOptions([prompt: true] as CFDictionary) else { + onError?("Keyboard shortcuts need the Accessibility permission: " + + "System Settings → Privacy & Security → Accessibility → Micro Manager.") + return + } + + let source = CGEventSource(stateID: .hidSystemState) + guard + let down = CGEvent(keyboardEventSource: source, + virtualKey: CGKeyCode(spec.keyCode), keyDown: true), + let up = CGEvent(keyboardEventSource: source, + virtualKey: CGKeyCode(spec.keyCode), keyDown: false) + else { + onError?("Could not synthesise that shortcut.") + return + } + + // The modifier goes on as a flag on the key event itself, exactly as + // the voice key's right-command tap does — cleared on the way up so + // nothing is left holding a modifier down. + down.flags = spec.modifiers.cgEventFlags + up.flags = [] + down.post(tap: .cghidEventTap) + up.post(tap: .cghidEventTap) + } +} + +private extension ShortcutSpec.Modifiers { + var cgEventFlags: CGEventFlags { + var flags: CGEventFlags = [] + if contains(.command) { flags.insert(.maskCommand) } + if contains(.shift) { flags.insert(.maskShift) } + if contains(.option) { flags.insert(.maskAlternate) } + if contains(.control) { flags.insert(.maskControl) } + return flags + } +} diff --git a/Tests/WLKitTests/HerdrDialCycleTests.swift b/Tests/WLKitTests/HerdrDialCycleTests.swift new file mode 100644 index 0000000..347b666 --- /dev/null +++ b/Tests/WLKitTests/HerdrDialCycleTests.swift @@ -0,0 +1,101 @@ +import XCTest +@testable import WLKit + +/// The dial's `agent` and `space` modes: step the focused agent or workspace +/// one place along, wrapping, in whichever direction the encoder turned. +final class HerdrAgentCycleTests: XCTestCase { + + private func agent(_ pane: String, focused: Bool = false) -> HerdrAgent { + HerdrAgent(status: "idle", paneID: pane, focused: focused) + } + + func testAdvancesToTheNextAgentInListOrder() { + let agents = [agent("p1", focused: true), agent("p2"), agent("p3")] + XCTAssertEqual(HerdrClient.adjacentAgent(in: agents, step: 1)?.paneID, "p2") + } + + func testStepsBackwardWithANegativeStep() { + let agents = [agent("p1"), agent("p2", focused: true), agent("p3")] + XCTAssertEqual(HerdrClient.adjacentAgent(in: agents, step: -1)?.paneID, "p1") + } + + func testWrapsForwardPastTheEnd() { + let agents = [agent("p1"), agent("p2"), agent("p3", focused: true)] + XCTAssertEqual(HerdrClient.adjacentAgent(in: agents, step: 1)?.paneID, "p1") + } + + func testWrapsBackwardPastTheStart() { + let agents = [agent("p1", focused: true), agent("p2")] + XCTAssertEqual(HerdrClient.adjacentAgent(in: agents, step: -1)?.paneID, "p2") + } + + /// `agent.list` order is sidebar order and is never re-sorted; the ids here + /// are deliberately out of alphabetical order to prove it. + func testKeepsListOrderAndNeverResorts() { + let agents = [agent("z"), agent("a", focused: true), agent("m")] + XCTAssertEqual(HerdrClient.adjacentAgent(in: agents, step: 1)?.paneID, "m") + } + + func testNoFocusedAgentDoesNothing() { + XCTAssertNil(HerdrClient.adjacentAgent(in: [agent("p1"), agent("p2")], step: 1)) + } + + func testASingleAgentHasNowhereToGo() { + XCTAssertNil(HerdrClient.adjacentAgent(in: [agent("p1", focused: true)], step: 1)) + } + + func testEmptyListIsSafe() { + XCTAssertNil(HerdrClient.adjacentAgent(in: [], step: 1)) + } +} + +final class HerdrWorkspaceCycleTests: XCTestCase { + + func testAdvancesToTheNextWorkspaceByNumber() { + let spaces = [ + HerdrWorkspace(workspaceID: "w1", number: 1, focused: true), + HerdrWorkspace(workspaceID: "w2", number: 2), + HerdrWorkspace(workspaceID: "w3", number: 3), + ] + XCTAssertEqual(HerdrClient.adjacentWorkspace(in: spaces, step: 1)?.workspaceID, "w2") + } + + func testStepsBackwardWithANegativeStep() { + let spaces = [ + HerdrWorkspace(workspaceID: "w1", number: 1), + HerdrWorkspace(workspaceID: "w2", number: 2, focused: true), + ] + XCTAssertEqual(HerdrClient.adjacentWorkspace(in: spaces, step: -1)?.workspaceID, "w1") + } + + func testWrapsFromTheLastWorkspaceToTheFirst() { + let spaces = [ + HerdrWorkspace(workspaceID: "w1", number: 1), + HerdrWorkspace(workspaceID: "w2", number: 2, focused: true), + ] + XCTAssertEqual(HerdrClient.adjacentWorkspace(in: spaces, step: 1)?.workspaceID, "w1") + } + + /// `workspace.list` order is not guaranteed to be display order; `number` is. + func testCyclesInNumberOrderNotListOrder() { + let spaces = [ + HerdrWorkspace(workspaceID: "w3", number: 3), + HerdrWorkspace(workspaceID: "w1", number: 1, focused: true), + HerdrWorkspace(workspaceID: "w2", number: 2), + ] + XCTAssertEqual(HerdrClient.adjacentWorkspace(in: spaces, step: 1)?.workspaceID, "w2") + } + + func testNoFocusedWorkspaceDoesNothing() { + let spaces = [ + HerdrWorkspace(workspaceID: "w1", number: 1), + HerdrWorkspace(workspaceID: "w2", number: 2), + ] + XCTAssertNil(HerdrClient.adjacentWorkspace(in: spaces, step: 1)) + } + + func testASingleWorkspaceHasNowhereToGo() { + let spaces = [HerdrWorkspace(workspaceID: "w1", number: 1, focused: true)] + XCTAssertNil(HerdrClient.adjacentWorkspace(in: spaces, step: 1)) + } +} diff --git a/Tests/WLKitTests/HerdrTabCycleTests.swift b/Tests/WLKitTests/HerdrTabCycleTests.swift index faeb887..43fc379 100644 --- a/Tests/WLKitTests/HerdrTabCycleTests.swift +++ b/Tests/WLKitTests/HerdrTabCycleTests.swift @@ -51,4 +51,35 @@ final class HerdrTabCycleTests: XCTestCase { ] XCTAssertNil(HerdrClient.nextTab(in: tabs)) } + + // MARK: - Direction + + /// The dial turns both ways, so tab cycling has to as well. + func testStepsBackwardToThePreviousTab() { + let tabs = [ + HerdrTab(tabID: "t1", number: 1), + HerdrTab(tabID: "t2", number: 2, focused: true), + HerdrTab(tabID: "t3", number: 3), + ] + XCTAssertEqual(HerdrClient.adjacentTab(in: tabs, step: -1)?.tabID, "t1") + } + + func testWrapsBackwardFromTheFirstTabToTheLast() { + let tabs = [ + HerdrTab(tabID: "t1", number: 1, focused: true), + HerdrTab(tabID: "t2", number: 2), + HerdrTab(tabID: "t3", number: 3), + ] + XCTAssertEqual(HerdrClient.adjacentTab(in: tabs, step: -1)?.tabID, "t3") + } + + /// The tabs key still works: it is `adjacentTab` with a step of one. + func testNextTabIsAdjacentTabForwardOne() { + let tabs = [ + HerdrTab(tabID: "t1", number: 1, focused: true), + HerdrTab(tabID: "t2", number: 2), + ] + XCTAssertEqual(HerdrClient.nextTab(in: tabs)?.tabID, + HerdrClient.adjacentTab(in: tabs, step: 1)?.tabID) + } } diff --git a/Tests/WLKitTests/KeyBindingsTests.swift b/Tests/WLKitTests/KeyBindingsTests.swift index 68954fc..5e18880 100644 --- a/Tests/WLKitTests/KeyBindingsTests.swift +++ b/Tests/WLKitTests/KeyBindingsTests.swift @@ -86,4 +86,111 @@ final class KeyBindingsTests: XCTestCase { XCTAssertEqual(bindings.claudeModels, KeyBindings.defaultClaudeModels, "a codex-only config leaves the claude side alone") } + + // MARK: - Dial mode + + /// With nothing in the file the dial keeps its original job: effort. + func testDialModeDefaultsToEffort() { + XCTAssertEqual(KeyBindings().dialMode, .effort) + XCTAssertEqual(parse("{}").dialMode, .effort) + } + + func testDialModeComesFromTheFile() { + XCTAssertEqual(parse(#"{"dial": "agent"}"#).dialMode, .agent) + XCTAssertEqual(parse(#"{"dial": "tab"}"#).dialMode, .tab) + XCTAssertEqual(parse(#"{"dial": "space"}"#).dialMode, .space) + XCTAssertEqual(parse(#"{"dial": "effort"}"#).dialMode, .effort) + } + + /// `space` is the pad-facing word; `workspace` is the socket API's. Take both. + func testDialModeAcceptsWorkspaceAsAnAliasForSpace() { + XCTAssertEqual(parse(#"{"dial": "workspace"}"#).dialMode, .space) + } + + func testDialModeIsCaseInsensitive() { + XCTAssertEqual(parse(#"{"dial": "AGENT"}"#).dialMode, .agent) + } + + /// A typo or the wrong type should not brick the dial — fall back to effort. + func testUnknownDialModeFallsBackToEffort() { + XCTAssertEqual(parse(#"{"dial": "banana"}"#).dialMode, .effort) + XCTAssertEqual(parse(#"{"dial": 5}"#).dialMode, .effort) + } + + func testDialModeSurvivesAKeysOnlyConfig() { + XCTAssertEqual(parse(#"{"keys": {"9": "Ship it"}}"#).dialMode, .effort) + } + + // MARK: - Key actions (text and shortcut) + + func testAKeyCanBeBoundToAShortcut() { + let bindings = parse(#"{"keys": {"6": {"shortcut": "cmd+shift+5"}}}"#) + XCTAssertEqual(bindings.action(for: 6), .shortcut("cmd+shift+5")) + XCTAssertNil(bindings.text(for: 6), "a shortcut binding is not a text binding") + } + + /// Stack, tabs, and land used to be fixed; a bound key now overrides them. + func testStackTabAndLandKeysAreOverridable() { + let bindings = parse(#""" + {"keys": {"6": {"shortcut": "cmd+shift+5"}, "7": "note", "8": ""}} + """#) + XCTAssertEqual(bindings.action(for: 6), .shortcut("cmd+shift+5")) + XCTAssertEqual(bindings.action(for: 7), .text("note")) + XCTAssertEqual(bindings.action(for: 8), .off, "empty string turns the key off outright") + } + + func testUnmentionedStackTabAndLandKeysHaveNoActionByDefault() { + // nil here means "unmentioned, keep the built-in job" — not the same + // as `.off`, which is an explicit override that beats the built-in. + XCTAssertNil(KeyBindings().action(for: Pad.stackKeyID)) + XCTAssertNil(KeyBindings().action(for: Pad.tabCycleKeyID)) + XCTAssertNil(KeyBindings().action(for: Pad.landKeyID)) + } + + func testMacroKeyDefaultsComeBackAsTextActions() { + XCTAssertEqual(KeyBindings().action(for: 9), + .text("Open PRs for all active GitButler branches")) + } + + /// A shortcut object with no recognised field binds nothing, same as a + /// malformed value anywhere else in the file — the key stays unmentioned. + func testAShortcutObjectMissingItsFieldIsIgnored() { + let bindings = parse(#"{"keys": {"6": {}}}"#) + XCTAssertNil(bindings.action(for: 6)) + } + + func testWideKeyShortcutBindsBothHalves() { + let bindings = parse(#"{"keys": {"10+11": {"shortcut": "cmd+shift+5"}}}"#) + XCTAssertEqual(bindings.action(for: 10), .shortcut("cmd+shift+5")) + XCTAssertEqual(bindings.action(for: 11), .shortcut("cmd+shift+5")) + } + + /// An empty shortcut string turns the key off too, same rule as text. + func testEmptyShortcutTurnsAKeyOff() { + let bindings = parse(#"{"keys": {"9": {"shortcut": ""}}}"#) + XCTAssertEqual(bindings.action(for: 9), .off) + } + + /// `false` is the explicit, no-ambiguity way to turn a key off — the + /// value to reach for on 6/7/8, whose built-in job would otherwise run. + func testFalseExplicitlyTurnsAKeyOff() { + let bindings = parse(#"{"keys": {"6": false, "8": false}}"#) + XCTAssertEqual(bindings.action(for: 6), .off) + XCTAssertEqual(bindings.action(for: 8), .off) + } + + /// `.off` beats the built-in even though it is a real, present binding — + /// unlike an unmentioned key, it is not nil. + func testOffIsARealBindingNotAnAbsentOne() { + let bindings = parse(#"{"keys": {"6": false}}"#) + XCTAssertNotNil(bindings.action(for: 6)) + XCTAssertEqual(bindings.action(for: 6), .off) + } + + /// `true` has no meaning for a key binding — ignored like any other + /// malformed value, leaving the key at its default. + func testTrueIsIgnored() { + let bindings = parse(#"{"keys": {"6": true}}"#) + XCTAssertNil(bindings.action(for: 6)) + } } diff --git a/Tests/WLKitTests/ShortcutSpecTests.swift b/Tests/WLKitTests/ShortcutSpecTests.swift new file mode 100644 index 0000000..a15b4c2 --- /dev/null +++ b/Tests/WLKitTests/ShortcutSpecTests.swift @@ -0,0 +1,58 @@ +import XCTest +@testable import WLKit + +/// Parsing for config-bound keyboard shortcuts ("cmd+shift+5"). Posting the +/// synthesised event is app-layer (needs AppKit); this is the pure half. +final class ShortcutSpecTests: XCTestCase { + + func testParsesABareKey() { + let spec = ShortcutSpec.parse("5") + XCTAssertEqual(spec?.keyCode, 23) + XCTAssertEqual(spec?.modifiers, []) + } + + func testParsesModifiersInAnyOrder() { + XCTAssertEqual(ShortcutSpec.parse("cmd+shift+5"), ShortcutSpec.parse("shift+cmd+5")) + } + + func testParsesAllFourModifiers() { + let spec = ShortcutSpec.parse("cmd+shift+ctrl+alt+5") + XCTAssertEqual(spec?.modifiers, [.command, .shift, .control, .option]) + } + + func testAcceptsAliasesForModifiers() { + XCTAssertEqual(ShortcutSpec.parse("command+option+5"), ShortcutSpec.parse("cmd+alt+5")) + XCTAssertEqual(ShortcutSpec.parse("control+5"), ShortcutSpec.parse("ctrl+5")) + } + + func testIsCaseInsensitive() { + XCTAssertEqual(ShortcutSpec.parse("CMD+SHIFT+5"), ShortcutSpec.parse("cmd+shift+5")) + } + + func testParsesNamedKeys() { + XCTAssertEqual(ShortcutSpec.parse("f13")?.keyCode, 105) + XCTAssertEqual(ShortcutSpec.parse("up")?.keyCode, 126) + XCTAssertEqual(ShortcutSpec.parse("space")?.keyCode, 49) + XCTAssertEqual(ShortcutSpec.parse("escape")?.keyCode, 53) + } + + func testRejectsAnUnknownKeyName() { + XCTAssertNil(ShortcutSpec.parse("cmd+banana")) + } + + func testRejectsMoreThanOneBaseKey() { + XCTAssertNil(ShortcutSpec.parse("5+6")) + } + + func testRejectsAModifierOnlySpec() { + XCTAssertNil(ShortcutSpec.parse("cmd+shift")) + } + + func testRejectsAnEmptySpec() { + XCTAssertNil(ShortcutSpec.parse("")) + } + + func testToleratesStraySpaces() { + XCTAssertEqual(ShortcutSpec.parse(" cmd + shift + 5 "), ShortcutSpec.parse("cmd+shift+5")) + } +} From 4f42cfdb0ca2992d3aa21b365610f228587cc1ce Mon Sep 17 00:00:00 2001 From: Peter Foti Date: Fri, 4 Sep 2026 11:08:02 -0400 Subject: [PATCH 4/4] fix: synthesise a shortcut's modifiers as their own keydown/keyup, not just a flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01RqHC39HxzGPUxiG1xtXip3 --- .../WLMicroManager/ShortcutController.swift | 64 +++++++++++++------ 1 file changed, 45 insertions(+), 19 deletions(-) diff --git a/Sources/WLMicroManager/ShortcutController.swift b/Sources/WLMicroManager/ShortcutController.swift index 37860f6..ab9069f 100644 --- a/Sources/WLMicroManager/ShortcutController.swift +++ b/Sources/WLMicroManager/ShortcutController.swift @@ -25,32 +25,58 @@ final class ShortcutController { let source = CGEventSource(stateID: .hidSystemState) guard - let down = CGEvent(keyboardEventSource: source, - virtualKey: CGKeyCode(spec.keyCode), keyDown: true), - let up = CGEvent(keyboardEventSource: source, - virtualKey: CGKeyCode(spec.keyCode), keyDown: false) + let baseDown = CGEvent(keyboardEventSource: source, + virtualKey: CGKeyCode(spec.keyCode), keyDown: true), + let baseUp = CGEvent(keyboardEventSource: source, + virtualKey: CGKeyCode(spec.keyCode), keyDown: false) else { onError?("Could not synthesise that shortcut.") return } - // The modifier goes on as a flag on the key event itself, exactly as - // the voice key's right-command tap does — cleared on the way up so - // nothing is left holding a modifier down. - down.flags = spec.modifiers.cgEventFlags - up.flags = [] - down.post(tap: .cghidEventTap) - up.post(tap: .cghidEventTap) + // 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 (an earlier version of this did only that) + // satisfies a listener that 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" as + // its own trigger, as OpenSuperWhisper's hotkey settings do. So each + // modifier gets a real keydown/keyup here too, building the flag set + // up one key at a time the way an actual chord's reports would. + var held: CGEventFlags = [] + let chords = ShortcutSpec.Modifiers.chord.filter { spec.modifiers.contains($0.modifier) } + + for (_, code, flag) in chords { + held.insert(flag) + let down = CGEvent(keyboardEventSource: source, virtualKey: code, keyDown: true) + down?.flags = held + down?.post(tap: .cghidEventTap) + } + + baseDown.flags = held + baseUp.flags = held // still held on release — the modifier ups come after + baseDown.post(tap: .cghidEventTap) + baseUp.post(tap: .cghidEventTap) + + for (_, code, flag) in chords.reversed() { + held.remove(flag) + let up = CGEvent(keyboardEventSource: source, virtualKey: code, keyDown: false) + up?.flags = held + up?.post(tap: .cghidEventTap) + } } } private extension ShortcutSpec.Modifiers { - var cgEventFlags: CGEventFlags { - var flags: CGEventFlags = [] - if contains(.command) { flags.insert(.maskCommand) } - if contains(.shift) { flags.insert(.maskShift) } - if contains(.option) { flags.insert(.maskAlternate) } - if contains(.control) { flags.insert(.maskControl) } - return flags - } + /// Left-hand virtual keycode and `CGEventFlags` bit for each modifier, + /// in a fixed press order. The order is arbitrary — a listener never + /// cares which modifier's HID report arrived first — but fixed, so a + /// chord presses and releases the same way every time. + static let chord: [(modifier: Self, keyCode: CGKeyCode, flag: CGEventFlags)] = [ + (.control, 0x3B, .maskControl), + (.option, 0x3A, .maskAlternate), + (.shift, 0x38, .maskShift), + (.command, 0x37, .maskCommand), + ] }