diff --git a/ProwlCLI/Commands/KeyCommand.swift b/ProwlCLI/Commands/KeyCommand.swift index 16aad29b..235334eb 100644 --- a/ProwlCLI/Commands/KeyCommand.swift +++ b/ProwlCLI/Commands/KeyCommand.swift @@ -7,11 +7,14 @@ import ProwlCLIShared struct KeyCommand: ParsableCommand { static let configuration = CommandConfiguration( commandName: "key", - abstract: "Send a user key event to a terminal pane.", + abstract: "Send a keyboard shortcut or special key to a terminal pane.", discussion: """ With one positional argument, the key is sent to the current pane. With two positional arguments, the first is the target (auto-resolved) and the second is the key token. + + Printable punctuation currently follows ANSI-style key token semantics. + For shifted symbols, prefer modifier combos such as shift-1 or shift-left-bracket. """ ) diff --git a/ProwlCLITests/ProwlCLIIntegrationTests.swift b/ProwlCLITests/ProwlCLIIntegrationTests.swift index ccf962ae..0c96a008 100644 --- a/ProwlCLITests/ProwlCLIIntegrationTests.swift +++ b/ProwlCLITests/ProwlCLIIntegrationTests.swift @@ -975,6 +975,7 @@ final class ProwlCLIIntegrationTests: XCTestCase { ("ctrl-z", "ctrl-z"), ("deleteforward", "delete-forward"), ("f12", "f12"), + ("[", "left-bracket"), ] for (raw, normalized) in tokenCases { @@ -1002,6 +1003,16 @@ final class ProwlCLIIntegrationTests: XCTestCase { } } + func testKeyCommandRejectsUnsupportedShiftedSymbolLiteral() throws { + let result = try runProwl(args: ["key", "!", "--json"]) + XCTAssertNotEqual(result.exitCode, 0) + let payload = try jsonObject(from: result.stdout) + XCTAssertEqual(payload["ok"] as? Bool, false) + XCTAssertEqual(payload["command"] as? String, "key") + let error = try XCTUnwrap(payload["error"] as? [String: Any]) + XCTAssertEqual(error["code"] as? String, CLIErrorCode.unsupportedKey) + } + // MARK: - Read command tests func testReadCommandRoundTripsOverSocket() throws { diff --git a/doc-onevcat/contracts/cli/key.md b/doc-onevcat/contracts/cli/key.md index 88f343be..1dacc820 100644 --- a/doc-onevcat/contracts/cli/key.md +++ b/doc-onevcat/contracts/cli/key.md @@ -13,6 +13,7 @@ Compared with the initial draft, this version makes `key` broader and more scrip - normalize aliases (`return` -> `enter`, `escape` -> `esc`, `pgup` -> `pageup`, ...) - accept modifier prefixes and combinations such as `cmd-c`, `shift-tab`, `opt-enter`, `cmd-shift-k` - accept additional named keys such as `delete-forward`, `insert`, and `f1`...`f12` +- keep CLI token acceptance aligned with the runtime's ANSI-style NSEvent materialization - add `--repeat ` as first-class input instead of forcing shell loops - split `requested` vs `normalized` in output so callers can debug token normalization - add `delivery` counters for machine verification (`attempted` / `delivered`) @@ -21,6 +22,8 @@ Compared with the initial draft, this version makes `key` broader and more scrip ## Contract goals - `key` should be deterministic for agent loops and TUI automation. +- v1 follows ANSI-style key token semantics for shortcuts, special keys, and supported punctuation. +- full layout-aware fidelity across keyboard layouts is out of scope for v1. - output must clearly answer: - what was requested - what token was actually accepted @@ -66,7 +69,7 @@ Canonical normalized tokens now include: - navigation keys such as `tab`, `up`, `down`, `left`, `right`, `pageup`, `pagedown`, `home`, `end` - editing keys such as `enter`, `backspace`, `delete-forward`, `insert` -- printable keys such as letters, digits, and common punctuation +- printable keys such as letters, digits, and supported ANSI punctuation tokens - control combinations such as `ctrl-c`, `ctrl-d`, `ctrl-l`, `ctrl-z` - shortcut combinations such as `cmd-c`, `cmd-shift-k`, `shift-tab`, `opt-enter` - function keys `f1`...`f12` @@ -83,10 +86,17 @@ Canonical normalized tokens now include: - `pgdn` -> `pagedown` - `forward-delete` / `deleteforward` -> `delete-forward` - `ins` -> `insert` +- punctuation aliases such as `[` -> `left-bracket`, `]` -> `right-bracket`, `,` -> `comma`, `'` -> `quote` - `command-*` -> `cmd-*` - `alt-*` / `option-*` -> `opt-*` - `ctrl+*` -> `ctrl-*` +### ANSI punctuation note + +- prefer canonical ANSI-style punctuation tokens such as `minus`, `equal`, `comma`, `period`, `slash`, `backslash`, `quote`, `left-bracket` +- express shifted symbols via modifier combos such as `shift-1`, `shift-quote`, `shift-left-bracket` +- raw shifted symbol literals such as `!`, `@`, `{`, `}` are intentionally unsupported in v1 + ### `--repeat` - optional, integer, default `1` diff --git a/supacode/CLIService/Shared/KeyCommandPayload.swift b/supacode/CLIService/Shared/KeyCommandPayload.swift index c2245bb8..9d0fe8a0 100644 --- a/supacode/CLIService/Shared/KeyCommandPayload.swift +++ b/supacode/CLIService/Shared/KeyCommandPayload.swift @@ -219,12 +219,60 @@ public enum KeyTokens { "apostrophe": KeyBaseDescriptor(canonical: "quote", category: .editing), "grave": KeyBaseDescriptor(canonical: "grave", category: .editing), "backtick": KeyBaseDescriptor(canonical: "grave", category: .editing), - "left-bracket": KeyBaseDescriptor(canonical: "[", category: .editing), - "leftbracket": KeyBaseDescriptor(canonical: "[", category: .editing), - "lbracket": KeyBaseDescriptor(canonical: "[", category: .editing), - "right-bracket": KeyBaseDescriptor(canonical: "]", category: .editing), - "rightbracket": KeyBaseDescriptor(canonical: "]", category: .editing), - "rbracket": KeyBaseDescriptor(canonical: "]", category: .editing), + "left-bracket": KeyBaseDescriptor(canonical: "left-bracket", category: .editing), + "leftbracket": KeyBaseDescriptor(canonical: "left-bracket", category: .editing), + "lbracket": KeyBaseDescriptor(canonical: "left-bracket", category: .editing), + "right-bracket": KeyBaseDescriptor(canonical: "right-bracket", category: .editing), + "rightbracket": KeyBaseDescriptor(canonical: "right-bracket", category: .editing), + "rbracket": KeyBaseDescriptor(canonical: "right-bracket", category: .editing), + ] + + private static let singleCharacterBaseDescriptors: [String: KeyBaseDescriptor] = [ + "a": KeyBaseDescriptor(canonical: "a", category: .editing), + "b": KeyBaseDescriptor(canonical: "b", category: .editing), + "c": KeyBaseDescriptor(canonical: "c", category: .editing), + "d": KeyBaseDescriptor(canonical: "d", category: .editing), + "e": KeyBaseDescriptor(canonical: "e", category: .editing), + "f": KeyBaseDescriptor(canonical: "f", category: .editing), + "g": KeyBaseDescriptor(canonical: "g", category: .editing), + "h": KeyBaseDescriptor(canonical: "h", category: .editing), + "i": KeyBaseDescriptor(canonical: "i", category: .editing), + "j": KeyBaseDescriptor(canonical: "j", category: .editing), + "k": KeyBaseDescriptor(canonical: "k", category: .editing), + "l": KeyBaseDescriptor(canonical: "l", category: .editing), + "m": KeyBaseDescriptor(canonical: "m", category: .editing), + "n": KeyBaseDescriptor(canonical: "n", category: .editing), + "o": KeyBaseDescriptor(canonical: "o", category: .editing), + "p": KeyBaseDescriptor(canonical: "p", category: .editing), + "q": KeyBaseDescriptor(canonical: "q", category: .editing), + "r": KeyBaseDescriptor(canonical: "r", category: .editing), + "s": KeyBaseDescriptor(canonical: "s", category: .editing), + "t": KeyBaseDescriptor(canonical: "t", category: .editing), + "u": KeyBaseDescriptor(canonical: "u", category: .editing), + "v": KeyBaseDescriptor(canonical: "v", category: .editing), + "w": KeyBaseDescriptor(canonical: "w", category: .editing), + "x": KeyBaseDescriptor(canonical: "x", category: .editing), + "y": KeyBaseDescriptor(canonical: "y", category: .editing), + "z": KeyBaseDescriptor(canonical: "z", category: .editing), + "0": KeyBaseDescriptor(canonical: "0", category: .editing), + "1": KeyBaseDescriptor(canonical: "1", category: .editing), + "2": KeyBaseDescriptor(canonical: "2", category: .editing), + "3": KeyBaseDescriptor(canonical: "3", category: .editing), + "4": KeyBaseDescriptor(canonical: "4", category: .editing), + "5": KeyBaseDescriptor(canonical: "5", category: .editing), + "6": KeyBaseDescriptor(canonical: "6", category: .editing), + "7": KeyBaseDescriptor(canonical: "7", category: .editing), + "8": KeyBaseDescriptor(canonical: "8", category: .editing), + "9": KeyBaseDescriptor(canonical: "9", category: .editing), + ",": KeyBaseDescriptor(canonical: "comma", category: .editing), + ".": KeyBaseDescriptor(canonical: "period", category: .editing), + "/": KeyBaseDescriptor(canonical: "slash", category: .editing), + "\\": KeyBaseDescriptor(canonical: "backslash", category: .editing), + ";": KeyBaseDescriptor(canonical: "semicolon", category: .editing), + "'": KeyBaseDescriptor(canonical: "quote", category: .editing), + "`": KeyBaseDescriptor(canonical: "grave", category: .editing), + "[": KeyBaseDescriptor(canonical: "left-bracket", category: .editing), + "]": KeyBaseDescriptor(canonical: "right-bracket", category: .editing), ] public static func normalize(_ raw: String) -> String? { @@ -290,10 +338,7 @@ public enum KeyTokens { private static func baseDescriptor(for raw: String) -> KeyBaseDescriptor? { if let base = namedBaseDescriptors[raw] { return base } - - if raw.count == 1, let scalar = raw.unicodeScalars.first, scalar.isASCII, !scalar.properties.isWhitespace { - return KeyBaseDescriptor(canonical: raw, category: .editing) - } + if let base = singleCharacterBaseDescriptors[raw] { return base } if raw.count >= 2, raw.first == "f", diff --git a/supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift b/supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift index 265f3338..bfb5aaf1 100644 --- a/supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift +++ b/supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift @@ -2042,22 +2042,15 @@ struct CLIKeySpec { "space": UInt16(kVK_Space), "minus": UInt16(kVK_ANSI_Minus), "equal": UInt16(kVK_ANSI_Equal), - ",": UInt16(kVK_ANSI_Comma), "comma": UInt16(kVK_ANSI_Comma), - ".": UInt16(kVK_ANSI_Period), "period": UInt16(kVK_ANSI_Period), - "/": UInt16(kVK_ANSI_Slash), "slash": UInt16(kVK_ANSI_Slash), - "\\": UInt16(kVK_ANSI_Backslash), "backslash": UInt16(kVK_ANSI_Backslash), - ";": UInt16(kVK_ANSI_Semicolon), "semicolon": UInt16(kVK_ANSI_Semicolon), - "'": UInt16(kVK_ANSI_Quote), "quote": UInt16(kVK_ANSI_Quote), - "`": UInt16(kVK_ANSI_Grave), "grave": UInt16(kVK_ANSI_Grave), - "[": UInt16(kVK_ANSI_LeftBracket), - "]": UInt16(kVK_ANSI_RightBracket), + "left-bracket": UInt16(kVK_ANSI_LeftBracket), + "right-bracket": UInt16(kVK_ANSI_RightBracket), ] private static let shiftedCharacterMap: [String: String] = [ @@ -2073,21 +2066,15 @@ struct CLIKeySpec { "0": ")", "minus": "_", "equal": "+", - ",": "<", "comma": "<", - ".": ">", "period": ">", - "/": "?", "slash": "?", - "\\": "|", "backslash": "|", - ";": ":", "semicolon": ":", - "'": "\"", "quote": "\"", - "`": "~", - "[": "{", - "]": "}", + "grave": "~", + "left-bracket": "{", + "right-bracket": "}", ] private static func eventModifiers(from modifiers: [KeyModifier]) -> NSEvent.ModifierFlags { @@ -2137,6 +2124,8 @@ struct CLIKeySpec { case "semicolon": return ";" case "quote": return "'" case "grave": return "`" + case "left-bracket": return "[" + case "right-bracket": return "]" default: guard token.count == 1 else { return nil } return token.first @@ -2161,9 +2150,9 @@ struct CLIKeySpec { } switch token { - case "[": return String(UnicodeScalar(27)!) - case "\\", "backslash": return String(UnicodeScalar(28)!) - case "]": return String(UnicodeScalar(29)!) + case "left-bracket": return String(UnicodeScalar(27)!) + case "backslash": return String(UnicodeScalar(28)!) + case "right-bracket": return String(UnicodeScalar(29)!) case "6": return String(UnicodeScalar(30)!) case "minus": return String(UnicodeScalar(31)!) default: return nil diff --git a/supacodeTests/CLIKeyTokenExpansionTests.swift b/supacodeTests/CLIKeyTokenExpansionTests.swift index 89d4f0d1..46f12a63 100644 --- a/supacodeTests/CLIKeyTokenExpansionTests.swift +++ b/supacodeTests/CLIKeyTokenExpansionTests.swift @@ -12,6 +12,13 @@ struct CLIKeyTokenExpansionTests { #expect(KeyTokens.normalize("ctrl-z") == "ctrl-z") } + @Test func normalizesPrintableAliasesToCanonicalAnsiTokens() { + #expect(KeyTokens.normalize("[") == "left-bracket") + #expect(KeyTokens.normalize("]") == "right-bracket") + #expect(KeyTokens.normalize(",") == "comma") + #expect(KeyTokens.normalize("'") == "quote") + } + @Test func normalizesAdditionalNamedKeys() { #expect(KeyTokens.normalize("deleteforward") == "delete-forward") #expect(KeyTokens.normalize("forward-delete") == "delete-forward") @@ -19,6 +26,13 @@ struct CLIKeyTokenExpansionTests { #expect(KeyTokens.normalize("f12") == "f12") } + @Test func rejectsUnsupportedShiftedSymbolLiterals() { + #expect(KeyTokens.normalize("!") == nil) + #expect(KeyTokens.normalize("@") == nil) + #expect(CLIKeySpec.from(token: "!") == nil) + #expect(CLIKeySpec.from(token: "@") == nil) + } + @Test func expandedCategoriesAreReported() { #expect(KeyTokens.category(for: "cmd-c") == .shortcut) #expect(KeyTokens.category(for: "ctrl-z") == .control) @@ -43,6 +57,15 @@ struct CLIKeyTokenExpansionTests { #expect(spec.charactersIgnoringModifiers == "k") } + @Test func cliKeySpecBuildsShiftedAnsiPunctuationEvent() throws { + let spec = try #require(CLIKeySpec.from(token: "shift-left-bracket")) + + #expect(spec.keyCode == UInt16(kVK_ANSI_LeftBracket)) + #expect(spec.modifiers == [.shift]) + #expect(spec.characters == "{") + #expect(spec.charactersIgnoringModifiers == "[") + } + @Test func cliKeySpecBuildsFunctionAndForwardDeleteEvents() throws { let f12 = try #require(CLIKeySpec.from(token: "f12")) #expect(f12.keyCode == UInt16(kVK_F12))