History

Fix terminal attach session launch context

Julius Marminge committed 6 months ago

Commit 93ec45e1 parent b4615a89 files · +249 −63Browse files

Unified · Split

9 files
  1. apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx +1 −0
  2. apps/server/src/terminal/Layers/Manager.test.ts +79 −0
  3. apps/server/src/terminal/Layers/Manager.ts +1 −1
  4. apps/web/src/components/ChatView.browser.tsx +6 −6
  5. apps/web/src/components/ChatView.tsx +33 −0
  6. apps/web/src/components/ThreadTerminalDrawer.browser.tsx +43 −21
  7. apps/web/src/components/ThreadTerminalDrawer.tsx +71 −35
  8. packages/contracts/src/terminal.test.ts +14 −0
  9. packages/contracts/src/terminal.ts +1 −0
apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx+1−0
@@ -509,6 +509,7 @@ export function ThreadTerminalRouteScreen() { · 508 unmodified lines
509 cols: lastGridSize.cols,509 cols: lastGridSize.cols,
510 rows: lastGridSize.rows,510 rows: lastGridSize.rows,
511 env: pendingLaunch?.env,511 env: pendingLaunch?.env,
512 ...(pendingLaunch ? { restartIfNotRunning: true } : {}),
512 },513 },
513 onEvent: logAttachStreamEvent,514 onEvent: logAttachStreamEvent,
514 onSnapshot: () => {515 onSnapshot: () => {
apps/server/src/terminal/Layers/Manager.test.ts+79−0
@@ -312,6 +312,85 @@ it.layer(NodeServices.layer, { excludeTestServices: true })("TerminalManager", ( · 311 unmodified lines
312 }),312 }),
313 );313 );
314 314
315 it.effect("attaches to exited sessions without restarting them", () =>
316 Effect.gen(function* () {
317 const { manager, ptyAdapter, getEvents } = yield* createManager();
318
319 yield* manager.open(openInput());
320 const process = ptyAdapter.processes[0];
321 expect(process).toBeDefined();
322 if (!process) return;
323
324 process.emitExit({ exitCode: 0, signal: 0 });
325
326 yield* waitFor(
327 Effect.map(getEvents, (events) => events.some((event) => event.type === "exited")),
328 "1200 millis",
329 );
330
331 const scope = yield* Effect.scope;
332 const attachEvents = yield* Ref.make<ReadonlyArray<TerminalAttachStreamEvent>>([]);
333 const unsubscribe = yield* manager.attachStream(
334 openInput({
335 env: {
336 T3CODE_WORKTREE_PATH: "/tmp/should-not-restart",
337 },
338 worktreePath: "/tmp/should-not-restart",
339 }),
340 (event) => Ref.update(attachEvents, (events) => [...events, event]),
341 );
342 yield* Scope.addFinalizer(scope, Effect.sync(unsubscribe));
343
344 const snapshot = (yield* Ref.get(attachEvents)).find((event) => event.type === "snapshot");
345 expect(snapshot).toBeDefined();
346 if (!snapshot || snapshot.type !== "snapshot") return;
347 assert.equal(snapshot.snapshot.status, "exited");
348 assert.equal(snapshot.snapshot.worktreePath, null);
349 expect(ptyAdapter.spawnInputs).toHaveLength(1);
350 }),
351 );
352
353 it.effect("restarts inactive sessions from attach only when requested", () =>
354 Effect.gen(function* () {
355 const { manager, ptyAdapter, getEvents } = yield* createManager();
356
357 yield* manager.open(openInput());
358 const process = ptyAdapter.processes[0];
359 expect(process).toBeDefined();
360 if (!process) return;
361
362 process.emitExit({ exitCode: 0, signal: 0 });
363
364 yield* waitFor(
365 Effect.map(getEvents, (events) => events.some((event) => event.type === "exited")),
366 "1200 millis",
367 );
368
369 const scope = yield* Effect.scope;
370 const attachEvents = yield* Ref.make<ReadonlyArray<TerminalAttachStreamEvent>>([]);
371 const unsubscribe = yield* manager.attachStream(
372 {
373 ...openInput({
374 env: {
375 T3CODE_WORKTREE_PATH: "/tmp/restart-requested",
376 },
377 worktreePath: "/tmp/restart-requested",
378 }),
379 restartIfNotRunning: true,
380 },
381 (event) => Ref.update(attachEvents, (events) => [...events, event]),
382 );
383 yield* Scope.addFinalizer(scope, Effect.sync(unsubscribe));
384
385 const snapshot = (yield* Ref.get(attachEvents)).find((event) => event.type === "snapshot");
386 expect(snapshot).toBeDefined();
387 if (!snapshot || snapshot.type !== "snapshot") return;
388 assert.equal(snapshot.snapshot.status, "running");
389 assert.equal(snapshot.snapshot.worktreePath, "/tmp/restart-requested");
390 expect(ptyAdapter.spawnInputs).toHaveLength(2);
391 }),
392 );
393
315 const makeDirectory = (filePath: string) =>394 const makeDirectory = (filePath: string) =>
316 Effect.flatMap(Effect.service(FileSystem.FileSystem), (fs) =>395 Effect.flatMap(Effect.service(FileSystem.FileSystem), (fs) =>
317 fs.makeDirectory(filePath, { recursive: true }),396 fs.makeDirectory(filePath, { recursive: true }),
apps/server/src/terminal/Layers/Manager.ts+1−1
@@ -1995,7 +1995,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith · 1994 unmodified lines
1995 const targetCols = input.cols ?? session.cols;1995 const targetCols = input.cols ?? session.cols;
1996 const targetRows = input.rows ?? session.rows;1996 const targetRows = input.rows ?? session.rows;
1997 1997
1998 if (!session.process && input.cwd) {1998 if (!session.process && input.cwd && input.restartIfNotRunning === true) {
1999 return yield* openLocked({1999 return yield* openLocked({
2000 ...input,2000 ...input,
2001 terminalId,2001 terminalId,
apps/web/src/components/ChatView.browser.tsx+6−6
@@ -1811,9 +1811,9 @@ describe("ChatView timeline estimator parity (full app)", () => { · 1810 unmodified lines
1811 try {1811 try {
1812 await vi.waitFor(1812 await vi.waitFor(
1813 () => {1813 () => {
1814 const openRequest = wsRequests.find(1814 const attachRequest = wsRequests
1815 (request) => request._tag === WS_METHODS.terminalOpen,1815 .toReversed()
1816 ) as1816 .find((request) => request._tag === WS_METHODS.terminalAttach) as
1817 | {1817 | {
1818 _tag: string;1818 _tag: string;
1819 cwd?: string;1819 cwd?: string;
@@ -1821,15 +1821,15 @@ describe("ChatView timeline estimator parity (full app)", () => { · 1 unmodified line
1821 env?: Record<string, string>;1821 env?: Record<string, string>;
1822 }1822 }
1823 | undefined;1823 | undefined;
1824 expect(openRequest).toMatchObject({1824 expect(attachRequest).toMatchObject({
1825 _tag: WS_METHODS.terminalOpen,1825 _tag: WS_METHODS.terminalAttach,
1826 cwd: "/repo/project",1826 cwd: "/repo/project",
1827 worktreePath: null,1827 worktreePath: null,
1828 env: {1828 env: {
1829 T3CODE_PROJECT_ROOT: "/repo/project",1829 T3CODE_PROJECT_ROOT: "/repo/project",
1830 },1830 },
1831 });1831 });
1832 expect(openRequest?.env?.T3CODE_WORKTREE_PATH).toBeUndefined();1832 expect(attachRequest?.env?.T3CODE_WORKTREE_PATH).toBeUndefined();
1833 },1833 },
1834 { timeout: 8_000, interval: 16 },1834 { timeout: 8_000, interval: 16 },
1835 );1835 );
apps/web/src/components/ChatView.tsx+33−0
@@ -501,6 +501,38 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra · 500 unmodified lines
501 }501 }
502 return next;502 return next;
503 }, [knownTerminalSessions]);503 }, [knownTerminalSessions]);
504 const terminalLaunchLocationsById = useMemo(() => {
505 const next = new Map<
506 string,
507 {
508 readonly cwd: string;
509 readonly worktreePath: string | null;
510 readonly runtimeEnv: Record<string, string>;
511 }
512 >();
513 if (!project) {
514 return next;
515 }
516
517 for (const session of knownTerminalSessions) {
518 const summary = session.state.summary;
519 if (!summary) {
520 continue;
521 }
522 const worktreePathForLaunch =
523 launchContext !== null ? launchContext.worktreePath : summary.worktreePath;
524 next.set(session.target.terminalId, {
525 cwd: launchContext?.cwd ?? summary.cwd,
526 worktreePath: worktreePathForLaunch,
527 runtimeEnv: projectScriptRuntimeEnv({
528 project: { cwd: project.cwd },
529 worktreePath: worktreePathForLaunch,
530 }),
531 });
532 }
533
534 return next;
535 }, [knownTerminalSessions, launchContext, project]);
504 const serverOrderedTerminalIds = useMemo(536 const serverOrderedTerminalIds = useMemo(
505 () => knownTerminalSessions.map((session) => session.target.terminalId),537 () => knownTerminalSessions.map((session) => session.target.terminalId),
506 [knownTerminalSessions],538 [knownTerminalSessions],
@@ -709,6 +741,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra · 202 unmodified lines
709 onHeightChange={setTerminalHeight}741 onHeightChange={setTerminalHeight}
710 onAddTerminalContext={handleAddTerminalContext}742 onAddTerminalContext={handleAddTerminalContext}
711 terminalLabelsById={terminalLabelsById}743 terminalLabelsById={terminalLabelsById}
744 terminalLaunchLocationsById={terminalLaunchLocationsById}
712 />745 />
713 </div>746 </div>
714 );747 );
apps/web/src/components/ThreadTerminalDrawer.browser.tsx+43−21
@@ -1,7 +1,7 @@
1import "../index.css";1import "../index.css";
2 2
3import { scopeThreadRef } from "@t3tools/client-runtime";3import { scopeThreadRef } from "@t3tools/client-runtime";
4import { ThreadId } from "@t3tools/contracts";4import { ThreadId, type TerminalAttachStreamEvent } from "@t3tools/contracts";
5import { afterEach, describe, expect, it, vi } from "vitest";5import { afterEach, describe, expect, it, vi } from "vitest";
6import { render } from "vitest-browser-react";6import { render } from "vitest-browser-react";
7 7
@@ -18,7 +18,17 @@ const { · 10 unmodified lines
18 terminalDisposeSpy: vi.fn(),18 terminalDisposeSpy: vi.fn(),
19 fitAddonFitSpy: vi.fn(),19 fitAddonFitSpy: vi.fn(),
20 fitAddonLoadSpy: vi.fn(),20 fitAddonLoadSpy: vi.fn(),
21 environmentApiById: new Map<string, { terminal: { open: ReturnType<typeof vi.fn> } }>(),21 environmentApiById: new Map<
22 string,
23 {
24 terminal: {
25 open: ReturnType<typeof vi.fn>;
26 attach: ReturnType<typeof vi.fn>;
27 write: ReturnType<typeof vi.fn>;
28 resize: ReturnType<typeof vi.fn>;
29 };
30 }
31 >(),
22 readEnvironmentApiMock: vi.fn((environmentId: string) => environmentApiById.get(environmentId)),32 readEnvironmentApiMock: vi.fn((environmentId: string) => environmentApiById.get(environmentId)),
23 readLocalApiMock: vi.fn<33 readLocalApiMock: vi.fn<
24 () =>34 () =>
@@ -124,21 +134,33 @@ import { TerminalViewport } from "./ThreadTerminalDrawer"; · 99 unmodified lines
124const THREAD_ID = ThreadId.make("thread-terminal-browser");134const THREAD_ID = ThreadId.make("thread-terminal-browser");
125 135
126function createEnvironmentApi() {136function createEnvironmentApi() {
137 const snapshot = {
138 threadId: THREAD_ID,
139 terminalId: "term-1",
140 cwd: "/repo/project",
141 worktreePath: null,
142 status: "running" as const,
143 pid: 123,
144 history: "",
145 exitCode: null,
146 exitSignal: null,
147 label: "Terminal 1",
148 updatedAt: "2026-04-07T00:00:00.000Z",
149 };
150
127 return {151 return {
128 terminal: {152 terminal: {
129 open: vi.fn(async () => ({153 open: vi.fn(async () => snapshot),
130 threadId: THREAD_ID,154 attach: vi.fn(
131 terminalId: "term-1",155 (
132 cwd: "/repo/project",156 _input: unknown,
133 worktreePath: null,157 listener: (event: TerminalAttachStreamEvent) => void,
134 status: "running" as const,158 _options?: unknown,
135 pid: 123,159 ) => {
136 history: "",160 listener({ type: "snapshot", snapshot });
137 exitCode: null,161 return vi.fn();
138 exitSignal: null,162 },
139 label: "Terminal 1",163 ),
140 updatedAt: "2026-04-07T00:00:00.000Z",
141 })),
142 write: vi.fn(async () => undefined),164 write: vi.fn(async () => undefined),
143 resize: vi.fn(async () => undefined),165 resize: vi.fn(async () => undefined),
144 },166 },
@@ -237,7 +259,7 @@ describe("TerminalViewport", () => { · 92 unmodified lines
237 }259 }
238 });260 });
239 261
240 it("reopens the terminal when the scoped thread reference changes", async () => {262 it("reattaches the terminal when the scoped thread reference changes", async () => {
241 const environmentA = createEnvironmentApi();263 const environmentA = createEnvironmentApi();
242 const environmentB = createEnvironmentApi();264 const environmentB = createEnvironmentApi();
243 environmentApiById.set("environment-a", environmentA);265 environmentApiById.set("environment-a", environmentA);
@@ -249,7 +271,7 @@ describe("TerminalViewport", () => { · 5 unmodified lines
249 271
250 try {272 try {
251 await vi.waitFor(() => {273 await vi.waitFor(() => {
252 expect(environmentA.terminal.open).toHaveBeenCalledTimes(1);274 expect(environmentA.terminal.attach).toHaveBeenCalledTimes(1);
253 });275 });
254 276
255 await mounted.rerender({277 await mounted.rerender({
@@ -257,7 +279,7 @@ describe("TerminalViewport", () => { · 1 unmodified line
257 });279 });
258 280
259 await vi.waitFor(() => {281 await vi.waitFor(() => {
260 expect(environmentB.terminal.open).toHaveBeenCalledTimes(1);282 expect(environmentB.terminal.attach).toHaveBeenCalledTimes(1);
261 });283 });
262 expect(terminalDisposeSpy).toHaveBeenCalledTimes(1);284 expect(terminalDisposeSpy).toHaveBeenCalledTimes(1);
263 } finally {285 } finally {
@@ -265,7 +287,7 @@ describe("TerminalViewport", () => { · 1 unmodified line
265 }287 }
266 });288 });
267 289
268 it("does not reopen the terminal when the scoped thread reference values stay the same", async () => {290 it("does not reattach the terminal when the scoped thread reference values stay the same", async () => {
269 const environment = createEnvironmentApi();291 const environment = createEnvironmentApi();
270 environmentApiById.set("environment-a", environment);292 environmentApiById.set("environment-a", environment);
271 293
@@ -275,7 +297,7 @@ describe("TerminalViewport", () => { · 3 unmodified lines
275 297
276 try {298 try {
277 await vi.waitFor(() => {299 await vi.waitFor(() => {
278 expect(environment.terminal.open).toHaveBeenCalledTimes(1);300 expect(environment.terminal.attach).toHaveBeenCalledTimes(1);
279 });301 });
280 302
281 await mounted.rerender({303 await mounted.rerender({
@@ -283,7 +305,7 @@ describe("TerminalViewport", () => { · 1 unmodified line
283 });305 });
284 306
285 await vi.waitFor(() => {307 await vi.waitFor(() => {
286 expect(environment.terminal.open).toHaveBeenCalledTimes(1);308 expect(environment.terminal.attach).toHaveBeenCalledTimes(1);
287 });309 });
288 expect(terminalDisposeSpy).not.toHaveBeenCalled();310 expect(terminalDisposeSpy).not.toHaveBeenCalled();
289 } finally {311 } finally {
apps/web/src/components/ThreadTerminalDrawer.tsx+71−35
@@ -250,6 +250,12 @@ interface TerminalViewportProps { · 249 unmodified lines
250 keybindings: ResolvedKeybindingsConfig;250 keybindings: ResolvedKeybindingsConfig;
251}251}
252 252
253interface TerminalLaunchLocation {
254 readonly cwd: string;
255 readonly worktreePath?: string | null;
256 readonly runtimeEnv?: Record<string, string>;
257}
258
253export function TerminalViewport({259export function TerminalViewport({
254 threadRef,260 threadRef,
255 threadId,261 threadId,
@@ -694,7 +700,7 @@ export function TerminalViewport({ · 438 unmodified lines
694 // autoFocus is intentionally omitted;700 // autoFocus is intentionally omitted;
695 // it is only read at mount time and must not trigger terminal teardown/recreation.701 // it is only read at mount time and must not trigger terminal teardown/recreation.
696 // eslint-disable-next-line react-hooks/exhaustive-deps702 // eslint-disable-next-line react-hooks/exhaustive-deps
697 }, [cwd, environmentId, runtimeEnv, terminalId, threadId]);703 }, [cwd, environmentId, runtimeEnv, terminalId, threadId, worktreePath]);
698 704
699 useEffect(() => {705 useEffect(() => {
700 if (!autoFocus) return;706 if (!autoFocus) return;
@@ -765,6 +771,8 @@ interface ThreadTerminalDrawerProps { · 64 unmodified lines
765 keybindings: ResolvedKeybindingsConfig;771 keybindings: ResolvedKeybindingsConfig;
766 /** Prefer server-provided tab titles when present (e.g. active subprocess name). */772 /** Prefer server-provided tab titles when present (e.g. active subprocess name). */
767 terminalLabelsById?: ReadonlyMap<string, string>;773 terminalLabelsById?: ReadonlyMap<string, string>;
774 /** Prefer per-session launch locations when the server already knows a terminal. */
775 terminalLaunchLocationsById?: ReadonlyMap<string, TerminalLaunchLocation>;
768}776}
769 777
770interface TerminalActionButtonProps {778interface TerminalActionButtonProps {
@@ -820,6 +828,7 @@ export default function ThreadTerminalDrawer({ · 49 unmodified lines
820 onAddTerminalContext,828 onAddTerminalContext,
821 keybindings,829 keybindings,
822 terminalLabelsById,830 terminalLabelsById,
831 terminalLaunchLocationsById,
823}: ThreadTerminalDrawerProps) {832}: ThreadTerminalDrawerProps) {
824 const [drawerHeight, setDrawerHeight] = useState(() => clampDrawerHeight(height));833 const [drawerHeight, setDrawerHeight] = useState(() => clampDrawerHeight(height));
825 const [resizeEpoch, setResizeEpoch] = useState(0);834 const [resizeEpoch, setResizeEpoch] = useState(0);
@@ -938,6 +947,18 @@ export default function ThreadTerminalDrawer({ · 112 unmodified lines
938 }947 }
939 return next;948 return next;
940 }, [normalizedTerminalIds, terminalLabelsById]);949 }, [normalizedTerminalIds, terminalLabelsById]);
950 const resolveTerminalLaunchLocation = useCallback(
951 (terminalId: string): TerminalLaunchLocation => {
952 return (
953 terminalLaunchLocationsById?.get(terminalId) ?? {
954 cwd,
955 ...(worktreePath !== undefined ? { worktreePath } : {}),
956 ...(runtimeEnv ? { runtimeEnv } : {}),
957 }
958 );
959 },
960 [cwd, runtimeEnv, terminalLaunchLocationsById, worktreePath],
961 );
941 const splitTerminalActionLabel = hasReachedSplitLimit962 const splitTerminalActionLabel = hasReachedSplitLimit
942 ? `Split Terminal (max ${MAX_TERMINALS_PER_GROUP} per group)`963 ? `Split Terminal (max ${MAX_TERMINALS_PER_GROUP} per group)`
943 : splitShortcutLabel964 : splitShortcutLabel
@@ -1086,6 +1107,8 @@ export default function ThreadTerminalDrawer({ · 142 unmodified lines
1086 );1107 );
1087 }1108 }
1088 1109
1110 const activeTerminalLaunchLocation = resolveTerminalLaunchLocation(resolvedActiveTerminalId);
1111
1089 return (1112 return (
1090 <aside1113 <aside
1091 className="thread-terminal-drawer relative flex min-w-0 shrink-0 flex-col overflow-hidden border-t border-border/80 bg-background"1114 className="thread-terminal-drawer relative flex min-w-0 shrink-0 flex-col overflow-hidden border-t border-border/80 bg-background"
@@ -1143,38 +1166,47 @@ export default function ThreadTerminalDrawer({ · 51 unmodified lines
1143 gridTemplateColumns: `repeat(${visibleTerminalIds.length}, minmax(0, 1fr))`,1166 gridTemplateColumns: `repeat(${visibleTerminalIds.length}, minmax(0, 1fr))`,
1144 }}1167 }}
1145 >1168 >
1146 {visibleTerminalIds.map((terminalId) => (1169 {visibleTerminalIds.map((terminalId) => {
1147 <div1170 const terminalLaunchLocation = resolveTerminalLaunchLocation(terminalId);
1148 key={terminalId}1171 return (
1149 className={`min-h-0 min-w-0 border-l first:border-l-0 ${1172 <div
1150 terminalId === resolvedActiveTerminalId ? "border-border" : "border-border/70"1173 key={terminalId}
1151 }`}1174 className={`min-h-0 min-w-0 border-l first:border-l-0 ${
1152 onMouseDown={() => {1175 terminalId === resolvedActiveTerminalId
1153 if (terminalId !== resolvedActiveTerminalId) {1176 ? "border-border"
1154 onActiveTerminalChange(terminalId);1177 : "border-border/70"
1155 }1178 }`}
1156 }}1179 onMouseDown={() => {
1157 >1180 if (terminalId !== resolvedActiveTerminalId) {
1158 <div className="h-full p-1">1181 onActiveTerminalChange(terminalId);
1159 <TerminalViewport1182 }
1160 threadRef={threadRef}1183 }}
1161 threadId={threadId}1184 >
1162 terminalId={terminalId}1185 <div className="h-full p-1">
1163 terminalLabel={terminalLabelById.get(terminalId) ?? "Terminal"}1186 <TerminalViewport
1164 cwd={cwd}1187 threadRef={threadRef}
1165 {...(worktreePath !== undefined ? { worktreePath } : {})}1188 threadId={threadId}
1166 {...(runtimeEnv ? { runtimeEnv } : {})}1189 terminalId={terminalId}
1167 onSessionExited={() => onCloseTerminal(terminalId)}1190 terminalLabel={terminalLabelById.get(terminalId) ?? "Terminal"}
1168 onAddTerminalContext={onAddTerminalContext}1191 cwd={terminalLaunchLocation.cwd}
1169 focusRequestId={focusRequestId}1192 {...(terminalLaunchLocation.worktreePath !== undefined
1170 autoFocus={terminalId === resolvedActiveTerminalId}1193 ? { worktreePath: terminalLaunchLocation.worktreePath }
1171 resizeEpoch={resizeEpoch}1194 : {})}
1172 drawerHeight={drawerHeight}1195 {...(terminalLaunchLocation.runtimeEnv
1173 keybindings={keybindings}1196 ? { runtimeEnv: terminalLaunchLocation.runtimeEnv }
1174 />1197 : {})}
1198 onSessionExited={() => onCloseTerminal(terminalId)}
1199 onAddTerminalContext={onAddTerminalContext}
1200 focusRequestId={focusRequestId}
1201 autoFocus={terminalId === resolvedActiveTerminalId}
1202 resizeEpoch={resizeEpoch}
1203 drawerHeight={drawerHeight}
1204 keybindings={keybindings}
1205 />
1206 </div>
1175 </div>1207 </div>
1176 </div>1208 );
1177 ))}1209 })}
1178 </div>1210 </div>
1179 ) : (1211 ) : (
1180 <div className="h-full p-1">1212 <div className="h-full p-1">
@@ -1184,9 +1216,13 @@ export default function ThreadTerminalDrawer({ · 3 unmodified lines
1184 threadId={threadId}1216 threadId={threadId}
1185 terminalId={resolvedActiveTerminalId}1217 terminalId={resolvedActiveTerminalId}
1186 terminalLabel={terminalLabelById.get(resolvedActiveTerminalId) ?? "Terminal"}1218 terminalLabel={terminalLabelById.get(resolvedActiveTerminalId) ?? "Terminal"}
1187 cwd={cwd}1219 cwd={activeTerminalLaunchLocation.cwd}
1188 {...(worktreePath !== undefined ? { worktreePath } : {})}1220 {...(activeTerminalLaunchLocation.worktreePath !== undefined
1189 {...(runtimeEnv ? { runtimeEnv } : {})}1221 ? { worktreePath: activeTerminalLaunchLocation.worktreePath }
1222 : {})}
1223 {...(activeTerminalLaunchLocation.runtimeEnv
1224 ? { runtimeEnv: activeTerminalLaunchLocation.runtimeEnv }
1225 : {})}
1190 onSessionExited={() => onCloseTerminal(resolvedActiveTerminalId)}1226 onSessionExited={() => onCloseTerminal(resolvedActiveTerminalId)}
1191 onAddTerminalContext={onAddTerminalContext}1227 onAddTerminalContext={onAddTerminalContext}
1192 focusRequestId={focusRequestId}1228 focusRequestId={focusRequestId}
packages/contracts/src/terminal.test.ts+14−0
@@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest"; · 2 unmodified lines
3 3
4import {4import {
5 DEFAULT_TERMINAL_ID,5 DEFAULT_TERMINAL_ID,
6 TerminalAttachInput,
6 TerminalClearInput,7 TerminalClearInput,
7 TerminalCloseInput,8 TerminalCloseInput,
8 TerminalEvent,9 TerminalEvent,
@@ -109,6 +110,19 @@ describe("TerminalOpenInput", () => { · 100 unmodified lines
109 });110 });
110});111});
111 112
113describe("TerminalAttachInput", () => {
114 it("accepts explicit inactive-session restart intent", () => {
115 const parsed = decodeSync(TerminalAttachInput, {
116 threadId: "thread-1",
117 terminalId: DEFAULT_TERMINAL_ID,
118 cwd: "/tmp/project",
119 restartIfNotRunning: true,
120 });
121
122 expect(parsed.restartIfNotRunning).toBe(true);
123 });
124});
125
112describe("TerminalWriteInput", () => {126describe("TerminalWriteInput", () => {
113 it("accepts non-empty data", () => {127 it("accepts non-empty data", () => {
114 expect(128 expect(
packages/contracts/src/terminal.ts+1−0
@@ -53,6 +53,7 @@ export const TerminalAttachInput = Schema.Struct({ · 52 unmodified lines
53 cols: Schema.optional(TerminalColsSchema),53 cols: Schema.optional(TerminalColsSchema),
54 rows: Schema.optional(TerminalRowsSchema),54 rows: Schema.optional(TerminalRowsSchema),
55 env: Schema.optional(TerminalEnvSchema),55 env: Schema.optional(TerminalEnvSchema),
56 restartIfNotRunning: Schema.optional(Schema.Boolean),
56});57});
57export type TerminalAttachInput = Schema.Codec.Encoded<typeof TerminalAttachInput>;58export type TerminalAttachInput = Schema.Codec.Encoded<typeof TerminalAttachInput>;
58 59