native macOS codings agent orchestrator
6
fork

Configure Feed

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

Merge pull request #148 from onevcat/friday/issue-138-send-capture

feat(cli-send): add --capture flag for screen buffer diff output

authored by

onevclaw and committed by
GitHub
1c01403c e278bcab

+412 -9
+19 -1
ProwlCLI/Commands/SendCommand.swift
··· 30 30 @Flag(name: .long, help: "Return immediately without waiting for command completion.") 31 31 var noWait = false 32 32 33 + @Flag(name: .long, help: "Capture screen output produced by the command and include it in the response.") 34 + var capture = false 35 + 33 36 @Option(name: .long, help: "Maximum seconds to wait for completion (1–300, default: 30).") 34 37 var timeout: Int? 35 38 ··· 44 47 throw ExitError( 45 48 code: CLIErrorCode.invalidArgument, 46 49 message: "Timeout must be between 1 and 300 seconds." 50 + ) 51 + } 52 + 53 + if capture && noWait { 54 + throw ExitError( 55 + code: CLIErrorCode.invalidArgument, 56 + message: "--capture requires waiting for command completion. Remove --no-wait." 57 + ) 58 + } 59 + 60 + if capture && noEnter { 61 + throw ExitError( 62 + code: CLIErrorCode.invalidArgument, 63 + message: "--capture requires a trailing Enter to run the command. Remove --no-enter." 47 64 ) 48 65 } 49 66 ··· 87 104 trailingEnter: !noEnter, 88 105 source: source, 89 106 wait: !noWait, 90 - timeoutSeconds: timeout 107 + timeoutSeconds: timeout, 108 + captureOutput: capture 91 109 )) 92 110 ) 93 111 try CLIRunner.execute(envelope)
+19
ProwlCLI/Output/OutputRenderer.swift
··· 213 213 lines.append(" \("wait:".dim) \("none (fire-and-forget)".dim)") 214 214 } 215 215 216 + if let capture = payload.capture { 217 + let truncLabel = capture.truncated ? " (truncated)".yellow : "" 218 + lines.append( 219 + " \("capture:".dim) \(capture.lineCount) lines" 220 + + " (\(capture.source.rawValue)\(truncLabel))" 221 + ) 222 + if !capture.text.isEmpty { 223 + lines.append(" \("--- output ---".dim)") 224 + let outputLines = capture.text.split(separator: "\n", omittingEmptySubsequences: false) 225 + let maxDisplay = 100 226 + for line in outputLines.prefix(maxDisplay) { 227 + lines.append(" \(line)") 228 + } 229 + if outputLines.count > maxDisplay { 230 + lines.append(" \("... (\(outputLines.count - maxDisplay) more lines)".dim)") 231 + } 232 + } 233 + } 234 + 216 235 return lines.joined(separator: "\n") 217 236 } 218 237
+12
supacode/App/supacodeApp.swift
··· 255 255 waiterProvider: { worktreeID, surfaceID in 256 256 terminalManager.stateIfExists(for: worktreeID)? 257 257 .waitForCommandFinished(surfaceID: surfaceID) 258 + }, 259 + captureProvider: { target in 260 + guard let state = terminalManager.stateIfExists(for: target.worktreeID), 261 + let surface = state.surfaceView(for: target.paneID), 262 + let viewportText = surface.readViewportContentsForCLI() 263 + else { 264 + return nil 265 + } 266 + return ReadCaptureInput( 267 + viewportText: viewportText, 268 + screenText: surface.readScreenContentsForCLI() 269 + ) 258 270 } 259 271 ) 260 272 let focusHandler = FocusCommandHandler(
+112 -2
supacode/CLIService/SendCommandHandler.swift
··· 59 59 typealias ResolveProvider = @MainActor (TargetSelector) -> Result<SendResolvedTarget, TargetResolverError> 60 60 typealias TextDelivery = @MainActor (SendResolvedTarget, String, Bool) -> Void 61 61 typealias WaiterProvider = @MainActor (String, UUID) -> AsyncStream<(exitCode: Int?, durationMs: Int)>? 62 + typealias CaptureProvider = @MainActor (SendResolvedTarget) -> ReadCaptureInput? 62 63 63 64 private let resolveProvider: ResolveProvider 64 65 private let textDelivery: TextDelivery 65 66 private let waiterProvider: WaiterProvider 67 + private let captureProvider: CaptureProvider? 66 68 67 69 init( 68 70 resolveProvider: @escaping ResolveProvider, 69 71 textDelivery: @escaping TextDelivery, 70 - waiterProvider: @escaping WaiterProvider 72 + waiterProvider: @escaping WaiterProvider, 73 + captureProvider: CaptureProvider? = nil 71 74 ) { 72 75 self.resolveProvider = resolveProvider 73 76 self.textDelivery = textDelivery 74 77 self.waiterProvider = waiterProvider 78 + self.captureProvider = captureProvider 75 79 } 76 80 77 81 func handle(envelope: CommandEnvelope) async -> CommandResponse { ··· 79 83 return errorResponse(code: CLIErrorCode.sendFailed, message: "Invalid command.") 80 84 } 81 85 86 + // Validate capture constraints 87 + if input.captureOutput { 88 + if !input.wait { 89 + return errorResponse( 90 + code: CLIErrorCode.invalidArgument, 91 + message: "--capture requires waiting for command completion." 92 + ) 93 + } 94 + if !input.trailingEnter { 95 + return errorResponse( 96 + code: CLIErrorCode.invalidArgument, 97 + message: "--capture requires a trailing Enter to run the command." 98 + ) 99 + } 100 + } 101 + 82 102 // Resolve target 83 103 let result = resolveProvider(input.selector) 84 104 let target: SendResolvedTarget ··· 91 111 92 112 let waitStream = input.wait ? waiterProvider(target.worktreeID, target.paneID) : nil 93 113 114 + // If capture is requested but the pane has no shell integration (no wait stream), 115 + // reject early with CAPTURE_UNSUPPORTED — do not send text and fall through to timeout. 116 + if input.captureOutput && waitStream == nil { 117 + return errorResponse( 118 + code: CLIErrorCode.captureUnsupported, 119 + message: "--capture requires shell integration (OSC 133) on the target pane. " 120 + + "This pane does not appear to support it." 121 + ) 122 + } 123 + 124 + // Pre-capture snapshot (before text delivery) 125 + let preCapture: ReadCaptureInput? = input.captureOutput ? captureProvider?(target) : nil 126 + 94 127 // Deliver text (and optional Enter) 95 128 textDelivery(target, input.text, input.trailingEnter) 96 129 ··· 112 145 waitResult = nil 113 146 } 114 147 148 + // Post-capture snapshot (after completion) and diff 149 + let capturedOutput: CapturedOutput? 150 + if input.captureOutput { 151 + if let pre = preCapture, let post = captureProvider?(target) { 152 + capturedOutput = diffCapture(pre: pre, post: post, commandText: input.text) 153 + } else { 154 + capturedOutput = nil 155 + } 156 + } else { 157 + capturedOutput = nil 158 + } 159 + 115 160 // Build payload 116 161 let payload = SendCommandPayload( 117 162 target: makePayloadTarget(from: target), ··· 122 167 trailingEnterSent: input.trailingEnter 123 168 ), 124 169 createdTab: false, 125 - wait: waitResult 170 + wait: waitResult, 171 + capture: capturedOutput 126 172 ) 127 173 128 174 do { ··· 136 182 sendLogger.warning("Failed to encode send payload: \(error)") 137 183 return errorResponse(code: CLIErrorCode.sendFailed, message: "Failed to encode response.") 138 184 } 185 + } 186 + 187 + // MARK: - Capture Diff 188 + 189 + private func diffCapture(pre: ReadCaptureInput, post: ReadCaptureInput, commandText: String) -> CapturedOutput { 190 + let preText = pre.screenText ?? pre.viewportText 191 + let postText = post.screenText ?? post.viewportText 192 + 193 + // Trim trailing whitespace-only lines from both snapshots (screen buffer padding) 194 + let preLines = trimTrailingBlankLines(splitLines(preText)) 195 + let postLines = trimTrailingBlankLines(splitLines(postText)) 196 + 197 + // If post has fewer lines than pre, the screen was cleared — return all of post as truncated 198 + if postLines.count < preLines.count { 199 + let text = postText 200 + let count = postLines.isEmpty ? 0 : postLines.count 201 + return CapturedOutput(text: text, lineCount: count, source: .screenDiff, truncated: true) 202 + } 203 + 204 + // Find common prefix length 205 + let commonPrefixLength = zip(preLines, postLines).prefix(while: { $0 == $1 }).count 206 + 207 + // New lines are everything after the common prefix in post 208 + var newLines = Array(postLines.dropFirst(commonPrefixLength)) 209 + 210 + // Strip echoed command line: if first new line matches command (trimmed) or ends with it (e.g. "$ cmd") 211 + let trimmedCommand = commandText.trimmingCharacters(in: .whitespacesAndNewlines) 212 + if let first = newLines.first { 213 + let firstStr = String(first).trimmingCharacters(in: .whitespacesAndNewlines) 214 + if firstStr == trimmedCommand || firstStr.hasSuffix(trimmedCommand) { 215 + newLines = Array(newLines.dropFirst()) 216 + } 217 + } 218 + 219 + // Strip trailing prompt line: if the last new line matches the last line of pre (e.g. "$ "), 220 + // remove it once — it's the new prompt after the command finished. 221 + if let lastPre = preLines.last, let lastNew = newLines.last, lastNew == lastPre { 222 + newLines = Array(newLines.dropLast()) 223 + } 224 + 225 + // Trim trailing empty lines (screen buffer padding / blank lines after output) 226 + while let last = newLines.last, String(last).trimmingCharacters(in: .whitespaces).isEmpty { 227 + newLines = Array(newLines.dropLast()) 228 + } 229 + 230 + if newLines.isEmpty { 231 + return CapturedOutput(text: "", lineCount: 0, source: .screenDiff, truncated: false) 232 + } 233 + 234 + let resultText = newLines.map(String.init).joined(separator: "\n") 235 + return CapturedOutput(text: resultText, lineCount: newLines.count, source: .screenDiff, truncated: false) 236 + } 237 + 238 + private func splitLines(_ text: String) -> [Substring] { 239 + guard !text.isEmpty else { return [] } 240 + return text.split(separator: "\n", omittingEmptySubsequences: false) 241 + } 242 + 243 + private func trimTrailingBlankLines(_ lines: [Substring]) -> [Substring] { 244 + var result = lines 245 + while let last = result.last, last.trimmingCharacters(in: .whitespaces).isEmpty { 246 + result.removeLast() 247 + } 248 + return result 139 249 } 140 250 141 251 // MARK: - Wait
+1
supacode/CLIService/Shared/ErrorCodes.swift
··· 27 27 public static let emptyInput = "EMPTY_INPUT" 28 28 public static let sendFailed = "SEND_FAILED" 29 29 public static let waitTimeout = "WAIT_TIMEOUT" 30 + public static let captureUnsupported = "CAPTURE_UNSUPPORTED" 30 31 31 32 // Key 32 33 public static let invalidRepeat = "INVALID_REPEAT"
+14 -1
supacode/CLIService/Shared/InputModels.swift
··· 46 46 public let source: InputSource 47 47 public let wait: Bool 48 48 public let timeoutSeconds: Int? 49 + public let captureOutput: Bool 50 + 51 + enum CodingKeys: String, CodingKey { 52 + case selector 53 + case text 54 + case trailingEnter = "trailing_enter" 55 + case source 56 + case wait 57 + case timeoutSeconds = "timeout_seconds" 58 + case captureOutput = "capture_output" 59 + } 49 60 50 61 public init( 51 62 selector: TargetSelector = .none, ··· 53 64 trailingEnter: Bool = true, 54 65 source: InputSource = .argv, 55 66 wait: Bool = true, 56 - timeoutSeconds: Int? = nil 67 + timeoutSeconds: Int? = nil, 68 + captureOutput: Bool = false 57 69 ) { 58 70 self.selector = selector 59 71 self.text = text ··· 61 73 self.source = source 62 74 self.wait = wait 63 75 self.timeoutSeconds = timeoutSeconds 76 + self.captureOutput = captureOutput 64 77 } 65 78 } 66 79
+30 -1
supacode/CLIService/Shared/SendCommandPayload.swift
··· 3 3 4 4 import Foundation 5 5 6 + public enum CaptureSource: String, Codable, Sendable { 7 + case screenDiff = "screen_diff" 8 + } 9 + 10 + public struct CapturedOutput: Codable, Sendable { 11 + public let text: String 12 + public let lineCount: Int 13 + public let source: CaptureSource 14 + public let truncated: Bool 15 + 16 + enum CodingKeys: String, CodingKey { 17 + case text 18 + case lineCount = "line_count" 19 + case source 20 + case truncated 21 + } 22 + 23 + public init(text: String, lineCount: Int, source: CaptureSource, truncated: Bool) { 24 + self.text = text 25 + self.lineCount = lineCount 26 + self.source = source 27 + self.truncated = truncated 28 + } 29 + } 30 + 6 31 public struct SendCommandPayload: Codable, Sendable { 7 32 public let target: SendTarget 8 33 public let input: SendInputInfo 9 34 public let createdTab: Bool 10 35 public let wait: SendWaitResult? 36 + public let capture: CapturedOutput? 11 37 12 38 enum CodingKeys: String, CodingKey { 13 39 case target 14 40 case input 15 41 case createdTab = "created_tab" 16 42 case wait 43 + case capture 17 44 } 18 45 19 46 public init( 20 47 target: SendTarget, 21 48 input: SendInputInfo, 22 49 createdTab: Bool, 23 - wait: SendWaitResult? 50 + wait: SendWaitResult?, 51 + capture: CapturedOutput? = nil 24 52 ) { 25 53 self.target = target 26 54 self.input = input 27 55 self.createdTab = createdTab 28 56 self.wait = wait 57 + self.capture = capture 29 58 } 30 59 } 31 60
+205 -4
supacodeTests/CLISendCommandHandlerTests.swift
··· 32 32 resolveResult: Result<SendResolvedTarget, TargetResolverError> = .success(makeTarget()), 33 33 waiterResult: (exitCode: Int?, durationMs: Int)? = nil, 34 34 waiterDelay: Duration? = nil, 35 - textDelivery: (@MainActor (SendResolvedTarget, String, Bool) -> Void)? = nil 35 + textDelivery: (@MainActor (SendResolvedTarget, String, Bool) -> Void)? = nil, 36 + captureProvider: (@MainActor (SendResolvedTarget) -> ReadCaptureInput?)? = nil 36 37 ) -> SendCommandHandler { 37 38 SendCommandHandler( 38 39 resolveProvider: { _ in resolveResult }, ··· 51 52 continuation.finish() 52 53 } 53 54 } 54 - } 55 + }, 56 + captureProvider: captureProvider 55 57 ) 56 58 } 57 59 ··· 60 62 trailingEnter: Bool = true, 61 63 source: InputSource = .argv, 62 64 wait: Bool = true, 63 - timeoutSeconds: Int? = nil 65 + timeoutSeconds: Int? = nil, 66 + captureOutput: Bool = false 64 67 ) -> CommandEnvelope { 65 68 CommandEnvelope( 66 69 output: .json, ··· 71 74 trailingEnter: trailingEnter, 72 75 source: source, 73 76 wait: wait, 74 - timeoutSeconds: timeoutSeconds 77 + timeoutSeconds: timeoutSeconds, 78 + captureOutput: captureOutput 75 79 )) 76 80 ) 77 81 } ··· 284 288 285 289 #expect(insertedPaneID == Self.testPaneID) 286 290 #expect(submittedPaneID == Self.testPaneID) 291 + } 292 + 293 + // MARK: - Capture Tests 294 + 295 + @Test func captureReturnsScreenDiff() async throws { 296 + let pre = ReadCaptureInput(viewportText: "line1\nline2", screenText: nil) 297 + let post = ReadCaptureInput(viewportText: "line1\nline2\noutput1\noutput2", screenText: nil) 298 + var callCount = 0 299 + let handler = Self.makeHandler( 300 + waiterResult: (exitCode: 0, durationMs: 10), 301 + captureProvider: { _ in 302 + defer { callCount += 1 } 303 + return callCount == 0 ? pre : post 304 + } 305 + ) 306 + let response = await handler.handle(envelope: Self.makeEnvelope(captureOutput: true)) 307 + 308 + #expect(response.ok) 309 + let payload = try #require(try response.data?.decode(as: SendCommandPayload.self)) 310 + let capture = try #require(payload.capture) 311 + #expect(capture.lineCount == 2) 312 + #expect(capture.source == .screenDiff) 313 + #expect(capture.text == "output1\noutput2") 314 + #expect(capture.truncated == false) 315 + } 316 + 317 + @Test func captureStripsEchoedCommand() async throws { 318 + let pre = ReadCaptureInput(viewportText: "$ ", screenText: nil) 319 + let post = ReadCaptureInput(viewportText: "$ \necho hello\nhello\n$ ", screenText: nil) 320 + var callCount = 0 321 + let handler = Self.makeHandler( 322 + waiterResult: (exitCode: 0, durationMs: 10), 323 + captureProvider: { _ in 324 + defer { callCount += 1 } 325 + return callCount == 0 ? pre : post 326 + } 327 + ) 328 + let response = await handler.handle( 329 + envelope: Self.makeEnvelope(text: "echo hello", captureOutput: true) 330 + ) 331 + 332 + #expect(response.ok) 333 + let payload = try #require(try response.data?.decode(as: SendCommandPayload.self)) 334 + let capture = try #require(payload.capture) 335 + #expect(capture.text == "hello") 336 + #expect(capture.lineCount == 1) 337 + } 338 + 339 + @Test func captureWithEmptyOutput() async throws { 340 + let snap = ReadCaptureInput(viewportText: "line1\nline2", screenText: nil) 341 + var callCount = 0 342 + let handler = Self.makeHandler( 343 + waiterResult: (exitCode: 0, durationMs: 10), 344 + captureProvider: { _ in 345 + defer { callCount += 1 } 346 + return snap 347 + } 348 + ) 349 + let response = await handler.handle(envelope: Self.makeEnvelope(captureOutput: true)) 350 + 351 + #expect(response.ok) 352 + let payload = try #require(try response.data?.decode(as: SendCommandPayload.self)) 353 + let capture = try #require(payload.capture) 354 + #expect(capture.text == "") 355 + #expect(capture.lineCount == 0) 356 + #expect(capture.truncated == false) 357 + } 358 + 359 + @Test func captureWithTruncation() async throws { 360 + let pre = ReadCaptureInput(viewportText: "line1\nline2\nline3", screenText: nil) 361 + let post = ReadCaptureInput(viewportText: "line1\nline2", screenText: nil) 362 + var callCount = 0 363 + let handler = Self.makeHandler( 364 + waiterResult: (exitCode: 0, durationMs: 10), 365 + captureProvider: { _ in 366 + defer { callCount += 1 } 367 + return callCount == 0 ? pre : post 368 + } 369 + ) 370 + let response = await handler.handle(envelope: Self.makeEnvelope(captureOutput: true)) 371 + 372 + #expect(response.ok) 373 + let payload = try #require(try response.data?.decode(as: SendCommandPayload.self)) 374 + let capture = try #require(payload.capture) 375 + #expect(capture.truncated == true) 376 + } 377 + 378 + @Test func captureNilWhenProviderFails() async throws { 379 + let handler = Self.makeHandler( 380 + waiterResult: (exitCode: 0, durationMs: 10), 381 + captureProvider: { _ in nil } 382 + ) 383 + let response = await handler.handle(envelope: Self.makeEnvelope(captureOutput: true)) 384 + 385 + #expect(response.ok) 386 + let payload = try #require(try response.data?.decode(as: SendCommandPayload.self)) 387 + #expect(payload.capture == nil) 388 + } 389 + 390 + @Test func captureNullWhenNotRequested() async throws { 391 + let handler = Self.makeHandler( 392 + waiterResult: (exitCode: 0, durationMs: 10) 393 + ) 394 + let response = await handler.handle(envelope: Self.makeEnvelope(captureOutput: false)) 395 + 396 + #expect(response.ok) 397 + let payload = try #require(try response.data?.decode(as: SendCommandPayload.self)) 398 + #expect(payload.capture == nil) 399 + } 400 + 401 + @Test func captureRequiresWaitValidation() async throws { 402 + let handler = Self.makeHandler() 403 + let response = await handler.handle( 404 + envelope: Self.makeEnvelope(wait: false, captureOutput: true) 405 + ) 406 + 407 + #expect(response.ok == false) 408 + #expect(response.error?.code == CLIErrorCode.invalidArgument) 409 + } 410 + 411 + @Test func captureRequiresEnterValidation() async throws { 412 + let handler = Self.makeHandler( 413 + waiterResult: (exitCode: 0, durationMs: 10) 414 + ) 415 + let response = await handler.handle( 416 + envelope: Self.makeEnvelope(trailingEnter: false, captureOutput: true) 417 + ) 418 + 419 + #expect(response.ok == false) 420 + #expect(response.error?.code == CLIErrorCode.invalidArgument) 421 + } 422 + 423 + @Test func captureWithRepeatedPromptLines() async throws { 424 + let pre = ReadCaptureInput(viewportText: "$ ", screenText: nil) 425 + let post = ReadCaptureInput(viewportText: "$ \n$ echo hello\nhello\n$ ", screenText: nil) 426 + var callCount = 0 427 + let handler = Self.makeHandler( 428 + waiterResult: (exitCode: 0, durationMs: 10), 429 + captureProvider: { _ in 430 + defer { callCount += 1 } 431 + return callCount == 0 ? pre : post 432 + } 433 + ) 434 + let response = await handler.handle( 435 + envelope: Self.makeEnvelope(text: "echo hello", captureOutput: true) 436 + ) 437 + 438 + #expect(response.ok) 439 + let payload = try #require(try response.data?.decode(as: SendCommandPayload.self)) 440 + let capture = try #require(payload.capture) 441 + // "$ echo hello" is echoed command, stripped; "hello" is the output; "$ " is trailing prompt (stripped) 442 + #expect(capture.text == "hello") 443 + #expect(capture.lineCount == 1) 444 + } 445 + 446 + @Test func captureUnsupportedWhenNoShellIntegration() async throws { 447 + // waiterProvider returns nil (no shell integration) → CAPTURE_UNSUPPORTED, not WAIT_TIMEOUT 448 + let handler = Self.makeHandler( 449 + waiterResult: nil, // nil means waiterProvider returns nil stream 450 + captureProvider: { _ in ReadCaptureInput(viewportText: "$ ", screenText: nil) } 451 + ) 452 + let response = await handler.handle( 453 + envelope: Self.makeEnvelope(captureOutput: true) 454 + ) 455 + 456 + #expect(response.ok == false) 457 + #expect(response.error?.code == CLIErrorCode.captureUnsupported) 458 + } 459 + 460 + @Test func captureMultilineOutputWithScreenPadding() async throws { 461 + // Simulates `ls` output with screen buffer padding (trailing blank lines) 462 + let pre = ReadCaptureInput( 463 + viewportText: "$ \n\n\n\n\n", 464 + screenText: "$ \n\n\n\n\n" 465 + ) 466 + let post = ReadCaptureInput( 467 + viewportText: "$ \nls\nfile1.txt\nfile2.txt\ndir1\n$ \n\n\n", 468 + screenText: "$ \nls\nfile1.txt\nfile2.txt\ndir1\n$ \n\n\n" 469 + ) 470 + var callCount = 0 471 + let handler = Self.makeHandler( 472 + waiterResult: (exitCode: 0, durationMs: 10), 473 + captureProvider: { _ in 474 + defer { callCount += 1 } 475 + return callCount == 0 ? pre : post 476 + } 477 + ) 478 + let response = await handler.handle( 479 + envelope: Self.makeEnvelope(text: "ls", captureOutput: true) 480 + ) 481 + 482 + #expect(response.ok) 483 + let payload = try #require(try response.data?.decode(as: SendCommandPayload.self)) 484 + let capture = try #require(payload.capture) 485 + #expect(capture.text == "file1.txt\nfile2.txt\ndir1") 486 + #expect(capture.lineCount == 3) 487 + #expect(capture.truncated == false) 287 488 } 288 489 }