refactor(app): extract terminal runtime out of react lifecycle

This commit is contained in:
Mohamed Boudra
2026-02-15 15:40:18 +07:00
parent 9244f1fbd6
commit adb511d2be
10 changed files with 1858 additions and 629 deletions
+57 -425
View File
@@ -1,29 +1,23 @@
"use dom";
import { useEffect, useRef } from "react";
import { FitAddon } from "@xterm/addon-fit";
import { Terminal } from "@xterm/xterm";
import type { DOMProps } from "expo/dom";
import "@xterm/xterm/css/xterm.css";
import {
type PendingTerminalModifiers,
isTerminalModifierDomKey,
mergeTerminalModifiers,
normalizeDomTerminalKey,
normalizeTerminalTransportKey,
shouldInterceptDomTerminalKey,
} from "../utils/terminal-keys";
import type { PendingTerminalModifiers } from "../utils/terminal-keys";
import { TerminalEmulatorRuntime } from "../terminal/runtime/terminal-emulator-runtime";
interface TerminalEmulatorProps {
dom?: DOMProps;
streamKey: string;
outputText: string;
initialOutputText: string;
outputChunkText: string;
outputChunkSequence: number;
testId?: string;
backgroundColor?: string;
foregroundColor?: string;
cursorColor?: string;
onInput?: (data: string) => Promise<void> | void;
onResize?: (rows: number, cols: number) => Promise<void> | void;
onResize?: (input: { rows: number; cols: number }) => Promise<void> | void;
onTerminalKey?: (input: {
key: string;
ctrl: boolean;
@@ -37,14 +31,14 @@ interface TerminalEmulatorProps {
}
declare global {
interface Window {
__paseoTerminal?: Terminal;
}
interface Window {}
}
export default function TerminalEmulator({
streamKey,
outputText,
initialOutputText,
outputChunkText,
outputChunkSequence,
testId = "terminal-surface",
backgroundColor = "#0b0b0b",
foregroundColor = "#e6e6e6",
@@ -58,36 +52,7 @@ export default function TerminalEmulator({
}: TerminalEmulatorProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const hostRef = useRef<HTMLDivElement | null>(null);
const terminalRef = useRef<Terminal | null>(null);
const renderedOutputRef = useRef("");
const lastSizeRef = useRef<{ rows: number; cols: number } | null>(null);
const onInputRef = useRef<TerminalEmulatorProps["onInput"]>(onInput);
const onResizeRef = useRef<TerminalEmulatorProps["onResize"]>(onResize);
const onTerminalKeyRef = useRef<TerminalEmulatorProps["onTerminalKey"]>(onTerminalKey);
const onPendingModifiersConsumedRef = useRef<
TerminalEmulatorProps["onPendingModifiersConsumed"]
>(onPendingModifiersConsumed);
const pendingModifiersRef = useRef<PendingTerminalModifiers>(pendingModifiers);
useEffect(() => {
onInputRef.current = onInput;
}, [onInput]);
useEffect(() => {
onResizeRef.current = onResize;
}, [onResize]);
useEffect(() => {
onTerminalKeyRef.current = onTerminalKey;
}, [onTerminalKey]);
useEffect(() => {
onPendingModifiersConsumedRef.current = onPendingModifiersConsumed;
}, [onPendingModifiersConsumed]);
useEffect(() => {
pendingModifiersRef.current = pendingModifiers;
}, [pendingModifiers]);
const runtimeRef = useRef<TerminalEmulatorRuntime | null>(null);
useEffect(() => {
const host = hostRef.current;
@@ -96,405 +61,72 @@ export default function TerminalEmulator({
return;
}
renderedOutputRef.current = "";
lastSizeRef.current = null;
host.innerHTML = "";
const terminal = new Terminal({
allowProposedApi: true,
convertEol: false,
cursorBlink: true,
cursorStyle: "bar",
fontFamily: "'SF Mono', Menlo, Monaco, Consolas, 'Liberation Mono', monospace",
fontSize: 13,
lineHeight: 1.25,
scrollback: 10_000,
theme: {
background: backgroundColor,
foreground: foregroundColor,
cursor: cursorColor,
const runtime = new TerminalEmulatorRuntime();
runtimeRef.current = runtime;
runtime.setCallbacks({
callbacks: {
onInput,
onResize,
onTerminalKey,
onPendingModifiersConsumed,
},
});
const fitAddon = new FitAddon();
terminal.loadAddon(fitAddon);
terminal.open(host);
const documentElement = document.documentElement;
const body = document.body;
const rootContainer = root.parentElement;
const previousDocumentElementOverflow = documentElement.style.overflow;
const previousDocumentElementWidth = documentElement.style.width;
const previousDocumentElementHeight = documentElement.style.height;
const previousBodyOverflow = body.style.overflow;
const previousBodyWidth = body.style.width;
const previousBodyHeight = body.style.height;
const previousBodyMargin = body.style.margin;
const previousBodyPadding = body.style.padding;
const previousRootOverflow = rootContainer?.style.overflow ?? "";
const previousRootWidth = rootContainer?.style.width ?? "";
const previousRootHeight = rootContainer?.style.height ?? "";
// Force document to follow WebView bounds; xterm viewport owns scrollback.
documentElement.style.overflow = "hidden";
documentElement.style.width = "100%";
documentElement.style.height = "100%";
body.style.overflow = "hidden";
body.style.width = "100%";
body.style.height = "100%";
body.style.margin = "0";
body.style.padding = "0";
if (rootContainer) {
rootContainer.style.overflow = "hidden";
rootContainer.style.width = "100%";
rootContainer.style.height = "100%";
}
const viewportElement = host.querySelector<HTMLElement>(".xterm-viewport");
const screenElement = host.querySelector<HTMLElement>(".xterm-screen");
const previousViewportOverscroll = viewportElement?.style.overscrollBehavior ?? "";
const previousViewportTouchAction = viewportElement?.style.touchAction ?? "";
const previousViewportOverflowY = viewportElement?.style.overflowY ?? "";
const previousViewportOverflowX = viewportElement?.style.overflowX ?? "";
const previousViewportPointerEvents = viewportElement?.style.pointerEvents ?? "";
const previousViewportWebkitOverflowScrolling =
viewportElement?.style.getPropertyValue("-webkit-overflow-scrolling") ?? "";
if (viewportElement) {
viewportElement.style.overscrollBehavior = "none";
viewportElement.style.touchAction = "pan-y";
viewportElement.style.overflowY = "auto";
viewportElement.style.overflowX = "hidden";
viewportElement.style.pointerEvents = "auto";
viewportElement.style.setProperty("-webkit-overflow-scrolling", "touch");
}
const previousScreenPointerEvents = screenElement?.style.pointerEvents ?? "";
if (screenElement) {
// xterm renders the screen layer above the viewport. Disable hit-testing on that layer
// so touch drags can reach the scrollable viewport on mobile.
screenElement.style.pointerEvents = "none";
}
terminalRef.current = terminal;
window.__paseoTerminal = terminal;
const fitAndEmitResize = (force = false) => {
const handler = onResizeRef.current;
if (!handler) {
return;
}
try {
fitAddon.fit();
} catch {
return;
}
const rows = terminal.rows;
const cols = terminal.cols;
const previous = lastSizeRef.current;
if (!force && previous && previous.rows === rows && previous.cols === cols) {
return;
}
lastSizeRef.current = { rows, cols };
void handler(rows, cols);
};
fitAndEmitResize(true);
const inputDisposable = terminal.onData((data) => {
const handler = onInputRef.current;
if (!handler) {
return;
}
void handler(data);
runtime.setPendingModifiers({ pendingModifiers });
runtime.mount({
root,
host,
initialOutputText,
theme: {
backgroundColor,
foregroundColor,
cursorColor,
},
});
terminal.attachCustomKeyEventHandler((event) => {
if (event.type !== "keydown" || event.isComposing) {
return true;
}
const normalizedKey = normalizeDomTerminalKey(event.key);
if (!normalizedKey || isTerminalModifierDomKey(event.key)) {
return true;
}
const pending = pendingModifiersRef.current;
if (
!shouldInterceptDomTerminalKey({
key: normalizedKey,
ctrlKey: event.ctrlKey,
altKey: event.altKey,
pendingModifiers: pending,
})
) {
return true;
}
const modifiers = mergeTerminalModifiers({
pendingModifiers: pending,
ctrlKey: event.ctrlKey,
shiftKey: event.shiftKey,
altKey: event.altKey,
metaKey: event.metaKey,
});
const keyPayload = {
key: normalizeTerminalTransportKey(normalizedKey),
...modifiers,
};
onTerminalKeyRef.current?.(keyPayload);
if (pending.ctrl || pending.shift || pending.alt) {
onPendingModifiersConsumedRef.current?.();
}
event.preventDefault();
event.stopPropagation();
return false;
});
let touchScrollRemainderPx = 0;
const touchScrollLineHeightPx = (() => {
const row = host.querySelector<HTMLElement>(".xterm-rows > div");
const measured = row?.getBoundingClientRect().height;
return measured && measured > 0 ? measured : 18;
})();
const activeTouchRef: {
identifier: number;
startX: number;
startY: number;
lastX: number;
lastY: number;
mode: "vertical" | "horizontal" | null;
} = {
identifier: -1,
startX: 0,
startY: 0,
lastX: 0,
lastY: 0,
mode: null,
};
const rootTouchStartHandler = (event: TouchEvent) => {
if (event.touches.length !== 1) {
touchScrollRemainderPx = 0;
activeTouchRef.identifier = -1;
activeTouchRef.mode = null;
return;
}
const touch = event.touches[0];
if (!touch) {
touchScrollRemainderPx = 0;
activeTouchRef.identifier = -1;
activeTouchRef.mode = null;
return;
}
activeTouchRef.identifier = touch.identifier;
activeTouchRef.startX = touch.clientX;
activeTouchRef.startY = touch.clientY;
activeTouchRef.lastX = touch.clientX;
activeTouchRef.lastY = touch.clientY;
activeTouchRef.mode = null;
touchScrollRemainderPx = 0;
};
const rootTouchMoveHandler = (event: TouchEvent) => {
if (event.touches.length !== 1) {
return;
}
const touch = Array.from(event.touches).find(
(candidate) => candidate.identifier === activeTouchRef.identifier
);
if (!touch) {
return;
}
const totalDeltaX = touch.clientX - activeTouchRef.startX;
const totalDeltaY = touch.clientY - activeTouchRef.startY;
if (activeTouchRef.mode === null) {
const absX = Math.abs(totalDeltaX);
const absY = Math.abs(totalDeltaY);
if (absX > 8 || absY > 8) {
activeTouchRef.mode = absY >= absX ? "vertical" : "horizontal";
}
}
const deltaY = touch.clientY - activeTouchRef.lastY;
activeTouchRef.lastX = touch.clientX;
activeTouchRef.lastY = touch.clientY;
if (activeTouchRef.mode !== "vertical") {
return;
}
// Manual vertical touch scrolling fallback for xterm's layered DOM.
touchScrollRemainderPx += deltaY;
const lineDelta = Math.trunc(touchScrollRemainderPx / touchScrollLineHeightPx);
if (lineDelta !== 0) {
const appliedLineDelta = -lineDelta;
terminal.scrollLines(appliedLineDelta);
touchScrollRemainderPx -= lineDelta * touchScrollLineHeightPx;
}
event.preventDefault();
};
const rootTouchEndHandler = (event: TouchEvent) => {
const changed = Array.from(event.changedTouches).some(
(touch) => touch.identifier === activeTouchRef.identifier
);
if (changed || event.touches.length === 0) {
touchScrollRemainderPx = 0;
activeTouchRef.identifier = -1;
activeTouchRef.mode = null;
}
};
const rootTouchCancelHandler = () => {
touchScrollRemainderPx = 0;
activeTouchRef.identifier = -1;
activeTouchRef.mode = null;
};
root.addEventListener("touchstart", rootTouchStartHandler, { passive: true });
root.addEventListener("touchmove", rootTouchMoveHandler, { passive: false });
root.addEventListener("touchend", rootTouchEndHandler, { passive: true });
root.addEventListener("touchcancel", rootTouchCancelHandler, { passive: true });
const resizeObserver = new ResizeObserver(() => {
fitAndEmitResize();
});
resizeObserver.observe(root);
resizeObserver.observe(host);
const windowResizeHandler = () => fitAndEmitResize();
window.addEventListener("resize", windowResizeHandler);
const visualViewport = window.visualViewport;
const visualViewportResizeHandler = () => fitAndEmitResize();
visualViewport?.addEventListener("resize", visualViewportResizeHandler);
// Safety net for keyboard/layout transitions that can skip callbacks.
const fitInterval = window.setInterval(() => {
fitAndEmitResize();
}, 250);
const fitTimeoutHandles = [0, 16, 48, 120, 250, 500, 1_000, 2_000].map((delay) =>
window.setTimeout(() => {
fitAndEmitResize(true);
}, delay)
);
const fontSet = document.fonts;
const fontReadyHandler = () => {
fitAndEmitResize(true);
};
fontSet?.addEventListener?.("loadingdone", fontReadyHandler);
void fontSet?.ready
.then(() => {
fitAndEmitResize(true);
})
.catch(() => {
// no-op
});
window.setTimeout(() => fitAndEmitResize(true), 0);
if (outputText.length > 0) {
terminal.write(outputText);
renderedOutputRef.current = outputText;
}
return () => {
inputDisposable.dispose();
resizeObserver.disconnect();
window.removeEventListener("resize", windowResizeHandler);
visualViewport?.removeEventListener("resize", visualViewportResizeHandler);
window.clearInterval(fitInterval);
for (const handle of fitTimeoutHandles) {
window.clearTimeout(handle);
runtime.unmount();
if (runtimeRef.current === runtime) {
runtimeRef.current = null;
}
fontSet?.removeEventListener?.("loadingdone", fontReadyHandler);
root.removeEventListener("touchstart", rootTouchStartHandler);
root.removeEventListener("touchmove", rootTouchMoveHandler);
root.removeEventListener("touchend", rootTouchEndHandler);
root.removeEventListener("touchcancel", rootTouchCancelHandler);
fitAddon.dispose();
terminal.dispose();
documentElement.style.overflow = previousDocumentElementOverflow;
documentElement.style.width = previousDocumentElementWidth;
documentElement.style.height = previousDocumentElementHeight;
body.style.overflow = previousBodyOverflow;
body.style.width = previousBodyWidth;
body.style.height = previousBodyHeight;
body.style.margin = previousBodyMargin;
body.style.padding = previousBodyPadding;
if (rootContainer) {
rootContainer.style.overflow = previousRootOverflow;
rootContainer.style.width = previousRootWidth;
rootContainer.style.height = previousRootHeight;
}
if (viewportElement) {
viewportElement.style.overscrollBehavior = previousViewportOverscroll;
viewportElement.style.touchAction = previousViewportTouchAction;
viewportElement.style.overflowY = previousViewportOverflowY;
viewportElement.style.overflowX = previousViewportOverflowX;
viewportElement.style.pointerEvents = previousViewportPointerEvents;
viewportElement.style.setProperty(
"-webkit-overflow-scrolling",
previousViewportWebkitOverflowScrolling
);
}
if (screenElement) {
screenElement.style.pointerEvents = previousScreenPointerEvents;
}
terminalRef.current = null;
if (window.__paseoTerminal === terminal) {
window.__paseoTerminal = undefined;
}
renderedOutputRef.current = "";
lastSizeRef.current = null;
};
}, [backgroundColor, cursorColor, foregroundColor, streamKey]);
useEffect(() => {
const terminal = terminalRef.current;
if (!terminal) {
runtimeRef.current?.setCallbacks({
callbacks: {
onInput,
onResize,
onTerminalKey,
onPendingModifiersConsumed,
},
});
}, [onInput, onPendingModifiersConsumed, onResize, onTerminalKey]);
useEffect(() => {
runtimeRef.current?.setPendingModifiers({ pendingModifiers });
}, [pendingModifiers]);
useEffect(() => {
const runtime = runtimeRef.current;
if (!runtime) {
return;
}
const previous = renderedOutputRef.current;
if (outputText === previous) {
if (outputChunkSequence <= 0) {
return;
}
if (previous.length > 0 && outputText.startsWith(previous)) {
const suffix = outputText.slice(previous.length);
if (suffix.length > 0) {
terminal.write(suffix);
}
} else {
terminal.reset();
terminal.clear();
if (outputText.length > 0) {
terminal.write(outputText);
}
if (outputChunkText.length === 0) {
runtime.clear();
return;
}
renderedOutputRef.current = outputText;
}, [outputText]);
runtime.write({ text: outputChunkText });
}, [outputChunkSequence, outputChunkText]);
useEffect(() => {
if (focusRequestToken <= 0) {
return;
}
terminalRef.current?.focus();
runtimeRef.current?.focus();
}, [focusRequestToken]);
return (
@@ -513,7 +145,7 @@ export default function TerminalEmulator({
overscrollBehavior: "none",
}}
onPointerDown={() => {
terminalRef.current?.focus();
runtimeRef.current?.focus();
}}
>
<div
+107 -204
View File
@@ -22,13 +22,13 @@ import {
resolvePendingModifierDataInput,
} from "@/utils/terminal-keys";
import {
getTerminalResumeOffset,
getTerminalAttachRetryDelayMs,
isTerminalAttachRetryableError,
updateTerminalResumeOffset,
waitForDuration,
withPromiseTimeout,
} from "@/utils/terminal-attach";
TerminalOutputPump,
type TerminalOutputChunk,
} from "@/terminal/runtime/terminal-output-pump";
import {
TerminalStreamController,
type TerminalStreamControllerStatus,
} from "@/terminal/runtime/terminal-stream-controller";
import TerminalEmulator from "./terminal-emulator";
interface TerminalPaneProps {
@@ -38,8 +38,6 @@ interface TerminalPaneProps {
const MAX_OUTPUT_CHARS = 200_000;
const TERMINAL_TAB_MAX_WIDTH = 220;
const TERMINAL_ATTACH_MAX_ATTEMPTS = 4;
const TERMINAL_ATTACH_TIMEOUT_MS = 12_000;
const MODIFIER_LABELS = {
ctrl: "Ctrl",
@@ -65,6 +63,11 @@ type ModifierState = {
alt: boolean;
};
type TerminalOutputChunkState = {
sequence: number;
text: string;
};
const EMPTY_MODIFIERS: ModifierState = {
ctrl: false,
shift: false,
@@ -112,17 +115,19 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
const scopeKey = useMemo(() => terminalScopeKey({ serverId, cwd }), [serverId, cwd]);
const selectedTerminalByScopeRef = useRef<Map<string, string>>(new Map());
const lastReportedSizeRef = useRef<{ rows: number; cols: number } | null>(null);
const resumeOffsetByTerminalIdRef = useRef<Map<string, number>>(new Map());
const streamControllerRef = useRef<TerminalStreamController | null>(null);
const outputPumpRef = useRef<TerminalOutputPump | null>(null);
const [selectedTerminalId, setSelectedTerminalId] = useState<string | null>(null);
const [outputByTerminalId, setOutputByTerminalId] = useState<Map<string, string>>(
() => new Map()
);
const [selectedOutputChunk, setSelectedOutputChunk] = useState<TerminalOutputChunkState>({
sequence: 0,
text: "",
});
const [selectedOutputSnapshot, setSelectedOutputSnapshot] = useState("");
const [activeStream, setActiveStream] = useState<{
terminalId: string;
streamId: number;
} | null>(null);
const [attachGeneration, setAttachGeneration] = useState(0);
const [isAttaching, setIsAttaching] = useState(false);
const [streamError, setStreamError] = useState<string | null>(null);
const [modifiers, setModifiers] = useState<ModifierState>(EMPTY_MODIFIERS);
@@ -133,18 +138,27 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
);
const hoverOutTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const selectedTerminalIdRef = useRef<string | null>(selectedTerminalId);
const activeStreamRef = useRef<{
terminalId: string;
streamId: number;
} | null>(activeStream);
useEffect(() => {
selectedTerminalIdRef.current = selectedTerminalId;
}, [selectedTerminalId]);
useEffect(() => {
activeStreamRef.current = activeStream;
}, [activeStream]);
const outputPump = new TerminalOutputPump({
maxOutputChars: MAX_OUTPUT_CHARS,
onSelectedOutputChunk: (chunk: TerminalOutputChunk) => {
setSelectedOutputChunk(chunk);
},
});
outputPumpRef.current = outputPump;
return () => {
if (outputPumpRef.current === outputPump) {
outputPumpRef.current = null;
}
outputPump.dispose();
};
}, []);
const clearHoverOutTimeout = useCallback(() => {
if (!hoverOutTimeoutRef.current) {
@@ -221,20 +235,11 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
return;
}
const activeStreamForTerminal = activeStreamRef.current;
if (
selectedTerminalIdRef.current === exitedTerminalId &&
activeStreamForTerminal?.terminalId === exitedTerminalId &&
activeStreamForTerminal.streamId === message.payload.streamId
) {
setActiveStream((current) =>
current?.terminalId === exitedTerminalId ? null : current
);
setStreamError("Terminal stream ended. Reconnecting…");
setIsAttaching(true);
setAttachGeneration((current) => current + 1);
setModifiers({ ...EMPTY_MODIFIERS });
}
streamControllerRef.current?.handleStreamExit({
terminalId: exitedTerminalId,
streamId: message.payload.streamId,
});
setModifiers({ ...EMPTY_MODIFIERS });
void queryClient.invalidateQueries({
queryKey: ["terminals", serverId, cwd],
@@ -277,15 +282,11 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
},
onSuccess: (_, terminalId) => {
setHoveredTerminalId((current) => (current === terminalId ? null : current));
resumeOffsetByTerminalIdRef.current.delete(terminalId);
outputPumpRef.current?.clearTerminal({ terminalId });
if (selectedTerminalIdRef.current === terminalId) {
setSelectedTerminalId((current) =>
current === terminalId ? null : current
);
setActiveStream((current) =>
current?.terminalId === terminalId ? null : current
);
setIsAttaching(false);
setModifiers({ ...EMPTY_MODIFIERS });
}
void queryClient.invalidateQueries({
@@ -336,178 +337,82 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
}, [scopeKey, terminals, selectedTerminalId]);
useEffect(() => {
if (terminals.length === 0) {
resumeOffsetByTerminalIdRef.current.clear();
return;
}
const terminalIdSet = new Set(terminals.map((terminal) => terminal.id));
for (const terminalId of Array.from(resumeOffsetByTerminalIdRef.current.keys())) {
if (!terminalIdSet.has(terminalId)) {
resumeOffsetByTerminalIdRef.current.delete(terminalId);
}
}
const terminalIds = terminals.map((terminal) => terminal.id);
outputPumpRef.current?.prune({ terminalIds });
streamControllerRef.current?.pruneResumeOffsets({ terminalIds });
}, [terminals]);
const appendOutput = useCallback((terminalId: string, text: string) => {
if (!text) {
return;
}
setOutputByTerminalId((previous) => {
const next = new Map(previous);
const existing = next.get(terminalId) ?? "";
const combined = `${existing}${text}`;
next.set(
terminalId,
combined.length > MAX_OUTPUT_CHARS
? combined.slice(combined.length - MAX_OUTPUT_CHARS)
: combined
);
return next;
});
}, []);
const handleStreamControllerStatus = useCallback(
(status: TerminalStreamControllerStatus) => {
setIsAttaching(status.isAttaching);
setStreamError(status.error);
if (status.terminalId && typeof status.streamId === "number") {
setActiveStream({
terminalId: status.terminalId,
streamId: status.streamId,
});
return;
}
setActiveStream(null);
},
[]
);
useEffect(() => {
let isCancelled = false;
let streamId: number | null = null;
let unsubscribe: (() => void) | null = null;
let decoder: TextDecoder | null = null;
const terminalId = selectedTerminalId;
streamControllerRef.current?.dispose();
streamControllerRef.current = null;
setActiveStream(null);
setIsAttaching(false);
setStreamError(null);
if (!client || !isConnected || !terminalId) {
setActiveStream(null);
setIsAttaching(false);
if (!client || !isConnected) {
return;
}
setIsAttaching(true);
setStreamError(null);
const outputPump = outputPumpRef.current;
if (!outputPump) {
return;
}
const attach = async () => {
try {
let lastErrorMessage = "Unable to attach terminal stream";
const totalAttempts = TERMINAL_ATTACH_MAX_ATTEMPTS;
for (let attempt = 0; attempt < totalAttempts; attempt += 1) {
try {
const lastSize = lastReportedSizeRef.current;
const resumeOffset = getTerminalResumeOffset({
terminalId,
resumeOffsetByTerminalId: resumeOffsetByTerminalIdRef.current,
});
const attachPayload = await withPromiseTimeout({
promise: client.attachTerminalStream(terminalId, {
...(resumeOffset !== undefined ? { resumeOffset } : {}),
...(lastSize
? {
rows: lastSize.rows,
cols: lastSize.cols,
}
: {}),
}),
timeoutMs: TERMINAL_ATTACH_TIMEOUT_MS,
timeoutMessage: "Timed out attaching terminal stream",
});
if (isCancelled) {
if (typeof attachPayload.streamId === "number") {
void client.detachTerminalStream(attachPayload.streamId).catch(() => {});
}
return;
}
if (attachPayload.error || typeof attachPayload.streamId !== "number") {
lastErrorMessage = attachPayload.error ?? "Unable to attach terminal stream";
const hasRemainingAttempts = attempt < totalAttempts - 1;
if (
hasRemainingAttempts &&
isTerminalAttachRetryableError({ message: lastErrorMessage })
) {
await waitForDuration({
durationMs: getTerminalAttachRetryDelayMs({ attempt }),
});
continue;
}
setStreamError(lastErrorMessage);
setActiveStream(null);
return;
}
streamId = attachPayload.streamId;
updateTerminalResumeOffset({
terminalId,
offset: attachPayload.currentOffset,
resumeOffsetByTerminalId: resumeOffsetByTerminalIdRef.current,
});
decoder = new TextDecoder();
setActiveStream({ terminalId, streamId });
setStreamError(null);
unsubscribe = client.onTerminalStreamData(streamId, (chunk) => {
if (isCancelled) {
return;
}
updateTerminalResumeOffset({
terminalId,
offset: chunk.endOffset,
resumeOffsetByTerminalId: resumeOffsetByTerminalIdRef.current,
});
const text = decoder?.decode(chunk.data, { stream: true }) ?? "";
appendOutput(terminalId, text);
});
return;
} catch (error) {
lastErrorMessage =
error instanceof Error ? error.message : "Unable to attach terminal stream";
const hasRemainingAttempts = attempt < totalAttempts - 1;
if (
hasRemainingAttempts &&
isTerminalAttachRetryableError({ message: lastErrorMessage })
) {
await waitForDuration({
durationMs: getTerminalAttachRetryDelayMs({ attempt }),
});
continue;
}
if (!isCancelled) {
setStreamError(lastErrorMessage);
setActiveStream(null);
}
return;
}
const controller = new TerminalStreamController({
client,
getPreferredSize: () => lastReportedSizeRef.current,
onChunk: ({ terminalId, text }) => {
outputPump.append({ terminalId, text });
},
onReset: ({ terminalId }) => {
outputPump.clearTerminal({ terminalId });
if (selectedTerminalIdRef.current === terminalId) {
setSelectedOutputSnapshot("");
}
},
onStatusChange: handleStreamControllerStatus,
});
if (!isCancelled) {
setStreamError(lastErrorMessage);
setActiveStream(null);
}
} finally {
if (!isCancelled) {
setIsAttaching(false);
}
}
};
void attach();
streamControllerRef.current = controller;
controller.setTerminal({ terminalId: selectedTerminalIdRef.current });
return () => {
isCancelled = true;
if (decoder) {
appendOutput(terminalId, decoder.decode());
decoder = null;
controller.dispose();
if (streamControllerRef.current === controller) {
streamControllerRef.current = null;
}
if (unsubscribe) {
unsubscribe();
unsubscribe = null;
}
if (streamId !== null) {
void client.detachTerminalStream(streamId).catch(() => {});
}
setActiveStream((current) =>
current?.terminalId === terminalId ? null : current
);
setIsAttaching(false);
};
}, [appendOutput, attachGeneration, client, isConnected, selectedTerminalId]);
}, [client, handleStreamControllerStatus, isConnected]);
useEffect(() => {
outputPumpRef.current?.setSelectedTerminal({
terminalId: selectedTerminalId,
});
streamControllerRef.current?.setTerminal({
terminalId: selectedTerminalId,
});
setSelectedOutputSnapshot(
outputPumpRef.current?.readSnapshot({
terminalId: selectedTerminalId,
}) ?? ""
);
}, [selectedTerminalId]);
const activeStreamId =
activeStream && activeStream.terminalId === selectedTerminalId
@@ -518,11 +423,6 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
() => terminals.find((terminal) => terminal.id === selectedTerminalId) ?? null,
[terminals, selectedTerminalId]
);
const currentOutput = selectedTerminalId
? (outputByTerminalId.get(selectedTerminalId) ?? "")
: "";
const handleCreateTerminal = useCallback(() => {
createTerminalMutation.mutate();
}, [createTerminalMutation]);
@@ -623,7 +523,8 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
);
const handleTerminalResize = useCallback(
async (rows: number, cols: number) => {
async (input: { rows: number; cols: number }) => {
const { rows, cols } = input;
if (!client || !selectedTerminalId || rows <= 0 || cols <= 0) {
return;
}
@@ -834,8 +735,10 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
automaticallyAdjustContentInsets: false,
contentInsetAdjustmentBehavior: "never",
}}
streamKey={`${scopeKey}:${selectedTerminal.id}:${activeStreamId ?? "none"}`}
outputText={currentOutput}
streamKey={`${scopeKey}:${selectedTerminal.id}`}
initialOutputText={selectedOutputSnapshot}
outputChunkText={selectedOutputChunk.text}
outputChunkSequence={selectedOutputChunk.sequence}
testId="terminal-surface"
backgroundColor={theme.colors.background}
foregroundColor={theme.colors.foreground}