From 066b5bcdac250fe29ce3f6edd11f7766716c71df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Thu, 18 Jun 2026 12:16:33 +0100 Subject: [PATCH] Port terminal-backed project actions --- .../src/terminal/Layers/Manager.test.ts | 57 ++++- apps/server/src/terminal/Layers/Manager.ts | 119 +++++++-- apps/web/src/components/ChatView.browser.tsx | 159 +++++++++++- apps/web/src/components/ChatView.tsx | 85 ++++--- apps/web/src/projectScriptTerminals.test.ts | 236 ++++++++++++++++++ apps/web/src/projectScriptTerminals.ts | 211 ++++++++++++++++ packages/shared/src/terminalLabels.test.ts | 5 + packages/shared/src/terminalLabels.ts | 5 + 8 files changed, 819 insertions(+), 58 deletions(-) create mode 100644 apps/web/src/projectScriptTerminals.test.ts create mode 100644 apps/web/src/projectScriptTerminals.ts diff --git a/apps/server/src/terminal/Layers/Manager.test.ts b/apps/server/src/terminal/Layers/Manager.test.ts index 8b5aa3adbcd..67dfae97ac6 100644 --- a/apps/server/src/terminal/Layers/Manager.test.ts +++ b/apps/server/src/terminal/Layers/Manager.test.ts @@ -35,7 +35,7 @@ import { type PtySpawnInput, PtySpawnError, } from "../Services/PTY.ts"; -import { makeTerminalManagerWithOptions } from "./Manager.ts"; +import { __testing, makeTerminalManagerWithOptions } from "./Manager.ts"; class WaitForConditionError extends Data.TaggedError("WaitForConditionError")<{ readonly message: string; @@ -786,6 +786,61 @@ it.layer( }), ); + it("treats an idle POSIX login shell child as no running subprocess", () => { + expect( + __testing.inspectPosixProcessTree({ + terminalPid: 9000, + childPid: 9001, + childCommand: "bash", + platform: "darwin", + processTable: [ + { pid: 9000, ppid: 1, command: "node-pty" }, + { pid: 9001, ppid: 9000, command: "bash" }, + ], + }), + ).toEqual({ hasRunningSubprocess: false, childCommand: null, processIds: [] }); + }); + + it("reports the first non-shell POSIX descendant as the running subprocess", () => { + expect( + __testing.inspectPosixProcessTree({ + terminalPid: 9000, + childPid: 9001, + childCommand: "bash", + platform: "darwin", + processTable: [ + { pid: 9000, ppid: 1, command: "node-pty" }, + { pid: 9001, ppid: 9000, command: "bash" }, + { pid: 9002, ppid: 9001, command: "pnpm" }, + { pid: 9003, ppid: 9002, command: "node" }, + ], + }), + ).toEqual({ + hasRunningSubprocess: true, + childCommand: "pnpm", + processIds: [9000, 9001, 9002, 9003], + }); + }); + + it("keeps a non-shell direct POSIX child marked as running", () => { + expect( + __testing.inspectPosixProcessTree({ + terminalPid: 9000, + childPid: 9001, + childCommand: "vim", + platform: "darwin", + processTable: [ + { pid: 9000, ppid: 1, command: "node-pty" }, + { pid: 9001, ppid: 9000, command: "vim" }, + ], + }), + ).toEqual({ + hasRunningSubprocess: true, + childCommand: "vim", + processIds: [9000, 9001], + }); + }); + it.effect("does not invoke subprocess polling until a terminal session is running", () => Effect.gen(function* () { let checks = 0; diff --git a/apps/server/src/terminal/Layers/Manager.ts b/apps/server/src/terminal/Layers/Manager.ts index e33d9b4b290..c8134ca7be9 100644 --- a/apps/server/src/terminal/Layers/Manager.ts +++ b/apps/server/src/terminal/Layers/Manager.ts @@ -61,6 +61,16 @@ const DEFAULT_OPEN_ROWS = 30; const TERMINAL_ENV_BLOCKLIST = new Set(["PORT", "ELECTRON_RENDERER_PORT", "ELECTRON_RUN_AS_NODE"]); const nowIso = Effect.map(DateTime.now, DateTime.formatIso); const MAX_TERMINAL_LABEL_LENGTH = 128; +const INTERACTIVE_SHELL_COMMANDS = new Set([ + "bash", + "csh", + "fish", + "powershell", + "pwsh", + "sh", + "tcsh", + "zsh", +]); class TerminalSubprocessCheckError extends Schema.TaggedErrorClass()( "TerminalSubprocessCheckError", @@ -190,6 +200,75 @@ function normalizeChildCommandName(raw: string, platform: NodeJS.Platform): stri return withoutExe.length > 0 ? withoutExe : null; } +function isInteractiveShellCommand(command: string | null): boolean { + return command !== null && INTERACTIVE_SHELL_COMMANDS.has(command.trim().toLowerCase()); +} + +interface ProcessTableEntry { + readonly pid: number; + readonly ppid: number; + readonly command: string | null; +} + +function inspectPosixProcessTree(input: { + readonly terminalPid: number; + readonly childPid: number; + readonly childCommand: string | null; + readonly platform: NodeJS.Platform; + readonly processTable: ReadonlyArray; +}): TerminalSubprocessInspectResult { + const childrenByParent = new Map(); + const commandByPid = new Map(); + for (const entry of input.processTable) { + const children = childrenByParent.get(entry.ppid) ?? []; + children.push(entry.pid); + childrenByParent.set(entry.ppid, children); + if (entry.command !== null && entry.command.trim().length > 0) { + commandByPid.set(entry.pid, entry.command); + } + } + + const processIds = new Set([input.terminalPid]); + const pending = [input.terminalPid]; + while (pending.length > 0) { + const parentPid = pending.pop(); + if (parentPid === undefined) continue; + for (const child of childrenByParent.get(parentPid) ?? []) { + if (processIds.has(child)) continue; + processIds.add(child); + pending.push(child); + } + } + + let normalized = input.childCommand; + if (isInteractiveShellCommand(normalized)) { + normalized = null; + const descendantPending = [...(childrenByParent.get(input.childPid) ?? [])]; + while (descendantPending.length > 0) { + const pid = descendantPending.shift(); + if (pid === undefined) continue; + const descendantCommand = normalizeChildCommandName( + commandByPid.get(pid) ?? "", + input.platform, + ); + if (descendantCommand !== null && !isInteractiveShellCommand(descendantCommand)) { + normalized = descendantCommand; + break; + } + descendantPending.push(...(childrenByParent.get(pid) ?? [])); + } + if (normalized === null) { + return { hasRunningSubprocess: false, childCommand: null, processIds: [] }; + } + } + + return { + hasRunningSubprocess: true, + childCommand: normalized ? truncateTerminalWireLabel(normalized) : null, + processIds: [...processIds], + }; +} + function terminalWireLabel(session: TerminalSessionState): string { if (session.hasRunningSubprocess && session.childCommandLabel) { const trimmed = session.childCommandLabel.trim(); @@ -605,7 +684,7 @@ const posixInspectSubprocess = Effect.fn("terminal.posixInspectSubprocess")(func const runPs = processRunner .run({ command: "ps", - args: ["-eo", "pid=,ppid="], + args: ["-eo", "pid=,ppid=,comm="], timeout: "1 second", maxOutputBytes: 262_144, outputMode: "truncate", @@ -687,36 +766,32 @@ const posixInspectSubprocess = Effect.fn("terminal.posixInspectSubprocess")(func } const normalized = rawComm ? normalizeChildCommandName(rawComm, platform) : null; - const processIds = new Set([terminalPid]); const psResult = yield* Effect.exit(runPs); if (psResult._tag === "Success" && psResult.value.code === 0) { - const childrenByParent = new Map(); + const processTable: ProcessTableEntry[] = []; for (const line of psResult.value.stdout.split(/\r?\n/g)) { - const [pidRaw, ppidRaw] = line.trim().split(/\s+/g); + const [pidRaw, ppidRaw, ...commandParts] = line.trim().split(/\s+/g); const pid = Number(pidRaw); const ppid = Number(ppidRaw); if (!Number.isInteger(pid) || !Number.isInteger(ppid)) continue; - const children = childrenByParent.get(ppid) ?? []; - children.push(pid); - childrenByParent.set(ppid, children); - } - const pending = [terminalPid]; - while (pending.length > 0) { - const parentPid = pending.pop(); - if (parentPid === undefined) continue; - for (const child of childrenByParent.get(parentPid) ?? []) { - if (processIds.has(child)) continue; - processIds.add(child); - pending.push(child); - } + const command = commandParts.join(" ").trim(); + processTable.push({ pid, ppid, command: command.length > 0 ? command : null }); } - } else { - processIds.add(childPid); + return inspectPosixProcessTree({ + terminalPid, + childPid, + childCommand: normalized, + platform, + processTable, + }); + } + if (isInteractiveShellCommand(normalized)) { + return { hasRunningSubprocess: false, childCommand: null, processIds: [] }; } return { hasRunningSubprocess: true, childCommand: normalized ? truncateTerminalWireLabel(normalized) : null, - processIds: [...processIds], + processIds: [terminalPid, childPid], }; }); @@ -2482,3 +2557,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith export const TerminalManagerLive = Layer.effect(TerminalManager, makeTerminalManager()).pipe( Layer.provide(ProcessRunner.layer), ); + +export const __testing = { + inspectPosixProcessTree, +}; diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 0bb881a8fac..88755d9c6d5 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -1788,6 +1788,31 @@ describe("ChatView timeline estimator parity (full app)", () => { if (request._tag === WS_METHODS.subscribeTerminalMetadata) { return fixture.terminalMetadataEvents; } + if (request._tag === WS_METHODS.terminalAttach) { + return [ + { + type: "snapshot", + snapshot: { + threadId: typeof request.threadId === "string" ? request.threadId : THREAD_ID, + terminalId: typeof request.terminalId === "string" ? request.terminalId : "default", + cwd: typeof request.cwd === "string" ? request.cwd : "/repo/project", + worktreePath: + typeof request.worktreePath === "string" + ? request.worktreePath + : request.worktreePath === null + ? null + : null, + status: "running", + pid: 123, + history: "$ ", + exitCode: null, + exitSignal: null, + label: "Terminal 1", + updatedAt: NOW_ISO, + }, + }, + ]; + } return []; }, }); @@ -3251,12 +3276,13 @@ describe("ChatView timeline estimator parity (full app)", () => { await vi.waitFor( () => { - const openRequest = wsRequests.find( - (request) => request._tag === WS_METHODS.terminalOpen, + const attachRequest = wsRequests.find( + (request) => request._tag === WS_METHODS.terminalAttach, ); - expect(openRequest).toMatchObject({ - _tag: WS_METHODS.terminalOpen, + expect(attachRequest).toMatchObject({ + _tag: WS_METHODS.terminalAttach, threadId: THREAD_ID, + terminalId: "action-lint", cwd: "/repo/project", env: { T3CODE_PROJECT_ROOT: "/repo/project", @@ -3268,14 +3294,130 @@ describe("ChatView timeline estimator parity (full app)", () => { await vi.waitFor( () => { + const attachRequest = wsRequests.find( + (request) => request._tag === WS_METHODS.terminalAttach, + ); const writeRequest = wsRequests.find( (request) => request._tag === WS_METHODS.terminalWrite, ); + expect(attachRequest).toMatchObject({ + _tag: WS_METHODS.terminalAttach, + threadId: THREAD_ID, + terminalId: "action-lint", + }); expect(writeRequest).toMatchObject({ _tag: WS_METHODS.terminalWrite, threadId: THREAD_ID, + terminalId: "action-lint", data: "bun run lint\r", }); + expect(wsRequests.indexOf(attachRequest!)).toBeLessThan( + wsRequests.indexOf(writeRequest!), + ); + }, + { timeout: 8_000, interval: 16 }, + ); + } finally { + await mounted.cleanup(); + } + }); + + it("reuses an existing idle project action terminal", async () => { + useComposerDraftStore.setState({ + draftThreadsByThreadKey: { + [THREAD_KEY]: { + threadId: THREAD_ID, + environmentId: LOCAL_ENVIRONMENT_ID, + projectId: PROJECT_ID, + logicalProjectKey: PROJECT_DRAFT_KEY, + createdAt: NOW_ISO, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + envMode: "local", + }, + }, + logicalProjectDraftThreadKeyByLogicalProjectKey: { + [PROJECT_DRAFT_KEY]: THREAD_KEY, + }, + }); + useTerminalUiStateStore.setState({ + terminalUiStateByThreadKey: { + [THREAD_KEY]: { + terminalOpen: true, + terminalHeight: 280, + terminalIds: ["action-lint"], + activeTerminalId: "action-lint", + terminalGroups: [{ id: "group-action-lint", terminalIds: ["action-lint"] }], + activeTerminalGroupId: "group-action-lint", + }, + }, + }); + + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: withProjectScripts(createDraftOnlySnapshot(), [ + { + id: "lint", + name: "Lint", + command: "bun run lint", + icon: "lint", + runOnWorktreeCreate: false, + }, + ]), + configureFixture: (nextFixture) => { + nextFixture.terminalMetadataEvents = [ + { + type: "upsert", + terminal: { + threadId: THREAD_ID, + terminalId: "action-lint", + cwd: "/repo/project", + worktreePath: null, + status: "running", + pid: 123, + exitCode: null, + exitSignal: null, + hasRunningSubprocess: false, + label: "Action: lint", + updatedAt: isoAt(1_200), + }, + }, + ]; + }, + }); + + try { + const runButton = await waitForElement( + () => + Array.from(document.querySelectorAll("button")).find( + (button) => button.getAttribute("aria-label") === "Run Lint", + ) as HTMLButtonElement | null, + "Unable to find Run Lint button.", + ); + runButton.click(); + + await vi.waitFor( + () => { + const openRequest = wsRequests.find( + (request) => request._tag === WS_METHODS.terminalOpen, + ); + const writeRequest = wsRequests.find( + (request) => request._tag === WS_METHODS.terminalWrite, + ); + expect(openRequest).toMatchObject({ + _tag: WS_METHODS.terminalOpen, + threadId: THREAD_ID, + terminalId: "action-lint", + }); + expect(writeRequest).toMatchObject({ + _tag: WS_METHODS.terminalWrite, + threadId: THREAD_ID, + terminalId: "action-lint", + data: "bun run lint\r", + }); + expect(wsRequests.indexOf(openRequest!)).toBeLessThan(wsRequests.indexOf(writeRequest!)); }, { timeout: 8_000, interval: 16 }, ); @@ -3330,12 +3472,13 @@ describe("ChatView timeline estimator parity (full app)", () => { await vi.waitFor( () => { - const openRequest = wsRequests.find( - (request) => request._tag === WS_METHODS.terminalOpen, + const attachRequest = wsRequests.find( + (request) => request._tag === WS_METHODS.terminalAttach, ); - expect(openRequest).toMatchObject({ - _tag: WS_METHODS.terminalOpen, + expect(attachRequest).toMatchObject({ + _tag: WS_METHODS.terminalAttach, threadId: THREAD_ID, + terminalId: "action-test", cwd: "/repo/worktrees/feature-draft", env: { T3CODE_PROJECT_ROOT: "/repo/project", diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 52f25945510..05e087a60ed 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -78,7 +78,6 @@ import { import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, - DEFAULT_THREAD_TERMINAL_ID, MAX_TERMINALS_PER_GROUP, type ChatMessage, type SessionPhase, @@ -129,6 +128,11 @@ import { nextProjectScriptId, projectScriptIdFromCommand, } from "~/projectScripts"; +import { + openTerminalAndWaitForInputReady, + resolveProjectActionTerminalId, + terminalSessionIsReadyForProjectActionInput, +} from "~/projectScriptTerminals"; import { newCommandId, newDraftId, newMessageId, newThreadId } from "~/lib/utils"; import { getProviderModelCapabilities, resolveSelectableProvider } from "../providerModels"; import { useSettings } from "../hooks/useSettings"; @@ -2755,11 +2759,6 @@ function ChatViewContent(props: ChatViewProps) { }); } const targetCwd = options?.cwd ?? gitCwd ?? activeProject.cwd; - const baseTerminalId = - terminalUiState.activeTerminalId || activeKnownTerminalIds[0] || DEFAULT_THREAD_TERMINAL_ID; - const isBaseTerminalBusy = runningTerminalIds.includes(baseTerminalId); - const wantsNewTerminal = Boolean(options?.preferNewTerminal) || isBaseTerminalBusy; - const shouldCreateNewTerminal = wantsNewTerminal; const targetWorktreePath = options?.worktreePath ?? activeThread.worktreePath ?? null; setTerminalUiLaunchContext({ @@ -2780,35 +2779,61 @@ function ChatViewContent(props: ChatViewProps) { worktreePath: targetWorktreePath, ...(options?.env ? { extraEnv: options.env } : {}), }); - const targetTerminalId = shouldCreateNewTerminal - ? nextTerminalId(activeKnownTerminalIds) - : baseTerminalId; - const openTerminalInput: TerminalOpenInput = shouldCreateNewTerminal - ? { - threadId: activeThreadId, - terminalId: targetTerminalId, - cwd: targetCwd, - ...(targetWorktreePath !== null ? { worktreePath: targetWorktreePath } : {}), - env: runtimeEnv, - cols: SCRIPT_TERMINAL_COLS, - rows: SCRIPT_TERMINAL_ROWS, - } - : { - threadId: activeThreadId, - terminalId: targetTerminalId, - cwd: targetCwd, - ...(targetWorktreePath !== null ? { worktreePath: targetWorktreePath } : {}), - env: runtimeEnv, - }; + const reusableTerminalById = new Map( + activeThreadKnownSessions.map((session) => [session.target.terminalId, session] as const), + ); + const effectiveRunningTerminalIds = runningTerminalIds.filter((terminalId) => { + const session = reusableTerminalById.get(terminalId); + if (!session) { + return true; + } + return !terminalSessionIsReadyForProjectActionInput({ + summary: session.state.summary, + buffer: session.state.buffer, + targetCwd, + targetWorktreePath, + }); + }); + const targetTerminalId = + options?.preferNewTerminal === true + ? nextTerminalId(activeKnownTerminalIds) + : resolveProjectActionTerminalId({ + scriptId: script.id, + terminalIds: activeKnownTerminalIds, + runningTerminalIds: effectiveRunningTerminalIds, + }); + const isKnownServerTerminal = activeServerOrderedTerminalIds.includes(targetTerminalId); + const isVisibleTerminal = terminalUiState.terminalIds.includes(targetTerminalId); + const targetSession = reusableTerminalById.get(targetTerminalId) ?? null; + const canWriteImmediately = terminalSessionIsReadyForProjectActionInput({ + summary: targetSession?.state.summary ?? null, + buffer: targetSession?.state.buffer ?? "", + targetCwd, + targetWorktreePath, + }); + const openTerminalInput: TerminalOpenInput = { + threadId: activeThreadId, + terminalId: targetTerminalId, + cwd: targetCwd, + ...(targetWorktreePath !== null ? { worktreePath: targetWorktreePath } : {}), + env: runtimeEnv, + ...(!isKnownServerTerminal + ? { cols: SCRIPT_TERMINAL_COLS, rows: SCRIPT_TERMINAL_ROWS } + : {}), + }; - if (shouldCreateNewTerminal) { + if (!isVisibleTerminal) { storeNewTerminal(activeThreadRef, targetTerminalId); } else { storeSetActiveTerminal(activeThreadRef, targetTerminalId); } try { - await api.terminal.open(openTerminalInput); + if (canWriteImmediately) { + await api.terminal.open(openTerminalInput); + } else { + await openTerminalAndWaitForInputReady(api, openTerminalInput); + } await api.terminal.write({ threadId: activeThreadId, terminalId: targetTerminalId, @@ -2849,10 +2874,12 @@ function ChatViewContent(props: ChatViewProps) { storeNewTerminal, storeSetActiveTerminal, setLastInvokedScriptByProjectId, + activeThreadKnownSessions, environmentId, activeKnownTerminalIds, + activeServerOrderedTerminalIds, runningTerminalIds, - terminalUiState.activeTerminalId, + terminalUiState.terminalIds, ], ); diff --git a/apps/web/src/projectScriptTerminals.test.ts b/apps/web/src/projectScriptTerminals.test.ts new file mode 100644 index 00000000000..78b29f1e313 --- /dev/null +++ b/apps/web/src/projectScriptTerminals.test.ts @@ -0,0 +1,236 @@ +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; +import type { + EnvironmentApi, + TerminalAttachInput, + TerminalAttachStreamEvent, + TerminalOpenInput, +} from "@t3tools/contracts"; + +import { + openTerminalAndWaitForInputReady, + projectActionTerminalId, + resolveProjectActionTerminalId, + terminalSessionIsReadyForProjectActionInput, + terminalOutputLooksReadyForInput, +} from "./projectScriptTerminals"; + +const OPEN_INPUT: TerminalOpenInput = { + threadId: "thread-1", + terminalId: "action-build", + cwd: "/repo", +}; + +function createReadyApi( + snapshotHistory: string, + unsubscribe = vi.fn(), +): Pick { + return { + terminal: { + attach: vi.fn((_input: TerminalAttachInput, callback) => { + const snapshotEvent: Extract = { + type: "snapshot", + snapshot: { + threadId: OPEN_INPUT.threadId, + terminalId: OPEN_INPUT.terminalId, + cwd: OPEN_INPUT.cwd, + worktreePath: null, + status: "running", + pid: 123, + history: snapshotHistory, + exitCode: null, + exitSignal: null, + label: "Terminal", + updatedAt: "2026-06-15T00:00:00.000Z", + }, + }; + callback(snapshotEvent); + return unsubscribe; + }), + } as unknown as EnvironmentApi["terminal"], + }; +} + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("project action terminal ids", () => { + it("uses a stable action-specific terminal id", () => { + expect(projectActionTerminalId("build")).toBe("action-build"); + expect( + resolveProjectActionTerminalId({ + scriptId: "build", + terminalIds: [], + runningTerminalIds: [], + }), + ).toBe("action-build"); + }); + + it("reuses an idle action fallback when the primary action terminal is busy", () => { + expect( + resolveProjectActionTerminalId({ + scriptId: "build", + terminalIds: ["action-build", "action-build-2", "term-1"], + runningTerminalIds: ["action-build"], + }), + ).toBe("action-build-2"); + }); + + it("allocates a suffixed action terminal when all existing action terminals are busy", () => { + expect( + resolveProjectActionTerminalId({ + scriptId: "build", + terminalIds: ["action-build", "action-build-2"], + runningTerminalIds: ["action-build", "action-build-2"], + }), + ).toBe("action-build-3"); + }); +}); + +describe("terminalOutputLooksReadyForInput", () => { + it("detects common shell prompts", () => { + expect(terminalOutputLooksReadyForInput("initializing...\n$ ")).toBe(true); + expect(terminalOutputLooksReadyForInput("\u001B[32mrepo\u001B[0m % ")).toBe(true); + }); + + it("ignores terminal title control sequences around prompts", () => { + expect(terminalOutputLooksReadyForInput("\u001B]0;repo\u0007$ \u001B[?2004h")).toBe(true); + }); + + it("does not treat plain command text as readiness", () => { + expect(terminalOutputLooksReadyForInput("pnpm run dist:desktop:dmg:arm64\n")).toBe(false); + }); +}); + +describe("terminalSessionIsReadyForProjectActionInput", () => { + it("treats shell-labeled sessions with a visible prompt as reusable", () => { + expect( + terminalSessionIsReadyForProjectActionInput({ + summary: { + cwd: "/repo", + hasRunningSubprocess: true, + label: "bash", + status: "running", + worktreePath: null, + }, + buffer: "$ ", + targetCwd: "/repo", + targetWorktreePath: null, + }), + ).toBe(true); + }); + + it("waits for prompt output before reusing an idle shell session", () => { + expect( + terminalSessionIsReadyForProjectActionInput({ + summary: { + cwd: "/repo", + hasRunningSubprocess: false, + label: "bash", + status: "running", + worktreePath: null, + }, + buffer: "loading profile...\n", + targetCwd: "/repo", + targetWorktreePath: null, + }), + ).toBe(false); + }); + + it("does not treat non-shell subprocess labels as reusable", () => { + expect( + terminalSessionIsReadyForProjectActionInput({ + summary: { + cwd: "/repo", + hasRunningSubprocess: true, + label: "vim", + status: "running", + worktreePath: null, + }, + buffer: "$ ", + targetCwd: "/repo", + targetWorktreePath: null, + }), + ).toBe(false); + }); +}); + +describe("openTerminalAndWaitForInputReady", () => { + it("resolves from a prompt already present in the snapshot history", async () => { + vi.useFakeTimers(); + const unsubscribe = vi.fn(); + const api = createReadyApi("ready\n$ ", unsubscribe); + + await openTerminalAndWaitForInputReady(api, OPEN_INPUT); + + expect(api.terminal.attach).toHaveBeenCalledWith( + expect.objectContaining({ + restartIfNotRunning: true, + terminalId: OPEN_INPUT.terminalId, + threadId: OPEN_INPUT.threadId, + }), + expect.any(Function), + ); + expect(unsubscribe).toHaveBeenCalledTimes(1); + }); + + it("waits for prompt output after the snapshot", async () => { + vi.useFakeTimers(); + const listenerRef: { current: ((event: TerminalAttachStreamEvent) => void) | null } = { + current: null, + }; + const unsubscribe = vi.fn(); + const api: Pick = { + terminal: { + attach: vi.fn((_input: TerminalAttachInput, callback) => { + listenerRef.current = callback; + callback({ + type: "snapshot", + snapshot: { + threadId: OPEN_INPUT.threadId, + terminalId: OPEN_INPUT.terminalId, + cwd: OPEN_INPUT.cwd, + worktreePath: null, + status: "running", + pid: 123, + history: "", + exitCode: null, + exitSignal: null, + label: "Terminal", + updatedAt: "2026-06-15T00:00:00.000Z", + }, + }); + return unsubscribe; + }), + } as unknown as EnvironmentApi["terminal"], + }; + + const ready = openTerminalAndWaitForInputReady(api, OPEN_INPUT); + await vi.advanceTimersByTimeAsync(2_000); + expect(unsubscribe).not.toHaveBeenCalled(); + + listenerRef.current?.({ + type: "output", + threadId: OPEN_INPUT.threadId, + terminalId: OPEN_INPUT.terminalId, + data: "$ ", + }); + await ready; + + expect(unsubscribe).toHaveBeenCalledTimes(1); + }); + + it("falls back after the timeout when no prompt is emitted", async () => { + vi.useFakeTimers(); + const unsubscribe = vi.fn(); + const api = createReadyApi("", unsubscribe); + + const ready = openTerminalAndWaitForInputReady(api, OPEN_INPUT, 1_000); + await vi.advanceTimersByTimeAsync(999); + expect(unsubscribe).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(1); + await ready; + + expect(unsubscribe).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/web/src/projectScriptTerminals.ts b/apps/web/src/projectScriptTerminals.ts new file mode 100644 index 00000000000..c5d37846e3c --- /dev/null +++ b/apps/web/src/projectScriptTerminals.ts @@ -0,0 +1,211 @@ +import type { + EnvironmentApi, + ProjectScript, + TerminalAttachStreamEvent, + TerminalOpenInput, + TerminalSummary, +} from "@t3tools/contracts"; + +const ACTION_TERMINAL_ID_PREFIX = "action-"; +const ACTION_TERMINAL_READY_TIMEOUT_MS = 4_000; +const MAX_TERMINAL_BUFFER_TAIL = 2_000; +const PROMPT_LINE_MAX_LENGTH = 180; +const ESCAPE_CHARACTER = String.fromCharCode(27); +const ANSI_ESCAPE_PATTERN = new RegExp( + `${ESCAPE_CHARACTER}(?:[@-Z\\\\-_]|\\[[0-?]*[ -/]*[@-~])`, + "g", +); +const OSC_ESCAPE_PATTERN = new RegExp( + `${ESCAPE_CHARACTER}\\][\\s\\S]*?(?:${String.fromCharCode(7)}|${ESCAPE_CHARACTER}\\\\)`, + "g", +); +const BELL_CHARACTER = String.fromCharCode(7); +const SHELL_LABELS = new Set(["bash", "zsh", "sh", "fish", "csh", "tcsh", "pwsh", "powershell"]); + +function projectActionTerminalIdBase(scriptId: ProjectScript["id"]): string { + return `${ACTION_TERMINAL_ID_PREFIX}${scriptId}`; +} + +export function isProjectActionTerminalId(terminalId: string): boolean { + return terminalId.startsWith(ACTION_TERMINAL_ID_PREFIX); +} + +export function projectActionTerminalId(scriptId: ProjectScript["id"], suffix?: number): string { + const base = projectActionTerminalIdBase(scriptId); + return suffix === undefined ? base : `${base}-${suffix}`; +} + +export function resolveProjectActionTerminalId(input: { + readonly scriptId: ProjectScript["id"]; + readonly terminalIds: ReadonlyArray; + readonly runningTerminalIds: ReadonlyArray; +}): string { + const busyTerminalIds = new Set(input.runningTerminalIds); + const baseTerminalId = projectActionTerminalId(input.scriptId); + if (!busyTerminalIds.has(baseTerminalId)) { + return baseTerminalId; + } + + const basePrefix = `${baseTerminalId}-`; + const idleActionTerminal = input.terminalIds.find( + (terminalId) => + (terminalId === baseTerminalId || terminalId.startsWith(basePrefix)) && + !busyTerminalIds.has(terminalId), + ); + if (idleActionTerminal) { + return idleActionTerminal; + } + + const takenTerminalIds = new Set(input.terminalIds); + let suffix = 2; + while (suffix < 10_000) { + const candidate = projectActionTerminalId(input.scriptId, suffix); + if (!takenTerminalIds.has(candidate)) { + return candidate; + } + suffix += 1; + } + + return projectActionTerminalId(input.scriptId, Date.now()); +} + +function stripAnsi(value: string): string { + return value.replace(OSC_ESCAPE_PATTERN, "").replace(ANSI_ESCAPE_PATTERN, ""); +} + +function stripControlCharacters(value: string): string { + let next = ""; + for (const char of value) { + const code = char.charCodeAt(0); + if (char === "\n" || char === "\r" || code >= 32) { + next += char; + } + } + return next; +} + +export function terminalOutputLooksReadyForInput(value: string): boolean { + const tail = stripControlCharacters(stripAnsi(value)) + .slice(-MAX_TERMINAL_BUFFER_TAIL) + .replaceAll(BELL_CHARACTER, ""); + const lines = tail.split(/\r?\n/); + for (let index = lines.length - 1; index >= 0; index -= 1) { + const line = lines[index]?.replace(/\r/g, "") ?? ""; + if (line.trim().length === 0) { + continue; + } + if (line.length > PROMPT_LINE_MAX_LENGTH) { + return false; + } + return /[$#%>]\s*$/.test(line); + } + return false; +} + +function normalizedShellLabel(label: string): string { + const trimmed = label.trim().toLowerCase(); + const basename = trimmed.split(/[\\/]/).at(-1) ?? trimmed; + return basename.replace(/^-+/, ""); +} + +export function terminalSessionIsReadyForProjectActionInput(input: { + readonly summary: Pick< + TerminalSummary, + "cwd" | "hasRunningSubprocess" | "label" | "status" | "worktreePath" + > | null; + readonly buffer: string; + readonly targetCwd: string; + readonly targetWorktreePath: string | null; +}): boolean { + const summary = input.summary; + if ( + !summary || + summary.status !== "running" || + summary.cwd !== input.targetCwd || + summary.worktreePath !== input.targetWorktreePath + ) { + return false; + } + if (!summary.hasRunningSubprocess || SHELL_LABELS.has(normalizedShellLabel(summary.label))) { + return terminalOutputLooksReadyForInput(input.buffer); + } + return false; +} + +function terminalAttachInputFromOpenInput(input: TerminalOpenInput) { + return { + threadId: input.threadId, + terminalId: input.terminalId, + cwd: input.cwd, + ...(input.worktreePath !== undefined ? { worktreePath: input.worktreePath } : {}), + ...(input.cols !== undefined ? { cols: input.cols } : {}), + ...(input.rows !== undefined ? { rows: input.rows } : {}), + ...(input.env !== undefined ? { env: input.env } : {}), + restartIfNotRunning: true, + }; +} + +export async function openTerminalAndWaitForInputReady( + api: Pick, + input: TerminalOpenInput, + timeoutMs = ACTION_TERMINAL_READY_TIMEOUT_MS, +): Promise { + let bufferTail = ""; + let unsubscribe: (() => void) | undefined; + let shouldUnsubscribe = false; + + await new Promise((resolve) => { + let settled = false; + const timer = setTimeout(settle, timeoutMs); + + function cleanup() { + if (unsubscribe) { + const dispose = unsubscribe; + unsubscribe = undefined; + dispose(); + } else { + shouldUnsubscribe = true; + } + } + + function settle() { + if (settled) { + return; + } + settled = true; + clearTimeout(timer); + cleanup(); + resolve(); + } + + function appendAndCheck(data: string) { + bufferTail = `${bufferTail}${data}`.slice(-MAX_TERMINAL_BUFFER_TAIL); + if (terminalOutputLooksReadyForInput(bufferTail)) { + settle(); + } + } + + function onEvent(event: TerminalAttachStreamEvent) { + if (event.type === "snapshot") { + appendAndCheck(event.snapshot.history); + return; + } + if (event.type === "output") { + appendAndCheck(event.data); + return; + } + if (event.type === "closed" || event.type === "error" || event.type === "exited") { + settle(); + } + } + + try { + unsubscribe = api.terminal.attach(terminalAttachInputFromOpenInput(input), onEvent); + if (shouldUnsubscribe) { + cleanup(); + } + } catch { + void api.terminal.open(input).finally(settle); + } + }); +} diff --git a/packages/shared/src/terminalLabels.test.ts b/packages/shared/src/terminalLabels.test.ts index 4621f3af808..9815b31d4ca 100644 --- a/packages/shared/src/terminalLabels.test.ts +++ b/packages/shared/src/terminalLabels.test.ts @@ -13,6 +13,11 @@ describe("getTerminalLabel", () => { expect(getTerminalLabel("terminal-3")).toBe("Terminal 3"); }); + it("uses readable labels for project action terminal ids", () => { + expect(getTerminalLabel("action-lint")).toBe("Action: lint"); + expect(getTerminalLabel("action-dist-desktop-dmg-2")).toBe("Action: dist desktop dmg 2"); + }); + it("falls back to the raw id for unknown shapes", () => { expect(getTerminalLabel("custom-session")).toBe("custom-session"); }); diff --git a/packages/shared/src/terminalLabels.ts b/packages/shared/src/terminalLabels.ts index 1cdae101430..7f5197f5e13 100644 --- a/packages/shared/src/terminalLabels.ts +++ b/packages/shared/src/terminalLabels.ts @@ -7,6 +7,11 @@ export function getTerminalLabel(terminalId: string): string { return `Terminal ${numericSuffix}`; } + const actionId = /^action-(.+)$/i.exec(terminalId)?.[1]?.trim(); + if (actionId) { + return `Action: ${actionId.replace(/-+/g, " ")}`; + } + return terminalId; }