diff --git a/apps/server/src/terminalManager.test.ts b/apps/server/src/terminalManager.test.ts index b8257e07c6..bf7da6199f 100644 --- a/apps/server/src/terminalManager.test.ts +++ b/apps/server/src/terminalManager.test.ts @@ -137,7 +137,11 @@ describe("TerminalManager", () => { function makeManager( historyLineLimit = 5, - options: { shellResolver?: () => string } = {}, + options: { + shellResolver?: () => string; + subprocessChecker?: (terminalPid: number) => Promise; + subprocessPollIntervalMs?: number; + } = {}, ) { const logsDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3code-terminal-")); tempDirs.push(logsDir); @@ -147,6 +151,10 @@ describe("TerminalManager", () => { ptyAdapter, historyLineLimit, shellResolver: options.shellResolver ?? (() => "/bin/bash"), + ...(options.subprocessChecker ? { subprocessChecker: options.subprocessChecker } : {}), + ...(options.subprocessPollIntervalMs + ? { subprocessPollIntervalMs: options.subprocessPollIntervalMs } + : {}), }); return { logsDir, ptyAdapter, manager }; } @@ -273,6 +281,42 @@ describe("TerminalManager", () => { manager.dispose(); }); + it("emits subprocess activity events when child-process state changes", async () => { + let hasRunningSubprocess = false; + const { manager } = makeManager(5, { + subprocessChecker: async () => hasRunningSubprocess, + subprocessPollIntervalMs: 20, + }); + const events: TerminalEvent[] = []; + manager.on("event", (event) => { + events.push(event); + }); + + await manager.open(openInput()); + await waitFor(() => events.some((event) => event.type === "started")); + expect(events.some((event) => event.type === "activity")).toBe(false); + + hasRunningSubprocess = true; + await waitFor( + () => + events.some( + (event) => event.type === "activity" && event.hasRunningSubprocess === true, + ), + 1_200, + ); + + hasRunningSubprocess = false; + await waitFor( + () => + events.some( + (event) => event.type === "activity" && event.hasRunningSubprocess === false, + ), + 1_200, + ); + + manager.dispose(); + }); + it("caps persisted history to configured line limit", async () => { const { manager, ptyAdapter } = makeManager(3); await manager.open(openInput()); @@ -334,15 +378,18 @@ describe("TerminalManager", () => { manager.dispose(); }); - it("loads existing legacy transcript filenames and keeps new naming for writes", async () => { + it("migrates legacy transcript filenames to terminal-scoped history path on open", async () => { const { manager, logsDir } = makeManager(); const legacyPath = path.join(logsDir, "thread-1.log"); + const nextPath = historyLogPath(logsDir); fs.writeFileSync(legacyPath, "legacy-line\n", "utf8"); const snapshot = await manager.open(openInput()); expect(snapshot.history).toBe("legacy-line\n"); - expect(fs.existsSync(legacyPath)).toBe(true); + expect(fs.existsSync(nextPath)).toBe(true); + expect(fs.readFileSync(nextPath, "utf8")).toBe("legacy-line\n"); + expect(fs.existsSync(legacyPath)).toBe(false); manager.dispose(); }); diff --git a/apps/server/src/terminalManager.ts b/apps/server/src/terminalManager.ts index 8c8d18cdf5..eb881f3cd0 100644 --- a/apps/server/src/terminalManager.ts +++ b/apps/server/src/terminalManager.ts @@ -21,9 +21,13 @@ import { import { createLogger } from "./logger"; import { NodePtyAdapter, type PtyAdapter, type PtyExitEvent, type PtyProcess } from "./ptyAdapter"; +import { runProcess } from "./processRunner"; const DEFAULT_HISTORY_LINE_LIMIT = 5_000; const DEFAULT_PERSIST_DEBOUNCE_MS = 40; +const DEFAULT_SUBPROCESS_POLL_INTERVAL_MS = 1_000; + +type TerminalSubprocessChecker = (terminalPid: number) => Promise; export interface TerminalManagerEvents { event: [event: TerminalEvent]; @@ -34,6 +38,8 @@ export interface TerminalManagerOptions { historyLineLimit?: number; ptyAdapter?: PtyAdapter; shellResolver?: () => string; + subprocessChecker?: TerminalSubprocessChecker; + subprocessPollIntervalMs?: number; } interface TerminalSessionState { @@ -51,6 +57,7 @@ interface TerminalSessionState { process: PtyProcess | null; unsubscribeData: (() => void) | null; unsubscribeExit: (() => void) | null; + hasRunningSubprocess: boolean; } function defaultShellResolver(): string { @@ -122,6 +129,83 @@ function isRetryableShellSpawnError(error: unknown): boolean { ); } +async function checkWindowsSubprocessActivity(terminalPid: number): Promise { + const command = [ + `$children = Get-CimInstance Win32_Process -Filter "ParentProcessId = ${terminalPid}" -ErrorAction SilentlyContinue`, + "if ($children) { exit 0 }", + "exit 1", + ].join("; "); + try { + const result = await runProcess( + "powershell.exe", + ["-NoProfile", "-NonInteractive", "-Command", command], + { + timeoutMs: 1_500, + allowNonZeroExit: true, + maxBufferBytes: 32_768, + outputMode: "truncate", + }, + ); + return result.code === 0; + } catch { + return false; + } +} + +async function checkPosixSubprocessActivity(terminalPid: number): Promise { + try { + const pgrepResult = await runProcess("pgrep", ["-P", String(terminalPid)], { + timeoutMs: 1_000, + allowNonZeroExit: true, + maxBufferBytes: 32_768, + outputMode: "truncate", + }); + if (pgrepResult.code === 0) { + return pgrepResult.stdout.trim().length > 0; + } + if (pgrepResult.code === 1) { + return false; + } + } catch { + // Fall back to ps when pgrep is unavailable. + } + + try { + const psResult = await runProcess("ps", ["-eo", "pid=,ppid="], { + timeoutMs: 1_000, + allowNonZeroExit: true, + maxBufferBytes: 262_144, + outputMode: "truncate", + }); + if (psResult.code !== 0) { + return false; + } + + for (const line of psResult.stdout.split(/\r?\n/g)) { + const [pidRaw, ppidRaw] = line.trim().split(/\s+/g); + const pid = Number(pidRaw); + const ppid = Number(ppidRaw); + if (!Number.isInteger(pid) || !Number.isInteger(ppid)) continue; + if (ppid === terminalPid) { + return true; + } + } + return false; + } catch { + return false; + } +} + +async function defaultSubprocessChecker(terminalPid: number): Promise { + if (!Number.isInteger(terminalPid) || terminalPid <= 0) { + return false; + } + if (process.platform === "win32") { + return checkWindowsSubprocessActivity(terminalPid); + } + return checkPosixSubprocessActivity(terminalPid); +} + function capHistory(history: string, maxLines: number): string { if (history.length === 0) return history; const hasTrailingNewline = history.endsWith("\n"); @@ -161,6 +245,10 @@ export class TerminalManager extends EventEmitter { private readonly pendingPersistHistory = new Map(); private readonly threadLocks = new Map>(); private readonly persistDebounceMs: number; + private readonly subprocessChecker: TerminalSubprocessChecker; + private readonly subprocessPollIntervalMs: number; + private subprocessPollTimer: ReturnType | null = null; + private subprocessPollInFlight = false; private readonly logger = createLogger("terminal"); constructor(options: TerminalManagerOptions = {}) { @@ -170,6 +258,9 @@ export class TerminalManager extends EventEmitter { this.ptyAdapter = options.ptyAdapter ?? new NodePtyAdapter(); this.shellResolver = options.shellResolver ?? defaultShellResolver; this.persistDebounceMs = DEFAULT_PERSIST_DEBOUNCE_MS; + this.subprocessChecker = options.subprocessChecker ?? defaultSubprocessChecker; + this.subprocessPollIntervalMs = + options.subprocessPollIntervalMs ?? DEFAULT_SUBPROCESS_POLL_INTERVAL_MS; fs.mkdirSync(this.logsDir, { recursive: true }); } @@ -198,6 +289,7 @@ export class TerminalManager extends EventEmitter { process: null, unsubscribeData: null, unsubscribeExit: null, + hasRunningSubprocess: false, }; this.sessions.set(sessionKey, session); this.startSession(session, input, "started"); @@ -294,6 +386,7 @@ export class TerminalManager extends EventEmitter { process: null, unsubscribeData: null, unsubscribeExit: null, + hasRunningSubprocess: false, }; this.sessions.set(sessionKey, session); } else { @@ -334,14 +427,17 @@ export class TerminalManager extends EventEmitter { if (input.deleteHistory) { await this.deleteAllHistoryForThread(input.threadId); } + this.updateSubprocessPollingState(); }); } dispose(): void { - for (const session of this.sessions.values()) { + this.stopSubprocessPolling(); + const sessions = [...this.sessions.values()]; + this.sessions.clear(); + for (const session of sessions) { this.stopProcess(session); } - this.sessions.clear(); for (const timer of this.persistTimers.values()) { clearTimeout(timer); } @@ -364,6 +460,7 @@ export class TerminalManager extends EventEmitter { session.rows = input.rows; session.exitCode = null; session.exitSignal = null; + session.hasRunningSubprocess = false; session.updatedAt = new Date().toISOString(); let ptyProcess: PtyProcess | null = null; @@ -413,6 +510,7 @@ export class TerminalManager extends EventEmitter { session.unsubscribeExit = ptyProcess.onExit((event) => { this.onProcessExit(session, event); }); + this.updateSubprocessPollingState(); this.emitEvent({ type: eventType, threadId: session.threadId, @@ -431,7 +529,9 @@ export class TerminalManager extends EventEmitter { session.status = "error"; session.pid = null; session.process = null; + session.hasRunningSubprocess = false; session.updatedAt = new Date().toISOString(); + this.updateSubprocessPollingState(); const message = error instanceof Error ? error.message : "Terminal start failed"; this.emitEvent({ type: "error", @@ -466,6 +566,7 @@ export class TerminalManager extends EventEmitter { this.cleanupProcessHandles(session); session.process = null; session.pid = null; + session.hasRunningSubprocess = false; session.status = "exited"; session.exitCode = Number.isInteger(event.exitCode) ? event.exitCode : null; session.exitSignal = Number.isInteger(event.signal) ? event.signal : null; @@ -478,6 +579,7 @@ export class TerminalManager extends EventEmitter { exitCode: session.exitCode, exitSignal: session.exitSignal, }); + this.updateSubprocessPollingState(); } private stopProcess(session: TerminalSessionState): void { @@ -486,6 +588,7 @@ export class TerminalManager extends EventEmitter { this.cleanupProcessHandles(session); session.process = null; session.pid = null; + session.hasRunningSubprocess = false; session.status = "exited"; session.updatedAt = new Date().toISOString(); try { @@ -498,6 +601,7 @@ export class TerminalManager extends EventEmitter { error: message, }); } + this.updateSubprocessPollingState(); } private cleanupProcessHandles(session: TerminalSessionState): void { @@ -582,12 +686,12 @@ export class TerminalManager extends EventEmitter { } private async readHistory(threadId: string, terminalId: string): Promise { - const historyPath = this.historyPath(threadId, terminalId); + const nextPath = this.historyPath(threadId, terminalId); try { - const raw = await fs.promises.readFile(historyPath, "utf8"); + const raw = await fs.promises.readFile(nextPath, "utf8"); const capped = capHistory(raw, this.historyLineLimit); if (capped !== raw) { - await fs.promises.writeFile(historyPath, capped, "utf8"); + await fs.promises.writeFile(nextPath, capped, "utf8"); } return capped; } catch (error) { @@ -600,12 +704,22 @@ export class TerminalManager extends EventEmitter { return ""; } + const legacyPath = this.legacyHistoryPath(threadId); try { - const raw = await fs.promises.readFile(this.legacyHistoryPath(threadId), "utf8"); + const raw = await fs.promises.readFile(legacyPath, "utf8"); const capped = capHistory(raw, this.historyLineLimit); - if (capped !== raw) { - await fs.promises.writeFile(this.legacyHistoryPath(threadId), capped, "utf8"); + + // Migrate legacy transcript filename to the terminal-scoped path. + await fs.promises.writeFile(nextPath, capped, "utf8"); + try { + await fs.promises.rm(legacyPath, { force: true }); + } catch (cleanupError) { + this.logger.warn("failed to remove legacy terminal history", { + threadId, + error: cleanupError instanceof Error ? cleanupError.message : String(cleanupError), + }); } + return capped; } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") { @@ -650,6 +764,86 @@ export class TerminalManager extends EventEmitter { } } + private updateSubprocessPollingState(): void { + const hasRunningSessions = [...this.sessions.values()].some( + (session) => session.status === "running" && session.pid !== null, + ); + if (hasRunningSessions) { + this.ensureSubprocessPolling(); + return; + } + this.stopSubprocessPolling(); + } + + private ensureSubprocessPolling(): void { + if (this.subprocessPollTimer) return; + this.subprocessPollTimer = setInterval(() => { + void this.pollSubprocessActivity(); + }, this.subprocessPollIntervalMs); + this.subprocessPollTimer.unref?.(); + void this.pollSubprocessActivity(); + } + + private stopSubprocessPolling(): void { + if (!this.subprocessPollTimer) return; + clearInterval(this.subprocessPollTimer); + this.subprocessPollTimer = null; + } + + private async pollSubprocessActivity(): Promise { + if (this.subprocessPollInFlight) return; + + const runningSessions = [...this.sessions.values()].filter( + (session): session is TerminalSessionState & { pid: number } => + session.status === "running" && Number.isInteger(session.pid), + ); + if (runningSessions.length === 0) { + this.stopSubprocessPolling(); + return; + } + + this.subprocessPollInFlight = true; + try { + await Promise.all( + runningSessions.map(async (session) => { + const terminalPid = session.pid; + let hasRunningSubprocess = false; + try { + hasRunningSubprocess = await this.subprocessChecker(terminalPid); + } catch (error) { + this.logger.warn("failed to check terminal subprocess activity", { + threadId: session.threadId, + terminalId: session.terminalId, + terminalPid, + error: error instanceof Error ? error.message : String(error), + }); + return; + } + + const liveSession = this.sessions.get(toSessionKey(session.threadId, session.terminalId)); + if (!liveSession || liveSession.status !== "running" || liveSession.pid !== terminalPid) { + return; + } + if (liveSession.hasRunningSubprocess === hasRunningSubprocess) { + return; + } + + liveSession.hasRunningSubprocess = hasRunningSubprocess; + liveSession.updatedAt = new Date().toISOString(); + this.emitEvent({ + type: "activity", + threadId: liveSession.threadId, + terminalId: liveSession.terminalId, + createdAt: new Date().toISOString(), + hasRunningSubprocess, + }); + }), + ); + } finally { + this.subprocessPollInFlight = false; + } + } + private async assertValidCwd(cwd: string): Promise { let stats: fs.Stats; try { @@ -676,6 +870,7 @@ export class TerminalManager extends EventEmitter { this.stopProcess(session); this.sessions.delete(key); } + this.updateSubprocessPollingState(); await this.flushPersistQueue(threadId, terminalId); if (deleteHistory) { await this.deleteHistory(threadId, terminalId); diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index a982c1fc6e..41c7ff36fc 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -27,6 +27,16 @@ function EventRouter() { }); }, [api, dispatch]); + useEffect(() => { + if (!api) return; + return api.terminal.onEvent((event) => { + dispatch({ + type: "APPLY_TERMINAL_EVENT", + event, + }); + }); + }, [api, dispatch]); + return null; } @@ -82,6 +92,7 @@ function AutoProjectBootstrap() { terminalOpen: false, terminalHeight: DEFAULT_THREAD_TERMINAL_HEIGHT, terminalIds: [DEFAULT_THREAD_TERMINAL_ID], + runningTerminalIds: [], activeTerminalId: DEFAULT_THREAD_TERMINAL_ID, terminalGroups: [ { diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 966d3eb4c2..5423ac60a6 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -263,12 +263,23 @@ export default function ChatView() { const toggleTerminalVisibility = useCallback(() => { if (!activeThreadId) return; const isOpen = Boolean(activeThread?.terminalOpen); + + if (isOpen && api) { + if (typeof api.terminal.close === "function") { + void api.terminal.close({ threadId: activeThreadId }).catch(() => { + void api.terminal.write({ threadId: activeThreadId, data: "exit\n" }).catch(() => undefined); + }); + } else { + void api.terminal.write({ threadId: activeThreadId, data: "exit\n" }).catch(() => undefined); + } + } + dispatch({ type: "SET_THREAD_TERMINAL_OPEN", threadId: activeThreadId, open: !isOpen, }); - }, [activeThread?.terminalOpen, activeThreadId, dispatch]); + }, [activeThread?.terminalOpen, activeThreadId, api, dispatch]); const splitTerminal = useCallback(() => { if (!activeThreadId) return; dispatch({ @@ -303,21 +314,13 @@ export default function ChatView() { (terminalId: string) => { if (!activeThreadId || !api) return; const fallbackExitWrite = () => - api.terminal - .write({ threadId: activeThreadId, terminalId, data: "exit\n" }) - .catch(() => undefined); - const fallbackClearAndExit = () => - api.terminal - .clear({ threadId: activeThreadId, terminalId }) - .catch(() => undefined) - .then(() => fallbackExitWrite()) - .catch(() => undefined); + api.terminal.write({ threadId: activeThreadId, terminalId, data: "exit\n" }).catch(() => undefined); if ("close" in api.terminal && typeof api.terminal.close === "function") { void api.terminal - .close({ threadId: activeThreadId, terminalId, deleteHistory: true }) - .catch(() => fallbackClearAndExit()); + .close({ threadId: activeThreadId, terminalId }) + .catch(() => fallbackExitWrite()); } else { - void fallbackClearAndExit(); + void fallbackExitWrite(); } dispatch({ type: "CLOSE_THREAD_TERMINAL", diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 32a832a491..858e438b50 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -32,7 +32,7 @@ function inferProjectName(cwd: string): string { } interface ThreadStatusPill { - label: "Working" | "Connecting" | "Completed" | "Awaiting response"; + label: "Working" | "Connecting" | "Completed" | "Awaiting response" | "Terminal"; colorClass: string; dotClass: string; pulse: boolean; @@ -89,6 +89,18 @@ function threadStatusPill(thread: Thread, hasPendingApprovals: boolean): ThreadS return null; } +function terminalStatusPill(thread: Thread): ThreadStatusPill | null { + if (thread.runningTerminalIds.length === 0) { + return null; + } + return { + label: "Terminal", + colorClass: "text-teal-600 dark:text-teal-300/90", + dotClass: "bg-teal-500 dark:bg-teal-300/90", + pulse: true, + }; +} + export default function Sidebar() { const { state, dispatch } = useStore(); const api = useNativeApi(); @@ -118,6 +130,7 @@ export default function Sidebar() { terminalOpen: false, terminalHeight: DEFAULT_THREAD_TERMINAL_HEIGHT, terminalIds: [DEFAULT_THREAD_TERMINAL_ID], + runningTerminalIds: [], activeTerminalId: DEFAULT_THREAD_TERMINAL_ID, terminalGroups: [ { @@ -388,6 +401,7 @@ export default function Sidebar() { thread, pendingApprovalByThreadId.get(thread.id) === true, ); + const terminalStatus = terminalStatusPill(thread); return (