native macOS codings agent orchestrator
6
fork

Configure Feed

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

Merge main into PR #133 and harden open path quoting

onevclaw e4f4caf1 0d25f06d

+2213 -22
+9 -1
.github/actions/setup-macos/action.yml
··· 32 32 Frameworks/GhosttyKit.xcframework 33 33 Resources/ghostty 34 34 Resources/terminfo 35 - key: ${{ runner.os }}-${{ runner.arch }}-ghostty-${{ env.GHOSTTY_SHA }} 35 + .ghostty_hash 36 + .ghostty_build_stamp 37 + key: ${{ runner.os }}-${{ runner.arch }}-ghostty-v1-${{ env.GHOSTTY_SHA }} 36 38 - name: Build ghostty 37 39 if: steps.ghostty_cache.outputs.cache-hit != 'true' 38 40 shell: bash 39 41 run: | 40 42 set -euo pipefail 41 43 make build-ghostty-xcframework 44 + - name: Sync ghostty marker files 45 + shell: bash 46 + run: | 47 + set -euo pipefail 48 + printf '%s\n' "$GHOSTTY_SHA" > .ghostty_hash 49 + touch .ghostty_build_stamp 42 50 43 51 - name: SPM cache 44 52 uses: actions/cache@v4
+8
.github/workflows/test.yml
··· 23 23 - run: make test 24 24 - run: make test-cli-smoke 25 25 - run: make test-cli-integration 26 + - name: Upload xcresult bundle (on failure) 27 + if: failure() 28 + uses: actions/upload-artifact@v4 29 + with: 30 + name: xcresult-${{ github.run_id }}-${{ github.run_attempt }} 31 + path: build/test-results/**/*.xcresult 32 + if-no-files-found: ignore 33 + retention-days: 7
+2
.gitignore
··· 65 65 Frameworks/GhosttyKit.xcframework 66 66 Resources/ghostty 67 67 Resources/terminfo 68 + .ghostty_hash 69 + .ghostty_build_stamp 68 70 *.profraw 69 71 .env 70 72 build/
+60 -6
Makefile
··· 13 13 GHOSTTY_RESOURCE_PATH := $(CURRENT_MAKEFILE_DIR)/Resources/ghostty 14 14 GHOSTTY_TERMINFO_PATH := $(CURRENT_MAKEFILE_DIR)/Resources/terminfo 15 15 GHOSTTY_BUILD_OUTPUTS := $(GHOSTTY_XCFRAMEWORK_PATH) $(GHOSTTY_RESOURCE_PATH) $(GHOSTTY_TERMINFO_PATH) 16 + GHOSTTY_BUILD_STAMP := $(CURRENT_MAKEFILE_DIR)/.ghostty_build_stamp 17 + GHOSTTY_HASH_FILE := $(CURRENT_MAKEFILE_DIR)/.ghostty_hash 16 18 SPM_CACHE_DIR := /tmp/supacode-spm-cache/SourcePackages 17 19 VERSION ?= 18 20 BUILD ?= 19 21 XCODEBUILD_FLAGS ?= 20 22 .DEFAULT_GOAL := help 21 - .PHONY: build-ghostty-xcframework build-app build-cli run-app install-dev-build install-release archive export-archive format lint check test test-cli-smoke test-cli-integration bump-version bump-and-release log-stream 23 + .PHONY: build-ghostty-xcframework ensure-ghostty sync-ghostty _check-ghostty-hash _record-ghostty-hash build-app build-cli run-app install-dev-build install-release archive export-archive format lint check test test-cli-smoke test-cli-integration bump-version bump-and-release log-stream 22 24 23 25 help: # Display this help. 24 26 @-+echo "Run make with one of the following targets:" 25 27 @-+echo 26 28 @-+grep -Eh "^[a-z-]+:.*#" $(CURRENT_MAKEFILE_PATH) | sed -E 's/^(.*:)(.*#+)(.*)/ \1 @@@ \3 /' | column -t -s "@@@" 27 29 28 - build-ghostty-xcframework: $(GHOSTTY_BUILD_OUTPUTS) # Build ghostty framework 30 + build-ghostty-xcframework: $(GHOSTTY_BUILD_STAMP) # Build ghostty framework 31 + @$(MAKE) _record-ghostty-hash 29 32 30 - $(GHOSTTY_BUILD_OUTPUTS): 33 + # Internal: actually rebuild ghostty. 34 + $(GHOSTTY_BUILD_STAMP): 31 35 @cd $(CURRENT_MAKEFILE_DIR)/ThirdParty/ghostty && mise exec -- zig build -Doptimize=ReleaseFast -Demit-xcframework=true -Dsentry=false 32 36 rsync -a ThirdParty/ghostty/macos/GhosttyKit.xcframework Frameworks 33 37 @src="$(CURRENT_MAKEFILE_DIR)/ThirdParty/ghostty/zig-out/share/ghostty"; \ ··· 38 42 rsync -a --delete "$$src/" "$$dst/"; \ 39 43 mkdir -p "$$terminfo_dst"; \ 40 44 rsync -a --delete "$$terminfo_src/" "$$terminfo_dst/" 45 + touch "$(GHOSTTY_BUILD_STAMP)" 41 46 42 - build-app: build-ghostty-xcframework # Build the macOS app (Debug) 47 + # Public entry point: only rebuilds ghostty if submodule SHA changed (or outputs are missing). 48 + ensure-ghostty: _check-ghostty-hash # Ensure GhosttyKit is up-to-date (fast path when unchanged) 49 + 50 + # Internal: compare current submodule SHA against the recorded one. 51 + _check-ghostty-hash: 52 + @current_sha="$$(git -C $(CURRENT_MAKEFILE_DIR)/ThirdParty/ghostty rev-parse HEAD)"; \ 53 + last_sha=""; \ 54 + if [ -f "$(GHOSTTY_HASH_FILE)" ]; then \ 55 + last_sha="$$(cat "$(GHOSTTY_HASH_FILE)")"; \ 56 + fi; \ 57 + artifacts_ok=1; \ 58 + for path in "$(GHOSTTY_XCFRAMEWORK_PATH)" "$(GHOSTTY_RESOURCE_PATH)" "$(GHOSTTY_TERMINFO_PATH)"; do \ 59 + if [ ! -e "$$path" ]; then \ 60 + artifacts_ok=0; \ 61 + fi; \ 62 + done; \ 63 + if [ "$$current_sha" != "$$last_sha" ] || [ "$$artifacts_ok" -ne 1 ]; then \ 64 + echo "Syncing GhosttyKit for submodule $$current_sha"; \ 65 + $(MAKE) -B build-ghostty-xcframework; \ 66 + if [ "$$current_sha" != "$$last_sha" ]; then \ 67 + rm -rf ~/Library/Developer/Xcode/DerivedData/supacode-*; \ 68 + echo "Cleared Xcode DerivedData for ghostty header/module changes"; \ 69 + fi; \ 70 + else \ 71 + echo "GhosttyKit up-to-date (SHA unchanged)"; \ 72 + fi 73 + 74 + # Internal: record the current submodule SHA after a successful build. 75 + _record-ghostty-hash: 76 + @git -C $(CURRENT_MAKEFILE_DIR)/ThirdParty/ghostty rev-parse HEAD > "$(GHOSTTY_HASH_FILE)" 77 + 78 + # Force a clean rebuild of GhosttyKit (ignores cached SHA, useful after submodule updates). 79 + sync-ghostty: # Force sync GhosttyKit to current submodule HEAD (always rebuilds) 80 + @echo "Forcing GhosttyKit rebuild..." 81 + $(MAKE) -B build-ghostty-xcframework 82 + rm -rf ~/Library/Developer/Xcode/DerivedData/supacode-* 83 + @echo "Done. Xcode module cache cleared for fresh compilation." 84 + 85 + build-app: ensure-ghostty # Build the macOS app (Debug) 43 86 bash -o pipefail -c 'xcodebuild -project supacode.xcodeproj -scheme supacode -configuration Debug build -skipMacroValidation -clonedSourcePackagesDirPath $(SPM_CACHE_DIR) 2>&1 | mise exec -- xcsift -qw --format toon' 44 87 45 88 build-cli: # Build Swift CLI binary (SPM) ··· 197 240 export-archive: # Export xarchive 198 241 bash -o pipefail -c 'xcodebuild -exportArchive -archivePath build/supacode.xcarchive -exportPath build/export -exportOptionsPlist build/ExportOptions.plist 2>&1 | mise exec -- xcsift -qw --format toon' 199 242 200 - test: build-ghostty-xcframework 201 - xcodebuild test -project supacode.xcodeproj -scheme supacode -destination "platform=macOS" CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO CODE_SIGN_IDENTITY="" -skipMacroValidation -clonedSourcePackagesDirPath $(SPM_CACHE_DIR) 2>&1 243 + test: ensure-ghostty 244 + @set -euo pipefail; \ 245 + result_bundle="$(CURRENT_MAKEFILE_DIR)/build/test-results/supacode-tests.xcresult"; \ 246 + mkdir -p "$$(dirname "$$result_bundle")"; \ 247 + rm -rf "$$result_bundle"; \ 248 + set +e; \ 249 + xcodebuild test -project supacode.xcodeproj -scheme supacode -destination "platform=macOS" -resultBundlePath "$$result_bundle" CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO CODE_SIGN_IDENTITY="" -skipMacroValidation -clonedSourcePackagesDirPath $(SPM_CACHE_DIR) 2>&1 | mise exec -- xcsift -w --format toon; \ 250 + xcodebuild_status=$${PIPESTATUS[0]}; \ 251 + set -e; \ 252 + if [ "$$xcodebuild_status" -ne 0 ]; then \ 253 + bash "$(CURRENT_MAKEFILE_DIR)/scripts/print-xcresult-failures.sh" "$$result_bundle" || true; \ 254 + fi; \ 255 + exit "$$xcodebuild_status" 202 256 203 257 test-cli-smoke: build-cli # Smoke test CLI executable 204 258 @set -euo pipefail; \
+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 ))
+1 -1
ProwlCLI/Commands/ReadCommand.swift
··· 16 16 var last: Int? 17 17 18 18 mutating func run() throws { 19 - try CLIExecution.run(command: "read", output: options.outputMode) { 19 + try CLIExecution.run(command: "read", output: options.outputMode, colorEnabled: options.colorEnabled) { 20 20 let sel = try selector.resolve() 21 21 22 22 if let n = last, n < 1 {
+81
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 + 76 + if response.command == "read", 77 + let data = response.data, 78 + let payload = try? data.decode(as: ReadCommandPayload.self) 79 + { 80 + print(renderRead(payload)) 81 + return 82 + } 83 + 68 84 print("ok: \(response.command)") 69 85 return 70 86 } ··· 219 235 if let cwd = pane.cwd { 220 236 lines.append(" \("cwd:".dim) \(cwd)") 221 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 + 265 + return lines.joined(separator: "\n") 266 + } 267 + 268 + private static func renderRead(_ payload: ReadCommandPayload) -> String { 269 + let wt = payload.target.worktree 270 + let pane = payload.target.pane 271 + let projectName = projectName(from: wt.path) 272 + 273 + let requestedLabel: String 274 + if let last = payload.last { 275 + requestedLabel = "last \(last)" 276 + } else { 277 + requestedLabel = "snapshot" 278 + } 279 + let truncatedLabel = payload.truncated ? "yes".yellow : "no".green 280 + 281 + var lines: [String] = [] 282 + lines.append( 283 + "Read from \(projectName.cyan.bold)\(":".dim)\(wt.name) → \(pane.title.green)" 284 + + " \(pane.id.dim)" 285 + ) 286 + lines.append( 287 + " \("mode:".dim) \(payload.mode.rawValue)" 288 + + " (\(requestedLabel))" 289 + + " \("source:".dim) \(payload.source.rawValue)" 290 + + " \("truncated:".dim) \(truncatedLabel)" 291 + + " \("lines:".dim) \(payload.lineCount)" 292 + ) 293 + 294 + if let cwd = pane.cwd { 295 + lines.append(" \("cwd:".dim) \(cwd)") 296 + } 297 + 298 + if !payload.text.isEmpty { 299 + lines.append("") 300 + lines.append(payload.text) 301 + } 302 + 222 303 return lines.joined(separator: "\n") 223 304 } 224 305
+552
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 + 938 + // MARK: - Read command tests 939 + 940 + func testReadCommandRoundTripsOverSocket() throws { 941 + let socketPath = temporarySocketPath(suffix: "read") 942 + let response = try CommandResponse( 943 + ok: true, 944 + command: "read", 945 + schemaVersion: "prowl.cli.read.v1", 946 + data: RawJSON(encoding: ReadResponseData( 947 + target: ReadResponseTarget( 948 + worktree: ListWorktree( 949 + id: "Prowl:/Projects/Prowl", 950 + name: "Prowl", 951 + path: "/Projects/Prowl", 952 + rootPath: "/Projects/Prowl", 953 + kind: "git" 954 + ), 955 + tab: ReadResponseTab( 956 + id: "2FC00CF0-3974-4E1B-BEF8-7A08A8E3B7C0", 957 + title: "Prowl 1", 958 + selected: true 959 + ), 960 + pane: ReadResponsePane( 961 + id: "6E1A2A10-D99F-4E3F-920C-D93AA3C05764", 962 + title: "zsh", 963 + cwd: "/Projects/Prowl", 964 + focused: true 965 + ) 966 + ), 967 + mode: "last", 968 + last: 5, 969 + source: "scrollback", 970 + truncated: false, 971 + lineCount: 5, 972 + text: "1\n2\n3\n4\n5" 973 + )) 974 + ) 975 + 976 + let (requestData, result) = try runWithMockServer( 977 + socketPath: socketPath, 978 + response: response, 979 + args: ["read", "--pane", "pane-123", "--last", "5", "--json"] 980 + ) 981 + 982 + XCTAssertEqual(result.exitCode, 0) 983 + let envelope = try JSONDecoder().decode(CommandEnvelope.self, from: requestData) 984 + if case .read(let input) = envelope.command { 985 + XCTAssertEqual(input.selector, .pane("pane-123")) 986 + XCTAssertEqual(input.last, 5) 987 + } else { 988 + XCTFail("Expected read command envelope") 989 + } 990 + 991 + let payload = try jsonObject(from: result.stdout) 992 + XCTAssertEqual(payload["ok"] as? Bool, true) 993 + XCTAssertEqual(payload["command"] as? String, "read") 994 + XCTAssertEqual(payload["schema_version"] as? String, "prowl.cli.read.v1") 995 + } 996 + 997 + func testReadWithoutLastDefaultsToSnapshot() throws { 998 + let socketPath = temporarySocketPath(suffix: "read-snapshot") 999 + let response = CommandResponse( 1000 + ok: true, 1001 + command: "read", 1002 + schemaVersion: "prowl.cli.read.v1" 1003 + ) 1004 + 1005 + let (requestData, result) = try runWithMockServer( 1006 + socketPath: socketPath, 1007 + response: response, 1008 + args: ["read", "--json"] 1009 + ) 1010 + 1011 + XCTAssertEqual(result.exitCode, 0) 1012 + let envelope = try JSONDecoder().decode(CommandEnvelope.self, from: requestData) 1013 + if case .read(let input) = envelope.command { 1014 + XCTAssertEqual(input.selector, .none) 1015 + XCTAssertNil(input.last) 1016 + } else { 1017 + XCTFail("Expected read command envelope") 1018 + } 1019 + } 1020 + 1021 + func testReadRejectsInvalidLastBeforeTransport() throws { 1022 + let result = try runProwl(args: ["read", "--last", "0", "--json"]) 1023 + 1024 + XCTAssertNotEqual(result.exitCode, 0) 1025 + let payload = try jsonObject(from: result.stdout) 1026 + XCTAssertEqual(payload["ok"] as? Bool, false) 1027 + XCTAssertEqual(payload["command"] as? String, "read") 1028 + let error = try XCTUnwrap(payload["error"] as? [String: Any]) 1029 + XCTAssertEqual(error["code"] as? String, CLIErrorCode.invalidArgument) 1030 + } 1031 + 1032 + func testReadRejectsMultipleSelectorsBeforeTransport() throws { 1033 + let result = try runProwl(args: ["read", "--worktree", "Prowl", "--pane", "pane-123", "--json"]) 1034 + 1035 + XCTAssertNotEqual(result.exitCode, 0) 1036 + let payload = try jsonObject(from: result.stdout) 1037 + XCTAssertEqual(payload["ok"] as? Bool, false) 1038 + XCTAssertEqual(payload["command"] as? String, "read") 1039 + let error = try XCTUnwrap(payload["error"] as? [String: Any]) 1040 + XCTAssertEqual(error["code"] as? String, CLIErrorCode.invalidArgument) 1041 + } 1042 + 1043 + func testReadTextRenderingFromSocket() throws { 1044 + let socketPath = temporarySocketPath(suffix: "read-text") 1045 + let response = try CommandResponse( 1046 + ok: true, 1047 + command: "read", 1048 + schemaVersion: "prowl.cli.read.v1", 1049 + data: RawJSON(encoding: ReadResponseData( 1050 + target: ReadResponseTarget( 1051 + worktree: ListWorktree( 1052 + id: "wt-1", 1053 + name: "main", 1054 + path: "/Projects/App", 1055 + rootPath: "/Projects/App", 1056 + kind: "git" 1057 + ), 1058 + tab: ReadResponseTab(id: "t1", title: "Tab 1", selected: true), 1059 + pane: ReadResponsePane(id: "p1", title: "zsh", cwd: "/Projects/App", focused: true) 1060 + ), 1061 + mode: "last", 1062 + last: 3, 1063 + source: "scrollback", 1064 + truncated: false, 1065 + lineCount: 3, 1066 + text: "a\nb\nc" 1067 + )) 1068 + ) 1069 + 1070 + let (_, result) = try runWithMockServer( 1071 + socketPath: socketPath, 1072 + response: response, 1073 + args: ["read"] 1074 + ) 1075 + 1076 + XCTAssertEqual(result.exitCode, 0) 1077 + XCTAssertTrue(result.stdout.contains("Read from"), "Missing header: \(result.stdout)") 1078 + XCTAssertTrue(result.stdout.contains("mode:"), "Missing mode line: \(result.stdout)") 1079 + XCTAssertTrue(result.stdout.contains("source:"), "Missing source line: \(result.stdout)") 1080 + XCTAssertTrue(result.stdout.contains("a\nb\nc"), "Missing text body: \(result.stdout)") 1081 + } 1082 + 613 1083 // MARK: - Helpers 614 1084 615 1085 private func runWithMockServer( ··· 895 1365 case exitCode = "exit_code" 896 1366 case durationMs = "duration_ms" 897 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 1410 + } 1411 + 1412 + private struct ReadResponseData: Encodable { 1413 + let target: ReadResponseTarget 1414 + let mode: String 1415 + let last: Int? 1416 + let source: String 1417 + let truncated: Bool 1418 + 1419 + enum CodingKeys: String, CodingKey { 1420 + case target 1421 + case mode 1422 + case last 1423 + case source 1424 + case truncated 1425 + case lineCount = "line_count" 1426 + case text 1427 + } 1428 + 1429 + let lineCount: Int 1430 + let text: String 1431 + } 1432 + 1433 + private struct ReadResponseTarget: Encodable { 1434 + let worktree: ListWorktree 1435 + let tab: ReadResponseTab 1436 + let pane: ReadResponsePane 1437 + } 1438 + 1439 + private struct ReadResponseTab: Encodable { 1440 + let id: String 1441 + let title: String 1442 + let selected: Bool 1443 + } 1444 + 1445 + private struct ReadResponsePane: Encodable { 1446 + let id: String 1447 + let title: String 1448 + let cwd: String? 1449 + let focused: Bool 898 1450 } 899 1451 900 1452 private struct CommandResult {
+9
README.md
··· 31 31 make format # Run swift-format 32 32 ``` 33 33 34 + ### Local Ghostty sync (avoid submodule/XCFramework drift) 35 + 36 + ```bash 37 + make ensure-ghostty # fast SHA check, rebuilds only when ThirdParty/ghostty changed 38 + make sync-ghostty # force rebuild + clear DerivedData 39 + ``` 40 + 41 + `build-app` and `test` already run `ensure-ghostty` automatically. 42 + 34 43 ## Contributing 35 44 36 45 - I actual prefer a well written issue describing features/bugs u want rather than a vibe-coded PR
+64
scripts/print-xcresult-failures.sh
··· 1 + #!/usr/bin/env bash 2 + 3 + set -euo pipefail 4 + 5 + if [ "$#" -ne 1 ]; then 6 + echo "usage: $0 <xcresult-path>" >&2 7 + exit 0 8 + fi 9 + 10 + result_bundle="$1" 11 + 12 + if [ ! -d "$result_bundle" ]; then 13 + echo "warning: xcresult bundle not found at $result_bundle" 14 + exit 0 15 + fi 16 + 17 + if ! command -v jq >/dev/null 2>&1; then 18 + echo "warning: jq is required to parse xcresult summary details" 19 + exit 0 20 + fi 21 + 22 + summary_json="$(mktemp)" 23 + cleanup() { 24 + rm -f "$summary_json" 25 + } 26 + trap cleanup EXIT 27 + 28 + if ! xcrun xcresulttool get test-results summary --path "$result_bundle" --compact >"$summary_json" 2>/dev/null; then 29 + echo "warning: failed to parse xcresult summary from $result_bundle" 30 + exit 0 31 + fi 32 + 33 + failed_tests="$( 34 + jq -r ' 35 + if (.failedTests? | type) == "number" then .failedTests 36 + elif (.failedTests? | type) == "string" then (.failedTests | tonumber? // 0) 37 + else 0 38 + end 39 + ' "$summary_json" 40 + )" 41 + 42 + if [ "$failed_tests" -eq 0 ]; then 43 + echo "No failed tests found in xcresult summary." 44 + exit 0 45 + fi 46 + 47 + echo 48 + echo "================ xcresult failure details ================" 49 + 50 + jq -r ' 51 + (.testFailures // []) as $failures 52 + | if ($failures | length) == 0 then 53 + "warning: summary has failedTests=\(.failedTests // "unknown"), but no testFailures entries were found." 54 + else 55 + $failures[] 56 + | "test: \(.testName // "unknown")", 57 + "target: \(.targetName // "unknown")", 58 + "identifier: \(.testIdentifierString // "n/a")", 59 + ("failure: " + ((.failureText // "n/a") | gsub("\\n"; "\n "))), 60 + "" 61 + end 62 + ' "$summary_json" 63 + 64 + echo "=========================================================="
+75 -2
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 ··· 255 268 } 256 269 ) 257 270 let openHandler = Self.makeOpenHandler(appStore: appStore, terminalManager: terminalManager) 271 + let readHandler = ReadCommandHandler( 272 + resolveProvider: { selector in 273 + let resolver = TargetResolver { 274 + TargetResolutionSnapshotBuilder.makeSnapshot( 275 + repositoriesState: appStore.state.repositories, 276 + terminalManager: terminalManager 277 + ) 278 + } 279 + return resolver.resolve(selector).map { ReadResolvedTarget(from: $0) } 280 + }, 281 + captureProvider: { target in 282 + guard let state = terminalManager.stateIfExists(for: target.worktreeID), 283 + let surface = state.surfaceView(for: target.paneID), 284 + let viewportText = surface.readViewportContentsForCLI() 285 + else { 286 + return nil 287 + } 288 + return ReadCaptureInput( 289 + viewportText: viewportText, 290 + screenText: surface.readScreenContentsForCLI() 291 + ) 292 + } 293 + ) 294 + let keyHandler = KeyCommandHandler( 295 + resolveProvider: { selector in 296 + let resolver = TargetResolver { 297 + TargetResolutionSnapshotBuilder.makeSnapshot( 298 + repositoriesState: appStore.state.repositories, 299 + terminalManager: terminalManager 300 + ) 301 + } 302 + return resolver.resolve(selector).map { KeyResolvedTarget(from: $0) } 303 + }, 304 + keyDelivery: { target, token, repeatCount in 305 + guard let state = terminalManager.stateIfExists(for: target.worktreeID) else { 306 + return KeyDeliveryResult(attempted: repeatCount, delivered: 0) 307 + } 308 + let delivered = (0..<repeatCount).count { _ in 309 + state.sendKeyToken(token, in: target.paneID) 310 + } 311 + return KeyDeliveryResult(attempted: repeatCount, delivered: delivered) 312 + } 313 + ) 258 314 let cliRouter = CLICommandRouter( 259 315 openHandler: openHandler, 260 316 listHandler: listHandler, 261 317 focusHandler: focusHandler, 262 - sendHandler: sendHandler 318 + sendHandler: sendHandler, 319 + keyHandler: keyHandler, 320 + readHandler: readHandler 263 321 ) 264 322 let cliServer = CLISocketServer(router: cliRouter) 265 323 let logger = SupaLogger("CLIService") ··· 297 355 let repositories = appStore.state.repositories 298 356 for repository in repositories.repositories { 299 357 if let worktree = repository.worktrees.first(where: { $0.id == worktreeID }) { 358 + let quotedPath = shellQuote(path) 300 359 terminalManager.handleCommand( 301 - .createTabWithInput(worktree, input: "cd \(path)", runSetupScriptIfNew: false) 360 + .createTabWithInput( 361 + worktree, 362 + input: "cd -- \(quotedPath)", 363 + runSetupScriptIfNew: false 364 + ) 302 365 ) 303 366 return 304 367 } ··· 404 467 resolution: .newRoot, worktreeID: nil, worktreeName: nil, 405 468 worktreePath: nil, rootPath: nil, worktreeKind: nil, resolvedPath: normalized 406 469 ) 470 + } 471 + 472 + private static func shellQuote(_ value: String) -> String { 473 + let needsQuoting = value.contains { character in 474 + character.isWhitespace || character == "\"" || character == "'" || character == "\\" 475 + } 476 + guard needsQuoting else { 477 + return value 478 + } 479 + return "'\(value.replacing("'", with: "'\"'\"'"))'" 407 480 } 408 481 409 482 private static func selectCLIWorktreeContext(
+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 + }
+216
supacode/CLIService/ReadCommandHandler.swift
··· 1 + // supacode/CLIService/ReadCommandHandler.swift 2 + // Handles `prowl read` by resolving target and reading snapshot/last text. 3 + 4 + import Foundation 5 + 6 + private struct ReadCapture { 7 + let text: String 8 + let source: ReadSource 9 + let truncated: Bool 10 + } 11 + 12 + struct ReadCaptureInput: Sendable { 13 + let viewportText: String 14 + let screenText: String? 15 + } 16 + 17 + /// Resolved target metadata for read payload construction. 18 + struct ReadResolvedTarget: Sendable { 19 + let worktreeID: String 20 + let worktreeName: String 21 + let worktreePath: String 22 + let worktreeRootPath: String 23 + let worktreeKind: ListCommandWorktree.Kind 24 + let tabID: UUID 25 + let tabTitle: String 26 + let tabSelected: Bool 27 + let paneID: UUID 28 + let paneTitle: String 29 + let paneCWD: String? 30 + let paneFocused: Bool 31 + } 32 + 33 + extension ReadResolvedTarget { 34 + init(from resolved: ResolvedTarget) { 35 + self.worktreeID = resolved.worktreeID 36 + self.worktreeName = resolved.worktreeName 37 + self.worktreePath = resolved.worktreePath 38 + self.worktreeRootPath = resolved.worktreeRootPath 39 + self.worktreeKind = resolved.worktreeKind 40 + self.tabID = resolved.tabID 41 + self.tabTitle = resolved.tabTitle 42 + self.tabSelected = resolved.tabSelected 43 + self.paneID = resolved.paneID 44 + self.paneTitle = resolved.paneTitle 45 + self.paneCWD = resolved.paneCWD 46 + self.paneFocused = resolved.paneFocused 47 + } 48 + } 49 + 50 + @MainActor 51 + final class ReadCommandHandler: CommandHandler { 52 + typealias ResolveProvider = @MainActor (TargetSelector) -> Result<ReadResolvedTarget, TargetResolverError> 53 + typealias CaptureProvider = @MainActor (ReadResolvedTarget) -> ReadCaptureInput? 54 + 55 + private let resolveProvider: ResolveProvider 56 + private let captureProvider: CaptureProvider 57 + 58 + init( 59 + resolveProvider: @escaping ResolveProvider, 60 + captureProvider: @escaping CaptureProvider 61 + ) { 62 + self.resolveProvider = resolveProvider 63 + self.captureProvider = captureProvider 64 + } 65 + 66 + // swiftlint:disable:next async_without_await 67 + func handle(envelope: CommandEnvelope) async -> CommandResponse { 68 + guard case .read(let input) = envelope.command else { 69 + return errorResponse(code: CLIErrorCode.readFailed, message: "Invalid command.") 70 + } 71 + 72 + let target: ReadResolvedTarget 73 + switch resolveProvider(input.selector) { 74 + case .success(let resolved): 75 + target = resolved 76 + case .failure(let error): 77 + return mapResolverError(error) 78 + } 79 + 80 + guard let captureInput = captureProvider(target) else { 81 + return errorResponse(code: CLIErrorCode.readFailed, message: "Failed to read terminal text.") 82 + } 83 + 84 + let capture: ReadCapture 85 + if let last = input.last { 86 + capture = captureLast( 87 + requestedLineCount: last, 88 + viewportText: captureInput.viewportText, 89 + screenText: captureInput.screenText 90 + ) 91 + } else { 92 + capture = ReadCapture( 93 + text: captureInput.viewportText, 94 + source: .screen, 95 + truncated: false 96 + ) 97 + } 98 + 99 + let payload = ReadCommandPayload( 100 + target: makePayloadTarget(from: target), 101 + mode: input.last == nil ? .snapshot : .last, 102 + last: input.last, 103 + source: capture.source, 104 + truncated: capture.truncated, 105 + lineCount: lineCount(in: capture.text), 106 + text: capture.text 107 + ) 108 + 109 + do { 110 + return try CommandResponse( 111 + ok: true, 112 + command: "read", 113 + schemaVersion: "prowl.cli.read.v1", 114 + data: RawJSON(encoding: payload) 115 + ) 116 + } catch { 117 + return errorResponse(code: CLIErrorCode.readFailed, message: "Failed to encode response.") 118 + } 119 + } 120 + 121 + private func captureLast( 122 + requestedLineCount: Int, 123 + viewportText: String, 124 + screenText: String? 125 + ) -> ReadCapture { 126 + let viewportLines = splitLines(viewportText) 127 + if viewportLines.count >= requestedLineCount { 128 + let text = joinLines(viewportLines.suffix(requestedLineCount)) 129 + return ReadCapture( 130 + text: text, 131 + source: .screen, 132 + truncated: false 133 + ) 134 + } 135 + 136 + guard let screenText else { 137 + return ReadCapture( 138 + text: joinLines(viewportLines.suffix(min(requestedLineCount, viewportLines.count))), 139 + source: .mixed, 140 + truncated: viewportLines.count < requestedLineCount 141 + ) 142 + } 143 + 144 + let screenLines = splitLines(screenText) 145 + if screenLines.count < viewportLines.count { 146 + return ReadCapture( 147 + text: joinLines(viewportLines.suffix(min(requestedLineCount, viewportLines.count))), 148 + source: .mixed, 149 + truncated: viewportLines.count < requestedLineCount 150 + ) 151 + } 152 + 153 + let source: ReadSource = screenLines.count > viewportLines.count ? .scrollback : .screen 154 + let text = joinLines(screenLines.suffix(min(requestedLineCount, screenLines.count))) 155 + 156 + return ReadCapture( 157 + text: text, 158 + source: source, 159 + truncated: screenLines.count < requestedLineCount 160 + ) 161 + } 162 + 163 + private func splitLines(_ text: String) -> [Substring] { 164 + guard !text.isEmpty else { return [] } 165 + return text.split(separator: "\n", omittingEmptySubsequences: false) 166 + } 167 + 168 + private func joinLines(_ lines: ArraySlice<Substring>) -> String { 169 + lines.map(String.init).joined(separator: "\n") 170 + } 171 + 172 + private func lineCount(in text: String) -> Int { 173 + splitLines(text).count 174 + } 175 + 176 + private func makePayloadTarget(from target: ReadResolvedTarget) -> ReadTarget { 177 + ReadTarget( 178 + worktree: ReadTargetWorktree( 179 + id: target.worktreeID, 180 + name: target.worktreeName, 181 + path: target.worktreePath, 182 + rootPath: target.worktreeRootPath, 183 + kind: target.worktreeKind.rawValue 184 + ), 185 + tab: ReadTargetTab( 186 + id: target.tabID.uuidString, 187 + title: target.tabTitle, 188 + selected: target.tabSelected 189 + ), 190 + pane: ReadTargetPane( 191 + id: target.paneID.uuidString, 192 + title: target.paneTitle, 193 + cwd: target.paneCWD, 194 + focused: target.paneFocused 195 + ) 196 + ) 197 + } 198 + 199 + private func mapResolverError(_ error: TargetResolverError) -> CommandResponse { 200 + switch error { 201 + case .notFound(let message): 202 + return errorResponse(code: CLIErrorCode.targetNotFound, message: message) 203 + case .notUnique(let message): 204 + return errorResponse(code: CLIErrorCode.targetNotUnique, message: message) 205 + } 206 + } 207 + 208 + private func errorResponse(code: String, message: String) -> CommandResponse { 209 + CommandResponse( 210 + ok: false, 211 + command: "read", 212 + schemaVersion: "prowl.cli.read.v1", 213 + error: CommandError(code: code, message: message) 214 + ) 215 + } 216 + }
+12
supacode/CLIService/Shared/InputModels.swift
··· 61 61 62 62 public struct KeyInput: Codable, Sendable { 63 63 public let selector: TargetSelector 64 + /// The user's original token after trimming (for `requested.token` in response). 65 + public let rawToken: String 66 + /// The canonical normalized token (for execution and `key.normalized` in response). 64 67 public let token: String 65 68 public let repeatCount: Int 66 69 70 + enum CodingKeys: String, CodingKey { 71 + case selector 72 + case rawToken = "raw_token" 73 + case token 74 + case repeatCount = "repeat_count" 75 + } 76 + 67 77 public init( 68 78 selector: TargetSelector = .none, 79 + rawToken: String, 69 80 token: String, 70 81 repeatCount: Int = 1 71 82 ) { 72 83 self.selector = selector 84 + self.rawToken = rawToken 73 85 self.token = token 74 86 self.repeatCount = repeatCount 75 87 }
+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 + }
+115
supacode/CLIService/Shared/ReadCommandPayload.swift
··· 1 + // ProwlShared/ReadCommandPayload.swift 2 + // Success payload for `prowl read --json` matching read.md contract. 3 + 4 + import Foundation 5 + 6 + public struct ReadCommandPayload: Codable, Sendable, Equatable { 7 + public let target: ReadTarget 8 + public let mode: ReadMode 9 + public let last: Int? 10 + public let source: ReadSource 11 + public let truncated: Bool 12 + public let lineCount: Int 13 + public let text: String 14 + 15 + enum CodingKeys: String, CodingKey { 16 + case target 17 + case mode 18 + case last 19 + case source 20 + case truncated 21 + case lineCount = "line_count" 22 + case text 23 + } 24 + 25 + public init( 26 + target: ReadTarget, 27 + mode: ReadMode, 28 + last: Int?, 29 + source: ReadSource, 30 + truncated: Bool, 31 + lineCount: Int, 32 + text: String 33 + ) { 34 + self.target = target 35 + self.mode = mode 36 + self.last = last 37 + self.source = source 38 + self.truncated = truncated 39 + self.lineCount = lineCount 40 + self.text = text 41 + } 42 + } 43 + 44 + public enum ReadMode: String, Codable, Sendable { 45 + case snapshot 46 + case last 47 + } 48 + 49 + public enum ReadSource: String, Codable, Sendable { 50 + case screen 51 + case scrollback 52 + case mixed 53 + } 54 + 55 + public struct ReadTarget: Codable, Sendable, Equatable { 56 + public let worktree: ReadTargetWorktree 57 + public let tab: ReadTargetTab 58 + public let pane: ReadTargetPane 59 + 60 + public init(worktree: ReadTargetWorktree, tab: ReadTargetTab, pane: ReadTargetPane) { 61 + self.worktree = worktree 62 + self.tab = tab 63 + self.pane = pane 64 + } 65 + } 66 + 67 + public struct ReadTargetWorktree: Codable, Sendable, Equatable { 68 + public let id: String 69 + public let name: String 70 + public let path: String 71 + public let rootPath: String 72 + public let kind: String 73 + 74 + enum CodingKeys: String, CodingKey { 75 + case id 76 + case name 77 + case path 78 + case rootPath = "root_path" 79 + case kind 80 + } 81 + 82 + public init(id: String, name: String, path: String, rootPath: String, kind: String) { 83 + self.id = id 84 + self.name = name 85 + self.path = path 86 + self.rootPath = rootPath 87 + self.kind = kind 88 + } 89 + } 90 + 91 + public struct ReadTargetTab: Codable, Sendable, Equatable { 92 + public let id: String 93 + public let title: String 94 + public let selected: Bool 95 + 96 + public init(id: String, title: String, selected: Bool) { 97 + self.id = id 98 + self.title = title 99 + self.selected = selected 100 + } 101 + } 102 + 103 + public struct ReadTargetPane: Codable, Sendable, Equatable { 104 + public let id: String 105 + public let title: String 106 + public let cwd: String? 107 + public let focused: Bool 108 + 109 + public init(id: String, title: String, cwd: String?, focused: Bool) { 110 + self.id = id 111 + self.title = title 112 + self.cwd = cwd 113 + self.focused = focused 114 + } 115 + }
+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 }
+136 -5
supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift
··· 567 567 } 568 568 } 569 569 570 - private func readScreenContents() -> String { 571 - guard let surface else { return "" } 570 + private func readText( 571 + topLeftTag: ghostty_point_tag_e, 572 + bottomRightTag: ghostty_point_tag_e 573 + ) -> String? { 574 + guard let surface else { return nil } 572 575 var text = ghostty_text_s() 573 576 let selection = ghostty_selection_s( 574 577 top_left: ghostty_point_s( 575 - tag: GHOSTTY_POINT_SCREEN, 578 + tag: topLeftTag, 576 579 coord: GHOSTTY_POINT_COORD_TOP_LEFT, 577 580 x: 0, 578 581 y: 0 579 582 ), 580 583 bottom_right: ghostty_point_s( 581 - tag: GHOSTTY_POINT_SCREEN, 584 + tag: bottomRightTag, 582 585 coord: GHOSTTY_POINT_COORD_BOTTOM_RIGHT, 583 586 x: 0, 584 587 y: 0 585 588 ), 586 589 rectangle: false 587 590 ) 588 - guard ghostty_surface_read_text(surface, selection, &text) else { return "" } 591 + guard ghostty_surface_read_text(surface, selection, &text) else { return nil } 589 592 defer { ghostty_surface_free_text(surface, &text) } 590 593 return String(cString: text.text) 594 + } 595 + 596 + private func readScreenContents() -> String { 597 + readText( 598 + topLeftTag: GHOSTTY_POINT_SCREEN, 599 + bottomRightTag: GHOSTTY_POINT_SCREEN 600 + ) ?? "" 601 + } 602 + 603 + func readViewportContentsForCLI() -> String? { 604 + readText( 605 + topLeftTag: GHOSTTY_POINT_VIEWPORT, 606 + bottomRightTag: GHOSTTY_POINT_VIEWPORT 607 + ) 608 + } 609 + 610 + func readScreenContentsForCLI() -> String? { 611 + readText( 612 + topLeftTag: GHOSTTY_POINT_SCREEN, 613 + bottomRightTag: GHOSTTY_POINT_SCREEN 614 + ) 591 615 } 592 616 593 617 override func keyDown(with event: NSEvent) { ··· 1755 1779 keyDown(with: keyDownEvent) 1756 1780 keyUp(with: keyUpEvent) 1757 1781 return true 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 + } 1758 1889 } 1759 1890 } 1760 1891
+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 + }
+243
supacodeTests/CLIReadCommandHandlerTests.swift
··· 1 + import Foundation 2 + import Testing 3 + 4 + @testable import supacode 5 + 6 + @MainActor 7 + struct CLIReadCommandHandlerTests { 8 + private static let paneID = UUID(uuidString: "6E1A2A10-D99F-4E3F-920C-D93AA3C05764")! 9 + private static let tabID = UUID(uuidString: "2FC00CF0-3974-4E1B-BEF8-7A08A8E3B7C0")! 10 + 11 + private static func makeTarget() -> ReadResolvedTarget { 12 + ReadResolvedTarget( 13 + worktreeID: "Prowl:/Users/onevcat/Projects/Prowl", 14 + worktreeName: "Prowl", 15 + worktreePath: "/Users/onevcat/Projects/Prowl", 16 + worktreeRootPath: "/Users/onevcat/Projects/Prowl", 17 + worktreeKind: .git, 18 + tabID: tabID, 19 + tabTitle: "Prowl 1", 20 + tabSelected: true, 21 + paneID: paneID, 22 + paneTitle: "zsh", 23 + paneCWD: "/Users/onevcat/Projects/Prowl", 24 + paneFocused: true 25 + ) 26 + } 27 + 28 + private static func makeEnvelope(last: Int? = nil) -> CommandEnvelope { 29 + CommandEnvelope( 30 + output: .json, 31 + command: .read(ReadInput(selector: .none, last: last)) 32 + ) 33 + } 34 + 35 + @Test func snapshotUsesViewportText() async throws { 36 + let handler = ReadCommandHandler( 37 + resolveProvider: { _ in .success(Self.makeTarget()) }, 38 + captureProvider: { _ in 39 + ReadCaptureInput( 40 + viewportText: "line-1\nline-2", 41 + screenText: "old\nline-1\nline-2" 42 + ) 43 + } 44 + ) 45 + 46 + let response = await handler.handle(envelope: Self.makeEnvelope()) 47 + 48 + #expect(response.ok) 49 + #expect(response.command == "read") 50 + #expect(response.schemaVersion == "prowl.cli.read.v1") 51 + let payload = try #require(try response.data?.decode(as: ReadCommandPayload.self)) 52 + #expect(payload.mode == .snapshot) 53 + #expect(payload.last == nil) 54 + #expect(payload.source == .screen) 55 + #expect(payload.truncated == false) 56 + #expect(payload.lineCount == 2) 57 + #expect(payload.text == "line-1\nline-2") 58 + } 59 + 60 + @Test func snapshotSucceedsWhenScreenCaptureUnavailable() async throws { 61 + let handler = ReadCommandHandler( 62 + resolveProvider: { _ in .success(Self.makeTarget()) }, 63 + captureProvider: { _ in 64 + ReadCaptureInput( 65 + viewportText: "line-1\nline-2", 66 + screenText: nil 67 + ) 68 + } 69 + ) 70 + 71 + let response = await handler.handle(envelope: Self.makeEnvelope()) 72 + 73 + #expect(response.ok) 74 + let payload = try #require(try response.data?.decode(as: ReadCommandPayload.self)) 75 + #expect(payload.mode == .snapshot) 76 + #expect(payload.source == .screen) 77 + #expect(payload.truncated == false) 78 + #expect(payload.lineCount == 2) 79 + #expect(payload.text == "line-1\nline-2") 80 + } 81 + 82 + @Test func lastUsesViewportWhenEnoughLines() async throws { 83 + let handler = ReadCommandHandler( 84 + resolveProvider: { _ in .success(Self.makeTarget()) }, 85 + captureProvider: { _ in 86 + ReadCaptureInput( 87 + viewportText: "a\nb\nc\nd", 88 + screenText: "x\na\nb\nc\nd" 89 + ) 90 + } 91 + ) 92 + 93 + let response = await handler.handle(envelope: Self.makeEnvelope(last: 2)) 94 + 95 + #expect(response.ok) 96 + let payload = try #require(try response.data?.decode(as: ReadCommandPayload.self)) 97 + #expect(payload.mode == .last) 98 + #expect(payload.last == 2) 99 + #expect(payload.source == .screen) 100 + #expect(payload.truncated == false) 101 + #expect(payload.lineCount == 2) 102 + #expect(payload.text == "c\nd") 103 + } 104 + 105 + @Test func lastUsesScrollbackWhenViewportInsufficient() async throws { 106 + let handler = ReadCommandHandler( 107 + resolveProvider: { _ in .success(Self.makeTarget()) }, 108 + captureProvider: { _ in 109 + ReadCaptureInput( 110 + viewportText: "c\nd", 111 + screenText: "a\nb\nc\nd" 112 + ) 113 + } 114 + ) 115 + 116 + let response = await handler.handle(envelope: Self.makeEnvelope(last: 3)) 117 + 118 + #expect(response.ok) 119 + let payload = try #require(try response.data?.decode(as: ReadCommandPayload.self)) 120 + #expect(payload.source == .scrollback) 121 + #expect(payload.truncated == false) 122 + #expect(payload.lineCount == 3) 123 + #expect(payload.text == "b\nc\nd") 124 + } 125 + 126 + @Test func lastMarksTruncatedWhenScreenInsufficient() async throws { 127 + let handler = ReadCommandHandler( 128 + resolveProvider: { _ in .success(Self.makeTarget()) }, 129 + captureProvider: { _ in 130 + ReadCaptureInput( 131 + viewportText: "three\nfour", 132 + screenText: "one\ntwo\nthree\nfour" 133 + ) 134 + } 135 + ) 136 + 137 + let response = await handler.handle(envelope: Self.makeEnvelope(last: 10)) 138 + 139 + #expect(response.ok) 140 + let payload = try #require(try response.data?.decode(as: ReadCommandPayload.self)) 141 + #expect(payload.source == .scrollback) 142 + #expect(payload.truncated == true) 143 + #expect(payload.lineCount == 4) 144 + #expect(payload.text == "one\ntwo\nthree\nfour") 145 + } 146 + 147 + @Test func lastFallsBackToViewportWhenScreenCaptureUnavailable() async throws { 148 + let handler = ReadCommandHandler( 149 + resolveProvider: { _ in .success(Self.makeTarget()) }, 150 + captureProvider: { _ in 151 + ReadCaptureInput( 152 + viewportText: "three\nfour", 153 + screenText: nil 154 + ) 155 + } 156 + ) 157 + 158 + let response = await handler.handle(envelope: Self.makeEnvelope(last: 10)) 159 + 160 + #expect(response.ok) 161 + let payload = try #require(try response.data?.decode(as: ReadCommandPayload.self)) 162 + #expect(payload.source == .mixed) 163 + #expect(payload.truncated == true) 164 + #expect(payload.lineCount == 2) 165 + #expect(payload.text == "three\nfour") 166 + } 167 + 168 + @Test func lastPrefersViewportWhenScreenHasFewerLines() async throws { 169 + let handler = ReadCommandHandler( 170 + resolveProvider: { _ in .success(Self.makeTarget()) }, 171 + captureProvider: { _ in 172 + ReadCaptureInput( 173 + viewportText: "b\nc\nd", 174 + screenText: "c\nd" 175 + ) 176 + } 177 + ) 178 + 179 + let response = await handler.handle(envelope: Self.makeEnvelope(last: 5)) 180 + 181 + #expect(response.ok) 182 + let payload = try #require(try response.data?.decode(as: ReadCommandPayload.self)) 183 + #expect(payload.source == .mixed) 184 + #expect(payload.truncated == true) 185 + #expect(payload.lineCount == 3) 186 + #expect(payload.text == "b\nc\nd") 187 + } 188 + 189 + @Test func trailingNewlineCountsAsExtraLine() async throws { 190 + let handler = ReadCommandHandler( 191 + resolveProvider: { _ in .success(Self.makeTarget()) }, 192 + captureProvider: { _ in 193 + ReadCaptureInput( 194 + viewportText: "done\n", 195 + screenText: "done\n" 196 + ) 197 + } 198 + ) 199 + 200 + let response = await handler.handle(envelope: Self.makeEnvelope()) 201 + 202 + #expect(response.ok) 203 + let payload = try #require(try response.data?.decode(as: ReadCommandPayload.self)) 204 + #expect(payload.lineCount == 2) 205 + #expect(payload.text == "done\n") 206 + } 207 + 208 + @Test func targetNotFoundMapsToContractCode() async { 209 + let handler = ReadCommandHandler( 210 + resolveProvider: { _ in .failure(.notFound("Pane missing")) }, 211 + captureProvider: { _ in nil } 212 + ) 213 + 214 + let response = await handler.handle(envelope: Self.makeEnvelope()) 215 + 216 + #expect(response.ok == false) 217 + #expect(response.error?.code == CLIErrorCode.targetNotFound) 218 + } 219 + 220 + @Test func targetNotUniqueMapsToContractCode() async { 221 + let handler = ReadCommandHandler( 222 + resolveProvider: { _ in .failure(.notUnique("Ambiguous worktree")) }, 223 + captureProvider: { _ in nil } 224 + ) 225 + 226 + let response = await handler.handle(envelope: Self.makeEnvelope(last: 3)) 227 + 228 + #expect(response.ok == false) 229 + #expect(response.error?.code == CLIErrorCode.targetNotUnique) 230 + } 231 + 232 + @Test func captureFailureReturnsReadFailed() async { 233 + let handler = ReadCommandHandler( 234 + resolveProvider: { _ in .success(Self.makeTarget()) }, 235 + captureProvider: { _ in nil } 236 + ) 237 + 238 + let response = await handler.handle(envelope: Self.makeEnvelope()) 239 + 240 + #expect(response.ok == false) 241 + #expect(response.error?.code == CLIErrorCode.readFailed) 242 + } 243 + }
+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 ]