native macOS codings agent orchestrator
6
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge pull request #164 from onevcat/onevcat/issue-143-expand-cli-key-review-fixes

Expand CLI key token support with ANSI control fixes

authored by

Wei Wang and committed by
GitHub
36b4c71e b473a3c1

+772 -133
+5 -2
ProwlCLI/Commands/KeyCommand.swift
··· 7 7 struct KeyCommand: ParsableCommand { 8 8 static let configuration = CommandConfiguration( 9 9 commandName: "key", 10 - abstract: "Send a key event to a terminal pane.", 10 + abstract: "Send a keyboard shortcut or special key to a terminal pane.", 11 11 discussion: """ 12 12 With one positional argument, the key is sent to the current pane. 13 13 With two positional arguments, the first is the target (auto-resolved) and 14 14 the second is the key token. 15 + 16 + Printable punctuation currently follows ANSI-style key token semantics. 17 + For shifted symbols, prefer modifier combos such as shift-1 or shift-left-bracket. 15 18 """ 16 19 ) 17 20 ··· 61 64 guard let normalized = KeyTokens.normalize(rawToken) else { 62 65 throw ExitError( 63 66 code: CLIErrorCode.unsupportedKey, 64 - message: "The key token '\(rawToken.lowercased())' is not supported in v1." 67 + message: "The key token '\(rawToken.lowercased())' is not supported." 65 68 ) 66 69 } 67 70
+55 -1
ProwlCLITests/ProwlCLIIntegrationTests.swift
··· 808 808 } 809 809 810 810 func testKeyCommandRejectUnsupportedKey() throws { 811 - let result = try runProwl(args: ["key", "ctrl-z", "--json"]) 811 + let result = try runProwl(args: ["key", "hyper-k", "--json"]) 812 812 XCTAssertNotEqual(result.exitCode, 0) 813 813 let payload = try jsonObject(from: result.stdout) 814 814 XCTAssertEqual(payload["ok"] as? Bool, false) ··· 965 965 XCTFail("Expected key command envelope for alias '\(alias)'") 966 966 } 967 967 } 968 + } 969 + 970 + func testKeyCommandExpandedTokensAccepted() throws { 971 + let tokenCases: [(raw: String, normalized: String)] = [ 972 + ("cmd-c", "cmd-c"), 973 + ("command-shift-k", "cmd-shift-k"), 974 + ("alt-enter", "opt-enter"), 975 + ("ctrl-z", "ctrl-z"), 976 + ("A", "shift-a"), 977 + ("Ctrl-A", "shift-ctrl-a"), 978 + ("CTRL-A", "shift-ctrl-a"), 979 + ("ctrl-left-bracket", "ctrl-left-bracket"), 980 + ("ctrl-backslash", "ctrl-backslash"), 981 + ("ctrl-right-bracket", "ctrl-right-bracket"), 982 + ("ctrl-shift-6", "shift-ctrl-6"), 983 + ("ctrl-shift-minus", "shift-ctrl-minus"), 984 + ("deleteforward", "delete-forward"), 985 + ("f12", "f12"), 986 + ("[", "left-bracket"), 987 + ] 988 + 989 + for (raw, normalized) in tokenCases { 990 + let socketPath = temporarySocketPath(suffix: "key-expanded-\(normalized.replacingOccurrences(of: "-", with: "_"))") 991 + let response = CommandResponse( 992 + ok: true, 993 + command: "key", 994 + schemaVersion: "prowl.cli.key.v1" 995 + ) 996 + 997 + let (requestData, result) = try runWithMockServer( 998 + socketPath: socketPath, 999 + response: response, 1000 + args: ["key", raw, "--json"] 1001 + ) 1002 + 1003 + XCTAssertEqual(result.exitCode, 0, "Token '\(raw)' should be accepted") 1004 + let envelope = try JSONDecoder().decode(CommandEnvelope.self, from: requestData) 1005 + if case .key(let input) = envelope.command { 1006 + XCTAssertEqual(input.token, normalized) 1007 + XCTAssertEqual(input.rawToken, raw) 1008 + } else { 1009 + XCTFail("Expected key command envelope for token '\(raw)'") 1010 + } 1011 + } 1012 + } 1013 + 1014 + func testKeyCommandRejectsUnsupportedShiftedSymbolLiteral() throws { 1015 + let result = try runProwl(args: ["key", "!", "--json"]) 1016 + XCTAssertNotEqual(result.exitCode, 0) 1017 + let payload = try jsonObject(from: result.stdout) 1018 + XCTAssertEqual(payload["ok"] as? Bool, false) 1019 + XCTAssertEqual(payload["command"] as? String, "key") 1020 + let error = try XCTUnwrap(payload["error"] as? [String: Any]) 1021 + XCTAssertEqual(error["code"] as? String, CLIErrorCode.unsupportedKey) 968 1022 } 969 1023 970 1024 // MARK: - Read command tests
+33 -24
doc-onevcat/contracts/cli/key.md
··· 8 8 9 9 ## What changed in this revision 10 10 11 - Compared with the initial draft, this version makes `key` safer and more script-friendly: 11 + Compared with the initial draft, this version makes `key` broader and more script-friendly: 12 12 13 - - define a strict input grammar with aliases (`return` -> `enter`, `escape` -> `esc`, `pgup` -> `pageup`, ...) 13 + - normalize aliases (`return` -> `enter`, `escape` -> `esc`, `pgup` -> `pageup`, ...) 14 + - accept modifier prefixes and combinations such as `cmd-c`, `shift-tab`, `opt-enter`, `cmd-shift-k` 15 + - accept additional named keys such as `delete-forward`, `insert`, and `f1`...`f12` 16 + - keep CLI token acceptance aligned with the runtime's ANSI-style NSEvent materialization 14 17 - add `--repeat <n>` as first-class input instead of forcing shell loops 15 18 - split `requested` vs `normalized` in output so callers can debug token normalization 16 19 - add `delivery` counters for machine verification (`attempted` / `delivered`) ··· 19 22 ## Contract goals 20 23 21 24 - `key` should be deterministic for agent loops and TUI automation. 25 + - v1 follows ANSI-style key token semantics for shortcuts, special keys, and supported punctuation. 26 + - full layout-aware fidelity across keyboard layouts is out of scope for v1. 22 27 - output must clearly answer: 23 28 - what was requested 24 29 - what token was actually accepted ··· 54 59 Rules: 55 60 56 61 - exactly one positional `<token>` is required 57 - - parsing is case-insensitive 58 62 - surrounding spaces are ignored 59 63 - canonical token form is lowercase kebab-case 64 + - modifier names and named-key aliases are parsed case-insensitively 65 + - single printable letters preserve case semantics: lowercase stays plain (`a`), uppercase implies `shift` (`A` -> `shift-a`) 66 + - mixed-case modifier combos keep that printable-letter rule, for example `Cmd-A` -> `cmd-shift-a` and `Ctrl-A` -> `shift-ctrl-a` 60 67 61 68 ### Canonical tokens 62 69 63 - - `enter` 64 - - `esc` 65 - - `tab` 66 - - `backspace` 67 - - `up` 68 - - `down` 69 - - `left` 70 - - `right` 71 - - `pageup` 72 - - `pagedown` 73 - - `home` 74 - - `end` 75 - - `ctrl-c` 76 - - `ctrl-d` 77 - - `ctrl-l` 70 + Canonical normalized tokens now include: 71 + 72 + - navigation keys such as `tab`, `up`, `down`, `left`, `right`, `pageup`, `pagedown`, `home`, `end` 73 + - editing keys such as `enter`, `backspace`, `delete-forward`, `insert` 74 + - printable keys such as letters, digits, and supported ANSI punctuation tokens 75 + - control combinations such as `ctrl-c`, `ctrl-d`, `ctrl-l`, `ctrl-z` 76 + - shortcut combinations such as `cmd-c`, `cmd-shift-k`, `shift-tab`, `opt-enter` 77 + - function keys `f1`...`f12` 78 78 79 79 ### Accepted aliases (normalized to canonical) 80 80 ··· 86 86 - `arrow-right` -> `right` 87 87 - `pgup` -> `pageup` 88 88 - `pgdn` -> `pagedown` 89 - - `ctrl+c` -> `ctrl-c` 90 - - `ctrl+d` -> `ctrl-d` 91 - - `ctrl+l` -> `ctrl-l` 89 + - `forward-delete` / `deleteforward` -> `delete-forward` 90 + - `ins` -> `insert` 91 + - punctuation aliases such as `[` -> `left-bracket`, `]` -> `right-bracket`, `,` -> `comma`, `'` -> `quote` 92 + - `command-*` -> `cmd-*` 93 + - `alt-*` / `option-*` -> `opt-*` 94 + - `ctrl+*` -> `ctrl-*` 95 + 96 + ### ANSI punctuation note 97 + 98 + - prefer canonical ANSI-style punctuation tokens such as `minus`, `equal`, `comma`, `period`, `slash`, `backslash`, `quote`, `left-bracket` 99 + - express shifted symbols via modifier combos such as `shift-1`, `shift-quote`, `shift-left-bracket` 100 + - raw shifted symbol literals such as `!`, `@`, `{`, `}` are intentionally unsupported in v1 92 101 93 102 ### `--repeat` 94 103 ··· 162 171 - `normalized`: string 163 172 - canonical token used by runtime 164 173 - must be one of the canonical tokens listed above 165 - - `category`: `"navigation"` | `"editing"` | `"control"` 174 + - `category`: `"navigation"` | `"editing"` | `"control"` | `"shortcut"` | `"function"` 166 175 167 176 ### `delivery` 168 177 ··· 216 225 "schema_version": "prowl.cli.key.v1", 217 226 "error": { 218 227 "code": "UNSUPPORTED_KEY", 219 - "message": "The key token 'ctrl-z' is not supported in v1", 228 + "message": "The key token 'hyper-k' is not supported.", 220 229 "details": { 221 - "token": "ctrl-z" 230 + "token": "hyper-k" 222 231 } 223 232 } 224 233 }
+1 -1
supacode/CLIService/KeyCommandHandler.swift
··· 70 70 guard let category = KeyTokens.category(for: input.token) else { 71 71 return errorResponse( 72 72 code: CLIErrorCode.unsupportedKey, 73 - message: "The key token '\(input.token)' is not supported in v1." 73 + message: "The key token '\(input.token)' is not supported." 74 74 ) 75 75 } 76 76
+252 -56
supacode/CLIService/Shared/KeyCommandPayload.swift
··· 46 46 case navigation 47 47 case editing 48 48 case control 49 + case shortcut 50 + case function 49 51 } 50 52 51 53 public struct KeyDelivery: Codable, Sendable { ··· 124 126 125 127 // MARK: - Token Normalization 126 128 129 + public enum KeyModifier: String, Codable, Sendable, CaseIterable { 130 + case cmd 131 + case shift 132 + case opt 133 + case ctrl 134 + 135 + static let canonicalOrder: [KeyModifier] = [.cmd, .shift, .opt, .ctrl] 136 + 137 + static func resolve(_ raw: String) -> KeyModifier? { 138 + switch raw { 139 + case "cmd", "command", "super": 140 + return .cmd 141 + case "shift": 142 + return .shift 143 + case "opt", "option", "alt": 144 + return .opt 145 + case "ctrl", "control": 146 + return .ctrl 147 + default: 148 + return nil 149 + } 150 + } 151 + } 152 + 153 + public struct KeyTokenDescriptor: Equatable, Sendable { 154 + public let normalized: String 155 + public let baseToken: String 156 + public let modifiers: [KeyModifier] 157 + public let category: KeyCategory 158 + 159 + public init(normalized: String, baseToken: String, modifiers: [KeyModifier], category: KeyCategory) { 160 + self.normalized = normalized 161 + self.baseToken = baseToken 162 + self.modifiers = modifiers 163 + self.category = category 164 + } 165 + } 166 + 167 + private struct KeyBaseDescriptor { 168 + let canonical: String 169 + let category: KeyCategory 170 + } 171 + 172 + private struct ResolvedKeyBaseDescriptor { 173 + let base: KeyBaseDescriptor 174 + let implicitModifiers: Set<KeyModifier> 175 + } 176 + 127 177 /// Shared token definitions for the `key` command. 128 178 /// Used by CLI for validation and by app for response building. 129 179 public enum KeyTokens { 130 - /// Alias map: accepted aliases → canonical token. 131 - public static let aliases: [String: String] = [ 132 - "return": "enter", 133 - "escape": "esc", 134 - "arrow-up": "up", 135 - "arrow-down": "down", 136 - "arrow-left": "left", 137 - "arrow-right": "right", 138 - "pgup": "pageup", 139 - "pgdn": "pagedown", 140 - "ctrl+c": "ctrl-c", 141 - "ctrl+d": "ctrl-d", 142 - "ctrl+l": "ctrl-l", 143 - ] 144 - 145 - /// All canonical tokens recognized in v1. 146 - public static let canonical: Set<String> = [ 147 - "enter", 148 - "esc", 149 - "tab", 150 - "backspace", 151 - "up", 152 - "down", 153 - "left", 154 - "right", 155 - "pageup", 156 - "pagedown", 157 - "home", 158 - "end", 159 - "ctrl-c", 160 - "ctrl-d", 161 - "ctrl-l", 180 + private static let namedBaseDescriptors: [String: KeyBaseDescriptor] = [ 181 + "enter": KeyBaseDescriptor(canonical: "enter", category: .editing), 182 + "return": KeyBaseDescriptor(canonical: "enter", category: .editing), 183 + "esc": KeyBaseDescriptor(canonical: "esc", category: .control), 184 + "escape": KeyBaseDescriptor(canonical: "esc", category: .control), 185 + "tab": KeyBaseDescriptor(canonical: "tab", category: .navigation), 186 + "backspace": KeyBaseDescriptor(canonical: "backspace", category: .editing), 187 + "delete": KeyBaseDescriptor(canonical: "backspace", category: .editing), 188 + "del": KeyBaseDescriptor(canonical: "backspace", category: .editing), 189 + "delete-forward": KeyBaseDescriptor(canonical: "delete-forward", category: .editing), 190 + "forward-delete": KeyBaseDescriptor(canonical: "delete-forward", category: .editing), 191 + "deleteforward": KeyBaseDescriptor(canonical: "delete-forward", category: .editing), 192 + "forwarddelete": KeyBaseDescriptor(canonical: "delete-forward", category: .editing), 193 + "insert": KeyBaseDescriptor(canonical: "insert", category: .editing), 194 + "ins": KeyBaseDescriptor(canonical: "insert", category: .editing), 195 + "up": KeyBaseDescriptor(canonical: "up", category: .navigation), 196 + "arrow-up": KeyBaseDescriptor(canonical: "up", category: .navigation), 197 + "down": KeyBaseDescriptor(canonical: "down", category: .navigation), 198 + "arrow-down": KeyBaseDescriptor(canonical: "down", category: .navigation), 199 + "left": KeyBaseDescriptor(canonical: "left", category: .navigation), 200 + "arrow-left": KeyBaseDescriptor(canonical: "left", category: .navigation), 201 + "right": KeyBaseDescriptor(canonical: "right", category: .navigation), 202 + "arrow-right": KeyBaseDescriptor(canonical: "right", category: .navigation), 203 + "pageup": KeyBaseDescriptor(canonical: "pageup", category: .navigation), 204 + "page-up": KeyBaseDescriptor(canonical: "pageup", category: .navigation), 205 + "pgup": KeyBaseDescriptor(canonical: "pageup", category: .navigation), 206 + "pagedown": KeyBaseDescriptor(canonical: "pagedown", category: .navigation), 207 + "page-down": KeyBaseDescriptor(canonical: "pagedown", category: .navigation), 208 + "pgdn": KeyBaseDescriptor(canonical: "pagedown", category: .navigation), 209 + "home": KeyBaseDescriptor(canonical: "home", category: .navigation), 210 + "end": KeyBaseDescriptor(canonical: "end", category: .navigation), 211 + "space": KeyBaseDescriptor(canonical: "space", category: .editing), 212 + "minus": KeyBaseDescriptor(canonical: "minus", category: .editing), 213 + "hyphen": KeyBaseDescriptor(canonical: "minus", category: .editing), 214 + "dash": KeyBaseDescriptor(canonical: "minus", category: .editing), 215 + "equal": KeyBaseDescriptor(canonical: "equal", category: .editing), 216 + "equals": KeyBaseDescriptor(canonical: "equal", category: .editing), 217 + "comma": KeyBaseDescriptor(canonical: "comma", category: .editing), 218 + "period": KeyBaseDescriptor(canonical: "period", category: .editing), 219 + "dot": KeyBaseDescriptor(canonical: "period", category: .editing), 220 + "slash": KeyBaseDescriptor(canonical: "slash", category: .editing), 221 + "backslash": KeyBaseDescriptor(canonical: "backslash", category: .editing), 222 + "semicolon": KeyBaseDescriptor(canonical: "semicolon", category: .editing), 223 + "quote": KeyBaseDescriptor(canonical: "quote", category: .editing), 224 + "apostrophe": KeyBaseDescriptor(canonical: "quote", category: .editing), 225 + "grave": KeyBaseDescriptor(canonical: "grave", category: .editing), 226 + "backtick": KeyBaseDescriptor(canonical: "grave", category: .editing), 227 + "left-bracket": KeyBaseDescriptor(canonical: "left-bracket", category: .editing), 228 + "leftbracket": KeyBaseDescriptor(canonical: "left-bracket", category: .editing), 229 + "lbracket": KeyBaseDescriptor(canonical: "left-bracket", category: .editing), 230 + "right-bracket": KeyBaseDescriptor(canonical: "right-bracket", category: .editing), 231 + "rightbracket": KeyBaseDescriptor(canonical: "right-bracket", category: .editing), 232 + "rbracket": KeyBaseDescriptor(canonical: "right-bracket", category: .editing), 162 233 ] 163 234 164 - /// Category for each canonical token. 165 - public static let categories: [String: KeyCategory] = [ 166 - "up": .navigation, 167 - "down": .navigation, 168 - "left": .navigation, 169 - "right": .navigation, 170 - "pageup": .navigation, 171 - "pagedown": .navigation, 172 - "home": .navigation, 173 - "end": .navigation, 174 - "tab": .navigation, 175 - "enter": .editing, 176 - "backspace": .editing, 177 - "esc": .control, 178 - "ctrl-c": .control, 179 - "ctrl-d": .control, 180 - "ctrl-l": .control, 235 + private static let singleCharacterBaseDescriptors: [String: KeyBaseDescriptor] = [ 236 + "a": KeyBaseDescriptor(canonical: "a", category: .editing), 237 + "b": KeyBaseDescriptor(canonical: "b", category: .editing), 238 + "c": KeyBaseDescriptor(canonical: "c", category: .editing), 239 + "d": KeyBaseDescriptor(canonical: "d", category: .editing), 240 + "e": KeyBaseDescriptor(canonical: "e", category: .editing), 241 + "f": KeyBaseDescriptor(canonical: "f", category: .editing), 242 + "g": KeyBaseDescriptor(canonical: "g", category: .editing), 243 + "h": KeyBaseDescriptor(canonical: "h", category: .editing), 244 + "i": KeyBaseDescriptor(canonical: "i", category: .editing), 245 + "j": KeyBaseDescriptor(canonical: "j", category: .editing), 246 + "k": KeyBaseDescriptor(canonical: "k", category: .editing), 247 + "l": KeyBaseDescriptor(canonical: "l", category: .editing), 248 + "m": KeyBaseDescriptor(canonical: "m", category: .editing), 249 + "n": KeyBaseDescriptor(canonical: "n", category: .editing), 250 + "o": KeyBaseDescriptor(canonical: "o", category: .editing), 251 + "p": KeyBaseDescriptor(canonical: "p", category: .editing), 252 + "q": KeyBaseDescriptor(canonical: "q", category: .editing), 253 + "r": KeyBaseDescriptor(canonical: "r", category: .editing), 254 + "s": KeyBaseDescriptor(canonical: "s", category: .editing), 255 + "t": KeyBaseDescriptor(canonical: "t", category: .editing), 256 + "u": KeyBaseDescriptor(canonical: "u", category: .editing), 257 + "v": KeyBaseDescriptor(canonical: "v", category: .editing), 258 + "w": KeyBaseDescriptor(canonical: "w", category: .editing), 259 + "x": KeyBaseDescriptor(canonical: "x", category: .editing), 260 + "y": KeyBaseDescriptor(canonical: "y", category: .editing), 261 + "z": KeyBaseDescriptor(canonical: "z", category: .editing), 262 + "0": KeyBaseDescriptor(canonical: "0", category: .editing), 263 + "1": KeyBaseDescriptor(canonical: "1", category: .editing), 264 + "2": KeyBaseDescriptor(canonical: "2", category: .editing), 265 + "3": KeyBaseDescriptor(canonical: "3", category: .editing), 266 + "4": KeyBaseDescriptor(canonical: "4", category: .editing), 267 + "5": KeyBaseDescriptor(canonical: "5", category: .editing), 268 + "6": KeyBaseDescriptor(canonical: "6", category: .editing), 269 + "7": KeyBaseDescriptor(canonical: "7", category: .editing), 270 + "8": KeyBaseDescriptor(canonical: "8", category: .editing), 271 + "9": KeyBaseDescriptor(canonical: "9", category: .editing), 272 + ",": KeyBaseDescriptor(canonical: "comma", category: .editing), 273 + ".": KeyBaseDescriptor(canonical: "period", category: .editing), 274 + "/": KeyBaseDescriptor(canonical: "slash", category: .editing), 275 + "\\": KeyBaseDescriptor(canonical: "backslash", category: .editing), 276 + ";": KeyBaseDescriptor(canonical: "semicolon", category: .editing), 277 + "'": KeyBaseDescriptor(canonical: "quote", category: .editing), 278 + "`": KeyBaseDescriptor(canonical: "grave", category: .editing), 279 + "[": KeyBaseDescriptor(canonical: "left-bracket", category: .editing), 280 + "]": KeyBaseDescriptor(canonical: "right-bracket", category: .editing), 181 281 ] 182 282 183 - /// Normalize a user-provided token string to its canonical form. 184 - /// Returns `nil` if the token is not recognized. 185 283 public static func normalize(_ raw: String) -> String? { 186 - let lowered = raw.lowercased() 187 - let resolved = aliases[lowered] ?? lowered 188 - return canonical.contains(resolved) ? resolved : nil 284 + descriptor(for: raw)?.normalized 189 285 } 190 286 191 - /// Get the category for a canonical token. 192 287 public static func category(for canonical: String) -> KeyCategory? { 193 - categories[canonical] 288 + descriptor(for: canonical)?.category 289 + } 290 + 291 + public static func descriptor(for raw: String) -> KeyTokenDescriptor? { 292 + let normalizedInput = raw 293 + .trimmingCharacters(in: .whitespacesAndNewlines) 294 + .replacing("+", with: "-") 295 + 296 + guard !normalizedInput.isEmpty else { return nil } 297 + 298 + let parts = normalizedInput 299 + .split(separator: "-", omittingEmptySubsequences: false) 300 + .map(String.init) 301 + guard !parts.isEmpty, !parts.contains(where: \.isEmpty) else { return nil } 302 + 303 + return descriptor(for: parts, baseLengths: [2, 1]) 304 + } 305 + 306 + private static func descriptor(for parts: [String], baseLengths: [Int]) -> KeyTokenDescriptor? { 307 + for baseLength in baseLengths where parts.count >= baseLength { 308 + let baseRaw = parts.suffix(baseLength).joined(separator: "-") 309 + guard let resolvedBase = baseDescriptor(for: baseRaw) else { continue } 310 + guard let modifiers = modifiers( 311 + from: parts.dropLast(baseLength), 312 + implicitModifiers: resolvedBase.implicitModifiers 313 + ) else { 314 + return nil 315 + } 316 + 317 + let normalized = (modifiers.map(\.rawValue) + [resolvedBase.base.canonical]).joined(separator: "-") 318 + return KeyTokenDescriptor( 319 + normalized: normalized, 320 + baseToken: resolvedBase.base.canonical, 321 + modifiers: modifiers, 322 + category: category(for: resolvedBase.base, modifiers: modifiers) 323 + ) 324 + } 325 + 326 + return nil 327 + } 328 + 329 + private static func modifiers(from parts: ArraySlice<String>, implicitModifiers: Set<KeyModifier>) -> [KeyModifier]? { 330 + var modifierSet = Set<KeyModifier>() 331 + for modifierRaw in parts { 332 + guard let modifier = KeyModifier.resolve(modifierRaw.lowercased()), modifierSet.insert(modifier).inserted else { 333 + return nil 334 + } 335 + } 336 + 337 + modifierSet.formUnion(implicitModifiers) 338 + return KeyModifier.canonicalOrder.filter { modifierSet.contains($0) } 339 + } 340 + 341 + private static func category(for base: KeyBaseDescriptor, modifiers: [KeyModifier]) -> KeyCategory { 342 + guard !modifiers.isEmpty else { return base.category } 343 + if usesControlCategory(for: modifiers) { 344 + return .control 345 + } 346 + return .shortcut 347 + } 348 + 349 + private static func baseDescriptor(for raw: String) -> ResolvedKeyBaseDescriptor? { 350 + let lowered = raw.lowercased() 351 + if let base = namedBaseDescriptors[lowered] { 352 + return ResolvedKeyBaseDescriptor(base: base, implicitModifiers: []) 353 + } 354 + 355 + if let base = singleCharacterBaseDescriptor(for: raw) { 356 + return base 357 + } 358 + 359 + if lowered.count >= 2, 360 + lowered.first == "f", 361 + let number = Int(lowered.dropFirst()), 362 + (1...12).contains(number) 363 + { 364 + return ResolvedKeyBaseDescriptor( 365 + base: KeyBaseDescriptor(canonical: lowered, category: .function), 366 + implicitModifiers: [] 367 + ) 368 + } 369 + 370 + return nil 371 + } 372 + 373 + private static func singleCharacterBaseDescriptor(for raw: String) -> ResolvedKeyBaseDescriptor? { 374 + guard raw.count == 1 else { return nil } 375 + 376 + if let character = raw.first, character.isLetter { 377 + let canonical = String(character).lowercased() 378 + guard let base = singleCharacterBaseDescriptors[canonical] else { return nil } 379 + let implicitModifiers: Set<KeyModifier> = character.isUppercase ? [.shift] : [] 380 + return ResolvedKeyBaseDescriptor(base: base, implicitModifiers: implicitModifiers) 381 + } 382 + 383 + guard let base = singleCharacterBaseDescriptors[raw] else { return nil } 384 + return ResolvedKeyBaseDescriptor(base: base, implicitModifiers: []) 385 + } 386 + 387 + private static func usesControlCategory(for modifiers: [KeyModifier]) -> Bool { 388 + let modifierSet = Set(modifiers) 389 + return modifierSet.contains(.ctrl) && modifierSet.isSubset(of: [.ctrl, .shift]) 194 390 } 195 391 }
+282 -48
supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift
··· 1878 1878 let charactersIgnoringModifiers: String 1879 1879 let modifiers: NSEvent.ModifierFlags 1880 1880 1881 - // swiftlint:disable:next cyclomatic_complexity 1882 1881 static func from(token: String) -> CLIKeySpec? { 1883 - switch token { 1884 - case "enter": 1885 - return CLIKeySpec(keyCode: 36, characters: "\r", charactersIgnoringModifiers: "\r", modifiers: []) 1886 - case "esc": 1887 - return CLIKeySpec(keyCode: 53, characters: "\u{1B}", charactersIgnoringModifiers: "\u{1B}", modifiers: []) 1888 - case "tab": 1889 - return CLIKeySpec(keyCode: 48, characters: "\t", charactersIgnoringModifiers: "\t", modifiers: []) 1890 - case "backspace": 1891 - return CLIKeySpec(keyCode: 51, characters: "\u{7F}", charactersIgnoringModifiers: "\u{7F}", modifiers: []) 1892 - case "up": 1893 - return CLIKeySpec( 1894 - keyCode: 126, characters: "\u{F700}", charactersIgnoringModifiers: "\u{F700}", modifiers: [.function] 1895 - ) 1896 - case "down": 1897 - return CLIKeySpec( 1898 - keyCode: 125, characters: "\u{F701}", charactersIgnoringModifiers: "\u{F701}", modifiers: [.function] 1899 - ) 1900 - case "left": 1901 - return CLIKeySpec( 1902 - keyCode: 123, characters: "\u{F702}", charactersIgnoringModifiers: "\u{F702}", modifiers: [.function] 1903 - ) 1904 - case "right": 1905 - return CLIKeySpec( 1906 - keyCode: 124, characters: "\u{F703}", charactersIgnoringModifiers: "\u{F703}", modifiers: [.function] 1907 - ) 1908 - case "pageup": 1909 - return CLIKeySpec( 1910 - keyCode: 116, characters: "\u{F72C}", charactersIgnoringModifiers: "\u{F72C}", modifiers: [.function] 1911 - ) 1912 - case "pagedown": 1913 - return CLIKeySpec( 1914 - keyCode: 121, characters: "\u{F72D}", charactersIgnoringModifiers: "\u{F72D}", modifiers: [.function] 1915 - ) 1916 - case "home": 1917 - return CLIKeySpec( 1918 - keyCode: 115, characters: "\u{F729}", charactersIgnoringModifiers: "\u{F729}", modifiers: [.function] 1919 - ) 1920 - case "end": 1921 - return CLIKeySpec( 1922 - keyCode: 119, characters: "\u{F72B}", charactersIgnoringModifiers: "\u{F72B}", modifiers: [.function] 1882 + guard let descriptor = KeyTokens.descriptor(for: token), 1883 + let base = baseSpec(for: descriptor.baseToken) 1884 + else { 1885 + return nil 1886 + } 1887 + 1888 + var modifierFlags = eventModifiers(from: descriptor.modifiers) 1889 + if base.usesFunctionModifier { 1890 + modifierFlags.insert(.function) 1891 + } 1892 + 1893 + let charactersIgnoringModifiers = base.charactersIgnoringModifiers 1894 + let characters: String 1895 + if usesControlCharacter(for: descriptor.modifiers), 1896 + let controlCharacter = controlCharacter(for: descriptor.baseToken) 1897 + { 1898 + characters = controlCharacter 1899 + } else if descriptor.modifiers.contains(.shift) { 1900 + characters = shiftedCharacters(for: descriptor.baseToken) ?? charactersIgnoringModifiers 1901 + } else { 1902 + characters = charactersIgnoringModifiers 1903 + } 1904 + 1905 + return CLIKeySpec( 1906 + keyCode: base.keyCode, 1907 + characters: characters, 1908 + charactersIgnoringModifiers: charactersIgnoringModifiers, 1909 + modifiers: modifierFlags 1910 + ) 1911 + } 1912 + 1913 + private struct BaseSpec { 1914 + let keyCode: UInt16 1915 + let charactersIgnoringModifiers: String 1916 + let usesFunctionModifier: Bool 1917 + } 1918 + 1919 + private static let namedBaseSpecs: [String: BaseSpec] = [ 1920 + "enter": BaseSpec( 1921 + keyCode: UInt16(kVK_Return), 1922 + charactersIgnoringModifiers: "\r", 1923 + usesFunctionModifier: false 1924 + ), 1925 + "esc": BaseSpec( 1926 + keyCode: UInt16(kVK_Escape), 1927 + charactersIgnoringModifiers: "\u{1B}", 1928 + usesFunctionModifier: false 1929 + ), 1930 + "tab": BaseSpec( 1931 + keyCode: UInt16(kVK_Tab), 1932 + charactersIgnoringModifiers: "\t", 1933 + usesFunctionModifier: false 1934 + ), 1935 + "backspace": BaseSpec( 1936 + keyCode: UInt16(kVK_Delete), 1937 + charactersIgnoringModifiers: "\u{7F}", 1938 + usesFunctionModifier: false 1939 + ), 1940 + "delete-forward": BaseSpec( 1941 + keyCode: UInt16(kVK_ForwardDelete), 1942 + charactersIgnoringModifiers: String(UnicodeScalar(NSDeleteFunctionKey)!), 1943 + usesFunctionModifier: true 1944 + ), 1945 + "insert": BaseSpec( 1946 + keyCode: UInt16(kVK_Help), 1947 + charactersIgnoringModifiers: String(UnicodeScalar(NSInsertFunctionKey)!), 1948 + usesFunctionModifier: true 1949 + ), 1950 + "up": BaseSpec( 1951 + keyCode: UInt16(kVK_UpArrow), 1952 + charactersIgnoringModifiers: String(UnicodeScalar(NSUpArrowFunctionKey)!), 1953 + usesFunctionModifier: true 1954 + ), 1955 + "down": BaseSpec( 1956 + keyCode: UInt16(kVK_DownArrow), 1957 + charactersIgnoringModifiers: String(UnicodeScalar(NSDownArrowFunctionKey)!), 1958 + usesFunctionModifier: true 1959 + ), 1960 + "left": BaseSpec( 1961 + keyCode: UInt16(kVK_LeftArrow), 1962 + charactersIgnoringModifiers: String(UnicodeScalar(NSLeftArrowFunctionKey)!), 1963 + usesFunctionModifier: true 1964 + ), 1965 + "right": BaseSpec( 1966 + keyCode: UInt16(kVK_RightArrow), 1967 + charactersIgnoringModifiers: String(UnicodeScalar(NSRightArrowFunctionKey)!), 1968 + usesFunctionModifier: true 1969 + ), 1970 + "pageup": BaseSpec( 1971 + keyCode: UInt16(kVK_PageUp), 1972 + charactersIgnoringModifiers: String(UnicodeScalar(NSPageUpFunctionKey)!), 1973 + usesFunctionModifier: true 1974 + ), 1975 + "pagedown": BaseSpec( 1976 + keyCode: UInt16(kVK_PageDown), 1977 + charactersIgnoringModifiers: String(UnicodeScalar(NSPageDownFunctionKey)!), 1978 + usesFunctionModifier: true 1979 + ), 1980 + "home": BaseSpec( 1981 + keyCode: UInt16(kVK_Home), 1982 + charactersIgnoringModifiers: String(UnicodeScalar(NSHomeFunctionKey)!), 1983 + usesFunctionModifier: true 1984 + ), 1985 + "end": BaseSpec( 1986 + keyCode: UInt16(kVK_End), 1987 + charactersIgnoringModifiers: String(UnicodeScalar(NSEndFunctionKey)!), 1988 + usesFunctionModifier: true 1989 + ), 1990 + ] 1991 + 1992 + private static let functionKeyMap: [String: (UInt16, Int)] = [ 1993 + "f1": (UInt16(kVK_F1), NSF1FunctionKey), 1994 + "f2": (UInt16(kVK_F2), NSF2FunctionKey), 1995 + "f3": (UInt16(kVK_F3), NSF3FunctionKey), 1996 + "f4": (UInt16(kVK_F4), NSF4FunctionKey), 1997 + "f5": (UInt16(kVK_F5), NSF5FunctionKey), 1998 + "f6": (UInt16(kVK_F6), NSF6FunctionKey), 1999 + "f7": (UInt16(kVK_F7), NSF7FunctionKey), 2000 + "f8": (UInt16(kVK_F8), NSF8FunctionKey), 2001 + "f9": (UInt16(kVK_F9), NSF9FunctionKey), 2002 + "f10": (UInt16(kVK_F10), NSF10FunctionKey), 2003 + "f11": (UInt16(kVK_F11), NSF11FunctionKey), 2004 + "f12": (UInt16(kVK_F12), NSF12FunctionKey), 2005 + ] 2006 + 2007 + private static let printableKeyCodes: [String: UInt16] = [ 2008 + "a": UInt16(kVK_ANSI_A), 2009 + "b": UInt16(kVK_ANSI_B), 2010 + "c": UInt16(kVK_ANSI_C), 2011 + "d": UInt16(kVK_ANSI_D), 2012 + "e": UInt16(kVK_ANSI_E), 2013 + "f": UInt16(kVK_ANSI_F), 2014 + "g": UInt16(kVK_ANSI_G), 2015 + "h": UInt16(kVK_ANSI_H), 2016 + "i": UInt16(kVK_ANSI_I), 2017 + "j": UInt16(kVK_ANSI_J), 2018 + "k": UInt16(kVK_ANSI_K), 2019 + "l": UInt16(kVK_ANSI_L), 2020 + "m": UInt16(kVK_ANSI_M), 2021 + "n": UInt16(kVK_ANSI_N), 2022 + "o": UInt16(kVK_ANSI_O), 2023 + "p": UInt16(kVK_ANSI_P), 2024 + "q": UInt16(kVK_ANSI_Q), 2025 + "r": UInt16(kVK_ANSI_R), 2026 + "s": UInt16(kVK_ANSI_S), 2027 + "t": UInt16(kVK_ANSI_T), 2028 + "u": UInt16(kVK_ANSI_U), 2029 + "v": UInt16(kVK_ANSI_V), 2030 + "w": UInt16(kVK_ANSI_W), 2031 + "x": UInt16(kVK_ANSI_X), 2032 + "y": UInt16(kVK_ANSI_Y), 2033 + "z": UInt16(kVK_ANSI_Z), 2034 + "0": UInt16(kVK_ANSI_0), 2035 + "1": UInt16(kVK_ANSI_1), 2036 + "2": UInt16(kVK_ANSI_2), 2037 + "3": UInt16(kVK_ANSI_3), 2038 + "4": UInt16(kVK_ANSI_4), 2039 + "5": UInt16(kVK_ANSI_5), 2040 + "6": UInt16(kVK_ANSI_6), 2041 + "7": UInt16(kVK_ANSI_7), 2042 + "8": UInt16(kVK_ANSI_8), 2043 + "9": UInt16(kVK_ANSI_9), 2044 + "space": UInt16(kVK_Space), 2045 + "minus": UInt16(kVK_ANSI_Minus), 2046 + "equal": UInt16(kVK_ANSI_Equal), 2047 + "comma": UInt16(kVK_ANSI_Comma), 2048 + "period": UInt16(kVK_ANSI_Period), 2049 + "slash": UInt16(kVK_ANSI_Slash), 2050 + "backslash": UInt16(kVK_ANSI_Backslash), 2051 + "semicolon": UInt16(kVK_ANSI_Semicolon), 2052 + "quote": UInt16(kVK_ANSI_Quote), 2053 + "grave": UInt16(kVK_ANSI_Grave), 2054 + "left-bracket": UInt16(kVK_ANSI_LeftBracket), 2055 + "right-bracket": UInt16(kVK_ANSI_RightBracket), 2056 + ] 2057 + 2058 + private static let shiftedCharacterMap: [String: String] = [ 2059 + "1": "!", 2060 + "2": "@", 2061 + "3": "#", 2062 + "4": "$", 2063 + "5": "%", 2064 + "6": "^", 2065 + "7": "&", 2066 + "8": "*", 2067 + "9": "(", 2068 + "0": ")", 2069 + "minus": "_", 2070 + "equal": "+", 2071 + "comma": "<", 2072 + "period": ">", 2073 + "slash": "?", 2074 + "backslash": "|", 2075 + "semicolon": ":", 2076 + "quote": "\"", 2077 + "grave": "~", 2078 + "left-bracket": "{", 2079 + "right-bracket": "}", 2080 + ] 2081 + 2082 + private static func eventModifiers(from modifiers: [KeyModifier]) -> NSEvent.ModifierFlags { 2083 + modifiers.reduce(into: NSEvent.ModifierFlags()) { result, modifier in 2084 + switch modifier { 2085 + case .cmd: result.insert(.command) 2086 + case .shift: result.insert(.shift) 2087 + case .opt: result.insert(.option) 2088 + case .ctrl: result.insert(.control) 2089 + } 2090 + } 2091 + } 2092 + 2093 + private static func baseSpec(for token: String) -> BaseSpec? { 2094 + if let base = namedBaseSpecs[token] { return base } 2095 + if let (keyCode, scalar) = functionKeyMap[token] { 2096 + return BaseSpec( 2097 + keyCode: keyCode, 2098 + charactersIgnoringModifiers: String(UnicodeScalar(scalar)!), 2099 + usesFunctionModifier: true 1923 2100 ) 1924 - case "ctrl-c": 1925 - return CLIKeySpec(keyCode: 8, characters: "\u{3}", charactersIgnoringModifiers: "c", modifiers: [.control]) 1926 - case "ctrl-d": 1927 - return CLIKeySpec(keyCode: 2, characters: "\u{4}", charactersIgnoringModifiers: "d", modifiers: [.control]) 1928 - case "ctrl-l": 1929 - return CLIKeySpec(keyCode: 37, characters: "\u{C}", charactersIgnoringModifiers: "l", modifiers: [.control]) 2101 + } 2102 + return printableBaseSpec(for: token) 2103 + } 2104 + 2105 + private static func printableBaseSpec(for token: String) -> BaseSpec? { 2106 + guard let character = printableCharacter(for: token), 2107 + let keyCode = printableKeyCode(for: token) 2108 + else { return nil } 2109 + 2110 + return BaseSpec( 2111 + keyCode: keyCode, 2112 + charactersIgnoringModifiers: String(character), 2113 + usesFunctionModifier: false 2114 + ) 2115 + } 2116 + 2117 + private static func printableCharacter(for token: String) -> Character? { 2118 + switch token { 2119 + case "space": return " " 2120 + case "minus": return "-" 2121 + case "equal": return "=" 2122 + case "comma": return "," 2123 + case "period": return "." 2124 + case "slash": return "/" 2125 + case "backslash": return "\\" 2126 + case "semicolon": return ";" 2127 + case "quote": return "'" 2128 + case "grave": return "`" 2129 + case "left-bracket": return "[" 2130 + case "right-bracket": return "]" 1930 2131 default: 1931 - return nil 2132 + guard token.count == 1 else { return nil } 2133 + return token.first 2134 + } 2135 + } 2136 + 2137 + private static func printableKeyCode(for token: String) -> UInt16? { 2138 + printableKeyCodes[token] 2139 + } 2140 + 2141 + private static func shiftedCharacters(for token: String) -> String? { 2142 + if token.count == 1, let character = token.first, character.isLetter { 2143 + return String(character).uppercased() 2144 + } 2145 + 2146 + return shiftedCharacterMap[token] 2147 + } 2148 + 2149 + private static func usesControlCharacter(for modifiers: [KeyModifier]) -> Bool { 2150 + let modifierSet = Set(modifiers) 2151 + return modifierSet.contains(.ctrl) && modifierSet.isSubset(of: [.ctrl, .shift]) 2152 + } 2153 + 2154 + private static func controlCharacter(for token: String) -> String? { 2155 + if token.count == 1, let scalar = token.unicodeScalars.first, scalar.properties.isAlphabetic { 2156 + return String(UnicodeScalar(scalar.value & 0x1F)!) 2157 + } 2158 + 2159 + switch token { 2160 + case "left-bracket": return String(UnicodeScalar(27)!) 2161 + case "backslash": return String(UnicodeScalar(28)!) 2162 + case "right-bracket": return String(UnicodeScalar(29)!) 2163 + case "6": return String(UnicodeScalar(30)!) 2164 + case "minus": return String(UnicodeScalar(31)!) 2165 + default: return nil 1932 2166 } 1933 2167 } 1934 2168 }
+1 -1
supacodeTests/CLIKeyCommandHandlerTests.swift
··· 238 238 @Test func unsupportedKeyReturnsError() async throws { 239 239 let handler = Self.makeHandler() 240 240 let response = await handler.handle( 241 - envelope: Self.makeEnvelope(token: "ctrl-z") 241 + envelope: Self.makeEnvelope(token: "hyper-k") 242 242 ) 243 243 244 244 #expect(response.ok == false)
+143
supacodeTests/CLIKeyTokenExpansionTests.swift
··· 1 + import AppKit 2 + import Carbon 3 + import Testing 4 + 5 + @testable import supacode 6 + 7 + struct CLIKeyTokenExpansionTests { 8 + @Test func normalizesExpandedModifierCombos() { 9 + #expect(KeyTokens.normalize("cmd-c") == "cmd-c") 10 + #expect(KeyTokens.normalize("command-shift-k") == "cmd-shift-k") 11 + #expect(KeyTokens.normalize("alt-enter") == "opt-enter") 12 + #expect(KeyTokens.normalize("ctrl-z") == "ctrl-z") 13 + } 14 + 15 + @Test func normalizesPrintableAliasesToCanonicalAnsiTokens() { 16 + #expect(KeyTokens.normalize("[") == "left-bracket") 17 + #expect(KeyTokens.normalize("]") == "right-bracket") 18 + #expect(KeyTokens.normalize(",") == "comma") 19 + #expect(KeyTokens.normalize("'") == "quote") 20 + } 21 + 22 + @Test func normalizesAdditionalNamedKeys() { 23 + #expect(KeyTokens.normalize("deleteforward") == "delete-forward") 24 + #expect(KeyTokens.normalize("forward-delete") == "delete-forward") 25 + #expect(KeyTokens.normalize("ins") == "insert") 26 + #expect(KeyTokens.normalize("f12") == "f12") 27 + } 28 + 29 + @Test func normalizesUppercaseLettersToShiftedPrintableCombos() { 30 + #expect(KeyTokens.normalize("A") == "shift-a") 31 + #expect(KeyTokens.normalize("cmd-A") == "cmd-shift-a") 32 + } 33 + 34 + @Test func normalizesMixedCaseControlTokensWithUppercaseSemantics() { 35 + #expect(KeyTokens.normalize("Ctrl-A") == "shift-ctrl-a") 36 + #expect(KeyTokens.normalize("CTRL-A") == "shift-ctrl-a") 37 + #expect(KeyTokens.normalize("ctrl-a") == "ctrl-a") 38 + #expect(KeyTokens.category(for: "Ctrl-A") == .control) 39 + } 40 + 41 + @Test func rejectsUnsupportedShiftedSymbolLiterals() { 42 + #expect(KeyTokens.normalize("!") == nil) 43 + #expect(KeyTokens.normalize("@") == nil) 44 + #expect(CLIKeySpec.from(token: "!") == nil) 45 + #expect(CLIKeySpec.from(token: "@") == nil) 46 + } 47 + 48 + @Test func expandedCategoriesAreReported() { 49 + #expect(KeyTokens.category(for: "cmd-c") == .shortcut) 50 + #expect(KeyTokens.category(for: "ctrl-z") == .control) 51 + #expect(KeyTokens.category(for: "ctrl-shift-minus") == .control) 52 + #expect(KeyTokens.category(for: "f12") == .function) 53 + } 54 + 55 + @Test func cliKeySpecBuildsCommandShortcutEvent() throws { 56 + let spec = try #require(CLIKeySpec.from(token: "cmd-c")) 57 + 58 + #expect(spec.keyCode == UInt16(kVK_ANSI_C)) 59 + #expect(spec.modifiers == [.command]) 60 + #expect(spec.characters == "c") 61 + #expect(spec.charactersIgnoringModifiers == "c") 62 + } 63 + 64 + @Test func cliKeySpecBuildsShiftedShortcutEvent() throws { 65 + let spec = try #require(CLIKeySpec.from(token: "cmd-shift-k")) 66 + 67 + #expect(spec.keyCode == UInt16(kVK_ANSI_K)) 68 + #expect(spec.modifiers == [.command, .shift]) 69 + #expect(spec.characters == "K") 70 + #expect(spec.charactersIgnoringModifiers == "k") 71 + } 72 + 73 + @Test func cliKeySpecBuildsShiftedAnsiPunctuationEvent() throws { 74 + let spec = try #require(CLIKeySpec.from(token: "shift-left-bracket")) 75 + 76 + #expect(spec.keyCode == UInt16(kVK_ANSI_LeftBracket)) 77 + #expect(spec.modifiers == [.shift]) 78 + #expect(spec.characters == "{") 79 + #expect(spec.charactersIgnoringModifiers == "[") 80 + } 81 + 82 + @Test func cliKeySpecBuildsUppercasePrintableEvent() throws { 83 + let spec = try #require(CLIKeySpec.from(token: "A")) 84 + 85 + #expect(spec.keyCode == UInt16(kVK_ANSI_A)) 86 + #expect(spec.modifiers == [.shift]) 87 + #expect(spec.characters == "A") 88 + #expect(spec.charactersIgnoringModifiers == "a") 89 + } 90 + 91 + @Test func cliKeySpecBuildsMixedCaseControlLetterEvent() throws { 92 + let spec = try #require(CLIKeySpec.from(token: "Ctrl-A")) 93 + 94 + #expect(spec.keyCode == UInt16(kVK_ANSI_A)) 95 + #expect(spec.modifiers == [.control, .shift]) 96 + #expect(spec.characters == String(UnicodeScalar(1)!)) 97 + #expect(spec.charactersIgnoringModifiers == "a") 98 + } 99 + 100 + @Test func cliKeySpecBuildsAnsiControlCharactersForCommonTerminalCombos() throws { 101 + let esc = try #require(CLIKeySpec.from(token: "ctrl-left-bracket")) 102 + #expect(esc.keyCode == UInt16(kVK_ANSI_LeftBracket)) 103 + #expect(esc.modifiers == [.control]) 104 + #expect(esc.characters == String(UnicodeScalar(27)!)) 105 + #expect(esc.charactersIgnoringModifiers == "[") 106 + 107 + let fileSeparator = try #require(CLIKeySpec.from(token: "ctrl-backslash")) 108 + #expect(fileSeparator.keyCode == UInt16(kVK_ANSI_Backslash)) 109 + #expect(fileSeparator.modifiers == [.control]) 110 + #expect(fileSeparator.characters == String(UnicodeScalar(28)!)) 111 + #expect(fileSeparator.charactersIgnoringModifiers == "\\") 112 + 113 + let groupSeparator = try #require(CLIKeySpec.from(token: "ctrl-right-bracket")) 114 + #expect(groupSeparator.keyCode == UInt16(kVK_ANSI_RightBracket)) 115 + #expect(groupSeparator.modifiers == [.control]) 116 + #expect(groupSeparator.characters == String(UnicodeScalar(29)!)) 117 + #expect(groupSeparator.charactersIgnoringModifiers == "]") 118 + 119 + let caret = try #require(CLIKeySpec.from(token: "ctrl-shift-6")) 120 + #expect(caret.keyCode == UInt16(kVK_ANSI_6)) 121 + #expect(caret.modifiers == [.control, .shift]) 122 + #expect(caret.characters == String(UnicodeScalar(30)!)) 123 + #expect(caret.charactersIgnoringModifiers == "6") 124 + 125 + let underscore = try #require(CLIKeySpec.from(token: "ctrl-shift-minus")) 126 + #expect(underscore.keyCode == UInt16(kVK_ANSI_Minus)) 127 + #expect(underscore.modifiers == [.control, .shift]) 128 + #expect(underscore.characters == String(UnicodeScalar(31)!)) 129 + #expect(underscore.charactersIgnoringModifiers == "-") 130 + } 131 + 132 + @Test func cliKeySpecBuildsFunctionAndForwardDeleteEvents() throws { 133 + let f12 = try #require(CLIKeySpec.from(token: "f12")) 134 + #expect(f12.keyCode == UInt16(kVK_F12)) 135 + #expect(f12.modifiers == [.function]) 136 + #expect(f12.characters == String(UnicodeScalar(NSF12FunctionKey)!)) 137 + 138 + let deleteForward = try #require(CLIKeySpec.from(token: "delete-forward")) 139 + #expect(deleteForward.keyCode == UInt16(kVK_ForwardDelete)) 140 + #expect(deleteForward.modifiers == [.function]) 141 + #expect(deleteForward.characters == String(UnicodeScalar(NSDeleteFunctionKey)!)) 142 + } 143 + }