diff --git a/core/config/types.ts b/core/config/types.ts index d9f58aca6e2..23e34521630 100644 --- a/core/config/types.ts +++ b/core/config/types.ts @@ -691,7 +691,9 @@ declare global { getExternalUri?(uri: string): Promise; runCommand(command: string): Promise; - + + runCommandWithOutput(command: string, cwd?: string): Promise; + saveFile(filepath: string): Promise; readFile(filepath: string): Promise; diff --git a/core/index.d.ts b/core/index.d.ts index 82941d189e7..f4c8e436575 100644 --- a/core/index.d.ts +++ b/core/index.d.ts @@ -96,7 +96,8 @@ type RequiredLLMOptions = | "completionOptions"; export interface ILLM - extends Omit, + extends + Omit, Required> { get providerName(): string; get underlyingProviderName(): string; @@ -872,6 +873,8 @@ export interface IDE { runCommand(command: string, options?: TerminalOptions): Promise; + runCommandWithOutput(command: string, cwd?: string): Promise; + saveFile(fileUri: string): Promise; readFile(fileUri: string): Promise; diff --git a/core/protocol/ide.ts b/core/protocol/ide.ts index 73492f18077..c466b7467f4 100644 --- a/core/protocol/ide.ts +++ b/core/protocol/ide.ts @@ -30,6 +30,7 @@ export type ToIdeFromWebviewOrCoreProtocol = { openFile: [{ path: string }, void]; openUrl: [string, void]; runCommand: [{ command: string; options?: TerminalOptions }, void]; + runCommandWithOutput: [{ command: string; cwd?: string }, string]; getSearchResults: [{ query: string; maxResults?: number }, string]; getFileResults: [{ pattern: string; maxResults?: number }, string[]]; subprocess: [{ command: string; cwd?: string }, [string, string]]; diff --git a/core/protocol/messenger/messageIde.ts b/core/protocol/messenger/messageIde.ts index 00b3bde9d5c..88b12822b47 100644 --- a/core/protocol/messenger/messageIde.ts +++ b/core/protocol/messenger/messageIde.ts @@ -188,6 +188,10 @@ export class MessageIde implements IDE { await this.request("runCommand", { command, options }); } + async runCommandWithOutput(command: string, cwd?: string): Promise { + return this.request("runCommandWithOutput", { command, cwd }); + } + async saveFile(fileUri: string): Promise { await this.request("saveFile", { filepath: fileUri }); } diff --git a/core/protocol/messenger/reverseMessageIde.ts b/core/protocol/messenger/reverseMessageIde.ts index 9ce82db54a9..787ad626e09 100644 --- a/core/protocol/messenger/reverseMessageIde.ts +++ b/core/protocol/messenger/reverseMessageIde.ts @@ -134,6 +134,10 @@ export class ReverseMessageIde { return this.ide.runCommand(data.command); }); + this.on("runCommandWithOutput", (data) => { + return this.ide.runCommandWithOutput(data.command, data.cwd); + }); + this.on("saveFile", (data) => { return this.ide.saveFile(data.filepath); }); diff --git a/core/tools/implementations/runTerminalCommand.ts b/core/tools/implementations/runTerminalCommand.ts index 351ee75cae6..fbf6caeb3b1 100644 --- a/core/tools/implementations/runTerminalCommand.ts +++ b/core/tools/implementations/runTerminalCommand.ts @@ -453,16 +453,32 @@ export const runTerminalCommandImpl: ToolImpl = async (args, extras) => { } } - // For remote environments, just run the command - // Note: waitForCompletion is not supported in remote environments yet - await extras.ide.runCommand(command); + // For remote environments, use shell integration for output capture + const workspaceDirs = await extras.ide.getWorkspaceDirs(); + const cwd = workspaceDirs.length > 0 ? workspaceDirs[0] : undefined; + + if (extras.onPartialOutput) { + extras.onPartialOutput({ + toolCallId, + contextItems: [ + { + name: "Terminal", + description: "Terminal command output", + content: "", + status: "Running command on remote...", + }, + ], + }); + } + + const output = await extras.ide.runCommandWithOutput(command, cwd); + return [ { name: "Terminal", description: "Terminal command output", - content: - "Terminal output not available. This is only available in local development environments and not in SSH environments for example.", - status: "Command failed", + content: output || "Command completed (no output captured)", + status: "Command completed", }, ]; }; diff --git a/core/tools/implementations/runTerminalCommand.vitest.ts b/core/tools/implementations/runTerminalCommand.vitest.ts index ea502f7c015..013b2581fd2 100644 --- a/core/tools/implementations/runTerminalCommand.vitest.ts +++ b/core/tools/implementations/runTerminalCommand.vitest.ts @@ -95,6 +95,7 @@ describe("runTerminalCommandImpl", () => { getIdeInfo: mockGetIdeInfo, getWorkspaceDirs: mockGetWorkspaceDirs, runCommand: mockRunCommand, + runCommandWithOutput: vi.fn().mockResolvedValue(""), // Add stubs for other required IDE methods getIdeSettings: vi.fn(), getDiff: vi.fn(), @@ -269,13 +270,10 @@ describe("runTerminalCommandImpl", () => { const result = await runTerminalCommandImpl(args, extras); - // In remote environments, it should use the IDE's runCommand - expect(mockRunCommand).toHaveBeenCalledWith("echo 'test'"); - // Match the actual output message - expect(result[0].content).toContain("Terminal output not available"); - expect(result[0].content).toContain("SSH environments"); - // Verify status field indicates command failed in remote environments - expect(result[0].status).toBe("Command failed"); + // In remote environments, it should use runCommandWithOutput + // and report completed (no double-execution via runCommand) + expect(mockRunCommand).not.toHaveBeenCalled(); + expect(result[0].status).toBe("Command completed"); }); it("should handle errors when executing invalid commands", async () => { @@ -370,6 +368,7 @@ describe("runTerminalCommandImpl", () => { .mockReturnValue(Promise.resolve({ remoteName: "local" })), getWorkspaceDirs: mockEmptyWorkspace, runCommand: vi.fn(), + runCommandWithOutput: vi.fn().mockResolvedValue(""), getIdeSettings: vi.fn(), getDiff: vi.fn(), getClipboardContent: vi.fn(), @@ -430,6 +429,7 @@ describe("runTerminalCommandImpl", () => { .mockReturnValue(Promise.resolve({ remoteName: "local" })), getWorkspaceDirs: mockEmptyWorkspace, runCommand: vi.fn(), + runCommandWithOutput: vi.fn().mockResolvedValue(""), getIdeSettings: vi.fn(), getDiff: vi.fn(), getClipboardContent: vi.fn(), @@ -618,8 +618,8 @@ describe("runTerminalCommandImpl", () => { extras, ); - expect(mockRunCommand).toHaveBeenCalledWith("echo test"); - expect(result[0].content).toContain("Terminal output not available"); + expect(mockRunCommand).not.toHaveBeenCalled(); + expect(result[0].status).toBe("Command completed"); }); it("should handle local environment with file URIs", async () => { @@ -658,9 +658,9 @@ describe("runTerminalCommandImpl", () => { extras, ); - // Should fall back to ide.runCommand, not try to spawn powershell.exe - expect(mockRunCommand).toHaveBeenCalledWith("echo test"); - expect(result[0].content).toContain("Terminal output not available"); + // Should use runCommandWithOutput, not try to spawn powershell.exe + expect(mockRunCommand).not.toHaveBeenCalled(); + expect(result[0].status).toBe("Command completed"); } finally { Object.defineProperty(process, "platform", { value: originalPlatform, diff --git a/core/util/filesystem.ts b/core/util/filesystem.ts index 6a813931250..1584d3d9f8c 100644 --- a/core/util/filesystem.ts +++ b/core/util/filesystem.ts @@ -232,6 +232,10 @@ class FileSystemIde implements IDE { return Promise.resolve(); } + runCommandWithOutput(command: string, cwd?: string): Promise { + return Promise.resolve(""); + } + saveFile(fileUri: string): Promise { return Promise.resolve(); } diff --git a/extensions/vscode/src/VsCodeIde.ts b/extensions/vscode/src/VsCodeIde.ts index 9e5853afa3b..4dccc4aa272 100644 --- a/extensions/vscode/src/VsCodeIde.ts +++ b/extensions/vscode/src/VsCodeIde.ts @@ -355,6 +355,92 @@ class VsCodeIde implements IDE { terminal.sendText(command, false); } + async runCommandWithOutput(command: string, cwd?: string): Promise { + const terminal = vscode.window.createTerminal({ + name: "Continue", + cwd: cwd ? vscode.Uri.parse(cwd) : undefined, + }); + + const shellIntegration = await this.waitForShellIntegration( + terminal, + 10000, + ); + + if (!shellIntegration) { + terminal.show(); + terminal.sendText(command, true); + return ""; + } + + try { + const execution = shellIntegration.executeCommand(command); + let output = ""; + + for await (const chunk of execution.read()) { + output += chunk; + } + + const exitCode = await execution.exitCode; + if (exitCode !== undefined && exitCode !== 0) { + output += `\n[Exit code: ${exitCode}]`; + } + + // Strip VS Code shell integration OSC sequences (]633;...) but preserve + // ANSI color/formatting codes (e.g. \033[31m) which are part of command output + output = output.replace(/\x1b\]633;[^\x07]*\x07/g, ""); + // Also strip OSC sequences that lost their escape byte during read() + output = output.replace(/\]633;[^\n]*/g, ""); + output = output.trim(); + + terminal.dispose(); + return output; + } catch (error) { + console.error( + "[Continue] shellIntegration.executeCommand failed:", + error, + ); + terminal.dispose(); + return ""; + } + } + + private async waitForShellIntegration( + terminal: vscode.Terminal, + timeoutMs: number, + ): Promise { + if ((terminal as any).shellIntegration) { + return (terminal as any).shellIntegration; + } + + if (!(vscode.window as any).onDidChangeTerminalShellIntegration) { + return undefined; + } + + return new Promise((resolve) => { + const timeout = setTimeout(() => { + disposable.dispose(); + resolve(undefined); + }, timeoutMs); + + const disposable = ( + vscode.window as any + ).onDidChangeTerminalShellIntegration((e: any) => { + if (e.terminal === terminal) { + clearTimeout(timeout); + disposable.dispose(); + resolve(e.shellIntegration); + } + }); + + // Race condition guard + if ((terminal as any).shellIntegration) { + clearTimeout(timeout); + disposable.dispose(); + resolve((terminal as any).shellIntegration); + } + }); + } + async saveFile(fileUri: string): Promise { await this.ideUtils.saveFile(vscode.Uri.parse(fileUri)); } diff --git a/extensions/vscode/src/extension/VsCodeMessenger.ts b/extensions/vscode/src/extension/VsCodeMessenger.ts index fd71d9354c6..7b269fb940e 100644 --- a/extensions/vscode/src/extension/VsCodeMessenger.ts +++ b/extensions/vscode/src/extension/VsCodeMessenger.ts @@ -722,6 +722,9 @@ export class VsCodeMessenger { this.onWebviewOrCore("runCommand", async (msg) => { await ide.runCommand(msg.data.command); }); + this.onWebviewOrCore("runCommandWithOutput", async (msg) => { + return ide.runCommandWithOutput(msg.data.command, msg.data.cwd); + }); this.onWebviewOrCore("getSearchResults", async (msg) => { return ide.getSearchResults(msg.data.query, msg.data.maxResults); });