diff --git a/apps/server/src/terminal/Layers/Manager.test.ts b/apps/server/src/terminal/Layers/Manager.test.ts index a55e5244893..6f291699e53 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; @@ -835,6 +835,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 6d528f02aa9..a2f8ad5301b 100644 --- a/apps/server/src/terminal/Layers/Manager.ts +++ b/apps/server/src/terminal/Layers/Manager.ts @@ -62,6 +62,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", @@ -191,6 +201,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(); @@ -606,7 +685,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", @@ -688,36 +767,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], }; }); @@ -2486,3 +2561,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.tsx b/apps/web/src/components/ChatView.tsx index a1ef90c4309..d447e6cc4ce 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -90,7 +90,6 @@ import { import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, - DEFAULT_THREAD_TERMINAL_ID, MAX_TERMINALS_PER_GROUP, type ChatMessage, type SessionPhase, @@ -136,6 +135,10 @@ import { nextProjectScriptId, projectScriptIdFromCommand, } from "~/projectScripts"; +import { + resolveProjectActionTerminalId, + terminalSessionIsReadyForProjectActionInput, +} from "~/projectScriptTerminals"; import { newDraftId, newMessageId, newThreadId } from "~/lib/utils"; import { getProviderModelCapabilities, resolveSelectableProvider } from "../providerModels"; import { useSettings } from "../hooks/useSettings"; @@ -177,6 +180,7 @@ import { serverEnvironment, } from "../state/server"; import { terminalEnvironment } from "../state/terminal"; +import { projectActionTerminalEnvironment } from "../state/projectActionTerminal"; import { threadEnvironment } from "../state/threads"; import { vcsEnvironment } from "../state/vcs"; import { useEnvironments, usePrimaryEnvironment } from "../state/environments"; @@ -986,6 +990,10 @@ function ChatViewContent(props: ChatViewProps) { }); const openTerminal = useAtomCommand(terminalEnvironment.open, "terminal open"); const writeTerminal = useAtomCommand(terminalEnvironment.write, "terminal write"); + const waitForProjectActionTerminalReady = useAtomCommand( + projectActionTerminalEnvironment.waitForInputReady, + "project action terminal wait for input ready", + ); const closeTerminalMutation = useAtomCommand(terminalEnvironment.close, "terminal close"); const createThread = useAtomCommand(threadEnvironment.create, { reportFailure: false }); const deleteThread = useAtomCommand(threadEnvironment.delete, { reportFailure: false }); @@ -2437,11 +2445,6 @@ function ChatViewContent(props: ChatViewProps) { }); } const targetCwd = options?.cwd ?? gitCwd ?? activeProject.workspaceRoot; - 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({ @@ -2462,28 +2465,50 @@ 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); @@ -2501,6 +2526,13 @@ function ChatViewContent(props: ChatViewProps) { return; } + if (!canWriteImmediately) { + await waitForProjectActionTerminalReady({ + environmentId, + input: openTerminalInput, + }); + } + const writeResult = await writeTerminal({ environmentId, input: { @@ -2528,11 +2560,14 @@ function ChatViewContent(props: ChatViewProps) { storeNewTerminal, storeSetActiveTerminal, setLastInvokedScriptByProjectId, + activeThreadKnownSessions, environmentId, openTerminal, activeKnownTerminalIds, + activeServerOrderedTerminalIds, runningTerminalIds, - terminalUiState.activeTerminalId, + terminalUiState.terminalIds, + waitForProjectActionTerminalReady, writeTerminal, ], ); 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..4d58c91291a --- /dev/null +++ b/apps/web/src/projectScriptTerminals.ts @@ -0,0 +1,256 @@ +import type { + EnvironmentApi, + ProjectScript, + TerminalAttachStreamEvent, + TerminalOpenInput, + TerminalSummary, +} from "@t3tools/contracts"; +import { WS_METHODS } from "@t3tools/contracts"; +import { subscribe } from "@t3tools/client-runtime/rpc"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Result from "effect/Result"; +import * as Stream from "effect/Stream"; + +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, + }; +} + +function terminalAttachEventCompletesReadyWait( + event: TerminalAttachStreamEvent, + appendAndCheck: (data: string) => boolean, +): boolean { + if (event.type === "snapshot") { + return appendAndCheck(event.snapshot.history); + } + if (event.type === "output") { + return appendAndCheck(event.data); + } + return event.type === "closed" || event.type === "error" || event.type === "exited"; +} + +export function waitForProjectActionTerminalInputReady( + input: TerminalOpenInput, + timeoutMs = ACTION_TERMINAL_READY_TIMEOUT_MS, +) { + let bufferTail = ""; + const appendAndCheck = (data: string) => { + bufferTail = `${bufferTail}${data}`.slice(-MAX_TERMINAL_BUFFER_TAIL); + return terminalOutputLooksReadyForInput(bufferTail); + }; + + return subscribe(WS_METHODS.terminalAttach, terminalAttachInputFromOpenInput(input)).pipe( + Stream.filterMap((event) => + terminalAttachEventCompletesReadyWait(event, appendAndCheck) + ? Result.succeed(undefined) + : Result.failVoid, + ), + Stream.runHead, + Effect.timeoutOption(Duration.millis(timeoutMs)), + Effect.flatMap((result) => + Option.isSome(result) && Option.isSome(result.value) ? Effect.void : Effect.void, + ), + Effect.catchCause(() => Effect.void), + ); +} + +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/apps/web/src/state/projectActionTerminal.ts b/apps/web/src/state/projectActionTerminal.ts new file mode 100644 index 00000000000..365e02e1760 --- /dev/null +++ b/apps/web/src/state/projectActionTerminal.ts @@ -0,0 +1,11 @@ +import { createEnvironmentCommand } from "@t3tools/client-runtime/state/runtime"; + +import { connectionAtomRuntime } from "../connection/runtime"; +import { waitForProjectActionTerminalInputReady } from "../projectScriptTerminals"; + +export const projectActionTerminalEnvironment = { + waitForInputReady: createEnvironmentCommand(connectionAtomRuntime, { + label: "environment-data:project-action-terminal:wait-for-input-ready", + execute: waitForProjectActionTerminalInputReady, + }), +}; 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; }