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 }) => {
|
test("terminal tab is removed when shell exits", async ({ page }) => {
|
||||||
const repo = await createTempGitRepo("paseo-e2e-terminal-exit-");
|
const repo = await createTempGitRepo("paseo-e2e-terminal-exit-");
|
||||||
|
|
||||||
|
|||||||
@@ -1,29 +1,23 @@
|
|||||||
"use dom";
|
"use dom";
|
||||||
|
|
||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import { FitAddon } from "@xterm/addon-fit";
|
|
||||||
import { Terminal } from "@xterm/xterm";
|
|
||||||
import type { DOMProps } from "expo/dom";
|
import type { DOMProps } from "expo/dom";
|
||||||
import "@xterm/xterm/css/xterm.css";
|
import "@xterm/xterm/css/xterm.css";
|
||||||
import {
|
import type { PendingTerminalModifiers } from "../utils/terminal-keys";
|
||||||
type PendingTerminalModifiers,
|
import { TerminalEmulatorRuntime } from "../terminal/runtime/terminal-emulator-runtime";
|
||||||
isTerminalModifierDomKey,
|
|
||||||
mergeTerminalModifiers,
|
|
||||||
normalizeDomTerminalKey,
|
|
||||||
normalizeTerminalTransportKey,
|
|
||||||
shouldInterceptDomTerminalKey,
|
|
||||||
} from "../utils/terminal-keys";
|
|
||||||
|
|
||||||
interface TerminalEmulatorProps {
|
interface TerminalEmulatorProps {
|
||||||
dom?: DOMProps;
|
dom?: DOMProps;
|
||||||
streamKey: string;
|
streamKey: string;
|
||||||
outputText: string;
|
initialOutputText: string;
|
||||||
|
outputChunkText: string;
|
||||||
|
outputChunkSequence: number;
|
||||||
testId?: string;
|
testId?: string;
|
||||||
backgroundColor?: string;
|
backgroundColor?: string;
|
||||||
foregroundColor?: string;
|
foregroundColor?: string;
|
||||||
cursorColor?: string;
|
cursorColor?: string;
|
||||||
onInput?: (data: string) => Promise<void> | void;
|
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: {
|
onTerminalKey?: (input: {
|
||||||
key: string;
|
key: string;
|
||||||
ctrl: boolean;
|
ctrl: boolean;
|
||||||
@@ -37,14 +31,14 @@ interface TerminalEmulatorProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {}
|
||||||
__paseoTerminal?: Terminal;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function TerminalEmulator({
|
export default function TerminalEmulator({
|
||||||
streamKey,
|
streamKey,
|
||||||
outputText,
|
initialOutputText,
|
||||||
|
outputChunkText,
|
||||||
|
outputChunkSequence,
|
||||||
testId = "terminal-surface",
|
testId = "terminal-surface",
|
||||||
backgroundColor = "#0b0b0b",
|
backgroundColor = "#0b0b0b",
|
||||||
foregroundColor = "#e6e6e6",
|
foregroundColor = "#e6e6e6",
|
||||||
@@ -58,36 +52,7 @@ export default function TerminalEmulator({
|
|||||||
}: TerminalEmulatorProps) {
|
}: TerminalEmulatorProps) {
|
||||||
const rootRef = useRef<HTMLDivElement | null>(null);
|
const rootRef = useRef<HTMLDivElement | null>(null);
|
||||||
const hostRef = useRef<HTMLDivElement | null>(null);
|
const hostRef = useRef<HTMLDivElement | null>(null);
|
||||||
const terminalRef = useRef<Terminal | null>(null);
|
const runtimeRef = useRef<TerminalEmulatorRuntime | 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]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const host = hostRef.current;
|
const host = hostRef.current;
|
||||||
@@ -96,405 +61,72 @@ export default function TerminalEmulator({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
renderedOutputRef.current = "";
|
const runtime = new TerminalEmulatorRuntime();
|
||||||
lastSizeRef.current = null;
|
runtimeRef.current = runtime;
|
||||||
host.innerHTML = "";
|
runtime.setCallbacks({
|
||||||
|
callbacks: {
|
||||||
const terminal = new Terminal({
|
onInput,
|
||||||
allowProposedApi: true,
|
onResize,
|
||||||
convertEol: false,
|
onTerminalKey,
|
||||||
cursorBlink: true,
|
onPendingModifiersConsumed,
|
||||||
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 fitAddon = new FitAddon();
|
runtime.setPendingModifiers({ pendingModifiers });
|
||||||
terminal.loadAddon(fitAddon);
|
runtime.mount({
|
||||||
terminal.open(host);
|
root,
|
||||||
|
host,
|
||||||
const documentElement = document.documentElement;
|
initialOutputText,
|
||||||
const body = document.body;
|
theme: {
|
||||||
const rootContainer = root.parentElement;
|
backgroundColor,
|
||||||
|
foregroundColor,
|
||||||
const previousDocumentElementOverflow = documentElement.style.overflow;
|
cursorColor,
|
||||||
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);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
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 () => {
|
return () => {
|
||||||
inputDisposable.dispose();
|
runtime.unmount();
|
||||||
resizeObserver.disconnect();
|
if (runtimeRef.current === runtime) {
|
||||||
window.removeEventListener("resize", windowResizeHandler);
|
runtimeRef.current = null;
|
||||||
visualViewport?.removeEventListener("resize", visualViewportResizeHandler);
|
|
||||||
window.clearInterval(fitInterval);
|
|
||||||
for (const handle of fitTimeoutHandles) {
|
|
||||||
window.clearTimeout(handle);
|
|
||||||
}
|
}
|
||||||
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]);
|
}, [backgroundColor, cursorColor, foregroundColor, streamKey]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const terminal = terminalRef.current;
|
runtimeRef.current?.setCallbacks({
|
||||||
if (!terminal) {
|
callbacks: {
|
||||||
|
onInput,
|
||||||
|
onResize,
|
||||||
|
onTerminalKey,
|
||||||
|
onPendingModifiersConsumed,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [onInput, onPendingModifiersConsumed, onResize, onTerminalKey]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
runtimeRef.current?.setPendingModifiers({ pendingModifiers });
|
||||||
|
}, [pendingModifiers]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const runtime = runtimeRef.current;
|
||||||
|
if (!runtime) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const previous = renderedOutputRef.current;
|
if (outputChunkSequence <= 0) {
|
||||||
if (outputText === previous) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (outputChunkText.length === 0) {
|
||||||
if (previous.length > 0 && outputText.startsWith(previous)) {
|
runtime.clear();
|
||||||
const suffix = outputText.slice(previous.length);
|
return;
|
||||||
if (suffix.length > 0) {
|
|
||||||
terminal.write(suffix);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
terminal.reset();
|
|
||||||
terminal.clear();
|
|
||||||
if (outputText.length > 0) {
|
|
||||||
terminal.write(outputText);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
runtime.write({ text: outputChunkText });
|
||||||
renderedOutputRef.current = outputText;
|
}, [outputChunkSequence, outputChunkText]);
|
||||||
}, [outputText]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (focusRequestToken <= 0) {
|
if (focusRequestToken <= 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
terminalRef.current?.focus();
|
runtimeRef.current?.focus();
|
||||||
}, [focusRequestToken]);
|
}, [focusRequestToken]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -513,7 +145,7 @@ export default function TerminalEmulator({
|
|||||||
overscrollBehavior: "none",
|
overscrollBehavior: "none",
|
||||||
}}
|
}}
|
||||||
onPointerDown={() => {
|
onPointerDown={() => {
|
||||||
terminalRef.current?.focus();
|
runtimeRef.current?.focus();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -22,13 +22,13 @@ import {
|
|||||||
resolvePendingModifierDataInput,
|
resolvePendingModifierDataInput,
|
||||||
} from "@/utils/terminal-keys";
|
} from "@/utils/terminal-keys";
|
||||||
import {
|
import {
|
||||||
getTerminalResumeOffset,
|
TerminalOutputPump,
|
||||||
getTerminalAttachRetryDelayMs,
|
type TerminalOutputChunk,
|
||||||
isTerminalAttachRetryableError,
|
} from "@/terminal/runtime/terminal-output-pump";
|
||||||
updateTerminalResumeOffset,
|
import {
|
||||||
waitForDuration,
|
TerminalStreamController,
|
||||||
withPromiseTimeout,
|
type TerminalStreamControllerStatus,
|
||||||
} from "@/utils/terminal-attach";
|
} from "@/terminal/runtime/terminal-stream-controller";
|
||||||
import TerminalEmulator from "./terminal-emulator";
|
import TerminalEmulator from "./terminal-emulator";
|
||||||
|
|
||||||
interface TerminalPaneProps {
|
interface TerminalPaneProps {
|
||||||
@@ -38,8 +38,6 @@ interface TerminalPaneProps {
|
|||||||
|
|
||||||
const MAX_OUTPUT_CHARS = 200_000;
|
const MAX_OUTPUT_CHARS = 200_000;
|
||||||
const TERMINAL_TAB_MAX_WIDTH = 220;
|
const TERMINAL_TAB_MAX_WIDTH = 220;
|
||||||
const TERMINAL_ATTACH_MAX_ATTEMPTS = 4;
|
|
||||||
const TERMINAL_ATTACH_TIMEOUT_MS = 12_000;
|
|
||||||
|
|
||||||
const MODIFIER_LABELS = {
|
const MODIFIER_LABELS = {
|
||||||
ctrl: "Ctrl",
|
ctrl: "Ctrl",
|
||||||
@@ -65,6 +63,11 @@ type ModifierState = {
|
|||||||
alt: boolean;
|
alt: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type TerminalOutputChunkState = {
|
||||||
|
sequence: number;
|
||||||
|
text: string;
|
||||||
|
};
|
||||||
|
|
||||||
const EMPTY_MODIFIERS: ModifierState = {
|
const EMPTY_MODIFIERS: ModifierState = {
|
||||||
ctrl: false,
|
ctrl: false,
|
||||||
shift: false,
|
shift: false,
|
||||||
@@ -112,17 +115,19 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
|
|||||||
const scopeKey = useMemo(() => terminalScopeKey({ serverId, cwd }), [serverId, cwd]);
|
const scopeKey = useMemo(() => terminalScopeKey({ serverId, cwd }), [serverId, cwd]);
|
||||||
const selectedTerminalByScopeRef = useRef<Map<string, string>>(new Map());
|
const selectedTerminalByScopeRef = useRef<Map<string, string>>(new Map());
|
||||||
const lastReportedSizeRef = useRef<{ rows: number; cols: number } | null>(null);
|
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 [selectedTerminalId, setSelectedTerminalId] = useState<string | null>(null);
|
||||||
const [outputByTerminalId, setOutputByTerminalId] = useState<Map<string, string>>(
|
const [selectedOutputChunk, setSelectedOutputChunk] = useState<TerminalOutputChunkState>({
|
||||||
() => new Map()
|
sequence: 0,
|
||||||
);
|
text: "",
|
||||||
|
});
|
||||||
|
const [selectedOutputSnapshot, setSelectedOutputSnapshot] = useState("");
|
||||||
const [activeStream, setActiveStream] = useState<{
|
const [activeStream, setActiveStream] = useState<{
|
||||||
terminalId: string;
|
terminalId: string;
|
||||||
streamId: number;
|
streamId: number;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [attachGeneration, setAttachGeneration] = useState(0);
|
|
||||||
const [isAttaching, setIsAttaching] = useState(false);
|
const [isAttaching, setIsAttaching] = useState(false);
|
||||||
const [streamError, setStreamError] = useState<string | null>(null);
|
const [streamError, setStreamError] = useState<string | null>(null);
|
||||||
const [modifiers, setModifiers] = useState<ModifierState>(EMPTY_MODIFIERS);
|
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 hoverOutTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const selectedTerminalIdRef = useRef<string | null>(selectedTerminalId);
|
const selectedTerminalIdRef = useRef<string | null>(selectedTerminalId);
|
||||||
const activeStreamRef = useRef<{
|
|
||||||
terminalId: string;
|
|
||||||
streamId: number;
|
|
||||||
} | null>(activeStream);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
selectedTerminalIdRef.current = selectedTerminalId;
|
selectedTerminalIdRef.current = selectedTerminalId;
|
||||||
}, [selectedTerminalId]);
|
}, [selectedTerminalId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
activeStreamRef.current = activeStream;
|
const outputPump = new TerminalOutputPump({
|
||||||
}, [activeStream]);
|
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(() => {
|
const clearHoverOutTimeout = useCallback(() => {
|
||||||
if (!hoverOutTimeoutRef.current) {
|
if (!hoverOutTimeoutRef.current) {
|
||||||
@@ -221,20 +235,11 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const activeStreamForTerminal = activeStreamRef.current;
|
streamControllerRef.current?.handleStreamExit({
|
||||||
if (
|
terminalId: exitedTerminalId,
|
||||||
selectedTerminalIdRef.current === exitedTerminalId &&
|
streamId: message.payload.streamId,
|
||||||
activeStreamForTerminal?.terminalId === exitedTerminalId &&
|
});
|
||||||
activeStreamForTerminal.streamId === message.payload.streamId
|
setModifiers({ ...EMPTY_MODIFIERS });
|
||||||
) {
|
|
||||||
setActiveStream((current) =>
|
|
||||||
current?.terminalId === exitedTerminalId ? null : current
|
|
||||||
);
|
|
||||||
setStreamError("Terminal stream ended. Reconnecting…");
|
|
||||||
setIsAttaching(true);
|
|
||||||
setAttachGeneration((current) => current + 1);
|
|
||||||
setModifiers({ ...EMPTY_MODIFIERS });
|
|
||||||
}
|
|
||||||
|
|
||||||
void queryClient.invalidateQueries({
|
void queryClient.invalidateQueries({
|
||||||
queryKey: ["terminals", serverId, cwd],
|
queryKey: ["terminals", serverId, cwd],
|
||||||
@@ -277,15 +282,11 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
|
|||||||
},
|
},
|
||||||
onSuccess: (_, terminalId) => {
|
onSuccess: (_, terminalId) => {
|
||||||
setHoveredTerminalId((current) => (current === terminalId ? null : current));
|
setHoveredTerminalId((current) => (current === terminalId ? null : current));
|
||||||
resumeOffsetByTerminalIdRef.current.delete(terminalId);
|
outputPumpRef.current?.clearTerminal({ terminalId });
|
||||||
if (selectedTerminalIdRef.current === terminalId) {
|
if (selectedTerminalIdRef.current === terminalId) {
|
||||||
setSelectedTerminalId((current) =>
|
setSelectedTerminalId((current) =>
|
||||||
current === terminalId ? null : current
|
current === terminalId ? null : current
|
||||||
);
|
);
|
||||||
setActiveStream((current) =>
|
|
||||||
current?.terminalId === terminalId ? null : current
|
|
||||||
);
|
|
||||||
setIsAttaching(false);
|
|
||||||
setModifiers({ ...EMPTY_MODIFIERS });
|
setModifiers({ ...EMPTY_MODIFIERS });
|
||||||
}
|
}
|
||||||
void queryClient.invalidateQueries({
|
void queryClient.invalidateQueries({
|
||||||
@@ -336,178 +337,82 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
|
|||||||
}, [scopeKey, terminals, selectedTerminalId]);
|
}, [scopeKey, terminals, selectedTerminalId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (terminals.length === 0) {
|
const terminalIds = terminals.map((terminal) => terminal.id);
|
||||||
resumeOffsetByTerminalIdRef.current.clear();
|
outputPumpRef.current?.prune({ terminalIds });
|
||||||
return;
|
streamControllerRef.current?.pruneResumeOffsets({ terminalIds });
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [terminals]);
|
}, [terminals]);
|
||||||
|
|
||||||
const appendOutput = useCallback((terminalId: string, text: string) => {
|
const handleStreamControllerStatus = useCallback(
|
||||||
if (!text) {
|
(status: TerminalStreamControllerStatus) => {
|
||||||
return;
|
setIsAttaching(status.isAttaching);
|
||||||
}
|
setStreamError(status.error);
|
||||||
setOutputByTerminalId((previous) => {
|
if (status.terminalId && typeof status.streamId === "number") {
|
||||||
const next = new Map(previous);
|
setActiveStream({
|
||||||
const existing = next.get(terminalId) ?? "";
|
terminalId: status.terminalId,
|
||||||
const combined = `${existing}${text}`;
|
streamId: status.streamId,
|
||||||
next.set(
|
});
|
||||||
terminalId,
|
return;
|
||||||
combined.length > MAX_OUTPUT_CHARS
|
}
|
||||||
? combined.slice(combined.length - MAX_OUTPUT_CHARS)
|
setActiveStream(null);
|
||||||
: combined
|
},
|
||||||
);
|
[]
|
||||||
return next;
|
);
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let isCancelled = false;
|
streamControllerRef.current?.dispose();
|
||||||
let streamId: number | null = null;
|
streamControllerRef.current = null;
|
||||||
let unsubscribe: (() => void) | null = null;
|
setActiveStream(null);
|
||||||
let decoder: TextDecoder | null = null;
|
setIsAttaching(false);
|
||||||
const terminalId = selectedTerminalId;
|
setStreamError(null);
|
||||||
|
|
||||||
if (!client || !isConnected || !terminalId) {
|
if (!client || !isConnected) {
|
||||||
setActiveStream(null);
|
|
||||||
setIsAttaching(false);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsAttaching(true);
|
const outputPump = outputPumpRef.current;
|
||||||
setStreamError(null);
|
if (!outputPump) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const attach = async () => {
|
const controller = new TerminalStreamController({
|
||||||
try {
|
client,
|
||||||
let lastErrorMessage = "Unable to attach terminal stream";
|
getPreferredSize: () => lastReportedSizeRef.current,
|
||||||
const totalAttempts = TERMINAL_ATTACH_MAX_ATTEMPTS;
|
onChunk: ({ terminalId, text }) => {
|
||||||
for (let attempt = 0; attempt < totalAttempts; attempt += 1) {
|
outputPump.append({ terminalId, text });
|
||||||
try {
|
},
|
||||||
const lastSize = lastReportedSizeRef.current;
|
onReset: ({ terminalId }) => {
|
||||||
const resumeOffset = getTerminalResumeOffset({
|
outputPump.clearTerminal({ terminalId });
|
||||||
terminalId,
|
if (selectedTerminalIdRef.current === terminalId) {
|
||||||
resumeOffsetByTerminalId: resumeOffsetByTerminalIdRef.current,
|
setSelectedOutputSnapshot("");
|
||||||
});
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
onStatusChange: handleStreamControllerStatus,
|
||||||
|
});
|
||||||
|
|
||||||
if (!isCancelled) {
|
streamControllerRef.current = controller;
|
||||||
setStreamError(lastErrorMessage);
|
controller.setTerminal({ terminalId: selectedTerminalIdRef.current });
|
||||||
setActiveStream(null);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
if (!isCancelled) {
|
|
||||||
setIsAttaching(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
void attach();
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
isCancelled = true;
|
controller.dispose();
|
||||||
if (decoder) {
|
if (streamControllerRef.current === controller) {
|
||||||
appendOutput(terminalId, decoder.decode());
|
streamControllerRef.current = null;
|
||||||
decoder = 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 =
|
const activeStreamId =
|
||||||
activeStream && activeStream.terminalId === selectedTerminalId
|
activeStream && activeStream.terminalId === selectedTerminalId
|
||||||
@@ -518,11 +423,6 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
|
|||||||
() => terminals.find((terminal) => terminal.id === selectedTerminalId) ?? null,
|
() => terminals.find((terminal) => terminal.id === selectedTerminalId) ?? null,
|
||||||
[terminals, selectedTerminalId]
|
[terminals, selectedTerminalId]
|
||||||
);
|
);
|
||||||
|
|
||||||
const currentOutput = selectedTerminalId
|
|
||||||
? (outputByTerminalId.get(selectedTerminalId) ?? "")
|
|
||||||
: "";
|
|
||||||
|
|
||||||
const handleCreateTerminal = useCallback(() => {
|
const handleCreateTerminal = useCallback(() => {
|
||||||
createTerminalMutation.mutate();
|
createTerminalMutation.mutate();
|
||||||
}, [createTerminalMutation]);
|
}, [createTerminalMutation]);
|
||||||
@@ -623,7 +523,8 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleTerminalResize = useCallback(
|
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) {
|
if (!client || !selectedTerminalId || rows <= 0 || cols <= 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -834,8 +735,10 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
|
|||||||
automaticallyAdjustContentInsets: false,
|
automaticallyAdjustContentInsets: false,
|
||||||
contentInsetAdjustmentBehavior: "never",
|
contentInsetAdjustmentBehavior: "never",
|
||||||
}}
|
}}
|
||||||
streamKey={`${scopeKey}:${selectedTerminal.id}:${activeStreamId ?? "none"}`}
|
streamKey={`${scopeKey}:${selectedTerminal.id}`}
|
||||||
outputText={currentOutput}
|
initialOutputText={selectedOutputSnapshot}
|
||||||
|
outputChunkText={selectedOutputChunk.text}
|
||||||
|
outputChunkSequence={selectedOutputChunk.sequence}
|
||||||
testId="terminal-surface"
|
testId="terminal-surface"
|
||||||
backgroundColor={theme.colors.background}
|
backgroundColor={theme.colors.background}
|
||||||
foregroundColor={theme.colors.foreground}
|
foregroundColor={theme.colors.foreground}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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