From adb511d2be5425c8db009b71b2fa197faa810d08 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 15 Feb 2026 15:40:18 +0700 Subject: [PATCH] refactor(app): extract terminal runtime out of react lifecycle --- packages/app/e2e/terminal-pane.spec.ts | 78 +++ .../app/src/components/terminal-emulator.tsx | 482 ++------------- packages/app/src/components/terminal-pane.tsx | 311 ++++------ .../runtime/terminal-emulator-runtime.ts | 560 ++++++++++++++++++ .../runtime/terminal-output-pump.test.ts | 101 ++++ .../terminal/runtime/terminal-output-pump.ts | 155 +++++ .../terminal-stream-controller.test.ts | 254 ++++++++ .../runtime/terminal-stream-controller.ts | 409 +++++++++++++ .../src/utils/terminal-output-buffer.test.ts | 42 ++ .../app/src/utils/terminal-output-buffer.ts | 95 +++ 10 files changed, 1858 insertions(+), 629 deletions(-) create mode 100644 packages/app/src/terminal/runtime/terminal-emulator-runtime.ts create mode 100644 packages/app/src/terminal/runtime/terminal-output-pump.test.ts create mode 100644 packages/app/src/terminal/runtime/terminal-output-pump.ts create mode 100644 packages/app/src/terminal/runtime/terminal-stream-controller.test.ts create mode 100644 packages/app/src/terminal/runtime/terminal-stream-controller.ts create mode 100644 packages/app/src/utils/terminal-output-buffer.test.ts create mode 100644 packages/app/src/utils/terminal-output-buffer.ts diff --git a/packages/app/e2e/terminal-pane.spec.ts b/packages/app/e2e/terminal-pane.spec.ts index f73be2370..0c9ad866a 100644 --- a/packages/app/e2e/terminal-pane.spec.ts +++ b/packages/app/e2e/terminal-pane.spec.ts @@ -284,6 +284,84 @@ test("terminal reattaches cleanly after heavy output and tab switches", async ({ } }); +test("terminal keeps prompt echo visible after enter and backspace churn", async ({ page }) => { + const repo = await createTempGitRepo("paseo-e2e-terminal-echo-churn-"); + + try { + await openNewAgentDraft(page); + await setWorkingDirectory(page, repo.path); + await ensureHostSelected(page); + await createAgent(page, "hello"); + + await openTerminalsPanel(page); + + const surface = page.getByTestId("terminal-surface").first(); + await expect(surface).toBeVisible({ timeout: 30000 }); + await surface.click({ force: true }); + + for (let iteration = 0; iteration < 40; iteration += 1) { + await page.keyboard.press("Enter"); + } + + const markerAfterEnters = `echo-visible-${Date.now()}`; + await page.keyboard.type(`echo ${markerAfterEnters}`, { delay: 0 }); + await expect(surface).toContainText(`echo ${markerAfterEnters}`, { + timeout: 30000, + }); + await page.keyboard.press("Enter"); + await expect(surface).toContainText(markerAfterEnters, { + timeout: 30000, + }); + + const longSuffix = "x".repeat(120); + await page.keyboard.type(`echo ${longSuffix}`, { delay: 0 }); + for (let iteration = 0; iteration < longSuffix.length; iteration += 1) { + await page.keyboard.press("Backspace"); + } + + const markerAfterBackspace = `echo-backspace-${Date.now()}`; + await page.keyboard.type(markerAfterBackspace, { delay: 0 }); + await page.keyboard.press("Enter"); + await expect(surface).toContainText(markerAfterBackspace, { + timeout: 30000, + }); + } finally { + await repo.cleanup(); + } +}); + +test("terminal remains interactive after alternate-screen enter/exit", async ({ page }) => { + const repo = await createTempGitRepo("paseo-e2e-terminal-alt-screen-"); + + try { + await openNewAgentDraft(page); + await setWorkingDirectory(page, repo.path); + await ensureHostSelected(page); + await createAgent(page, "hello"); + + await openTerminalsPanel(page); + + const surface = page.getByTestId("terminal-surface").first(); + await expect(surface).toBeVisible({ timeout: 30000 }); + await surface.click({ force: true }); + + await page.keyboard.type( + "printf '\\033[?1049h\\033[2J\\033[HALT\\033[?1049l\\n'", + { delay: 0 } + ); + await page.keyboard.press("Enter"); + + const marker = `post-alt-screen-${Date.now()}`; + await page.keyboard.type(`echo ${marker}`, { delay: 0 }); + await page.keyboard.press("Enter"); + await expect(surface).toContainText(marker, { + timeout: 30000, + }); + } finally { + await repo.cleanup(); + } +}); + test("terminal tab is removed when shell exits", async ({ page }) => { const repo = await createTempGitRepo("paseo-e2e-terminal-exit-"); diff --git a/packages/app/src/components/terminal-emulator.tsx b/packages/app/src/components/terminal-emulator.tsx index 6e40cef23..44580b8b1 100644 --- a/packages/app/src/components/terminal-emulator.tsx +++ b/packages/app/src/components/terminal-emulator.tsx @@ -1,29 +1,23 @@ "use dom"; import { useEffect, useRef } from "react"; -import { FitAddon } from "@xterm/addon-fit"; -import { Terminal } from "@xterm/xterm"; import type { DOMProps } from "expo/dom"; import "@xterm/xterm/css/xterm.css"; -import { - type PendingTerminalModifiers, - isTerminalModifierDomKey, - mergeTerminalModifiers, - normalizeDomTerminalKey, - normalizeTerminalTransportKey, - shouldInterceptDomTerminalKey, -} from "../utils/terminal-keys"; +import type { PendingTerminalModifiers } from "../utils/terminal-keys"; +import { TerminalEmulatorRuntime } from "../terminal/runtime/terminal-emulator-runtime"; interface TerminalEmulatorProps { dom?: DOMProps; streamKey: string; - outputText: string; + initialOutputText: string; + outputChunkText: string; + outputChunkSequence: number; testId?: string; backgroundColor?: string; foregroundColor?: string; cursorColor?: string; onInput?: (data: string) => Promise | void; - onResize?: (rows: number, cols: number) => Promise | void; + onResize?: (input: { rows: number; cols: number }) => Promise | void; onTerminalKey?: (input: { key: string; ctrl: boolean; @@ -37,14 +31,14 @@ interface TerminalEmulatorProps { } declare global { - interface Window { - __paseoTerminal?: Terminal; - } + interface Window {} } export default function TerminalEmulator({ streamKey, - outputText, + initialOutputText, + outputChunkText, + outputChunkSequence, testId = "terminal-surface", backgroundColor = "#0b0b0b", foregroundColor = "#e6e6e6", @@ -58,36 +52,7 @@ export default function TerminalEmulator({ }: TerminalEmulatorProps) { const rootRef = useRef(null); const hostRef = useRef(null); - const terminalRef = useRef(null); - const renderedOutputRef = useRef(""); - const lastSizeRef = useRef<{ rows: number; cols: number } | null>(null); - const onInputRef = useRef(onInput); - const onResizeRef = useRef(onResize); - const onTerminalKeyRef = useRef(onTerminalKey); - const onPendingModifiersConsumedRef = useRef< - TerminalEmulatorProps["onPendingModifiersConsumed"] - >(onPendingModifiersConsumed); - const pendingModifiersRef = useRef(pendingModifiers); - - useEffect(() => { - onInputRef.current = onInput; - }, [onInput]); - - useEffect(() => { - onResizeRef.current = onResize; - }, [onResize]); - - useEffect(() => { - onTerminalKeyRef.current = onTerminalKey; - }, [onTerminalKey]); - - useEffect(() => { - onPendingModifiersConsumedRef.current = onPendingModifiersConsumed; - }, [onPendingModifiersConsumed]); - - useEffect(() => { - pendingModifiersRef.current = pendingModifiers; - }, [pendingModifiers]); + const runtimeRef = useRef(null); useEffect(() => { const host = hostRef.current; @@ -96,405 +61,72 @@ export default function TerminalEmulator({ return; } - renderedOutputRef.current = ""; - lastSizeRef.current = null; - host.innerHTML = ""; - - const terminal = new Terminal({ - allowProposedApi: true, - convertEol: false, - cursorBlink: true, - cursorStyle: "bar", - fontFamily: "'SF Mono', Menlo, Monaco, Consolas, 'Liberation Mono', monospace", - fontSize: 13, - lineHeight: 1.25, - scrollback: 10_000, - theme: { - background: backgroundColor, - foreground: foregroundColor, - cursor: cursorColor, + const runtime = new TerminalEmulatorRuntime(); + runtimeRef.current = runtime; + runtime.setCallbacks({ + callbacks: { + onInput, + onResize, + onTerminalKey, + onPendingModifiersConsumed, }, }); - const fitAddon = new FitAddon(); - terminal.loadAddon(fitAddon); - terminal.open(host); - - const documentElement = document.documentElement; - const body = document.body; - const rootContainer = root.parentElement; - - const previousDocumentElementOverflow = documentElement.style.overflow; - const previousDocumentElementWidth = documentElement.style.width; - const previousDocumentElementHeight = documentElement.style.height; - - const previousBodyOverflow = body.style.overflow; - const previousBodyWidth = body.style.width; - const previousBodyHeight = body.style.height; - const previousBodyMargin = body.style.margin; - const previousBodyPadding = body.style.padding; - - const previousRootOverflow = rootContainer?.style.overflow ?? ""; - const previousRootWidth = rootContainer?.style.width ?? ""; - const previousRootHeight = rootContainer?.style.height ?? ""; - - // Force document to follow WebView bounds; xterm viewport owns scrollback. - documentElement.style.overflow = "hidden"; - documentElement.style.width = "100%"; - documentElement.style.height = "100%"; - - body.style.overflow = "hidden"; - body.style.width = "100%"; - body.style.height = "100%"; - body.style.margin = "0"; - body.style.padding = "0"; - - if (rootContainer) { - rootContainer.style.overflow = "hidden"; - rootContainer.style.width = "100%"; - rootContainer.style.height = "100%"; - } - - const viewportElement = host.querySelector(".xterm-viewport"); - const screenElement = host.querySelector(".xterm-screen"); - const previousViewportOverscroll = viewportElement?.style.overscrollBehavior ?? ""; - const previousViewportTouchAction = viewportElement?.style.touchAction ?? ""; - const previousViewportOverflowY = viewportElement?.style.overflowY ?? ""; - const previousViewportOverflowX = viewportElement?.style.overflowX ?? ""; - const previousViewportPointerEvents = viewportElement?.style.pointerEvents ?? ""; - const previousViewportWebkitOverflowScrolling = - viewportElement?.style.getPropertyValue("-webkit-overflow-scrolling") ?? ""; - if (viewportElement) { - viewportElement.style.overscrollBehavior = "none"; - viewportElement.style.touchAction = "pan-y"; - viewportElement.style.overflowY = "auto"; - viewportElement.style.overflowX = "hidden"; - viewportElement.style.pointerEvents = "auto"; - viewportElement.style.setProperty("-webkit-overflow-scrolling", "touch"); - } - const previousScreenPointerEvents = screenElement?.style.pointerEvents ?? ""; - if (screenElement) { - // xterm renders the screen layer above the viewport. Disable hit-testing on that layer - // so touch drags can reach the scrollable viewport on mobile. - screenElement.style.pointerEvents = "none"; - } - - terminalRef.current = terminal; - window.__paseoTerminal = terminal; - - const fitAndEmitResize = (force = false) => { - const handler = onResizeRef.current; - if (!handler) { - return; - } - - try { - fitAddon.fit(); - } catch { - return; - } - - const rows = terminal.rows; - const cols = terminal.cols; - const previous = lastSizeRef.current; - if (!force && previous && previous.rows === rows && previous.cols === cols) { - return; - } - - lastSizeRef.current = { rows, cols }; - void handler(rows, cols); - }; - - fitAndEmitResize(true); - - const inputDisposable = terminal.onData((data) => { - const handler = onInputRef.current; - if (!handler) { - return; - } - void handler(data); + runtime.setPendingModifiers({ pendingModifiers }); + runtime.mount({ + root, + host, + initialOutputText, + theme: { + backgroundColor, + foregroundColor, + cursorColor, + }, }); - terminal.attachCustomKeyEventHandler((event) => { - if (event.type !== "keydown" || event.isComposing) { - return true; - } - - const normalizedKey = normalizeDomTerminalKey(event.key); - if (!normalizedKey || isTerminalModifierDomKey(event.key)) { - return true; - } - - const pending = pendingModifiersRef.current; - if ( - !shouldInterceptDomTerminalKey({ - key: normalizedKey, - ctrlKey: event.ctrlKey, - altKey: event.altKey, - pendingModifiers: pending, - }) - ) { - return true; - } - - const modifiers = mergeTerminalModifiers({ - pendingModifiers: pending, - ctrlKey: event.ctrlKey, - shiftKey: event.shiftKey, - altKey: event.altKey, - metaKey: event.metaKey, - }); - - const keyPayload = { - key: normalizeTerminalTransportKey(normalizedKey), - ...modifiers, - }; - onTerminalKeyRef.current?.(keyPayload); - - if (pending.ctrl || pending.shift || pending.alt) { - onPendingModifiersConsumedRef.current?.(); - } - - event.preventDefault(); - event.stopPropagation(); - return false; - }); - - let touchScrollRemainderPx = 0; - const touchScrollLineHeightPx = (() => { - const row = host.querySelector(".xterm-rows > div"); - const measured = row?.getBoundingClientRect().height; - return measured && measured > 0 ? measured : 18; - })(); - const activeTouchRef: { - identifier: number; - startX: number; - startY: number; - lastX: number; - lastY: number; - mode: "vertical" | "horizontal" | null; - } = { - identifier: -1, - startX: 0, - startY: 0, - lastX: 0, - lastY: 0, - mode: null, - }; - const rootTouchStartHandler = (event: TouchEvent) => { - if (event.touches.length !== 1) { - touchScrollRemainderPx = 0; - activeTouchRef.identifier = -1; - activeTouchRef.mode = null; - return; - } - const touch = event.touches[0]; - if (!touch) { - touchScrollRemainderPx = 0; - activeTouchRef.identifier = -1; - activeTouchRef.mode = null; - return; - } - activeTouchRef.identifier = touch.identifier; - activeTouchRef.startX = touch.clientX; - activeTouchRef.startY = touch.clientY; - activeTouchRef.lastX = touch.clientX; - activeTouchRef.lastY = touch.clientY; - activeTouchRef.mode = null; - touchScrollRemainderPx = 0; - }; - const rootTouchMoveHandler = (event: TouchEvent) => { - if (event.touches.length !== 1) { - return; - } - const touch = Array.from(event.touches).find( - (candidate) => candidate.identifier === activeTouchRef.identifier - ); - if (!touch) { - return; - } - - const totalDeltaX = touch.clientX - activeTouchRef.startX; - const totalDeltaY = touch.clientY - activeTouchRef.startY; - if (activeTouchRef.mode === null) { - const absX = Math.abs(totalDeltaX); - const absY = Math.abs(totalDeltaY); - if (absX > 8 || absY > 8) { - activeTouchRef.mode = absY >= absX ? "vertical" : "horizontal"; - } - } - - const deltaY = touch.clientY - activeTouchRef.lastY; - activeTouchRef.lastX = touch.clientX; - activeTouchRef.lastY = touch.clientY; - - if (activeTouchRef.mode !== "vertical") { - return; - } - - // Manual vertical touch scrolling fallback for xterm's layered DOM. - touchScrollRemainderPx += deltaY; - const lineDelta = Math.trunc(touchScrollRemainderPx / touchScrollLineHeightPx); - if (lineDelta !== 0) { - const appliedLineDelta = -lineDelta; - terminal.scrollLines(appliedLineDelta); - touchScrollRemainderPx -= lineDelta * touchScrollLineHeightPx; - } - event.preventDefault(); - }; - const rootTouchEndHandler = (event: TouchEvent) => { - const changed = Array.from(event.changedTouches).some( - (touch) => touch.identifier === activeTouchRef.identifier - ); - if (changed || event.touches.length === 0) { - touchScrollRemainderPx = 0; - activeTouchRef.identifier = -1; - activeTouchRef.mode = null; - } - }; - const rootTouchCancelHandler = () => { - touchScrollRemainderPx = 0; - activeTouchRef.identifier = -1; - activeTouchRef.mode = null; - }; - root.addEventListener("touchstart", rootTouchStartHandler, { passive: true }); - root.addEventListener("touchmove", rootTouchMoveHandler, { passive: false }); - root.addEventListener("touchend", rootTouchEndHandler, { passive: true }); - root.addEventListener("touchcancel", rootTouchCancelHandler, { passive: true }); - - const resizeObserver = new ResizeObserver(() => { - fitAndEmitResize(); - }); - resizeObserver.observe(root); - resizeObserver.observe(host); - - const windowResizeHandler = () => fitAndEmitResize(); - window.addEventListener("resize", windowResizeHandler); - - const visualViewport = window.visualViewport; - const visualViewportResizeHandler = () => fitAndEmitResize(); - visualViewport?.addEventListener("resize", visualViewportResizeHandler); - - // Safety net for keyboard/layout transitions that can skip callbacks. - const fitInterval = window.setInterval(() => { - fitAndEmitResize(); - }, 250); - - const fitTimeoutHandles = [0, 16, 48, 120, 250, 500, 1_000, 2_000].map((delay) => - window.setTimeout(() => { - fitAndEmitResize(true); - }, delay) - ); - - const fontSet = document.fonts; - const fontReadyHandler = () => { - fitAndEmitResize(true); - }; - fontSet?.addEventListener?.("loadingdone", fontReadyHandler); - void fontSet?.ready - .then(() => { - fitAndEmitResize(true); - }) - .catch(() => { - // no-op - }); - - window.setTimeout(() => fitAndEmitResize(true), 0); - - if (outputText.length > 0) { - terminal.write(outputText); - renderedOutputRef.current = outputText; - } - return () => { - inputDisposable.dispose(); - resizeObserver.disconnect(); - window.removeEventListener("resize", windowResizeHandler); - visualViewport?.removeEventListener("resize", visualViewportResizeHandler); - window.clearInterval(fitInterval); - for (const handle of fitTimeoutHandles) { - window.clearTimeout(handle); + runtime.unmount(); + if (runtimeRef.current === runtime) { + runtimeRef.current = null; } - fontSet?.removeEventListener?.("loadingdone", fontReadyHandler); - root.removeEventListener("touchstart", rootTouchStartHandler); - root.removeEventListener("touchmove", rootTouchMoveHandler); - root.removeEventListener("touchend", rootTouchEndHandler); - root.removeEventListener("touchcancel", rootTouchCancelHandler); - - fitAddon.dispose(); - terminal.dispose(); - - documentElement.style.overflow = previousDocumentElementOverflow; - documentElement.style.width = previousDocumentElementWidth; - documentElement.style.height = previousDocumentElementHeight; - - body.style.overflow = previousBodyOverflow; - body.style.width = previousBodyWidth; - body.style.height = previousBodyHeight; - body.style.margin = previousBodyMargin; - body.style.padding = previousBodyPadding; - - if (rootContainer) { - rootContainer.style.overflow = previousRootOverflow; - rootContainer.style.width = previousRootWidth; - rootContainer.style.height = previousRootHeight; - } - - if (viewportElement) { - viewportElement.style.overscrollBehavior = previousViewportOverscroll; - viewportElement.style.touchAction = previousViewportTouchAction; - viewportElement.style.overflowY = previousViewportOverflowY; - viewportElement.style.overflowX = previousViewportOverflowX; - viewportElement.style.pointerEvents = previousViewportPointerEvents; - viewportElement.style.setProperty( - "-webkit-overflow-scrolling", - previousViewportWebkitOverflowScrolling - ); - } - if (screenElement) { - screenElement.style.pointerEvents = previousScreenPointerEvents; - } - - terminalRef.current = null; - if (window.__paseoTerminal === terminal) { - window.__paseoTerminal = undefined; - } - renderedOutputRef.current = ""; - lastSizeRef.current = null; }; }, [backgroundColor, cursorColor, foregroundColor, streamKey]); useEffect(() => { - const terminal = terminalRef.current; - if (!terminal) { + runtimeRef.current?.setCallbacks({ + callbacks: { + onInput, + onResize, + onTerminalKey, + onPendingModifiersConsumed, + }, + }); + }, [onInput, onPendingModifiersConsumed, onResize, onTerminalKey]); + + useEffect(() => { + runtimeRef.current?.setPendingModifiers({ pendingModifiers }); + }, [pendingModifiers]); + + useEffect(() => { + const runtime = runtimeRef.current; + if (!runtime) { return; } - const previous = renderedOutputRef.current; - if (outputText === previous) { + if (outputChunkSequence <= 0) { return; } - - if (previous.length > 0 && outputText.startsWith(previous)) { - const suffix = outputText.slice(previous.length); - if (suffix.length > 0) { - terminal.write(suffix); - } - } else { - terminal.reset(); - terminal.clear(); - if (outputText.length > 0) { - terminal.write(outputText); - } + if (outputChunkText.length === 0) { + runtime.clear(); + return; } - - renderedOutputRef.current = outputText; - }, [outputText]); + runtime.write({ text: outputChunkText }); + }, [outputChunkSequence, outputChunkText]); useEffect(() => { if (focusRequestToken <= 0) { return; } - terminalRef.current?.focus(); + runtimeRef.current?.focus(); }, [focusRequestToken]); return ( @@ -513,7 +145,7 @@ export default function TerminalEmulator({ overscrollBehavior: "none", }} onPointerDown={() => { - terminalRef.current?.focus(); + runtimeRef.current?.focus(); }} >
terminalScopeKey({ serverId, cwd }), [serverId, cwd]); const selectedTerminalByScopeRef = useRef>(new Map()); const lastReportedSizeRef = useRef<{ rows: number; cols: number } | null>(null); - const resumeOffsetByTerminalIdRef = useRef>(new Map()); + const streamControllerRef = useRef(null); + const outputPumpRef = useRef(null); const [selectedTerminalId, setSelectedTerminalId] = useState(null); - const [outputByTerminalId, setOutputByTerminalId] = useState>( - () => new Map() - ); + const [selectedOutputChunk, setSelectedOutputChunk] = useState({ + sequence: 0, + text: "", + }); + const [selectedOutputSnapshot, setSelectedOutputSnapshot] = useState(""); const [activeStream, setActiveStream] = useState<{ terminalId: string; streamId: number; } | null>(null); - const [attachGeneration, setAttachGeneration] = useState(0); const [isAttaching, setIsAttaching] = useState(false); const [streamError, setStreamError] = useState(null); const [modifiers, setModifiers] = useState(EMPTY_MODIFIERS); @@ -133,18 +138,27 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) { ); const hoverOutTimeoutRef = useRef | null>(null); const selectedTerminalIdRef = useRef(selectedTerminalId); - const activeStreamRef = useRef<{ - terminalId: string; - streamId: number; - } | null>(activeStream); useEffect(() => { selectedTerminalIdRef.current = selectedTerminalId; }, [selectedTerminalId]); useEffect(() => { - activeStreamRef.current = activeStream; - }, [activeStream]); + const outputPump = new TerminalOutputPump({ + maxOutputChars: MAX_OUTPUT_CHARS, + onSelectedOutputChunk: (chunk: TerminalOutputChunk) => { + setSelectedOutputChunk(chunk); + }, + }); + outputPumpRef.current = outputPump; + + return () => { + if (outputPumpRef.current === outputPump) { + outputPumpRef.current = null; + } + outputPump.dispose(); + }; + }, []); const clearHoverOutTimeout = useCallback(() => { if (!hoverOutTimeoutRef.current) { @@ -221,20 +235,11 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) { return; } - const activeStreamForTerminal = activeStreamRef.current; - if ( - selectedTerminalIdRef.current === exitedTerminalId && - activeStreamForTerminal?.terminalId === exitedTerminalId && - activeStreamForTerminal.streamId === message.payload.streamId - ) { - setActiveStream((current) => - current?.terminalId === exitedTerminalId ? null : current - ); - setStreamError("Terminal stream ended. Reconnecting…"); - setIsAttaching(true); - setAttachGeneration((current) => current + 1); - setModifiers({ ...EMPTY_MODIFIERS }); - } + streamControllerRef.current?.handleStreamExit({ + terminalId: exitedTerminalId, + streamId: message.payload.streamId, + }); + setModifiers({ ...EMPTY_MODIFIERS }); void queryClient.invalidateQueries({ queryKey: ["terminals", serverId, cwd], @@ -277,15 +282,11 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) { }, onSuccess: (_, terminalId) => { setHoveredTerminalId((current) => (current === terminalId ? null : current)); - resumeOffsetByTerminalIdRef.current.delete(terminalId); + outputPumpRef.current?.clearTerminal({ terminalId }); if (selectedTerminalIdRef.current === terminalId) { setSelectedTerminalId((current) => current === terminalId ? null : current ); - setActiveStream((current) => - current?.terminalId === terminalId ? null : current - ); - setIsAttaching(false); setModifiers({ ...EMPTY_MODIFIERS }); } void queryClient.invalidateQueries({ @@ -336,178 +337,82 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) { }, [scopeKey, terminals, selectedTerminalId]); useEffect(() => { - if (terminals.length === 0) { - resumeOffsetByTerminalIdRef.current.clear(); - return; - } - - const terminalIdSet = new Set(terminals.map((terminal) => terminal.id)); - for (const terminalId of Array.from(resumeOffsetByTerminalIdRef.current.keys())) { - if (!terminalIdSet.has(terminalId)) { - resumeOffsetByTerminalIdRef.current.delete(terminalId); - } - } + const terminalIds = terminals.map((terminal) => terminal.id); + outputPumpRef.current?.prune({ terminalIds }); + streamControllerRef.current?.pruneResumeOffsets({ terminalIds }); }, [terminals]); - const appendOutput = useCallback((terminalId: string, text: string) => { - if (!text) { - return; - } - setOutputByTerminalId((previous) => { - const next = new Map(previous); - const existing = next.get(terminalId) ?? ""; - const combined = `${existing}${text}`; - next.set( - terminalId, - combined.length > MAX_OUTPUT_CHARS - ? combined.slice(combined.length - MAX_OUTPUT_CHARS) - : combined - ); - return next; - }); - }, []); + const handleStreamControllerStatus = useCallback( + (status: TerminalStreamControllerStatus) => { + setIsAttaching(status.isAttaching); + setStreamError(status.error); + if (status.terminalId && typeof status.streamId === "number") { + setActiveStream({ + terminalId: status.terminalId, + streamId: status.streamId, + }); + return; + } + setActiveStream(null); + }, + [] + ); useEffect(() => { - let isCancelled = false; - let streamId: number | null = null; - let unsubscribe: (() => void) | null = null; - let decoder: TextDecoder | null = null; - const terminalId = selectedTerminalId; + streamControllerRef.current?.dispose(); + streamControllerRef.current = null; + setActiveStream(null); + setIsAttaching(false); + setStreamError(null); - if (!client || !isConnected || !terminalId) { - setActiveStream(null); - setIsAttaching(false); + if (!client || !isConnected) { return; } - setIsAttaching(true); - setStreamError(null); + const outputPump = outputPumpRef.current; + if (!outputPump) { + return; + } - const attach = async () => { - try { - let lastErrorMessage = "Unable to attach terminal stream"; - const totalAttempts = TERMINAL_ATTACH_MAX_ATTEMPTS; - for (let attempt = 0; attempt < totalAttempts; attempt += 1) { - try { - const lastSize = lastReportedSizeRef.current; - const resumeOffset = getTerminalResumeOffset({ - terminalId, - resumeOffsetByTerminalId: resumeOffsetByTerminalIdRef.current, - }); - const attachPayload = await withPromiseTimeout({ - promise: client.attachTerminalStream(terminalId, { - ...(resumeOffset !== undefined ? { resumeOffset } : {}), - ...(lastSize - ? { - rows: lastSize.rows, - cols: lastSize.cols, - } - : {}), - }), - timeoutMs: TERMINAL_ATTACH_TIMEOUT_MS, - timeoutMessage: "Timed out attaching terminal stream", - }); - if (isCancelled) { - if (typeof attachPayload.streamId === "number") { - void client.detachTerminalStream(attachPayload.streamId).catch(() => {}); - } - return; - } - - if (attachPayload.error || typeof attachPayload.streamId !== "number") { - lastErrorMessage = attachPayload.error ?? "Unable to attach terminal stream"; - const hasRemainingAttempts = attempt < totalAttempts - 1; - if ( - hasRemainingAttempts && - isTerminalAttachRetryableError({ message: lastErrorMessage }) - ) { - await waitForDuration({ - durationMs: getTerminalAttachRetryDelayMs({ attempt }), - }); - continue; - } - setStreamError(lastErrorMessage); - setActiveStream(null); - return; - } - - streamId = attachPayload.streamId; - updateTerminalResumeOffset({ - terminalId, - offset: attachPayload.currentOffset, - resumeOffsetByTerminalId: resumeOffsetByTerminalIdRef.current, - }); - decoder = new TextDecoder(); - setActiveStream({ terminalId, streamId }); - setStreamError(null); - - unsubscribe = client.onTerminalStreamData(streamId, (chunk) => { - if (isCancelled) { - return; - } - updateTerminalResumeOffset({ - terminalId, - offset: chunk.endOffset, - resumeOffsetByTerminalId: resumeOffsetByTerminalIdRef.current, - }); - const text = decoder?.decode(chunk.data, { stream: true }) ?? ""; - appendOutput(terminalId, text); - }); - return; - } catch (error) { - lastErrorMessage = - error instanceof Error ? error.message : "Unable to attach terminal stream"; - const hasRemainingAttempts = attempt < totalAttempts - 1; - if ( - hasRemainingAttempts && - isTerminalAttachRetryableError({ message: lastErrorMessage }) - ) { - await waitForDuration({ - durationMs: getTerminalAttachRetryDelayMs({ attempt }), - }); - continue; - } - - if (!isCancelled) { - setStreamError(lastErrorMessage); - setActiveStream(null); - } - return; - } + const controller = new TerminalStreamController({ + client, + getPreferredSize: () => lastReportedSizeRef.current, + onChunk: ({ terminalId, text }) => { + outputPump.append({ terminalId, text }); + }, + onReset: ({ terminalId }) => { + outputPump.clearTerminal({ terminalId }); + if (selectedTerminalIdRef.current === terminalId) { + setSelectedOutputSnapshot(""); } + }, + onStatusChange: handleStreamControllerStatus, + }); - if (!isCancelled) { - setStreamError(lastErrorMessage); - setActiveStream(null); - } - } finally { - if (!isCancelled) { - setIsAttaching(false); - } - } - }; - - void attach(); + streamControllerRef.current = controller; + controller.setTerminal({ terminalId: selectedTerminalIdRef.current }); return () => { - isCancelled = true; - if (decoder) { - appendOutput(terminalId, decoder.decode()); - decoder = null; + controller.dispose(); + if (streamControllerRef.current === controller) { + streamControllerRef.current = null; } - if (unsubscribe) { - unsubscribe(); - unsubscribe = null; - } - if (streamId !== null) { - void client.detachTerminalStream(streamId).catch(() => {}); - } - setActiveStream((current) => - current?.terminalId === terminalId ? null : current - ); - setIsAttaching(false); }; - }, [appendOutput, attachGeneration, client, isConnected, selectedTerminalId]); + }, [client, handleStreamControllerStatus, isConnected]); + + useEffect(() => { + outputPumpRef.current?.setSelectedTerminal({ + terminalId: selectedTerminalId, + }); + streamControllerRef.current?.setTerminal({ + terminalId: selectedTerminalId, + }); + setSelectedOutputSnapshot( + outputPumpRef.current?.readSnapshot({ + terminalId: selectedTerminalId, + }) ?? "" + ); + }, [selectedTerminalId]); const activeStreamId = activeStream && activeStream.terminalId === selectedTerminalId @@ -518,11 +423,6 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) { () => terminals.find((terminal) => terminal.id === selectedTerminalId) ?? null, [terminals, selectedTerminalId] ); - - const currentOutput = selectedTerminalId - ? (outputByTerminalId.get(selectedTerminalId) ?? "") - : ""; - const handleCreateTerminal = useCallback(() => { createTerminalMutation.mutate(); }, [createTerminalMutation]); @@ -623,7 +523,8 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) { ); const handleTerminalResize = useCallback( - async (rows: number, cols: number) => { + async (input: { rows: number; cols: number }) => { + const { rows, cols } = input; if (!client || !selectedTerminalId || rows <= 0 || cols <= 0) { return; } @@ -834,8 +735,10 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) { automaticallyAdjustContentInsets: false, contentInsetAdjustmentBehavior: "never", }} - streamKey={`${scopeKey}:${selectedTerminal.id}:${activeStreamId ?? "none"}`} - outputText={currentOutput} + streamKey={`${scopeKey}:${selectedTerminal.id}`} + initialOutputText={selectedOutputSnapshot} + outputChunkText={selectedOutputChunk.text} + outputChunkSequence={selectedOutputChunk.sequence} testId="terminal-surface" backgroundColor={theme.colors.background} foregroundColor={theme.colors.foreground} diff --git a/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts b/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts new file mode 100644 index 000000000..9b273beb9 --- /dev/null +++ b/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts @@ -0,0 +1,560 @@ +import { FitAddon } from "@xterm/addon-fit"; +import { Terminal } from "@xterm/xterm"; +import { + type PendingTerminalModifiers, + isTerminalModifierDomKey, + mergeTerminalModifiers, + normalizeDomTerminalKey, + normalizeTerminalTransportKey, + shouldInterceptDomTerminalKey, +} from "@/utils/terminal-keys"; + +export type TerminalEmulatorRuntimeTheme = { + backgroundColor: string; + foregroundColor: string; + cursorColor: string; +}; + +export type TerminalEmulatorRuntimeMountInput = { + root: HTMLDivElement; + host: HTMLDivElement; + initialOutputText: string; + theme: TerminalEmulatorRuntimeTheme; +}; + +export type TerminalEmulatorRuntimeCallbacks = { + onInput?: (data: string) => Promise | void; + onResize?: (input: { rows: number; cols: number }) => Promise | void; + onTerminalKey?: (input: { + key: string; + ctrl: boolean; + shift: boolean; + alt: boolean; + meta: boolean; + }) => Promise | void; + onPendingModifiersConsumed?: () => Promise | void; +}; + +type TerminalEmulatorRuntimeDisposables = { + disposeInput: () => void; + disconnectResizeObserver: () => void; + removeWindowResize: () => void; + removeVisualViewportResize: () => void; + clearFitInterval: () => void; + clearFitTimeouts: () => void; + removeFontListeners: () => void; + removeTouchListeners: () => void; + restoreDocumentStyles: () => void; + restoreViewportStyles: () => void; + disposeFitAddon: () => void; + disposeTerminal: () => void; +}; + +declare global { + interface Window { + __paseoTerminal?: Terminal; + } +} + +const DEFAULT_TOUCH_SCROLL_LINE_HEIGHT_PX = 18; +const FIT_TIMEOUT_DELAYS_MS = [0, 16, 48, 120, 250, 500, 1_000, 2_000]; + +export class TerminalEmulatorRuntime { + private callbacks: TerminalEmulatorRuntimeCallbacks = {}; + private pendingModifiers: PendingTerminalModifiers = { + ctrl: false, + shift: false, + alt: false, + }; + private terminal: Terminal | null = null; + private fitAddon: FitAddon | null = null; + private lastSize: { rows: number; cols: number } | null = null; + private cleanup: (() => void) | null = null; + private pendingWriteText = ""; + private isWriteFlushQueued = false; + + setCallbacks(input: { callbacks: TerminalEmulatorRuntimeCallbacks }): void { + this.callbacks = input.callbacks; + } + + setPendingModifiers(input: { pendingModifiers: PendingTerminalModifiers }): void { + this.pendingModifiers = input.pendingModifiers; + } + + mount(input: TerminalEmulatorRuntimeMountInput): void { + this.unmount(); + + input.host.innerHTML = ""; + this.lastSize = null; + + const terminal = new Terminal({ + allowProposedApi: true, + convertEol: false, + cursorBlink: true, + cursorStyle: "bar", + fontFamily: "'SF Mono', Menlo, Monaco, Consolas, 'Liberation Mono', monospace", + fontSize: 13, + lineHeight: 1.25, + scrollback: 10_000, + theme: { + background: input.theme.backgroundColor, + foreground: input.theme.foregroundColor, + cursor: input.theme.cursorColor, + }, + }); + const fitAddon = new FitAddon(); + terminal.loadAddon(fitAddon); + terminal.open(input.host); + + const restoreDocumentStyles = this.applyDocumentBoundsStyles({ + root: input.root, + }); + const restoreViewportStyles = this.applyViewportTouchStyles({ + host: input.host, + }); + + this.terminal = terminal; + this.fitAddon = fitAddon; + window.__paseoTerminal = terminal; + + const fitAndEmitResize = (force: boolean): void => { + const currentTerminal = this.terminal; + const currentFitAddon = this.fitAddon; + if (!currentTerminal || !currentFitAddon) { + return; + } + + try { + currentFitAddon.fit(); + } catch { + return; + } + + const nextRows = currentTerminal.rows; + const nextCols = currentTerminal.cols; + const previous = this.lastSize; + if (!force && previous && previous.rows === nextRows && previous.cols === nextCols) { + return; + } + + this.lastSize = { rows: nextRows, cols: nextCols }; + this.callbacks.onResize?.({ + rows: nextRows, + cols: nextCols, + }); + }; + + fitAndEmitResize(true); + + const inputDisposable = terminal.onData((data) => { + this.callbacks.onInput?.(data); + }); + + terminal.attachCustomKeyEventHandler((event) => { + if (event.type !== "keydown" || event.isComposing) { + return true; + } + + const normalizedKey = normalizeDomTerminalKey(event.key); + if (!normalizedKey || isTerminalModifierDomKey(event.key)) { + return true; + } + + if ( + !shouldInterceptDomTerminalKey({ + key: normalizedKey, + ctrlKey: event.ctrlKey, + altKey: event.altKey, + pendingModifiers: this.pendingModifiers, + }) + ) { + return true; + } + + const modifiers = mergeTerminalModifiers({ + pendingModifiers: this.pendingModifiers, + ctrlKey: event.ctrlKey, + shiftKey: event.shiftKey, + altKey: event.altKey, + metaKey: event.metaKey, + }); + this.callbacks.onTerminalKey?.({ + key: normalizeTerminalTransportKey(normalizedKey), + ...modifiers, + }); + + if (this.pendingModifiers.ctrl || this.pendingModifiers.shift || this.pendingModifiers.alt) { + this.callbacks.onPendingModifiersConsumed?.(); + } + + event.preventDefault(); + event.stopPropagation(); + return false; + }); + + const removeTouchListeners = this.setupTouchScrollHandlers({ + root: input.root, + host: input.host, + terminal, + }); + const resizeObserver = new ResizeObserver(() => { + fitAndEmitResize(false); + }); + resizeObserver.observe(input.root); + resizeObserver.observe(input.host); + + const windowResizeHandler = () => fitAndEmitResize(false); + window.addEventListener("resize", windowResizeHandler); + + const visualViewport = window.visualViewport; + const visualViewportResizeHandler = () => fitAndEmitResize(false); + visualViewport?.addEventListener("resize", visualViewportResizeHandler); + + const fitInterval = window.setInterval(() => { + fitAndEmitResize(false); + }, 250); + const fitTimeouts = FIT_TIMEOUT_DELAYS_MS.map((delayMs) => + window.setTimeout(() => { + fitAndEmitResize(true); + }, delayMs) + ); + + const fontSet = document.fonts; + const fontReadyHandler = () => { + fitAndEmitResize(true); + }; + fontSet?.addEventListener?.("loadingdone", fontReadyHandler); + void fontSet?.ready + .then(() => { + fitAndEmitResize(true); + }) + .catch(() => { + // no-op + }); + + window.setTimeout(() => { + fitAndEmitResize(true); + }, 0); + + if (input.initialOutputText.length > 0) { + terminal.write(input.initialOutputText); + } + + const disposables: TerminalEmulatorRuntimeDisposables = { + disposeInput: () => { + inputDisposable.dispose(); + }, + disconnectResizeObserver: () => { + resizeObserver.disconnect(); + }, + removeWindowResize: () => { + window.removeEventListener("resize", windowResizeHandler); + }, + removeVisualViewportResize: () => { + visualViewport?.removeEventListener("resize", visualViewportResizeHandler); + }, + clearFitInterval: () => { + window.clearInterval(fitInterval); + }, + clearFitTimeouts: () => { + for (const handle of fitTimeouts) { + window.clearTimeout(handle); + } + }, + removeFontListeners: () => { + fontSet?.removeEventListener?.("loadingdone", fontReadyHandler); + }, + removeTouchListeners, + restoreDocumentStyles, + restoreViewportStyles, + disposeFitAddon: () => { + fitAddon.dispose(); + }, + disposeTerminal: () => { + terminal.dispose(); + }, + }; + + this.cleanup = () => { + disposables.disposeInput(); + disposables.disconnectResizeObserver(); + disposables.removeWindowResize(); + disposables.removeVisualViewportResize(); + disposables.clearFitInterval(); + disposables.clearFitTimeouts(); + disposables.removeFontListeners(); + disposables.removeTouchListeners(); + disposables.disposeFitAddon(); + disposables.disposeTerminal(); + disposables.restoreDocumentStyles(); + disposables.restoreViewportStyles(); + }; + } + + write(input: { text: string }): void { + if (!this.terminal || input.text.length === 0) { + return; + } + this.pendingWriteText += input.text; + this.scheduleWriteFlush(); + } + + clear(): void { + this.pendingWriteText = ""; + this.terminal?.reset(); + } + + focus(): void { + this.terminal?.focus(); + } + + unmount(): void { + this.pendingWriteText = ""; + this.isWriteFlushQueued = false; + + this.cleanup?.(); + this.cleanup = null; + if (window.__paseoTerminal === this.terminal) { + window.__paseoTerminal = undefined; + } + this.terminal = null; + this.fitAddon = null; + this.lastSize = null; + } + + private scheduleWriteFlush(): void { + if (this.isWriteFlushQueued) { + return; + } + + this.isWriteFlushQueued = true; + queueMicrotask(() => { + this.isWriteFlushQueued = false; + this.flushWriteQueue(); + }); + } + + private flushWriteQueue(): void { + if (!this.terminal || this.pendingWriteText.length === 0) { + return; + } + const text = this.pendingWriteText; + this.pendingWriteText = ""; + this.terminal.write(text); + } + + private applyDocumentBoundsStyles(input: { root: HTMLDivElement }): () => void { + const documentElement = document.documentElement; + const body = document.body; + const rootContainer = input.root.parentElement; + + const previousDocumentElementOverflow = documentElement.style.overflow; + const previousDocumentElementWidth = documentElement.style.width; + const previousDocumentElementHeight = documentElement.style.height; + + const previousBodyOverflow = body.style.overflow; + const previousBodyWidth = body.style.width; + const previousBodyHeight = body.style.height; + const previousBodyMargin = body.style.margin; + const previousBodyPadding = body.style.padding; + + const previousRootOverflow = rootContainer?.style.overflow ?? ""; + const previousRootWidth = rootContainer?.style.width ?? ""; + const previousRootHeight = rootContainer?.style.height ?? ""; + + documentElement.style.overflow = "hidden"; + documentElement.style.width = "100%"; + documentElement.style.height = "100%"; + + body.style.overflow = "hidden"; + body.style.width = "100%"; + body.style.height = "100%"; + body.style.margin = "0"; + body.style.padding = "0"; + + if (rootContainer) { + rootContainer.style.overflow = "hidden"; + rootContainer.style.width = "100%"; + rootContainer.style.height = "100%"; + } + + return () => { + documentElement.style.overflow = previousDocumentElementOverflow; + documentElement.style.width = previousDocumentElementWidth; + documentElement.style.height = previousDocumentElementHeight; + + body.style.overflow = previousBodyOverflow; + body.style.width = previousBodyWidth; + body.style.height = previousBodyHeight; + body.style.margin = previousBodyMargin; + body.style.padding = previousBodyPadding; + + if (rootContainer) { + rootContainer.style.overflow = previousRootOverflow; + rootContainer.style.width = previousRootWidth; + rootContainer.style.height = previousRootHeight; + } + }; + } + + private applyViewportTouchStyles(input: { host: HTMLDivElement }): () => void { + const viewportElement = input.host.querySelector(".xterm-viewport"); + const screenElement = input.host.querySelector(".xterm-screen"); + + const previousViewportOverscroll = viewportElement?.style.overscrollBehavior ?? ""; + const previousViewportTouchAction = viewportElement?.style.touchAction ?? ""; + const previousViewportOverflowY = viewportElement?.style.overflowY ?? ""; + const previousViewportOverflowX = viewportElement?.style.overflowX ?? ""; + const previousViewportPointerEvents = viewportElement?.style.pointerEvents ?? ""; + const previousViewportWebkitOverflowScrolling = + viewportElement?.style.getPropertyValue("-webkit-overflow-scrolling") ?? ""; + const previousScreenPointerEvents = screenElement?.style.pointerEvents ?? ""; + + if (viewportElement) { + viewportElement.style.overscrollBehavior = "none"; + viewportElement.style.touchAction = "pan-y"; + viewportElement.style.overflowY = "auto"; + viewportElement.style.overflowX = "hidden"; + viewportElement.style.pointerEvents = "auto"; + viewportElement.style.setProperty("-webkit-overflow-scrolling", "touch"); + } + if (screenElement) { + screenElement.style.pointerEvents = "none"; + } + + return () => { + if (viewportElement) { + viewportElement.style.overscrollBehavior = previousViewportOverscroll; + viewportElement.style.touchAction = previousViewportTouchAction; + viewportElement.style.overflowY = previousViewportOverflowY; + viewportElement.style.overflowX = previousViewportOverflowX; + viewportElement.style.pointerEvents = previousViewportPointerEvents; + viewportElement.style.setProperty( + "-webkit-overflow-scrolling", + previousViewportWebkitOverflowScrolling + ); + } + if (screenElement) { + screenElement.style.pointerEvents = previousScreenPointerEvents; + } + }; + } + + private setupTouchScrollHandlers(input: { + root: HTMLDivElement; + host: HTMLDivElement; + terminal: Terminal; + }): () => void { + let touchScrollRemainderPx = 0; + const measuredLineHeight = + input.host.querySelector(".xterm-rows > div")?.getBoundingClientRect() + .height ?? 0; + const touchScrollLineHeightPx = + measuredLineHeight > 0 + ? measuredLineHeight + : DEFAULT_TOUCH_SCROLL_LINE_HEIGHT_PX; + + const activeTouch = { + identifier: -1, + startX: 0, + startY: 0, + lastX: 0, + lastY: 0, + mode: null as "vertical" | "horizontal" | null, + }; + + const touchStartHandler = (event: TouchEvent) => { + if (event.touches.length !== 1) { + touchScrollRemainderPx = 0; + activeTouch.identifier = -1; + activeTouch.mode = null; + return; + } + + const touch = event.touches[0]; + if (!touch) { + touchScrollRemainderPx = 0; + activeTouch.identifier = -1; + activeTouch.mode = null; + return; + } + + activeTouch.identifier = touch.identifier; + activeTouch.startX = touch.clientX; + activeTouch.startY = touch.clientY; + activeTouch.lastX = touch.clientX; + activeTouch.lastY = touch.clientY; + activeTouch.mode = null; + touchScrollRemainderPx = 0; + }; + + const touchMoveHandler = (event: TouchEvent) => { + if (event.touches.length !== 1) { + return; + } + + const touch = Array.from(event.touches).find( + (candidate) => candidate.identifier === activeTouch.identifier + ); + if (!touch) { + return; + } + + const totalDeltaX = touch.clientX - activeTouch.startX; + const totalDeltaY = touch.clientY - activeTouch.startY; + if (activeTouch.mode === null) { + const absX = Math.abs(totalDeltaX); + const absY = Math.abs(totalDeltaY); + if (absX > 8 || absY > 8) { + activeTouch.mode = absY >= absX ? "vertical" : "horizontal"; + } + } + + const deltaY = touch.clientY - activeTouch.lastY; + activeTouch.lastX = touch.clientX; + activeTouch.lastY = touch.clientY; + + if (activeTouch.mode !== "vertical") { + return; + } + + touchScrollRemainderPx += deltaY; + const lineDelta = Math.trunc(touchScrollRemainderPx / touchScrollLineHeightPx); + if (lineDelta !== 0) { + input.terminal.scrollLines(-lineDelta); + touchScrollRemainderPx -= lineDelta * touchScrollLineHeightPx; + } + + event.preventDefault(); + }; + + const touchEndHandler = (event: TouchEvent) => { + const activeTouchEnded = Array.from(event.changedTouches).some( + (touch) => touch.identifier === activeTouch.identifier + ); + if (activeTouchEnded || event.touches.length === 0) { + touchScrollRemainderPx = 0; + activeTouch.identifier = -1; + activeTouch.mode = null; + } + }; + + const touchCancelHandler = () => { + touchScrollRemainderPx = 0; + activeTouch.identifier = -1; + activeTouch.mode = null; + }; + + input.root.addEventListener("touchstart", touchStartHandler, { passive: true }); + input.root.addEventListener("touchmove", touchMoveHandler, { passive: false }); + input.root.addEventListener("touchend", touchEndHandler, { passive: true }); + input.root.addEventListener("touchcancel", touchCancelHandler, { passive: true }); + + return () => { + input.root.removeEventListener("touchstart", touchStartHandler); + input.root.removeEventListener("touchmove", touchMoveHandler); + input.root.removeEventListener("touchend", touchEndHandler); + input.root.removeEventListener("touchcancel", touchCancelHandler); + }; + } +} diff --git a/packages/app/src/terminal/runtime/terminal-output-pump.test.ts b/packages/app/src/terminal/runtime/terminal-output-pump.test.ts new file mode 100644 index 000000000..76c14cdfd --- /dev/null +++ b/packages/app/src/terminal/runtime/terminal-output-pump.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it, vi } from "vitest"; + +import { TerminalOutputPump } from "./terminal-output-pump"; + +describe("terminal-output-pump", () => { + it("batches selected-terminal chunk bursts into ordered flushes", () => { + vi.useFakeTimers(); + const chunks: Array<{ sequence: number; text: string }> = []; + const pump = new TerminalOutputPump({ + maxOutputChars: 100, + onSelectedOutputChunk: (chunk) => { + chunks.push(chunk); + }, + }); + + pump.setSelectedTerminal({ terminalId: "term-1" }); + pump.append({ terminalId: "term-1", text: "a" }); + pump.append({ terminalId: "term-1", text: "b" }); + pump.append({ terminalId: "term-1", text: "c" }); + + expect(chunks).toEqual([]); + + vi.runOnlyPendingTimers(); + + expect(chunks).toEqual([ + { sequence: 1, text: "abc" }, + ]); + + vi.useRealTimers(); + }); + + it("keeps per-terminal snapshots and switches selected stream deterministically", () => { + vi.useFakeTimers(); + const chunks: Array<{ sequence: number; text: string }> = []; + const pump = new TerminalOutputPump({ + maxOutputChars: 10, + onSelectedOutputChunk: (chunk) => { + chunks.push(chunk); + }, + }); + + pump.setSelectedTerminal({ terminalId: "term-1" }); + pump.append({ terminalId: "term-1", text: "hello" }); + vi.runOnlyPendingTimers(); + expect(pump.readSnapshot({ terminalId: "term-1" })).toBe("hello"); + + pump.append({ terminalId: "term-2", text: "world" }); + vi.runOnlyPendingTimers(); + expect(pump.readSnapshot({ terminalId: "term-2" })).toBe("world"); + + pump.setSelectedTerminal({ terminalId: "term-2" }); + pump.append({ terminalId: "term-2", text: "!" }); + vi.runOnlyPendingTimers(); + + expect(chunks).toEqual([ + { sequence: 1, text: "hello" }, + { sequence: 2, text: "!" }, + ]); + + vi.useRealTimers(); + }); + + it("resets selected output when clearing selected terminal", () => { + vi.useFakeTimers(); + const chunks: Array<{ sequence: number; text: string }> = []; + const pump = new TerminalOutputPump({ + maxOutputChars: 10, + onSelectedOutputChunk: (chunk) => { + chunks.push(chunk); + }, + }); + + pump.setSelectedTerminal({ terminalId: "term-1" }); + pump.append({ terminalId: "term-1", text: "abc" }); + vi.runOnlyPendingTimers(); + + pump.clearTerminal({ terminalId: "term-1" }); + + expect(pump.readSnapshot({ terminalId: "term-1" })).toBe(""); + expect(chunks).toEqual([ + { sequence: 1, text: "abc" }, + { sequence: 2, text: "" }, + ]); + + vi.useRealTimers(); + }); + + it("prunes orphaned terminal buffers", () => { + const pump = new TerminalOutputPump({ + maxOutputChars: 100, + onSelectedOutputChunk: () => {}, + }); + + pump.append({ terminalId: "a", text: "one" }); + pump.append({ terminalId: "b", text: "two" }); + pump.prune({ terminalIds: ["b"] }); + + expect(pump.readSnapshot({ terminalId: "a" })).toBe(""); + expect(pump.readSnapshot({ terminalId: "b" })).toBe("two"); + }); +}); diff --git a/packages/app/src/terminal/runtime/terminal-output-pump.ts b/packages/app/src/terminal/runtime/terminal-output-pump.ts new file mode 100644 index 000000000..0fc22f6d4 --- /dev/null +++ b/packages/app/src/terminal/runtime/terminal-output-pump.ts @@ -0,0 +1,155 @@ +import { + appendTerminalOutputBuffer, + createTerminalOutputBuffer, + readTerminalOutputBuffer, + type TerminalOutputBuffer, +} from "@/utils/terminal-output-buffer"; + +export type TerminalOutputChunk = { + sequence: number; + text: string; +}; + +export type TerminalOutputPumpOptions = { + maxOutputChars: number; + onSelectedOutputChunk: (chunk: TerminalOutputChunk) => void; +}; + +export type TerminalOutputPumpSetSelectedInput = { + terminalId: string | null; +}; + +export type TerminalOutputPumpAppendInput = { + terminalId: string; + text: string; +}; + +export type TerminalOutputPumpReadInput = { + terminalId: string | null; +}; + +export type TerminalOutputPumpClearInput = { + terminalId: string; +}; + +export type TerminalOutputPumpPruneInput = { + terminalIds: string[]; +}; + +export class TerminalOutputPump { + private readonly buffersByTerminalId = new Map(); + private selectedTerminalId: string | null = null; + private selectedChunkSequence = 0; + private selectedChunkAccumulator = ""; + private selectedChunkFlushTimer: ReturnType | null = null; + + constructor(private readonly options: TerminalOutputPumpOptions) {} + + setSelectedTerminal(input: TerminalOutputPumpSetSelectedInput): void { + if (this.selectedTerminalId === input.terminalId) { + return; + } + + this.clearSelectedChunkFlushTimer(); + this.selectedChunkAccumulator = ""; + this.selectedTerminalId = input.terminalId; + } + + append(input: TerminalOutputPumpAppendInput): void { + if (input.text.length === 0) { + return; + } + + let buffer = this.buffersByTerminalId.get(input.terminalId); + if (!buffer) { + buffer = createTerminalOutputBuffer(); + this.buffersByTerminalId.set(input.terminalId, buffer); + } + + appendTerminalOutputBuffer({ + buffer, + text: input.text, + maxChars: this.options.maxOutputChars, + }); + + if (this.selectedTerminalId !== input.terminalId) { + return; + } + + this.selectedChunkAccumulator += input.text; + this.scheduleSelectedChunkFlush(); + } + + clearTerminal(input: TerminalOutputPumpClearInput): void { + this.buffersByTerminalId.delete(input.terminalId); + if (this.selectedTerminalId === input.terminalId) { + this.clearSelectedChunkFlushTimer(); + this.selectedChunkAccumulator = ""; + this.emitSelectedChunk({ text: "" }); + } + } + + prune(input: TerminalOutputPumpPruneInput): void { + const terminalIdSet = new Set(input.terminalIds); + for (const terminalId of Array.from(this.buffersByTerminalId.keys())) { + if (!terminalIdSet.has(terminalId)) { + this.buffersByTerminalId.delete(terminalId); + } + } + } + + readSnapshot(input: TerminalOutputPumpReadInput): string { + if (!input.terminalId) { + return ""; + } + const buffer = this.buffersByTerminalId.get(input.terminalId); + if (!buffer) { + return ""; + } + return readTerminalOutputBuffer({ buffer }); + } + + dispose(): void { + this.clearSelectedChunkFlushTimer(); + this.selectedChunkAccumulator = ""; + this.selectedTerminalId = null; + this.buffersByTerminalId.clear(); + } + + private scheduleSelectedChunkFlush(): void { + if (this.selectedChunkFlushTimer) { + return; + } + + this.selectedChunkFlushTimer = setTimeout(() => { + this.selectedChunkFlushTimer = null; + this.flushSelectedChunkAccumulator(); + }, 0); + } + + private flushSelectedChunkAccumulator(): void { + if (this.selectedChunkAccumulator.length === 0) { + return; + } + + const text = this.selectedChunkAccumulator; + this.selectedChunkAccumulator = ""; + this.emitSelectedChunk({ text }); + } + + private emitSelectedChunk(input: { text: string }): void { + this.selectedChunkSequence += 1; + this.options.onSelectedOutputChunk({ + sequence: this.selectedChunkSequence, + text: input.text, + }); + } + + private clearSelectedChunkFlushTimer(): void { + if (!this.selectedChunkFlushTimer) { + return; + } + clearTimeout(this.selectedChunkFlushTimer); + this.selectedChunkFlushTimer = null; + } +} diff --git a/packages/app/src/terminal/runtime/terminal-stream-controller.test.ts b/packages/app/src/terminal/runtime/terminal-stream-controller.test.ts new file mode 100644 index 000000000..90cf0c93e --- /dev/null +++ b/packages/app/src/terminal/runtime/terminal-stream-controller.test.ts @@ -0,0 +1,254 @@ +import { describe, expect, it } from "vitest"; + +import { + TerminalStreamController, + type TerminalStreamControllerAttachPayload, + type TerminalStreamControllerChunk, + type TerminalStreamControllerClient, + type TerminalStreamControllerStatus, +} from "./terminal-stream-controller"; + +type FakeStreamSubscriber = (chunk: TerminalStreamControllerChunk) => void; + +class FakeTerminalStreamClient implements TerminalStreamControllerClient { + private readonly streamSubscribers = new Map>(); + public attachCalls: Array<{ + terminalId: string; + options?: { + resumeOffset?: number; + rows?: number; + cols?: number; + }; + }> = []; + public detachCalls: number[] = []; + public nextAttachResponses: TerminalStreamControllerAttachPayload[] = []; + + async attachTerminalStream( + terminalId: string, + options?: { + resumeOffset?: number; + rows?: number; + cols?: number; + } + ): Promise { + this.attachCalls.push({ terminalId, options }); + const response = this.nextAttachResponses.shift(); + if (!response) { + throw new Error("Missing fake attach response"); + } + return response; + } + + async detachTerminalStream(streamId: number): Promise { + this.detachCalls.push(streamId); + } + + onTerminalStreamData( + streamId: number, + handler: (chunk: TerminalStreamControllerChunk) => void + ): () => void { + const subscribers = this.streamSubscribers.get(streamId) ?? new Set(); + subscribers.add(handler); + this.streamSubscribers.set(streamId, subscribers); + return () => { + const current = this.streamSubscribers.get(streamId); + current?.delete(handler); + if (current && current.size === 0) { + this.streamSubscribers.delete(streamId); + } + }; + } + + emitChunk(input: { + streamId: number; + endOffset: number; + data: string; + }): void { + const subscribers = this.streamSubscribers.get(input.streamId); + if (!subscribers || subscribers.size === 0) { + return; + } + const chunk: TerminalStreamControllerChunk = { + endOffset: input.endOffset, + data: new TextEncoder().encode(input.data), + }; + for (const subscriber of subscribers) { + subscriber(chunk); + } + } +} + +function createControllerHarness(input?: { + client?: FakeTerminalStreamClient; +}): { + client: FakeTerminalStreamClient; + chunks: Array<{ terminalId: string; text: string }>; + statuses: TerminalStreamControllerStatus[]; + resets: string[]; + controller: TerminalStreamController; +} { + const client = input?.client ?? new FakeTerminalStreamClient(); + const chunks: Array<{ terminalId: string; text: string }> = []; + const statuses: TerminalStreamControllerStatus[] = []; + const resets: string[] = []; + + const controller = new TerminalStreamController({ + client, + getPreferredSize: () => ({ rows: 24, cols: 80 }), + onChunk: (chunk) => { + chunks.push(chunk); + }, + onStatusChange: (status) => { + statuses.push(status); + }, + onReset: ({ terminalId }) => { + resets.push(terminalId); + }, + waitForDelay: async () => {}, + }); + + return { + client, + chunks, + statuses, + resets, + controller, + }; +} + +async function flushAsyncWork(): Promise { + await Promise.resolve(); + await new Promise((resolve) => { + setTimeout(() => resolve(), 0); + }); + await Promise.resolve(); +} + +describe("terminal-stream-controller", () => { + it("streams burst chunks in order without dropping intermediate chunks", async () => { + const harness = createControllerHarness(); + harness.client.nextAttachResponses.push({ + streamId: 7, + currentOffset: 0, + reset: false, + error: null, + }); + + harness.controller.setTerminal({ terminalId: "term-1" }); + await flushAsyncWork(); + + harness.client.emitChunk({ + streamId: 7, + endOffset: 1, + data: "a", + }); + harness.client.emitChunk({ + streamId: 7, + endOffset: 2, + data: "b", + }); + harness.client.emitChunk({ + streamId: 7, + endOffset: 3, + data: "c", + }); + + expect(harness.chunks).toEqual([ + { terminalId: "term-1", text: "a" }, + { terminalId: "term-1", text: "b" }, + { terminalId: "term-1", text: "c" }, + ]); + }); + + it("retries retryable attach failures and then attaches", async () => { + const harness = createControllerHarness(); + harness.client.nextAttachResponses.push({ + streamId: null, + currentOffset: 0, + reset: false, + error: "network disconnected", + }); + harness.client.nextAttachResponses.push({ + streamId: 9, + currentOffset: 5, + reset: false, + error: null, + }); + + harness.controller.setTerminal({ terminalId: "term-1" }); + await flushAsyncWork(); + + expect(harness.client.attachCalls.length).toBe(2); + expect(harness.client.attachCalls[1]?.options).toEqual({ + rows: 24, + cols: 80, + }); + expect(harness.controller.getActiveStreamId()).toBe(9); + expect(harness.statuses.at(-1)).toEqual({ + terminalId: "term-1", + streamId: 9, + isAttaching: false, + error: null, + }); + }); + + it("handles stream exit by reconnecting on the same terminal", async () => { + const harness = createControllerHarness(); + harness.client.nextAttachResponses.push({ + streamId: 3, + currentOffset: 0, + reset: false, + error: null, + }); + harness.client.nextAttachResponses.push({ + streamId: 4, + currentOffset: 2, + reset: false, + error: null, + }); + + harness.controller.setTerminal({ terminalId: "term-1" }); + await flushAsyncWork(); + + harness.client.emitChunk({ + streamId: 3, + endOffset: 2, + data: "hi", + }); + harness.controller.handleStreamExit({ + terminalId: "term-1", + streamId: 3, + }); + await flushAsyncWork(); + + expect(harness.client.attachCalls.length).toBe(2); + expect(harness.client.attachCalls[1]?.options).toEqual({ + resumeOffset: 2, + rows: 24, + cols: 80, + }); + expect(harness.controller.getActiveStreamId()).toBe(4); + expect(harness.statuses.at(-1)).toEqual({ + terminalId: "term-1", + streamId: 4, + isAttaching: false, + error: null, + }); + }); + + it("emits reset callback when attach indicates output reset", async () => { + const harness = createControllerHarness(); + harness.client.nextAttachResponses.push({ + streamId: 12, + currentOffset: 0, + reset: true, + error: null, + }); + + harness.controller.setTerminal({ terminalId: "term-reset" }); + await flushAsyncWork(); + + expect(harness.resets).toEqual(["term-reset"]); + expect(harness.controller.getActiveStreamId()).toBe(12); + }); +}); diff --git a/packages/app/src/terminal/runtime/terminal-stream-controller.ts b/packages/app/src/terminal/runtime/terminal-stream-controller.ts new file mode 100644 index 000000000..df5127d07 --- /dev/null +++ b/packages/app/src/terminal/runtime/terminal-stream-controller.ts @@ -0,0 +1,409 @@ +import { + getTerminalAttachRetryDelayMs, + getTerminalResumeOffset, + isTerminalAttachRetryableError, + updateTerminalResumeOffset, + waitForDuration, + withPromiseTimeout, +} from "@/utils/terminal-attach"; + +export type TerminalStreamControllerAttachPayload = { + streamId: number | null; + currentOffset: number; + reset: boolean; + error?: string | null; +}; + +export type TerminalStreamControllerChunk = { + endOffset: number; + data: Uint8Array; +}; + +export type TerminalStreamControllerClient = { + attachTerminalStream: ( + terminalId: string, + options?: { + resumeOffset?: number; + rows?: number; + cols?: number; + } + ) => Promise; + detachTerminalStream: (streamId: number) => Promise; + onTerminalStreamData: ( + streamId: number, + handler: (chunk: TerminalStreamControllerChunk) => void + ) => () => void; +}; + +export type TerminalStreamControllerSize = { + rows: number; + cols: number; +}; + +export type TerminalStreamControllerStatus = { + terminalId: string | null; + streamId: number | null; + isAttaching: boolean; + error: string | null; +}; + +export type TerminalStreamControllerOptions = { + client: TerminalStreamControllerClient; + getPreferredSize: () => TerminalStreamControllerSize | null; + onChunk: (input: { terminalId: string; text: string }) => void; + onReset?: (input: { terminalId: string }) => void; + onStatusChange?: (status: TerminalStreamControllerStatus) => void; + maxAttachAttempts?: number; + attachTimeoutMs?: number; + reconnectErrorMessage?: string; + withTimeout?: (input: { + promise: Promise; + timeoutMs: number; + timeoutMessage: string; + }) => Promise; + waitForDelay?: (input: { durationMs: number }) => Promise; + isRetryableError?: (input: { message: string }) => boolean; + getRetryDelayMs?: (input: { attempt: number }) => number; +}; + +type TerminalStreamControllerActiveStream = { + terminalId: string; + streamId: number; + decoder: TextDecoder; + unsubscribe: () => void; +}; + +const DEFAULT_ATTACH_MAX_ATTEMPTS = 4; +const DEFAULT_ATTACH_TIMEOUT_MS = 12_000; +const DEFAULT_RECONNECT_ERROR_MESSAGE = "Terminal stream ended. Reconnecting…"; + +export class TerminalStreamController { + private readonly resumeOffsetByTerminalId = new Map(); + private selectedTerminalId: string | null = null; + private activeStream: TerminalStreamControllerActiveStream | null = null; + private attachGeneration = 0; + private isDisposed = false; + private status: TerminalStreamControllerStatus = { + terminalId: null, + streamId: null, + isAttaching: false, + error: null, + }; + + constructor(private readonly options: TerminalStreamControllerOptions) {} + + getActiveStreamId(): number | null { + return this.activeStream?.streamId ?? null; + } + + setTerminal(input: { terminalId: string | null }): void { + if (this.isDisposed) { + return; + } + + const nextTerminalId = input.terminalId; + const previousTerminalId = this.selectedTerminalId; + const isSameTerminal = previousTerminalId === nextTerminalId; + const hasActiveStreamForSelection = + isSameTerminal && + this.activeStream?.terminalId === nextTerminalId && + typeof this.activeStream.streamId === "number"; + if (hasActiveStreamForSelection) { + return; + } + + this.selectedTerminalId = nextTerminalId; + this.attachGeneration += 1; + const generation = this.attachGeneration; + + void this.detachActiveStream({ shouldDetach: true }); + + if (!nextTerminalId) { + this.updateStatus({ + terminalId: null, + streamId: null, + isAttaching: false, + error: null, + }); + return; + } + + this.updateStatus({ + terminalId: nextTerminalId, + streamId: null, + isAttaching: true, + error: null, + }); + void this.attachTerminal({ + terminalId: nextTerminalId, + generation, + }); + } + + handleStreamExit(input: { terminalId: string; streamId: number }): void { + if (this.isDisposed) { + return; + } + + const activeStream = this.activeStream; + if (!activeStream) { + return; + } + if (activeStream.terminalId !== input.terminalId || activeStream.streamId !== input.streamId) { + return; + } + if (this.selectedTerminalId !== input.terminalId) { + return; + } + + this.attachGeneration += 1; + const generation = this.attachGeneration; + void this.detachActiveStream({ shouldDetach: false }); + this.updateStatus({ + terminalId: input.terminalId, + streamId: null, + isAttaching: true, + error: + this.options.reconnectErrorMessage ?? DEFAULT_RECONNECT_ERROR_MESSAGE, + }); + void this.attachTerminal({ + terminalId: input.terminalId, + generation, + }); + } + + pruneResumeOffsets(input: { terminalIds: string[] }): void { + const terminalIdSet = new Set(input.terminalIds); + for (const terminalId of Array.from(this.resumeOffsetByTerminalId.keys())) { + if (!terminalIdSet.has(terminalId)) { + this.resumeOffsetByTerminalId.delete(terminalId); + } + } + } + + dispose(): void { + if (this.isDisposed) { + return; + } + this.isDisposed = true; + this.attachGeneration += 1; + this.selectedTerminalId = null; + void this.detachActiveStream({ shouldDetach: true }); + this.resumeOffsetByTerminalId.clear(); + this.updateStatus({ + terminalId: null, + streamId: null, + isAttaching: false, + error: null, + }); + } + + private async attachTerminal(input: { + terminalId: string; + generation: number; + }): Promise { + const { + maxAttachAttempts = DEFAULT_ATTACH_MAX_ATTEMPTS, + attachTimeoutMs = DEFAULT_ATTACH_TIMEOUT_MS, + withTimeout = withPromiseTimeout, + waitForDelay = waitForDuration, + isRetryableError = isTerminalAttachRetryableError, + getRetryDelayMs = getTerminalAttachRetryDelayMs, + } = this.options; + + let lastErrorMessage = "Unable to attach terminal stream"; + + for (let attempt = 0; attempt < maxAttachAttempts; attempt += 1) { + if (!this.isAttachGenerationCurrent({ generation: input.generation, terminalId: input.terminalId })) { + return; + } + + try { + const preferredSize = this.options.getPreferredSize(); + const resumeOffset = getTerminalResumeOffset({ + terminalId: input.terminalId, + resumeOffsetByTerminalId: this.resumeOffsetByTerminalId, + }); + const attachPayload = await withTimeout({ + promise: this.options.client.attachTerminalStream(input.terminalId, { + ...(resumeOffset !== undefined ? { resumeOffset } : {}), + ...(preferredSize + ? { rows: preferredSize.rows, cols: preferredSize.cols } + : {}), + }), + timeoutMs: attachTimeoutMs, + timeoutMessage: "Timed out attaching terminal stream", + }); + + if (!this.isAttachGenerationCurrent({ generation: input.generation, terminalId: input.terminalId })) { + if (typeof attachPayload.streamId === "number") { + void this.options.client.detachTerminalStream(attachPayload.streamId).catch(() => {}); + } + return; + } + + if (attachPayload.error || typeof attachPayload.streamId !== "number") { + lastErrorMessage = attachPayload.error ?? "Unable to attach terminal stream"; + const hasRemainingAttempts = attempt < maxAttachAttempts - 1; + if (hasRemainingAttempts && isRetryableError({ message: lastErrorMessage })) { + await waitForDelay({ durationMs: getRetryDelayMs({ attempt }) }); + continue; + } + + this.updateStatus({ + terminalId: input.terminalId, + streamId: null, + isAttaching: false, + error: lastErrorMessage, + }); + return; + } + + if (attachPayload.reset) { + this.resumeOffsetByTerminalId.delete(input.terminalId); + this.options.onReset?.({ terminalId: input.terminalId }); + } + + updateTerminalResumeOffset({ + terminalId: input.terminalId, + offset: attachPayload.currentOffset, + resumeOffsetByTerminalId: this.resumeOffsetByTerminalId, + }); + + const decoder = new TextDecoder(); + const streamId = attachPayload.streamId; + const unsubscribe = this.options.client.onTerminalStreamData(streamId, (chunk) => { + this.handleChunk({ + terminalId: input.terminalId, + streamId, + chunk, + decoder, + }); + }); + + this.activeStream = { + terminalId: input.terminalId, + streamId, + decoder, + unsubscribe, + }; + this.updateStatus({ + terminalId: input.terminalId, + streamId, + isAttaching: false, + error: null, + }); + return; + } catch (error) { + lastErrorMessage = + error instanceof Error ? error.message : "Unable to attach terminal stream"; + const hasRemainingAttempts = attempt < maxAttachAttempts - 1; + if (hasRemainingAttempts && isRetryableError({ message: lastErrorMessage })) { + await waitForDelay({ durationMs: getRetryDelayMs({ attempt }) }); + continue; + } + + this.updateStatus({ + terminalId: input.terminalId, + streamId: null, + isAttaching: false, + error: lastErrorMessage, + }); + return; + } + } + + this.updateStatus({ + terminalId: input.terminalId, + streamId: null, + isAttaching: false, + error: lastErrorMessage, + }); + } + + private handleChunk(input: { + terminalId: string; + streamId: number; + chunk: TerminalStreamControllerChunk; + decoder: TextDecoder; + }): void { + const activeStream = this.activeStream; + if (!activeStream) { + return; + } + if (activeStream.streamId !== input.streamId || activeStream.terminalId !== input.terminalId) { + return; + } + + updateTerminalResumeOffset({ + terminalId: input.terminalId, + offset: input.chunk.endOffset, + resumeOffsetByTerminalId: this.resumeOffsetByTerminalId, + }); + + const text = input.decoder.decode(input.chunk.data, { stream: true }); + if (text.length === 0) { + return; + } + + this.options.onChunk({ + terminalId: input.terminalId, + text, + }); + } + + private async detachActiveStream(input: { shouldDetach: boolean }): Promise { + const activeStream = this.activeStream; + if (!activeStream) { + return; + } + this.activeStream = null; + + try { + const tail = activeStream.decoder.decode(); + if (tail.length > 0) { + this.options.onChunk({ + terminalId: activeStream.terminalId, + text: tail, + }); + } + } catch { + // no-op + } + + try { + activeStream.unsubscribe(); + } catch { + // no-op + } + + if (!input.shouldDetach) { + return; + } + + try { + await this.options.client.detachTerminalStream(activeStream.streamId); + } catch { + // no-op + } + } + + private isAttachGenerationCurrent(input: { + generation: number; + terminalId: string; + }): boolean { + if (this.isDisposed) { + return false; + } + return ( + this.attachGeneration === input.generation && + this.selectedTerminalId === input.terminalId + ); + } + + private updateStatus(status: TerminalStreamControllerStatus): void { + this.status = status; + this.options.onStatusChange?.(status); + } +} + diff --git a/packages/app/src/utils/terminal-output-buffer.test.ts b/packages/app/src/utils/terminal-output-buffer.test.ts new file mode 100644 index 000000000..8674ae723 --- /dev/null +++ b/packages/app/src/utils/terminal-output-buffer.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; + +import { + appendTerminalOutputBuffer, + createTerminalOutputBuffer, + readTerminalOutputBuffer, +} from "./terminal-output-buffer"; + +describe("terminal-output-buffer", () => { + it("keeps appended text within max chars without rebuilding from scratch", () => { + const buffer = createTerminalOutputBuffer(); + + appendTerminalOutputBuffer({ buffer, text: "abc", maxChars: 5 }); + appendTerminalOutputBuffer({ buffer, text: "de", maxChars: 5 }); + + expect(readTerminalOutputBuffer({ buffer })).toBe("abcde"); + + appendTerminalOutputBuffer({ buffer, text: "f", maxChars: 5 }); + expect(readTerminalOutputBuffer({ buffer })).toBe("bcdef"); + + appendTerminalOutputBuffer({ buffer, text: "gh", maxChars: 5 }); + expect(readTerminalOutputBuffer({ buffer })).toBe("defgh"); + }); + + it("ignores empty appends and preserves current content", () => { + const buffer = createTerminalOutputBuffer(); + appendTerminalOutputBuffer({ buffer, text: "hello", maxChars: 10 }); + appendTerminalOutputBuffer({ buffer, text: "", maxChars: 10 }); + + expect(readTerminalOutputBuffer({ buffer })).toBe("hello"); + }); + + it("handles large overflow by trimming entire leading segments", () => { + const buffer = createTerminalOutputBuffer(); + + appendTerminalOutputBuffer({ buffer, text: "12345", maxChars: 8 }); + appendTerminalOutputBuffer({ buffer, text: "6789", maxChars: 8 }); + appendTerminalOutputBuffer({ buffer, text: "ABCDEF", maxChars: 8 }); + + expect(readTerminalOutputBuffer({ buffer })).toBe("89ABCDEF"); + }); +}); diff --git a/packages/app/src/utils/terminal-output-buffer.ts b/packages/app/src/utils/terminal-output-buffer.ts new file mode 100644 index 000000000..97bcf202f --- /dev/null +++ b/packages/app/src/utils/terminal-output-buffer.ts @@ -0,0 +1,95 @@ +export interface TerminalOutputBuffer { + segments: string[]; + startIndex: number; + totalChars: number; +} + +const COMPACT_SEGMENT_THRESHOLD = 256; + +export function createTerminalOutputBuffer(): TerminalOutputBuffer { + return { + segments: [], + startIndex: 0, + totalChars: 0, + }; +} + +function normalizeMaxChars(input: { maxChars: number }): number { + if (!Number.isFinite(input.maxChars)) { + return 0; + } + return Math.max(0, Math.floor(input.maxChars)); +} + +function compactTerminalOutputBuffer(input: { buffer: TerminalOutputBuffer }): void { + const { buffer } = input; + if (buffer.startIndex <= COMPACT_SEGMENT_THRESHOLD) { + return; + } + + buffer.segments = buffer.segments.slice(buffer.startIndex); + buffer.startIndex = 0; +} + +function trimTerminalOutputBufferToMax(input: { + buffer: TerminalOutputBuffer; + maxChars: number; +}): void { + const { buffer } = input; + const maxChars = normalizeMaxChars({ maxChars: input.maxChars }); + + while (buffer.totalChars > maxChars) { + const leadingSegment = buffer.segments[buffer.startIndex]; + if (!leadingSegment) { + buffer.segments = []; + buffer.startIndex = 0; + buffer.totalChars = 0; + return; + } + + const overflowChars = buffer.totalChars - maxChars; + if (leadingSegment.length <= overflowChars) { + buffer.startIndex += 1; + buffer.totalChars -= leadingSegment.length; + continue; + } + + buffer.segments[buffer.startIndex] = leadingSegment.slice(overflowChars); + buffer.totalChars -= overflowChars; + break; + } + + compactTerminalOutputBuffer({ buffer }); +} + +export function appendTerminalOutputBuffer(input: { + buffer: TerminalOutputBuffer; + text: string; + maxChars: number; +}): void { + if (!input.text) { + return; + } + + input.buffer.segments.push(input.text); + input.buffer.totalChars += input.text.length; + trimTerminalOutputBufferToMax({ + buffer: input.buffer, + maxChars: input.maxChars, + }); +} + +export function readTerminalOutputBuffer(input: { + buffer: TerminalOutputBuffer; +}): string { + const { buffer } = input; + if (buffer.totalChars <= 0) { + return ""; + } + + if (buffer.startIndex === 0) { + return buffer.segments.join(""); + } + + return buffer.segments.slice(buffer.startIndex).join(""); +}