native macOS codings agent orchestrator
6
fork

Configure Feed

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

Merge pull request #137 from onevcat/feat/issue-111-cli-read-runtime

Implement CLI read runtime (contract-driven)

authored by

Wei Wang and committed by
GitHub
d802b4ae 01a5b9b4

+860 -7
+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 {
+46
ProwlCLI/Output/OutputRenderer.swift
··· 65 65 return 66 66 } 67 67 68 + if response.command == "read", 69 + let data = response.data, 70 + let payload = try? data.decode(as: ReadCommandPayload.self) 71 + { 72 + print(renderRead(payload)) 73 + return 74 + } 75 + 68 76 print("ok: \(response.command)") 69 77 return 70 78 } ··· 219 227 if let cwd = pane.cwd { 220 228 lines.append(" \("cwd:".dim) \(cwd)") 221 229 } 230 + return lines.joined(separator: "\n") 231 + } 232 + 233 + private static func renderRead(_ payload: ReadCommandPayload) -> String { 234 + let wt = payload.target.worktree 235 + let pane = payload.target.pane 236 + let projectName = projectName(from: wt.path) 237 + 238 + let requestedLabel: String 239 + if let last = payload.last { 240 + requestedLabel = "last \(last)" 241 + } else { 242 + requestedLabel = "snapshot" 243 + } 244 + let truncatedLabel = payload.truncated ? "yes".yellow : "no".green 245 + 246 + var lines: [String] = [] 247 + lines.append( 248 + "Read from \(projectName.cyan.bold)\(":".dim)\(wt.name) → \(pane.title.green)" 249 + + " \(pane.id.dim)" 250 + ) 251 + lines.append( 252 + " \("mode:".dim) \(payload.mode.rawValue)" 253 + + " (\(requestedLabel))" 254 + + " \("source:".dim) \(payload.source.rawValue)" 255 + + " \("truncated:".dim) \(truncatedLabel)" 256 + + " \("lines:".dim) \(payload.lineCount)" 257 + ) 258 + 259 + if let cwd = pane.cwd { 260 + lines.append(" \("cwd:".dim) \(cwd)") 261 + } 262 + 263 + if !payload.text.isEmpty { 264 + lines.append("") 265 + lines.append(payload.text) 266 + } 267 + 222 268 return lines.joined(separator: "\n") 223 269 } 224 270
+185
ProwlCLITests/ProwlCLIIntegrationTests.swift
··· 610 610 XCTAssertEqual(error["code"] as? String, "INVALID_ARGUMENT") 611 611 } 612 612 613 + // MARK: - Read command tests 614 + 615 + func testReadCommandRoundTripsOverSocket() throws { 616 + let socketPath = temporarySocketPath(suffix: "read") 617 + let response = try CommandResponse( 618 + ok: true, 619 + command: "read", 620 + schemaVersion: "prowl.cli.read.v1", 621 + data: RawJSON(encoding: ReadResponseData( 622 + target: ReadResponseTarget( 623 + worktree: ListWorktree( 624 + id: "Prowl:/Projects/Prowl", 625 + name: "Prowl", 626 + path: "/Projects/Prowl", 627 + rootPath: "/Projects/Prowl", 628 + kind: "git" 629 + ), 630 + tab: ReadResponseTab( 631 + id: "2FC00CF0-3974-4E1B-BEF8-7A08A8E3B7C0", 632 + title: "Prowl 1", 633 + selected: true 634 + ), 635 + pane: ReadResponsePane( 636 + id: "6E1A2A10-D99F-4E3F-920C-D93AA3C05764", 637 + title: "zsh", 638 + cwd: "/Projects/Prowl", 639 + focused: true 640 + ) 641 + ), 642 + mode: "last", 643 + last: 5, 644 + source: "scrollback", 645 + truncated: false, 646 + lineCount: 5, 647 + text: "1\n2\n3\n4\n5" 648 + )) 649 + ) 650 + 651 + let (requestData, result) = try runWithMockServer( 652 + socketPath: socketPath, 653 + response: response, 654 + args: ["read", "--pane", "pane-123", "--last", "5", "--json"] 655 + ) 656 + 657 + XCTAssertEqual(result.exitCode, 0) 658 + let envelope = try JSONDecoder().decode(CommandEnvelope.self, from: requestData) 659 + if case .read(let input) = envelope.command { 660 + XCTAssertEqual(input.selector, .pane("pane-123")) 661 + XCTAssertEqual(input.last, 5) 662 + } else { 663 + XCTFail("Expected read command envelope") 664 + } 665 + 666 + let payload = try jsonObject(from: result.stdout) 667 + XCTAssertEqual(payload["ok"] as? Bool, true) 668 + XCTAssertEqual(payload["command"] as? String, "read") 669 + XCTAssertEqual(payload["schema_version"] as? String, "prowl.cli.read.v1") 670 + } 671 + 672 + func testReadWithoutLastDefaultsToSnapshot() throws { 673 + let socketPath = temporarySocketPath(suffix: "read-snapshot") 674 + let response = CommandResponse( 675 + ok: true, 676 + command: "read", 677 + schemaVersion: "prowl.cli.read.v1" 678 + ) 679 + 680 + let (requestData, result) = try runWithMockServer( 681 + socketPath: socketPath, 682 + response: response, 683 + args: ["read", "--json"] 684 + ) 685 + 686 + XCTAssertEqual(result.exitCode, 0) 687 + let envelope = try JSONDecoder().decode(CommandEnvelope.self, from: requestData) 688 + if case .read(let input) = envelope.command { 689 + XCTAssertEqual(input.selector, .none) 690 + XCTAssertNil(input.last) 691 + } else { 692 + XCTFail("Expected read command envelope") 693 + } 694 + } 695 + 696 + func testReadRejectsInvalidLastBeforeTransport() throws { 697 + let result = try runProwl(args: ["read", "--last", "0", "--json"]) 698 + 699 + XCTAssertNotEqual(result.exitCode, 0) 700 + let payload = try jsonObject(from: result.stdout) 701 + XCTAssertEqual(payload["ok"] as? Bool, false) 702 + XCTAssertEqual(payload["command"] as? String, "read") 703 + let error = try XCTUnwrap(payload["error"] as? [String: Any]) 704 + XCTAssertEqual(error["code"] as? String, CLIErrorCode.invalidArgument) 705 + } 706 + 707 + func testReadRejectsMultipleSelectorsBeforeTransport() throws { 708 + let result = try runProwl(args: ["read", "--worktree", "Prowl", "--pane", "pane-123", "--json"]) 709 + 710 + XCTAssertNotEqual(result.exitCode, 0) 711 + let payload = try jsonObject(from: result.stdout) 712 + XCTAssertEqual(payload["ok"] as? Bool, false) 713 + XCTAssertEqual(payload["command"] as? String, "read") 714 + let error = try XCTUnwrap(payload["error"] as? [String: Any]) 715 + XCTAssertEqual(error["code"] as? String, CLIErrorCode.invalidArgument) 716 + } 717 + 718 + func testReadTextRenderingFromSocket() throws { 719 + let socketPath = temporarySocketPath(suffix: "read-text") 720 + let response = try CommandResponse( 721 + ok: true, 722 + command: "read", 723 + schemaVersion: "prowl.cli.read.v1", 724 + data: RawJSON(encoding: ReadResponseData( 725 + target: ReadResponseTarget( 726 + worktree: ListWorktree( 727 + id: "wt-1", 728 + name: "main", 729 + path: "/Projects/App", 730 + rootPath: "/Projects/App", 731 + kind: "git" 732 + ), 733 + tab: ReadResponseTab(id: "t1", title: "Tab 1", selected: true), 734 + pane: ReadResponsePane(id: "p1", title: "zsh", cwd: "/Projects/App", focused: true) 735 + ), 736 + mode: "last", 737 + last: 3, 738 + source: "scrollback", 739 + truncated: false, 740 + lineCount: 3, 741 + text: "a\nb\nc" 742 + )) 743 + ) 744 + 745 + let (_, result) = try runWithMockServer( 746 + socketPath: socketPath, 747 + response: response, 748 + args: ["read"] 749 + ) 750 + 751 + XCTAssertEqual(result.exitCode, 0) 752 + XCTAssertTrue(result.stdout.contains("Read from"), "Missing header: \(result.stdout)") 753 + XCTAssertTrue(result.stdout.contains("mode:"), "Missing mode line: \(result.stdout)") 754 + XCTAssertTrue(result.stdout.contains("source:"), "Missing source line: \(result.stdout)") 755 + XCTAssertTrue(result.stdout.contains("a\nb\nc"), "Missing text body: \(result.stdout)") 756 + } 757 + 613 758 // MARK: - Helpers 614 759 615 760 private func runWithMockServer( ··· 895 1040 case exitCode = "exit_code" 896 1041 case durationMs = "duration_ms" 897 1042 } 1043 + } 1044 + 1045 + private struct ReadResponseData: Encodable { 1046 + let target: ReadResponseTarget 1047 + let mode: String 1048 + let last: Int? 1049 + let source: String 1050 + let truncated: Bool 1051 + 1052 + enum CodingKeys: String, CodingKey { 1053 + case target 1054 + case mode 1055 + case last 1056 + case source 1057 + case truncated 1058 + case lineCount = "line_count" 1059 + case text 1060 + } 1061 + 1062 + let lineCount: Int 1063 + let text: String 1064 + } 1065 + 1066 + private struct ReadResponseTarget: Encodable { 1067 + let worktree: ListWorktree 1068 + let tab: ReadResponseTab 1069 + let pane: ReadResponsePane 1070 + } 1071 + 1072 + private struct ReadResponseTab: Encodable { 1073 + let id: String 1074 + let title: String 1075 + let selected: Bool 1076 + } 1077 + 1078 + private struct ReadResponsePane: Encodable { 1079 + let id: String 1080 + let title: String 1081 + let cwd: String? 1082 + let focused: Bool 898 1083 } 899 1084 900 1085 private struct CommandResult {
+25 -1
supacode/App/supacodeApp.swift
··· 254 254 bringMainWindowToFront() 255 255 } 256 256 ) 257 + let readHandler = ReadCommandHandler( 258 + resolveProvider: { selector in 259 + let resolver = TargetResolver { 260 + TargetResolutionSnapshotBuilder.makeSnapshot( 261 + repositoriesState: appStore.state.repositories, 262 + terminalManager: terminalManager 263 + ) 264 + } 265 + return resolver.resolve(selector).map { ReadResolvedTarget(from: $0) } 266 + }, 267 + captureProvider: { target in 268 + guard let state = terminalManager.stateIfExists(for: target.worktreeID), 269 + let surface = state.surfaceView(for: target.paneID), 270 + let viewportText = surface.readViewportContentsForCLI() 271 + else { 272 + return nil 273 + } 274 + return ReadCaptureInput( 275 + viewportText: viewportText, 276 + screenText: surface.readScreenContentsForCLI() 277 + ) 278 + } 279 + ) 257 280 let cliRouter = CLICommandRouter( 258 281 listHandler: listHandler, 259 282 focusHandler: focusHandler, 260 - sendHandler: sendHandler 283 + sendHandler: sendHandler, 284 + readHandler: readHandler 261 285 ) 262 286 let cliServer = CLISocketServer(router: cliRouter) 263 287 let logger = SupaLogger("CLIService")
+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 + }
+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 + }
+29 -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) {
+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 + }