mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
refactor(app): extract terminal runtime out of react lifecycle
This commit is contained in:
@@ -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-");
|
||||
|
||||
|
||||
@@ -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> | void;
|
||||
onResize?: (rows: number, cols: number) => Promise<void> | void;
|
||||
onResize?: (input: { rows: number; cols: number }) => Promise<void> | 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<HTMLDivElement | null>(null);
|
||||
const hostRef = useRef<HTMLDivElement | null>(null);
|
||||
const terminalRef = useRef<Terminal | null>(null);
|
||||
const renderedOutputRef = useRef("");
|
||||
const lastSizeRef = useRef<{ rows: number; cols: number } | null>(null);
|
||||
const onInputRef = useRef<TerminalEmulatorProps["onInput"]>(onInput);
|
||||
const onResizeRef = useRef<TerminalEmulatorProps["onResize"]>(onResize);
|
||||
const onTerminalKeyRef = useRef<TerminalEmulatorProps["onTerminalKey"]>(onTerminalKey);
|
||||
const onPendingModifiersConsumedRef = useRef<
|
||||
TerminalEmulatorProps["onPendingModifiersConsumed"]
|
||||
>(onPendingModifiersConsumed);
|
||||
const pendingModifiersRef = useRef<PendingTerminalModifiers>(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<TerminalEmulatorRuntime | null>(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<HTMLElement>(".xterm-viewport");
|
||||
const screenElement = host.querySelector<HTMLElement>(".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<HTMLElement>(".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();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
|
||||
@@ -22,13 +22,13 @@ import {
|
||||
resolvePendingModifierDataInput,
|
||||
} from "@/utils/terminal-keys";
|
||||
import {
|
||||
getTerminalResumeOffset,
|
||||
getTerminalAttachRetryDelayMs,
|
||||
isTerminalAttachRetryableError,
|
||||
updateTerminalResumeOffset,
|
||||
waitForDuration,
|
||||
withPromiseTimeout,
|
||||
} from "@/utils/terminal-attach";
|
||||
TerminalOutputPump,
|
||||
type TerminalOutputChunk,
|
||||
} from "@/terminal/runtime/terminal-output-pump";
|
||||
import {
|
||||
TerminalStreamController,
|
||||
type TerminalStreamControllerStatus,
|
||||
} from "@/terminal/runtime/terminal-stream-controller";
|
||||
import TerminalEmulator from "./terminal-emulator";
|
||||
|
||||
interface TerminalPaneProps {
|
||||
@@ -38,8 +38,6 @@ interface TerminalPaneProps {
|
||||
|
||||
const MAX_OUTPUT_CHARS = 200_000;
|
||||
const TERMINAL_TAB_MAX_WIDTH = 220;
|
||||
const TERMINAL_ATTACH_MAX_ATTEMPTS = 4;
|
||||
const TERMINAL_ATTACH_TIMEOUT_MS = 12_000;
|
||||
|
||||
const MODIFIER_LABELS = {
|
||||
ctrl: "Ctrl",
|
||||
@@ -65,6 +63,11 @@ type ModifierState = {
|
||||
alt: boolean;
|
||||
};
|
||||
|
||||
type TerminalOutputChunkState = {
|
||||
sequence: number;
|
||||
text: string;
|
||||
};
|
||||
|
||||
const EMPTY_MODIFIERS: ModifierState = {
|
||||
ctrl: false,
|
||||
shift: false,
|
||||
@@ -112,17 +115,19 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
|
||||
const scopeKey = useMemo(() => terminalScopeKey({ serverId, cwd }), [serverId, cwd]);
|
||||
const selectedTerminalByScopeRef = useRef<Map<string, string>>(new Map());
|
||||
const lastReportedSizeRef = useRef<{ rows: number; cols: number } | null>(null);
|
||||
const resumeOffsetByTerminalIdRef = useRef<Map<string, number>>(new Map());
|
||||
const streamControllerRef = useRef<TerminalStreamController | null>(null);
|
||||
const outputPumpRef = useRef<TerminalOutputPump | null>(null);
|
||||
|
||||
const [selectedTerminalId, setSelectedTerminalId] = useState<string | null>(null);
|
||||
const [outputByTerminalId, setOutputByTerminalId] = useState<Map<string, string>>(
|
||||
() => new Map()
|
||||
);
|
||||
const [selectedOutputChunk, setSelectedOutputChunk] = useState<TerminalOutputChunkState>({
|
||||
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<string | null>(null);
|
||||
const [modifiers, setModifiers] = useState<ModifierState>(EMPTY_MODIFIERS);
|
||||
@@ -133,18 +138,27 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
|
||||
);
|
||||
const hoverOutTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const selectedTerminalIdRef = useRef<string | null>(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}
|
||||
|
||||
560
packages/app/src/terminal/runtime/terminal-emulator-runtime.ts
Normal file
560
packages/app/src/terminal/runtime/terminal-emulator-runtime.ts
Normal file
@@ -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> | void;
|
||||
onResize?: (input: { rows: number; cols: number }) => Promise<void> | void;
|
||||
onTerminalKey?: (input: {
|
||||
key: string;
|
||||
ctrl: boolean;
|
||||
shift: boolean;
|
||||
alt: boolean;
|
||||
meta: boolean;
|
||||
}) => Promise<void> | void;
|
||||
onPendingModifiersConsumed?: () => Promise<void> | 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<HTMLElement>(".xterm-viewport");
|
||||
const screenElement = input.host.querySelector<HTMLElement>(".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<HTMLElement>(".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);
|
||||
};
|
||||
}
|
||||
}
|
||||
101
packages/app/src/terminal/runtime/terminal-output-pump.test.ts
Normal file
101
packages/app/src/terminal/runtime/terminal-output-pump.test.ts
Normal file
@@ -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");
|
||||
});
|
||||
});
|
||||
155
packages/app/src/terminal/runtime/terminal-output-pump.ts
Normal file
155
packages/app/src/terminal/runtime/terminal-output-pump.ts
Normal file
@@ -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<string, TerminalOutputBuffer>();
|
||||
private selectedTerminalId: string | null = null;
|
||||
private selectedChunkSequence = 0;
|
||||
private selectedChunkAccumulator = "";
|
||||
private selectedChunkFlushTimer: ReturnType<typeof setTimeout> | 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;
|
||||
}
|
||||
}
|
||||
@@ -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<number, Set<FakeStreamSubscriber>>();
|
||||
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<TerminalStreamControllerAttachPayload> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
await Promise.resolve();
|
||||
await new Promise<void>((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);
|
||||
});
|
||||
});
|
||||
409
packages/app/src/terminal/runtime/terminal-stream-controller.ts
Normal file
409
packages/app/src/terminal/runtime/terminal-stream-controller.ts
Normal file
@@ -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<TerminalStreamControllerAttachPayload>;
|
||||
detachTerminalStream: (streamId: number) => Promise<unknown>;
|
||||
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?: <T>(input: {
|
||||
promise: Promise<T>;
|
||||
timeoutMs: number;
|
||||
timeoutMessage: string;
|
||||
}) => Promise<T>;
|
||||
waitForDelay?: (input: { durationMs: number }) => Promise<void>;
|
||||
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<string, number>();
|
||||
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<void> {
|
||||
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<void> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
42
packages/app/src/utils/terminal-output-buffer.test.ts
Normal file
42
packages/app/src/utils/terminal-output-buffer.test.ts
Normal file
@@ -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");
|
||||
});
|
||||
});
|
||||
95
packages/app/src/utils/terminal-output-buffer.ts
Normal file
95
packages/app/src/utils/terminal-output-buffer.ts
Normal file
@@ -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("");
|
||||
}
|
||||
Reference in New Issue
Block a user