native macOS codings agent orchestrator
5
fork

Configure Feed

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

Merge pull request #141 from onevcat/feat/issue-110-cli-key-runtime

CLI/key v1 runtime implementation

authored by

Wei Wang and committed by
GitHub
e9a581be 5fa7024b

+1185 -7
+11 -2
ProwlCLI/Commands/KeyCommand.swift
··· 1 1 // ProwlCLI/Commands/KeyCommand.swift 2 2 3 3 import ArgumentParser 4 + import Foundation 4 5 import ProwlCLIShared 5 6 6 7 struct KeyCommand: ParsableCommand { ··· 19 20 var token: String 20 21 21 22 mutating func run() throws { 22 - try CLIExecution.run(command: "key", output: options.outputMode) { 23 + try CLIExecution.run(command: "key", output: options.outputMode, colorEnabled: options.colorEnabled) { 23 24 let sel = try selector.resolve() 24 25 25 26 guard (1...100).contains(self.repeat) else { ··· 29 30 ) 30 31 } 31 32 32 - let normalized = token.lowercased() 33 + let rawToken = token.trimmingCharacters(in: .whitespaces) 34 + 35 + guard let normalized = KeyTokens.normalize(rawToken) else { 36 + throw ExitError( 37 + code: CLIErrorCode.unsupportedKey, 38 + message: "The key token '\(rawToken.lowercased())' is not supported in v1." 39 + ) 40 + } 33 41 34 42 let envelope = CommandEnvelope( 35 43 output: options.outputMode, 36 44 command: .key(KeyInput( 37 45 selector: sel, 46 + rawToken: rawToken, 38 47 token: normalized, 39 48 repeatCount: self.repeat 40 49 ))
+35
ProwlCLI/Output/OutputRenderer.swift
··· 65 65 return 66 66 } 67 67 68 + if response.command == "key", 69 + let data = response.data, 70 + let payload = try? data.decode(as: KeyCommandPayload.self) 71 + { 72 + print(renderKey(payload)) 73 + return 74 + } 75 + 68 76 if response.command == "read", 69 77 let data = response.data, 70 78 let payload = try? data.decode(as: ReadCommandPayload.self) ··· 227 235 if let cwd = pane.cwd { 228 236 lines.append(" \("cwd:".dim) \(cwd)") 229 237 } 238 + return lines.joined(separator: "\n") 239 + } 240 + 241 + private static func renderKey(_ payload: KeyCommandPayload) -> String { 242 + let wt = payload.target.worktree 243 + let pane = payload.target.pane 244 + 245 + let projectName = projectName(from: wt.path) 246 + var lines: [String] = [] 247 + 248 + lines.append( 249 + "Key sent to \(projectName.cyan.bold)\(":".dim)\(wt.name) → \(pane.title.green)" 250 + + " \(pane.id.dim)" 251 + ) 252 + 253 + let categoryLabel = payload.key.category.rawValue 254 + let deliveredLabel = 255 + payload.delivery.delivered == payload.delivery.attempted 256 + ? "\(payload.delivery.delivered)".green 257 + : "\(payload.delivery.delivered)".red.bold 258 + lines.append( 259 + " \("token:".dim) \(payload.key.normalized)" 260 + + " \("category:".dim) \(categoryLabel)" 261 + + " \("repeat:".dim) \(payload.requested.repeat)" 262 + + " \("delivered:".dim) \(deliveredLabel)/\(payload.delivery.attempted)" 263 + ) 264 + 230 265 return lines.joined(separator: "\n") 231 266 } 232 267
+367
ProwlCLITests/ProwlCLIIntegrationTests.swift
··· 610 610 XCTAssertEqual(error["code"] as? String, "INVALID_ARGUMENT") 611 611 } 612 612 613 + // MARK: - Key command tests 614 + 615 + func testKeyCommandRoundTripsOverSocket() throws { 616 + let socketPath = temporarySocketPath(suffix: "key") 617 + let response = try CommandResponse( 618 + ok: true, 619 + command: "key", 620 + schemaVersion: "prowl.cli.key.v1", 621 + data: RawJSON(encoding: KeyResponseData( 622 + requested: KeyResponseRequested(token: "enter", repeat: 1), 623 + key: KeyResponseKey(normalized: "enter", category: "editing"), 624 + delivery: KeyResponseDelivery(attempted: 1, delivered: 1, mode: "keyDownUp"), 625 + target: KeyResponseTarget( 626 + worktree: ListWorktree( 627 + id: "Prowl:/Projects/Prowl", name: "Prowl", 628 + path: "/Projects/Prowl", rootPath: "/Projects/Prowl", kind: "git" 629 + ), 630 + tab: KeyResponseTab(id: "2FC00CF0-3974-4E1B-BEF8-7A08A8E3B7C0", title: "Prowl 1", selected: true), 631 + pane: KeyResponsePane( 632 + id: "6E1A2A10-D99F-4E3F-920C-D93AA3C05764", 633 + title: "zsh", cwd: "/Projects/Prowl", focused: true 634 + ) 635 + ) 636 + )) 637 + ) 638 + 639 + let (requestData, result) = try runWithMockServer( 640 + socketPath: socketPath, 641 + response: response, 642 + args: ["key", "enter", "--json"] 643 + ) 644 + 645 + XCTAssertEqual(result.exitCode, 0) 646 + let envelope = try JSONDecoder().decode(CommandEnvelope.self, from: requestData) 647 + if case .key(let input) = envelope.command { 648 + XCTAssertEqual(input.token, "enter") 649 + XCTAssertEqual(input.rawToken, "enter") 650 + XCTAssertEqual(input.repeatCount, 1) 651 + XCTAssertEqual(input.selector, .none) 652 + } else { 653 + XCTFail("Expected key command envelope") 654 + } 655 + 656 + let payload = try jsonObject(from: result.stdout) 657 + XCTAssertEqual(payload["ok"] as? Bool, true) 658 + XCTAssertEqual(payload["command"] as? String, "key") 659 + XCTAssertEqual(payload["schema_version"] as? String, "prowl.cli.key.v1") 660 + let data = try XCTUnwrap(payload["data"] as? [String: Any]) 661 + let key = try XCTUnwrap(data["key"] as? [String: Any]) 662 + XCTAssertEqual(key["normalized"] as? String, "enter") 663 + XCTAssertEqual(key["category"] as? String, "editing") 664 + } 665 + 666 + func testKeyCommandAliasNormalization() throws { 667 + let socketPath = temporarySocketPath(suffix: "key-alias") 668 + let response = CommandResponse( 669 + ok: true, 670 + command: "key", 671 + schemaVersion: "prowl.cli.key.v1" 672 + ) 673 + 674 + let (requestData, _) = try runWithMockServer( 675 + socketPath: socketPath, 676 + response: response, 677 + args: ["key", "return", "--json"] 678 + ) 679 + 680 + let envelope = try JSONDecoder().decode(CommandEnvelope.self, from: requestData) 681 + if case .key(let input) = envelope.command { 682 + XCTAssertEqual(input.token, "enter", "Alias 'return' should normalize to 'enter'") 683 + XCTAssertEqual(input.rawToken, "return", "rawToken should preserve original input") 684 + } else { 685 + XCTFail("Expected key command envelope") 686 + } 687 + } 688 + 689 + func testKeyCommandCtrlAliasNormalization() throws { 690 + let socketPath = temporarySocketPath(suffix: "key-ctrl-alias") 691 + let response = CommandResponse( 692 + ok: true, 693 + command: "key", 694 + schemaVersion: "prowl.cli.key.v1" 695 + ) 696 + 697 + let (requestData, _) = try runWithMockServer( 698 + socketPath: socketPath, 699 + response: response, 700 + args: ["key", "ctrl+c", "--json"] 701 + ) 702 + 703 + let envelope = try JSONDecoder().decode(CommandEnvelope.self, from: requestData) 704 + if case .key(let input) = envelope.command { 705 + XCTAssertEqual(input.token, "ctrl-c", "Alias 'ctrl+c' should normalize to 'ctrl-c'") 706 + XCTAssertEqual(input.rawToken, "ctrl+c") 707 + } else { 708 + XCTFail("Expected key command envelope") 709 + } 710 + } 711 + 712 + func testKeyCommandCaseInsensitive() throws { 713 + let socketPath = temporarySocketPath(suffix: "key-case") 714 + let response = CommandResponse( 715 + ok: true, 716 + command: "key", 717 + schemaVersion: "prowl.cli.key.v1" 718 + ) 719 + 720 + let (requestData, _) = try runWithMockServer( 721 + socketPath: socketPath, 722 + response: response, 723 + args: ["key", "ENTER", "--json"] 724 + ) 725 + 726 + let envelope = try JSONDecoder().decode(CommandEnvelope.self, from: requestData) 727 + if case .key(let input) = envelope.command { 728 + XCTAssertEqual(input.token, "enter", "Token parsing should be case-insensitive") 729 + } else { 730 + XCTFail("Expected key command envelope") 731 + } 732 + } 733 + 734 + func testKeyCommandWithRepeatAndSelector() throws { 735 + let socketPath = temporarySocketPath(suffix: "key-repeat") 736 + let response = CommandResponse( 737 + ok: true, 738 + command: "key", 739 + schemaVersion: "prowl.cli.key.v1" 740 + ) 741 + 742 + let (requestData, result) = try runWithMockServer( 743 + socketPath: socketPath, 744 + response: response, 745 + args: ["key", "--pane", "pane-abc", "up", "--repeat", "5", "--json"] 746 + ) 747 + 748 + XCTAssertEqual(result.exitCode, 0) 749 + let envelope = try JSONDecoder().decode(CommandEnvelope.self, from: requestData) 750 + if case .key(let input) = envelope.command { 751 + XCTAssertEqual(input.token, "up") 752 + XCTAssertEqual(input.repeatCount, 5) 753 + XCTAssertEqual(input.selector, .pane("pane-abc")) 754 + } else { 755 + XCTFail("Expected key command envelope") 756 + } 757 + } 758 + 759 + func testKeyCommandRejectInvalidRepeatZero() throws { 760 + let result = try runProwl(args: ["key", "enter", "--repeat", "0", "--json"]) 761 + XCTAssertNotEqual(result.exitCode, 0) 762 + let payload = try jsonObject(from: result.stdout) 763 + XCTAssertEqual(payload["ok"] as? Bool, false) 764 + XCTAssertEqual(payload["command"] as? String, "key") 765 + let error = try XCTUnwrap(payload["error"] as? [String: Any]) 766 + XCTAssertEqual(error["code"] as? String, CLIErrorCode.invalidRepeat) 767 + } 768 + 769 + func testKeyCommandRejectInvalidRepeatOver100() throws { 770 + let result = try runProwl(args: ["key", "enter", "--repeat", "101", "--json"]) 771 + XCTAssertNotEqual(result.exitCode, 0) 772 + let payload = try jsonObject(from: result.stdout) 773 + XCTAssertEqual(payload["ok"] as? Bool, false) 774 + let error = try XCTUnwrap(payload["error"] as? [String: Any]) 775 + XCTAssertEqual(error["code"] as? String, CLIErrorCode.invalidRepeat) 776 + } 777 + 778 + func testKeyCommandRejectUnsupportedKey() throws { 779 + let result = try runProwl(args: ["key", "ctrl-z", "--json"]) 780 + XCTAssertNotEqual(result.exitCode, 0) 781 + let payload = try jsonObject(from: result.stdout) 782 + XCTAssertEqual(payload["ok"] as? Bool, false) 783 + XCTAssertEqual(payload["command"] as? String, "key") 784 + let error = try XCTUnwrap(payload["error"] as? [String: Any]) 785 + XCTAssertEqual(error["code"] as? String, CLIErrorCode.unsupportedKey) 786 + } 787 + 788 + func testKeyCommandRejectMultipleSelectors() throws { 789 + let result = try runProwl(args: ["key", "enter", "--worktree", "Prowl", "--pane", "pane-123", "--json"]) 790 + XCTAssertNotEqual(result.exitCode, 0) 791 + let payload = try jsonObject(from: result.stdout) 792 + XCTAssertEqual(payload["ok"] as? Bool, false) 793 + XCTAssertEqual(payload["command"] as? String, "key") 794 + let error = try XCTUnwrap(payload["error"] as? [String: Any]) 795 + XCTAssertEqual(error["code"] as? String, CLIErrorCode.invalidArgument) 796 + } 797 + 798 + func testKeyCommandTextRenderingFromSocket() throws { 799 + let socketPath = temporarySocketPath(suffix: "key-text") 800 + let response = try CommandResponse( 801 + ok: true, 802 + command: "key", 803 + schemaVersion: "prowl.cli.key.v1", 804 + data: RawJSON(encoding: KeyResponseData( 805 + requested: KeyResponseRequested(token: "Ctrl+C", repeat: 3), 806 + key: KeyResponseKey(normalized: "ctrl-c", category: "control"), 807 + delivery: KeyResponseDelivery(attempted: 3, delivered: 3, mode: "keyDownUp"), 808 + target: KeyResponseTarget( 809 + worktree: ListWorktree( 810 + id: "Prowl:/Projects/Prowl", name: "Prowl", 811 + path: "/Projects/Prowl", rootPath: "/Projects/Prowl", kind: "git" 812 + ), 813 + tab: KeyResponseTab(id: "t1", title: "Prowl 1", selected: true), 814 + pane: KeyResponsePane(id: "p1", title: "Claude", cwd: "/Projects/Prowl", focused: true) 815 + ) 816 + )) 817 + ) 818 + 819 + let (_, result) = try runWithMockServer( 820 + socketPath: socketPath, 821 + response: response, 822 + args: ["key", "ctrl+c", "--repeat", "3"] 823 + ) 824 + 825 + XCTAssertEqual(result.exitCode, 0) 826 + XCTAssertTrue(result.stdout.contains("Key sent to"), "Missing header: \(result.stdout)") 827 + XCTAssertTrue(result.stdout.contains("Prowl:Prowl"), "Missing worktree: \(result.stdout)") 828 + XCTAssertTrue(result.stdout.contains("Claude"), "Missing pane title: \(result.stdout)") 829 + XCTAssertTrue(result.stdout.contains("token:"), "Missing token label: \(result.stdout)") 830 + XCTAssertTrue(result.stdout.contains("ctrl-c"), "Missing normalized token: \(result.stdout)") 831 + XCTAssertTrue(result.stdout.contains("category:"), "Missing category label: \(result.stdout)") 832 + XCTAssertTrue(result.stdout.contains("control"), "Missing category value: \(result.stdout)") 833 + XCTAssertTrue(result.stdout.contains("repeat:"), "Missing repeat label: \(result.stdout)") 834 + XCTAssertTrue(result.stdout.contains("delivered:"), "Missing delivered label: \(result.stdout)") 835 + } 836 + 837 + func testKeyCommandNoColorProducesCleanOutput() throws { 838 + let socketPath = temporarySocketPath(suffix: "key-no-color") 839 + let response = try CommandResponse( 840 + ok: true, 841 + command: "key", 842 + schemaVersion: "prowl.cli.key.v1", 843 + data: RawJSON(encoding: KeyResponseData( 844 + requested: KeyResponseRequested(token: "esc", repeat: 1), 845 + key: KeyResponseKey(normalized: "esc", category: "control"), 846 + delivery: KeyResponseDelivery(attempted: 1, delivered: 1, mode: "keyDownUp"), 847 + target: KeyResponseTarget( 848 + worktree: ListWorktree( 849 + id: "wt-1", name: "main", 850 + path: "/Projects/App", rootPath: "/Projects/App", kind: "git" 851 + ), 852 + tab: KeyResponseTab(id: "t1", title: "Tab 1", selected: true), 853 + pane: KeyResponsePane(id: "p1", title: "zsh", cwd: "/Projects/App", focused: true) 854 + ) 855 + )) 856 + ) 857 + 858 + let (_, result) = try runWithMockServer( 859 + socketPath: socketPath, 860 + response: response, 861 + args: ["key", "esc", "--no-color"] 862 + ) 863 + 864 + XCTAssertEqual(result.exitCode, 0) 865 + XCTAssertFalse(result.stdout.contains("\u{1B}["), "Should not contain ANSI escape codes: \(result.stdout)") 866 + XCTAssertTrue(result.stdout.contains("Key sent to"), "Missing header: \(result.stdout)") 867 + } 868 + 869 + func testKeyCommandAllCanonicalTokensAccepted() throws { 870 + let canonicalTokens = [ 871 + "enter", "esc", "tab", "backspace", 872 + "up", "down", "left", "right", 873 + "pageup", "pagedown", "home", "end", 874 + "ctrl-c", "ctrl-d", "ctrl-l", 875 + ] 876 + for token in canonicalTokens { 877 + let socketPath = temporarySocketPath(suffix: "key-canonical-\(token)") 878 + let response = CommandResponse( 879 + ok: true, 880 + command: "key", 881 + schemaVersion: "prowl.cli.key.v1" 882 + ) 883 + 884 + let (requestData, result) = try runWithMockServer( 885 + socketPath: socketPath, 886 + response: response, 887 + args: ["key", token, "--json"] 888 + ) 889 + 890 + XCTAssertEqual(result.exitCode, 0, "Token '\(token)' should be accepted") 891 + let envelope = try JSONDecoder().decode(CommandEnvelope.self, from: requestData) 892 + if case .key(let input) = envelope.command { 893 + XCTAssertEqual(input.token, token, "Canonical token '\(token)' should pass through unchanged") 894 + } else { 895 + XCTFail("Expected key command envelope for token '\(token)'") 896 + } 897 + } 898 + } 899 + 900 + func testKeyCommandAllAliasesNormalize() throws { 901 + let aliasMap: [(alias: String, canonical: String)] = [ 902 + ("return", "enter"), 903 + ("escape", "esc"), 904 + ("arrow-up", "up"), 905 + ("arrow-down", "down"), 906 + ("arrow-left", "left"), 907 + ("arrow-right", "right"), 908 + ("pgup", "pageup"), 909 + ("pgdn", "pagedown"), 910 + ("ctrl+c", "ctrl-c"), 911 + ("ctrl+d", "ctrl-d"), 912 + ("ctrl+l", "ctrl-l"), 913 + ] 914 + for (alias, canonical) in aliasMap { 915 + let socketPath = temporarySocketPath(suffix: "key-alias-\(alias)") 916 + let response = CommandResponse( 917 + ok: true, 918 + command: "key", 919 + schemaVersion: "prowl.cli.key.v1" 920 + ) 921 + 922 + let (requestData, _) = try runWithMockServer( 923 + socketPath: socketPath, 924 + response: response, 925 + args: ["key", alias, "--json"] 926 + ) 927 + 928 + let envelope = try JSONDecoder().decode(CommandEnvelope.self, from: requestData) 929 + if case .key(let input) = envelope.command { 930 + XCTAssertEqual(input.token, canonical, "Alias '\(alias)' should normalize to '\(canonical)'") 931 + XCTAssertEqual(input.rawToken, alias, "rawToken should preserve '\(alias)'") 932 + } else { 933 + XCTFail("Expected key command envelope for alias '\(alias)'") 934 + } 935 + } 936 + } 937 + 613 938 // MARK: - Read command tests 614 939 615 940 func testReadCommandRoundTripsOverSocket() throws { ··· 1040 1365 case exitCode = "exit_code" 1041 1366 case durationMs = "duration_ms" 1042 1367 } 1368 + } 1369 + 1370 + private struct KeyResponseData: Encodable { 1371 + let requested: KeyResponseRequested 1372 + let key: KeyResponseKey 1373 + let delivery: KeyResponseDelivery 1374 + let target: KeyResponseTarget 1375 + } 1376 + 1377 + private struct KeyResponseRequested: Encodable { 1378 + let token: String 1379 + let `repeat`: Int 1380 + } 1381 + 1382 + private struct KeyResponseKey: Encodable { 1383 + let normalized: String 1384 + let category: String 1385 + } 1386 + 1387 + private struct KeyResponseDelivery: Encodable { 1388 + let attempted: Int 1389 + let delivered: Int 1390 + let mode: String 1391 + } 1392 + 1393 + private struct KeyResponseTarget: Encodable { 1394 + let worktree: ListWorktree 1395 + let tab: KeyResponseTab 1396 + let pane: KeyResponsePane 1397 + } 1398 + 1399 + private struct KeyResponseTab: Encodable { 1400 + let id: String 1401 + let title: String 1402 + let selected: Bool 1403 + } 1404 + 1405 + private struct KeyResponsePane: Encodable { 1406 + let id: String 1407 + let title: String 1408 + let cwd: String? 1409 + let focused: Bool 1043 1410 } 1044 1411 1045 1412 private struct ReadResponseData: Encodable {
+34
supacode/App/supacodeApp.swift
··· 192 192 ) 193 193 } 194 194 195 + private static func makeTargetResolver( 196 + appStore: StoreOf<AppFeature>, 197 + terminalManager: WorktreeTerminalManager 198 + ) -> TargetResolver { 199 + TargetResolver { 200 + TargetResolutionSnapshotBuilder.makeSnapshot( 201 + repositoriesState: appStore.state.repositories, 202 + terminalManager: terminalManager 203 + ) 204 + } 205 + } 206 + 207 + // swiftlint:disable:next function_body_length 195 208 private static func makeCLISocketServer( 196 209 appStore: StoreOf<AppFeature>, 197 210 terminalManager: WorktreeTerminalManager ··· 277 290 ) 278 291 } 279 292 ) 293 + let keyHandler = KeyCommandHandler( 294 + resolveProvider: { selector in 295 + let resolver = TargetResolver { 296 + TargetResolutionSnapshotBuilder.makeSnapshot( 297 + repositoriesState: appStore.state.repositories, 298 + terminalManager: terminalManager 299 + ) 300 + } 301 + return resolver.resolve(selector).map { KeyResolvedTarget(from: $0) } 302 + }, 303 + keyDelivery: { target, token, repeatCount in 304 + guard let state = terminalManager.stateIfExists(for: target.worktreeID) else { 305 + return KeyDeliveryResult(attempted: repeatCount, delivered: 0) 306 + } 307 + let delivered = (0..<repeatCount).count { _ in 308 + state.sendKeyToken(token, in: target.paneID) 309 + } 310 + return KeyDeliveryResult(attempted: repeatCount, delivered: delivered) 311 + } 312 + ) 280 313 let cliRouter = CLICommandRouter( 281 314 listHandler: listHandler, 282 315 focusHandler: focusHandler, 283 316 sendHandler: sendHandler, 317 + keyHandler: keyHandler, 284 318 readHandler: readHandler 285 319 ) 286 320 let cliServer = CLISocketServer(router: cliRouter)
+165
supacode/CLIService/KeyCommandHandler.swift
··· 1 + // supacode/CLIService/KeyCommandHandler.swift 2 + // Handles `prowl key` by resolving target, delivering key events, and building response. 3 + 4 + import Foundation 5 + 6 + private let keyLogger = SupaLogger("KeyCommandHandler") 7 + 8 + /// Result of delivering key events to a terminal pane. 9 + struct KeyDeliveryResult: Sendable { 10 + let attempted: Int 11 + let delivered: Int 12 + } 13 + 14 + /// Resolved target metadata for key payload construction (no live view reference). 15 + struct KeyResolvedTarget: Sendable { 16 + let worktreeID: String 17 + let worktreeName: String 18 + let worktreePath: String 19 + let worktreeRootPath: String 20 + let worktreeKind: ListCommandWorktree.Kind 21 + let tabID: UUID 22 + let tabTitle: String 23 + let tabSelected: Bool 24 + let paneID: UUID 25 + let paneTitle: String 26 + let paneCWD: String? 27 + let paneFocused: Bool 28 + } 29 + 30 + extension KeyResolvedTarget { 31 + init(from resolved: ResolvedTarget) { 32 + self.worktreeID = resolved.worktreeID 33 + self.worktreeName = resolved.worktreeName 34 + self.worktreePath = resolved.worktreePath 35 + self.worktreeRootPath = resolved.worktreeRootPath 36 + self.worktreeKind = resolved.worktreeKind 37 + self.tabID = resolved.tabID 38 + self.tabTitle = resolved.tabTitle 39 + self.tabSelected = resolved.tabSelected 40 + self.paneID = resolved.paneID 41 + self.paneTitle = resolved.paneTitle 42 + self.paneCWD = resolved.paneCWD 43 + self.paneFocused = resolved.paneFocused 44 + } 45 + } 46 + 47 + @MainActor 48 + final class KeyCommandHandler: CommandHandler { 49 + typealias ResolveProvider = @MainActor (TargetSelector) -> Result<KeyResolvedTarget, TargetResolverError> 50 + typealias KeyDeliveryProvider = @MainActor (KeyResolvedTarget, String, Int) -> KeyDeliveryResult 51 + 52 + private let resolveProvider: ResolveProvider 53 + private let keyDelivery: KeyDeliveryProvider 54 + 55 + init( 56 + resolveProvider: @escaping ResolveProvider, 57 + keyDelivery: @escaping KeyDeliveryProvider 58 + ) { 59 + self.resolveProvider = resolveProvider 60 + self.keyDelivery = keyDelivery 61 + } 62 + 63 + // swiftlint:disable:next async_without_await 64 + func handle(envelope: CommandEnvelope) async -> CommandResponse { 65 + guard case .key(let input) = envelope.command else { 66 + return errorResponse(code: CLIErrorCode.keyDeliveryFailed, message: "Invalid command.") 67 + } 68 + 69 + // Validate token is supported 70 + guard let category = KeyTokens.category(for: input.token) else { 71 + return errorResponse( 72 + code: CLIErrorCode.unsupportedKey, 73 + message: "The key token '\(input.token)' is not supported in v1." 74 + ) 75 + } 76 + 77 + // Resolve target 78 + let target: KeyResolvedTarget 79 + switch resolveProvider(input.selector) { 80 + case .success(let resolved): 81 + target = resolved 82 + case .failure(let error): 83 + if input.selector == .none, case .notFound = error { 84 + return errorResponse( 85 + code: CLIErrorCode.noActivePane, 86 + message: "No active pane to receive key events." 87 + ) 88 + } 89 + return mapResolverError(error) 90 + } 91 + 92 + // Deliver key events 93 + let result = keyDelivery(target, input.token, input.repeatCount) 94 + 95 + guard result.delivered == result.attempted else { 96 + return errorResponse( 97 + code: CLIErrorCode.keyDeliveryFailed, 98 + message: "Key delivery failed: \(result.delivered)/\(result.attempted) events delivered." 99 + ) 100 + } 101 + 102 + // Build payload 103 + let payload = KeyCommandPayload( 104 + requested: KeyRequested(token: input.rawToken, repeat: input.repeatCount), 105 + key: KeyInfo(normalized: input.token, category: category), 106 + delivery: KeyDelivery(attempted: result.attempted, delivered: result.delivered), 107 + target: makePayloadTarget(from: target) 108 + ) 109 + 110 + do { 111 + return try CommandResponse( 112 + ok: true, 113 + command: "key", 114 + schemaVersion: "prowl.cli.key.v1", 115 + data: RawJSON(encoding: payload) 116 + ) 117 + } catch { 118 + keyLogger.warning("Failed to encode key payload: \(error)") 119 + return errorResponse(code: CLIErrorCode.keyDeliveryFailed, message: "Failed to encode response.") 120 + } 121 + } 122 + 123 + // MARK: - Helpers 124 + 125 + private func makePayloadTarget(from target: KeyResolvedTarget) -> KeyTarget { 126 + KeyTarget( 127 + worktree: KeyTargetWorktree( 128 + id: target.worktreeID, 129 + name: target.worktreeName, 130 + path: target.worktreePath, 131 + rootPath: target.worktreeRootPath, 132 + kind: target.worktreeKind.rawValue 133 + ), 134 + tab: KeyTargetTab( 135 + id: target.tabID.uuidString, 136 + title: target.tabTitle, 137 + selected: target.tabSelected 138 + ), 139 + pane: KeyTargetPane( 140 + id: target.paneID.uuidString, 141 + title: target.paneTitle, 142 + cwd: target.paneCWD, 143 + focused: target.paneFocused 144 + ) 145 + ) 146 + } 147 + 148 + private func mapResolverError(_ error: TargetResolverError) -> CommandResponse { 149 + switch error { 150 + case .notFound(let message): 151 + return errorResponse(code: CLIErrorCode.targetNotFound, message: message) 152 + case .notUnique(let message): 153 + return errorResponse(code: CLIErrorCode.targetNotUnique, message: message) 154 + } 155 + } 156 + 157 + private func errorResponse(code: String, message: String) -> CommandResponse { 158 + CommandResponse( 159 + ok: false, 160 + command: "key", 161 + schemaVersion: "prowl.cli.key.v1", 162 + error: CommandError(code: code, message: message) 163 + ) 164 + } 165 + }
+12
supacode/CLIService/Shared/InputModels.swift
··· 56 56 57 57 public struct KeyInput: Codable, Sendable { 58 58 public let selector: TargetSelector 59 + /// The user's original token after trimming (for `requested.token` in response). 60 + public let rawToken: String 61 + /// The canonical normalized token (for execution and `key.normalized` in response). 59 62 public let token: String 60 63 public let repeatCount: Int 61 64 65 + enum CodingKeys: String, CodingKey { 66 + case selector 67 + case rawToken = "raw_token" 68 + case token 69 + case repeatCount = "repeat_count" 70 + } 71 + 62 72 public init( 63 73 selector: TargetSelector = .none, 74 + rawToken: String, 64 75 token: String, 65 76 repeatCount: Int = 1 66 77 ) { 67 78 self.selector = selector 79 + self.rawToken = rawToken 68 80 self.token = token 69 81 self.repeatCount = repeatCount 70 82 }
+195
supacode/CLIService/Shared/KeyCommandPayload.swift
··· 1 + // ProwlShared/KeyCommandPayload.swift 2 + // Success payload for `prowl key --json` matching key.md contract. 3 + 4 + import Foundation 5 + 6 + public struct KeyCommandPayload: Codable, Sendable { 7 + public let requested: KeyRequested 8 + public let key: KeyInfo 9 + public let delivery: KeyDelivery 10 + public let target: KeyTarget 11 + 12 + public init( 13 + requested: KeyRequested, 14 + key: KeyInfo, 15 + delivery: KeyDelivery, 16 + target: KeyTarget 17 + ) { 18 + self.requested = requested 19 + self.key = key 20 + self.delivery = delivery 21 + self.target = target 22 + } 23 + } 24 + 25 + public struct KeyRequested: Codable, Sendable { 26 + public let token: String 27 + public let `repeat`: Int 28 + 29 + public init(token: String, repeat: Int) { 30 + self.token = token 31 + self.repeat = `repeat` 32 + } 33 + } 34 + 35 + public struct KeyInfo: Codable, Sendable { 36 + public let normalized: String 37 + public let category: KeyCategory 38 + 39 + public init(normalized: String, category: KeyCategory) { 40 + self.normalized = normalized 41 + self.category = category 42 + } 43 + } 44 + 45 + public enum KeyCategory: String, Codable, Sendable { 46 + case navigation 47 + case editing 48 + case control 49 + } 50 + 51 + public struct KeyDelivery: Codable, Sendable { 52 + public let attempted: Int 53 + public let delivered: Int 54 + public let mode: String 55 + 56 + public init(attempted: Int, delivered: Int, mode: String = "keyDownUp") { 57 + self.attempted = attempted 58 + self.delivered = delivered 59 + self.mode = mode 60 + } 61 + } 62 + 63 + public struct KeyTarget: Codable, Sendable { 64 + public let worktree: KeyTargetWorktree 65 + public let tab: KeyTargetTab 66 + public let pane: KeyTargetPane 67 + 68 + public init(worktree: KeyTargetWorktree, tab: KeyTargetTab, pane: KeyTargetPane) { 69 + self.worktree = worktree 70 + self.tab = tab 71 + self.pane = pane 72 + } 73 + } 74 + 75 + public struct KeyTargetWorktree: Codable, Sendable { 76 + public let id: String 77 + public let name: String 78 + public let path: String 79 + public let rootPath: String 80 + public let kind: String 81 + 82 + enum CodingKeys: String, CodingKey { 83 + case id 84 + case name 85 + case path 86 + case rootPath = "root_path" 87 + case kind 88 + } 89 + 90 + public init(id: String, name: String, path: String, rootPath: String, kind: String) { 91 + self.id = id 92 + self.name = name 93 + self.path = path 94 + self.rootPath = rootPath 95 + self.kind = kind 96 + } 97 + } 98 + 99 + public struct KeyTargetTab: Codable, Sendable { 100 + public let id: String 101 + public let title: String 102 + public let selected: Bool 103 + 104 + public init(id: String, title: String, selected: Bool) { 105 + self.id = id 106 + self.title = title 107 + self.selected = selected 108 + } 109 + } 110 + 111 + public struct KeyTargetPane: Codable, Sendable { 112 + public let id: String 113 + public let title: String 114 + public let cwd: String? 115 + public let focused: Bool 116 + 117 + public init(id: String, title: String, cwd: String?, focused: Bool) { 118 + self.id = id 119 + self.title = title 120 + self.cwd = cwd 121 + self.focused = focused 122 + } 123 + } 124 + 125 + // MARK: - Token Normalization 126 + 127 + /// Shared token definitions for the `key` command. 128 + /// Used by CLI for validation and by app for response building. 129 + 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", 162 + ] 163 + 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, 181 + ] 182 + 183 + /// Normalize a user-provided token string to its canonical form. 184 + /// Returns `nil` if the token is not recognized. 185 + 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 189 + } 190 + 191 + /// Get the category for a canonical token. 192 + public static func category(for canonical: String) -> KeyCategory? { 193 + categories[canonical] 194 + } 195 + }
+6
supacode/Features/Terminal/Models/WorktreeTerminalState.swift
··· 123 123 return surface.submitLine() 124 124 } 125 125 126 + @discardableResult 127 + func sendKeyToken(_ token: String, in surfaceID: UUID) -> Bool { 128 + guard let surface = surfaceView(for: surfaceID) else { return false } 129 + return surface.sendCLIKeyToken(token) 130 + } 131 + 126 132 var taskStatus: WorktreeTaskStatus { 127 133 tabIsRunningById.values.contains(true) ? .running : .idle 128 134 }
+107
supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift
··· 1780 1780 keyUp(with: keyUpEvent) 1781 1781 return true 1782 1782 } 1783 + 1784 + // MARK: - CLI key token delivery 1785 + 1786 + /// Send a single key event for a canonical CLI token (e.g. "enter", "ctrl-c", "pageup"). 1787 + /// Creates synthetic keyDown/keyUp NSEvents matching the token spec. 1788 + @discardableResult 1789 + func sendCLIKeyToken(_ token: String) -> Bool { 1790 + guard surface != nil else { return false } 1791 + guard let spec = CLIKeySpec.from(token: token) else { return false } 1792 + let timestamp = ProcessInfo.processInfo.systemUptime 1793 + let windowNumber = window?.windowNumber ?? 0 1794 + guard 1795 + let keyDownEvent = NSEvent.keyEvent( 1796 + with: .keyDown, 1797 + location: .zero, 1798 + modifierFlags: spec.modifiers, 1799 + timestamp: timestamp, 1800 + windowNumber: windowNumber, 1801 + context: nil, 1802 + characters: spec.characters, 1803 + charactersIgnoringModifiers: spec.charactersIgnoringModifiers, 1804 + isARepeat: false, 1805 + keyCode: spec.keyCode 1806 + ), 1807 + let keyUpEvent = NSEvent.keyEvent( 1808 + with: .keyUp, 1809 + location: .zero, 1810 + modifierFlags: spec.modifiers, 1811 + timestamp: timestamp, 1812 + windowNumber: windowNumber, 1813 + context: nil, 1814 + characters: spec.characters, 1815 + charactersIgnoringModifiers: spec.charactersIgnoringModifiers, 1816 + isARepeat: false, 1817 + keyCode: spec.keyCode 1818 + ) 1819 + else { 1820 + return false 1821 + } 1822 + keyDown(with: keyDownEvent) 1823 + keyUp(with: keyUpEvent) 1824 + return true 1825 + } 1826 + } 1827 + 1828 + // MARK: - CLI Key Spec 1829 + 1830 + /// Maps canonical CLI key tokens to macOS NSEvent parameters. 1831 + struct CLIKeySpec { 1832 + let keyCode: UInt16 1833 + let characters: String 1834 + let charactersIgnoringModifiers: String 1835 + let modifiers: NSEvent.ModifierFlags 1836 + 1837 + // swiftlint:disable:next cyclomatic_complexity 1838 + static func from(token: String) -> CLIKeySpec? { 1839 + switch token { 1840 + case "enter": 1841 + return CLIKeySpec(keyCode: 36, characters: "\r", charactersIgnoringModifiers: "\r", modifiers: []) 1842 + case "esc": 1843 + return CLIKeySpec(keyCode: 53, characters: "\u{1B}", charactersIgnoringModifiers: "\u{1B}", modifiers: []) 1844 + case "tab": 1845 + return CLIKeySpec(keyCode: 48, characters: "\t", charactersIgnoringModifiers: "\t", modifiers: []) 1846 + case "backspace": 1847 + return CLIKeySpec(keyCode: 51, characters: "\u{7F}", charactersIgnoringModifiers: "\u{7F}", modifiers: []) 1848 + case "up": 1849 + return CLIKeySpec( 1850 + keyCode: 126, characters: "\u{F700}", charactersIgnoringModifiers: "\u{F700}", modifiers: [.function] 1851 + ) 1852 + case "down": 1853 + return CLIKeySpec( 1854 + keyCode: 125, characters: "\u{F701}", charactersIgnoringModifiers: "\u{F701}", modifiers: [.function] 1855 + ) 1856 + case "left": 1857 + return CLIKeySpec( 1858 + keyCode: 123, characters: "\u{F702}", charactersIgnoringModifiers: "\u{F702}", modifiers: [.function] 1859 + ) 1860 + case "right": 1861 + return CLIKeySpec( 1862 + keyCode: 124, characters: "\u{F703}", charactersIgnoringModifiers: "\u{F703}", modifiers: [.function] 1863 + ) 1864 + case "pageup": 1865 + return CLIKeySpec( 1866 + keyCode: 116, characters: "\u{F72C}", charactersIgnoringModifiers: "\u{F72C}", modifiers: [.function] 1867 + ) 1868 + case "pagedown": 1869 + return CLIKeySpec( 1870 + keyCode: 121, characters: "\u{F72D}", charactersIgnoringModifiers: "\u{F72D}", modifiers: [.function] 1871 + ) 1872 + case "home": 1873 + return CLIKeySpec( 1874 + keyCode: 115, characters: "\u{F729}", charactersIgnoringModifiers: "\u{F729}", modifiers: [.function] 1875 + ) 1876 + case "end": 1877 + return CLIKeySpec( 1878 + keyCode: 119, characters: "\u{F72B}", charactersIgnoringModifiers: "\u{F72B}", modifiers: [.function] 1879 + ) 1880 + case "ctrl-c": 1881 + return CLIKeySpec(keyCode: 8, characters: "\u{3}", charactersIgnoringModifiers: "c", modifiers: [.control]) 1882 + case "ctrl-d": 1883 + return CLIKeySpec(keyCode: 2, characters: "\u{4}", charactersIgnoringModifiers: "d", modifiers: [.control]) 1884 + case "ctrl-l": 1885 + return CLIKeySpec(keyCode: 37, characters: "\u{C}", charactersIgnoringModifiers: "l", modifiers: [.control]) 1886 + default: 1887 + return nil 1888 + } 1889 + } 1783 1890 } 1784 1891 1785 1892 extension GhosttySurfaceView: NSServicesMenuRequestor {
+3 -2
supacodeTests/CLICommandEnvelopeTests.swift
··· 83 83 command: .key( 84 84 KeyInput( 85 85 selector: .tab("tab-1"), 86 + rawToken: "enter", 86 87 token: "enter", 87 88 repeatCount: 5 88 89 )) ··· 135 136 (.list(ListInput()), "list"), 136 137 (.focus(FocusInput()), "focus"), 137 138 (.send(SendInput(text: "x")), "send"), 138 - (.key(KeyInput(token: "tab")), "key"), 139 + (.key(KeyInput(rawToken: "tab", token: "tab")), "key"), 139 140 (.read(ReadInput()), "read"), 140 141 ] 141 142 for (command, expected) in commands { ··· 151 152 .list(ListInput()), 152 153 .focus(FocusInput()), 153 154 .send(SendInput(text: "test")), 154 - .key(KeyInput(token: "enter")), 155 + .key(KeyInput(rawToken: "enter", token: "enter")), 155 156 .read(ReadInput()), 156 157 ] 157 158 for cmd in commands {
+2 -2
supacodeTests/CLICommandRouterTests.swift
··· 60 60 let router = CLICommandRouter() 61 61 let envelope = CommandEnvelope( 62 62 output: .json, 63 - command: .key(KeyInput(token: "enter", repeatCount: 3)) 63 + command: .key(KeyInput(rawToken: "enter", token: "enter", repeatCount: 3)) 64 64 ) 65 65 let response = await router.route(envelope) 66 66 #expect(response.command == "key") ··· 104 104 .list(ListInput()), 105 105 .focus(FocusInput()), 106 106 .send(SendInput(text: "x")), 107 - .key(KeyInput(token: "tab")), 107 + .key(KeyInput(rawToken: "tab", token: "tab")), 108 108 .read(ReadInput()), 109 109 ] 110 110 let router = CLICommandRouter()
+247
supacodeTests/CLIKeyCommandHandlerTests.swift
··· 1 + import Foundation 2 + import Testing 3 + 4 + @testable import supacode 5 + 6 + @MainActor 7 + struct CLIKeyCommandHandlerTests { 8 + 9 + // MARK: - Helpers 10 + 11 + private static let testPaneID = UUID(uuidString: "6E1A2A10-D99F-4E3F-920C-D93AA3C05764")! 12 + private static let testTabID = UUID(uuidString: "2FC00CF0-3974-4E1B-BEF8-7A08A8E3B7C0")! 13 + 14 + private static func makeTarget() -> KeyResolvedTarget { 15 + KeyResolvedTarget( 16 + worktreeID: "Prowl:/Users/onevcat/Projects/Prowl", 17 + worktreeName: "Prowl", 18 + worktreePath: "/Users/onevcat/Projects/Prowl", 19 + worktreeRootPath: "/Users/onevcat/Projects/Prowl", 20 + worktreeKind: .git, 21 + tabID: testTabID, 22 + tabTitle: "Prowl 1", 23 + tabSelected: true, 24 + paneID: testPaneID, 25 + paneTitle: "zsh", 26 + paneCWD: "/Users/onevcat/Projects/Prowl", 27 + paneFocused: true 28 + ) 29 + } 30 + 31 + private static func makeHandler( 32 + resolveResult: Result<KeyResolvedTarget, TargetResolverError> = .success(makeTarget()), 33 + deliverySuccess: Bool = true, 34 + keyDelivery: (@MainActor (KeyResolvedTarget, String, Int) -> KeyDeliveryResult)? = nil 35 + ) -> KeyCommandHandler { 36 + KeyCommandHandler( 37 + resolveProvider: { _ in resolveResult }, 38 + keyDelivery: keyDelivery ?? { _, _, repeatCount in 39 + KeyDeliveryResult( 40 + attempted: repeatCount, 41 + delivered: deliverySuccess ? repeatCount : 0 42 + ) 43 + } 44 + ) 45 + } 46 + 47 + private static func makeEnvelope( 48 + rawToken: String = "enter", 49 + token: String = "enter", 50 + repeatCount: Int = 1, 51 + selector: TargetSelector = .none 52 + ) -> CommandEnvelope { 53 + CommandEnvelope( 54 + output: .json, 55 + command: .key( 56 + KeyInput( 57 + selector: selector, 58 + rawToken: rawToken, 59 + token: token, 60 + repeatCount: repeatCount 61 + )) 62 + ) 63 + } 64 + 65 + // MARK: - Success tests 66 + 67 + @Test func successfulKeyDelivery() async throws { 68 + let handler = Self.makeHandler() 69 + let response = await handler.handle(envelope: Self.makeEnvelope()) 70 + 71 + #expect(response.ok) 72 + #expect(response.command == "key") 73 + #expect(response.schemaVersion == "prowl.cli.key.v1") 74 + 75 + let payload = try #require(try response.data?.decode(as: KeyCommandPayload.self)) 76 + #expect(payload.requested.token == "enter") 77 + #expect(payload.requested.repeat == 1) 78 + #expect(payload.key.normalized == "enter") 79 + #expect(payload.key.category == .editing) 80 + #expect(payload.delivery.attempted == 1) 81 + #expect(payload.delivery.delivered == 1) 82 + #expect(payload.delivery.mode == "keyDownUp") 83 + #expect(payload.target.worktree.id == "Prowl:/Users/onevcat/Projects/Prowl") 84 + #expect(payload.target.worktree.kind == "git") 85 + #expect(payload.target.tab.id == Self.testTabID.uuidString) 86 + #expect(payload.target.tab.selected == true) 87 + #expect(payload.target.pane.id == Self.testPaneID.uuidString) 88 + #expect(payload.target.pane.focused == true) 89 + } 90 + 91 + @Test func repeatCountPassesThroughToDelivery() async throws { 92 + var deliveredRepeatCount: Int? 93 + let handler = Self.makeHandler( 94 + keyDelivery: { _, _, repeatCount in 95 + deliveredRepeatCount = repeatCount 96 + return KeyDeliveryResult(attempted: repeatCount, delivered: repeatCount) 97 + } 98 + ) 99 + let response = await handler.handle( 100 + envelope: Self.makeEnvelope(repeatCount: 5) 101 + ) 102 + 103 + #expect(response.ok) 104 + #expect(deliveredRepeatCount == 5) 105 + let payload = try #require(try response.data?.decode(as: KeyCommandPayload.self)) 106 + #expect(payload.requested.repeat == 5) 107 + #expect(payload.delivery.attempted == 5) 108 + #expect(payload.delivery.delivered == 5) 109 + } 110 + 111 + @Test func rawTokenPreservedInResponse() async throws { 112 + let handler = Self.makeHandler() 113 + let response = await handler.handle( 114 + envelope: Self.makeEnvelope(rawToken: "Return", token: "enter") 115 + ) 116 + 117 + #expect(response.ok) 118 + let payload = try #require(try response.data?.decode(as: KeyCommandPayload.self)) 119 + #expect(payload.requested.token == "Return") 120 + #expect(payload.key.normalized == "enter") 121 + } 122 + 123 + @Test func deliveryReceivesCorrectTokenAndTarget() async throws { 124 + var deliveredToken: String? 125 + var deliveredTarget: KeyResolvedTarget? 126 + let handler = Self.makeHandler( 127 + keyDelivery: { target, token, repeatCount in 128 + deliveredTarget = target 129 + deliveredToken = token 130 + return KeyDeliveryResult(attempted: repeatCount, delivered: repeatCount) 131 + } 132 + ) 133 + _ = await handler.handle( 134 + envelope: Self.makeEnvelope(token: "ctrl-c") 135 + ) 136 + 137 + #expect(deliveredToken == "ctrl-c") 138 + #expect(deliveredTarget?.paneID == Self.testPaneID) 139 + } 140 + 141 + // MARK: - Category tests 142 + 143 + @Test func navigationCategory() async throws { 144 + let handler = Self.makeHandler() 145 + let response = await handler.handle( 146 + envelope: Self.makeEnvelope(token: "up") 147 + ) 148 + 149 + #expect(response.ok) 150 + let payload = try #require(try response.data?.decode(as: KeyCommandPayload.self)) 151 + #expect(payload.key.category == .navigation) 152 + } 153 + 154 + @Test func controlCategory() async throws { 155 + let handler = Self.makeHandler() 156 + let response = await handler.handle( 157 + envelope: Self.makeEnvelope(token: "ctrl-c") 158 + ) 159 + 160 + #expect(response.ok) 161 + let payload = try #require(try response.data?.decode(as: KeyCommandPayload.self)) 162 + #expect(payload.key.category == .control) 163 + } 164 + 165 + @Test func editingCategory() async throws { 166 + let handler = Self.makeHandler() 167 + let response = await handler.handle( 168 + envelope: Self.makeEnvelope(token: "backspace") 169 + ) 170 + 171 + #expect(response.ok) 172 + let payload = try #require(try response.data?.decode(as: KeyCommandPayload.self)) 173 + #expect(payload.key.category == .editing) 174 + } 175 + 176 + // MARK: - Error tests 177 + 178 + @Test func deliveryFailureReturnsError() async throws { 179 + let handler = Self.makeHandler(deliverySuccess: false) 180 + let response = await handler.handle( 181 + envelope: Self.makeEnvelope(repeatCount: 3) 182 + ) 183 + 184 + #expect(response.ok == false) 185 + #expect(response.error?.code == CLIErrorCode.keyDeliveryFailed) 186 + } 187 + 188 + @Test func partialDeliveryReturnsError() async throws { 189 + let handler = Self.makeHandler( 190 + keyDelivery: { _, _, repeatCount in 191 + KeyDeliveryResult(attempted: repeatCount, delivered: repeatCount - 1) 192 + } 193 + ) 194 + let response = await handler.handle( 195 + envelope: Self.makeEnvelope(repeatCount: 3) 196 + ) 197 + 198 + #expect(response.ok == false) 199 + #expect(response.error?.code == CLIErrorCode.keyDeliveryFailed) 200 + } 201 + 202 + @Test func noActivePaneWhenNoSelector() async throws { 203 + let handler = Self.makeHandler( 204 + resolveResult: .failure(.notFound("No focused pane in selected tab.")) 205 + ) 206 + let response = await handler.handle( 207 + envelope: Self.makeEnvelope(selector: .none) 208 + ) 209 + 210 + #expect(response.ok == false) 211 + #expect(response.error?.code == CLIErrorCode.noActivePane) 212 + } 213 + 214 + @Test func targetNotFoundWithExplicitSelector() async throws { 215 + let handler = Self.makeHandler( 216 + resolveResult: .failure(.notFound("Worktree 'missing' not found.")) 217 + ) 218 + let response = await handler.handle( 219 + envelope: Self.makeEnvelope(selector: .worktree("missing")) 220 + ) 221 + 222 + #expect(response.ok == false) 223 + #expect(response.error?.code == CLIErrorCode.targetNotFound) 224 + } 225 + 226 + @Test func targetNotUniqueError() async throws { 227 + let handler = Self.makeHandler( 228 + resolveResult: .failure(.notUnique("Worktree 'Prowl' matches 2 worktrees.")) 229 + ) 230 + let response = await handler.handle( 231 + envelope: Self.makeEnvelope(selector: .worktree("Prowl")) 232 + ) 233 + 234 + #expect(response.ok == false) 235 + #expect(response.error?.code == CLIErrorCode.targetNotUnique) 236 + } 237 + 238 + @Test func unsupportedKeyReturnsError() async throws { 239 + let handler = Self.makeHandler() 240 + let response = await handler.handle( 241 + envelope: Self.makeEnvelope(token: "ctrl-z") 242 + ) 243 + 244 + #expect(response.ok == false) 245 + #expect(response.error?.code == CLIErrorCode.unsupportedKey) 246 + } 247 + }
+1 -1
supacodeTests/CLITransportProtocolTests.swift
··· 104 104 .focus(FocusInput(selector: .worktree("wt"))), 105 105 .focus(FocusInput(selector: .none)), 106 106 .send(SendInput(selector: .tab("t1"), text: "cmd", trailingEnter: true)), 107 - .key(KeyInput(selector: .pane("p1"), token: "ctrl-c", repeatCount: 100)), 107 + .key(KeyInput(selector: .pane("p1"), rawToken: "Ctrl+C", token: "ctrl-c", repeatCount: 100)), 108 108 .read(ReadInput(selector: .none, last: nil)), 109 109 .read(ReadInput(selector: .worktree("w"), last: 1)), 110 110 ]