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: [], }; }