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