diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index b2c16abb427..7982e43a8d1 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -86,6 +86,7 @@ import { BrowserTraceCollector, type BrowserTraceCollectorShape, } from "./observability/Services/BrowserTraceCollector.ts"; +import { resolveCurrentShell } from "./terminal/terminalProfile.ts"; import { ProjectFaviconResolverLive } from "./project/Layers/ProjectFaviconResolver.ts"; import { ProjectSetupScriptRunner, @@ -1848,6 +1849,12 @@ it.layer(NodeServices.layer)("server router seam", (it) => { assert.equal(first.config.observability.otlpTracesEnabled, true); assert.equal(first.config.observability.otlpMetricsUrl, "http://localhost:4318/v1/metrics"); assert.equal(first.config.observability.otlpMetricsEnabled, true); + assert.equal(first.config.terminal.platform, process.platform); + assert.equal( + first.config.terminal.currentShell, + resolveCurrentShell(process.platform, process.env), + ); + assert.isTrue(Array.isArray(first.config.terminal.discoveredShells)); assert.deepEqual(first.config.settings, DEFAULT_SERVER_SETTINGS); } assert.deepEqual(second, { @@ -1858,6 +1865,35 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("routes websocket rpc serverGetConfig with terminal discovery", () => + Effect.gen(function* () { + yield* buildAppUnderTest({ + layers: { + keybindings: { + loadConfigState: Effect.succeed({ + keybindings: [], + issues: [], + }), + streamChanges: Stream.empty, + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const response = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => client[WS_METHODS.serverGetConfig]({})), + ); + + assert.equal(response.terminal.platform, process.platform); + assert.equal( + response.terminal.currentShell, + resolveCurrentShell(process.platform, process.env), + ); + assert.isTrue(Array.isArray(response.terminal.discoveredShells)); + assert.deepEqual(response.settings, DEFAULT_SERVER_SETTINGS); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("routes websocket rpc subscribeServerConfig emits provider status updates", () => Effect.gen(function* () { const nextProviders = [ diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index dcf3fa189ef..952de401428 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -41,6 +41,25 @@ it.layer(NodeServices.layer)("server settings", (it) => { }, }, ); + + assert.deepEqual( + decodePatch({ + terminal: { + profile: { + shellPath: "/bin/zsh", + shellArgs: ["-f"], + }, + }, + }), + { + terminal: { + profile: { + shellPath: "/bin/zsh", + shellArgs: ["-f"], + }, + }, + }, + ); }), ); @@ -211,6 +230,47 @@ it.layer(NodeServices.layer)("server settings", (it) => { }).pipe(Effect.provide(makeServerSettingsLayer())), ); + it.effect("writes terminal profile overrides without serializing default values", () => + Effect.gen(function* () { + const serverSettings = yield* ServerSettingsService; + const serverConfig = yield* ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + + const next = yield* serverSettings.updateSettings({ + terminal: { + profile: { + shellPath: "/bin/zsh", + shellArgs: ["-f"], + env: { + ZDOTDIR: "/tmp/t3code-zsh", + }, + }, + }, + }); + + assert.deepEqual(next.terminal.profile, { + shellPath: "/bin/zsh", + shellArgs: ["-f"], + env: { + ZDOTDIR: "/tmp/t3code-zsh", + }, + }); + + const raw = yield* fileSystem.readFileString(serverConfig.settingsPath); + assert.deepEqual(JSON.parse(raw), { + terminal: { + profile: { + shellPath: "/bin/zsh", + shellArgs: ["-f"], + env: { + ZDOTDIR: "/tmp/t3code-zsh", + }, + }, + }, + }); + }).pipe(Effect.provide(makeServerSettingsLayer())), + ); + it.effect("writes only non-default server settings to disk", () => Effect.gen(function* () { const serverSettings = yield* ServerSettingsService; diff --git a/apps/server/src/terminal/Layers/Manager.test.ts b/apps/server/src/terminal/Layers/Manager.test.ts index 8207861e206..b3700c166f7 100644 --- a/apps/server/src/terminal/Layers/Manager.test.ts +++ b/apps/server/src/terminal/Layers/Manager.test.ts @@ -6,6 +6,7 @@ import { DEFAULT_TERMINAL_ID, type TerminalEvent, type TerminalOpenInput, + type TerminalProfileSettings, type TerminalRestartInput, } from "@t3tools/contracts"; import { @@ -188,6 +189,7 @@ function multiTerminalHistoryLogPath( interface CreateManagerOptions { shellResolver?: () => string; + terminalProfileResolver?: Effect.Effect; subprocessChecker?: (terminalPid: number) => Effect.Effect; subprocessPollIntervalMs?: number; processKillGraceMs?: number; @@ -222,6 +224,9 @@ const createManager = ( historyLineLimit, ptyAdapter, ...(options.shellResolver !== undefined ? { shellResolver: options.shellResolver } : {}), + ...(options.terminalProfileResolver !== undefined + ? { terminalProfileResolver: options.terminalProfileResolver } + : {}), ...(options.subprocessChecker !== undefined ? { subprocessChecker: options.subprocessChecker } : {}), @@ -323,6 +328,41 @@ it.layer(NodeServices.layer, { excludeTestServices: true })("TerminalManager", ( }), ); + it.effect("spawns terminals with the configured shell profile", () => + Effect.gen(function* () { + const { manager, ptyAdapter } = yield* createManager(5, { + shellResolver: () => "/bin/bash", + terminalProfileResolver: Effect.succeed({ + shellPath: "/bin/zsh", + shellArgs: ["-f"], + env: { + ZDOTDIR: "/tmp/t3code-zdotdir", + PATH: "/opt/t3/bin", + }, + }), + }); + + yield* manager.open( + openInput({ + env: { + PATH: "/workspace/bin", + CUSTOM_FLAG: "1", + }, + }), + ); + + const spawned = ptyAdapter.spawnInputs[0]; + expect(spawned).toBeDefined(); + if (!spawned) return; + + expect(spawned.shell).toBe("/bin/zsh"); + expect(spawned.args).toEqual(["-f"]); + expect(spawned.env.ZDOTDIR).toBe("/tmp/t3code-zdotdir"); + expect(spawned.env.PATH).toBe("/workspace/bin"); + expect(spawned.env.CUSTOM_FLAG).toBe("1"); + }), + ); + it.effect("forwards write and resize to active pty process", () => Effect.gen(function* () { const { manager, ptyAdapter } = yield* createManager(); diff --git a/apps/server/src/terminal/Layers/Manager.ts b/apps/server/src/terminal/Layers/Manager.ts index 4bdeba68e16..2c4f1753b1c 100644 --- a/apps/server/src/terminal/Layers/Manager.ts +++ b/apps/server/src/terminal/Layers/Manager.ts @@ -3,6 +3,7 @@ import path from "node:path"; import { DEFAULT_TERMINAL_ID, type TerminalEvent, + type TerminalProfileSettings, type TerminalSessionSnapshot, type TerminalSessionStatus, } from "@t3tools/contracts"; @@ -29,11 +30,13 @@ import { terminalSessionsTotal, } from "../../observability/Metrics"; import { runProcess } from "../../processRunner"; +import { ServerSettingsService } from "../../serverSettings"; import { TerminalCwdError, TerminalHistoryError, TerminalManager, TerminalNotRunningError, + type ShellCandidate, TerminalSessionLookupError, type TerminalManagerShape, } from "../Services/Manager"; @@ -44,6 +47,7 @@ import { type PtyExitEvent, type PtyProcess, } from "../Services/PTY"; +import { resolveCurrentShell, resolveTerminalShellSpawnConfig } from "../terminalProfile"; const DEFAULT_HISTORY_LINE_LIMIT = 5_000; const DEFAULT_PERSIST_DEBOUNCE_MS = 40; @@ -77,11 +81,6 @@ interface TerminalSubprocessChecker { (terminalPid: number): Effect.Effect; } -interface ShellCandidate { - shell: string; - args?: string[]; -} - interface TerminalStartInput { threadId: string; terminalId: string; @@ -187,33 +186,7 @@ function enqueueProcessEvent( } function defaultShellResolver(): string { - if (process.platform === "win32") { - return process.env.ComSpec ?? "cmd.exe"; - } - return process.env.SHELL ?? "bash"; -} - -function normalizeShellCommand(value: string | undefined): string | null { - if (!value) return null; - const trimmed = value.trim(); - if (trimmed.length === 0) return null; - - if (process.platform === "win32") { - return trimmed; - } - - const firstToken = trimmed.split(/\s+/g)[0]?.trim(); - if (!firstToken) return null; - return firstToken.replace(/^['"]|['"]$/g, ""); -} - -function shellCandidateFromCommand(command: string | null): ShellCandidate | null { - if (!command || command.length === 0) return null; - const shellName = path.basename(command).toLowerCase(); - if (process.platform !== "win32" && shellName === "zsh") { - return { shell: command, args: ["-o", "nopromptsp"] }; - } - return { shell: command }; + return resolveCurrentShell(process.platform, process.env); } function formatShellCandidate(candidate: ShellCandidate): string { @@ -221,43 +194,6 @@ function formatShellCandidate(candidate: ShellCandidate): string { return `${candidate.shell} ${candidate.args.join(" ")}`; } -function uniqueShellCandidates(candidates: Array): ShellCandidate[] { - const seen = new Set(); - const ordered: ShellCandidate[] = []; - for (const candidate of candidates) { - if (!candidate) continue; - const key = formatShellCandidate(candidate); - if (seen.has(key)) continue; - seen.add(key); - ordered.push(candidate); - } - return ordered; -} - -function resolveShellCandidates(shellResolver: () => string): ShellCandidate[] { - const requested = shellCandidateFromCommand(normalizeShellCommand(shellResolver())); - - if (process.platform === "win32") { - return uniqueShellCandidates([ - requested, - shellCandidateFromCommand(process.env.ComSpec ?? null), - shellCandidateFromCommand("powershell.exe"), - shellCandidateFromCommand("cmd.exe"), - ]); - } - - return uniqueShellCandidates([ - requested, - shellCandidateFromCommand(normalizeShellCommand(process.env.SHELL)), - shellCandidateFromCommand("/bin/zsh"), - shellCandidateFromCommand("/bin/bash"), - shellCandidateFromCommand("/bin/sh"), - shellCandidateFromCommand("zsh"), - shellCandidateFromCommand("bash"), - shellCandidateFromCommand("sh"), - ]); -} - function isRetryableShellSpawnError(error: PtySpawnError): boolean { const queue: unknown[] = [error]; const seen = new Set(); @@ -621,6 +557,7 @@ function shouldExcludeTerminalEnvKey(key: string): boolean { function createTerminalSpawnEnv( baseEnv: NodeJS.ProcessEnv, + profileEnv?: Record | null, runtimeEnv?: Record | null, ): NodeJS.ProcessEnv { const spawnEnv: NodeJS.ProcessEnv = {}; @@ -629,6 +566,11 @@ function createTerminalSpawnEnv( if (shouldExcludeTerminalEnvKey(key)) continue; spawnEnv[key] = value; } + if (profileEnv) { + for (const [key, value] of Object.entries(profileEnv)) { + spawnEnv[key] = value; + } + } if (runtimeEnv) { for (const [key, value] of Object.entries(runtimeEnv)) { spawnEnv[key] = value; @@ -651,6 +593,7 @@ interface TerminalManagerOptions { historyLineLimit?: number; ptyAdapter: PtyAdapterShape; shellResolver?: () => string; + terminalProfileResolver?: Effect.Effect; subprocessChecker?: TerminalSubprocessChecker; subprocessPollIntervalMs?: number; processKillGraceMs?: number; @@ -660,9 +603,30 @@ interface TerminalManagerOptions { const makeTerminalManager = Effect.fn("makeTerminalManager")(function* () { const { terminalLogsDir } = yield* ServerConfig; const ptyAdapter = yield* PtyAdapter; + const serverSettings = yield* Effect.serviceOption(ServerSettingsService); + const terminalProfileResolver = Option.match(serverSettings, { + onNone: () => + Effect.succeed({ + shellPath: "", + shellArgs: [], + env: {}, + } satisfies TerminalProfileSettings), + onSome: (settingsService) => + settingsService.getSettings.pipe( + Effect.map((settings) => settings.terminal.profile), + Effect.catch(() => + Effect.succeed({ + shellPath: "", + shellArgs: [], + env: {}, + } satisfies TerminalProfileSettings), + ), + ), + }); return yield* makeTerminalManagerWithOptions({ logsDir: terminalLogsDir, ptyAdapter, + terminalProfileResolver, }); }); @@ -675,6 +639,13 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith const logsDir = options.logsDir; const historyLineLimit = options.historyLineLimit ?? DEFAULT_HISTORY_LINE_LIMIT; const shellResolver = options.shellResolver ?? defaultShellResolver; + const terminalProfileResolver = + options.terminalProfileResolver ?? + Effect.succeed({ + shellPath: "", + shellArgs: [], + env: {}, + } satisfies TerminalProfileSettings); const subprocessChecker = options.subprocessChecker ?? defaultSubprocessChecker; const subprocessPollIntervalMs = options.subprocessPollIntervalMs ?? DEFAULT_SUBPROCESS_POLL_INTERVAL_MS; @@ -1337,8 +1308,19 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith increment(terminalSessionsTotal, { lifecycle: eventType }).pipe( Effect.andThen( Effect.gen(function* () { - const shellCandidates = resolveShellCandidates(shellResolver); - const terminalEnv = createTerminalSpawnEnv(process.env, session.runtimeEnv); + const terminalProfile = yield* terminalProfileResolver; + const resolvedSpawnConfig = resolveTerminalShellSpawnConfig({ + platform: process.platform, + processEnv: process.env, + shellResolver, + profile: terminalProfile, + }); + const shellCandidates = resolvedSpawnConfig.shellCandidates; + const terminalEnv = createTerminalSpawnEnv( + process.env, + resolvedSpawnConfig.profileEnv, + session.runtimeEnv, + ); const spawnResult = yield* trySpawn(shellCandidates, terminalEnv, session); ptyProcess = spawnResult.process; startedShell = spawnResult.shellLabel; diff --git a/apps/server/src/terminal/terminalProfile.test.ts b/apps/server/src/terminal/terminalProfile.test.ts new file mode 100644 index 00000000000..ee96faf70b5 --- /dev/null +++ b/apps/server/src/terminal/terminalProfile.test.ts @@ -0,0 +1,229 @@ +import { describe, expect, it } from "vitest"; + +import { + discoverTerminalShells, + discoverWindowsTerminalShells, + resolveTerminalShellSpawnConfig, + type TerminalShellPathProbe, +} from "./terminalProfile"; + +const rejectingCmdProbe: TerminalShellPathProbe = async (candidate) => { + if (candidate === "C:\\Windows\\System32\\cmd.exe") { + throw new Error("permission denied"); + } + return false; +}; + +describe("resolveTerminalShellSpawnConfig", () => { + it("uses the explicit custom shell profile when one is configured", () => { + const result = resolveTerminalShellSpawnConfig({ + platform: "darwin", + processEnv: { SHELL: "/bin/bash" }, + shellResolver: () => "/bin/bash", + profile: { + shellPath: "/bin/zsh", + shellArgs: ["-f", " "], + env: { + ZDOTDIR: "/tmp/t3code-zdotdir", + " PATH ": "/custom/bin", + }, + }, + }); + + expect(result.shellCandidates).toEqual([{ shell: "/bin/zsh", args: ["-f"] }]); + expect(result.profileEnv).toEqual({ + PATH: "/custom/bin", + ZDOTDIR: "/tmp/t3code-zdotdir", + }); + }); + + it("preserves the existing fallback order when no custom shell path is configured", () => { + const result = resolveTerminalShellSpawnConfig({ + platform: "darwin", + processEnv: { SHELL: "/bin/bash" }, + shellResolver: () => "/bin/bash", + profile: { + shellPath: "", + shellArgs: [], + env: {}, + }, + }); + + expect(result.shellCandidates.slice(0, 4)).toEqual([ + { shell: "/bin/bash" }, + { shell: "/bin/zsh", args: ["-o", "nopromptsp"] }, + { shell: "/bin/sh" }, + { shell: "zsh", args: ["-o", "nopromptsp"] }, + ]); + }); + + it("applies custom shell args to the first fallback shell when no shell path is set", () => { + const result = resolveTerminalShellSpawnConfig({ + platform: "win32", + processEnv: { ComSpec: "C:\\Windows\\System32\\cmd.exe" }, + shellResolver: () => "powershell.exe", + profile: { + shellPath: "", + shellArgs: ["-NoLogo", "-NoProfile"], + env: {}, + }, + }); + + expect(result.shellCandidates[0]).toEqual({ + shell: "powershell.exe", + args: ["-NoLogo", "-NoProfile"], + }); + expect(result.shellCandidates[1]).toEqual({ + shell: "C:\\Windows\\System32\\cmd.exe", + }); + }); +}); + +describe("discoverWindowsTerminalShells", () => { + it("reports common Windows shell availability for future preset UX", async () => { + const existingPaths = new Set([ + "C:\\Windows\\System32\\cmd.exe", + "C:\\Program Files\\Git\\bin\\bash.exe", + "C:\\Windows\\System32\\wsl.exe", + ]); + const probe: TerminalShellPathProbe = async (candidate) => existingPaths.has(candidate); + + const result = await discoverWindowsTerminalShells({ + env: { + ComSpec: "C:\\Windows\\System32\\cmd.exe", + SystemRoot: "C:\\Windows", + }, + probe, + }); + + expect(result.cmd).toEqual({ + available: true, + path: "C:\\Windows\\System32\\cmd.exe", + }); + expect(result.powershell).toEqual({ + available: false, + path: null, + }); + expect(result.gitBash).toEqual({ + available: true, + path: "C:\\Program Files\\Git\\bin\\bash.exe", + }); + expect(result.wsl).toEqual({ + available: true, + path: "C:\\Windows\\System32\\wsl.exe", + }); + }); +}); + +describe("discoverTerminalShells", () => { + it("returns an empty discovery list on non-Windows platforms", async () => { + let probeCalls = 0; + const result = await discoverTerminalShells({ + platform: "darwin", + env: {}, + probe: async () => { + probeCalls += 1; + return false; + }, + }); + + expect(result).toEqual({ + platform: "darwin", + currentShell: "bash", + discoveredShells: [], + }); + expect(probeCalls).toBe(0); + }); + + it("maps Windows shell discovery into stable server config entries", async () => { + const existingPaths = new Set([ + "C:\\Windows\\System32\\cmd.exe", + "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe", + "C:\\Program Files\\Git\\bin\\bash.exe", + "C:\\Windows\\System32\\wsl.exe", + ]); + const probe: TerminalShellPathProbe = async (candidate) => existingPaths.has(candidate); + + const result = await discoverTerminalShells({ + platform: "win32", + env: { + ComSpec: "C:\\Windows\\System32\\cmd.exe", + SystemRoot: "C:\\Windows", + }, + probe, + }); + + expect(result).toEqual({ + platform: "win32", + currentShell: "C:\\Windows\\System32\\cmd.exe", + discoveredShells: [ + { + id: "powershell", + label: "PowerShell", + available: true, + path: "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe", + }, + { + id: "cmd", + label: "Command Prompt", + available: true, + path: "C:\\Windows\\System32\\cmd.exe", + }, + { + id: "gitBash", + label: "Git Bash", + available: true, + path: "C:\\Program Files\\Git\\bin\\bash.exe", + }, + { + id: "wsl", + label: "WSL", + available: true, + path: "C:\\Windows\\System32\\wsl.exe", + }, + ], + }); + }); + + it("treats probe failures as unavailable shells instead of failing discovery", async () => { + const result = await discoverTerminalShells({ + platform: "win32", + env: { + ComSpec: "C:\\Windows\\System32\\cmd.exe", + SystemRoot: "C:\\Windows", + }, + probe: rejectingCmdProbe, + }); + + expect(result).toEqual({ + platform: "win32", + currentShell: "C:\\Windows\\System32\\cmd.exe", + discoveredShells: [ + { + id: "powershell", + label: "PowerShell", + available: false, + path: null, + }, + { + id: "cmd", + label: "Command Prompt", + available: false, + path: null, + }, + { + id: "gitBash", + label: "Git Bash", + available: false, + path: null, + }, + { + id: "wsl", + label: "WSL", + available: false, + path: null, + }, + ], + }); + }); +}); diff --git a/apps/server/src/terminal/terminalProfile.ts b/apps/server/src/terminal/terminalProfile.ts new file mode 100644 index 00000000000..14c35107717 --- /dev/null +++ b/apps/server/src/terminal/terminalProfile.ts @@ -0,0 +1,265 @@ +import path from "node:path"; + +import { + type ServerTerminal, + type ServerTerminalDiscoveredShell, + type TerminalProfileSettings, +} from "@t3tools/contracts"; + +import { type ShellCandidate } from "./Services/Manager"; + +export type TerminalPlatform = NodeJS.Platform; + +export interface ResolveTerminalShellSpawnConfigInput { + platform: TerminalPlatform; + processEnv: NodeJS.ProcessEnv; + shellResolver: () => string; + profile: TerminalProfileSettings | null | undefined; +} + +export interface ResolvedTerminalShellSpawnConfig { + shellCandidates: ShellCandidate[]; + profileEnv: Record | null; +} + +export type TerminalShellPathProbe = (candidatePath: string) => Promise; + +export interface WindowsTerminalShellDiscovery { + cmd: { available: boolean; path: string | null }; + powershell: { available: boolean; path: string | null }; + gitBash: { available: boolean; path: string | null }; + wsl: { available: boolean; path: string | null }; +} + +export function resolveCurrentShell( + platform: TerminalPlatform, + processEnv: NodeJS.ProcessEnv, +): string { + if (platform === "win32") { + return processEnv.ComSpec ?? "cmd.exe"; + } + return processEnv.SHELL ?? "bash"; +} + +function createDiscoveredShell( + id: ServerTerminalDiscoveredShell["id"], + label: string, + shell: { available: boolean; path: string | null }, +): ServerTerminalDiscoveredShell { + return { + id, + label, + available: shell.available, + path: shell.path, + }; +} + +function normalizeShellCommand( + value: string | undefined, + platform: TerminalPlatform, +): string | null { + if (!value) return null; + const trimmed = value.trim(); + if (trimmed.length === 0) return null; + + if (platform === "win32") { + return trimmed; + } + + const firstToken = trimmed.split(/\s+/g)[0]?.trim(); + if (!firstToken) return null; + return firstToken.replace(/^['"]|['"]$/g, ""); +} + +function shellCandidateFromCommand( + command: string | null, + platform: TerminalPlatform, +): ShellCandidate | null { + if (!command || command.length === 0) return null; + const shellName = path.basename(command).toLowerCase(); + if (platform !== "win32" && shellName === "zsh") { + return { shell: command, args: ["-o", "nopromptsp"] }; + } + return { shell: command }; +} + +function formatShellCandidate(candidate: ShellCandidate): string { + if (!candidate.args || candidate.args.length === 0) return candidate.shell; + return `${candidate.shell} ${candidate.args.join(" ")}`; +} + +function uniqueShellCandidates(candidates: Array): ShellCandidate[] { + const seen = new Set(); + const ordered: ShellCandidate[] = []; + for (const candidate of candidates) { + if (!candidate) continue; + const key = formatShellCandidate(candidate); + if (seen.has(key)) continue; + seen.add(key); + ordered.push(candidate); + } + return ordered; +} + +function resolveDefaultShellCandidates(input: { + platform: TerminalPlatform; + processEnv: NodeJS.ProcessEnv; + shellResolver: () => string; +}): ShellCandidate[] { + const requested = shellCandidateFromCommand( + normalizeShellCommand(input.shellResolver(), input.platform), + input.platform, + ); + + if (input.platform === "win32") { + return uniqueShellCandidates([ + requested, + shellCandidateFromCommand(input.processEnv.ComSpec ?? null, input.platform), + shellCandidateFromCommand("powershell.exe", input.platform), + shellCandidateFromCommand("cmd.exe", input.platform), + ]); + } + + return uniqueShellCandidates([ + requested, + shellCandidateFromCommand( + normalizeShellCommand(input.processEnv.SHELL, input.platform), + input.platform, + ), + shellCandidateFromCommand("/bin/zsh", input.platform), + shellCandidateFromCommand("/bin/bash", input.platform), + shellCandidateFromCommand("/bin/sh", input.platform), + shellCandidateFromCommand("zsh", input.platform), + shellCandidateFromCommand("bash", input.platform), + shellCandidateFromCommand("sh", input.platform), + ]); +} + +function normalizeShellArgs(shellArgs: ReadonlyArray | undefined): string[] { + if (!shellArgs || shellArgs.length === 0) return []; + return shellArgs.map((arg) => arg.trim()).filter((arg) => arg.length > 0); +} + +function normalizeTerminalProfileEnv( + env: TerminalProfileSettings["env"] | undefined, +): Record | null { + if (!env) return null; + const entries = Object.entries(env) + .map(([key, value]) => [key.trim(), value] as const) + .filter(([key]) => key.length > 0); + if (entries.length === 0) return null; + return Object.fromEntries(entries); +} + +export function resolveTerminalShellSpawnConfig( + input: ResolveTerminalShellSpawnConfigInput, +): ResolvedTerminalShellSpawnConfig { + const shellPath = normalizeShellCommand(input.profile?.shellPath, input.platform); + const shellArgs = normalizeShellArgs(input.profile?.shellArgs); + const profileEnv = normalizeTerminalProfileEnv(input.profile?.env); + + if (shellPath) { + return { + shellCandidates: uniqueShellCandidates([ + { + shell: shellPath, + ...(shellArgs.length > 0 ? { args: shellArgs } : {}), + }, + ]), + profileEnv, + }; + } + + const shellCandidates = resolveDefaultShellCandidates(input); + if (shellArgs.length === 0) { + return { shellCandidates, profileEnv }; + } + + return { + shellCandidates: shellCandidates.map((candidate, index) => + index === 0 ? { shell: candidate.shell, args: shellArgs } : candidate, + ), + profileEnv, + }; +} + +async function firstExistingPath( + candidates: string[], + probe: TerminalShellPathProbe, +): Promise { + for (const candidate of candidates) { + try { + if (await probe(candidate)) { + return candidate; + } + } catch { + // Discovery is best-effort; treat probe failures as unavailable paths. + } + } + return null; +} + +export async function discoverWindowsTerminalShells(input: { + env: NodeJS.ProcessEnv; + probe: TerminalShellPathProbe; +}): Promise { + const systemRoot = input.env.SystemRoot ?? "C:\\Windows"; + const cmdPath = await firstExistingPath( + [input.env.ComSpec ?? path.win32.join(systemRoot, "System32", "cmd.exe")], + input.probe, + ); + const powershellPath = await firstExistingPath( + [path.win32.join(systemRoot, "System32", "WindowsPowerShell", "v1.0", "powershell.exe")], + input.probe, + ); + const gitBashPath = await firstExistingPath( + [ + "C:\\Program Files\\Git\\bin\\bash.exe", + "C:\\Program Files\\Git\\usr\\bin\\bash.exe", + "C:\\Program Files (x86)\\Git\\bin\\bash.exe", + "C:\\Program Files (x86)\\Git\\usr\\bin\\bash.exe", + ], + input.probe, + ); + const wslPath = await firstExistingPath( + [path.win32.join(systemRoot, "System32", "wsl.exe")], + input.probe, + ); + + return { + cmd: { available: cmdPath !== null, path: cmdPath }, + powershell: { available: powershellPath !== null, path: powershellPath }, + gitBash: { available: gitBashPath !== null, path: gitBashPath }, + wsl: { available: wslPath !== null, path: wslPath }, + }; +} + +export async function discoverTerminalShells(input: { + platform: TerminalPlatform; + env: NodeJS.ProcessEnv; + probe: TerminalShellPathProbe; +}): Promise { + if (input.platform !== "win32") { + return { + platform: input.platform, + currentShell: resolveCurrentShell(input.platform, input.env), + discoveredShells: [], + }; + } + + const windowsShells = await discoverWindowsTerminalShells({ + env: input.env, + probe: input.probe, + }); + + return { + platform: input.platform, + currentShell: resolveCurrentShell(input.platform, input.env), + discoveredShells: [ + createDiscoveredShell("powershell", "PowerShell", windowsShells.powershell), + createDiscoveredShell("cmd", "Command Prompt", windowsShells.cmd), + createDiscoveredShell("gitBash", "Git Bash", windowsShells.gitBash), + createDiscoveredShell("wsl", "WSL", windowsShells.wsl), + ], + }; +} diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 96b5b54d71b..4b07be712c4 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1,4 +1,15 @@ -import { Cause, Duration, Effect, Layer, Option, Queue, Ref, Schema, Stream } from "effect"; +import { + Cause, + Duration, + Effect, + FileSystem, + Layer, + Option, + Queue, + Ref, + Schema, + Stream, +} from "effect"; import { type AuthAccessStreamEvent, AuthSessionId, @@ -47,6 +58,7 @@ import { ServerLifecycleEvents } from "./serverLifecycleEvents"; import { ServerRuntimeStartup } from "./serverRuntimeStartup"; import { ServerSettingsService } from "./serverSettings"; import { TerminalManager } from "./terminal/Services/Manager"; +import { discoverTerminalShells } from "./terminal/terminalProfile"; import { WorkspaceEntries } from "./workspace/Services/WorkspaceEntries"; import { WorkspaceFileSystem } from "./workspace/Services/WorkspaceFileSystem"; import { WorkspacePathOutsideRootError } from "./workspace/Services/WorkspacePaths"; @@ -147,6 +159,7 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => const startup = yield* ServerRuntimeStartup; const workspaceEntries = yield* WorkspaceEntries; const workspaceFileSystem = yield* WorkspaceFileSystem; + const fileSystem = yield* FileSystem.FileSystem; const projectSetupScriptRunner = yield* ProjectSetupScriptRunner; const repositoryIdentityResolver = yield* RepositoryIdentityResolver; const serverEnvironment = yield* ServerEnvironment; @@ -515,6 +528,13 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => const settings = yield* serverSettings.getSettings; const environment = yield* serverEnvironment.getDescriptor; const auth = yield* serverAuth.getDescriptor(); + const terminal = yield* Effect.promise(() => + discoverTerminalShells({ + platform: process.platform, + env: process.env, + probe: (candidatePath) => Effect.runPromise(fileSystem.exists(candidatePath)), + }), + ); return { environment, @@ -525,6 +545,7 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => issues: keybindingsConfig.issues, providers, availableEditors: resolveAvailableEditors(), + terminal, observability: { logsDirectoryPath: config.logsDir, localTracingEnabled: true, diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 1774a15e3ec..a2704373947 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -178,6 +178,11 @@ function createBaseServerConfig(): ServerConfig { otlpTracesEnabled: false, otlpMetricsEnabled: false, }, + terminal: { + platform: "darwin", + currentShell: "/bin/zsh", + discoveredShells: [], + }, settings: { ...DEFAULT_SERVER_SETTINGS, ...DEFAULT_CLIENT_SETTINGS, diff --git a/apps/web/src/components/KeybindingsToast.browser.tsx b/apps/web/src/components/KeybindingsToast.browser.tsx index 92ba61aa5f4..512d2ff7dc4 100644 --- a/apps/web/src/components/KeybindingsToast.browser.tsx +++ b/apps/web/src/components/KeybindingsToast.browser.tsx @@ -91,6 +91,11 @@ function createBaseServerConfig(): ServerConfig { otlpTracesEnabled: false, otlpMetricsEnabled: false, }, + terminal: { + platform: "darwin", + currentShell: "/bin/zsh", + discoveredShells: [], + }, settings: { ...DEFAULT_SERVER_SETTINGS, enableAssistantStreaming: false, @@ -100,6 +105,13 @@ function createBaseServerConfig(): ServerConfig { codex: { enabled: true, binaryPath: "", homePath: "", customModels: [] }, claudeAgent: { enabled: true, binaryPath: "", customModels: [] }, }, + terminal: { + profile: { + shellPath: "", + shellArgs: [], + env: {}, + }, + }, }, }; } diff --git a/apps/web/src/components/settings/SettingsPanels.browser.tsx b/apps/web/src/components/settings/SettingsPanels.browser.tsx index 43812154649..ef7f8fac22c 100644 --- a/apps/web/src/components/settings/SettingsPanels.browser.tsx +++ b/apps/web/src/components/settings/SettingsPanels.browser.tsx @@ -197,6 +197,11 @@ function createBaseServerConfig(): ServerConfig { otlpTracesEnabled: true, otlpMetricsEnabled: false, }, + terminal: { + platform: "darwin", + currentShell: "/bin/zsh", + discoveredShells: [], + }, settings: DEFAULT_SERVER_SETTINGS, }; } diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 1d297add3c5..8502fb510cd 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -9,7 +9,7 @@ import { XIcon, } from "lucide-react"; import { useQueryClient } from "@tanstack/react-query"; -import { type ReactNode, useCallback, useMemo, useRef, useState } from "react"; +import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { PROVIDER_DISPLAY_NAMES, type DesktopUpdateChannel, @@ -61,6 +61,7 @@ import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from ".. import { Input } from "../ui/input"; import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; import { Switch } from "../ui/switch"; +import { Textarea } from "../ui/textarea"; import { toastManager } from "../ui/toast"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { @@ -73,6 +74,7 @@ import { import { ProjectFavicon } from "../ProjectFavicon"; import { useServerAvailableEditors, + useServerConfig, useServerKeybindingsConfigPath, useServerObservability, useServerProviders, @@ -414,6 +416,58 @@ function AboutVersionSection() { ); } +function formatTerminalShellArgs(shellArgs: ReadonlyArray) { + return shellArgs.join("\n"); +} + +function parseTerminalShellArgs(shellArgsText: string) { + return shellArgsText + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0); +} + +function formatTerminalEnv(env: Record) { + return Object.entries(env) + .map(([key, value]) => `${key}=${value}`) + .join("\n"); +} + +function parseTerminalEnv(envText: string) { + const env: Record = {}; + const lines = envText + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0); + + for (const line of lines) { + const separatorIndex = line.indexOf("="); + if (separatorIndex <= 0) { + return { + env: null, + error: "Environment variables must use KEY=VALUE format.", + } as const; + } + + const key = line.slice(0, separatorIndex).trim(); + const value = line.slice(separatorIndex + 1); + + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) { + return { + env: null, + error: "Environment variable names must start with a letter or underscore.", + } as const; + } + + env[key] = value; + } + + return { + env, + error: null, + } as const; +} + export function useSettingsRestore(onRestored?: () => void) { const { theme, setTheme } = useTheme(); const settings = useSettings(); @@ -428,6 +482,10 @@ export function useSettingsRestore(onRestored?: () => void) { const defaultSettings = DEFAULT_UNIFIED_SETTINGS.providers[providerSettings.provider]; return !Equal.equals(currentSettings, defaultSettings); }); + const isTerminalProfileDirty = !Equal.equals( + settings.terminal, + DEFAULT_UNIFIED_SETTINGS.terminal, + ); const changedSettingLabels = useMemo( () => [ @@ -455,10 +513,12 @@ export function useSettingsRestore(onRestored?: () => void) { : []), ...(isGitWritingModelDirty ? ["Git writing model"] : []), ...(areProviderSettingsDirty ? ["Providers"] : []), + ...(isTerminalProfileDirty ? ["Terminal profile"] : []), ], [ areProviderSettingsDirty, isGitWritingModelDirty, + isTerminalProfileDirty, settings.confirmThreadArchive, settings.confirmThreadDelete, settings.addProjectBaseDirectory, @@ -523,6 +583,13 @@ export function GeneralSettingsPanel() { const [customModelErrorByProvider, setCustomModelErrorByProvider] = useState< Partial> >({}); + const [terminalShellArgsDraft, setTerminalShellArgsDraft] = useState(() => + formatTerminalShellArgs(settings.terminal.profile.shellArgs), + ); + const [terminalEnvDraft, setTerminalEnvDraft] = useState(() => + formatTerminalEnv(settings.terminal.profile.env), + ); + const [terminalEnvError, setTerminalEnvError] = useState(null); const [isRefreshingProviders, setIsRefreshingProviders] = useState(false); const refreshingRef = useRef(false); const modelListRefs = useRef>>({}); @@ -544,7 +611,9 @@ export function GeneralSettingsPanel() { const keybindingsConfigPath = useServerKeybindingsConfigPath(); const availableEditors = useServerAvailableEditors(); const observability = useServerObservability(); + const serverConfig = useServerConfig(); const serverProviders = useServerProviders(); + const currentShell = serverConfig?.terminal.currentShell ?? ""; const codexHomePath = settings.providers.codex.homePath; const logsDirectoryPath = observability?.logsDirectoryPath ?? null; const diagnosticsDescription = (() => { @@ -573,6 +642,47 @@ export function GeneralSettingsPanel() { settings.textGenerationModelSelection ?? null, DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection ?? null, ); + const updateTerminalProfile = useCallback( + ( + patch: Partial<{ + shellPath: string; + shellArgs: ReadonlyArray; + env: Record; + }>, + ) => { + updateSettings({ + terminal: { + profile: { + ...settings.terminal.profile, + ...patch, + }, + }, + }); + }, + [settings.terminal.profile, updateSettings], + ); + + useEffect(() => { + setTerminalShellArgsDraft((currentDraft) => + Equal.equals(parseTerminalShellArgs(currentDraft), settings.terminal.profile.shellArgs) + ? currentDraft + : formatTerminalShellArgs(settings.terminal.profile.shellArgs), + ); + }, [settings.terminal.profile.shellArgs]); + + useEffect(() => { + setTerminalEnvDraft((currentDraft) => { + const parsedDraft = parseTerminalEnv(currentDraft); + if ( + parsedDraft.error === null && + Equal.equals(parsedDraft.env, settings.terminal.profile.env) + ) { + return currentDraft; + } + return formatTerminalEnv(settings.terminal.profile.env); + }); + setTerminalEnvError(null); + }, [settings.terminal.profile.env]); const openInPreferredEditor = useCallback( (target: "keybindings" | "logsDirectory", path: string | null, failureMessage: string) => { @@ -1416,6 +1526,128 @@ export function GeneralSettingsPanel() { })} + + + updateTerminalProfile({ + shellPath: DEFAULT_UNIFIED_SETTINGS.terminal.profile.shellPath, + }) + } + /> + ) : null + } + > +
+ +
+
+ + + updateTerminalProfile({ + shellArgs: DEFAULT_UNIFIED_SETTINGS.terminal.profile.shellArgs, + }) + } + /> + ) : null + } + > +
+