|
|
@@ -5,6 +5,34 @@ import { inspect } from "util"
|
|
|
|
|
|
import { ExitCodeDetails } from "./TerminalManager"
|
|
|
import { TerminalInfo, TerminalRegistry } from "./TerminalRegistry"
|
|
|
+import { OutputBuilder } from "./OutputBuilder"
|
|
|
+
|
|
|
+// How long to wait after a process outputs anything before we consider it
|
|
|
+// "cool" again
|
|
|
+const PROCESS_HOT_TIMEOUT_NORMAL = 2_000
|
|
|
+const PROCESS_HOT_TIMEOUT_COMPILING = 15_000
|
|
|
+
|
|
|
+// These markers indicate the command is some kind of local dev server
|
|
|
+// recompiling the app, which we want to wait for output of before sending
|
|
|
+// request to Roo.
|
|
|
+const COMPILE_MARKERS = ["compiling", "building", "bundling", "transpiling", "generating", "starting"]
|
|
|
+
|
|
|
+const COMPILE_MARKER_NULLIFIERS = [
|
|
|
+ "compiled",
|
|
|
+ "success",
|
|
|
+ "finish",
|
|
|
+ "complete",
|
|
|
+ "succeed",
|
|
|
+ "done",
|
|
|
+ "end",
|
|
|
+ "stop",
|
|
|
+ "exit",
|
|
|
+ "terminate",
|
|
|
+ "error",
|
|
|
+ "fail",
|
|
|
+]
|
|
|
+
|
|
|
+const EMIT_INTERVAL = 250
|
|
|
|
|
|
export interface TerminalProcessEvents {
|
|
|
line: [line: string]
|
|
|
@@ -21,20 +49,28 @@ export interface TerminalProcessEvents {
|
|
|
stream_available: [id: number, stream: AsyncIterable<string>]
|
|
|
}
|
|
|
|
|
|
-// how long to wait after a process outputs anything before we consider it "cool" again
|
|
|
-const PROCESS_HOT_TIMEOUT_NORMAL = 2_000
|
|
|
-const PROCESS_HOT_TIMEOUT_COMPILING = 15_000
|
|
|
-
|
|
|
export class TerminalProcess extends EventEmitter<TerminalProcessEvents> {
|
|
|
- waitForShellIntegration: boolean = true
|
|
|
- private isListening: boolean = true
|
|
|
+ public waitForShellIntegration = true
|
|
|
+ private _isHot = false
|
|
|
+
|
|
|
+ private isListening = true
|
|
|
private terminalInfo: TerminalInfo | undefined
|
|
|
- private lastEmitTime_ms: number = 0
|
|
|
- private fullOutput: string = ""
|
|
|
- private lastRetrievedIndex: number = 0
|
|
|
- isHot: boolean = false
|
|
|
+ private lastEmitTime_ms = 0
|
|
|
+ private outputBuilder?: OutputBuilder
|
|
|
private hotTimer: NodeJS.Timeout | null = null
|
|
|
|
|
|
+ public get isHot() {
|
|
|
+ return this._isHot
|
|
|
+ }
|
|
|
+
|
|
|
+ private set isHot(value: boolean) {
|
|
|
+ this._isHot = value
|
|
|
+ }
|
|
|
+
|
|
|
+ constructor(private readonly terminalOutputLimit: number) {
|
|
|
+ super()
|
|
|
+ }
|
|
|
+
|
|
|
async run(terminal: vscode.Terminal, command: string) {
|
|
|
if (terminal.shellIntegration && terminal.shellIntegration.executeCommand) {
|
|
|
// Get terminal info to access stream
|
|
|
@@ -66,7 +102,7 @@ export class TerminalProcess extends EventEmitter<TerminalProcessEvents> {
|
|
|
})
|
|
|
})
|
|
|
|
|
|
- // getUnretrievedOutput needs to know if streamClosed, so store this for later
|
|
|
+ // readLine needs to know if streamClosed, so store this for later
|
|
|
this.terminalInfo = terminalInfo
|
|
|
|
|
|
// Execute command
|
|
|
@@ -89,61 +125,58 @@ export class TerminalProcess extends EventEmitter<TerminalProcessEvents> {
|
|
|
* - OSC 633 ; E ; <commandline> [; <nonce>] ST - Explicitly set command line with optional nonce
|
|
|
*/
|
|
|
|
|
|
- // Process stream data
|
|
|
+ this.outputBuilder = new OutputBuilder({ maxSize: this.terminalOutputLimit })
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Some commands won't result in output flushing until the command
|
|
|
+ * completes. This locks the UI. Should we set a timer to prompt
|
|
|
+ * the user to continue?
|
|
|
+ */
|
|
|
+
|
|
|
for await (let data of stream) {
|
|
|
- // Check for command output start marker
|
|
|
+ // 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
|
|
|
+ this.outputBuilder.reset() // Reset output when command actually starts.
|
|
|
} else {
|
|
|
continue
|
|
|
}
|
|
|
}
|
|
|
|
|
|
// 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),
|
|
|
+ // Notice to future programmers: do not add escape sequence
|
|
|
+ // filtering here: output cannot change in length (see `readLine`),
|
|
|
// and chunks may not be complete so you cannot rely on detecting or removing escape sequences mid-stream.
|
|
|
- this.fullOutput += data
|
|
|
+ this.outputBuilder.append(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
|
|
|
+ // right away but this wouldn't 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()
|
|
|
+ const timeSinceLastEmit = now - this.lastEmitTime_ms
|
|
|
+
|
|
|
+ if (this.isListening && timeSinceLastEmit > EMIT_INTERVAL) {
|
|
|
+ this.flushLine()
|
|
|
this.lastEmitTime_ms = now
|
|
|
}
|
|
|
|
|
|
- // 2. Set isHot depending on the command.
|
|
|
+ // Set isHot depending on the command.
|
|
|
// This stalls API requests until terminal is cool again.
|
|
|
this.isHot = true
|
|
|
+
|
|
|
if (this.hotTimer) {
|
|
|
clearTimeout(this.hotTimer)
|
|
|
}
|
|
|
- // these markers indicate the command is some kind of local dev server recompiling the app, which we want to wait for output of before sending request to cline
|
|
|
- const compilingMarkers = ["compiling", "building", "bundling", "transpiling", "generating", "starting"]
|
|
|
- const markerNullifiers = [
|
|
|
- "compiled",
|
|
|
- "success",
|
|
|
- "finish",
|
|
|
- "complete",
|
|
|
- "succeed",
|
|
|
- "done",
|
|
|
- "end",
|
|
|
- "stop",
|
|
|
- "exit",
|
|
|
- "terminate",
|
|
|
- "error",
|
|
|
- "fail",
|
|
|
- ]
|
|
|
+
|
|
|
const isCompiling =
|
|
|
- compilingMarkers.some((marker) => data.toLowerCase().includes(marker.toLowerCase())) &&
|
|
|
- !markerNullifiers.some((nullifier) => data.toLowerCase().includes(nullifier.toLowerCase()))
|
|
|
+ COMPILE_MARKERS.some((marker) => data.toLowerCase().includes(marker.toLowerCase())) &&
|
|
|
+ !COMPILE_MARKER_NULLIFIERS.some((nullifier) => data.toLowerCase().includes(nullifier.toLowerCase()))
|
|
|
+
|
|
|
this.hotTimer = setTimeout(
|
|
|
() => {
|
|
|
this.isHot = false
|
|
|
@@ -152,18 +185,18 @@ export class TerminalProcess extends EventEmitter<TerminalProcessEvents> {
|
|
|
)
|
|
|
}
|
|
|
|
|
|
- // Set streamClosed immediately after stream ends
|
|
|
+ // 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
|
|
|
+ // Wait for shell execution to complete and handle exit details.
|
|
|
+ await shellExecutionComplete
|
|
|
this.isHot = false
|
|
|
|
|
|
if (commandOutputStarted) {
|
|
|
- // Emit any remaining output before completing
|
|
|
- this.emitRemainingBufferIfListening()
|
|
|
+ // Emit any remaining output before completing.
|
|
|
+ this.flushAll()
|
|
|
} else {
|
|
|
console.error(
|
|
|
"[Terminal Process] VSCE output start escape sequence (]633;C or ]133;C) not received! VSCE Bug? preOutput: " +
|
|
|
@@ -171,62 +204,77 @@ export class TerminalProcess extends EventEmitter<TerminalProcessEvents> {
|
|
|
)
|
|
|
}
|
|
|
|
|
|
- // 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
|
|
|
+ // Output 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)
|
|
|
+ const match = this.matchBeforeVsceEndMarkers(this.outputBuilder.content)
|
|
|
+
|
|
|
if (match !== undefined) {
|
|
|
- this.fullOutput = match
|
|
|
+ this.outputBuilder.reset(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")
|
|
|
+ // 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) {
|
|
|
clearTimeout(this.hotTimer)
|
|
|
}
|
|
|
+
|
|
|
this.isHot = false
|
|
|
|
|
|
- this.emit("completed", this.removeEscapeSequences(this.fullOutput))
|
|
|
+ this.emit("completed", this.removeEscapeSequences(this.outputBuilder.content))
|
|
|
this.emit("continue")
|
|
|
} else {
|
|
|
terminal.sendText(command, true)
|
|
|
- // For terminals without shell integration, we can't know when the command completes
|
|
|
- // So we'll just emit the continue event after a delay
|
|
|
+ // For terminals without shell integration, we can't know when the command completes.
|
|
|
+ // So we'll just emit the continue event.
|
|
|
this.emit("completed")
|
|
|
this.emit("continue")
|
|
|
this.emit("no_shell_integration")
|
|
|
- // setTimeout(() => {
|
|
|
- // console.log(`Emitting continue after delay for terminal`)
|
|
|
- // // can't emit completed since we don't if the command actually completed, it could still be running server
|
|
|
- // }, 500) // Adjust this delay as needed
|
|
|
}
|
|
|
}
|
|
|
|
|
|
- private emitRemainingBufferIfListening() {
|
|
|
- if (this.isListening) {
|
|
|
- const remainingBuffer = this.getUnretrievedOutput()
|
|
|
- if (remainingBuffer !== "") {
|
|
|
- this.emit("line", remainingBuffer)
|
|
|
- }
|
|
|
- }
|
|
|
+ public readLine() {
|
|
|
+ return this.processOutput(this.outputBuilder?.readLine() || "")
|
|
|
}
|
|
|
|
|
|
- continue() {
|
|
|
- this.emitRemainingBufferIfListening()
|
|
|
+ public read() {
|
|
|
+ return this.processOutput(this.outputBuilder?.read() || "")
|
|
|
+ }
|
|
|
+
|
|
|
+ public continue() {
|
|
|
+ this.flushAll()
|
|
|
this.isListening = false
|
|
|
this.removeAllListeners("line")
|
|
|
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 {
|
|
|
- // Get raw unretrieved output
|
|
|
- let outputToProcess = this.fullOutput.slice(this.lastRetrievedIndex)
|
|
|
+ private flushLine() {
|
|
|
+ if (!this.isListening) {
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ const line = this.readLine()
|
|
|
+
|
|
|
+ if (line) {
|
|
|
+ this.emit("line", line)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private flushAll() {
|
|
|
+ if (!this.isListening) {
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ const buffer = this.read()
|
|
|
|
|
|
- // Check for VSCE command end markers
|
|
|
+ if (buffer) {
|
|
|
+ this.emit("line", buffer)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private processOutput(outputToProcess: string) {
|
|
|
+ // Check for VSCE command end markers.
|
|
|
const index633 = outputToProcess.indexOf("\x1b]633;D")
|
|
|
const index133 = outputToProcess.indexOf("\x1b]133;D")
|
|
|
let endIndex = -1
|
|
|
@@ -239,32 +287,7 @@ export class TerminalProcess extends EventEmitter<TerminalProcessEvents> {
|
|
|
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)
|
|
|
+ return this.removeEscapeSequences(endIndex >= 0 ? outputToProcess.slice(0, endIndex) : outputToProcess)
|
|
|
}
|
|
|
|
|
|
private stringIndexMatch(
|
|
|
@@ -282,18 +305,20 @@ export class TerminalProcess extends EventEmitter<TerminalProcessEvents> {
|
|
|
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
|
|
|
@@ -307,6 +332,7 @@ export class TerminalProcess extends EventEmitter<TerminalProcessEvents> {
|
|
|
endIndex = data.length
|
|
|
} else {
|
|
|
endIndex = data.indexOf(suffix, contentStart)
|
|
|
+
|
|
|
if (endIndex === -1) {
|
|
|
return undefined
|
|
|
}
|
|
|
@@ -323,7 +349,7 @@ export class TerminalProcess extends EventEmitter<TerminalProcessEvents> {
|
|
|
// 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 {
|
|
|
+ private removeEscapeSequences(str: string) {
|
|
|
return stripAnsi(str.replace(/\x1b\]633;[^\x07]+\x07/gs, "").replace(/\x1b\]133;[^\x07]+\x07/gs, ""))
|
|
|
}
|
|
|
|
|
|
@@ -396,20 +422,3 @@ export class TerminalProcess extends EventEmitter<TerminalProcessEvents> {
|
|
|
return match133 !== undefined ? match133 : match633
|
|
|
}
|
|
|
}
|
|
|
-
|
|
|
-export type TerminalProcessResultPromise = TerminalProcess & Promise<void>
|
|
|
-
|
|
|
-// Similar to execa's ResultPromise, this lets us create a mixin of both a TerminalProcess and a Promise: https://github.com/sindresorhus/execa/blob/main/lib/methods/promise.js
|
|
|
-export function mergePromise(process: TerminalProcess, promise: Promise<void>): TerminalProcessResultPromise {
|
|
|
- const nativePromisePrototype = (async () => {})().constructor.prototype
|
|
|
- const descriptors = ["then", "catch", "finally"].map(
|
|
|
- (property) => [property, Reflect.getOwnPropertyDescriptor(nativePromisePrototype, property)] as const,
|
|
|
- )
|
|
|
- for (const [property, descriptor] of descriptors) {
|
|
|
- if (descriptor) {
|
|
|
- const value = descriptor.value.bind(promise)
|
|
|
- Reflect.defineProperty(process, property, { ...descriptor, value })
|
|
|
- }
|
|
|
- }
|
|
|
- return process as TerminalProcessResultPromise
|
|
|
-}
|