mirror of
https://github.com/cline/cline.git
synced 2026-09-15 13:02:17 +08:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9c80d58e7a | ||
|
|
cab80d32a8 | ||
|
|
5f1581e168 | ||
|
|
7500cc28a3 |
@@ -31,20 +31,35 @@ export class TerminalProcess extends EventEmitter<TerminalProcessEvents> {
|
||||
// constructor() {
|
||||
// super()
|
||||
|
||||
async run(terminal: vscode.Terminal, command: string) {
|
||||
// When command does not produce any output, we can assume the shell integration API failed and as a fallback return the current terminal contents
|
||||
const emitCurrentTerminalContents = async () => {
|
||||
try {
|
||||
const terminalSnapshot = await getLatestTerminalOutput()
|
||||
if (terminalSnapshot && terminalSnapshot.trim()) {
|
||||
const fallbackMessage = `The command's output could not be captured due to some technical issue, however it has been executed successfully. Here's the current terminal's content to help you get the command's output:\n\n${terminalSnapshot}`
|
||||
this.emit("line", fallbackMessage)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error capturing terminal output:", error)
|
||||
private async emitCurrentTerminalContents(): Promise<void> {
|
||||
try {
|
||||
const terminalSnapshot = await getLatestTerminalOutput()
|
||||
if (terminalSnapshot && terminalSnapshot.trim()) {
|
||||
const fallbackMessage = `The command's output could not be captured due to some technical issue, however it has been executed successfully. Here's the current terminal's content to help you get the command's output:\n\n${terminalSnapshot}`
|
||||
this.emit("line", fallbackMessage)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error capturing terminal output:", error)
|
||||
}
|
||||
}
|
||||
|
||||
async run(terminal: vscode.Terminal, command: string) {
|
||||
// Initialize state for new command
|
||||
await this.initializeForNewCommand(command)
|
||||
|
||||
console.log(`[TerminalProcess] Starting command: "${command}"`)
|
||||
console.log(`[TerminalProcess] Shell integration available: ${!!terminal.shellIntegration?.executeCommand}`)
|
||||
console.log(`[TerminalProcess] Terminal ID: ${terminal.name}`)
|
||||
|
||||
if (terminal.shellIntegration?.executeCommand) {
|
||||
await this.runWithShellIntegration(terminal, command)
|
||||
} else {
|
||||
// no shell integration detected, we'll fallback to running the command and capturing the terminal's output after some time
|
||||
await this.runWithoutShellIntegration(terminal, command)
|
||||
}
|
||||
}
|
||||
|
||||
private async initializeForNewCommand(command: string): Promise<void> {
|
||||
// Clear any existing grace period timer from previous commands
|
||||
if (this.gracePeriodTimer) {
|
||||
clearTimeout(this.gracePeriodTimer)
|
||||
@@ -65,291 +80,370 @@ export class TerminalProcess extends EventEmitter<TerminalProcessEvents> {
|
||||
this.lastRetrievedIndex = 0
|
||||
this.isListening = true
|
||||
this.isHot = false
|
||||
|
||||
this.command = command
|
||||
console.log(`[TerminalProcess] Starting command: "${command}"`)
|
||||
console.log(`[TerminalProcess] Shell integration available: ${!!terminal.shellIntegration?.executeCommand}`)
|
||||
console.log(`[TerminalProcess] Terminal ID: ${terminal.name}`)
|
||||
if (terminal.shellIntegration && terminal.shellIntegration.executeCommand) {
|
||||
let execution
|
||||
let stream
|
||||
}
|
||||
|
||||
try {
|
||||
execution = terminal.shellIntegration.executeCommand(command)
|
||||
stream = execution.read()
|
||||
} catch (error) {
|
||||
console.error(`[TerminalProcess] Failed to execute command: ${error}`)
|
||||
this.emit("error", error as Error)
|
||||
return
|
||||
private async runWithShellIntegration(terminal: vscode.Terminal, command: string): Promise<void> {
|
||||
// Execute command and get stream
|
||||
const stream = await this.executeCommandWithShellIntegration(terminal, command)
|
||||
if (!stream) return
|
||||
|
||||
// Initialize state for stream processing
|
||||
const streamState = this.initializeStreamState()
|
||||
|
||||
// Set up timeout for commands with no output
|
||||
const firstChunkTimeout = this.setupNoOutputTimeout(command, streamState)
|
||||
|
||||
// Process the output stream
|
||||
await this.processOutputStream(stream, command, streamState, firstChunkTimeout)
|
||||
|
||||
// Handle post-stream cleanup
|
||||
this.cleanupAfterStream(streamState, firstChunkTimeout)
|
||||
|
||||
// Determine command completion behavior
|
||||
await this.handleCommandCompletion(command, streamState)
|
||||
}
|
||||
|
||||
private async executeCommandWithShellIntegration(terminal: vscode.Terminal, command: string): Promise<any> {
|
||||
try {
|
||||
if (!terminal.shellIntegration || !terminal.shellIntegration.executeCommand) {
|
||||
throw new Error("Shell integration not available")
|
||||
}
|
||||
const execution = terminal.shellIntegration.executeCommand(command)
|
||||
return execution.read()
|
||||
} catch (error) {
|
||||
console.error(`[TerminalProcess] Failed to execute command: ${error}`)
|
||||
this.emit("error", error as Error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// todo: need to handle errors
|
||||
let isFirstChunk = true
|
||||
let didOutputNonCommand = false
|
||||
let didEmitEmptyLine = false
|
||||
let receivedFirstChunk = false
|
||||
private initializeStreamState() {
|
||||
return {
|
||||
isFirstChunk: true,
|
||||
didOutputNonCommand: false,
|
||||
didEmitEmptyLine: false,
|
||||
receivedFirstChunk: false,
|
||||
}
|
||||
}
|
||||
|
||||
// Set up a timeout to emit empty line if no output is received within 3 seconds
|
||||
// This ensures the "proceed while running" button appears even for commands with no/delayed output
|
||||
const firstChunkTimeout = setTimeout(() => {
|
||||
if (!receivedFirstChunk && !didEmitEmptyLine) {
|
||||
console.log(`[TerminalProcess] First chunk timeout fired - no output received within 3s for: "${command}"`)
|
||||
this.emit("line", "") // empty line to show proceed button
|
||||
didEmitEmptyLine = true
|
||||
private setupNoOutputTimeout(command: string, streamState: any): NodeJS.Timeout {
|
||||
// Set up a 3-second timeout to handle commands with no/delayed output.
|
||||
// This ensures the UI remains responsive by:
|
||||
// 1. Showing the "proceed while running" button after 3 seconds
|
||||
// 2. Capturing current terminal contents in case shell integration missed output
|
||||
// 3. Informing the user that the command is still running
|
||||
return setTimeout(async () => {
|
||||
if (!streamState.receivedFirstChunk && !streamState.didEmitEmptyLine) {
|
||||
console.log(`[TerminalProcess] First chunk timeout fired - no output received within 3s for: "${command}"`)
|
||||
this.emit("line", "") // empty line to show proceed button
|
||||
streamState.didEmitEmptyLine = true
|
||||
|
||||
// Also emit a message indicating the command might be running without output
|
||||
this.emit("line", "[Command is running but producing no output]")
|
||||
}
|
||||
}, 3000) // 3 second timeout
|
||||
|
||||
for await (let data of stream) {
|
||||
// Clear the timeout since we received output
|
||||
if (!receivedFirstChunk) {
|
||||
clearTimeout(firstChunkTimeout)
|
||||
receivedFirstChunk = true
|
||||
console.log(`[TerminalProcess] First chunk received for command: "${command}"`)
|
||||
}
|
||||
|
||||
// Log raw data length
|
||||
console.log(`[TerminalProcess] Raw data chunk received: ${data.length} chars`)
|
||||
if (!data || data.trim() === "") {
|
||||
console.log(`[TerminalProcess] WARNING: Received empty or whitespace-only chunk`)
|
||||
}
|
||||
// 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)
|
||||
}
|
||||
// Place output back after removing vscode sequences
|
||||
if (outputBetweenSequences) {
|
||||
data = outputBetweenSequences + "\n" + data
|
||||
}
|
||||
// remove ansi
|
||||
data = stripAnsi(data)
|
||||
// Split data by newlines
|
||||
const 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 for duplicated first character that might be a terminal artifact
|
||||
// But skip this check for known syntax characters like {, [, ", etc.
|
||||
if (
|
||||
lines.length > 0 &&
|
||||
lines[0].length >= 2 &&
|
||||
lines[0][0] === lines[0][1] &&
|
||||
!["[", "{", '"', "'", "<", "("].includes(lines[0][0])
|
||||
) {
|
||||
lines[0] = lines[0].slice(1)
|
||||
}
|
||||
// Only remove specific terminal artifacts from line beginnings while preserving JSON syntax
|
||||
if (lines.length > 0) {
|
||||
// This regex only removes common terminal artifacts (%, $, >, #) and invisible control chars
|
||||
// but preserves important syntax chars like {, [, ", etc.
|
||||
lines[0] = lines[0].replace(/^[\x00-\x1F%$>#\s]*/, "")
|
||||
}
|
||||
if (lines.length > 1) {
|
||||
lines[1] = lines[1].replace(/^[\x00-\x1F%$>#\s]*/, "")
|
||||
}
|
||||
// Join lines back
|
||||
data = lines.join("\n")
|
||||
isFirstChunk = false
|
||||
} else {
|
||||
data = stripAnsi(data)
|
||||
}
|
||||
|
||||
// Ctrl+C detection: if user presses Ctrl+C, treat as command terminated
|
||||
if (data.includes("^C") || data.includes("\u0003")) {
|
||||
if (this.hotTimer) {
|
||||
clearTimeout(this.hotTimer)
|
||||
}
|
||||
this.isHot = false
|
||||
break
|
||||
}
|
||||
|
||||
// first few chunks could be the command being echoed back, so we must ignore
|
||||
// note this means that 'echo' commands won't 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")
|
||||
}
|
||||
|
||||
// 2. Set isHot depending on the command
|
||||
// Set to hot to stall 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()))
|
||||
this.hotTimer = setTimeout(
|
||||
() => {
|
||||
this.isHot = false
|
||||
},
|
||||
isCompiling ? PROCESS_HOT_TIMEOUT_COMPILING : PROCESS_HOT_TIMEOUT_NORMAL,
|
||||
)
|
||||
|
||||
// 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
|
||||
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
|
||||
}
|
||||
// Capture terminal contents as fallback for shell integration issues
|
||||
await this.emitCurrentTerminalContents()
|
||||
}
|
||||
}, 3000)
|
||||
}
|
||||
|
||||
this.emitRemainingBufferIfListening()
|
||||
|
||||
// Clean up the first chunk timeout if it's still active
|
||||
if (!receivedFirstChunk) {
|
||||
private async processOutputStream(
|
||||
stream: any,
|
||||
command: string,
|
||||
streamState: any,
|
||||
firstChunkTimeout: NodeJS.Timeout,
|
||||
): Promise<void> {
|
||||
for await (let data of stream) {
|
||||
// Handle first chunk received
|
||||
if (!streamState.receivedFirstChunk) {
|
||||
clearTimeout(firstChunkTimeout)
|
||||
console.log(`[TerminalProcess] WARNING: Stream ended without receiving any chunks for command: "${command}"`)
|
||||
|
||||
// If we never received any chunks and haven't emitted anything yet, emit now
|
||||
if (!didEmitEmptyLine) {
|
||||
console.log(`[TerminalProcess] Emitting fallback empty line for no-output command`)
|
||||
this.emit("line", "") // empty line to show proceed button
|
||||
// this.emit("line", "[Command completed with no output]")
|
||||
await emitCurrentTerminalContents()
|
||||
didEmitEmptyLine = true
|
||||
}
|
||||
streamState.receivedFirstChunk = true
|
||||
console.log(`[TerminalProcess] First chunk received for command: "${command}"`)
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
console.log(`[TerminalProcess] Stream ended for command: "${command}"`)
|
||||
console.log(`[TerminalProcess] Final output length: ${this.fullOutput.length} characters`)
|
||||
|
||||
// Check if this looks like a command that completed vs one that's still running
|
||||
const quickCommands = ["cd ", "pwd", "ls ", "echo ", "mkdir ", "touch ", "rm ", "cp ", "mv "]
|
||||
const isQuickCommand = quickCommands.some((cmd) => command.startsWith(cmd) || command.includes(" && " + cmd))
|
||||
|
||||
// Check if output suggests a long-running process
|
||||
const longRunningIndicators = [
|
||||
"listening on",
|
||||
"server running",
|
||||
"started on",
|
||||
"watching for",
|
||||
"compiled successfully",
|
||||
"webpack",
|
||||
"vite",
|
||||
"nodemon",
|
||||
"dev server",
|
||||
"press ctrl",
|
||||
"to quit",
|
||||
"to exit",
|
||||
"to stop",
|
||||
]
|
||||
const hasLongRunningOutput = longRunningIndicators.some((indicator) =>
|
||||
this.fullOutput.toLowerCase().includes(indicator),
|
||||
)
|
||||
|
||||
// Check if this is likely a command that starts a server or long-running process
|
||||
const longRunningCommands = ["npm run", "npm start", "yarn", "node ", "python ", "serve", "dev", "watch"]
|
||||
const isLongRunningCommand = longRunningCommands.some((cmd) => command.includes(cmd))
|
||||
|
||||
if (this.fullOutput.length === 0) {
|
||||
console.log(`[TerminalProcess] WARNING: Process completed but no output was captured`)
|
||||
// Ensure we emit at least one line for UI feedback
|
||||
if (!didEmitEmptyLine) {
|
||||
// this.emit("line", "[Command completed silently]")
|
||||
await emitCurrentTerminalContents()
|
||||
}
|
||||
// Log chunk info
|
||||
console.log(`[TerminalProcess] Raw data chunk received: ${data.length} chars`)
|
||||
if (!data || data.trim() === "") {
|
||||
console.log(`[TerminalProcess] WARNING: Received empty or whitespace-only chunk`)
|
||||
}
|
||||
|
||||
// Only skip grace period for truly quick commands that have no output or are known to complete instantly
|
||||
if ((this.fullOutput.length === 0 || isQuickCommand) && !isLongRunningCommand && !hasLongRunningOutput) {
|
||||
console.log(`[TerminalProcess] Command appears to have completed immediately, skipping grace period`)
|
||||
this.emit("completed")
|
||||
this.emit("continue")
|
||||
} else {
|
||||
console.log(
|
||||
`[TerminalProcess] Command may still be running (longRunningCommand: ${isLongRunningCommand}, longRunningOutput: ${hasLongRunningOutput})`,
|
||||
)
|
||||
console.log(`[TerminalProcess] Starting grace period to detect true completion...`)
|
||||
// Start grace period - wait 2.5 seconds to see if more output comes
|
||||
// This prevents premature "proceed while running" for commands that clearly finished
|
||||
this.startGracePeriod()
|
||||
// Process the chunk
|
||||
data = await this.processChunk(data, command, streamState)
|
||||
|
||||
// Check for Ctrl+C interruption
|
||||
if (this.isCommandInterrupted(data)) {
|
||||
this.handleInterruption()
|
||||
break
|
||||
}
|
||||
|
||||
// Update hot/cool state based on output
|
||||
this.updateHotState(data)
|
||||
|
||||
// Handle output emission
|
||||
this.handleChunkOutput(data, streamState)
|
||||
}
|
||||
|
||||
// Emit any remaining buffer content
|
||||
this.emitRemainingBufferIfListening()
|
||||
}
|
||||
|
||||
private async processChunk(data: string, command: string, streamState: any): Promise<string> {
|
||||
if (streamState.isFirstChunk) {
|
||||
data = this.cleanFirstChunk(data)
|
||||
streamState.isFirstChunk = false
|
||||
} else {
|
||||
// no shell integration detected, we'll fallback to running the command and capturing the terminal's output after some time
|
||||
terminal.sendText(command, true)
|
||||
data = stripAnsi(data)
|
||||
}
|
||||
|
||||
// wait 3 seconds for the command to run
|
||||
await new Promise((resolve) => setTimeout(resolve, 3000))
|
||||
// Remove command echo if not yet seen real output
|
||||
if (!streamState.didOutputNonCommand) {
|
||||
data = this.removeCommandEcho(data, command, streamState)
|
||||
}
|
||||
|
||||
// For terminals without shell integration, also try to capture terminal content
|
||||
await emitCurrentTerminalContents()
|
||||
// For terminals without shell integration, we can't know when the command completes
|
||||
// So we'll just emit the continue event after a delay
|
||||
return data
|
||||
}
|
||||
|
||||
private cleanFirstChunk(data: string): string {
|
||||
/*
|
||||
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
|
||||
*/
|
||||
// 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)
|
||||
}
|
||||
// Place output back after removing vscode sequences
|
||||
if (outputBetweenSequences) {
|
||||
data = outputBetweenSequences + "\n" + data
|
||||
}
|
||||
|
||||
// remove ansi codes
|
||||
data = stripAnsi(data)
|
||||
|
||||
// Split data by newlines for line-by-line processing
|
||||
const 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 for duplicated first character that might be a terminal artifact
|
||||
// But skip this check for known syntax characters like {, [, ", etc.
|
||||
if (
|
||||
lines.length > 0 &&
|
||||
lines[0].length >= 2 &&
|
||||
lines[0][0] === lines[0][1] &&
|
||||
!["[", "{", '"', "'", "<", "("].includes(lines[0][0])
|
||||
) {
|
||||
lines[0] = lines[0].slice(1)
|
||||
}
|
||||
|
||||
// Remove specific terminal artifacts from line beginnings while preserving JSON syntax
|
||||
if (lines.length > 0) {
|
||||
// This regex only removes common terminal artifacts (%, $, >, #) and invisible control chars
|
||||
// but preserves important syntax chars like {, [, ", etc.
|
||||
lines[0] = lines[0].replace(/^[\x00-\x1F%$>#\s]*/, "")
|
||||
}
|
||||
if (lines.length > 1) {
|
||||
lines[1] = lines[1].replace(/^[\x00-\x1F%$>#\s]*/, "")
|
||||
}
|
||||
|
||||
// Join lines back
|
||||
return lines.join("\n")
|
||||
}
|
||||
|
||||
private removeCommandEcho(data: string, command: string, streamState: any): string {
|
||||
// first few chunks could be the command being echoed back, so we must ignore
|
||||
// note this means that 'echo' commands won't work properly
|
||||
const lines = data.split("\n")
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const trimmedLine = lines[i].trim()
|
||||
// Check if the line is the command being echoed back
|
||||
if (trimmedLine && trimmedLine === command.trim()) {
|
||||
lines.splice(i, 1)
|
||||
i-- // Adjust index after removal
|
||||
} else if (trimmedLine) {
|
||||
// We've hit actual output, not just the command echo
|
||||
streamState.didOutputNonCommand = true
|
||||
break
|
||||
}
|
||||
}
|
||||
return lines.join("\n")
|
||||
}
|
||||
|
||||
private isCommandInterrupted(data: string): boolean {
|
||||
// Ctrl+C detection: if user presses Ctrl+C, treat as command terminated
|
||||
return data.includes("^C") || data.includes("\u0003")
|
||||
}
|
||||
|
||||
private handleInterruption(): void {
|
||||
if (this.hotTimer) {
|
||||
clearTimeout(this.hotTimer)
|
||||
}
|
||||
this.isHot = false
|
||||
}
|
||||
|
||||
private updateHotState(data: string): void {
|
||||
// Set to hot to stall API requests until terminal is cool again
|
||||
this.isHot = true
|
||||
if (this.hotTimer) {
|
||||
clearTimeout(this.hotTimer)
|
||||
}
|
||||
|
||||
const isCompiling = this.detectCompilationStatus(data)
|
||||
const timeout = isCompiling ? PROCESS_HOT_TIMEOUT_COMPILING : PROCESS_HOT_TIMEOUT_NORMAL
|
||||
|
||||
this.hotTimer = setTimeout(() => {
|
||||
this.isHot = false
|
||||
}, timeout)
|
||||
}
|
||||
|
||||
private detectCompilationStatus(data: string): boolean {
|
||||
// these markers indicate the command is some kind of local dev server recompiling the app
|
||||
const compilingMarkers = ["compiling", "building", "bundling", "transpiling", "generating", "starting"]
|
||||
const markerNullifiers = [
|
||||
"compiled",
|
||||
"success",
|
||||
"finish",
|
||||
"complete",
|
||||
"succeed",
|
||||
"done",
|
||||
"end",
|
||||
"stop",
|
||||
"exit",
|
||||
"terminate",
|
||||
"error",
|
||||
"fail",
|
||||
]
|
||||
|
||||
return (
|
||||
compilingMarkers.some((marker) => data.toLowerCase().includes(marker.toLowerCase())) &&
|
||||
!markerNullifiers.some((nullifier) => data.toLowerCase().includes(nullifier.toLowerCase()))
|
||||
)
|
||||
}
|
||||
|
||||
private handleChunkOutput(data: string, streamState: any): void {
|
||||
// For non-immediately returning commands we want to show loading spinner right away
|
||||
if (!streamState.didEmitEmptyLine && !this.fullOutput && data) {
|
||||
this.emit("line", "") // empty line to indicate start of command output stream
|
||||
streamState.didEmitEmptyLine = true
|
||||
}
|
||||
|
||||
this.fullOutput += data
|
||||
if (this.isListening) {
|
||||
this.emitIfEol(data)
|
||||
this.lastRetrievedIndex = this.fullOutput.length - this.buffer.length
|
||||
}
|
||||
}
|
||||
|
||||
private cleanupAfterStream(streamState: any, firstChunkTimeout: NodeJS.Timeout): void {
|
||||
// Clean up the first chunk timeout if it's still active
|
||||
if (!streamState.receivedFirstChunk) {
|
||||
clearTimeout(firstChunkTimeout)
|
||||
console.log(`[TerminalProcess] WARNING: Stream ended without receiving any chunks for command: "${this.command}"`)
|
||||
}
|
||||
|
||||
// Clear hot timer
|
||||
if (this.hotTimer) {
|
||||
clearTimeout(this.hotTimer)
|
||||
}
|
||||
this.isHot = false
|
||||
|
||||
console.log(`[TerminalProcess] Stream ended for command: "${this.command}"`)
|
||||
console.log(`[TerminalProcess] Final output length: ${this.fullOutput.length} characters`)
|
||||
}
|
||||
|
||||
private async handleCommandCompletion(command: string, streamState: any): Promise<void> {
|
||||
const commandType = this.analyzeCommandType(command)
|
||||
|
||||
// Handle commands with no output
|
||||
if (this.fullOutput.length === 0) {
|
||||
console.log(`[TerminalProcess] WARNING: Process completed but no output was captured`)
|
||||
if (!streamState.didEmitEmptyLine) {
|
||||
await this.emitCurrentTerminalContents()
|
||||
}
|
||||
}
|
||||
|
||||
// Determine if we should skip grace period
|
||||
if (commandType.isQuickCommand && !commandType.isLongRunning) {
|
||||
console.log(`[TerminalProcess] Command appears to have completed immediately, skipping grace period`)
|
||||
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
|
||||
} else {
|
||||
console.log(`[TerminalProcess] Command may still be running (longRunningCommand: ${commandType.isLongRunning})`)
|
||||
console.log(`[TerminalProcess] Starting grace period to detect true completion...`)
|
||||
this.startGracePeriod()
|
||||
}
|
||||
}
|
||||
|
||||
private analyzeCommandType(command: string): { isQuickCommand: boolean; isLongRunning: boolean } {
|
||||
// Check if this looks like a command that completed vs one that's still running
|
||||
const quickCommands = ["cd ", "pwd", "ls ", "echo ", "mkdir ", "touch ", "rm ", "cp ", "mv "]
|
||||
const isQuickCommand = quickCommands.some((cmd) => command.startsWith(cmd) || command.includes(" && " + cmd))
|
||||
|
||||
// Check if output suggests a long-running process
|
||||
const longRunningIndicators = [
|
||||
"listening on",
|
||||
"server running",
|
||||
"started on",
|
||||
"watching for",
|
||||
"compiled successfully",
|
||||
"webpack",
|
||||
"vite",
|
||||
"nodemon",
|
||||
"dev server",
|
||||
"press ctrl",
|
||||
"to quit",
|
||||
"to exit",
|
||||
"to stop",
|
||||
]
|
||||
const hasLongRunningOutput = longRunningIndicators.some((indicator) => this.fullOutput.toLowerCase().includes(indicator))
|
||||
|
||||
// Check if this is likely a command that starts a server or long-running process
|
||||
const longRunningCommands = ["npm run", "npm start", "yarn", "node ", "python ", "serve", "dev", "watch"]
|
||||
const isLongRunningCommand = longRunningCommands.some((cmd) => command.includes(cmd))
|
||||
|
||||
return {
|
||||
isQuickCommand: this.fullOutput.length === 0 || isQuickCommand,
|
||||
isLongRunning: isLongRunningCommand || hasLongRunningOutput,
|
||||
}
|
||||
}
|
||||
|
||||
private async runWithoutShellIntegration(terminal: vscode.Terminal, command: string): Promise<void> {
|
||||
// Send command to terminal
|
||||
terminal.sendText(command, true)
|
||||
|
||||
// wait 3 seconds for the command to run
|
||||
await new Promise((resolve) => setTimeout(resolve, 3000))
|
||||
|
||||
// For terminals without shell integration, also try to capture terminal content
|
||||
await this.emitCurrentTerminalContents()
|
||||
// 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
|
||||
}
|
||||
|
||||
// Inspired by https://github.com/sindresorhus/execa/blob/main/lib/transform/split.js
|
||||
private emitIfEol(chunk: string) {
|
||||
this.buffer += chunk
|
||||
|
||||
Reference in New Issue
Block a user