feat: add file tabs and improve terminal rendering with WebGL

This commit is contained in:
Mohamed Boudra
2026-03-02 20:02:54 +07:00
parent 92b5f1e963
commit f418f3ab02
25 changed files with 1214 additions and 696 deletions
@@ -1,5 +1,7 @@
import { FitAddon } from "@xterm/addon-fit";
import { Terminal } from "@xterm/xterm";
import { Unicode11Addon } from "@xterm/addon-unicode11";
import { WebglAddon } from "@xterm/addon-webgl";
import { Terminal, type ITheme } from "@xterm/xterm";
import {
type PendingTerminalModifiers,
isTerminalModifierDomKey,
@@ -10,17 +12,11 @@ import {
} from "@/utils/terminal-keys";
import { summarizeTerminalText, terminalDebugLog } from "./terminal-debug";
export type TerminalEmulatorRuntimeTheme = {
backgroundColor: string;
foregroundColor: string;
cursorColor: string;
};
export type TerminalEmulatorRuntimeMountInput = {
root: HTMLDivElement;
host: HTMLDivElement;
initialOutputText: string;
theme: TerminalEmulatorRuntimeTheme;
theme: ITheme;
};
export type TerminalEmulatorRuntimeCallbacks = {
@@ -48,12 +44,14 @@ type TerminalEmulatorRuntimeDisposables = {
restoreDocumentStyles: () => void;
restoreViewportStyles: () => void;
disposeFitAddon: () => void;
disposeWebglAddon: () => void;
disposeTerminal: () => void;
};
type TerminalOutputOperation = {
type: "write" | "clear";
text: string;
suppressInput?: boolean;
onCommitted?: () => void;
};
@@ -67,6 +65,26 @@ const DEFAULT_TOUCH_SCROLL_LINE_HEIGHT_PX = 18;
const FIT_TIMEOUT_DELAYS_MS = [0, 16, 48, 120, 250, 500, 1_000, 2_000];
const OUTPUT_OPERATION_TIMEOUT_MS = 5_000;
const DEFAULT_TERMINAL_FONT_FAMILY = [
// Prefer common developer fonts, with Nerd Font variants for prompt/TUI glyphs.
"JetBrains Mono",
"JetBrainsMono Nerd Font",
"JetBrainsMono NF",
"MesloLGM Nerd Font",
"MesloLGM NF",
"Hack Nerd Font",
"FiraCode Nerd Font",
// PUA-only fallback (many Nerd glyphs live here on some systems).
"Symbols Nerd Font",
// System fallbacks.
"SF Mono",
"Menlo",
"Monaco",
"Consolas",
"'Liberation Mono'",
"monospace",
].join(", ");
export class TerminalEmulatorRuntime {
private callbacks: TerminalEmulatorRuntimeCallbacks = {};
private pendingModifiers: PendingTerminalModifiers = {
@@ -82,6 +100,7 @@ export class TerminalEmulatorRuntime {
private outputOperations: TerminalOutputOperation[] = [];
private inFlightOutputOperation: TerminalOutputOperation | null = null;
private inFlightOutputOperationTimeout: ReturnType<typeof setTimeout> | null = null;
private suppressInput = false;
setCallbacks(input: { callbacks: TerminalEmulatorRuntimeCallbacks }): void {
this.callbacks = input.callbacks;
@@ -109,19 +128,49 @@ export class TerminalEmulatorRuntime {
convertEol: false,
cursorBlink: true,
cursorStyle: "bar",
fontFamily: "'SF Mono', Menlo, Monaco, Consolas, 'Liberation Mono', monospace",
fontFamily: DEFAULT_TERMINAL_FONT_FAMILY,
fontSize: 13,
lineHeight: 1.25,
lineHeight: 1.0,
scrollback: 10_000,
theme: {
background: input.theme.backgroundColor,
foreground: input.theme.foregroundColor,
cursor: input.theme.cursorColor,
},
theme: input.theme,
});
const fitAddon = new FitAddon();
const unicode11Addon = new Unicode11Addon();
let webglAddon: WebglAddon | null = null;
terminal.loadAddon(fitAddon);
terminal.loadAddon(unicode11Addon);
terminal.open(input.host);
try {
terminal.unicode.activeVersion = "11";
} catch {
// Ignore if unicode API isn't available in this build/runtime.
}
// Prefer GPU rendering when available. This tends to reduce visible seams in block characters
// and improves scroll performance, but must gracefully fall back on platforms without WebGL.
let webglAddonRaf: number | null = requestAnimationFrame(() => {
webglAddonRaf = null;
try {
webglAddon = new WebglAddon();
webglAddon.onContextLoss(() => {
try {
webglAddon?.dispose();
} catch {
// ignore
}
webglAddon = null;
// Force a refresh after context loss to avoid visual corruption.
try {
terminal.refresh(0, terminal.rows - 1);
} catch {
// ignore
}
});
terminal.loadAddon(webglAddon);
} catch {
webglAddon = null;
}
});
const restoreDocumentStyles = this.applyDocumentBoundsStyles({
root: input.root,
@@ -174,6 +223,17 @@ export class TerminalEmulatorRuntime {
fitAndEmitResize(true);
const inputDisposable = terminal.onData((data) => {
if (this.suppressInput) {
terminalDebugLog({
scope: "emulator-runtime",
event: "input:suppressed",
details: {
length: data.length,
preview: summarizeTerminalText({ text: data, maxChars: 64 }),
},
});
return;
}
terminalDebugLog({
scope: "emulator-runtime",
event: "input:onData",
@@ -283,7 +343,7 @@ export class TerminalEmulatorRuntime {
}, 0);
if (input.initialOutputText.length > 0) {
terminal.write(input.initialOutputText);
this.write({ text: input.initialOutputText, suppressInput: true });
terminalDebugLog({
scope: "emulator-runtime",
event: "output:initial-write",
@@ -329,6 +389,18 @@ export class TerminalEmulatorRuntime {
disposeFitAddon: () => {
fitAddon.dispose();
},
disposeWebglAddon: () => {
if (webglAddonRaf !== null) {
cancelAnimationFrame(webglAddonRaf);
webglAddonRaf = null;
}
try {
webglAddon?.dispose();
} catch {
// ignore
}
webglAddon = null;
},
disposeTerminal: () => {
terminal.dispose();
},
@@ -344,13 +416,14 @@ export class TerminalEmulatorRuntime {
disposables.removeFontListeners();
disposables.removeTouchListeners();
disposables.disposeFitAddon();
disposables.disposeWebglAddon();
disposables.disposeTerminal();
disposables.restoreDocumentStyles();
disposables.restoreViewportStyles();
};
}
write(input: { text: string; onCommitted?: () => void }): void {
write(input: { text: string; suppressInput?: boolean; onCommitted?: () => void }): void {
if (input.text.length === 0) {
input.onCommitted?.();
return;
@@ -358,6 +431,7 @@ export class TerminalEmulatorRuntime {
this.outputOperations.push({
type: "write",
text: input.text,
suppressInput: input.suppressInput ?? false,
...(input.onCommitted ? { onCommitted: input.onCommitted } : {}),
});
terminalDebugLog({
@@ -376,6 +450,7 @@ export class TerminalEmulatorRuntime {
this.outputOperations.push({
type: "clear",
text: "",
suppressInput: false,
...(input?.onCommitted ? { onCommitted: input.onCommitted } : {}),
});
terminalDebugLog({
@@ -421,6 +496,7 @@ export class TerminalEmulatorRuntime {
this.fitAddon = null;
this.fitAndEmitResize = null;
this.lastSize = null;
this.suppressInput = false;
}
private processOutputQueue(): void {
@@ -439,12 +515,17 @@ export class TerminalEmulatorRuntime {
}
this.inFlightOutputOperation = operation;
const previousSuppressInput = this.suppressInput;
if (operation.type === "write") {
this.suppressInput = Boolean(operation.suppressInput);
}
const finalizeOperation = (expectedOperation: TerminalOutputOperation) => {
if (this.inFlightOutputOperation !== expectedOperation) {
return;
}
this.inFlightOutputOperation = null;
this.clearInFlightOutputTimeout();
this.suppressInput = previousSuppressInput;
expectedOperation.onCommitted?.();
this.processOutputQueue();
};
@@ -493,12 +574,19 @@ export class TerminalEmulatorRuntime {
const previousDocumentElementOverflow = documentElement.style.overflow;
const previousDocumentElementWidth = documentElement.style.width;
const previousDocumentElementHeight = documentElement.style.height;
const previousDocumentElementTextSizeAdjust =
documentElement.style.getPropertyValue("text-size-adjust");
const previousDocumentElementWebkitTextSizeAdjust =
documentElement.style.getPropertyValue("-webkit-text-size-adjust");
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 previousBodyTextSizeAdjust = body.style.getPropertyValue("text-size-adjust");
const previousBodyWebkitTextSizeAdjust =
body.style.getPropertyValue("-webkit-text-size-adjust");
const previousRootOverflow = rootContainer?.style.overflow ?? "";
const previousRootWidth = rootContainer?.style.width ?? "";
@@ -507,12 +595,16 @@ export class TerminalEmulatorRuntime {
documentElement.style.overflow = "hidden";
documentElement.style.width = "100%";
documentElement.style.height = "100%";
documentElement.style.setProperty("text-size-adjust", "100%");
documentElement.style.setProperty("-webkit-text-size-adjust", "100%");
body.style.overflow = "hidden";
body.style.width = "100%";
body.style.height = "100%";
body.style.margin = "0";
body.style.padding = "0";
body.style.setProperty("text-size-adjust", "100%");
body.style.setProperty("-webkit-text-size-adjust", "100%");
if (rootContainer) {
rootContainer.style.overflow = "hidden";
@@ -524,12 +616,19 @@ export class TerminalEmulatorRuntime {
documentElement.style.overflow = previousDocumentElementOverflow;
documentElement.style.width = previousDocumentElementWidth;
documentElement.style.height = previousDocumentElementHeight;
documentElement.style.setProperty("text-size-adjust", previousDocumentElementTextSizeAdjust);
documentElement.style.setProperty(
"-webkit-text-size-adjust",
previousDocumentElementWebkitTextSizeAdjust
);
body.style.overflow = previousBodyOverflow;
body.style.width = previousBodyWidth;
body.style.height = previousBodyHeight;
body.style.margin = previousBodyMargin;
body.style.padding = previousBodyPadding;
body.style.setProperty("text-size-adjust", previousBodyTextSizeAdjust);
body.style.setProperty("-webkit-text-size-adjust", previousBodyWebkitTextSizeAdjust);
if (rootContainer) {
rootContainer.style.overflow = previousRootOverflow;
@@ -5,7 +5,7 @@ import { TerminalOutputDeliveryQueue } from "./terminal-output-delivery-queue";
describe("terminal-output-delivery-queue", () => {
it("retries in-flight delivery when consume is missing", () => {
vi.useFakeTimers();
const delivered: Array<{ sequence: number; text: string }> = [];
const delivered: Array<{ sequence: number; text: string; replay: boolean }> = [];
const queue = new TerminalOutputDeliveryQueue({
onDeliver: (chunk) => {
delivered.push(chunk);
@@ -13,45 +13,45 @@ describe("terminal-output-delivery-queue", () => {
deliveryTimeoutMs: 100,
});
queue.enqueue({ sequence: 1, text: "a" });
queue.enqueue({ sequence: 2, text: "b" });
queue.enqueue({ sequence: 1, text: "a", replay: false });
queue.enqueue({ sequence: 2, text: "b", replay: false });
expect(delivered).toEqual([{ sequence: 1, text: "a" }]);
expect(delivered).toEqual([{ sequence: 1, text: "a", replay: false }]);
vi.advanceTimersByTime(100);
expect(delivered).toEqual([
{ sequence: 1, text: "a" },
{ sequence: 1, text: "a" },
{ sequence: 1, text: "a", replay: false },
{ sequence: 1, text: "a", replay: false },
]);
queue.consume({ sequence: 1 });
expect(delivered).toEqual([
{ sequence: 1, text: "a" },
{ sequence: 1, text: "a" },
{ sequence: 2, text: "b" },
{ sequence: 1, text: "a", replay: false },
{ sequence: 1, text: "a", replay: false },
{ sequence: 2, text: "b", replay: false },
]);
vi.useRealTimers();
});
it("delivers first chunk immediately and blocks later chunks until consumed", () => {
const delivered: Array<{ sequence: number; text: string }> = [];
const delivered: Array<{ sequence: number; text: string; replay: boolean }> = [];
const queue = new TerminalOutputDeliveryQueue({
onDeliver: (chunk) => {
delivered.push(chunk);
},
});
queue.enqueue({ sequence: 1, text: "a" });
queue.enqueue({ sequence: 2, text: "b" });
queue.enqueue({ sequence: 3, text: "c" });
queue.enqueue({ sequence: 1, text: "a", replay: false });
queue.enqueue({ sequence: 2, text: "b", replay: false });
queue.enqueue({ sequence: 3, text: "c", replay: false });
expect(delivered).toEqual([{ sequence: 1, text: "a" }]);
expect(delivered).toEqual([{ sequence: 1, text: "a", replay: false }]);
queue.consume({ sequence: 1 });
expect(delivered).toEqual([
{ sequence: 1, text: "a" },
{ sequence: 3, text: "bc" },
{ sequence: 1, text: "a", replay: false },
{ sequence: 3, text: "bc", replay: false },
]);
});
@@ -59,83 +59,83 @@ describe("terminal-output-delivery-queue", () => {
const delivered = vi.fn();
const queue = new TerminalOutputDeliveryQueue({ onDeliver: delivered });
queue.enqueue({ sequence: 1, text: "x" });
queue.enqueue({ sequence: 1, text: "x", replay: false });
queue.consume({ sequence: 99 });
queue.enqueue({ sequence: 2, text: "y" });
queue.enqueue({ sequence: 2, text: "y", replay: false });
expect(delivered).toHaveBeenCalledTimes(1);
expect(delivered).toHaveBeenNthCalledWith(1, { sequence: 1, text: "x" });
expect(delivered).toHaveBeenNthCalledWith(1, { sequence: 1, text: "x", replay: false });
queue.consume({ sequence: 1 });
expect(delivered).toHaveBeenCalledTimes(2);
expect(delivered).toHaveBeenNthCalledWith(2, { sequence: 2, text: "y" });
expect(delivered).toHaveBeenNthCalledWith(2, { sequence: 2, text: "y", replay: false });
});
it("resets in-flight and pending chunks", () => {
const delivered: Array<{ sequence: number; text: string }> = [];
const delivered: Array<{ sequence: number; text: string; replay: boolean }> = [];
const queue = new TerminalOutputDeliveryQueue({
onDeliver: (chunk) => {
delivered.push(chunk);
},
});
queue.enqueue({ sequence: 1, text: "hello" });
queue.enqueue({ sequence: 2, text: " world" });
queue.enqueue({ sequence: 1, text: "hello", replay: false });
queue.enqueue({ sequence: 2, text: " world", replay: false });
queue.reset();
queue.enqueue({ sequence: 3, text: "next" });
queue.enqueue({ sequence: 3, text: "next", replay: false });
expect(delivered).toEqual([
{ sequence: 1, text: "hello" },
{ sequence: 3, text: "next" },
{ sequence: 1, text: "hello", replay: false },
{ sequence: 3, text: "next", replay: false },
]);
});
it("preserves empty chunk payloads for authoritative clears", () => {
const delivered: Array<{ sequence: number; text: string }> = [];
const delivered: Array<{ sequence: number; text: string; replay: boolean }> = [];
const queue = new TerminalOutputDeliveryQueue({
onDeliver: (chunk) => {
delivered.push(chunk);
},
});
queue.enqueue({ sequence: 1, text: "abc" });
queue.enqueue({ sequence: 1, text: "abc", replay: false });
queue.consume({ sequence: 1 });
queue.enqueue({ sequence: 2, text: "" });
queue.enqueue({ sequence: 2, text: "", replay: false });
expect(delivered).toEqual([
{ sequence: 1, text: "abc" },
{ sequence: 2, text: "" },
{ sequence: 1, text: "abc", replay: false },
{ sequence: 2, text: "", replay: false },
]);
});
it("treats clear chunks as a delivery barrier and drops pending stale text", () => {
const delivered: Array<{ sequence: number; text: string }> = [];
const delivered: Array<{ sequence: number; text: string; replay: boolean }> = [];
const queue = new TerminalOutputDeliveryQueue({
onDeliver: (chunk) => {
delivered.push(chunk);
},
});
queue.enqueue({ sequence: 1, text: "a" });
queue.enqueue({ sequence: 2, text: "b" });
queue.enqueue({ sequence: 3, text: "" });
queue.enqueue({ sequence: 4, text: "c" });
queue.enqueue({ sequence: 1, text: "a", replay: false });
queue.enqueue({ sequence: 2, text: "b", replay: false });
queue.enqueue({ sequence: 3, text: "", replay: false });
queue.enqueue({ sequence: 4, text: "c", replay: false });
expect(delivered).toEqual([{ sequence: 1, text: "a" }]);
expect(delivered).toEqual([{ sequence: 1, text: "a", replay: false }]);
queue.consume({ sequence: 1 });
expect(delivered).toEqual([
{ sequence: 1, text: "a" },
{ sequence: 3, text: "" },
{ sequence: 1, text: "a", replay: false },
{ sequence: 3, text: "", replay: false },
]);
queue.consume({ sequence: 3 });
expect(delivered).toEqual([
{ sequence: 1, text: "a" },
{ sequence: 3, text: "" },
{ sequence: 4, text: "c" },
{ sequence: 1, text: "a", replay: false },
{ sequence: 3, text: "", replay: false },
{ sequence: 4, text: "c", replay: false },
]);
});
});
@@ -3,6 +3,7 @@ import { summarizeTerminalText, terminalDebugLog } from "./terminal-debug";
export type TerminalOutputDeliveryChunk = {
sequence: number;
text: string;
replay: boolean;
};
export type TerminalOutputDeliveryQueueOptions = {
@@ -48,7 +49,11 @@ export class TerminalOutputDeliveryQueue {
}
const lastPendingChunk = this.pendingChunks[this.pendingChunks.length - 1];
if (lastPendingChunk && lastPendingChunk.text.length > 0) {
if (
lastPendingChunk &&
lastPendingChunk.text.length > 0 &&
lastPendingChunk.replay === chunk.replay
) {
lastPendingChunk.sequence = chunk.sequence;
lastPendingChunk.text += chunk.text;
} else {
@@ -138,6 +143,7 @@ export class TerminalOutputDeliveryQueue {
this.options.onDeliver({
sequence: chunk.sequence,
text: chunk.text,
replay: chunk.replay,
});
}
@@ -5,7 +5,7 @@ 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 chunks: Array<{ sequence: number; text: string; replay: boolean }> = [];
const pump = new TerminalOutputPump({
maxOutputChars: 100,
onSelectedOutputChunk: (chunk) => {
@@ -14,16 +14,16 @@ describe("terminal-output-pump", () => {
});
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" });
pump.append({ terminalId: "term-1", text: "a", replay: false });
pump.append({ terminalId: "term-1", text: "b", replay: false });
pump.append({ terminalId: "term-1", text: "c", replay: false });
expect(chunks).toEqual([]);
vi.runOnlyPendingTimers();
expect(chunks).toEqual([
{ sequence: 1, text: "abc" },
{ sequence: 1, text: "abc", replay: false },
]);
vi.useRealTimers();
@@ -31,7 +31,7 @@ describe("terminal-output-pump", () => {
it("keeps per-terminal snapshots and switches selected stream deterministically", () => {
vi.useFakeTimers();
const chunks: Array<{ sequence: number; text: string }> = [];
const chunks: Array<{ sequence: number; text: string; replay: boolean }> = [];
const pump = new TerminalOutputPump({
maxOutputChars: 10,
onSelectedOutputChunk: (chunk) => {
@@ -40,21 +40,21 @@ describe("terminal-output-pump", () => {
});
pump.setSelectedTerminal({ terminalId: "term-1" });
pump.append({ terminalId: "term-1", text: "hello" });
pump.append({ terminalId: "term-1", text: "hello", replay: false });
vi.runOnlyPendingTimers();
expect(pump.readSnapshot({ terminalId: "term-1" })).toBe("hello");
pump.append({ terminalId: "term-2", text: "world" });
pump.append({ terminalId: "term-2", text: "world", replay: false });
vi.runOnlyPendingTimers();
expect(pump.readSnapshot({ terminalId: "term-2" })).toBe("world");
pump.setSelectedTerminal({ terminalId: "term-2" });
pump.append({ terminalId: "term-2", text: "!" });
pump.append({ terminalId: "term-2", text: "!", replay: false });
vi.runOnlyPendingTimers();
expect(chunks).toEqual([
{ sequence: 1, text: "hello" },
{ sequence: 2, text: "!" },
{ sequence: 1, text: "hello", replay: false },
{ sequence: 2, text: "!", replay: false },
]);
vi.useRealTimers();
@@ -62,7 +62,7 @@ describe("terminal-output-pump", () => {
it("resets selected output when clearing selected terminal", () => {
vi.useFakeTimers();
const chunks: Array<{ sequence: number; text: string }> = [];
const chunks: Array<{ sequence: number; text: string; replay: boolean }> = [];
const pump = new TerminalOutputPump({
maxOutputChars: 10,
onSelectedOutputChunk: (chunk) => {
@@ -71,15 +71,15 @@ describe("terminal-output-pump", () => {
});
pump.setSelectedTerminal({ terminalId: "term-1" });
pump.append({ terminalId: "term-1", text: "abc" });
pump.append({ terminalId: "term-1", text: "abc", replay: false });
vi.runOnlyPendingTimers();
pump.clearTerminal({ terminalId: "term-1" });
expect(pump.readSnapshot({ terminalId: "term-1" })).toBe("");
expect(chunks).toEqual([
{ sequence: 1, text: "abc" },
{ sequence: 2, text: "" },
{ sequence: 1, text: "abc", replay: false },
{ sequence: 2, text: "", replay: false },
]);
vi.useRealTimers();
@@ -91,8 +91,8 @@ describe("terminal-output-pump", () => {
onSelectedOutputChunk: () => {},
});
pump.append({ terminalId: "a", text: "one" });
pump.append({ terminalId: "b", text: "two" });
pump.append({ terminalId: "a", text: "one", replay: false });
pump.append({ terminalId: "b", text: "two", replay: false });
pump.prune({ terminalIds: ["b"] });
expect(pump.readSnapshot({ terminalId: "a" })).toBe("");
@@ -9,6 +9,7 @@ import { summarizeTerminalText, terminalDebugLog } from "./terminal-debug";
export type TerminalOutputChunk = {
sequence: number;
text: string;
replay: boolean;
};
export type TerminalOutputPumpOptions = {
@@ -23,6 +24,7 @@ export type TerminalOutputPumpSetSelectedInput = {
export type TerminalOutputPumpAppendInput = {
terminalId: string;
text: string;
replay: boolean;
};
export type TerminalOutputPumpReadInput = {
@@ -42,6 +44,7 @@ export class TerminalOutputPump {
private selectedTerminalId: string | null = null;
private selectedChunkSequence = 0;
private selectedChunkAccumulator = "";
private selectedChunkAccumulatorReplay: boolean | null = null;
private selectedChunkFlushTimer: ReturnType<typeof setTimeout> | null = null;
constructor(private readonly options: TerminalOutputPumpOptions) {}
@@ -61,6 +64,7 @@ export class TerminalOutputPump {
});
this.clearSelectedChunkFlushTimer();
this.selectedChunkAccumulator = "";
this.selectedChunkAccumulatorReplay = null;
this.selectedTerminalId = input.terminalId;
}
@@ -85,6 +89,18 @@ export class TerminalOutputPump {
return;
}
if (
this.selectedChunkAccumulator.length > 0 &&
typeof this.selectedChunkAccumulatorReplay === "boolean" &&
this.selectedChunkAccumulatorReplay !== input.replay
) {
this.flushSelectedChunkAccumulator();
}
if (this.selectedChunkAccumulator.length === 0) {
this.selectedChunkAccumulatorReplay = input.replay;
}
this.selectedChunkAccumulator += input.text;
terminalDebugLog({
scope: "output-pump",
@@ -93,6 +109,7 @@ export class TerminalOutputPump {
terminalId: input.terminalId,
appendedLength: input.text.length,
accumulatorLength: this.selectedChunkAccumulator.length,
replay: input.replay,
preview: summarizeTerminalText({ text: input.text, maxChars: 80 }),
},
});
@@ -104,7 +121,8 @@ export class TerminalOutputPump {
if (this.selectedTerminalId === input.terminalId) {
this.clearSelectedChunkFlushTimer();
this.selectedChunkAccumulator = "";
this.emitSelectedChunk({ text: "" });
this.selectedChunkAccumulatorReplay = null;
this.emitSelectedChunk({ text: "", replay: false });
}
}
@@ -152,24 +170,28 @@ export class TerminalOutputPump {
}
const text = this.selectedChunkAccumulator;
const replay = this.selectedChunkAccumulatorReplay ?? false;
this.selectedChunkAccumulator = "";
this.selectedChunkAccumulatorReplay = null;
terminalDebugLog({
scope: "output-pump",
event: "selected-terminal:flush",
details: {
terminalId: this.selectedTerminalId,
textLength: text.length,
replay,
preview: summarizeTerminalText({ text, maxChars: 96 }),
},
});
this.emitSelectedChunk({ text });
this.emitSelectedChunk({ text, replay });
}
private emitSelectedChunk(input: { text: string }): void {
private emitSelectedChunk(input: { text: string; replay: boolean }): void {
this.selectedChunkSequence += 1;
this.options.onSelectedOutputChunk({
sequence: this.selectedChunkSequence,
text: input.text,
replay: input.replay,
});
}
@@ -54,7 +54,7 @@ export type TerminalStreamControllerStatus = {
export type TerminalStreamControllerOptions = {
client: TerminalStreamControllerClient;
getPreferredSize: () => TerminalStreamControllerSize | null;
onChunk: (input: { terminalId: string; text: string }) => void;
onChunk: (input: { terminalId: string; text: string; replay: boolean }) => void;
onReset?: (input: { terminalId: string }) => void;
onStatusChange?: (status: TerminalStreamControllerStatus) => void;
maxAttachAttempts?: number;
@@ -500,6 +500,7 @@ export class TerminalStreamController {
this.options.onChunk({
terminalId: input.terminalId,
text,
replay: Boolean(input.chunk.replay),
});
}
@@ -565,6 +566,7 @@ export class TerminalStreamController {
this.options.onChunk({
terminalId: activeStream.terminalId,
text: tail,
replay: false,
});
}
} catch {