Skip to content

Commit 019d857

Browse files
feat(terminal): add VS Code integrated-terminal shell override (#277)
* feat(terminal): add inline terminal profile selection (#119) Add a 'terminalProfile' setting that lets users choose which VS Code terminal profile the inline terminal uses. On Windows the default cmd/PowerShell shell may use a non-UTF-8 code page (e.g. GBK) and garble output; selecting a UTF-8 profile such as Git Bash resolves this. The setting reuses VS Code's terminal profile concept: when set, the profile name is resolved against terminal.integrated.profiles.<platform> to derive shellPath/shellArgs for createTerminal. When empty/unset the default terminal behavior is preserved unchanged. Adds backend unit tests for profile resolution and a webview test for the settings dropdown wiring. * fix(test): relax spy types for overloaded VS Code APIs (#119) * fix(test): use ES import instead of require() in terminal profile spec (#119) * refactor(terminal): address review feedback on inline terminal profile (#119) - Route profile names through a dedicated allowlisted `requestTerminalProfiles` message instead of the generic `getVSCodeSetting` (which reads any key the webview supplies); the extension reads the profiles and returns only names. - Preserve the profile's `env` (sanitized to string/null; null unsets a var), merged onto the base env in createTerminal. - Clarify the setting copy (en + es) vs the 'Use Inline Terminal' description. - Add tests: updateSettings->setTerminalProfile bridge, resolveWebviewView startup hydration, and profile env preservation/sanitization. * fix(terminal): resolve profile path[] to first existing candidate (#119) VS Code selects the first terminal-profile path candidate that exists on disk; mirror that instead of always taking index 0, falling back to the first non-empty candidate when none exist. Addresses CodeRabbit review on #277. * fix(terminal): hide inline profile picker when inline execution is off The terminal-profile dropdown only affects inline execution, which is active when shell integration is disabled. It previously stayed visible and editable even when inline mode was off, where it has no effect. Guard it with terminalShellIntegrationDisabled (defaulting to shown, matching the checkbox), mirroring the inline-only settings below. Addresses PR #277 review (edelauna). * fix(terminal): address review feedback * refactor(terminal): scope profile picker to VS Code integrated terminal, filter source-only profiles * fix(terminal): scope profile config to user settings, filter shellArgs, fix PATH join * refactor(terminal): address review feedback on profile picker * refactor(terminal): address review feedback on profile picker * refactor(terminal): promote no_shell_integration payload to typed object * feat(terminal): replace pWaitFor with event-based shell integration wait; add cmd.exe fast-path * fix(terminal): retry via execa silently when shell integration fails before submission * fix(terminal): close idle terminals when profile changes * fix(terminal): skip ZDOTDIR injection with profile override; clear cached profile on picker open * test(e2e): smoke-test VS Code terminal profile override lifecycle * fix(terminal): avoid replaying commands after shell integration failure * fix(terminal): harden profile settings and cleanup * Revert "feat(terminal): replace pWaitFor with event-based shell integration wait; add cmd.exe fast-path" This reverts commit 40e17b1. * fix(TerminalProcess): warning even when shell opens --------- Co-authored-by: Armando Vaquera <263793884+proyectoauraorg@users.noreply.github.com> Co-authored-by: Elliott de Launay <edelauna@gmail.com>
1 parent f9c9c09 commit 019d857

53 files changed

Lines changed: 2687 additions & 159 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
{
2+
"fixtures": [
3+
{
4+
"match": {
5+
"userMessage": "TERMINAL_PROFILE_E2E_OVERRIDE"
6+
},
7+
"response": {
8+
"toolCalls": [
9+
{
10+
"name": "execute_command",
11+
"arguments": "{\"command\":\"printf 'zoo-profile-override-ok\\\\n' > terminal-profile-e2e/terminal-profile-override.txt\"}",
12+
"id": "call_terminal_profile_override_001"
13+
}
14+
]
15+
}
16+
},
17+
{
18+
"match": {
19+
"userMessage": "TERMINAL_PROFILE_E2E_DEFAULT"
20+
},
21+
"response": {
22+
"toolCalls": [
23+
{
24+
"name": "execute_command",
25+
"arguments": "{\"command\":\"printf 'zoo-profile-default-ok\\\\n' > terminal-profile-e2e/terminal-profile-default.txt\"}",
26+
"id": "call_terminal_profile_default_001"
27+
}
28+
]
29+
}
30+
}
31+
]
32+
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { LLMock } from "@copilotkit/aimock"
2+
3+
import { toolResultContains } from "./tool-result"
4+
5+
type TerminalProfileToolCall = {
6+
name: "execute_command" | "attempt_completion"
7+
params: Record<string, unknown>
8+
id: string
9+
}
10+
11+
type TerminalProfileFixture = {
12+
toolCallId: string
13+
expected: string[]
14+
toolCalls: TerminalProfileToolCall[]
15+
}
16+
17+
export function addTerminalProfileResultFixtures(mock: InstanceType<typeof LLMock>) {
18+
const fixtures: TerminalProfileFixture[] = [
19+
{
20+
toolCallId: "call_terminal_profile_override_001",
21+
expected: ["Exit code: 0"],
22+
toolCalls: [
23+
{
24+
name: "attempt_completion",
25+
params: { result: "Ran the command using the Zoo E2E Bash profile override." },
26+
id: "call_terminal_profile_override_002",
27+
},
28+
],
29+
},
30+
{
31+
toolCallId: "call_terminal_profile_default_001",
32+
expected: ["Exit code: 0"],
33+
toolCalls: [
34+
{
35+
name: "attempt_completion",
36+
params: { result: "Ran the command using the default terminal profile." },
37+
id: "call_terminal_profile_default_002",
38+
},
39+
],
40+
},
41+
]
42+
43+
for (const fixture of fixtures) {
44+
mock.addFixture({
45+
match: {
46+
toolCallId: fixture.toolCallId,
47+
predicate: (req) => toolResultContains(req, fixture.toolCallId, fixture.expected),
48+
},
49+
response: {
50+
toolCalls: fixture.toolCalls.map((toolCall) => ({
51+
name: toolCall.name,
52+
arguments: JSON.stringify(toolCall.params),
53+
id: toolCall.id,
54+
})),
55+
},
56+
})
57+
}
58+
}

apps/vscode-e2e/src/runTest.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { LLMock } from "@copilotkit/aimock"
77

88
import { addApplyDiffResultFixtures } from "./fixtures/apply-diff"
99
import { addExecuteCommandResultFixtures } from "./fixtures/execute-command"
10+
import { addTerminalProfileResultFixtures } from "./fixtures/terminal-profile"
1011
import { addListFilesResultFixtures } from "./fixtures/list-files"
1112
import { addReadFileResultFixtures } from "./fixtures/read-file"
1213
import { addSearchFilesResultFixtures } from "./fixtures/search-files"
@@ -108,6 +109,7 @@ async function main() {
108109
if (!isRecord) {
109110
addApplyDiffResultFixtures(mock)
110111
addExecuteCommandResultFixtures(mock)
112+
addTerminalProfileResultFixtures(mock)
111113
addListFilesResultFixtures(mock)
112114
addReadFileResultFixtures(mock)
113115
addSearchFilesResultFixtures(mock)
Lines changed: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,232 @@
1+
/**
2+
* Linux-only e2e smoke test for the VS Code terminal profile override.
3+
*
4+
* Proves that:
5+
* 1. Setting a profile override causes commands to run through the selected
6+
* VS Code integrated-terminal shell without a shell_integration_warning.
7+
* 2. Clearing the override starts a fresh terminal on the next command.
8+
*
9+
* Windows profile coverage (cmd.exe fast-path, PowerShell) is proven by unit
10+
* tests in src/integrations/terminal/__tests__/. This test requires /bin/bash
11+
* which only exists on Linux/macOS.
12+
*/
13+
import * as assert from "assert"
14+
import * as fs from "fs/promises"
15+
import * as path from "path"
16+
import * as vscode from "vscode"
17+
18+
import { RooCodeEventName, type ClineMessage } from "@roo-code/types"
19+
20+
import { sleep, waitUntilCompleted } from "../utils"
21+
import { setDefaultSuiteTimeout } from "../test-utils"
22+
23+
const TEST_DIR_NAME = "terminal-profile-e2e"
24+
const OVERRIDE_FILE = "terminal-profile-override.txt"
25+
const DEFAULT_FILE = "terminal-profile-default.txt"
26+
const PROFILE_NAME = "Zoo E2E Bash"
27+
28+
suite("Terminal Profile", function () {
29+
if (process.platform !== "linux") {
30+
return
31+
}
32+
33+
setDefaultSuiteTimeout(this)
34+
35+
let workspaceDir: string
36+
let testDir: string
37+
let originalProfiles: Record<string, unknown> | undefined
38+
39+
suiteSetup(async () => {
40+
const aimockUrl = process.env.AIMOCK_URL
41+
const isRecord = process.env.AIMOCK_RECORD === "true"
42+
43+
await globalThis.api.setConfiguration({
44+
apiProvider: "openrouter" as const,
45+
openRouterApiKey: aimockUrl && !isRecord ? "mock-key" : process.env.OPENROUTER_API_KEY!,
46+
openRouterModelId: "anthropic/claude-sonnet-4.5",
47+
...(aimockUrl && { openRouterBaseUrl: `${aimockUrl}/v1` }),
48+
})
49+
50+
const workspaceFolders = vscode.workspace.workspaceFolders
51+
if (!workspaceFolders?.length) throw new Error("No workspace folder found")
52+
workspaceDir = workspaceFolders[0]!.uri.fsPath
53+
testDir = path.join(workspaceDir, TEST_DIR_NAME)
54+
await fs.rm(testDir, { recursive: true, force: true })
55+
await fs.mkdir(testDir, { recursive: true })
56+
57+
// Save the current global linux profiles so we can restore them in teardown.
58+
originalProfiles = vscode.workspace
59+
.getConfiguration("terminal.integrated.profiles")
60+
.inspect<Record<string, unknown>>("linux")?.globalValue
61+
62+
// Write the test profile to VS Code user (global) settings.
63+
// Terminal.getConfiguredProfiles() intentionally excludes workspace settings
64+
// for security, so global scope is required here.
65+
await vscode.workspace.getConfiguration("terminal.integrated.profiles").update(
66+
"linux",
67+
{
68+
...originalProfiles,
69+
[PROFILE_NAME]: { path: "/bin/bash", args: ["--noprofile", "--norc"] },
70+
},
71+
vscode.ConfigurationTarget.Global,
72+
)
73+
74+
// Activate the profile override in-process. api.setConfiguration() alone
75+
// does not call Terminal.setTerminalProfile(), so this dedicated method is
76+
// required to wire up the static in the running extension host.
77+
globalThis.api.setTerminalProfile(PROFILE_NAME)
78+
})
79+
80+
suiteTeardown(async () => {
81+
try {
82+
await globalThis.api.cancelCurrentTask()
83+
} catch {
84+
// task may not be running
85+
}
86+
87+
// Always restore — order matters: clear profile first so any subsequent
88+
// terminal creation uses the default, then restore VS Code settings.
89+
globalThis.api.setTerminalProfile(undefined)
90+
91+
await vscode.workspace
92+
.getConfiguration("terminal.integrated.profiles")
93+
.update("linux", originalProfiles, vscode.ConfigurationTarget.Global)
94+
95+
await fs.rm(testDir, { recursive: true, force: true })
96+
97+
const aimockUrl = process.env.AIMOCK_URL
98+
const isRecord = process.env.AIMOCK_RECORD === "true"
99+
await globalThis.api.setConfiguration({
100+
apiProvider: "openrouter" as const,
101+
openRouterApiKey: aimockUrl && !isRecord ? "mock-key" : process.env.OPENROUTER_API_KEY!,
102+
openRouterModelId: "openai/gpt-4.1",
103+
...(aimockUrl && { openRouterBaseUrl: `${aimockUrl}/v1` }),
104+
})
105+
})
106+
107+
setup(async () => {
108+
try {
109+
await globalThis.api.cancelCurrentTask()
110+
} catch {
111+
// task may not be running
112+
}
113+
114+
await fs.rm(path.join(testDir, OVERRIDE_FILE), { force: true })
115+
await fs.rm(path.join(testDir, DEFAULT_FILE), { force: true })
116+
await sleep(100)
117+
})
118+
119+
teardown(async () => {
120+
try {
121+
await globalThis.api.cancelCurrentTask()
122+
} catch {
123+
// task may not be running
124+
}
125+
126+
await sleep(100)
127+
})
128+
129+
test("executes command through profile override without shell integration warning", async function () {
130+
const api = globalThis.api
131+
const messages: ClineMessage[] = []
132+
133+
const messageHandler = ({ message }: { message: ClineMessage }) => {
134+
messages.push(message)
135+
}
136+
api.on(RooCodeEventName.Message, messageHandler)
137+
138+
try {
139+
await waitUntilCompleted({
140+
api,
141+
start: () =>
142+
api.startNewTask({
143+
configuration: {
144+
mode: "code",
145+
autoApprovalEnabled: true,
146+
alwaysAllowExecute: true,
147+
allowedCommands: ["*"],
148+
terminalShellIntegrationDisabled: false,
149+
},
150+
text: "TERMINAL_PROFILE_E2E_OVERRIDE",
151+
}),
152+
timeout: 90_000,
153+
})
154+
155+
const gotWarning = messages.some((m) => m.type === "say" && m.say === "shell_integration_warning")
156+
const gotError = messages.some((m) => m.type === "say" && m.say === "error")
157+
158+
assert.strictEqual(gotWarning, false, "Shell integration warning should not fire with a valid profile")
159+
assert.strictEqual(
160+
gotError,
161+
false,
162+
`Unexpected error: ${messages.find((m) => m.type === "say" && m.say === "error")?.text}`,
163+
)
164+
165+
const content = await fs.readFile(path.join(testDir, OVERRIDE_FILE), "utf-8")
166+
assert.ok(content.includes("zoo-profile-override-ok"), `Output file should contain marker, got: ${content}`)
167+
168+
assert.ok(vscode.window.terminals.length >= 1, "At least one VS Code terminal should exist")
169+
const profileTerminal = vscode.window.terminals.find((terminal) => {
170+
const options = terminal.creationOptions as vscode.TerminalOptions
171+
return (
172+
options.name === "Zoo Code" &&
173+
options.shellPath === "/bin/bash" &&
174+
Array.isArray(options.shellArgs) &&
175+
options.shellArgs.includes("--noprofile") &&
176+
options.shellArgs.includes("--norc")
177+
)
178+
})
179+
assert.ok(profileTerminal, "Expected a Zoo Code terminal created with the configured Bash profile")
180+
} finally {
181+
api.off(RooCodeEventName.Message, messageHandler)
182+
}
183+
})
184+
185+
test("starts a fresh terminal after clearing the profile override", async function () {
186+
const api = globalThis.api
187+
const messages: ClineMessage[] = []
188+
189+
const messageHandler = ({ message }: { message: ClineMessage }) => {
190+
messages.push(message)
191+
}
192+
api.on(RooCodeEventName.Message, messageHandler)
193+
194+
try {
195+
// Clear the override — this also calls TerminalRegistry.closeIdleTerminals()
196+
// so the terminal from test 1 is disposed before this task runs.
197+
api.setTerminalProfile(undefined)
198+
await sleep(200) // let VS Code process the disposal before the next task
199+
200+
await waitUntilCompleted({
201+
api,
202+
start: () =>
203+
api.startNewTask({
204+
configuration: {
205+
mode: "code",
206+
autoApprovalEnabled: true,
207+
alwaysAllowExecute: true,
208+
allowedCommands: ["*"],
209+
terminalShellIntegrationDisabled: false,
210+
},
211+
text: "TERMINAL_PROFILE_E2E_DEFAULT",
212+
}),
213+
timeout: 90_000,
214+
})
215+
216+
const gotWarning = messages.some((m) => m.type === "say" && m.say === "shell_integration_warning")
217+
const gotError = messages.some((m) => m.type === "say" && m.say === "error")
218+
219+
assert.strictEqual(gotWarning, false, "Shell integration warning should not fire with the default profile")
220+
assert.strictEqual(
221+
gotError,
222+
false,
223+
`Unexpected error: ${messages.find((m) => m.type === "say" && m.say === "error")?.text}`,
224+
)
225+
226+
const content = await fs.readFile(path.join(testDir, DEFAULT_FILE), "utf-8")
227+
assert.ok(content.includes("zoo-profile-default-ok"), `Output file should contain marker, got: ${content}`)
228+
} finally {
229+
api.off(RooCodeEventName.Message, messageHandler)
230+
}
231+
})
232+
})

packages/types/src/api.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,13 @@ export interface RooCodeAPI extends EventEmitter<RooCodeAPIEvents> {
139139
* @throws Error if the profile does not exist
140140
*/
141141
setActiveProfile(name: string): Promise<string | undefined>
142+
/**
143+
* Activates a process-wide VS Code terminal profile override for Zoo Code
144+
* commands. This is intended for trusted extension integrations.
145+
* Passing undefined restores the VS Code default profile behavior and
146+
* closes idle terminals so the next command starts fresh.
147+
*/
148+
setTerminalProfile(name: string | undefined): void
142149
}
143150

144151
export interface RooCodeIpcServer extends EventEmitter<IpcServerEvents> {

packages/types/src/global-settings.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,7 @@ export const globalSettingsSchema = z.object({
176176
terminalZshOhMy: z.boolean().optional(),
177177
terminalZshP10k: z.boolean().optional(),
178178
terminalZdotdir: z.boolean().optional(),
179+
terminalProfile: z.string().optional(),
179180
execaShellPath: z.string().optional(),
180181

181182
diagnosticsEnabled: z.boolean().optional(),

packages/types/src/vscode-extension-host.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ export interface ExtensionMessage {
6363
| "commandExecutionStatus"
6464
| "mcpExecutionStatus"
6565
| "vsCodeSetting"
66+
| "terminalProfiles"
6667
| "authenticatedUser"
6768
| "condenseTaskContextStarted"
6869
| "condenseTaskContextResponse"
@@ -152,6 +153,8 @@ export interface ExtensionMessage {
152153
error?: string
153154
setting?: string
154155
value?: any // eslint-disable-line @typescript-eslint/no-explicit-any
156+
/** Sanitized VS Code terminal profile names for the `terminalProfiles` message. */
157+
profiles?: string[]
155158
hasContent?: boolean
156159
items?: MarketplaceItem[]
157160
userInfo?: CloudUserInfo
@@ -275,6 +278,7 @@ export type ExtensionState = Pick<
275278
| "terminalZshOhMy"
276279
| "terminalZshP10k"
277280
| "terminalZdotdir"
281+
| "terminalProfile"
278282
| "execaShellPath"
279283
| "diagnosticsEnabled"
280284
| "language"
@@ -453,6 +457,8 @@ export interface WebviewMessage {
453457
| "updateVSCodeSetting"
454458
| "getVSCodeSetting"
455459
| "vsCodeSetting"
460+
| "requestTerminalProfiles"
461+
| "openTerminalProfilePicker"
456462
| "updateCondensingPrompt"
457463
| "playSound"
458464
| "playTts"

0 commit comments

Comments
 (0)