| author | |
| committer | |
| log | da7e46d08e85bcb07ecd78721a40f7b612fac2f2 |
| tree | dd14e64aa9b665d52f3c654f6495ff638c118236 |
| parent | 7839140e5e93d3f401d7eb45b86cf1a234eb3609 |
| signature | Signed by PGP key B5690EEEBB952194 |
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.
Co-Authored-By: Wout Stiens <71498452+StiensWout@users.noreply.github.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>8 files changed, 928 insertions(+), 67 deletions(-)
apps/mobile/src/features/terminal/terminalMenu.test.ts+5-2| ... | ... | @@ -1,6 +1,9 @@ |
| 1 | 1 | import { describe, expect, it } from "vite-plus/test"; |
| 2 | 2 | |
| 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"; | |
| 4 | 7 | import { DEFAULT_TERMINAL_ID, EnvironmentId, ThreadId } from "@t3tools/contracts"; |
| 5 | 8 | |
| 6 | 9 | import { getTerminalLabel } from "@t3tools/shared/terminalLabels"; |
| ... | ... | @@ -55,7 +58,7 @@ function makeKnownSession(input: { |
| 55 | 58 | updatedAt: input.updatedAt ?? "2026-04-15T20:00:00.000Z", |
| 56 | 59 | } |
| 57 | 60 | : null, |
| 58 | buffer: "", | |
| 61 | output: EMPTY_TERMINAL_BUFFER_STATE.output, | |
| 59 | 62 | status: input.status, |
| 60 | 63 | error: null, |
| 61 | 64 | hasRunningSubprocess: false, |
apps/mobile/src/state/use-terminal-session.ts+18-4| ... | ... | @@ -2,6 +2,7 @@ import { |
| 2 | 2 | combineTerminalSessionState, |
| 3 | 3 | EMPTY_TERMINAL_BUFFER_STATE, |
| 4 | 4 | EMPTY_TERMINAL_SESSION_STATE, |
| 5 | terminalOutputText, | |
| 5 | 6 | type KnownTerminalSession, |
| 6 | 7 | type TerminalSessionState, |
| 7 | 8 | } from "@t3tools/client-runtime/state/terminal"; |
| ... | ... | @@ -11,10 +12,16 @@ import { useMemo } from "react"; |
| 11 | 12 | import { useEnvironmentQuery } from "./query"; |
| 12 | 13 | import { terminalEnvironment } from "./terminal"; |
| 13 | 14 | |
| 15 | type LegacyTerminalSessionState = TerminalSessionState & { readonly buffer: string }; | |
| 16 | const EMPTY_LEGACY_TERMINAL_SESSION_STATE: LegacyTerminalSessionState = { | |
| 17 | ...EMPTY_TERMINAL_SESSION_STATE, | |
| 18 | buffer: "", | |
| 19 | }; | |
| 20 | ||
| 14 | 21 | export function useAttachedTerminalSession(input: { |
| 15 | 22 | readonly environmentId: EnvironmentId | null; |
| 16 | 23 | readonly terminal: TerminalAttachInput | null; |
| 17 | }): TerminalSessionState { | |
| 24 | }): LegacyTerminalSessionState { | |
| 18 | 25 | const attach = useEnvironmentQuery( |
| 19 | 26 | input.environmentId !== null && input.terminal !== null |
| 20 | 27 | ? terminalEnvironment.attach({ |
| ... | ... | @@ -31,10 +38,14 @@ export function useAttachedTerminalSession(input: { |
| 31 | 38 | input: null, |
| 32 | 39 | }), |
| 33 | 40 | ); |
| 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]); | |
| 34 | 45 | |
| 35 | 46 | return useMemo(() => { |
| 36 | 47 | if (input.environmentId === null || input.terminal === null) { |
| 37 | return EMPTY_TERMINAL_SESSION_STATE; | |
| 48 | return EMPTY_LEGACY_TERMINAL_SESSION_STATE; | |
| 38 | 49 | } |
| 39 | 50 | const summary = |
| 40 | 51 | metadata.data?.find( |
| ... | ... | @@ -42,9 +53,12 @@ export function useAttachedTerminalSession(input: { |
| 42 | 53 | terminal.threadId === input.terminal?.threadId && |
| 43 | 54 | terminal.terminalId === input.terminal?.terminalId, |
| 44 | 55 | ) ?? 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 | }; | |
| 46 | 60 | 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]); | |
| 48 | 62 | } |
| 49 | 63 | |
| 50 | 64 | export function useKnownTerminalSessions(input: { |
apps/web/src/components/ThreadTerminalDrawer.tsx+34-18| ... | ... | @@ -3,7 +3,13 @@ import { |
| 3 | 3 | isAtomCommandInterrupted, |
| 4 | 4 | squashAtomCommandFailure, |
| 5 | 5 | } 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"; | |
| 7 | 13 | import { |
| 8 | 14 | Plus, |
| 9 | 15 | Square, |
| ... | ... | @@ -99,8 +105,15 @@ function writeSystemMessage(terminal: GhosttyTerminalSurface, message: string): |
| 99 | 105 | terminal.write(`\r\n[terminal] ${message}\r\n`); |
| 100 | 106 | } |
| 101 | 107 | |
| 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 | } | |
| 104 | 117 | } |
| 105 | 118 | |
| 106 | 119 | function parseTerminalColor(value: string, fallback: GhosttyColor): GhosttyColor { |
| ... | ... | @@ -380,9 +393,10 @@ export function TerminalViewport({ |
| 380 | 393 | input: { threadId, terminalId, cols, rows }, |
| 381 | 394 | }), |
| 382 | 395 | ); |
| 383 | const terminalBuffer = terminalSession.buffer; | |
| 396 | const terminalOutput = terminalSession.output; | |
| 384 | 397 | const terminalError = terminalSession.error; |
| 385 | 398 | const terminalStatus = terminalSession.status; |
| 399 | const outputCursorRef = useRef<TerminalOutputCursor>(INITIAL_TERMINAL_OUTPUT_CURSOR); | |
| 386 | 400 | const synchronizedStatusRef = useRef<TerminalSessionState["status"]>("closed"); |
| 387 | 401 | const synchronizeTerminalStatus = useEffectEvent( |
| 388 | 402 | (terminal: GhosttyTerminalSurface, status: TerminalSessionState["status"]) => { |
| ... | ... | @@ -403,14 +417,14 @@ export function TerminalViewport({ |
| 403 | 417 | ); |
| 404 | 418 | const terminalVersion = terminalSession.version; |
| 405 | 419 | const previousSessionRef = useRef({ |
| 406 | buffer: terminalBuffer, | |
| 420 | output: terminalOutput, | |
| 407 | 421 | error: terminalError, |
| 408 | 422 | status: terminalStatus, |
| 409 | 423 | version: terminalVersion, |
| 410 | 424 | }); |
| 411 | 425 | const latestSessionRef = useRef(previousSessionRef.current); |
| 412 | 426 | latestSessionRef.current = { |
| 413 | buffer: terminalBuffer, | |
| 427 | output: terminalOutput, | |
| 414 | 428 | error: terminalError, |
| 415 | 429 | status: terminalStatus, |
| 416 | 430 | version: terminalVersion, |
| ... | ... | @@ -474,7 +488,14 @@ export function TerminalViewport({ |
| 474 | 488 | } |
| 475 | 489 | const latestSession = latestSessionRef.current; |
| 476 | 490 | 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; | |
| 478 | 499 | if (latestSession.error !== null) writeSystemMessage(terminal, latestSession.error); |
| 479 | 500 | // Attaching to a session that already exited must still run exit handling |
| 480 | 501 | // once, so mount synchronization starts from the empty "closed" state. |
| ... | ... | @@ -845,7 +866,7 @@ export function TerminalViewport({ |
| 845 | 866 | useEffect(() => { |
| 846 | 867 | const terminal = terminalRef.current; |
| 847 | 868 | const current = { |
| 848 | buffer: terminalBuffer, | |
| 869 | output: terminalOutput, | |
| 849 | 870 | error: terminalError, |
| 850 | 871 | status: terminalStatus, |
| 851 | 872 | version: terminalVersion, |
| ... | ... | @@ -857,18 +878,13 @@ export function TerminalViewport({ |
| 857 | 878 | |
| 858 | 879 | const previous = previousSessionRef.current; |
| 859 | 880 | synchronizeTerminalStatus(terminal, current.status); |
| 860 | if (current.version === previous.version) { | |
| 881 | if (current.version === previous.version && current.output === previous.output) { | |
| 861 | 882 | return; |
| 862 | 883 | } |
| 863 | 884 | |
| 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; | |
| 872 | 888 | terminal.clearSelection(); |
| 873 | 889 | |
| 874 | 890 | if (current.error !== null && current.error !== previous.error) { |
| ... | ... | @@ -881,7 +897,7 @@ export function TerminalViewport({ |
| 881 | 897 | }); |
| 882 | 898 | } |
| 883 | 899 | previousSessionRef.current = current; |
| 884 | }, [autoFocus, terminalBuffer, terminalError, terminalStatus, terminalVersion]); | |
| 900 | }, [autoFocus, terminalOutput, terminalError, terminalStatus, terminalVersion]); | |
| 885 | 901 | |
| 886 | 902 | useEffect(() => { |
| 887 | 903 | if (!autoFocus) return; |
apps/web/src/terminal/ghostty/core.test.ts+234-2| ... | ... | @@ -1,5 +1,15 @@ |
| 1 | 1 | 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"; | |
| 2 | 11 | |
| 12 | import { writeTerminalOutputUpdate } from "../../components/ThreadTerminalDrawer"; | |
| 3 | 13 | import { GHOSTTY_CELL_WIDE, GhosttyTerminalCore, ghosttyCellText } from "./core"; |
| 4 | 14 | import { loadGhosttyRuntime } from "./runtime"; |
| 5 | 15 | |
| ... | ... | @@ -50,7 +60,7 @@ describe("ghosttyCellText", () => { |
| 50 | 60 | describe("GhosttyTerminalCore snapshots", () => { |
| 51 | 61 | const cores = new Set<GhosttyTerminalCore>(); |
| 52 | 62 | |
| 53 | async function createCore() { | |
| 63 | async function createCore(onData: (data: string) => void = () => {}) { | |
| 54 | 64 | const core = await GhosttyTerminalCore.create( |
| 55 | 65 | 12, |
| 56 | 66 | 3, |
| ... | ... | @@ -61,12 +71,40 @@ describe("GhosttyTerminalCore snapshots", () => { |
| 61 | 71 | background: { r: 0, g: 0, b: 0 }, |
| 62 | 72 | cursor: { r: 255, g: 255, b: 255 }, |
| 63 | 73 | }, |
| 64 | () => {}, | |
| 74 | onData, | |
| 65 | 75 | ); |
| 66 | 76 | cores.add(core); |
| 67 | 77 | return core; |
| 68 | 78 | } |
| 69 | 79 | |
| 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 | ||
| 70 | 108 | afterEach(() => { |
| 71 | 109 | for (const core of cores) core.dispose(); |
| 72 | 110 | cores.clear(); |
| ... | ... | @@ -145,4 +183,198 @@ describe("GhosttyTerminalCore snapshots", () => { |
| 145 | 183 | core.dispose(); |
| 146 | 184 | expect(free).toHaveBeenCalledWith(buffer, capacity); |
| 147 | 185 | }); |
| 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 | }); | |
| 148 | 380 | }); |
packages/client-runtime/src/state/terminal.ts+5-3| ... | ... | @@ -13,7 +13,7 @@ import { subscribe, type EnvironmentRpcInput } from "../rpc/client.ts"; |
| 13 | 13 | import { |
| 14 | 14 | applyTerminalAttachStreamEvent, |
| 15 | 15 | applyTerminalMetadataStreamEvent, |
| 16 | EMPTY_TERMINAL_BUFFER_STATE, | |
| 16 | nextTerminalAttachSeedState, | |
| 17 | 17 | } from "./terminalSession.ts"; |
| 18 | 18 | |
| 19 | 19 | export function createTerminalEnvironmentAtoms<R, E>( |
| ... | ... | @@ -40,8 +40,10 @@ export function createTerminalEnvironmentAtoms<R, E>( |
| 40 | 40 | attach: createEnvironmentSubscriptionAtomFamily(runtime, { |
| 41 | 41 | label: "environment-data:terminal:attach", |
| 42 | 42 | 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 | ), | |
| 45 | 47 | ), |
| 46 | 48 | }), |
| 47 | 49 | events: createEnvironmentRpcSubscriptionAtomFamily(runtime, { |
packages/client-runtime/src/state/terminalOutput.ts created+320| ... | ... | @@ -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"; | |
| 2 | 2 | |
| 3 | 3 | import { EnvironmentId, TerminalSessionSnapshot, ThreadId } from "@t3tools/contracts"; |
| 4 | 4 | |
| ... | ... | @@ -6,8 +6,13 @@ import { |
| 6 | 6 | applyTerminalAttachStreamEvent, |
| 7 | 7 | applyTerminalMetadataStreamEvent, |
| 8 | 8 | combineTerminalSessionState, |
| 9 | DEFAULT_MAX_TERMINAL_BUFFER_BYTES, | |
| 9 | 10 | EMPTY_TERMINAL_BUFFER_STATE, |
| 11 | INITIAL_TERMINAL_OUTPUT_CURSOR, | |
| 12 | nextTerminalAttachSeedState, | |
| 13 | readTerminalOutputUpdate, | |
| 10 | 14 | selectRunningSubprocessTerminalIds, |
| 15 | terminalOutputText, | |
| 11 | 16 | } from "./terminalSession.ts"; |
| 12 | 17 | |
| 13 | 18 | const TARGET = { |
| ... | ... | @@ -126,11 +131,11 @@ describe("terminal session reducers", () => { |
| 126 | 131 | ); |
| 127 | 132 | |
| 128 | 133 | expect(output).toMatchObject({ |
| 129 | buffer: "lo world", | |
| 130 | 134 | status: "running", |
| 131 | 135 | error: null, |
| 132 | 136 | version: 2, |
| 133 | 137 | }); |
| 138 | expect(terminalOutputText(output.output)).toBe("lo world"); | |
| 134 | 139 | }); |
| 135 | 140 | |
| 136 | 141 | it("does not advance the lifecycle for the initial attach snapshot", () => { |
| ... | ... | @@ -220,6 +225,270 @@ describe("terminal session reducers", () => { |
| 220 | 225 | 4, |
| 221 | 226 | ); |
| 222 | 227 | |
| 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 | }); | |
| 224 | 493 | }); |
| 225 | 494 | }); |
packages/client-runtime/src/state/terminalSession.ts+40-35| ... | ... | @@ -6,10 +6,27 @@ import type { |
| 6 | 6 | TerminalSummary, |
| 7 | 7 | ThreadId, |
| 8 | 8 | } 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"; | |
| 9 | 26 | |
| 10 | 27 | export interface TerminalSessionState { |
| 11 | 28 | readonly summary: TerminalSummary | null; |
| 12 | readonly buffer: string; | |
| 29 | readonly output: TerminalOutputState; | |
| 13 | 30 | readonly status: TerminalSessionSnapshot["status"] | "closed"; |
| 14 | 31 | readonly error: string | null; |
| 15 | 32 | readonly hasRunningSubprocess: boolean; |
| ... | ... | @@ -19,7 +36,7 @@ export interface TerminalSessionState { |
| 19 | 36 | } |
| 20 | 37 | |
| 21 | 38 | export interface TerminalBufferState { |
| 22 | readonly buffer: string; | |
| 39 | readonly output: TerminalOutputState; | |
| 23 | 40 | readonly status: TerminalSessionSnapshot["status"] | "closed"; |
| 24 | 41 | readonly error: string | null; |
| 25 | 42 | readonly updatedAt: string | null; |
| ... | ... | @@ -47,7 +64,7 @@ export function selectRunningSubprocessTerminalIds( |
| 47 | 64 | } |
| 48 | 65 | |
| 49 | 66 | export const EMPTY_TERMINAL_BUFFER_STATE = Object.freeze<TerminalBufferState>({ |
| 50 | buffer: "", | |
| 67 | output: EMPTY_TERMINAL_OUTPUT_STATE, | |
| 51 | 68 | status: "closed", |
| 52 | 69 | error: null, |
| 53 | 70 | updatedAt: null, |
| ... | ... | @@ -57,7 +74,7 @@ export const EMPTY_TERMINAL_BUFFER_STATE = Object.freeze<TerminalBufferState>({ |
| 57 | 74 | |
| 58 | 75 | export const EMPTY_TERMINAL_SESSION_STATE = Object.freeze<TerminalSessionState>({ |
| 59 | 76 | summary: null, |
| 60 | buffer: "", | |
| 77 | output: EMPTY_TERMINAL_OUTPUT_STATE, | |
| 61 | 78 | status: "closed", |
| 62 | 79 | error: null, |
| 63 | 80 | hasRunningSubprocess: false, |
| ... | ... | @@ -66,43 +83,31 @@ export const EMPTY_TERMINAL_SESSION_STATE = Object.freeze<TerminalSessionState>( |
| 66 | 83 | lifecycleVersion: 0, |
| 67 | 84 | }); |
| 68 | 85 | |
| 69 | export const DEFAULT_MAX_TERMINAL_BUFFER_BYTES = 512 * 1024; | |
| 70 | const textEncoder = new TextEncoder(); | |
| 71 | const textDecoder = new TextDecoder(); | |
| 86 | let terminalAttachGeneration = 0; | |
| 72 | 87 | |
| 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 | }; | |
| 93 | 97 | } |
| 94 | 98 | |
| 95 | 99 | export function terminalBufferStateFromSnapshot( |
| 96 | 100 | snapshot: TerminalSessionSnapshot, |
| 97 | 101 | maxBufferBytes: number, |
| 102 | current: TerminalBufferState = EMPTY_TERMINAL_BUFFER_STATE, | |
| 98 | 103 | ): TerminalBufferState { |
| 99 | 104 | return { |
| 100 | buffer: trimBufferToBytes(snapshot.history, maxBufferBytes), | |
| 105 | output: resetOutput(current.output, snapshot.history, maxBufferBytes), | |
| 101 | 106 | status: snapshot.status, |
| 102 | 107 | error: null, |
| 103 | 108 | updatedAt: snapshot.updatedAt, |
| 104 | version: 1, | |
| 105 | lifecycleVersion: 0, | |
| 109 | version: current.version + 1, | |
| 110 | lifecycleVersion: current.lifecycleVersion, | |
| 106 | 111 | }; |
| 107 | 112 | } |
| 108 | 113 | |
| ... | ... | @@ -118,7 +123,7 @@ export function combineTerminalSessionState( |
| 118 | 123 | ): TerminalSessionState { |
| 119 | 124 | return { |
| 120 | 125 | summary, |
| 121 | buffer: buffer.buffer, | |
| 126 | output: buffer.output, | |
| 122 | 127 | status: buffer.version > 0 ? buffer.status : (summary?.status ?? buffer.status), |
| 123 | 128 | error: buffer.error, |
| 124 | 129 | hasRunningSubprocess: summary?.hasRunningSubprocess ?? false, |
| ... | ... | @@ -136,19 +141,19 @@ export function applyTerminalAttachStreamEvent( |
| 136 | 141 | switch (event.type) { |
| 137 | 142 | case "snapshot": |
| 138 | 143 | return { |
| 139 | ...terminalBufferStateFromSnapshot(event.snapshot, maxBufferBytes), | |
| 144 | ...terminalBufferStateFromSnapshot(event.snapshot, maxBufferBytes, current), | |
| 140 | 145 | lifecycleVersion: |
| 141 | 146 | current.version === 0 ? current.lifecycleVersion : current.lifecycleVersion + 1, |
| 142 | 147 | }; |
| 143 | 148 | case "restarted": |
| 144 | 149 | return { |
| 145 | ...terminalBufferStateFromSnapshot(event.snapshot, maxBufferBytes), | |
| 150 | ...terminalBufferStateFromSnapshot(event.snapshot, maxBufferBytes, current), | |
| 146 | 151 | lifecycleVersion: current.lifecycleVersion + 1, |
| 147 | 152 | }; |
| 148 | 153 | case "output": |
| 149 | 154 | return { |
| 150 | 155 | ...current, |
| 151 | buffer: trimBufferToBytes(`${current.buffer}${event.data}`, maxBufferBytes), | |
| 156 | output: appendOutput(current.output, event.data, maxBufferBytes), | |
| 152 | 157 | status: current.status === "closed" ? "running" : current.status, |
| 153 | 158 | error: null, |
| 154 | 159 | version: current.version + 1, |
| ... | ... | @@ -156,7 +161,7 @@ export function applyTerminalAttachStreamEvent( |
| 156 | 161 | case "cleared": |
| 157 | 162 | return { |
| 158 | 163 | ...current, |
| 159 | buffer: "", | |
| 164 | output: resetOutput(current.output, "", maxBufferBytes), | |
| 160 | 165 | error: null, |
| 161 | 166 | version: current.version + 1, |
| 162 | 167 | }; |