mirror of
https://github.com/cline/cline.git
synced 2026-09-15 21:04:27 +08:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d5bf48062c | ||
|
|
08f135a254 |
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Fix terminal commands putting webview in blocked state
|
||||
@@ -1,6 +1,6 @@
|
||||
import { EventEmitter } from "events"
|
||||
import { stripAnsi } from "./ansiUtils"
|
||||
import * as vscode from "vscode"
|
||||
import { stripAnsi } from "./ansiUtils"
|
||||
import { getLatestTerminalOutput } from "./get-latest-output"
|
||||
|
||||
export interface TerminalProcessEvents {
|
||||
@@ -23,447 +23,217 @@ export class TerminalProcess extends EventEmitter<TerminalProcessEvents> {
|
||||
private lastRetrievedIndex: number = 0
|
||||
isHot: boolean = false
|
||||
private hotTimer: NodeJS.Timeout | null = null
|
||||
private command: string = ""
|
||||
private gracePeriodTimer: NodeJS.Timeout | null = null
|
||||
private hasEmittedCompleted: boolean = false
|
||||
|
||||
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)
|
||||
this.gracePeriodTimer = null
|
||||
console.log(`[TerminalProcess] Cleared existing grace period timer before starting new command`)
|
||||
}
|
||||
|
||||
// Clear any existing hot timer
|
||||
if (this.hotTimer) {
|
||||
clearTimeout(this.hotTimer)
|
||||
this.hotTimer = null
|
||||
}
|
||||
|
||||
// Reset state for new command
|
||||
this.hasEmittedCompleted = false
|
||||
this.buffer = ""
|
||||
this.fullOutput = ""
|
||||
this.lastRetrievedIndex = 0
|
||||
this.isListening = true
|
||||
this.isHot = false
|
||||
this.command = command
|
||||
}
|
||||
|
||||
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")
|
||||
// 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 returnCurrentTerminalContents = 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)
|
||||
}
|
||||
}
|
||||
|
||||
if (terminal.shellIntegration && terminal.shellIntegration.executeCommand) {
|
||||
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
|
||||
}
|
||||
}
|
||||
const stream = execution.read()
|
||||
// todo: need to handle errors
|
||||
let isFirstChunk = true
|
||||
let didOutputNonCommand = false
|
||||
let didEmitEmptyLine = false
|
||||
|
||||
private initializeStreamState() {
|
||||
return {
|
||||
isFirstChunk: true,
|
||||
didOutputNonCommand: false,
|
||||
didEmitEmptyLine: false,
|
||||
receivedFirstChunk: false,
|
||||
}
|
||||
}
|
||||
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.
|
||||
*/
|
||||
|
||||
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
|
||||
// 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()
|
||||
|
||||
// Capture terminal contents as fallback for shell integration issues
|
||||
await this.emitCurrentTerminalContents()
|
||||
}
|
||||
}, 3000)
|
||||
}
|
||||
// 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)
|
||||
}
|
||||
|
||||
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)
|
||||
streamState.receivedFirstChunk = true
|
||||
console.log(`[TerminalProcess] First chunk received for command: "${command}"`)
|
||||
// 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
|
||||
// This is only done for the sake of unblocking the UI, in case there may be some time before the command emits a full line
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// 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`)
|
||||
this.emitRemainingBufferIfListening()
|
||||
|
||||
// the command process is finished, let's check the output to see if we need to use the terminal capture fallback
|
||||
if (!this.fullOutput.trim()) {
|
||||
await returnCurrentTerminalContents()
|
||||
}
|
||||
|
||||
// Process the chunk
|
||||
data = await this.processChunk(data, command, streamState)
|
||||
|
||||
// Check for Ctrl+C interruption
|
||||
if (this.isCommandInterrupted(data)) {
|
||||
this.handleInterruption()
|
||||
break
|
||||
// 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")
|
||||
// to explain this further, before we would send workspace diagnostics automatically with each request, but now we only send new diagnostics after file edits, so there's no need to wait for a bit after commands run to let diagnostics catch up
|
||||
if (this.hotTimer) {
|
||||
clearTimeout(this.hotTimer)
|
||||
}
|
||||
|
||||
// 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 {
|
||||
data = stripAnsi(data)
|
||||
}
|
||||
|
||||
// Remove command echo if not yet seen real output
|
||||
if (!streamState.didOutputNonCommand) {
|
||||
data = this.removeCommandEcho(data, command, streamState)
|
||||
}
|
||||
|
||||
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")
|
||||
} else {
|
||||
console.log(`[TerminalProcess] Command may still be running (longRunningCommand: ${commandType.isLongRunning})`)
|
||||
console.log(`[TerminalProcess] Starting grace period to detect true completion...`)
|
||||
this.startGracePeriod()
|
||||
// no shell integration detected, we'll fallback to running the command and capturing the terminal's output after some time
|
||||
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 returnCurrentTerminalContents()
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
let lineEndIndex: number
|
||||
let lineCount = 0
|
||||
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)
|
||||
// }
|
||||
if (!line || line.trim() === "") {
|
||||
console.log(`[TerminalProcess] Emitting empty line`)
|
||||
} else {
|
||||
console.log(`[TerminalProcess] Emitting line: ${line.substring(0, 100)}${line.length > 100 ? "..." : ""}`)
|
||||
}
|
||||
this.emit("line", line)
|
||||
this.buffer = this.buffer.slice(lineEndIndex + 1)
|
||||
lineCount++
|
||||
}
|
||||
if (lineCount === 0 && chunk.length > 0) {
|
||||
console.log(`[TerminalProcess] Buffering partial line, buffer size: ${this.buffer.length}`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -478,41 +248,7 @@ export class TerminalProcess extends EventEmitter<TerminalProcessEvents> {
|
||||
}
|
||||
}
|
||||
|
||||
private startGracePeriod() {
|
||||
// Clear any existing grace period timer
|
||||
if (this.gracePeriodTimer) {
|
||||
clearTimeout(this.gracePeriodTimer)
|
||||
}
|
||||
|
||||
// Emit completed event for UI to show "proceed while running" button
|
||||
console.log(`[TerminalProcess] Emitting completed event for UI (grace period active)`)
|
||||
this.emit("completed")
|
||||
|
||||
// Wait 2.5 seconds to see if the command is truly finished
|
||||
this.gracePeriodTimer = setTimeout(() => {
|
||||
// Double-check the timer hasn't been cleared
|
||||
if (this.gracePeriodTimer && !this.hasEmittedCompleted) {
|
||||
console.log(`[TerminalProcess] Grace period completed - command appears truly finished: "${this.command}"`)
|
||||
console.log(`[TerminalProcess] Auto-continuing without user intervention`)
|
||||
this.hasEmittedCompleted = true
|
||||
this.gracePeriodTimer = null
|
||||
// Only emit continue after the grace period, not immediately
|
||||
this.emit("continue")
|
||||
}
|
||||
}, 2500) // 2.5 second grace period
|
||||
}
|
||||
|
||||
continue() {
|
||||
console.log(`[TerminalProcess] Manual continue() called for: "${this.command}"`)
|
||||
|
||||
// Clear grace period since user manually continued
|
||||
if (this.gracePeriodTimer) {
|
||||
console.log(`[TerminalProcess] Clearing grace period timer due to manual continue`)
|
||||
clearTimeout(this.gracePeriodTimer)
|
||||
this.gracePeriodTimer = null
|
||||
}
|
||||
|
||||
this.hasEmittedCompleted = true
|
||||
this.emitRemainingBufferIfListening()
|
||||
this.isListening = false
|
||||
this.removeAllListeners("line")
|
||||
|
||||
Reference in New Issue
Block a user