native macOS codings agent orchestrator
6
fork

Configure Feed

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

Merge pull request #135 from onevcat/onevclaw/issue-109-send-v1

feat: CLI/send v1 runtime implementation

authored by

Wei Wang and committed by
GitHub
d19ad6da fe6107df

+1365 -31
+30 -7
ProwlCLI/Commands/SendCommand.swift
··· 10 10 import ProwlCLIShared 11 11 12 12 struct SendCommand: ParsableCommand { 13 + /// Check if stdin has readable data using poll(2) with zero timeout. 14 + private static func stdinHasData() -> Bool { 15 + var pfd = pollfd(fd: fileno(stdin), events: Int16(POLLIN), revents: 0) 16 + return poll(&pfd, 1, 0) > 0 && (pfd.revents & Int16(POLLIN)) != 0 17 + } 18 + 13 19 static let configuration = CommandConfiguration( 14 20 commandName: "send", 15 21 abstract: "Send text input to a terminal pane." ··· 21 27 @Flag(name: .long, help: "Do not send trailing Enter after text.") 22 28 var noEnter = false 23 29 30 + @Flag(name: .long, help: "Return immediately without waiting for command completion.") 31 + var noWait = false 32 + 33 + @Option(name: .long, help: "Maximum seconds to wait for completion (1–300, default: 30).") 34 + var timeout: Int? 35 + 24 36 @Argument(help: "Text to send. Alternatively pipe via stdin.") 25 37 var text: String? 26 38 27 39 mutating func run() throws { 28 - try CLIExecution.run(command: "send", output: options.outputMode) { 40 + try CLIExecution.run(command: "send", output: options.outputMode, colorEnabled: options.colorEnabled) { 29 41 let sel = try selector.resolve() 30 42 43 + if let timeout, (timeout < 1 || timeout > 300) { 44 + throw ExitError( 45 + code: CLIErrorCode.invalidArgument, 46 + message: "Timeout must be between 1 and 300 seconds." 47 + ) 48 + } 49 + 31 50 // Resolve input source: argv xor stdin 51 + let stdinIsPiped = isatty(fileno(stdin)) == 0 && Self.stdinHasData() 32 52 let inputText: String 53 + let source: InputSource 33 54 if let argText = text { 34 - // Check stdin is not also provided 35 - if isatty(fileno(stdin)) == 0 { 36 - // stdin has data too — ambiguous 55 + if stdinIsPiped { 37 56 throw ExitError( 38 57 code: CLIErrorCode.invalidArgument, 39 58 message: "Cannot provide text as both argument and stdin." 40 59 ) 41 60 } 42 61 inputText = argText 43 - } else if isatty(fileno(stdin)) == 0 { 44 - // Read from stdin 62 + source = .argv 63 + } else if stdinIsPiped { 45 64 guard let stdinData = try? FileHandle.standardInput.readToEnd(), 46 65 let stdinText = String(data: stdinData, encoding: .utf8), 47 66 !stdinText.isEmpty ··· 52 71 ) 53 72 } 54 73 inputText = stdinText 74 + source = .stdin 55 75 } else { 56 76 throw ExitError( 57 77 code: CLIErrorCode.emptyInput, ··· 64 84 command: .send(SendInput( 65 85 selector: sel, 66 86 text: inputText, 67 - trailingEnter: !noEnter 87 + trailingEnter: !noEnter, 88 + source: source, 89 + wait: !noWait, 90 + timeoutSeconds: timeout 68 91 )) 69 92 ) 70 93 try CLIRunner.execute(envelope)
+58
ProwlCLI/Output/OutputRenderer.swift
··· 49 49 return 50 50 } 51 51 52 + if response.command == "send", 53 + let data = response.data, 54 + let payload = try? data.decode(as: SendCommandPayload.self) 55 + { 56 + print(renderSend(payload)) 57 + return 58 + } 59 + 52 60 print("ok: \(response.command)") 53 61 return 54 62 } ··· 141 149 } 142 150 143 151 return lines.joined(separator: "\n") 152 + } 153 + 154 + private static func renderSend(_ payload: SendCommandPayload) -> String { 155 + let wt = payload.target.worktree 156 + let pane = payload.target.pane 157 + let input = payload.input 158 + 159 + let projectName = projectName(from: wt.path) 160 + var lines: [String] = [] 161 + 162 + lines.append( 163 + "Sent to \(projectName.cyan.bold)\(":".dim)\(wt.name) → \(pane.title.green)" 164 + + " \(pane.id.dim)" 165 + ) 166 + 167 + let enterLabel = input.trailingEnterSent ? "yes".green : "no".dim 168 + lines.append( 169 + " \("source:".dim) \(input.source)" 170 + + " \("chars:".dim) \(input.characters)" 171 + + " \("bytes:".dim) \(input.bytes)" 172 + + " \("enter:".dim) \(enterLabel)" 173 + ) 174 + 175 + if let wait = payload.wait { 176 + let exitLabel: String 177 + if let code = wait.exitCode { 178 + exitLabel = code == 0 ? "0".green : "\(code)".red.bold 179 + } else { 180 + exitLabel = "n/a".dim 181 + } 182 + let durationLabel = formatDurationMs(wait.durationMs) 183 + lines.append(" \("exit:".dim) \(exitLabel) \("duration:".dim) \(durationLabel)") 184 + } else { 185 + lines.append(" \("wait:".dim) \("none (fire-and-forget)".dim)") 186 + } 187 + 188 + return lines.joined(separator: "\n") 189 + } 190 + 191 + private static func formatDurationMs(_ ms: Int) -> String { 192 + if ms < 1000 { 193 + return "\(ms)ms" 194 + } 195 + let seconds = ms / 1000 196 + if seconds < 60 { 197 + return "\(seconds).\(String(format: "%03d", ms % 1000))s" 198 + } 199 + let minutes = seconds / 60 200 + let remainingSeconds = seconds % 60 201 + return remainingSeconds > 0 ? "\(minutes)m \(remainingSeconds)s" : "\(minutes)m" 144 202 } 145 203 146 204 private static func projectName(from path: String) -> String {
+271 -3
ProwlCLITests/ProwlCLIIntegrationTests.swift
··· 354 354 XCTAssertTrue(result.stdout.contains("App:main (running)"), "Missing header: \(result.stdout)") 355 355 } 356 356 357 + // MARK: - Send command tests 358 + 359 + func testSendCommandRoundTripsOverSocket() throws { 360 + let socketPath = temporarySocketPath(suffix: "send") 361 + let response = try CommandResponse( 362 + ok: true, 363 + command: "send", 364 + schemaVersion: "prowl.cli.send.v1", 365 + data: RawJSON(encoding: SendResponseData( 366 + target: SendResponseTarget( 367 + worktree: ListWorktree( 368 + id: "Prowl:/Projects/Prowl", name: "Prowl", 369 + path: "/Projects/Prowl", rootPath: "/Projects/Prowl", kind: "git" 370 + ), 371 + tab: SendResponseTab(id: "2FC00CF0-3974-4E1B-BEF8-7A08A8E3B7C0", title: "Prowl 1", selected: true), 372 + pane: SendResponsePane( 373 + id: "6E1A2A10-D99F-4E3F-920C-D93AA3C05764", 374 + title: "zsh", cwd: "/Projects/Prowl", focused: true 375 + ) 376 + ), 377 + input: SendResponseInput(source: "argv", characters: 10, bytes: 10, trailingEnterSent: true), 378 + createdTab: false, 379 + wait: SendResponseWait(exitCode: 0, durationMs: 1234) 380 + )) 381 + ) 382 + 383 + let (requestData, result) = try runWithMockServer( 384 + socketPath: socketPath, 385 + response: response, 386 + args: ["send", "echo hello", "--json"] 387 + ) 388 + 389 + XCTAssertEqual(result.exitCode, 0) 390 + let envelope = try JSONDecoder().decode(CommandEnvelope.self, from: requestData) 391 + if case .send(let input) = envelope.command { 392 + XCTAssertEqual(input.text, "echo hello") 393 + XCTAssertEqual(input.source, .argv) 394 + XCTAssertTrue(input.trailingEnter) 395 + XCTAssertTrue(input.wait) 396 + } else { 397 + XCTFail("Expected send command envelope") 398 + } 399 + 400 + let payload = try jsonObject(from: result.stdout) 401 + XCTAssertEqual(payload["ok"] as? Bool, true) 402 + XCTAssertEqual(payload["command"] as? String, "send") 403 + let data = try XCTUnwrap(payload["data"] as? [String: Any]) 404 + let wait = try XCTUnwrap(data["wait"] as? [String: Any]) 405 + XCTAssertEqual(wait["exit_code"] as? Int, 0) 406 + XCTAssertEqual(wait["duration_ms"] as? Int, 1234) 407 + } 408 + 409 + func testSendNoWaitJsonShowsNullWait() throws { 410 + let socketPath = temporarySocketPath(suffix: "send-no-wait") 411 + let response = try CommandResponse( 412 + ok: true, 413 + command: "send", 414 + schemaVersion: "prowl.cli.send.v1", 415 + data: RawJSON(encoding: SendResponseData( 416 + target: SendResponseTarget( 417 + worktree: ListWorktree( 418 + id: "wt-1", name: "main", 419 + path: "/Projects/App", rootPath: "/Projects/App", kind: "git" 420 + ), 421 + tab: SendResponseTab(id: "t1", title: "Tab 1", selected: true), 422 + pane: SendResponsePane(id: "p1", title: "zsh", cwd: "/Projects/App", focused: true) 423 + ), 424 + input: SendResponseInput(source: "argv", characters: 5, bytes: 5, trailingEnterSent: true), 425 + createdTab: false, 426 + wait: nil 427 + )) 428 + ) 429 + 430 + let (requestData, result) = try runWithMockServer( 431 + socketPath: socketPath, 432 + response: response, 433 + args: ["send", "hello", "--no-wait", "--json"] 434 + ) 435 + 436 + XCTAssertEqual(result.exitCode, 0) 437 + let envelope = try JSONDecoder().decode(CommandEnvelope.self, from: requestData) 438 + if case .send(let input) = envelope.command { 439 + XCTAssertFalse(input.wait) 440 + } else { 441 + XCTFail("Expected send command envelope") 442 + } 443 + 444 + let payload = try jsonObject(from: result.stdout) 445 + let data = try XCTUnwrap(payload["data"] as? [String: Any]) 446 + XCTAssertTrue(data["wait"] is NSNull, "wait should be null: \(data["wait"] ?? "missing")") 447 + } 448 + 449 + func testSendTextRenderingFromSocket() throws { 450 + let socketPath = temporarySocketPath(suffix: "send-text") 451 + let response = try CommandResponse( 452 + ok: true, 453 + command: "send", 454 + schemaVersion: "prowl.cli.send.v1", 455 + data: RawJSON(encoding: SendResponseData( 456 + target: SendResponseTarget( 457 + worktree: ListWorktree( 458 + id: "wt-1", name: "main", 459 + path: "/Projects/App", rootPath: "/Projects/App", kind: "git" 460 + ), 461 + tab: SendResponseTab(id: "t1", title: "Tab 1", selected: true), 462 + pane: SendResponsePane(id: "p1", title: "zsh", cwd: "/Projects/App", focused: true) 463 + ), 464 + input: SendResponseInput(source: "argv", characters: 10, bytes: 10, trailingEnterSent: true), 465 + createdTab: false, 466 + wait: SendResponseWait(exitCode: 0, durationMs: 350) 467 + )) 468 + ) 469 + 470 + let (_, result) = try runWithMockServer( 471 + socketPath: socketPath, 472 + response: response, 473 + args: ["send", "echo hello"] 474 + ) 475 + 476 + XCTAssertEqual(result.exitCode, 0) 477 + XCTAssertTrue(result.stdout.contains("Sent to"), "Missing 'Sent to' header: \(result.stdout)") 478 + XCTAssertTrue(result.stdout.contains("App:main"), "Missing worktree: \(result.stdout)") 479 + XCTAssertTrue(result.stdout.contains("zsh"), "Missing pane title: \(result.stdout)") 480 + XCTAssertTrue(result.stdout.contains("chars:"), "Missing chars label: \(result.stdout)") 481 + } 482 + 483 + func testSendNoColorProducesCleanOutput() throws { 484 + let socketPath = temporarySocketPath(suffix: "send-no-color") 485 + let response = try CommandResponse( 486 + ok: true, 487 + command: "send", 488 + schemaVersion: "prowl.cli.send.v1", 489 + data: RawJSON(encoding: SendResponseData( 490 + target: SendResponseTarget( 491 + worktree: ListWorktree( 492 + id: "wt-1", name: "main", 493 + path: "/Projects/App", rootPath: "/Projects/App", kind: "git" 494 + ), 495 + tab: SendResponseTab(id: "t1", title: "Tab 1", selected: true), 496 + pane: SendResponsePane(id: "p1", title: "zsh", cwd: "/Projects/App", focused: true) 497 + ), 498 + input: SendResponseInput(source: "argv", characters: 5, bytes: 5, trailingEnterSent: true), 499 + createdTab: false, 500 + wait: SendResponseWait(exitCode: 0, durationMs: 100) 501 + )) 502 + ) 503 + 504 + let (_, result) = try runWithMockServer( 505 + socketPath: socketPath, 506 + response: response, 507 + args: ["send", "hello", "--no-color"] 508 + ) 509 + 510 + XCTAssertEqual(result.exitCode, 0) 511 + XCTAssertFalse(result.stdout.contains("\u{1B}["), "Should not contain ANSI escape codes: \(result.stdout)") 512 + XCTAssertTrue(result.stdout.contains("Sent to"), "Missing header: \(result.stdout)") 513 + } 514 + 515 + func testSendEmptyInputReturnsError() throws { 516 + let result = try runProwl(args: ["send", "--json"]) 517 + XCTAssertNotEqual(result.exitCode, 0) 518 + let payload = try jsonObject(from: result.stdout) 519 + XCTAssertEqual(payload["ok"] as? Bool, false) 520 + let error = try XCTUnwrap(payload["error"] as? [String: Any]) 521 + XCTAssertEqual(error["code"] as? String, "EMPTY_INPUT") 522 + } 523 + 524 + func testSendTimeoutValidation() throws { 525 + let result = try runProwl(args: ["send", "hello", "--timeout", "0", "--json"]) 526 + XCTAssertNotEqual(result.exitCode, 0) 527 + let payload = try jsonObject(from: result.stdout) 528 + XCTAssertEqual(payload["ok"] as? Bool, false) 529 + let error = try XCTUnwrap(payload["error"] as? [String: Any]) 530 + XCTAssertEqual(error["code"] as? String, "INVALID_ARGUMENT") 531 + } 532 + 357 533 // MARK: - Helpers 358 534 359 535 private func runWithMockServer( ··· 378 554 379 555 private func runProwl( 380 556 args: [String], 381 - environment: [String: String] = [:] 557 + environment: [String: String] = [:], 558 + stdinData: Data? = nil 382 559 ) throws -> CommandResult { 383 560 let binaryPath = try ensureProwlBinary() 384 561 var mergedEnvironment = ProcessInfo.processInfo.environment ··· 389 566 executable: binaryPath, 390 567 arguments: args, 391 568 currentDirectory: repoRoot.path, 392 - environment: mergedEnvironment 569 + environment: mergedEnvironment, 570 + stdinData: stdinData 393 571 ) 394 572 } 395 573 ··· 417 595 executable: String, 418 596 arguments: [String], 419 597 currentDirectory: String, 420 - environment: [String: String]? = nil 598 + environment: [String: String]? = nil, 599 + stdinData: Data? = nil 421 600 ) throws -> CommandResult { 422 601 let process = Process() 423 602 process.executableURL = URL(fileURLWithPath: executable) ··· 432 611 process.standardOutput = stdoutPipe 433 612 process.standardError = stderrPipe 434 613 614 + if let stdinData { 615 + let stdinPipe = Pipe() 616 + process.standardInput = stdinPipe 617 + stdinPipe.fileHandleForWriting.write(stdinData) 618 + stdinPipe.fileHandleForWriting.closeFile() 619 + } else { 620 + // Use /dev/null so isatty(stdin) doesn't incorrectly report stdin data. 621 + process.standardInput = FileHandle.nullDevice 622 + } 623 + 435 624 try process.run() 436 625 process.waitUntilExit() 437 626 ··· 508 697 509 698 private struct ListTask: Encodable { 510 699 let status: String? 700 + } 701 + 702 + private struct SendResponseData: Encodable { 703 + let target: SendResponseTarget 704 + let input: SendResponseInput 705 + 706 + enum CodingKeys: String, CodingKey { 707 + case target 708 + case input 709 + case createdTab = "created_tab" 710 + case wait 711 + } 712 + 713 + let createdTab: Bool 714 + let wait: SendResponseWait? 715 + 716 + func encode(to encoder: Encoder) throws { 717 + var container = encoder.container(keyedBy: CodingKeys.self) 718 + try container.encode(target, forKey: .target) 719 + try container.encode(input, forKey: .input) 720 + try container.encode(createdTab, forKey: .createdTab) 721 + if let wait { 722 + try container.encode(wait, forKey: .wait) 723 + } else { 724 + try container.encodeNil(forKey: .wait) 725 + } 726 + } 727 + } 728 + 729 + private struct SendResponseTarget: Encodable { 730 + let worktree: ListWorktree 731 + let tab: SendResponseTab 732 + let pane: SendResponsePane 733 + } 734 + 735 + private struct SendResponseTab: Encodable { 736 + let id: String 737 + let title: String 738 + let selected: Bool 739 + } 740 + 741 + private struct SendResponsePane: Encodable { 742 + let id: String 743 + let title: String 744 + let cwd: String? 745 + let focused: Bool 746 + } 747 + 748 + private struct SendResponseInput: Encodable { 749 + let source: String 750 + let characters: Int 751 + let bytes: Int 752 + 753 + enum CodingKeys: String, CodingKey { 754 + case source 755 + case characters 756 + case bytes 757 + case trailingEnterSent = "trailing_enter_sent" 758 + } 759 + 760 + let trailingEnterSent: Bool 761 + 762 + func encode(to encoder: Encoder) throws { 763 + var container = encoder.container(keyedBy: CodingKeys.self) 764 + try container.encode(source, forKey: .source) 765 + try container.encode(characters, forKey: .characters) 766 + try container.encode(bytes, forKey: .bytes) 767 + try container.encode(trailingEnterSent, forKey: .trailingEnterSent) 768 + } 769 + } 770 + 771 + private struct SendResponseWait: Encodable { 772 + let exitCode: Int? 773 + let durationMs: Int 774 + 775 + enum CodingKeys: String, CodingKey { 776 + case exitCode = "exit_code" 777 + case durationMs = "duration_ms" 778 + } 511 779 } 512 780 513 781 private struct CommandResult {
+28 -1
supacode/App/supacodeApp.swift
··· 202 202 terminalManager: terminalManager 203 203 ) 204 204 } 205 - let cliRouter = CLICommandRouter(listHandler: listHandler) 205 + let sendHandler = SendCommandHandler( 206 + resolveProvider: { selector in 207 + let resolver = TargetResolver { 208 + TargetResolutionSnapshotBuilder.makeSnapshot( 209 + repositoriesState: appStore.state.repositories, 210 + terminalManager: terminalManager 211 + ) 212 + } 213 + return resolver.resolve(selector).map { SendResolvedTarget(from: $0) } 214 + }, 215 + textDelivery: { target, text, trailingEnter in 216 + guard let state = terminalManager.stateIfExists(for: target.worktreeID) else { return } 217 + let delivery = CLISendTextDelivery( 218 + insertText: { paneID, payload in 219 + state.insertCommittedText(payload, in: paneID) 220 + }, 221 + submitLine: { paneID in 222 + state.submitLine(in: paneID) 223 + } 224 + ) 225 + delivery.deliver(to: target, text: text, trailingEnter: trailingEnter) 226 + }, 227 + waiterProvider: { worktreeID, surfaceID in 228 + terminalManager.stateIfExists(for: worktreeID)? 229 + .waitForCommandFinished(surfaceID: surfaceID) 230 + } 231 + ) 232 + let cliRouter = CLICommandRouter(listHandler: listHandler, sendHandler: sendHandler) 206 233 let cliServer = CLISocketServer(router: cliRouter) 207 234 let logger = SupaLogger("CLIService") 208 235 do {
+5 -4
supacode/CLIService/ListRuntimeSnapshotBuilder.swift
··· 14 14 repositoriesState: RepositoriesFeature.State, 15 15 terminalManager: WorktreeTerminalManager 16 16 ) -> ListRuntimeSnapshot { 17 - let activeSnapshots = Dictionary(uniqueKeysWithValues: terminalManager.activeWorktreeStates.map { 18 - ($0.worktreeID, $0.makeCLIListSnapshot()) 19 - }) 17 + let activeSnapshots = Dictionary( 18 + uniqueKeysWithValues: terminalManager.activeWorktreeStates.map { 19 + ($0.worktreeID, $0.makeCLIListSnapshot()) 20 + }) 20 21 21 22 let orderedContexts = orderedWorktreeContexts(from: repositoriesState) 22 23 let focusedWorktreeID = terminalManager.selectedWorktreeID ?? terminalManager.canvasFocusedWorktreeID ··· 66 67 return ListRuntimeSnapshot(worktrees: worktrees, focusedWorktreeID: focusedWorktreeID) 67 68 } 68 69 69 - private static func orderedWorktreeContexts(from repositoriesState: RepositoriesFeature.State) -> [WorktreeContext] { 70 + static func orderedWorktreeContexts(from repositoriesState: RepositoriesFeature.State) -> [WorktreeContext] { 70 71 var contexts: [WorktreeContext] = [] 71 72 let repositoriesByID = Dictionary(uniqueKeysWithValues: repositoriesState.repositories.map { ($0.id, $0) }) 72 73
+218
supacode/CLIService/SendCommandHandler.swift
··· 1 + // supacode/CLIService/SendCommandHandler.swift 2 + // Handles `prowl send` by resolving target, delivering text, and optionally waiting. 3 + 4 + import Foundation 5 + 6 + private let sendLogger = SupaLogger("SendCommandHandler") 7 + 8 + /// Resolved target metadata for payload construction (no live view reference). 9 + struct SendResolvedTarget: Sendable { 10 + let worktreeID: String 11 + let worktreeName: String 12 + let worktreePath: String 13 + let worktreeRootPath: String 14 + let worktreeKind: ListCommandWorktree.Kind 15 + let tabID: UUID 16 + let tabTitle: String 17 + let tabSelected: Bool 18 + let paneID: UUID 19 + let paneTitle: String 20 + let paneCWD: String? 21 + let paneFocused: Bool 22 + } 23 + 24 + extension SendResolvedTarget { 25 + init(from resolved: ResolvedTarget) { 26 + self.worktreeID = resolved.worktreeID 27 + self.worktreeName = resolved.worktreeName 28 + self.worktreePath = resolved.worktreePath 29 + self.worktreeRootPath = resolved.worktreeRootPath 30 + self.worktreeKind = resolved.worktreeKind 31 + self.tabID = resolved.tabID 32 + self.tabTitle = resolved.tabTitle 33 + self.tabSelected = resolved.tabSelected 34 + self.paneID = resolved.paneID 35 + self.paneTitle = resolved.paneTitle 36 + self.paneCWD = resolved.paneCWD 37 + self.paneFocused = resolved.paneFocused 38 + } 39 + } 40 + 41 + @MainActor 42 + struct CLISendTextDelivery { 43 + typealias InsertText = @MainActor (UUID, String) -> Bool 44 + typealias SubmitLine = @MainActor (UUID) -> Bool 45 + 46 + let insertText: InsertText 47 + let submitLine: SubmitLine 48 + 49 + func deliver(to target: SendResolvedTarget, text: String, trailingEnter: Bool) { 50 + _ = insertText(target.paneID, text) 51 + if trailingEnter { 52 + _ = submitLine(target.paneID) 53 + } 54 + } 55 + } 56 + 57 + @MainActor 58 + final class SendCommandHandler: CommandHandler { 59 + typealias ResolveProvider = @MainActor (TargetSelector) -> Result<SendResolvedTarget, TargetResolverError> 60 + typealias TextDelivery = @MainActor (SendResolvedTarget, String, Bool) -> Void 61 + typealias WaiterProvider = @MainActor (String, UUID) -> AsyncStream<(exitCode: Int?, durationMs: Int)>? 62 + 63 + private let resolveProvider: ResolveProvider 64 + private let textDelivery: TextDelivery 65 + private let waiterProvider: WaiterProvider 66 + 67 + init( 68 + resolveProvider: @escaping ResolveProvider, 69 + textDelivery: @escaping TextDelivery, 70 + waiterProvider: @escaping WaiterProvider 71 + ) { 72 + self.resolveProvider = resolveProvider 73 + self.textDelivery = textDelivery 74 + self.waiterProvider = waiterProvider 75 + } 76 + 77 + func handle(envelope: CommandEnvelope) async -> CommandResponse { 78 + guard case .send(let input) = envelope.command else { 79 + return errorResponse(code: CLIErrorCode.sendFailed, message: "Invalid command.") 80 + } 81 + 82 + // Resolve target 83 + let result = resolveProvider(input.selector) 84 + let target: SendResolvedTarget 85 + switch result { 86 + case .success(let resolved): 87 + target = resolved 88 + case .failure(let error): 89 + return mapResolverError(error) 90 + } 91 + 92 + let waitStream = input.wait ? waiterProvider(target.worktreeID, target.paneID) : nil 93 + 94 + // Deliver text (and optional Enter) 95 + textDelivery(target, input.text, input.trailingEnter) 96 + 97 + // Wait for command completion if requested 98 + let waitResult: SendWaitResult? 99 + if input.wait { 100 + waitResult = await waitForCompletion( 101 + stream: waitStream, 102 + timeoutSeconds: input.timeoutSeconds ?? 30 103 + ) 104 + if waitResult == nil { 105 + return errorResponse( 106 + code: CLIErrorCode.waitTimeout, 107 + message: "Timed out waiting for command to finish. " 108 + + "This may happen if the terminal does not have shell integration (OSC 133) enabled." 109 + ) 110 + } 111 + } else { 112 + waitResult = nil 113 + } 114 + 115 + // Build payload 116 + let payload = SendCommandPayload( 117 + target: makePayloadTarget(from: target), 118 + input: SendInputInfo( 119 + source: input.source.rawValue, 120 + characters: input.text.unicodeScalars.count, 121 + bytes: input.text.utf8.count, 122 + trailingEnterSent: input.trailingEnter 123 + ), 124 + createdTab: false, 125 + wait: waitResult 126 + ) 127 + 128 + do { 129 + return try CommandResponse( 130 + ok: true, 131 + command: "send", 132 + schemaVersion: "prowl.cli.send.v1", 133 + data: RawJSON(encoding: payload) 134 + ) 135 + } catch { 136 + sendLogger.warning("Failed to encode send payload: \(error)") 137 + return errorResponse(code: CLIErrorCode.sendFailed, message: "Failed to encode response.") 138 + } 139 + } 140 + 141 + // MARK: - Wait 142 + 143 + private func waitForCompletion( 144 + stream: AsyncStream<(exitCode: Int?, durationMs: Int)>?, 145 + timeoutSeconds: Int 146 + ) async -> SendWaitResult? { 147 + guard let stream else { 148 + return nil 149 + } 150 + 151 + // Race stream result against timeout using raw tuples (Sendable-safe). 152 + let raw: (exitCode: Int?, durationMs: Int)? = await withTaskGroup( 153 + of: (Int?, Int)?.self 154 + ) { group in 155 + group.addTask { 156 + for await result in stream { 157 + return (result.exitCode, result.durationMs) 158 + } 159 + return nil 160 + } 161 + 162 + group.addTask { 163 + try? await Task.sleep(for: .seconds(timeoutSeconds)) 164 + return nil 165 + } 166 + 167 + let first = await group.next() ?? nil 168 + group.cancelAll() 169 + return first 170 + } 171 + 172 + guard let raw else { return nil } 173 + return SendWaitResult(exitCode: raw.exitCode, durationMs: raw.durationMs) 174 + } 175 + 176 + // MARK: - Helpers 177 + 178 + private func makePayloadTarget(from target: SendResolvedTarget) -> SendTarget { 179 + SendTarget( 180 + worktree: SendTargetWorktree( 181 + id: target.worktreeID, 182 + name: target.worktreeName, 183 + path: target.worktreePath, 184 + rootPath: target.worktreeRootPath, 185 + kind: target.worktreeKind.rawValue 186 + ), 187 + tab: SendTargetTab( 188 + id: target.tabID.uuidString, 189 + title: target.tabTitle, 190 + selected: target.tabSelected 191 + ), 192 + pane: SendTargetPane( 193 + id: target.paneID.uuidString, 194 + title: target.paneTitle, 195 + cwd: target.paneCWD, 196 + focused: target.paneFocused 197 + ) 198 + ) 199 + } 200 + 201 + private func mapResolverError(_ error: TargetResolverError) -> CommandResponse { 202 + switch error { 203 + case .notFound(let message): 204 + return errorResponse(code: CLIErrorCode.targetNotFound, message: message) 205 + case .notUnique(let message): 206 + return errorResponse(code: CLIErrorCode.targetNotUnique, message: message) 207 + } 208 + } 209 + 210 + private func errorResponse(code: String, message: String) -> CommandResponse { 211 + CommandResponse( 212 + ok: false, 213 + command: "send", 214 + schemaVersion: "prowl.cli.send.v1", 215 + error: CommandError(code: code, message: message) 216 + ) 217 + } 218 + }
+1
supacode/CLIService/Shared/ErrorCodes.swift
··· 26 26 // Send 27 27 public static let emptyInput = "EMPTY_INPUT" 28 28 public static let sendFailed = "SEND_FAILED" 29 + public static let waitTimeout = "WAIT_TIMEOUT" 29 30 30 31 // Key 31 32 public static let invalidRepeat = "INVALID_REPEAT"
+15 -1
supacode/CLIService/Shared/InputModels.swift
··· 24 24 } 25 25 } 26 26 27 + public enum InputSource: String, Codable, Sendable { 28 + case argv 29 + case stdin 30 + } 31 + 27 32 public struct SendInput: Codable, Sendable { 28 33 public let selector: TargetSelector 29 34 public let text: String 30 35 public let trailingEnter: Bool 36 + public let source: InputSource 37 + public let wait: Bool 38 + public let timeoutSeconds: Int? 31 39 32 40 public init( 33 41 selector: TargetSelector = .none, 34 42 text: String, 35 - trailingEnter: Bool = true 43 + trailingEnter: Bool = true, 44 + source: InputSource = .argv, 45 + wait: Bool = true, 46 + timeoutSeconds: Int? = nil 36 47 ) { 37 48 self.selector = selector 38 49 self.text = text 39 50 self.trailingEnter = trailingEnter 51 + self.source = source 52 + self.wait = wait 53 + self.timeoutSeconds = timeoutSeconds 40 54 } 41 55 } 42 56
+128
supacode/CLIService/Shared/SendCommandPayload.swift
··· 1 + // ProwlShared/SendCommandPayload.swift 2 + // Success payload for `prowl send --json` matching send.md contract. 3 + 4 + import Foundation 5 + 6 + public struct SendCommandPayload: Codable, Sendable { 7 + public let target: SendTarget 8 + public let input: SendInputInfo 9 + public let createdTab: Bool 10 + public let wait: SendWaitResult? 11 + 12 + enum CodingKeys: String, CodingKey { 13 + case target 14 + case input 15 + case createdTab = "created_tab" 16 + case wait 17 + } 18 + 19 + public init( 20 + target: SendTarget, 21 + input: SendInputInfo, 22 + createdTab: Bool, 23 + wait: SendWaitResult? 24 + ) { 25 + self.target = target 26 + self.input = input 27 + self.createdTab = createdTab 28 + self.wait = wait 29 + } 30 + } 31 + 32 + public struct SendTarget: Codable, Sendable { 33 + public let worktree: SendTargetWorktree 34 + public let tab: SendTargetTab 35 + public let pane: SendTargetPane 36 + 37 + public init(worktree: SendTargetWorktree, tab: SendTargetTab, pane: SendTargetPane) { 38 + self.worktree = worktree 39 + self.tab = tab 40 + self.pane = pane 41 + } 42 + } 43 + 44 + public struct SendTargetWorktree: Codable, Sendable { 45 + public let id: String 46 + public let name: String 47 + public let path: String 48 + public let rootPath: String 49 + public let kind: String 50 + 51 + enum CodingKeys: String, CodingKey { 52 + case id 53 + case name 54 + case path 55 + case rootPath = "root_path" 56 + case kind 57 + } 58 + 59 + public init(id: String, name: String, path: String, rootPath: String, kind: String) { 60 + self.id = id 61 + self.name = name 62 + self.path = path 63 + self.rootPath = rootPath 64 + self.kind = kind 65 + } 66 + } 67 + 68 + public struct SendTargetTab: Codable, Sendable { 69 + public let id: String 70 + public let title: String 71 + public let selected: Bool 72 + 73 + public init(id: String, title: String, selected: Bool) { 74 + self.id = id 75 + self.title = title 76 + self.selected = selected 77 + } 78 + } 79 + 80 + public struct SendTargetPane: Codable, Sendable { 81 + public let id: String 82 + public let title: String 83 + public let cwd: String? 84 + public let focused: Bool 85 + 86 + public init(id: String, title: String, cwd: String?, focused: Bool) { 87 + self.id = id 88 + self.title = title 89 + self.cwd = cwd 90 + self.focused = focused 91 + } 92 + } 93 + 94 + public struct SendInputInfo: Codable, Sendable { 95 + public let source: String 96 + public let characters: Int 97 + public let bytes: Int 98 + public let trailingEnterSent: Bool 99 + 100 + enum CodingKeys: String, CodingKey { 101 + case source 102 + case characters 103 + case bytes 104 + case trailingEnterSent = "trailing_enter_sent" 105 + } 106 + 107 + public init(source: String, characters: Int, bytes: Int, trailingEnterSent: Bool) { 108 + self.source = source 109 + self.characters = characters 110 + self.bytes = bytes 111 + self.trailingEnterSent = trailingEnterSent 112 + } 113 + } 114 + 115 + public struct SendWaitResult: Codable, Sendable { 116 + public let exitCode: Int? 117 + public let durationMs: Int 118 + 119 + enum CodingKeys: String, CodingKey { 120 + case exitCode = "exit_code" 121 + case durationMs = "duration_ms" 122 + } 123 + 124 + public init(exitCode: Int?, durationMs: Int) { 125 + self.exitCode = exitCode 126 + self.durationMs = durationMs 127 + } 128 + }
+244 -15
supacode/CLIService/TargetResolver.swift
··· 1 1 // supacode/CLIService/TargetResolver.swift 2 2 // Resolves target selectors against current app state. 3 - // Scaffold — actual resolution depends on wiring to WorktreeTerminalManager. 4 3 5 4 import Foundation 5 + import GhosttyKit 6 + 7 + /// Fully resolved target with metadata for response payload. 8 + struct ResolvedTarget: Sendable { 9 + let worktreeID: String 10 + let worktreeName: String 11 + let worktreePath: String 12 + let worktreeRootPath: String 13 + let worktreeKind: ListCommandWorktree.Kind 14 + 15 + let tabID: UUID 16 + let tabTitle: String 17 + let tabSelected: Bool 18 + 19 + let paneID: UUID 20 + let paneTitle: String 21 + let paneCWD: String? 22 + let paneFocused: Bool 23 + 24 + let surfaceView: GhosttySurfaceView 25 + } 26 + 27 + enum TargetResolverError: Error { 28 + case notFound(String) 29 + case notUnique(String) 30 + } 6 31 7 32 @MainActor 8 33 final class TargetResolver { 9 - /// Resolve a target selector to concrete worktree/tab/pane IDs. 10 - /// Returns nil if the target cannot be found. 11 - func resolve(_ selector: TargetSelector) -> ResolvedTarget? { 12 - // Scaffold: resolution not yet wired to WorktreeTerminalManager. 13 - // Will be implemented when command handlers are built out. 34 + typealias SnapshotProvider = @MainActor () -> TargetResolutionSnapshot 35 + 36 + private let snapshotProvider: SnapshotProvider 37 + 38 + init(snapshotProvider: @escaping SnapshotProvider) { 39 + self.snapshotProvider = snapshotProvider 40 + } 41 + 42 + func resolve(_ selector: TargetSelector) -> Result<ResolvedTarget, TargetResolverError> { 43 + let snapshot = snapshotProvider() 14 44 switch selector { 15 45 case .none: 16 - return nil 17 - case .worktree, .tab, .pane: 18 - return nil 46 + return resolveNone(snapshot) 47 + case .worktree(let value): 48 + return resolveWorktree(value, snapshot) 49 + case .tab(let value): 50 + return resolveTab(value, snapshot) 51 + case .pane(let value): 52 + return resolvePane(value, snapshot) 53 + } 54 + } 55 + 56 + // MARK: - .none: focused worktree → selected tab → focused pane 57 + 58 + private func resolveNone(_ snapshot: TargetResolutionSnapshot) -> Result<ResolvedTarget, TargetResolverError> { 59 + guard let focusedWorktreeID = snapshot.focusedWorktreeID else { 60 + return .failure(.notFound("No focused worktree.")) 61 + } 62 + guard let worktree = snapshot.worktrees.first(where: { $0.id == focusedWorktreeID }) else { 63 + return .failure(.notFound("Focused worktree not found.")) 64 + } 65 + guard let tab = worktree.tabs.first(where: { $0.selected }) else { 66 + return .failure(.notFound("No selected tab in focused worktree.")) 67 + } 68 + guard let pane = tab.focusedPane else { 69 + return .failure(.notFound("No focused pane in selected tab.")) 70 + } 71 + return .success(makeTarget(worktree: worktree, tab: tab, pane: pane, focusedWorktreeID: focusedWorktreeID)) 72 + } 73 + 74 + // MARK: - .worktree: match by id, name, or path 75 + 76 + private func resolveWorktree( 77 + _ value: String, 78 + _ snapshot: TargetResolutionSnapshot 79 + ) -> Result<ResolvedTarget, TargetResolverError> { 80 + let matches = snapshot.worktrees.filter { worktree in 81 + worktree.id == value || worktree.name == value || worktree.path == value 82 + } 83 + guard !matches.isEmpty else { 84 + return .failure(.notFound("Worktree '\(value)' not found.")) 85 + } 86 + guard matches.count == 1 else { 87 + return .failure(.notUnique("Worktree '\(value)' matches \(matches.count) worktrees.")) 88 + } 89 + let worktree = matches[0] 90 + guard let tab = worktree.tabs.first(where: { $0.selected }) ?? worktree.tabs.first else { 91 + return .failure(.notFound("No tabs in worktree '\(value)'.")) 92 + } 93 + guard let pane = tab.focusedPane ?? tab.panes.first else { 94 + return .failure(.notFound("No panes in worktree '\(value)'.")) 95 + } 96 + return .success(makeTarget(worktree: worktree, tab: tab, pane: pane, focusedWorktreeID: snapshot.focusedWorktreeID)) 97 + } 98 + 99 + // MARK: - .tab: find by UUID 100 + 101 + private func resolveTab( 102 + _ value: String, 103 + _ snapshot: TargetResolutionSnapshot 104 + ) -> Result<ResolvedTarget, TargetResolverError> { 105 + guard let uuid = UUID(uuidString: value) else { 106 + return .failure(.notFound("Invalid tab UUID: '\(value)'.")) 107 + } 108 + for worktree in snapshot.worktrees { 109 + for tab in worktree.tabs where tab.id == uuid { 110 + guard let pane = tab.focusedPane ?? tab.panes.first else { 111 + return .failure(.notFound("No panes in tab '\(value)'.")) 112 + } 113 + return .success( 114 + makeTarget( 115 + worktree: worktree, 116 + tab: tab, 117 + pane: pane, 118 + focusedWorktreeID: snapshot.focusedWorktreeID 119 + )) 120 + } 121 + } 122 + return .failure(.notFound("Tab '\(value)' not found.")) 123 + } 124 + 125 + // MARK: - .pane: find by UUID across all worktrees/tabs 126 + 127 + private func resolvePane( 128 + _ value: String, 129 + _ snapshot: TargetResolutionSnapshot 130 + ) -> Result<ResolvedTarget, TargetResolverError> { 131 + guard let uuid = UUID(uuidString: value) else { 132 + return .failure(.notFound("Invalid pane UUID: '\(value)'.")) 133 + } 134 + for worktree in snapshot.worktrees { 135 + for tab in worktree.tabs { 136 + for pane in tab.panes where pane.id == uuid { 137 + return .success( 138 + makeTarget( 139 + worktree: worktree, 140 + tab: tab, 141 + pane: pane, 142 + focusedWorktreeID: snapshot.focusedWorktreeID 143 + )) 144 + } 145 + } 146 + } 147 + return .failure(.notFound("Pane '\(value)' not found.")) 148 + } 149 + 150 + // MARK: - Helpers 151 + 152 + private func makeTarget( 153 + worktree: TargetResolutionSnapshot.Worktree, 154 + tab: TargetResolutionSnapshot.Tab, 155 + pane: TargetResolutionSnapshot.Pane, 156 + focusedWorktreeID: String? 157 + ) -> ResolvedTarget { 158 + let isFocusedWorktree = worktree.id == focusedWorktreeID 159 + return ResolvedTarget( 160 + worktreeID: worktree.id, 161 + worktreeName: worktree.name, 162 + worktreePath: worktree.path, 163 + worktreeRootPath: worktree.rootPath, 164 + worktreeKind: worktree.kind, 165 + tabID: tab.id, 166 + tabTitle: tab.title, 167 + tabSelected: tab.selected, 168 + paneID: pane.id, 169 + paneTitle: pane.title, 170 + paneCWD: pane.cwd, 171 + paneFocused: isFocusedWorktree && tab.selected && pane.isFocusedInTab, 172 + surfaceView: pane.surfaceView 173 + ) 174 + } 175 + } 176 + 177 + // MARK: - Snapshot model (decouples from live state) 178 + 179 + struct TargetResolutionSnapshot: Sendable { 180 + struct Worktree: Sendable { 181 + let id: String 182 + let name: String 183 + let path: String 184 + let rootPath: String 185 + let kind: ListCommandWorktree.Kind 186 + let tabs: [Tab] 187 + } 188 + 189 + struct Tab: Sendable { 190 + let id: UUID 191 + let title: String 192 + let selected: Bool 193 + let panes: [Pane] 194 + let focusedPaneID: UUID? 195 + 196 + var focusedPane: Pane? { 197 + guard let focusedPaneID else { return nil } 198 + return panes.first { $0.id == focusedPaneID } 19 199 } 20 200 } 201 + 202 + struct Pane: @unchecked Sendable { 203 + let id: UUID 204 + let title: String 205 + let cwd: String? 206 + let isFocusedInTab: Bool 207 + let surfaceView: GhosttySurfaceView 208 + } 209 + 210 + let worktrees: [Worktree] 211 + let focusedWorktreeID: String? 21 212 } 22 213 23 - /// Placeholder for resolved target information. 24 - /// Will be populated with actual worktree/tab/pane data when wired. 25 - struct ResolvedTarget { 26 - let worktreeID: String 27 - let tabID: String 28 - let paneID: String 214 + // MARK: - Snapshot builder 215 + 216 + @MainActor 217 + enum TargetResolutionSnapshotBuilder { 218 + static func makeSnapshot( 219 + repositoriesState: RepositoriesFeature.State, 220 + terminalManager: WorktreeTerminalManager 221 + ) -> TargetResolutionSnapshot { 222 + let activeSnapshots = Dictionary( 223 + uniqueKeysWithValues: terminalManager.activeWorktreeStates.map { 224 + ($0.worktreeID, $0) 225 + } 226 + ) 227 + 228 + let orderedContexts = ListRuntimeSnapshotBuilder.orderedWorktreeContexts(from: repositoriesState) 229 + let focusedWorktreeID = terminalManager.selectedWorktreeID ?? terminalManager.canvasFocusedWorktreeID 230 + 231 + let worktrees: [TargetResolutionSnapshot.Worktree] = orderedContexts.compactMap { context in 232 + guard let state = activeSnapshots[context.id] else { return nil } 233 + let selectedTabID = state.tabManager.selectedTabId 234 + let tabs: [TargetResolutionSnapshot.Tab] = state.tabManager.tabs.compactMap { tab in 235 + let snapshot = state.makeCLISendSnapshot(for: tab.id) 236 + guard let snapshot else { return nil } 237 + return TargetResolutionSnapshot.Tab( 238 + id: tab.id.rawValue, 239 + title: tab.title, 240 + selected: tab.id == selectedTabID, 241 + panes: snapshot.panes, 242 + focusedPaneID: snapshot.focusedPaneID 243 + ) 244 + } 245 + guard !tabs.isEmpty else { return nil } 246 + return TargetResolutionSnapshot.Worktree( 247 + id: context.id, 248 + name: context.name, 249 + path: context.path, 250 + rootPath: context.rootPath, 251 + kind: context.kind, 252 + tabs: tabs 253 + ) 254 + } 255 + 256 + return TargetResolutionSnapshot(worktrees: worktrees, focusedWorktreeID: focusedWorktreeID) 257 + } 29 258 }
+79
supacode/Features/Terminal/Models/WorktreeTerminalState.swift
··· 37 37 private var commandFinishedNotificationEnabled = true 38 38 private var commandFinishedNotificationThreshold = 10 39 39 private var lastKeyInputTimeBySurface: [UUID: ContinuousClock.Instant] = [:] 40 + private var commandFinishedWaiters: [UUID: AsyncStream<(exitCode: Int?, durationMs: Int)>.Continuation] = [:] 40 41 var hasUnseenNotification: Bool { 41 42 notifications.contains { !$0.isRead } 42 43 } ··· 92 93 return surfaces[surfaceId] 93 94 } 94 95 96 + func surfaceView(for surfaceID: UUID) -> GhosttySurfaceView? { 97 + surfaces[surfaceID] 98 + } 99 + 95 100 @discardableResult 96 101 func insertCommittedText(_ text: String, in tabId: TerminalTabID) -> Bool { 97 102 guard let surface = surfaceView(for: tabId) else { return false } ··· 100 105 } 101 106 102 107 @discardableResult 108 + func insertCommittedText(_ text: String, in surfaceID: UUID) -> Bool { 109 + guard let surface = surfaceView(for: surfaceID) else { return false } 110 + surface.insertCommittedTextForBroadcast(text) 111 + return true 112 + } 113 + 114 + @discardableResult 103 115 func applyMirroredKey(_ key: MirroredTerminalKey, in tabId: TerminalTabID) -> Bool { 104 116 guard let surface = surfaceView(for: tabId) else { return false } 105 117 return surface.applyMirroredKeyForBroadcast(key) 118 + } 119 + 120 + @discardableResult 121 + func submitLine(in surfaceID: UUID) -> Bool { 122 + guard let surface = surfaceView(for: surfaceID) else { return false } 123 + return surface.submitLine() 106 124 } 107 125 108 126 var taskStatus: WorktreeTaskStatus { ··· 1199 1217 } 1200 1218 1201 1219 func handleCommandFinished(exitCode: Int?, durationNs: UInt64, surfaceId: UUID) { 1220 + // Notify CLI waiters unconditionally before applying notification filters. 1221 + if let continuation = commandFinishedWaiters.removeValue(forKey: surfaceId) { 1222 + let durationMs = Int(durationNs / 1_000_000) 1223 + continuation.yield((exitCode: exitCode, durationMs: durationMs)) 1224 + continuation.finish() 1225 + } 1226 + 1202 1227 guard commandFinishedNotificationEnabled else { return } 1203 1228 let durationSeconds = Int(durationNs / 1_000_000_000) 1204 1229 guard durationSeconds >= commandFinishedNotificationThreshold else { return } ··· 1516 1541 } 1517 1542 1518 1543 return fallbackTabTitle 1544 + } 1545 + } 1546 + 1547 + // MARK: - CLI Command Finished Waiting 1548 + 1549 + extension WorktreeTerminalState { 1550 + /// Returns an `AsyncStream` that yields exactly once when the command finishes 1551 + /// on the given surface. The caller should race this against a timeout. 1552 + func waitForCommandFinished(surfaceID: UUID) -> AsyncStream<(exitCode: Int?, durationMs: Int)> { 1553 + // Cancel any existing waiter for this surface. 1554 + commandFinishedWaiters[surfaceID]?.finish() 1555 + commandFinishedWaiters.removeValue(forKey: surfaceID) 1556 + 1557 + return AsyncStream { continuation in 1558 + commandFinishedWaiters[surfaceID] = continuation 1559 + continuation.onTermination = { [weak self] _ in 1560 + Task { @MainActor in 1561 + self?.commandFinishedWaiters.removeValue(forKey: surfaceID) 1562 + } 1563 + } 1564 + } 1565 + } 1566 + } 1567 + 1568 + // MARK: - CLI Send Snapshot 1569 + 1570 + struct CLISendTabSnapshot { 1571 + let focusedPaneID: UUID? 1572 + let panes: [TargetResolutionSnapshot.Pane] 1573 + } 1574 + 1575 + extension WorktreeTerminalState { 1576 + func makeCLISendSnapshot(for tabId: TerminalTabID) -> CLISendTabSnapshot? { 1577 + let paneIDs = trees[tabId]?.leaves().map(\.id) ?? [] 1578 + guard !paneIDs.isEmpty else { return nil } 1579 + 1580 + let focusedPaneID = focusedSurfaceIdByTab[tabId] 1581 + let panes: [TargetResolutionSnapshot.Pane] = paneIDs.compactMap { paneID in 1582 + guard let surfaceView = surfaces[paneID] else { return nil } 1583 + let cwd = inheritedSurfaceConfig( 1584 + fromSurfaceId: paneID, 1585 + context: GHOSTTY_SURFACE_CONTEXT_TAB 1586 + ).workingDirectory?.path(percentEncoded: false) 1587 + let title = paneTitle(surfaceID: paneID, fallbackTabTitle: "") 1588 + return TargetResolutionSnapshot.Pane( 1589 + id: paneID, 1590 + title: title, 1591 + cwd: cwd, 1592 + isFocusedInTab: paneID == focusedPaneID, 1593 + surfaceView: surfaceView 1594 + ) 1595 + } 1596 + 1597 + return CLISendTabSnapshot(focusedPaneID: focusedPaneID, panes: panes) 1519 1598 } 1520 1599 } 1521 1600
+288
supacodeTests/CLISendCommandHandlerTests.swift
··· 1 + import Foundation 2 + import Testing 3 + 4 + @testable import supacode 5 + 6 + @MainActor 7 + struct CLISendCommandHandlerTests { 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() -> SendResolvedTarget { 15 + SendResolvedTarget( 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<SendResolvedTarget, TargetResolverError> = .success(makeTarget()), 33 + waiterResult: (exitCode: Int?, durationMs: Int)? = nil, 34 + waiterDelay: Duration? = nil, 35 + textDelivery: (@MainActor (SendResolvedTarget, String, Bool) -> Void)? = nil 36 + ) -> SendCommandHandler { 37 + SendCommandHandler( 38 + resolveProvider: { _ in resolveResult }, 39 + textDelivery: textDelivery ?? { _, _, _ in }, 40 + waiterProvider: { _, _ in 41 + guard let waiterResult else { return nil } 42 + return AsyncStream { continuation in 43 + if let delay = waiterDelay { 44 + Task { 45 + try? await Task.sleep(for: delay) 46 + continuation.yield(waiterResult) 47 + continuation.finish() 48 + } 49 + } else { 50 + continuation.yield(waiterResult) 51 + continuation.finish() 52 + } 53 + } 54 + } 55 + ) 56 + } 57 + 58 + private static func makeEnvelope( 59 + text: String = "echo hello", 60 + trailingEnter: Bool = true, 61 + source: InputSource = .argv, 62 + wait: Bool = true, 63 + timeoutSeconds: Int? = nil 64 + ) -> CommandEnvelope { 65 + CommandEnvelope( 66 + output: .json, 67 + command: .send( 68 + SendInput( 69 + selector: .none, 70 + text: text, 71 + trailingEnter: trailingEnter, 72 + source: source, 73 + wait: wait, 74 + timeoutSeconds: timeoutSeconds 75 + )) 76 + ) 77 + } 78 + 79 + // MARK: - Tests 80 + 81 + @Test func successfulSendWithWait() async throws { 82 + let handler = Self.makeHandler( 83 + waiterResult: (exitCode: 0, durationMs: 1234) 84 + ) 85 + let response = await handler.handle(envelope: Self.makeEnvelope()) 86 + 87 + #expect(response.ok) 88 + #expect(response.command == "send") 89 + #expect(response.schemaVersion == "prowl.cli.send.v1") 90 + 91 + let payload = try #require(try response.data?.decode(as: SendCommandPayload.self)) 92 + #expect(payload.target.worktree.id == "Prowl:/Users/onevcat/Projects/Prowl") 93 + #expect(payload.target.worktree.kind == "git") 94 + #expect(payload.target.tab.id == Self.testTabID.uuidString) 95 + #expect(payload.target.tab.selected == true) 96 + #expect(payload.target.pane.id == Self.testPaneID.uuidString) 97 + #expect(payload.target.pane.focused == true) 98 + #expect(payload.input.source == "argv") 99 + #expect(payload.input.characters == 10) 100 + #expect(payload.input.bytes == 10) 101 + #expect(payload.input.trailingEnterSent == true) 102 + #expect(payload.createdTab == false) 103 + #expect(payload.wait?.exitCode == 0) 104 + #expect(payload.wait?.durationMs == 1234) 105 + } 106 + 107 + @Test func noWaitReturnsNullWait() async throws { 108 + let handler = Self.makeHandler() 109 + let response = await handler.handle(envelope: Self.makeEnvelope(wait: false)) 110 + 111 + #expect(response.ok) 112 + let payload = try #require(try response.data?.decode(as: SendCommandPayload.self)) 113 + #expect(payload.wait == nil) 114 + } 115 + 116 + @Test func timeoutReturnsWaitTimeoutError() async throws { 117 + let handler = Self.makeHandler( 118 + waiterResult: (exitCode: 0, durationMs: 5000), 119 + waiterDelay: .seconds(10) 120 + ) 121 + let response = await handler.handle( 122 + envelope: Self.makeEnvelope(timeoutSeconds: 1) 123 + ) 124 + 125 + #expect(response.ok == false) 126 + #expect(response.error?.code == CLIErrorCode.waitTimeout) 127 + } 128 + 129 + @Test func sourceFieldReflectsStdin() async throws { 130 + let handler = Self.makeHandler( 131 + waiterResult: (exitCode: 0, durationMs: 100) 132 + ) 133 + let response = await handler.handle( 134 + envelope: Self.makeEnvelope(source: .stdin) 135 + ) 136 + 137 + #expect(response.ok) 138 + let payload = try #require(try response.data?.decode(as: SendCommandPayload.self)) 139 + #expect(payload.input.source == "stdin") 140 + } 141 + 142 + @Test func multibyteCounts() async throws { 143 + let handler = Self.makeHandler( 144 + waiterResult: (exitCode: 0, durationMs: 50) 145 + ) 146 + // "café" = 4 unicode scalars, 5 UTF-8 bytes (é = 2 bytes) 147 + let response = await handler.handle( 148 + envelope: Self.makeEnvelope(text: "café") 149 + ) 150 + 151 + #expect(response.ok) 152 + let payload = try #require(try response.data?.decode(as: SendCommandPayload.self)) 153 + #expect(payload.input.characters == 4) 154 + #expect(payload.input.bytes == 5) 155 + } 156 + 157 + @Test func emojiCounts() async throws { 158 + let handler = Self.makeHandler( 159 + waiterResult: (exitCode: 0, durationMs: 50) 160 + ) 161 + // "hi👋" = 3 unicode scalars (hi + wave), 6 UTF-8 bytes (h=1, i=1, 👋=4) 162 + let response = await handler.handle( 163 + envelope: Self.makeEnvelope(text: "hi👋") 164 + ) 165 + 166 + #expect(response.ok) 167 + let payload = try #require(try response.data?.decode(as: SendCommandPayload.self)) 168 + #expect(payload.input.characters == 3) 169 + #expect(payload.input.bytes == 6) 170 + } 171 + 172 + @Test func trailingEnterSentMatchesInput() async throws { 173 + let handler = Self.makeHandler( 174 + waiterResult: (exitCode: 0, durationMs: 50) 175 + ) 176 + let response = await handler.handle( 177 + envelope: Self.makeEnvelope(trailingEnter: false) 178 + ) 179 + 180 + #expect(response.ok) 181 + let payload = try #require(try response.data?.decode(as: SendCommandPayload.self)) 182 + #expect(payload.input.trailingEnterSent == false) 183 + } 184 + 185 + @Test func targetNotFoundError() async throws { 186 + let handler = Self.makeHandler( 187 + resolveResult: .failure(.notFound("Worktree 'missing' not found.")) 188 + ) 189 + let response = await handler.handle(envelope: Self.makeEnvelope()) 190 + 191 + #expect(response.ok == false) 192 + #expect(response.error?.code == CLIErrorCode.targetNotFound) 193 + } 194 + 195 + @Test func targetNotUniqueError() async throws { 196 + let handler = Self.makeHandler( 197 + resolveResult: .failure(.notUnique("Worktree 'Prowl' matches 2 worktrees.")) 198 + ) 199 + let response = await handler.handle(envelope: Self.makeEnvelope()) 200 + 201 + #expect(response.ok == false) 202 + #expect(response.error?.code == CLIErrorCode.targetNotUnique) 203 + } 204 + 205 + @Test func waitWithNonZeroExitCode() async throws { 206 + let handler = Self.makeHandler( 207 + waiterResult: (exitCode: 1, durationMs: 500) 208 + ) 209 + let response = await handler.handle(envelope: Self.makeEnvelope()) 210 + 211 + #expect(response.ok) 212 + let payload = try #require(try response.data?.decode(as: SendCommandPayload.self)) 213 + #expect(payload.wait?.exitCode == 1) 214 + #expect(payload.wait?.durationMs == 500) 215 + } 216 + 217 + @Test func waitWithNullExitCode() async throws { 218 + let handler = Self.makeHandler( 219 + waiterResult: (exitCode: nil, durationMs: 200) 220 + ) 221 + let response = await handler.handle(envelope: Self.makeEnvelope()) 222 + 223 + #expect(response.ok) 224 + let payload = try #require(try response.data?.decode(as: SendCommandPayload.self)) 225 + #expect(payload.wait?.exitCode == nil) 226 + } 227 + 228 + @Test func waitRegistrationHappensBeforeTextDelivery() async throws { 229 + var continuation: AsyncStream<(exitCode: Int?, durationMs: Int)>.Continuation? 230 + 231 + let handler = SendCommandHandler( 232 + resolveProvider: { _ in .success(Self.makeTarget()) }, 233 + textDelivery: { _, _, _ in 234 + continuation?.yield((exitCode: 0, durationMs: 1)) 235 + continuation?.finish() 236 + }, 237 + waiterProvider: { _, _ in 238 + AsyncStream { streamContinuation in 239 + continuation = streamContinuation 240 + } 241 + } 242 + ) 243 + 244 + let response = await handler.handle(envelope: Self.makeEnvelope(timeoutSeconds: 1)) 245 + 246 + #expect(response.ok) 247 + let payload = try #require(try response.data?.decode(as: SendCommandPayload.self)) 248 + #expect(payload.wait?.exitCode == 0) 249 + #expect(payload.wait?.durationMs == 1) 250 + } 251 + 252 + @Test func textDeliveryReceivesCorrectArguments() async throws { 253 + var deliveredText: String? 254 + var deliveredTrailingEnter: Bool? 255 + 256 + let handler = Self.makeHandler( 257 + waiterResult: (exitCode: 0, durationMs: 10), 258 + textDelivery: { _, text, trailingEnter in 259 + deliveredText = text 260 + deliveredTrailingEnter = trailingEnter 261 + } 262 + ) 263 + _ = await handler.handle(envelope: Self.makeEnvelope(text: "git status", trailingEnter: false)) 264 + 265 + #expect(deliveredText == "git status") 266 + #expect(deliveredTrailingEnter == false) 267 + } 268 + 269 + @Test func deliveryTargetsResolvedPane() { 270 + var insertedPaneID: UUID? 271 + var submittedPaneID: UUID? 272 + let delivery = CLISendTextDelivery( 273 + insertText: { paneID, _ in 274 + insertedPaneID = paneID 275 + return true 276 + }, 277 + submitLine: { paneID in 278 + submittedPaneID = paneID 279 + return true 280 + } 281 + ) 282 + 283 + delivery.deliver(to: Self.makeTarget(), text: "echo hi", trailingEnter: true) 284 + 285 + #expect(insertedPaneID == Self.testPaneID) 286 + #expect(submittedPaneID == Self.testPaneID) 287 + } 288 + }