From 90babb601f27f5ae40bdaa7f2e12a4e5e1cf2fc2 Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Tue, 20 Oct 2026 07:40:15 -0700 Subject: [PATCH] Mapper: captured sequences render as macros; a lone media key is its system action MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found on the Air: Mute followed by ⌃⇧⌘4 was rendered by the generator as a bare list "(mute M-S-C-4)", which Kanata cannot parse, so the save was refused with the bridge's generic message. Multi-token outputs are now always a "(macro …)". A lone captured media key is now the system action it names (MapperViewModel.applyCapturedOutput), so the output tile shows the action's symbol from the first moment instead of an emoji that changed to the symbol after the save, and the save goes through the action path. Co-Authored-By: Claude Fable 5.1 --- .../Config/ConfigurationService.swift | 6 ++-- .../MapperViewModel+CaptureManagement.swift | 19 ++++++++++-- Tests/KeyPathTests/KeyPathTests.swift | 3 ++ .../UI/MediaKeyCaptureToSaveTests.swift | 31 +++++++++++++++++++ docs/architecture/rules-admission-review.md | 5 +++ 5 files changed, 60 insertions(+), 4 deletions(-) diff --git a/Sources/KeyPathAppKit/Infrastructure/Config/ConfigurationService.swift b/Sources/KeyPathAppKit/Infrastructure/Config/ConfigurationService.swift index 771285df0..89a0eadd9 100644 --- a/Sources/KeyPathAppKit/Infrastructure/Config/ConfigurationService.swift +++ b/Sources/KeyPathAppKit/Infrastructure/Config/ConfigurationService.swift @@ -1784,11 +1784,13 @@ public enum KanataKeyConverter { return "" } - // Multiple whitespace-separated tokens (e.g., "cmd space") → chord/sequence + // Multiple whitespace-separated tokens are a sequence to type in order + // (a chord arrives as one token with modifier prefixes, "M-S-C-4"). A + // bare list "(mute M-S-C-4)" is not Kanata; a macro is. if tokens.count > 1 { // Use convertToKanataKeyForMacro to preserve uppercase modifier prefixes let kanataKeys = tokens.map { convertToKanataKeyForMacro($0) } - return "(\(kanataKeys.joined(separator: " ")))" + return "(macro \(kanataKeys.joined(separator: " ")))" } // Single token - check if it's a text sequence to type (e.g., "123", "hello") diff --git a/Sources/KeyPathAppKit/UI/Mapper/MapperViewModel+CaptureManagement.swift b/Sources/KeyPathAppKit/UI/Mapper/MapperViewModel+CaptureManagement.swift index cf8604e69..4661ade0a 100644 --- a/Sources/KeyPathAppKit/UI/Mapper/MapperViewModel+CaptureManagement.swift +++ b/Sources/KeyPathAppKit/UI/Mapper/MapperViewModel+CaptureManagement.swift @@ -316,8 +316,7 @@ extension MapperViewModel { viewModel.shiftedOutputSequence = sequence viewModel.shiftedOutputLabel = sequence.displayString } else { - viewModel.outputSequence = sequence - viewModel.outputLabel = sequence.displayString + viewModel.applyCapturedOutput(sequence) } // Reset finalize timer - wait for more keys @@ -423,6 +422,22 @@ extension MapperViewModel { } } + /// A captured output. A lone media key (volume, brightness, play/pause…) + /// is the system action it names, so the tile shows that action's symbol + /// from the first moment and the save goes the system-action way; any + /// other capture is a key sequence. + func applyCapturedOutput(_ sequence: KeySequence) { + if sequence.keys.count == 1, let press = sequence.keys.first, press.keyCode >= 1000, + let action = SystemActionInfo.find(byOutput: KeyMappingFormatter.toKanataFormat(sequence)), + action.isSupportedInDriverlessSession + { + selectSystemAction(action) + return + } + outputSequence = sequence + outputLabel = sequence.displayString + } + func finalizeCapture() { finalizeTimer?.invalidate() finalizeTimer = nil diff --git a/Tests/KeyPathTests/KeyPathTests.swift b/Tests/KeyPathTests/KeyPathTests.swift index 63d06cd89..ab4af3958 100644 --- a/Tests/KeyPathTests/KeyPathTests.swift +++ b/Tests/KeyPathTests/KeyPathTests.swift @@ -72,6 +72,9 @@ final class KeyPathTests: KeyPathTestCase { // Test sequences XCTAssertEqual(manager.convertToKanataSequence("hello"), "(macro h e l l o)") + // A captured sequence (space-separated tokens) is a macro, never a bare list. + XCTAssertEqual(manager.convertToKanataSequence("mute M-S-C-4"), "(macro mute M-S-C-4)") + XCTAssertEqual(manager.convertToKanataSequence("escape a"), "(macro esc a)") XCTAssertEqual(manager.convertToKanataSequence("abc"), "(macro a b c)") } diff --git a/Tests/KeyPathTests/UI/MediaKeyCaptureToSaveTests.swift b/Tests/KeyPathTests/UI/MediaKeyCaptureToSaveTests.swift index 756c05148..67d61d308 100644 --- a/Tests/KeyPathTests/UI/MediaKeyCaptureToSaveTests.swift +++ b/Tests/KeyPathTests/UI/MediaKeyCaptureToSaveTests.swift @@ -69,4 +69,35 @@ final class MediaKeyCaptureToSaveTests: KeyPathTestCase { XCTAssertFalse(config.contains("(macro"), "a media key must never be typed as letters") } } + + /// A media key followed by a chord (the 2026-10-20 edge case: Mute then + /// ⌃⇧⌘4) is a sequence, rendered as a macro Kanata accepts. + func testMediaKeyThenChordSavesAsAMacro() async throws { + let mute = try XCTUnwrap(KeyboardCapture.mediaKeyPress(from: mediaEvent(nxKeyType: 7, down: true))) + let chord = KeyPress(baseKey: "4", modifiers: [.control, .shift, .command], keyCode: 21) + let vm = MapperViewModel() + vm.configure(kanataManager: coordinator) + vm.inputSequence = KeySequence(keys: [KeyPress(baseKey: "grave", modifiers: [], keyCode: 50)], captureMode: .single) + vm.outputSequence = KeySequence(keys: [mute, chord], captureMode: .sequence) + await vm.save(kanataManager: coordinator) + XCTAssertFalse(vm.statusIsError, vm.statusMessage ?? "") + let config = try String(contentsOfFile: service.configurationPath, encoding: .utf8) + XCTAssertTrue(config.contains("(macro mute M-S-C-4)"), config) + } + + /// A lone captured media key becomes the system action it names, so the + /// Mapper shows one symbol throughout and saves through the action path. + func testLoneMediaKeyCaptureBecomesTheSystemAction() throws { + let vm = MapperViewModel() + vm.configure(kanataManager: coordinator) + let mute = try XCTUnwrap(KeyboardCapture.mediaKeyPress(from: mediaEvent(nxKeyType: 7, down: true))) + vm.applyCapturedOutput(KeySequence(keys: [mute], captureMode: .single)) + XCTAssertEqual(vm.selectedSystemAction?.id, "mute") + XCTAssertNil(vm.outputSequence) + XCTAssertEqual(vm.outputLabel, "Mute") + // A sequence stays a sequence. + let chord = KeyPress(baseKey: "4", modifiers: [.control, .shift, .command], keyCode: 21) + vm.applyCapturedOutput(KeySequence(keys: [mute, chord], captureMode: .sequence)) + XCTAssertNotNil(vm.outputSequence) + } } diff --git a/docs/architecture/rules-admission-review.md b/docs/architecture/rules-admission-review.md index 5659570f1..d1ca13c6f 100644 --- a/docs/architecture/rules-admission-review.md +++ b/docs/architecture/rules-admission-review.md @@ -539,6 +539,11 @@ So the session emits media keys as Kanata's ordinary outputs: of macOS Function Keys and the eight media system actions became available with no Swift change; the former hard-coded refusals survive only as the no-bridge fallback (`fallbackConsumerKeys`), and the tests now pin both answers. +- **Found on hardware the same day:** a captured sequence (Mute then ⌃⇧⌘4) was rendered + by the generator as a bare list `(mute M-S-C-4)`, which Kanata cannot parse; multi-token + outputs are now always a `(macro …)`. And a lone captured media key is treated as the + system action it names (`MapperViewModel.applyCapturedOutput`), so the Mapper shows + one symbol throughout and saves through the action path. - **Known limits** (from the spike): a posted brightness event drives the built-in panel only (Studio Display unchanged); per-device media rules are impossible (system-defined events carry no source device); keyboard backlight (21–23) is in