Skip to content
Closed
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 49 additions & 5 deletions src/integrations/terminal/TerminalProcess.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { inspect } from "util"
import type { ExitCodeDetails } from "./types"
import { BaseTerminalProcess } from "./BaseTerminalProcess"
import { Terminal } from "./Terminal"
import { parseCommand, CommandSegment } from "./commandParser"

export class TerminalProcess extends BaseTerminalProcess {
private terminalRef: WeakRef<Terminal>
Expand Down Expand Up @@ -72,6 +73,46 @@ export class TerminalProcess extends BaseTerminalProcess {
return
}

// Parse the command to check for compound operators
const parsedCommand = parseCommand(command)

if (parsedCommand.isCompound) {
console.info(`[TerminalProcess] Detected compound command with ${parsedCommand.segments.length} segments`)
console.info(`[TerminalProcess] Executing compound command as a single shell command to preserve context`)
// Execute compound commands as a single command to preserve shell context
// This ensures operators like && and || work correctly and state is maintained
await this.runSingleCommand(command)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I notice this implementation takes a simpler approach than what was discussed in issue #7430. The discussion mentioned tracking ALL processes spawned by compound commands and waiting for them to complete before reporting to the LLM. However, this executes the entire compound command as a single shell command.

Is this intentional? While it does preserve shell context correctly (which solves the immediate problem), it doesn't actually implement multi-process tracking. The shell handles the compound operators internally, but we're not tracking individual process spawns.

This approach works and is simpler, but wanted to confirm it meets the requirements?

return
}

// Execute single command as before
await this.runSingleCommand(command)
}

/**
* @deprecated This method is no longer used. Compound commands are now executed
* as a single shell command to preserve context and proper operator behavior.
* Keeping for reference only.
*
* Previously attempted to execute compound commands by splitting them into segments,
* but this approach lost shell context between commands (e.g., cd wouldn't affect
* subsequent commands).
*/
private async runCompoundCommand_DEPRECATED(segments: CommandSegment[]) {
// This method is intentionally left empty and deprecated
// Compound commands are now handled by executing them as a single command
throw new Error("runCompoundCommand is deprecated. Compound commands should be executed as a single command.")
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This deprecated method throws an error if called, but it's never actually called anywhere in the codebase. Since it's marked as deprecated and exists only for reference, could we either:

  1. Remove it entirely to avoid confusion
  2. Make it clearer that it's documentation-only (maybe rename to )
  3. Convert it to a comment block?

Leaving executable code that throws errors might confuse future maintainers.

}

private lastExitCode?: number
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This property is set at line 254 but never read anywhere. Is this intended for future use, or can it be removed? If it's for future use, maybe add a comment explaining the planned usage?


/**
* Executes a command through VSCode's shell integration.
* Handles both simple and compound commands.
*/
private async runSingleCommand(command: string): Promise<string> {
const terminal = this.terminal.terminal

// Create a promise that resolves when the stream becomes available
const streamAvailable = new Promise<AsyncIterable<string>>((resolve, reject) => {
const timeoutId = setTimeout(() => {
Expand Down Expand Up @@ -127,9 +168,9 @@ export class TerminalProcess extends BaseTerminalProcess {
commandToExecute += ` ; start-sleep -milliseconds ${Terminal.getCommandDelay()}`
}

terminal.shellIntegration.executeCommand(commandToExecute)
terminal.shellIntegration!.executeCommand(commandToExecute)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using non-null assertion here, but at line 53 we properly check for null. Could we add a guard check or at least a comment explaining why we're certain is non-null at this point? The check at line 53 suggests it could be null.

} else {
terminal.shellIntegration.executeCommand(command)
terminal.shellIntegration!.executeCommand(command)
}

this.isHot = true
Expand All @@ -153,7 +194,7 @@ export class TerminalProcess extends BaseTerminalProcess {

// Emit continue event to allow execution to proceed
this.emit("continue")
return
return ""
}

let preOutput = ""
Expand Down Expand Up @@ -209,7 +250,8 @@ export class TerminalProcess extends BaseTerminalProcess {
this.terminal.setActiveStream(undefined)

// Wait for shell execution to complete.
await shellExecutionComplete
const exitDetails = await shellExecutionComplete
this.lastExitCode = exitDetails.exitCode

this.isHot = false

Expand Down Expand Up @@ -237,7 +279,7 @@ export class TerminalProcess extends BaseTerminalProcess {
this.continue()

// Return early since we can't process output without shell integration markers
return
return ""
}

// fullOutput begins after C marker so we only need to trim off D marker
Expand All @@ -253,8 +295,10 @@ export class TerminalProcess extends BaseTerminalProcess {
// command is finished, we still want to consider it 'hot' in case
// so that api request stalls to let diagnostics catch up").
this.stopHotTimer()

this.emit("completed", this.removeEscapeSequences(this.fullOutput))
this.emit("continue")
return this.removeEscapeSequences(this.fullOutput)
}

public override continue() {
Expand Down
109 changes: 109 additions & 0 deletions src/integrations/terminal/__tests__/TerminalProcess.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { mergePromise } from "../mergePromise"
import { TerminalProcess } from "../TerminalProcess"
import { Terminal } from "../Terminal"
import { TerminalRegistry } from "../TerminalRegistry"
import { parseCommand } from "../commandParser"

vi.mock("execa", () => ({
execa: vi.fn(),
Expand Down Expand Up @@ -165,6 +166,114 @@ describe("TerminalProcess", () => {
await completePromise
expect(terminalProcess.isHot).toBe(false)
})

it("handles compound commands as single command", async () => {
// Test that compound commands are executed as a single command
// to preserve shell context between segments
const compoundCommand = "cd foo && npm test"

let executedCommand = ""

mockStream = (async function* () {
yield "\x1b]633;C\x07"
yield "Changed directory to foo\n"
yield "Running tests...\n"
yield "Tests passed"
yield "\x1b]633;D\x07"
terminalProcess.emit("shell_execution_complete", { exitCode: 0 })
})()

mockTerminal.shellIntegration.executeCommand.mockImplementation((cmd: string) => {
executedCommand = cmd
return {
read: vi.fn().mockReturnValue(mockStream),
}
})

const runPromise = terminalProcess.run(compoundCommand)

// Emit stream available with the mock stream
terminalProcess.emit("stream_available", mockStream)

await runPromise

// Verify the compound command was executed as a single command
expect(executedCommand).toBe(compoundCommand)
expect(mockTerminal.shellIntegration.executeCommand).toHaveBeenCalledTimes(1)
expect(mockTerminal.shellIntegration.executeCommand).toHaveBeenCalledWith(compoundCommand)
})
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

While the tests cover the happy path well, there's no test for what happens if the deprecated method is accidentally called. Should we add a test to ensure it throws the expected error, or is the method going to be removed?


it("detects compound commands correctly", async () => {
// Test various compound command patterns
const testCases = [
{ command: "cd foo && npm test", isCompound: true },
{ command: "npm test || echo 'Tests failed'", isCompound: true },
{ command: "echo start; npm test; echo done", isCompound: true },
{ command: "ls -la | grep test", isCompound: true },
{ command: "npm test", isCompound: false },
{ command: "echo 'test && test'", isCompound: false }, // Quoted operators shouldn't be detected
]

for (const testCase of testCases) {
const parsed = parseCommand(testCase.command)
expect(parsed.isCompound).toBe(testCase.isCompound)
}
})

it("preserves shell context in compound commands", async () => {
// This test verifies that compound commands maintain context
// For example, cd in the first part affects the second part
const command = "cd /tmp && pwd"

mockStream = (async function* () {
yield "\x1b]633;C\x07"
yield "/tmp\n" // pwd should output /tmp since cd was in same shell context
yield "\x1b]633;D\x07"
terminalProcess.emit("shell_execution_complete", { exitCode: 0 })
})()

mockTerminal.shellIntegration.executeCommand.mockReturnValue({
read: vi.fn().mockReturnValue(mockStream),
})

let output = ""
terminalProcess.on("completed", (result) => {
output = result || ""
})

const runPromise = terminalProcess.run(command)
terminalProcess.emit("stream_available", mockStream)
await runPromise

expect(output.trim()).toBe("/tmp")
// Verify it was executed as a single command
expect(mockTerminal.shellIntegration.executeCommand).toHaveBeenCalledWith(command)
})

it("handles complex compound commands with multiple operators", async () => {
const complexCommand = "git add . && git commit -m 'test' && git push || echo 'Push failed'"

mockStream = (async function* () {
yield "\x1b]633;C\x07"
yield "Files added\n"
yield "Committed\n"
yield "Pushed successfully\n"
yield "\x1b]633;D\x07"
terminalProcess.emit("shell_execution_complete", { exitCode: 0 })
})()

mockTerminal.shellIntegration.executeCommand.mockReturnValue({
read: vi.fn().mockReturnValue(mockStream),
})

const runPromise = terminalProcess.run(complexCommand)
terminalProcess.emit("stream_available", mockStream)
await runPromise

// Should execute as single command
expect(mockTerminal.shellIntegration.executeCommand).toHaveBeenCalledTimes(1)
expect(mockTerminal.shellIntegration.executeCommand).toHaveBeenCalledWith(complexCommand)
})
})

describe("continue", () => {
Expand Down
Loading
Loading