Skip to content

Commit f8e9317

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 f8e9317

10 files changed

Lines changed: 150 additions & 12 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: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -872,6 +872,8 @@ export interface IDE {
872872

873873
runCommand(command: string, options?: TerminalOptions): Promise<void>;
874874

875+
runCommandWithOutput(command: string, cwd?: string): Promise<string>;
876+
875877
saveFile(fileUri: string): Promise<void>;
876878

877879
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: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -453,16 +453,48 @@ 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
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+
476+
if (output) {
477+
return [
478+
{
479+
name: "Terminal",
480+
description: "Terminal command output",
481+
content: output,
482+
status: "Command completed",
483+
},
484+
];
485+
}
486+
487+
// Shell integration unavailable — fall back to sendText with no capture
458488
await extras.ide.runCommand(command);
459489
return [
460490
{
461491
name: "Terminal",
462492
description: "Terminal command output",
463493
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",
494+
"Command was sent to the terminal but output capture is not available. " +
495+
"This requires VS Code 1.93+ with shell integration enabled. " +
496+
"Check the terminal panel for output.",
497+
status: "Command sent (no output capture)",
466498
},
467499
];
468500
};

core/tools/implementations/runTerminalCommand.vitest.ts

Lines changed: 10 additions & 7 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,13 @@ describe("runTerminalCommandImpl", () => {
269270

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

272-
// In remote environments, it should use the IDE's runCommand
273+
// In remote environments, it should try runCommandWithOutput first,
274+
// then fall back to ide.runCommand when output is empty
273275
expect(mockRunCommand).toHaveBeenCalledWith("echo 'test'");
274276
// 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");
277+
expect(result[0].content).toContain("output capture is not available");
278+
// Verify status field indicates command was sent without capture
279+
expect(result[0].status).toBe("Command sent (no output capture)");
279280
});
280281

281282
it("should handle errors when executing invalid commands", async () => {
@@ -370,6 +371,7 @@ describe("runTerminalCommandImpl", () => {
370371
.mockReturnValue(Promise.resolve({ remoteName: "local" })),
371372
getWorkspaceDirs: mockEmptyWorkspace,
372373
runCommand: vi.fn(),
374+
runCommandWithOutput: vi.fn().mockResolvedValue(""),
373375
getIdeSettings: vi.fn(),
374376
getDiff: vi.fn(),
375377
getClipboardContent: vi.fn(),
@@ -430,6 +432,7 @@ describe("runTerminalCommandImpl", () => {
430432
.mockReturnValue(Promise.resolve({ remoteName: "local" })),
431433
getWorkspaceDirs: mockEmptyWorkspace,
432434
runCommand: vi.fn(),
435+
runCommandWithOutput: vi.fn().mockResolvedValue(""),
433436
getIdeSettings: vi.fn(),
434437
getDiff: vi.fn(),
435438
getClipboardContent: vi.fn(),
@@ -619,7 +622,7 @@ describe("runTerminalCommandImpl", () => {
619622
);
620623

621624
expect(mockRunCommand).toHaveBeenCalledWith("echo test");
622-
expect(result[0].content).toContain("Terminal output not available");
625+
expect(result[0].content).toContain("output capture is not available");
623626
});
624627

625628
it("should handle local environment with file URIs", async () => {
@@ -660,7 +663,7 @@ describe("runTerminalCommandImpl", () => {
660663

661664
// Should fall back to ide.runCommand, not try to spawn powershell.exe
662665
expect(mockRunCommand).toHaveBeenCalledWith("echo test");
663-
expect(result[0].content).toContain("Terminal output not available");
666+
expect(result[0].content).toContain("output capture is not available");
664667
} finally {
665668
Object.defineProperty(process, "platform", {
666669
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: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -355,6 +355,89 @@ class VsCodeIde implements IDE {
355355
terminal.sendText(command, false);
356356
}
357357

358+
async runCommandWithOutput(
359+
command: string,
360+
cwd?: string,
361+
): Promise<string> {
362+
const terminal = vscode.window.createTerminal({
363+
name: "Continue",
364+
cwd: cwd ? vscode.Uri.parse(cwd) : undefined,
365+
});
366+
367+
const shellIntegration = await this.waitForShellIntegration(terminal, 10000);
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("[Continue] shellIntegration.executeCommand failed:", error);
399+
terminal.dispose();
400+
return "";
401+
}
402+
}
403+
404+
private async waitForShellIntegration(
405+
terminal: vscode.Terminal,
406+
timeoutMs: number,
407+
): Promise<any> {
408+
if ((terminal as any).shellIntegration) {
409+
return (terminal as any).shellIntegration;
410+
}
411+
412+
if (!(vscode.window as any).onDidChangeTerminalShellIntegration) {
413+
return undefined;
414+
}
415+
416+
return new Promise<any>((resolve) => {
417+
const timeout = setTimeout(() => {
418+
disposable.dispose();
419+
resolve(undefined);
420+
}, timeoutMs);
421+
422+
const disposable = (
423+
vscode.window as any
424+
).onDidChangeTerminalShellIntegration((e: any) => {
425+
if (e.terminal === terminal) {
426+
clearTimeout(timeout);
427+
disposable.dispose();
428+
resolve(e.shellIntegration);
429+
}
430+
});
431+
432+
// Race condition guard
433+
if ((terminal as any).shellIntegration) {
434+
clearTimeout(timeout);
435+
disposable.dispose();
436+
resolve((terminal as any).shellIntegration);
437+
}
438+
});
439+
}
440+
358441
async saveFile(fileUri: string): Promise<void> {
359442
await this.ideUtils.saveFile(vscode.Uri.parse(fileUri));
360443
}

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)