diff --git a/apps/server/src/ptyAdapter.ts b/apps/server/src/ptyAdapter.ts index 903b5911b13e8e77631fbe02448b1537243a07eb..60061e04c88d3ddb13a4b151b082faf4b4c2d83f 100644 --- a/apps/server/src/ptyAdapter.ts +++ b/apps/server/src/ptyAdapter.ts @@ -20,6 +20,7 @@ export interface PtyProcess { export interface PtySpawnInput { shell: string; + args?: string[]; cwd: string; cols: number; rows: number; @@ -118,7 +119,7 @@ class NodePtyProcess implements PtyProcess { export class NodePtyAdapter implements PtyAdapter { spawn(input: PtySpawnInput): PtyProcess { ensureNodePtySpawnHelperExecutable(); - const ptyProcess = nodePty.spawn(input.shell, [], { + const ptyProcess = nodePty.spawn(input.shell, input.args ?? [], { cwd: input.cwd, cols: input.cols, rows: input.rows, diff --git a/apps/server/src/terminalManager.test.ts b/apps/server/src/terminalManager.test.ts index 55b11068be618f7e3357b7c9e312cc2e17e12950..7c0169cb1721d0a2209647300bcfec65645a2e37 100644 --- a/apps/server/src/terminalManager.test.ts +++ b/apps/server/src/terminalManager.test.ts @@ -466,4 +466,42 @@ describe("TerminalManager", () => { restoreEnv(); } }); + + it("injects runtime env overrides into spawned terminals", async () => { + const { manager, ptyAdapter } = makeManager(); + await manager.open( + openInput({ + env: { + T3CODE_PROJECT_ROOT: "/repo", + T3CODE_WORKTREE_PATH: "/repo/worktree-a", + CUSTOM_FLAG: "1", + }, + }), + ); + const spawnInput = ptyAdapter.spawnInputs[0]; + expect(spawnInput).toBeDefined(); + if (!spawnInput) return; + + expect(spawnInput.env.T3CODE_PROJECT_ROOT).toBe("/repo"); + expect(spawnInput.env.T3CODE_WORKTREE_PATH).toBe("/repo/worktree-a"); + expect(spawnInput.env.CUSTOM_FLAG).toBe("1"); + + manager.dispose(); + }); + + it("starts zsh with prompt spacer disabled to avoid `%` end markers", async () => { + if (process.platform === "win32") return; + const { manager, ptyAdapter } = makeManager(5, { + shellResolver: () => "/bin/zsh", + }); + await manager.open(openInput()); + const spawnInput = ptyAdapter.spawnInputs[0]; + expect(spawnInput).toBeDefined(); + if (!spawnInput) return; + + expect(spawnInput.shell).toBe("/bin/zsh"); + expect(spawnInput.args).toEqual(["-o", "nopromptsp"]); + + manager.dispose(); + }); }); diff --git a/apps/server/src/terminalManager.ts b/apps/server/src/terminalManager.ts index b6fb31af3bb8642134fc7b61705837b38e1b84f5..291a0f27c477a609dbffa4280e1a217b740e320f 100644 --- a/apps/server/src/terminalManager.ts +++ b/apps/server/src/terminalManager.ts @@ -59,6 +59,12 @@ interface TerminalSessionState { unsubscribeData: (() => void) | null; unsubscribeExit: (() => void) | null; hasRunningSubprocess: boolean; + runtimeEnv: Record | null; +} + +interface ShellCandidate { + shell: string; + args?: string[]; } function defaultShellResolver(): string { @@ -82,34 +88,54 @@ function normalizeShellCommand(value: string | undefined): string | null { return firstToken.replace(/^['"]|['"]$/g, ""); } -function uniqueShells(shells: Array): string[] { +function shellCandidateFromCommand(command: string | null): ShellCandidate | null { + if (!command || command.length === 0) return null; + const shellName = path.basename(command).toLowerCase(); + if (process.platform !== "win32" && shellName === "zsh") { + return { shell: command, args: ["-o", "nopromptsp"] }; + } + return { shell: command }; +} + +function formatShellCandidate(candidate: ShellCandidate): string { + if (!candidate.args || candidate.args.length === 0) return candidate.shell; + return `${candidate.shell} ${candidate.args.join(" ")}`; +} + +function uniqueShellCandidates(candidates: Array): ShellCandidate[] { const seen = new Set(); - const ordered: string[] = []; - for (const shell of shells) { - if (!shell || shell.length === 0) continue; - if (seen.has(shell)) continue; - seen.add(shell); - ordered.push(shell); + const ordered: ShellCandidate[] = []; + for (const candidate of candidates) { + if (!candidate) continue; + const key = formatShellCandidate(candidate); + if (seen.has(key)) continue; + seen.add(key); + ordered.push(candidate); } return ordered; } -function resolveShellCandidates(shellResolver: () => string): string[] { - const requested = normalizeShellCommand(shellResolver()); +function resolveShellCandidates(shellResolver: () => string): ShellCandidate[] { + const requested = shellCandidateFromCommand(normalizeShellCommand(shellResolver())); if (process.platform === "win32") { - return uniqueShells([requested, process.env.ComSpec ?? null, "powershell.exe", "cmd.exe"]); + return uniqueShellCandidates([ + requested, + shellCandidateFromCommand(process.env.ComSpec ?? null), + shellCandidateFromCommand("powershell.exe"), + shellCandidateFromCommand("cmd.exe"), + ]); } - return uniqueShells([ + return uniqueShellCandidates([ requested, - normalizeShellCommand(process.env.SHELL), - "/bin/zsh", - "/bin/bash", - "/bin/sh", - "zsh", - "bash", - "sh", + shellCandidateFromCommand(normalizeShellCommand(process.env.SHELL)), + shellCandidateFromCommand("/bin/zsh"), + shellCandidateFromCommand("/bin/bash"), + shellCandidateFromCommand("/bin/sh"), + shellCandidateFromCommand("zsh"), + shellCandidateFromCommand("bash"), + shellCandidateFromCommand("sh"), ]); } @@ -241,16 +267,33 @@ function shouldExcludeTerminalEnvKey(key: string): boolean { return TERMINAL_ENV_BLOCKLIST.has(normalizedKey); } -function createTerminalSpawnEnv(baseEnv: NodeJS.ProcessEnv): NodeJS.ProcessEnv { +function createTerminalSpawnEnv( + baseEnv: NodeJS.ProcessEnv, + runtimeEnv?: Record | null, +): NodeJS.ProcessEnv { const spawnEnv: NodeJS.ProcessEnv = {}; for (const [key, value] of Object.entries(baseEnv)) { if (value === undefined) continue; if (shouldExcludeTerminalEnvKey(key)) continue; spawnEnv[key] = value; } + if (runtimeEnv) { + for (const [key, value] of Object.entries(runtimeEnv)) { + spawnEnv[key] = value; + } + } return spawnEnv; } +function normalizedRuntimeEnv( + env: Record | undefined, +): Record | null { + if (!env) return null; + const entries = Object.entries(env); + if (entries.length === 0) return null; + return Object.fromEntries(entries.sort(([left], [right]) => left.localeCompare(right))); +} + export class TerminalManager extends EventEmitter { private readonly sessions = new Map(); private readonly logsDir: string; @@ -307,20 +350,30 @@ export class TerminalManager extends EventEmitter { unsubscribeData: null, unsubscribeExit: null, hasRunningSubprocess: false, + runtimeEnv: normalizedRuntimeEnv(input.env), }; this.sessions.set(sessionKey, session); this.startSession(session, input, "started"); return this.snapshot(session); } - if (existing.cwd !== input.cwd) { + const nextRuntimeEnv = normalizedRuntimeEnv(input.env); + const currentRuntimeEnv = existing.runtimeEnv; + const runtimeEnvChanged = + JSON.stringify(currentRuntimeEnv) !== JSON.stringify(nextRuntimeEnv); + + if (existing.cwd !== input.cwd || runtimeEnvChanged) { this.stopProcess(existing); existing.cwd = input.cwd; + existing.runtimeEnv = nextRuntimeEnv; existing.history = ""; await this.persistHistory(existing.threadId, existing.terminalId, existing.history); } else if (existing.status === "exited" || existing.status === "error") { + existing.runtimeEnv = nextRuntimeEnv; existing.history = ""; await this.persistHistory(existing.threadId, existing.terminalId, existing.history); + } else if (currentRuntimeEnv !== nextRuntimeEnv) { + existing.runtimeEnv = nextRuntimeEnv; } if (!existing.process) { @@ -404,11 +457,13 @@ export class TerminalManager extends EventEmitter { unsubscribeData: null, unsubscribeExit: null, hasRunningSubprocess: false, + runtimeEnv: normalizedRuntimeEnv(input.env), }; this.sessions.set(sessionKey, session); } else { this.stopProcess(session); session.cwd = input.cwd; + session.runtimeEnv = normalizedRuntimeEnv(input.env); } session.history = ""; @@ -480,19 +535,20 @@ export class TerminalManager extends EventEmitter { let startedShell: string | null = null; try { const shellCandidates = resolveShellCandidates(this.shellResolver); - const terminalEnv = createTerminalSpawnEnv(process.env); + const terminalEnv = createTerminalSpawnEnv(process.env, session.runtimeEnv); let lastSpawnError: unknown = null; - for (const shell of shellCandidates) { + for (const candidate of shellCandidates) { try { ptyProcess = this.ptyAdapter.spawn({ - shell, + shell: candidate.shell, + ...(candidate.args ? { args: candidate.args } : {}), cwd: session.cwd, cols: session.cols, rows: session.rows, env: terminalEnv, }); - startedShell = shell; + startedShell = formatShellCandidate(candidate); break; } catch (error) { lastSpawnError = error; @@ -506,7 +562,9 @@ export class TerminalManager extends EventEmitter { const detail = lastSpawnError instanceof Error ? lastSpawnError.message : "Terminal start failed"; const tried = - shellCandidates.length > 0 ? ` Tried shells: ${shellCandidates.join(", ")}.` : ""; + shellCandidates.length > 0 + ? ` Tried shells: ${shellCandidates.map((candidate) => formatShellCandidate(candidate)).join(", ")}.` + : ""; throw new Error(`${detail}.${tried}`.trim()); } diff --git a/apps/server/src/wsServer.test.ts b/apps/server/src/wsServer.test.ts index bf492e19915fb90178a7f7d63f15f6fd2c01e4bc..635bfbb81291c39a8240ae27b7e8277e5ee7863e 100644 --- a/apps/server/src/wsServer.test.ts +++ b/apps/server/src/wsServer.test.ts @@ -323,6 +323,8 @@ describe("WebSocket Server", () => { }); it("responds to server.getConfig", async () => { + const fakeHome = makeTempDir("t3code-home-"); + vi.spyOn(os, "homedir").mockReturnValue(fakeHome); server = createTestServer({ cwd: "/my/workspace" }); await server.start(); const addr = server.httpServer.address(); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index b514da8d418d4ebf6b8e9c4194d44f16b9776c90..4da86ceba2e6f12f29e1a0f142ed5c17d00cd83b 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -102,7 +102,6 @@ import { Command, CommandInput, CommandItem, CommandList } from "./ui/command"; import ProjectScriptsControl, { type NewProjectScriptInput } from "./ProjectScriptsControl"; import { commandForProjectScript, - injectEnvIntoShellCommand, nextProjectScriptId, projectScriptRuntimeEnv, projectScriptIdFromCommand, @@ -545,6 +544,15 @@ export default function ChatView() { [composerHighlightedItemId, composerMenuItems], ); const keybindings = keybindingsQuery.data ?? EMPTY_KEYBINDINGS; + const threadTerminalRuntimeEnv = useMemo(() => { + if (!activeProject) return {}; + return projectScriptRuntimeEnv({ + project: { + cwd: activeProject.cwd, + }, + worktreePath: activeThread?.worktreePath ?? null, + }); + }, [activeProject, activeThread?.worktreePath]); // Default true while loading to avoid toolbar flicker. const isGitRepo = branchesQuery.data?.isRepo ?? true; const splitTerminalShortcutLabel = useMemo( @@ -661,13 +669,16 @@ export default function ChatView() { env?: Record; worktreePath?: string | null; preferNewTerminal?: boolean; + rememberAsLastInvoked?: boolean; }, ) => { if (!api || !activeThreadId || !activeProject || !activeThread) return; - setLastInvokedScriptByProjectId((current) => { - if (current[activeProject.id] === script.id) return current; - return { ...current, [activeProject.id]: script.id }; - }); + if (options?.rememberAsLastInvoked !== false) { + setLastInvokedScriptByProjectId((current) => { + if (current[activeProject.id] === script.id) return current; + return { ...current, [activeProject.id]: script.id }; + }); + } const targetCwd = options?.cwd ?? gitCwd ?? activeProject.cwd; const baseTerminalId = activeThread.activeTerminalId || @@ -701,20 +712,13 @@ export default function ChatView() { } setTerminalFocusRequestId((value) => value + 1); - const resolvedCommand = injectEnvIntoShellCommand( - script.command, - projectScriptRuntimeEnv({ - project: { - id: activeProject.id, - name: activeProject.name, - cwd: activeProject.cwd, - }, - script, - threadId: activeThreadId, - worktreePath: options?.worktreePath ?? activeThread.worktreePath ?? null, - ...(options?.env ? { extraEnv: options.env } : {}), - }), - ); + const runtimeEnv = projectScriptRuntimeEnv({ + project: { + cwd: activeProject.cwd, + }, + worktreePath: options?.worktreePath ?? activeThread.worktreePath ?? null, + ...(options?.env ? { extraEnv: options.env } : {}), + }); try { await api.terminal.open({ @@ -723,11 +727,12 @@ export default function ChatView() { cwd: targetCwd, cols: SCRIPT_TERMINAL_COLS, rows: SCRIPT_TERMINAL_ROWS, + env: runtimeEnv, }); await api.terminal.write({ threadId: activeThreadId, terminalId: targetTerminalId, - data: `${resolvedCommand}\n`, + data: `${script.command}\r`, }); } catch (error) { dispatch({ @@ -1337,6 +1342,7 @@ export default function ChatView() { void runProjectScript(setupScript, { cwd: result.worktree.path, worktreePath: result.worktree.path, + rememberAsLastInvoked: false, }); } } catch (err) { @@ -1889,6 +1895,7 @@ export default function ChatView() { api={api} threadId={activeThread.id} cwd={gitCwd ?? activeProject.cwd} + runtimeEnv={threadTerminalRuntimeEnv} height={activeThread.terminalHeight} terminalIds={activeThread.terminalIds} activeTerminalId={activeThread.activeTerminalId} diff --git a/apps/web/src/components/ProjectScriptsControl.tsx b/apps/web/src/components/ProjectScriptsControl.tsx index 319d79e8fff79f8cbe01084d8dcbb649ca96c734..f2e192e349d68455bede7835be5d8daeb35afc77 100644 --- a/apps/web/src/components/ProjectScriptsControl.tsx +++ b/apps/web/src/components/ProjectScriptsControl.tsx @@ -6,12 +6,12 @@ import type { import { BugIcon, ChevronDownIcon, - CogIcon, FlaskConicalIcon, HammerIcon, ListChecksIcon, PlayIcon, PlusIcon, + SettingsIcon, WrenchIcon, } from "lucide-react"; import React, { type FormEvent, type KeyboardEvent, useMemo, useState } from "react"; @@ -319,7 +319,7 @@ export default function ProjectScriptsControl({ openEditDialog(script); }} > - + diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index eaf05571cb868acf825b5ff5a9bf5131dd9b70b2..5d5c377fa6ee019703bdea67da4889e3d7ac65ea 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -111,6 +111,7 @@ interface TerminalViewportProps { threadId: string; terminalId: string; cwd: string; + runtimeEnv?: Record; focusRequestId: number; autoFocus: boolean; resizeEpoch: number; @@ -122,6 +123,7 @@ function TerminalViewport({ threadId, terminalId, cwd, + runtimeEnv, focusRequestId, autoFocus, resizeEpoch, @@ -274,6 +276,7 @@ function TerminalViewport({ cwd, cols: activeTerminal.cols, rows: activeTerminal.rows, + ...(runtimeEnv ? { env: runtimeEnv } : {}), }); if (disposed) return; activeTerminal.write("\u001bc"); @@ -369,7 +372,7 @@ function TerminalViewport({ fitAddonRef.current = null; terminal.dispose(); }; - }, [api, cwd, terminalId, threadId]); + }, [api, autoFocus, cwd, runtimeEnv, terminalId, threadId]); useEffect(() => { if (!autoFocus) return; @@ -414,6 +417,7 @@ interface ThreadTerminalDrawerProps { api: NativeApi; threadId: string; cwd: string; + runtimeEnv?: Record; height: number; terminalIds: string[]; activeTerminalId: string; @@ -463,6 +467,7 @@ export default function ThreadTerminalDrawer({ api, threadId, cwd, + runtimeEnv, height, terminalIds, activeTerminalId, @@ -778,6 +783,7 @@ export default function ThreadTerminalDrawer({ threadId={threadId} terminalId={terminalId} cwd={cwd} + {...(runtimeEnv ? { runtimeEnv } : {})} focusRequestId={focusRequestId} autoFocus={terminalId === resolvedActiveTerminalId} resizeEpoch={resizeEpoch} @@ -795,6 +801,7 @@ export default function ThreadTerminalDrawer({ threadId={threadId} terminalId={resolvedActiveTerminalId} cwd={cwd} + {...(runtimeEnv ? { runtimeEnv } : {})} focusRequestId={focusRequestId} autoFocus resizeEpoch={resizeEpoch} diff --git a/apps/web/src/projectScripts.test.ts b/apps/web/src/projectScripts.test.ts index 3dad5824ab577f1a05c95b0eb59418f6b493ba88..525513446464e53014c63371894f6c12ceb732c2 100644 --- a/apps/web/src/projectScripts.test.ts +++ b/apps/web/src/projectScripts.test.ts @@ -67,40 +67,19 @@ describe("projectScripts helpers", () => { it("builds default runtime env for scripts", () => { const env = projectScriptRuntimeEnv({ - project: { id: "project-1", name: "acme-web", cwd: "/repo" }, - script: { - id: "test", - name: "Test", - icon: "test", - runOnWorktreeCreate: false, - }, - threadId: "thread-1", + project: { cwd: "/repo" }, worktreePath: "/repo/worktree-a", }); expect(env).toMatchObject({ T3CODE_PROJECT_ROOT: "/repo", - T3CODE_PROJECT_ID: "project-1", - T3CODE_PROJECT_NAME: "acme-web", - T3CODE_THREAD_ID: "thread-1", - T3CODE_SCRIPT_ID: "test", - T3CODE_SCRIPT_NAME: "Test", - T3CODE_SCRIPT_ICON: "test", - T3CODE_SCRIPT_IS_SETUP: "0", T3CODE_WORKTREE_PATH: "/repo/worktree-a", }); }); it("allows overriding runtime env values", () => { const env = projectScriptRuntimeEnv({ - project: { id: "project-1", name: "acme-web", cwd: "/repo" }, - script: { - id: "setup", - name: "Setup", - icon: "configure", - runOnWorktreeCreate: true, - }, - threadId: "thread-1", + project: { cwd: "/repo" }, extraEnv: { T3CODE_PROJECT_ROOT: "/custom-root", CUSTOM_FLAG: "1", @@ -108,7 +87,6 @@ describe("projectScripts helpers", () => { }); expect(env.T3CODE_PROJECT_ROOT).toBe("/custom-root"); - expect(env.T3CODE_SCRIPT_IS_SETUP).toBe("1"); expect(env.CUSTOM_FLAG).toBe("1"); expect(env.T3CODE_WORKTREE_PATH).toBeUndefined(); }); diff --git a/apps/web/src/projectScripts.ts b/apps/web/src/projectScripts.ts index e7086a2f7a2527c47bce7fb78a531f62dae9247c..15d0790a74e99a98077e1401c6aab079e99cdd3f 100644 --- a/apps/web/src/projectScripts.ts +++ b/apps/web/src/projectScripts.ts @@ -85,12 +85,8 @@ export function injectEnvIntoShellCommand( interface ProjectScriptRuntimeEnvInput { project: { - id: string; - name: string; cwd: string; }; - script: Pick; - threadId: string; worktreePath?: string | null; extraEnv?: Record; } @@ -98,13 +94,6 @@ interface ProjectScriptRuntimeEnvInput { export function projectScriptRuntimeEnv(input: ProjectScriptRuntimeEnvInput): Record { const env: Record = { T3CODE_PROJECT_ROOT: input.project.cwd, - T3CODE_PROJECT_ID: input.project.id, - T3CODE_PROJECT_NAME: input.project.name, - T3CODE_THREAD_ID: input.threadId, - T3CODE_SCRIPT_ID: input.script.id, - T3CODE_SCRIPT_NAME: input.script.name, - T3CODE_SCRIPT_ICON: input.script.icon, - T3CODE_SCRIPT_IS_SETUP: input.script.runOnWorktreeCreate ? "1" : "0", }; if (input.worktreePath) { env.T3CODE_WORKTREE_PATH = input.worktreePath; diff --git a/packages/contracts/src/terminal.test.ts b/packages/contracts/src/terminal.test.ts index 8704ee1a53375cadd41d8ca52e0c2991f2e1eb8c..6d7691b29a27cf10fdc3c0ad4ccd465faedd8663 100644 --- a/packages/contracts/src/terminal.test.ts +++ b/packages/contracts/src/terminal.test.ts @@ -42,6 +42,36 @@ describe("terminalOpenInputSchema", () => { }); expect(parsed.terminalId).toBe(DEFAULT_TERMINAL_ID); }); + + it("accepts optional env overrides", () => { + const parsed = terminalOpenInputSchema.parse({ + threadId: "thread-1", + cwd: "/tmp/project", + cols: 100, + rows: 24, + env: { + T3CODE_PROJECT_ROOT: "/tmp/project", + CUSTOM_FLAG: "1", + }, + }); + expect(parsed.env).toMatchObject({ + T3CODE_PROJECT_ROOT: "/tmp/project", + CUSTOM_FLAG: "1", + }); + }); + + it("rejects invalid env keys", () => { + const result = terminalOpenInputSchema.safeParse({ + threadId: "thread-1", + cwd: "/tmp/project", + cols: 100, + rows: 24, + env: { + "bad-key": "1", + }, + }); + expect(result.success).toBe(false); + }); }); describe("terminalWriteInputSchema", () => { diff --git a/packages/contracts/src/terminal.ts b/packages/contracts/src/terminal.ts index 3b1d82e447b007d5c3c45790f38a887c6f2e0bc4..b4f478dcc77f1bda95e769d6755b7f2aa56b104c 100644 --- a/packages/contracts/src/terminal.ts +++ b/packages/contracts/src/terminal.ts @@ -5,6 +5,11 @@ export const DEFAULT_TERMINAL_ID = "default"; const terminalColsSchema = z.number().int().min(20).max(400); const terminalRowsSchema = z.number().int().min(5).max(200); const terminalIdSchema = z.string().trim().min(1).max(128); +const terminalEnvKeySchema = z.string().regex(/^[A-Za-z_][A-Za-z0-9_]*$/).max(128); +const terminalEnvValueSchema = z.string().max(8_192); +const terminalEnvSchema = z + .record(terminalEnvKeySchema, terminalEnvValueSchema) + .refine((env) => Object.keys(env).length <= 128, "Too many terminal env vars"); export const terminalThreadInputSchema = z.object({ threadId: z.string().trim().min(1), @@ -18,6 +23,7 @@ export const terminalOpenInputSchema = terminalSessionInputSchema.extend({ cwd: z.string().trim().min(1), cols: terminalColsSchema, rows: terminalRowsSchema, + env: terminalEnvSchema.optional(), }); export const terminalWriteInputSchema = terminalSessionInputSchema.extend({