diff --git a/apps/web/src/components/ThreadTerminalDrawer.browser.tsx b/apps/web/src/components/ThreadTerminalDrawer.browser.tsx index 2df2e04f5c4..bd9647e1e21 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.browser.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.browser.tsx @@ -1,13 +1,14 @@ import "../index.css"; import { scopeThreadRef } from "@t3tools/client-runtime"; -import { ThreadId } from "@t3tools/contracts"; +import { ThreadId, type TerminalEvent } from "@t3tools/contracts"; import { afterEach, describe, expect, it, vi } from "vitest"; import { render } from "vitest-browser-react"; const { terminalConstructorSpy, terminalDisposeSpy, + terminalWriteSpy, fitAddonFitSpy, fitAddonLoadSpy, environmentApiById, @@ -16,9 +17,19 @@ const { } = vi.hoisted(() => ({ terminalConstructorSpy: vi.fn(), terminalDisposeSpy: vi.fn(), + terminalWriteSpy: vi.fn(), fitAddonFitSpy: vi.fn(), fitAddonLoadSpy: vi.fn(), - environmentApiById: new Map } }>(), + environmentApiById: new Map< + string, + { + terminal: { + open: ReturnType; + onEvent: ReturnType; + }; + emitTerminalEvent: (event: TerminalEvent) => void; + } + >(), readEnvironmentApiMock: vi.fn((environmentId: string) => environmentApiById.get(environmentId)), readLocalApiMock: vi.fn< () => @@ -62,7 +73,9 @@ vi.mock("@xterm/xterm", () => ({ open() {} - write() {} + write(data: string) { + terminalWriteSpy(data); + } clear() {} @@ -124,6 +137,7 @@ import { TerminalViewport } from "./ThreadTerminalDrawer"; const THREAD_ID = ThreadId.make("thread-terminal-browser"); function createEnvironmentApi() { + const terminalEventListeners = new Set<(event: TerminalEvent) => void>(); return { terminal: { open: vi.fn(async () => ({ @@ -140,6 +154,19 @@ function createEnvironmentApi() { })), write: vi.fn(async () => undefined), resize: vi.fn(async () => undefined), + clear: vi.fn(async () => undefined), + close: vi.fn(async () => undefined), + onEvent: vi.fn((listener: (event: TerminalEvent) => void) => { + terminalEventListeners.add(listener); + return () => { + terminalEventListeners.delete(listener); + }; + }), + }, + emitTerminalEvent: (event: TerminalEvent) => { + for (const listener of terminalEventListeners) { + listener(event); + } }, }; } @@ -215,6 +242,7 @@ describe("TerminalViewport", () => { readLocalApiMock.mockClear(); terminalConstructorSpy.mockClear(); terminalDisposeSpy.mockClear(); + terminalWriteSpy.mockClear(); fitAddonFitSpy.mockClear(); fitAddonLoadSpy.mockClear(); }); @@ -317,4 +345,35 @@ describe("TerminalViewport", () => { await mounted.cleanup(); } }); + + it("applies output from the viewport-owned terminal event stream", async () => { + const environment = createEnvironmentApi(); + environmentApiById.set("environment-a", environment); + + const mounted = await mountTerminalViewport({ + threadRef: scopeThreadRef("environment-a" as never, THREAD_ID), + }); + + try { + await vi.waitFor(() => { + expect(environment.terminal.open).toHaveBeenCalledTimes(1); + expect(environment.terminal.onEvent).toHaveBeenCalledTimes(1); + }); + terminalWriteSpy.mockClear(); + + environment.emitTerminalEvent({ + type: "output", + threadId: THREAD_ID, + terminalId: "default", + createdAt: "2026-04-07T00:00:01.000Z", + data: "ls\r\n", + }); + + await vi.waitFor(() => { + expect(terminalWriteSpy).toHaveBeenCalledWith("ls\r\n"); + }); + } finally { + await mounted.cleanup(); + } + }); }); diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index 6c71e5eb334..cdf95a1d257 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -52,6 +52,7 @@ import { selectTerminalEventEntries, useTerminalStateStore } from "../terminalSt const MIN_DRAWER_HEIGHT = 180; const MAX_DRAWER_HEIGHT_RATIO = 0.75; const MULTI_CLICK_SELECTION_ACTION_DELAY_MS = 260; +const MAX_APPLIED_TERMINAL_EVENT_KEYS = 500; function maxDrawerHeight(): number { if (typeof window === "undefined") return DEFAULT_THREAD_TERMINAL_HEIGHT; @@ -89,6 +90,45 @@ export function selectPendingTerminalEventEntries( return entries.filter((entry) => entry.id > lastAppliedTerminalEventId); } +function terminalEventDedupeKey(event: TerminalEvent): string { + switch (event.type) { + case "output": + return [event.threadId, event.terminalId, event.createdAt, event.type, event.data].join("\0"); + case "started": + case "restarted": + return [ + event.threadId, + event.terminalId, + event.createdAt, + event.type, + event.snapshot.updatedAt, + ].join("\0"); + case "activity": + return [ + event.threadId, + event.terminalId, + event.createdAt, + event.type, + String(event.hasRunningSubprocess), + ].join("\0"); + case "error": + return [event.threadId, event.terminalId, event.createdAt, event.type, event.message].join( + "\0", + ); + case "exited": + return [ + event.threadId, + event.terminalId, + event.createdAt, + event.type, + String(event.exitCode), + String(event.exitSignal), + ].join("\0"); + case "cleared": + return [event.threadId, event.terminalId, event.createdAt, event.type].join("\0"); + } +} + function normalizeComputedColor(value: string | null | undefined, fallback: string): string { const normalizedValue = value?.trim().toLowerCase(); if ( @@ -292,6 +332,7 @@ export function TerminalViewport({ const selectionActionTimerRef = useRef(null); const keybindingsRef = useRef(keybindings); const lastAppliedTerminalEventIdRef = useRef(0); + const appliedTerminalEventKeysRef = useRef>(new Set()); const terminalHydratedRef = useRef(false); const handleSessionExited = useEffectEvent(() => { onSessionExited(); @@ -405,6 +446,40 @@ export function TerminalViewport({ } }; + const terminalOpenInput = () => { + const activeTerminal = terminalRef.current; + const activeFitAddon = fitAddonRef.current; + if (!activeTerminal || !activeFitAddon) { + return null; + } + activeFitAddon.fit(); + return { + threadId, + terminalId, + cwd, + ...(worktreePath !== undefined ? { worktreePath } : {}), + cols: activeTerminal.cols, + rows: activeTerminal.rows, + ...(runtimeEnv ? { env: runtimeEnv } : {}), + }; + }; + + const markTerminalEventApplied = (event: TerminalEvent) => { + const key = terminalEventDedupeKey(event); + const keys = appliedTerminalEventKeysRef.current; + if (keys.has(key)) { + return false; + } + keys.add(key); + if (keys.size > MAX_APPLIED_TERMINAL_EVENT_KEYS) { + const oldestKey = keys.values().next().value; + if (typeof oldestKey === "string") { + keys.delete(oldestKey); + } + } + return true; + }; + const sendTerminalInput = async (data: string, fallbackError: string) => { const activeTerminal = terminalRef.current; if (!activeTerminal) return; @@ -514,14 +589,7 @@ export function TerminalViewport({ }); const inputDisposable = terminal.onData((data) => { - void api.terminal - .write({ threadId, terminalId, data }) - .catch((err) => - writeSystemMessage( - terminal, - err instanceof Error ? err.message : "Terminal write failed", - ), - ); + void sendTerminalInput(data, "Terminal write failed"); }); const selectionDisposable = terminal.onSelectionChange(() => { @@ -572,6 +640,9 @@ export function TerminalViewport({ if (!activeTerminal) { return; } + if (!markTerminalEventApplied(event)) { + return; + } if (event.type === "activity") { return; @@ -663,6 +734,12 @@ export function TerminalViewport({ applyPendingTerminalEvents(nextEntries); }); + const unsubscribeLiveTerminalEvents = api.terminal.onEvent((event) => { + if (event.threadId !== threadId || event.terminalId !== terminalId) { + return; + } + applyTerminalEvent(event); + }); const openTerminal = async () => { try { @@ -670,15 +747,9 @@ export function TerminalViewport({ const activeFitAddon = fitAddonRef.current; if (!activeTerminal || !activeFitAddon) return; activeFitAddon.fit(); - const snapshot = await api.terminal.open({ - threadId, - terminalId, - cwd, - ...(worktreePath !== undefined ? { worktreePath } : {}), - cols: activeTerminal.cols, - rows: activeTerminal.rows, - ...(runtimeEnv ? { env: runtimeEnv } : {}), - }); + const input = terminalOpenInput(); + if (!input) return; + const snapshot = await api.terminal.open(input); if (disposed) return; writeTerminalSnapshot(activeTerminal, snapshot); const bufferedEntries = selectTerminalEventEntries( @@ -734,7 +805,9 @@ export function TerminalViewport({ disposed = true; terminalHydratedRef.current = false; lastAppliedTerminalEventIdRef.current = 0; + appliedTerminalEventKeysRef.current.clear(); unsubscribeTerminalEvents(); + unsubscribeLiveTerminalEvents(); window.clearTimeout(fitTimer); inputDisposable.dispose(); selectionDisposable.dispose();