Improve terminal command execution output parsing

This commit is contained in:
Chris Estreich 2025-02-28 09:38:48 -08:00
parent 1750247bca
commit 4b66ce8255
7 changed files with 591 additions and 257 deletions

View file

@ -22,7 +22,7 @@ import {
everyLineHasLineNumbers,
truncateOutput,
} from "../integrations/misc/extract-text"
import { TerminalManager } from "../integrations/terminal/TerminalManager"
import { TerminalManager, ExitCodeDetails } from "../integrations/terminal/TerminalManager"
import { UrlContentFetcher } from "../services/browser/UrlContentFetcher"
import { listFiles } from "../services/glob/list-files"
import { regexSearchFiles } from "../services/ripgrep"
@ -834,10 +834,21 @@ export class Cline {
})
let completed = false
process.once("completed", () => {
let exitDetails: ExitCodeDetails | undefined
process.once("completed", (output?: string) => {
// Use provided output if available, otherwise keep existing result.
if (output) {
lines = output.split("\n")
}
completed = true
})
process.once("shell_execution_complete", (id: number, details: ExitCodeDetails) => {
if (id === terminalInfo.id) {
exitDetails = details
}
})
process.once("no_shell_integration", async () => {
await this.say("shell_integration_warning")
})
@ -869,7 +880,18 @@ export class Cline {
}
if (completed) {
return [false, `Command executed.${result.length > 0 ? `\nOutput:\n${result}` : ""}`]
let exitStatus = "No exit code available"
if (exitDetails !== undefined) {
if (exitDetails.signal) {
exitStatus = `Process terminated by signal ${exitDetails.signal} (${exitDetails.signalName})`
if (exitDetails.coreDumpPossible) {
exitStatus += " - core dump possible"
}
} else {
exitStatus = `Exit code: ${exitDetails.exitCode}`
}
}
return [false, `Command executed. ${exitStatus}${result.length > 0 ? `\nOutput:\n${result}` : ""}`]
} else {
return [
false,

View file

@ -70,6 +70,15 @@ Interestingly, some environments like Cursor enable these APIs even without the
This approach allows us to leverage advanced features when available while ensuring broad compatibility.
*/
declare module "vscode" {
// https://github.com/microsoft/vscode/blob/f0417069c62e20f3667506f4b7e53ca0004b4e3e/src/vscode-dts/vscode.d.ts#L7442
// interface Terminal {
// shellIntegration?: {
// cwd?: vscode.Uri
// executeCommand?: (command: string) => {
// read: () => AsyncIterable<string>
// }
// }
// }
// https://github.com/microsoft/vscode/blob/f0417069c62e20f3667506f4b7e53ca0004b4e3e/src/vscode-dts/vscode.d.ts#L10794
interface Window {
onDidStartTerminalShellExecution?: (
@ -77,17 +86,19 @@ declare module "vscode" {
thisArgs?: any,
disposables?: vscode.Disposable[],
) => vscode.Disposable
onDidEndTerminalShellExecution?: (
listener: (e: { terminal: vscode.Terminal; exitCode?: number; shellType?: string }) => any,
thisArgs?: any,
disposables?: vscode.Disposable[],
) => vscode.Disposable
}
}
// Extend the Terminal type to include our custom properties
type ExtendedTerminal = vscode.Terminal & {
shellIntegration?: {
cwd?: vscode.Uri
executeCommand?: (command: string) => {
read: () => AsyncIterable<string>
}
}
export interface ExitCodeDetails {
exitCode: number | undefined
signal?: number | undefined
signalName?: string
coreDumpPossible?: boolean
}
export class TerminalManager {
@ -95,18 +106,156 @@ export class TerminalManager {
private processes: Map<number, TerminalProcess> = new Map()
private disposables: vscode.Disposable[] = []
private interpretExitCode(exitCode: number | undefined): ExitCodeDetails {
if (exitCode === undefined) {
return { exitCode }
}
if (exitCode <= 128) {
return { exitCode }
}
const signal = exitCode - 128
const signals: Record<number, string> = {
// Standard signals
1: "SIGHUP",
2: "SIGINT",
3: "SIGQUIT",
4: "SIGILL",
5: "SIGTRAP",
6: "SIGABRT",
7: "SIGBUS",
8: "SIGFPE",
9: "SIGKILL",
10: "SIGUSR1",
11: "SIGSEGV",
12: "SIGUSR2",
13: "SIGPIPE",
14: "SIGALRM",
15: "SIGTERM",
16: "SIGSTKFLT",
17: "SIGCHLD",
18: "SIGCONT",
19: "SIGSTOP",
20: "SIGTSTP",
21: "SIGTTIN",
22: "SIGTTOU",
23: "SIGURG",
24: "SIGXCPU",
25: "SIGXFSZ",
26: "SIGVTALRM",
27: "SIGPROF",
28: "SIGWINCH",
29: "SIGIO",
30: "SIGPWR",
31: "SIGSYS",
// Real-time signals base
34: "SIGRTMIN",
// SIGRTMIN+n signals
35: "SIGRTMIN+1",
36: "SIGRTMIN+2",
37: "SIGRTMIN+3",
38: "SIGRTMIN+4",
39: "SIGRTMIN+5",
40: "SIGRTMIN+6",
41: "SIGRTMIN+7",
42: "SIGRTMIN+8",
43: "SIGRTMIN+9",
44: "SIGRTMIN+10",
45: "SIGRTMIN+11",
46: "SIGRTMIN+12",
47: "SIGRTMIN+13",
48: "SIGRTMIN+14",
49: "SIGRTMIN+15",
// SIGRTMAX-n signals
50: "SIGRTMAX-14",
51: "SIGRTMAX-13",
52: "SIGRTMAX-12",
53: "SIGRTMAX-11",
54: "SIGRTMAX-10",
55: "SIGRTMAX-9",
56: "SIGRTMAX-8",
57: "SIGRTMAX-7",
58: "SIGRTMAX-6",
59: "SIGRTMAX-5",
60: "SIGRTMAX-4",
61: "SIGRTMAX-3",
62: "SIGRTMAX-2",
63: "SIGRTMAX-1",
64: "SIGRTMAX",
}
// These signals may produce core dumps:
// SIGQUIT, SIGILL, SIGABRT, SIGBUS, SIGFPE, SIGSEGV
const coreDumpPossible = new Set([3, 4, 6, 7, 8, 11])
return {
exitCode,
signal,
signalName: signals[signal] || `Unknown Signal (${signal})`,
coreDumpPossible: coreDumpPossible.has(signal),
}
}
constructor() {
let disposable: vscode.Disposable | undefined
let startDisposable: vscode.Disposable | undefined
let endDisposable: vscode.Disposable | undefined
try {
disposable = (vscode.window as vscode.Window).onDidStartTerminalShellExecution?.(async (e) => {
// Creating a read stream here results in a more consistent output. This is most obvious when running the `date` command.
e?.execution?.read()
// onDidStartTerminalShellExecution
startDisposable = (vscode.window as vscode.Window).onDidStartTerminalShellExecution?.(async (e) => {
// Get a handle to the stream as early as possible:
const stream = e?.execution.read()
const terminalInfo = TerminalRegistry.getTerminalInfoByTerminal(e.terminal)
if (stream && terminalInfo) {
const process = this.processes.get(terminalInfo.id)
if (process) {
terminalInfo.stream = stream
terminalInfo.running = true
terminalInfo.streamClosed = false
process.emit("stream_available", terminalInfo.id, stream)
}
} else {
console.error("[TerminalManager] Stream failed, not registered for terminal")
}
console.info("[TerminalManager] Shell execution started:", {
hasExecution: !!e?.execution,
command: e?.execution?.commandLine?.value,
terminalId: terminalInfo?.id,
})
})
// onDidEndTerminalShellExecution
endDisposable = (vscode.window as vscode.Window).onDidEndTerminalShellExecution?.(async (e) => {
const exitDetails = this.interpretExitCode(e?.exitCode)
console.info("[TerminalManager] Shell execution ended:", {
...exitDetails,
})
// Signal completion to any waiting processes
for (const id of this.terminalIds) {
const info = TerminalRegistry.getTerminal(id)
if (info && info.terminal === e.terminal) {
info.running = false
const process = this.processes.get(id)
if (process) {
process.emit("shell_execution_complete", id, exitDetails)
}
break
}
}
})
} catch (error) {
// console.error("Error setting up onDidEndTerminalShellExecution", error)
console.error("[TerminalManager] Error setting up shell execution handlers:", error)
}
if (disposable) {
this.disposables.push(disposable)
if (startDisposable) {
this.disposables.push(startDisposable)
}
if (endDisposable) {
this.disposables.push(endDisposable)
}
}
@ -140,19 +289,16 @@ export class TerminalManager {
})
// if shell integration is already active, run the command immediately
const terminal = terminalInfo.terminal as ExtendedTerminal
if (terminal.shellIntegration) {
if (terminalInfo.terminal.shellIntegration) {
process.waitForShellIntegration = false
process.run(terminal, command)
process.run(terminalInfo.terminal, command)
} else {
// docs recommend waiting 3s for shell integration to activate
pWaitFor(() => (terminalInfo.terminal as ExtendedTerminal).shellIntegration !== undefined, {
timeout: 4000,
}).finally(() => {
pWaitFor(() => terminalInfo.terminal.shellIntegration !== undefined, { timeout: 4000 }).finally(() => {
const existingProcess = this.processes.get(terminalInfo.id)
if (existingProcess && existingProcess.waitForShellIntegration) {
existingProcess.waitForShellIntegration = false
existingProcess.run(terminal, command)
existingProcess.run(terminalInfo.terminal, command)
}
})
}
@ -168,8 +314,7 @@ export class TerminalManager {
if (t.busy) {
return false
}
const terminal = t.terminal as ExtendedTerminal
const terminalCwd = terminal.shellIntegration?.cwd // one of cline's commands could have changed the cwd of the terminal
const terminalCwd = t.terminal.shellIntegration?.cwd // one of cline's commands could have changed the cwd of the terminal
if (!terminalCwd) {
return false
}

View file

@ -1,13 +1,24 @@
import { EventEmitter } from "events"
import stripAnsi from "strip-ansi"
import * as vscode from "vscode"
import { inspect } from "util"
import { ExitCodeDetails } from "./TerminalManager"
import { TerminalInfo, TerminalRegistry } from "./TerminalRegistry"
export interface TerminalProcessEvents {
line: [line: string]
continue: []
completed: []
completed: [output?: string]
error: [error: Error]
no_shell_integration: []
/**
* Emitted when a shell execution completes
* @param id The terminal ID
* @param exitDetails Contains exit code and signal information if process was terminated by signal
*/
shell_execution_complete: [id: number, exitDetails: ExitCodeDetails]
stream_available: [id: number, stream: AsyncIterable<string>]
}
// how long to wait after a process outputs anything before we consider it "cool" again
@ -17,104 +28,99 @@ const PROCESS_HOT_TIMEOUT_COMPILING = 15_000
export class TerminalProcess extends EventEmitter<TerminalProcessEvents> {
waitForShellIntegration: boolean = true
private isListening: boolean = true
private buffer: string = ""
private terminalInfo: TerminalInfo | undefined
private lastEmitTime_ms: number = 0
private fullOutput: string = ""
private lastRetrievedIndex: number = 0
isHot: boolean = false
private hotTimer: NodeJS.Timeout | null = null
// constructor() {
// super()
async run(terminal: vscode.Terminal, command: string) {
if (terminal.shellIntegration && terminal.shellIntegration.executeCommand) {
const execution = terminal.shellIntegration.executeCommand(command)
const stream = execution.read()
// todo: need to handle errors
let isFirstChunk = true
let didOutputNonCommand = false
let didEmitEmptyLine = false
// Get terminal info to access stream
const terminalInfo = TerminalRegistry.getTerminalInfoByTerminal(terminal)
if (!terminalInfo) {
console.error("[TerminalProcess] Terminal not found in registry")
this.emit("no_shell_integration")
this.emit("completed")
this.emit("continue")
return
}
// When executeCommand() is called, onDidStartTerminalShellExecution will fire in TerminalManager
// which creates a new stream via execution.read() and emits 'stream_available'
const streamAvailable = new Promise<AsyncIterable<string>>((resolve) => {
this.once("stream_available", (id: number, stream: AsyncIterable<string>) => {
if (id === terminalInfo.id) {
resolve(stream)
}
})
})
// Create promise that resolves when shell execution completes for this terminal
const shellExecutionComplete = new Promise<ExitCodeDetails>((resolve) => {
this.once("shell_execution_complete", (id: number, exitDetails: ExitCodeDetails) => {
if (id === terminalInfo.id) {
resolve(exitDetails)
}
})
})
// getUnretrievedOutput needs to know if streamClosed, so store this for later
this.terminalInfo = terminalInfo
// Execute command
terminal.shellIntegration.executeCommand(command)
this.isHot = true
// Wait for stream to be available
const stream = await streamAvailable
let preOutput = ""
let commandOutputStarted = false
/*
* Extract clean output from raw accumulated output. FYI:
* ]633 is a custom sequence number used by VSCode shell integration:
* - OSC 633 ; A ST - Mark prompt start
* - OSC 633 ; B ST - Mark prompt end
* - OSC 633 ; C ST - Mark pre-execution (start of command output)
* - OSC 633 ; D [; <exitcode>] ST - Mark execution finished with optional exit code
* - OSC 633 ; E ; <commandline> [; <nonce>] ST - Explicitly set command line with optional nonce
*/
// Process stream data
for await (let data of stream) {
// 1. Process chunk and remove artifacts
if (isFirstChunk) {
/*
The first chunk we get from this stream needs to be processed to be more human readable, ie remove vscode's custom escape sequences and identifiers, removing duplicate first char bug, etc.
*/
// bug where sometimes the command output makes its way into vscode shell integration metadata
/*
]633 is a custom sequence number used by VSCode shell integration:
- OSC 633 ; A ST - Mark prompt start
- OSC 633 ; B ST - Mark prompt end
- OSC 633 ; C ST - Mark pre-execution (start of command output)
- OSC 633 ; D [; <exitcode>] ST - Mark execution finished with optional exit code
- OSC 633 ; E ; <commandline> [; <nonce>] ST - Explicitly set command line with optional nonce
*/
// if you print this data you might see something like "eecho hello worldo hello world;5ba85d14-e92a-40c4-b2fd-71525581eeb0]633;C" but this is actually just a bunch of escape sequences, ignore up to the first ;C
/* ddateb15026-6a64-40db-b21f-2a621a9830f0]633;CTue Sep 17 06:37:04 EDT 2024 % ]633;D;0]633;P;Cwd=/Users/saoud/Repositories/test */
// Gets output between ]633;C (command start) and ]633;D (command end)
const outputBetweenSequences = this.removeLastLineArtifacts(
data.match(/\]633;C([\s\S]*?)\]633;D/)?.[1] || "",
).trim()
// Once we've retrieved any potential output between sequences, we can remove everything up to end of the last sequence
// https://code.visualstudio.com/docs/terminal/shell-integration#_vs-code-custom-sequences-osc-633-st
const vscodeSequenceRegex = /\x1b\]633;.[^\x07]*\x07/g
const lastMatch = [...data.matchAll(vscodeSequenceRegex)].pop()
if (lastMatch && lastMatch.index !== undefined) {
data = data.slice(lastMatch.index + lastMatch[0].length)
// Check for command output start marker
if (!commandOutputStarted) {
preOutput += data
const match = this.matchAfterVsceStartMarkers(data)
if (match !== undefined) {
commandOutputStarted = true
data = match
this.fullOutput = "" // Reset fullOutput when command actually starts
} else {
continue
}
// Place output back after removing vscode sequences
if (outputBetweenSequences) {
data = outputBetweenSequences + "\n" + data
}
// remove ansi
data = stripAnsi(data)
// Split data by newlines
let lines = data ? data.split("\n") : []
// Remove non-human readable characters from the first line
if (lines.length > 0) {
lines[0] = lines[0].replace(/[^\x20-\x7E]/g, "")
}
// Check if first two characters are the same, if so remove the first character
if (lines.length > 0 && lines[0].length >= 2 && lines[0][0] === lines[0][1]) {
lines[0] = lines[0].slice(1)
}
// Remove everything up to the first alphanumeric character for first two lines
if (lines.length > 0) {
lines[0] = lines[0].replace(/^[^a-zA-Z0-9]*/, "")
}
if (lines.length > 1) {
lines[1] = lines[1].replace(/^[^a-zA-Z0-9]*/, "")
}
// Join lines back
data = lines.join("\n")
isFirstChunk = false
} else {
data = stripAnsi(data)
}
// first few chunks could be the command being echoed back, so we must ignore
// note this means that 'echo' commands wont work
if (!didOutputNonCommand) {
const lines = data.split("\n")
for (let i = 0; i < lines.length; i++) {
if (command.includes(lines[i].trim())) {
lines.splice(i, 1)
i-- // Adjust index after removal
} else {
didOutputNonCommand = true
break
}
}
data = lines.join("\n")
// Command output started, accumulate data without filtering.
// notice to future programmers: do not add escape sequence
// filtering here: fullOutput cannot change in length (see getUnretrievedOutput),
// and chunks may not be complete so you cannot rely on detecting or removing escape sequences mid-stream.
this.fullOutput += data
// For non-immediately returning commands we want to show loading spinner
// right away but this wouldnt happen until it emits a line break, so
// as soon as we get any output we emit to let webview know to show spinner
const now = Date.now()
if (this.isListening && (now - this.lastEmitTime_ms > 100 || this.lastEmitTime_ms === 0)) {
this.emitRemainingBufferIfListening()
this.lastEmitTime_ms = now
}
// FIXME: right now it seems that data chunks returned to us from the shell integration stream contains random commas, which from what I can tell is not the expected behavior. There has to be a better solution here than just removing all commas.
data = data.replace(/,/g, "")
// 2. Set isHot depending on the command
// Set to hot to stall API requests until terminal is cool again
// 2. Set isHot depending on the command.
// This stalls API requests until terminal is cool again.
this.isHot = true
if (this.hotTimer) {
clearTimeout(this.hotTimer)
@ -144,21 +150,37 @@ export class TerminalProcess extends EventEmitter<TerminalProcessEvents> {
},
isCompiling ? PROCESS_HOT_TIMEOUT_COMPILING : PROCESS_HOT_TIMEOUT_NORMAL,
)
// For non-immediately returning commands we want to show loading spinner right away but this wouldnt happen until it emits a line break, so as soon as we get any output we emit "" to let webview know to show spinner
if (!didEmitEmptyLine && !this.fullOutput && data) {
this.emit("line", "") // empty line to indicate start of command output stream
didEmitEmptyLine = true
}
this.fullOutput += data
if (this.isListening) {
this.emitIfEol(data)
this.lastRetrievedIndex = this.fullOutput.length - this.buffer.length
}
}
this.emitRemainingBufferIfListening()
// Set streamClosed immediately after stream ends
if (this.terminalInfo) {
this.terminalInfo.streamClosed = true
}
// Wait for shell execution to complete and handle exit details
const exitDetails = await shellExecutionComplete
this.isHot = false
if (commandOutputStarted) {
// Emit any remaining output before completing
this.emitRemainingBufferIfListening()
} else {
console.error(
"[Terminal Process] VSCE output start escape sequence (]633;C or ]133;C) not received! VSCE Bug? preOutput: " +
inspect(preOutput, { colors: false, breakLength: Infinity }),
)
}
// console.debug("[Terminal Process] raw output: " + inspect(output, { colors: false, breakLength: Infinity }))
// fullOutput begins after C marker so we only need to trim off D marker
// (if D exists, see VSCode bug# 237208):
const match = this.matchBeforeVsceEndMarkers(this.fullOutput)
if (match !== undefined) {
this.fullOutput = match
}
// console.debug(`[Terminal Process] processed output via ${matchSource}: ` + inspect(output, { colors: false, breakLength: Infinity }))
// for now we don't want this delaying requests since we don't send diagnostics automatically anymore (previous: "even though the command is finished, we still want to consider it 'hot' in case so that api request stalls to let diagnostics catch up")
if (this.hotTimer) {
@ -166,7 +188,7 @@ export class TerminalProcess extends EventEmitter<TerminalProcessEvents> {
}
this.isHot = false
this.emit("completed")
this.emit("completed", this.removeEscapeSequences(this.fullOutput))
this.emit("continue")
} else {
terminal.sendText(command, true)
@ -182,29 +204,12 @@ export class TerminalProcess extends EventEmitter<TerminalProcessEvents> {
}
}
// Inspired by https://github.com/sindresorhus/execa/blob/main/lib/transform/split.js
private emitIfEol(chunk: string) {
this.buffer += chunk
let lineEndIndex: number
while ((lineEndIndex = this.buffer.indexOf("\n")) !== -1) {
let line = this.buffer.slice(0, lineEndIndex).trimEnd() // removes trailing \r
// Remove \r if present (for Windows-style line endings)
// if (line.endsWith("\r")) {
// line = line.slice(0, -1)
// }
this.emit("line", line)
this.buffer = this.buffer.slice(lineEndIndex + 1)
}
}
private emitRemainingBufferIfListening() {
if (this.buffer && this.isListening) {
const remainingBuffer = this.removeLastLineArtifacts(this.buffer)
if (remainingBuffer) {
if (this.isListening) {
const remainingBuffer = this.getUnretrievedOutput()
if (remainingBuffer !== "") {
this.emit("line", remainingBuffer)
}
this.buffer = ""
this.lastRetrievedIndex = this.fullOutput.length
}
}
@ -215,22 +220,180 @@ export class TerminalProcess extends EventEmitter<TerminalProcessEvents> {
this.emit("continue")
}
// Returns complete lines with their carriage returns.
// The final line may lack a carriage return if the program didn't send one.
getUnretrievedOutput(): string {
const unretrieved = this.fullOutput.slice(this.lastRetrievedIndex)
this.lastRetrievedIndex = this.fullOutput.length
return this.removeLastLineArtifacts(unretrieved)
// Get raw unretrieved output
let outputToProcess = this.fullOutput.slice(this.lastRetrievedIndex)
// Check for VSCE command end markers
const index633 = outputToProcess.indexOf("\x1b]633;D")
const index133 = outputToProcess.indexOf("\x1b]133;D")
let endIndex = -1
if (index633 !== -1 && index133 !== -1) {
endIndex = Math.min(index633, index133)
} else if (index633 !== -1) {
endIndex = index633
} else if (index133 !== -1) {
endIndex = index133
}
// If no end markers were found yet (possibly due to VSCode bug#237208):
// For active streams: return only complete lines (up to last \n).
// For closed streams: return all remaining content.
if (endIndex === -1) {
if (!this.terminalInfo?.streamClosed) {
// Stream still running - only process complete lines
endIndex = outputToProcess.lastIndexOf("\n")
if (endIndex === -1) {
// No complete lines
return ""
}
// Include carriage return
endIndex++
} else {
// Stream closed - process all remaining output
endIndex = outputToProcess.length
}
}
// Update index and slice output
this.lastRetrievedIndex += endIndex
outputToProcess = outputToProcess.slice(0, endIndex)
// Clean and return output
return this.removeEscapeSequences(outputToProcess)
}
// some processing to remove artifacts like '%' at the end of the buffer (it seems that since vsode uses % at the beginning of newlines in terminal, it makes its way into the stream)
// This modification will remove '%', '$', '#', or '>' followed by optional whitespace
removeLastLineArtifacts(output: string) {
const lines = output.trimEnd().split("\n")
if (lines.length > 0) {
const lastLine = lines[lines.length - 1]
// Remove prompt characters and trailing whitespace from the last line
lines[lines.length - 1] = lastLine.replace(/[%$#>]\s*$/, "")
private stringIndexMatch(
data: string,
prefix?: string,
suffix?: string,
bell: string = "\x07",
): string | undefined {
let startIndex: number
let endIndex: number
let prefixLength: number
if (prefix === undefined) {
startIndex = 0
prefixLength = 0
} else {
startIndex = data.indexOf(prefix)
if (startIndex === -1) {
return undefined
}
if (bell.length > 0) {
// Find the bell character after the prefix
const bellIndex = data.indexOf(bell, startIndex + prefix.length)
if (bellIndex === -1) {
return undefined
}
const distanceToBell = bellIndex - startIndex
prefixLength = distanceToBell + bell.length
} else {
prefixLength = prefix.length
}
}
return lines.join("\n").trimEnd()
const contentStart = startIndex + prefixLength
if (suffix === undefined) {
// When suffix is undefined, match to end
endIndex = data.length
} else {
endIndex = data.indexOf(suffix, contentStart)
if (endIndex === -1) {
return undefined
}
}
return data.slice(contentStart, endIndex)
}
// Removes ANSI escape sequences and VSCode-specific terminal control codes from output.
// While stripAnsi handles most ANSI codes, VSCode's shell integration adds custom
// escape sequences (OSC 633) that need special handling. These sequences control
// terminal features like marking command start/end and setting prompts.
//
// This method could be extended to handle other escape sequences, but any additions
// should be carefully considered to ensure they only remove control codes and don't
// alter the actual content or behavior of the output stream.
private removeEscapeSequences(str: string): string {
return stripAnsi(str.replace(/\x1b\]633;[^\x07]+\x07/gs, "").replace(/\x1b\]133;[^\x07]+\x07/gs, ""))
}
/**
* Helper function to match VSCode shell integration start markers (C).
* Looks for content after ]633;C or ]133;C markers.
* If both exist, takes the content after the last marker found.
*/
private matchAfterVsceStartMarkers(data: string): string | undefined {
return this.matchVsceMarkers(data, "\x1b]633;C", "\x1b]133;C", undefined, undefined)
}
/**
* Helper function to match VSCode shell integration end markers (D).
* Looks for content before ]633;D or ]133;D markers.
* If both exist, takes the content before the first marker found.
*/
private matchBeforeVsceEndMarkers(data: string): string | undefined {
return this.matchVsceMarkers(data, undefined, undefined, "\x1b]633;D", "\x1b]133;D")
}
/**
* Handles VSCode shell integration markers for command output:
*
* For C (Command Start):
* - Looks for content after ]633;C or ]133;C markers
* - These markers indicate the start of command output
* - If both exist, takes the content after the last marker found
* - This ensures we get the actual command output after any shell integration prefixes
*
* For D (Command End):
* - Looks for content before ]633;D or ]133;D markers
* - These markers indicate command completion
* - If both exist, takes the content before the first marker found
* - This ensures we don't include shell integration suffixes in the output
*
* In both cases, checks 633 first since it's more commonly used in VSCode shell integration
*
* @param data The string to search for markers in
* @param prefix633 The 633 marker to match after (for C markers)
* @param prefix133 The 133 marker to match after (for C markers)
* @param suffix633 The 633 marker to match before (for D markers)
* @param suffix133 The 133 marker to match before (for D markers)
* @returns The content between/after markers, or undefined if no markers found
*
* Note: Always makes exactly 2 calls to stringIndexMatch regardless of match results.
* Using string indexOf matching is ~500x faster than regular expressions, so even
* matching twice is still very efficient comparatively.
*/
private matchVsceMarkers(
data: string,
prefix633: string | undefined,
prefix133: string | undefined,
suffix633: string | undefined,
suffix133: string | undefined,
): string | undefined {
// Support both VSCode shell integration markers (633 and 133)
// Check 633 first since it's more commonly used in VSCode shell integration
let match133: string | undefined
const match633 = this.stringIndexMatch(data, prefix633, suffix633)
// Must check explicitly for undefined because stringIndexMatch can return empty strings
// that are valid matches (e.g., when a marker exists but has no content between markers)
if (match633 !== undefined) {
match133 = this.stringIndexMatch(match633, prefix133, suffix133)
} else {
match133 = this.stringIndexMatch(data, prefix133, suffix133)
}
return match133 !== undefined ? match133 : match633
}
}

View file

@ -5,6 +5,9 @@ export interface TerminalInfo {
busy: boolean
lastCommand: string
id: number
stream?: AsyncIterable<string>
running: boolean
streamClosed: boolean
}
// Although vscode.window.terminals provides a list of all open terminals, there's no way to know whether they're busy or not (exitStatus does not provide useful information for most commands). In order to prevent creating too many terminals, we need to keep track of terminals through the life of the extension, as well as session specific terminals for the life of a task (to get latest unretrieved output).
@ -20,34 +23,61 @@ export class TerminalRegistry {
iconPath: new vscode.ThemeIcon("rocket"),
env: {
PAGER: "cat",
// VSCode bug#237208: Command output can be lost due to a race between completion
// sequences and consumers. Add 50ms delay via PROMPT_COMMAND to ensure the
// \x1b]633;D escape sequence arrives after command output is processed.
PROMPT_COMMAND: "sleep 0.050",
// VTE must be disabled because it prevents the prompt command above from executing
// See https://wiki.gnome.org/Apps/Terminal/VTE
VTE_VERSION: "0",
},
})
const newInfo: TerminalInfo = {
terminal,
busy: false,
lastCommand: "",
id: this.nextTerminalId++,
running: false,
streamClosed: false,
}
this.terminals.push(newInfo)
return newInfo
}
static getTerminal(id: number): TerminalInfo | undefined {
const terminalInfo = this.terminals.find((t) => t.id === id)
if (terminalInfo && this.isTerminalClosed(terminalInfo.terminal)) {
this.removeTerminal(id)
return undefined
}
return terminalInfo
}
static updateTerminal(id: number, updates: Partial<TerminalInfo>) {
const terminal = this.getTerminal(id)
if (terminal) {
Object.assign(terminal, updates)
}
}
static getTerminalInfoByTerminal(terminal: vscode.Terminal): TerminalInfo | undefined {
const terminalInfo = this.terminals.find((t) => t.terminal === terminal)
if (terminalInfo && this.isTerminalClosed(terminalInfo.terminal)) {
this.removeTerminal(terminalInfo.id)
return undefined
}
return terminalInfo
}
static removeTerminal(id: number) {
this.terminals = this.terminals.filter((t) => t.id !== id)
}

View file

@ -1,9 +1,24 @@
import { TerminalProcess, mergePromise } from "../TerminalProcess"
import * as vscode from "vscode"
import { EventEmitter } from "events"
// npx jest src/integrations/terminal/__tests__/TerminalProcess.test.ts
// Mock vscode
jest.mock("vscode")
import * as vscode from "vscode"
import { TerminalProcess, mergePromise } from "../TerminalProcess"
import { TerminalInfo, TerminalRegistry } from "../TerminalRegistry"
// Mock vscode.window.createTerminal
const mockCreateTerminal = jest.fn()
jest.mock("vscode", () => ({
window: {
createTerminal: (...args: any[]) => {
mockCreateTerminal(...args)
return {
exitStatus: undefined,
}
},
},
ThemeIcon: jest.fn(),
}))
describe("TerminalProcess", () => {
let terminalProcess: TerminalProcess
@ -14,6 +29,7 @@ describe("TerminalProcess", () => {
}
}
>
let mockTerminalInfo: TerminalInfo
let mockExecution: any
let mockStream: AsyncIterableIterator<string>
@ -25,7 +41,7 @@ describe("TerminalProcess", () => {
shellIntegration: {
executeCommand: jest.fn(),
},
name: "Mock Terminal",
name: "Roo Code",
processId: Promise.resolve(123),
creationOptions: {},
exitStatus: undefined,
@ -42,27 +58,39 @@ describe("TerminalProcess", () => {
}
>
mockTerminalInfo = {
terminal: mockTerminal,
busy: false,
lastCommand: "",
id: 1,
running: false,
streamClosed: false,
}
TerminalRegistry["terminals"].push(mockTerminalInfo)
// Reset event listeners
terminalProcess.removeAllListeners()
})
describe("run", () => {
it("handles shell integration commands correctly", async () => {
const lines: string[] = []
terminalProcess.on("line", (line) => {
// Skip empty lines used for loading spinner
if (line !== "") {
lines.push(line)
let lines: string[] = []
terminalProcess.on("completed", (output) => {
if (output) {
lines = output.split("\n")
}
})
// Mock stream data with shell integration sequences
// Mock stream data with shell integration sequences.
mockStream = (async function* () {
// The first chunk contains the command start sequence
yield "\x1b]633;C\x07" // The first chunk contains the command start sequence with bell character.
yield "Initial output\n"
yield "More output\n"
// The last chunk contains the command end sequence
yield "Final output"
yield "\x1b]633;D\x07" // The last chunk contains the command end sequence with bell character.
terminalProcess.emit("shell_execution_complete", mockTerminalInfo.id, { exitCode: 0 })
})()
mockExecution = {
@ -71,12 +99,9 @@ describe("TerminalProcess", () => {
mockTerminal.shellIntegration.executeCommand.mockReturnValue(mockExecution)
const completedPromise = new Promise<void>((resolve) => {
terminalProcess.once("completed", resolve)
})
await terminalProcess.run(mockTerminal, "test command")
await completedPromise
const runPromise = terminalProcess.run(mockTerminal, "test command")
terminalProcess.emit("stream_available", mockTerminalInfo.id, mockStream)
await runPromise
expect(lines).toEqual(["Initial output", "More output", "Final output"])
expect(terminalProcess.isHot).toBe(false)
@ -99,95 +124,41 @@ describe("TerminalProcess", () => {
})
it("sets hot state for compiling commands", async () => {
const lines: string[] = []
terminalProcess.on("line", (line) => {
if (line !== "") {
lines.push(line)
let lines: string[] = []
terminalProcess.on("completed", (output) => {
if (output) {
lines = output.split("\n")
}
})
// Create a promise that resolves when the first chunk is processed
const firstChunkProcessed = new Promise<void>((resolve) => {
terminalProcess.on("line", () => resolve())
const completePromise = new Promise<void>((resolve) => {
terminalProcess.on("shell_execution_complete", () => resolve())
})
mockStream = (async function* () {
yield "\x1b]633;C\x07" // The first chunk contains the command start sequence with bell character.
yield "compiling...\n"
// Wait to ensure hot state check happens after first chunk
await new Promise((resolve) => setTimeout(resolve, 10))
yield "still compiling...\n"
yield "done"
yield "\x1b]633;D\x07" // The last chunk contains the command end sequence with bell character.
terminalProcess.emit("shell_execution_complete", mockTerminalInfo.id, { exitCode: 0 })
})()
mockExecution = {
mockTerminal.shellIntegration.executeCommand.mockReturnValue({
read: jest.fn().mockReturnValue(mockStream),
}
mockTerminal.shellIntegration.executeCommand.mockReturnValue(mockExecution)
// Start the command execution
const runPromise = terminalProcess.run(mockTerminal, "npm run build")
// Wait for the first chunk to be processed
await firstChunkProcessed
// Hot state should be true while compiling
expect(terminalProcess.isHot).toBe(true)
// Complete the execution
const completedPromise = new Promise<void>((resolve) => {
terminalProcess.once("completed", resolve)
})
const runPromise = terminalProcess.run(mockTerminal, "npm run build")
terminalProcess.emit("stream_available", mockTerminalInfo.id, mockStream)
expect(terminalProcess.isHot).toBe(true)
await runPromise
await completedPromise
expect(lines).toEqual(["compiling...", "still compiling...", "done"])
})
})
describe("buffer processing", () => {
it("correctly processes and emits lines", () => {
const lines: string[] = []
terminalProcess.on("line", (line) => lines.push(line))
// Simulate incoming chunks
terminalProcess["emitIfEol"]("first line\n")
terminalProcess["emitIfEol"]("second")
terminalProcess["emitIfEol"](" line\n")
terminalProcess["emitIfEol"]("third line")
expect(lines).toEqual(["first line", "second line"])
// Process remaining buffer
terminalProcess["emitRemainingBufferIfListening"]()
expect(lines).toEqual(["first line", "second line", "third line"])
})
it("handles Windows-style line endings", () => {
const lines: string[] = []
terminalProcess.on("line", (line) => lines.push(line))
terminalProcess["emitIfEol"]("line1\r\nline2\r\n")
expect(lines).toEqual(["line1", "line2"])
})
})
describe("removeLastLineArtifacts", () => {
it("removes terminal artifacts from output", () => {
const cases = [
["output%", "output"],
["output$ ", "output"],
["output#", "output"],
["output> ", "output"],
["multi\nline%", "multi\nline"],
["no artifacts", "no artifacts"],
]
for (const [input, expected] of cases) {
expect(terminalProcess["removeLastLineArtifacts"](input)).toBe(expected)
}
await completePromise
expect(terminalProcess.isHot).toBe(false)
})
})
@ -204,14 +175,14 @@ describe("TerminalProcess", () => {
})
describe("getUnretrievedOutput", () => {
it("returns and clears unretrieved output", () => {
terminalProcess["fullOutput"] = "previous\nnew output"
terminalProcess["lastRetrievedIndex"] = 9 // After "previous\n"
it.only("returns and clears unretrieved output", () => {
terminalProcess["fullOutput"] = `\x1b]633;C\x07previous\nnew output\x1b]633;D\x07`
terminalProcess["lastRetrievedIndex"] = 17 // After "previous\n"
const unretrieved = terminalProcess.getUnretrievedOutput()
expect(unretrieved).toBe("new output")
expect(terminalProcess["lastRetrievedIndex"]).toBe(terminalProcess["fullOutput"].length)
expect(terminalProcess["lastRetrievedIndex"]).toBe(terminalProcess["fullOutput"].length - "previous".length)
})
})

View file

@ -1,4 +1,5 @@
import * as vscode from "vscode"
// npx jest src/integrations/terminal/__tests__/TerminalRegistry.test.ts
import { TerminalRegistry } from "../TerminalRegistry"
// Mock vscode.window.createTerminal
@ -30,6 +31,8 @@ describe("TerminalRegistry", () => {
iconPath: expect.any(Object),
env: {
PAGER: "cat",
PROMPT_COMMAND: "sleep 0.050",
VTE_VERSION: "0",
},
})
})

View file

@ -44,7 +44,7 @@ export function combineCommandSequences(messages: ClineMessage[]): ClineMessage[
// handle cases where we receive empty command_output (ie when extension is relinquishing control over exit command button)
const output = messages[j].text || ""
if (output.length > 0) {
combinedText += "\n" + output
combinedText += output
}
}
j++