From 748d50057eaa1d2b241c256980bc26a0274c52b3 Mon Sep 17 00:00:00 2001 From: Vitezslav Date: Thu, 2 Jul 2026 23:39:07 +0200 Subject: [PATCH] feat: add terminal shell picker setting Adds a global "Terminal > Shell" setting that controls which shell the built-in terminal launches. Detected shells (COMSPEC/$SHELL plus cmd/PowerShell/pwsh on Windows, bash/zsh/fish/sh elsewhere) are offered in a dropdown; "Auto" keeps the existing environment-based behavior. The preference is stored as terminalShell in the global config and applies to newly started shell sessions. If the configured shell is no longer available on PATH, sessions silently fall back to the environment default and the session summary reports the shell that actually ran. --- src/config/runtime-config.ts | 45 ++++- src/core/api-contract.ts | 3 + src/core/shell.ts | 63 ++++++- src/server/runtime-server.ts | 2 +- src/terminal/agent-registry.ts | 3 + src/trpc/runtime-api.ts | 5 +- test/runtime/config/runtime-config.test.ts | 77 ++++++++ test/runtime/core/shell.test.ts | 173 ++++++++++++++++++ test/runtime/terminal/agent-registry.test.ts | 1 + test/runtime/trpc/runtime-api.test.ts | 1 + .../runtime-settings-dialog.test.tsx | 42 +++++ .../components/runtime-settings-dialog.tsx | 77 +++++++- web-ui/src/hooks/use-git-actions.test.tsx | 2 + .../src/hooks/use-home-agent-session.test.tsx | 2 + ...runtime-settings-cline-controller.test.tsx | 2 + .../src/hooks/use-startup-onboarding.test.tsx | 2 + web-ui/src/runtime/native-agent.test.ts | 2 + web-ui/src/runtime/runtime-config-query.ts | 1 + .../src/runtime/use-runtime-config.test.tsx | 2 + web-ui/src/runtime/use-runtime-config.ts | 2 + .../use-runtime-project-config.test.tsx | 2 + 21 files changed, 502 insertions(+), 7 deletions(-) create mode 100644 test/runtime/core/shell.test.ts diff --git a/src/config/runtime-config.ts b/src/config/runtime-config.ts index 215228cd51..14e77b546d 100644 --- a/src/config/runtime-config.ts +++ b/src/config/runtime-config.ts @@ -17,6 +17,7 @@ interface RuntimeGlobalConfigFileShape { readyForReviewNotificationsEnabled?: boolean; commitPromptTemplate?: string; openPrPromptTemplate?: string; + terminalShell?: string; } interface RuntimeProjectConfigFileShape { @@ -35,6 +36,7 @@ export interface RuntimeConfigState { openPrPromptTemplate: string; commitPromptTemplateDefault: string; openPrPromptTemplateDefault: string; + terminalShell: string | null; } export interface RuntimeConfigUpdateInput { @@ -45,6 +47,7 @@ export interface RuntimeConfigUpdateInput { shortcuts?: RuntimeProjectShortcut[]; commitPromptTemplate?: string; openPrPromptTemplate?: string; + terminalShell?: string | null; } const RUNTIME_HOME_PARENT_DIR = ".cline"; @@ -185,7 +188,7 @@ function normalizeBoolean(value: unknown, fallback: boolean): boolean { return fallback; } -function normalizeShortcutLabel(value: unknown): string | null { +function normalizeOptionalTrimmedString(value: unknown): string | null { if (typeof value !== "string") { return null; } @@ -193,6 +196,10 @@ function normalizeShortcutLabel(value: unknown): string | null { return normalized.length > 0 ? normalized : null; } +function normalizeShortcutLabel(value: unknown): string | null { + return normalizeOptionalTrimmedString(value); +} + function hasOwnKey(value: T | null, key: keyof T): boolean { if (!value) { return false; @@ -291,6 +298,7 @@ function toRuntimeConfigState({ ), commitPromptTemplateDefault: DEFAULT_COMMIT_PROMPT_TEMPLATE, openPrPromptTemplateDefault: DEFAULT_OPEN_PR_PROMPT_TEMPLATE, + terminalShell: normalizeOptionalTrimmedString(globalConfig?.terminalShell), }; } @@ -312,6 +320,7 @@ async function writeRuntimeGlobalConfigFile( readyForReviewNotificationsEnabled?: boolean; commitPromptTemplate?: string; openPrPromptTemplate?: string; + terminalShell?: string | null; }, ): Promise { const existing = await readRuntimeConfigFile(configPath); @@ -340,6 +349,11 @@ async function writeRuntimeGlobalConfigFile( config.openPrPromptTemplate === undefined ? DEFAULT_OPEN_PR_PROMPT_TEMPLATE : normalizePromptTemplate(config.openPrPromptTemplate, DEFAULT_OPEN_PR_PROMPT_TEMPLATE); + const terminalShell = + config.terminalShell === undefined ? undefined : normalizeOptionalTrimmedString(config.terminalShell); + const existingTerminalShell = hasOwnKey(existing, "terminalShell") + ? normalizeOptionalTrimmedString(existing?.terminalShell) + : undefined; const payload: RuntimeGlobalConfigFileShape = {}; if (selectedAgentId !== undefined) { @@ -374,6 +388,13 @@ async function writeRuntimeGlobalConfigFile( if (hasOwnKey(existing, "openPrPromptTemplate") || openPrPromptTemplate !== DEFAULT_OPEN_PR_PROMPT_TEMPLATE) { payload.openPrPromptTemplate = openPrPromptTemplate; } + if (terminalShell !== undefined) { + if (terminalShell) { + payload.terminalShell = terminalShell; + } + } else if (existingTerminalShell) { + payload.terminalShell = existingTerminalShell; + } await lockedFileSystem.writeJsonFileAtomic(configPath, payload, { lock: null, @@ -456,6 +477,7 @@ function createRuntimeConfigStateFromValues(input: { shortcuts: RuntimeProjectShortcut[]; commitPromptTemplate: string; openPrPromptTemplate: string; + terminalShell: string | null; }): RuntimeConfigState { return { globalConfigPath: input.globalConfigPath, @@ -475,6 +497,7 @@ function createRuntimeConfigStateFromValues(input: { openPrPromptTemplate: normalizePromptTemplate(input.openPrPromptTemplate, DEFAULT_OPEN_PR_PROMPT_TEMPLATE), commitPromptTemplateDefault: DEFAULT_COMMIT_PROMPT_TEMPLATE, openPrPromptTemplateDefault: DEFAULT_OPEN_PR_PROMPT_TEMPLATE, + terminalShell: normalizeOptionalTrimmedString(input.terminalShell), }; } @@ -489,6 +512,7 @@ export function toGlobalRuntimeConfigState(current: RuntimeConfigState): Runtime shortcuts: [], commitPromptTemplate: current.commitPromptTemplate, openPrPromptTemplate: current.openPrPromptTemplate, + terminalShell: current.terminalShell, }); } @@ -524,6 +548,7 @@ export async function saveRuntimeConfig( shortcuts: RuntimeProjectShortcut[]; commitPromptTemplate: string; openPrPromptTemplate: string; + terminalShell: string | null; }, ): Promise { const { globalConfigPath, projectConfigPath } = resolveRuntimeConfigPaths(cwd); @@ -535,6 +560,7 @@ export async function saveRuntimeConfig( readyForReviewNotificationsEnabled: config.readyForReviewNotificationsEnabled, commitPromptTemplate: config.commitPromptTemplate, openPrPromptTemplate: config.openPrPromptTemplate, + terminalShell: config.terminalShell, }); await writeRuntimeProjectConfigFile(projectConfigPath, { shortcuts: config.shortcuts }); return createRuntimeConfigStateFromValues({ @@ -547,6 +573,7 @@ export async function saveRuntimeConfig( shortcuts: config.shortcuts, commitPromptTemplate: config.commitPromptTemplate, openPrPromptTemplate: config.openPrPromptTemplate, + terminalShell: config.terminalShell, }); }); } @@ -568,6 +595,10 @@ export async function updateRuntimeConfig(cwd: string, updates: RuntimeConfigUpd shortcuts: projectConfigPath ? (updates.shortcuts ?? current.shortcuts) : current.shortcuts, commitPromptTemplate: updates.commitPromptTemplate ?? current.commitPromptTemplate, openPrPromptTemplate: updates.openPrPromptTemplate ?? current.openPrPromptTemplate, + terminalShell: + updates.terminalShell === undefined + ? current.terminalShell + : normalizeOptionalTrimmedString(updates.terminalShell), }; const hasChanges = @@ -577,6 +608,7 @@ export async function updateRuntimeConfig(cwd: string, updates: RuntimeConfigUpd nextConfig.readyForReviewNotificationsEnabled !== current.readyForReviewNotificationsEnabled || nextConfig.commitPromptTemplate !== current.commitPromptTemplate || nextConfig.openPrPromptTemplate !== current.openPrPromptTemplate || + nextConfig.terminalShell !== current.terminalShell || !areRuntimeProjectShortcutsEqual(nextConfig.shortcuts, current.shortcuts); if (!hasChanges) { @@ -590,6 +622,7 @@ export async function updateRuntimeConfig(cwd: string, updates: RuntimeConfigUpd readyForReviewNotificationsEnabled: nextConfig.readyForReviewNotificationsEnabled, commitPromptTemplate: nextConfig.commitPromptTemplate, openPrPromptTemplate: nextConfig.openPrPromptTemplate, + terminalShell: nextConfig.terminalShell, }); await writeRuntimeProjectConfigFile(projectConfigPath, { shortcuts: nextConfig.shortcuts, @@ -604,6 +637,7 @@ export async function updateRuntimeConfig(cwd: string, updates: RuntimeConfigUpd shortcuts: nextConfig.shortcuts, commitPromptTemplate: nextConfig.commitPromptTemplate, openPrPromptTemplate: nextConfig.openPrPromptTemplate, + terminalShell: nextConfig.terminalShell, }); }); } @@ -633,6 +667,10 @@ export async function updateGlobalRuntimeConfig( shortcuts: current.shortcuts, commitPromptTemplate: updates.commitPromptTemplate ?? current.commitPromptTemplate, openPrPromptTemplate: updates.openPrPromptTemplate ?? current.openPrPromptTemplate, + terminalShell: + updates.terminalShell === undefined + ? current.terminalShell + : normalizeOptionalTrimmedString(updates.terminalShell), }; const hasChanges = @@ -641,7 +679,8 @@ export async function updateGlobalRuntimeConfig( nextConfig.agentAutonomousModeEnabled !== current.agentAutonomousModeEnabled || nextConfig.readyForReviewNotificationsEnabled !== current.readyForReviewNotificationsEnabled || nextConfig.commitPromptTemplate !== current.commitPromptTemplate || - nextConfig.openPrPromptTemplate !== current.openPrPromptTemplate; + nextConfig.openPrPromptTemplate !== current.openPrPromptTemplate || + nextConfig.terminalShell !== current.terminalShell; if (!hasChanges) { return current; @@ -654,6 +693,7 @@ export async function updateGlobalRuntimeConfig( readyForReviewNotificationsEnabled: nextConfig.readyForReviewNotificationsEnabled, commitPromptTemplate: nextConfig.commitPromptTemplate, openPrPromptTemplate: nextConfig.openPrPromptTemplate, + terminalShell: nextConfig.terminalShell, }); return createRuntimeConfigStateFromValues({ @@ -666,6 +706,7 @@ export async function updateGlobalRuntimeConfig( shortcuts: nextConfig.shortcuts, commitPromptTemplate: nextConfig.commitPromptTemplate, openPrPromptTemplate: nextConfig.openPrPromptTemplate, + terminalShell: nextConfig.terminalShell, }); }, ); diff --git a/src/core/api-contract.ts b/src/core/api-contract.ts index 774ecf8573..e1144891c5 100644 --- a/src/core/api-contract.ts +++ b/src/core/api-contract.ts @@ -955,6 +955,8 @@ export const runtimeConfigResponseSchema = z.object({ openPrPromptTemplate: z.string(), commitPromptTemplateDefault: z.string(), openPrPromptTemplateDefault: z.string(), + terminalShell: z.string().nullable(), + detectedShells: z.array(z.string()), }); export type RuntimeConfigResponse = z.infer; @@ -966,6 +968,7 @@ export const runtimeConfigSaveRequestSchema = z.object({ readyForReviewNotificationsEnabled: z.boolean().optional(), commitPromptTemplate: z.string().optional(), openPrPromptTemplate: z.string().optional(), + terminalShell: z.string().nullable().optional(), }); export type RuntimeConfigSaveRequest = z.infer; diff --git a/src/core/shell.ts b/src/core/shell.ts index 14cdfa93f1..a95fded1ca 100644 --- a/src/core/shell.ts +++ b/src/core/shell.ts @@ -1,4 +1,44 @@ -export function resolveInteractiveShellCommand(): { binary: string; args: string[] } { +import { isBinaryAvailableOnPath } from "../terminal/command-discovery"; + +const WINDOWS_SHELL_CANDIDATES = ["cmd.exe", "powershell.exe", "pwsh"] as const; +const POSIX_SHELL_CANDIDATES = ["bash", "zsh", "fish", "sh"] as const; + +function getShellFamily(binary: string): string { + const baseName = binary.replaceAll("\\", "/").split("/").at(-1) ?? binary; + return baseName.toLowerCase().replace(/\.(exe|com|cmd|bat)$/, ""); +} + +export function getInteractiveShellArgs(binary: string): string[] { + const family = getShellFamily(binary); + if (family === "cmd") { + return []; + } + if (family === "powershell" || family === "pwsh") { + return ["-NoLogo"]; + } + if ((POSIX_SHELL_CANDIDATES as readonly string[]).includes(family)) { + return ["-i"]; + } + // Unknown shells: no flags on Windows, interactive flag elsewhere to match + // the historical $SHELL launch behavior. + if (process.platform === "win32") { + return []; + } + return ["-i"]; +} + +export function resolveInteractiveShellCommand(preferredShell?: string | null): { binary: string; args: string[] } { + // A configured shell that disappeared from PATH (uninstalled, PATH change) + // silently falls back to the environment default instead of failing the + // terminal; the session response reports the shell that actually ran. + const preferred = preferredShell?.trim(); + if (preferred && isBinaryAvailableOnPath(preferred)) { + return { + binary: preferred, + args: getInteractiveShellArgs(preferred), + }; + } + if (process.platform === "win32") { const command = process.env.COMSPEC?.trim(); if (command) { @@ -26,6 +66,27 @@ export function resolveInteractiveShellCommand(): { binary: string; args: string }; } +export function detectAvailableShells(): string[] { + const candidates: (string | undefined)[] = + process.platform === "win32" + ? [process.env.COMSPEC?.trim(), ...WINDOWS_SHELL_CANDIDATES] + : [process.env.SHELL?.trim(), ...POSIX_SHELL_CANDIDATES]; + const detected: string[] = []; + const seenFamilies = new Set(); + for (const candidate of candidates) { + if (!candidate) { + continue; + } + const family = getShellFamily(candidate); + if (seenFamilies.has(family) || !isBinaryAvailableOnPath(candidate)) { + continue; + } + seenFamilies.add(family); + detected.push(candidate); + } + return detected; +} + export function quoteShellArg(value: string): string { if (process.platform === "win32") { return `"${value.replaceAll('"', '""')}"`; diff --git a/src/server/runtime-server.ts b/src/server/runtime-server.ts index 85256dafbf..5e496f9ecc 100644 --- a/src/server/runtime-server.ts +++ b/src/server/runtime-server.ts @@ -59,7 +59,7 @@ export interface CreateRuntimeServerDependencies { runtimeStateHub: RuntimeStateHub; warn: (message: string) => void; ensureTerminalManagerForWorkspace: (workspaceId: string, repoPath: string) => Promise; - resolveInteractiveShellCommand: () => { binary: string; args: string[] }; + resolveInteractiveShellCommand: (preferredShell?: string | null) => { binary: string; args: string[] }; runCommand: (command: string, cwd: string) => Promise; resolveProjectInputPath: (inputPath: string, basePath: string) => string; assertPathIsDirectory: (targetPath: string) => Promise; diff --git a/src/terminal/agent-registry.ts b/src/terminal/agent-registry.ts index 4775a128b6..02f37f3a14 100644 --- a/src/terminal/agent-registry.ts +++ b/src/terminal/agent-registry.ts @@ -6,6 +6,7 @@ import type { RuntimeClineProviderSettings, RuntimeConfigResponse, } from "../core/api-contract"; +import { detectAvailableShells } from "../core/shell"; import { isBinaryAvailableOnPath } from "./command-discovery"; export interface ResolvedAgentCommand { @@ -124,5 +125,7 @@ export function buildRuntimeConfigResponse( openPrPromptTemplate: runtimeConfig.openPrPromptTemplate, commitPromptTemplateDefault: runtimeConfig.commitPromptTemplateDefault, openPrPromptTemplateDefault: runtimeConfig.openPrPromptTemplateDefault, + terminalShell: runtimeConfig.terminalShell, + detectedShells: detectAvailableShells(), }; } diff --git a/src/trpc/runtime-api.ts b/src/trpc/runtime-api.ts index a494c86b97..c29f913ccd 100644 --- a/src/trpc/runtime-api.ts +++ b/src/trpc/runtime-api.ts @@ -57,7 +57,7 @@ export interface CreateRuntimeApiDependencies { setActiveRuntimeConfig: (config: RuntimeConfigState) => void; getScopedTerminalManager: (scope: RuntimeTrpcWorkspaceScope) => Promise; getScopedClineTaskSessionService: (scope: RuntimeTrpcWorkspaceScope) => Promise; - resolveInteractiveShellCommand: () => { binary: string; args: string[] }; + resolveInteractiveShellCommand: (preferredShell?: string | null) => { binary: string; args: string[] }; runCommand: (command: string, cwd: string) => Promise; broadcastClineMcpAuthStatusesUpdated?: ( statuses: Awaited["getAuthStatuses"]>>, @@ -660,7 +660,8 @@ export function createRuntimeApi(deps: CreateRuntimeApiDependencies): RuntimeTrp try { const body = parseShellSessionStartRequest(input); const terminalManager = await deps.getScopedTerminalManager(workspaceScope); - const shell = deps.resolveInteractiveShellCommand(); + const runtimeConfig = await deps.loadScopedRuntimeConfig(workspaceScope); + const shell = deps.resolveInteractiveShellCommand(runtimeConfig.terminalShell); const shellCwd = body.workspaceTaskId ? await resolveTaskCwd({ cwd: workspaceScope.workspacePath, diff --git a/test/runtime/config/runtime-config.test.ts b/test/runtime/config/runtime-config.test.ts index 884b383d73..538bcb2822 100644 --- a/test/runtime/config/runtime-config.test.ts +++ b/test/runtime/config/runtime-config.test.ts @@ -294,6 +294,7 @@ describe.sequential("runtime-config auto agent selection", () => { shortcuts: [], commitPromptTemplate: current.commitPromptTemplateDefault, openPrPromptTemplate: current.openPrPromptTemplateDefault, + terminalShell: null, }); const globalPayload = JSON.parse( @@ -304,12 +305,14 @@ describe.sequential("runtime-config auto agent selection", () => { readyForReviewNotificationsEnabled?: boolean; commitPromptTemplate?: string; openPrPromptTemplate?: string; + terminalShell?: string; }; expect(globalPayload.selectedAgentId).toBeUndefined(); expect(globalPayload.agentAutonomousModeEnabled).toBeUndefined(); expect(globalPayload.readyForReviewNotificationsEnabled).toBeUndefined(); expect(globalPayload.commitPromptTemplate).toBeUndefined(); expect(globalPayload.openPrPromptTemplate).toBeUndefined(); + expect(globalPayload.terminalShell).toBeUndefined(); expect(existsSync(join(tempProject, ".cline", "kanban", "config.json"))).toBe(false); }); } finally { @@ -339,6 +342,7 @@ describe.sequential("runtime-config auto agent selection", () => { shortcuts: [], commitPromptTemplate: current.commitPromptTemplateDefault, openPrPromptTemplate: current.openPrPromptTemplateDefault, + terminalShell: null, }); expect(existsSync(join(tempProject, ".cline", "kanban", "config.json"))).toBe(false); @@ -366,6 +370,7 @@ describe.sequential("runtime-config auto agent selection", () => { shortcuts: [{ label: "Ship", command: "npm run ship", icon: "rocket" }], commitPromptTemplate: current.commitPromptTemplateDefault, openPrPromptTemplate: current.openPrPromptTemplateDefault, + terminalShell: null, }); expect(existsSync(join(tempProject, ".cline", "kanban", "config.json"))).toBe(true); @@ -413,6 +418,78 @@ describe.sequential("runtime-config auto agent selection", () => { } }); + it("persists, reloads, and clears the terminal shell preference", async () => { + const { path: tempHome, cleanup: cleanupHome } = createTempDir("kanban-home-runtime-config-terminal-shell-"); + const { path: tempProject, cleanup: cleanupProject } = createTempDir( + "kanban-project-runtime-config-terminal-shell-", + ); + + try { + await withTemporaryEnv({ home: tempHome }, async () => { + const initial = await loadRuntimeConfig(tempProject); + expect(initial.terminalShell).toBeNull(); + + const updated = await updateRuntimeConfig(tempProject, { + terminalShell: "pwsh", + }); + expect(updated.terminalShell).toBe("pwsh"); + + const globalPayload = JSON.parse( + readFileSync(join(tempHome, ".cline", "kanban", "config.json"), "utf8"), + ) as { + terminalShell?: string; + }; + expect(globalPayload.terminalShell).toBe("pwsh"); + + const reloaded = await loadRuntimeConfig(tempProject); + expect(reloaded.terminalShell).toBe("pwsh"); + + const cleared = await updateRuntimeConfig(tempProject, { + terminalShell: null, + }); + expect(cleared.terminalShell).toBeNull(); + const clearedPayload = JSON.parse( + readFileSync(join(tempHome, ".cline", "kanban", "config.json"), "utf8"), + ) as { + terminalShell?: string; + }; + expect(clearedPayload.terminalShell).toBeUndefined(); + }); + } finally { + cleanupProject(); + cleanupHome(); + } + }); + + it("normalizes a blank terminal shell preference to null", async () => { + const { path: tempHome, cleanup: cleanupHome } = createTempDir("kanban-home-runtime-config-terminal-blank-"); + const { path: tempProject, cleanup: cleanupProject } = createTempDir( + "kanban-project-runtime-config-terminal-blank-", + ); + + try { + await withTemporaryEnv({ home: tempHome }, async () => { + const updated = await updateRuntimeConfig(tempProject, { + terminalShell: " ", + agentAutonomousModeEnabled: false, + }); + expect(updated.terminalShell).toBeNull(); + + const globalPayload = JSON.parse( + readFileSync(join(tempHome, ".cline", "kanban", "config.json"), "utf8"), + ) as { + agentAutonomousModeEnabled?: boolean; + terminalShell?: string; + }; + expect(globalPayload.agentAutonomousModeEnabled).toBe(false); + expect(globalPayload.terminalShell).toBeUndefined(); + }); + } finally { + cleanupProject(); + cleanupHome(); + } + }); + it("persists autonomous mode when disabled", async () => { const { path: tempHome, cleanup: cleanupHome } = createTempDir("kanban-home-runtime-config-autonomous-disabled-"); const { path: tempProject, cleanup: cleanupProject } = createTempDir( diff --git a/test/runtime/core/shell.test.ts b/test/runtime/core/shell.test.ts new file mode 100644 index 0000000000..398f2cda73 --- /dev/null +++ b/test/runtime/core/shell.test.ts @@ -0,0 +1,173 @@ +import { chmodSync, mkdirSync, writeFileSync } from "node:fs"; +import { delimiter, join } from "node:path"; +import { describe, expect, it } from "vitest"; + +import { + detectAvailableShells, + getInteractiveShellArgs, + resolveInteractiveShellCommand, +} from "../../../src/core/shell"; +import { createTempDir } from "../../utilities/temp-dir"; + +function withEnv(overrides: Record, run: () => T): T { + const previous = new Map(); + for (const [key, value] of Object.entries(overrides)) { + previous.set(key, process.env[key]); + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + try { + return run(); + } finally { + for (const [key, value] of previous) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + } +} + +function writeFakeShell(binDir: string, name: string): string { + mkdirSync(binDir, { recursive: true }); + if (process.platform === "win32") { + const filePath = join(binDir, `${name}.exe`); + writeFileSync(filePath, ""); + return filePath; + } + const filePath = join(binDir, name); + writeFileSync(filePath, "#!/bin/sh\nexit 0\n"); + chmodSync(filePath, 0o755); + return filePath; +} + +describe("getInteractiveShellArgs", () => { + it("passes no flags to cmd", () => { + expect(getInteractiveShellArgs("cmd")).toEqual([]); + expect(getInteractiveShellArgs("cmd.exe")).toEqual([]); + expect(getInteractiveShellArgs("C:\\Windows\\System32\\cmd.exe")).toEqual([]); + }); + + it("passes -NoLogo to PowerShell family shells", () => { + expect(getInteractiveShellArgs("powershell.exe")).toEqual(["-NoLogo"]); + expect(getInteractiveShellArgs("pwsh")).toEqual(["-NoLogo"]); + expect(getInteractiveShellArgs("/usr/local/bin/pwsh")).toEqual(["-NoLogo"]); + }); + + it("passes -i to POSIX shells on every platform", () => { + expect(getInteractiveShellArgs("bash")).toEqual(["-i"]); + expect(getInteractiveShellArgs("/bin/zsh")).toEqual(["-i"]); + expect(getInteractiveShellArgs("fish")).toEqual(["-i"]); + expect(getInteractiveShellArgs("bash.exe")).toEqual(["-i"]); + }); +}); + +describe("resolveInteractiveShellCommand", () => { + it("uses the preferred shell when it is available on PATH", () => { + const { path: tempBin, cleanup } = createTempDir("kanban-shell-preferred-"); + try { + writeFakeShell(tempBin, "zsh"); + withEnv({ PATH: tempBin }, () => { + const resolved = resolveInteractiveShellCommand("zsh"); + expect(resolved.binary).toBe("zsh"); + expect(resolved.args).toEqual(["-i"]); + }); + } finally { + cleanup(); + } + }); + + it("uses a preferred shell given as an absolute path", () => { + const { path: tempBin, cleanup } = createTempDir("kanban-shell-preferred-path-"); + try { + const shellPath = writeFakeShell(tempBin, "fish"); + const resolved = resolveInteractiveShellCommand(shellPath); + expect(resolved.binary).toBe(shellPath); + expect(resolved.args).toEqual(["-i"]); + } finally { + cleanup(); + } + }); + + it("falls back to the environment shell when the preferred shell is unavailable", () => { + const fallback = resolveInteractiveShellCommand(); + expect(resolveInteractiveShellCommand("definitely-not-a-shell-xyz")).toEqual(fallback); + }); + + it("falls back to the environment shell for null, undefined, and blank preferences", () => { + const fallback = resolveInteractiveShellCommand(); + expect(resolveInteractiveShellCommand(null)).toEqual(fallback); + expect(resolveInteractiveShellCommand(undefined)).toEqual(fallback); + expect(resolveInteractiveShellCommand(" ")).toEqual(fallback); + }); + + it.runIf(process.platform === "win32")("uses COMSPEC when no preference is set on Windows", () => { + withEnv({ COMSPEC: "C:\\Windows\\System32\\cmd.exe" }, () => { + expect(resolveInteractiveShellCommand()).toEqual({ + binary: "C:\\Windows\\System32\\cmd.exe", + args: [], + }); + }); + }); + + it.runIf(process.platform !== "win32")("uses SHELL when no preference is set on POSIX", () => { + withEnv({ SHELL: "/bin/zsh" }, () => { + expect(resolveInteractiveShellCommand()).toEqual({ + binary: "/bin/zsh", + args: ["-i"], + }); + }); + }); +}); + +describe("detectAvailableShells", () => { + it.runIf(process.platform === "win32")("detects Windows shells and dedupes COMSPEC by family", () => { + const { path: tempBin, cleanup } = createTempDir("kanban-shell-detect-"); + try { + const comspecPath = writeFakeShell(tempBin, "cmd"); + writeFakeShell(tempBin, "pwsh"); + withEnv({ PATH: tempBin, COMSPEC: comspecPath }, () => { + const detected = detectAvailableShells(); + expect(detected).toEqual([comspecPath, "pwsh"]); + }); + } finally { + cleanup(); + } + }); + + it.runIf(process.platform !== "win32")("detects POSIX shells and dedupes SHELL by family", () => { + const { path: tempBin, cleanup } = createTempDir("kanban-shell-detect-"); + try { + const shellPath = writeFakeShell(tempBin, "zsh"); + writeFakeShell(tempBin, "bash"); + withEnv({ PATH: tempBin, SHELL: shellPath }, () => { + const detected = detectAvailableShells(); + expect(detected).toEqual([shellPath, "bash"]); + }); + } finally { + cleanup(); + } + }); + + it("omits shells that are not available on PATH", () => { + const { path: tempBin, cleanup } = createTempDir("kanban-shell-detect-empty-"); + try { + withEnv( + { + PATH: join(tempBin, "empty") + delimiter, + SHELL: undefined, + COMSPEC: undefined, + }, + () => { + expect(detectAvailableShells()).toEqual([]); + }, + ); + } finally { + cleanup(); + } + }); +}); diff --git a/test/runtime/terminal/agent-registry.test.ts b/test/runtime/terminal/agent-registry.test.ts index 0c54bd147b..02f4a6bd1b 100644 --- a/test/runtime/terminal/agent-registry.test.ts +++ b/test/runtime/terminal/agent-registry.test.ts @@ -28,6 +28,7 @@ function createRuntimeConfigState(overrides: Partial = {}): openPrPromptTemplate: "pr", commitPromptTemplateDefault: "commit", openPrPromptTemplateDefault: "pr", + terminalShell: null, ...overrides, }; } diff --git a/test/runtime/trpc/runtime-api.test.ts b/test/runtime/trpc/runtime-api.test.ts index 59ebd73959..4f75dedf88 100644 --- a/test/runtime/trpc/runtime-api.test.ts +++ b/test/runtime/trpc/runtime-api.test.ts @@ -187,6 +187,7 @@ function createRuntimeConfigState(): RuntimeConfigState { openPrPromptTemplate: "pr", commitPromptTemplateDefault: "commit", openPrPromptTemplateDefault: "pr", + terminalShell: null, globalConfigPath: "/tmp/global-config.json", projectConfigPath: "/tmp/project-config.json", }; diff --git a/web-ui/src/components/runtime-settings-dialog.test.tsx b/web-ui/src/components/runtime-settings-dialog.test.tsx index 9b9d6b4712..1b1d40ba6c 100644 --- a/web-ui/src/components/runtime-settings-dialog.test.tsx +++ b/web-ui/src/components/runtime-settings-dialog.test.tsx @@ -178,6 +178,8 @@ const savedClineOauthConfig = { openPrPromptTemplate: "", commitPromptTemplateDefault: "", openPrPromptTemplateDefault: "", + terminalShell: null, + detectedShells: [], globalConfigPath: null, projectConfigPath: null, agents: [ @@ -366,6 +368,46 @@ describe("RuntimeSettingsDialog", () => { expect(document.documentElement.getAttribute("data-theme")).toBe("graphite"); }); + it("lists detected shells in the terminal section and enables save on change", async () => { + await act(async () => { + root.render( + {}} + />, + ); + }); + + const saveButton = findButtonByText(document.body, "Save"); + const shellSelect = document.body.querySelector( + 'select[aria-label="Terminal shell"]', + ) as HTMLSelectElement | null; + + expect(saveButton).toBeInstanceOf(HTMLButtonElement); + expect(shellSelect).toBeInstanceOf(HTMLSelectElement); + expect(saveButton?.disabled).toBe(true); + expect(Array.from(shellSelect?.options ?? []).map((option) => option.value)).toEqual([ + "__auto__", + "pwsh", + "bash", + ]); + expect(shellSelect?.value).toBe("__auto__"); + + // Assign through the prototype setter so React's value tracker sees the change. + const nativeValueSetter = Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, "value")?.set; + await act(async () => { + if (shellSelect) { + nativeValueSetter?.call(shellSelect, "pwsh"); + shellSelect.dispatchEvent(new Event("change", { bubbles: true })); + } + }); + + expect(shellSelect?.value).toBe("pwsh"); + expect(saveButton?.disabled).toBe(false); + }); + it("forwards cline setup saves to the dialog onSaved callback", async () => { const handleSaved = vi.fn(); await act(async () => { diff --git a/web-ui/src/components/runtime-settings-dialog.tsx b/web-ui/src/components/runtime-settings-dialog.tsx index 314e80c682..7bc0466ec5 100644 --- a/web-ui/src/components/runtime-settings-dialog.tsx +++ b/web-ui/src/components/runtime-settings-dialog.tsx @@ -21,6 +21,7 @@ import { Plus, Settings, SlidersHorizontal, + Terminal, X, } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; @@ -90,11 +91,34 @@ const GIT_PROMPT_VARIANT_OPTIONS: Array<{ value: TaskGitAction; label: string }> { value: "pr", label: "Make PR" }, ]; +const TERMINAL_SHELL_AUTO_OPTION = "__auto__"; + +const TERMINAL_SHELL_FAMILY_LABELS: Record = { + cmd: "Command Prompt (cmd)", + powershell: "Windows PowerShell", + pwsh: "PowerShell (pwsh)", + bash: "Bash", + zsh: "Zsh", + fish: "Fish", + sh: "sh", +}; + +function formatTerminalShellLabel(shell: string): string { + const baseName = shell.replaceAll("\\", "/").split("/").at(-1) ?? shell; + const family = baseName.toLowerCase().replace(/\.(exe|com|cmd|bat)$/, ""); + const label = TERMINAL_SHELL_FAMILY_LABELS[family]; + if (!label) { + return shell; + } + const isPath = shell.includes("/") || shell.includes("\\"); + return isPath ? `${label} — ${shell}` : label; +} + export type RuntimeSettingsSection = "shortcuts"; const SETTINGS_AGENT_ORDER: readonly RuntimeAgentId[] = ["cline", "claude", "codex", "droid", "kiro"]; -type SettingsNavId = "general" | "cline" | "git-prompts" | "notifications" | "appearance" | "project"; +type SettingsNavId = "general" | "cline" | "git-prompts" | "notifications" | "terminal" | "appearance" | "project"; const SETTINGS_NAV_ITEMS: ReadonlyArray<{ id: SettingsNavId; @@ -106,6 +130,7 @@ const SETTINGS_NAV_ITEMS: ReadonlyArray<{ { id: "cline", label: "Cline", icon: , clineOnly: true }, { id: "git-prompts", label: "Git Prompts", icon: }, { id: "notifications", label: "Notifications", icon: }, + { id: "terminal", label: "Terminal", icon: }, { id: "appearance", label: "Appearance", icon: }, { id: "project", label: "Project", icon: }, ]; @@ -369,6 +394,7 @@ export function RuntimeSettingsDialog({ const [selectedAgentId, setSelectedAgentId] = useState("claude"); const [agentAutonomousModeEnabled, setAgentAutonomousModeEnabled] = useState(true); const [readyForReviewNotificationsEnabled, setReadyForReviewNotificationsEnabled] = useState(true); + const [terminalShell, setTerminalShell] = useState(null); const [initialThemeId, setInitialThemeId] = useState(readStoredThemeId); const [draftThemeId, setDraftThemeId] = useState(readStoredThemeId); const [notificationPermission, setNotificationPermission] = useState("unsupported"); @@ -442,6 +468,7 @@ export function RuntimeSettingsDialog({ const initialSelectedAgentId = configuredAgentId ?? fallbackAgentId; const initialAgentAutonomousModeEnabled = config?.agentAutonomousModeEnabled ?? true; const initialReadyForReviewNotificationsEnabled = config?.readyForReviewNotificationsEnabled ?? true; + const initialTerminalShell = config?.terminalShell ?? null; const initialShortcuts = config?.shortcuts ?? []; const initialCommitPromptTemplate = config?.commitPromptTemplate ?? ""; const initialOpenPrPromptTemplate = config?.openPrPromptTemplate ?? ""; @@ -457,6 +484,13 @@ export function RuntimeSettingsDialog({ selectedAgentId, liveAuthStatuses: liveMcpAuthStatuses, }); + const terminalShellOptions = useMemo(() => { + const options = [...(config?.detectedShells ?? [])]; + if (terminalShell && !options.includes(terminalShell)) { + options.push(terminalShell); + } + return options; + }, [config?.detectedShells, terminalShell]); const hasUnsavedChanges = useMemo(() => { if (!config) { return false; @@ -470,6 +504,9 @@ export function RuntimeSettingsDialog({ if (readyForReviewNotificationsEnabled !== initialReadyForReviewNotificationsEnabled) { return true; } + if (terminalShell !== initialTerminalShell) { + return true; + } if (clineSettings.hasUnsavedChanges) { return true; } @@ -505,11 +542,13 @@ export function RuntimeSettingsDialog({ initialReadyForReviewNotificationsEnabled, initialSelectedAgentId, initialShortcuts, + initialTerminalShell, initialThemeId, openPrPromptTemplate, readyForReviewNotificationsEnabled, selectedAgentId, shortcuts, + terminalShell, ]); useEffect(() => { @@ -519,6 +558,7 @@ export function RuntimeSettingsDialog({ setSelectedAgentId(configuredAgentId ?? fallbackAgentId); setAgentAutonomousModeEnabled(config?.agentAutonomousModeEnabled ?? true); setReadyForReviewNotificationsEnabled(config?.readyForReviewNotificationsEnabled ?? true); + setTerminalShell(config?.terminalShell ?? null); setShortcuts(config?.shortcuts ?? []); setCommitPromptTemplate(config?.commitPromptTemplate ?? ""); setOpenPrPromptTemplate(config?.openPrPromptTemplate ?? ""); @@ -530,6 +570,7 @@ export function RuntimeSettingsDialog({ config?.readyForReviewNotificationsEnabled, config?.selectedAgentId, config?.shortcuts, + config?.terminalShell, fallbackAgentId, open, ]); @@ -704,6 +745,7 @@ export function RuntimeSettingsDialog({ shortcuts, commitPromptTemplate, openPrPromptTemplate, + terminalShell, }); if (!saved) { setSaveError("Could not save runtime settings. Check runtime logs and try again."); @@ -941,6 +983,39 @@ export function RuntimeSettingsDialog({ + {/* ---- Terminal ---- */} +
+
+

+ + Terminal +

+
+
+
+ Shell +
+ + setTerminalShell(event.target.value === TERMINAL_SHELL_AUTO_OPTION ? null : event.target.value) + } + disabled={controlsDisabled} + aria-label="Terminal shell" + style={{ minWidth: 220 }} + > + + {terminalShellOptions.map((shell) => ( + + ))} + +

+ Shell used when opening the built-in terminal. Applies to newly started terminal sessions. +

+
+ {/* ---- Appearance ---- */}
diff --git a/web-ui/src/hooks/use-git-actions.test.tsx b/web-ui/src/hooks/use-git-actions.test.tsx index 75d00a53a2..b73dc6953e 100644 --- a/web-ui/src/hooks/use-git-actions.test.tsx +++ b/web-ui/src/hooks/use-git-actions.test.tsx @@ -113,6 +113,8 @@ function createRuntimeConfig(selectedAgentId: RuntimeConfigResponse["selectedAge openPrPromptTemplate: "pr", commitPromptTemplateDefault: "commit", openPrPromptTemplateDefault: "pr", + terminalShell: null, + detectedShells: [], }; } diff --git a/web-ui/src/hooks/use-home-agent-session.test.tsx b/web-ui/src/hooks/use-home-agent-session.test.tsx index efb861c56e..7455560c33 100644 --- a/web-ui/src/hooks/use-home-agent-session.test.tsx +++ b/web-ui/src/hooks/use-home-agent-session.test.tsx @@ -114,6 +114,8 @@ function createRuntimeConfig(overrides: Partial = {}): Ru openPrPromptTemplate: "pr", commitPromptTemplateDefault: "commit", openPrPromptTemplateDefault: "pr", + terminalShell: null, + detectedShells: [], ...overrides, }; } diff --git a/web-ui/src/hooks/use-runtime-settings-cline-controller.test.tsx b/web-ui/src/hooks/use-runtime-settings-cline-controller.test.tsx index 6281ea7e5d..224857d498 100644 --- a/web-ui/src/hooks/use-runtime-settings-cline-controller.test.tsx +++ b/web-ui/src/hooks/use-runtime-settings-cline-controller.test.tsx @@ -105,6 +105,8 @@ function createRuntimeConfigResponse( openPrPromptTemplate: "", commitPromptTemplateDefault: "", openPrPromptTemplateDefault: "", + terminalShell: null, + detectedShells: [], }; } diff --git a/web-ui/src/hooks/use-startup-onboarding.test.tsx b/web-ui/src/hooks/use-startup-onboarding.test.tsx index 3f88430ac7..d79e67269d 100644 --- a/web-ui/src/hooks/use-startup-onboarding.test.tsx +++ b/web-ui/src/hooks/use-startup-onboarding.test.tsx @@ -51,6 +51,8 @@ function createRuntimeConfigResponse(selectedAgentId: RuntimeConfigResponse["sel openPrPromptTemplate: "", commitPromptTemplateDefault: "", openPrPromptTemplateDefault: "", + terminalShell: null, + detectedShells: [], }; } diff --git a/web-ui/src/runtime/native-agent.test.ts b/web-ui/src/runtime/native-agent.test.ts index 59671565c1..af79fc0128 100644 --- a/web-ui/src/runtime/native-agent.test.ts +++ b/web-ui/src/runtime/native-agent.test.ts @@ -59,6 +59,8 @@ function createRuntimeConfigResponse( openPrPromptTemplate: "", commitPromptTemplateDefault: "", openPrPromptTemplateDefault: "", + terminalShell: null, + detectedShells: [], }; return { ...nextConfig, diff --git a/web-ui/src/runtime/runtime-config-query.ts b/web-ui/src/runtime/runtime-config-query.ts index 35db4f9f6f..9cbf0892b3 100644 --- a/web-ui/src/runtime/runtime-config-query.ts +++ b/web-ui/src/runtime/runtime-config-query.ts @@ -48,6 +48,7 @@ export async function saveRuntimeConfig( readyForReviewNotificationsEnabled?: boolean; commitPromptTemplate?: string; openPrPromptTemplate?: string; + terminalShell?: string | null; }, ): Promise { const trpcClient = getRuntimeTrpcClient(workspaceId); diff --git a/web-ui/src/runtime/use-runtime-config.test.tsx b/web-ui/src/runtime/use-runtime-config.test.tsx index 58f043d526..f7125a802f 100644 --- a/web-ui/src/runtime/use-runtime-config.test.tsx +++ b/web-ui/src/runtime/use-runtime-config.test.tsx @@ -60,6 +60,8 @@ function createRuntimeConfigResponse(selectedAgentId: RuntimeConfigResponse["sel openPrPromptTemplate: "", commitPromptTemplateDefault: "", openPrPromptTemplateDefault: "", + terminalShell: null, + detectedShells: [], }; } diff --git a/web-ui/src/runtime/use-runtime-config.ts b/web-ui/src/runtime/use-runtime-config.ts index 0f8d222004..37d433a301 100644 --- a/web-ui/src/runtime/use-runtime-config.ts +++ b/web-ui/src/runtime/use-runtime-config.ts @@ -17,6 +17,7 @@ export interface UseRuntimeConfigResult { readyForReviewNotificationsEnabled?: boolean; commitPromptTemplate?: string; openPrPromptTemplate?: string; + terminalShell?: string | null; }) => Promise; } @@ -84,6 +85,7 @@ export function useRuntimeConfig( readyForReviewNotificationsEnabled?: boolean; commitPromptTemplate?: string; openPrPromptTemplate?: string; + terminalShell?: string | null; }): Promise => { setIsSaving(true); try { diff --git a/web-ui/src/runtime/use-runtime-project-config.test.tsx b/web-ui/src/runtime/use-runtime-project-config.test.tsx index 293e19b0ba..033473f611 100644 --- a/web-ui/src/runtime/use-runtime-project-config.test.tsx +++ b/web-ui/src/runtime/use-runtime-project-config.test.tsx @@ -70,6 +70,8 @@ function createRuntimeConfigResponse( openPrPromptTemplate: "", commitPromptTemplateDefault: "", openPrPromptTemplateDefault: "", + terminalShell: null, + detectedShells: [], }; }