diff --git a/apps/vscode-e2e/fixtures/terminal-profile.json b/apps/vscode-e2e/fixtures/terminal-profile.json new file mode 100644 index 0000000000..5fdb581133 --- /dev/null +++ b/apps/vscode-e2e/fixtures/terminal-profile.json @@ -0,0 +1,32 @@ +{ + "fixtures": [ + { + "match": { + "userMessage": "TERMINAL_PROFILE_E2E_OVERRIDE" + }, + "response": { + "toolCalls": [ + { + "name": "execute_command", + "arguments": "{\"command\":\"printf 'zoo-profile-override-ok\\\\n' > terminal-profile-e2e/terminal-profile-override.txt\"}", + "id": "call_terminal_profile_override_001" + } + ] + } + }, + { + "match": { + "userMessage": "TERMINAL_PROFILE_E2E_DEFAULT" + }, + "response": { + "toolCalls": [ + { + "name": "execute_command", + "arguments": "{\"command\":\"printf 'zoo-profile-default-ok\\\\n' > terminal-profile-e2e/terminal-profile-default.txt\"}", + "id": "call_terminal_profile_default_001" + } + ] + } + } + ] +} diff --git a/apps/vscode-e2e/src/fixtures/terminal-profile.ts b/apps/vscode-e2e/src/fixtures/terminal-profile.ts new file mode 100644 index 0000000000..d71f4a572c --- /dev/null +++ b/apps/vscode-e2e/src/fixtures/terminal-profile.ts @@ -0,0 +1,58 @@ +import { LLMock } from "@copilotkit/aimock" + +import { toolResultContains } from "./tool-result" + +type TerminalProfileToolCall = { + name: "execute_command" | "attempt_completion" + params: Record + id: string +} + +type TerminalProfileFixture = { + toolCallId: string + expected: string[] + toolCalls: TerminalProfileToolCall[] +} + +export function addTerminalProfileResultFixtures(mock: InstanceType) { + const fixtures: TerminalProfileFixture[] = [ + { + toolCallId: "call_terminal_profile_override_001", + expected: ["Exit code: 0"], + toolCalls: [ + { + name: "attempt_completion", + params: { result: "Ran the command using the Zoo E2E Bash profile override." }, + id: "call_terminal_profile_override_002", + }, + ], + }, + { + toolCallId: "call_terminal_profile_default_001", + expected: ["Exit code: 0"], + toolCalls: [ + { + name: "attempt_completion", + params: { result: "Ran the command using the default terminal profile." }, + id: "call_terminal_profile_default_002", + }, + ], + }, + ] + + for (const fixture of fixtures) { + mock.addFixture({ + match: { + toolCallId: fixture.toolCallId, + predicate: (req) => toolResultContains(req, fixture.toolCallId, fixture.expected), + }, + response: { + toolCalls: fixture.toolCalls.map((toolCall) => ({ + name: toolCall.name, + arguments: JSON.stringify(toolCall.params), + id: toolCall.id, + })), + }, + }) + } +} diff --git a/apps/vscode-e2e/src/runTest.ts b/apps/vscode-e2e/src/runTest.ts index f3afa51ea2..10dd29678c 100644 --- a/apps/vscode-e2e/src/runTest.ts +++ b/apps/vscode-e2e/src/runTest.ts @@ -7,6 +7,7 @@ import { LLMock } from "@copilotkit/aimock" import { addApplyDiffResultFixtures } from "./fixtures/apply-diff" import { addExecuteCommandResultFixtures } from "./fixtures/execute-command" +import { addTerminalProfileResultFixtures } from "./fixtures/terminal-profile" import { addListFilesResultFixtures } from "./fixtures/list-files" import { addReadFileResultFixtures } from "./fixtures/read-file" import { addSearchFilesResultFixtures } from "./fixtures/search-files" @@ -108,6 +109,7 @@ async function main() { if (!isRecord) { addApplyDiffResultFixtures(mock) addExecuteCommandResultFixtures(mock) + addTerminalProfileResultFixtures(mock) addListFilesResultFixtures(mock) addReadFileResultFixtures(mock) addSearchFilesResultFixtures(mock) diff --git a/apps/vscode-e2e/src/suite/tools/terminal-profile.test.ts b/apps/vscode-e2e/src/suite/tools/terminal-profile.test.ts new file mode 100644 index 0000000000..a23bd8163c --- /dev/null +++ b/apps/vscode-e2e/src/suite/tools/terminal-profile.test.ts @@ -0,0 +1,232 @@ +/** + * Linux-only e2e smoke test for the VS Code terminal profile override. + * + * Proves that: + * 1. Setting a profile override causes commands to run through the selected + * VS Code integrated-terminal shell without a shell_integration_warning. + * 2. Clearing the override starts a fresh terminal on the next command. + * + * Windows profile coverage (cmd.exe fast-path, PowerShell) is proven by unit + * tests in src/integrations/terminal/__tests__/. This test requires /bin/bash + * which only exists on Linux/macOS. + */ +import * as assert from "assert" +import * as fs from "fs/promises" +import * as path from "path" +import * as vscode from "vscode" + +import { RooCodeEventName, type ClineMessage } from "@roo-code/types" + +import { sleep, waitUntilCompleted } from "../utils" +import { setDefaultSuiteTimeout } from "../test-utils" + +const TEST_DIR_NAME = "terminal-profile-e2e" +const OVERRIDE_FILE = "terminal-profile-override.txt" +const DEFAULT_FILE = "terminal-profile-default.txt" +const PROFILE_NAME = "Zoo E2E Bash" + +suite("Terminal Profile", function () { + if (process.platform !== "linux") { + return + } + + setDefaultSuiteTimeout(this) + + let workspaceDir: string + let testDir: string + let originalProfiles: Record | undefined + + suiteSetup(async () => { + const aimockUrl = process.env.AIMOCK_URL + const isRecord = process.env.AIMOCK_RECORD === "true" + + await globalThis.api.setConfiguration({ + apiProvider: "openrouter" as const, + openRouterApiKey: aimockUrl && !isRecord ? "mock-key" : process.env.OPENROUTER_API_KEY!, + openRouterModelId: "anthropic/claude-sonnet-4.5", + ...(aimockUrl && { openRouterBaseUrl: `${aimockUrl}/v1` }), + }) + + const workspaceFolders = vscode.workspace.workspaceFolders + if (!workspaceFolders?.length) throw new Error("No workspace folder found") + workspaceDir = workspaceFolders[0]!.uri.fsPath + testDir = path.join(workspaceDir, TEST_DIR_NAME) + await fs.rm(testDir, { recursive: true, force: true }) + await fs.mkdir(testDir, { recursive: true }) + + // Save the current global linux profiles so we can restore them in teardown. + originalProfiles = vscode.workspace + .getConfiguration("terminal.integrated.profiles") + .inspect>("linux")?.globalValue + + // Write the test profile to VS Code user (global) settings. + // Terminal.getConfiguredProfiles() intentionally excludes workspace settings + // for security, so global scope is required here. + await vscode.workspace.getConfiguration("terminal.integrated.profiles").update( + "linux", + { + ...originalProfiles, + [PROFILE_NAME]: { path: "/bin/bash", args: ["--noprofile", "--norc"] }, + }, + vscode.ConfigurationTarget.Global, + ) + + // Activate the profile override in-process. api.setConfiguration() alone + // does not call Terminal.setTerminalProfile(), so this dedicated method is + // required to wire up the static in the running extension host. + globalThis.api.setTerminalProfile(PROFILE_NAME) + }) + + suiteTeardown(async () => { + try { + await globalThis.api.cancelCurrentTask() + } catch { + // task may not be running + } + + // Always restore — order matters: clear profile first so any subsequent + // terminal creation uses the default, then restore VS Code settings. + globalThis.api.setTerminalProfile(undefined) + + await vscode.workspace + .getConfiguration("terminal.integrated.profiles") + .update("linux", originalProfiles, vscode.ConfigurationTarget.Global) + + await fs.rm(testDir, { recursive: true, force: true }) + + const aimockUrl = process.env.AIMOCK_URL + const isRecord = process.env.AIMOCK_RECORD === "true" + await globalThis.api.setConfiguration({ + apiProvider: "openrouter" as const, + openRouterApiKey: aimockUrl && !isRecord ? "mock-key" : process.env.OPENROUTER_API_KEY!, + openRouterModelId: "openai/gpt-4.1", + ...(aimockUrl && { openRouterBaseUrl: `${aimockUrl}/v1` }), + }) + }) + + setup(async () => { + try { + await globalThis.api.cancelCurrentTask() + } catch { + // task may not be running + } + + await fs.rm(path.join(testDir, OVERRIDE_FILE), { force: true }) + await fs.rm(path.join(testDir, DEFAULT_FILE), { force: true }) + await sleep(100) + }) + + teardown(async () => { + try { + await globalThis.api.cancelCurrentTask() + } catch { + // task may not be running + } + + await sleep(100) + }) + + test("executes command through profile override without shell integration warning", async function () { + const api = globalThis.api + const messages: ClineMessage[] = [] + + const messageHandler = ({ message }: { message: ClineMessage }) => { + messages.push(message) + } + api.on(RooCodeEventName.Message, messageHandler) + + try { + await waitUntilCompleted({ + api, + start: () => + api.startNewTask({ + configuration: { + mode: "code", + autoApprovalEnabled: true, + alwaysAllowExecute: true, + allowedCommands: ["*"], + terminalShellIntegrationDisabled: false, + }, + text: "TERMINAL_PROFILE_E2E_OVERRIDE", + }), + timeout: 90_000, + }) + + const gotWarning = messages.some((m) => m.type === "say" && m.say === "shell_integration_warning") + const gotError = messages.some((m) => m.type === "say" && m.say === "error") + + assert.strictEqual(gotWarning, false, "Shell integration warning should not fire with a valid profile") + assert.strictEqual( + gotError, + false, + `Unexpected error: ${messages.find((m) => m.type === "say" && m.say === "error")?.text}`, + ) + + const content = await fs.readFile(path.join(testDir, OVERRIDE_FILE), "utf-8") + assert.ok(content.includes("zoo-profile-override-ok"), `Output file should contain marker, got: ${content}`) + + assert.ok(vscode.window.terminals.length >= 1, "At least one VS Code terminal should exist") + const profileTerminal = vscode.window.terminals.find((terminal) => { + const options = terminal.creationOptions as vscode.TerminalOptions + return ( + options.name === "Zoo Code" && + options.shellPath === "/bin/bash" && + Array.isArray(options.shellArgs) && + options.shellArgs.includes("--noprofile") && + options.shellArgs.includes("--norc") + ) + }) + assert.ok(profileTerminal, "Expected a Zoo Code terminal created with the configured Bash profile") + } finally { + api.off(RooCodeEventName.Message, messageHandler) + } + }) + + test("starts a fresh terminal after clearing the profile override", async function () { + const api = globalThis.api + const messages: ClineMessage[] = [] + + const messageHandler = ({ message }: { message: ClineMessage }) => { + messages.push(message) + } + api.on(RooCodeEventName.Message, messageHandler) + + try { + // Clear the override — this also calls TerminalRegistry.closeIdleTerminals() + // so the terminal from test 1 is disposed before this task runs. + api.setTerminalProfile(undefined) + await sleep(200) // let VS Code process the disposal before the next task + + await waitUntilCompleted({ + api, + start: () => + api.startNewTask({ + configuration: { + mode: "code", + autoApprovalEnabled: true, + alwaysAllowExecute: true, + allowedCommands: ["*"], + terminalShellIntegrationDisabled: false, + }, + text: "TERMINAL_PROFILE_E2E_DEFAULT", + }), + timeout: 90_000, + }) + + const gotWarning = messages.some((m) => m.type === "say" && m.say === "shell_integration_warning") + const gotError = messages.some((m) => m.type === "say" && m.say === "error") + + assert.strictEqual(gotWarning, false, "Shell integration warning should not fire with the default profile") + assert.strictEqual( + gotError, + false, + `Unexpected error: ${messages.find((m) => m.type === "say" && m.say === "error")?.text}`, + ) + + const content = await fs.readFile(path.join(testDir, DEFAULT_FILE), "utf-8") + assert.ok(content.includes("zoo-profile-default-ok"), `Output file should contain marker, got: ${content}`) + } finally { + api.off(RooCodeEventName.Message, messageHandler) + } + }) +}) diff --git a/packages/types/src/api.ts b/packages/types/src/api.ts index ad2130fb32..c9b89b3b00 100644 --- a/packages/types/src/api.ts +++ b/packages/types/src/api.ts @@ -139,6 +139,13 @@ export interface RooCodeAPI extends EventEmitter { * @throws Error if the profile does not exist */ setActiveProfile(name: string): Promise + /** + * Activates a process-wide VS Code terminal profile override for Zoo Code + * commands. This is intended for trusted extension integrations. + * Passing undefined restores the VS Code default profile behavior and + * closes idle terminals so the next command starts fresh. + */ + setTerminalProfile(name: string | undefined): void } export interface RooCodeIpcServer extends EventEmitter { diff --git a/packages/types/src/global-settings.ts b/packages/types/src/global-settings.ts index dd7c84cf2f..8399ae310e 100644 --- a/packages/types/src/global-settings.ts +++ b/packages/types/src/global-settings.ts @@ -176,6 +176,7 @@ export const globalSettingsSchema = z.object({ terminalZshOhMy: z.boolean().optional(), terminalZshP10k: z.boolean().optional(), terminalZdotdir: z.boolean().optional(), + terminalProfile: z.string().optional(), execaShellPath: z.string().optional(), diagnosticsEnabled: z.boolean().optional(), diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index 5853e536de..0f9e00ed99 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -63,6 +63,7 @@ export interface ExtensionMessage { | "commandExecutionStatus" | "mcpExecutionStatus" | "vsCodeSetting" + | "terminalProfiles" | "authenticatedUser" | "condenseTaskContextStarted" | "condenseTaskContextResponse" @@ -152,6 +153,8 @@ export interface ExtensionMessage { error?: string setting?: string value?: any // eslint-disable-line @typescript-eslint/no-explicit-any + /** Sanitized VS Code terminal profile names for the `terminalProfiles` message. */ + profiles?: string[] hasContent?: boolean items?: MarketplaceItem[] userInfo?: CloudUserInfo @@ -275,6 +278,7 @@ export type ExtensionState = Pick< | "terminalZshOhMy" | "terminalZshP10k" | "terminalZdotdir" + | "terminalProfile" | "execaShellPath" | "diagnosticsEnabled" | "language" @@ -453,6 +457,8 @@ export interface WebviewMessage { | "updateVSCodeSetting" | "getVSCodeSetting" | "vsCodeSetting" + | "requestTerminalProfiles" + | "openTerminalProfilePicker" | "updateCondensingPrompt" | "playSound" | "playTts" diff --git a/src/core/tools/ExecuteCommandTool.ts b/src/core/tools/ExecuteCommandTool.ts index 8fcb917b13..16b5a37e3c 100644 --- a/src/core/tools/ExecuteCommandTool.ts +++ b/src/core/tools/ExecuteCommandTool.ts @@ -12,7 +12,14 @@ import { Task } from "../task/Task" import { ToolUse, ToolResponse } from "../../shared/tools" import { formatResponse } from "../prompts/responses" import { unescapeHtmlEntities } from "../../utils/text-normalization" -import { ExitCodeDetails, RooTerminalCallbacks, RooTerminalProcess } from "../../integrations/terminal/types" +import { + ExitCodeDetails, + RooTerminalCallbacks, + RooTerminalProvider, + RooTerminalProcess, + ShellIntegrationError, + ShellIntegrationErrorDetails, +} from "../../integrations/terminal/types" import { TerminalRegistry } from "../../integrations/terminal/TerminalRegistry" import { Terminal } from "../../integrations/terminal/Terminal" import { OutputInterceptor } from "../../integrations/terminal/OutputInterceptor" @@ -21,7 +28,21 @@ import { t } from "../../i18n" import { getTaskDirectoryPath } from "../../utils/storage" import { BaseTool, ToolCallbacks } from "./BaseTool" -class ShellIntegrationError extends Error {} +export { ShellIntegrationError } from "../../integrations/terminal/types" + +export function canRetryShellIntegrationError(error: unknown): error is ShellIntegrationError { + return error instanceof ShellIntegrationError && !error.commandSubmitted +} + +export function getTerminalProviderForExecution(terminalShellIntegrationDisabled: boolean): { + terminalProvider: RooTerminalProvider + isCmdExeFallback: boolean +} { + const isCmdExeFallback = !terminalShellIntegrationDisabled && Terminal.isActiveShellCmdExe() + const terminalProvider = terminalShellIntegrationDisabled || isCmdExeFallback ? "execa" : "vscode" + + return { terminalProvider, isCmdExeFallback } +} interface ExecuteCommandParams { command: string @@ -116,14 +137,14 @@ export class ExecuteCommandTool extends BaseTool<"execute_command"> { pushToolResult(result) } catch (error: unknown) { - const status: CommandExecutionStatus = { executionId, status: "fallback" } - provider?.postMessageToWebview({ type: "commandExecutionStatus", text: JSON.stringify(status) }) - await task.say("shell_integration_warning") - // Invalidate pending ask from first execution to prevent race condition task.supersedePendingAsk() - if (error instanceof ShellIntegrationError) { + if (canRetryShellIntegrationError(error)) { + // Silent retry via execa — shell startup race, command was not submitted. + const status: CommandExecutionStatus = { executionId, status: "fallback" } + provider?.postMessageToWebview({ type: "commandExecutionStatus", text: JSON.stringify(status) }) + const [rejected, result] = await executeCommandInTerminal(task, { ...options, terminalShellIntegrationDisabled: true, @@ -135,7 +156,16 @@ export class ExecuteCommandTool extends BaseTool<"execute_command"> { pushToolResult(result) } else { - pushToolResult(`Command failed to execute in terminal due to a shell integration error.`) + // Command was submitted but shell integration lost track of it — show warning. + await task.say("shell_integration_warning") + + if (error instanceof ShellIntegrationError) { + pushToolResult( + "Command was submitted in the VS Code terminal, but shell integration did not report its output or completion status. Do not run the command again automatically.", + ) + } else { + pushToolResult(`Command failed to execute in terminal due to a shell integration error.`) + } } } @@ -196,12 +226,19 @@ export async function executeCommandInTerminal( let result: string = "" let persistedResult: PersistedCommandOutput | undefined let exitDetails: ExitCodeDetails | undefined - let shellIntegrationError: string | undefined + let shellIntegrationError: ShellIntegrationError | undefined let hasAskedForCommandOutput = false - const terminalProvider = terminalShellIntegrationDisabled ? "execa" : "vscode" + const { terminalProvider, isCmdExeFallback } = getTerminalProviderForExecution(terminalShellIntegrationDisabled) const provider = await task.providerRef.deref() + // cmd.exe can't use shell integration — tell the webview to expand the output + // panel immediately (same effect as the retry-fallback path). + if (isCmdExeFallback) { + const status: CommandExecutionStatus = { executionId, status: "fallback" } + provider?.postMessageToWebview({ type: "commandExecutionStatus", text: JSON.stringify(status) }) + } + // Get global storage path for persisted output artifacts const globalStoragePath = provider?.context?.globalStorageUri?.fsPath let interceptor: OutputInterceptor | undefined @@ -360,9 +397,9 @@ export async function executeCommandInTerminal( } if (terminalProvider === "vscode") { - callbacks.onNoShellIntegration = async (error: string) => { + callbacks.onNoShellIntegration = async (details: ShellIntegrationErrorDetails) => { TelemetryService.instance.captureShellIntegrationError(task.taskId) - shellIntegrationError = error + shellIntegrationError = new ShellIntegrationError(details.message, details.commandSubmitted) } } @@ -442,7 +479,7 @@ export async function executeCommandInTerminal( } if (shellIntegrationError) { - throw new ShellIntegrationError(shellIntegrationError) + throw shellIntegrationError } // Wait for a short delay to ensure all messages are sent to the webview. @@ -482,31 +519,15 @@ export async function executeCommandInTerminal( return [false, formatPersistedOutput(persistedResult, exitDetails, currentWorkingDir)] } - // Use inline format for small outputs (original behavior with exit status) - let exitStatus: string = "" - - if (exitDetails !== undefined) { - if (exitDetails.signalName) { - exitStatus = `Process terminated by signal ${exitDetails.signalName}` - - if (exitDetails.coreDumpPossible) { - exitStatus += " - core dump possible" - } - } else if (exitDetails.exitCode === undefined) { - result += "" - exitStatus = `Exit code: ` - } else { - if (exitDetails.exitCode !== 0) { - exitStatus += "Command execution was not successful, inspect the cause and adjust as needed.\n" - } - - exitStatus += `Exit code: ${exitDetails.exitCode}` - } - } else { + // Use inline format for small outputs (original behavior with exit status). + if (exitDetails === undefined) { result += "" - exitStatus = `Exit code: ` + } else if (!exitDetails.signalName && exitDetails.exitCode === undefined) { + result += "" } + const exitStatus = formatExitStatus(exitDetails) + return [ false, `Command executed in terminal within working directory '${currentWorkingDir}'. ${exitStatus}\nOutput:\n${result}`, diff --git a/src/core/tools/__tests__/executeCommandTool.spec.ts b/src/core/tools/__tests__/executeCommandTool.spec.ts index d445f0aeb5..bf186046a5 100644 --- a/src/core/tools/__tests__/executeCommandTool.spec.ts +++ b/src/core/tools/__tests__/executeCommandTool.spec.ts @@ -7,6 +7,7 @@ import { Task } from "../../task/Task" import { formatResponse } from "../../prompts/responses" import { ToolUse, AskApproval, HandleError, PushToolResult } from "../../../shared/tools" import { unescapeHtmlEntities } from "../../../utils/text-normalization" +import { Terminal } from "../../../integrations/terminal/Terminal" // Mock dependencies vitest.mock("execa", () => ({ @@ -256,6 +257,27 @@ describe("executeCommandTool", () => { expect(mockAskApproval).not.toHaveBeenCalled() // executeCommandInTerminal should not be called since rooignore blocked it }) + + it("allows Execa retry when shell integration fails before command submission", () => { + const error = new executeCommandModule.ShellIntegrationError("startup failed", false) + + expect(executeCommandModule.canRetryShellIntegrationError(error)).toBe(true) + }) + + it("prevents Execa retry when shell integration fails after command submission", () => { + const error = new executeCommandModule.ShellIntegrationError("stream missing", true) + + expect(executeCommandModule.canRetryShellIntegrationError(error)).toBe(false) + }) + + it("selects the Execa fallback provider for cmd.exe shell integration", () => { + vitest.spyOn(Terminal, "isActiveShellCmdExe").mockReturnValue(true) + + expect(executeCommandModule.getTerminalProviderForExecution(false)).toEqual({ + terminalProvider: "execa", + isCmdExeFallback: true, + }) + }) }) describe("Command execution timeout configuration", () => { diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index b213c0862d..38ad631271 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -781,6 +781,7 @@ export class ClineProvider terminalZshP10k = false, terminalPowershellCounter = false, terminalZdotdir = false, + terminalProfile, ttsEnabled, ttsSpeed, }) => { @@ -792,6 +793,7 @@ export class ClineProvider Terminal.setTerminalZshP10k(terminalZshP10k) Terminal.setPowershellCounter(terminalPowershellCounter) Terminal.setTerminalZdotdir(terminalZdotdir) + Terminal.setTerminalProfile(terminalProfile) setTtsEnabled(ttsEnabled ?? false) setTtsSpeed(ttsSpeed ?? 1) }, @@ -2084,6 +2086,7 @@ export class ClineProvider terminalZshOhMy, terminalZshP10k, terminalZdotdir, + terminalProfile, mcpEnabled, currentApiConfigName, listApiConfigMeta, @@ -2237,6 +2240,7 @@ export class ClineProvider terminalZshOhMy: terminalZshOhMy ?? false, terminalZshP10k: terminalZshP10k ?? false, terminalZdotdir: terminalZdotdir ?? false, + terminalProfile, mcpEnabled: mcpEnabled ?? true, currentApiConfigName: currentApiConfigName ?? "default", listApiConfigMeta: listApiConfigMeta ?? [], @@ -2441,6 +2445,7 @@ export class ClineProvider terminalZshOhMy: stateValues.terminalZshOhMy ?? false, terminalZshP10k: stateValues.terminalZshP10k ?? false, terminalZdotdir: stateValues.terminalZdotdir ?? false, + terminalProfile: stateValues.terminalProfile, mode: stateValues.mode ?? defaultModeSlug, language: stateValues.language ?? formatLanguage(vscode.env.language), mcpEnabled: stateValues.mcpEnabled ?? true, diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index 16ab791c26..fd63f2a978 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -24,6 +24,7 @@ import { Task, TaskOptions } from "../../task/Task" import { safeWriteJson } from "../../../utils/safeWriteJson" import { ClineProvider } from "../ClineProvider" +import { Terminal } from "../../../integrations/terminal/Terminal" import { MessageManager } from "../../message-manager" // Mock setup must come before imports. @@ -471,6 +472,20 @@ describe("ClineProvider", () => { expect(ClineProvider.getVisibleInstance()).toBe(provider) }) + test("resolveWebviewView hydrates the saved terminalProfile into the process-wide Terminal state", async () => { + const setTerminalProfileSpy = vi.spyOn(Terminal, "setTerminalProfile").mockImplementation(() => {}) + // Seed the persisted setting so the real getState() returns it during hydration. + await (provider as any).contextProxy.setValue("terminalProfile", "Git Bash") + + await provider.resolveWebviewView(mockWebviewView) + // The hydration runs in a getState().then(...) callback, so flush microtasks. + await new Promise((resolve) => setImmediate(resolve)) + + expect(setTerminalProfileSpy).toHaveBeenCalledWith("Git Bash") + + setTerminalProfileSpy.mockRestore() + }) + test("resolveWebviewView sets up webview correctly", async () => { await provider.resolveWebviewView(mockWebviewView) diff --git a/src/core/webview/__tests__/webviewMessageHandler.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.spec.ts index 4af2be1e38..224e9dece6 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.spec.ts @@ -103,6 +103,10 @@ vi.mock("vscode", () => { workspace: { workspaceFolders: [{ uri: { fsPath: "/mock/workspace" } }], openTextDocument, + getConfiguration: vi.fn(() => ({ get: vi.fn() })), + }, + commands: { + executeCommand: vi.fn().mockResolvedValue(undefined), }, } }) @@ -167,6 +171,8 @@ vi.mock("../../mentions/resolveImageMentions", () => ({ })) import { resolveImageMentions } from "../../mentions/resolveImageMentions" +import { Terminal } from "../../../integrations/terminal/Terminal" +import { TerminalRegistry } from "../../../integrations/terminal/TerminalRegistry" describe("webviewMessageHandler - requestLmStudioModels", () => { beforeEach(() => { @@ -866,6 +872,129 @@ describe("webviewMessageHandler - mcpEnabled", () => { }) }) +describe("webviewMessageHandler - terminalProfile", () => { + beforeEach(() => { + vi.clearAllMocks() + Terminal.setTerminalProfile(undefined) + }) + + afterEach(() => { + Terminal.setTerminalProfile(undefined) + vi.restoreAllMocks() + }) + + it("normalizes and persists a saved terminalProfile, then closes stale idle terminals", async () => { + const closeIdleTerminalsSpy = vi.spyOn(TerminalRegistry, "closeIdleTerminals").mockImplementation(() => {}) + + await webviewMessageHandler(mockClineProvider, { + type: "updateSettings", + updatedSettings: { terminalProfile: " Git Bash " }, + }) + + expect(Terminal.getTerminalProfile()).toBe("Git Bash") + expect(mockClineProvider.contextProxy.setValue).toHaveBeenCalledWith("terminalProfile", "Git Bash") + expect(closeIdleTerminalsSpy).toHaveBeenCalledTimes(1) + }) + + it("does not close idle terminals when hydration sends the unchanged profile", async () => { + Terminal.setTerminalProfile("Git Bash") + const closeIdleTerminalsSpy = vi.spyOn(TerminalRegistry, "closeIdleTerminals").mockImplementation(() => {}) + + await webviewMessageHandler(mockClineProvider, { + type: "updateSettings", + updatedSettings: { terminalProfile: " Git Bash " }, + }) + + expect(mockClineProvider.contextProxy.setValue).toHaveBeenCalledWith("terminalProfile", "Git Bash") + expect(closeIdleTerminalsSpy).not.toHaveBeenCalled() + }) + + it("clears the persisted profile when SettingsView sends the empty-string sentinel", async () => { + Terminal.setTerminalProfile("Git Bash") + const closeIdleTerminalsSpy = vi.spyOn(TerminalRegistry, "closeIdleTerminals").mockImplementation(() => {}) + + await webviewMessageHandler(mockClineProvider, { + type: "updateSettings", + updatedSettings: { terminalProfile: "" }, + }) + + expect(Terminal.getTerminalProfile()).toBeUndefined() + expect(mockClineProvider.contextProxy.setValue).toHaveBeenCalledWith("terminalProfile", undefined) + expect(closeIdleTerminalsSpy).toHaveBeenCalledTimes(1) + }) + + it("does not close idle terminals when the empty-string sentinel leaves the profile unset", async () => { + const closeIdleTerminalsSpy = vi.spyOn(TerminalRegistry, "closeIdleTerminals").mockImplementation(() => {}) + + await webviewMessageHandler(mockClineProvider, { + type: "updateSettings", + updatedSettings: { terminalProfile: "" }, + }) + + expect(mockClineProvider.contextProxy.setValue).toHaveBeenCalledWith("terminalProfile", undefined) + expect(closeIdleTerminalsSpy).not.toHaveBeenCalled() + }) + + it("treats non-string terminalProfile values as unset", async () => { + Terminal.setTerminalProfile("Git Bash") + const closeIdleTerminalsSpy = vi.spyOn(TerminalRegistry, "closeIdleTerminals").mockImplementation(() => {}) + + await webviewMessageHandler(mockClineProvider, { + type: "updateSettings", + updatedSettings: { terminalProfile: 42 as any }, + }) + + expect(Terminal.getTerminalProfile()).toBeUndefined() + expect(mockClineProvider.contextProxy.setValue).toHaveBeenCalledWith("terminalProfile", undefined) + expect(closeIdleTerminalsSpy).toHaveBeenCalledTimes(1) + }) +}) + +describe("webviewMessageHandler - requestTerminalProfiles", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it("posts available profile names", async () => { + vi.spyOn(Terminal, "getAvailableProfileNames").mockReturnValue(["Git Bash", "bash"]) + + await webviewMessageHandler(mockClineProvider, { type: "requestTerminalProfiles" }) + + expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({ + type: "terminalProfiles", + profiles: ["Git Bash", "bash"], + }) + }) + + it("posts an empty array when profile discovery throws", async () => { + vi.spyOn(Terminal, "getAvailableProfileNames").mockImplementation(() => { + throw new Error("config error") + }) + + await webviewMessageHandler(mockClineProvider, { type: "requestTerminalProfiles" }) + + expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({ + type: "terminalProfiles", + profiles: [], + }) + }) +}) + +describe("webviewMessageHandler - openTerminalProfilePicker", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it("executes the VS Code selectDefaultShell command", async () => { + await webviewMessageHandler(mockClineProvider, { type: "openTerminalProfilePicker" }) + expect(vscode.commands.executeCommand).toHaveBeenCalledWith("workbench.action.terminal.selectDefaultShell") + }) +}) + describe("webviewMessageHandler - requestCommands", () => { beforeEach(() => { vi.clearAllMocks() diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index b5bda4b147..2ee7373119 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -50,6 +50,7 @@ import { checkExistKey } from "../../shared/checkExistApiConfig" import { getRouterRemovalMessage, getRouterUnavailableSignInMessage } from "../config/routerRemoval" import { experimentDefault } from "../../shared/experiments" import { Terminal } from "../../integrations/terminal/Terminal" +import { TerminalRegistry } from "../../integrations/terminal/TerminalRegistry" import { openFile } from "../../integrations/misc/open-file" import { openImage, saveImage } from "../../integrations/misc/image-handler" import { selectImages } from "../../integrations/misc/process-images" @@ -726,6 +727,17 @@ export const webviewMessageHandler = async ( if (value !== undefined) { Terminal.setTerminalZdotdir(value as boolean) } + } else if (key === "terminalProfile") { + const previousProfile = Terminal.getTerminalProfile() + Terminal.setTerminalProfile(typeof value === "string" ? value : undefined) + newValue = Terminal.getTerminalProfile() + + if (newValue !== previousProfile) { + // Discard idle terminals so the next command gets a fresh + // terminal using the new profile's shell instead of reusing + // a stale one from the previous profile. + TerminalRegistry.closeIdleTerminals() + } } else if (key === "execaShellPath") { Terminal.setExecaShellPath(value as string | undefined) } else if (key === "mcpEnabled") { @@ -1316,6 +1328,12 @@ export const webviewMessageHandler = async ( break } + case "openTerminalProfilePicker": { + // Open VS Code's native terminal profile picker so the user can set the + // default shell without leaving VS Code's own settings UI. + await vscode.commands.executeCommand("workbench.action.terminal.selectDefaultShell") + break + } case "openKeyboardShortcuts": { // Open VSCode keyboard shortcuts settings and optionally filter to show the Roo Code commands const searchQuery = message.text || "" @@ -1517,6 +1535,27 @@ export const webviewMessageHandler = async ( break + case "requestTerminalProfiles": { + // Allowlisted request: read VS Code's terminal profiles server-side and + // return only the sanitized profile names. The terminal profile dropdown + // only needs names, so this avoids routing it through the generic + // `getVSCodeSetting` handler (which reads any key the webview supplies). + // Only profiles with a resolvable `path` are returned — source-only + // profiles (e.g. { source: "PowerShell" }) cannot be mapped to a shell + // binary by an extension and would silently fall back to the default. + try { + await provider.postMessageToWebview({ + type: "terminalProfiles", + profiles: Terminal.getAvailableProfileNames(), + }) + } catch (error) { + console.error("Failed to get terminal profiles:", error) + await provider.postMessageToWebview({ type: "terminalProfiles", profiles: [] }) + } + + break + } + case "mode": await provider.handleModeSwitch(message.text as Mode) break diff --git a/src/extension.ts b/src/extension.ts index 44c1243528..e326509c3d 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -31,6 +31,7 @@ import { formatLanguage } from "./shared/language" import { ContextProxy } from "./core/config/ContextProxy" import { ClineProvider } from "./core/webview/ClineProvider" import { DIFF_VIEW_URI_SCHEME } from "./integrations/editor/DiffViewProvider" +import { Terminal } from "./integrations/terminal/Terminal" import { TerminalRegistry } from "./integrations/terminal/TerminalRegistry" import { openAiCodexOAuthManager } from "./integrations/openai-codex/oauth" import { McpServerManager } from "./services/mcp/McpServerManager" @@ -387,5 +388,6 @@ export async function deactivate() { await McpServerManager.cleanup(extensionContext) TelemetryService.instance.shutdown() + Terminal.setTerminalProfile(undefined) TerminalRegistry.cleanup() } diff --git a/src/extension/__tests__/api-terminal-profile.spec.ts b/src/extension/__tests__/api-terminal-profile.spec.ts new file mode 100644 index 0000000000..9c7f80654f --- /dev/null +++ b/src/extension/__tests__/api-terminal-profile.spec.ts @@ -0,0 +1,41 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest" +import type * as vscode from "vscode" + +import { API } from "../api" +import type { ClineProvider } from "../../core/webview/ClineProvider" +import { Terminal } from "../../integrations/terminal/Terminal" +import { TerminalRegistry } from "../../integrations/terminal/TerminalRegistry" + +vi.mock("@roo-code/ipc", () => ({ + IpcServer: class {}, +})) + +describe("API - terminal profile", () => { + let api: API + + beforeEach(() => { + const outputChannel = { appendLine: vi.fn() } as unknown as vscode.OutputChannel + const provider = { + context: {}, + on: vi.fn(), + } as unknown as ClineProvider + + Terminal.setTerminalProfile(undefined) + api = new API(outputChannel, provider) + }) + + afterEach(() => { + Terminal.setTerminalProfile(undefined) + vi.restoreAllMocks() + }) + + it("closes idle terminals only when the normalized profile changes", () => { + const closeIdleTerminalsSpy = vi.spyOn(TerminalRegistry, "closeIdleTerminals").mockImplementation(() => {}) + + api.setTerminalProfile(" Git Bash ") + api.setTerminalProfile("Git Bash") + + expect(Terminal.getTerminalProfile()).toBe("Git Bash") + expect(closeIdleTerminalsSpy).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/extension/api.ts b/src/extension/api.ts index af21028944..0f056f16da 100644 --- a/src/extension/api.ts +++ b/src/extension/api.ts @@ -24,6 +24,8 @@ import { IpcServer } from "@roo-code/ipc" import { Package } from "../shared/package" import { ClineProvider } from "../core/webview/ClineProvider" +import { Terminal } from "../integrations/terminal/Terminal" +import { TerminalRegistry } from "../integrations/terminal/TerminalRegistry" import { openClineInNewTab } from "../activate/registerCommands" import { getCommands } from "../services/command/commands" import { getModels } from "../api/providers/fetchers/modelCache" @@ -477,6 +479,15 @@ export class API extends EventEmitter implements RooCodeAPI { await this.sidebarProvider.postStateToWebview() } + public setTerminalProfile(name: string | undefined): void { + const previousProfile = Terminal.getTerminalProfile() + Terminal.setTerminalProfile(name) + + if (Terminal.getTerminalProfile() !== previousProfile) { + TerminalRegistry.closeIdleTerminals() + } + } + // Provider Profile Management public getProfiles(): string[] { diff --git a/src/integrations/terminal/BaseTerminal.ts b/src/integrations/terminal/BaseTerminal.ts index ee26254934..7480f271d3 100644 --- a/src/integrations/terminal/BaseTerminal.ts +++ b/src/integrations/terminal/BaseTerminal.ts @@ -13,6 +13,7 @@ export abstract class BaseTerminal implements RooTerminal { public readonly provider: RooTerminalProvider public readonly id: number public readonly initialCwd: string + public readonly reuseKey: string public busy: boolean public running: boolean @@ -22,10 +23,11 @@ export abstract class BaseTerminal implements RooTerminal { public process?: RooTerminalProcess public completedProcesses: RooTerminalProcess[] = [] - constructor(provider: RooTerminalProvider, id: number, cwd: string) { + constructor(provider: RooTerminalProvider, id: number, cwd: string, reuseKey: string = provider) { this.provider = provider this.id = id this.initialCwd = cwd + this.reuseKey = reuseKey this.busy = false this.running = false this.streamClosed = false @@ -42,7 +44,7 @@ export abstract class BaseTerminal implements RooTerminal { /** * Sets the active stream for this terminal and notifies the process * @param stream The stream to set, or undefined to clean up - * @throws Error if process is undefined when a stream is provided + * If no process exists when a stream is provided, logs a warning and returns. */ public setActiveStream(stream: AsyncIterable | undefined, pid?: number): void { if (stream) { @@ -161,6 +163,7 @@ export abstract class BaseTerminal implements RooTerminal { private static terminalZshOhMy: boolean = false private static terminalZshP10k: boolean = false private static terminalZdotdir: boolean = false + private static terminalProfile: string | undefined = undefined private static execaShellPath: string | undefined = undefined /** @@ -296,6 +299,25 @@ export abstract class BaseTerminal implements RooTerminal { return BaseTerminal.terminalZdotdir } + /** + * Sets the name of the VS Code terminal profile to use for the integrated + * terminal. An empty/undefined value falls back to VS Code's default terminal + * behavior. + * @param profile The terminal profile name, or undefined for the default + */ + public static setTerminalProfile(profile: string | undefined): void { + const normalized = profile?.trim() + BaseTerminal.terminalProfile = normalized && normalized.length > 0 ? normalized : undefined + } + + /** + * Gets the name of the VS Code terminal profile to use for the integrated terminal. + * @returns The terminal profile name, or undefined when the default should be used + */ + public static getTerminalProfile(): string | undefined { + return BaseTerminal.terminalProfile + } + public static setExecaShellPath(shellPath: string | undefined): void { BaseTerminal.execaShellPath = shellPath } diff --git a/src/integrations/terminal/Terminal.ts b/src/integrations/terminal/Terminal.ts index 38ace9d4b1..49dc347c52 100644 --- a/src/integrations/terminal/Terminal.ts +++ b/src/integrations/terminal/Terminal.ts @@ -1,3 +1,6 @@ +import { existsSync } from "fs" +import * as path from "path" + import * as vscode from "vscode" import pWaitFor from "p-wait-for" @@ -12,14 +15,49 @@ export class Terminal extends BaseTerminal { public cmdCounter: number = 0 + public activeShellExecution?: vscode.TerminalShellExecution + constructor(id: number, terminal: vscode.Terminal | undefined, cwd: string) { - super("vscode", id, cwd) + super("vscode", id, cwd, Terminal.getReuseKey()) const env = Terminal.getEnv() const iconPath = new vscode.ThemeIcon("rocket") - this.terminal = terminal ?? vscode.window.createTerminal({ cwd, name: "Roo Code", iconPath, env }) - if (Terminal.getTerminalZdotdir()) { + if (terminal) { + this.terminal = terminal + } else { + const options: vscode.TerminalOptions = { cwd, name: "Zoo Code", iconPath, env } + + // When the user has chosen a VS Code terminal profile, resolve it to a + // shell path/args/env so the integrated terminal uses that shell. When + // unset, shellPath/shellArgs are left undefined so VS Code's default + // terminal behavior is preserved. + const profileShell = Terminal.getProfileShell() + + if (profileShell?.shellPath) { + options.shellPath = profileShell.shellPath + + if (profileShell.shellArgs) { + options.shellArgs = profileShell.shellArgs + } + + console.info( + `[Terminal] Creating terminal with profile "${Terminal.getTerminalProfile()}" -> ${profileShell.shellPath}`, + ) + + // Preserve profile-specific variables (e.g. locale/PATH), but keep + // Zoo Code's shell-integration controls authoritative. + if (profileShell.env) { + options.env = { ...profileShell.env, ...env } + } + } + + this.terminal = vscode.window.createTerminal(options) + } + + // Only register ZDOTDIR cleanup when we actually set it (i.e. no profile + // override is active — see getEnv() for the same guard). + if (Terminal.getTerminalZdotdir() && !Terminal.getTerminalProfile()) { ShellIntegrationManager.terminalTmpDirs.set(id, env.ZDOTDIR) } } @@ -57,7 +95,7 @@ export class Terminal extends BaseTerminal { process.once("completed", (output) => callbacks.onCompleted(output, process)) process.once("shell_execution_started", (pid) => callbacks.onShellExecutionStarted(pid, process)) process.once("shell_execution_complete", (details) => callbacks.onShellExecutionComplete(details, process)) - process.once("no_shell_integration", (msg) => callbacks.onNoShellIntegration?.(msg, process)) + process.once("no_shell_integration", (details) => callbacks.onNoShellIntegration?.(details, process)) const promise = new Promise((resolve, reject) => { // Set up event handlers @@ -67,28 +105,41 @@ export class Terminal extends BaseTerminal { reject(error) }) - // Wait for shell integration before executing the command - pWaitFor(() => this.terminal.shellIntegration !== undefined, { - timeout: Terminal.getShellIntegrationTimeout(), - }) - .then(() => { - // Clean up temporary directory if shell integration is available, zsh did its job: - ShellIntegrationManager.zshCleanupTmpDir(this.id) - - // Run the command in the terminal - process.run(command) + if (Terminal.isActiveShellCmdExe()) { + // Keep this defensive fallback for callers that invoke Terminal.runCommand() + // directly instead of routing through executeCommandInTerminal(). + // cmd.exe cannot emit OSC 633;A — skip the timeout entirely and go + // straight to the execa fallback (VS Code issue #164646). + ShellIntegrationManager.zshCleanupTmpDir(this.id) + process.emit("no_shell_integration", { + message: + "cmd.exe does not support shell integration (VS Code issue #164646). Command will run via fallback.", + commandSubmitted: false, }) - .catch(() => { - console.log(`[Terminal ${this.id}] Shell integration not available. Command execution aborted.`) - - // Clean up temporary directory if shell integration is not available - ShellIntegrationManager.zshCleanupTmpDir(this.id) - - process.emit( - "no_shell_integration", - `Shell integration initialization sequence '\\x1b]633;A' was not received within ${Terminal.getShellIntegrationTimeout() / 1000}s. Shell integration has been disabled for this terminal instance. Increase the timeout in the settings if necessary.`, - ) + } else { + // Wait for shell integration before executing the command + pWaitFor(() => this.terminal.shellIntegration !== undefined, { + timeout: Terminal.getShellIntegrationTimeout(), }) + .then(() => { + // Clean up temporary directory if shell integration is available, zsh did its job: + ShellIntegrationManager.zshCleanupTmpDir(this.id) + + // Run the command in the terminal + process.run(command) + }) + .catch(() => { + console.log(`[Terminal ${this.id}] Shell integration not available. Command execution aborted.`) + + // Clean up temporary directory if shell integration is not available + ShellIntegrationManager.zshCleanupTmpDir(this.id) + + process.emit("no_shell_integration", { + message: `Shell integration initialization sequence '\\x1b]633;A' was not received within ${Terminal.getShellIntegrationTimeout() / 1000}s. Shell integration has been disabled for this terminal instance. Increase the timeout in the settings if necessary.`, + commandSubmitted: false, + }) + }) + } }) return mergePromise(process, promise) @@ -184,11 +235,355 @@ export class Terminal extends BaseTerminal { env.PROMPT_EOL_MARK = "" } - // Handle ZDOTDIR for zsh if enabled - if (Terminal.getTerminalZdotdir()) { + // Handle ZDOTDIR for zsh if enabled. Skip when a profile override is + // active: VS Code's own shell integration injector also sets ZDOTDIR for + // zsh, and the two would fight each other (VS Code's ambient env wins per + // issue #96295). Let VS Code handle injection for the selected profile. + if (Terminal.getTerminalZdotdir() && !Terminal.getTerminalProfile()) { env.ZDOTDIR = ShellIntegrationManager.zshInitTmpDir(env) } return env } + + /** + * Returns the VS Code config section key (`windows`/`osx`/`linux`) used for + * platform-specific terminal profiles. + */ + public static getPlatformProfileKey(platform: NodeJS.Platform = process.platform): "windows" | "osx" | "linux" { + if (platform === "win32") { + return "windows" + } + + if (platform === "darwin") { + return "osx" + } + + return "linux" + } + + /** + * Resolves a profile path to an executable on disk. VS Code's built-in Unix + * profiles commonly use bare command names such as `bash`, so check PATH in + * addition to explicit filesystem paths. + */ + public static resolveProfilePath( + profilePath: unknown, + platform: NodeJS.Platform = process.platform, + env: NodeJS.ProcessEnv = process.env, + ): string | undefined { + const candidates = Array.isArray(profilePath) ? profilePath : [profilePath] + const pathValue = env.PATH ?? env.Path ?? env.path + const pathEntries = pathValue?.split(platform === "win32" ? ";" : ":") ?? [] + const platformJoin = platform === "win32" ? path.win32.join : path.posix.join + + for (const value of candidates) { + if (typeof value !== "string") { + continue + } + + const candidate = value.trim() + + if (!candidate) { + continue + } + + if (/[\\/]/.test(candidate)) { + if (existsSync(candidate)) { + return candidate + } + + continue + } + + const extensions = + platform === "win32" && path.extname(candidate) === "" + ? (env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";") + : [""] + + for (const entry of pathEntries) { + const directory = entry.replace(/^"(.*)"$/, "$1") + + for (const extension of extensions) { + const resolved = platformJoin(directory, `${candidate}${extension}`) + + if (existsSync(resolved)) { + return resolved + } + } + } + } + + return undefined + } + + /** + * Reads profiles from trusted settings scopes only. Workspace settings are + * intentionally excluded because opening a repository must not allow its + * `.vscode/settings.json` to select an executable for Zoo Code to launch. + */ + public static getConfiguredProfiles(platform: NodeJS.Platform = process.platform): Record { + const platformKey = Terminal.getPlatformProfileKey(platform) + const configuration = vscode.workspace.getConfiguration("terminal.integrated.profiles") + + // Some test doubles and older embedders expose get() without inspect(). + // Falling back to no profiles preserves the trusted-scope guarantee. + if (typeof configuration.inspect !== "function") { + return {} + } + + const inspected = configuration.inspect>(platformKey) + + return { + ...(inspected?.defaultValue ?? {}), + ...(inspected?.globalValue ?? {}), + } + } + + /** + * Reads the configured default profile from trusted settings scopes only. + */ + public static getConfiguredDefaultProfileName(platform: NodeJS.Platform = process.platform): string | undefined { + const platformKey = Terminal.getPlatformProfileKey(platform) + const configuration = vscode.workspace.getConfiguration("terminal.integrated") + + // Some test doubles and older embedders expose get() without inspect(). + // Falling back to undefined preserves the trusted-scope guarantee. + if (typeof configuration.inspect !== "function") { + return undefined + } + + const inspected = configuration.inspect(`defaultProfile.${platformKey}`) + + return inspected?.globalValue ?? inspected?.defaultValue + } + + /** + * Returns true when the resolved shell path is cmd.exe. cmd.exe cannot emit + * the OSC 633;C sequence (VS Code issue #164646, closed as not planned), so + * shell integration will never work for it — exclude it from the picker. + */ + public static isCmdExe(shellPath: string): boolean { + return /[/\\]cmd\.exe$/i.test(shellPath) + } + + public static isPowerShell(shellPath: string): boolean { + return /[/\\](?:pwsh|powershell)(?:\.exe)?$/i.test(shellPath) + } + + public static isFish(shellPath: string): boolean { + return /[/\\]fish(?:\.exe)?$/i.test(shellPath) + } + + /** + * Returns true when the active shell (profile override or VS Code default) is + * cmd.exe. Used to skip the shell integration timeout entirely for cmd.exe. + */ + public static isActiveShellCmdExe(platform: NodeJS.Platform = process.platform): boolean { + if (platform !== "win32") { + return false + } + + // Check explicit profile override first. + const profileShell = Terminal.getProfileShell(platform) + + if (profileShell?.shellPath) { + return Terminal.isCmdExe(profileShell.shellPath) + } + + // Fall back to VS Code's configured default profile for Windows. + const defaultProfileName = Terminal.getConfiguredDefaultProfileName(platform) + + if (!defaultProfileName) { + return false + } + + const profiles = Terminal.getConfiguredProfiles(platform) + const profile = profiles[defaultProfileName] as { path?: unknown } | null | undefined + + if (!profile) { + return false + } + + const resolved = Terminal.resolveProfilePath(profile.path, platform) + return resolved ? Terminal.isCmdExe(resolved) : false + } + + public static isActiveShellPowerShell(platform: NodeJS.Platform = process.platform): boolean { + if (platform !== "win32") { + return false + } + + const profileOverride = Terminal.getTerminalProfile() + + if (profileOverride) { + const profileShell = Terminal.getProfileShell(platform) + return profileShell?.shellPath ? Terminal.isPowerShell(profileShell.shellPath) : false + } + + const defaultProfileName = Terminal.getConfiguredDefaultProfileName(platform) + + if (!defaultProfileName) { + return false + } + + const profiles = Terminal.getConfiguredProfiles(platform) + const profile = profiles[defaultProfileName] as { path?: unknown; source?: unknown } | null | undefined + + if (!profile) { + return false + } + + const resolved = Terminal.resolveProfilePath(profile.path, platform) + + if (resolved) { + return Terminal.isPowerShell(resolved) + } + + return typeof profile.source === "string" && profile.source.toLowerCase().includes("powershell") + } + + public static isActiveShellFish(platform: NodeJS.Platform = process.platform): boolean { + const profileOverride = Terminal.getTerminalProfile() + + if (profileOverride) { + const profileShell = Terminal.getProfileShell(platform) + return profileShell?.shellPath ? Terminal.isFish(profileShell.shellPath) : false + } + + const defaultProfileName = Terminal.getConfiguredDefaultProfileName(platform) + + if (!defaultProfileName) { + return false + } + + const profiles = Terminal.getConfiguredProfiles(platform) + const profile = profiles[defaultProfileName] as { path?: unknown } | null | undefined + + if (!profile) { + return false + } + + const resolved = Terminal.resolveProfilePath(profile.path, platform) + return resolved ? Terminal.isFish(resolved) : false + } + + public static getAvailableProfileNames(platform: NodeJS.Platform = process.platform): string[] { + const names: string[] = [] + + for (const [name, entry] of Object.entries(Terminal.getConfiguredProfiles(platform))) { + if (!entry || typeof entry !== "object") { + continue + } + + const { path: profilePath } = entry as { path?: unknown } + const resolved = Terminal.resolveProfilePath(profilePath, platform) + + if (resolved && !Terminal.isCmdExe(resolved)) { + names.push(name) + } + } + + return names.sort() + } + + /** + * Returns a stable key that prevents terminals created with different VS Code + * profile overrides from being reused interchangeably. + */ + public static getReuseKey(): string { + return `vscode:${Terminal.getTerminalProfile() ?? "default"}` + } + + /** + * Resolves the configured VS Code terminal profile (see `terminalProfile` + * setting / {@link Terminal.getTerminalProfile}) into a shell path and args by + * reading VS Code's `terminal.integrated.profiles.` configuration. + * + * This reuses VS Code's terminal profile concept so users can pick, for + * example, a Git Bash profile instead of the default shell. Only profiles + * with a resolvable `path` are supported; source-only profiles (e.g. + * `{ source: "PowerShell" }`) cannot be mapped to a shell binary by an + * extension and return undefined. + * + * @returns The resolved shell path/args, or undefined when no profile is + * configured or the profile cannot be resolved (default behavior). + */ + public static getProfileShell( + platform: NodeJS.Platform = process.platform, + ): { shellPath: string; shellArgs?: string[]; env?: Record } | undefined { + const profileName = Terminal.getTerminalProfile() + + if (!profileName) { + return undefined + } + + const platformKey = Terminal.getPlatformProfileKey(platform) + + const profiles = Terminal.getConfiguredProfiles(platform) + + const profile = profiles?.[profileName] as + | { + path?: string | string[] + args?: string | string[] + source?: string + env?: Record + } + | null + | undefined + + if (!profile) { + console.warn(`[Terminal] Configured terminal profile "${profileName}" not found for ${platformKey}.`) + return undefined + } + + const pathValue = Terminal.resolveProfilePath(profile.path, platform) + + if (!pathValue) { + // Profiles defined only by `source` (e.g. "PowerShell") can't be mapped to + // a shell path here, so we fall back to the default terminal. + console.warn( + `[Terminal] Terminal profile "${profileName}" has no resolvable "path"; using default terminal.`, + ) + return undefined + } + + const shellArgs = Array.isArray(profile.args) + ? profile.args.filter((arg): arg is string => typeof arg === "string") + : typeof profile.args === "string" + ? [profile.args] + : undefined + + // VS Code profiles may declare their own `env` (e.g. to set a UTF-8 locale or + // a custom PATH). Preserve it so the inline terminal doesn't lose environment + // the user configured on the profile. A `null` value unsets that variable. + // Values come from user `settings.json`, so sanitize to string/null only. + let env: Record | undefined + + if (profile.env && typeof profile.env === "object") { + const sanitized: Record = {} + const blockedKeys = new Set([ + "ZDOTDIR", + "PROMPT_COMMAND", + "LD_PRELOAD", + "LD_LIBRARY_PATH", + "DYLD_INSERT_LIBRARIES", + "DYLD_LIBRARY_PATH", + "BASH_ENV", + "ENV", + ]) + + for (const [key, val] of Object.entries(profile.env)) { + if (!blockedKeys.has(key.toUpperCase()) && (typeof val === "string" || val === null)) { + sanitized[key] = val + } + } + + if (Object.keys(sanitized).length > 0) { + env = sanitized + } + } + + return { shellPath: pathValue, shellArgs, env } + } } diff --git a/src/integrations/terminal/TerminalProcess.ts b/src/integrations/terminal/TerminalProcess.ts index 465611c2f9..694c5ff340 100644 --- a/src/integrations/terminal/TerminalProcess.ts +++ b/src/integrations/terminal/TerminalProcess.ts @@ -1,5 +1,4 @@ import * as vscode from "vscode" -import { inspect } from "util" import type { ExitCodeDetails } from "./types" import { BaseTerminalProcess } from "./BaseTerminalProcess" @@ -62,10 +61,10 @@ export class TerminalProcess extends BaseTerminalProcess { "[TerminalProcess] Shell integration not available. Command sent without knowledge of response.", ) - this.emit( - "no_shell_integration", - "Command was submitted; output is not available, as shell integration is inactive.", - ) + this.emit("no_shell_integration", { + message: "Command was submitted; output is not available, as shell integration is inactive.", + commandSubmitted: true, + }) this.emit( "completed", @@ -83,10 +82,10 @@ export class TerminalProcess extends BaseTerminalProcess { this.removeAllListeners("stream_available") // Emit no_shell_integration event with descriptive message - this.emit( - "no_shell_integration", - `VSCE shell integration stream did not start within ${Terminal.getShellIntegrationTimeout() / 1000} seconds. Terminal problem?`, - ) + this.emit("no_shell_integration", { + message: `VSCE shell integration stream did not start within ${Terminal.getShellIntegrationTimeout() / 1000} seconds. Terminal problem?`, + commandSubmitted: true, + }) // Reject with descriptive error reject( @@ -108,19 +107,19 @@ export class TerminalProcess extends BaseTerminalProcess { this.once("shell_execution_complete", (details: ExitCodeDetails) => resolve(details)) }) - // Execute command - const defaultWindowsShellProfile = vscode.workspace - .getConfiguration("terminal.integrated.defaultProfile") - .get("windows") - - const isPowerShell = - process.platform === "win32" && - (defaultWindowsShellProfile === null || - (defaultWindowsShellProfile as string)?.toLowerCase().includes("powershell")) - - if (isPowerShell) { - let commandToExecute = command + // Execute command. + // Determine whether the active shell is PowerShell so we can apply the + // PS-specific counter/sleep workarounds. Prefer the Zoo Code profile + // override (if set) over the VS Code default profile. Fix for the wrong + // config API: must be getConfiguration("terminal.integrated").get( + // "defaultProfile.windows"), not the reversed form that always returns null. + const shellKind = { + isPowerShell: Terminal.isActiveShellPowerShell(), + isFish: Terminal.isActiveShellFish(), + } + let commandToExecute = command + if (shellKind.isPowerShell) { // Only add the PowerShell counter workaround if enabled if (Terminal.getPowershellCounter()) { commandToExecute += ` ; "(Roo/PS Workaround: ${this.terminal.cmdCounter++})" > $null` @@ -130,10 +129,22 @@ export class TerminalProcess extends BaseTerminalProcess { if (Terminal.getCommandDelay() > 0) { commandToExecute += ` ; start-sleep -milliseconds ${Terminal.getCommandDelay()}` } + } - terminal.shellIntegration.executeCommand(commandToExecute) - } else { - terminal.shellIntegration.executeCommand(command) + try { + const execution = terminal.shellIntegration.executeCommand( + this.prepareCommandForShellIntegration(commandToExecute, shellKind), + ) + + this.terminal.activeShellExecution = execution + + // VS Code only captures data written after read() is first called, so read + // the execution stream immediately instead of waiting for the global start + // event to deliver the same execution later. + this.terminal.setActiveStream(execution.read()) + } catch (error) { + this.terminal.activeShellExecution = undefined + throw error } this.isHot = true @@ -160,9 +171,6 @@ export class TerminalProcess extends BaseTerminalProcess { return } - let preOutput = "" - let commandOutputStarted = false - /* * Extract clean output from raw accumulated output. FYI: * ]633 is a custom sequence number used by VSCode shell integration: @@ -175,22 +183,14 @@ export class TerminalProcess extends BaseTerminalProcess { // Process stream data for await (let data of stream) { - // Check for command output start marker - if (!commandOutputStarted) { - preOutput += data - const match = this.matchAfterVsceStartMarkers(data) - - if (match !== undefined) { - commandOutputStarted = true - data = match - this.fullOutput = "" // Reset fullOutput when command actually starts - this.emit("line", "") // Trigger UI to proceed - } else { - continue - } + const match = this.fullOutput === "" ? this.matchAfterVsceStartMarkers(data) : undefined + + if (match !== undefined) { + data = match + this.emit("line", "") // Trigger UI to proceed } - // Command output started, accumulate data without filtering. + // Accumulate data without filtering. // notice to future programmers: do not add escape sequence // filtering here: fullOutput cannot change in length (see getUnretrievedOutput), // and chunks may not be complete so you cannot rely on detecting or removing escape sequences mid-stream. @@ -214,35 +214,12 @@ export class TerminalProcess extends BaseTerminalProcess { // Wait for shell execution to complete. await shellExecutionComplete + this.terminal.activeShellExecution = undefined this.isHot = false - if (commandOutputStarted) { - // Emit any remaining output before completing - this.emitRemainingBufferIfListening() - } else { - const errorMsg = - "VSCE output start escape sequence (]633;C or ]133;C) not received, but the stream has started. Upstream VSCE Bug?" - - const inspectPreOutput = inspect(preOutput, { colors: false, breakLength: Infinity }) - console.error(`[Terminal Process] ${errorMsg} preOutput: ${inspectPreOutput}`) - - // Emit no_shell_integration event - this.emit("no_shell_integration", errorMsg) - - // Emit completed event with error message - this.emit( - "completed", - "\n" + - `${inspectPreOutput}\n` + - "AI MODEL: You MUST notify the user with the information above so they can open a bug report.", - ) - - this.continue() - - // Return early since we can't process output without shell integration markers - return - } + // Emit any remaining output before completing. + this.emitRemainingBufferIfListening() // fullOutput begins after C marker so we only need to trim off D marker // (if D exists, see VSCode bug# 237208): @@ -261,6 +238,31 @@ export class TerminalProcess extends BaseTerminalProcess { this.emit("continue") } + /** + * VS Code reports each complete top-level statement in multiline input as a + * separate shell execution. Keep the submitted script in one execution so a + * leading assignment cannot complete and detach the tracked process before + * the remaining statements run. + */ + private prepareCommandForShellIntegration( + command: string, + shellKind: { isPowerShell: boolean; isFish: boolean }, + ): string { + if (!command.includes("\n")) { + return command + } + + if (shellKind.isPowerShell) { + return `. {\n${command}\n}` + } + + if (shellKind.isFish) { + return `begin\n${command}\nend` + } + + return `{\n${command}\n}` + } + public override continue() { this.emitRemainingBufferIfListening() this.isListening = false diff --git a/src/integrations/terminal/TerminalRegistry.ts b/src/integrations/terminal/TerminalRegistry.ts index 358793fc21..c21eddb03f 100644 --- a/src/integrations/terminal/TerminalRegistry.ts +++ b/src/integrations/terminal/TerminalRegistry.ts @@ -48,8 +48,6 @@ export class TerminalRegistry { try { const startDisposable = vscode.window.onDidStartTerminalShellExecution?.( async (e: vscode.TerminalShellExecutionStartEvent) => { - // Get a handle to the stream as early as possible: - const stream = e.execution.read() const terminal = this.getTerminalByVSCETerminal(e.terminal) console.info("[onDidStartTerminalShellExecution]", { @@ -57,7 +55,13 @@ export class TerminalRegistry { terminalId: terminal?.id, }) - if (terminal) { + if (terminal instanceof Terminal) { + if (terminal.activeShellExecution === e.execution) { + return + } + + // Get a handle to the stream as early as possible. + const stream = e.execution.read() terminal.setActiveStream(stream) terminal.busy = true // Mark terminal as busy when shell execution starts } else { @@ -94,6 +98,10 @@ export class TerminalRegistry { return } + if (terminal instanceof Terminal && terminal.activeShellExecution === e.execution) { + terminal.activeShellExecution = undefined + } + if (!terminal.running) { console.error( "[TerminalRegistry] Shell execution end event received, but process is not running for terminal:", @@ -155,13 +163,14 @@ export class TerminalRegistry { provider: RooTerminalProvider = "vscode", ): Promise { const terminals = this.getAllTerminals() + const reuseKey = provider === "vscode" ? Terminal.getReuseKey() : provider let terminal: RooTerminal | undefined // First priority: Find a terminal already assigned to this task with // matching directory. if (taskId) { terminal = terminals.find((t) => { - if (t.busy || t.taskId !== taskId || t.provider !== provider) { + if (t.busy || t.taskId !== taskId || t.provider !== provider || t.reuseKey !== reuseKey) { return false } @@ -178,7 +187,7 @@ export class TerminalRegistry { // Second priority: Find any available terminal with matching directory. if (!terminal) { terminal = terminals.find((t) => { - if (t.busy || t.provider !== provider) { + if (t.busy || t.provider !== provider || t.reuseKey !== reuseKey) { return false } @@ -276,6 +285,22 @@ export class TerminalRegistry { this.disposables = [] } + /** + * Disposes all idle (non-busy) VS Code terminals so they are not reused + * after a shell profile change. Busy terminals are left untouched. + */ + public static closeIdleTerminals(): void { + this.terminals = this.terminals.filter((t) => { + if (t.busy || !(t instanceof Terminal)) { + return true + } + + t.terminal.dispose() + ShellIntegrationManager.zshCleanupTmpDir(t.id) + return false + }) + } + /** * Releases all terminals associated with a task. * diff --git a/src/integrations/terminal/__tests__/TerminalProcess.spec.ts b/src/integrations/terminal/__tests__/TerminalProcess.spec.ts index 890d76d6e6..a1755f60dc 100644 --- a/src/integrations/terminal/__tests__/TerminalProcess.spec.ts +++ b/src/integrations/terminal/__tests__/TerminalProcess.spec.ts @@ -50,6 +50,7 @@ describe("TerminalProcess", () => { // Create a process for testing terminalProcess = new TestTerminalProcess(mockTerminalInfo) + mockTerminalInfo.process = terminalProcess TerminalRegistry["terminals"].push(mockTerminalInfo) @@ -58,6 +59,35 @@ describe("TerminalProcess", () => { }) describe("run", () => { + it("emits no_shell_integration with commandSubmitted=false when shell integration startup times out", async () => { + vi.useFakeTimers() + const previousTimeout = Terminal.getShellIntegrationTimeout() + Terminal.setShellIntegrationTimeout(10) + + try { + mockTerminal.shellIntegration = undefined + let commandSubmitted: boolean | undefined + const runPromise = mockTerminalInfo.runCommand("test command", { + onLine: vi.fn(), + onCompleted: vi.fn(), + onShellExecutionStarted: vi.fn(), + onShellExecutionComplete: vi.fn(), + onNoShellIntegration: (details) => { + commandSubmitted = details.commandSubmitted + }, + }) + + await vi.advanceTimersByTimeAsync(20) + await runPromise + + expect(commandSubmitted).toBe(false) + expect(mockTerminal.sendText).not.toHaveBeenCalled() + } finally { + Terminal.setShellIntegrationTimeout(previousTimeout) + vi.useRealTimers() + } + }) + it("handles shell integration commands correctly", async () => { let lines: string[] = [] @@ -91,6 +121,57 @@ describe("TerminalProcess", () => { expect(terminalProcess.isHot).toBe(false) }) + it("wraps multiline POSIX scripts so VS Code tracks them as one shell execution", async () => { + const command = 'PR_SHA=abc123\nfor f in one two; do\n echo "$f @ $PR_SHA"\ndone' + + mockStream = (async function* () { + yield "\x1b]633;C\x07" + yield "one @ abc123\ntwo @ abc123\n" + yield "\x1b]633;D\x07" + terminalProcess.emit("shell_execution_complete", { exitCode: 0 }) + })() + + mockTerminal.shellIntegration.executeCommand.mockReturnValue({ + read: vi.fn().mockReturnValue(mockStream), + }) + + const runPromise = terminalProcess.run(command) + terminalProcess.emit("stream_available", mockStream) + await runPromise + + expect(mockTerminal.shellIntegration.executeCommand).toHaveBeenCalledWith(`{\n${command}\n}`) + }) + + it.each([ + ["PowerShell", true, false, ". {\necho one\necho two\n}"], + ["fish", false, true, "begin\necho one\necho two\nend"], + ])("uses the %s multiline wrapper", async (_profile, isPowerShell, isFish, expectedCommand) => { + const psSpy = vi.spyOn(Terminal, "isActiveShellPowerShell").mockReturnValue(isPowerShell) + const fishSpy = vi.spyOn(Terminal, "isActiveShellFish").mockReturnValue(isFish) + + try { + mockStream = (async function* () { + yield "\x1b]633;C\x07" + yield "one\ntwo\n" + yield "\x1b]633;D\x07" + terminalProcess.emit("shell_execution_complete", { exitCode: 0 }) + })() + + mockTerminal.shellIntegration.executeCommand.mockReturnValue({ + read: vi.fn().mockReturnValue(mockStream), + }) + + const runPromise = terminalProcess.run("echo one\necho two") + terminalProcess.emit("stream_available", mockStream) + await runPromise + + expect(mockTerminal.shellIntegration.executeCommand).toHaveBeenCalledWith(expectedCommand) + } finally { + psSpy.mockRestore() + fishSpy.mockRestore() + } + }) + it("handles terminals without shell integration", async () => { // Temporarily suppress the expected console.warn for this test const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}) @@ -114,11 +195,15 @@ describe("TerminalProcess", () => { // Create new process with the no-shell terminal const noShellProcess = new TerminalProcess(noShellTerminalInfo) + let commandSubmitted: boolean | undefined // Set up event listeners to verify events are emitted const eventPromises = Promise.all([ new Promise((resolve) => - noShellProcess.once("no_shell_integration", (_message: string) => resolve()), + noShellProcess.once("no_shell_integration", (details) => { + commandSubmitted = details.commandSubmitted + resolve() + }), ), new Promise((resolve) => noShellProcess.once("completed", (_output?: string) => resolve())), new Promise((resolve) => noShellProcess.once("continue", resolve)), @@ -130,11 +215,80 @@ describe("TerminalProcess", () => { // Verify sendText was called with the command expect(noShellTerminal.sendText).toHaveBeenCalledWith("test command", true) + expect(commandSubmitted).toBe(true) // Restore the original console.warn consoleWarnSpy.mockRestore() }) + it("completes without warning when the execution stream is empty after submission", async () => { + const noShellIntegrationSpy = vi.fn() + let completedOutput: string | undefined + + const eventPromises = Promise.all([ + new Promise((resolve) => + terminalProcess.once("completed", (output?: string) => { + completedOutput = output + resolve() + }), + ), + new Promise((resolve) => terminalProcess.once("continue", resolve)), + ]) + + async function* emptyStream(): AsyncGenerator { + terminalProcess.emit("shell_execution_complete", { exitCode: 0 }) + return + yield "" // satisfy require-yield; never reached + } + mockStream = emptyStream() + + mockExecution = { read: vi.fn().mockReturnValue(mockStream) } + mockTerminal.shellIntegration.executeCommand.mockReturnValue(mockExecution) + + terminalProcess.once("no_shell_integration", noShellIntegrationSpy) + + const runPromise = terminalProcess.run("test command") + await runPromise + await eventPromises + + expect(mockExecution.read).toHaveBeenCalledTimes(1) + expect(completedOutput).toBe("") + expect(noShellIntegrationSpy).not.toHaveBeenCalled() + }) + + it("captures execution output even when VS Code does not include start markers", async () => { + const noShellIntegrationSpy = vi.fn() + let completedOutput: string | undefined + + const eventPromises = Promise.all([ + new Promise((resolve) => + terminalProcess.once("completed", (output?: string) => { + completedOutput = output + resolve() + }), + ), + new Promise((resolve) => terminalProcess.once("continue", resolve)), + ]) + + mockStream = (async function* () { + yield "some output without marker\n" + terminalProcess.emit("shell_execution_complete", { exitCode: 0 }) + })() + + mockExecution = { read: vi.fn().mockReturnValue(mockStream) } + mockTerminal.shellIntegration.executeCommand.mockReturnValue(mockExecution) + + terminalProcess.once("no_shell_integration", noShellIntegrationSpy) + + const runPromise = terminalProcess.run("test command") + await runPromise + await eventPromises + + expect(mockExecution.read).toHaveBeenCalledTimes(1) + expect(completedOutput).toBe("some output without marker\n") + expect(noShellIntegrationSpy).not.toHaveBeenCalled() + }) + it("sets hot state for compiling commands", async () => { let lines: string[] = [] diff --git a/src/integrations/terminal/__tests__/TerminalProcessExec.bash.spec.ts b/src/integrations/terminal/__tests__/TerminalProcessExec.bash.spec.ts index 720fb427a5..47245ac6e6 100644 --- a/src/integrations/terminal/__tests__/TerminalProcessExec.bash.spec.ts +++ b/src/integrations/terminal/__tests__/TerminalProcessExec.bash.spec.ts @@ -37,6 +37,7 @@ vi.mock("vscode", () => { eventHandlers.closeTerminal = handler return { dispose: vi.fn() } }), + onDidChangeTerminalShellIntegration: vi.fn().mockReturnValue({ dispose: vi.fn() }), }, ThemeIcon: class ThemeIcon { constructor(id: string) { @@ -92,7 +93,7 @@ function createRealCommandStream(command: string): { stream: AsyncIterable { eventHandlers.closeTerminal = handler return { dispose: vi.fn() } }), + onDidChangeTerminalShellIntegration: vi.fn().mockReturnValue({ dispose: vi.fn() }), }, ThemeIcon: class ThemeIcon { constructor(id: string) { diff --git a/src/integrations/terminal/__tests__/TerminalProcessExec.pwsh.spec.ts b/src/integrations/terminal/__tests__/TerminalProcessExec.pwsh.spec.ts index 2d03843057..cf24a1d506 100644 --- a/src/integrations/terminal/__tests__/TerminalProcessExec.pwsh.spec.ts +++ b/src/integrations/terminal/__tests__/TerminalProcessExec.pwsh.spec.ts @@ -42,6 +42,7 @@ vi.mock("vscode", () => { eventHandlers.closeTerminal = handler return { dispose: vi.fn() } }), + onDidChangeTerminalShellIntegration: vi.fn().mockReturnValue({ dispose: vi.fn() }), }, ThemeIcon: class ThemeIcon { constructor(id: string) { diff --git a/src/integrations/terminal/__tests__/TerminalProfile.spec.ts b/src/integrations/terminal/__tests__/TerminalProfile.spec.ts new file mode 100644 index 0000000000..68a5876a35 --- /dev/null +++ b/src/integrations/terminal/__tests__/TerminalProfile.spec.ts @@ -0,0 +1,694 @@ +// npx vitest run src/integrations/terminal/__tests__/TerminalProfile.spec.ts + +import { existsSync } from "fs" + +import * as vscode from "vscode" + +import { Terminal } from "../Terminal" +import { TerminalRegistry } from "../TerminalRegistry" +import { ShellIntegrationManager } from "../ShellIntegrationManager" + +vi.mock("execa", () => ({ + execa: vi.fn(), +})) + +vi.mock("fs", () => ({ + existsSync: vi.fn(() => false), +})) + +const mockedExistsSync = existsSync as unknown as ReturnType + +describe("Terminal VS Code terminal profile (#277)", () => { + // VS Code's getConfiguration/createTerminal are overloaded, so the precise + // spy MockInstance type isn't worth fighting in a test — `any` keeps it simple. + let getConfigurationSpy: any + let createTerminalSpy: any + + const mockTerminal = () => + ({ + exitStatus: undefined, + name: "Roo Code", + processId: Promise.resolve(123), + creationOptions: {}, + state: { isInteractedWith: true }, + dispose: vi.fn(), + hide: vi.fn(), + show: vi.fn(), + sendText: vi.fn(), + shellIntegration: { executeCommand: vi.fn() }, + }) as any + + // Helper to stub `terminal.integrated.profiles.` config reads. + const stubProfiles = ( + profilesByPlatform: Record, + workspaceProfilesByPlatform: Record = {}, + ) => { + getConfigurationSpy = vi.spyOn(vscode.workspace, "getConfiguration").mockImplementation((section?: string) => { + if (section === "terminal.integrated.profiles") { + return { + inspect: (platformKey: string) => ({ + defaultValue: profilesByPlatform[platformKey], + workspaceValue: workspaceProfilesByPlatform[platformKey], + }), + } as any + } + + return { + get: (_key: string, defaultValue?: unknown) => defaultValue, + inspect: () => undefined, + } as any + }) + } + + beforeEach(() => { + createTerminalSpy = vi.spyOn(vscode.window, "createTerminal").mockImplementation(() => mockTerminal()) + // Default: explicit profile paths exist unless a test says otherwise. + mockedExistsSync.mockReset() + mockedExistsSync.mockReturnValue(true) + // Reset to default (unset) before each test. + Terminal.setTerminalProfile(undefined) + }) + + afterEach(() => { + Terminal.setTerminalProfile(undefined) + vi.restoreAllMocks() + }) + + describe("getTerminalProfile / setTerminalProfile", () => { + it("defaults to undefined", () => { + expect(Terminal.getTerminalProfile()).toBeUndefined() + }) + + it("stores a profile name", () => { + Terminal.setTerminalProfile("Git Bash") + expect(Terminal.getTerminalProfile()).toBe("Git Bash") + }) + + it("treats empty/whitespace strings as unset (default behavior)", () => { + Terminal.setTerminalProfile("Git Bash") + Terminal.setTerminalProfile("") + expect(Terminal.getTerminalProfile()).toBeUndefined() + + Terminal.setTerminalProfile(" ") + expect(Terminal.getTerminalProfile()).toBeUndefined() + }) + }) + + describe("getConfiguredProfiles / getAvailableProfileNames", () => { + it("merges default and global profiles while ignoring workspace profiles", () => { + getConfigurationSpy = vi + .spyOn(vscode.workspace, "getConfiguration") + .mockImplementation((section?: string) => { + if (section === "terminal.integrated.profiles") { + return { + inspect: () => ({ + defaultValue: { bash: { path: "/bin/bash" } }, + globalValue: { zsh: { path: "/bin/zsh" } }, + workspaceValue: { malicious: { path: "/workspace/malicious-shell" } }, + }), + } as any + } + + return { get: (_key: string, defaultValue?: unknown) => defaultValue } as any + }) + + expect(Terminal.getConfiguredProfiles("linux")).toEqual({ + bash: { path: "/bin/bash" }, + zsh: { path: "/bin/zsh" }, + }) + }) + + it("returns sorted names for profiles with resolvable paths only", () => { + stubProfiles({ + linux: { + zsh: { path: "/bin/zsh" }, + PowerShell: { source: "PowerShell" }, + disabled: null, + bash: { path: "/bin/bash" }, + missing: { path: "/missing/bash" }, + }, + }) + mockedExistsSync.mockImplementation((profilePath: string) => profilePath !== "/missing/bash") + + expect(Terminal.getAvailableProfileNames("linux")).toEqual(["bash", "zsh"]) + }) + + it("excludes cmd.exe profiles on Windows (shell integration unsupported)", () => { + stubProfiles({ + windows: { + "Command Prompt": { path: "C:\\Windows\\System32\\cmd.exe" }, + PowerShell: { path: "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe" }, + }, + }) + + expect(Terminal.getAvailableProfileNames("win32")).toEqual(["PowerShell"]) + }) + + describe("isCmdExe", () => { + it.each([ + ["C:\\Windows\\System32\\cmd.exe", true], + ["C:\\WINDOWS\\SYSTEM32\\CMD.EXE", true], + ["/mnt/c/Windows/System32/cmd.exe", true], + ["/bin/bash", false], + ["pwsh.exe", false], + ["cmd", false], + ])("isCmdExe(%s) === %s", (input, expected) => { + expect(Terminal.isCmdExe(input)).toBe(expected) + }) + }) + + describe("isPowerShell", () => { + it.each([ + ["C:\\Program Files\\PowerShell\\pwsh.exe", true], + ["C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe", true], + ["/usr/bin/pwsh", true], + ["C:\\Tools\\PowerShell Wrapper\\bash.exe", false], + ["/bin/bash", false], + ])("isPowerShell(%s) === %s", (input, expected) => { + expect(Terminal.isPowerShell(input)).toBe(expected) + }) + }) + + describe("isActiveShellCmdExe", () => { + it("returns false on non-Windows platforms", () => { + expect(Terminal.isActiveShellCmdExe("linux")).toBe(false) + expect(Terminal.isActiveShellCmdExe("darwin")).toBe(false) + }) + + it("returns true when profile override resolves to cmd.exe", () => { + stubProfiles({ windows: { "Command Prompt": { path: "C:\\Windows\\System32\\cmd.exe" } } }) + Terminal.setTerminalProfile("Command Prompt") + expect(Terminal.isActiveShellCmdExe("win32")).toBe(true) + }) + + it("returns false when profile override resolves to a non-cmd shell", () => { + stubProfiles({ windows: { PowerShell: { path: "C:\\Program Files\\PowerShell\\pwsh.exe" } } }) + Terminal.setTerminalProfile("PowerShell") + expect(Terminal.isActiveShellCmdExe("win32")).toBe(false) + }) + + it("returns true when no override and default profile is cmd.exe", () => { + Terminal.setTerminalProfile(undefined) + stubProfiles({ windows: { "Command Prompt": { path: "C:\\Windows\\System32\\cmd.exe" } } }) + getConfigurationSpy = vi + .spyOn(vscode.workspace, "getConfiguration") + .mockImplementation((section?: string) => { + if (section === "terminal.integrated.profiles") { + return { + inspect: (_key: string) => ({ + defaultValue: { "Command Prompt": { path: "C:\\Windows\\System32\\cmd.exe" } }, + globalValue: undefined, + }), + } as any + } + if (section === "terminal.integrated") { + return { + inspect: (key: string) => + key === "defaultProfile.windows" ? { defaultValue: "Command Prompt" } : undefined, + } as any + } + return { get: (_key: string, defaultValue?: unknown) => defaultValue } as any + }) + expect(Terminal.isActiveShellCmdExe("win32")).toBe(true) + }) + + it("returns false when no override and default profile is PowerShell", () => { + Terminal.setTerminalProfile(undefined) + getConfigurationSpy = vi + .spyOn(vscode.workspace, "getConfiguration") + .mockImplementation((section?: string) => { + if (section === "terminal.integrated.profiles") { + return { + inspect: (_key: string) => ({ + defaultValue: { PowerShell: { path: "C:\\Program Files\\PowerShell\\pwsh.exe" } }, + globalValue: undefined, + }), + } as any + } + if (section === "terminal.integrated") { + return { + inspect: (key: string) => + key === "defaultProfile.windows" ? { defaultValue: "PowerShell" } : undefined, + } as any + } + return { get: (_key: string, defaultValue?: unknown) => defaultValue } as any + }) + expect(Terminal.isActiveShellCmdExe("win32")).toBe(false) + }) + + it("returns false when no override and no default profile configured", () => { + Terminal.setTerminalProfile(undefined) + stubProfiles({}) + expect(Terminal.isActiveShellCmdExe("win32")).toBe(false) + }) + + it("ignores a workspace default-profile override", () => { + Terminal.setTerminalProfile(undefined) + getConfigurationSpy = vi + .spyOn(vscode.workspace, "getConfiguration") + .mockImplementation((section?: string) => { + if (section === "terminal.integrated.profiles") { + return { + inspect: () => ({ + defaultValue: { + PowerShell: { path: "C:\\Program Files\\PowerShell\\pwsh.exe" }, + "Command Prompt": { path: "C:\\Windows\\System32\\cmd.exe" }, + }, + }), + } as any + } + if (section === "terminal.integrated") { + return { + inspect: () => ({ + defaultValue: "PowerShell", + workspaceValue: "Command Prompt", + }), + } as any + } + return { get: (_key: string, defaultValue?: unknown) => defaultValue } as any + }) + + expect(Terminal.isActiveShellCmdExe("win32")).toBe(false) + }) + + it("returns false when the configured default profile entry is missing", () => { + Terminal.setTerminalProfile(undefined) + getConfigurationSpy = vi + .spyOn(vscode.workspace, "getConfiguration") + .mockImplementation((section?: string) => { + if (section === "terminal.integrated.profiles") { + return { inspect: () => ({ defaultValue: {} }) } as any + } + if (section === "terminal.integrated") { + return { inspect: () => ({ defaultValue: "Deleted Profile" }) } as any + } + return { get: (_key: string, defaultValue?: unknown) => defaultValue } as any + }) + + expect(Terminal.isActiveShellCmdExe("win32")).toBe(false) + }) + }) + + describe("isActiveShellPowerShell", () => { + it("returns false on non-Windows platforms", () => { + expect(Terminal.isActiveShellPowerShell("linux")).toBe(false) + expect(Terminal.isActiveShellPowerShell("darwin")).toBe(false) + }) + + it("returns true when a custom-named profile resolves to pwsh.exe", () => { + stubProfiles({ windows: { "My Terminal": { path: "C:\\Program Files\\PowerShell\\pwsh.exe" } } }) + + Terminal.setTerminalProfile("My Terminal") + + expect(Terminal.isActiveShellPowerShell("win32")).toBe(true) + }) + + it("returns false when a PowerShell-named profile resolves to a non-PowerShell shell", () => { + stubProfiles({ windows: { "PowerShell Wrapper": { path: "C:\\Program Files\\Git\\bin\\bash.exe" } } }) + + Terminal.setTerminalProfile("PowerShell Wrapper") + + expect(Terminal.isActiveShellPowerShell("win32")).toBe(false) + }) + + it("returns true when no override and the default profile resolves to powershell.exe", () => { + Terminal.setTerminalProfile(undefined) + getConfigurationSpy = vi + .spyOn(vscode.workspace, "getConfiguration") + .mockImplementation((section?: string) => { + if (section === "terminal.integrated.profiles") { + return { + inspect: (_key: string) => ({ + defaultValue: { + "Custom PS": { + path: "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe", + }, + }, + }), + } as any + } + if (section === "terminal.integrated") { + return { + inspect: (key: string) => + key === "defaultProfile.windows" ? { defaultValue: "Custom PS" } : undefined, + } as any + } + return { get: (_key: string, defaultValue?: unknown) => defaultValue } as any + }) + + expect(Terminal.isActiveShellPowerShell("win32")).toBe(true) + }) + + it("recognizes source-only PowerShell default profiles", () => { + Terminal.setTerminalProfile(undefined) + getConfigurationSpy = vi + .spyOn(vscode.workspace, "getConfiguration") + .mockImplementation((section?: string) => { + if (section === "terminal.integrated.profiles") { + return { + inspect: (_key: string) => ({ + defaultValue: { PowerShell: { source: "PowerShell" } }, + }), + } as any + } + if (section === "terminal.integrated") { + return { + inspect: (key: string) => + key === "defaultProfile.windows" ? { defaultValue: "PowerShell" } : undefined, + } as any + } + return { get: (_key: string, defaultValue?: unknown) => defaultValue } as any + }) + + expect(Terminal.isActiveShellPowerShell("win32")).toBe(true) + }) + }) + }) + + describe("getProfileShell", () => { + it("returns undefined when no profile is configured (default behavior preserved)", () => { + stubProfiles({}) + expect(Terminal.getProfileShell("win32")).toBeUndefined() + }) + + it("resolves a Windows Git Bash profile to its shell path and args", () => { + stubProfiles({ + windows: { + "Git Bash": { + path: "C:\\Program Files\\Git\\bin\\bash.exe", + args: ["--login", "-i"], + }, + }, + }) + + Terminal.setTerminalProfile("Git Bash") + + expect(Terminal.getProfileShell("win32")).toEqual({ + shellPath: "C:\\Program Files\\Git\\bin\\bash.exe", + shellArgs: ["--login", "-i"], + }) + }) + + it("preserves the profile's env and sanitizes non-string/null values", () => { + stubProfiles({ + linux: { + "Custom Bash": { + path: "/bin/bash", + env: { + LANG: "en_US.UTF-8", + UNSET_ME: null, + BAD: 123, + BASH_ENV: "/tmp/bash-init", + ENV: "/tmp/sh-init", + PROMPT_COMMAND: "echo broken", + ZDOTDIR: "/tmp/profile", + LD_PRELOAD: "/tmp/inject.so", + LD_LIBRARY_PATH: "/tmp/lib", + DYLD_INSERT_LIBRARIES: "/tmp/inject.dylib", + DYLD_LIBRARY_PATH: "/tmp/dylib", + }, + }, + }, + }) + + Terminal.setTerminalProfile("Custom Bash") + + expect(Terminal.getProfileShell("linux")).toEqual({ + shellPath: "/bin/bash", + shellArgs: undefined, + // `null` is preserved (unsets the var); unsafe and non-string values are dropped. + env: { LANG: "en_US.UTF-8", UNSET_ME: null }, + }) + }) + + it("picks the first existing path candidate when path is an array", () => { + stubProfiles({ + windows: { + "Git Bash": { + path: ["C:\\missing\\bash.exe", "C:\\Program Files\\Git\\bin\\bash.exe"], + }, + }, + }) + // Only the second candidate exists on disk; VS Code would pick it. + mockedExistsSync.mockImplementation((p: string) => p === "C:\\Program Files\\Git\\bin\\bash.exe") + + Terminal.setTerminalProfile("Git Bash") + + expect(Terminal.getProfileShell("win32")).toEqual({ + shellPath: "C:\\Program Files\\Git\\bin\\bash.exe", + shellArgs: undefined, + }) + }) + + it("falls back to default when none of the path candidates exist", () => { + stubProfiles({ + windows: { + "Git Bash": { + path: ["C:\\missing\\bash.exe", "C:\\also-missing\\bash.exe"], + }, + }, + }) + mockedExistsSync.mockReturnValue(false) + + Terminal.setTerminalProfile("Git Bash") + + expect(Terminal.getProfileShell("win32")).toBeUndefined() + }) + + it("wraps a string args value into an array", () => { + stubProfiles({ + linux: { + bash: { path: "/bin/bash", args: "-l" }, + }, + }) + + Terminal.setTerminalProfile("bash") + + expect(Terminal.getProfileShell("linux")).toEqual({ + shellPath: "/bin/bash", + shellArgs: ["-l"], + }) + }) + + it("drops non-string args array entries", () => { + stubProfiles({ + linux: { + bash: { path: "/bin/bash", args: ["-l", 42, null] }, + }, + }) + + Terminal.setTerminalProfile("bash") + + expect(Terminal.getProfileShell("linux")).toEqual({ + shellPath: "/bin/bash", + shellArgs: ["-l"], + }) + }) + + it("reads the osx profile section on darwin", () => { + stubProfiles({ + osx: { zsh: { path: "/bin/zsh" } }, + }) + + Terminal.setTerminalProfile("zsh") + + expect(Terminal.getProfileShell("darwin")).toEqual({ + shellPath: "/bin/zsh", + shellArgs: undefined, + }) + }) + + it("falls back to default when the configured profile is not found", () => { + stubProfiles({ windows: { PowerShell: { path: "pwsh.exe" } } }) + + Terminal.setTerminalProfile("Nonexistent") + + expect(Terminal.getProfileShell("win32")).toBeUndefined() + }) + + it("falls back to default when the profile has no resolvable path (source-only profile)", () => { + stubProfiles({ windows: { PowerShell: { source: "PowerShell" } } }) + + Terminal.setTerminalProfile("PowerShell") + + expect(Terminal.getProfileShell("win32")).toBeUndefined() + }) + + it("resolves profiles defined only in user/global settings", () => { + getConfigurationSpy = vi + .spyOn(vscode.workspace, "getConfiguration") + .mockImplementation((section?: string) => { + if (section === "terminal.integrated.profiles") { + return { + inspect: () => ({ + defaultValue: undefined, + globalValue: { "User Bash": { path: "/usr/bin/bash" } }, + workspaceValue: { "User Bash": { path: "/workspace/bash" } }, + }), + } as any + } + + return { + get: (_key: string, defaultValue?: unknown) => defaultValue, + inspect: () => undefined, + } as any + }) + + Terminal.setTerminalProfile("User Bash") + + expect(Terminal.getProfileShell("linux")).toEqual({ + shellPath: "/usr/bin/bash", + shellArgs: undefined, + }) + }) + }) + + describe("resolveProfilePath", () => { + it("resolves a bare executable name through PATH", () => { + mockedExistsSync.mockImplementation((p: string) => p === "/usr/local/bin/fish") + + expect(Terminal.resolveProfilePath("fish", "linux", { PATH: "/usr/bin:/usr/local/bin" })).toBe( + "/usr/local/bin/fish", + ) + }) + + it("returns undefined when an executable cannot be found", () => { + mockedExistsSync.mockReturnValue(false) + + expect(Terminal.resolveProfilePath("/missing/bash", "linux", { PATH: "/usr/bin" })).toBeUndefined() + }) + + it("ignores disabled or missing profile paths", () => { + expect(Terminal.resolveProfilePath(null, "linux", { PATH: "/usr/bin" })).toBeUndefined() + expect(Terminal.resolveProfilePath(undefined, "linux", { PATH: "/usr/bin" })).toBeUndefined() + }) + + it("resolves a bare Windows executable name through PATH and PATHEXT", () => { + mockedExistsSync.mockImplementation((p: string) => p === "C:\\Tools\\pwsh.EXE") + + expect( + Terminal.resolveProfilePath("pwsh", "win32", { + PATH: "C:\\Windows\\System32;C:\\Tools", + PATHEXT: ".COM;.EXE", + }), + ).toBe("C:\\Tools\\pwsh.EXE") + }) + + it("resolves a bare Windows executable name through Path when PATH is absent", () => { + mockedExistsSync.mockImplementation((p: string) => p === "C:\\Tools\\pwsh.EXE") + + expect( + Terminal.resolveProfilePath("pwsh", "win32", { + Path: "C:\\Windows\\System32;C:\\Tools", + PATHEXT: ".COM;.EXE", + }), + ).toBe("C:\\Tools\\pwsh.EXE") + }) + }) + + describe("createTerminal integration", () => { + afterEach(() => { + TerminalRegistry["terminals"] = [] + }) + + it("does NOT pass shellPath/shellArgs when no profile is configured", () => { + stubProfiles({}) + TerminalRegistry.createTerminal("/test/path", "vscode") + + const options = createTerminalSpy.mock.calls[0][0] as vscode.TerminalOptions + expect(options.shellPath).toBeUndefined() + expect(options.shellArgs).toBeUndefined() + }) + + it("passes the resolved shellPath/shellArgs when a profile is configured", () => { + stubProfiles({ + [Terminal.getPlatformProfileKey(process.platform)]: { + "Git Bash": { path: "/usr/bin/bash", args: ["-i"] }, + }, + }) + + Terminal.setTerminalProfile("Git Bash") + TerminalRegistry.createTerminal("/test/path", "vscode") + + const options = createTerminalSpy.mock.calls[0][0] as vscode.TerminalOptions + expect(options.shellPath).toBe("/usr/bin/bash") + expect(options.shellArgs).toEqual(["-i"]) + }) + + it("falls back to VS Code defaults when a configured profile disappears", () => { + stubProfiles({ + [Terminal.getPlatformProfileKey(process.platform)]: { + "Git Bash": { path: "/missing/bash" }, + }, + }) + mockedExistsSync.mockReturnValue(false) + + Terminal.setTerminalProfile("Git Bash") + TerminalRegistry.createTerminal("/test/path", "vscode") + + const options = createTerminalSpy.mock.calls[0][0] as vscode.TerminalOptions + expect(options.shellPath).toBeUndefined() + expect(options.shellArgs).toBeUndefined() + }) + + it("merges safe profile env while preserving Zoo Code shell-integration vars", () => { + stubProfiles({ + [Terminal.getPlatformProfileKey(process.platform)]: { + "Custom Bash": { + path: "/usr/bin/bash", + env: { LANG: "en_US.UTF-8", PAGER: "less", ZDOTDIR: "/tmp/profile" }, + }, + }, + }) + + Terminal.setTerminalProfile("Custom Bash") + TerminalRegistry.createTerminal("/test/path", "vscode") + + const options = createTerminalSpy.mock.calls[0][0] as vscode.TerminalOptions + expect(options.env).toMatchObject({ + LANG: "en_US.UTF-8", + PAGER: process.platform === "win32" ? "" : "cat", + ROO_ACTIVE: "true", + VTE_VERSION: "0", + }) + expect(options.env?.ZDOTDIR).toBeUndefined() + }) + }) + + describe("ZDOTDIR injection guard", () => { + let zshInitTmpDirSpy: any + + beforeEach(() => { + zshInitTmpDirSpy = vi + .spyOn(ShellIntegrationManager, "zshInitTmpDir") + .mockReturnValue("/tmp/roo-zdotdir-test") + Terminal.setTerminalZdotdir(true) + }) + + afterEach(() => { + Terminal.setTerminalZdotdir(false) + Terminal.setTerminalProfile(undefined) + TerminalRegistry["terminals"] = [] + vi.restoreAllMocks() + }) + + it("sets ZDOTDIR when zdotdir is enabled and no profile is configured", () => { + stubProfiles({}) + const env = Terminal.getEnv() + expect(zshInitTmpDirSpy).toHaveBeenCalledTimes(1) + expect(env.ZDOTDIR).toBe("/tmp/roo-zdotdir-test") + }) + + it("skips ZDOTDIR when zdotdir is enabled but a profile is configured", () => { + stubProfiles({ + [Terminal.getPlatformProfileKey(process.platform)]: { + zsh: { path: "/bin/zsh" }, + }, + }) + Terminal.setTerminalProfile("zsh") + const env = Terminal.getEnv() + expect(zshInitTmpDirSpy).not.toHaveBeenCalled() + expect(env.ZDOTDIR).toBeUndefined() + }) + }) +}) diff --git a/src/integrations/terminal/__tests__/TerminalRegistry.spec.ts b/src/integrations/terminal/__tests__/TerminalRegistry.spec.ts index 5039b2a326..586c3abd57 100644 --- a/src/integrations/terminal/__tests__/TerminalRegistry.spec.ts +++ b/src/integrations/terminal/__tests__/TerminalRegistry.spec.ts @@ -1,6 +1,8 @@ // npx vitest run src/integrations/terminal/__tests__/TerminalRegistry.spec.ts import * as vscode from "vscode" +import { ExecaTerminal } from "../ExecaTerminal" +import { ShellIntegrationManager } from "../ShellIntegrationManager" import { Terminal } from "../Terminal" import { TerminalRegistry } from "../TerminalRegistry" @@ -14,11 +16,13 @@ describe("TerminalRegistry", () => { let mockCreateTerminal: any beforeEach(() => { + TerminalRegistry["terminals"] = [] + Terminal.setTerminalProfile(undefined) mockCreateTerminal = vi.spyOn(vscode.window, "createTerminal").mockImplementation( (...args: any[]) => ({ exitStatus: undefined, - name: "Roo Code", + name: "Zoo Code", processId: Promise.resolve(123), creationOptions: {}, state: { @@ -36,13 +40,19 @@ describe("TerminalRegistry", () => { ) }) + afterEach(() => { + TerminalRegistry["terminals"] = [] + Terminal.setTerminalProfile(undefined) + vi.restoreAllMocks() + }) + describe("createTerminal", () => { it("creates terminal with PAGER set appropriately for platform", () => { TerminalRegistry.createTerminal("/test/path", "vscode") expect(mockCreateTerminal).toHaveBeenCalledWith({ cwd: "/test/path", - name: "Roo Code", + name: "Zoo Code", iconPath: expect.any(Object), env: { PAGER, @@ -63,7 +73,7 @@ describe("TerminalRegistry", () => { expect(mockCreateTerminal).toHaveBeenCalledWith({ cwd: "/test/path", - name: "Roo Code", + name: "Zoo Code", iconPath: expect.any(Object), env: { PAGER, @@ -86,7 +96,7 @@ describe("TerminalRegistry", () => { expect(mockCreateTerminal).toHaveBeenCalledWith({ cwd: "/test/path", - name: "Roo Code", + name: "Zoo Code", iconPath: expect.any(Object), env: { PAGER, @@ -108,7 +118,7 @@ describe("TerminalRegistry", () => { expect(mockCreateTerminal).toHaveBeenCalledWith({ cwd: "/test/path", - name: "Roo Code", + name: "Zoo Code", iconPath: expect.any(Object), env: { PAGER, @@ -124,6 +134,77 @@ describe("TerminalRegistry", () => { }) }) + describe("getOrCreateTerminal", () => { + it("reuses an idle VS Code terminal when the selected profile is unchanged", async () => { + const first = await TerminalRegistry.getOrCreateTerminal("/test/path", "task", "vscode") + const second = await TerminalRegistry.getOrCreateTerminal("/test/path", "task", "vscode") + + expect(second).toBe(first) + expect(mockCreateTerminal).toHaveBeenCalledTimes(1) + }) + + it("creates a new VS Code terminal after changing from default to an override", async () => { + vi.spyOn(Terminal, "getProfileShell").mockReturnValue(undefined) + const first = await TerminalRegistry.getOrCreateTerminal("/test/path", "task", "vscode") + + Terminal.setTerminalProfile("Git Bash") + const second = await TerminalRegistry.getOrCreateTerminal("/test/path", "task", "vscode") + + expect(second).not.toBe(first) + expect(mockCreateTerminal).toHaveBeenCalledTimes(2) + }) + + it("creates a new VS Code terminal after changing from an override to default", async () => { + vi.spyOn(Terminal, "getProfileShell").mockReturnValue(undefined) + Terminal.setTerminalProfile("Git Bash") + const first = await TerminalRegistry.getOrCreateTerminal("/test/path", "task", "vscode") + + Terminal.setTerminalProfile(undefined) + const second = await TerminalRegistry.getOrCreateTerminal("/test/path", "task", "vscode") + + expect(second).not.toBe(first) + expect(mockCreateTerminal).toHaveBeenCalledTimes(2) + }) + + it("creates a new VS Code terminal after changing between named profiles", async () => { + vi.spyOn(Terminal, "getProfileShell").mockReturnValue(undefined) + Terminal.setTerminalProfile("Git Bash") + const first = await TerminalRegistry.getOrCreateTerminal("/test/path", "task", "vscode") + + Terminal.setTerminalProfile("zsh") + const second = await TerminalRegistry.getOrCreateTerminal("/test/path", "task", "vscode") + + expect(second).not.toBe(first) + expect(mockCreateTerminal).toHaveBeenCalledTimes(2) + }) + + it("continues to reuse Execa terminals when the VS Code profile changes", async () => { + const first = await TerminalRegistry.getOrCreateTerminal("/test/path", "task", "execa") + + Terminal.setTerminalProfile("Git Bash") + const second = await TerminalRegistry.getOrCreateTerminal("/test/path", "task", "execa") + + expect(second).toBe(first) + }) + }) + + describe("closeIdleTerminals", () => { + it("disposes only idle VS Code terminals and cleans up their temporary zsh directories", () => { + const idle = TerminalRegistry.createTerminal("/idle", "vscode") as Terminal + const busy = TerminalRegistry.createTerminal("/busy", "vscode") as Terminal + const execa = TerminalRegistry.createTerminal("/inline", "execa") as ExecaTerminal + busy.busy = true + const cleanupSpy = vi.spyOn(ShellIntegrationManager, "zshCleanupTmpDir") + + TerminalRegistry.closeIdleTerminals() + + expect(idle.terminal.dispose).toHaveBeenCalledTimes(1) + expect(cleanupSpy).toHaveBeenCalledWith(idle.id) + expect(busy.terminal.dispose).not.toHaveBeenCalled() + expect(TerminalRegistry["terminals"]).toEqual([busy, execa]) + }) + }) + describe("releaseTerminalsForTask", () => { it("aborts a busy terminal's running process and disassociates it from the task (#245)", () => { const terminal = TerminalRegistry.createTerminal("/test/path", "vscode") diff --git a/src/integrations/terminal/__tests__/streamUtils/bashStream.ts b/src/integrations/terminal/__tests__/streamUtils/bashStream.ts index 3bd1496d17..0f5dab1f44 100644 --- a/src/integrations/terminal/__tests__/streamUtils/bashStream.ts +++ b/src/integrations/terminal/__tests__/streamUtils/bashStream.ts @@ -40,7 +40,7 @@ export function createBashCommandStream(command: string): CommandStream { exitCode = 1 } } else { - exitCode = error.status || 1 // Use status if available, default to 1 + exitCode = error.status ?? 1 // Use status if available, default to 1 } } diff --git a/src/integrations/terminal/__tests__/streamUtils/cmdStream.ts b/src/integrations/terminal/__tests__/streamUtils/cmdStream.ts index c6df7f8272..a34e180f91 100644 --- a/src/integrations/terminal/__tests__/streamUtils/cmdStream.ts +++ b/src/integrations/terminal/__tests__/streamUtils/cmdStream.ts @@ -25,7 +25,7 @@ export function createCmdCommandStream(command: string): CommandStream { } catch (error: any) { // Command failed - get output and exit code from error realOutput = error.stdout?.toString() || "" - exitCode = error.status || 1 + exitCode = error.status ?? 1 } // Create an async iterator for the stream diff --git a/src/integrations/terminal/__tests__/streamUtils/pwshStream.ts b/src/integrations/terminal/__tests__/streamUtils/pwshStream.ts index cb690eb86e..9496bf7553 100644 --- a/src/integrations/terminal/__tests__/streamUtils/pwshStream.ts +++ b/src/integrations/terminal/__tests__/streamUtils/pwshStream.ts @@ -38,11 +38,11 @@ export function createPowerShellStream(command: string): CommandStream { } catch (error: any) { // Command failed - get output and exit code from error realOutput = error.stdout?.toString() || "" - console.error(`PowerShell command failed with status ${error.status || "unknown"}:`, error.message) + console.error(`PowerShell command failed with status ${error.status ?? "unknown"}:`, error.message) if (error.stderr) { console.error(`stderr: ${error.stderr.toString()}`) } - exitCode = error.status || 1 + exitCode = error.status ?? 1 } // Create an async iterator for the stream diff --git a/src/integrations/terminal/types.ts b/src/integrations/terminal/types.ts index a0c5cde5d5..8224875b60 100644 --- a/src/integrations/terminal/types.ts +++ b/src/integrations/terminal/types.ts @@ -5,6 +5,7 @@ export type RooTerminalProvider = "vscode" | "execa" export interface RooTerminal { provider: RooTerminalProvider id: number + reuseKey: string busy: boolean running: boolean taskId?: string @@ -25,7 +26,21 @@ export interface RooTerminalCallbacks { onCompleted: (output: string | undefined, process: RooTerminalProcess) => void | Promise onShellExecutionStarted: (pid: number | undefined, process: RooTerminalProcess) => void onShellExecutionComplete: (details: ExitCodeDetails, process: RooTerminalProcess) => void - onNoShellIntegration?: (message: string, process: RooTerminalProcess) => void + onNoShellIntegration?: (details: ShellIntegrationErrorDetails, process: RooTerminalProcess) => void +} + +export interface ShellIntegrationErrorDetails { + message: string + commandSubmitted: boolean +} + +export class ShellIntegrationError extends Error { + constructor( + message: string, + public readonly commandSubmitted: boolean, + ) { + super(message) + } } export interface RooTerminalProcess extends EventEmitter { @@ -49,7 +64,7 @@ export interface RooTerminalProcessEvents { shell_execution_started: [pid: number | undefined] shell_execution_complete: [exitDetails: ExitCodeDetails] error: [error: Error] - no_shell_integration: [message: string] + no_shell_integration: [details: ShellIntegrationErrorDetails] } export interface ExitCodeDetails { diff --git a/webview-ui/src/components/settings/SettingsView.tsx b/webview-ui/src/components/settings/SettingsView.tsx index 8197c858a7..5b311f8902 100644 --- a/webview-ui/src/components/settings/SettingsView.tsx +++ b/webview-ui/src/components/settings/SettingsView.tsx @@ -183,6 +183,7 @@ const SettingsView = forwardRef(({ onDone, t terminalZshOhMy, terminalZshP10k, terminalZdotdir, + terminalProfile, writeDelayMs, showRooIgnoredFiles, enableSubfolderRules, @@ -397,6 +398,7 @@ const SettingsView = forwardRef(({ onDone, t terminalZshOhMy, terminalZshP10k, terminalZdotdir, + terminalProfile: terminalProfile ?? "", // "" clears a saved profile; undefined is dropped by JSON.stringify terminalOutputPreviewSize: terminalOutputPreviewSize ?? "medium", mcpEnabled, maxOpenTabsContext: Math.min(Math.max(0, maxOpenTabsContext ?? 20), 500), @@ -864,6 +866,8 @@ const SettingsView = forwardRef(({ onDone, t terminalZshOhMy={terminalZshOhMy} terminalZshP10k={terminalZshP10k} terminalZdotdir={terminalZdotdir} + terminalProfile={terminalProfile} + onTerminalProfilePickerOpened={() => setChangeDetected(true)} setCachedStateField={setCachedStateField} /> )} diff --git a/webview-ui/src/components/settings/TerminalSettings.tsx b/webview-ui/src/components/settings/TerminalSettings.tsx index 07f062cc01..b265692944 100644 --- a/webview-ui/src/components/settings/TerminalSettings.tsx +++ b/webview-ui/src/components/settings/TerminalSettings.tsx @@ -1,7 +1,7 @@ -import { HTMLAttributes, useState, useCallback } from "react" +import { HTMLAttributes, useState, useCallback, useEffect, useId } from "react" import { useAppTranslation } from "@/i18n/TranslationContext" import { vscode } from "@/utils/vscode" -import { VSCodeCheckbox, VSCodeLink } from "@vscode/webview-ui-toolkit/react" +import { VSCodeCheckbox, VSCodeLink, VSCodeButton } from "@vscode/webview-ui-toolkit/react" import { Trans } from "react-i18next" import { buildDocLink } from "@src/utils/docLinks" import { useEvent, useMount } from "react-use" @@ -26,6 +26,8 @@ type TerminalSettingsProps = HTMLAttributes & { terminalZshOhMy?: boolean terminalZshP10k?: boolean terminalZdotdir?: boolean + terminalProfile?: string + onTerminalProfilePickerOpened?: () => void setCachedStateField: SetCachedStateField< | "terminalOutputPreviewSize" | "terminalShellIntegrationTimeout" @@ -36,9 +38,14 @@ type TerminalSettingsProps = HTMLAttributes & { | "terminalZshOhMy" | "terminalZshP10k" | "terminalZdotdir" + | "terminalProfile" > } +// Sentinel value that maps to `undefined` (use VS Code's default shell). +// The Select component cannot accept empty-string item values. +const DEFAULT_PROFILE_VALUE = "__default__" + export const TerminalSettings = ({ terminalOutputPreviewSize, terminalShellIntegrationTimeout, @@ -49,6 +56,8 @@ export const TerminalSettings = ({ terminalZshOhMy, terminalZshP10k, terminalZdotdir, + terminalProfile, + onTerminalProfilePickerOpened, setCachedStateField, className, ...props @@ -56,22 +65,34 @@ export const TerminalSettings = ({ const { t } = useAppTranslation() const [inheritEnv, setInheritEnv] = useState(true) + const [profileNames, setProfileNames] = useState([]) + const [isProfilesLoaded, setIsProfilesLoaded] = useState(false) + const profileModeId = useId() + const defaultProfileId = `${profileModeId}-default` + const overrideProfileId = `${profileModeId}-override` + const isProfileOverrideSelected = !!terminalProfile && (!isProfilesLoaded || profileNames.includes(terminalProfile)) + const isVSCodeTerminalEnabled = terminalShellIntegrationDisabled === false - useMount(() => vscode.postMessage({ type: "getVSCodeSetting", setting: "terminal.integrated.inheritEnv" })) + useMount(() => { + vscode.postMessage({ type: "getVSCodeSetting", setting: "terminal.integrated.inheritEnv" }) + // Request the terminal profile names through a dedicated, allowlisted message + // (the extension reads the profiles and returns only sanitized names). + vscode.postMessage({ type: "requestTerminalProfiles" }) + }) const onMessage = useCallback((event: MessageEvent) => { const message: ExtensionMessage = event.data switch (message.type) { case "vsCodeSetting": - switch (message.setting) { - case "terminal.integrated.inheritEnv": - setInheritEnv(message.value ?? true) - break - default: - break + if (message.setting === "terminal.integrated.inheritEnv") { + setInheritEnv(message.value ?? true) } break + case "terminalProfiles": + setProfileNames(message.profiles ?? []) + setIsProfilesLoaded(true) + break default: break } @@ -79,6 +100,12 @@ export const TerminalSettings = ({ useEvent("message", onMessage) + useEffect(() => { + if (isProfilesLoaded && terminalProfile && !profileNames.includes(terminalProfile)) { + setCachedStateField("terminalProfile", undefined) + } + }, [isProfilesLoaded, profileNames, setCachedStateField, terminalProfile]) + return (
{t("settings:sections.terminal")} @@ -139,6 +166,111 @@ export const TerminalSettings = ({
+ {/* Profile override — only applies when VS Code integrated terminal is active + (shell integration enabled). Hidden in Execa/inline mode since getProfileShell() + is not wired there. */} + {isVSCodeTerminalEnabled && ( + + + + {/* Level 1: Default (recommended) */} +
+ setCachedStateField("terminalProfile", undefined)} + data-testid="terminal-profile-default-radio" + /> + + { + onTerminalProfilePickerOpened?.() + vscode.postMessage({ type: "openTerminalProfilePicker" }) + }} + data-testid="terminal-profile-configure-button"> + {t("settings:terminal.profile.configureButton")} + +
+ + {/* Level 2: Override */} +
+ { + if (!terminalProfile && profileNames.length > 0) { + setCachedStateField("terminalProfile", profileNames[0]) + } + }} + data-testid="terminal-profile-override-radio" + /> + + {profileNames.length === 0 && ( + + {t("settings:terminal.profile.noProfiles")} + + )} +
+ + {isProfileOverrideSelected && profileNames.length > 0 && ( + + )} + +
+ + + {" "} + + +
+
+ )} + - {!terminalShellIntegrationDisabled && ( + {isVSCodeTerminalEnabled && ( <> - {terminalCommandDelay ?? 50}ms + {terminalCommandDelay ?? 0}ms
diff --git a/webview-ui/src/components/settings/__tests__/SettingsView.change-detection.spec.tsx b/webview-ui/src/components/settings/__tests__/SettingsView.change-detection.spec.tsx index 42239e33a3..cb5dc8ec28 100644 --- a/webview-ui/src/components/settings/__tests__/SettingsView.change-detection.spec.tsx +++ b/webview-ui/src/components/settings/__tests__/SettingsView.change-detection.spec.tsx @@ -286,6 +286,7 @@ describe("SettingsView - Change Detection Fix", () => { terminalZshOhMy: false, terminalZshP10k: false, terminalZdotdir: false, + terminalProfile: undefined, writeDelayMs: 0, showRooIgnoredFiles: false, maxReadFileLine: -1, diff --git a/webview-ui/src/components/settings/__tests__/TerminalSettings.profile.spec.tsx b/webview-ui/src/components/settings/__tests__/TerminalSettings.profile.spec.tsx new file mode 100644 index 0000000000..350ab78b05 --- /dev/null +++ b/webview-ui/src/components/settings/__tests__/TerminalSettings.profile.spec.tsx @@ -0,0 +1,231 @@ +// npx vitest run src/components/settings/__tests__/TerminalSettings.profile.spec.tsx + +import * as React from "react" + +import { render, screen, fireEvent, act } from "@/utils/test-utils" + +import { TerminalSettings } from "../TerminalSettings" + +// Mock translation hook to echo keys +vi.mock("@/i18n/TranslationContext", () => ({ + useAppTranslation: () => ({ t: (key: string) => key }), +})) + +vi.mock("@src/utils/docLinks", () => ({ + buildDocLink: () => "https://example.com", +})) + +const postMessageMock = vi.fn() +vi.mock("@/utils/vscode", () => ({ + vscode: { postMessage: (...args: any[]) => postMessageMock(...args) }, +})) + +// Render Select as a list of buttons so we can drive onValueChange in tests. +vi.mock("@/components/ui", () => ({ + Select: ({ children, value, onValueChange, "data-testid": testId }: any) => ( +
+ {renderSelectChildren(children, onValueChange)} +
+ ), + SelectTrigger: ({ children, ...rest }: any) =>
{children}
, + SelectValue: ({ children }: any) =>
{children}
, + SelectContent: ({ children }: any) =>
{children}
, + SelectItem: ({ children, value }: any) =>
{children}
, + Slider: ({ value, onValueChange }: any) => ( + onValueChange([parseFloat(e.target.value)])} /> + ), +})) + +vi.mock("@vscode/webview-ui-toolkit/react", () => ({ + VSCodeCheckbox: ({ checked, onChange, children }: any) => ( + + ), + VSCodeLink: ({ children }: any) => {children}, + VSCodeButton: ({ children, onClick, ...rest }: any) => ( + + ), +})) + +// Helper used by the Select mock to render SelectItem children as buttons. +function renderSelectChildren(children: any, onValueChange: (value: string) => void): any { + return React.Children.map(children, (child: any) => { + if (!child || typeof child !== "object") return child + const itemValue = child.props?.value ?? child.props?.["data-item-value"] + if (child.props?.children && itemValue === undefined) { + return renderSelectChildren(child.props.children, onValueChange) + } + if (itemValue !== undefined) { + return ( + + ) + } + return child + }) +} + +describe("TerminalSettings VS Code terminal profile (#277)", () => { + beforeEach(() => { + postMessageMock.mockClear() + }) + + // The profile section applies to the VS Code integrated terminal (terminalShellIntegrationDisabled === false). + const setup = (terminalProfile?: string) => { + const setCachedStateField = vi.fn() + const onTerminalProfilePickerOpened = vi.fn() + render( + , + ) + return { onTerminalProfilePickerOpened, setCachedStateField } + } + + it("requests the terminal profile names on mount via the allowlisted message", () => { + setup() + const types = postMessageMock.mock.calls.map((c) => c[0]?.type) + expect(types).toContain("requestTerminalProfiles") + }) + + it("shows the default radio selected and no dropdown when no profile is set", () => { + setup() + const defaultRadio = screen.getByTestId("terminal-profile-default-radio") + expect(defaultRadio).toBeChecked() + expect(screen.queryByTestId("terminal-profile-dropdown")).not.toBeInTheDocument() + }) + + it("shows the override radio selected and dropdown when a profile is set and profiles are available", () => { + setup("Git Bash") + act(() => { + window.dispatchEvent( + new MessageEvent("message", { + data: { type: "terminalProfiles", profiles: ["Git Bash", "zsh"] }, + }), + ) + }) + const overrideRadio = screen.getByTestId("terminal-profile-override-radio") + expect(overrideRadio).toBeChecked() + expect(screen.getByTestId("terminal-profile-dropdown")).toBeInTheDocument() + }) + + it("keeps a saved profile selected while profile names are loading", () => { + const { setCachedStateField } = setup("Git Bash") + + expect(screen.getByTestId("terminal-profile-override-radio")).toBeChecked() + expect(screen.queryByTestId("terminal-profile-dropdown")).not.toBeInTheDocument() + expect(setCachedStateField).not.toHaveBeenCalled() + }) + + it("falls back to the default radio and clears an unavailable saved profile after profiles load", () => { + const { setCachedStateField } = setup("Git Bash") + act(() => { + window.dispatchEvent( + new MessageEvent("message", { + data: { type: "terminalProfiles", profiles: ["Command Prompt"] }, + }), + ) + }) + + expect(screen.getByTestId("terminal-profile-default-radio")).toBeChecked() + expect(screen.getByTestId("terminal-profile-override-radio")).not.toBeChecked() + expect(screen.queryByTestId("terminal-profile-dropdown")).not.toBeInTheDocument() + expect(setCachedStateField).toHaveBeenCalledWith("terminalProfile", undefined) + }) + + it("uses instance-local radio groups", () => { + render( + <> + + + , + ) + + const defaultRadios = screen.getAllByTestId("terminal-profile-default-radio") + expect(defaultRadios[0]).toBeChecked() + expect(defaultRadios[1]).toBeChecked() + expect(defaultRadios[0]).not.toHaveAttribute("name", defaultRadios[1].getAttribute("name")) + }) + + it("populates the dropdown from received profile names and selecting one sets the profile", () => { + const { setCachedStateField } = setup("Git Bash") + + act(() => { + window.dispatchEvent( + new MessageEvent("message", { + data: { type: "terminalProfiles", profiles: ["Git Bash", "zsh"] }, + }), + ) + }) + + fireEvent.click(screen.getByTestId("option-zsh")) + expect(setCachedStateField).toHaveBeenCalledWith("terminalProfile", "zsh") + }) + + it("clicking default radio sets terminalProfile to undefined", () => { + const { setCachedStateField } = setup("Git Bash") + fireEvent.click(screen.getByTestId("terminal-profile-default-radio")) + expect(setCachedStateField).toHaveBeenCalledWith("terminalProfile", undefined) + }) + + it("renders the native profile configure button and posts openTerminalProfilePicker when clicked", () => { + const { onTerminalProfilePickerOpened, setCachedStateField } = setup("Git Bash") + const btn = screen.getByTestId("terminal-profile-configure-button") + expect(btn).toBeInTheDocument() + fireEvent.click(btn) + expect(onTerminalProfilePickerOpened).toHaveBeenCalledTimes(1) + expect(postMessageMock).toHaveBeenCalledWith({ type: "openTerminalProfilePicker" }) + expect(setCachedStateField).not.toHaveBeenCalledWith("terminalProfile", undefined) + }) + + it("shows picker section when VS Code integrated terminal is active (shell integration enabled)", () => { + render() + expect(screen.getByTestId("terminal-profile-default-radio")).toBeInTheDocument() + }) + + it("hides picker section when inline/Execa execution is active (shell integration disabled)", () => { + render() + expect(screen.queryByTestId("terminal-profile-default-radio")).not.toBeInTheDocument() + }) + + it("hides picker section when terminalShellIntegrationDisabled is undefined (defaults to inline mode)", () => { + render() + expect(screen.queryByTestId("terminal-profile-default-radio")).not.toBeInTheDocument() + expect(screen.queryByText("settings:terminal.inheritEnv.label")).not.toBeInTheDocument() + }) + + it("shows the command delay default as 0ms", () => { + render() + expect(screen.getByText("0ms")).toBeInTheDocument() + }) + + it("disables override radio and shows hint when no profiles are available", () => { + setup() + // No terminalProfiles message dispatched → profileNames stays [] + const overrideRadio = screen.getByTestId("terminal-profile-override-radio") + expect(overrideRadio).toBeDisabled() + expect(screen.getByTestId("terminal-profile-no-profiles-hint")).toBeInTheDocument() + }) + + it("enables override radio after profiles are received", () => { + setup() + act(() => { + window.dispatchEvent( + new MessageEvent("message", { + data: { type: "terminalProfiles", profiles: ["zsh"] }, + }), + ) + }) + const overrideRadio = screen.getByTestId("terminal-profile-override-radio") + expect(overrideRadio).not.toBeDisabled() + expect(screen.queryByTestId("terminal-profile-no-profiles-hint")).not.toBeInTheDocument() + }) +}) diff --git a/webview-ui/src/context/ExtensionStateContext.tsx b/webview-ui/src/context/ExtensionStateContext.tsx index f81de054c7..3c6698f3d1 100644 --- a/webview-ui/src/context/ExtensionStateContext.tsx +++ b/webview-ui/src/context/ExtensionStateContext.tsx @@ -84,6 +84,7 @@ export interface ExtensionStateContextType extends ExtensionState { setTerminalShellIntegrationDisabled: (value: boolean) => void terminalZdotdir?: boolean setTerminalZdotdir: (value: boolean) => void + terminalProfile?: string setTtsEnabled: (value: boolean) => void setTtsSpeed: (value: number) => void setEnableCheckpoints: (value: boolean) => void @@ -235,6 +236,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode terminalZshOhMy: false, // Default Oh My Zsh integration setting terminalZshP10k: false, // Default Powerlevel10k integration setting terminalZdotdir: false, // Default ZDOTDIR handling setting + terminalProfile: undefined, // Default VS Code terminal profile (use VS Code default) historyPreviewCollapsed: false, // Initialize the new state (default to expanded) reasoningBlockCollapsed: true, // Default to collapsed enterBehavior: "send", // Default: Enter sends, Shift+Enter creates newline diff --git a/webview-ui/src/i18n/locales/ca/settings.json b/webview-ui/src/i18n/locales/ca/settings.json index f7e3bb69e2..55561a14a7 100644 --- a/webview-ui/src/i18n/locales/ca/settings.json +++ b/webview-ui/src/i18n/locales/ca/settings.json @@ -741,6 +741,14 @@ "inheritEnv": { "label": "Hereta variables d'entorn", "description": "Activa per heretar variables d'entorn del procés pare de VS Code. <0>Aprèn-ne més" + }, + "profile": { + "label": "Substitució del terminal de Zoo Code", + "default": "Utilitza el perfil predeterminat de VS Code (recomanat)", + "description": "Per defecte, Zoo Code utilitza la shell que VS Code té configurada. Seleccioneu Substituir per triar un perfil de shell amb ruta explícita exposat per VS Code. Els perfils de només font (p. ex., l'entrada integrada de PowerShell) no es poden llistar aquí. <0>Més informació", + "overrideLabel": "Substituir la shell per a Zoo Code", + "configureButton": "Trieu el perfil predeterminat a VS Code", + "noProfiles": "(no s'han trobat perfils amb ruta a terminal.integrated.profiles)" } }, "advancedSettings": { diff --git a/webview-ui/src/i18n/locales/de/settings.json b/webview-ui/src/i18n/locales/de/settings.json index 1aa9f96053..104db111c1 100644 --- a/webview-ui/src/i18n/locales/de/settings.json +++ b/webview-ui/src/i18n/locales/de/settings.json @@ -741,6 +741,14 @@ "inheritEnv": { "label": "Umgebungsvariablen erben", "description": "Schalte dies ein, um Umgebungsvariablen vom übergeordneten VS Code-Prozess zu erben. <0>Mehr erfahren" + }, + "profile": { + "label": "Zoo Code Terminal-Überschreibung", + "default": "VS Code-Standardprofil verwenden (empfohlen)", + "description": "Standardmäßig verwendet Zoo Code die in VS Code konfigurierte Shell. Wähle Überschreiben, um ein pfadbasiertes Shell-Profil zu wählen, das VS Code bereitstellt. Quellenbasierte Profile (z. B. der integrierte PowerShell-Eintrag) können hier nicht aufgelistet werden. <0>Mehr erfahren", + "overrideLabel": "Shell für Zoo Code überschreiben", + "configureButton": "Standardprofil in VS Code auswählen", + "noProfiles": "(keine pfadbasierten Profile in terminal.integrated.profiles gefunden)" } }, "advancedSettings": { diff --git a/webview-ui/src/i18n/locales/en/settings.json b/webview-ui/src/i18n/locales/en/settings.json index 7f3527df6b..2ec5be1934 100644 --- a/webview-ui/src/i18n/locales/en/settings.json +++ b/webview-ui/src/i18n/locales/en/settings.json @@ -809,6 +809,14 @@ "inheritEnv": { "label": "Inherit environment variables", "description": "Turn this on to inherit environment variables from the parent VS Code process. <0>Learn more" + }, + "profile": { + "label": "Zoo Code terminal override", + "default": "Use VS Code default profile (recommended)", + "overrideLabel": "Override shell for Zoo Code", + "configureButton": "Choose default profile in VS Code", + "noProfiles": "(no path-based profiles found in terminal.integrated.profiles)", + "description": "By default Zoo Code uses whatever shell VS Code is configured to use. Select Override to pick a path-based shell profile exposed by VS Code. Source-only profiles (e.g. the built-in PowerShell entry) cannot be listed here. <0>Learn more" } }, "advancedSettings": { diff --git a/webview-ui/src/i18n/locales/es/settings.json b/webview-ui/src/i18n/locales/es/settings.json index 154319baef..97aa3bd38a 100644 --- a/webview-ui/src/i18n/locales/es/settings.json +++ b/webview-ui/src/i18n/locales/es/settings.json @@ -741,6 +741,14 @@ "inheritEnv": { "label": "Heredar variables de entorno", "description": "Activa para heredar variables de entorno del proceso padre de VS Code. <0>Más información" + }, + "profile": { + "label": "Anulación del terminal de Zoo Code", + "default": "Usar perfil predeterminado de VS Code (recomendado)", + "description": "De forma predeterminada, Zoo Code usa la shell que VS Code tiene configurada. Selecciona Anular para elegir un perfil de shell con ruta expuesto por VS Code. Los perfiles solo de fuente (p. ej., la entrada integrada de PowerShell) no se pueden listar aquí. <0>Más información", + "overrideLabel": "Anular shell para Zoo Code", + "configureButton": "Elegir perfil predeterminado en VS Code", + "noProfiles": "(no se encontraron perfiles con ruta en terminal.integrated.profiles)" } }, "advancedSettings": { diff --git a/webview-ui/src/i18n/locales/fr/settings.json b/webview-ui/src/i18n/locales/fr/settings.json index 30cdd37785..6764f3b5aa 100644 --- a/webview-ui/src/i18n/locales/fr/settings.json +++ b/webview-ui/src/i18n/locales/fr/settings.json @@ -741,6 +741,14 @@ "inheritEnv": { "label": "Hériter des variables d'environnement", "description": "Activez pour hériter des variables d'environnement du processus parent VS Code. <0>En savoir plus" + }, + "profile": { + "label": "Remplacement du terminal Zoo Code", + "default": "Utiliser le profil par défaut VS Code (recommandé)", + "description": "Par défaut, Zoo Code utilise le shell configuré dans VS Code. Sélectionnez Remplacer pour choisir un profil shell avec chemin exposé par VS Code. Les profils source uniquement (ex. : l'entrée PowerShell intégrée) ne peuvent pas être listés ici. <0>En savoir plus", + "overrideLabel": "Remplacer le shell pour Zoo Code", + "configureButton": "Choisir le profil par défaut dans VS Code", + "noProfiles": "(aucun profil avec chemin trouvé dans terminal.integrated.profiles)" } }, "advancedSettings": { diff --git a/webview-ui/src/i18n/locales/hi/settings.json b/webview-ui/src/i18n/locales/hi/settings.json index a334b8cb5f..f6b6b712ed 100644 --- a/webview-ui/src/i18n/locales/hi/settings.json +++ b/webview-ui/src/i18n/locales/hi/settings.json @@ -741,6 +741,14 @@ "inheritEnv": { "label": "पर्यावरण चर विरासत में लें", "description": "पैरेंट VS Code प्रोसेस से पर्यावरण चर विरासत में लेने के लिए इसे चालू करें। <0>अधिक जानें" + }, + "profile": { + "label": "Zoo Code टर्मिनल ओवरराइड", + "default": "VS Code डिफ़ॉल्ट प्रोफ़ाइल उपयोग करें (अनुशंसित)", + "description": "डिफ़ॉल्ट रूप से Zoo Code VS Code में कॉन्फ़िगर की गई शेल का उपयोग करता है। VS Code द्वारा प्रदर्शित पथ-आधारित शेल प्रोफ़ाइल चुनने के लिए ओवरराइड चुनें। केवल-स्रोत प्रोफ़ाइल (जैसे, अंतर्निर्मित PowerShell प्रविष्टि) यहाँ सूचीबद्ध नहीं किए जा सकते। <0>अधिक जानें", + "overrideLabel": "Zoo Code के लिए शेल ओवरराइड करें", + "configureButton": "VS Code में डिफ़ॉल्ट प्रोफ़ाइल चुनें", + "noProfiles": "(terminal.integrated.profiles में कोई पथ-आधारित प्रोफ़ाइल नहीं मिली)" } }, "advancedSettings": { diff --git a/webview-ui/src/i18n/locales/id/settings.json b/webview-ui/src/i18n/locales/id/settings.json index 23e974429e..7509b09d66 100644 --- a/webview-ui/src/i18n/locales/id/settings.json +++ b/webview-ui/src/i18n/locales/id/settings.json @@ -741,6 +741,14 @@ "inheritEnv": { "label": "Warisi variabel lingkungan", "description": "Aktifkan untuk mewarisi variabel lingkungan dari proses induk VS Code. <0>Pelajari lebih lanjut" + }, + "profile": { + "label": "Penimpaan terminal Zoo Code", + "default": "Gunakan profil default VS Code (direkomendasikan)", + "description": "Secara default Zoo Code menggunakan shell yang dikonfigurasi VS Code. Pilih Timpa untuk memilih profil shell berbasis jalur yang diekspos oleh VS Code. Profil hanya sumber (mis., entri PowerShell bawaan) tidak dapat tercantum di sini. <0>Pelajari lebih lanjut", + "overrideLabel": "Timpa shell untuk Zoo Code", + "configureButton": "Pilih profil default di VS Code", + "noProfiles": "(tidak ada profil berbasis jalur di terminal.integrated.profiles)" } }, "advancedSettings": { diff --git a/webview-ui/src/i18n/locales/it/settings.json b/webview-ui/src/i18n/locales/it/settings.json index 3015d9338d..ffb4d6afbc 100644 --- a/webview-ui/src/i18n/locales/it/settings.json +++ b/webview-ui/src/i18n/locales/it/settings.json @@ -741,6 +741,14 @@ "inheritEnv": { "label": "Eredita variabili d'ambiente", "description": "Attiva per ereditare le variabili d'ambiente dal processo padre di VS Code. <0>Scopri di più" + }, + "profile": { + "label": "Override terminale Zoo Code", + "default": "Usa il profilo predefinito di VS Code (consigliato)", + "description": "Per impostazione predefinita Zoo Code usa la shell configurata in VS Code. Seleziona Override per scegliere un profilo shell con percorso esposto da VS Code. I profili solo sorgente (es. la voce PowerShell integrata) non possono essere elencati qui. <0>Ulteriori informazioni", + "overrideLabel": "Sostituisci shell per Zoo Code", + "configureButton": "Scegli il profilo predefinito in VS Code", + "noProfiles": "(nessun profilo con percorso trovato in terminal.integrated.profiles)" } }, "advancedSettings": { diff --git a/webview-ui/src/i18n/locales/ja/settings.json b/webview-ui/src/i18n/locales/ja/settings.json index 02091510e5..e8eeaf8580 100644 --- a/webview-ui/src/i18n/locales/ja/settings.json +++ b/webview-ui/src/i18n/locales/ja/settings.json @@ -741,6 +741,14 @@ "inheritEnv": { "label": "環境変数を継承", "description": "親VS Codeプロセスから環境変数を継承するには、これをオンにします。<0>詳細情報" + }, + "profile": { + "label": "Zoo Code ターミナルの上書き", + "default": "VS Code のデフォルトプロファイルを使用する(推奨)", + "description": "デフォルトでは Zoo Code は VS Code に設定されたシェルを使用します。VS Code が公開するパスベースのシェルプロファイルを選択するには「上書き」を選択してください。ソースのみのプロファイル(例:組み込みの PowerShell エントリ)はここに表示できません。 <0>詳細を見る", + "overrideLabel": "Zoo Code 用シェルを上書き", + "configureButton": "VS Code でデフォルトプロファイルを選択", + "noProfiles": "(terminal.integrated.profiles にパスベースのプロファイルが見つかりません)" } }, "advancedSettings": { diff --git a/webview-ui/src/i18n/locales/ko/settings.json b/webview-ui/src/i18n/locales/ko/settings.json index 4a2155aba4..37e5b0b682 100644 --- a/webview-ui/src/i18n/locales/ko/settings.json +++ b/webview-ui/src/i18n/locales/ko/settings.json @@ -741,6 +741,14 @@ "inheritEnv": { "label": "환경 변수 상속", "description": "부모 VS Code 프로세���에서 환경 변수를 상속하려면 이 기능을 켜십시오. <0>자세히 알아보기" + }, + "profile": { + "label": "Zoo Code 터미널 재정의", + "default": "VS Code 기본 프로필 사용 (권장)", + "description": "기본적으로 Zoo Code는 VS Code에 구성된 쉘을 사용합니다. VS Code가 노출하는 경로 기반 쉘 프로필을 선택하려면 재정의를 선택하세요. 소스 전용 프로필(예: 내장 PowerShell 항목)은 여기에 나열할 수 없습니다. <0>자세히 알아보기", + "overrideLabel": "Zoo Code용 쉘 재정의", + "configureButton": "VS Code에서 기본 프로필 선택", + "noProfiles": "(terminal.integrated.profiles에 경로 기반 프로필이 없습니다)" } }, "advancedSettings": { diff --git a/webview-ui/src/i18n/locales/nl/settings.json b/webview-ui/src/i18n/locales/nl/settings.json index 006fd8721f..fb88aaaa81 100644 --- a/webview-ui/src/i18n/locales/nl/settings.json +++ b/webview-ui/src/i18n/locales/nl/settings.json @@ -741,6 +741,14 @@ "inheritEnv": { "label": "Omgevingsvariabelen overnemen", "description": "Schakel in om omgevingsvariabelen over te nemen van het bovenliggende VS Code-proces. <0>Meer informatie" + }, + "profile": { + "label": "Zoo Code terminal-overschrijving", + "default": "VS Code standaardprofiel gebruiken (aanbevolen)", + "description": "Standaard gebruikt Zoo Code de shell die in VS Code is geconfigureerd. Selecteer Overschrijven om een shell-profiel met pad te kiezen dat VS Code beschikbaar stelt. Uitsluitend op bron gebaseerde profielen (bijv. de ingebouwde PowerShell-vermelding) kunnen hier niet worden vermeld. <0>Meer informatie", + "overrideLabel": "Shell voor Zoo Code overschrijven", + "configureButton": "Standaardprofiel kiezen in VS Code", + "noProfiles": "(geen profielen met pad gevonden in terminal.integrated.profiles)" } }, "advancedSettings": { diff --git a/webview-ui/src/i18n/locales/pl/settings.json b/webview-ui/src/i18n/locales/pl/settings.json index c4492da87c..81a8f4491b 100644 --- a/webview-ui/src/i18n/locales/pl/settings.json +++ b/webview-ui/src/i18n/locales/pl/settings.json @@ -741,6 +741,14 @@ "inheritEnv": { "label": "Dziedzicz zmienne środowiskowe", "description": "Włącz, aby dziedziczyć zmienne środowiskowe z procesu nadrzędnego VS Code. <0>Dowiedz się więcej" + }, + "profile": { + "label": "Nadpisanie terminala Zoo Code", + "default": "Użyj domyślnego profilu VS Code (zalecane)", + "description": "Domyślnie Zoo Code używa powłoki skonfigurowanej w VS Code. Wybierz Nadpisanie, aby wybrać profil powłoki ze ścieżką udostępniony przez VS Code. Profile tylko ze źródłem (np. wbudowany wpis PowerShell) nie mogą być tutaj wyświetlone. <0>Dowiedz się więcej", + "overrideLabel": "Nadpisz powłokę dla Zoo Code", + "configureButton": "Wybierz domyślny profil w VS Code", + "noProfiles": "(brak profili ze ścieżką w terminal.integrated.profiles)" } }, "advancedSettings": { diff --git a/webview-ui/src/i18n/locales/pt-BR/settings.json b/webview-ui/src/i18n/locales/pt-BR/settings.json index 6e19325c5c..19737167e4 100644 --- a/webview-ui/src/i18n/locales/pt-BR/settings.json +++ b/webview-ui/src/i18n/locales/pt-BR/settings.json @@ -741,6 +741,14 @@ "inheritEnv": { "label": "Herdar variáveis de ambiente", "description": "Ative isso para herdar variáveis de ambiente do processo pai do VS Code. <0>Saiba mais" + }, + "profile": { + "label": "Substituição de terminal do Zoo Code", + "default": "Usar perfil padrão do VS Code (recomendado)", + "description": "Por padrão, o Zoo Code usa o shell configurado no VS Code. Selecione Substituir para escolher um perfil de shell com caminho exposto pelo VS Code. Perfis somente de fonte (ex.: a entrada integrada do PowerShell) não podem ser listados aqui. <0>Saiba mais", + "overrideLabel": "Substituir shell para o Zoo Code", + "configureButton": "Escolher perfil padrão no VS Code", + "noProfiles": "(nenhum perfil com caminho encontrado em terminal.integrated.profiles)" } }, "advancedSettings": { diff --git a/webview-ui/src/i18n/locales/ru/settings.json b/webview-ui/src/i18n/locales/ru/settings.json index 2641939b9b..7f5b38b0c9 100644 --- a/webview-ui/src/i18n/locales/ru/settings.json +++ b/webview-ui/src/i18n/locales/ru/settings.json @@ -741,6 +741,14 @@ "inheritEnv": { "label": "Наследовать переменные среды", "description": "Включите для наследования переменных среды от родительского процесса VS Code. <0>Подробнее" + }, + "profile": { + "label": "Переопределение терминала Zoo Code", + "default": "Использовать профиль VS Code по умолчанию (рекомендуется)", + "description": "По умолчанию Zoo Code использует оболочку, настроенную в VS Code. Выберите Переопределить, чтобы указать профиль оболочки с путём, предоставленный VS Code. Профили только с источником (например, встроенная запись PowerShell) не могут быть перечислены здесь. <0>Подробнее", + "overrideLabel": "Переопределить оболочку для Zoo Code", + "configureButton": "Выбрать профиль по умолчанию в VS Code", + "noProfiles": "(профили с путём в terminal.integrated.profiles не найдены)" } }, "advancedSettings": { diff --git a/webview-ui/src/i18n/locales/tr/settings.json b/webview-ui/src/i18n/locales/tr/settings.json index 2ed19417e7..221181bca9 100644 --- a/webview-ui/src/i18n/locales/tr/settings.json +++ b/webview-ui/src/i18n/locales/tr/settings.json @@ -741,6 +741,14 @@ "inheritEnv": { "label": "Ortam değişkenlerini devral", "description": "Ana VS Code işleminden ortam değişkenlerini devralmak için bunu açın. <0>Daha fazla bilgi edinin" + }, + "profile": { + "label": "Zoo Code terminal geçersiz kılma", + "default": "VS Code varsayılan profilini kullan (önerilir)", + "description": "Varsayılan olarak Zoo Code, VS Code'da yapılandırılmış kabuğu kullanır. VS Code'un sunduğu yol tabanlı bir kabuk profili seçmek için Geçersiz Kıl'ı seçin. Yalnızca kaynak profiller (örn. yerleşik PowerShell girişi) burada listelenemez. <0>Daha fazla bilgi", + "overrideLabel": "Zoo Code için kabuk geçersiz kıl", + "configureButton": "VS Code'da varsayılan profili seç", + "noProfiles": "(terminal.integrated.profiles'da yol tabanlı profil bulunamadı)" } }, "advancedSettings": { diff --git a/webview-ui/src/i18n/locales/vi/settings.json b/webview-ui/src/i18n/locales/vi/settings.json index e32beeff3e..0287781402 100644 --- a/webview-ui/src/i18n/locales/vi/settings.json +++ b/webview-ui/src/i18n/locales/vi/settings.json @@ -741,6 +741,14 @@ "inheritEnv": { "label": "Kế thừa biến môi trường", "description": "Bật tính năng này để kế thừa các biến môi trường từ quy trình mẹ của VS Code. <0>Tìm hiểu thêm" + }, + "profile": { + "label": "Ghi đè terminal Zoo Code", + "default": "Dùng hồ sơ mặc định của VS Code (khuyến nghị)", + "description": "Theo mặc định Zoo Code sử dụng shell được cấu hình trong VS Code. Chọn Ghi đè để chọn hồ sơ shell dựa trên đường dẫn được VS Code cung cấp. Các hồ sơ chỉ nguồn (ví dụ: mục PowerShell tích hợp) không thể được liệt kê ở đây. <0>Tìm hiểu thêm", + "overrideLabel": "Ghi đè shell cho Zoo Code", + "configureButton": "Chọn hồ sơ mặc định trong VS Code", + "noProfiles": "(không tìm thấy hồ sơ dựa trên đường dẫn trong terminal.integrated.profiles)" } }, "advancedSettings": { diff --git a/webview-ui/src/i18n/locales/zh-CN/settings.json b/webview-ui/src/i18n/locales/zh-CN/settings.json index fb67bb89c0..d9038a363b 100644 --- a/webview-ui/src/i18n/locales/zh-CN/settings.json +++ b/webview-ui/src/i18n/locales/zh-CN/settings.json @@ -741,6 +741,14 @@ "inheritEnv": { "label": "继承环境变量", "description": "启用此选项以从父 VS Code 进程继承环境变量。<0>了解更多" + }, + "profile": { + "label": "Zoo Code 终端覆盖", + "default": "使用 VS Code 默认配置文件(推荐)", + "description": "默认情况下,Zoo Code 使用 VS Code 配置的 Shell。选择覆盖可从 VS Code 公开的路径型 Shell 配置文件中选取。仅含来源的配置文件(如内置的 PowerShell 条目)无法在此列出。 <0>了解更多", + "overrideLabel": "为 Zoo Code 覆盖 Shell", + "configureButton": "在 VS Code 中选择默认配置文件", + "noProfiles": "(在 terminal.integrated.profiles 中未找到路径型配置文件)" } }, "advancedSettings": { diff --git a/webview-ui/src/i18n/locales/zh-TW/settings.json b/webview-ui/src/i18n/locales/zh-TW/settings.json index f0d4725cd2..69d5953eff 100644 --- a/webview-ui/src/i18n/locales/zh-TW/settings.json +++ b/webview-ui/src/i18n/locales/zh-TW/settings.json @@ -756,6 +756,14 @@ "inheritEnv": { "label": "繼承環境變數", "description": "啟用此選項以從父 VS Code 程序繼承環境變數。<0>了解更多" + }, + "profile": { + "label": "Zoo Code 終端機覆寫", + "default": "使用 VS Code 預設設定檔(建議)", + "description": "預設情況下,Zoo Code 使用 VS Code 設定的 Shell。選擇覆寫可從 VS Code 公開的路徑型 Shell 設定檔中選取。僅含來源的設定檔(如內建的 PowerShell 項目)無法在此列出。 <0>了解更多", + "overrideLabel": "為 Zoo Code 覆寫 Shell", + "configureButton": "在 VS Code 中選擇預設設定檔", + "noProfiles": "(在 terminal.integrated.profiles 中未找到路徑型設定檔)" } }, "advancedSettings": {