-
Notifications
You must be signed in to change notification settings - Fork 4.4k
[codex] fix(vscode): run commands in remote terminals #12063
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,118 @@ | ||
| import type { TerminalOptions } from "core"; | ||
| import * as vscode from "vscode"; | ||
|
|
||
| const REMOTE_TERMINAL_TIMEOUT_MS = 5000; | ||
|
|
||
| const terminalCacheByName = new Map<string, vscode.Terminal>(); | ||
|
|
||
| function getReusableTerminal( | ||
| options: TerminalOptions, | ||
| ): vscode.Terminal | undefined { | ||
| if (!vscode.window.terminals.length || !options.reuseTerminal) { | ||
| return undefined; | ||
| } | ||
|
|
||
| if (options.terminalName) { | ||
| const cachedTerminal = terminalCacheByName.get(options.terminalName); | ||
| if (cachedTerminal && vscode.window.terminals.includes(cachedTerminal)) { | ||
| return cachedTerminal; | ||
| } | ||
|
|
||
| terminalCacheByName.delete(options.terminalName); | ||
| return vscode.window.terminals.find( | ||
| (terminal) => terminal?.name === options.terminalName, | ||
| ); | ||
| } | ||
|
|
||
| return vscode.window.activeTerminal ?? vscode.window.terminals[0]; | ||
| } | ||
|
|
||
| async function createTerminal( | ||
| options: TerminalOptions, | ||
| ): Promise<vscode.Terminal> { | ||
| if (!vscode.env.remoteName) { | ||
| return vscode.window.createTerminal(options.terminalName); | ||
| } | ||
|
|
||
| const existingTerminals = new Set(vscode.window.terminals); | ||
|
|
||
| return await new Promise<vscode.Terminal>((resolve, reject) => { | ||
| let settled = false; | ||
| let timeoutHandle: ReturnType<typeof setTimeout> | undefined; | ||
|
|
||
| const cleanup = () => { | ||
| terminalListener.dispose(); | ||
| if (timeoutHandle) { | ||
| clearTimeout(timeoutHandle); | ||
| } | ||
| }; | ||
|
|
||
| const resolveIfNewTerminalExists = () => { | ||
| const newTerminal = vscode.window.terminals.find( | ||
| (terminal) => !existingTerminals.has(terminal), | ||
| ); | ||
| if (!newTerminal) { | ||
| return false; | ||
| } | ||
|
|
||
| settled = true; | ||
| cleanup(); | ||
| resolve(newTerminal); | ||
| return true; | ||
| }; | ||
|
|
||
| const terminalListener = vscode.window.onDidOpenTerminal((terminal) => { | ||
| if (settled || existingTerminals.has(terminal)) { | ||
| return; | ||
| } | ||
|
|
||
| settled = true; | ||
| cleanup(); | ||
| resolve(terminal); | ||
| }); | ||
|
|
||
| timeoutHandle = setTimeout(() => { | ||
| if (settled) { | ||
| return; | ||
| } | ||
|
|
||
| settled = true; | ||
| cleanup(); | ||
| reject(new Error("Timed out waiting for remote terminal to open")); | ||
| }, REMOTE_TERMINAL_TIMEOUT_MS); | ||
|
|
||
| if (resolveIfNewTerminalExists()) { | ||
| return; | ||
|
Comment on lines
+84
to
+85
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The early Useful? React with 👍 / 👎. |
||
| } | ||
|
|
||
| void vscode.commands.executeCommand("workbench.action.terminal.new").then( | ||
| () => { | ||
| resolveIfNewTerminalExists(); | ||
| }, | ||
| (error: unknown) => { | ||
| if (settled) { | ||
| return; | ||
| } | ||
|
|
||
| settled = true; | ||
| cleanup(); | ||
| reject(error); | ||
| }, | ||
| ); | ||
| }); | ||
| } | ||
|
|
||
| export async function runCommandInTerminal( | ||
| command: string, | ||
| options: TerminalOptions = { reuseTerminal: true }, | ||
| ): Promise<void> { | ||
| const terminal = | ||
| getReusableTerminal(options) ?? (await createTerminal(options)); | ||
|
|
||
| if (options.terminalName) { | ||
| terminalCacheByName.set(options.terminalName, terminal); | ||
| } | ||
|
|
||
| terminal.show(); | ||
| terminal.sendText(command, true); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,159 @@ | ||
| import { beforeEach, describe, expect, it, vi } from "vitest"; | ||
|
|
||
| type MockTerminal = { | ||
| name: string; | ||
| show: ReturnType<typeof vi.fn>; | ||
| sendText: ReturnType<typeof vi.fn>; | ||
| }; | ||
|
|
||
| const terminalListeners = new Set<(terminal: MockTerminal) => void>(); | ||
| const terminals: MockTerminal[] = []; | ||
|
|
||
| const windowMock = { | ||
| terminals, | ||
| activeTerminal: undefined as MockTerminal | undefined, | ||
| createTerminal: vi.fn<(name?: string) => MockTerminal>(), | ||
| onDidOpenTerminal: vi.fn((listener: (terminal: MockTerminal) => void) => { | ||
| terminalListeners.add(listener); | ||
| return { | ||
| dispose: vi.fn(() => terminalListeners.delete(listener)), | ||
| }; | ||
| }), | ||
| }; | ||
|
|
||
| const commandsMock = { | ||
| executeCommand: vi.fn<(command: string) => Promise<void>>(), | ||
| }; | ||
|
|
||
| const envMock = { | ||
| remoteName: undefined as string | undefined, | ||
| }; | ||
|
|
||
| vi.mock("vscode", () => ({ | ||
| window: windowMock, | ||
| commands: commandsMock, | ||
| env: envMock, | ||
| })); | ||
|
|
||
| function createTerminal(name: string): MockTerminal { | ||
| return { | ||
| name, | ||
| show: vi.fn(), | ||
| sendText: vi.fn(), | ||
| }; | ||
| } | ||
|
|
||
| describe("runCommandInTerminal", () => { | ||
| beforeEach(() => { | ||
| vi.resetModules(); | ||
| vi.clearAllMocks(); | ||
|
|
||
| terminals.length = 0; | ||
| terminalListeners.clear(); | ||
| windowMock.activeTerminal = undefined; | ||
| envMock.remoteName = undefined; | ||
|
|
||
| windowMock.createTerminal.mockImplementation((name?: string) => { | ||
| const terminal = createTerminal( | ||
| name ?? "Terminal " + (terminals.length + 1), | ||
| ); | ||
| terminals.push(terminal); | ||
| return terminal; | ||
| }); | ||
|
|
||
| commandsMock.executeCommand.mockResolvedValue(); | ||
| }); | ||
|
|
||
| it("reuses the active terminal and sends an executing command", async () => { | ||
| const terminal = createTerminal("Active"); | ||
| terminals.push(terminal); | ||
| windowMock.activeTerminal = terminal; | ||
|
|
||
| const { runCommandInTerminal } = await import("./runCommandInTerminal"); | ||
|
|
||
| await runCommandInTerminal("echo hello"); | ||
|
|
||
| expect(windowMock.createTerminal).not.toHaveBeenCalled(); | ||
| expect(commandsMock.executeCommand).not.toHaveBeenCalled(); | ||
| expect(terminal.show).toHaveBeenCalledOnce(); | ||
| expect(terminal.sendText).toHaveBeenCalledWith("echo hello", true); | ||
| }); | ||
|
|
||
| it("creates a named local terminal when no reusable terminal exists", async () => { | ||
| const { runCommandInTerminal } = await import("./runCommandInTerminal"); | ||
|
|
||
| await runCommandInTerminal("npm test", { | ||
| reuseTerminal: true, | ||
| terminalName: "Start Ollama", | ||
| }); | ||
|
|
||
| expect(windowMock.createTerminal).toHaveBeenCalledWith("Start Ollama"); | ||
| expect(commandsMock.executeCommand).not.toHaveBeenCalled(); | ||
|
|
||
| const createdTerminal = terminals[0]; | ||
| expect(createdTerminal.show).toHaveBeenCalledOnce(); | ||
| expect(createdTerminal.sendText).toHaveBeenCalledWith("npm test", true); | ||
| }); | ||
|
|
||
| it("creates remote terminals through the remote-aware VS Code command", async () => { | ||
| envMock.remoteName = "ssh-remote"; | ||
| commandsMock.executeCommand.mockImplementation(async (command: string) => { | ||
| expect(command).toBe("workbench.action.terminal.new"); | ||
| const terminal = createTerminal("Remote Shell"); | ||
| terminals.push(terminal); | ||
| for (const listener of terminalListeners) { | ||
| listener(terminal); | ||
| } | ||
| }); | ||
|
|
||
| const { runCommandInTerminal } = await import("./runCommandInTerminal"); | ||
|
|
||
| await runCommandInTerminal("pwd", { reuseTerminal: true }); | ||
|
|
||
| expect(windowMock.createTerminal).not.toHaveBeenCalled(); | ||
| expect(commandsMock.executeCommand).toHaveBeenCalledWith( | ||
| "workbench.action.terminal.new", | ||
| ); | ||
|
|
||
| const createdTerminal = terminals[0]; | ||
| expect(createdTerminal.show).toHaveBeenCalledOnce(); | ||
| expect(createdTerminal.sendText).toHaveBeenCalledWith("pwd", true); | ||
| }); | ||
|
|
||
| it("reuses cached remote terminals for named commands", async () => { | ||
| envMock.remoteName = "dev-container"; | ||
|
|
||
| let createdCount = 0; | ||
| commandsMock.executeCommand.mockImplementation(async () => { | ||
| createdCount += 1; | ||
| const terminal = createTerminal("Remote " + createdCount); | ||
| terminals.push(terminal); | ||
| for (const listener of terminalListeners) { | ||
| listener(terminal); | ||
| } | ||
| }); | ||
|
|
||
| const { runCommandInTerminal } = await import("./runCommandInTerminal"); | ||
|
|
||
| await runCommandInTerminal("ollama serve", { | ||
| reuseTerminal: true, | ||
| terminalName: "Start Ollama", | ||
| }); | ||
| await runCommandInTerminal("ollama serve", { | ||
| reuseTerminal: true, | ||
| terminalName: "Start Ollama", | ||
| }); | ||
|
|
||
| expect(commandsMock.executeCommand).toHaveBeenCalledTimes(1); | ||
| expect(terminals[0].sendText).toHaveBeenNthCalledWith( | ||
| 1, | ||
| "ollama serve", | ||
| true, | ||
| ); | ||
| expect(terminals[0].sendText).toHaveBeenNthCalledWith( | ||
| 2, | ||
| "ollama serve", | ||
| true, | ||
| ); | ||
| }); | ||
| }); |
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
P2: Remote terminal creation is race-prone and can resolve to an unrelated concurrently opened terminal, causing commands to run in the wrong terminal session.
Prompt for AI agents