authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-17 23:01:21-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-02-18 00:59:06-08:00
log88f31d2f65b1db7a557320d99f70b8c707cfdf4f
tree393e7485bcb37cdf692bafc373a29122dd523b5f
parentc8c0dc6c3c3aa99a36f9d29a9718306a62b4727a

Pass runtime env through terminal open and disable zsh prompt spacer

- add optional `env` to terminal open contract and validate env keys/limits - propagate runtime env from web terminal/script launches to server PTY spawn - start `zsh` with `-o nopromptsp` to avoid trailing `%` markers and add coverage

11 files changed, 200 insertions(+), 84 deletions(-)

apps/server/src/ptyAdapter.ts+2-1
...@@ -20,6 +20,7 @@ export interface PtyProcess {...@@ -20,6 +20,7 @@ export interface PtyProcess {
2020
21export interface PtySpawnInput {21export interface PtySpawnInput {
22 shell: string;22 shell: string;
23 args?: string[];
23 cwd: string;24 cwd: string;
24 cols: number;25 cols: number;
25 rows: number;26 rows: number;
...@@ -118,7 +119,7 @@ class NodePtyProcess implements PtyProcess {...@@ -118,7 +119,7 @@ class NodePtyProcess implements PtyProcess {
118export class NodePtyAdapter implements PtyAdapter {119export class NodePtyAdapter implements PtyAdapter {
119 spawn(input: PtySpawnInput): PtyProcess {120 spawn(input: PtySpawnInput): PtyProcess {
120 ensureNodePtySpawnHelperExecutable();121 ensureNodePtySpawnHelperExecutable();
121 const ptyProcess = nodePty.spawn(input.shell, [], {122 const ptyProcess = nodePty.spawn(input.shell, input.args ?? [], {
122 cwd: input.cwd,123 cwd: input.cwd,
123 cols: input.cols,124 cols: input.cols,
124 rows: input.rows,125 rows: input.rows,
apps/server/src/terminalManager.test.ts+38
...@@ -466,4 +466,42 @@ describe("TerminalManager", () => {...@@ -466,4 +466,42 @@ describe("TerminalManager", () => {
466 restoreEnv();466 restoreEnv();
467 }467 }
468 });468 });
469
470 it("injects runtime env overrides into spawned terminals", async () => {
471 const { manager, ptyAdapter } = makeManager();
472 await manager.open(
473 openInput({
474 env: {
475 T3CODE_PROJECT_ROOT: "/repo",
476 T3CODE_WORKTREE_PATH: "/repo/worktree-a",
477 CUSTOM_FLAG: "1",
478 },
479 }),
480 );
481 const spawnInput = ptyAdapter.spawnInputs[0];
482 expect(spawnInput).toBeDefined();
483 if (!spawnInput) return;
484
485 expect(spawnInput.env.T3CODE_PROJECT_ROOT).toBe("/repo");
486 expect(spawnInput.env.T3CODE_WORKTREE_PATH).toBe("/repo/worktree-a");
487 expect(spawnInput.env.CUSTOM_FLAG).toBe("1");
488
489 manager.dispose();
490 });
491
492 it("starts zsh with prompt spacer disabled to avoid `%` end markers", async () => {
493 if (process.platform === "win32") return;
494 const { manager, ptyAdapter } = makeManager(5, {
495 shellResolver: () => "/bin/zsh",
496 });
497 await manager.open(openInput());
498 const spawnInput = ptyAdapter.spawnInputs[0];
499 expect(spawnInput).toBeDefined();
500 if (!spawnInput) return;
501
502 expect(spawnInput.shell).toBe("/bin/zsh");
503 expect(spawnInput.args).toEqual(["-o", "nopromptsp"]);
504
505 manager.dispose();
506 });
469});507});
apps/server/src/terminalManager.ts+83-25
...@@ -59,6 +59,12 @@ interface TerminalSessionState {...@@ -59,6 +59,12 @@ interface TerminalSessionState {
59 unsubscribeData: (() => void) | null;59 unsubscribeData: (() => void) | null;
60 unsubscribeExit: (() => void) | null;60 unsubscribeExit: (() => void) | null;
61 hasRunningSubprocess: boolean;61 hasRunningSubprocess: boolean;
62 runtimeEnv: Record<string, string> | null;
63}
64
65interface ShellCandidate {
66 shell: string;
67 args?: string[];
62}68}
6369
64function defaultShellResolver(): string {70function defaultShellResolver(): string {
...@@ -82,34 +88,54 @@ function normalizeShellCommand(value: string | undefined): string | null {...@@ -82,34 +88,54 @@ function normalizeShellCommand(value: string | undefined): string | null {
82 return firstToken.replace(/^['"]|['"]$/g, "");88 return firstToken.replace(/^['"]|['"]$/g, "");
83}89}
8490
85function uniqueShells(shells: Array<string | null>): string[] {91function shellCandidateFromCommand(command: string | null): ShellCandidate | null {
92 if (!command || command.length === 0) return null;
93 const shellName = path.basename(command).toLowerCase();
94 if (process.platform !== "win32" && shellName === "zsh") {
95 return { shell: command, args: ["-o", "nopromptsp"] };
96 }
97 return { shell: command };
98}
99
100function formatShellCandidate(candidate: ShellCandidate): string {
101 if (!candidate.args || candidate.args.length === 0) return candidate.shell;
102 return `${candidate.shell} ${candidate.args.join(" ")}`;
103}
104
105function uniqueShellCandidates(candidates: Array<ShellCandidate | null>): ShellCandidate[] {
86 const seen = new Set<string>();106 const seen = new Set<string>();
87 const ordered: string[] = [];107 const ordered: ShellCandidate[] = [];
88 for (const shell of shells) {108 for (const candidate of candidates) {
89 if (!shell || shell.length === 0) continue;109 if (!candidate) continue;
90 if (seen.has(shell)) continue;110 const key = formatShellCandidate(candidate);
91 seen.add(shell);111 if (seen.has(key)) continue;
92 ordered.push(shell);112 seen.add(key);
113 ordered.push(candidate);
93 }114 }
94 return ordered;115 return ordered;
95}116}
96117
97function resolveShellCandidates(shellResolver: () => string): string[] {118function resolveShellCandidates(shellResolver: () => string): ShellCandidate[] {
98 const requested = normalizeShellCommand(shellResolver());119 const requested = shellCandidateFromCommand(normalizeShellCommand(shellResolver()));
99120
100 if (process.platform === "win32") {121 if (process.platform === "win32") {
101 return uniqueShells([requested, process.env.ComSpec ?? null, "powershell.exe", "cmd.exe"]);122 return uniqueShellCandidates([
123 requested,
124 shellCandidateFromCommand(process.env.ComSpec ?? null),
125 shellCandidateFromCommand("powershell.exe"),
126 shellCandidateFromCommand("cmd.exe"),
127 ]);
102 }128 }
103129
104 return uniqueShells([130 return uniqueShellCandidates([
105 requested,131 requested,
106 normalizeShellCommand(process.env.SHELL),132 shellCandidateFromCommand(normalizeShellCommand(process.env.SHELL)),
107 "/bin/zsh",133 shellCandidateFromCommand("/bin/zsh"),
108 "/bin/bash",134 shellCandidateFromCommand("/bin/bash"),
109 "/bin/sh",135 shellCandidateFromCommand("/bin/sh"),
110 "zsh",136 shellCandidateFromCommand("zsh"),
111 "bash",137 shellCandidateFromCommand("bash"),
112 "sh",138 shellCandidateFromCommand("sh"),
113 ]);139 ]);
114}140}
115141
...@@ -241,16 +267,33 @@ function shouldExcludeTerminalEnvKey(key: string): boolean {...@@ -241,16 +267,33 @@ function shouldExcludeTerminalEnvKey(key: string): boolean {
241 return TERMINAL_ENV_BLOCKLIST.has(normalizedKey);267 return TERMINAL_ENV_BLOCKLIST.has(normalizedKey);
242}268}
243269
244function createTerminalSpawnEnv(baseEnv: NodeJS.ProcessEnv): NodeJS.ProcessEnv {270function createTerminalSpawnEnv(
271 baseEnv: NodeJS.ProcessEnv,
272 runtimeEnv?: Record<string, string> | null,
273): NodeJS.ProcessEnv {
245 const spawnEnv: NodeJS.ProcessEnv = {};274 const spawnEnv: NodeJS.ProcessEnv = {};
246 for (const [key, value] of Object.entries(baseEnv)) {275 for (const [key, value] of Object.entries(baseEnv)) {
247 if (value === undefined) continue;276 if (value === undefined) continue;
248 if (shouldExcludeTerminalEnvKey(key)) continue;277 if (shouldExcludeTerminalEnvKey(key)) continue;
249 spawnEnv[key] = value;278 spawnEnv[key] = value;
250 }279 }
280 if (runtimeEnv) {
281 for (const [key, value] of Object.entries(runtimeEnv)) {
282 spawnEnv[key] = value;
283 }
284 }
251 return spawnEnv;285 return spawnEnv;
252}286}
253287
288function normalizedRuntimeEnv(
289 env: Record<string, string> | undefined,
290): Record<string, string> | null {
291 if (!env) return null;
292 const entries = Object.entries(env);
293 if (entries.length === 0) return null;
294 return Object.fromEntries(entries.sort(([left], [right]) => left.localeCompare(right)));
295}
296
254export class TerminalManager extends EventEmitter<TerminalManagerEvents> {297export class TerminalManager extends EventEmitter<TerminalManagerEvents> {
255 private readonly sessions = new Map<string, TerminalSessionState>();298 private readonly sessions = new Map<string, TerminalSessionState>();
256 private readonly logsDir: string;299 private readonly logsDir: string;
...@@ -307,20 +350,30 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> {...@@ -307,20 +350,30 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> {
307 unsubscribeData: null,350 unsubscribeData: null,
308 unsubscribeExit: null,351 unsubscribeExit: null,
309 hasRunningSubprocess: false,352 hasRunningSubprocess: false,
353 runtimeEnv: normalizedRuntimeEnv(input.env),
310 };354 };
311 this.sessions.set(sessionKey, session);355 this.sessions.set(sessionKey, session);
312 this.startSession(session, input, "started");356 this.startSession(session, input, "started");
313 return this.snapshot(session);357 return this.snapshot(session);
314 }358 }
315359
316 if (existing.cwd !== input.cwd) {360 const nextRuntimeEnv = normalizedRuntimeEnv(input.env);
361 const currentRuntimeEnv = existing.runtimeEnv;
362 const runtimeEnvChanged =
363 JSON.stringify(currentRuntimeEnv) !== JSON.stringify(nextRuntimeEnv);
364
365 if (existing.cwd !== input.cwd || runtimeEnvChanged) {
317 this.stopProcess(existing);366 this.stopProcess(existing);
318 existing.cwd = input.cwd;367 existing.cwd = input.cwd;
368 existing.runtimeEnv = nextRuntimeEnv;
319 existing.history = "";369 existing.history = "";
320 await this.persistHistory(existing.threadId, existing.terminalId, existing.history);370 await this.persistHistory(existing.threadId, existing.terminalId, existing.history);
321 } else if (existing.status === "exited" || existing.status === "error") {371 } else if (existing.status === "exited" || existing.status === "error") {
372 existing.runtimeEnv = nextRuntimeEnv;
322 existing.history = "";373 existing.history = "";
323 await this.persistHistory(existing.threadId, existing.terminalId, existing.history);374 await this.persistHistory(existing.threadId, existing.terminalId, existing.history);
375 } else if (currentRuntimeEnv !== nextRuntimeEnv) {
376 existing.runtimeEnv = nextRuntimeEnv;
324 }377 }
325378
326 if (!existing.process) {379 if (!existing.process) {
...@@ -404,11 +457,13 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> {...@@ -404,11 +457,13 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> {
404 unsubscribeData: null,457 unsubscribeData: null,
405 unsubscribeExit: null,458 unsubscribeExit: null,
406 hasRunningSubprocess: false,459 hasRunningSubprocess: false,
460 runtimeEnv: normalizedRuntimeEnv(input.env),
407 };461 };
408 this.sessions.set(sessionKey, session);462 this.sessions.set(sessionKey, session);
409 } else {463 } else {
410 this.stopProcess(session);464 this.stopProcess(session);
411 session.cwd = input.cwd;465 session.cwd = input.cwd;
466 session.runtimeEnv = normalizedRuntimeEnv(input.env);
412 }467 }
413468
414 session.history = "";469 session.history = "";
...@@ -480,19 +535,20 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> {...@@ -480,19 +535,20 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> {
480 let startedShell: string | null = null;535 let startedShell: string | null = null;
481 try {536 try {
482 const shellCandidates = resolveShellCandidates(this.shellResolver);537 const shellCandidates = resolveShellCandidates(this.shellResolver);
483 const terminalEnv = createTerminalSpawnEnv(process.env);538 const terminalEnv = createTerminalSpawnEnv(process.env, session.runtimeEnv);
484 let lastSpawnError: unknown = null;539 let lastSpawnError: unknown = null;
485540
486 for (const shell of shellCandidates) {541 for (const candidate of shellCandidates) {
487 try {542 try {
488 ptyProcess = this.ptyAdapter.spawn({543 ptyProcess = this.ptyAdapter.spawn({
489 shell,544 shell: candidate.shell,
545 ...(candidate.args ? { args: candidate.args } : {}),
490 cwd: session.cwd,546 cwd: session.cwd,
491 cols: session.cols,547 cols: session.cols,
492 rows: session.rows,548 rows: session.rows,
493 env: terminalEnv,549 env: terminalEnv,
494 });550 });
495 startedShell = shell;551 startedShell = formatShellCandidate(candidate);
496 break;552 break;
497 } catch (error) {553 } catch (error) {
498 lastSpawnError = error;554 lastSpawnError = error;
...@@ -506,7 +562,9 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> {...@@ -506,7 +562,9 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> {
506 const detail =562 const detail =
507 lastSpawnError instanceof Error ? lastSpawnError.message : "Terminal start failed";563 lastSpawnError instanceof Error ? lastSpawnError.message : "Terminal start failed";
508 const tried =564 const tried =
509 shellCandidates.length > 0 ? ` Tried shells: ${shellCandidates.join(", ")}.` : "";565 shellCandidates.length > 0
566 ? ` Tried shells: ${shellCandidates.map((candidate) => formatShellCandidate(candidate)).join(", ")}.`
567 : "";
510 throw new Error(`${detail}.${tried}`.trim());568 throw new Error(`${detail}.${tried}`.trim());
511 }569 }
512570
apps/server/src/wsServer.test.ts+2
...@@ -323,6 +323,8 @@ describe("WebSocket Server", () => {...@@ -323,6 +323,8 @@ describe("WebSocket Server", () => {
323 });323 });
324324
325 it("responds to server.getConfig", async () => {325 it("responds to server.getConfig", async () => {
326 const fakeHome = makeTempDir("t3code-home-");
327 vi.spyOn(os, "homedir").mockReturnValue(fakeHome);
326 server = createTestServer({ cwd: "/my/workspace" });328 server = createTestServer({ cwd: "/my/workspace" });
327 await server.start();329 await server.start();
328 const addr = server.httpServer.address();330 const addr = server.httpServer.address();
apps/web/src/components/ChatView.tsx+27-20
...@@ -102,7 +102,6 @@ import { Command, CommandInput, CommandItem, CommandList } from "./ui/command";...@@ -102,7 +102,6 @@ import { Command, CommandInput, CommandItem, CommandList } from "./ui/command";
102import ProjectScriptsControl, { type NewProjectScriptInput } from "./ProjectScriptsControl";102import ProjectScriptsControl, { type NewProjectScriptInput } from "./ProjectScriptsControl";
103import {103import {
104 commandForProjectScript,104 commandForProjectScript,
105 injectEnvIntoShellCommand,
106 nextProjectScriptId,105 nextProjectScriptId,
107 projectScriptRuntimeEnv,106 projectScriptRuntimeEnv,
108 projectScriptIdFromCommand,107 projectScriptIdFromCommand,
...@@ -545,6 +544,15 @@ export default function ChatView() {...@@ -545,6 +544,15 @@ export default function ChatView() {
545 [composerHighlightedItemId, composerMenuItems],544 [composerHighlightedItemId, composerMenuItems],
546 );545 );
547 const keybindings = keybindingsQuery.data ?? EMPTY_KEYBINDINGS;546 const keybindings = keybindingsQuery.data ?? EMPTY_KEYBINDINGS;
547 const threadTerminalRuntimeEnv = useMemo(() => {
548 if (!activeProject) return {};
549 return projectScriptRuntimeEnv({
550 project: {
551 cwd: activeProject.cwd,
552 },
553 worktreePath: activeThread?.worktreePath ?? null,
554 });
555 }, [activeProject, activeThread?.worktreePath]);
548 // Default true while loading to avoid toolbar flicker.556 // Default true while loading to avoid toolbar flicker.
549 const isGitRepo = branchesQuery.data?.isRepo ?? true;557 const isGitRepo = branchesQuery.data?.isRepo ?? true;
550 const splitTerminalShortcutLabel = useMemo(558 const splitTerminalShortcutLabel = useMemo(
...@@ -661,13 +669,16 @@ export default function ChatView() {...@@ -661,13 +669,16 @@ export default function ChatView() {
661 env?: Record<string, string>;669 env?: Record<string, string>;
662 worktreePath?: string | null;670 worktreePath?: string | null;
663 preferNewTerminal?: boolean;671 preferNewTerminal?: boolean;
672 rememberAsLastInvoked?: boolean;
664 },673 },
665 ) => {674 ) => {
666 if (!api || !activeThreadId || !activeProject || !activeThread) return;675 if (!api || !activeThreadId || !activeProject || !activeThread) return;
667 setLastInvokedScriptByProjectId((current) => {676 if (options?.rememberAsLastInvoked !== false) {
668 if (current[activeProject.id] === script.id) return current;677 setLastInvokedScriptByProjectId((current) => {
669 return { ...current, [activeProject.id]: script.id };678 if (current[activeProject.id] === script.id) return current;
670 });679 return { ...current, [activeProject.id]: script.id };
680 });
681 }
671 const targetCwd = options?.cwd ?? gitCwd ?? activeProject.cwd;682 const targetCwd = options?.cwd ?? gitCwd ?? activeProject.cwd;
672 const baseTerminalId =683 const baseTerminalId =
673 activeThread.activeTerminalId ||684 activeThread.activeTerminalId ||
...@@ -701,20 +712,13 @@ export default function ChatView() {...@@ -701,20 +712,13 @@ export default function ChatView() {
701 }712 }
702 setTerminalFocusRequestId((value) => value + 1);713 setTerminalFocusRequestId((value) => value + 1);
703714
704 const resolvedCommand = injectEnvIntoShellCommand(715 const runtimeEnv = projectScriptRuntimeEnv({
705 script.command,716 project: {
706 projectScriptRuntimeEnv({717 cwd: activeProject.cwd,
707 project: {718 },
708 id: activeProject.id,719 worktreePath: options?.worktreePath ?? activeThread.worktreePath ?? null,
709 name: activeProject.name,720 ...(options?.env ? { extraEnv: options.env } : {}),
710 cwd: activeProject.cwd,721 });
711 },
712 script,
713 threadId: activeThreadId,
714 worktreePath: options?.worktreePath ?? activeThread.worktreePath ?? null,
715 ...(options?.env ? { extraEnv: options.env } : {}),
716 }),
717 );
718722
719 try {723 try {
720 await api.terminal.open({724 await api.terminal.open({
...@@ -723,11 +727,12 @@ export default function ChatView() {...@@ -723,11 +727,12 @@ export default function ChatView() {
723 cwd: targetCwd,727 cwd: targetCwd,
724 cols: SCRIPT_TERMINAL_COLS,728 cols: SCRIPT_TERMINAL_COLS,
725 rows: SCRIPT_TERMINAL_ROWS,729 rows: SCRIPT_TERMINAL_ROWS,
730 env: runtimeEnv,
726 });731 });
727 await api.terminal.write({732 await api.terminal.write({
728 threadId: activeThreadId,733 threadId: activeThreadId,
729 terminalId: targetTerminalId,734 terminalId: targetTerminalId,
730 data: `${resolvedCommand}\n`,735 data: `${script.command}\r`,
731 });736 });
732 } catch (error) {737 } catch (error) {
733 dispatch({738 dispatch({
...@@ -1337,6 +1342,7 @@ export default function ChatView() {...@@ -1337,6 +1342,7 @@ export default function ChatView() {
1337 void runProjectScript(setupScript, {1342 void runProjectScript(setupScript, {
1338 cwd: result.worktree.path,1343 cwd: result.worktree.path,
1339 worktreePath: result.worktree.path,1344 worktreePath: result.worktree.path,
1345 rememberAsLastInvoked: false,
1340 });1346 });
1341 }1347 }
1342 } catch (err) {1348 } catch (err) {
...@@ -1889,6 +1895,7 @@ export default function ChatView() {...@@ -1889,6 +1895,7 @@ export default function ChatView() {
1889 api={api}1895 api={api}
1890 threadId={activeThread.id}1896 threadId={activeThread.id}
1891 cwd={gitCwd ?? activeProject.cwd}1897 cwd={gitCwd ?? activeProject.cwd}
1898 runtimeEnv={threadTerminalRuntimeEnv}
1892 height={activeThread.terminalHeight}1899 height={activeThread.terminalHeight}
1893 terminalIds={activeThread.terminalIds}1900 terminalIds={activeThread.terminalIds}
1894 activeTerminalId={activeThread.activeTerminalId}1901 activeTerminalId={activeThread.activeTerminalId}
apps/web/src/components/ProjectScriptsControl.tsx+2-2
...@@ -6,12 +6,12 @@ import type {...@@ -6,12 +6,12 @@ import type {
6import {6import {
7 BugIcon,7 BugIcon,
8 ChevronDownIcon,8 ChevronDownIcon,
9 CogIcon,
10 FlaskConicalIcon,9 FlaskConicalIcon,
11 HammerIcon,10 HammerIcon,
12 ListChecksIcon,11 ListChecksIcon,
13 PlayIcon,12 PlayIcon,
14 PlusIcon,13 PlusIcon,
14 SettingsIcon,
15 WrenchIcon,15 WrenchIcon,
16} from "lucide-react";16} from "lucide-react";
17import React, { type FormEvent, type KeyboardEvent, useMemo, useState } from "react";17import React, { type FormEvent, type KeyboardEvent, useMemo, useState } from "react";
...@@ -319,7 +319,7 @@ export default function ProjectScriptsControl({...@@ -319,7 +319,7 @@ export default function ProjectScriptsControl({
319 openEditDialog(script);319 openEditDialog(script);
320 }}320 }}
321 >321 >
322 <CogIcon className="size-3.5" />322 <SettingsIcon className="size-3.5" />
323 </Button>323 </Button>
324 </span>324 </span>
325 </MenuItem>325 </MenuItem>
apps/web/src/components/ThreadTerminalDrawer.tsx+8-1
...@@ -111,6 +111,7 @@ interface TerminalViewportProps {...@@ -111,6 +111,7 @@ interface TerminalViewportProps {
111 threadId: string;111 threadId: string;
112 terminalId: string;112 terminalId: string;
113 cwd: string;113 cwd: string;
114 runtimeEnv?: Record<string, string>;
114 focusRequestId: number;115 focusRequestId: number;
115 autoFocus: boolean;116 autoFocus: boolean;
116 resizeEpoch: number;117 resizeEpoch: number;
...@@ -122,6 +123,7 @@ function TerminalViewport({...@@ -122,6 +123,7 @@ function TerminalViewport({
122 threadId,123 threadId,
123 terminalId,124 terminalId,
124 cwd,125 cwd,
126 runtimeEnv,
125 focusRequestId,127 focusRequestId,
126 autoFocus,128 autoFocus,
127 resizeEpoch,129 resizeEpoch,
...@@ -274,6 +276,7 @@ function TerminalViewport({...@@ -274,6 +276,7 @@ function TerminalViewport({
274 cwd,276 cwd,
275 cols: activeTerminal.cols,277 cols: activeTerminal.cols,
276 rows: activeTerminal.rows,278 rows: activeTerminal.rows,
279 ...(runtimeEnv ? { env: runtimeEnv } : {}),
277 });280 });
278 if (disposed) return;281 if (disposed) return;
279 activeTerminal.write("\u001bc");282 activeTerminal.write("\u001bc");
...@@ -369,7 +372,7 @@ function TerminalViewport({...@@ -369,7 +372,7 @@ function TerminalViewport({
369 fitAddonRef.current = null;372 fitAddonRef.current = null;
370 terminal.dispose();373 terminal.dispose();
371 };374 };
372 }, [api, cwd, terminalId, threadId]);375 }, [api, autoFocus, cwd, runtimeEnv, terminalId, threadId]);
373376
374 useEffect(() => {377 useEffect(() => {
375 if (!autoFocus) return;378 if (!autoFocus) return;
...@@ -414,6 +417,7 @@ interface ThreadTerminalDrawerProps {...@@ -414,6 +417,7 @@ interface ThreadTerminalDrawerProps {
414 api: NativeApi;417 api: NativeApi;
415 threadId: string;418 threadId: string;
416 cwd: string;419 cwd: string;
420 runtimeEnv?: Record<string, string>;
417 height: number;421 height: number;
418 terminalIds: string[];422 terminalIds: string[];
419 activeTerminalId: string;423 activeTerminalId: string;
...@@ -463,6 +467,7 @@ export default function ThreadTerminalDrawer({...@@ -463,6 +467,7 @@ export default function ThreadTerminalDrawer({
463 api,467 api,
464 threadId,468 threadId,
465 cwd,469 cwd,
470 runtimeEnv,
466 height,471 height,
467 terminalIds,472 terminalIds,
468 activeTerminalId,473 activeTerminalId,
...@@ -778,6 +783,7 @@ export default function ThreadTerminalDrawer({...@@ -778,6 +783,7 @@ export default function ThreadTerminalDrawer({
778 threadId={threadId}783 threadId={threadId}
779 terminalId={terminalId}784 terminalId={terminalId}
780 cwd={cwd}785 cwd={cwd}
786 {...(runtimeEnv ? { runtimeEnv } : {})}
781 focusRequestId={focusRequestId}787 focusRequestId={focusRequestId}
782 autoFocus={terminalId === resolvedActiveTerminalId}788 autoFocus={terminalId === resolvedActiveTerminalId}
783 resizeEpoch={resizeEpoch}789 resizeEpoch={resizeEpoch}
...@@ -795,6 +801,7 @@ export default function ThreadTerminalDrawer({...@@ -795,6 +801,7 @@ export default function ThreadTerminalDrawer({
795 threadId={threadId}801 threadId={threadId}
796 terminalId={resolvedActiveTerminalId}802 terminalId={resolvedActiveTerminalId}
797 cwd={cwd}803 cwd={cwd}
804 {...(runtimeEnv ? { runtimeEnv } : {})}
798 focusRequestId={focusRequestId}805 focusRequestId={focusRequestId}
799 autoFocus806 autoFocus
800 resizeEpoch={resizeEpoch}807 resizeEpoch={resizeEpoch}
apps/web/src/projectScripts.test.ts+2-24
...@@ -67,40 +67,19 @@ describe("projectScripts helpers", () => {...@@ -67,40 +67,19 @@ describe("projectScripts helpers", () => {
6767
68 it("builds default runtime env for scripts", () => {68 it("builds default runtime env for scripts", () => {
69 const env = projectScriptRuntimeEnv({69 const env = projectScriptRuntimeEnv({
70 project: { id: "project-1", name: "acme-web", cwd: "/repo" },70 project: { cwd: "/repo" },
71 script: {
72 id: "test",
73 name: "Test",
74 icon: "test",
75 runOnWorktreeCreate: false,
76 },
77 threadId: "thread-1",
78 worktreePath: "/repo/worktree-a",71 worktreePath: "/repo/worktree-a",
79 });72 });
8073
81 expect(env).toMatchObject({74 expect(env).toMatchObject({
82 T3CODE_PROJECT_ROOT: "/repo",75 T3CODE_PROJECT_ROOT: "/repo",
83 T3CODE_PROJECT_ID: "project-1",
84 T3CODE_PROJECT_NAME: "acme-web",
85 T3CODE_THREAD_ID: "thread-1",
86 T3CODE_SCRIPT_ID: "test",
87 T3CODE_SCRIPT_NAME: "Test",
88 T3CODE_SCRIPT_ICON: "test",
89 T3CODE_SCRIPT_IS_SETUP: "0",
90 T3CODE_WORKTREE_PATH: "/repo/worktree-a",76 T3CODE_WORKTREE_PATH: "/repo/worktree-a",
91 });77 });
92 });78 });
9379
94 it("allows overriding runtime env values", () => {80 it("allows overriding runtime env values", () => {
95 const env = projectScriptRuntimeEnv({81 const env = projectScriptRuntimeEnv({
96 project: { id: "project-1", name: "acme-web", cwd: "/repo" },82 project: { cwd: "/repo" },
97 script: {
98 id: "setup",
99 name: "Setup",
100 icon: "configure",
101 runOnWorktreeCreate: true,
102 },
103 threadId: "thread-1",
104 extraEnv: {83 extraEnv: {
105 T3CODE_PROJECT_ROOT: "/custom-root",84 T3CODE_PROJECT_ROOT: "/custom-root",
106 CUSTOM_FLAG: "1",85 CUSTOM_FLAG: "1",
...@@ -108,7 +87,6 @@ describe("projectScripts helpers", () => {...@@ -108,7 +87,6 @@ describe("projectScripts helpers", () => {
108 });87 });
10988
110 expect(env.T3CODE_PROJECT_ROOT).toBe("/custom-root");89 expect(env.T3CODE_PROJECT_ROOT).toBe("/custom-root");
111 expect(env.T3CODE_SCRIPT_IS_SETUP).toBe("1");
112 expect(env.CUSTOM_FLAG).toBe("1");90 expect(env.CUSTOM_FLAG).toBe("1");
113 expect(env.T3CODE_WORKTREE_PATH).toBeUndefined();91 expect(env.T3CODE_WORKTREE_PATH).toBeUndefined();
114 });92 });
apps/web/src/projectScripts.ts-11
...@@ -85,12 +85,8 @@ export function injectEnvIntoShellCommand(...@@ -85,12 +85,8 @@ export function injectEnvIntoShellCommand(
8585
86interface ProjectScriptRuntimeEnvInput {86interface ProjectScriptRuntimeEnvInput {
87 project: {87 project: {
88 id: string;
89 name: string;
90 cwd: string;88 cwd: string;
91 };89 };
92 script: Pick<ProjectScript, "id" | "name" | "icon" | "runOnWorktreeCreate">;
93 threadId: string;
94 worktreePath?: string | null;90 worktreePath?: string | null;
95 extraEnv?: Record<string, string>;91 extraEnv?: Record<string, string>;
96}92}
...@@ -98,13 +94,6 @@ interface ProjectScriptRuntimeEnvInput {...@@ -98,13 +94,6 @@ interface ProjectScriptRuntimeEnvInput {
98export function projectScriptRuntimeEnv(input: ProjectScriptRuntimeEnvInput): Record<string, string> {94export function projectScriptRuntimeEnv(input: ProjectScriptRuntimeEnvInput): Record<string, string> {
99 const env: Record<string, string> = {95 const env: Record<string, string> = {
100 T3CODE_PROJECT_ROOT: input.project.cwd,96 T3CODE_PROJECT_ROOT: input.project.cwd,
101 T3CODE_PROJECT_ID: input.project.id,
102 T3CODE_PROJECT_NAME: input.project.name,
103 T3CODE_THREAD_ID: input.threadId,
104 T3CODE_SCRIPT_ID: input.script.id,
105 T3CODE_SCRIPT_NAME: input.script.name,
106 T3CODE_SCRIPT_ICON: input.script.icon,
107 T3CODE_SCRIPT_IS_SETUP: input.script.runOnWorktreeCreate ? "1" : "0",
108 };97 };
109 if (input.worktreePath) {98 if (input.worktreePath) {
110 env.T3CODE_WORKTREE_PATH = input.worktreePath;99 env.T3CODE_WORKTREE_PATH = input.worktreePath;
packages/contracts/src/terminal.test.ts+30
...@@ -42,6 +42,36 @@ describe("terminalOpenInputSchema", () => {...@@ -42,6 +42,36 @@ describe("terminalOpenInputSchema", () => {
42 });42 });
43 expect(parsed.terminalId).toBe(DEFAULT_TERMINAL_ID);43 expect(parsed.terminalId).toBe(DEFAULT_TERMINAL_ID);
44 });44 });
45
46 it("accepts optional env overrides", () => {
47 const parsed = terminalOpenInputSchema.parse({
48 threadId: "thread-1",
49 cwd: "/tmp/project",
50 cols: 100,
51 rows: 24,
52 env: {
53 T3CODE_PROJECT_ROOT: "/tmp/project",
54 CUSTOM_FLAG: "1",
55 },
56 });
57 expect(parsed.env).toMatchObject({
58 T3CODE_PROJECT_ROOT: "/tmp/project",
59 CUSTOM_FLAG: "1",
60 });
61 });
62
63 it("rejects invalid env keys", () => {
64 const result = terminalOpenInputSchema.safeParse({
65 threadId: "thread-1",
66 cwd: "/tmp/project",
67 cols: 100,
68 rows: 24,
69 env: {
70 "bad-key": "1",
71 },
72 });
73 expect(result.success).toBe(false);
74 });
45});75});
4676
47describe("terminalWriteInputSchema", () => {77describe("terminalWriteInputSchema", () => {
packages/contracts/src/terminal.ts+6
...@@ -5,6 +5,11 @@ export const DEFAULT_TERMINAL_ID = "default";...@@ -5,6 +5,11 @@ export const DEFAULT_TERMINAL_ID = "default";
5const terminalColsSchema = z.number().int().min(20).max(400);5const terminalColsSchema = z.number().int().min(20).max(400);
6const terminalRowsSchema = z.number().int().min(5).max(200);6const terminalRowsSchema = z.number().int().min(5).max(200);
7const terminalIdSchema = z.string().trim().min(1).max(128);7const terminalIdSchema = z.string().trim().min(1).max(128);
8const terminalEnvKeySchema = z.string().regex(/^[A-Za-z_][A-Za-z0-9_]*$/).max(128);
9const terminalEnvValueSchema = z.string().max(8_192);
10const terminalEnvSchema = z
11 .record(terminalEnvKeySchema, terminalEnvValueSchema)
12 .refine((env) => Object.keys(env).length <= 128, "Too many terminal env vars");
813
9export const terminalThreadInputSchema = z.object({14export const terminalThreadInputSchema = z.object({
10 threadId: z.string().trim().min(1),15 threadId: z.string().trim().min(1),
...@@ -18,6 +23,7 @@ export const terminalOpenInputSchema = terminalSessionInputSchema.extend({...@@ -18,6 +23,7 @@ export const terminalOpenInputSchema = terminalSessionInputSchema.extend({
18 cwd: z.string().trim().min(1),23 cwd: z.string().trim().min(1),
19 cols: terminalColsSchema,24 cols: terminalColsSchema,
20 rows: terminalRowsSchema,25 rows: terminalRowsSchema,
26 env: terminalEnvSchema.optional(),
21});27});
2228
23export const terminalWriteInputSchema = terminalSessionInputSchema.extend({29export const terminalWriteInputSchema = terminalSessionInputSchema.extend({