From bccf81f32b18e910c0535bdbf32978129ac75752 Mon Sep 17 00:00:00 2001 From: shanevcantwell <153727980+shanevcantwell@users.noreply.github.com> Date: Tue, 24 Feb 2026 05:13:12 -0700 Subject: [PATCH] feat: add shell integration-based terminal output capture for remote environments When the extension host runs on Windows with a remote workspace (SSH, WSL, Dev Container), childProcess.spawn executes locally instead of on the remote. The existing fallback used ide.runCommand (sendText) which had no output capture, returning a hardcoded "Command failed" status. This adds a new runCommandWithOutput IDE method that uses VS Code's Shell Integration API (1.93+) to execute commands on remote terminals with full output capture. The method creates an invisible terminal, waits for shell integration to activate, executes via shellIntegration.executeCommand(), and reads output via the async iterable read() API. ANSI color codes are preserved for rendering by the UnifiedTerminal component; only VS Code's internal OSC 633 shell integration markers are stripped. Falls back gracefully to sendText when shell integration is unavailable. Co-Authored-By: Claude Opus 4.6 --- core/config/types.ts | 4 +- core/index.d.ts | 5 +- core/protocol/ide.ts | 1 + core/protocol/messenger/messageIde.ts | 4 + core/protocol/messenger/reverseMessageIde.ts | 4 + .../implementations/runTerminalCommand.ts | 28 ++++-- .../runTerminalCommand.vitest.ts | 24 +++--- core/util/filesystem.ts | 4 + extensions/vscode/src/VsCodeIde.ts | 86 +++++++++++++++++++ .../vscode/src/extension/VsCodeMessenger.ts | 3 + 10 files changed, 143 insertions(+), 20 deletions(-) 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); });