Skip to content

Commit bccf81f

Browse files
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 <noreply@anthropic.com>
1 parent a2c1fe6 commit bccf81f

10 files changed

Lines changed: 143 additions & 20 deletions

File tree

core/config/types.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -691,7 +691,9 @@ declare global {
691691
getExternalUri?(uri: string): Promise<string>;
692692
693693
runCommand(command: string): Promise<void>;
694-
694+
695+
runCommandWithOutput(command: string, cwd?: string): Promise<string>;
696+
695697
saveFile(filepath: string): Promise<void>;
696698
697699
readFile(filepath: string): Promise<string>;

core/index.d.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,8 @@ type RequiredLLMOptions =
9696
| "completionOptions";
9797

9898
export interface ILLM
99-
extends Omit<LLMOptions, RequiredLLMOptions>,
99+
extends
100+
Omit<LLMOptions, RequiredLLMOptions>,
100101
Required<Pick<LLMOptions, RequiredLLMOptions>> {
101102
get providerName(): string;
102103
get underlyingProviderName(): string;
@@ -872,6 +873,8 @@ export interface IDE {
872873

873874
runCommand(command: string, options?: TerminalOptions): Promise<void>;
874875

876+
runCommandWithOutput(command: string, cwd?: string): Promise<string>;
877+
875878
saveFile(fileUri: string): Promise<void>;
876879

877880
readFile(fileUri: string): Promise<string>;

core/protocol/ide.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ export type ToIdeFromWebviewOrCoreProtocol = {
3030
openFile: [{ path: string }, void];
3131
openUrl: [string, void];
3232
runCommand: [{ command: string; options?: TerminalOptions }, void];
33+
runCommandWithOutput: [{ command: string; cwd?: string }, string];
3334
getSearchResults: [{ query: string; maxResults?: number }, string];
3435
getFileResults: [{ pattern: string; maxResults?: number }, string[]];
3536
subprocess: [{ command: string; cwd?: string }, [string, string]];

core/protocol/messenger/messageIde.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,10 @@ export class MessageIde implements IDE {
188188
await this.request("runCommand", { command, options });
189189
}
190190

191+
async runCommandWithOutput(command: string, cwd?: string): Promise<string> {
192+
return this.request("runCommandWithOutput", { command, cwd });
193+
}
194+
191195
async saveFile(fileUri: string): Promise<void> {
192196
await this.request("saveFile", { filepath: fileUri });
193197
}

core/protocol/messenger/reverseMessageIde.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,10 @@ export class ReverseMessageIde {
134134
return this.ide.runCommand(data.command);
135135
});
136136

137+
this.on("runCommandWithOutput", (data) => {
138+
return this.ide.runCommandWithOutput(data.command, data.cwd);
139+
});
140+
137141
this.on("saveFile", (data) => {
138142
return this.ide.saveFile(data.filepath);
139143
});

core/tools/implementations/runTerminalCommand.ts

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -453,16 +453,32 @@ export const runTerminalCommandImpl: ToolImpl = async (args, extras) => {
453453
}
454454
}
455455

456-
// For remote environments, just run the command
457-
// Note: waitForCompletion is not supported in remote environments yet
458-
await extras.ide.runCommand(command);
456+
// For remote environments, use shell integration for output capture
457+
const workspaceDirs = await extras.ide.getWorkspaceDirs();
458+
const cwd = workspaceDirs.length > 0 ? workspaceDirs[0] : undefined;
459+
460+
if (extras.onPartialOutput) {
461+
extras.onPartialOutput({
462+
toolCallId,
463+
contextItems: [
464+
{
465+
name: "Terminal",
466+
description: "Terminal command output",
467+
content: "",
468+
status: "Running command on remote...",
469+
},
470+
],
471+
});
472+
}
473+
474+
const output = await extras.ide.runCommandWithOutput(command, cwd);
475+
459476
return [
460477
{
461478
name: "Terminal",
462479
description: "Terminal command output",
463-
content:
464-
"Terminal output not available. This is only available in local development environments and not in SSH environments for example.",
465-
status: "Command failed",
480+
content: output || "Command completed (no output captured)",
481+
status: "Command completed",
466482
},
467483
];
468484
};

core/tools/implementations/runTerminalCommand.vitest.ts

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ describe("runTerminalCommandImpl", () => {
9595
getIdeInfo: mockGetIdeInfo,
9696
getWorkspaceDirs: mockGetWorkspaceDirs,
9797
runCommand: mockRunCommand,
98+
runCommandWithOutput: vi.fn().mockResolvedValue(""),
9899
// Add stubs for other required IDE methods
99100
getIdeSettings: vi.fn(),
100101
getDiff: vi.fn(),
@@ -269,13 +270,10 @@ describe("runTerminalCommandImpl", () => {
269270

270271
const result = await runTerminalCommandImpl(args, extras);
271272

272-
// In remote environments, it should use the IDE's runCommand
273-
expect(mockRunCommand).toHaveBeenCalledWith("echo 'test'");
274-
// Match the actual output message
275-
expect(result[0].content).toContain("Terminal output not available");
276-
expect(result[0].content).toContain("SSH environments");
277-
// Verify status field indicates command failed in remote environments
278-
expect(result[0].status).toBe("Command failed");
273+
// In remote environments, it should use runCommandWithOutput
274+
// and report completed (no double-execution via runCommand)
275+
expect(mockRunCommand).not.toHaveBeenCalled();
276+
expect(result[0].status).toBe("Command completed");
279277
});
280278

281279
it("should handle errors when executing invalid commands", async () => {
@@ -370,6 +368,7 @@ describe("runTerminalCommandImpl", () => {
370368
.mockReturnValue(Promise.resolve({ remoteName: "local" })),
371369
getWorkspaceDirs: mockEmptyWorkspace,
372370
runCommand: vi.fn(),
371+
runCommandWithOutput: vi.fn().mockResolvedValue(""),
373372
getIdeSettings: vi.fn(),
374373
getDiff: vi.fn(),
375374
getClipboardContent: vi.fn(),
@@ -430,6 +429,7 @@ describe("runTerminalCommandImpl", () => {
430429
.mockReturnValue(Promise.resolve({ remoteName: "local" })),
431430
getWorkspaceDirs: mockEmptyWorkspace,
432431
runCommand: vi.fn(),
432+
runCommandWithOutput: vi.fn().mockResolvedValue(""),
433433
getIdeSettings: vi.fn(),
434434
getDiff: vi.fn(),
435435
getClipboardContent: vi.fn(),
@@ -618,8 +618,8 @@ describe("runTerminalCommandImpl", () => {
618618
extras,
619619
);
620620

621-
expect(mockRunCommand).toHaveBeenCalledWith("echo test");
622-
expect(result[0].content).toContain("Terminal output not available");
621+
expect(mockRunCommand).not.toHaveBeenCalled();
622+
expect(result[0].status).toBe("Command completed");
623623
});
624624

625625
it("should handle local environment with file URIs", async () => {
@@ -658,9 +658,9 @@ describe("runTerminalCommandImpl", () => {
658658
extras,
659659
);
660660

661-
// Should fall back to ide.runCommand, not try to spawn powershell.exe
662-
expect(mockRunCommand).toHaveBeenCalledWith("echo test");
663-
expect(result[0].content).toContain("Terminal output not available");
661+
// Should use runCommandWithOutput, not try to spawn powershell.exe
662+
expect(mockRunCommand).not.toHaveBeenCalled();
663+
expect(result[0].status).toBe("Command completed");
664664
} finally {
665665
Object.defineProperty(process, "platform", {
666666
value: originalPlatform,

core/util/filesystem.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,10 @@ class FileSystemIde implements IDE {
232232
return Promise.resolve();
233233
}
234234

235+
runCommandWithOutput(command: string, cwd?: string): Promise<string> {
236+
return Promise.resolve("");
237+
}
238+
235239
saveFile(fileUri: string): Promise<void> {
236240
return Promise.resolve();
237241
}

extensions/vscode/src/VsCodeIde.ts

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -355,6 +355,92 @@ class VsCodeIde implements IDE {
355355
terminal.sendText(command, false);
356356
}
357357

358+
async runCommandWithOutput(command: string, cwd?: string): Promise<string> {
359+
const terminal = vscode.window.createTerminal({
360+
name: "Continue",
361+
cwd: cwd ? vscode.Uri.parse(cwd) : undefined,
362+
});
363+
364+
const shellIntegration = await this.waitForShellIntegration(
365+
terminal,
366+
10000,
367+
);
368+
369+
if (!shellIntegration) {
370+
terminal.show();
371+
terminal.sendText(command, true);
372+
return "";
373+
}
374+
375+
try {
376+
const execution = shellIntegration.executeCommand(command);
377+
let output = "";
378+
379+
for await (const chunk of execution.read()) {
380+
output += chunk;
381+
}
382+
383+
const exitCode = await execution.exitCode;
384+
if (exitCode !== undefined && exitCode !== 0) {
385+
output += `\n[Exit code: ${exitCode}]`;
386+
}
387+
388+
// Strip VS Code shell integration OSC sequences (]633;...) but preserve
389+
// ANSI color/formatting codes (e.g. \033[31m) which are part of command output
390+
output = output.replace(/\x1b\]633;[^\x07]*\x07/g, "");
391+
// Also strip OSC sequences that lost their escape byte during read()
392+
output = output.replace(/\]633;[^\n]*/g, "");
393+
output = output.trim();
394+
395+
terminal.dispose();
396+
return output;
397+
} catch (error) {
398+
console.error(
399+
"[Continue] shellIntegration.executeCommand failed:",
400+
error,
401+
);
402+
terminal.dispose();
403+
return "";
404+
}
405+
}
406+
407+
private async waitForShellIntegration(
408+
terminal: vscode.Terminal,
409+
timeoutMs: number,
410+
): Promise<any> {
411+
if ((terminal as any).shellIntegration) {
412+
return (terminal as any).shellIntegration;
413+
}
414+
415+
if (!(vscode.window as any).onDidChangeTerminalShellIntegration) {
416+
return undefined;
417+
}
418+
419+
return new Promise<any>((resolve) => {
420+
const timeout = setTimeout(() => {
421+
disposable.dispose();
422+
resolve(undefined);
423+
}, timeoutMs);
424+
425+
const disposable = (
426+
vscode.window as any
427+
).onDidChangeTerminalShellIntegration((e: any) => {
428+
if (e.terminal === terminal) {
429+
clearTimeout(timeout);
430+
disposable.dispose();
431+
resolve(e.shellIntegration);
432+
}
433+
});
434+
435+
// Race condition guard
436+
if ((terminal as any).shellIntegration) {
437+
clearTimeout(timeout);
438+
disposable.dispose();
439+
resolve((terminal as any).shellIntegration);
440+
}
441+
});
442+
}
443+
358444
async saveFile(fileUri: string): Promise<void> {
359445
await this.ideUtils.saveFile(vscode.Uri.parse(fileUri));
360446
}

extensions/vscode/src/extension/VsCodeMessenger.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -722,6 +722,9 @@ export class VsCodeMessenger {
722722
this.onWebviewOrCore("runCommand", async (msg) => {
723723
await ide.runCommand(msg.data.command);
724724
});
725+
this.onWebviewOrCore("runCommandWithOutput", async (msg) => {
726+
return ide.runCommandWithOutput(msg.data.command, msg.data.cwd);
727+
});
725728
this.onWebviewOrCore("getSearchResults", async (msg) => {
726729
return ide.getSearchResults(msg.data.query, msg.data.maxResults);
727730
});

0 commit comments

Comments
 (0)