From 7149d49c7d457b234c6f1a640428a5b30a6660ed Mon Sep 17 00:00:00 2001 From: sharull Date: Thu, 22 Oct 2026 20:59:50 +0530 Subject: [PATCH 1/5] feat(terminal): let users choose a shell for new terminals - Detect available shells and resolve defaults from project and environment settings - Expose shell selection in terminal menus and settings --- .../terminal/ThreadTerminalRouteScreen.tsx | 97 +++++-- .../terminal/terminalLaunchContext.test.ts | 15 ++ .../terminal/terminalLaunchContext.ts | 4 +- apps/server/src/auth/RpcAuthorization.ts | 1 + .../src/observability/RpcInstrumentation.ts | 1 + apps/server/src/server.ts | 1 + apps/server/src/terminal/Manager.test.ts | 183 +++++++++++++ apps/server/src/terminal/Manager.ts | 246 +++++++++++++----- .../server/src/terminal/ShellProfiles.test.ts | 92 +++++++ apps/server/src/terminal/ShellProfiles.ts | 188 +++++++++++++ apps/server/src/ws.ts | 1 + apps/web/src/components/ChatView.tsx | 3 +- .../components/PersistentThreadTerminals.tsx | 13 +- .../src/components/ThreadTerminalDrawer.tsx | 67 ++++- .../components/chat/useChatTerminalActions.ts | 9 +- .../settings/ProjectDefaultsSettings.tsx | 3 + .../settings/SettingInheritance.tsx | 6 +- .../settings/TerminalShellSettingRow.tsx | 158 +++++++++++ .../src/components/settings/settingsSearch.ts | 9 + .../terminal/NewTerminalShellMenu.tsx | 77 ++++++ .../terminal/terminalShell.logic.test.ts | 40 +++ .../terminal/terminalShell.logic.ts | 39 +++ .../components/terminal/useTerminalShells.ts | 42 +++ .../terminal/useThreadTerminalActions.ts | 4 + .../src/components/ui/middle-truncate.test.ts | 23 ++ .../web/src/components/ui/middle-truncate.tsx | 5 +- docs/user/terminal.md | 23 ++ packages/client-runtime/src/state/terminal.ts | 4 + packages/contracts/src/rpc.ts | 9 + packages/contracts/src/settings.test.ts | 20 ++ packages/contracts/src/settings.ts | 9 + packages/contracts/src/t3ProjectFile.ts | 7 + packages/contracts/src/terminal.ts | 36 +++ packages/shared/src/t3ProjectFile.test.ts | 5 + 34 files changed, 1334 insertions(+), 106 deletions(-) create mode 100644 apps/server/src/terminal/ShellProfiles.test.ts create mode 100644 apps/server/src/terminal/ShellProfiles.ts create mode 100644 apps/web/src/components/settings/TerminalShellSettingRow.tsx create mode 100644 apps/web/src/components/terminal/NewTerminalShellMenu.tsx create mode 100644 apps/web/src/components/terminal/terminalShell.logic.test.ts create mode 100644 apps/web/src/components/terminal/terminalShell.logic.ts create mode 100644 apps/web/src/components/terminal/useTerminalShells.ts create mode 100644 apps/web/src/components/ui/middle-truncate.test.ts diff --git a/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx b/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx index 34a1ec4b4fbab..8f8fcdcd1f51b 100644 --- a/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx +++ b/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx @@ -5,6 +5,7 @@ import { DEFAULT_TERMINAL_ID, EnvironmentId, ThreadId, + type TerminalShellProfile, sessionGrantsScope, } from "@t3tools/contracts"; import { type KnownTerminalSession } from "@t3tools/client-runtime/state/terminal"; @@ -71,6 +72,7 @@ import { } from "./terminalBufferReplay"; import { useInitialTerminalLaunchLocation, + stagePendingTerminalLaunch, takePendingTerminalLaunch, type PendingTerminalLaunch, } from "./terminalLaunchContext"; @@ -106,7 +108,8 @@ function TerminalHeader(props: { readonly onCloseTerminal: () => void; readonly onDecreaseFontSize: () => void; readonly onIncreaseFontSize: () => void; - readonly onOpenNewTerminal: () => void; + readonly shells: ReadonlyArray; + readonly onOpenNewTerminal: (shell?: string) => void; readonly onSelectTerminal: (terminalId: string) => void; }) { return ( @@ -168,8 +171,23 @@ function TerminalHeader(props: { title: "Open new terminal", icon: "plus", subtitle: `Start another shell in ${basename(props.workspaceRoot) ?? "this workspace"}`, - onPress: props.onOpenNewTerminal, + onPress: () => props.onOpenNewTerminal(), }, + ...(props.shells.length > 0 + ? [ + { + id: "terminal-new-with-shell", + title: "New terminal with…", + icon: "plus", + disabled: !props.canOperateTerminal, + items: props.shells.map((shell) => ({ + id: `terminal-new-shell:${shell.id}`, + title: shell.label, + onPress: () => props.onOpenNewTerminal(shell.id), + })), + }, + ] + : []), ], }, ] @@ -261,6 +279,7 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) const clearTerminal = useAtomCommand(terminalEnvironment.clear, "terminal clear"); const closeTerminal = useAtomCommand(terminalEnvironment.close, "terminal close"); const openTerminal = useAtomCommand(terminalEnvironment.open, "terminal open"); + const listShells = useAtomCommand(terminalEnvironment.listShells, "terminal list shells"); const retryEnvironment = useAtomCommand(environmentCatalog.retryNow, "environment retry"); const connectionsReady = useConnectionsReady(); const params = props.route.params; @@ -433,6 +452,7 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) cols: initialAttachGridSize.cols, rows: initialAttachGridSize.rows, ...(pendingLaunch?.env ? { env: pendingLaunch.env } : {}), + ...(pendingLaunch?.shell ? { shell: pendingLaunch.shell } : {}), ...(pendingLaunch ? { restartIfNotRunning: true } : {}), } : null, @@ -964,6 +984,7 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) cols: terminalAttachInput.cols, rows: terminalAttachInput.rows, ...(terminalAttachInput.env ? { env: terminalAttachInput.env } : {}), + ...(terminalAttachInput.shell ? { shell: terminalAttachInput.shell } : {}), }, }); return result._tag === "Success"; @@ -1001,29 +1022,62 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) [navigateAwayAfterExit, navigation, terminalKey], ); - const handleOpenNewTerminal = useCallback(() => { - if ( - !selectedThread || - !readEnvironmentScope(selectedThread.environmentId, AuthTerminalOperateScope) - ) { + const [shells, setShells] = useState>([]); + const shellsEnvironmentId = selectedThread?.environmentId ?? null; + useEffect(() => { + setShells([]); + if (shellsEnvironmentId === null || !canOperateTerminal || !isEnvironmentReady) { return; } + let cancelled = false; + void listShells({ environmentId: shellsEnvironmentId, input: {} }).then((result) => { + if (!cancelled && result._tag === "Success") { + setShells(result.value.shells); + } + }); + return () => { + cancelled = true; + }; + }, [canOperateTerminal, isEnvironmentReady, listShells, shellsEnvironmentId]); - navigation.dispatch( - StackActions.replace("ThreadTerminal", { - environmentId: String(selectedThread.environmentId), - threadId: String(selectedThread.id), - terminalId: nextOpenTerminalId({ - listedTerminalIds: terminalMenuSessions.map((session) => session.terminalId), - activeRouteTerminalId: terminalId, - ...(knownSessions === null || - !readEnvironmentScope(selectedThread.environmentId, AuthTerminalReadScope) - ? { uniqueSuffix: uuidv4() } - : {}), + const handleOpenNewTerminal = useCallback( + (shell?: string) => { + if ( + !selectedThread || + !readEnvironmentScope(selectedThread.environmentId, AuthTerminalOperateScope) + ) { + return; + } + + const nextTerminalId = nextOpenTerminalId({ + listedTerminalIds: terminalMenuSessions.map((session) => session.terminalId), + activeRouteTerminalId: terminalId, + ...(knownSessions === null || + !readEnvironmentScope(selectedThread.environmentId, AuthTerminalReadScope) + ? { uniqueSuffix: uuidv4() } + : {}), + }); + if (shell !== undefined && launchLocation !== null) { + stagePendingTerminalLaunch({ + target: { + environmentId: selectedThread.environmentId, + threadId: selectedThread.id, + terminalId: nextTerminalId, + }, + launch: { ...launchLocation, shell }, + }); + } + + navigation.dispatch( + StackActions.replace("ThreadTerminal", { + environmentId: String(selectedThread.environmentId), + threadId: String(selectedThread.id), + terminalId: nextTerminalId, }), - }), - ); - }, [knownSessions, navigation, selectedThread, terminalId, terminalMenuSessions]); + ); + }, + [knownSessions, launchLocation, navigation, selectedThread, terminalId, terminalMenuSessions], + ); const handleDecreaseFontSize = useCallback(() => { setTerminalFontSize(stepTerminalFontSize(fontSize, -1)); @@ -1157,6 +1211,7 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) onCloseTerminal={handleCloseTerminal} onDecreaseFontSize={handleDecreaseFontSize} onIncreaseFontSize={handleIncreaseFontSize} + shells={shells} onOpenNewTerminal={handleOpenNewTerminal} onSelectTerminal={handleSelectTerminal} /> diff --git a/apps/mobile/src/features/terminal/terminalLaunchContext.test.ts b/apps/mobile/src/features/terminal/terminalLaunchContext.test.ts index 89d3edc7fa303..8b815febf7448 100644 --- a/apps/mobile/src/features/terminal/terminalLaunchContext.test.ts +++ b/apps/mobile/src/features/terminal/terminalLaunchContext.test.ts @@ -81,4 +81,19 @@ describe("pending terminal launches", () => { }); expect(takePendingTerminalLaunch(target)).toBeNull(); }); + + it("keeps the shell chosen for a new terminal", () => { + const target = { + environmentId: EnvironmentId.make("env-1"), + threadId: ThreadId.make("thread-1"), + terminalId: "term-3", + }; + + stagePendingTerminalLaunch({ + target, + launch: { cwd: "/repo", worktreePath: null, shell: "bash" }, + }); + + expect(takePendingTerminalLaunch(target)?.shell).toBe("bash"); + }); }); diff --git a/apps/mobile/src/features/terminal/terminalLaunchContext.ts b/apps/mobile/src/features/terminal/terminalLaunchContext.ts index 5196600364637..202e772b13993 100644 --- a/apps/mobile/src/features/terminal/terminalLaunchContext.ts +++ b/apps/mobile/src/features/terminal/terminalLaunchContext.ts @@ -1,4 +1,4 @@ -import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import type { EnvironmentId, TerminalShellSetting, ThreadId } from "@t3tools/contracts"; import { useEffect, useState } from "react"; interface TerminalLocationLike { @@ -17,6 +17,7 @@ export interface PendingTerminalLaunch { readonly worktreePath: string | null; readonly env?: Record; readonly initialInput?: string; + readonly shell?: TerminalShellSetting; } const pendingTerminalLaunches = new Map(); @@ -34,6 +35,7 @@ export function stagePendingTerminalLaunch(input: { worktreePath: input.launch.worktreePath, env: input.launch.env ? { ...input.launch.env } : undefined, initialInput: input.launch.initialInput, + shell: input.launch.shell, }); } diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 18919cea55ecf..a94e994fa71cb 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -180,6 +180,7 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.terminalClear]: AuthTerminalOperateScope, [WS_METHODS.terminalRestart]: AuthTerminalOperateScope, [WS_METHODS.terminalClose]: AuthTerminalOperateScope, + [WS_METHODS.terminalListShells]: AuthTerminalOperateScope, [WS_METHODS.subscribeTerminalEvents]: AuthTerminalReadScope, [WS_METHODS.subscribeTerminalMetadata]: AuthTerminalReadScope, [WS_METHODS.previewOpen]: AuthPreviewOperateScope, diff --git a/apps/server/src/observability/RpcInstrumentation.ts b/apps/server/src/observability/RpcInstrumentation.ts index 3f3942e75d082..2f4ef2a963f4f 100644 --- a/apps/server/src/observability/RpcInstrumentation.ts +++ b/apps/server/src/observability/RpcInstrumentation.ts @@ -184,6 +184,7 @@ const RPC_AGGREGATES = { [WS_METHODS.terminalClear]: "terminal", [WS_METHODS.terminalRestart]: "terminal", [WS_METHODS.terminalClose]: "terminal", + [WS_METHODS.terminalListShells]: "terminal", [WS_METHODS.subscribeTerminalEvents]: "terminal", [WS_METHODS.subscribeTerminalMetadata]: "terminal", [WS_METHODS.previewOpen]: "preview", diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 48d701103c077..ed9859a3b0ff4 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -424,6 +424,7 @@ const layerTerminal = TerminalManager.layer.pipe( Layer.provide(layerPtyAdapter), Layer.provide(layerPortScanner), Layer.provide(layerNativeTelemetry), + Layer.provide(ProjectionStoreV2.layer), ); const layerPreview = Layer.empty.pipe( diff --git a/apps/server/src/terminal/Manager.test.ts b/apps/server/src/terminal/Manager.test.ts index 413436ea452c5..4f22597014c74 100644 --- a/apps/server/src/terminal/Manager.test.ts +++ b/apps/server/src/terminal/Manager.test.ts @@ -6,10 +6,18 @@ import { type TerminalEvent, type TerminalOpenInput, type TerminalRestartInput, + type TerminalShellProfile, + type TerminalShellSetting, + type TerminalSummary, ProviderDriverKind, ProviderInstanceId, TerminalProviderInstanceNotFoundError, + DEFAULT_SERVER_SETTINGS, + ProjectId, + ThreadId, + type T3ProjectFile, } from "@t3tools/contracts"; +import * as Option from "effect/Option"; import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; @@ -225,6 +233,10 @@ const historyLogPath = (logsDir: string, threadId = "thread-1") => interface CreateManagerOptions { shellResolver?: () => string; + shellProfiles?: ReadonlyArray; + resolveConfiguredShell?: Parameters< + typeof TerminalManager.makeWithOptions + >[0]["resolveConfiguredShell"]; env?: NodeJS.ProcessEnv; subprocessInspector?: (terminalPid: number) => Effect.Effect<{ readonly hasRunningSubprocess: boolean; @@ -281,6 +293,12 @@ const createManager = ( ? { historyByteLimit: options.historyByteLimit } : {}), ...(options.shellResolver !== undefined ? { shellResolver: options.shellResolver } : {}), + ...(options.shellProfiles !== undefined + ? { shellProfiles: Effect.succeed(options.shellProfiles) } + : {}), + ...(options.resolveConfiguredShell !== undefined + ? { resolveConfiguredShell: options.resolveConfiguredShell } + : {}), ...(options.env !== undefined ? { env: options.env } : {}), ...(options.subprocessInspector !== undefined ? { subprocessInspector: options.subprocessInspector } @@ -888,6 +906,171 @@ it.layer( }), ); + const zsh: TerminalShellProfile = { + id: "zsh", + label: "zsh", + command: "/bin/zsh", + args: ["-o", "nopromptsp"], + }; + const fish: TerminalShellProfile = { id: "fish", label: "fish", command: "/bin/fish", args: [] }; + const shellProfiles = [zsh, fish]; + const configured = (setting: TerminalShellSetting) => () => Effect.succeed(setting); + + it.effect("prefers the explicit shell over the configured one", () => + Effect.gen(function* () { + const { manager, ptyAdapter } = yield* createManager(5, { + shellProfiles, + resolveConfiguredShell: configured("zsh"), + }).pipe(Effect.provide(layerWithHostPlatform("linux"))); + + const snapshot = yield* manager.open(openInput({ shell: "fish" })); + + expect(ptyAdapter.spawnInputs[0]?.shell).toBe("/bin/fish"); + expect(snapshot.history).toBe(""); + const metadata = yield* manager.subscribeMetadata((event) => + event.type === "snapshot" + ? Effect.sync(() => { + expect(event.terminals[0]?.shell).toBe("fish"); + }) + : Effect.void, + ); + metadata(); + }), + ); + + it.effect("uses the configured shell, and auto when nothing is configured", () => + Effect.gen(function* () { + const first = yield* createManager(5, { + shellProfiles, + resolveConfiguredShell: configured("zsh"), + shellResolver: () => "/bin/sh", + }).pipe(Effect.provide(layerWithHostPlatform("linux"))); + yield* first.manager.open(openInput()); + expect(first.ptyAdapter.spawnInputs[0]).toMatchObject({ + shell: "/bin/zsh", + args: ["-o", "nopromptsp"], + }); + + const second = yield* createManager(5, { + shellProfiles, + resolveConfiguredShell: configured("auto"), + shellResolver: () => "/bin/sh", + }).pipe(Effect.provide(layerWithHostPlatform("linux"))); + yield* second.manager.open(openInput()); + expect(second.ptyAdapter.spawnInputs[0]?.shell).toBe("/bin/sh"); + }), + ); + + it.effect("treats a profile id this machine lacks as auto", () => + Effect.gen(function* () { + const { manager, ptyAdapter } = yield* createManager(5, { + shellProfiles, + resolveConfiguredShell: configured("nushell"), + shellResolver: () => "/bin/sh", + }).pipe(Effect.provide(layerWithHostPlatform("linux"))); + + const snapshot = yield* manager.open(openInput()); + + expect(ptyAdapter.spawnInputs[0]?.shell).toBe("/bin/sh"); + expect(snapshot.history).toBe(""); + }), + ); + + it.effect("reports the fallback in metadata when a custom shell cannot start", () => + Effect.gen(function* () { + const { manager, ptyAdapter } = yield* createManager(5, { + shellProfiles, + shellResolver: () => "/bin/sh", + }).pipe(Effect.provide(layerWithHostPlatform("linux"))); + ptyAdapter.spawnFailures.push(new Error("spawn /opt/missing ENOENT")); + const summaries = Effect.gen(function* () { + let terminals: ReadonlyArray = []; + const unsubscribe = yield* manager.subscribeMetadata((event) => + Effect.sync(() => { + if (event.type === "snapshot") terminals = event.terminals; + }), + ); + unsubscribe(); + return terminals; + }); + + const snapshot = yield* manager.open( + openInput({ shell: { command: "/opt/missing", args: ["-l"] } }), + ); + + expect(ptyAdapter.spawnInputs[0]).toMatchObject({ shell: "/opt/missing", args: ["-l"] }); + expect(snapshot.status).toBe("running"); + expect(snapshot.history).toBe(""); + const [fellBack] = yield* summaries; + expect(fellBack).toMatchObject({ shell: "sh", shellFallbackFrom: "missing" }); + + yield* manager.restart(restartInput()); + const [recovered] = yield* summaries; + expect(recovered?.shell).toBe("missing"); + expect(recovered?.shellFallbackFrom).toBeUndefined(); + }), + ); + + it.effect("restart and respawn keep the terminal's shell", () => + Effect.gen(function* () { + const { manager, ptyAdapter } = yield* createManager(5, { + shellProfiles, + resolveConfiguredShell: configured("zsh"), + }).pipe(Effect.provide(layerWithHostPlatform("linux"))); + + yield* manager.open(openInput({ shell: "fish" })); + yield* manager.restart(restartInput()); + expect(ptyAdapter.spawnInputs.map((input) => input.shell)).toEqual([ + "/bin/fish", + "/bin/fish", + ]); + + yield* manager.restart(restartInput({ shell: "zsh" })); + yield* manager.restart(restartInput()); + expect(ptyAdapter.spawnInputs.map((input) => input.shell).slice(2)).toEqual([ + "/bin/zsh", + "/bin/zsh", + ]); + }), + ); + + const projectId = ProjectId.make("project-a"); + const threads = { + getThreadShell: () => Effect.succeed({ projectId } as never), + }; + const resolve = (input: { + readonly environment?: TerminalShellSetting; + readonly project?: TerminalShellSetting; + readonly file?: T3ProjectFile; + }) => + TerminalManager.resolveConfiguredTerminalShell({ + serverSettings: { + getSettings: Effect.succeed({ + ...DEFAULT_SERVER_SETTINGS, + terminalShell: input.environment ?? null, + projectSettingsOverrides: + input.project === undefined ? {} : { [projectId]: { terminalShell: input.project } }, + }), + }, + projectFiles: { + load: () => Effect.succeed(input.file ? Option.some(input.file) : Option.none()), + }, + threads, + threadId: ThreadId.make("thread-1"), + cwd: "/repo", + }); + + it.effect("orders project override, environment default, t3.json, then auto", () => + Effect.gen(function* () { + const file = { terminalShell: "cmd" } satisfies T3ProjectFile; + expect(yield* resolve({ project: "zsh", environment: "bash", file })).toBe("zsh"); + expect(yield* resolve({ environment: "bash", file })).toBe("bash"); + expect(yield* resolve({ file })).toBe("cmd"); + expect(yield* resolve({})).toBe("auto"); + expect(yield* resolve({ project: "auto", file })).toBe("auto"); + }), + ); + it.effect("retries with fallback shells when preferred shell spawn fails", () => Effect.gen(function* () { const platform = yield* HostProcess.Platform; diff --git a/apps/server/src/terminal/Manager.ts b/apps/server/src/terminal/Manager.ts index a75f6efccf050..b325d5e84f769 100644 --- a/apps/server/src/terminal/Manager.ts +++ b/apps/server/src/terminal/Manager.ts @@ -1,5 +1,7 @@ // @effect-diagnostics-next-line nodeBuiltinImport:off -- Effect's Crypto digest is effectful; a sync hash keeps historyPath a plain function. import * as NodeCrypto from "node:crypto"; +// @effect-diagnostics-next-line nodeBuiltinImport:off -- Shell detection probes a handful of candidate paths synchronously; the pure detector takes a sync predicate. +import * as NodeFS from "node:fs"; import { withWorkspaceLease } from "../workspace/workspaceLease.ts"; import { @@ -28,14 +30,23 @@ import { type TerminalResizeInput, type ResourceMonitorProcessTableEntry, type TerminalRestartInput, + type TerminalListShellsResult, type TerminalSessionSnapshot, + type TerminalShellProfile, + type TerminalShellSetting, type TerminalSessionStatus, type TerminalSummary, type TerminalWriteInput, ClaudeSettings, CodexSettings, ProviderInstanceId, + ThreadId, } from "@t3tools/contracts"; +import { + hasProjectSettingsOverrides, + resolveProjectFileBackedSetting, + resolveProjectSettings, +} from "@t3tools/shared/projectSettings"; import { makeKeyedCoalescingWorker } from "@t3tools/shared/KeyedCoalescingWorker"; import * as KeyedLock from "@t3tools/shared/KeyedLock"; import { AgentScope } from "@t3tools/shared/AgentScope"; @@ -76,6 +87,17 @@ import * as ProcessRunner from "../processRunner.ts"; import * as PortScanner from "../preview/PortScanner.ts"; import * as NativeTelemetryClient from "../resourceTelemetry/NativeTelemetryClient.ts"; import * as PtyAdapter from "@t3tools/shared/PtyAdapter"; +import * as T3ProjectFileLoader from "../project/T3ProjectFileLoader.ts"; +import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts"; +import { + basenameForPlatform, + candidateFromProfile, + detectShellProfiles, + shellCandidateFromCommand, + windowsCmdPath, + windowsPowerShellPath, + type ShellCandidate, +} from "./ShellProfiles.ts"; export { TerminalCwdError, @@ -214,6 +236,9 @@ export class TerminalManager extends Context.Service< readonly subscribeMetadata: ( listener: (event: TerminalMetadataStreamEvent) => Effect.Effect, ) => Effect.Effect<() => void>; + + /** Shells installed on this environment, in the order clients should offer them. */ + readonly listShells: () => Effect.Effect; } >()("t3/terminal/Manager/TerminalManager") {} @@ -248,11 +273,6 @@ const resizePtyProcess = ( }), }); -export interface ShellCandidate { - shell: string; - args?: string[]; -} - export interface TerminalStartInput extends TerminalOpenInput { cols: number; rows: number; @@ -283,6 +303,12 @@ interface TerminalSessionState { hasRunningSubprocess: boolean; childCommandLabel: string | null; runtimeEnv: Record | null; + /** Explicit choice for this terminal; undefined defers to the configured settings. */ + shell: TerminalShellSetting | undefined; + /** Label of the shell actually running. */ + shellLabel: string | null; + /** Label of the requested shell that failed to spawn; null when it started. */ + shellFallbackFrom: string | null; } interface PersistHistoryRequest { @@ -381,6 +407,8 @@ function summary(session: TerminalSessionState): TerminalSummary { exitSignal: session.exitSignal, hasRunningSubprocess: session.hasRunningSubprocess, label: terminalWireLabel(session), + ...(session.shellLabel !== null ? { shell: session.shellLabel } : {}), + ...(session.shellFallbackFrom !== null ? { shellFallbackFrom: session.shellFallbackFrom } : {}), updatedAt: session.updatedAt, }; } @@ -499,58 +527,6 @@ function normalizeShellCommand( return firstToken.replace(/^['"]|['"]$/g, ""); } -function basenameForPlatform(command: string, platform: NodeJS.Platform): string { - const normalized = - platform === "win32" ? command.replaceAll("/", "\\") : command.replaceAll("\\", "/"); - const parts = normalized - .split(platform === "win32" ? /\\+/ : /\/+/) - .filter((part) => part.length > 0); - return parts.at(-1) ?? normalized; -} - -function joinWindowsPath(...parts: ReadonlyArray): string { - return parts - .map((part, index) => { - if (index === 0) return part.replace(/[\\/]+$/g, ""); - return part.replace(/^[\\/]+|[\\/]+$/g, ""); - }) - .filter((part) => part.length > 0) - .join("\\"); -} - -function shellCandidateFromCommand( - command: string | null, - platform: NodeJS.Platform, -): ShellCandidate | null { - if (!command || command.length === 0) return null; - const shellName = basenameForPlatform(command, platform).toLowerCase(); - if (platform === "win32" && (shellName === "pwsh.exe" || shellName === "powershell.exe")) { - return { shell: command, args: ["-NoLogo"] }; - } - if (platform !== "win32" && shellName === "zsh") { - return { shell: command, args: ["-o", "nopromptsp"] }; - } - return { shell: command }; -} - -function windowsSystemRoot(env: NodeJS.ProcessEnv): string { - return env.SystemRoot?.trim() || env.windir?.trim() || "C:\\Windows"; -} - -function windowsPowerShellPath(env: NodeJS.ProcessEnv): string { - return joinWindowsPath( - windowsSystemRoot(env), - "System32", - "WindowsPowerShell", - "v1.0", - "powershell.exe", - ); -} - -function windowsCmdPath(env: NodeJS.ProcessEnv): string { - return joinWindowsPath(windowsSystemRoot(env), "System32", "cmd.exe"); -} - function formatShellCandidate(candidate: ShellCandidate): string { if (!candidate.args || candidate.args.length === 0) return candidate.shell; return `${candidate.shell} ${candidate.args.join(" ")}`; @@ -570,6 +546,7 @@ function uniqueShellCandidates(candidates: Array): ShellC } function resolveShellCandidates( + preferred: ShellCandidate | null, shellResolver: () => string, platform: NodeJS.Platform, env: NodeJS.ProcessEnv, @@ -581,6 +558,7 @@ function resolveShellCandidates( if (platform === "win32") { return uniqueShellCandidates([ + preferred, requested, shellCandidateFromCommand("pwsh.exe", platform), shellCandidateFromCommand(windowsPowerShellPath(env), platform), @@ -592,6 +570,7 @@ function resolveShellCandidates( } return uniqueShellCandidates([ + preferred, requested, shellCandidateFromCommand(normalizeShellCommand(env.SHELL, platform), platform), shellCandidateFromCommand("/bin/zsh", platform), @@ -603,6 +582,43 @@ function resolveShellCandidates( ]); } +function preferredShellCandidate( + setting: TerminalShellSetting, + profiles: ReadonlyArray, + platform: NodeJS.Platform, +): ShellCandidate | null { + if (setting === "auto") return null; + if (typeof setting === "string") { + // An id this machine does not have (say a t3.json written on another OS) means auto. + const profile = profiles.find((candidate) => candidate.id === setting); + return profile === undefined ? null : candidateFromProfile(profile); + } + return { + shell: setting.command, + args: [...(setting.args ?? [])], + label: basenameForPlatform(setting.command, platform), + }; +} + +const WINDOWS_SHELL_LABELS: Readonly> = { + "pwsh.exe": "PowerShell 7", + "powershell.exe": "Windows PowerShell", + "cmd.exe": "Command Prompt", +}; + +function shellCandidateLabel( + candidate: ShellCandidate, + profiles: ReadonlyArray, + platform: NodeJS.Platform, +): string { + if (candidate.label !== undefined) return candidate.label; + const command = candidate.shell.toLowerCase(); + const profile = profiles.find((entry) => entry.command.toLowerCase() === command); + if (profile !== undefined) return profile.label; + const name = basenameForPlatform(candidate.shell, platform); + return (platform === "win32" ? WINDOWS_SHELL_LABELS[name.toLowerCase()] : undefined) ?? name; +} + function isRetryableShellSpawnError(error: PtyAdapter.PtySpawnError): boolean { const queue: unknown[] = [error]; const seen = new Set(); @@ -1398,6 +1414,16 @@ interface TerminalManagerOptions { historyByteLimit?: number; ptyAdapter: PtyAdapter.PtyAdapter["Service"]; shellResolver?: () => string; + /** Overrides host detection of installed shells. */ + shellProfiles?: Effect.Effect>; + /** + * The shell configured for the terminal's project: project override, environment default, + * then t3.json. "auto" when nothing is configured. + */ + resolveConfiguredShell?: (input: { + readonly threadId: string; + readonly cwd: string; + }) => Effect.Effect; env?: NodeJS.ProcessEnv; managedBinaryCacheDir?: string; managedBinaryToolsDir?: string; @@ -1468,6 +1494,34 @@ export const resolveProviderInstanceTerminalEnvironment = Effect.fn( ); }); +/** + * Precedence below a terminal's own choice: project override, environment default, then the + * repository's t3.json. "auto" when nothing is configured or the lookup fails. + */ +export const resolveConfiguredTerminalShell = Effect.fn("terminal.resolveConfiguredShell")( + function* (input: { + readonly serverSettings: Pick; + readonly projectFiles: T3ProjectFileLoader.T3ProjectFileLoader["Service"]; + readonly threads: Pick; + readonly threadId: string; + readonly cwd: string; + }) { + const settings = yield* input.serverSettings.getSettings; + const projectId = hasProjectSettingsOverrides(settings) + ? yield* input.threads.getThreadShell(ThreadId.make(input.threadId)).pipe( + Effect.map((thread) => thread?.projectId ?? null), + Effect.orElseSucceed(() => null), + ) + : null; + const resolved = resolveProjectSettings(settings, projectId).settings.terminalShell; + if (resolved !== null) return resolved; + const projectFile = yield* input.projectFiles.load(input.cwd); + return resolveProjectFileBackedSetting("terminalShell", null, Option.getOrNull(projectFile)) + .value; + }, + Effect.orElseSucceed((): TerminalShellSetting => "auto"), +); + /** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.fn("TerminalManager.make")(function* () { const { terminalLogsDir, providerStatusCacheDir, baseDir } = yield* ServerConfig.ServerConfig; @@ -1475,7 +1529,11 @@ export const make = Effect.fn("TerminalManager.make")(function* () { const portDiscovery = yield* PortScanner.PortDiscovery; const nativeTelemetry = yield* NativeTelemetryClient.NativeTelemetryClient; const serverSettings = yield* ServerSettings.ServerSettingsService; + const projectFiles = yield* T3ProjectFileLoader.T3ProjectFileLoader; + const threads = yield* ProjectionStore.ProjectionStoreV2; const path = yield* Path.Path; + const resolveConfiguredShell = (input: { readonly threadId: string; readonly cwd: string }) => + resolveConfiguredTerminalShell({ serverSettings, projectFiles, threads, ...input }); const resolveProviderInstanceEnvironment = Effect.fn( "terminal.resolveProviderInstanceEnvironment", )((rawProviderInstanceId: string, env: Record | undefined) => @@ -1499,6 +1557,7 @@ export const make = Effect.fn("TerminalManager.make")(function* () { registerTerminalProcesses: portDiscovery.registerTerminalProcesses, unregisterTerminal: portDiscovery.unregisterTerminal, resolveProviderInstanceEnvironment, + resolveConfiguredShell, }); }); @@ -1522,6 +1581,33 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func const baseEnv = options.env ?? process.env; const shellResolver = options.shellResolver ?? (() => defaultShellResolver(platform, baseEnv)); const processRunner = yield* ProcessRunner.ProcessRunner; + const detectedShellProfiles = yield* Effect.cached( + options.shellProfiles ?? + Effect.gen(function* () { + const etcShells = + platform === "win32" + ? null + : yield* fileSystem + .readFileString("/etc/shells") + .pipe(Effect.orElseSucceed(() => null)); + return detectShellProfiles({ + platform, + env: baseEnv, + // Microsoft Store installs (e.g. PowerShell 7) expose app execution aliases + // under WindowsApps; stat fails on them with EACCES, so existsSync says no. + fileExists: (candidate) => { + try { + return NodeFS.lstatSync(candidate, { throwIfNoEntry: false }) !== undefined; + } catch { + return false; + } + }, + etcShells, + }); + }), + ); + const listShells: TerminalManager["Service"]["listShells"] = () => + detectedShellProfiles.pipe(Effect.map((shells) => ({ shells }))); const resolveLaunchInputEnvironment = Effect.fn("terminal.resolveLaunchInputEnvironment")( function* ( input: Input, @@ -2225,7 +2311,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func index = 0, lastError: PtyAdapter.PtySpawnError | null = null, ): Effect.fn.Return< - { process: PtyAdapter.PtyProcess; shellLabel: string }, + { process: PtyAdapter.PtyProcess; candidate: ShellCandidate; index: number }, PtyAdapter.PtySpawnError > { if (index >= shellCandidates.length) { @@ -2266,10 +2352,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func ); if (attempt._tag === "Success") { - return { - process: attempt.success, - shellLabel: formatShellCandidate(candidate), - }; + return { process: attempt.success, candidate, index }; } const spawnError = attempt.failure; @@ -2318,7 +2401,22 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func increment(terminalSessionsTotal, { lifecycle: eventType }).pipe( Effect.andThen( Effect.gen(function* () { - const shellCandidates = resolveShellCandidates(shellResolver, platform, baseEnv); + const profiles = yield* detectedShellProfiles; + const setting = + session.shell ?? + (options.resolveConfiguredShell === undefined + ? "auto" + : yield* options.resolveConfiguredShell({ + threadId: session.threadId, + cwd: session.worktreePath ?? session.cwd, + })); + const preferred = preferredShellCandidate(setting, profiles, platform); + const shellCandidates = resolveShellCandidates( + preferred, + shellResolver, + platform, + baseEnv, + ); const terminalEnv = createTerminalSpawnEnv( baseEnv, session.runtimeEnv, @@ -2361,7 +2459,8 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func } const spawnResult = yield* trySpawn(shellCandidates, terminalEnv, session); ptyProcess = spawnResult.process; - startedShell = spawnResult.shellLabel; + startedShell = formatShellCandidate(spawnResult.candidate); + const shellLabel = shellCandidateLabel(spawnResult.candidate, profiles, platform); const processPid = ptyProcess.pid; let eventsActivated = false; @@ -2374,6 +2473,11 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func session.process = ptyProcess; session.pid = processPid; session.status = "running"; + session.shellLabel = shellLabel; + session.shellFallbackFrom = + preferred !== null && spawnResult.index > 0 + ? shellCandidateLabel(preferred, profiles, platform) + : null; // onExit may replay an exit immediately; accept it before subscribing. session.unsubscribeData = spawnResult.process.onData((data) => { if (!enqueueProcessEvent(session, processPid, { type: "output", data })) { @@ -2761,6 +2865,9 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func hasRunningSubprocess: false, childCommandLabel: null, runtimeEnv: normalizedRuntimeEnv(input.env), + shell: input.shell, + shellLabel: null, + shellFallbackFrom: null, }; const createdSession = session; @@ -2789,6 +2896,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func } const liveSession = existing.value; + if (input.shell !== undefined) liveSession.shell = input.shell; const nextRuntimeEnv = normalizedRuntimeEnv(input.env); const currentRuntimeEnv = liveSession.runtimeEnv; const targetCols = input.cols ?? liveSession.cols; @@ -3246,6 +3354,9 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func hasRunningSubprocess: false, childCommandLabel: null, runtimeEnv: normalizedRuntimeEnv(input.env), + shell: input.shell, + shellLabel: null, + shellFallbackFrom: null, }; const createdSession = session; yield* modifyManagerState((state) => { @@ -3257,6 +3368,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func yield* evictInactiveSessionsIfNeeded(); } else { session = existingSession.value; + if (input.shell !== undefined) session.shell = input.shell; yield* stopProcess(session); session.cwd = input.cwd; session.worktreePath = input.worktreePath ?? null; @@ -3376,7 +3488,11 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func closeIdle, subscribe, subscribeMetadata, + listShells, }); }); -export const layer = Layer.effect(TerminalManager, make()).pipe(Layer.provide(ProcessRunner.layer)); +export const layer = Layer.effect(TerminalManager, make()).pipe( + Layer.provide(ProcessRunner.layer), + Layer.provide(T3ProjectFileLoader.layer), +); diff --git a/apps/server/src/terminal/ShellProfiles.test.ts b/apps/server/src/terminal/ShellProfiles.test.ts new file mode 100644 index 0000000000000..a9d07198c1085 --- /dev/null +++ b/apps/server/src/terminal/ShellProfiles.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { detectShellProfiles } from "./ShellProfiles.ts"; + +const detectWindows = ( + existing: ReadonlyArray, + env: NodeJS.ProcessEnv = {}, +): ReadonlyArray => { + const files = new Set(existing.map((file) => file.toLowerCase())); + return detectShellProfiles({ + platform: "win32", + env: { SystemRoot: "C:\\Windows", ProgramFiles: "C:\\Program Files", ...env }, + fileExists: (file) => files.has(file.toLowerCase()), + etcShells: null, + }).map((profile) => profile.id); +}; + +describe("detectShellProfiles", () => { + it("detects Git Bash in Program Files", () => { + const profiles = detectShellProfiles({ + platform: "win32", + env: { ProgramFiles: "C:\\Program Files" }, + fileExists: (file) => file === "C:\\Program Files\\Git\\bin\\bash.exe", + etcShells: null, + }); + expect(profiles).toEqual([ + { + id: "bash", + label: "Git Bash", + command: "C:\\Program Files\\Git\\bin\\bash.exe", + args: ["--login", "-i"], + }, + ]); + }); + + it("derives Git Bash from git.exe on PATH", () => { + expect( + detectWindows(["D:\\Tools\\Git\\cmd\\git.exe", "D:\\Tools\\Git\\bin\\bash.exe"], { + PATH: "C:\\Other;D:\\Tools\\Git\\cmd", + }), + ).toEqual(["bash"]); + expect( + detectWindows(["D:\\Tools\\Git\\mingw64\\bin\\git.exe", "D:\\Tools\\Git\\bin\\bash.exe"], { + Path: "D:\\Tools\\Git\\mingw64\\bin", + }), + ).toEqual(["bash"]); + }); + + it("omits shells that are not installed and orders the rest", () => { + expect(detectWindows([])).toEqual([]); + expect( + detectWindows([ + "C:\\Windows\\System32\\cmd.exe", + "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe", + "C:\\Program Files\\PowerShell\\7\\pwsh.exe", + ]), + ).toEqual(["pwsh", "powershell", "cmd"]); + }); + + it("parses /etc/shells, skipping comments, missing and duplicate entries", () => { + const present = new Set(["/bin/zsh", "/usr/bin/zsh", "/bin/bash", "/usr/sbin/nologin"]); + const profiles = detectShellProfiles({ + platform: "linux", + env: { SHELL: "/bin/zsh" }, + fileExists: (file) => present.has(file), + etcShells: [ + "# valid login shells", + "/bin/zsh", + "/usr/bin/zsh", + "/bin/bash", + "/bin/fish", + "/usr/sbin/nologin", + "", + ].join("\n"), + }); + expect(profiles).toEqual([ + { id: "zsh", label: "zsh", command: "/bin/zsh", args: ["-o", "nopromptsp"] }, + { id: "bash", label: "bash", command: "/bin/bash", args: [] }, + ]); + }); + + it("falls back to $SHELL when /etc/shells is unavailable", () => { + expect( + detectShellProfiles({ + platform: "darwin", + env: { SHELL: "/bin/zsh" }, + fileExists: () => true, + etcShells: null, + }).map((profile) => profile.id), + ).toEqual(["zsh"]); + }); +}); diff --git a/apps/server/src/terminal/ShellProfiles.ts b/apps/server/src/terminal/ShellProfiles.ts new file mode 100644 index 0000000000000..40bcb92ca9735 --- /dev/null +++ b/apps/server/src/terminal/ShellProfiles.ts @@ -0,0 +1,188 @@ +import type { TerminalShellProfile } from "@t3tools/contracts"; + +export interface ShellCandidate { + shell: string; + args?: string[]; + /** Name shown to users; defaults to the command line when absent. */ + label?: string; +} + +export function basenameForPlatform(command: string, platform: NodeJS.Platform): string { + const normalized = + platform === "win32" ? command.replaceAll("/", "\\") : command.replaceAll("\\", "/"); + const parts = normalized + .split(platform === "win32" ? /\\+/ : /\/+/) + .filter((part) => part.length > 0); + return parts.at(-1) ?? normalized; +} + +function joinWindowsPath(...parts: ReadonlyArray): string { + return parts + .map((part, index) => { + if (index === 0) return part.replace(/[\\/]+$/g, ""); + return part.replace(/^[\\/]+|[\\/]+$/g, ""); + }) + .filter((part) => part.length > 0) + .join("\\"); +} + +export function shellCandidateFromCommand( + command: string | null, + platform: NodeJS.Platform, +): ShellCandidate | null { + if (!command || command.length === 0) return null; + const shellName = basenameForPlatform(command, platform).toLowerCase(); + if (platform === "win32" && (shellName === "pwsh.exe" || shellName === "powershell.exe")) { + return { shell: command, args: ["-NoLogo"] }; + } + if (platform !== "win32" && shellName === "zsh") { + return { shell: command, args: ["-o", "nopromptsp"] }; + } + return { shell: command }; +} + +function windowsSystemRoot(env: NodeJS.ProcessEnv): string { + return env.SystemRoot?.trim() || env.windir?.trim() || "C:\\Windows"; +} + +export function windowsPowerShellPath(env: NodeJS.ProcessEnv): string { + return joinWindowsPath( + windowsSystemRoot(env), + "System32", + "WindowsPowerShell", + "v1.0", + "powershell.exe", + ); +} + +export function windowsCmdPath(env: NodeJS.ProcessEnv): string { + return joinWindowsPath(windowsSystemRoot(env), "System32", "cmd.exe"); +} + +export interface ShellDetectionInput { + readonly platform: NodeJS.Platform; + readonly env: NodeJS.ProcessEnv; + readonly fileExists: (path: string) => boolean; + /** Contents of /etc/shells, or null when unreadable. Unused on Windows. */ + readonly etcShells: string | null; +} + +function envValue(env: NodeJS.ProcessEnv, name: string): string | undefined { + const key = Object.keys(env).find((candidate) => candidate.toLowerCase() === name.toLowerCase()); + const value = key === undefined ? undefined : env[key]?.trim(); + return value ? value : undefined; +} + +function windowsParentDirectory(directory: string): string | null { + const trimmed = directory.replace(/[\\/]+$/g, ""); + const index = Math.max(trimmed.lastIndexOf("\\"), trimmed.lastIndexOf("/")); + return index > 0 ? trimmed.slice(0, index) : null; +} + +function detectWindowsShells(input: ShellDetectionInput): TerminalShellProfile[] { + const { env, fileExists } = input; + const pathDirectories = (envValue(env, "PATH") ?? "") + .split(";") + .map((entry) => entry.trim().replace(/^"|"$/g, "")) + .filter((entry) => entry.length > 0); + const findOnPath = (executable: string) => + pathDirectories + .map((directory) => joinWindowsPath(directory, executable)) + .find((candidate) => fileExists(candidate)); + const firstExisting = (candidates: ReadonlyArray) => + candidates.find( + (candidate): candidate is string => candidate !== undefined && fileExists(candidate), + ); + const programFiles = [ + envValue(env, "ProgramFiles"), + envValue(env, "ProgramW6432"), + envValue(env, "ProgramFiles(x86)"), + ]; + const profiles: TerminalShellProfile[] = []; + + const pwsh = firstExisting([ + findOnPath("pwsh.exe"), + ...programFiles.map((root) => + root === undefined ? undefined : joinWindowsPath(root, "PowerShell", "7", "pwsh.exe"), + ), + ]); + if (pwsh !== undefined) { + profiles.push({ id: "pwsh", label: "PowerShell 7", command: pwsh, args: ["-NoLogo"] }); + } + + const powershell = windowsPowerShellPath(env); + if (fileExists(powershell)) { + profiles.push({ + id: "powershell", + label: "Windows PowerShell", + command: powershell, + args: ["-NoLogo"], + }); + } + + const cmd = firstExisting([envValue(env, "ComSpec"), windowsCmdPath(env)]); + if (cmd !== undefined) { + profiles.push({ id: "cmd", label: "Command Prompt", command: cmd, args: [] }); + } + + // git.exe sits in `\cmd` or `\mingw64\bin`; bash.exe is in `\bin`. + const gitExecutable = findOnPath("git.exe"); + const gitDirectory = gitExecutable ? windowsParentDirectory(gitExecutable) : null; + const gitRoot = gitDirectory ? windowsParentDirectory(gitDirectory) : null; + const gitRoots = [ + ...programFiles.map((root) => (root === undefined ? undefined : joinWindowsPath(root, "Git"))), + (() => { + const localAppData = envValue(env, "LocalAppData"); + return localAppData === undefined + ? undefined + : joinWindowsPath(localAppData, "Programs", "Git"); + })(), + gitRoot ?? undefined, + gitRoot ? (windowsParentDirectory(gitRoot) ?? undefined) : undefined, + ]; + const bash = firstExisting( + gitRoots.map((root) => + root === undefined ? undefined : joinWindowsPath(root, "bin", "bash.exe"), + ), + ); + if (bash !== undefined) { + profiles.push({ id: "bash", label: "Git Bash", command: bash, args: ["--login", "-i"] }); + } + + return profiles; +} + +const UNUSABLE_UNIX_SHELLS = new Set(["nologin", "false", "git-shell"]); + +function detectUnixShells(input: ShellDetectionInput): TerminalShellProfile[] { + const listed = (input.etcShells ?? "") + .split(/\r?\n/g) + .map((line) => line.trim()) + .filter((line) => line.startsWith("/")); + const fromEnv = envValue(input.env, "SHELL"); + const commands = listed.length > 0 ? listed : fromEnv ? [fromEnv] : []; + + const profiles: TerminalShellProfile[] = []; + const seen = new Set(); + for (const command of commands) { + const id = basenameForPlatform(command, input.platform); + if (id.length === 0 || seen.has(id) || UNUSABLE_UNIX_SHELLS.has(id)) continue; + if (!input.fileExists(command)) continue; + seen.add(id); + profiles.push({ + id, + label: id, + command, + args: shellCandidateFromCommand(command, input.platform)?.args ?? [], + }); + } + return profiles; +} + +export function detectShellProfiles(input: ShellDetectionInput): TerminalShellProfile[] { + return input.platform === "win32" ? detectWindowsShells(input) : detectUnixShells(input); +} + +export function candidateFromProfile(profile: TerminalShellProfile): ShellCandidate { + return { shell: profile.command, args: [...profile.args], label: profile.label }; +} diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 1ba4ff99ff644..f6536dab40259 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -2871,6 +2871,7 @@ export const layerWsRpc = ( [WS_METHODS.reviewGetDiffPreview]: (input) => review.getDiffPreview(input), [WS_METHODS.reviewGetDiffFileContents]: (input) => review.getDiffFileContents(input), [WS_METHODS.terminalOpen]: (input) => terminalManager.open(input), + [WS_METHODS.terminalListShells]: () => terminalManager.listShells(), [WS_METHODS.terminalAttach]: (input) => terminalOutputStream( (listener, registerCleanup) => diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 92707af8ca119..d17af53e73b1a 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1846,6 +1846,7 @@ export default function ChatView(props: ChatViewProps) { activatePanelTerminalStable, addTerminalContextToDraftStable, addTerminalSurfaceStable, + addTerminalSurfaceWithShellStable, closePanelTerminalStable, lastInvokedScriptByProjectId, runProjectScriptStable, @@ -3084,7 +3085,7 @@ export default function ChatView(props: ChatViewProps) { onAddTerminalContext: addTerminalContextToDraftStable, onSplitTerminal: splitPanelTerminalStable, onSplitTerminalVertical: splitPanelTerminalVerticalStable, - onNewTerminal: addTerminalSurfaceStable, + onNewTerminal: addTerminalSurfaceWithShellStable, onActiveTerminalChange: activatePanelTerminalStable, onCloseTerminal: closePanelTerminalStable, splitShortcutLabel: splitTerminalShortcutLabel ?? undefined, diff --git a/apps/web/src/components/PersistentThreadTerminals.tsx b/apps/web/src/components/PersistentThreadTerminals.tsx index 7cd80449ee901..9bc508da8b10a 100644 --- a/apps/web/src/components/PersistentThreadTerminals.tsx +++ b/apps/web/src/components/PersistentThreadTerminals.tsx @@ -3,6 +3,7 @@ import { type EnvironmentId, type ResolvedKeybindingsConfig, type ScopedThreadRef, + type TerminalShellSetting, type ThreadId, } from "@t3tools/contracts"; import { scopeProjectRef } from "@t3tools/client-runtime/environment"; @@ -208,10 +209,14 @@ export const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerm storeSetTerminalHeight(threadRef, height); }; - const launchDrawerTerminal = (place: (terminalId: string) => void) => { + const launchDrawerTerminal = ( + place: (terminalId: string) => void, + shell?: TerminalShellSetting, + ) => { if (!cwd) return; const launched = terminalActions.launchTerminal({ place, + ...(shell !== undefined ? { shell } : {}), location: { cwd, worktreePath: effectiveWorktreePath }, }); if (!launched) return; @@ -224,8 +229,8 @@ export const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerm const splitTerminalVertical = useStableCallback(() => launchDrawerTerminal((terminalId) => storeSplitTerminalVertical(threadRef, terminalId)), ); - const createNewTerminal = useStableCallback(() => - launchDrawerTerminal((terminalId) => storeNewTerminal(threadRef, terminalId)), + const createNewTerminal = useStableCallback((shell?: TerminalShellSetting) => + launchDrawerTerminal((terminalId) => storeNewTerminal(threadRef, terminalId), shell), ); const activateTerminal = (terminalId: string) => { @@ -307,7 +312,7 @@ export interface PersistentThreadTerminalPanelProps { onAddTerminalContext: (selection: TerminalContextSelection) => void; onSplitTerminal: () => void; onSplitTerminalVertical: () => void; - onNewTerminal: () => void; + onNewTerminal: (shell?: TerminalShellSetting) => void; onActiveTerminalChange: (terminalId: string) => void; onCloseTerminal: (terminalId: string) => void; splitShortcutLabel?: string | undefined; diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index 0a12a6535c2d5..4b9f8551f53c3 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -26,6 +26,7 @@ import { type ProviderInstanceId, type ResolvedKeybindingsConfig, type ScopedThreadRef, + type TerminalShellSetting, type ThreadId, } from "@t3tools/contracts"; import { getTerminalLabel } from "@t3tools/shared/terminalLabels"; @@ -42,6 +43,7 @@ import { } from "react"; import { Popover, PopoverPopup, PopoverTrigger } from "~/components/ui/popover"; import { Button } from "~/components/ui/button"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; import { PanelTabCloseButton } from "~/components/ui/panel-tab-close-button"; import { stackedThreadToast, toastManager } from "~/components/ui/toast"; import { readTextFromClipboard, writeTextToClipboard } from "~/hooks/useCopyToClipboard"; @@ -79,7 +81,8 @@ import { readLocalApi } from "~/localApi"; import { confirmTerminalClose } from "~/lib/terminalCloseConfirm"; import { useClientSettings } from "../hooks/useSettings"; import { useLocalStorage } from "../hooks/useLocalStorage"; -import { useAttachedTerminalSession } from "../state/terminalSessions"; +import { useAttachedTerminalSession, useKnownTerminalSessions } from "../state/terminalSessions"; +import { NewTerminalShellMenu } from "./terminal/NewTerminalShellMenu"; import { serverEnvironment } from "../state/server"; import { previewEnvironment } from "../state/preview"; import { readEnvironmentScope } from "../state/session"; @@ -1163,7 +1166,7 @@ interface ThreadTerminalDrawerProps { focusRequestId: number; onSplitTerminal: () => void; onSplitTerminalVertical: () => void; - onNewTerminal: () => void; + onNewTerminal: (shell?: TerminalShellSetting) => void; splitShortcutLabel?: string | undefined; splitVerticalShortcutLabel?: string | undefined; newShortcutLabel?: string | undefined; @@ -1321,6 +1324,7 @@ function TerminalSidebarGroup({ activeTerminalId, showHeader, terminalLabelById, + terminalShellById, closeShortcutLabel, canOperateTerminal, onActiveTerminalChange, @@ -1330,6 +1334,7 @@ function TerminalSidebarGroup({ activeTerminalId: string; showHeader: boolean; terminalLabelById: ReadonlyMap; + terminalShellById: ReadonlyMap; closeShortcutLabel: string | undefined; canOperateTerminal: boolean; onActiveTerminalChange: (terminalId: string) => void; @@ -1374,6 +1379,16 @@ function TerminalSidebarGroup({ {terminalGroup.terminalIds.map((terminalId) => { const isActive = terminalId === activeTerminalId; const terminalLabel = terminalLabelById.get(terminalId) ?? "Terminal"; + const terminalShell = terminalShellById.get(terminalId); + const tabButton = ( + + ); const closeTerminalLabel = `Close ${terminalLabel}${ isActive && closeShortcutLabel ? ` (${closeShortcutLabel})` : "" }`; @@ -1398,13 +1413,16 @@ function TerminalSidebarGroup({ ) : ( )} - + {terminalShell ? ( + + + {terminalLabel} + + {terminalShell} + + ) : ( + tabButton + )} ); })} @@ -1639,6 +1657,26 @@ export default function ThreadTerminalDrawer({ if (!canOperateTerminal) return; onNewTerminal(); }; + const onNewTerminalWithShell = (shell: TerminalShellSetting) => { + if (!canOperateTerminal) return; + onNewTerminal(shell); + }; + const knownSessions = useKnownTerminalSessions({ + environmentId: threadRef.environmentId, + threadId, + }); + const terminalShellById = new Map(); + for (const session of knownSessions ?? []) { + const summary = session.state.summary; + if (summary?.shell) { + terminalShellById.set( + session.target.terminalId, + summary.shellFallbackFrom + ? `${summary.shell} · couldn't start ${summary.shellFallbackFrom}` + : summary.shell, + ); + } + } const confirmCloseTerminal = (terminalId: string) => { if (!canOperateTerminal) return; const label = terminalLabelById.get(terminalId) ?? getTerminalLabel(terminalId); @@ -1867,6 +1905,11 @@ export default function ThreadTerminalDrawer({ > +
+ { + const addTerminalSurface = (shell?: TerminalShellSetting) => { if (!activeThreadRef) return; const launched = terminalActions.launchTerminal({ place: (terminalId) => useRightPanelStore.getState().openTerminal(activeThreadRef, terminalId), + ...(shell !== undefined ? { shell } : {}), }); if (!launched) return; setTerminalFocusRequestId((value) => value + 1); void launched.result; }; - const addTerminalSurfaceStable = useStableCallback(addTerminalSurface); + // Tab buttons pass click events to this, so only the shell-aware variant takes an argument. + const addTerminalSurfaceStable = useStableCallback(() => addTerminalSurface()); + const addTerminalSurfaceWithShellStable = useStableCallback(addTerminalSurface); const splitPanelTerminal = (direction: "horizontal" | "vertical" = "horizontal") => { if ( !activeThreadRef || @@ -435,6 +439,7 @@ export function useChatTerminalActions(input: ChatTerminalActionsInput) { activatePanelTerminalStable, addTerminalContextToDraftStable, addTerminalSurfaceStable, + addTerminalSurfaceWithShellStable, closePanelTerminalStable, createNewTerminalStable, lastInvokedScriptByProjectId, diff --git a/apps/web/src/components/settings/ProjectDefaultsSettings.tsx b/apps/web/src/components/settings/ProjectDefaultsSettings.tsx index 184b66256dce3..b61583657d50f 100644 --- a/apps/web/src/components/settings/ProjectDefaultsSettings.tsx +++ b/apps/web/src/components/settings/ProjectDefaultsSettings.tsx @@ -31,6 +31,7 @@ import { toastManager } from "../ui/toast"; import { Switch } from "../ui/switch"; import type { ProjectSettingsCategory } from "./ProjectSettingsPanel"; import { searchableSetting } from "./settingsSearch"; +import { TerminalShellSettingRow } from "./TerminalShellSettingRow"; import { useSettingsScope } from "./SettingsScopeContext"; import { SETTINGS_PICKER_TRIGGER_CLASSNAME, @@ -264,6 +265,7 @@ export function ProjectDefaultsSettings({ category }: { category: ProjectSetting <> {modelRow} {workspaceRow} + ) : category === "general" ? ( <> @@ -330,6 +332,7 @@ export function ProjectDefaultsSettings({ category }: { category: ProjectSetting } /> {workspaceRow} + shell.id === effective) + : undefined; + + // The trigger needs the list to show a profile's label; other values fetch when the select opens. + const needsList = !mixed && typeof effective === "string" && effective !== "auto"; + useEffect(() => { + if (needsList) load(); + }, [needsList, load]); + + const save = (value: TerminalShellSetting | null) => updateSettings({ terminalShell: value }); + + return ( + { + setCustomRequested(false); + save(null); + }} + /> + ) : null + } + control={ + + } + > + {showCustom ? ( +
+ { + const shell = buildCustomShell(next, formatShellArgs(custom?.args)); + if (shell) save(shell); + }} + /> + { + if (!custom) return; + const shell = buildCustomShell(custom.command, next); + if (shell) save(shell); + }} + /> +
+ ) : selectedProfile ? ( +
+ + +
+ ) : null} +
+ ); +} diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index c7c5f26ae90d8..47e2f983a210f 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -478,6 +478,15 @@ export const SETTINGS_SEARCH_ITEMS = [ scope: "project-defaults", searchTerms: ["git submodule init recursive top-level none worktree t3.json"], }, + { + id: "terminal-shell", + title: "Default shell", + to: "/settings/general", + scope: "project-defaults", + searchTerms: [ + "terminal shell powershell cmd command prompt git bash zsh fish custom executable t3.json", + ], + }, { id: "start-from-origin", title: "Start from remote", diff --git a/apps/web/src/components/terminal/NewTerminalShellMenu.tsx b/apps/web/src/components/terminal/NewTerminalShellMenu.tsx new file mode 100644 index 0000000000000..8f3cb59b49f02 --- /dev/null +++ b/apps/web/src/components/terminal/NewTerminalShellMenu.tsx @@ -0,0 +1,77 @@ +import type { ScopedThreadRef, TerminalShellSetting } from "@t3tools/contracts"; +import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; +import { ChevronDownIcon } from "lucide-react"; + +import { useComposerDraftStore } from "../../composerDraftStore"; +import { useEnvironmentSettings } from "../../hooks/useSettings"; +import { useQuietThreadShell } from "../../state/entities"; +import { Button } from "../ui/button"; +import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../ui/menu"; +import { MiddleTruncate } from "../ui/middle-truncate"; +import { isCustomShell } from "./terminalShell.logic"; +import { useTerminalShells } from "./useTerminalShells"; + +/** + * Chevron beside "New terminal": lists the shells detected on the thread's + * environment. The list is fetched when the menu first opens. + */ +export function NewTerminalShellMenu({ + threadRef, + disabled, + onSelect, +}: { + threadRef: ScopedThreadRef; + disabled: boolean; + onSelect: (shell: TerminalShellSetting) => void; +}) { + const { shells, load } = useTerminalShells(threadRef.environmentId); + const settings = useEnvironmentSettings(threadRef.environmentId); + const serverThread = useQuietThreadShell(threadRef); + const draftThread = useComposerDraftStore((store) => store.getDraftThreadByRef(threadRef)); + const projectId = serverThread?.projectId ?? draftThread?.projectId ?? null; + const configured = resolveProjectSettings(settings, projectId, null).settings.terminalShell; + const customShell = isCustomShell(configured) ? configured : null; + + return ( + open && load()}> + + } + > + + + + {(shells ?? []).map((shell) => ( + onSelect(shell.id)}> + + + ))} + {customShell ? ( + onSelect(customShell)}> + + + ) : null} + {shells === null ? ( + Loading… + ) : null} + + + ); +} + +export function ShellOption({ label, command }: { label: string; command: string }) { + return ( + + {label} + + + + + ); +} diff --git a/apps/web/src/components/terminal/terminalShell.logic.test.ts b/apps/web/src/components/terminal/terminalShell.logic.test.ts new file mode 100644 index 0000000000000..ebbed2576c6d6 --- /dev/null +++ b/apps/web/src/components/terminal/terminalShell.logic.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + buildCustomShell, + CUSTOM_SHELL_SELECT_VALUE, + parseShellArgs, + shellSettingLabel, + shellSettingToSelectValue, +} from "./terminalShell.logic"; + +describe("terminal shell settings", () => { + it("splits custom args on any whitespace", () => { + expect(parseShellArgs(" -NoLogo \t -l\n")).toEqual(["-NoLogo", "-l"]); + expect(parseShellArgs(" ")).toEqual([]); + }); + + it("builds a custom shell and omits empty args", () => { + expect(buildCustomShell(" /usr/bin/fish ", "")).toEqual({ command: "/usr/bin/fish" }); + expect(buildCustomShell("/bin/zsh", "-l -i")).toEqual({ + command: "/bin/zsh", + args: ["-l", "-i"], + }); + expect(buildCustomShell(" ", "-l")).toBeNull(); + }); + + it("maps settings to select values", () => { + expect(shellSettingToSelectValue(null)).toBe("auto"); + expect(shellSettingToSelectValue("auto")).toBe("auto"); + expect(shellSettingToSelectValue("pwsh")).toBe("pwsh"); + expect(shellSettingToSelectValue({ command: "/bin/fish" })).toBe(CUSTOM_SHELL_SELECT_VALUE); + }); + + it("labels a profile id by its detected label and falls back to the id", () => { + const shells = [{ id: "pwsh", label: "PowerShell 7", command: "pwsh.exe", args: [] }]; + expect(shellSettingLabel("pwsh", shells)).toBe("PowerShell 7"); + expect(shellSettingLabel("gone", shells)).toBe("gone"); + expect(shellSettingLabel(null, shells)).toBe("Automatic"); + expect(shellSettingLabel({ command: "/bin/fish" }, shells)).toBe("/bin/fish"); + }); +}); diff --git a/apps/web/src/components/terminal/terminalShell.logic.ts b/apps/web/src/components/terminal/terminalShell.logic.ts new file mode 100644 index 0000000000000..6b90efd2aa6b8 --- /dev/null +++ b/apps/web/src/components/terminal/terminalShell.logic.ts @@ -0,0 +1,39 @@ +import type { TerminalShellProfile, TerminalShellSetting } from "@t3tools/contracts"; + +export const CUSTOM_SHELL_SELECT_VALUE = "__custom__"; + +type CustomShell = Extract; + +export function isCustomShell(setting: TerminalShellSetting | null): setting is CustomShell { + return typeof setting === "object" && setting !== null; +} + +export function parseShellArgs(text: string): string[] { + return text.split(/\s+/).filter((arg) => arg.length > 0); +} + +export function formatShellArgs(args: readonly string[] | undefined): string { + return (args ?? []).join(" "); +} + +export function buildCustomShell(commandText: string, argsText: string): CustomShell | null { + const command = commandText.trim(); + if (command.length === 0) return null; + const args = parseShellArgs(argsText); + return args.length > 0 ? { command, args } : { command }; +} + +/** `null` and "auto" both mean the built-in fallback chain. */ +export function shellSettingToSelectValue(setting: TerminalShellSetting | null): string { + if (setting === null || setting === "auto") return "auto"; + return isCustomShell(setting) ? CUSTOM_SHELL_SELECT_VALUE : setting; +} + +export function shellSettingLabel( + setting: TerminalShellSetting | null, + shells: readonly TerminalShellProfile[], +): string { + if (setting === null || setting === "auto") return "Automatic"; + if (isCustomShell(setting)) return setting.command; + return shells.find((shell) => shell.id === setting)?.label ?? setting; +} diff --git a/apps/web/src/components/terminal/useTerminalShells.ts b/apps/web/src/components/terminal/useTerminalShells.ts new file mode 100644 index 0000000000000..3cf638b2910f5 --- /dev/null +++ b/apps/web/src/components/terminal/useTerminalShells.ts @@ -0,0 +1,42 @@ +import type { EnvironmentId, TerminalShellProfile } from "@t3tools/contracts"; +import { useState } from "react"; + +import { terminalEnvironment } from "../../state/terminal"; +import { useAtomCommand } from "../../state/use-atom-command"; + +// Detected shells only change when the user installs one, so one fetch per +// environment per session is enough. Keyed by environment because the list is +// detected on that server. +const shellsByEnvironment = new Map(); +const inflightByEnvironment = new Map>(); + +/** Lists the shells detected on `environmentId`; nothing is fetched until `load` runs. */ +export function useTerminalShells(environmentId: EnvironmentId | null) { + const listShells = useAtomCommand(terminalEnvironment.listShells, { reportFailure: false }); + const [loaded, setLoaded] = useState<{ + readonly environmentId: string; + readonly shells: readonly TerminalShellProfile[]; + } | null>(null); + + const cached = environmentId === null ? undefined : shellsByEnvironment.get(environmentId); + const shells = cached ?? (loaded?.environmentId === environmentId ? loaded.shells : null); + + const load = () => { + if (environmentId === null || shellsByEnvironment.has(environmentId)) return; + let pending = inflightByEnvironment.get(environmentId); + if (!pending) { + pending = listShells({ environmentId, input: {} }).then((result) => { + inflightByEnvironment.delete(environmentId); + if (result._tag !== "Success") return null; + shellsByEnvironment.set(environmentId, result.value.shells); + return result.value.shells; + }); + inflightByEnvironment.set(environmentId, pending); + } + void pending.then((next) => { + if (next) setLoaded({ environmentId, shells: next }); + }); + }; + + return { shells, load }; +} diff --git a/apps/web/src/components/terminal/useThreadTerminalActions.ts b/apps/web/src/components/terminal/useThreadTerminalActions.ts index 10d3a8e9e9b4e..850340bb83cf9 100644 --- a/apps/web/src/components/terminal/useThreadTerminalActions.ts +++ b/apps/web/src/components/terminal/useThreadTerminalActions.ts @@ -2,6 +2,7 @@ import { AuthTerminalOperateScope, AuthTerminalReadScope, type ScopedThreadRef, + type TerminalShellSetting, } from "@t3tools/contracts"; import { scopeProjectRef } from "@t3tools/client-runtime/environment"; import { isAtomCommandInterrupted } from "@t3tools/client-runtime/state/runtime"; @@ -100,6 +101,8 @@ export function useThreadTerminalActions(threadRef: ScopedThreadRef | null) { readonly location?: TerminalLaunchLocation | null; readonly extraEnv?: Record; readonly size?: { readonly cols: number; readonly rows: number }; + /** Omit to use the configured default; "auto" would skip project and t3.json settings. */ + readonly shell?: TerminalShellSetting; }) => { if (!threadRef || !canWrite() || !project) return null; const location = input.location ?? defaultLaunchLocation(); @@ -119,6 +122,7 @@ export function useThreadTerminalActions(threadRef: ScopedThreadRef | null) { ...(input.extraEnv ? { extraEnv: input.extraEnv } : {}), }), ...input.size, + ...(input.shell !== undefined ? { shell: input.shell } : {}), }, }); return { terminalId, result }; diff --git a/apps/web/src/components/ui/middle-truncate.test.ts b/apps/web/src/components/ui/middle-truncate.test.ts new file mode 100644 index 0000000000000..1b615b6559b4d --- /dev/null +++ b/apps/web/src/components/ui/middle-truncate.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { splitForMiddleTruncate } from "./middle-truncate"; + +describe("splitForMiddleTruncate", () => { + it("keeps the last posix path segment with its separator", () => { + expect(splitForMiddleTruncate("/usr/local/bin/zsh")).toEqual({ + head: "/usr/local/bin", + tail: "/zsh", + }); + }); + + it("keeps the last windows path segment with its separator", () => { + expect( + splitForMiddleTruncate( + "C:\\Users\\Arafa\\AppData\\Local\\Microsoft\\WindowsApps\\Microsoft.PowerShell_8wekyb3d8bbwe\\pwsh.exe", + ), + ).toEqual({ + head: "C:\\Users\\Arafa\\AppData\\Local\\Microsoft\\WindowsApps\\Microsoft.PowerShell_8wekyb3d8bbwe", + tail: "\\pwsh.exe", + }); + }); +}); diff --git a/apps/web/src/components/ui/middle-truncate.tsx b/apps/web/src/components/ui/middle-truncate.tsx index 3c7b9c85a63f7..f0e44403504f8 100644 --- a/apps/web/src/components/ui/middle-truncate.tsx +++ b/apps/web/src/components/ui/middle-truncate.tsx @@ -51,10 +51,11 @@ export function splitForMiddleTruncate( const chars = Array.from(value); let keep = tail ?? DEFAULT_TAIL; if (tail === undefined) { - const slash = chars.lastIndexOf("/"); + const slash = Math.max(chars.lastIndexOf("/"), chars.lastIndexOf("\\")); if (slash > 0 && slash < chars.length - 1) { const segment = chars.length - slash - 1; - keep = segment <= MAX_SEGMENT_TAIL ? segment : DEFAULT_TAIL; + // The separator rides with the tail; at the end of the head the ellipsis would hide it. + keep = segment <= MAX_SEGMENT_TAIL ? segment + 1 : DEFAULT_TAIL; } } if (keep <= 0 || chars.length <= keep + 4) return null; diff --git a/docs/user/terminal.md b/docs/user/terminal.md index 347b051f0bc89..922f9f4965661 100644 --- a/docs/user/terminal.md +++ b/docs/user/terminal.md @@ -1,3 +1,26 @@ +# Terminal shell + +Terminals open with your system's default shell. To choose another, set **Default +shell** in **Settings → General**. T3 Code lists the shells it finds on that +machine: PowerShell 7, Windows PowerShell, Command Prompt, and Git Bash on +Windows, and the shells in `/etc/shells` elsewhere. Choose **Custom…** to enter +the path of any executable. The same row in a project's settings overrides the +choice for that project. + +To use a different shell for one terminal, open the menu next to **New terminal** +in the terminal drawer and pick a shell. On mobile, use **New terminal with…** in +the terminal menu. + +A repository can suggest a shell with `terminalShell` in `t3.json`, using a shell +name such as `"pwsh"`, `"powershell"`, `"cmd"`, or `"bash"`. `"bash"` means Git +Bash on Windows. Custom paths aren't allowed there. + +A setting in T3 Code always wins over `t3.json`: a project override, then the +environment setting, then `t3.json`, then automatic detection. If the chosen +shell can't start, the terminal falls back to the default, and the terminal tab's +tooltip shows which shell is running and which one couldn't start. WSL shells +aren't supported. + # Terminal history Each terminal keeps up to 5,000 lines and 8 MiB of scrollback on its environment diff --git a/packages/client-runtime/src/state/terminal.ts b/packages/client-runtime/src/state/terminal.ts index 8c0a1113e1517..fcb4494ab502f 100644 --- a/packages/client-runtime/src/state/terminal.ts +++ b/packages/client-runtime/src/state/terminal.ts @@ -111,6 +111,10 @@ export function createTerminalEnvironmentAtoms( scheduler: lifecycleScheduler, concurrency: lifecycleConcurrency, }), + listShells: createEnvironmentRpcCommand(runtime, { + label: "environment-data:terminal:list-shells", + tag: WS_METHODS.terminalListShells, + }), }; } diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index d7a11234c4467..784077c4c575e 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -247,6 +247,7 @@ import { TerminalClearInput, TerminalCloseInput, TerminalError, + TerminalListShellsResult, TerminalEvent, TerminalMetadataStreamEvent, TerminalOpenInput, @@ -448,6 +449,7 @@ export const WS_METHODS = { terminalClear: "terminal.clear", terminalRestart: "terminal.restart", terminalClose: "terminal.close", + terminalListShells: "terminal.listShells", previewOpen: "preview.open", previewNavigate: "preview.navigate", @@ -1485,6 +1487,12 @@ const WsTerminalRestartRpc = Rpc.make(WS_METHODS.terminalRestart, { error: Schema.Union([TerminalError, EnvironmentAuthorizationError]), }); +const WsTerminalListShellsRpc = Rpc.make(WS_METHODS.terminalListShells, { + payload: Schema.Struct({}), + success: TerminalListShellsResult, + error: EnvironmentAuthorizationError, +}); + const WsTerminalCloseRpc = Rpc.make(WS_METHODS.terminalClose, { payload: TerminalCloseInput, error: Schema.Union([TerminalError, EnvironmentAuthorizationError]), @@ -2033,6 +2041,7 @@ export const WsRpcGroup = RpcGroup.make( WsTerminalClearRpc, WsTerminalRestartRpc, WsTerminalCloseRpc, + WsTerminalListShellsRpc, WsSubscribeTerminalEventsRpc, WsSubscribeTerminalMetadataRpc, WsPreviewOpenRpc, diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index 25523c2d0fb4a..382bb3e7594c2 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -256,6 +256,26 @@ describe("ServerSettings worktree defaults", () => { }); }); +describe("ServerSettings.terminalShell", () => { + it("inherits by default and decodes profile ids, auto, and custom executables", () => { + expect(decodeServerSettings({}).terminalShell).toBeNull(); + expect(decodeServerSettings({ terminalShell: "auto" }).terminalShell).toBe("auto"); + expect(decodeServerSettings({ terminalShell: "pwsh" }).terminalShell).toBe("pwsh"); + expect( + decodeServerSettings({ terminalShell: { command: "/opt/tools/sh", args: ["-l"] } }) + .terminalShell, + ).toEqual({ command: "/opt/tools/sh", args: ["-l"] }); + expect(decodeServerSettings({ terminalShell: "" }).terminalShell).toBeNull(); + expect(decodeServerSettingsPatch({ terminalShell: null }).terminalShell).toBeNull(); + }); + + it("keeps an inherited shell off the wire for older clients", () => { + const encode = Schema.encodeSync(ServerSettings); + expect("terminalShell" in encode(decodeServerSettings({}))).toBe(false); + expect(encode(decodeServerSettings({ terminalShell: "cmd" })).terminalShell).toBe("cmd"); + }); +}); + describe("ServerSettings.sourceControlWritingStyle", () => { it.each([ { legacy: "", mode: "repo_conventions", customInstructions: "" }, diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index fbe4d945cff25..8022a9945e304 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -20,6 +20,7 @@ import { TrimmedString, } from "./baseSchemas.ts"; import { UsageLimitSourceId } from "./usageLimitSourceId.ts"; +import { TerminalShellSetting } from "./terminal.ts"; import { EnvironmentMachineKind, ThreadEnvMode, WorktreeSubmodules } from "./environment.ts"; import { KeybindingShortcut } from "./keybindings.ts"; import { @@ -972,6 +973,7 @@ export const PROJECT_SCOPED_SERVER_SETTING_KEYS = [ "defaultModelSelection", "defaultRuntimeMode", "defaultThreadEnvMode", + "terminalShell", "newWorktreesStartFromOrigin", "worktreeSubmodules", "defaultAutoPull", @@ -1015,6 +1017,7 @@ export const ProjectSettingsOverrides = Schema.Struct({ defaultModelSelection: Schema.optionalKey(Schema.NullOr(ModelSelection)), defaultRuntimeMode: Schema.optionalKey(RuntimeMode), defaultThreadEnvMode: Schema.optionalKey(ThreadEnvMode), + terminalShell: Schema.optionalKey(TerminalShellSetting), newWorktreesStartFromOrigin: Schema.optionalKey(Schema.Boolean), worktreeSubmodules: ForwardCompatibleOptional(WorktreeSubmodules), defaultAutoPull: Schema.optionalKey(Schema.Boolean), @@ -1239,6 +1242,11 @@ export const ServerSettings = Schema.Struct({ * so older clients, which require a literal here, keep decoding. */ defaultThreadEnvMode: OmittedWhenNull(ThreadEnvMode), + /** + * Null means inherit: the repository's t3.json, then "auto" (the platform default shell). + * Null stays off the wire like defaultThreadEnvMode. + */ + terminalShell: OmittedWhenNull(TerminalShellSetting), newWorktreesStartFromOrigin: Schema.Boolean.pipe( Schema.withDecodingDefault(Effect.succeed(true)), ), @@ -1583,6 +1591,7 @@ export const ServerSettingsPatch = Schema.Struct({ backgroundActivityProfile: Schema.optionalKey(BackgroundActivityProfile), environmentIcon: Schema.optionalKey(Schema.NullOr(EnvironmentMachineKind)), defaultThreadEnvMode: Schema.optionalKey(Schema.NullOr(ThreadEnvMode)), + terminalShell: Schema.optionalKey(Schema.NullOr(TerminalShellSetting)), newWorktreesStartFromOrigin: Schema.optionalKey(Schema.Boolean), worktreeSubmodules: Schema.optionalKey(Schema.NullOr(WorktreeSubmodules)), addProjectBaseDirectory: Schema.optionalKey(TrimmedString), diff --git a/packages/contracts/src/t3ProjectFile.ts b/packages/contracts/src/t3ProjectFile.ts index c60ddcf8e09cc..5d66a3ef7c180 100644 --- a/packages/contracts/src/t3ProjectFile.ts +++ b/packages/contracts/src/t3ProjectFile.ts @@ -91,6 +91,12 @@ export const T3ProjectFile = Schema.Struct({ 'Where new threads start for this repository: "worktree" for a fresh git worktree, "local" for the current checkout. A per-project setting in T3 Code overrides this; when neither is set, the global default applies.', }), ), + terminalShell: Schema.optionalKey( + trimmedNonEmpty({ + description: + 'Shell new terminals open with for this repository, by profile id such as "pwsh", "powershell", "cmd", "zsh", or "bash" (Git Bash on Windows, so one value works across platforms). A per-project setting in T3 Code overrides this; an id that is not installed falls back to the default shell. Custom executables cannot be set here.', + }), + ), worktreeSubmodules: Schema.optionalKey( WorktreeSubmodules.annotate({ description: @@ -119,6 +125,7 @@ export type T3ProjectFile = typeof T3ProjectFile.Type; */ export const PROJECT_FILE_BACKED_SETTINGS = { defaultThreadEnvMode: { field: "defaultThreadEnvMode", builtIn: "local" }, + terminalShell: { field: "terminalShell", builtIn: "auto" }, worktreeSubmodules: { field: "worktreeSubmodules", builtIn: "recursive" }, } as const satisfies { // `providerInstances` is scoped per project but overrides field by field diff --git a/packages/contracts/src/terminal.ts b/packages/contracts/src/terminal.ts index c00b9300b355f..e21a03b529231 100644 --- a/packages/contracts/src/terminal.ts +++ b/packages/contracts/src/terminal.ts @@ -13,6 +13,7 @@ const TerminalRowsSchema = Schema.Int.check(Schema.isGreaterThanOrEqualTo(1)).ch Schema.isLessThanOrEqualTo(500), ); const TerminalIdSchema = TrimmedNonEmptyStringSchema.check(Schema.isMaxLength(128)); +const TerminalShellProfileIdSchema = TrimmedNonEmptyStringSchema.check(Schema.isMaxLength(128)); const TerminalEnvKeySchema = Schema.String.check( Schema.isPattern(/^[A-Za-z_][A-Za-z0-9_]*$/), ).check(Schema.isMaxLength(128)); @@ -21,6 +22,33 @@ const TerminalEnvSchema = Schema.Record(TerminalEnvKeySchema, TerminalEnvValueSc Schema.isMaxProperties(128), ); +export const TerminalShellProfile = Schema.Struct({ + id: TerminalShellProfileIdSchema, + label: Schema.String.check(Schema.isNonEmpty()).check(Schema.isMaxLength(128)), + command: TrimmedNonEmptyStringSchema, + args: Schema.Array(Schema.String), +}); +export type TerminalShellProfile = typeof TerminalShellProfile.Type; + +export const TerminalListShellsResult = Schema.Struct({ + shells: Schema.Array(TerminalShellProfile), +}); +export type TerminalListShellsResult = typeof TerminalListShellsResult.Type; + +/** + * "auto" is reserved and listed first so it never decodes as a profile id. + * A bare string names a detected profile; the object form is an explicit executable. + */ +export const TerminalShellSetting = Schema.Union([ + Schema.Literal("auto"), + TerminalShellProfileIdSchema, + Schema.Struct({ + command: TrimmedNonEmptyStringSchema, + args: Schema.optional(Schema.Array(Schema.String)), + }), +]); +export type TerminalShellSetting = typeof TerminalShellSetting.Type; + export const TerminalThreadInput = Schema.Struct({ threadId: TrimmedNonEmptyStringSchema, }); @@ -41,6 +69,7 @@ export const TerminalOpenInput = Schema.Struct({ rows: Schema.optional(TerminalRowsSchema), env: Schema.optional(TerminalEnvSchema), providerInstanceId: Schema.optional(ProviderInstanceId), + shell: Schema.optional(TerminalShellSetting), }); export type TerminalOpenInput = typeof TerminalOpenInput.Type; @@ -52,6 +81,7 @@ export const TerminalAttachInput = Schema.Struct({ rows: Schema.optional(TerminalRowsSchema), env: Schema.optional(TerminalEnvSchema), providerInstanceId: Schema.optional(ProviderInstanceId), + shell: Schema.optional(TerminalShellSetting), restartIfNotRunning: Schema.optional(Schema.Boolean), // Resubscriptions preserve stopped terminals; unknown IDs recover after a server restart. resume: Schema.optional(Schema.Boolean), @@ -85,6 +115,7 @@ export const TerminalRestartInput = Schema.Struct({ rows: TerminalRowsSchema, env: Schema.optional(TerminalEnvSchema), providerInstanceId: Schema.optional(ProviderInstanceId), + shell: Schema.optional(TerminalShellSetting), }); export type TerminalRestartInput = typeof TerminalRestartInput.Type; @@ -125,6 +156,11 @@ export const TerminalSummary = Schema.Struct({ exitSignal: Schema.NullOr(Schema.Int), hasRunningSubprocess: Schema.Boolean, label: Schema.String.check(Schema.isMaxLength(128)), + // Label of the shell actually running; absent from servers that predate shell choice. + shell: Schema.optional(Schema.String), + // Label of the requested shell that couldn't start. ConPTY clears the screen when the fallback + // starts, so a notice in the output stream would vanish; it is surfaced in metadata instead. + shellFallbackFrom: Schema.optional(Schema.String), updatedAt: Schema.String, }); export type TerminalSummary = typeof TerminalSummary.Type; diff --git a/packages/shared/src/t3ProjectFile.test.ts b/packages/shared/src/t3ProjectFile.test.ts index d2f3a70e94ba0..dc9e3ffa3d3d8 100644 --- a/packages/shared/src/t3ProjectFile.test.ts +++ b/packages/shared/src/t3ProjectFile.test.ts @@ -29,4 +29,9 @@ describe("parseT3ProjectFile", () => { }`), ).toEqual({ defaultThreadEnvMode: "worktree" }); }); + + it("accepts a terminal shell profile id but not a custom executable", () => { + expect(parseT3ProjectFile('{ "terminalShell": " bash " }')).toEqual({ terminalShell: "bash" }); + expect(parseT3ProjectFile('{ "terminalShell": { "command": "C:\\x.exe" } }')).toBeNull(); + }); }); From c0c680bc82b63735e5cbfdbc0b8416d2d11f0590 Mon Sep 17 00:00:00 2001 From: sharull Date: Fri, 23 Oct 2026 23:45:10 +0530 Subject: [PATCH 2/5] refactor(terminal): limit shell choice to installed shells Drop the custom command setting so users pick from the shells the host reports. A saved id the host lacks shows as not installed and opens the automatic shell. Report a failed shell in TerminalSummary.shellFallbackFrom and the tab tooltip, since ConPTY clears any notice written to the stream. Detect Store-installed PowerShell 7 via lstat and middle-truncate long shell paths in the drawer menu and settings. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/terminal/Manager.test.ts | 14 ++--- apps/server/src/terminal/Manager.ts | 16 ++--- .../settings/SettingInheritance.tsx | 1 - .../settings/TerminalShellSettingRow.tsx | 62 ++----------------- .../terminal/NewTerminalShellMenu.tsx | 16 ----- .../terminal/terminalShell.logic.test.ts | 24 +------ .../terminal/terminalShell.logic.ts | 26 +------- docs/user/terminal.md | 7 +-- packages/contracts/src/settings.test.ts | 4 +- packages/contracts/src/t3ProjectFile.ts | 2 +- packages/contracts/src/terminal.ts | 14 +---- 11 files changed, 25 insertions(+), 161 deletions(-) diff --git a/apps/server/src/terminal/Manager.test.ts b/apps/server/src/terminal/Manager.test.ts index 4f22597014c74..b3547f3226030 100644 --- a/apps/server/src/terminal/Manager.test.ts +++ b/apps/server/src/terminal/Manager.test.ts @@ -976,13 +976,13 @@ it.layer( }), ); - it.effect("reports the fallback in metadata when a custom shell cannot start", () => + it.effect("reports the fallback in metadata when a detected shell cannot start", () => Effect.gen(function* () { const { manager, ptyAdapter } = yield* createManager(5, { shellProfiles, shellResolver: () => "/bin/sh", }).pipe(Effect.provide(layerWithHostPlatform("linux"))); - ptyAdapter.spawnFailures.push(new Error("spawn /opt/missing ENOENT")); + ptyAdapter.spawnFailures.push(new Error("spawn /bin/fish ENOENT")); const summaries = Effect.gen(function* () { let terminals: ReadonlyArray = []; const unsubscribe = yield* manager.subscribeMetadata((event) => @@ -994,19 +994,17 @@ it.layer( return terminals; }); - const snapshot = yield* manager.open( - openInput({ shell: { command: "/opt/missing", args: ["-l"] } }), - ); + const snapshot = yield* manager.open(openInput({ shell: "fish" })); - expect(ptyAdapter.spawnInputs[0]).toMatchObject({ shell: "/opt/missing", args: ["-l"] }); + expect(ptyAdapter.spawnInputs[0]?.shell).toBe("/bin/fish"); expect(snapshot.status).toBe("running"); expect(snapshot.history).toBe(""); const [fellBack] = yield* summaries; - expect(fellBack).toMatchObject({ shell: "sh", shellFallbackFrom: "missing" }); + expect(fellBack).toMatchObject({ shell: "sh", shellFallbackFrom: "fish" }); yield* manager.restart(restartInput()); const [recovered] = yield* summaries; - expect(recovered?.shell).toBe("missing"); + expect(recovered?.shell).toBe("fish"); expect(recovered?.shellFallbackFrom).toBeUndefined(); }), ); diff --git a/apps/server/src/terminal/Manager.ts b/apps/server/src/terminal/Manager.ts index b325d5e84f769..0d354a022bc1d 100644 --- a/apps/server/src/terminal/Manager.ts +++ b/apps/server/src/terminal/Manager.ts @@ -585,19 +585,11 @@ function resolveShellCandidates( function preferredShellCandidate( setting: TerminalShellSetting, profiles: ReadonlyArray, - platform: NodeJS.Platform, ): ShellCandidate | null { if (setting === "auto") return null; - if (typeof setting === "string") { - // An id this machine does not have (say a t3.json written on another OS) means auto. - const profile = profiles.find((candidate) => candidate.id === setting); - return profile === undefined ? null : candidateFromProfile(profile); - } - return { - shell: setting.command, - args: [...(setting.args ?? [])], - label: basenameForPlatform(setting.command, platform), - }; + // An id this machine does not have (say a t3.json written on another OS) means auto. + const profile = profiles.find((candidate) => candidate.id === setting); + return profile === undefined ? null : candidateFromProfile(profile); } const WINDOWS_SHELL_LABELS: Readonly> = { @@ -2410,7 +2402,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func threadId: session.threadId, cwd: session.worktreePath ?? session.cwd, })); - const preferred = preferredShellCandidate(setting, profiles, platform); + const preferred = preferredShellCandidate(setting, profiles); const shellCandidates = resolveShellCandidates( preferred, shellResolver, diff --git a/apps/web/src/components/settings/SettingInheritance.tsx b/apps/web/src/components/settings/SettingInheritance.tsx index f04110c72aee9..d14f9b797fab4 100644 --- a/apps/web/src/components/settings/SettingInheritance.tsx +++ b/apps/web/src/components/settings/SettingInheritance.tsx @@ -79,7 +79,6 @@ function formatValue(key: keyof ServerSettings, value: unknown): string { if (Array.isArray(value)) return `${value.length} ${value.length === 1 ? "item" : "items"}`; if (typeof value === "object") { if ("model" in value && typeof value.model === "string") return value.model; - if ("command" in value && typeof value.command === "string") return value.command; if ("mode" in value && typeof value.mode === "string") { return WRITING_STYLE_LABELS[value.mode] ?? value.mode; } diff --git a/apps/web/src/components/settings/TerminalShellSettingRow.tsx b/apps/web/src/components/settings/TerminalShellSettingRow.tsx index 72c7fb162b7f6..6124222b8648c 100644 --- a/apps/web/src/components/settings/TerminalShellSettingRow.tsx +++ b/apps/web/src/components/settings/TerminalShellSettingRow.tsx @@ -1,19 +1,11 @@ import type { TerminalShellSetting } from "@t3tools/contracts"; import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; -import { useEffect, useState } from "react"; +import { useEffect } from "react"; -import { - buildCustomShell, - CUSTOM_SHELL_SELECT_VALUE, - formatShellArgs, - isCustomShell, - shellSettingLabel, - shellSettingToSelectValue, -} from "../terminal/terminalShell.logic"; +import { shellSettingLabel, shellSettingToSelectValue } from "../terminal/terminalShell.logic"; import { ShellOption } from "../terminal/NewTerminalShellMenu"; import { useTerminalShells } from "../terminal/useTerminalShells"; import { DiffFilePathCopyButton } from "../DiffFilePathCopyButton"; -import { DraftInput } from "../ui/draft-input"; import { MiddleTruncate } from "../ui/middle-truncate"; import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; import { searchableSetting } from "./settingsSearch"; @@ -35,15 +27,12 @@ export function TerminalShellSettingRow() { const updateSettings = useUpdateScopedSettings(); const mixed = useScopedSettingsMixed(["terminalShell"]); const { shells, load } = useTerminalShells(target?.environmentId ?? null); - const [customRequested, setCustomRequested] = useState(false); const isProjectScope = scope.kind === "project" || scope.kind === "checkout"; const unavailable = connectedEnvironments.length === 0; const effective = target ? resolveProjectSettings(target.settings, null, null, null).settings.terminalShell : null; const selectValue = mixed ? null : shellSettingToSelectValue(effective); - const showCustom = !mixed && (customRequested || isCustomShell(effective)); - const custom = isCustomShell(effective) ? effective : null; const selectedProfile = !mixed && typeof effective === "string" && effective !== "auto" ? shells?.find((shell) => shell.id === effective) @@ -70,13 +59,7 @@ export function TerminalShellSettingRow() { } resetAction={ !isProjectScope && settings.terminalShell !== null ? ( - { - setCustomRequested(false); - save(null); - }} - /> + save(null)} /> ) : null } control={ @@ -86,11 +69,6 @@ export function TerminalShellSettingRow() { if (open) load(); }} onValueChange={(value) => { - if (value === CUSTOM_SHELL_SELECT_VALUE) { - setCustomRequested(true); - return; - } - setCustomRequested(false); if (value === "auto") save(isProjectScope ? "auto" : null); else if (value) save(value); }} @@ -102,9 +80,7 @@ export function TerminalShellSettingRow() { ? unavailable ? "Unavailable" : "Mixed" - : value === CUSTOM_SHELL_SELECT_VALUE - ? "Custom" - : shellSettingLabel(value === "auto" ? null : value, shells ?? []) + : shellSettingLabel(value === "auto" ? null : value, shells ?? []) } @@ -115,39 +91,11 @@ export function TerminalShellSettingRow() { ))} - Custom… } > - {showCustom ? ( -
- { - const shell = buildCustomShell(next, formatShellArgs(custom?.args)); - if (shell) save(shell); - }} - /> - { - if (!custom) return; - const shell = buildCustomShell(custom.command, next); - if (shell) save(shell); - }} - /> -
- ) : selectedProfile ? ( + {selectedProfile ? (
diff --git a/apps/web/src/components/terminal/NewTerminalShellMenu.tsx b/apps/web/src/components/terminal/NewTerminalShellMenu.tsx index 8f3cb59b49f02..fac648b033781 100644 --- a/apps/web/src/components/terminal/NewTerminalShellMenu.tsx +++ b/apps/web/src/components/terminal/NewTerminalShellMenu.tsx @@ -1,14 +1,9 @@ import type { ScopedThreadRef, TerminalShellSetting } from "@t3tools/contracts"; -import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; import { ChevronDownIcon } from "lucide-react"; -import { useComposerDraftStore } from "../../composerDraftStore"; -import { useEnvironmentSettings } from "../../hooks/useSettings"; -import { useQuietThreadShell } from "../../state/entities"; import { Button } from "../ui/button"; import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../ui/menu"; import { MiddleTruncate } from "../ui/middle-truncate"; -import { isCustomShell } from "./terminalShell.logic"; import { useTerminalShells } from "./useTerminalShells"; /** @@ -25,12 +20,6 @@ export function NewTerminalShellMenu({ onSelect: (shell: TerminalShellSetting) => void; }) { const { shells, load } = useTerminalShells(threadRef.environmentId); - const settings = useEnvironmentSettings(threadRef.environmentId); - const serverThread = useQuietThreadShell(threadRef); - const draftThread = useComposerDraftStore((store) => store.getDraftThreadByRef(threadRef)); - const projectId = serverThread?.projectId ?? draftThread?.projectId ?? null; - const configured = resolveProjectSettings(settings, projectId, null).settings.terminalShell; - const customShell = isCustomShell(configured) ? configured : null; return ( open && load()}> @@ -52,11 +41,6 @@ export function NewTerminalShellMenu({ ))} - {customShell ? ( - onSelect(customShell)}> - - - ) : null} {shells === null ? ( Loading… ) : null} diff --git a/apps/web/src/components/terminal/terminalShell.logic.test.ts b/apps/web/src/components/terminal/terminalShell.logic.test.ts index ebbed2576c6d6..71f5cf5402b83 100644 --- a/apps/web/src/components/terminal/terminalShell.logic.test.ts +++ b/apps/web/src/components/terminal/terminalShell.logic.test.ts @@ -1,33 +1,12 @@ import { describe, expect, it } from "vite-plus/test"; -import { - buildCustomShell, - CUSTOM_SHELL_SELECT_VALUE, - parseShellArgs, - shellSettingLabel, - shellSettingToSelectValue, -} from "./terminalShell.logic"; +import { shellSettingLabel, shellSettingToSelectValue } from "./terminalShell.logic"; describe("terminal shell settings", () => { - it("splits custom args on any whitespace", () => { - expect(parseShellArgs(" -NoLogo \t -l\n")).toEqual(["-NoLogo", "-l"]); - expect(parseShellArgs(" ")).toEqual([]); - }); - - it("builds a custom shell and omits empty args", () => { - expect(buildCustomShell(" /usr/bin/fish ", "")).toEqual({ command: "/usr/bin/fish" }); - expect(buildCustomShell("/bin/zsh", "-l -i")).toEqual({ - command: "/bin/zsh", - args: ["-l", "-i"], - }); - expect(buildCustomShell(" ", "-l")).toBeNull(); - }); - it("maps settings to select values", () => { expect(shellSettingToSelectValue(null)).toBe("auto"); expect(shellSettingToSelectValue("auto")).toBe("auto"); expect(shellSettingToSelectValue("pwsh")).toBe("pwsh"); - expect(shellSettingToSelectValue({ command: "/bin/fish" })).toBe(CUSTOM_SHELL_SELECT_VALUE); }); it("labels a profile id by its detected label and falls back to the id", () => { @@ -35,6 +14,5 @@ describe("terminal shell settings", () => { expect(shellSettingLabel("pwsh", shells)).toBe("PowerShell 7"); expect(shellSettingLabel("gone", shells)).toBe("gone"); expect(shellSettingLabel(null, shells)).toBe("Automatic"); - expect(shellSettingLabel({ command: "/bin/fish" }, shells)).toBe("/bin/fish"); }); }); diff --git a/apps/web/src/components/terminal/terminalShell.logic.ts b/apps/web/src/components/terminal/terminalShell.logic.ts index 6b90efd2aa6b8..5ccdcbb8480dd 100644 --- a/apps/web/src/components/terminal/terminalShell.logic.ts +++ b/apps/web/src/components/terminal/terminalShell.logic.ts @@ -1,32 +1,9 @@ import type { TerminalShellProfile, TerminalShellSetting } from "@t3tools/contracts"; -export const CUSTOM_SHELL_SELECT_VALUE = "__custom__"; - -type CustomShell = Extract; - -export function isCustomShell(setting: TerminalShellSetting | null): setting is CustomShell { - return typeof setting === "object" && setting !== null; -} - -export function parseShellArgs(text: string): string[] { - return text.split(/\s+/).filter((arg) => arg.length > 0); -} - -export function formatShellArgs(args: readonly string[] | undefined): string { - return (args ?? []).join(" "); -} - -export function buildCustomShell(commandText: string, argsText: string): CustomShell | null { - const command = commandText.trim(); - if (command.length === 0) return null; - const args = parseShellArgs(argsText); - return args.length > 0 ? { command, args } : { command }; -} - /** `null` and "auto" both mean the built-in fallback chain. */ export function shellSettingToSelectValue(setting: TerminalShellSetting | null): string { if (setting === null || setting === "auto") return "auto"; - return isCustomShell(setting) ? CUSTOM_SHELL_SELECT_VALUE : setting; + return setting; } export function shellSettingLabel( @@ -34,6 +11,5 @@ export function shellSettingLabel( shells: readonly TerminalShellProfile[], ): string { if (setting === null || setting === "auto") return "Automatic"; - if (isCustomShell(setting)) return setting.command; return shells.find((shell) => shell.id === setting)?.label ?? setting; } diff --git a/docs/user/terminal.md b/docs/user/terminal.md index 922f9f4965661..e481d06d6f9a8 100644 --- a/docs/user/terminal.md +++ b/docs/user/terminal.md @@ -3,9 +3,8 @@ Terminals open with your system's default shell. To choose another, set **Default shell** in **Settings → General**. T3 Code lists the shells it finds on that machine: PowerShell 7, Windows PowerShell, Command Prompt, and Git Bash on -Windows, and the shells in `/etc/shells` elsewhere. Choose **Custom…** to enter -the path of any executable. The same row in a project's settings overrides the -choice for that project. +Windows, and the shells in `/etc/shells` elsewhere. The same row in a project's +settings overrides the choice for that project. To use a different shell for one terminal, open the menu next to **New terminal** in the terminal drawer and pick a shell. On mobile, use **New terminal with…** in @@ -13,7 +12,7 @@ the terminal menu. A repository can suggest a shell with `terminalShell` in `t3.json`, using a shell name such as `"pwsh"`, `"powershell"`, `"cmd"`, or `"bash"`. `"bash"` means Git -Bash on Windows. Custom paths aren't allowed there. +Bash on Windows. A setting in T3 Code always wins over `t3.json`: a project override, then the environment setting, then `t3.json`, then automatic detection. If the chosen diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index 382bb3e7594c2..d8b3a961d62a9 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -257,14 +257,14 @@ describe("ServerSettings worktree defaults", () => { }); describe("ServerSettings.terminalShell", () => { - it("inherits by default and decodes profile ids, auto, and custom executables", () => { + it("inherits by default and decodes profile ids and auto, not custom executables", () => { expect(decodeServerSettings({}).terminalShell).toBeNull(); expect(decodeServerSettings({ terminalShell: "auto" }).terminalShell).toBe("auto"); expect(decodeServerSettings({ terminalShell: "pwsh" }).terminalShell).toBe("pwsh"); expect( decodeServerSettings({ terminalShell: { command: "/opt/tools/sh", args: ["-l"] } }) .terminalShell, - ).toEqual({ command: "/opt/tools/sh", args: ["-l"] }); + ).toBeNull(); expect(decodeServerSettings({ terminalShell: "" }).terminalShell).toBeNull(); expect(decodeServerSettingsPatch({ terminalShell: null }).terminalShell).toBeNull(); }); diff --git a/packages/contracts/src/t3ProjectFile.ts b/packages/contracts/src/t3ProjectFile.ts index 5d66a3ef7c180..17bab145ae19c 100644 --- a/packages/contracts/src/t3ProjectFile.ts +++ b/packages/contracts/src/t3ProjectFile.ts @@ -94,7 +94,7 @@ export const T3ProjectFile = Schema.Struct({ terminalShell: Schema.optionalKey( trimmedNonEmpty({ description: - 'Shell new terminals open with for this repository, by profile id such as "pwsh", "powershell", "cmd", "zsh", or "bash" (Git Bash on Windows, so one value works across platforms). A per-project setting in T3 Code overrides this; an id that is not installed falls back to the default shell. Custom executables cannot be set here.', + 'Shell new terminals open with for this repository, by profile id such as "pwsh", "powershell", "cmd", "zsh", or "bash" (Git Bash on Windows, so one value works across platforms). A per-project setting in T3 Code overrides this; an id that is not installed falls back to the default shell.', }), ), worktreeSubmodules: Schema.optionalKey( diff --git a/packages/contracts/src/terminal.ts b/packages/contracts/src/terminal.ts index e21a03b529231..c5a095f5344ec 100644 --- a/packages/contracts/src/terminal.ts +++ b/packages/contracts/src/terminal.ts @@ -35,18 +35,8 @@ export const TerminalListShellsResult = Schema.Struct({ }); export type TerminalListShellsResult = typeof TerminalListShellsResult.Type; -/** - * "auto" is reserved and listed first so it never decodes as a profile id. - * A bare string names a detected profile; the object form is an explicit executable. - */ -export const TerminalShellSetting = Schema.Union([ - Schema.Literal("auto"), - TerminalShellProfileIdSchema, - Schema.Struct({ - command: TrimmedNonEmptyStringSchema, - args: Schema.optional(Schema.Array(Schema.String)), - }), -]); +/** "auto" picks the platform default; any other value names a detected shell profile id. */ +export const TerminalShellSetting = TerminalShellProfileIdSchema; export type TerminalShellSetting = typeof TerminalShellSetting.Type; export const TerminalThreadInput = Schema.Struct({ From a0d923acdf6df7abd25063552d91cfa078bb46cd Mon Sep 17 00:00:00 2001 From: sharull Date: Sat, 24 Oct 2026 00:13:19 +0530 Subject: [PATCH 3/5] fix(terminal): surface unavailable shell profiles - Report configured shells that fall back to automatic selection - Add retryable, environment-scoped shell discovery states across clients --- .../terminal/ThreadTerminalRouteScreen.tsx | 8 +- apps/server/src/terminal/Manager.test.ts | 13 ++- apps/server/src/terminal/Manager.ts | 10 +- .../settings/TerminalShellSettingRow.tsx | 17 +++- .../terminal/NewTerminalShellMenu.tsx | 8 +- .../terminal/terminalShell.logic.test.ts | 9 +- .../terminal/terminalShell.logic.ts | 5 +- .../terminal/useTerminalShells.test.ts | 95 +++++++++++++++++++ .../components/terminal/useTerminalShells.ts | 28 ++++-- 9 files changed, 169 insertions(+), 24 deletions(-) create mode 100644 apps/web/src/components/terminal/useTerminalShells.test.ts diff --git a/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx b/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx index 8f8fcdcd1f51b..db96ceb004afc 100644 --- a/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx +++ b/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx @@ -100,6 +100,7 @@ function TerminalHeader(props: { readonly subtitle: string; readonly isEnvironmentReady: boolean; readonly canOperateTerminal: boolean; + readonly canSelectShell: boolean; readonly fontSize: number; readonly terminalId: string; readonly sessions: ReadonlyArray; @@ -179,10 +180,11 @@ function TerminalHeader(props: { id: "terminal-new-with-shell", title: "New terminal with…", icon: "plus", - disabled: !props.canOperateTerminal, + disabled: !props.canSelectShell, items: props.shells.map((shell) => ({ id: `terminal-new-shell:${shell.id}`, title: shell.label, + disabled: !props.canSelectShell, onPress: () => props.onOpenNewTerminal(shell.id), })), }, @@ -1044,7 +1046,8 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) (shell?: string) => { if ( !selectedThread || - !readEnvironmentScope(selectedThread.environmentId, AuthTerminalOperateScope) + !readEnvironmentScope(selectedThread.environmentId, AuthTerminalOperateScope) || + (shell !== undefined && launchLocation === null) ) { return; } @@ -1198,6 +1201,7 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) ) : null} + it.effect("reports an unavailable configured profile when falling back to auto", () => Effect.gen(function* () { const { manager, ptyAdapter } = yield* createManager(5, { shellProfiles, @@ -973,6 +973,17 @@ it.layer( expect(ptyAdapter.spawnInputs[0]?.shell).toBe("/bin/sh"); expect(snapshot.history).toBe(""); + const unsubscribe = yield* manager.subscribeMetadata((event) => + Effect.sync(() => { + if (event.type === "snapshot") { + expect(event.terminals[0]).toMatchObject({ + shell: "sh", + shellFallbackFrom: "nushell", + }); + } + }), + ); + unsubscribe(); }), ); diff --git a/apps/server/src/terminal/Manager.ts b/apps/server/src/terminal/Manager.ts index 0d354a022bc1d..a2ab45c39a8e7 100644 --- a/apps/server/src/terminal/Manager.ts +++ b/apps/server/src/terminal/Manager.ts @@ -2467,9 +2467,13 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func session.status = "running"; session.shellLabel = shellLabel; session.shellFallbackFrom = - preferred !== null && spawnResult.index > 0 - ? shellCandidateLabel(preferred, profiles, platform) - : null; + preferred === null + ? setting === "auto" + ? null + : setting + : spawnResult.index > 0 + ? shellCandidateLabel(preferred, profiles, platform) + : null; // onExit may replay an exit immediately; accept it before subscribing. session.unsubscribeData = spawnResult.process.onData((data) => { if (!enqueueProcessEvent(session, processPid, { type: "output", data })) { diff --git a/apps/web/src/components/settings/TerminalShellSettingRow.tsx b/apps/web/src/components/settings/TerminalShellSettingRow.tsx index 6124222b8648c..119272f736a81 100644 --- a/apps/web/src/components/settings/TerminalShellSettingRow.tsx +++ b/apps/web/src/components/settings/TerminalShellSettingRow.tsx @@ -6,6 +6,7 @@ import { shellSettingLabel, shellSettingToSelectValue } from "../terminal/termin import { ShellOption } from "../terminal/NewTerminalShellMenu"; import { useTerminalShells } from "../terminal/useTerminalShells"; import { DiffFilePathCopyButton } from "../DiffFilePathCopyButton"; +import { Button } from "../ui/button"; import { MiddleTruncate } from "../ui/middle-truncate"; import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; import { searchableSetting } from "./settingsSearch"; @@ -26,7 +27,7 @@ export function TerminalShellSettingRow() { const settings = useScopedSettings(); const updateSettings = useUpdateScopedSettings(); const mixed = useScopedSettingsMixed(["terminalShell"]); - const { shells, load } = useTerminalShells(target?.environmentId ?? null); + const { shells, status, load } = useTerminalShells(target?.environmentId ?? null); const isProjectScope = scope.kind === "project" || scope.kind === "checkout"; const unavailable = connectedEnvironments.length === 0; const effective = target @@ -41,8 +42,8 @@ export function TerminalShellSettingRow() { // The trigger needs the list to show a profile's label; other values fetch when the select opens. const needsList = !mixed && typeof effective === "string" && effective !== "auto"; useEffect(() => { - if (needsList) load(); - }, [needsList, load]); + if (needsList && status === "idle") void load(); + }, [needsList, status, load]); const save = (value: TerminalShellSetting | null) => updateSettings({ terminalShell: value }); @@ -80,7 +81,7 @@ export function TerminalShellSettingRow() { ? unavailable ? "Unavailable" : "Mixed" - : shellSettingLabel(value === "auto" ? null : value, shells ?? []) + : shellSettingLabel(value === "auto" ? null : value, shells) } @@ -95,6 +96,14 @@ export function TerminalShellSettingRow() { } > + {status === "error" ? ( +
+ Could not load shells. + +
+ ) : null} {selectedProfile ? (
diff --git a/apps/web/src/components/terminal/NewTerminalShellMenu.tsx b/apps/web/src/components/terminal/NewTerminalShellMenu.tsx index fac648b033781..5a35d04ab0cf1 100644 --- a/apps/web/src/components/terminal/NewTerminalShellMenu.tsx +++ b/apps/web/src/components/terminal/NewTerminalShellMenu.tsx @@ -19,7 +19,7 @@ export function NewTerminalShellMenu({ disabled: boolean; onSelect: (shell: TerminalShellSetting) => void; }) { - const { shells, load } = useTerminalShells(threadRef.environmentId); + const { shells, status, load } = useTerminalShells(threadRef.environmentId); return ( open && load()}> @@ -41,7 +41,11 @@ export function NewTerminalShellMenu({ ))} - {shells === null ? ( + {status === "error" ? ( + void load()} closeOnClick={false}> + Could not load shells. Retry + + ) : shells === null ? ( Loading… ) : null} diff --git a/apps/web/src/components/terminal/terminalShell.logic.test.ts b/apps/web/src/components/terminal/terminalShell.logic.test.ts index 71f5cf5402b83..79814106be209 100644 --- a/apps/web/src/components/terminal/terminalShell.logic.test.ts +++ b/apps/web/src/components/terminal/terminalShell.logic.test.ts @@ -9,10 +9,15 @@ describe("terminal shell settings", () => { expect(shellSettingToSelectValue("pwsh")).toBe("pwsh"); }); - it("labels a profile id by its detected label and falls back to the id", () => { + it("labels detected profiles and marks missing profiles unavailable", () => { const shells = [{ id: "pwsh", label: "PowerShell 7", command: "pwsh.exe", args: [] }]; expect(shellSettingLabel("pwsh", shells)).toBe("PowerShell 7"); - expect(shellSettingLabel("gone", shells)).toBe("gone"); + expect(shellSettingLabel("gone", shells)).toBe("gone (unavailable)"); expect(shellSettingLabel(null, shells)).toBe("Automatic"); }); + + it("does not claim a shell is unavailable before discovery succeeds", () => { + expect(shellSettingLabel("fish", null)).toBe("fish"); + expect(shellSettingLabel("fish", [])).toBe("fish (unavailable)"); + }); }); diff --git a/apps/web/src/components/terminal/terminalShell.logic.ts b/apps/web/src/components/terminal/terminalShell.logic.ts index 5ccdcbb8480dd..a5d64b89d1e0e 100644 --- a/apps/web/src/components/terminal/terminalShell.logic.ts +++ b/apps/web/src/components/terminal/terminalShell.logic.ts @@ -8,8 +8,9 @@ export function shellSettingToSelectValue(setting: TerminalShellSetting | null): export function shellSettingLabel( setting: TerminalShellSetting | null, - shells: readonly TerminalShellProfile[], + shells: readonly TerminalShellProfile[] | null, ): string { if (setting === null || setting === "auto") return "Automatic"; - return shells.find((shell) => shell.id === setting)?.label ?? setting; + if (shells === null) return setting; + return shells.find((shell) => shell.id === setting)?.label ?? `${setting} (unavailable)`; } diff --git a/apps/web/src/components/terminal/useTerminalShells.test.ts b/apps/web/src/components/terminal/useTerminalShells.test.ts new file mode 100644 index 0000000000000..11cf49aa85343 --- /dev/null +++ b/apps/web/src/components/terminal/useTerminalShells.test.ts @@ -0,0 +1,95 @@ +import { EnvironmentId } from "@t3tools/contracts"; +import { describe, expect, it, vi } from "vite-plus/test"; + +const state = vi.hoisted(() => ({ + consumer: { value: null as unknown, updates: 0 }, + listShells: vi.fn(), +})); + +vi.mock("react", async (importOriginal) => ({ + ...(await importOriginal()), + useState: () => { + const consumer = state.consumer; + return [ + consumer.value, + (next: unknown) => { + const value = typeof next === "function" ? next(consumer.value) : next; + if (!Object.is(value, consumer.value)) consumer.updates++; + consumer.value = value; + }, + ]; + }, +})); +vi.mock("../../state/terminal", () => ({ terminalEnvironment: { listShells: {} } })); +vi.mock("../../state/use-atom-command", () => ({ useAtomCommand: () => state.listShells })); + +async function setup() { + vi.resetModules(); + state.listShells.mockReset(); + const { useTerminalShells } = await import("./useTerminalShells"); + const environmentId = EnvironmentId.make("shell-test"); + const shells = [{ id: "fish", label: "fish", command: "/bin/fish", args: [] }]; + const consumer = () => { + const entry = { value: null as unknown, updates: 0 }; + return { + entry, + read: (id: EnvironmentId | null = environmentId) => { + state.consumer = entry; + return useTerminalShells(id); + }, + }; + }; + return { consumer, shells }; +} + +describe("terminal shell discovery", () => { + it("settles a failure and retries successfully", async () => { + const { consumer, shells } = await setup(); + state.listShells.mockResolvedValueOnce({ _tag: "Failure" }); + state.listShells.mockResolvedValueOnce({ _tag: "Success", value: { shells } }); + const menu = consumer(); + await menu.read().load(); + expect(menu.read()).toMatchObject({ status: "error", shells: null }); + await menu.read().load(); + expect(menu.read()).toMatchObject({ status: "success", shells }); + expect(state.listShells).toHaveBeenCalledTimes(2); + }); + + it("updates an existing consumer when another consumer populated the cache", async () => { + const { consumer, shells } = await setup(); + state.listShells.mockResolvedValue({ _tag: "Success", value: { shells } }); + const first = consumer(); + const second = consumer(); + const stale = second.read(); + await first.read().load(); + await stale.load(); + expect(second.entry.updates).toBeGreaterThan(0); + expect(second.read().shells).toEqual(shells); + const updates = second.entry.updates; + await second.read().load(); + expect(second.entry.updates).toBe(updates); + expect(state.listShells).toHaveBeenCalledTimes(1); + }); + + it("shares an in-flight request and keeps results scoped to their environment", async () => { + const { consumer, shells } = await setup(); + let resolveResponse!: (value: unknown) => void; + const response = new Promise((resolve) => { + resolveResponse = resolve; + }); + state.listShells.mockReturnValue(response); + const first = consumer(); + const second = consumer(); + const firstLoad = first.read().load(); + const secondLoad = second.read().load(); + expect(state.listShells).toHaveBeenCalledTimes(1); + resolveResponse({ _tag: "Success", value: { shells } }); + await Promise.all([firstLoad, secondLoad]); + expect(first.read().shells).toEqual(shells); + expect(second.read().shells).toEqual(shells); + expect(second.read(EnvironmentId.make("other"))).toMatchObject({ + status: "idle", + shells: null, + }); + }); +}); diff --git a/apps/web/src/components/terminal/useTerminalShells.ts b/apps/web/src/components/terminal/useTerminalShells.ts index 3cf638b2910f5..a59d06fc628b2 100644 --- a/apps/web/src/components/terminal/useTerminalShells.ts +++ b/apps/web/src/components/terminal/useTerminalShells.ts @@ -15,14 +15,27 @@ export function useTerminalShells(environmentId: EnvironmentId | null) { const listShells = useAtomCommand(terminalEnvironment.listShells, { reportFailure: false }); const [loaded, setLoaded] = useState<{ readonly environmentId: string; - readonly shells: readonly TerminalShellProfile[]; + readonly shells: readonly TerminalShellProfile[] | null; + readonly status: "loading" | "success" | "error"; } | null>(null); const cached = environmentId === null ? undefined : shellsByEnvironment.get(environmentId); - const shells = cached ?? (loaded?.environmentId === environmentId ? loaded.shells : null); + const current = loaded?.environmentId === environmentId ? loaded : null; + const shells = cached ?? current?.shells ?? null; + const status = cached !== undefined ? "success" : (current?.status ?? "idle"); - const load = () => { - if (environmentId === null || shellsByEnvironment.has(environmentId)) return; + const load = async () => { + if (environmentId === null) return; + const cachedShells = shellsByEnvironment.get(environmentId); + if (cachedShells !== undefined) { + setLoaded((previous) => + previous?.environmentId === environmentId && previous.shells === cachedShells + ? previous + : { environmentId, shells: cachedShells, status: "success" }, + ); + return; + } + setLoaded({ environmentId, shells: null, status: "loading" }); let pending = inflightByEnvironment.get(environmentId); if (!pending) { pending = listShells({ environmentId, input: {} }).then((result) => { @@ -33,10 +46,9 @@ export function useTerminalShells(environmentId: EnvironmentId | null) { }); inflightByEnvironment.set(environmentId, pending); } - void pending.then((next) => { - if (next) setLoaded({ environmentId, shells: next }); - }); + const next = await pending; + setLoaded({ environmentId, shells: next, status: next === null ? "error" : "success" }); }; - return { shells, load }; + return { shells, status, load }; } From a33176a2eddb17eec0bd3012c6d3a02501dedc45 Mon Sep 17 00:00:00 2001 From: sharull Date: Sat, 24 Oct 2026 00:31:50 +0530 Subject: [PATCH 4/5] feat(terminal): choose a shell for new terminals - Preserve project context and selected shells across terminal launches and restarts - Require terminal permissions before discovering available shells --- apps/server/src/terminal/Manager.test.ts | 106 +++++++++++++++++- apps/server/src/terminal/Manager.ts | 26 ++++- .../components/PersistentThreadTerminals.tsx | 2 + .../src/components/ThreadTerminalDrawer.tsx | 33 ++++-- .../settings/TerminalShellSettingRow.tsx | 10 +- .../terminal/NewTerminalShellMenu.tsx | 4 +- .../terminal/useTerminalShells.test.ts | 18 ++- .../components/terminal/useTerminalShells.ts | 6 +- .../terminal/useThreadTerminalActions.ts | 1 + .../src/state/commandPermissions.test.ts | 26 +++++ .../contracts/src/clientRpcPermissions.ts | 2 + packages/contracts/src/terminal.test.ts | 16 +++ packages/contracts/src/terminal.ts | 5 +- 13 files changed, 230 insertions(+), 25 deletions(-) diff --git a/apps/server/src/terminal/Manager.test.ts b/apps/server/src/terminal/Manager.test.ts index 5dc0efb841277..439d832eb76ff 100644 --- a/apps/server/src/terminal/Manager.test.ts +++ b/apps/server/src/terminal/Manager.test.ts @@ -1051,6 +1051,8 @@ it.layer( readonly environment?: TerminalShellSetting; readonly project?: TerminalShellSetting; readonly file?: T3ProjectFile; + readonly draftProjectId?: ProjectId; + readonly projected?: boolean; }) => TerminalManager.resolveConfiguredTerminalShell({ serverSettings: { @@ -1064,9 +1066,10 @@ it.layer( projectFiles: { load: () => Effect.succeed(input.file ? Option.some(input.file) : Option.none()), }, - threads, + threads: input.projected === false ? { getThreadShell: () => Effect.succeed(null) } : threads, threadId: ThreadId.make("thread-1"), cwd: "/repo", + ...(input.draftProjectId ? { projectId: input.draftProjectId } : {}), }); it.effect("orders project override, environment default, t3.json, then auto", () => @@ -1077,6 +1080,107 @@ it.layer( expect(yield* resolve({ file })).toBe("cmd"); expect(yield* resolve({})).toBe("auto"); expect(yield* resolve({ project: "auto", file })).toBe("auto"); + expect(yield* resolve({ environment: "auto", file })).toBe("auto"); + }), + ); + + it.effect("resolves draft project overrides without a projected thread", () => + Effect.gen(function* () { + const file = { terminalShell: "cmd" } satisfies T3ProjectFile; + expect( + yield* resolve({ + project: "fish", + environment: "bash", + file, + draftProjectId: projectId, + projected: false, + }), + ).toBe("fish"); + expect( + yield* resolve({ + project: "auto", + environment: "bash", + file, + draftProjectId: projectId, + projected: false, + }), + ).toBe("auto"); + expect( + yield* resolve({ + project: "fish", + environment: "bash", + file, + draftProjectId: ProjectId.make("other"), + projected: false, + }), + ).toBe("bash"); + expect( + yield* resolve({ + project: "fish", + environment: "bash", + file, + draftProjectId: ProjectId.make("other"), + }), + ).toBe("fish"); + }), + ); + + it.effect("keeps the inherited shell on restart after settings change", () => + Effect.gen(function* () { + let setting: TerminalShellSetting = "fish"; + const { manager, ptyAdapter } = yield* createManager(5, { + shellProfiles, + resolveConfiguredShell: () => Effect.sync(() => setting), + }).pipe(Effect.provide(layerWithHostPlatform("linux"))); + yield* manager.open(openInput()); + setting = "zsh"; + yield* manager.restart(restartInput()); + yield* manager.open(openInput({ terminalId: "term-2" })); + expect(ptyAdapter.spawnInputs.map((input) => input.shell)).toEqual([ + "/bin/fish", + "/bin/fish", + "/bin/zsh", + ]); + }), + ); + + it.effect("honors an explicit shell when attach creates the terminal before open", () => + Effect.gen(function* () { + const { manager, ptyAdapter } = yield* createManager(5, { + shellProfiles, + resolveConfiguredShell: configured("zsh"), + }).pipe(Effect.provide(layerWithHostPlatform("linux"))); + const stop = yield* manager.attachStream(openInput(), () => Effect.void); + stop(); + yield* manager.open(openInput({ shell: "fish" })); + expect(ptyAdapter.spawnInputs.map((input) => input.shell)).toEqual(["/bin/zsh", "/bin/fish"]); + expect(ptyAdapter.processes[0]?.killed).toBe(true); + yield* manager.open(openInput({ shell: "fish" })); + expect(ptyAdapter.spawnInputs).toHaveLength(2); + }), + ); + + it.effect("retains draft project context through open, attach recovery and restart", () => + Effect.gen(function* () { + const { manager, ptyAdapter } = yield* createManager(5, { + shellProfiles, + resolveConfiguredShell: (input) => + Effect.succeed(input.projectId === projectId ? "fish" : "zsh"), + }).pipe(Effect.provide(layerWithHostPlatform("linux"))); + yield* manager.open(openInput({ projectId })); + yield* manager.restart(restartInput()); + const stop = yield* manager.attachStream( + openInput({ terminalId: "term-2", projectId }), + () => Effect.void, + ); + stop(); + yield* manager.restart(restartInput({ terminalId: "term-2" })); + expect(ptyAdapter.spawnInputs.map((input) => input.shell)).toEqual([ + "/bin/fish", + "/bin/fish", + "/bin/fish", + "/bin/fish", + ]); }), ); diff --git a/apps/server/src/terminal/Manager.ts b/apps/server/src/terminal/Manager.ts index a2ab45c39a8e7..42fffd5e440a1 100644 --- a/apps/server/src/terminal/Manager.ts +++ b/apps/server/src/terminal/Manager.ts @@ -40,6 +40,7 @@ import { ClaudeSettings, CodexSettings, ProviderInstanceId, + type ProjectId, ThreadId, } from "@t3tools/contracts"; import { @@ -279,6 +280,7 @@ export interface TerminalStartInput extends TerminalOpenInput { } interface TerminalSessionState { + projectId: ProjectId | undefined; threadId: string; terminalId: string; cwd: string; @@ -303,7 +305,7 @@ interface TerminalSessionState { hasRunningSubprocess: boolean; childCommandLabel: string | null; runtimeEnv: Record | null; - /** Explicit choice for this terminal; undefined defers to the configured settings. */ + /** Requested profile, resolved from settings on first launch when omitted. */ shell: TerminalShellSetting | undefined; /** Label of the shell actually running. */ shellLabel: string | null; @@ -1415,6 +1417,7 @@ interface TerminalManagerOptions { resolveConfiguredShell?: (input: { readonly threadId: string; readonly cwd: string; + readonly projectId?: ProjectId; }) => Effect.Effect; env?: NodeJS.ProcessEnv; managedBinaryCacheDir?: string; @@ -1497,12 +1500,13 @@ export const resolveConfiguredTerminalShell = Effect.fn("terminal.resolveConfigu readonly threads: Pick; readonly threadId: string; readonly cwd: string; + readonly projectId?: ProjectId; }) { const settings = yield* input.serverSettings.getSettings; const projectId = hasProjectSettingsOverrides(settings) ? yield* input.threads.getThreadShell(ThreadId.make(input.threadId)).pipe( - Effect.map((thread) => thread?.projectId ?? null), - Effect.orElseSucceed(() => null), + Effect.map((thread) => thread?.projectId ?? input.projectId ?? null), + Effect.orElseSucceed(() => input.projectId ?? null), ) : null; const resolved = resolveProjectSettings(settings, projectId).settings.terminalShell; @@ -1524,8 +1528,11 @@ export const make = Effect.fn("TerminalManager.make")(function* () { const projectFiles = yield* T3ProjectFileLoader.T3ProjectFileLoader; const threads = yield* ProjectionStore.ProjectionStoreV2; const path = yield* Path.Path; - const resolveConfiguredShell = (input: { readonly threadId: string; readonly cwd: string }) => - resolveConfiguredTerminalShell({ serverSettings, projectFiles, threads, ...input }); + const resolveConfiguredShell = (input: { + readonly threadId: string; + readonly cwd: string; + readonly projectId?: ProjectId; + }) => resolveConfiguredTerminalShell({ serverSettings, projectFiles, threads, ...input }); const resolveProviderInstanceEnvironment = Effect.fn( "terminal.resolveProviderInstanceEnvironment", )((rawProviderInstanceId: string, env: Record | undefined) => @@ -2401,6 +2408,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func : yield* options.resolveConfiguredShell({ threadId: session.threadId, cwd: session.worktreePath ?? session.cwd, + ...(session.projectId !== undefined ? { projectId: session.projectId } : {}), })); const preferred = preferredShellCandidate(setting, profiles); const shellCandidates = resolveShellCandidates( @@ -2465,6 +2473,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func session.process = ptyProcess; session.pid = processPid; session.status = "running"; + session.shell = setting; session.shellLabel = shellLabel; session.shellFallbackFrom = preferred === null @@ -2837,6 +2846,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func const cols = input.cols ?? DEFAULT_OPEN_COLS; const rows = input.rows ?? DEFAULT_OPEN_ROWS; const session: TerminalSessionState = { + projectId: input.projectId, threadId: input.threadId, terminalId, cwd: input.cwd, @@ -2892,6 +2902,9 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func } const liveSession = existing.value; + if (input.projectId !== undefined) liveSession.projectId = input.projectId; + // Attach may create the session before a queued open supplies the selected shell. + const shellChanged = input.shell !== undefined && input.shell !== liveSession.shell; if (input.shell !== undefined) liveSession.shell = input.shell; const nextRuntimeEnv = normalizedRuntimeEnv(input.env); const currentRuntimeEnv = liveSession.runtimeEnv; @@ -2903,6 +2916,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func const launchContextChanged = liveSession.cwd !== input.cwd || runtimeEnvChanged || + shellChanged || liveSession.worktreePath !== nextWorktreePath; if (launchContextChanged) { @@ -3326,6 +3340,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func const cols = input.cols ?? DEFAULT_OPEN_COLS; const rows = input.rows ?? DEFAULT_OPEN_ROWS; session = { + projectId: input.projectId, threadId: input.threadId, terminalId, cwd: input.cwd, @@ -3364,6 +3379,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func yield* evictInactiveSessionsIfNeeded(); } else { session = existingSession.value; + if (input.projectId !== undefined) session.projectId = input.projectId; if (input.shell !== undefined) session.shell = input.shell; yield* stopProcess(session); session.cwd = input.cwd; diff --git a/apps/web/src/components/PersistentThreadTerminals.tsx b/apps/web/src/components/PersistentThreadTerminals.tsx index 9bc508da8b10a..2af6f1486a552 100644 --- a/apps/web/src/components/PersistentThreadTerminals.tsx +++ b/apps/web/src/components/PersistentThreadTerminals.tsx @@ -270,6 +270,7 @@ export const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerm ; @@ -1235,6 +1240,7 @@ function TerminalSplitPanes({ advancedTypography, threadRef, threadId, + projectId, onAddTerminalContext, focusRequestId, visible, @@ -1252,6 +1258,7 @@ function TerminalSplitPanes({ advancedTypography: boolean; threadRef: ScopedThreadRef; threadId: ThreadId; + projectId?: ProjectId; onAddTerminalContext: (selection: TerminalContextSelection) => void; focusRequestId: number; visible: boolean; @@ -1293,6 +1300,7 @@ function TerminalSplitPanes({ advancedTypography={advancedTypography} threadRef={threadRef} threadId={threadId} + {...(projectId !== undefined ? { projectId } : {})} terminalId={terminalId} terminalLabel={terminalLabelById.get(terminalId) ?? "Terminal"} cwd={terminalLaunchLocation.cwd} @@ -1435,6 +1443,7 @@ export default function ThreadTerminalDrawer({ mode = "drawer", threadRef, threadId, + projectId, cwd, worktreePath, runtimeEnv, @@ -1833,14 +1842,21 @@ export default function ThreadTerminalDrawer({ ) : null}

No terminal sessions for this thread yet.

- +
+ + +
); @@ -1942,6 +1958,7 @@ export default function ThreadTerminalDrawer({ advancedTypography={advancedTypography} threadRef={threadRef} threadId={threadId} + {...(projectId !== undefined ? { projectId } : {})} onAddTerminalContext={onAddTerminalContext} focusRequestId={focusRequestId} visible={visible} diff --git a/apps/web/src/components/settings/TerminalShellSettingRow.tsx b/apps/web/src/components/settings/TerminalShellSettingRow.tsx index 119272f736a81..ce38b2fa4ec56 100644 --- a/apps/web/src/components/settings/TerminalShellSettingRow.tsx +++ b/apps/web/src/components/settings/TerminalShellSettingRow.tsx @@ -27,7 +27,7 @@ export function TerminalShellSettingRow() { const settings = useScopedSettings(); const updateSettings = useUpdateScopedSettings(); const mixed = useScopedSettingsMixed(["terminalShell"]); - const { shells, status, load } = useTerminalShells(target?.environmentId ?? null); + const { shells, status, load, canLoad } = useTerminalShells(target?.environmentId ?? null); const isProjectScope = scope.kind === "project" || scope.kind === "checkout"; const unavailable = connectedEnvironments.length === 0; const effective = target @@ -56,7 +56,7 @@ export function TerminalShellSettingRow() { description={ isProjectScope ? "Shell for new terminals in this project." - : "Shell for new terminals. Projects and their t3.json can override it." + : "Shell for new terminals. Projects can override it." } resetAction={ !isProjectScope && settings.terminalShell !== null ? ( @@ -66,12 +66,12 @@ export function TerminalShellSettingRow() { control={ } > - {status === "error" ? ( + {status === "error" && canLoad ? (
Could not load shells.