History

perf(web): stop replaying terminal buffers on rollover (#9707)

Keep bounded, byte-counted terminal chunks and append only unread output. Use UTF-16 cursors so compaction preserves live terminal replies. Reset on lifecycle changes or a real retained-data gap. Keep the existing wire protocol, native buffer interface, and client retention limit. Native streaming and strict server replay byte bounds remain separate. Continue the client helpers from https://github.com/pingdotgg/t3code/pull/9027 at source head 9391da2b48439d1d7a2b01d169e785682bf8abb8. Created with GPT-6 Astra (preview) in Codex.

Theo Browne committed 2 months ago

Commit da7e46d1 parent 78391408 files · +928 −67Browse files

Unified · Split

8 files
  1. apps/mobile/src/features/terminal/terminalMenu.test.ts +5 −2
  2. apps/mobile/src/state/use-terminal-session.ts +18 −4
  3. apps/web/src/components/ThreadTerminalDrawer.tsx +34 −18
  4. apps/web/src/terminal/ghostty/core.test.ts +234 −2
  5. packages/client-runtime/src/state/terminal.ts +5 −3
  6. packages/client-runtime/src/state/terminalOutput.ts +320 −0
  7. packages/client-runtime/src/state/terminalSession.test.ts +272 −3
  8. packages/client-runtime/src/state/terminalSession.ts +40 −35
apps/mobile/src/features/terminal/terminalMenu.test.ts+5−2
@@ -1,6 +1,9 @@
11 import { describe, expect, it } from "vite-plus/test";
22
3−import { type KnownTerminalSession } from "@t3tools/client-runtime/state/terminal";
3+import {
4+ EMPTY_TERMINAL_BUFFER_STATE,
5+ type KnownTerminalSession,
6+} from "@t3tools/client-runtime/state/terminal";
47 import { DEFAULT_TERMINAL_ID, EnvironmentId, ThreadId } from "@t3tools/contracts";
58
69 import { getTerminalLabel } from "@t3tools/shared/terminalLabels";
@@ -55,7 +58,7 @@ function makeKnownSession(input: { · 48 unmodified lines
5558 updatedAt: input.updatedAt ?? "2026-04-15T20:00:00.000Z",
5659 }
5760 : null,
58− buffer: "",
61+ output: EMPTY_TERMINAL_BUFFER_STATE.output,
5962 status: input.status,
6063 error: null,
6164 hasRunningSubprocess: false,
apps/mobile/src/state/use-terminal-session.ts+18−4
@@ -2,6 +2,7 @@ import { · 1 unmodified line
22 combineTerminalSessionState,
33 EMPTY_TERMINAL_BUFFER_STATE,
44 EMPTY_TERMINAL_SESSION_STATE,
5+ terminalOutputText,
56 type KnownTerminalSession,
67 type TerminalSessionState,
78 } from "@t3tools/client-runtime/state/terminal";
@@ -11,10 +12,16 @@ import { useMemo } from "react"; · 3 unmodified lines
1112 import { useEnvironmentQuery } from "./query";
1213 import { terminalEnvironment } from "./terminal";
1314
15+type LegacyTerminalSessionState = TerminalSessionState & { readonly buffer: string };
16+const EMPTY_LEGACY_TERMINAL_SESSION_STATE: LegacyTerminalSessionState = {
17+ ...EMPTY_TERMINAL_SESSION_STATE,
18+ buffer: "",
19+};
20+
1421 export function useAttachedTerminalSession(input: {
1522 readonly environmentId: EnvironmentId | null;
1623 readonly terminal: TerminalAttachInput | null;
17−}): TerminalSessionState {
24+}): LegacyTerminalSessionState {
1825 const attach = useEnvironmentQuery(
1926 input.environmentId !== null && input.terminal !== null
2027 ? terminalEnvironment.attach({
@@ -31,10 +38,14 @@ export function useAttachedTerminalSession(input: { · 10 unmodified lines
3138 input: null,
3239 }),
3340 );
41+ const output = attach.data?.output ?? EMPTY_TERMINAL_BUFFER_STATE.output;
42+ // Installed native binaries still accept initialBuffer. Keep materialization
43+ // at this mobile boundary until the native streaming API is released.
44+ const buffer = useMemo(() => terminalOutputText(output), [output]);
3445
3546 return useMemo(() => {
3647 if (input.environmentId === null || input.terminal === null) {
37− return EMPTY_TERMINAL_SESSION_STATE;
48+ return EMPTY_LEGACY_TERMINAL_SESSION_STATE;
3849 }
3950 const summary =
4051 metadata.data?.find(
@@ -42,9 +53,12 @@ export function useAttachedTerminalSession(input: { · 1 unmodified line
4253 terminal.threadId === input.terminal?.threadId &&
4354 terminal.terminalId === input.terminal?.terminalId,
4455 ) ?? null;
45− const state = combineTerminalSessionState(summary, attach.data ?? EMPTY_TERMINAL_BUFFER_STATE);
56+ const state = {
57+ ...combineTerminalSessionState(summary, attach.data ?? EMPTY_TERMINAL_BUFFER_STATE),
58+ buffer,
59+ };
4660 return attach.error === null ? state : { ...state, error: attach.error, status: "error" };
47− }, [attach.data, attach.error, input.environmentId, input.terminal, metadata.data]);
61+ }, [attach.data, attach.error, buffer, input.environmentId, input.terminal, metadata.data]);
4862 }
4963
5064 export function useKnownTerminalSessions(input: {
apps/web/src/components/ThreadTerminalDrawer.tsx+34−18
@@ -3,7 +3,13 @@ import { · 2 unmodified lines
33 isAtomCommandInterrupted,
44 squashAtomCommandFailure,
55 } from "@t3tools/client-runtime/state/runtime";
6−import { type TerminalSessionState } from "@t3tools/client-runtime/state/terminal";
6+import {
7+ INITIAL_TERMINAL_OUTPUT_CURSOR,
8+ readTerminalOutputUpdate,
9+ type TerminalOutputCursor,
10+ type TerminalOutputUpdate,
11+ type TerminalSessionState,
12+} from "@t3tools/client-runtime/state/terminal";
713 import {
814 Plus,
915 Square,
@@ -99,8 +105,15 @@ function writeSystemMessage(terminal: GhosttyTerminalSurface, message: string): · 89 unmodified lines
99105 terminal.write(`\r\n[terminal] ${message}\r\n`);
100106 }
101107
102−function writeTerminalBuffer(terminal: GhosttyTerminalSurface, buffer: string): void {
103− terminal.resetAndWrite(buffer);
108+export function writeTerminalOutputUpdate(
109+ terminal: Pick<GhosttyTerminalSurface, "resetAndWrite" | "write">,
110+ update: TerminalOutputUpdate,
111+): void {
112+ if (update.type === "reset") {
113+ terminal.resetAndWrite(update.data);
114+ } else if (update.type === "append") {
115+ terminal.write(update.data);
116+ }
104117 }
105118
106119 function parseTerminalColor(value: string, fallback: GhosttyColor): GhosttyColor {
@@ -380,9 +393,10 @@ export function TerminalViewport({ · 273 unmodified lines
380393 input: { threadId, terminalId, cols, rows },
381394 }),
382395 );
383− const terminalBuffer = terminalSession.buffer;
396+ const terminalOutput = terminalSession.output;
384397 const terminalError = terminalSession.error;
385398 const terminalStatus = terminalSession.status;
399+ const outputCursorRef = useRef<TerminalOutputCursor>(INITIAL_TERMINAL_OUTPUT_CURSOR);
386400 const synchronizedStatusRef = useRef<TerminalSessionState["status"]>("closed");
387401 const synchronizeTerminalStatus = useEffectEvent(
388402 (terminal: GhosttyTerminalSurface, status: TerminalSessionState["status"]) => {
@@ -403,14 +417,14 @@ export function TerminalViewport({ · 14 unmodified lines
403417 );
404418 const terminalVersion = terminalSession.version;
405419 const previousSessionRef = useRef({
406− buffer: terminalBuffer,
420+ output: terminalOutput,
407421 error: terminalError,
408422 status: terminalStatus,
409423 version: terminalVersion,
410424 });
411425 const latestSessionRef = useRef(previousSessionRef.current);
412426 latestSessionRef.current = {
413− buffer: terminalBuffer,
427+ output: terminalOutput,
414428 error: terminalError,
415429 status: terminalStatus,
416430 version: terminalVersion,
@@ -474,7 +488,14 @@ export function TerminalViewport({ · 57 unmodified lines
474488 }
475489 const latestSession = latestSessionRef.current;
476490 previousSessionRef.current = latestSession;
477− if (latestSession.buffer.length > 0) terminal.resetAndWrite(latestSession.buffer);
491+ const initialOutput = readTerminalOutputUpdate(
492+ latestSession.output,
493+ INITIAL_TERMINAL_OUTPUT_CURSOR,
494+ );
495+ if (initialOutput.type === "reset" && initialOutput.data.length > 0) {
496+ writeTerminalOutputUpdate(terminal, initialOutput);
497+ }
498+ outputCursorRef.current = initialOutput.cursor;
478499 if (latestSession.error !== null) writeSystemMessage(terminal, latestSession.error);
479500 // Attaching to a session that already exited must still run exit handling
480501 // once, so mount synchronization starts from the empty "closed" state.
@@ -845,7 +866,7 @@ export function TerminalViewport({ · 364 unmodified lines
845866 useEffect(() => {
846867 const terminal = terminalRef.current;
847868 const current = {
848− buffer: terminalBuffer,
869+ output: terminalOutput,
849870 error: terminalError,
850871 status: terminalStatus,
851872 version: terminalVersion,
@@ -857,18 +878,13 @@ export function TerminalViewport({ · 5 unmodified lines
857878
858879 const previous = previousSessionRef.current;
859880 synchronizeTerminalStatus(terminal, current.status);
860− if (current.version === previous.version) {
881+ if (current.version === previous.version && current.output === previous.output) {
861882 return;
862883 }
863884
864− if (
865− current.buffer.length >= previous.buffer.length &&
866− current.buffer.startsWith(previous.buffer)
867− ) {
868− terminal.write(current.buffer.slice(previous.buffer.length));
869− } else {
870− writeTerminalBuffer(terminal, current.buffer);
871− }
885+ const outputUpdate = readTerminalOutputUpdate(current.output, outputCursorRef.current);
886+ writeTerminalOutputUpdate(terminal, outputUpdate);
887+ outputCursorRef.current = outputUpdate.cursor;
872888 terminal.clearSelection();
873889
874890 if (current.error !== null && current.error !== previous.error) {
@@ -881,7 +897,7 @@ export function TerminalViewport({ · 6 unmodified lines
881897 });
882898 }
883899 previousSessionRef.current = current;
884− }, [autoFocus, terminalBuffer, terminalError, terminalStatus, terminalVersion]);
900+ }, [autoFocus, terminalOutput, terminalError, terminalStatus, terminalVersion]);
885901
886902 useEffect(() => {
887903 if (!autoFocus) return;
apps/web/src/terminal/ghostty/core.test.ts+234−2
@@ -1,5 +1,15 @@
11 import { afterEach, describe, expect, it, vi } from "vite-plus/test";
2+import {
3+ applyTerminalAttachStreamEvent,
4+ DEFAULT_MAX_TERMINAL_BUFFER_BYTES,
5+ INITIAL_TERMINAL_OUTPUT_CURSOR,
6+ nextTerminalAttachSeedState,
7+ readTerminalOutputUpdate,
8+ terminalOutputText,
9+ type TerminalBufferState,
10+} from "@t3tools/client-runtime/state/terminal";
211
12+import { writeTerminalOutputUpdate } from "../../components/ThreadTerminalDrawer";
313 import { GHOSTTY_CELL_WIDE, GhosttyTerminalCore, ghosttyCellText } from "./core";
414 import { loadGhosttyRuntime } from "./runtime";
515
@@ -50,7 +60,7 @@ describe("ghosttyCellText", () => { · 44 unmodified lines
5060 describe("GhosttyTerminalCore snapshots", () => {
5161 const cores = new Set<GhosttyTerminalCore>();
5262
53− async function createCore() {
63+ async function createCore(onData: (data: string) => void = () => {}) {
5464 const core = await GhosttyTerminalCore.create(
5565 12,
5666 3,
@@ -61,12 +71,40 @@ describe("GhosttyTerminalCore snapshots", () => { · 4 unmodified lines
6171 background: { r: 0, g: 0, b: 0 },
6272 cursor: { r: 255, g: 255, b: 255 },
6373 },
64− () => {},
74+ onData,
6575 );
6676 cores.add(core);
6777 return core;
6878 }
6979
80+ function createSession(history: string) {
81+ return applyTerminalAttachStreamEvent(nextTerminalAttachSeedState(), {
82+ type: "snapshot",
83+ snapshot: {
84+ threadId: "terminal-stream-test",
85+ terminalId: "term-1",
86+ cwd: "/repo",
87+ worktreePath: null,
88+ status: "running",
89+ pid: 123,
90+ history,
91+ exitCode: null,
92+ exitSignal: null,
93+ label: "Terminal",
94+ updatedAt: "2026-09-04T00:00:00.000Z",
95+ },
96+ });
97+ }
98+
99+ function append(state: TerminalBufferState, data: string) {
100+ return applyTerminalAttachStreamEvent(state, {
101+ type: "output",
102+ threadId: "terminal-stream-test",
103+ terminalId: "term-1",
104+ data,
105+ });
106+ }
107+
70108 afterEach(() => {
71109 for (const core of cores) core.dispose();
72110 cores.clear();
@@ -145,4 +183,198 @@ describe("GhosttyTerminalCore snapshots", () => { · 72 unmodified lines
145183 core.dispose();
146184 expect(free).toHaveBeenCalledWith(buffer, capacity);
147185 });
186+
187+ it.each(["varied", "identical"] as const)(
188+ "preserves Ghostty state through a full MiB of %s output without rollover resets",
189+ async (kind) => {
190+ const [core, reference] = await Promise.all([createCore(), createCore()]);
191+ const initial = "\x1b[31m";
192+ let state = createSession(initial);
193+ const first = readTerminalOutputUpdate(state.output, INITIAL_TERMINAL_OUTPUT_CURSOR);
194+ writeTerminalOutputUpdate(core, first);
195+ reference.resetAndWrite(initial);
196+ let cursor = first.cursor;
197+ const reset = vi.spyOn(core, "resetAndWrite");
198+ const inputs: string[] = [];
199+ let receivedCharacters = 0;
200+
201+ for (let index = 0; index < 128; index += 1) {
202+ const data =
203+ kind === "identical"
204+ ? "x".repeat(8192)
205+ : `${index.toString().padStart(4, "0")}\r\n${"x".repeat(8186)}`;
206+ inputs.push(data);
207+ state = append(state, data);
208+ const update = readTerminalOutputUpdate(state.output, cursor);
209+ if (update.type !== "append") throw new Error(`Expected append, received ${update.type}`);
210+ receivedCharacters += update.data.length;
211+ writeTerminalOutputUpdate(core, update);
212+ cursor = update.cursor;
213+ }
214+
215+ reference.write(inputs.join(""));
216+ expect(receivedCharacters).toBe(1024 * 1024);
217+ expect(state.output.retainedBytes).toBe(DEFAULT_MAX_TERMINAL_BUFFER_BYTES);
218+ expect(reset).not.toHaveBeenCalled();
219+ expect(core.snapshot()).toEqual(reference.snapshot());
220+ },
221+ );
222+
223+ it("preserves Unicode and split ANSI parser state across batched renderer reads", async () => {
224+ const [core, reference] = await Promise.all([createCore(), createCore()]);
225+ let state = createSession("");
226+ const first = readTerminalOutputUpdate(state.output, INITIAL_TERMINAL_OUTPUT_CURSOR);
227+ writeTerminalOutputUpdate(core, first);
228+ let cursor = first.cursor;
229+ const reset = vi.spyOn(core, "resetAndWrite");
230+ const inputs = [
231+ `${"a".repeat(16_383)}🙂`,
232+ "\x1b[3",
233+ "1m",
234+ "e",
235+ "\u0301界🙂",
236+ "\x1b[0",
237+ "m\r\n",
238+ "\x1b]8;;https://t3.codes\x1b",
239+ "\\link",
240+ "\x1b]8;;\x1b",
241+ "\\\x1b[?1049h",
242+ "alternate",
243+ "\x1b[?1049l",
244+ "\r\nend",
245+ ];
246+ let received = "";
247+ for (const [index, data] of inputs.entries()) {
248+ state = append(state, data);
249+ if (index % 3 !== 0 && index !== inputs.length - 1) continue;
250+ const update = readTerminalOutputUpdate(state.output, cursor);
251+ if (update.type !== "append") throw new Error(`Expected append, received ${update.type}`);
252+ received += update.data;
253+ writeTerminalOutputUpdate(core, update);
254+ cursor = update.cursor;
255+ }
256+
257+ reference.write(inputs.join(""));
258+ expect(received).toBe(inputs.join(""));
259+ expect(reset).not.toHaveBeenCalled();
260+ expect(core.snapshot()).toEqual(reference.snapshot());
261+ });
262+
263+ it("answers a live VT query when batched reads cross a chunk compaction", async () => {
264+ const replies: string[] = [];
265+ const core = await createCore((data) => replies.push(data));
266+ core.write("\x1b[5n");
267+ expect(replies).toEqual(["\x1b[0n"]);
268+ replies.length = 0;
269+
270+ let state = createSession("");
271+ const initial = readTerminalOutputUpdate(state.output, INITIAL_TERMINAL_OUTPUT_CURSOR);
272+ writeTerminalOutputUpdate(core, initial);
273+ let cursor = initial.cursor;
274+ for (let index = 0; index < 1000; index += 1) {
275+ state = append(state, "x");
276+ const update = readTerminalOutputUpdate(state.output, cursor);
277+ writeTerminalOutputUpdate(core, update);
278+ cursor = update.cursor;
279+ }
280+ for (let index = 0; index < 24; index += 1) state = append(state, "x");
281+ state = append(state, "\x1b[5n");
282+ const update = readTerminalOutputUpdate(state.output, cursor);
283+ writeTerminalOutputUpdate(core, update);
284+
285+ expect({ type: update.type, replies }).toEqual({ type: "append", replies: ["\x1b[0n"] });
286+ });
287+
288+ it("recovers a lagging renderer once from bounded output and resumes appending", async () => {
289+ const [core, reference] = await Promise.all([createCore(), createCore()]);
290+ let state = createSession("\x1b[31mold");
291+ const initial = readTerminalOutputUpdate(state.output, INITIAL_TERMINAL_OUTPUT_CURSOR);
292+ writeTerminalOutputUpdate(core, initial);
293+ const reset = vi.spyOn(core, "resetAndWrite");
294+ const data = "line\r\n".repeat(8192);
295+ for (let index = 0; index < 16; index += 1) state = append(state, data);
296+
297+ const recovery = readTerminalOutputUpdate(state.output, initial.cursor);
298+ if (recovery.type !== "reset") throw new Error(`Expected reset, received ${recovery.type}`);
299+ expect(new TextEncoder().encode(recovery.data).byteLength).toBe(
300+ DEFAULT_MAX_TERMINAL_BUFFER_BYTES,
301+ );
302+ writeTerminalOutputUpdate(core, recovery);
303+ reference.resetAndWrite(recovery.data);
304+ expect(core.snapshot()).toEqual(reference.snapshot());
305+
306+ state = append(state, "\r\nlatest");
307+ const next = readTerminalOutputUpdate(state.output, recovery.cursor);
308+ expect(next.type).toBe("append");
309+ writeTerminalOutputUpdate(core, next);
310+ reference.write("\r\nlatest");
311+ expect(reset).toHaveBeenCalledTimes(1);
312+ expect(core.snapshot()).toEqual(reference.snapshot());
313+ });
314+
315+ it("replays the latest retained output when WASM arrives after several events", async () => {
316+ const pendingCore = createCore();
317+ let state = createSession("before");
318+ state = append(state, "\r\nduring ");
319+ state = append(state, "🙂 load");
320+ const core = await pendingCore;
321+ const first = readTerminalOutputUpdate(state.output, INITIAL_TERMINAL_OUTPUT_CURSOR);
322+ writeTerminalOutputUpdate(core, first);
323+ const reference = await createCore();
324+ reference.resetAndWrite(terminalOutputText(state.output));
325+ const reset = vi.spyOn(core, "resetAndWrite");
326+
327+ state = append(state, "\r\nafter");
328+ const next = readTerminalOutputUpdate(state.output, first.cursor);
329+ expect(next).toMatchObject({ type: "append", data: "\r\nafter" });
330+ writeTerminalOutputUpdate(core, next);
331+ reference.write("\r\nafter");
332+ expect(reset).not.toHaveBeenCalled();
333+ expect(core.snapshot()).toEqual(reference.snapshot());
334+ });
335+
336+ it("resets real Ghostty for a repeated snapshot, clear, restart, and a fresh attach", async () => {
337+ const [core, reference] = await Promise.all([createCore(), createCore()]);
338+ let state = createSession("hello");
339+ const initial = readTerminalOutputUpdate(state.output, INITIAL_TERMINAL_OUTPUT_CURSOR);
340+ writeTerminalOutputUpdate(core, initial);
341+ let cursor = initial.cursor;
342+
343+ const snapshot = {
344+ threadId: "terminal-stream-test",
345+ terminalId: "term-1",
346+ cwd: "/repo",
347+ worktreePath: null,
348+ status: "running" as const,
349+ pid: 456,
350+ history: "hello",
351+ exitCode: null,
352+ exitSignal: null,
353+ label: "Terminal",
354+ updatedAt: "2026-09-04T00:00:01.000Z",
355+ };
356+ const events = [
357+ { type: "snapshot", snapshot },
358+ { type: "cleared", threadId: snapshot.threadId, terminalId: snapshot.terminalId },
359+ { type: "restarted", threadId: snapshot.threadId, terminalId: snapshot.terminalId, snapshot },
360+ ] as const;
361+ for (const event of events) {
362+ core.write("\r\nstale local text");
363+ state = applyTerminalAttachStreamEvent(state, event);
364+ const update = readTerminalOutputUpdate(state.output, cursor);
365+ expect(update.type).toBe("reset");
366+ writeTerminalOutputUpdate(core, update);
367+ cursor = update.cursor;
368+ reference.resetAndWrite(event.type === "cleared" ? "" : "hello");
369+ expect(core.snapshot()).toEqual(reference.snapshot());
370+ }
371+
372+ core.write("\r\nstale local text");
373+ state = createSession("hello");
374+ const reattached = readTerminalOutputUpdate(state.output, cursor);
375+ expect(reattached.type).toBe("reset");
376+ writeTerminalOutputUpdate(core, reattached);
377+ reference.resetAndWrite("hello");
378+ expect(core.snapshot()).toEqual(reference.snapshot());
379+ });
148380 });
packages/client-runtime/src/state/terminal.ts+5−3
@@ -13,7 +13,7 @@ import { subscribe, type EnvironmentRpcInput } from "../rpc/client.ts"; · 12 unmodified lines
1313 import {
1414 applyTerminalAttachStreamEvent,
1515 applyTerminalMetadataStreamEvent,
16− EMPTY_TERMINAL_BUFFER_STATE,
16+ nextTerminalAttachSeedState,
1717 } from "./terminalSession.ts";
1818
1919 export function createTerminalEnvironmentAtoms<R, E>(
@@ -40,8 +40,10 @@ export function createTerminalEnvironmentAtoms<R, E>( · 20 unmodified lines
4040 attach: createEnvironmentSubscriptionAtomFamily(runtime, {
4141 label: "environment-data:terminal:attach",
4242 subscribe: (input: EnvironmentRpcInput<typeof WS_METHODS.terminalAttach>) =>
43− subscribe(WS_METHODS.terminalAttach, input).pipe(
44− Stream.scan(EMPTY_TERMINAL_BUFFER_STATE, applyTerminalAttachStreamEvent),
43+ Stream.suspend(() =>
44+ subscribe(WS_METHODS.terminalAttach, input).pipe(
45+ Stream.scan(nextTerminalAttachSeedState(), applyTerminalAttachStreamEvent),
46+ ),
4547 ),
4648 }),
4749 events: createEnvironmentRpcSubscriptionAtomFamily(runtime, {
packages/client-runtime/src/state/terminalOutput.ts+320−0
@@ -0,0 +1,320 @@
1+export interface TerminalOutputChunk {
2+ /** UTF-16 string offset within this generation and reset. */
3+ readonly startOffset: number;
4+ readonly data: string;
5+ readonly byteLength: number;
6+}
7+
8+export interface TerminalOutputState {
9+ readonly generation: number;
10+ readonly chunks: ReadonlyArray<TerminalOutputChunk>;
11+ readonly retainedBytes: number;
12+ readonly resetVersion: number;
13+ readonly nextOffset: number;
14+}
15+
16+export interface TerminalOutputCursor {
17+ readonly generation: number;
18+ readonly resetVersion: number;
19+ readonly offset: number;
20+}
21+
22+/** Forces the first `readTerminalOutputUpdate` to resynchronize from a reset. */
23+export const INITIAL_TERMINAL_OUTPUT_CURSOR = Object.freeze<TerminalOutputCursor>({
24+ generation: -1,
25+ resetVersion: -1,
26+ offset: 0,
27+});
28+
29+export type TerminalOutputUpdate =
30+ | {
31+ readonly type: "none";
32+ readonly cursor: TerminalOutputCursor;
33+ }
34+ | {
35+ readonly type: "reset";
36+ readonly data: string;
37+ readonly cursor: TerminalOutputCursor;
38+ }
39+ | {
40+ readonly type: "append";
41+ readonly cursor: TerminalOutputCursor;
42+ readonly data: string;
43+ };
44+
45+export const DEFAULT_MAX_TERMINAL_BUFFER_BYTES = 512 * 1024;
46+const DEFAULT_TERMINAL_CHUNK_BYTES = 16 * 1024;
47+const MAX_TERMINAL_OUTPUT_CHUNKS = 1_024;
48+const textEncoder = new TextEncoder();
49+// A BOM at a retained chunk boundary is terminal data, not an encoding marker.
50+const textDecoder = new TextDecoder("utf-8", { ignoreBOM: true });
51+
52+export const EMPTY_TERMINAL_OUTPUT_STATE = Object.freeze<TerminalOutputState>({
53+ generation: 0,
54+ chunks: Object.freeze([]),
55+ retainedBytes: 0,
56+ resetVersion: 0,
57+ nextOffset: 0,
58+});
59+
60+interface Utf8Chunk {
61+ readonly data: string;
62+ readonly byteLength: number;
63+}
64+
65+/**
66+ * Split a string into chunks of at most `maxBytes` UTF-8 bytes without cutting
67+ * a code point in half. The retained-output budget always supplies a positive
68+ * size. Only new output is encoded on live updates.
69+ *
70+ * A chunk that fits whole is returned as the original string, so the common
71+ * small-write path pays one encode and no decode.
72+ */
73+function splitStringByUtf8Bytes(data: string, maxBytes: number): ReadonlyArray<Utf8Chunk> {
74+ if (data.length === 0) return [];
75+
76+ const encoded = textEncoder.encode(data);
77+ if (encoded.byteLength <= maxBytes) {
78+ return [{ data, byteLength: encoded.byteLength }];
79+ }
80+
81+ const chunks: Utf8Chunk[] = [];
82+ let offset = 0;
83+ while (offset < encoded.byteLength) {
84+ let end = Math.min(offset + maxBytes, encoded.byteLength);
85+ while (end < encoded.byteLength && ((encoded[end] ?? 0) & 0xc0) === 0x80) {
86+ end -= 1;
87+ }
88+ // A degenerate budget smaller than one code point still has to advance:
89+ // include the whole code point rather than looping forever.
90+ if (end === offset) {
91+ end = Math.min(offset + maxBytes, encoded.byteLength);
92+ while (end < encoded.byteLength && ((encoded[end] ?? 0) & 0xc0) === 0x80) {
93+ end += 1;
94+ }
95+ }
96+ const bytes = encoded.subarray(offset, end);
97+ chunks.push({ data: textDecoder.decode(bytes), byteLength: bytes.byteLength });
98+ offset = end;
99+ }
100+
101+ return chunks;
102+}
103+
104+function trimBufferToBytes(buffer: string, maxBufferBytes: number): string {
105+ if (maxBufferBytes <= 0) {
106+ return "";
107+ }
108+
109+ const encoded = textEncoder.encode(buffer);
110+ if (encoded.byteLength <= maxBufferBytes) {
111+ return buffer;
112+ }
113+
114+ let start = encoded.byteLength - maxBufferBytes;
115+ while (start < encoded.length) {
116+ const byte = encoded[start];
117+ if (byte === undefined || (byte & 0b1100_0000) !== 0b1000_0000) {
118+ break;
119+ }
120+ start += 1;
121+ }
122+
123+ return textDecoder.decode(encoded.subarray(start));
124+}
125+
126+function splitOutputChunks(
127+ data: string,
128+ firstOffset: number,
129+ maxChunkBytes = DEFAULT_TERMINAL_CHUNK_BYTES,
130+): {
131+ readonly chunks: ReadonlyArray<TerminalOutputChunk>;
132+ readonly nextOffset: number;
133+ readonly byteLength: number;
134+} {
135+ const split = splitStringByUtf8Bytes(data, maxChunkBytes);
136+ let byteLength = 0;
137+ let nextOffset = firstOffset;
138+ const chunks = split.map((chunk) => {
139+ byteLength += chunk.byteLength;
140+ const startOffset = nextOffset;
141+ nextOffset += chunk.data.length;
142+ return {
143+ startOffset,
144+ data: chunk.data,
145+ byteLength: chunk.byteLength,
146+ };
147+ });
148+
149+ return {
150+ chunks,
151+ nextOffset,
152+ byteLength,
153+ };
154+}
155+
156+/**
157+ * Merge adjacent chunks without changing their string positions. A reader can
158+ * still append the unread suffix when its cursor falls inside a merged chunk.
159+ */
160+function compactRetainedChunks(chunks: ReadonlyArray<TerminalOutputChunk>) {
161+ const compacted: TerminalOutputChunk[] = [];
162+ for (const chunk of chunks) {
163+ const previous = compacted.at(-1);
164+ if (
165+ previous !== undefined &&
166+ previous.startOffset + previous.data.length === chunk.startOffset &&
167+ previous.byteLength + chunk.byteLength <= DEFAULT_TERMINAL_CHUNK_BYTES
168+ ) {
169+ compacted[compacted.length - 1] = {
170+ startOffset: previous.startOffset,
171+ data: `${previous.data}${chunk.data}`,
172+ byteLength: previous.byteLength + chunk.byteLength,
173+ };
174+ } else {
175+ compacted.push(chunk);
176+ }
177+ }
178+ return compacted;
179+}
180+
181+// Scan only the removed prefix instead of encoding retained output again.
182+function trimOutputChunkStart(
183+ chunk: TerminalOutputChunk,
184+ bytesToDrop: number,
185+): TerminalOutputChunk {
186+ let offset = 0;
187+ let droppedBytes = 0;
188+ while (droppedBytes < bytesToDrop && offset < chunk.data.length) {
189+ const codepoint = chunk.data.codePointAt(offset)!;
190+ droppedBytes += codepoint <= 0x7f ? 1 : codepoint <= 0x7ff ? 2 : codepoint <= 0xffff ? 3 : 4;
191+ offset += codepoint <= 0xffff ? 1 : 2;
192+ }
193+ return {
194+ ...chunk,
195+ startOffset: chunk.startOffset + offset,
196+ data: chunk.data.slice(offset),
197+ byteLength: chunk.byteLength - droppedBytes,
198+ };
199+}
200+
201+function appendOutput(
202+ current: TerminalOutputState,
203+ data: string,
204+ maxBufferBytes: number,
205+): TerminalOutputState {
206+ if (data.length === 0) return current;
207+ if (maxBufferBytes <= 0) {
208+ return {
209+ generation: current.generation,
210+ chunks: [],
211+ retainedBytes: 0,
212+ resetVersion: current.resetVersion + 1,
213+ nextOffset: current.nextOffset + data.length,
214+ };
215+ }
216+ const appended = splitOutputChunks(
217+ data,
218+ current.nextOffset,
219+ Math.min(DEFAULT_TERMINAL_CHUNK_BYTES, Math.max(1, maxBufferBytes)),
220+ );
221+
222+ const chunks = [...current.chunks, ...appended.chunks];
223+ let retainedBytes = current.retainedBytes + appended.byteLength;
224+ let firstRetainedIndex = 0;
225+ while (retainedBytes > maxBufferBytes && firstRetainedIndex < chunks.length) {
226+ const first = chunks[firstRetainedIndex]!;
227+ const bytesToDrop = retainedBytes - maxBufferBytes;
228+ if (bytesToDrop < first.byteLength) {
229+ const trimmed = trimOutputChunkStart(first, bytesToDrop);
230+ retainedBytes -= first.byteLength - trimmed.byteLength;
231+ if (trimmed.byteLength > 0) {
232+ chunks[firstRetainedIndex] = trimmed;
233+ } else {
234+ firstRetainedIndex += 1;
235+ }
236+ break;
237+ }
238+ retainedBytes -= first.byteLength;
239+ firstRetainedIndex += 1;
240+ }
241+
242+ let retainedChunks = firstRetainedIndex === 0 ? chunks : chunks.slice(firstRetainedIndex);
243+ if (retainedChunks.length > MAX_TERMINAL_OUTPUT_CHUNKS) {
244+ retainedChunks = compactRetainedChunks(retainedChunks);
245+ const excessChunks = retainedChunks.length - MAX_TERMINAL_OUTPUT_CHUNKS;
246+ if (excessChunks > 0) {
247+ for (const chunk of retainedChunks.slice(0, excessChunks)) {
248+ retainedBytes -= chunk.byteLength;
249+ }
250+ retainedChunks = retainedChunks.slice(excessChunks);
251+ }
252+ }
253+
254+ return {
255+ generation: current.generation,
256+ chunks: retainedChunks,
257+ retainedBytes,
258+ resetVersion: current.resetVersion,
259+ nextOffset: appended.nextOffset,
260+ };
261+}
262+
263+function resetOutput(
264+ current: TerminalOutputState,
265+ data: string,
266+ maxBufferBytes: number,
267+): TerminalOutputState {
268+ const retained = trimBufferToBytes(data, maxBufferBytes);
269+ const reset = splitOutputChunks(
270+ retained,
271+ 0,
272+ Math.min(DEFAULT_TERMINAL_CHUNK_BYTES, Math.max(1, maxBufferBytes)),
273+ );
274+ return {
275+ generation: current.generation,
276+ chunks: reset.chunks,
277+ retainedBytes: reset.byteLength,
278+ resetVersion: current.resetVersion + 1,
279+ nextOffset: reset.nextOffset,
280+ };
281+}
282+
283+export function terminalOutputText(output: TerminalOutputState): string {
284+ return output.chunks.map((chunk) => chunk.data).join("");
285+}
286+
287+export function readTerminalOutputUpdate(
288+ output: TerminalOutputState,
289+ cursor: TerminalOutputCursor,
290+): TerminalOutputUpdate {
291+ const nextCursor = {
292+ generation: output.generation,
293+ resetVersion: output.resetVersion,
294+ offset: output.nextOffset,
295+ };
296+ const firstChunk = output.chunks[0];
297+ if (
298+ cursor.generation !== output.generation ||
299+ cursor.resetVersion !== output.resetVersion ||
300+ cursor.offset < (firstChunk?.startOffset ?? output.nextOffset)
301+ ) {
302+ return { type: "reset", data: terminalOutputText(output), cursor: nextCursor };
303+ }
304+
305+ const appended = output.chunks.filter(
306+ (chunk) => chunk.startOffset + chunk.data.length > cursor.offset,
307+ );
308+ if (appended.length === 0) {
309+ return { type: "none", cursor: nextCursor };
310+ }
311+ return {
312+ type: "append",
313+ data: appended
314+ .map((chunk) => chunk.data.slice(Math.max(0, cursor.offset - chunk.startOffset)))
315+ .join(""),
316+ cursor: nextCursor,
317+ };
318+}
319+
320+export { appendOutput, resetOutput };
packages/client-runtime/src/state/terminalSession.test.ts+272−3
@@ -1,4 +1,4 @@
1−import { describe, expect, it } from "vite-plus/test";
1+import { describe, expect, it, vi } from "vite-plus/test";
22
33 import { EnvironmentId, TerminalSessionSnapshot, ThreadId } from "@t3tools/contracts";
44
@@ -6,8 +6,13 @@ import { · 1 unmodified line
66 applyTerminalAttachStreamEvent,
77 applyTerminalMetadataStreamEvent,
88 combineTerminalSessionState,
9+ DEFAULT_MAX_TERMINAL_BUFFER_BYTES,
910 EMPTY_TERMINAL_BUFFER_STATE,
11+ INITIAL_TERMINAL_OUTPUT_CURSOR,
12+ nextTerminalAttachSeedState,
13+ readTerminalOutputUpdate,
1014 selectRunningSubprocessTerminalIds,
15+ terminalOutputText,
1116 } from "./terminalSession.ts";
1217
1318 const TARGET = {
@@ -126,11 +131,11 @@ describe("terminal session reducers", () => { · 112 unmodified lines
126131 );
127132
128133 expect(output).toMatchObject({
129− buffer: "lo world",
130134 status: "running",
131135 error: null,
132136 version: 2,
133137 });
138+ expect(terminalOutputText(output.output)).toBe("lo world");
134139 });
135140
136141 it("does not advance the lifecycle for the initial attach snapshot", () => {
@@ -220,6 +225,270 @@ describe("terminal session reducers", () => { · 83 unmodified lines
220225 4,
221226 );
222227
223− expect(state.buffer).toBe("🙂");
228+ expect(terminalOutputText(state.output)).toBe("🙂");
229+ });
230+
231+ it("preserves a BOM code point at a new output chunk boundary", () => {
232+ const initial = applyTerminalAttachStreamEvent(EMPTY_TERMINAL_BUFFER_STATE, {
233+ type: "snapshot",
234+ snapshot: { ...BASE_SNAPSHOT, history: "" },
235+ });
236+ const cursor = readTerminalOutputUpdate(initial.output, INITIAL_TERMINAL_OUTPUT_CURSOR).cursor;
237+ const data = `${"x".repeat(16_384)}\uFEFF${"y".repeat(20)}`;
238+ const state = applyTerminalAttachStreamEvent(initial, {
239+ type: "output",
240+ threadId: TARGET.threadId,
241+ terminalId: TARGET.terminalId,
242+ data,
243+ });
244+
245+ expect(terminalOutputText(state.output)).toBe(data);
246+ expect(readTerminalOutputUpdate(state.output, cursor)).toMatchObject({ type: "append", data });
247+ expect(state.output.retainedBytes).toBe(new TextEncoder().encode(data).byteLength);
248+ });
249+
250+ it("preserves a leading BOM in the retained snapshot tail", () => {
251+ const state = applyTerminalAttachStreamEvent(
252+ EMPTY_TERMINAL_BUFFER_STATE,
253+ { type: "snapshot", snapshot: { ...BASE_SNAPSHOT, history: "discard\uFEFFtail" } },
254+ 7,
255+ );
256+
257+ expect(terminalOutputText(state.output)).toBe("\uFEFFtail");
258+ expect(state.output.retainedBytes).toBe(7);
259+ });
260+
261+ it("trims whole Unicode code points from a partially retained chunk", () => {
262+ let state = applyTerminalAttachStreamEvent(
263+ EMPTY_TERMINAL_BUFFER_STATE,
264+ { type: "snapshot", snapshot: { ...BASE_SNAPSHOT, history: "é界🙂end" } },
265+ 12,
266+ );
267+ let cursor = readTerminalOutputUpdate(state.output, INITIAL_TERMINAL_OUTPUT_CURSOR).cursor;
268+ for (const [data, expected, retainedBytes] of [
269+ ["x", "界🙂endx", 11],
270+ ["yz", "🙂endxyz", 10],
271+ ["abc", "endxyzabc", 9],
272+ ] as const) {
273+ state = applyTerminalAttachStreamEvent(
274+ state,
275+ { type: "output", threadId: TARGET.threadId, terminalId: TARGET.terminalId, data },
276+ 12,
277+ );
278+ const update = readTerminalOutputUpdate(state.output, cursor);
279+ expect(update).toMatchObject({ type: "append", data });
280+ expect(terminalOutputText(state.output)).toBe(expected);
281+ expect(state.output.retainedBytes).toBe(retainedBytes);
282+ cursor = update.cursor;
283+ }
284+ });
285+
286+ it("delivers all output when several events arrive before a renderer reads", () => {
287+ const initial = applyTerminalAttachStreamEvent(EMPTY_TERMINAL_BUFFER_STATE, {
288+ type: "snapshot",
289+ snapshot: BASE_SNAPSHOT,
290+ });
291+ const cursor = readTerminalOutputUpdate(initial.output, INITIAL_TERMINAL_OUTPUT_CURSOR).cursor;
292+ let state = initial;
293+ for (const data of [" one", " two", " three"]) {
294+ state = applyTerminalAttachStreamEvent(state, {
295+ type: "output",
296+ threadId: TARGET.threadId,
297+ terminalId: TARGET.terminalId,
298+ data,
299+ });
300+ }
301+ const update = readTerminalOutputUpdate(state.output, cursor);
302+ expect(update).toMatchObject({ type: "append", data: " one two three" });
303+ expect(readTerminalOutputUpdate(state.output, update.cursor).type).toBe("none");
304+ });
305+
306+ it("preserves the byte-limited tail and resets a cursor before a partially trimmed chunk", () => {
307+ const initial = applyTerminalAttachStreamEvent(EMPTY_TERMINAL_BUFFER_STATE, {
308+ type: "snapshot",
309+ snapshot: { ...BASE_SNAPSHOT, history: "" },
310+ });
311+ const staleCursor = readTerminalOutputUpdate(
312+ initial.output,
313+ INITIAL_TERMINAL_OUTPUT_CURSOR,
314+ ).cursor;
315+ const first = applyTerminalAttachStreamEvent(initial, {
316+ type: "output",
317+ threadId: TARGET.threadId,
318+ terminalId: TARGET.terminalId,
319+ data: "hello",
320+ });
321+ const caughtUpCursor = readTerminalOutputUpdate(first.output, staleCursor).cursor;
322+ const state = applyTerminalAttachStreamEvent(
323+ first,
324+ { type: "output", threadId: TARGET.threadId, terminalId: TARGET.terminalId, data: " world" },
325+ 8,
326+ );
327+
328+ expect(readTerminalOutputUpdate(state.output, caughtUpCursor)).toMatchObject({
329+ type: "append",
330+ data: " world",
331+ });
332+ expect(readTerminalOutputUpdate(state.output, staleCursor)).toMatchObject({
333+ type: "reset",
334+ data: "lo world",
335+ });
336+ expect(state.output.retainedBytes).toBe(8);
337+ });
338+
339+ it("does not encode retained history again when appending at the byte limit", () => {
340+ let state = applyTerminalAttachStreamEvent(EMPTY_TERMINAL_BUFFER_STATE, {
341+ type: "snapshot",
342+ snapshot: { ...BASE_SNAPSHOT, history: "x".repeat(DEFAULT_MAX_TERMINAL_BUFFER_BYTES) },
343+ });
344+ const data = "y".repeat(8192);
345+ const encode = vi.spyOn(TextEncoder.prototype, "encode");
346+ try {
347+ for (let index = 0; index < 100; index += 1) {
348+ state = applyTerminalAttachStreamEvent(state, {
349+ type: "output",
350+ threadId: TARGET.threadId,
351+ terminalId: TARGET.terminalId,
352+ data,
353+ });
354+ }
355+ expect(encode.mock.calls.reduce((total, [text]) => total + (text?.length ?? 0), 0)).toBe(
356+ data.length * 100,
357+ );
358+ expect(state.output.retainedBytes).toBe(DEFAULT_MAX_TERMINAL_BUFFER_BYTES);
359+ } finally {
360+ encode.mockRestore();
361+ }
362+ });
363+
364+ it.each([0, -1])("discards output without encoding when the byte budget is %s", (maxBytes) => {
365+ const initial = applyTerminalAttachStreamEvent(EMPTY_TERMINAL_BUFFER_STATE, {
366+ type: "snapshot",
367+ snapshot: BASE_SNAPSHOT,
368+ });
369+ const cursor = readTerminalOutputUpdate(initial.output, INITIAL_TERMINAL_OUTPUT_CURSOR).cursor;
370+ const data = "x".repeat(65_536);
371+ const encode = vi.spyOn(TextEncoder.prototype, "encode");
372+ try {
373+ const discarded = applyTerminalAttachStreamEvent(
374+ initial,
375+ { type: "output", threadId: TARGET.threadId, terminalId: TARGET.terminalId, data },
376+ maxBytes,
377+ );
378+ expect(encode).not.toHaveBeenCalled();
379+ expect(discarded.output.nextOffset).toBe(initial.output.nextOffset + data.length);
380+ expect(discarded.output.retainedBytes).toBe(0);
381+ expect(readTerminalOutputUpdate(discarded.output, cursor)).toMatchObject({
382+ type: "reset",
383+ data: "",
384+ });
385+
386+ const empty = applyTerminalAttachStreamEvent(
387+ initial,
388+ { type: "output", threadId: TARGET.threadId, terminalId: TARGET.terminalId, data: "" },
389+ maxBytes,
390+ );
391+ expect(empty.output).toBe(initial.output);
392+ expect(encode).not.toHaveBeenCalled();
393+ } finally {
394+ encode.mockRestore();
395+ }
396+ });
397+
398+ it("resets for repeated snapshots, clear, and restart even when output text repeats", () => {
399+ let state = applyTerminalAttachStreamEvent(EMPTY_TERMINAL_BUFFER_STATE, {
400+ type: "snapshot",
401+ snapshot: BASE_SNAPSHOT,
402+ });
403+ let cursor = readTerminalOutputUpdate(state.output, INITIAL_TERMINAL_OUTPUT_CURSOR).cursor;
404+ state = applyTerminalAttachStreamEvent(state, { type: "snapshot", snapshot: BASE_SNAPSHOT });
405+ const repeated = readTerminalOutputUpdate(state.output, cursor);
406+ expect(repeated).toMatchObject({ type: "reset", data: "hello" });
407+ expect(state.version).toBe(2);
408+ cursor = repeated.cursor;
409+
410+ state = applyTerminalAttachStreamEvent(state, {
411+ type: "cleared",
412+ threadId: TARGET.threadId,
413+ terminalId: TARGET.terminalId,
414+ });
415+ const cleared = readTerminalOutputUpdate(state.output, cursor);
416+ expect(cleared).toMatchObject({ type: "reset", data: "" });
417+ state = applyTerminalAttachStreamEvent(state, {
418+ type: "restarted",
419+ threadId: TARGET.threadId,
420+ terminalId: TARGET.terminalId,
421+ snapshot: BASE_SNAPSHOT,
422+ });
423+ expect(readTerminalOutputUpdate(state.output, cleared.cursor)).toMatchObject({
424+ type: "reset",
425+ data: "hello",
426+ });
427+ expect(state.lifecycleVersion).toBe(2);
428+ });
429+
430+ it("does not reuse a renderer cursor when a fresh attach has matching counters", () => {
431+ const first = applyTerminalAttachStreamEvent(nextTerminalAttachSeedState(), {
432+ type: "snapshot",
433+ snapshot: BASE_SNAPSHOT,
434+ });
435+ const cursor = readTerminalOutputUpdate(first.output, INITIAL_TERMINAL_OUTPUT_CURSOR).cursor;
436+ const next = applyTerminalAttachStreamEvent(nextTerminalAttachSeedState(), {
437+ type: "snapshot",
438+ snapshot: { ...BASE_SNAPSHOT, history: "other" },
439+ });
440+ expect(next.output.resetVersion).toBe(first.output.resetVersion);
441+ expect(next.output.nextOffset).toBe(first.output.nextOffset);
442+ expect(readTerminalOutputUpdate(next.output, cursor)).toMatchObject({
443+ type: "reset",
444+ data: "other",
445+ });
446+ expect(next.lifecycleVersion).toBe(0);
447+ });
448+
449+ it("keeps appending while compacting metadata for many small writes", () => {
450+ let state = applyTerminalAttachStreamEvent(EMPTY_TERMINAL_BUFFER_STATE, {
451+ type: "snapshot",
452+ snapshot: { ...BASE_SNAPSHOT, history: "" },
453+ });
454+ let cursor = readTerminalOutputUpdate(state.output, INITIAL_TERMINAL_OUTPUT_CURSOR).cursor;
455+ let received = "";
456+ for (let index = 0; index < 2500; index += 1) {
457+ state = applyTerminalAttachStreamEvent(state, {
458+ type: "output",
459+ threadId: TARGET.threadId,
460+ terminalId: TARGET.terminalId,
461+ data: "x",
462+ });
463+ const update = readTerminalOutputUpdate(state.output, cursor);
464+ if (update.type !== "append") throw new Error(`Expected append, received ${update.type}`);
465+ received += update.data;
466+ cursor = update.cursor;
467+ }
468+ expect(received).toBe("x".repeat(2500));
469+ expect(state.output.chunks.length).toBeLessThan(1024);
470+ expect(terminalOutputText(state.output)).toBe(received);
471+ });
472+
473+ it("appends every unread character across a compaction boundary", () => {
474+ let state = applyTerminalAttachStreamEvent(EMPTY_TERMINAL_BUFFER_STATE, {
475+ type: "snapshot",
476+ snapshot: { ...BASE_SNAPSHOT, history: "" },
477+ });
478+ let cursor = readTerminalOutputUpdate(state.output, INITIAL_TERMINAL_OUTPUT_CURSOR).cursor;
479+ const writes = Array.from({ length: 1200 }, (_, index) => ["x", "é", "界", "🙂"][index % 4]!);
480+ for (const [index, data] of writes.entries()) {
481+ state = applyTerminalAttachStreamEvent(state, {
482+ type: "output",
483+ threadId: TARGET.threadId,
484+ terminalId: TARGET.terminalId,
485+ data,
486+ });
487+ if (index === 99) cursor = readTerminalOutputUpdate(state.output, cursor).cursor;
488+ }
489+ expect(readTerminalOutputUpdate(state.output, cursor)).toMatchObject({
490+ type: "append",
491+ data: writes.slice(100).join(""),
492+ });
224493 });
225494 });
packages/client-runtime/src/state/terminalSession.ts+40−35
@@ -6,10 +6,27 @@ import type { · 5 unmodified lines
66 TerminalSummary,
77 ThreadId,
88 } from "@t3tools/contracts";
9+import {
10+ appendOutput,
11+ DEFAULT_MAX_TERMINAL_BUFFER_BYTES,
12+ EMPTY_TERMINAL_OUTPUT_STATE,
13+ resetOutput,
14+ type TerminalOutputState,
15+} from "./terminalOutput.ts";
16+
17+export {
18+ DEFAULT_MAX_TERMINAL_BUFFER_BYTES,
19+ INITIAL_TERMINAL_OUTPUT_CURSOR,
20+ readTerminalOutputUpdate,
21+ terminalOutputText,
22+ type TerminalOutputCursor,
23+ type TerminalOutputState,
24+ type TerminalOutputUpdate,
25+} from "./terminalOutput.ts";
926
1027 export interface TerminalSessionState {
1128 readonly summary: TerminalSummary | null;
12− readonly buffer: string;
29+ readonly output: TerminalOutputState;
1330 readonly status: TerminalSessionSnapshot["status"] | "closed";
1431 readonly error: string | null;
1532 readonly hasRunningSubprocess: boolean;
@@ -19,7 +36,7 @@ export interface TerminalSessionState { · 3 unmodified lines
1936 }
2037
2138 export interface TerminalBufferState {
22− readonly buffer: string;
39+ readonly output: TerminalOutputState;
2340 readonly status: TerminalSessionSnapshot["status"] | "closed";
2441 readonly error: string | null;
2542 readonly updatedAt: string | null;
@@ -47,7 +64,7 @@ export function selectRunningSubprocessTerminalIds( · 21 unmodified lines
4764 }
4865
4966 export const EMPTY_TERMINAL_BUFFER_STATE = Object.freeze<TerminalBufferState>({
50− buffer: "",
67+ output: EMPTY_TERMINAL_OUTPUT_STATE,
5168 status: "closed",
5269 error: null,
5370 updatedAt: null,
@@ -57,7 +74,7 @@ export const EMPTY_TERMINAL_BUFFER_STATE = Object.freeze<TerminalBufferState>({ · 3 unmodified lines
5774
5875 export const EMPTY_TERMINAL_SESSION_STATE = Object.freeze<TerminalSessionState>({
5976 summary: null,
60− buffer: "",
77+ output: EMPTY_TERMINAL_OUTPUT_STATE,
6178 status: "closed",
6279 error: null,
6380 hasRunningSubprocess: false,
@@ -66,43 +83,31 @@ export const EMPTY_TERMINAL_SESSION_STATE = Object.freeze<TerminalSessionState>( · 2 unmodified lines
6683 lifecycleVersion: 0,
6784 });
6885
69−export const DEFAULT_MAX_TERMINAL_BUFFER_BYTES = 512 * 1024;
70−const textEncoder = new TextEncoder();
71−const textDecoder = new TextDecoder();
86+let terminalAttachGeneration = 0;
7287
73−function trimBufferToBytes(buffer: string, maxBufferBytes: number): string {
74− if (maxBufferBytes <= 0) {
75− return "";
76− }
77−
78− const encoded = textEncoder.encode(buffer);
79− if (encoded.byteLength <= maxBufferBytes) {
80− return buffer;
81− }
82−
83− let start = encoded.byteLength - maxBufferBytes;
84− while (start < encoded.length) {
85− const byte = encoded[start];
86− if (byte === undefined || (byte & 0b1100_0000) !== 0b1000_0000) {
87− break;
88− }
89− start += 1;
90− }
91−
92− return textDecoder.decode(encoded.subarray(start));
88+/** A reinstalled attach stream must not reuse an old renderer's output cursor. */
89+export function nextTerminalAttachSeedState(): TerminalBufferState {
90+ return {
91+ ...EMPTY_TERMINAL_BUFFER_STATE,
92+ output: {
93+ ...EMPTY_TERMINAL_OUTPUT_STATE,
94+ generation: ++terminalAttachGeneration,
95+ },
96+ };
9397 }
9498
9599 export function terminalBufferStateFromSnapshot(
96100 snapshot: TerminalSessionSnapshot,
97101 maxBufferBytes: number,
102+ current: TerminalBufferState = EMPTY_TERMINAL_BUFFER_STATE,
98103 ): TerminalBufferState {
99104 return {
100− buffer: trimBufferToBytes(snapshot.history, maxBufferBytes),
105+ output: resetOutput(current.output, snapshot.history, maxBufferBytes),
101106 status: snapshot.status,
102107 error: null,
103108 updatedAt: snapshot.updatedAt,
104− version: 1,
105− lifecycleVersion: 0,
109+ version: current.version + 1,
110+ lifecycleVersion: current.lifecycleVersion,
106111 };
107112 }
108113
@@ -118,7 +123,7 @@ export function combineTerminalSessionState( · 9 unmodified lines
118123 ): TerminalSessionState {
119124 return {
120125 summary,
121− buffer: buffer.buffer,
126+ output: buffer.output,
122127 status: buffer.version > 0 ? buffer.status : (summary?.status ?? buffer.status),
123128 error: buffer.error,
124129 hasRunningSubprocess: summary?.hasRunningSubprocess ?? false,
@@ -136,19 +141,19 @@ export function applyTerminalAttachStreamEvent( · 11 unmodified lines
136141 switch (event.type) {
137142 case "snapshot":
138143 return {
139− ...terminalBufferStateFromSnapshot(event.snapshot, maxBufferBytes),
144+ ...terminalBufferStateFromSnapshot(event.snapshot, maxBufferBytes, current),
140145 lifecycleVersion:
141146 current.version === 0 ? current.lifecycleVersion : current.lifecycleVersion + 1,
142147 };
143148 case "restarted":
144149 return {
145− ...terminalBufferStateFromSnapshot(event.snapshot, maxBufferBytes),
150+ ...terminalBufferStateFromSnapshot(event.snapshot, maxBufferBytes, current),
146151 lifecycleVersion: current.lifecycleVersion + 1,
147152 };
148153 case "output":
149154 return {
150155 ...current,
151− buffer: trimBufferToBytes(`${current.buffer}${event.data}`, maxBufferBytes),
156+ output: appendOutput(current.output, event.data, maxBufferBytes),
152157 status: current.status === "closed" ? "running" : current.status,
153158 error: null,
154159 version: current.version + 1,
@@ -156,7 +161,7 @@ export function applyTerminalAttachStreamEvent( · 1 unmodified line
156161 case "cleared":
157162 return {
158163 ...current,
159− buffer: "",
164+ output: resetOutput(current.output, "", maxBufferBytes),
160165 error: null,
161166 version: current.version + 1,
162167 };