···3030 @Flag(name: .long, help: "Return immediately without waiting for command completion.")
3131 var noWait = false
32323333+ @Flag(name: .long, help: "Capture screen output produced by the command and include it in the response.")
3434+ var capture = false
3535+3336 @Option(name: .long, help: "Maximum seconds to wait for completion (1–300, default: 30).")
3437 var timeout: Int?
3538···4447 throw ExitError(
4548 code: CLIErrorCode.invalidArgument,
4649 message: "Timeout must be between 1 and 300 seconds."
5050+ )
5151+ }
5252+5353+ if capture && noWait {
5454+ throw ExitError(
5555+ code: CLIErrorCode.invalidArgument,
5656+ message: "--capture requires waiting for command completion. Remove --no-wait."
5757+ )
5858+ }
5959+6060+ if capture && noEnter {
6161+ throw ExitError(
6262+ code: CLIErrorCode.invalidArgument,
6363+ message: "--capture requires a trailing Enter to run the command. Remove --no-enter."
4764 )
4865 }
4966···87104 trailingEnter: !noEnter,
88105 source: source,
89106 wait: !noWait,
9090- timeoutSeconds: timeout
107107+ timeoutSeconds: timeout,
108108+ captureOutput: capture
91109 ))
92110 )
93111 try CLIRunner.execute(envelope)
+19
ProwlCLI/Output/OutputRenderer.swift
···213213 lines.append(" \("wait:".dim) \("none (fire-and-forget)".dim)")
214214 }
215215216216+ if let capture = payload.capture {
217217+ let truncLabel = capture.truncated ? " (truncated)".yellow : ""
218218+ lines.append(
219219+ " \("capture:".dim) \(capture.lineCount) lines"
220220+ + " (\(capture.source.rawValue)\(truncLabel))"
221221+ )
222222+ if !capture.text.isEmpty {
223223+ lines.append(" \("--- output ---".dim)")
224224+ let outputLines = capture.text.split(separator: "\n", omittingEmptySubsequences: false)
225225+ let maxDisplay = 100
226226+ for line in outputLines.prefix(maxDisplay) {
227227+ lines.append(" \(line)")
228228+ }
229229+ if outputLines.count > maxDisplay {
230230+ lines.append(" \("... (\(outputLines.count - maxDisplay) more lines)".dim)")
231231+ }
232232+ }
233233+ }
234234+216235 return lines.joined(separator: "\n")
217236 }
218237
+12
supacode/App/supacodeApp.swift
···255255 waiterProvider: { worktreeID, surfaceID in
256256 terminalManager.stateIfExists(for: worktreeID)?
257257 .waitForCommandFinished(surfaceID: surfaceID)
258258+ },
259259+ captureProvider: { target in
260260+ guard let state = terminalManager.stateIfExists(for: target.worktreeID),
261261+ let surface = state.surfaceView(for: target.paneID),
262262+ let viewportText = surface.readViewportContentsForCLI()
263263+ else {
264264+ return nil
265265+ }
266266+ return ReadCaptureInput(
267267+ viewportText: viewportText,
268268+ screenText: surface.readScreenContentsForCLI()
269269+ )
258270 }
259271 )
260272 let focusHandler = FocusCommandHandler(
+112-2
supacode/CLIService/SendCommandHandler.swift
···5959 typealias ResolveProvider = @MainActor (TargetSelector) -> Result<SendResolvedTarget, TargetResolverError>
6060 typealias TextDelivery = @MainActor (SendResolvedTarget, String, Bool) -> Void
6161 typealias WaiterProvider = @MainActor (String, UUID) -> AsyncStream<(exitCode: Int?, durationMs: Int)>?
6262+ typealias CaptureProvider = @MainActor (SendResolvedTarget) -> ReadCaptureInput?
62636364 private let resolveProvider: ResolveProvider
6465 private let textDelivery: TextDelivery
6566 private let waiterProvider: WaiterProvider
6767+ private let captureProvider: CaptureProvider?
66686769 init(
6870 resolveProvider: @escaping ResolveProvider,
6971 textDelivery: @escaping TextDelivery,
7070- waiterProvider: @escaping WaiterProvider
7272+ waiterProvider: @escaping WaiterProvider,
7373+ captureProvider: CaptureProvider? = nil
7174 ) {
7275 self.resolveProvider = resolveProvider
7376 self.textDelivery = textDelivery
7477 self.waiterProvider = waiterProvider
7878+ self.captureProvider = captureProvider
7579 }
76807781 func handle(envelope: CommandEnvelope) async -> CommandResponse {
···7983 return errorResponse(code: CLIErrorCode.sendFailed, message: "Invalid command.")
8084 }
81858686+ // Validate capture constraints
8787+ if input.captureOutput {
8888+ if !input.wait {
8989+ return errorResponse(
9090+ code: CLIErrorCode.invalidArgument,
9191+ message: "--capture requires waiting for command completion."
9292+ )
9393+ }
9494+ if !input.trailingEnter {
9595+ return errorResponse(
9696+ code: CLIErrorCode.invalidArgument,
9797+ message: "--capture requires a trailing Enter to run the command."
9898+ )
9999+ }
100100+ }
101101+82102 // Resolve target
83103 let result = resolveProvider(input.selector)
84104 let target: SendResolvedTarget
···9111192112 let waitStream = input.wait ? waiterProvider(target.worktreeID, target.paneID) : nil
93113114114+ // If capture is requested but the pane has no shell integration (no wait stream),
115115+ // reject early with CAPTURE_UNSUPPORTED — do not send text and fall through to timeout.
116116+ if input.captureOutput && waitStream == nil {
117117+ return errorResponse(
118118+ code: CLIErrorCode.captureUnsupported,
119119+ message: "--capture requires shell integration (OSC 133) on the target pane. "
120120+ + "This pane does not appear to support it."
121121+ )
122122+ }
123123+124124+ // Pre-capture snapshot (before text delivery)
125125+ let preCapture: ReadCaptureInput? = input.captureOutput ? captureProvider?(target) : nil
126126+94127 // Deliver text (and optional Enter)
95128 textDelivery(target, input.text, input.trailingEnter)
96129···112145 waitResult = nil
113146 }
114147148148+ // Post-capture snapshot (after completion) and diff
149149+ let capturedOutput: CapturedOutput?
150150+ if input.captureOutput {
151151+ if let pre = preCapture, let post = captureProvider?(target) {
152152+ capturedOutput = diffCapture(pre: pre, post: post, commandText: input.text)
153153+ } else {
154154+ capturedOutput = nil
155155+ }
156156+ } else {
157157+ capturedOutput = nil
158158+ }
159159+115160 // Build payload
116161 let payload = SendCommandPayload(
117162 target: makePayloadTarget(from: target),
···122167 trailingEnterSent: input.trailingEnter
123168 ),
124169 createdTab: false,
125125- wait: waitResult
170170+ wait: waitResult,
171171+ capture: capturedOutput
126172 )
127173128174 do {
···136182 sendLogger.warning("Failed to encode send payload: \(error)")
137183 return errorResponse(code: CLIErrorCode.sendFailed, message: "Failed to encode response.")
138184 }
185185+ }
186186+187187+ // MARK: - Capture Diff
188188+189189+ private func diffCapture(pre: ReadCaptureInput, post: ReadCaptureInput, commandText: String) -> CapturedOutput {
190190+ let preText = pre.screenText ?? pre.viewportText
191191+ let postText = post.screenText ?? post.viewportText
192192+193193+ // Trim trailing whitespace-only lines from both snapshots (screen buffer padding)
194194+ let preLines = trimTrailingBlankLines(splitLines(preText))
195195+ let postLines = trimTrailingBlankLines(splitLines(postText))
196196+197197+ // If post has fewer lines than pre, the screen was cleared — return all of post as truncated
198198+ if postLines.count < preLines.count {
199199+ let text = postText
200200+ let count = postLines.isEmpty ? 0 : postLines.count
201201+ return CapturedOutput(text: text, lineCount: count, source: .screenDiff, truncated: true)
202202+ }
203203+204204+ // Find common prefix length
205205+ let commonPrefixLength = zip(preLines, postLines).prefix(while: { $0 == $1 }).count
206206+207207+ // New lines are everything after the common prefix in post
208208+ var newLines = Array(postLines.dropFirst(commonPrefixLength))
209209+210210+ // Strip echoed command line: if first new line matches command (trimmed) or ends with it (e.g. "$ cmd")
211211+ let trimmedCommand = commandText.trimmingCharacters(in: .whitespacesAndNewlines)
212212+ if let first = newLines.first {
213213+ let firstStr = String(first).trimmingCharacters(in: .whitespacesAndNewlines)
214214+ if firstStr == trimmedCommand || firstStr.hasSuffix(trimmedCommand) {
215215+ newLines = Array(newLines.dropFirst())
216216+ }
217217+ }
218218+219219+ // Strip trailing prompt line: if the last new line matches the last line of pre (e.g. "$ "),
220220+ // remove it once — it's the new prompt after the command finished.
221221+ if let lastPre = preLines.last, let lastNew = newLines.last, lastNew == lastPre {
222222+ newLines = Array(newLines.dropLast())
223223+ }
224224+225225+ // Trim trailing empty lines (screen buffer padding / blank lines after output)
226226+ while let last = newLines.last, String(last).trimmingCharacters(in: .whitespaces).isEmpty {
227227+ newLines = Array(newLines.dropLast())
228228+ }
229229+230230+ if newLines.isEmpty {
231231+ return CapturedOutput(text: "", lineCount: 0, source: .screenDiff, truncated: false)
232232+ }
233233+234234+ let resultText = newLines.map(String.init).joined(separator: "\n")
235235+ return CapturedOutput(text: resultText, lineCount: newLines.count, source: .screenDiff, truncated: false)
236236+ }
237237+238238+ private func splitLines(_ text: String) -> [Substring] {
239239+ guard !text.isEmpty else { return [] }
240240+ return text.split(separator: "\n", omittingEmptySubsequences: false)
241241+ }
242242+243243+ private func trimTrailingBlankLines(_ lines: [Substring]) -> [Substring] {
244244+ var result = lines
245245+ while let last = result.last, last.trimmingCharacters(in: .whitespaces).isEmpty {
246246+ result.removeLast()
247247+ }
248248+ return result
139249 }
140250141251 // MARK: - Wait
+1
supacode/CLIService/Shared/ErrorCodes.swift
···2727 public static let emptyInput = "EMPTY_INPUT"
2828 public static let sendFailed = "SEND_FAILED"
2929 public static let waitTimeout = "WAIT_TIMEOUT"
3030+ public static let captureUnsupported = "CAPTURE_UNSUPPORTED"
30313132 // Key
3233 public static let invalidRepeat = "INVALID_REPEAT"
+14-1
supacode/CLIService/Shared/InputModels.swift
···4646 public let source: InputSource
4747 public let wait: Bool
4848 public let timeoutSeconds: Int?
4949+ public let captureOutput: Bool
5050+5151+ enum CodingKeys: String, CodingKey {
5252+ case selector
5353+ case text
5454+ case trailingEnter = "trailing_enter"
5555+ case source
5656+ case wait
5757+ case timeoutSeconds = "timeout_seconds"
5858+ case captureOutput = "capture_output"
5959+ }
49605061 public init(
5162 selector: TargetSelector = .none,
···5364 trailingEnter: Bool = true,
5465 source: InputSource = .argv,
5566 wait: Bool = true,
5656- timeoutSeconds: Int? = nil
6767+ timeoutSeconds: Int? = nil,
6868+ captureOutput: Bool = false
5769 ) {
5870 self.selector = selector
5971 self.text = text
···6173 self.source = source
6274 self.wait = wait
6375 self.timeoutSeconds = timeoutSeconds
7676+ self.captureOutput = captureOutput
6477 }
6578}
6679