From 6c36642755c5b39aaa12b1cc3a625e571cdcdd77 Mon Sep 17 00:00:00 2001 From: Armando Vaquera <263793884+proyectoauraorg@users.noreply.github.com> Date: Sat, 23 May 2026 18:58:06 -0600 Subject: [PATCH 01/21] feat(terminal): add inline terminal profile selection (#119) Add a 'terminalProfile' setting that lets users choose which VS Code terminal profile the inline terminal uses. On Windows the default cmd/PowerShell shell may use a non-UTF-8 code page (e.g. GBK) and garble output; selecting a UTF-8 profile such as Git Bash resolves this. The setting reuses VS Code's terminal profile concept: when set, the profile name is resolved against terminal.integrated.profiles. to derive shellPath/shellArgs for createTerminal. When empty/unset the default terminal behavior is preserved unchanged. Adds backend unit tests for profile resolution and a webview test for the settings dropdown wiring. --- packages/types/src/global-settings.ts | 1 + packages/types/src/vscode-extension-host.ts | 1 + src/core/webview/ClineProvider.ts | 5 + src/core/webview/webviewMessageHandler.ts | 2 + src/integrations/terminal/BaseTerminal.ts | 19 ++ src/integrations/terminal/Terminal.ts | 98 ++++++++- .../__tests__/TerminalProfile.spec.ts | 189 ++++++++++++++++++ .../src/components/settings/SettingsView.tsx | 3 + .../components/settings/TerminalSettings.tsx | 60 +++++- .../SettingsView.change-detection.spec.tsx | 1 + .../TerminalSettings.profile.spec.tsx | 135 +++++++++++++ .../src/context/ExtensionStateContext.tsx | 4 + webview-ui/src/i18n/locales/ca/settings.json | 5 + webview-ui/src/i18n/locales/de/settings.json | 5 + webview-ui/src/i18n/locales/en/settings.json | 5 + webview-ui/src/i18n/locales/es/settings.json | 5 + webview-ui/src/i18n/locales/fr/settings.json | 5 + webview-ui/src/i18n/locales/hi/settings.json | 5 + webview-ui/src/i18n/locales/id/settings.json | 5 + webview-ui/src/i18n/locales/it/settings.json | 5 + webview-ui/src/i18n/locales/ja/settings.json | 5 + webview-ui/src/i18n/locales/ko/settings.json | 5 + webview-ui/src/i18n/locales/nl/settings.json | 5 + webview-ui/src/i18n/locales/pl/settings.json | 5 + .../src/i18n/locales/pt-BR/settings.json | 5 + webview-ui/src/i18n/locales/ru/settings.json | 5 + webview-ui/src/i18n/locales/tr/settings.json | 5 + webview-ui/src/i18n/locales/vi/settings.json | 5 + .../src/i18n/locales/zh-CN/settings.json | 5 + .../src/i18n/locales/zh-TW/settings.json | 5 + 30 files changed, 606 insertions(+), 2 deletions(-) create mode 100644 src/integrations/terminal/__tests__/TerminalProfile.spec.ts create mode 100644 webview-ui/src/components/settings/__tests__/TerminalSettings.profile.spec.tsx 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..fef244f29b 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -275,6 +275,7 @@ export type ExtensionState = Pick< | "terminalZshOhMy" | "terminalZshP10k" | "terminalZdotdir" + | "terminalProfile" | "execaShellPath" | "diagnosticsEnabled" | "language" 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/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index b5bda4b147..e257b594ae 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -726,6 +726,8 @@ export const webviewMessageHandler = async ( if (value !== undefined) { Terminal.setTerminalZdotdir(value as boolean) } + } else if (key === "terminalProfile") { + Terminal.setTerminalProfile(value as string | undefined) } else if (key === "execaShellPath") { Terminal.setExecaShellPath(value as string | undefined) } else if (key === "mcpEnabled") { diff --git a/src/integrations/terminal/BaseTerminal.ts b/src/integrations/terminal/BaseTerminal.ts index ee26254934..cb807a259c 100644 --- a/src/integrations/terminal/BaseTerminal.ts +++ b/src/integrations/terminal/BaseTerminal.ts @@ -161,6 +161,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 +297,24 @@ export abstract class BaseTerminal implements RooTerminal { return BaseTerminal.terminalZdotdir } + /** + * Sets the name of the VS Code terminal profile to use for the inline + * (shell-integration) 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 { + BaseTerminal.terminalProfile = profile && profile.trim().length > 0 ? profile : undefined + } + + /** + * Gets the name of the VS Code terminal profile to use for the inline 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..23d59b05ae 100644 --- a/src/integrations/terminal/Terminal.ts +++ b/src/integrations/terminal/Terminal.ts @@ -17,7 +17,28 @@ export class Terminal extends BaseTerminal { const env = Terminal.getEnv() const iconPath = new vscode.ThemeIcon("rocket") - this.terminal = terminal ?? vscode.window.createTerminal({ cwd, name: "Roo Code", iconPath, env }) + + if (terminal) { + this.terminal = terminal + } else { + const options: vscode.TerminalOptions = { cwd, name: "Roo Code", iconPath, env } + + // When the user has chosen a specific terminal profile, resolve it to a + // shell path/args so the inline terminal uses that shell (e.g. Git Bash + // with a UTF-8 charset on Windows). When unset, we leave shellPath/shellArgs + // undefined so VS Code's default terminal behavior is preserved (#119). + const profileShell = Terminal.getProfileShell() + + if (profileShell?.shellPath) { + options.shellPath = profileShell.shellPath + + if (profileShell.shellArgs) { + options.shellArgs = profileShell.shellArgs + } + } + + this.terminal = vscode.window.createTerminal(options) + } if (Terminal.getTerminalZdotdir()) { ShellIntegrationManager.terminalTmpDirs.set(id, env.ZDOTDIR) @@ -191,4 +212,79 @@ export class Terminal extends BaseTerminal { return env } + + /** + * Returns the VS Code config section key (`windows`/`osx`/`linux`) used for + * platform-specific terminal profiles. + */ + private static getPlatformProfileKey(platform: NodeJS.Platform = process.platform): "windows" | "osx" | "linux" { + if (platform === "win32") { + return "windows" + } + + if (platform === "darwin") { + return "osx" + } + + return "linux" + } + + /** + * Resolves the configured inline 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 (UTF-8) instead of the default cmd/PowerShell + * (which may use a non-UTF-8 charset such as GBK) on Windows (#119). + * + * @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[] } | undefined { + const profileName = Terminal.getTerminalProfile() + + if (!profileName) { + return undefined + } + + const platformKey = Terminal.getPlatformProfileKey(platform) + + const profiles = vscode.workspace + .getConfiguration("terminal.integrated.profiles") + .get>(platformKey) + + const profile = profiles?.[profileName] as + | { path?: string | string[]; args?: string | string[]; source?: string } + | null + | undefined + + if (!profile) { + console.warn(`[Terminal] Configured terminal profile "${profileName}" not found for ${platformKey}.`) + return undefined + } + + // A `path` may be a single string or an array of candidate paths (VS Code + // picks the first that exists). We pass the first candidate to createTerminal. + const pathValue = Array.isArray(profile.path) ? profile.path[0] : profile.path + + 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 + : typeof profile.args === "string" + ? [profile.args] + : undefined + + return { shellPath: pathValue, shellArgs } + } } diff --git a/src/integrations/terminal/__tests__/TerminalProfile.spec.ts b/src/integrations/terminal/__tests__/TerminalProfile.spec.ts new file mode 100644 index 0000000000..f2e252429b --- /dev/null +++ b/src/integrations/terminal/__tests__/TerminalProfile.spec.ts @@ -0,0 +1,189 @@ +// npx vitest run src/integrations/terminal/__tests__/TerminalProfile.spec.ts + +import * as vscode from "vscode" + +import { Terminal } from "../Terminal" +import { TerminalRegistry } from "../TerminalRegistry" + +vi.mock("execa", () => ({ + execa: vi.fn(), +})) + +describe("Terminal inline terminal profile (#119)", () => { + let getConfigurationSpy: ReturnType + let createTerminalSpy: ReturnType + + 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) => { + getConfigurationSpy = vi.spyOn(vscode.workspace, "getConfiguration").mockImplementation((section?: string) => { + if (section === "terminal.integrated.profiles") { + return { + get: (platformKey: string) => profilesByPlatform[platformKey], + } as any + } + + return { get: (_key: string, defaultValue?: unknown) => defaultValue } as any + }) + } + + beforeEach(() => { + createTerminalSpy = vi.spyOn(vscode.window, "createTerminal").mockImplementation(() => mockTerminal()) + // 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("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("uses the first path candidate when path is an array", () => { + stubProfiles({ + windows: { + "Git Bash": { + path: ["C:\\missing\\bash.exe", "C:\\Program Files\\Git\\bin\\bash.exe"], + }, + }, + }) + + Terminal.setTerminalProfile("Git Bash") + + expect(Terminal.getProfileShell("win32")).toEqual({ + shellPath: "C:\\missing\\bash.exe", + shellArgs: undefined, + }) + }) + + 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("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() + }) + }) + + 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"]) + }) + }) +}) diff --git a/webview-ui/src/components/settings/SettingsView.tsx b/webview-ui/src/components/settings/SettingsView.tsx index 8197c858a7..0215470248 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, terminalOutputPreviewSize: terminalOutputPreviewSize ?? "medium", mcpEnabled, maxOpenTabsContext: Math.min(Math.max(0, maxOpenTabsContext ?? 20), 500), @@ -864,6 +866,7 @@ const SettingsView = forwardRef(({ onDone, t terminalZshOhMy={terminalZshOhMy} terminalZshP10k={terminalZshP10k} terminalZdotdir={terminalZdotdir} + terminalProfile={terminalProfile} setCachedStateField={setCachedStateField} /> )} diff --git a/webview-ui/src/components/settings/TerminalSettings.tsx b/webview-ui/src/components/settings/TerminalSettings.tsx index 07f062cc01..e945edfaac 100644 --- a/webview-ui/src/components/settings/TerminalSettings.tsx +++ b/webview-ui/src/components/settings/TerminalSettings.tsx @@ -26,6 +26,7 @@ type TerminalSettingsProps = HTMLAttributes & { terminalZshOhMy?: boolean terminalZshP10k?: boolean terminalZdotdir?: boolean + terminalProfile?: string setCachedStateField: SetCachedStateField< | "terminalOutputPreviewSize" | "terminalShellIntegrationTimeout" @@ -36,9 +37,22 @@ type TerminalSettingsProps = HTMLAttributes & { | "terminalZshOhMy" | "terminalZshP10k" | "terminalZdotdir" + | "terminalProfile" > } +// Sentinel value for the "Default" option; the Select component cannot use an +// empty-string item value, so we map it to/from `undefined` in the handler. +const DEFAULT_PROFILE_VALUE = "__default__" + +// VS Code stores terminal profiles per platform; we request all of them so the +// profile dropdown works regardless of which OS the extension host runs on. +const PROFILE_SETTING_KEYS = [ + "terminal.integrated.profiles.windows", + "terminal.integrated.profiles.osx", + "terminal.integrated.profiles.linux", +] + export const TerminalSettings = ({ terminalOutputPreviewSize, terminalShellIntegrationTimeout, @@ -49,6 +63,7 @@ export const TerminalSettings = ({ terminalZshOhMy, terminalZshP10k, terminalZdotdir, + terminalProfile, setCachedStateField, className, ...props @@ -56,8 +71,12 @@ export const TerminalSettings = ({ const { t } = useAppTranslation() const [inheritEnv, setInheritEnv] = useState(true) + const [profileNames, setProfileNames] = useState([]) - useMount(() => vscode.postMessage({ type: "getVSCodeSetting", setting: "terminal.integrated.inheritEnv" })) + useMount(() => { + vscode.postMessage({ type: "getVSCodeSetting", setting: "terminal.integrated.inheritEnv" }) + PROFILE_SETTING_KEYS.forEach((setting) => vscode.postMessage({ type: "getVSCodeSetting", setting })) + }) const onMessage = useCallback((event: MessageEvent) => { const message: ExtensionMessage = event.data @@ -68,6 +87,13 @@ export const TerminalSettings = ({ case "terminal.integrated.inheritEnv": setInheritEnv(message.value ?? true) break + case "terminal.integrated.profiles.windows": + case "terminal.integrated.profiles.osx": + case "terminal.integrated.profiles.linux": { + const names = message.value && typeof message.value === "object" ? Object.keys(message.value) : [] + setProfileNames((prev) => Array.from(new Set([...prev, ...names])).sort()) + break + } default: break } @@ -139,6 +165,38 @@ export const TerminalSettings = ({
+ + + +
+ {t("settings:terminal.profile.description")} +
+
+ { 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..d8b41c77d3 --- /dev/null +++ b/webview-ui/src/components/settings/__tests__/TerminalSettings.profile.spec.tsx @@ -0,0 +1,135 @@ +// npx vitest run src/components/settings/__tests__/TerminalSettings.profile.spec.tsx + +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 }: any) => ( +
+ {/* Recursively render items and wire their value to onValueChange */} + {renderSelectChildren(children, onValueChange)} +
+ ), + SelectTrigger: ({ children }: 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}, +})) + +// Helper used by the Select mock to render SelectItem children as buttons. +function renderSelectChildren(children: any, onValueChange: (value: string) => void): any { + const React = require("react") + return React.Children.map(children, (child: any) => { + if (!child || typeof child !== "object") return child + const itemValue = child.props?.value ?? child.props?.["data-item-value"] + // SelectContent wraps SelectItems; recurse into it. + if (child.props?.children && itemValue === undefined) { + return renderSelectChildren(child.props.children, onValueChange) + } + if (itemValue !== undefined) { + return ( + + ) + } + return child + }) +} + +describe("TerminalSettings inline terminal profile (#119)", () => { + beforeEach(() => { + postMessageMock.mockClear() + }) + + const setup = (terminalProfile?: string) => { + const setCachedStateField = vi.fn() + render( + , + ) + return { setCachedStateField } + } + + it("requests the VS Code terminal profile lists on mount", () => { + setup() + + const requested = postMessageMock.mock.calls.map((c) => c[0]?.setting) + expect(requested).toContain("terminal.integrated.profiles.windows") + expect(requested).toContain("terminal.integrated.profiles.osx") + expect(requested).toContain("terminal.integrated.profiles.linux") + }) + + it("does not call setCachedStateField on init (only the Default option is shown)", () => { + const { setCachedStateField } = setup() + // No profiles received yet -> only the Default option exists. + expect(screen.getByTestId("option-__default__")).toBeInTheDocument() + expect(setCachedStateField).not.toHaveBeenCalled() + }) + + it("populates the dropdown from received profile lists and selecting one sets the profile name", () => { + const { setCachedStateField } = setup() + + // Simulate the extension responding with a Windows profile list. + act(() => { + window.dispatchEvent( + new MessageEvent("message", { + data: { + type: "vsCodeSetting", + setting: "terminal.integrated.profiles.windows", + value: { "Git Bash": { path: "C:/Program Files/Git/bin/bash.exe" }, PowerShell: {} }, + }, + }), + ) + }) + + // User selects the Git Bash profile. + fireEvent.click(screen.getByTestId("option-Git Bash")) + + expect(setCachedStateField).toHaveBeenCalledWith("terminalProfile", "Git Bash") + }) + + it("maps the Default option back to undefined (restores default behavior)", () => { + const { setCachedStateField } = setup("Git Bash") + + fireEvent.click(screen.getByTestId("option-__default__")) + + expect(setCachedStateField).toHaveBeenCalledWith("terminalProfile", undefined) + }) +}) diff --git a/webview-ui/src/context/ExtensionStateContext.tsx b/webview-ui/src/context/ExtensionStateContext.tsx index f81de054c7..0aa8bcd15e 100644 --- a/webview-ui/src/context/ExtensionStateContext.tsx +++ b/webview-ui/src/context/ExtensionStateContext.tsx @@ -84,6 +84,8 @@ export interface ExtensionStateContextType extends ExtensionState { setTerminalShellIntegrationDisabled: (value: boolean) => void terminalZdotdir?: boolean setTerminalZdotdir: (value: boolean) => void + terminalProfile?: string + setTerminalProfile: (value: string | undefined) => void setTtsEnabled: (value: boolean) => void setTtsSpeed: (value: number) => void setEnableCheckpoints: (value: boolean) => void @@ -235,6 +237,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 inline 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 @@ -549,6 +552,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode setTerminalShellIntegrationDisabled: (value) => setState((prevState) => ({ ...prevState, terminalShellIntegrationDisabled: value })), setTerminalZdotdir: (value) => setState((prevState) => ({ ...prevState, terminalZdotdir: value })), + setTerminalProfile: (value) => setState((prevState) => ({ ...prevState, terminalProfile: value })), setMcpEnabled: (value) => setState((prevState) => ({ ...prevState, mcpEnabled: value })), setTaskSyncEnabled: (value) => setState((prevState) => ({ ...prevState, taskSyncEnabled: value }) as any), setCurrentApiConfigName: (value) => setState((prevState) => ({ ...prevState, currentApiConfigName: value })), diff --git a/webview-ui/src/i18n/locales/ca/settings.json b/webview-ui/src/i18n/locales/ca/settings.json index f7e3bb69e2..fedc51686a 100644 --- a/webview-ui/src/i18n/locales/ca/settings.json +++ b/webview-ui/src/i18n/locales/ca/settings.json @@ -741,6 +741,11 @@ "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": "Perfil de terminal en línia", + "default": "Predeterminat (intèrpret d'ordres predeterminat de VS Code)", + "description": "Tria quin perfil de terminal de VS Code utilitza el terminal en línia. Selecciona un intèrpret d'ordres UTF-8 com Git Bash si el predeterminat distorsiona la sortida no ASCII (per exemple, a Windows amb pàgina de codis GBK). Deixa-ho en Predeterminat per mantenir el comportament per defecte de VS Code." } }, "advancedSettings": { diff --git a/webview-ui/src/i18n/locales/de/settings.json b/webview-ui/src/i18n/locales/de/settings.json index 1aa9f96053..7f684091e8 100644 --- a/webview-ui/src/i18n/locales/de/settings.json +++ b/webview-ui/src/i18n/locales/de/settings.json @@ -741,6 +741,11 @@ "inheritEnv": { "label": "Umgebungsvariablen erben", "description": "Schalte dies ein, um Umgebungsvariablen vom übergeordneten VS Code-Prozess zu erben. <0>Mehr erfahren" + }, + "profile": { + "label": "Inline-Terminal-Profil", + "default": "Standard (VS Code-Standardshell)", + "description": "Wähle, welches VS Code-Terminalprofil das Inline-Terminal verwendet. Wähle eine UTF-8-Shell wie Git Bash, wenn die Standardshell Nicht-ASCII-Ausgaben verstümmelt (z. B. unter Windows mit GBK-Codepage). Belasse es auf Standard, um das Standardverhalten von VS Code beizubehalten." } }, "advancedSettings": { diff --git a/webview-ui/src/i18n/locales/en/settings.json b/webview-ui/src/i18n/locales/en/settings.json index 7f3527df6b..58d5129a82 100644 --- a/webview-ui/src/i18n/locales/en/settings.json +++ b/webview-ui/src/i18n/locales/en/settings.json @@ -809,6 +809,11 @@ "inheritEnv": { "label": "Inherit environment variables", "description": "Turn this on to inherit environment variables from the parent VS Code process. <0>Learn more" + }, + "profile": { + "label": "Inline terminal profile", + "default": "Default (VS Code default shell)", + "description": "Choose which VS Code terminal profile the inline terminal uses. Pick a UTF-8 shell such as Git Bash if the default shell garbles non-ASCII output (e.g. on Windows with a GBK code page). Leave on Default to keep VS Code's default behavior." } }, "advancedSettings": { diff --git a/webview-ui/src/i18n/locales/es/settings.json b/webview-ui/src/i18n/locales/es/settings.json index 154319baef..44d199df16 100644 --- a/webview-ui/src/i18n/locales/es/settings.json +++ b/webview-ui/src/i18n/locales/es/settings.json @@ -741,6 +741,11 @@ "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": "Perfil de terminal en línea", + "default": "Predeterminado (shell predeterminado de VS Code)", + "description": "Elige qué perfil de terminal de VS Code usa el terminal en línea. Selecciona un shell UTF-8 como Git Bash si el shell predeterminado distorsiona la salida no ASCII (por ejemplo, en Windows con página de códigos GBK). Déjalo en Predeterminado para mantener el comportamiento predeterminado de VS Code." } }, "advancedSettings": { diff --git a/webview-ui/src/i18n/locales/fr/settings.json b/webview-ui/src/i18n/locales/fr/settings.json index 30cdd37785..01bcfef36d 100644 --- a/webview-ui/src/i18n/locales/fr/settings.json +++ b/webview-ui/src/i18n/locales/fr/settings.json @@ -741,6 +741,11 @@ "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": "Profil de terminal intégré", + "default": "Par défaut (shell par défaut de VS Code)", + "description": "Choisissez le profil de terminal VS Code utilisé par le terminal intégré. Sélectionnez un shell UTF-8 tel que Git Bash si le shell par défaut altère la sortie non ASCII (par exemple sous Windows avec une page de codes GBK). Laissez sur Par défaut pour conserver le comportement par défaut de VS Code." } }, "advancedSettings": { diff --git a/webview-ui/src/i18n/locales/hi/settings.json b/webview-ui/src/i18n/locales/hi/settings.json index a334b8cb5f..de1c146375 100644 --- a/webview-ui/src/i18n/locales/hi/settings.json +++ b/webview-ui/src/i18n/locales/hi/settings.json @@ -741,6 +741,11 @@ "inheritEnv": { "label": "पर्यावरण चर विरासत में लें", "description": "पैरेंट VS Code प्रोसेस से पर्यावरण चर विरासत में लेने के लिए इसे चालू करें। <0>अधिक जानें" + }, + "profile": { + "label": "इनलाइन टर्मिनल प्रोफ़ाइल", + "default": "डिफ़ॉल्ट (VS Code डिफ़ॉल्ट शेल)", + "description": "चुनें कि इनलाइन टर्मिनल किस VS Code टर्मिनल प्रोफ़ाइल का उपयोग करता है। यदि डिफ़ॉल्ट शेल गैर-ASCII आउटपुट को विकृत करता है (जैसे GBK कोड पेज वाले Windows पर) तो Git Bash जैसा UTF-8 शेल चुनें। VS Code के डिफ़ॉल्ट व्यवहार को बनाए रखने के लिए डिफ़ॉल्ट पर छोड़ दें।" } }, "advancedSettings": { diff --git a/webview-ui/src/i18n/locales/id/settings.json b/webview-ui/src/i18n/locales/id/settings.json index 23e974429e..1af8c70d80 100644 --- a/webview-ui/src/i18n/locales/id/settings.json +++ b/webview-ui/src/i18n/locales/id/settings.json @@ -741,6 +741,11 @@ "inheritEnv": { "label": "Warisi variabel lingkungan", "description": "Aktifkan untuk mewarisi variabel lingkungan dari proses induk VS Code. <0>Pelajari lebih lanjut" + }, + "profile": { + "label": "Profil terminal sebaris", + "default": "Default (shell default VS Code)", + "description": "Pilih profil terminal VS Code yang digunakan terminal sebaris. Pilih shell UTF-8 seperti Git Bash jika shell default merusak keluaran non-ASCII (misalnya di Windows dengan code page GBK). Biarkan pada Default untuk mempertahankan perilaku default VS Code." } }, "advancedSettings": { diff --git a/webview-ui/src/i18n/locales/it/settings.json b/webview-ui/src/i18n/locales/it/settings.json index 3015d9338d..4b03bb9ac1 100644 --- a/webview-ui/src/i18n/locales/it/settings.json +++ b/webview-ui/src/i18n/locales/it/settings.json @@ -741,6 +741,11 @@ "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": "Profilo del terminale inline", + "default": "Predefinito (shell predefinita di VS Code)", + "description": "Scegli quale profilo del terminale di VS Code usa il terminale inline. Seleziona una shell UTF-8 come Git Bash se la shell predefinita corrompe l'output non ASCII (ad esempio su Windows con codepage GBK). Lascia su Predefinito per mantenere il comportamento predefinito di VS Code." } }, "advancedSettings": { diff --git a/webview-ui/src/i18n/locales/ja/settings.json b/webview-ui/src/i18n/locales/ja/settings.json index 02091510e5..646e97d234 100644 --- a/webview-ui/src/i18n/locales/ja/settings.json +++ b/webview-ui/src/i18n/locales/ja/settings.json @@ -741,6 +741,11 @@ "inheritEnv": { "label": "環境変数を継承", "description": "親VS Codeプロセスから環境変数を継承するには、これをオンにします。<0>詳細情報" + }, + "profile": { + "label": "インラインターミナルプロファイル", + "default": "デフォルト(VS Code のデフォルトシェル)", + "description": "インラインターミナルが使用する VS Code のターミナルプロファイルを選択します。デフォルトのシェルが ASCII 以外の出力を文字化けさせる場合(例: GBK コードページの Windows)、Git Bash などの UTF-8 シェルを選択してください。VS Code のデフォルト動作を維持するにはデフォルトのままにします。" } }, "advancedSettings": { diff --git a/webview-ui/src/i18n/locales/ko/settings.json b/webview-ui/src/i18n/locales/ko/settings.json index 4a2155aba4..581a708dfb 100644 --- a/webview-ui/src/i18n/locales/ko/settings.json +++ b/webview-ui/src/i18n/locales/ko/settings.json @@ -741,6 +741,11 @@ "inheritEnv": { "label": "환경 변수 상속", "description": "부모 VS Code 프로세���에서 환경 변수를 상속하려면 이 기능을 켜십시오. <0>자세히 알아보기" + }, + "profile": { + "label": "인라인 터미널 프로필", + "default": "기본값 (VS Code 기본 셸)", + "description": "인라인 터미널이 사용할 VS Code 터미널 프로필을 선택합니다. 기본 셸이 ASCII가 아닌 출력을 깨뜨리는 경우(예: GBK 코드 페이지의 Windows) Git Bash와 같은 UTF-8 셸을 선택하세요. VS Code 기본 동작을 유지하려면 기본값으로 두세요." } }, "advancedSettings": { diff --git a/webview-ui/src/i18n/locales/nl/settings.json b/webview-ui/src/i18n/locales/nl/settings.json index 006fd8721f..5ead34b53a 100644 --- a/webview-ui/src/i18n/locales/nl/settings.json +++ b/webview-ui/src/i18n/locales/nl/settings.json @@ -741,6 +741,11 @@ "inheritEnv": { "label": "Omgevingsvariabelen overnemen", "description": "Schakel in om omgevingsvariabelen over te nemen van het bovenliggende VS Code-proces. <0>Meer informatie" + }, + "profile": { + "label": "Inline-terminalprofiel", + "default": "Standaard (standaardshell van VS Code)", + "description": "Kies welk VS Code-terminalprofiel de inline-terminal gebruikt. Selecteer een UTF-8-shell zoals Git Bash als de standaardshell niet-ASCII-uitvoer verminkt (bijvoorbeeld op Windows met een GBK-codepagina). Laat op Standaard staan om het standaardgedrag van VS Code te behouden." } }, "advancedSettings": { diff --git a/webview-ui/src/i18n/locales/pl/settings.json b/webview-ui/src/i18n/locales/pl/settings.json index c4492da87c..e228db58d6 100644 --- a/webview-ui/src/i18n/locales/pl/settings.json +++ b/webview-ui/src/i18n/locales/pl/settings.json @@ -741,6 +741,11 @@ "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": "Profil terminala wbudowanego", + "default": "Domyślny (domyślna powłoka VS Code)", + "description": "Wybierz, którego profilu terminala VS Code używa terminal wbudowany. Wybierz powłokę UTF-8, np. Git Bash, jeśli domyślna powłoka zniekształca dane wyjściowe spoza ASCII (np. w systemie Windows ze stroną kodową GBK). Pozostaw Domyślny, aby zachować domyślne zachowanie VS Code." } }, "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..9f560d5040 100644 --- a/webview-ui/src/i18n/locales/pt-BR/settings.json +++ b/webview-ui/src/i18n/locales/pt-BR/settings.json @@ -741,6 +741,11 @@ "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": "Perfil do terminal embutido", + "default": "Padrão (shell padrão do VS Code)", + "description": "Escolha qual perfil de terminal do VS Code o terminal embutido usa. Selecione um shell UTF-8 como o Git Bash se o shell padrão corromper a saída não ASCII (por exemplo, no Windows com página de código GBK). Mantenha em Padrão para preservar o comportamento padrão do VS Code." } }, "advancedSettings": { diff --git a/webview-ui/src/i18n/locales/ru/settings.json b/webview-ui/src/i18n/locales/ru/settings.json index 2641939b9b..8f58f903c8 100644 --- a/webview-ui/src/i18n/locales/ru/settings.json +++ b/webview-ui/src/i18n/locales/ru/settings.json @@ -741,6 +741,11 @@ "inheritEnv": { "label": "Наследовать переменные среды", "description": "Включите для наследования переменных среды от родительского процесса VS Code. <0>Подробнее" + }, + "profile": { + "label": "Профиль встроенного терминала", + "default": "По умолчанию (оболочка VS Code по умолчанию)", + "description": "Выберите профиль терминала VS Code, который использует встроенный терминал. Выберите оболочку UTF-8, например Git Bash, если оболочка по умолчанию искажает не-ASCII вывод (например, в Windows с кодовой страницей GBK). Оставьте «По умолчанию», чтобы сохранить стандартное поведение VS Code." } }, "advancedSettings": { diff --git a/webview-ui/src/i18n/locales/tr/settings.json b/webview-ui/src/i18n/locales/tr/settings.json index 2ed19417e7..ed371e3987 100644 --- a/webview-ui/src/i18n/locales/tr/settings.json +++ b/webview-ui/src/i18n/locales/tr/settings.json @@ -741,6 +741,11 @@ "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": "Satır içi terminal profili", + "default": "Varsayılan (VS Code varsayılan kabuğu)", + "description": "Satır içi terminalin kullanacağı VS Code terminal profilini seçin. Varsayılan kabuk ASCII olmayan çıktıyı bozuyorsa (örneğin GBK kod sayfası olan Windows'ta) Git Bash gibi bir UTF-8 kabuğu seçin. VS Code varsayılan davranışını korumak için Varsayılan olarak bırakın." } }, "advancedSettings": { diff --git a/webview-ui/src/i18n/locales/vi/settings.json b/webview-ui/src/i18n/locales/vi/settings.json index e32beeff3e..e3b74227f0 100644 --- a/webview-ui/src/i18n/locales/vi/settings.json +++ b/webview-ui/src/i18n/locales/vi/settings.json @@ -741,6 +741,11 @@ "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": "Hồ sơ terminal nội tuyến", + "default": "Mặc định (shell mặc định của VS Code)", + "description": "Chọn hồ sơ terminal VS Code mà terminal nội tuyến sử dụng. Chọn một shell UTF-8 như Git Bash nếu shell mặc định làm hỏng đầu ra không phải ASCII (ví dụ trên Windows với bảng mã GBK). Để Mặc định để giữ hành vi mặc định của VS Code." } }, "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..ba0af3f538 100644 --- a/webview-ui/src/i18n/locales/zh-CN/settings.json +++ b/webview-ui/src/i18n/locales/zh-CN/settings.json @@ -741,6 +741,11 @@ "inheritEnv": { "label": "继承环境变量", "description": "启用此选项以从父 VS Code 进程继承环境变量。<0>了解更多" + }, + "profile": { + "label": "内联终端配置文件", + "default": "默认(VS Code 默认 Shell)", + "description": "选择内联终端使用的 VS Code 终端配置文件。如果默认 Shell 会使非 ASCII 输出乱码(例如在使用 GBK 代码页的 Windows 上),请选择 Git Bash 等 UTF-8 Shell。保留为“默认”以保持 VS Code 的默认行为。" } }, "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..1af770604d 100644 --- a/webview-ui/src/i18n/locales/zh-TW/settings.json +++ b/webview-ui/src/i18n/locales/zh-TW/settings.json @@ -756,6 +756,11 @@ "inheritEnv": { "label": "繼承環境變數", "description": "啟用此選項以從父 VS Code 程序繼承環境變數。<0>了解更多" + }, + "profile": { + "label": "內嵌終端機設定檔", + "default": "預設(VS Code 預設 Shell)", + "description": "選擇內嵌終端機使用的 VS Code 終端機設定檔。如果預設 Shell 會使非 ASCII 輸出亂碼(例如在使用 GBK 字碼頁的 Windows 上),請選擇 Git Bash 等 UTF-8 Shell。保留為「預設」以維持 VS Code 的預設行為。" } }, "advancedSettings": { From 5c825c7e56a8653992e9aeb72e04233a24c1cbc3 Mon Sep 17 00:00:00 2001 From: Armando Vaquera <263793884+proyectoauraorg@users.noreply.github.com> Date: Sat, 23 May 2026 19:40:34 -0600 Subject: [PATCH 02/21] fix(test): relax spy types for overloaded VS Code APIs (#119) --- src/integrations/terminal/__tests__/TerminalProfile.spec.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/integrations/terminal/__tests__/TerminalProfile.spec.ts b/src/integrations/terminal/__tests__/TerminalProfile.spec.ts index f2e252429b..56d7a3eea0 100644 --- a/src/integrations/terminal/__tests__/TerminalProfile.spec.ts +++ b/src/integrations/terminal/__tests__/TerminalProfile.spec.ts @@ -10,8 +10,10 @@ vi.mock("execa", () => ({ })) describe("Terminal inline terminal profile (#119)", () => { - let getConfigurationSpy: ReturnType - let createTerminalSpy: ReturnType + // 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 = () => ({ From 4abd4c2cdb7cff0491e2cfe36816865526fb0d30 Mon Sep 17 00:00:00 2001 From: Armando Vaquera <263793884+proyectoauraorg@users.noreply.github.com> Date: Sat, 23 May 2026 20:02:11 -0600 Subject: [PATCH 03/21] fix(test): use ES import instead of require() in terminal profile spec (#119) --- .../settings/__tests__/TerminalSettings.profile.spec.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/webview-ui/src/components/settings/__tests__/TerminalSettings.profile.spec.tsx b/webview-ui/src/components/settings/__tests__/TerminalSettings.profile.spec.tsx index d8b41c77d3..a045658c18 100644 --- a/webview-ui/src/components/settings/__tests__/TerminalSettings.profile.spec.tsx +++ b/webview-ui/src/components/settings/__tests__/TerminalSettings.profile.spec.tsx @@ -1,5 +1,7 @@ // 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" @@ -51,7 +53,6 @@ vi.mock("@vscode/webview-ui-toolkit/react", () => ({ // Helper used by the Select mock to render SelectItem children as buttons. function renderSelectChildren(children: any, onValueChange: (value: string) => void): any { - const React = require("react") return React.Children.map(children, (child: any) => { if (!child || typeof child !== "object") return child const itemValue = child.props?.value ?? child.props?.["data-item-value"] From e2b0d64a6db9778845bf91f4a0b50953628f0f03 Mon Sep 17 00:00:00 2001 From: Armando Vaquera <263793884+proyectoauraorg@users.noreply.github.com> Date: Sun, 24 May 2026 16:01:30 -0600 Subject: [PATCH 04/21] refactor(terminal): address review feedback on inline terminal profile (#119) - Route profile names through a dedicated allowlisted `requestTerminalProfiles` message instead of the generic `getVSCodeSetting` (which reads any key the webview supplies); the extension reads the profiles and returns only names. - Preserve the profile's `env` (sanitized to string/null; null unsets a var), merged onto the base env in createTerminal. - Clarify the setting copy (en + es) vs the 'Use Inline Terminal' description. - Add tests: updateSettings->setTerminalProfile bridge, resolveWebviewView startup hydration, and profile env preservation/sanitization. --- packages/types/src/vscode-extension-host.ts | 4 ++ .../webview/__tests__/ClineProvider.spec.ts | 15 +++++++ .../__tests__/webviewMessageHandler.spec.ts | 33 ++++++++++++++++ src/core/webview/webviewMessageHandler.ts | 32 +++++++++++++++ src/integrations/terminal/Terminal.ts | 39 +++++++++++++++++-- .../__tests__/TerminalProfile.spec.ts | 20 ++++++++++ .../components/settings/TerminalSettings.tsx | 30 ++++---------- .../TerminalSettings.profile.spec.tsx | 24 ++++-------- webview-ui/src/i18n/locales/en/settings.json | 2 +- webview-ui/src/i18n/locales/es/settings.json | 2 +- 10 files changed, 156 insertions(+), 45 deletions(-) diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index fef244f29b..9cf6f38a2e 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 @@ -454,6 +457,7 @@ export interface WebviewMessage { | "updateVSCodeSetting" | "getVSCodeSetting" | "vsCodeSetting" + | "requestTerminalProfiles" | "updateCondensingPrompt" | "playSound" | "playTts" 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..06c36a398d 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.spec.ts @@ -167,6 +167,7 @@ vi.mock("../../mentions/resolveImageMentions", () => ({ })) import { resolveImageMentions } from "../../mentions/resolveImageMentions" +import { Terminal } from "../../../integrations/terminal/Terminal" describe("webviewMessageHandler - requestLmStudioModels", () => { beforeEach(() => { @@ -866,6 +867,38 @@ describe("webviewMessageHandler - mcpEnabled", () => { }) }) +describe("webviewMessageHandler - terminalProfile", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it("bridges a saved terminalProfile from updateSettings into the process-wide terminal state", async () => { + const setTerminalProfileSpy = vi.spyOn(Terminal, "setTerminalProfile").mockImplementation(() => {}) + + await webviewMessageHandler(mockClineProvider, { + type: "updateSettings", + updatedSettings: { terminalProfile: "Git Bash" }, + }) + + expect(setTerminalProfileSpy).toHaveBeenCalledWith("Git Bash") + + setTerminalProfileSpy.mockRestore() + }) + + it("clears the terminal profile when updateSettings sends undefined", async () => { + const setTerminalProfileSpy = vi.spyOn(Terminal, "setTerminalProfile").mockImplementation(() => {}) + + await webviewMessageHandler(mockClineProvider, { + type: "updateSettings", + updatedSettings: { terminalProfile: undefined }, + }) + + expect(setTerminalProfileSpy).toHaveBeenCalledWith(undefined) + + setTerminalProfileSpy.mockRestore() + }) +}) + describe("webviewMessageHandler - requestCommands", () => { beforeEach(() => { vi.clearAllMocks() diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index e257b594ae..2101bf22c9 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -1519,6 +1519,38 @@ 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). + try { + const names = new Set() + + for (const platform of ["windows", "osx", "linux"] as const) { + const profiles = vscode.workspace + .getConfiguration("terminal.integrated.profiles") + .get>(platform) + + if (profiles && typeof profiles === "object") { + for (const name of Object.keys(profiles)) { + names.add(name) + } + } + } + + await provider.postMessageToWebview({ + type: "terminalProfiles", + profiles: Array.from(names).sort(), + }) + } 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/integrations/terminal/Terminal.ts b/src/integrations/terminal/Terminal.ts index 23d59b05ae..8fb651e5c7 100644 --- a/src/integrations/terminal/Terminal.ts +++ b/src/integrations/terminal/Terminal.ts @@ -24,7 +24,7 @@ export class Terminal extends BaseTerminal { const options: vscode.TerminalOptions = { cwd, name: "Roo Code", iconPath, env } // When the user has chosen a specific terminal profile, resolve it to a - // shell path/args so the inline terminal uses that shell (e.g. Git Bash + // shell path/args/env so the inline terminal uses that shell (e.g. Git Bash // with a UTF-8 charset on Windows). When unset, we leave shellPath/shellArgs // undefined so VS Code's default terminal behavior is preserved (#119). const profileShell = Terminal.getProfileShell() @@ -35,6 +35,12 @@ export class Terminal extends BaseTerminal { if (profileShell.shellArgs) { options.shellArgs = profileShell.shellArgs } + + // Merge the profile's own env on top of the base env so profile-specific + // variables (e.g. locale/PATH) are not lost. A `null` value unsets one. + if (profileShell.env) { + options.env = { ...env, ...profileShell.env } + } } this.terminal = vscode.window.createTerminal(options) @@ -243,7 +249,7 @@ export class Terminal extends BaseTerminal { */ public static getProfileShell( platform: NodeJS.Platform = process.platform, - ): { shellPath: string; shellArgs?: string[] } | undefined { + ): { shellPath: string; shellArgs?: string[]; env?: Record } | undefined { const profileName = Terminal.getTerminalProfile() if (!profileName) { @@ -257,7 +263,12 @@ export class Terminal extends BaseTerminal { .get>(platformKey) const profile = profiles?.[profileName] as - | { path?: string | string[]; args?: string | string[]; source?: string } + | { + path?: string | string[] + args?: string | string[] + source?: string + env?: Record + } | null | undefined @@ -285,6 +296,26 @@ export class Terminal extends BaseTerminal { ? [profile.args] : undefined - return { shellPath: pathValue, shellArgs } + // 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 = {} + + for (const [key, val] of Object.entries(profile.env)) { + if (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/__tests__/TerminalProfile.spec.ts b/src/integrations/terminal/__tests__/TerminalProfile.spec.ts index 56d7a3eea0..297c737acf 100644 --- a/src/integrations/terminal/__tests__/TerminalProfile.spec.ts +++ b/src/integrations/terminal/__tests__/TerminalProfile.spec.ts @@ -97,6 +97,26 @@ describe("Terminal inline terminal profile (#119)", () => { }) }) + 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 }, + }, + }, + }) + + Terminal.setTerminalProfile("Custom Bash") + + expect(Terminal.getProfileShell("linux")).toEqual({ + shellPath: "/bin/bash", + shellArgs: undefined, + // `null` is preserved (unsets the var); the numeric `BAD` is dropped. + env: { LANG: "en_US.UTF-8", UNSET_ME: null }, + }) + }) + it("uses the first path candidate when path is an array", () => { stubProfiles({ windows: { diff --git a/webview-ui/src/components/settings/TerminalSettings.tsx b/webview-ui/src/components/settings/TerminalSettings.tsx index e945edfaac..f3b6c98667 100644 --- a/webview-ui/src/components/settings/TerminalSettings.tsx +++ b/webview-ui/src/components/settings/TerminalSettings.tsx @@ -45,14 +45,6 @@ type TerminalSettingsProps = HTMLAttributes & { // empty-string item value, so we map it to/from `undefined` in the handler. const DEFAULT_PROFILE_VALUE = "__default__" -// VS Code stores terminal profiles per platform; we request all of them so the -// profile dropdown works regardless of which OS the extension host runs on. -const PROFILE_SETTING_KEYS = [ - "terminal.integrated.profiles.windows", - "terminal.integrated.profiles.osx", - "terminal.integrated.profiles.linux", -] - export const TerminalSettings = ({ terminalOutputPreviewSize, terminalShellIntegrationTimeout, @@ -75,7 +67,9 @@ export const TerminalSettings = ({ useMount(() => { vscode.postMessage({ type: "getVSCodeSetting", setting: "terminal.integrated.inheritEnv" }) - PROFILE_SETTING_KEYS.forEach((setting) => vscode.postMessage({ type: "getVSCodeSetting", setting })) + // 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) => { @@ -83,21 +77,13 @@ export const TerminalSettings = ({ switch (message.type) { case "vsCodeSetting": - switch (message.setting) { - case "terminal.integrated.inheritEnv": - setInheritEnv(message.value ?? true) - break - case "terminal.integrated.profiles.windows": - case "terminal.integrated.profiles.osx": - case "terminal.integrated.profiles.linux": { - const names = message.value && typeof message.value === "object" ? Object.keys(message.value) : [] - setProfileNames((prev) => Array.from(new Set([...prev, ...names])).sort()) - break - } - default: - break + if (message.setting === "terminal.integrated.inheritEnv") { + setInheritEnv(message.value ?? true) } break + case "terminalProfiles": + setProfileNames(message.profiles ?? []) + break default: break } diff --git a/webview-ui/src/components/settings/__tests__/TerminalSettings.profile.spec.tsx b/webview-ui/src/components/settings/__tests__/TerminalSettings.profile.spec.tsx index a045658c18..b8c6b0de30 100644 --- a/webview-ui/src/components/settings/__tests__/TerminalSettings.profile.spec.tsx +++ b/webview-ui/src/components/settings/__tests__/TerminalSettings.profile.spec.tsx @@ -33,11 +33,7 @@ vi.mock("@/components/ui", () => ({ SelectContent: ({ children }: any) =>
{children}
, SelectItem: ({ children, value }: any) =>
{children}
, Slider: ({ value, onValueChange }: any) => ( - onValueChange([parseFloat(e.target.value)])} - /> + onValueChange([parseFloat(e.target.value)])} /> ), })) @@ -88,13 +84,11 @@ describe("TerminalSettings inline terminal profile (#119)", () => { return { setCachedStateField } } - it("requests the VS Code terminal profile lists on mount", () => { + it("requests the terminal profile names on mount via the allowlisted message", () => { setup() - const requested = postMessageMock.mock.calls.map((c) => c[0]?.setting) - expect(requested).toContain("terminal.integrated.profiles.windows") - expect(requested).toContain("terminal.integrated.profiles.osx") - expect(requested).toContain("terminal.integrated.profiles.linux") + const types = postMessageMock.mock.calls.map((c) => c[0]?.type) + expect(types).toContain("requestTerminalProfiles") }) it("does not call setCachedStateField on init (only the Default option is shown)", () => { @@ -104,18 +98,14 @@ describe("TerminalSettings inline terminal profile (#119)", () => { expect(setCachedStateField).not.toHaveBeenCalled() }) - it("populates the dropdown from received profile lists and selecting one sets the profile name", () => { + it("populates the dropdown from the received profile names and selecting one sets the profile name", () => { const { setCachedStateField } = setup() - // Simulate the extension responding with a Windows profile list. + // Simulate the extension responding with the sanitized profile names. act(() => { window.dispatchEvent( new MessageEvent("message", { - data: { - type: "vsCodeSetting", - setting: "terminal.integrated.profiles.windows", - value: { "Git Bash": { path: "C:/Program Files/Git/bin/bash.exe" }, PowerShell: {} }, - }, + data: { type: "terminalProfiles", profiles: ["Git Bash", "PowerShell"] }, }), ) }) diff --git a/webview-ui/src/i18n/locales/en/settings.json b/webview-ui/src/i18n/locales/en/settings.json index 58d5129a82..bb07409bef 100644 --- a/webview-ui/src/i18n/locales/en/settings.json +++ b/webview-ui/src/i18n/locales/en/settings.json @@ -813,7 +813,7 @@ "profile": { "label": "Inline terminal profile", "default": "Default (VS Code default shell)", - "description": "Choose which VS Code terminal profile the inline terminal uses. Pick a UTF-8 shell such as Git Bash if the default shell garbles non-ASCII output (e.g. on Windows with a GBK code page). Leave on Default to keep VS Code's default behavior." + "description": "Pick which shell the Inline Terminal launches, using a VS Code terminal profile's path and arguments. This only changes the shell binary — the Inline Terminal still bypasses shell integration, prompts, and plugins. Useful to choose a UTF-8 shell such as Git Bash when the default garbles non-ASCII output (e.g. on Windows with a GBK code page). Leave on Default to keep VS Code's default shell." } }, "advancedSettings": { diff --git a/webview-ui/src/i18n/locales/es/settings.json b/webview-ui/src/i18n/locales/es/settings.json index 44d199df16..669cf33fa3 100644 --- a/webview-ui/src/i18n/locales/es/settings.json +++ b/webview-ui/src/i18n/locales/es/settings.json @@ -745,7 +745,7 @@ "profile": { "label": "Perfil de terminal en línea", "default": "Predeterminado (shell predeterminado de VS Code)", - "description": "Elige qué perfil de terminal de VS Code usa el terminal en línea. Selecciona un shell UTF-8 como Git Bash si el shell predeterminado distorsiona la salida no ASCII (por ejemplo, en Windows con página de códigos GBK). Déjalo en Predeterminado para mantener el comportamiento predeterminado de VS Code." + "description": "Elige qué shell lanza el Terminal en línea, usando la ruta y argumentos de un perfil de terminal de VS Code. Esto solo cambia el binario del shell — el Terminal en línea sigue evitando la integración del shell, prompts y plugins. Útil para elegir un shell UTF-8 como Git Bash cuando el predeterminado distorsiona la salida no ASCII (por ejemplo, en Windows con página de códigos GBK). Déjalo en Predeterminado para mantener el shell predeterminado de VS Code." } }, "advancedSettings": { From aa60a5dd2601b36cb860d05f82b6be28feba4ffe Mon Sep 17 00:00:00 2001 From: Armando Vaquera <263793884+proyectoauraorg@users.noreply.github.com> Date: Sun, 24 May 2026 20:59:11 -0600 Subject: [PATCH 05/21] fix(terminal): resolve profile path[] to first existing candidate (#119) VS Code selects the first terminal-profile path candidate that exists on disk; mirror that instead of always taking index 0, falling back to the first non-empty candidate when none exist. Addresses CodeRabbit review on #277. --- src/integrations/terminal/Terminal.ts | 11 +++++-- .../__tests__/TerminalProfile.spec.ts | 33 ++++++++++++++++++- 2 files changed, 40 insertions(+), 4 deletions(-) diff --git a/src/integrations/terminal/Terminal.ts b/src/integrations/terminal/Terminal.ts index 8fb651e5c7..2e90f2eb1c 100644 --- a/src/integrations/terminal/Terminal.ts +++ b/src/integrations/terminal/Terminal.ts @@ -1,3 +1,5 @@ +import { existsSync } from "fs" + import * as vscode from "vscode" import pWaitFor from "p-wait-for" @@ -277,9 +279,12 @@ export class Terminal extends BaseTerminal { return undefined } - // A `path` may be a single string or an array of candidate paths (VS Code - // picks the first that exists). We pass the first candidate to createTerminal. - const pathValue = Array.isArray(profile.path) ? profile.path[0] : profile.path + // A `path` may be a single string or an array of candidate paths. VS Code + // picks the first candidate that exists on disk, so mirror that: prefer the + // first existing path, otherwise fall back to the first non-empty candidate. + const candidates = Array.isArray(profile.path) ? profile.path : [profile.path] + const nonEmpty = candidates.filter((p): p is string => typeof p === "string" && p.length > 0) + const pathValue = nonEmpty.find((p) => existsSync(p)) ?? nonEmpty[0] if (!pathValue) { // Profiles defined only by `source` (e.g. "PowerShell") can't be mapped to diff --git a/src/integrations/terminal/__tests__/TerminalProfile.spec.ts b/src/integrations/terminal/__tests__/TerminalProfile.spec.ts index 297c737acf..974b335de7 100644 --- a/src/integrations/terminal/__tests__/TerminalProfile.spec.ts +++ b/src/integrations/terminal/__tests__/TerminalProfile.spec.ts @@ -1,5 +1,7 @@ // npx vitest run src/integrations/terminal/__tests__/TerminalProfile.spec.ts +import { existsSync } from "fs" + import * as vscode from "vscode" import { Terminal } from "../Terminal" @@ -9,6 +11,12 @@ vi.mock("execa", () => ({ execa: vi.fn(), })) +vi.mock("fs", () => ({ + existsSync: vi.fn(() => false), +})) + +const mockedExistsSync = existsSync as unknown as ReturnType + describe("Terminal inline terminal profile (#119)", () => { // VS Code's getConfiguration/createTerminal are overloaded, so the precise // spy MockInstance type isn't worth fighting in a test — `any` keeps it simple. @@ -44,6 +52,9 @@ describe("Terminal inline terminal profile (#119)", () => { beforeEach(() => { createTerminalSpy = vi.spyOn(vscode.window, "createTerminal").mockImplementation(() => mockTerminal()) + // Default: no candidate path exists on disk unless a test says otherwise. + mockedExistsSync.mockReset() + mockedExistsSync.mockReturnValue(false) // Reset to default (unset) before each test. Terminal.setTerminalProfile(undefined) }) @@ -117,7 +128,7 @@ describe("Terminal inline terminal profile (#119)", () => { }) }) - it("uses the first path candidate when path is an array", () => { + it("picks the first existing path candidate when path is an array", () => { stubProfiles({ windows: { "Git Bash": { @@ -125,6 +136,26 @@ describe("Terminal inline terminal profile (#119)", () => { }, }, }) + // 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 the first non-empty candidate when none of the paths exist", () => { + stubProfiles({ + windows: { + "Git Bash": { + path: ["C:\\missing\\bash.exe", "C:\\also-missing\\bash.exe"], + }, + }, + }) + // existsSync defaults to false for every candidate. Terminal.setTerminalProfile("Git Bash") From 7f1d10743e018160cb22b5326e182b9d49efa28b Mon Sep 17 00:00:00 2001 From: Armando Vaquera <263793884+proyectoauraorg@users.noreply.github.com> Date: Wed, 27 May 2026 00:15:15 -0600 Subject: [PATCH 06/21] fix(terminal): hide inline profile picker when inline execution is off The terminal-profile dropdown only affects inline execution, which is active when shell integration is disabled. It previously stayed visible and editable even when inline mode was off, where it has no effect. Guard it with terminalShellIntegrationDisabled (defaulting to shown, matching the checkbox), mirroring the inline-only settings below. Addresses PR #277 review (edelauna). --- .../components/settings/TerminalSettings.tsx | 66 ++++++++++--------- .../TerminalSettings.profile.spec.tsx | 12 +++- 2 files changed, 47 insertions(+), 31 deletions(-) diff --git a/webview-ui/src/components/settings/TerminalSettings.tsx b/webview-ui/src/components/settings/TerminalSettings.tsx index f3b6c98667..9474fe652c 100644 --- a/webview-ui/src/components/settings/TerminalSettings.tsx +++ b/webview-ui/src/components/settings/TerminalSettings.tsx @@ -151,37 +151,43 @@ export const TerminalSettings = ({
- - - + setCachedStateField( + "terminalProfile", + value === DEFAULT_PROFILE_VALUE ? undefined : value, + ) + }> + + + + + + {t("settings:terminal.profile.default")} - ))} - - -
- {t("settings:terminal.profile.description")} -
-
+ {profileNames.map((name) => ( + + {name} + + ))} + + +
+ {t("settings:terminal.profile.description")} +
+ + )} { postMessageMock.mockClear() }) + // Inline execution is active when shell integration is disabled, which is when the + // profile picker is shown; render in that state for the picker-behavior tests. const setup = (terminalProfile?: string) => { const setCachedStateField = vi.fn() render( , @@ -123,4 +125,12 @@ describe("TerminalSettings inline terminal profile (#119)", () => { expect(setCachedStateField).toHaveBeenCalledWith("terminalProfile", undefined) }) + + it("hides the profile picker when inline execution is off (shell integration enabled)", () => { + render() + // The picker is inline-only: with shell integration enabled it must not render. + expect(screen.queryByTestId("option-__default__")).not.toBeInTheDocument() + // But the names are still requested on mount (cheap, harmless, keeps state warm). + expect(postMessageMock.mock.calls.map((c) => c[0]?.type)).toContain("requestTerminalProfiles") + }) }) From 4101d12279291851e8dab34a8b21a2b82e7ac8fb Mon Sep 17 00:00:00 2001 From: Elliott de Launay Date: Sun, 31 May 2026 02:12:01 +0000 Subject: [PATCH 07/21] fix(terminal): address review feedback --- .../__tests__/webviewMessageHandler.spec.ts | 44 +++++++++++++++++++ src/core/webview/webviewMessageHandler.ts | 18 ++++---- src/integrations/terminal/BaseTerminal.ts | 3 +- .../src/components/settings/SettingsView.tsx | 2 +- 4 files changed, 56 insertions(+), 11 deletions(-) diff --git a/src/core/webview/__tests__/webviewMessageHandler.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.spec.ts index 06c36a398d..7cb9f20a38 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.spec.ts @@ -103,6 +103,7 @@ vi.mock("vscode", () => { workspace: { workspaceFolders: [{ uri: { fsPath: "/mock/workspace" } }], openTextDocument, + getConfiguration: vi.fn(() => ({ get: vi.fn() })), }, } }) @@ -899,6 +900,49 @@ describe("webviewMessageHandler - terminalProfile", () => { }) }) +describe("webviewMessageHandler - requestTerminalProfiles", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it("posts sorted profile names for the active platform", async () => { + const mockGet = vi.fn().mockReturnValue({ "Git Bash": {}, PowerShell: {}, "Command Prompt": {} }) + vi.mocked(vscode.workspace.getConfiguration).mockReturnValue({ get: mockGet } as any) + + await webviewMessageHandler(mockClineProvider, { type: "requestTerminalProfiles" }) + + expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({ + type: "terminalProfiles", + profiles: ["Command Prompt", "Git Bash", "PowerShell"], + }) + }) + + it("posts an empty array when getConfiguration throws", async () => { + vi.mocked(vscode.workspace.getConfiguration).mockImplementation(() => { + throw new Error("config error") + }) + + await webviewMessageHandler(mockClineProvider, { type: "requestTerminalProfiles" }) + + expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({ + type: "terminalProfiles", + profiles: [], + }) + }) + + it("posts an empty array when no profiles are configured", async () => { + const mockGet = vi.fn().mockReturnValue(undefined) + vi.mocked(vscode.workspace.getConfiguration).mockReturnValue({ get: mockGet } as any) + + await webviewMessageHandler(mockClineProvider, { type: "requestTerminalProfiles" }) + + expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({ + type: "terminalProfiles", + profiles: [], + }) + }) +}) + describe("webviewMessageHandler - requestCommands", () => { beforeEach(() => { vi.clearAllMocks() diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 2101bf22c9..ffc28f1709 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -1527,15 +1527,15 @@ export const webviewMessageHandler = async ( try { const names = new Set() - for (const platform of ["windows", "osx", "linux"] as const) { - const profiles = vscode.workspace - .getConfiguration("terminal.integrated.profiles") - .get>(platform) - - if (profiles && typeof profiles === "object") { - for (const name of Object.keys(profiles)) { - names.add(name) - } + const platformKey = + process.platform === "win32" ? "windows" : process.platform === "darwin" ? "osx" : "linux" + const profiles = vscode.workspace + .getConfiguration("terminal.integrated.profiles") + .get>(platformKey) + + if (profiles && typeof profiles === "object") { + for (const name of Object.keys(profiles)) { + names.add(name) } } diff --git a/src/integrations/terminal/BaseTerminal.ts b/src/integrations/terminal/BaseTerminal.ts index cb807a259c..083b877be6 100644 --- a/src/integrations/terminal/BaseTerminal.ts +++ b/src/integrations/terminal/BaseTerminal.ts @@ -304,7 +304,8 @@ export abstract class BaseTerminal implements RooTerminal { * @param profile The terminal profile name, or undefined for the default */ public static setTerminalProfile(profile: string | undefined): void { - BaseTerminal.terminalProfile = profile && profile.trim().length > 0 ? profile : undefined + const normalized = profile?.trim() + BaseTerminal.terminalProfile = normalized && normalized.length > 0 ? normalized : undefined } /** diff --git a/webview-ui/src/components/settings/SettingsView.tsx b/webview-ui/src/components/settings/SettingsView.tsx index 0215470248..f5ce1a14f4 100644 --- a/webview-ui/src/components/settings/SettingsView.tsx +++ b/webview-ui/src/components/settings/SettingsView.tsx @@ -398,7 +398,7 @@ const SettingsView = forwardRef(({ onDone, t terminalZshOhMy, terminalZshP10k, terminalZdotdir, - terminalProfile, + terminalProfile: terminalProfile ?? "", terminalOutputPreviewSize: terminalOutputPreviewSize ?? "medium", mcpEnabled, maxOpenTabsContext: Math.min(Math.max(0, maxOpenTabsContext ?? 20), 500), From 1347ae60cca20f4096b7f7a8b26af8d630408ad8 Mon Sep 17 00:00:00 2001 From: Elliott de Launay Date: Sun, 31 May 2026 02:52:00 +0000 Subject: [PATCH 08/21] refactor(terminal): scope profile picker to VS Code integrated terminal, filter source-only profiles --- packages/types/src/vscode-extension-host.ts | 1 + .../__tests__/webviewMessageHandler.spec.ts | 98 ++++++++++++- src/core/webview/webviewMessageHandler.ts | 26 +++- src/integrations/terminal/BaseTerminal.ts | 8 +- src/integrations/terminal/Terminal.ts | 80 +++++++++-- .../__tests__/TerminalProfile.spec.ts | 33 +++-- .../src/components/settings/SettingsView.tsx | 3 +- .../components/settings/TerminalSettings.tsx | 131 +++++++++++++----- .../TerminalSettings.profile.spec.tsx | 112 ++++++++++----- .../src/context/ExtensionStateContext.tsx | 2 +- webview-ui/src/i18n/locales/ca/settings.json | 9 +- webview-ui/src/i18n/locales/de/settings.json | 9 +- webview-ui/src/i18n/locales/en/settings.json | 9 +- webview-ui/src/i18n/locales/es/settings.json | 9 +- webview-ui/src/i18n/locales/fr/settings.json | 9 +- webview-ui/src/i18n/locales/hi/settings.json | 9 +- webview-ui/src/i18n/locales/id/settings.json | 9 +- webview-ui/src/i18n/locales/it/settings.json | 9 +- webview-ui/src/i18n/locales/ja/settings.json | 9 +- webview-ui/src/i18n/locales/ko/settings.json | 9 +- webview-ui/src/i18n/locales/nl/settings.json | 9 +- webview-ui/src/i18n/locales/pl/settings.json | 9 +- .../src/i18n/locales/pt-BR/settings.json | 9 +- webview-ui/src/i18n/locales/ru/settings.json | 9 +- webview-ui/src/i18n/locales/tr/settings.json | 9 +- webview-ui/src/i18n/locales/vi/settings.json | 9 +- .../src/i18n/locales/zh-CN/settings.json | 9 +- .../src/i18n/locales/zh-TW/settings.json | 9 +- 28 files changed, 503 insertions(+), 153 deletions(-) diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index 9cf6f38a2e..0f9e00ed99 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -458,6 +458,7 @@ export interface WebviewMessage { | "getVSCodeSetting" | "vsCodeSetting" | "requestTerminalProfiles" + | "openTerminalProfilePicker" | "updateCondensingPrompt" | "playSound" | "playTts" diff --git a/src/core/webview/__tests__/webviewMessageHandler.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.spec.ts index 7cb9f20a38..c371583a9e 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.spec.ts @@ -105,6 +105,9 @@ vi.mock("vscode", () => { openTextDocument, getConfiguration: vi.fn(() => ({ get: vi.fn() })), }, + commands: { + executeCommand: vi.fn().mockResolvedValue(undefined), + }, } }) @@ -903,17 +906,48 @@ describe("webviewMessageHandler - terminalProfile", () => { describe("webviewMessageHandler - requestTerminalProfiles", () => { beforeEach(() => { vi.clearAllMocks() + vi.spyOn(Terminal, "resolveProfilePath").mockImplementation((profilePath) => { + const candidates = Array.isArray(profilePath) ? profilePath : [profilePath] + const value = candidates.find( + (candidate) => + typeof candidate === "string" && candidate.trim().length > 0 && !candidate.includes("missing"), + ) + return typeof value === "string" ? value.trim() : undefined + }) + }) + + afterEach(() => { + vi.restoreAllMocks() }) - it("posts sorted profile names for the active platform", async () => { - const mockGet = vi.fn().mockReturnValue({ "Git Bash": {}, PowerShell: {}, "Command Prompt": {} }) + it("posts sorted path-resolvable profile names for the active platform", async () => { + const mockGet = vi.fn().mockReturnValue({ + "Git Bash": { path: "C:\\Git\\bin\\bash.exe" }, + bash: { path: "/bin/bash" }, + PowerShell: { source: "PowerShell" }, // source-only — must be excluded + }) vi.mocked(vscode.workspace.getConfiguration).mockReturnValue({ get: mockGet } as any) await webviewMessageHandler(mockClineProvider, { type: "requestTerminalProfiles" }) expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({ type: "terminalProfiles", - profiles: ["Command Prompt", "Git Bash", "PowerShell"], + profiles: ["Git Bash", "bash"], + }) + }) + + it("excludes source-only profiles that have no path field", async () => { + const mockGet = vi.fn().mockReturnValue({ + PowerShell: { source: "PowerShell" }, + "Windows PowerShell": { source: "PowerShell" }, + }) + vi.mocked(vscode.workspace.getConfiguration).mockReturnValue({ get: mockGet } as any) + + await webviewMessageHandler(mockClineProvider, { type: "requestTerminalProfiles" }) + + expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({ + type: "terminalProfiles", + profiles: [], }) }) @@ -941,6 +975,64 @@ describe("webviewMessageHandler - requestTerminalProfiles", () => { profiles: [], }) }) + + it("excludes profiles with empty or whitespace-only path strings", async () => { + const mockGet = vi.fn().mockReturnValue({ + empty: { path: "" }, + whitespace: { path: " " }, + valid: { path: "/bin/bash" }, + }) + vi.mocked(vscode.workspace.getConfiguration).mockReturnValue({ get: mockGet } as any) + + await webviewMessageHandler(mockClineProvider, { type: "requestTerminalProfiles" }) + + expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({ + type: "terminalProfiles", + profiles: ["valid"], + }) + }) + + it("excludes profiles with path arrays containing only empty or whitespace strings", async () => { + const mockGet = vi.fn().mockReturnValue({ + emptyArray: { path: [] }, + whitespaceArray: { path: ["", " "] }, + valid: { path: ["/bin/bash", "/usr/bin/bash"] }, + }) + vi.mocked(vscode.workspace.getConfiguration).mockReturnValue({ get: mockGet } as any) + + await webviewMessageHandler(mockClineProvider, { type: "requestTerminalProfiles" }) + + expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({ + type: "terminalProfiles", + profiles: ["valid"], + }) + }) + + it("excludes profiles whose executable cannot be resolved", async () => { + const mockGet = vi.fn().mockReturnValue({ + missing: { path: "/missing/bash" }, + valid: { path: "/bin/bash" }, + }) + vi.mocked(vscode.workspace.getConfiguration).mockReturnValue({ get: mockGet } as any) + + await webviewMessageHandler(mockClineProvider, { type: "requestTerminalProfiles" }) + + expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({ + type: "terminalProfiles", + profiles: ["valid"], + }) + }) +}) + +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", () => { diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index ffc28f1709..b49d92a9c2 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -1318,6 +1318,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 || "" @@ -1524,18 +1530,30 @@ export const webviewMessageHandler = async ( // 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 { const names = new Set() - const platformKey = - process.platform === "win32" ? "windows" : process.platform === "darwin" ? "osx" : "linux" + const platformKey = Terminal.getPlatformProfileKey() const profiles = vscode.workspace .getConfiguration("terminal.integrated.profiles") .get>(platformKey) if (profiles && typeof profiles === "object") { - for (const name of Object.keys(profiles)) { - names.add(name) + for (const [name, entry] of Object.entries(profiles)) { + if (!entry || typeof entry !== "object") { + continue + } + + const { path } = entry as { path?: unknown } + + // Source-only profiles and paths that cannot be found on disk or + // PATH are excluded because the override would fail at launch. + if (Terminal.resolveProfilePath(path)) { + names.add(name) + } } } diff --git a/src/integrations/terminal/BaseTerminal.ts b/src/integrations/terminal/BaseTerminal.ts index 083b877be6..0d5e6a498b 100644 --- a/src/integrations/terminal/BaseTerminal.ts +++ b/src/integrations/terminal/BaseTerminal.ts @@ -298,9 +298,9 @@ export abstract class BaseTerminal implements RooTerminal { } /** - * Sets the name of the VS Code terminal profile to use for the inline - * (shell-integration) terminal. An empty/undefined value falls back to - * VS Code's default terminal behavior. + * 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 { @@ -309,7 +309,7 @@ export abstract class BaseTerminal implements RooTerminal { } /** - * Gets the name of the VS Code terminal profile to use for the inline terminal. + * 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 { diff --git a/src/integrations/terminal/Terminal.ts b/src/integrations/terminal/Terminal.ts index 2e90f2eb1c..b244dce657 100644 --- a/src/integrations/terminal/Terminal.ts +++ b/src/integrations/terminal/Terminal.ts @@ -1,4 +1,5 @@ import { existsSync } from "fs" +import * as path from "path" import * as vscode from "vscode" import pWaitFor from "p-wait-for" @@ -25,10 +26,10 @@ export class Terminal extends BaseTerminal { } else { const options: vscode.TerminalOptions = { cwd, name: "Roo Code", iconPath, env } - // When the user has chosen a specific terminal profile, resolve it to a - // shell path/args/env so the inline terminal uses that shell (e.g. Git Bash - // with a UTF-8 charset on Windows). When unset, we leave shellPath/shellArgs - // undefined so VS Code's default terminal behavior is preserved (#119). + // 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) { @@ -225,7 +226,7 @@ export class Terminal extends BaseTerminal { * Returns the VS Code config section key (`windows`/`osx`/`linux`) used for * platform-specific terminal profiles. */ - private static getPlatformProfileKey(platform: NodeJS.Platform = process.platform): "windows" | "osx" | "linux" { + public static getPlatformProfileKey(platform: NodeJS.Platform = process.platform): "windows" | "osx" | "linux" { if (platform === "win32") { return "windows" } @@ -238,13 +239,69 @@ export class Terminal extends BaseTerminal { } /** - * Resolves the configured inline terminal profile (see `terminalProfile` + * 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" ? ";" : ":") ?? [] + + 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 = path.join(directory, `${candidate}${extension}`) + + if (existsSync(resolved)) { + return resolved + } + } + } + } + + return undefined + } + + /** + * 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 (UTF-8) instead of the default cmd/PowerShell - * (which may use a non-UTF-8 charset such as GBK) on Windows (#119). + * 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). @@ -279,12 +336,7 @@ export class Terminal extends BaseTerminal { return undefined } - // A `path` may be a single string or an array of candidate paths. VS Code - // picks the first candidate that exists on disk, so mirror that: prefer the - // first existing path, otherwise fall back to the first non-empty candidate. - const candidates = Array.isArray(profile.path) ? profile.path : [profile.path] - const nonEmpty = candidates.filter((p): p is string => typeof p === "string" && p.length > 0) - const pathValue = nonEmpty.find((p) => existsSync(p)) ?? nonEmpty[0] + const pathValue = Terminal.resolveProfilePath(profile.path, platform) if (!pathValue) { // Profiles defined only by `source` (e.g. "PowerShell") can't be mapped to diff --git a/src/integrations/terminal/__tests__/TerminalProfile.spec.ts b/src/integrations/terminal/__tests__/TerminalProfile.spec.ts index 974b335de7..217f1d1414 100644 --- a/src/integrations/terminal/__tests__/TerminalProfile.spec.ts +++ b/src/integrations/terminal/__tests__/TerminalProfile.spec.ts @@ -17,7 +17,7 @@ vi.mock("fs", () => ({ const mockedExistsSync = existsSync as unknown as ReturnType -describe("Terminal inline terminal profile (#119)", () => { +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 @@ -52,9 +52,9 @@ describe("Terminal inline terminal profile (#119)", () => { beforeEach(() => { createTerminalSpy = vi.spyOn(vscode.window, "createTerminal").mockImplementation(() => mockTerminal()) - // Default: no candidate path exists on disk unless a test says otherwise. + // Default: explicit profile paths exist unless a test says otherwise. mockedExistsSync.mockReset() - mockedExistsSync.mockReturnValue(false) + mockedExistsSync.mockReturnValue(true) // Reset to default (unset) before each test. Terminal.setTerminalProfile(undefined) }) @@ -147,7 +147,7 @@ describe("Terminal inline terminal profile (#119)", () => { }) }) - it("falls back to the first non-empty candidate when none of the paths exist", () => { + it("falls back to default when none of the path candidates exist", () => { stubProfiles({ windows: { "Git Bash": { @@ -155,14 +155,11 @@ describe("Terminal inline terminal profile (#119)", () => { }, }, }) - // existsSync defaults to false for every candidate. + mockedExistsSync.mockReturnValue(false) Terminal.setTerminalProfile("Git Bash") - expect(Terminal.getProfileShell("win32")).toEqual({ - shellPath: "C:\\missing\\bash.exe", - shellArgs: undefined, - }) + expect(Terminal.getProfileShell("win32")).toBeUndefined() }) it("wraps a string args value into an array", () => { @@ -210,6 +207,22 @@ describe("Terminal inline terminal profile (#119)", () => { }) }) + 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() + }) + }) + describe("createTerminal integration", () => { afterEach(() => { TerminalRegistry["terminals"] = [] @@ -226,7 +239,7 @@ describe("Terminal inline terminal profile (#119)", () => { it("passes the resolved shellPath/shellArgs when a profile is configured", () => { stubProfiles({ - [Terminal["getPlatformProfileKey"](process.platform)]: { + [Terminal.getPlatformProfileKey(process.platform)]: { "Git Bash": { path: "/usr/bin/bash", args: ["-i"] }, }, }) diff --git a/webview-ui/src/components/settings/SettingsView.tsx b/webview-ui/src/components/settings/SettingsView.tsx index f5ce1a14f4..e4102d1de3 100644 --- a/webview-ui/src/components/settings/SettingsView.tsx +++ b/webview-ui/src/components/settings/SettingsView.tsx @@ -398,7 +398,7 @@ const SettingsView = forwardRef(({ onDone, t terminalZshOhMy, terminalZshP10k, terminalZdotdir, - terminalProfile: terminalProfile ?? "", + terminalProfile, terminalOutputPreviewSize: terminalOutputPreviewSize ?? "medium", mcpEnabled, maxOpenTabsContext: Math.min(Math.max(0, maxOpenTabsContext ?? 20), 500), @@ -867,6 +867,7 @@ const SettingsView = forwardRef(({ onDone, t 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 9474fe652c..ce20759013 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 { 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" @@ -27,6 +27,7 @@ type TerminalSettingsProps = HTMLAttributes & { terminalZshP10k?: boolean terminalZdotdir?: boolean terminalProfile?: string + onTerminalProfilePickerOpened?: () => void setCachedStateField: SetCachedStateField< | "terminalOutputPreviewSize" | "terminalShellIntegrationTimeout" @@ -41,8 +42,8 @@ type TerminalSettingsProps = HTMLAttributes & { > } -// Sentinel value for the "Default" option; the Select component cannot use an -// empty-string item value, so we map it to/from `undefined` in the handler. +// 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 = ({ @@ -56,6 +57,7 @@ export const TerminalSettings = ({ terminalZshP10k, terminalZdotdir, terminalProfile, + onTerminalProfilePickerOpened, setCachedStateField, className, ...props @@ -151,40 +153,107 @@ export const TerminalSettings = ({
- {/* The profile picker only affects inline execution, which is active when shell - integration is disabled. Hide it otherwise so it isn't shown but ineffective - (mirrors the inline-only settings guarded below). Defaults to shown, matching - the checkbox's `?? true`. See PR #277 review. */} - {(terminalShellIntegrationDisabled ?? true) && ( + {/* 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. */} + {terminalShellIntegrationDisabled === false && ( - + + {/* 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")} + + )} +
+ + {!!terminalProfile && profileNames.length > 0 && ( + + )} +
- {t("settings:terminal.profile.description")} + + + {" "} + +
)} diff --git a/webview-ui/src/components/settings/__tests__/TerminalSettings.profile.spec.tsx b/webview-ui/src/components/settings/__tests__/TerminalSettings.profile.spec.tsx index 87b67a4218..df5bb30396 100644 --- a/webview-ui/src/components/settings/__tests__/TerminalSettings.profile.spec.tsx +++ b/webview-ui/src/components/settings/__tests__/TerminalSettings.profile.spec.tsx @@ -22,13 +22,12 @@ vi.mock("@/utils/vscode", () => ({ // Render Select as a list of buttons so we can drive onValueChange in tests. vi.mock("@/components/ui", () => ({ - Select: ({ children, value, onValueChange }: any) => ( -
- {/* Recursively render items and wire their value to onValueChange */} + Select: ({ children, value, onValueChange, "data-testid": testId }: any) => ( +
{renderSelectChildren(children, onValueChange)}
), - SelectTrigger: ({ children }: any) =>
{children}
, + SelectTrigger: ({ children, ...rest }: any) =>
{children}
, SelectValue: ({ children }: any) =>
{children}
, SelectContent: ({ children }: any) =>
{children}
, SelectItem: ({ children, value }: any) =>
{children}
, @@ -45,6 +44,11 @@ vi.mock("@vscode/webview-ui-toolkit/react", () => ({ ), VSCodeLink: ({ children }: any) => {children}, + VSCodeButton: ({ children, onClick, ...rest }: any) => ( + + ), })) // Helper used by the Select mock to render SelectItem children as buttons. @@ -52,7 +56,6 @@ function renderSelectChildren(children: any, onValueChange: (value: string) => v return React.Children.map(children, (child: any) => { if (!child || typeof child !== "object") return child const itemValue = child.props?.value ?? child.props?.["data-item-value"] - // SelectContent wraps SelectItems; recurse into it. if (child.props?.children && itemValue === undefined) { return renderSelectChildren(child.props.children, onValueChange) } @@ -67,70 +70,117 @@ function renderSelectChildren(children: any, onValueChange: (value: string) => v }) } -describe("TerminalSettings inline terminal profile (#119)", () => { +describe("TerminalSettings VS Code terminal profile (#277)", () => { beforeEach(() => { postMessageMock.mockClear() }) - // Inline execution is active when shell integration is disabled, which is when the - // profile picker is shown; render in that state for the picker-behavior tests. + // 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 { setCachedStateField } + 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("does not call setCachedStateField on init (only the Default option is shown)", () => { - const { setCachedStateField } = setup() - // No profiles received yet -> only the Default option exists. - expect(screen.getByTestId("option-__default__")).toBeInTheDocument() - expect(setCachedStateField).not.toHaveBeenCalled() + 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("populates the dropdown from the received profile names and selecting one sets the profile name", () => { - const { setCachedStateField } = setup() + 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("populates the dropdown from received profile names and selecting one sets the profile", () => { + const { setCachedStateField } = setup("Git Bash") - // Simulate the extension responding with the sanitized profile names. act(() => { window.dispatchEvent( new MessageEvent("message", { - data: { type: "terminalProfiles", profiles: ["Git Bash", "PowerShell"] }, + data: { type: "terminalProfiles", profiles: ["Git Bash", "zsh"] }, }), ) }) - // User selects the Git Bash profile. fireEvent.click(screen.getByTestId("option-Git Bash")) - expect(setCachedStateField).toHaveBeenCalledWith("terminalProfile", "Git Bash") }) - it("maps the Default option back to undefined (restores default behavior)", () => { + it("clicking default radio sets terminalProfile to undefined", () => { const { setCachedStateField } = setup("Git Bash") - - fireEvent.click(screen.getByTestId("option-__default__")) - + fireEvent.click(screen.getByTestId("terminal-profile-default-radio")) expect(setCachedStateField).toHaveBeenCalledWith("terminalProfile", undefined) }) - it("hides the profile picker when inline execution is off (shell integration enabled)", () => { + it("renders the native profile configure button and posts openTerminalProfilePicker when clicked", () => { + const { onTerminalProfilePickerOpened } = setup() + const btn = screen.getByTestId("terminal-profile-configure-button") + expect(btn).toBeInTheDocument() + fireEvent.click(btn) + expect(onTerminalProfilePickerOpened).toHaveBeenCalledTimes(1) + expect(postMessageMock).toHaveBeenCalledWith({ type: "openTerminalProfilePicker" }) + }) + + it("shows picker section when VS Code integrated terminal is active (shell integration enabled)", () => { render() - // The picker is inline-only: with shell integration enabled it must not render. - expect(screen.queryByTestId("option-__default__")).not.toBeInTheDocument() - // But the names are still requested on mount (cheap, harmless, keeps state warm). - expect(postMessageMock.mock.calls.map((c) => c[0]?.type)).toContain("requestTerminalProfiles") + 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() + }) + + 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 0aa8bcd15e..05f1609fa6 100644 --- a/webview-ui/src/context/ExtensionStateContext.tsx +++ b/webview-ui/src/context/ExtensionStateContext.tsx @@ -237,7 +237,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 inline terminal profile (use VS Code default) + 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 fedc51686a..55561a14a7 100644 --- a/webview-ui/src/i18n/locales/ca/settings.json +++ b/webview-ui/src/i18n/locales/ca/settings.json @@ -743,9 +743,12 @@ "description": "Activa per heretar variables d'entorn del procés pare de VS Code. <0>Aprèn-ne més" }, "profile": { - "label": "Perfil de terminal en línia", - "default": "Predeterminat (intèrpret d'ordres predeterminat de VS Code)", - "description": "Tria quin perfil de terminal de VS Code utilitza el terminal en línia. Selecciona un intèrpret d'ordres UTF-8 com Git Bash si el predeterminat distorsiona la sortida no ASCII (per exemple, a Windows amb pàgina de codis GBK). Deixa-ho en Predeterminat per mantenir el comportament per defecte de VS Code." + "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 7f684091e8..104db111c1 100644 --- a/webview-ui/src/i18n/locales/de/settings.json +++ b/webview-ui/src/i18n/locales/de/settings.json @@ -743,9 +743,12 @@ "description": "Schalte dies ein, um Umgebungsvariablen vom übergeordneten VS Code-Prozess zu erben. <0>Mehr erfahren" }, "profile": { - "label": "Inline-Terminal-Profil", - "default": "Standard (VS Code-Standardshell)", - "description": "Wähle, welches VS Code-Terminalprofil das Inline-Terminal verwendet. Wähle eine UTF-8-Shell wie Git Bash, wenn die Standardshell Nicht-ASCII-Ausgaben verstümmelt (z. B. unter Windows mit GBK-Codepage). Belasse es auf Standard, um das Standardverhalten von VS Code beizubehalten." + "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 bb07409bef..2ec5be1934 100644 --- a/webview-ui/src/i18n/locales/en/settings.json +++ b/webview-ui/src/i18n/locales/en/settings.json @@ -811,9 +811,12 @@ "description": "Turn this on to inherit environment variables from the parent VS Code process. <0>Learn more" }, "profile": { - "label": "Inline terminal profile", - "default": "Default (VS Code default shell)", - "description": "Pick which shell the Inline Terminal launches, using a VS Code terminal profile's path and arguments. This only changes the shell binary — the Inline Terminal still bypasses shell integration, prompts, and plugins. Useful to choose a UTF-8 shell such as Git Bash when the default garbles non-ASCII output (e.g. on Windows with a GBK code page). Leave on Default to keep VS Code's default shell." + "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 669cf33fa3..97aa3bd38a 100644 --- a/webview-ui/src/i18n/locales/es/settings.json +++ b/webview-ui/src/i18n/locales/es/settings.json @@ -743,9 +743,12 @@ "description": "Activa para heredar variables de entorno del proceso padre de VS Code. <0>Más información" }, "profile": { - "label": "Perfil de terminal en línea", - "default": "Predeterminado (shell predeterminado de VS Code)", - "description": "Elige qué shell lanza el Terminal en línea, usando la ruta y argumentos de un perfil de terminal de VS Code. Esto solo cambia el binario del shell — el Terminal en línea sigue evitando la integración del shell, prompts y plugins. Útil para elegir un shell UTF-8 como Git Bash cuando el predeterminado distorsiona la salida no ASCII (por ejemplo, en Windows con página de códigos GBK). Déjalo en Predeterminado para mantener el shell predeterminado de VS Code." + "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 01bcfef36d..6764f3b5aa 100644 --- a/webview-ui/src/i18n/locales/fr/settings.json +++ b/webview-ui/src/i18n/locales/fr/settings.json @@ -743,9 +743,12 @@ "description": "Activez pour hériter des variables d'environnement du processus parent VS Code. <0>En savoir plus" }, "profile": { - "label": "Profil de terminal intégré", - "default": "Par défaut (shell par défaut de VS Code)", - "description": "Choisissez le profil de terminal VS Code utilisé par le terminal intégré. Sélectionnez un shell UTF-8 tel que Git Bash si le shell par défaut altère la sortie non ASCII (par exemple sous Windows avec une page de codes GBK). Laissez sur Par défaut pour conserver le comportement par défaut de VS Code." + "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 de1c146375..f6b6b712ed 100644 --- a/webview-ui/src/i18n/locales/hi/settings.json +++ b/webview-ui/src/i18n/locales/hi/settings.json @@ -743,9 +743,12 @@ "description": "पैरेंट VS Code प्रोसेस से पर्यावरण चर विरासत में लेने के लिए इसे चालू करें। <0>अधिक जानें" }, "profile": { - "label": "इनलाइन टर्मिनल प्रोफ़ाइल", - "default": "डिफ़ॉल्ट (VS Code डिफ़ॉल्ट शेल)", - "description": "चुनें कि इनलाइन टर्मिनल किस VS Code टर्मिनल प्रोफ़ाइल का उपयोग करता है। यदि डिफ़ॉल्ट शेल गैर-ASCII आउटपुट को विकृत करता है (जैसे GBK कोड पेज वाले Windows पर) तो Git Bash जैसा UTF-8 शेल चुनें। VS Code के डिफ़ॉल्ट व्यवहार को बनाए रखने के लिए डिफ़ॉल्ट पर छोड़ दें।" + "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 1af8c70d80..7509b09d66 100644 --- a/webview-ui/src/i18n/locales/id/settings.json +++ b/webview-ui/src/i18n/locales/id/settings.json @@ -743,9 +743,12 @@ "description": "Aktifkan untuk mewarisi variabel lingkungan dari proses induk VS Code. <0>Pelajari lebih lanjut" }, "profile": { - "label": "Profil terminal sebaris", - "default": "Default (shell default VS Code)", - "description": "Pilih profil terminal VS Code yang digunakan terminal sebaris. Pilih shell UTF-8 seperti Git Bash jika shell default merusak keluaran non-ASCII (misalnya di Windows dengan code page GBK). Biarkan pada Default untuk mempertahankan perilaku default VS Code." + "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 4b03bb9ac1..ffb4d6afbc 100644 --- a/webview-ui/src/i18n/locales/it/settings.json +++ b/webview-ui/src/i18n/locales/it/settings.json @@ -743,9 +743,12 @@ "description": "Attiva per ereditare le variabili d'ambiente dal processo padre di VS Code. <0>Scopri di più" }, "profile": { - "label": "Profilo del terminale inline", - "default": "Predefinito (shell predefinita di VS Code)", - "description": "Scegli quale profilo del terminale di VS Code usa il terminale inline. Seleziona una shell UTF-8 come Git Bash se la shell predefinita corrompe l'output non ASCII (ad esempio su Windows con codepage GBK). Lascia su Predefinito per mantenere il comportamento predefinito di VS Code." + "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 646e97d234..e8eeaf8580 100644 --- a/webview-ui/src/i18n/locales/ja/settings.json +++ b/webview-ui/src/i18n/locales/ja/settings.json @@ -743,9 +743,12 @@ "description": "親VS Codeプロセスから環境変数を継承するには、これをオンにします。<0>詳細情報" }, "profile": { - "label": "インラインターミナルプロファイル", - "default": "デフォルト(VS Code のデフォルトシェル)", - "description": "インラインターミナルが使用する VS Code のターミナルプロファイルを選択します。デフォルトのシェルが ASCII 以外の出力を文字化けさせる場合(例: GBK コードページの Windows)、Git Bash などの UTF-8 シェルを選択してください。VS Code のデフォルト動作を維持するにはデフォルトのままにします。" + "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 581a708dfb..37e5b0b682 100644 --- a/webview-ui/src/i18n/locales/ko/settings.json +++ b/webview-ui/src/i18n/locales/ko/settings.json @@ -743,9 +743,12 @@ "description": "부모 VS Code 프로세���에서 환경 변수를 상속하려면 이 기능을 켜십시오. <0>자세히 알아보기" }, "profile": { - "label": "인라인 터미널 프로필", - "default": "기본값 (VS Code 기본 셸)", - "description": "인라인 터미널이 사용할 VS Code 터미널 프로필을 선택합니다. 기본 셸이 ASCII가 아닌 출력을 깨뜨리는 경우(예: GBK 코드 페이지의 Windows) Git Bash와 같은 UTF-8 셸을 선택하세요. VS Code 기본 동작을 유지하려면 기본값으로 두세요." + "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 5ead34b53a..fb88aaaa81 100644 --- a/webview-ui/src/i18n/locales/nl/settings.json +++ b/webview-ui/src/i18n/locales/nl/settings.json @@ -743,9 +743,12 @@ "description": "Schakel in om omgevingsvariabelen over te nemen van het bovenliggende VS Code-proces. <0>Meer informatie" }, "profile": { - "label": "Inline-terminalprofiel", - "default": "Standaard (standaardshell van VS Code)", - "description": "Kies welk VS Code-terminalprofiel de inline-terminal gebruikt. Selecteer een UTF-8-shell zoals Git Bash als de standaardshell niet-ASCII-uitvoer verminkt (bijvoorbeeld op Windows met een GBK-codepagina). Laat op Standaard staan om het standaardgedrag van VS Code te behouden." + "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 e228db58d6..81a8f4491b 100644 --- a/webview-ui/src/i18n/locales/pl/settings.json +++ b/webview-ui/src/i18n/locales/pl/settings.json @@ -743,9 +743,12 @@ "description": "Włącz, aby dziedziczyć zmienne środowiskowe z procesu nadrzędnego VS Code. <0>Dowiedz się więcej" }, "profile": { - "label": "Profil terminala wbudowanego", - "default": "Domyślny (domyślna powłoka VS Code)", - "description": "Wybierz, którego profilu terminala VS Code używa terminal wbudowany. Wybierz powłokę UTF-8, np. Git Bash, jeśli domyślna powłoka zniekształca dane wyjściowe spoza ASCII (np. w systemie Windows ze stroną kodową GBK). Pozostaw Domyślny, aby zachować domyślne zachowanie VS Code." + "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 9f560d5040..19737167e4 100644 --- a/webview-ui/src/i18n/locales/pt-BR/settings.json +++ b/webview-ui/src/i18n/locales/pt-BR/settings.json @@ -743,9 +743,12 @@ "description": "Ative isso para herdar variáveis de ambiente do processo pai do VS Code. <0>Saiba mais" }, "profile": { - "label": "Perfil do terminal embutido", - "default": "Padrão (shell padrão do VS Code)", - "description": "Escolha qual perfil de terminal do VS Code o terminal embutido usa. Selecione um shell UTF-8 como o Git Bash se o shell padrão corromper a saída não ASCII (por exemplo, no Windows com página de código GBK). Mantenha em Padrão para preservar o comportamento padrão do VS Code." + "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 8f58f903c8..7f5b38b0c9 100644 --- a/webview-ui/src/i18n/locales/ru/settings.json +++ b/webview-ui/src/i18n/locales/ru/settings.json @@ -743,9 +743,12 @@ "description": "Включите для наследования переменных среды от родительского процесса VS Code. <0>Подробнее" }, "profile": { - "label": "Профиль встроенного терминала", - "default": "По умолчанию (оболочка VS Code по умолчанию)", - "description": "Выберите профиль терминала VS Code, который использует встроенный терминал. Выберите оболочку UTF-8, например Git Bash, если оболочка по умолчанию искажает не-ASCII вывод (например, в Windows с кодовой страницей GBK). Оставьте «По умолчанию», чтобы сохранить стандартное поведение VS Code." + "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 ed371e3987..221181bca9 100644 --- a/webview-ui/src/i18n/locales/tr/settings.json +++ b/webview-ui/src/i18n/locales/tr/settings.json @@ -743,9 +743,12 @@ "description": "Ana VS Code işleminden ortam değişkenlerini devralmak için bunu açın. <0>Daha fazla bilgi edinin" }, "profile": { - "label": "Satır içi terminal profili", - "default": "Varsayılan (VS Code varsayılan kabuğu)", - "description": "Satır içi terminalin kullanacağı VS Code terminal profilini seçin. Varsayılan kabuk ASCII olmayan çıktıyı bozuyorsa (örneğin GBK kod sayfası olan Windows'ta) Git Bash gibi bir UTF-8 kabuğu seçin. VS Code varsayılan davranışını korumak için Varsayılan olarak bırakın." + "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 e3b74227f0..0287781402 100644 --- a/webview-ui/src/i18n/locales/vi/settings.json +++ b/webview-ui/src/i18n/locales/vi/settings.json @@ -743,9 +743,12 @@ "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": "Hồ sơ terminal nội tuyến", - "default": "Mặc định (shell mặc định của VS Code)", - "description": "Chọn hồ sơ terminal VS Code mà terminal nội tuyến sử dụng. Chọn một shell UTF-8 như Git Bash nếu shell mặc định làm hỏng đầu ra không phải ASCII (ví dụ trên Windows với bảng mã GBK). Để Mặc định để giữ hành vi mặc định của VS Code." + "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 ba0af3f538..d9038a363b 100644 --- a/webview-ui/src/i18n/locales/zh-CN/settings.json +++ b/webview-ui/src/i18n/locales/zh-CN/settings.json @@ -743,9 +743,12 @@ "description": "启用此选项以从父 VS Code 进程继承环境变量。<0>了解更多" }, "profile": { - "label": "内联终端配置文件", - "default": "默认(VS Code 默认 Shell)", - "description": "选择内联终端使用的 VS Code 终端配置文件。如果默认 Shell 会使非 ASCII 输出乱码(例如在使用 GBK 代码页的 Windows 上),请选择 Git Bash 等 UTF-8 Shell。保留为“默认”以保持 VS Code 的默认行为。" + "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 1af770604d..69d5953eff 100644 --- a/webview-ui/src/i18n/locales/zh-TW/settings.json +++ b/webview-ui/src/i18n/locales/zh-TW/settings.json @@ -758,9 +758,12 @@ "description": "啟用此選項以從父 VS Code 程序繼承環境變數。<0>了解更多" }, "profile": { - "label": "內嵌終端機設定檔", - "default": "預設(VS Code 預設 Shell)", - "description": "選擇內嵌終端機使用的 VS Code 終端機設定檔。如果預設 Shell 會使非 ASCII 輸出亂碼(例如在使用 GBK 字碼頁的 Windows 上),請選擇 Git Bash 等 UTF-8 Shell。保留為「預設」以維持 VS Code 的預設行為。" + "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": { From 3169654994b7dcb607251a2062b53c64540ddbe4 Mon Sep 17 00:00:00 2001 From: Elliott de Launay Date: Sun, 31 May 2026 13:00:53 +0000 Subject: [PATCH 09/21] fix(terminal): scope profile config to user settings, filter shellArgs, fix PATH join --- .../__tests__/webviewMessageHandler.spec.ts | 95 +------------------ src/core/webview/webviewMessageHandler.ts | 25 +---- src/integrations/terminal/Terminal.ts | 44 ++++++++- .../__tests__/TerminalProfile.spec.ts | 80 +++++++++++++++- 4 files changed, 122 insertions(+), 122 deletions(-) diff --git a/src/core/webview/__tests__/webviewMessageHandler.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.spec.ts index c371583a9e..bf49ba5bd7 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.spec.ts @@ -906,27 +906,14 @@ describe("webviewMessageHandler - terminalProfile", () => { describe("webviewMessageHandler - requestTerminalProfiles", () => { beforeEach(() => { vi.clearAllMocks() - vi.spyOn(Terminal, "resolveProfilePath").mockImplementation((profilePath) => { - const candidates = Array.isArray(profilePath) ? profilePath : [profilePath] - const value = candidates.find( - (candidate) => - typeof candidate === "string" && candidate.trim().length > 0 && !candidate.includes("missing"), - ) - return typeof value === "string" ? value.trim() : undefined - }) }) afterEach(() => { vi.restoreAllMocks() }) - it("posts sorted path-resolvable profile names for the active platform", async () => { - const mockGet = vi.fn().mockReturnValue({ - "Git Bash": { path: "C:\\Git\\bin\\bash.exe" }, - bash: { path: "/bin/bash" }, - PowerShell: { source: "PowerShell" }, // source-only — must be excluded - }) - vi.mocked(vscode.workspace.getConfiguration).mockReturnValue({ get: mockGet } as any) + it("posts available profile names", async () => { + vi.spyOn(Terminal, "getAvailableProfileNames").mockReturnValue(["Git Bash", "bash"]) await webviewMessageHandler(mockClineProvider, { type: "requestTerminalProfiles" }) @@ -936,23 +923,8 @@ describe("webviewMessageHandler - requestTerminalProfiles", () => { }) }) - it("excludes source-only profiles that have no path field", async () => { - const mockGet = vi.fn().mockReturnValue({ - PowerShell: { source: "PowerShell" }, - "Windows PowerShell": { source: "PowerShell" }, - }) - vi.mocked(vscode.workspace.getConfiguration).mockReturnValue({ get: mockGet } as any) - - await webviewMessageHandler(mockClineProvider, { type: "requestTerminalProfiles" }) - - expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({ - type: "terminalProfiles", - profiles: [], - }) - }) - - it("posts an empty array when getConfiguration throws", async () => { - vi.mocked(vscode.workspace.getConfiguration).mockImplementation(() => { + it("posts an empty array when profile discovery throws", async () => { + vi.spyOn(Terminal, "getAvailableProfileNames").mockImplementation(() => { throw new Error("config error") }) @@ -963,65 +935,6 @@ describe("webviewMessageHandler - requestTerminalProfiles", () => { profiles: [], }) }) - - it("posts an empty array when no profiles are configured", async () => { - const mockGet = vi.fn().mockReturnValue(undefined) - vi.mocked(vscode.workspace.getConfiguration).mockReturnValue({ get: mockGet } as any) - - await webviewMessageHandler(mockClineProvider, { type: "requestTerminalProfiles" }) - - expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({ - type: "terminalProfiles", - profiles: [], - }) - }) - - it("excludes profiles with empty or whitespace-only path strings", async () => { - const mockGet = vi.fn().mockReturnValue({ - empty: { path: "" }, - whitespace: { path: " " }, - valid: { path: "/bin/bash" }, - }) - vi.mocked(vscode.workspace.getConfiguration).mockReturnValue({ get: mockGet } as any) - - await webviewMessageHandler(mockClineProvider, { type: "requestTerminalProfiles" }) - - expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({ - type: "terminalProfiles", - profiles: ["valid"], - }) - }) - - it("excludes profiles with path arrays containing only empty or whitespace strings", async () => { - const mockGet = vi.fn().mockReturnValue({ - emptyArray: { path: [] }, - whitespaceArray: { path: ["", " "] }, - valid: { path: ["/bin/bash", "/usr/bin/bash"] }, - }) - vi.mocked(vscode.workspace.getConfiguration).mockReturnValue({ get: mockGet } as any) - - await webviewMessageHandler(mockClineProvider, { type: "requestTerminalProfiles" }) - - expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({ - type: "terminalProfiles", - profiles: ["valid"], - }) - }) - - it("excludes profiles whose executable cannot be resolved", async () => { - const mockGet = vi.fn().mockReturnValue({ - missing: { path: "/missing/bash" }, - valid: { path: "/bin/bash" }, - }) - vi.mocked(vscode.workspace.getConfiguration).mockReturnValue({ get: mockGet } as any) - - await webviewMessageHandler(mockClineProvider, { type: "requestTerminalProfiles" }) - - expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({ - type: "terminalProfiles", - profiles: ["valid"], - }) - }) }) describe("webviewMessageHandler - openTerminalProfilePicker", () => { diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index b49d92a9c2..cb05a1a071 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -1534,32 +1534,9 @@ export const webviewMessageHandler = async ( // profiles (e.g. { source: "PowerShell" }) cannot be mapped to a shell // binary by an extension and would silently fall back to the default. try { - const names = new Set() - - const platformKey = Terminal.getPlatformProfileKey() - const profiles = vscode.workspace - .getConfiguration("terminal.integrated.profiles") - .get>(platformKey) - - if (profiles && typeof profiles === "object") { - for (const [name, entry] of Object.entries(profiles)) { - if (!entry || typeof entry !== "object") { - continue - } - - const { path } = entry as { path?: unknown } - - // Source-only profiles and paths that cannot be found on disk or - // PATH are excluded because the override would fail at launch. - if (Terminal.resolveProfilePath(path)) { - names.add(name) - } - } - } - await provider.postMessageToWebview({ type: "terminalProfiles", - profiles: Array.from(names).sort(), + profiles: Terminal.getAvailableProfileNames(), }) } catch (error) { console.error("Failed to get terminal profiles:", error) diff --git a/src/integrations/terminal/Terminal.ts b/src/integrations/terminal/Terminal.ts index b244dce657..fea85cf18b 100644 --- a/src/integrations/terminal/Terminal.ts +++ b/src/integrations/terminal/Terminal.ts @@ -251,6 +251,7 @@ export class Terminal extends BaseTerminal { 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") { @@ -280,7 +281,7 @@ export class Terminal extends BaseTerminal { const directory = entry.replace(/^"(.*)"$/, "$1") for (const extension of extensions) { - const resolved = path.join(directory, `${candidate}${extension}`) + const resolved = platformJoin(directory, `${candidate}${extension}`) if (existsSync(resolved)) { return resolved @@ -292,6 +293,41 @@ export class Terminal extends BaseTerminal { 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 inspected = vscode.workspace + .getConfiguration("terminal.integrated.profiles") + .inspect>(platformKey) + + return { + ...(inspected?.defaultValue ?? {}), + ...(inspected?.globalValue ?? {}), + } + } + + public static getAvailableProfileNames(platform: NodeJS.Platform = process.platform): string[] { + const names = new Set() + + for (const [name, entry] of Object.entries(Terminal.getConfiguredProfiles(platform))) { + if (!entry || typeof entry !== "object") { + continue + } + + const { path: profilePath } = entry as { path?: unknown } + + if (Terminal.resolveProfilePath(profilePath, platform)) { + names.add(name) + } + } + + return Array.from(names).sort() + } + /** * Resolves the configured VS Code terminal profile (see `terminalProfile` * setting / {@link Terminal.getTerminalProfile}) into a shell path and args by @@ -317,9 +353,7 @@ export class Terminal extends BaseTerminal { const platformKey = Terminal.getPlatformProfileKey(platform) - const profiles = vscode.workspace - .getConfiguration("terminal.integrated.profiles") - .get>(platformKey) + const profiles = Terminal.getConfiguredProfiles(platform) const profile = profiles?.[profileName] as | { @@ -348,7 +382,7 @@ export class Terminal extends BaseTerminal { } const shellArgs = Array.isArray(profile.args) - ? profile.args + ? profile.args.filter((arg): arg is string => typeof arg === "string") : typeof profile.args === "string" ? [profile.args] : undefined diff --git a/src/integrations/terminal/__tests__/TerminalProfile.spec.ts b/src/integrations/terminal/__tests__/TerminalProfile.spec.ts index 217f1d1414..3e5deed3d9 100644 --- a/src/integrations/terminal/__tests__/TerminalProfile.spec.ts +++ b/src/integrations/terminal/__tests__/TerminalProfile.spec.ts @@ -38,11 +38,17 @@ describe("Terminal VS Code terminal profile (#277)", () => { }) as any // Helper to stub `terminal.integrated.profiles.` config reads. - const stubProfiles = (profilesByPlatform: Record) => { + const stubProfiles = ( + profilesByPlatform: Record, + workspaceProfilesByPlatform: Record = {}, + ) => { getConfigurationSpy = vi.spyOn(vscode.workspace, "getConfiguration").mockImplementation((section?: string) => { if (section === "terminal.integrated.profiles") { return { - get: (platformKey: string) => profilesByPlatform[platformKey], + inspect: (platformKey: string) => ({ + defaultValue: profilesByPlatform[platformKey], + workspaceValue: workspaceProfilesByPlatform[platformKey], + }), } as any } @@ -84,6 +90,45 @@ describe("Terminal VS Code terminal profile (#277)", () => { }) }) + 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" }, + bash: { path: "/bin/bash" }, + missing: { path: "/missing/bash" }, + }, + }) + mockedExistsSync.mockImplementation((profilePath: string) => profilePath !== "/missing/bash") + + expect(Terminal.getAvailableProfileNames("linux")).toEqual(["bash", "zsh"]) + }) + }) + describe("getProfileShell", () => { it("returns undefined when no profile is configured (default behavior preserved)", () => { stubProfiles({}) @@ -177,6 +222,21 @@ describe("Terminal VS Code terminal profile (#277)", () => { }) }) + 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" } }, @@ -251,5 +311,21 @@ describe("Terminal VS Code terminal profile (#277)", () => { 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() + }) }) }) From 515e59e674fc395c63d07579da776f3fa464757b Mon Sep 17 00:00:00 2001 From: Elliott de Launay Date: Sun, 31 May 2026 13:01:45 +0000 Subject: [PATCH 10/21] refactor(terminal): address review feedback on profile picker --- src/integrations/terminal/Terminal.ts | 2 +- .../src/components/settings/SettingsView.tsx | 2 +- .../components/settings/TerminalSettings.tsx | 32 +++++++++++------ .../TerminalSettings.profile.spec.tsx | 34 +++++++++++++++++-- .../src/context/ExtensionStateContext.tsx | 2 -- 5 files changed, 56 insertions(+), 16 deletions(-) diff --git a/src/integrations/terminal/Terminal.ts b/src/integrations/terminal/Terminal.ts index fea85cf18b..7bf3fa3ea0 100644 --- a/src/integrations/terminal/Terminal.ts +++ b/src/integrations/terminal/Terminal.ts @@ -24,7 +24,7 @@ export class Terminal extends BaseTerminal { if (terminal) { this.terminal = terminal } else { - const options: vscode.TerminalOptions = { cwd, name: "Roo Code", iconPath, env } + 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 diff --git a/webview-ui/src/components/settings/SettingsView.tsx b/webview-ui/src/components/settings/SettingsView.tsx index e4102d1de3..5b311f8902 100644 --- a/webview-ui/src/components/settings/SettingsView.tsx +++ b/webview-ui/src/components/settings/SettingsView.tsx @@ -398,7 +398,7 @@ const SettingsView = forwardRef(({ onDone, t terminalZshOhMy, terminalZshP10k, terminalZdotdir, - terminalProfile, + 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), diff --git a/webview-ui/src/components/settings/TerminalSettings.tsx b/webview-ui/src/components/settings/TerminalSettings.tsx index ce20759013..ecf8cdf026 100644 --- a/webview-ui/src/components/settings/TerminalSettings.tsx +++ b/webview-ui/src/components/settings/TerminalSettings.tsx @@ -1,4 +1,4 @@ -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, VSCodeButton } from "@vscode/webview-ui-toolkit/react" @@ -66,6 +66,11 @@ export const TerminalSettings = ({ 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)) useMount(() => { vscode.postMessage({ type: "getVSCodeSetting", setting: "terminal.integrated.inheritEnv" }) @@ -85,6 +90,7 @@ export const TerminalSettings = ({ break case "terminalProfiles": setProfileNames(message.profiles ?? []) + setIsProfilesLoaded(true) break default: break @@ -93,6 +99,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")} @@ -167,13 +179,13 @@ export const TerminalSettings = ({
setCachedStateField("terminalProfile", undefined)} data-testid="terminal-profile-default-radio" /> -