Skip to content

Commit e36d9ec

Browse files
committed
fix(vscode): run commands in remote terminals
1 parent d971363 commit e36d9ec

File tree

6 files changed

+298
-18
lines changed

6 files changed

+298
-18
lines changed

core/llm/llms/OpenRouter.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { ChatCompletionCreateParams } from "openai/resources/index";
22

3+
import { OPENROUTER_HEADERS } from "@continuedev/openai-adapters";
4+
35
import { LLMOptions } from "../../index.js";
46
import { osModelsEditPrompt } from "../templates/edit.js";
57

@@ -18,6 +20,19 @@ class OpenRouter extends OpenAI {
1820
useLegacyCompletionsEndpoint: false,
1921
};
2022

23+
constructor(options: LLMOptions) {
24+
super({
25+
...options,
26+
requestOptions: {
27+
...options.requestOptions,
28+
headers: {
29+
...OPENROUTER_HEADERS,
30+
...options.requestOptions?.headers,
31+
},
32+
},
33+
});
34+
}
35+
2136
private isAnthropicModel(model?: string): boolean {
2237
if (!model) return false;
2338
const modelLower = model.toLowerCase();

extensions/vscode/src/VsCodeIde.ts

Lines changed: 2 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
import { Repository } from "./otherExtensions/git";
1616
import { SecretStorage } from "./stubs/SecretStorage";
1717
import { VsCodeIdeUtils } from "./util/ideUtils";
18+
import { runCommandInTerminal } from "./util/runCommandInTerminal";
1819
import { getExtensionVersion, isExtensionPrerelease } from "./util/util";
1920
import { getExtensionUri, openEditorAndRevealRange } from "./util/vscode";
2021
import { VsCodeWebviewProtocol } from "./webviewProtocol";
@@ -337,22 +338,7 @@ class VsCodeIde implements IDE {
337338
command: string,
338339
options: TerminalOptions = { reuseTerminal: true },
339340
): Promise<void> {
340-
let terminal: vscode.Terminal | undefined;
341-
if (vscode.window.terminals.length && options.reuseTerminal) {
342-
if (options.terminalName) {
343-
terminal = vscode.window.terminals.find(
344-
(t) => t?.name === options.terminalName,
345-
);
346-
} else {
347-
terminal = vscode.window.activeTerminal ?? vscode.window.terminals[0];
348-
}
349-
}
350-
351-
if (!terminal) {
352-
terminal = vscode.window.createTerminal(options?.terminalName);
353-
}
354-
terminal.show();
355-
terminal.sendText(command, false);
341+
await runCommandInTerminal(command, options);
356342
}
357343

358344
async saveFile(fileUri: string): Promise<void> {
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
import type { TerminalOptions } from "core";
2+
import * as vscode from "vscode";
3+
4+
const REMOTE_TERMINAL_TIMEOUT_MS = 5000;
5+
6+
const terminalCacheByName = new Map<string, vscode.Terminal>();
7+
8+
function getReusableTerminal(
9+
options: TerminalOptions,
10+
): vscode.Terminal | undefined {
11+
if (!vscode.window.terminals.length || !options.reuseTerminal) {
12+
return undefined;
13+
}
14+
15+
if (options.terminalName) {
16+
const cachedTerminal = terminalCacheByName.get(options.terminalName);
17+
if (cachedTerminal && vscode.window.terminals.includes(cachedTerminal)) {
18+
return cachedTerminal;
19+
}
20+
21+
terminalCacheByName.delete(options.terminalName);
22+
return vscode.window.terminals.find(
23+
(terminal) => terminal?.name === options.terminalName,
24+
);
25+
}
26+
27+
return vscode.window.activeTerminal ?? vscode.window.terminals[0];
28+
}
29+
30+
async function createTerminal(
31+
options: TerminalOptions,
32+
): Promise<vscode.Terminal> {
33+
if (!vscode.env.remoteName) {
34+
return vscode.window.createTerminal(options.terminalName);
35+
}
36+
37+
const existingTerminals = new Set(vscode.window.terminals);
38+
39+
return await new Promise<vscode.Terminal>((resolve, reject) => {
40+
let settled = false;
41+
let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
42+
43+
const cleanup = () => {
44+
terminalListener.dispose();
45+
if (timeoutHandle) {
46+
clearTimeout(timeoutHandle);
47+
}
48+
};
49+
50+
const resolveIfNewTerminalExists = () => {
51+
const newTerminal = vscode.window.terminals.find(
52+
(terminal) => !existingTerminals.has(terminal),
53+
);
54+
if (!newTerminal) {
55+
return false;
56+
}
57+
58+
settled = true;
59+
cleanup();
60+
resolve(newTerminal);
61+
return true;
62+
};
63+
64+
const terminalListener = vscode.window.onDidOpenTerminal((terminal) => {
65+
if (settled || existingTerminals.has(terminal)) {
66+
return;
67+
}
68+
69+
settled = true;
70+
cleanup();
71+
resolve(terminal);
72+
});
73+
74+
timeoutHandle = setTimeout(() => {
75+
if (settled) {
76+
return;
77+
}
78+
79+
settled = true;
80+
cleanup();
81+
reject(new Error("Timed out waiting for remote terminal to open"));
82+
}, REMOTE_TERMINAL_TIMEOUT_MS);
83+
84+
if (resolveIfNewTerminalExists()) {
85+
return;
86+
}
87+
88+
void vscode.commands.executeCommand("workbench.action.terminal.new").then(
89+
() => {
90+
resolveIfNewTerminalExists();
91+
},
92+
(error: unknown) => {
93+
if (settled) {
94+
return;
95+
}
96+
97+
settled = true;
98+
cleanup();
99+
reject(error);
100+
},
101+
);
102+
});
103+
}
104+
105+
export async function runCommandInTerminal(
106+
command: string,
107+
options: TerminalOptions = { reuseTerminal: true },
108+
): Promise<void> {
109+
const terminal =
110+
getReusableTerminal(options) ?? (await createTerminal(options));
111+
112+
if (options.terminalName) {
113+
terminalCacheByName.set(options.terminalName, terminal);
114+
}
115+
116+
terminal.show();
117+
terminal.sendText(command, true);
118+
}
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
import { beforeEach, describe, expect, it, vi } from "vitest";
2+
3+
type MockTerminal = {
4+
name: string;
5+
show: ReturnType<typeof vi.fn>;
6+
sendText: ReturnType<typeof vi.fn>;
7+
};
8+
9+
const terminalListeners = new Set<(terminal: MockTerminal) => void>();
10+
const terminals: MockTerminal[] = [];
11+
12+
const windowMock = {
13+
terminals,
14+
activeTerminal: undefined as MockTerminal | undefined,
15+
createTerminal: vi.fn<(name?: string) => MockTerminal>(),
16+
onDidOpenTerminal: vi.fn((listener: (terminal: MockTerminal) => void) => {
17+
terminalListeners.add(listener);
18+
return {
19+
dispose: vi.fn(() => terminalListeners.delete(listener)),
20+
};
21+
}),
22+
};
23+
24+
const commandsMock = {
25+
executeCommand: vi.fn<(command: string) => Promise<void>>(),
26+
};
27+
28+
const envMock = {
29+
remoteName: undefined as string | undefined,
30+
};
31+
32+
vi.mock("vscode", () => ({
33+
window: windowMock,
34+
commands: commandsMock,
35+
env: envMock,
36+
}));
37+
38+
function createTerminal(name: string): MockTerminal {
39+
return {
40+
name,
41+
show: vi.fn(),
42+
sendText: vi.fn(),
43+
};
44+
}
45+
46+
describe("runCommandInTerminal", () => {
47+
beforeEach(() => {
48+
vi.resetModules();
49+
vi.clearAllMocks();
50+
51+
terminals.length = 0;
52+
terminalListeners.clear();
53+
windowMock.activeTerminal = undefined;
54+
envMock.remoteName = undefined;
55+
56+
windowMock.createTerminal.mockImplementation((name?: string) => {
57+
const terminal = createTerminal(
58+
name ?? "Terminal " + (terminals.length + 1),
59+
);
60+
terminals.push(terminal);
61+
return terminal;
62+
});
63+
64+
commandsMock.executeCommand.mockResolvedValue();
65+
});
66+
67+
it("reuses the active terminal and sends an executing command", async () => {
68+
const terminal = createTerminal("Active");
69+
terminals.push(terminal);
70+
windowMock.activeTerminal = terminal;
71+
72+
const { runCommandInTerminal } = await import("./runCommandInTerminal");
73+
74+
await runCommandInTerminal("echo hello");
75+
76+
expect(windowMock.createTerminal).not.toHaveBeenCalled();
77+
expect(commandsMock.executeCommand).not.toHaveBeenCalled();
78+
expect(terminal.show).toHaveBeenCalledOnce();
79+
expect(terminal.sendText).toHaveBeenCalledWith("echo hello", true);
80+
});
81+
82+
it("creates a named local terminal when no reusable terminal exists", async () => {
83+
const { runCommandInTerminal } = await import("./runCommandInTerminal");
84+
85+
await runCommandInTerminal("npm test", {
86+
reuseTerminal: true,
87+
terminalName: "Start Ollama",
88+
});
89+
90+
expect(windowMock.createTerminal).toHaveBeenCalledWith("Start Ollama");
91+
expect(commandsMock.executeCommand).not.toHaveBeenCalled();
92+
93+
const createdTerminal = terminals[0];
94+
expect(createdTerminal.show).toHaveBeenCalledOnce();
95+
expect(createdTerminal.sendText).toHaveBeenCalledWith("npm test", true);
96+
});
97+
98+
it("creates remote terminals through the remote-aware VS Code command", async () => {
99+
envMock.remoteName = "ssh-remote";
100+
commandsMock.executeCommand.mockImplementation(async (command: string) => {
101+
expect(command).toBe("workbench.action.terminal.new");
102+
const terminal = createTerminal("Remote Shell");
103+
terminals.push(terminal);
104+
for (const listener of terminalListeners) {
105+
listener(terminal);
106+
}
107+
});
108+
109+
const { runCommandInTerminal } = await import("./runCommandInTerminal");
110+
111+
await runCommandInTerminal("pwd", { reuseTerminal: true });
112+
113+
expect(windowMock.createTerminal).not.toHaveBeenCalled();
114+
expect(commandsMock.executeCommand).toHaveBeenCalledWith(
115+
"workbench.action.terminal.new",
116+
);
117+
118+
const createdTerminal = terminals[0];
119+
expect(createdTerminal.show).toHaveBeenCalledOnce();
120+
expect(createdTerminal.sendText).toHaveBeenCalledWith("pwd", true);
121+
});
122+
123+
it("reuses cached remote terminals for named commands", async () => {
124+
envMock.remoteName = "dev-container";
125+
126+
let createdCount = 0;
127+
commandsMock.executeCommand.mockImplementation(async () => {
128+
createdCount += 1;
129+
const terminal = createTerminal("Remote " + createdCount);
130+
terminals.push(terminal);
131+
for (const listener of terminalListeners) {
132+
listener(terminal);
133+
}
134+
});
135+
136+
const { runCommandInTerminal } = await import("./runCommandInTerminal");
137+
138+
await runCommandInTerminal("ollama serve", {
139+
reuseTerminal: true,
140+
terminalName: "Start Ollama",
141+
});
142+
await runCommandInTerminal("ollama serve", {
143+
reuseTerminal: true,
144+
terminalName: "Start Ollama",
145+
});
146+
147+
expect(commandsMock.executeCommand).toHaveBeenCalledTimes(1);
148+
expect(terminals[0].sendText).toHaveBeenNthCalledWith(
149+
1,
150+
"ollama serve",
151+
true,
152+
);
153+
expect(terminals[0].sendText).toHaveBeenNthCalledWith(
154+
2,
155+
"ollama serve",
156+
true,
157+
);
158+
});
159+
});

packages/openai-adapters/src/apis/OpenRouter.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,10 @@ export interface OpenRouterConfig extends OpenAIConfig {
1010

1111
// TODO: Extract detailed error info from OpenRouter's error.metadata.raw to surface better messages
1212

13-
const OPENROUTER_HEADERS: Record<string, string> = {
13+
export const OPENROUTER_HEADERS: Record<string, string> = {
1414
"HTTP-Referer": "https://www.continue.dev/",
15-
"X-Title": "Continue",
15+
"X-OpenRouter-Title": "Continue",
16+
"X-OpenRouter-Categories": "ide-extension",
1617
};
1718

1819
export class OpenRouterApi extends OpenAIApi {

packages/openai-adapters/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,4 +243,5 @@ export {
243243
} from "./apis/AnthropicUtils.js";
244244

245245
export { isResponsesModel } from "./apis/openaiResponses.js";
246+
export { OPENROUTER_HEADERS } from "./apis/OpenRouter.js";
246247
export { extractBase64FromDataUrl, parseDataUrl } from "./util/url.js";

0 commit comments

Comments
 (0)