From d59cdbacbfaa9855d2d0f686af396054d5a128a4 Mon Sep 17 00:00:00 2001 From: "Zoo (VP)" Date: Tue, 4 Aug 2026 20:31:20 +0900 Subject: [PATCH 1/5] feat: add unified shell resolution contracts and settings UI --- .../__tests__/terminal-shell-settings.spec.ts | 316 ++++++++++++++++ packages/types/src/global-settings.ts | 36 ++ packages/types/src/terminal.ts | 11 + packages/types/src/vscode-extension-host.ts | 56 ++- .../src/components/settings/SettingsView.tsx | 31 ++ .../components/settings/TerminalSettings.tsx | 159 +++++++- .../SettingsView.shell-selection.spec.tsx | 346 ++++++++++++++++++ .../__tests__/TerminalSettings.shell.spec.tsx | 217 +++++++++++ webview-ui/src/i18n/locales/ca/settings.json | 18 + webview-ui/src/i18n/locales/de/settings.json | 18 + webview-ui/src/i18n/locales/en/settings.json | 18 + webview-ui/src/i18n/locales/es/settings.json | 18 + webview-ui/src/i18n/locales/fr/settings.json | 18 + webview-ui/src/i18n/locales/hi/settings.json | 18 + webview-ui/src/i18n/locales/id/settings.json | 18 + webview-ui/src/i18n/locales/it/settings.json | 18 + webview-ui/src/i18n/locales/ja/settings.json | 18 + webview-ui/src/i18n/locales/ko/settings.json | 18 + webview-ui/src/i18n/locales/nl/settings.json | 18 + webview-ui/src/i18n/locales/pl/settings.json | 18 + .../src/i18n/locales/pt-BR/settings.json | 18 + webview-ui/src/i18n/locales/ru/settings.json | 18 + webview-ui/src/i18n/locales/tr/settings.json | 18 + webview-ui/src/i18n/locales/vi/settings.json | 18 + .../src/i18n/locales/zh-CN/settings.json | 18 + .../src/i18n/locales/zh-TW/settings.json | 18 + 26 files changed, 1493 insertions(+), 3 deletions(-) create mode 100644 packages/types/src/__tests__/terminal-shell-settings.spec.ts create mode 100644 webview-ui/src/components/settings/__tests__/SettingsView.shell-selection.spec.tsx create mode 100644 webview-ui/src/components/settings/__tests__/TerminalSettings.shell.spec.tsx diff --git a/packages/types/src/__tests__/terminal-shell-settings.spec.ts b/packages/types/src/__tests__/terminal-shell-settings.spec.ts new file mode 100644 index 0000000000..83b7e9ff5c --- /dev/null +++ b/packages/types/src/__tests__/terminal-shell-settings.spec.ts @@ -0,0 +1,316 @@ +/** + * Tests for the terminal shell selection settings and message contracts. + * + * Validates: + * - `terminalShellSelection` is optional and older settings import unchanged + * - Discriminated shape validation (auto, profile, path) + * - Legacy `execaShellPath` remains readable + * - Message payload types compile correctly + */ +import { describe, it, expect } from "vitest" + +import { + globalSettingsSchema, + terminalShellSelectionSchema, + type GlobalSettings, + type TerminalShellSelection, +} from "../global-settings.js" + +import type { + ExtensionMessage, + WebviewMessage, + TerminalShellOption, + TerminalShellOptionsPayload, +} from "../vscode-extension-host.js" + +describe("terminalShellSelectionSchema", () => { + // ── Discriminated union validation ────────────────────────────────── + + describe("auto mode", () => { + it("should parse { kind: 'auto' }", () => { + const result = terminalShellSelectionSchema.parse({ kind: "auto" }) + expect(result).toEqual({ kind: "auto" }) + }) + + it("should strip extra fields on auto variant", () => { + // Zod discriminated union objects are non-strict by default; + // extra keys are stripped rather than rejected. + const result = terminalShellSelectionSchema.parse({ + kind: "auto", + path: "/bin/sh", + }) + expect(result).toEqual({ kind: "auto" }) + expect(result).not.toHaveProperty("path") + }) + }) + + describe("profile mode", () => { + it("should parse { kind: 'profile', profileName: 'PowerShell' }", () => { + const result = terminalShellSelectionSchema.parse({ + kind: "profile", + profileName: "PowerShell", + }) + expect(result).toEqual({ kind: "profile", profileName: "PowerShell" }) + }) + + it("should reject profile without profileName", () => { + expect(() => terminalShellSelectionSchema.parse({ kind: "profile" })).toThrow() + }) + + it("should reject profile with empty profileName", () => { + expect(() => terminalShellSelectionSchema.parse({ kind: "profile", profileName: "" })).not.toThrow() // z.string() accepts empty; validation is extension-host responsibility + }) + }) + + describe("path mode", () => { + it("should parse { kind: 'path', path: 'C:\\\\System32\\\\WindowsPowerShell\\\\v1.0\\\\powershell.exe' }", () => { + const result = terminalShellSelectionSchema.parse({ + kind: "path", + path: "C:\\System32\\WindowsPowerShell\\v1.0\\powershell.exe", + }) + expect(result.kind).toBe("path") + if (result.kind === "path") { + expect(result.path).toContain("powershell.exe") + } + }) + + it("should reject path without path field", () => { + expect(() => terminalShellSelectionSchema.parse({ kind: "path" })).toThrow() + }) + }) + + describe("invalid discriminated shapes", () => { + it("should reject unknown kind", () => { + expect(() => terminalShellSelectionSchema.parse({ kind: "unknown" })).toThrow() + }) + + it("should reject missing kind", () => { + expect(() => terminalShellSelectionSchema.parse({})).toThrow() + }) + + it("should reject null", () => { + expect(() => terminalShellSelectionSchema.parse(null)).toThrow() + }) + + it("should reject non-object", () => { + expect(() => terminalShellSelectionSchema.parse("auto")).toThrow() + }) + }) +}) + +describe("globalSettingsSchema — terminalShellSelection", () => { + // ── Optionality and backward compatibility ────────────────────────── + + it("should accept settings without terminalShellSelection (backward compat)", () => { + const legacySettings = { + terminalProfile: "PowerShell", + execaShellPath: "/bin/bash", + } + const result = globalSettingsSchema.parse(legacySettings) + expect(result.terminalShellSelection).toBeUndefined() + expect(result.execaShellPath).toBe("/bin/bash") + expect(result.terminalProfile).toBe("PowerShell") + }) + + it("should accept settings with terminalShellSelection auto", () => { + const result = globalSettingsSchema.parse({ + terminalShellSelection: { kind: "auto" }, + }) + expect(result.terminalShellSelection).toEqual({ kind: "auto" }) + }) + + it("should accept settings with terminalShellSelection profile", () => { + const result = globalSettingsSchema.parse({ + terminalShellSelection: { kind: "profile", profileName: "Git Bash" }, + }) + expect(result.terminalShellSelection).toEqual({ + kind: "profile", + profileName: "Git Bash", + }) + }) + + it("should accept settings with terminalShellSelection path", () => { + const result = globalSettingsSchema.parse({ + terminalShellSelection: { kind: "path", path: "/usr/bin/fish" }, + }) + expect(result.terminalShellSelection).toEqual({ + kind: "path", + path: "/usr/bin/fish", + }) + }) + + it("should reject settings with invalid terminalShellSelection shape", () => { + expect(() => + globalSettingsSchema.parse({ + terminalShellSelection: { kind: "invalid" }, + }), + ).toThrow() + }) + + it("should allow both terminalShellSelection and legacy execaShellPath", () => { + const result = globalSettingsSchema.parse({ + terminalShellSelection: { kind: "auto" }, + execaShellPath: "/bin/zsh", + }) + expect(result.terminalShellSelection).toEqual({ kind: "auto" }) + expect(result.execaShellPath).toBe("/bin/zsh") + }) + + // ── Legacy field readability ──────────────────────────────────────── + + it("should keep execaShellPath readable when present", () => { + const result = globalSettingsSchema.parse({ + execaShellPath: "C:\\Program Files\\PowerShell\\7\\pwsh.exe", + }) + expect(result.execaShellPath).toBe("C:\\Program Files\\PowerShell\\7\\pwsh.exe") + }) + + it("should keep execaShellPath undefined when absent", () => { + const result = globalSettingsSchema.parse({}) + expect(result.execaShellPath).toBeUndefined() + }) +}) + +describe("message payload type compilation", () => { + // ── Type-level compile checks (runtime no-ops) ────────────────────── + // These tests verify that the message payload types are correctly + // typed and can carry the expected data shapes. + + it("TerminalShellOption should have all required fields", () => { + const option: TerminalShellOption = { + id: "auto", + label: "Auto (follow default profile)", + family: "powershell", + source: "os-default", + available: true, + } + expect(option.id).toBe("auto") + expect(option.label).toBe("Auto (follow default profile)") + expect(option.family).toBe("powershell") + expect(option.source).toBe("os-default") + expect(option.available).toBe(true) + }) + + it("TerminalShellOption family should accept all valid families", () => { + const families: TerminalShellOption["family"][] = ["powershell", "cmd", "posix", "fish", "wsl"] + families.forEach((family) => { + const option: TerminalShellOption = { + id: `test-${family}`, + label: family, + family, + source: "test", + available: true, + } + expect(option.family).toBe(family) + }) + }) + + it("TerminalShellOptionsPayload should carry options and effectiveShell", () => { + const payload: TerminalShellOptionsPayload = { + options: [ + { + id: "auto", + label: "Auto", + family: "powershell", + source: "os-default", + available: true, + }, + { + id: "profile:PowerShell", + label: "PowerShell", + family: "powershell", + source: "vscode-default", + available: true, + }, + ], + effectiveShell: { + label: "PowerShell 7 (pwsh.exe)", + family: "powershell", + source: "vscode-default", + }, + } + expect(payload.options).toHaveLength(2) + expect(payload.effectiveShell?.family).toBe("powershell") + }) + + it("TerminalShellOptionsPayload should allow error without effectiveShell", () => { + const payload: TerminalShellOptionsPayload = { + options: [], + error: "SHELL/terminalShellOptions/001: profile discovery failed", + } + expect(payload.options).toHaveLength(0) + expect(payload.error).toBeDefined() + }) + + it("WebviewMessage should carry terminalShellSelection for setTerminalShellSelection", () => { + const msg: WebviewMessage = { + type: "setTerminalShellSelection", + terminalShellSelection: { kind: "profile", profileName: "PowerShell" }, + } + expect(msg.type).toBe("setTerminalShellSelection") + expect(msg.terminalShellSelection?.kind).toBe("profile") + }) + + it("WebviewMessage should carry requestTerminalShellOptions without payload", () => { + const msg: WebviewMessage = { + type: "requestTerminalShellOptions", + } + expect(msg.type).toBe("requestTerminalShellOptions") + expect(msg.terminalShellSelection).toBeUndefined() + }) + + it("ExtensionMessage should carry terminalShellOptions response", () => { + const msg: ExtensionMessage = { + type: "terminalShellOptions", + terminalShellOptions: { + options: [ + { + id: "auto", + label: "Auto", + family: "posix", + source: "os-default", + available: true, + }, + ], + effectiveShell: { + label: "/bin/bash", + family: "posix", + source: "os-default", + }, + }, + } + expect(msg.type).toBe("terminalShellOptions") + expect(msg.terminalShellOptions?.options).toHaveLength(1) + }) + + it("TerminalShellSelection type should narrow correctly", () => { + const pathSelection: TerminalShellSelection = { kind: "path", path: "/bin/zsh" } + if (pathSelection.kind === "path") { + // TypeScript narrows to the path variant + expect(pathSelection.path).toBe("/bin/zsh") + } + + const profileSelection: TerminalShellSelection = { + kind: "profile", + profileName: "PowerShell", + } + if (profileSelection.kind === "profile") { + expect(profileSelection.profileName).toBe("PowerShell") + } + + const autoSelection: TerminalShellSelection = { kind: "auto" } + if (autoSelection.kind === "auto") { + expect(autoSelection.kind).toBe("auto") + } + }) + + it("GlobalSettings should include terminalShellSelection as optional", () => { + const settings: GlobalSettings = {} + expect(settings.terminalShellSelection).toBeUndefined() + + const settingsWithSelection: GlobalSettings = { + terminalShellSelection: { kind: "auto" }, + } + expect(settingsWithSelection.terminalShellSelection).toEqual({ kind: "auto" }) + }) +}) diff --git a/packages/types/src/global-settings.ts b/packages/types/src/global-settings.ts index 88b5408fca..d46d7f2f97 100644 --- a/packages/types/src/global-settings.ts +++ b/packages/types/src/global-settings.ts @@ -97,6 +97,25 @@ export const MAX_CHECKPOINT_TIMEOUT_SECONDS = 60 */ export const DEFAULT_CHECKPOINT_TIMEOUT_SECONDS = 15 +/** + * TerminalShellSelection + * + * Discriminated union for the user-selected inline-terminal shell resolution + * mode. Absence of the field (undefined) means Auto mode. + * + * - `auto`: follow trusted VS Code default/global profile, then OS default, + * then safe platform fallback. + * - `profile`: use a named trusted VS Code terminal profile. + * - `path`: use an explicit executable path validated by the extension host. + */ +export const terminalShellSelectionSchema = z.discriminatedUnion("kind", [ + z.object({ kind: z.literal("auto") }), + z.object({ kind: z.literal("profile"), profileName: z.string() }), + z.object({ kind: z.literal("path"), path: z.string() }), +]) + +export type TerminalShellSelection = z.infer + /** * GlobalSettings */ @@ -206,7 +225,24 @@ export const globalSettingsSchema = z.object({ terminalZshP10k: z.boolean().optional(), terminalZdotdir: z.boolean().optional(), terminalProfile: z.string().optional(), + /** + * @deprecated Use `terminalShellSelection` instead. Retained for migration + * from pre-unified settings; treated as a `legacyOverride` when + * `terminalShellSelection` is absent. + */ execaShellPath: z.string().optional(), + /** + * User-selected inline-terminal shell resolution mode. + * + * - `auto`: follow trusted VS Code default/global profile, then OS default, + * then safe platform fallback (default when absent). + * - `profile`: use a named trusted VS Code terminal profile. + * - `path`: use an explicit executable path validated by the extension host. + * + * Absence of this field means Auto mode, preserving backward compatibility + * with settings persisted before the unified shell resolution feature. + */ + terminalShellSelection: terminalShellSelectionSchema.optional(), diagnosticsEnabled: z.boolean().optional(), autoCloseZooOpenedFiles: z.boolean().optional(), diff --git a/packages/types/src/terminal.ts b/packages/types/src/terminal.ts index 3a32866cdb..6a43f224b8 100644 --- a/packages/types/src/terminal.ts +++ b/packages/types/src/terminal.ts @@ -24,6 +24,7 @@ export const commandExecutionStatusSchema = z.discriminatedUnion("status", [ z.object({ executionId: z.string(), status: z.literal("fallback"), + reasonCode: z.string().optional(), }), z.object({ executionId: z.string(), @@ -33,6 +34,16 @@ export const commandExecutionStatusSchema = z.discriminatedUnion("status", [ executionId: z.string(), status: z.literal("error"), message: z.string().optional(), + code: z.string().optional(), + }), + z.object({ + executionId: z.string(), + status: z.literal("queued"), + }), + z.object({ + executionId: z.string(), + status: z.literal("recovering"), + errorCode: z.string().optional(), }), ]) diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index c35a5da538..7453d873ab 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -1,6 +1,6 @@ import { z } from "zod" -import type { GlobalSettings, RooCodeSettings } from "./global-settings.js" +import type { GlobalSettings, RooCodeSettings, TerminalShellSelection } from "./global-settings.js" import type { ProviderSettings, ProviderSettingsEntry } from "./provider-settings.js" import type { HistoryItem } from "./history.js" import type { ModeConfig, PromptComponent } from "./mode.js" @@ -103,6 +103,8 @@ export interface ExtensionMessage { | "rules" | "fileContent" | "rooHistoryImportProgress" + // Terminal shell options response type + | "terminalShellOptions" text?: string /** For fileContent: { path, content, error? } */ fileContent?: { path: string; content: string | null; error?: string } @@ -246,6 +248,9 @@ export interface ExtensionMessage { copyProgressBytesCopied?: number copyProgressTotalBytes?: number copyProgressItemName?: string + // Terminal shell options response payload. + // Contains sanitized trusted shell options and the effective-shell summary. + terminalShellOptions?: TerminalShellOptionsPayload // folderSelected path?: string } @@ -294,6 +299,7 @@ export type ExtensionState = Pick< | "terminalZdotdir" | "terminalProfile" | "execaShellPath" + | "terminalShellSelection" | "diagnosticsEnabled" | "autoCloseZooOpenedFiles" | "autoCloseZooOpenedFilesAfterUserEdited" @@ -420,6 +426,47 @@ export type ExtensionState = Pick< clineMessagesSeq?: number } +/** + * A sanitized, display-safe shell option for the inline-terminal shell selector. + * + * The extension host populates this from trusted VS Code default/global profile + * scopes and known OS defaults. Workspace-controlled profiles are never included. + */ +export interface TerminalShellOption { + /** Stable identifier for this option (e.g. "auto", "profile:PowerShell", "path:C:\..."). */ + id: string + /** User-facing display label. */ + label: string + /** Shell family controlling invocation semantics and command chaining. */ + family: "powershell" | "cmd" | "posix" | "fish" | "wsl" + /** Resolution source description (e.g. "vscode-default", "os-default", "user-override"). */ + source: string + /** Whether the shell executable is currently available on this machine. */ + available: boolean +} + +/** + * Payload for the `terminalShellOptions` extension-host → webview response. + * + * Contains the list of selectable shell options and a summary of the + * currently effective shell so the settings UI can display it read-only. + */ +export interface TerminalShellOptionsPayload { + /** Selectable shell options grouped by family. */ + options: TerminalShellOption[] + /** Summary of the currently effective resolved shell. */ + effectiveShell?: { + /** Display label for the effective shell executable. */ + label: string + /** Shell family of the effective shell. */ + family: TerminalShellOption["family"] + /** Resolution source of the effective shell. */ + source: string + } + /** Error message if option discovery failed (non-fatal; UI shows warning). */ + error?: string +} + export interface Command { name: string source: "global" | "project" | "built-in" @@ -631,6 +678,10 @@ export interface WebviewMessage { | "deleteRule" | "openRuleFile" | "openRulesDirectory" + // Terminal shell selection messages + | "requestTerminalShellOptions" + | "setTerminalShellSelection" + | "requestCustomShellPath" text?: string taskId?: string editedMessageContent?: string @@ -741,6 +792,9 @@ export interface WebviewMessage { worktreeForce?: boolean worktreeNewWindow?: boolean worktreeIncludeContent?: string + // Terminal shell selection payload for `setTerminalShellSelection`. + // The extension host validates this before persisting to global settings. + terminalShellSelection?: TerminalShellSelection } export interface RequestOpenAiCodexRateLimitsMessage { diff --git a/webview-ui/src/components/settings/SettingsView.tsx b/webview-ui/src/components/settings/SettingsView.tsx index c45ca38eea..e91f92037a 100644 --- a/webview-ui/src/components/settings/SettingsView.tsx +++ b/webview-ui/src/components/settings/SettingsView.tsx @@ -35,6 +35,7 @@ import { type ProviderSettings, type ExperimentId, type TelemetrySetting, + type TerminalShellSelection, DEFAULT_AUTO_CLOSE_ZOO_OPENED_FILES, DEFAULT_AUTO_CLOSE_ZOO_OPENED_FILES_AFTER_USER_EDITED, DEFAULT_AUTO_CLOSE_ZOO_OPENED_NEW_FILES, @@ -135,6 +136,9 @@ const SettingsView = forwardRef(({ onDone, t const [isDiscardDialogShow, setDiscardDialogShow] = useState(false) const [isChangeDetected, setChangeDetected] = useState(false) const [errorMessage, setErrorMessage] = useState(undefined) + const [pendingTerminalShellSelection, setPendingTerminalShellSelection] = useState< + TerminalShellSelection | undefined + >(undefined) const [activeTab, setActiveTab] = useState( targetSection && sectionNames.includes(targetSection as SectionName) ? (targetSection as SectionName) @@ -191,6 +195,7 @@ const SettingsView = forwardRef(({ onDone, t terminalZshP10k, terminalZdotdir, terminalProfile, + terminalShellSelection, writeDelayMs, diffFuzzyThreshold, showRooIgnoredFiles, @@ -455,6 +460,22 @@ const SettingsView = forwardRef(({ onDone, t vscode.postMessage({ type: "telemetrySetting", text: telemetrySetting }) vscode.postMessage({ type: "debugSetting", bool: cachedState.debug }) + // Send pending terminal shell selection (uses a separate message + // type with validation that isn't part of the updateSettings flow). + // Note: Do NOT reset pendingTerminalShellSelection here. Resetting it + // immediately causes the prop to TerminalSettings to temporarily revert + // to the stale state_terminalShellSelection (before postStateToWebview + // arrives), which triggers the useEffect that overwrites the user's + // selection and makes the dropdown show "Auto". Instead, let the + // pending value persist until the extension host syncs the updated + // state back via postStateToWebview(). + if (pendingTerminalShellSelection) { + vscode.postMessage({ + type: "setTerminalShellSelection", + terminalShellSelection: pendingTerminalShellSelection, + }) + } + setChangeDetected(false) } } @@ -479,6 +500,7 @@ const SettingsView = forwardRef(({ onDone, t // Discard changes: Reset state and flag setCachedState(extensionState) // Revert to original state setChangeDetected(false) // Reset change flag + setPendingTerminalShellSelection(undefined) // Revert pending shell selection confirmDialogHandler.current?.() // Execute the pending action (e.g., tab switch) } // If confirm is false (Cancel), do nothing, dialog closes automatically @@ -892,7 +914,16 @@ const SettingsView = forwardRef(({ onDone, t terminalZshP10k={terminalZshP10k} terminalZdotdir={terminalZdotdir} terminalProfile={terminalProfile} + terminalShellSelection={pendingTerminalShellSelection ?? terminalShellSelection} onTerminalProfilePickerOpened={() => setChangeDetected(true)} + onShellSelectionChange={(selection) => { + // Buffer the selection and explicitly mark the settings as + // dirty so the Save button enables on shell-only changes. + // (Previously the dirty flag was only set incidentally via + // onTerminalProfilePickerOpened.) + setPendingTerminalShellSelection(selection) + setChangeDetected(true) + }} setCachedStateField={setCachedStateField} /> )} diff --git a/webview-ui/src/components/settings/TerminalSettings.tsx b/webview-ui/src/components/settings/TerminalSettings.tsx index 3601f1876e..eb8ed94a7d 100644 --- a/webview-ui/src/components/settings/TerminalSettings.tsx +++ b/webview-ui/src/components/settings/TerminalSettings.tsx @@ -7,7 +7,12 @@ import { buildDocLink } from "@src/utils/docLinks" import { useEvent, useMount } from "react-use" import { Terminal } from "lucide-react" -import { type ExtensionMessage, type TerminalOutputPreviewSize } from "@roo-code/types" +import { + type ExtensionMessage, + type TerminalOutputPreviewSize, + type TerminalShellOptionsPayload, + type TerminalShellSelection, +} from "@roo-code/types" import { cn } from "@/lib/utils" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Slider, Button } from "@/components/ui" @@ -28,7 +33,9 @@ type TerminalSettingsProps = HTMLAttributes & { terminalZshP10k?: boolean terminalZdotdir?: boolean terminalProfile?: string + terminalShellSelection?: TerminalShellSelection onTerminalProfilePickerOpened?: () => void + onShellSelectionChange?: (selection: TerminalShellSelection) => void setCachedStateField: SetCachedStateField< | "terminalOutputPreviewSize" | "terminalShellIntegrationTimeout" @@ -58,7 +65,9 @@ export const TerminalSettings = ({ terminalZshP10k, terminalZdotdir, terminalProfile, + terminalShellSelection, onTerminalProfilePickerOpened, + onShellSelectionChange, setCachedStateField, className, ...props @@ -68,13 +77,21 @@ export const TerminalSettings = ({ const [inheritEnv, setInheritEnv] = useState(true) const [profileNames, setProfileNames] = useState([]) const [isProfilesLoaded, setIsProfilesLoaded] = useState(false) + const [shellOptions, setShellOptions] = useState(undefined) + const [shellError, setShellError] = useState(undefined) + const [pendingShellSelection, setPendingShellSelection] = useState( + terminalShellSelection, + ) const isVSCodeTerminalEnabled = terminalShellIntegrationDisabled === false + const isInlineModeEnabled = terminalShellIntegrationDisabled !== false useMount(() => { vscode.postMessage({ type: "getVSCodeSetting", setting: "terminal.integrated.inheritEnv" }) // Request the terminal profile names through a dedicated, allowlisted message // (the extension reads the profiles and returns only sanitized names). vscode.postMessage({ type: "requestTerminalProfiles" }) + // Request inline shell options from the extension host. + vscode.postMessage({ type: "requestTerminalShellOptions" }) }) const onMessage = useCallback((event: MessageEvent) => { @@ -90,6 +107,10 @@ export const TerminalSettings = ({ setProfileNames(message.profiles ?? []) setIsProfilesLoaded(true) break + case "terminalShellOptions": + setShellOptions(message.terminalShellOptions) + setShellError(message.terminalShellOptions?.error) + break default: break } @@ -103,6 +124,12 @@ export const TerminalSettings = ({ } }, [isProfilesLoaded, profileNames, setCachedStateField, terminalProfile]) + // Sync pending selection when the persisted value changes (e.g. after Save + // updates extension state, or when settings are discarded). + useEffect(() => { + setPendingShellSelection(terminalShellSelection) + }, [terminalShellSelection]) + return (
{t("settings:sections.terminal")} @@ -190,7 +217,135 @@ export const TerminalSettings = ({
- + + {isInlineModeEnabled && ( + + + + + {/* Custom executable button */} +
+ +
+ + {/* Effective shell display */} + {shellOptions?.effectiveShell && ( +
+
+ {t("settings:terminal.inlineShell.effectiveShell.label")} +
+
+ {t("settings:terminal.inlineShell.effectiveShell.family")}:{" "} + {shellOptions.effectiveShell.family} +
+
+ {t("settings:terminal.inlineShell.effectiveShell.source")}:{" "} + {shellOptions.effectiveShell.source} +
+
+ {t("settings:terminal.inlineShell.effectiveShell.fallbackDescription")} +
+
+ )} + + {/* Error message */} + {shellError && ( +
+ {t("settings:terminal.inlineShell.error.invalid")} +
+ )} + +
+ {t("settings:terminal.inlineShell.description")} +
+
+ )} + {isVSCodeTerminalEnabled && ( <> {/* Profile override — unified dropdown, now below checkbox */} diff --git a/webview-ui/src/components/settings/__tests__/SettingsView.shell-selection.spec.tsx b/webview-ui/src/components/settings/__tests__/SettingsView.shell-selection.spec.tsx new file mode 100644 index 0000000000..e6e84aaeae --- /dev/null +++ b/webview-ui/src/components/settings/__tests__/SettingsView.shell-selection.spec.tsx @@ -0,0 +1,346 @@ +// pnpm --filter @roo-code/vscode-webview test src/components/settings/__tests__/SettingsView.shell-selection.spec.tsx + +/** + * Tests for the SettingsView ↔ TerminalSettings shell-selection wiring. + * + * Verifies that: + * - Changing the shell selection marks the settings as dirty so the Save + * button enables on shell-only changes (previously the dirty flag was + * only set incidentally via onTerminalProfilePickerOpened). + * - Save posts the pending selection through the existing + * `setTerminalShellSelection` message (the only path that persists it). + */ + +import { render, screen, fireEvent, act } from "@/utils/test-utils" +import { QueryClient, QueryClientProvider } from "@tanstack/react-query" + +import { vscode } from "@/utils/vscode" +import { ExtensionStateContextProvider } from "@/context/ExtensionStateContext" + +import SettingsView from "../SettingsView" + +vi.mock("@src/utils/vscode", () => ({ vscode: { postMessage: vi.fn() } })) + +vi.mock("../ApiConfigManager", () => ({ + __esModule: true, + default: ({ currentApiConfigName }: any) => ( +
+ Current config: {currentApiConfigName} +
+ ), +})) + +// Capture the props SettingsView passes to TerminalSettings so tests can +// drive onShellSelectionChange directly. +const capturedTerminalProps = vi.hoisted(() => ({ current: null as any })) + +vi.mock("../TerminalSettings", () => ({ + DEFAULT_PROFILE_VALUE: "__zoo_code_follow_vscode_sentinel__", + TerminalSettings: (props: any) => { + capturedTerminalProps.current = props + return
+ }, +})) + +vi.mock("@vscode/webview-ui-toolkit/react", () => ({ + VSCodeButton: ({ children, onClick, appearance, "data-testid": dataTestId }: any) => + appearance === "icon" ? ( + + ) : ( + + ), + VSCodeCheckbox: ({ children, onChange, checked, "data-testid": dataTestId }: any) => ( + + ), + VSCodeTextField: ({ value, onInput, placeholder, "data-testid": dataTestId }: any) => ( + onInput({ target: { value: e.target.value } })} + placeholder={placeholder} + data-testid={dataTestId} + /> + ), + VSCodeLink: ({ children, href }: any) => {children}, + VSCodeRadio: ({ value, checked, onChange }: any) => ( + + ), + VSCodeRadioGroup: ({ children, onChange }: any) =>
{children}
, + VSCodeTextArea: ({ value, onChange, rows, className, "data-testid": dataTestId }: any) => ( +