|
|
@@ -5,34 +5,6 @@ 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]
|
|
|
@@ -49,28 +21,20 @@ export interface TerminalProcessEvents {
|
|
|
stream_available: [id: number, stream: AsyncIterable<string>]
|
|
|
}
|
|
|
|
|
|
-export class TerminalProcess extends EventEmitter<TerminalProcessEvents> {
|
|
|
- public waitForShellIntegration = true
|
|
|
- private _isHot = false
|
|
|
+// 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
|
|
|
|
|
|
- private isListening = true
|
|
|
+export class TerminalProcess extends EventEmitter<TerminalProcessEvents> {
|
|
|
+ waitForShellIntegration: boolean = true
|
|
|
+ private isListening: boolean = true
|
|
|
private terminalInfo: TerminalInfo | undefined
|
|
|
- private lastEmitTime_ms = 0
|
|
|
- private outputBuilder?: OutputBuilder
|
|
|
+ private lastEmitTime_ms: number = 0
|
|
|
+ private fullOutput: string = ""
|
|
|
+ private lastRetrievedIndex: number = 0
|
|
|
+ isHot: boolean = false
|
|
|
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
|
|
|
@@ -102,7 +66,7 @@ export class TerminalProcess extends EventEmitter<TerminalProcessEvents> {
|
|
|
})
|
|
|
})
|
|
|
|
|
|
- // readLine needs to know if streamClosed, so store this for later
|
|
|
+ // getUnretrievedOutput needs to know if streamClosed, so store this for later
|
|
|
this.terminalInfo = terminalInfo
|
|
|
|
|
|
// Execute command
|
|
|
@@ -125,58 +89,61 @@ export class TerminalProcess extends EventEmitter<TerminalProcessEvents> {
|
|
|
* - OSC 633 ; E ; <commandline> [; <nonce>] ST - Explicitly set command line with optional nonce
|
|
|
*/
|
|
|
|
|
|
- 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?
|
|
|
- */
|
|
|
-
|
|
|
+ // Process stream data
|
|
|
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.outputBuilder.reset() // Reset output when command actually starts.
|
|
|
+ this.fullOutput = "" // Reset fullOutput when command actually starts
|
|
|
} else {
|
|
|
continue
|
|
|
}
|
|
|
}
|
|
|
|
|
|
// Command output started, accumulate data without filtering.
|
|
|
- // Notice to future programmers: do not add escape sequence
|
|
|
- // filtering here: output cannot change in length (see `readLine`),
|
|
|
+ // 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.outputBuilder.append(data)
|
|
|
+ this.fullOutput += data
|
|
|
|
|
|
// For non-immediately returning commands we want to show loading 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.
|
|
|
+ // 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()
|
|
|
- const timeSinceLastEmit = now - this.lastEmitTime_ms
|
|
|
-
|
|
|
- if (this.isListening && timeSinceLastEmit > EMIT_INTERVAL) {
|
|
|
- this.flushLine()
|
|
|
+ if (this.isListening && (now - this.lastEmitTime_ms > 100 || this.lastEmitTime_ms === 0)) {
|
|
|
+ this.emitRemainingBufferIfListening()
|
|
|
this.lastEmitTime_ms = now
|
|
|
}
|
|
|
|
|
|
- // Set isHot depending on the command.
|
|
|
+ // 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)
|
|
|
}
|
|
|
-
|
|
|
+ // 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 =
|
|
|
- COMPILE_MARKERS.some((marker) => data.toLowerCase().includes(marker.toLowerCase())) &&
|
|
|
- !COMPILE_MARKER_NULLIFIERS.some((nullifier) => data.toLowerCase().includes(nullifier.toLowerCase()))
|
|
|
-
|
|
|
+ compilingMarkers.some((marker) => data.toLowerCase().includes(marker.toLowerCase())) &&
|
|
|
+ !markerNullifiers.some((nullifier) => data.toLowerCase().includes(nullifier.toLowerCase()))
|
|
|
this.hotTimer = setTimeout(
|
|
|
() => {
|
|
|
this.isHot = false
|
|
|
@@ -185,18 +152,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.
|
|
|
- await shellExecutionComplete
|
|
|
+ // 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.flushAll()
|
|
|
+ // 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: " +
|
|
|
@@ -204,77 +171,62 @@ export class TerminalProcess extends EventEmitter<TerminalProcessEvents> {
|
|
|
)
|
|
|
}
|
|
|
|
|
|
- // 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.outputBuilder.content)
|
|
|
+ // 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.outputBuilder.reset(match)
|
|
|
+ this.fullOutput = match
|
|
|
}
|
|
|
|
|
|
- // 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").
|
|
|
+ // 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) {
|
|
|
clearTimeout(this.hotTimer)
|
|
|
}
|
|
|
-
|
|
|
this.isHot = false
|
|
|
|
|
|
- this.emit("completed", this.removeEscapeSequences(this.outputBuilder.content))
|
|
|
+ this.emit("completed", this.removeEscapeSequences(this.fullOutput))
|
|
|
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.
|
|
|
+ // For terminals without shell integration, we can't know when the command completes
|
|
|
+ // So we'll just emit the continue event after a delay
|
|
|
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
|
|
|
}
|
|
|
}
|
|
|
|
|
|
- public readLine() {
|
|
|
- return this.processOutput(this.outputBuilder?.readLine() || "")
|
|
|
- }
|
|
|
-
|
|
|
- public read() {
|
|
|
- return this.processOutput(this.outputBuilder?.read() || "")
|
|
|
+ private emitRemainingBufferIfListening() {
|
|
|
+ if (this.isListening) {
|
|
|
+ const remainingBuffer = this.getUnretrievedOutput()
|
|
|
+ if (remainingBuffer !== "") {
|
|
|
+ this.emit("line", remainingBuffer)
|
|
|
+ }
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
- public continue() {
|
|
|
- this.flushAll()
|
|
|
+ continue() {
|
|
|
+ this.emitRemainingBufferIfListening()
|
|
|
this.isListening = false
|
|
|
this.removeAllListeners("line")
|
|
|
this.emit("continue")
|
|
|
}
|
|
|
|
|
|
- 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()
|
|
|
+ // 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)
|
|
|
|
|
|
- if (buffer) {
|
|
|
- this.emit("line", buffer)
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- private processOutput(outputToProcess: string) {
|
|
|
- // Check for VSCE command end markers.
|
|
|
+ // Check for VSCE command end markers
|
|
|
const index633 = outputToProcess.indexOf("\x1b]633;D")
|
|
|
const index133 = outputToProcess.indexOf("\x1b]133;D")
|
|
|
let endIndex = -1
|
|
|
@@ -287,7 +239,32 @@ export class TerminalProcess extends EventEmitter<TerminalProcessEvents> {
|
|
|
endIndex = index133
|
|
|
}
|
|
|
|
|
|
- return this.removeEscapeSequences(endIndex >= 0 ? outputToProcess.slice(0, endIndex) : outputToProcess)
|
|
|
+ // 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)
|
|
|
}
|
|
|
|
|
|
private stringIndexMatch(
|
|
|
@@ -305,20 +282,18 @@ 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
|
|
|
@@ -332,7 +307,6 @@ export class TerminalProcess extends EventEmitter<TerminalProcessEvents> {
|
|
|
endIndex = data.length
|
|
|
} else {
|
|
|
endIndex = data.indexOf(suffix, contentStart)
|
|
|
-
|
|
|
if (endIndex === -1) {
|
|
|
return undefined
|
|
|
}
|
|
|
@@ -349,7 +323,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) {
|
|
|
+ private removeEscapeSequences(str: string): string {
|
|
|
return stripAnsi(str.replace(/\x1b\]633;[^\x07]+\x07/gs, "").replace(/\x1b\]133;[^\x07]+\x07/gs, ""))
|
|
|
}
|
|
|
|
|
|
@@ -422,3 +396,20 @@ 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
|
|
|
+}
|