From 33468a7292023cad68dd8a957398cb1baaa365e1 Mon Sep 17 00:00:00 2001 From: Chi Zhang Date: Sat, 15 Aug 2026 19:44:20 +0800 Subject: [PATCH] feat: bind key chords from the config file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A text macro composes a prompt but deliberately leaves it unsubmitted, so finishing a dictated line still meant reaching for the keyboard. A `"chords"` entry presses keys in the focused agent's pane instead of typing into it, which is the other half of that gesture: `{"chords": {"12": "enter"}}` makes the bottom-right key submit. The names are Herdr's own, handed to `pane.send_keys` untouched, so a chord is whatever that vocabulary allows — "enter", "esc", "ctrl+alt+u", or an array pressed in order. A chord outranks a text macro on the same key: the text defaults are inherited rather than chosen, so an explicit chord should not have to unbind one first. Co-Authored-By: Claude Opus 5 --- README.md | 8 ++++ Sources/WLKit/BridgeController.swift | 33 +++++++++++++--- Sources/WLKit/KeyBindings.swift | 48 ++++++++++++++++++++++- Tests/WLKitTests/KeyBindingsTests.swift | 51 +++++++++++++++++++++++++ 4 files changed, 134 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index fc8db2c..6a53fd4 100644 --- a/README.md +++ b/README.md @@ -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"] } } @@ -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. diff --git a/Sources/WLKit/BridgeController.swift b/Sources/WLKit/BridgeController.swift index bf18e02..7fa4205 100644 --- a/Sources/WLKit/BridgeController.swift +++ b/Sources/WLKit/BridgeController.swift @@ -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) { @@ -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?() @@ -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 } diff --git a/Sources/WLKit/KeyBindings.swift b/Sources/WLKit/KeyBindings.swift index fa49425..083fc91 100644 --- a/Sources/WLKit/KeyBindings.swift +++ b/Sources/WLKit/KeyBindings.swift @@ -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): @@ -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"] } /// } @@ -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] @@ -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 @@ -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 } @@ -93,6 +112,32 @@ 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 } @@ -100,6 +145,7 @@ public struct KeyBindings: Sendable, Equatable { 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 ?? [] diff --git a/Tests/WLKitTests/KeyBindingsTests.swift b/Tests/WLKitTests/KeyBindingsTests.swift index 68954fc..50881d6 100644 --- a/Tests/WLKitTests/KeyBindingsTests.swift +++ b/Tests/WLKitTests/KeyBindingsTests.swift @@ -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") + } }