From 7bdfbf61385eb466e4caa5bb11de949c8aafa6bd Mon Sep 17 00:00:00 2001 From: gmackie Date: Mon, 30 Mar 2026 13:04:02 -0700 Subject: [PATCH 1/2] Add terminal profile settings groundwork --- apps/server/src/serverSettings.test.ts | 60 ++++ .../src/terminal/Layers/Manager.test.ts | 40 +++ apps/server/src/terminal/Layers/Manager.ts | 116 ++++---- .../src/terminal/terminalProfile.test.ts | 180 ++++++++++++ apps/server/src/terminal/terminalProfile.ts | 261 ++++++++++++++++++ apps/server/src/wsServer.test.ts | 58 ++++ apps/server/src/wsServer.ts | 13 + apps/web/src/components/ChatView.browser.tsx | 5 + .../components/KeybindingsToast.browser.tsx | 12 + .../settings/SettingsPanels.browser.tsx | 138 +++++++++ .../components/settings/SettingsPanels.tsx | 217 +++++++++++++++ packages/contracts/src/server.ts | 24 ++ packages/contracts/src/settings.test.ts | 19 +- packages/contracts/src/settings.ts | 31 +++ 14 files changed, 1105 insertions(+), 69 deletions(-) create mode 100644 apps/server/src/terminal/terminalProfile.test.ts create mode 100644 apps/server/src/terminal/terminalProfile.ts create mode 100644 apps/web/src/components/settings/SettingsPanels.browser.tsx diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index f26fece246..2aa124a7e0 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"], + }, + }, + }, + ); }), ); @@ -154,6 +173,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 5717fda39e..9c2840bd36 100644 --- a/apps/server/src/terminal/Layers/Manager.test.ts +++ b/apps/server/src/terminal/Layers/Manager.test.ts @@ -7,6 +7,7 @@ import { type TerminalEvent, type TerminalOpenInput, type TerminalRestartInput, + type TerminalProfileSettings, } from "@t3tools/contracts"; import { afterEach, describe, expect, it } from "vitest"; @@ -187,6 +188,7 @@ describe("TerminalManager", () => { processKillGraceMs?: number; maxRetainedInactiveSessions?: number; ptyAdapter?: FakePtyAdapter; + terminalProfileResolver?: () => TerminalProfileSettings; } = {}, ) { const logsDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3code-terminal-")); @@ -205,6 +207,9 @@ describe("TerminalManager", () => { ...(options.maxRetainedInactiveSessions ? { maxRetainedInactiveSessions: options.maxRetainedInactiveSessions } : {}), + ...(options.terminalProfileResolver + ? { terminalProfileResolver: options.terminalProfileResolver } + : {}), }); return { logsDir, ptyAdapter, manager }; } @@ -313,6 +318,41 @@ describe("TerminalManager", () => { manager.dispose(); }); + it("spawns terminals with the configured shell profile", async () => { + const { manager, ptyAdapter } = makeManager(5, { + shellResolver: () => "/bin/bash", + terminalProfileResolver: () => ({ + shellPath: "/bin/zsh", + shellArgs: ["-f"], + env: { + ZDOTDIR: "/tmp/t3code-zdotdir", + PATH: "/opt/t3/bin", + }, + }), + }); + + await 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"); + + manager.dispose(); + }); + it("supports multiple terminals per thread with isolated sessions", async () => { const { manager, ptyAdapter } = makeManager(); await manager.open(openInput({ terminalId: "default" })); diff --git a/apps/server/src/terminal/Layers/Manager.ts b/apps/server/src/terminal/Layers/Manager.ts index b5085220c2..64b20f9819 100644 --- a/apps/server/src/terminal/Layers/Manager.ts +++ b/apps/server/src/terminal/Layers/Manager.ts @@ -11,6 +11,7 @@ import { TerminalRestartInput, TerminalWriteInput, type TerminalEvent, + type TerminalProfileSettings, type TerminalSessionSnapshot, } from "@t3tools/contracts"; import { Effect, Encoding, Layer, Schema } from "effect"; @@ -19,6 +20,7 @@ import { createLogger } from "../../logger"; import { PtyAdapter, PtyAdapterShape, type PtyExitEvent, type PtyProcess } from "../Services/PTY"; import { runProcess } from "../../processRunner"; import { ServerConfig } from "../../config"; +import { ServerSettingsService } from "../../serverSettings"; import { ShellCandidate, TerminalError, @@ -27,6 +29,7 @@ import { TerminalSessionState, TerminalStartInput, } from "../Services/Manager"; +import { resolveCurrentShell, resolveTerminalShellSpawnConfig } from "../terminalProfile"; const DEFAULT_HISTORY_LINE_LIMIT = 5_000; const DEFAULT_PERSIST_DEBOUNCE_MS = 40; @@ -47,33 +50,7 @@ const decodeTerminalCloseInput = Schema.decodeUnknownSync(TerminalCloseInput); type TerminalSubprocessChecker = (terminalPid: number) => Promise; 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 { @@ -81,43 +58,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: unknown): boolean { const queue: unknown[] = [error]; const seen = new Set(); @@ -457,6 +397,7 @@ function shouldExcludeTerminalEnvKey(key: string): boolean { function createTerminalSpawnEnv( baseEnv: NodeJS.ProcessEnv, + profileEnv?: Record | null, runtimeEnv?: Record | null, ): NodeJS.ProcessEnv { const spawnEnv: NodeJS.ProcessEnv = {}; @@ -465,6 +406,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; @@ -491,6 +437,7 @@ interface TerminalManagerOptions { historyLineLimit?: number; ptyAdapter: PtyAdapterShape; shellResolver?: () => string; + terminalProfileResolver?: () => TerminalProfileSettings | Promise; subprocessChecker?: TerminalSubprocessChecker; subprocessPollIntervalMs?: number; processKillGraceMs?: number; @@ -503,6 +450,9 @@ export class TerminalManagerRuntime extends EventEmitter private readonly historyLineLimit: number; private readonly ptyAdapter: PtyAdapterShape; private readonly shellResolver: () => string; + private readonly terminalProfileResolver: () => + | TerminalProfileSettings + | Promise; private readonly persistQueues = new Map>(); private readonly persistTimers = new Map>(); private readonly pendingPersistHistory = new Map(); @@ -523,6 +473,13 @@ export class TerminalManagerRuntime extends EventEmitter this.historyLineLimit = options.historyLineLimit ?? DEFAULT_HISTORY_LINE_LIMIT; this.ptyAdapter = options.ptyAdapter; this.shellResolver = options.shellResolver ?? defaultShellResolver; + this.terminalProfileResolver = + options.terminalProfileResolver ?? + (() => ({ + shellPath: "", + shellArgs: [], + env: {}, + })); this.persistDebounceMs = DEFAULT_PERSIST_DEBOUNCE_MS; this.subprocessChecker = options.subprocessChecker ?? defaultSubprocessChecker; this.subprocessPollIntervalMs = @@ -771,8 +728,19 @@ export class TerminalManagerRuntime extends EventEmitter let ptyProcess: PtyProcess | null = null; let startedShell: string | null = null; try { - const shellCandidates = resolveShellCandidates(this.shellResolver); - const terminalEnv = createTerminalSpawnEnv(process.env, session.runtimeEnv); + const terminalProfile = await this.terminalProfileResolver(); + const resolvedSpawnConfig = resolveTerminalShellSpawnConfig({ + platform: process.platform, + processEnv: process.env, + shellResolver: this.shellResolver, + profile: terminalProfile, + }); + const shellCandidates = resolvedSpawnConfig.shellCandidates; + const terminalEnv = createTerminalSpawnEnv( + process.env, + resolvedSpawnConfig.profileEnv, + session.runtimeEnv, + ); let lastSpawnError: unknown = null; const spawnWithCandidate = (candidate: ShellCandidate) => @@ -1362,10 +1330,22 @@ export const TerminalManagerLive = Layer.effect( TerminalManager, Effect.gen(function* () { const { terminalLogsDir } = yield* ServerConfig; - const ptyAdapter = yield* PtyAdapter; + const serverSettings = yield* ServerSettingsService; const runtime = yield* Effect.acquireRelease( - Effect.sync(() => new TerminalManagerRuntime({ logsDir: terminalLogsDir, ptyAdapter })), + Effect.sync( + () => + new TerminalManagerRuntime({ + logsDir: terminalLogsDir, + ptyAdapter, + terminalProfileResolver: () => + Effect.runPromise( + serverSettings.getSettings.pipe( + Effect.map((settings) => settings.terminal.profile), + ), + ), + }), + ), (r) => Effect.sync(() => r.dispose()), ); diff --git a/apps/server/src/terminal/terminalProfile.test.ts b/apps/server/src/terminal/terminalProfile.test.ts new file mode 100644 index 0000000000..1591d3887a --- /dev/null +++ b/apps/server/src/terminal/terminalProfile.test.ts @@ -0,0 +1,180 @@ +import { describe, expect, it } from "vitest"; + +import { + discoverTerminalShells, + discoverWindowsTerminalShells, + resolveTerminalShellSpawnConfig, + type TerminalShellPathProbe, +} from "./terminalProfile"; + +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", + }, + ], + }); + }); +}); diff --git a/apps/server/src/terminal/terminalProfile.ts b/apps/server/src/terminal/terminalProfile.ts new file mode 100644 index 0000000000..07d12e7e89 --- /dev/null +++ b/apps/server/src/terminal/terminalProfile.ts @@ -0,0 +1,261 @@ +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) { + if (await probe(candidate)) { + return candidate; + } + } + 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/wsServer.test.ts b/apps/server/src/wsServer.test.ts index 826b9ad6fd..9a921d42b2 100644 --- a/apps/server/src/wsServer.test.ts +++ b/apps/server/src/wsServer.test.ts @@ -870,11 +870,49 @@ describe("WebSocket Server", () => { issues: [], providers: defaultProviderStatuses, availableEditors: expect.any(Array), + terminal: { + platform: process.platform, + currentShell: expect.any(String), + discoveredShells: expect.any(Array), + }, settings: defaultServerSettings, }); expectAvailableEditors((response.result as { availableEditors: unknown }).availableEditors); }); + it("includes terminal discovery in server.getConfig", async () => { + const baseDir = makeTempDir("t3code-state-get-config-terminal-"); + const { keybindingsConfigPath: keybindingsPath } = deriveServerPathsSync(baseDir, undefined); + ensureParentDir(keybindingsPath); + fs.writeFileSync(keybindingsPath, "[]", "utf8"); + + server = await createTestServer({ cwd: "/my/workspace", baseDir }); + const addr = server.address(); + const port = typeof addr === "object" && addr !== null ? addr.port : 0; + + const [ws] = await connectAndAwaitWelcome(port); + connections.push(ws); + + const response = await sendRequest(ws, WS_METHODS.serverGetConfig); + expect(response.error).toBeUndefined(); + + expect( + ( + response.result as { + terminal?: { + platform: string; + currentShell: string; + discoveredShells: unknown[]; + }; + } + ).terminal, + ).toEqual({ + platform: process.platform, + currentShell: expect.any(String), + discoveredShells: expect.any(Array), + }); + }); + it("bootstraps default keybindings file when missing", async () => { const baseDir = makeTempDir("t3code-state-bootstrap-keybindings-"); const { keybindingsConfigPath: keybindingsPath } = deriveServerPathsSync(baseDir, undefined); @@ -896,6 +934,11 @@ describe("WebSocket Server", () => { issues: [], providers: defaultProviderStatuses, availableEditors: expect.any(Array), + terminal: { + platform: process.platform, + currentShell: expect.any(String), + discoveredShells: expect.any(Array), + }, settings: defaultServerSettings, }); expectAvailableEditors((response.result as { availableEditors: unknown }).availableEditors); @@ -933,6 +976,11 @@ describe("WebSocket Server", () => { ], providers: defaultProviderStatuses, availableEditors: expect.any(Array), + terminal: { + platform: process.platform, + currentShell: expect.any(String), + discoveredShells: expect.any(Array), + }, settings: defaultServerSettings, }); expectAvailableEditors((response.result as { availableEditors: unknown }).availableEditors); @@ -1083,6 +1131,11 @@ describe("WebSocket Server", () => { issues: [], providers: defaultProviderStatuses, availableEditors: expect.any(Array), + terminal: { + platform: process.platform, + currentShell: expect.any(String), + discoveredShells: expect.any(Array), + }, settings: defaultServerSettings, }); expectAvailableEditors((response.result as { availableEditors: unknown }).availableEditors); @@ -1132,6 +1185,11 @@ describe("WebSocket Server", () => { issues: [], providers: defaultProviderStatuses, availableEditors: expect.any(Array), + terminal: { + platform: process.platform, + currentShell: expect.any(String), + discoveredShells: expect.any(Array), + }, settings: defaultServerSettings, }); expectAvailableEditors( diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index c04d913d52..34d46f157f 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -79,6 +79,7 @@ import { expandHomePath } from "./os-jank.ts"; import { makeServerPushBus } from "./wsServer/pushBus.ts"; import { makeServerReadiness } from "./wsServer/readiness.ts"; import { decodeJsonResult, formatSchemaError } from "@t3tools/shared/schemaJson"; +import { discoverTerminalShells } from "./terminal/terminalProfile"; /** * ServerShape - Service API for server lifecycle control. @@ -309,6 +310,16 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< ), ); + const loadTerminalDiscovery = Effect.fnUntraced(function* () { + return yield* Effect.promise(() => + discoverTerminalShells({ + platform: process.platform, + env: process.env, + probe: (candidatePath) => Effect.runPromise(fileSystem.exists(candidatePath)), + }), + ); + }); + const normalizeDispatchCommand = Effect.fnUntraced(function* (input: { readonly command: ClientOrchestrationCommand; }) { @@ -905,6 +916,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< const keybindingsConfig = yield* keybindingsManager.loadConfigState; const settings = yield* serverSettingsManager.getSettings; const providers = yield* Ref.get(providersRef); + const terminal = yield* loadTerminalDiscovery(); return { cwd, keybindingsConfigPath, @@ -912,6 +924,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< issues: keybindingsConfig.issues, providers, availableEditors, + terminal, settings, }; } diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 06cbf0efbd..ef4d382613 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -123,6 +123,11 @@ function createBaseServerConfig(): ServerConfig { }, ], availableEditors: [], + 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 5d6ec94a50..3e359fdf17 100644 --- a/apps/web/src/components/KeybindingsToast.browser.tsx +++ b/apps/web/src/components/KeybindingsToast.browser.tsx @@ -56,6 +56,11 @@ function createBaseServerConfig(): ServerConfig { }, ], availableEditors: [], + terminal: { + platform: "darwin", + currentShell: "/bin/zsh", + discoveredShells: [], + }, settings: { enableAssistantStreaming: false, defaultThreadEnvMode: "local" as const, @@ -64,6 +69,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 new file mode 100644 index 0000000000..79091bdb54 --- /dev/null +++ b/apps/web/src/components/settings/SettingsPanels.browser.tsx @@ -0,0 +1,138 @@ +import "../../index.css"; + +import type { NativeApi, ServerConfig } from "@t3tools/contracts"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { page } from "vitest/browser"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { render } from "vitest-browser-react"; + +import { GeneralSettingsPanel } from "./SettingsPanels"; + +function createServerConfig(): ServerConfig & { + terminal: ServerConfig["terminal"] & { currentShell: string }; +} { + return { + cwd: "/repo/project", + keybindingsConfigPath: "/repo/project/.t3code/keybindings.json", + keybindings: [], + issues: [], + providers: [ + { + provider: "codex", + enabled: true, + installed: true, + version: "0.117.0", + status: "ready", + authStatus: "authenticated", + checkedAt: "2026-03-30T12:00:00.000Z", + models: [], + }, + ], + availableEditors: [], + terminal: { + platform: "darwin", + discoveredShells: [], + currentShell: "/bin/bash", + }, + settings: { + enableAssistantStreaming: false, + defaultThreadEnvMode: "local", + textGenerationModelSelection: { provider: "codex", model: "gpt-5.4-mini" }, + providers: { + codex: { enabled: true, binaryPath: "", homePath: "", customModels: [] }, + claudeAgent: { enabled: true, binaryPath: "", customModels: [] }, + }, + terminal: { + profile: { + shellPath: "", + shellArgs: ["-l"], + env: { + TERM_PROGRAM: "T3Code", + }, + }, + }, + }, + }; +} + +async function mountSettingsPanel() { + const queryClient = new QueryClient(); + const host = document.createElement("div"); + document.body.append(host); + + const serverConfig = createServerConfig(); + const updateSettings = vi.fn().mockResolvedValue(serverConfig.settings); + + window.nativeApi = { + server: { + getConfig: vi.fn().mockResolvedValue(serverConfig), + updateSettings, + refreshProviders: vi.fn().mockResolvedValue(undefined), + }, + } as unknown as NativeApi; + + const screen = await render( + + + , + { container: host }, + ); + + return { + updateSettings, + cleanup: async () => { + await screen.unmount(); + host.remove(); + queryClient.clear(); + }, + }; +} + +describe("GeneralSettingsPanel terminal settings", () => { + afterEach(() => { + document.body.innerHTML = ""; + localStorage.clear(); + Reflect.deleteProperty(window, "nativeApi"); + }); + + it("shows terminal profile controls and persists shell path changes", async () => { + const mounted = await mountSettingsPanel(); + + try { + await vi.waitFor(() => { + expect(document.body.textContent ?? "").toContain("Terminal"); + expect(document.body.textContent ?? "").not.toContain( + "Leave blank to keep the current shell and only change the terminal profile.", + ); + }); + + const shellPathInput = page.getByPlaceholder("/bin/bash"); + const envTextarea = page.getByPlaceholder("TERM_PROGRAM=T3Code\nTERM=xterm-256color"); + + await expect.element(shellPathInput).toHaveValue(""); + await expect + .element(envTextarea) + .toHaveAttribute( + "placeholder", + "TERM_PROGRAM=T3Code\nTERM=xterm-256color\nZDOTDIR=/Users/you/.config/t3code-zsh", + ); + await shellPathInput.fill("/bin/zsh"); + + await vi.waitFor(() => { + expect(mounted.updateSettings).toHaveBeenLastCalledWith({ + terminal: { + profile: { + shellPath: "/bin/zsh", + shellArgs: ["-l"], + env: { + TERM_PROGRAM: "T3Code", + }, + }, + }, + }); + }); + } finally { + await mounted.cleanup(); + } + }); +}); diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index f9fdb1d615..4659a8de59 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -56,6 +56,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 { ProjectFavicon } from "../ProjectFavicon"; @@ -438,6 +439,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(); @@ -452,6 +505,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( () => [ @@ -476,10 +533,12 @@ export function useSettingsRestore(onRestored?: () => void) { : []), ...(isGitWritingModelDirty ? ["Git writing model"] : []), ...(areProviderSettingsDirty ? ["Providers"] : []), + ...(isTerminalProfileDirty ? ["Terminal profile"] : []), ], [ areProviderSettingsDirty, isGitWritingModelDirty, + isTerminalProfileDirty, settings.confirmThreadArchive, settings.confirmThreadDelete, settings.defaultThreadEnvMode, @@ -539,6 +598,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 queryClient = useQueryClient(); @@ -562,6 +628,7 @@ export function GeneralSettingsPanel() { const keybindingsConfigPath = serverConfigQuery.data?.keybindingsConfigPath ?? null; const availableEditors = serverConfigQuery.data?.availableEditors; const serverProviders = serverConfigQuery.data?.providers ?? EMPTY_SERVER_PROVIDERS; + const currentShell = serverConfigQuery.data?.terminal.currentShell ?? ""; const codexHomePath = settings.providers.codex.homePath; const textGenerationModelSelection = resolveAppModelSelectionState(settings, serverProviders); @@ -578,6 +645,34 @@ 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(formatTerminalShellArgs(settings.terminal.profile.shellArgs)); + }, [settings.terminal.profile.shellArgs]); + + useEffect(() => { + setTerminalEnvDraft(formatTerminalEnv(settings.terminal.profile.env)); + setTerminalEnvError(null); + }, [settings.terminal.profile.env]); const openKeybindingsFile = useCallback(() => { if (!keybindingsConfigPath) return; @@ -1370,6 +1465,128 @@ export function GeneralSettingsPanel() { })} + + + updateTerminalProfile({ + shellPath: DEFAULT_UNIFIED_SETTINGS.terminal.profile.shellPath, + }) + } + /> + ) : null + } + > +
+ +
+
+ + + updateTerminalProfile({ + shellArgs: DEFAULT_UNIFIED_SETTINGS.terminal.profile.shellArgs, + }) + } + /> + ) : null + } + > +
+