Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 35 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
|---|---|
Expand All @@ -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

Expand Down Expand Up @@ -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
Expand Down
134 changes: 108 additions & 26 deletions Sources/WLKit/BridgeController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -193,12 +196,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
Expand Down Expand Up @@ -332,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.
Expand Down Expand Up @@ -413,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) {
Expand All @@ -436,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 {
Expand Down Expand Up @@ -474,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 {
Expand Down
33 changes: 33 additions & 0 deletions Sources/WLKit/DeviceOpenFailure.swift
Original file line number Diff line number Diff line change
@@ -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))"
}
}
}
Loading