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
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@ the dial and joystick offer, drop a `config.json` into
"12": "Run but pull",
"10+11": "Summarise what you are working on"
},
"chords": { "12": "enter" },
"claude": { "models": ["fable", "opus"], "efforts": ["low", "high"] },
"codex": { "models": ["gpt-5.6-sol", "gpt-5.6-codex"] }
}
Expand All @@ -161,6 +162,13 @@ 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.

`"chords"` presses keys in the focused agent's pane instead of typing into it,
using Herdr's crossterm-style names — `"enter"`, `"esc"`, `"ctrl+alt+u"` — and an
array is a sequence pressed in order. A chord outranks a text macro on the same
key, which is how `{"chords": {"12": "enter"}}` turns the bottom-right key into
submit without unbinding its default. The macro and wide keys take chords; the
rest keep their own jobs.

`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
to read it from.
Expand Down
33 changes: 28 additions & 5 deletions Sources/WLKit/BridgeController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -333,9 +333,10 @@ public final class BridgeController: ObservableObject {

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.
// light, in the same precedence `handleKeyPress` uses: a configured
// chord or 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 {
if keyBindings.chord(for: key) != nil || keyBindings.text(for: key) != nil {
return StatusMapper.macroThread(id: key, config)
}
if Pad.voiceKeyIDs.contains(key) {
Expand Down Expand Up @@ -420,9 +421,13 @@ public final class BridgeController: ObservableObject {
} 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) {
// A configured binding wins over the key's built-in role, which is
// how the config file may repurpose the wide voice key. A chord
// outranks a text macro: the text defaults are inherited rather
// than chosen, so an explicit chord should not have to fight them.
if let keys = keyBindings.chord(for: index) {
Task { await sendChord(keys) }
} else if let text = keyBindings.text(for: index) {
Task { await injectPrompt(text) }
} else if Pad.voiceKeyIDs.contains(index) {
onVoiceKey?()
Expand Down Expand Up @@ -452,6 +457,24 @@ public final class BridgeController: ObservableObject {
}
}

/// Presses a key chord in the focused agent's pane — the other half of
/// dictation, where `injectPrompt` deliberately leaves the text unsubmitted.
/// The names are Herdr's to interpret, so a typo surfaces as an error from
/// there rather than being caught when the config is read.
public func sendChord(_ keys: [String]) async {
do {
guard let agent = try await HerdrClient.focusedAgent(),
let pane = agent.paneID
else {
lastError = "Nothing has focus in Herdr right now."
return
}
try await HerdrClient.sendKeys(paneID: pane, keys: keys)
} catch {
lastError = error.localizedDescription
}
}

/// Repaints the voice key. Same contract as `setStackPanelOpen`.
public func setVoiceActive(_ active: Bool) async {
guard voiceActive != active else { return }
Expand Down
48 changes: 47 additions & 1 deletion Sources/WLKit/KeyBindings.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import Foundation

/// User-configurable text macros for the spare keys.
/// User-configurable text macros and key chords for the spare keys.
///
/// Read from `$XDG_CONFIG_HOME/micromanager/config.json`
/// (`~/.config/micromanager/config.json` by default):
Expand All @@ -11,6 +11,7 @@ import Foundation
/// "12": "Run but pull",
/// "10+11": "Summarise what you are working on"
/// },
/// "chords": { "12": "enter", "9": ["ctrl+a", "enter"] },
/// "claude": { "models": ["fable", "opus"], "efforts": ["low", "high"] },
/// "codex": { "models": ["gpt-5.6-sol", "gpt-5.6-codex"] }
/// }
Expand All @@ -20,9 +21,19 @@ import Foundation
/// 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.
///
/// `"chords"` presses keys in the focused agent's pane instead of typing into
/// it, which is what submits a prompt rather than composing one. A **chord
/// outranks a text macro on the same key**: the text defaults below are
/// inherited rather than chosen, so an explicitly configured chord should not
/// have to fight them.
public struct KeyBindings: Sendable, Equatable {

public private(set) var texts: [Int: String]
/// Key chords pressed in the focused agent's pane, crossterm-style
/// ("enter", "ctrl+m", "f13"). Herdr owns the name vocabulary, so these
/// pass through untouched — a bad name fails there, not here.
public private(set) var chords: [Int: [String]]
/// 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]
Expand All @@ -44,11 +55,13 @@ public struct KeyBindings: Sendable, Equatable {

public init(
texts: [Int: String] = KeyBindings.defaults,
chords: [Int: [String]] = [:],
claudeModels: [String] = KeyBindings.defaultClaudeModels,
claudeEfforts: [String] = KeyBindings.defaultClaudeEfforts,
codexModels: [String] = []
) {
self.texts = texts
self.chords = chords
self.claudeModels = claudeModels
self.claudeEfforts = claudeEfforts
self.codexModels = codexModels
Expand All @@ -60,6 +73,12 @@ public struct KeyBindings: Sendable, Equatable {
return text
}

/// The chord bound to a key, or nil when nothing is. Outranks `text(for:)`.
public func chord(for key: Int) -> [String]? {
guard let keys = chords[key], !keys.isEmpty else { return nil }
return keys
}

public static func configPath() -> String {
let env = ProcessInfo.processInfo.environment
let base = env["XDG_CONFIG_HOME"].flatMap { $0.isEmpty ? nil : $0 }
Expand Down Expand Up @@ -93,13 +112,40 @@ public struct KeyBindings: Sendable, Equatable {
}
}

// Chords have no defaults to inherit, so this starts empty. The unbind
// branch is kept anyway, so `""` behaves the same here as in `keys`.
var chords: [Int: [String]] = [:]
if let configured = json["chords"] as? [String: Any] {
for (name, value) in configured {
// A bare string is the common case; an array is a sequence
// pressed in order.
let names: [String]
if let single = value as? String {
names = [single]
} else if let sequence = value as? [String] {
names = sequence
} else {
continue
}
let cleaned = names.filter { !$0.isEmpty }
for key in keyIDs(for: name) {
if cleaned.isEmpty {
chords.removeValue(forKey: key)
} else {
chords[key] = cleaned
}
}
}
}

let claude = json["claude"] as? [String: Any]
let models = (claude?["models"] as? [String])?.filter { !$0.isEmpty }
let efforts = (claude?["efforts"] as? [String])?.filter { !$0.isEmpty }
let codex = json["codex"] as? [String: Any]
let codexModels = (codex?["models"] as? [String])?.filter { !$0.isEmpty }
return KeyBindings(
texts: texts,
chords: chords,
claudeModels: models?.isEmpty == false ? models! : defaultClaudeModels,
claudeEfforts: efforts?.isEmpty == false ? efforts! : defaultClaudeEfforts,
codexModels: codexModels ?? []
Expand Down
51 changes: 51 additions & 0 deletions Tests/WLKitTests/KeyBindingsTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -86,4 +86,55 @@ final class KeyBindingsTests: XCTestCase {
XCTAssertEqual(bindings.claudeModels, KeyBindings.defaultClaudeModels,
"a codex-only config leaves the claude side alone")
}

// MARK: - Chords

/// Unlike the text macros there is nothing sensible to default to: a chord
/// presses keys in a live pane, so guessing one would act on its own.
func testChordsAreEmptyUntilConfigured() {
XCTAssertNil(KeyBindings().chord(for: 12))
XCTAssertNil(parse(#"{"keys": {"9": "Ship it"}}"#).chord(for: 9))
}

func testASingleStringIsAOneKeyChord() {
XCTAssertEqual(parse(#"{"chords": {"12": "enter"}}"#).chord(for: 12), ["enter"])
}

/// An array is a sequence pressed in order, which is how a chord can do
/// something a single key cannot — clear the line, then submit it.
func testAnArrayIsASequence() {
let bindings = parse(#"{"chords": {"9": ["ctrl+a", "enter"]}}"#)
XCTAssertEqual(bindings.chord(for: 9), ["ctrl+a", "enter"])
}

/// The text defaults are inherited rather than chosen, so an explicitly
/// configured chord should not have to unbind them first.
func testChordOutranksATextMacroOnTheSameKey() {
let bindings = parse(#"{"chords": {"12": "enter"}}"#)
XCTAssertEqual(bindings.chord(for: 12), ["enter"])
XCTAssertEqual(bindings.text(for: 12), "Run but pull",
"the text is still there; precedence is the caller's job")
}

/// "10+11" means the wide key as one, exactly as it does for text.
func testWideKeyChordBindsBothHalves() {
let bindings = parse(#"{"chords": {"10+11": "esc"}}"#)
XCTAssertEqual(bindings.chord(for: 10), ["esc"])
XCTAssertEqual(bindings.chord(for: 11), ["esc"])
}

func testEmptyChordUnbinds() {
XCTAssertNil(parse(#"{"chords": {"12": ""}}"#).chord(for: 12))
XCTAssertNil(parse(#"{"chords": {"12": []}}"#).chord(for: 12))
XCTAssertNil(parse(#"{"chords": {"12": ["", ""]}}"#).chord(for: 12))
}

/// A wrong shape is skipped rather than crashing or half-applying — the
/// same contract the rest of the file has.
func testMalformedChordsAreIgnored() {
XCTAssertNil(parse(#"{"chords": {"12": 5}}"#).chord(for: 12))
XCTAssertEqual(parse(#"{"chords": "nope"}"#), KeyBindings())
XCTAssertEqual(parse(#"{"chords": {"12": "enter", "nonsense": "esc"}}"#).chord(for: 12),
["enter"], "an unparseable key name drops that entry alone")
}
}