diff --git a/apps/mobile/src/features/terminal/terminalMenu.test.ts b/apps/mobile/src/features/terminal/terminalMenu.test.ts index 966312270..3e09dc872 100644 --- a/apps/mobile/src/features/terminal/terminalMenu.test.ts +++ b/apps/mobile/src/features/terminal/terminalMenu.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from "vite-plus/test"; -import { type KnownTerminalSession } from "@t3tools/client-runtime/state/terminal"; +import { + EMPTY_TERMINAL_BUFFER_STATE, + type KnownTerminalSession, +} from "@t3tools/client-runtime/state/terminal"; import { DEFAULT_TERMINAL_ID, EnvironmentId, ThreadId } from "@t3tools/contracts"; import { getTerminalLabel } from "@t3tools/shared/terminalLabels"; @@ -55,7 +58,7 @@ function makeKnownSession(input: { updatedAt: input.updatedAt ?? "2026-04-15T20:00:00.000Z", } : null, - buffer: "", + output: EMPTY_TERMINAL_BUFFER_STATE.output, status: input.status, error: null, hasRunningSubprocess: false, diff --git a/apps/mobile/src/state/use-terminal-session.ts b/apps/mobile/src/state/use-terminal-session.ts index 328557a20..6be57007a 100644 --- a/apps/mobile/src/state/use-terminal-session.ts +++ b/apps/mobile/src/state/use-terminal-session.ts @@ -2,6 +2,7 @@ import { combineTerminalSessionState, EMPTY_TERMINAL_BUFFER_STATE, EMPTY_TERMINAL_SESSION_STATE, + terminalOutputText, type KnownTerminalSession, type TerminalSessionState, } from "@t3tools/client-runtime/state/terminal"; @@ -11,10 +12,16 @@ import { useMemo } from "react"; import { useEnvironmentQuery } from "./query"; import { terminalEnvironment } from "./terminal"; +type LegacyTerminalSessionState = TerminalSessionState & { readonly buffer: string }; +const EMPTY_LEGACY_TERMINAL_SESSION_STATE: LegacyTerminalSessionState = { + ...EMPTY_TERMINAL_SESSION_STATE, + buffer: "", +}; + export function useAttachedTerminalSession(input: { readonly environmentId: EnvironmentId | null; readonly terminal: TerminalAttachInput | null; -}): TerminalSessionState { +}): LegacyTerminalSessionState { const attach = useEnvironmentQuery( input.environmentId !== null && input.terminal !== null ? terminalEnvironment.attach({ @@ -31,10 +38,14 @@ export function useAttachedTerminalSession(input: { input: null, }), ); + const output = attach.data?.output ?? EMPTY_TERMINAL_BUFFER_STATE.output; + // Installed native binaries still accept initialBuffer. Keep materialization + // at this mobile boundary until the native streaming API is released. + const buffer = useMemo(() => terminalOutputText(output), [output]); return useMemo(() => { if (input.environmentId === null || input.terminal === null) { - return EMPTY_TERMINAL_SESSION_STATE; + return EMPTY_LEGACY_TERMINAL_SESSION_STATE; } const summary = metadata.data?.find( @@ -42,9 +53,12 @@ export function useAttachedTerminalSession(input: { terminal.threadId === input.terminal?.threadId && terminal.terminalId === input.terminal?.terminalId, ) ?? null; - const state = combineTerminalSessionState(summary, attach.data ?? EMPTY_TERMINAL_BUFFER_STATE); + const state = { + ...combineTerminalSessionState(summary, attach.data ?? EMPTY_TERMINAL_BUFFER_STATE), + buffer, + }; return attach.error === null ? state : { ...state, error: attach.error, status: "error" }; - }, [attach.data, attach.error, input.environmentId, input.terminal, metadata.data]); + }, [attach.data, attach.error, buffer, input.environmentId, input.terminal, metadata.data]); } export function useKnownTerminalSessions(input: { diff --git a/apps/server/src/terminal/Manager.test.ts b/apps/server/src/terminal/Manager.test.ts index 47d91e451..deea39631 100644 --- a/apps/server/src/terminal/Manager.test.ts +++ b/apps/server/src/terminal/Manager.test.ts @@ -213,6 +213,7 @@ interface CreateManagerOptions { subprocessPollIntervalMs?: number; processKillGraceMs?: number; maxRetainedInactiveSessions?: number; + historyByteLimit?: number; ptyAdapter?: FakePtyAdapter; } @@ -243,6 +244,9 @@ const createManager = ( logsDir, historyLineLimit, ptyAdapter, + ...(options.historyByteLimit !== undefined + ? { historyByteLimit: options.historyByteLimit } + : {}), ...(options.shellResolver !== undefined ? { shellResolver: options.shellResolver } : {}), ...(options.env !== undefined ? { env: options.env } : {}), ...(options.subprocessInspector !== undefined @@ -276,6 +280,105 @@ const createManager = ( const withHostPlatform = (platform: NodeJS.Platform) => Layer.succeed(HostProcessPlatform, platform); +// Apply the existing line policy, then find the longest code-point-aligned byte tail. +function retainedHistory(text: string, maxLines: number, maxBytes = Infinity): string { + const terminated = text.endsWith("\n"); + const lines = text.split("\n"); + if (terminated) lines.pop(); + const retained = lines.slice(Math.max(0, lines.length - maxLines)).join("\n"); + const capped = terminated ? `${retained}\n` : retained; + if (Buffer.byteLength(capped) <= maxBytes) return capped; + const points = Array.from(capped); + let start = points.length; + let bytes = 0; + while (start > 0) { + const next = Buffer.byteLength(points[start - 1]!); + if (bytes + next > maxBytes) break; + bytes += next; + start -= 1; + } + return points.slice(start).join(""); +} + +it("preserves line and byte limits across arbitrary chunks, Unicode, ANSI sequences, and clear", () => { + let randomSeed = 0x20260904; + const fragments = [ + "", + "a", + "\n", + "\n\n", + "\r", + "\r\n", + "cafΓ©", + "名", + "πŸš€", + "\u001b[31m", + "\u001b[0m", + "\u001b]8;;url\u0007", + "\ud83d", + "\ude80", + ]; + const nextFragment = () => { + randomSeed = (Math.imul(randomSeed, 1_664_525) + 1_013_904_223) >>> 0; + return fragments[randomSeed % fragments.length]!; + }; + + for (const maxBytes of [0, 3, 8, 64, Infinity]) { + for (const maxLines of [0, 1, 3, 5, 5_000]) { + let expected = retainedHistory("before\ninitial\n", maxLines, maxBytes); + const history = new TerminalManager.BoundedTerminalHistory( + maxLines, + "before\ninitial\n", + maxBytes, + ); + expect(history.value()).toBe(expected); + + for (let step = 0; step < 300; step += 1) { + if (step % 73 === 0) { + history.clear(); + expected = ""; + expect(history.value()).toBe(expected); + } + const chunk = nextFragment() + nextFragment(); + history.append(chunk); + expected = retainedHistory(expected + chunk, maxLines, maxBytes); + expect(history.value()).toBe(expected); + } + } + } +}); + +it("bounds long partial lines and joins surrogate pairs across chunk boundaries", () => { + const maxBytes = 65_539; + let expected = ""; + const history = new TerminalManager.BoundedTerminalHistory(5_000, "", maxBytes); + const writes = [ + "a".repeat(16_383) + "πŸ˜€" + "b".repeat(70_000), + "\r" + "c".repeat(70_000) + "\ud83d", + "\ude80" + "d".repeat(100), + "\uFEFF" + "名".repeat(30_000), + ]; + for (const text of writes) { + history.append(text); + expected = retainedHistory(expected + text, 5_000, maxBytes); + expect(history.value()).toBe(expected); + expect(Buffer.byteLength(history.value())).toBeLessThanOrEqual(maxBytes); + } +}); + +it("preserves retained lines as older storage is compacted", () => { + for (const maxLines of [3, 5_000]) { + let expected = ""; + const history = new TerminalManager.BoundedTerminalHistory(maxLines, expected); + for (let batch = 0; batch < 40; batch += 1) { + const chunk = Array.from({ length: 300 }, (_, line) => `${batch}:${line}\n`).join(""); + history.append(chunk); + expected = retainedHistory(expected + chunk, maxLines); + expect(history.value()).toBe(expected); + } + } +}); + it.layer( Layer.merge(NodeServices.layer, ProcessRunner.layer.pipe(Layer.provide(NodeServices.layer))), { excludeTestServices: true }, @@ -1090,6 +1193,105 @@ it.layer( }), ); + it.effect("caps incrementally appended history without losing partial or empty lines", () => + Effect.gen(function* () { + const { manager, ptyAdapter } = yield* createManager(3); + yield* manager.open(openInput()); + const process = ptyAdapter.processes[0]; + expect(process).toBeDefined(); + if (!process) return; + + process.emitData("line1\n"); + process.emitData("\n"); + process.emitData("line3"); + process.emitData("-continued\nline4"); + yield* manager.close({ threadId: "thread-1" }); + + const reopened = yield* manager.open(openInput()); + expect(reopened.history).toBe("\nline3-continued\nline4"); + }), + ); + + it.effect("bounds persisted and attached history without truncating live output", () => + Effect.gen(function* () { + const { manager, ptyAdapter, logsDir } = yield* createManager(5, { historyByteLimit: 10 }); + const attachEvents = yield* Ref.make>([]); + const unsubscribe = yield* manager.attachStream(openInput(), (event) => + Ref.update(attachEvents, (events) => [...events, event]), + ); + yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)); + const writes = ["a".repeat(32), "πŸ˜€\rEND"]; + const process = ptyAdapter.processes[0]!; + for (const text of writes) process.emitData(text); + yield* manager.close({ threadId: "thread-1" }); + expect(yield* readFileString(yield* historyLogPath(logsDir))).toBe("aaπŸ˜€\rEND"); + + const reopened = yield* manager.open(openInput()); + const events = yield* Ref.get(attachEvents); + expect(events.filter((event) => event.type === "output").map((event) => event.data)).toEqual( + writes, + ); + const snapshot = events.filter((event) => event.type === "snapshot").at(-1)?.snapshot; + expect(snapshot?.history).toBe("aaπŸ˜€\rEND"); + expect(snapshot?.sequence).toBe(reopened.sequence); + }), + ); + + for (const source of ["current", "legacy"] as const) { + it.effect(`reads only a Unicode-safe tail from oversized ${source} history`, () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + let sourcePath: string | undefined; + let closedReads = 0; + const readRequests: number[] = []; + const trackedFileSystem = FileSystem.FileSystem.of({ + ...fs, + readFileString: (candidate, encoding) => + candidate === sourcePath + ? Effect.die("History restoration must not read the whole file") + : fs.readFileString(candidate, encoding), + open: (candidate, options) => + Effect.gen(function* () { + if (candidate !== sourcePath) return yield* fs.open(candidate, options); + yield* Effect.addFinalizer(() => + Effect.sync(() => { + closedReads += 1; + }), + ); + const file = yield* fs.open(candidate, options); + return new Proxy(file, { + get(target, key) { + if (key === "read") { + return (buffer: Uint8Array) => { + readRequests.push(buffer.byteLength); + return target.read(buffer.subarray(0, 5)); + }; + } + return Reflect.get(target, key, target); + }, + }); + }), + }); + const { manager, logsDir } = yield* createManager(5, { historyByteLimit: 15 }).pipe( + Effect.provideService(FileSystem.FileSystem, trackedFileSystem), + ); + const nextPath = yield* historyLogPath(logsDir); + sourcePath = source === "current" ? nextPath : path.join(logsDir, "thread-1.log"); + yield* fs.writeFileString(sourcePath, "old".repeat(32_768) + "πŸ˜€\uFEFFnewest\rΓ©"); + + const snapshot = yield* manager.open(openInput()); + expect(snapshot.history).toBe("\uFEFFnewest\rΓ©"); + expect(readRequests).toEqual([15, 10, 5]); + expect(closedReads).toBe(1); + expect(Buffer.from(yield* fs.readFile(nextPath)).toString()).toBe("\uFEFFnewest\rΓ©"); + if (source === "legacy") expect(yield* fs.exists(sourcePath)).toBe(false); + yield* manager.close({ threadId: "thread-1" }); + expect((yield* manager.open(openInput())).history).toBe("\uFEFFnewest\rΓ©"); + }), + ); + } + it.effect("strips replay-unsafe terminal query and reply sequences from persisted history", () => Effect.gen(function* () { const { manager, ptyAdapter } = yield* createManager(); @@ -1485,6 +1687,31 @@ it.layer( }), ); + it.effect.each(["linux", "darwin", "win32"] as const)( + "advertises truecolor before the PTY backend on %s without replacing explicit values", + (platform) => + Effect.gen(function* () { + for (const [parentColor, runtimeColor, expected] of [ + [undefined, undefined, "truecolor"], + ["", undefined, "truecolor"], + ["24bit", undefined, "24bit"], + ["24bit", "", "truecolor"], + ["24bit", "custom", "custom"], + ] as const) { + const env = Object.freeze({ COLORTERM: parentColor }); + const { manager, ptyAdapter } = yield* createManager(5, { + shellResolver: () => "/bin/sh", + env, + }).pipe(Effect.provide(withHostPlatform(platform))); + yield* manager.open( + openInput({ env: runtimeColor === undefined ? {} : { COLORTERM: runtimeColor } }), + ); + expect(ptyAdapter.spawnInputs[0]?.env.COLORTERM).toBe(expected); + expect(env.COLORTERM).toBe(parentColor); + } + }), + ); + it.effect("filters app runtime env variables from terminal sessions", () => Effect.gen(function* () { const { manager, ptyAdapter } = yield* createManager(5, { diff --git a/apps/server/src/terminal/Manager.ts b/apps/server/src/terminal/Manager.ts index 64c2dbb91..f04e3c2d8 100644 --- a/apps/server/src/terminal/Manager.ts +++ b/apps/server/src/terminal/Manager.ts @@ -75,6 +75,8 @@ export { }; const DEFAULT_HISTORY_LINE_LIMIT = 5_000; +const DEFAULT_HISTORY_BYTE_LIMIT = 8 * 1024 * 1024; +const MAX_HISTORY_CHUNK_LENGTH = 16 * 1024; const DEFAULT_PERSIST_DEBOUNCE_MS = 40; const DEFAULT_SUBPROCESS_POLL_INTERVAL_MS = 1_000; const DEFAULT_PROCESS_KILL_GRACE_MS = 1_000; @@ -238,14 +240,14 @@ export interface TerminalStartInput extends TerminalOpenInput { rows: number; } -export interface TerminalSessionState { +interface TerminalSessionState { threadId: string; terminalId: string; cwd: string; worktreePath: string | null; status: TerminalSessionStatus; pid: number | null; - history: string; + history: BoundedTerminalHistory; pendingHistoryControlSequence: string; pendingProcessEvents: Array; pendingProcessEventIndex: number; @@ -266,7 +268,7 @@ export interface TerminalSessionState { } interface PersistHistoryRequest { - history: string; + history: BoundedTerminalHistory; immediate: boolean; } @@ -281,7 +283,7 @@ type DrainProcessEventAction = threadId: string; terminalId: string; sequence: number; - history: string | null; + history: BoundedTerminalHistory | null; data: string; } | { @@ -340,7 +342,7 @@ function snapshot(session: TerminalSessionState): TerminalSessionSnapshot { worktreePath: session.worktreePath, status: session.status, pid: session.pid, - history: session.history, + history: session.history.value(), exitCode: session.exitCode, exitSignal: session.exitSignal, label: terminalWireLabel(session), @@ -784,16 +786,188 @@ const windowsProcessTableSnapshot = Effect.fn("terminal.windowsProcessTableSnaps }, ); -function capHistory(history: string, maxLines: number): string { - if (history.length === 0) return history; - const hasTrailingNewline = history.endsWith("\n"); - const lines = history.split("\n"); - if (hasTrailingNewline) { - lines.pop(); +interface TerminalHistoryChunk { + data: string; + byteLength: number; + lineBreaks: number; +} + +export class BoundedTerminalHistory { + private readonly maxLines: number; + private readonly maxBytes: number; + private chunks: Array = []; + private start = 0; + private byteLength = 0; + private lineBreaks = 0; + // Reading the old string's tail on each append can force chunk concatenation. + private lastCodeUnit: number | undefined; + private cachedValue: string | null = ""; + + constructor(maxLines: number, initial: string, maxBytes = DEFAULT_HISTORY_BYTE_LIMIT) { + this.maxLines = maxLines; + this.maxBytes = maxBytes; + this.append(initial); + } + + append(text: string): void { + if (text.length === 0) return; + this.cachedValue = null; + if (this.maxBytes <= 0 || this.maxLines <= 0) { + this.clear(); + // Preserve the existing zero-line limit's trailing newline behavior. + if (this.maxBytes > 0 && text.endsWith("\n")) this.appendChunk("\n"); + return; + } + + let offset = 0; + const previous = this.chunks.at(-1); + const lastCode = this.lastCodeUnit; + const firstCode = text.charCodeAt(0); + if ( + previous && + lastCode !== undefined && + lastCode >= 0xd800 && + lastCode <= 0xdbff && + firstCode >= 0xdc00 && + firstCode <= 0xdfff + ) { + // Joining a split surrogate changes its UTF-8 size from 3 to 4 bytes. + previous.data += text[0]; + previous.byteLength += 1; + this.byteLength += 1; + this.lastCodeUnit = firstCode; + offset = 1; + this.trim(); + } + + while (offset < text.length) { + let end = Math.min(offset + MAX_HISTORY_CHUNK_LENGTH, text.length); + const before = text.charCodeAt(end - 1); + const after = text.charCodeAt(end); + if (before >= 0xd800 && before <= 0xdbff && after >= 0xdc00 && after <= 0xdfff) { + end -= 1; + } + const data = text.slice(offset, end); + // Detach small chunks from large input strings so evicted prefixes can be collected. + this.appendChunk( + text.length > MAX_HISTORY_CHUNK_LENGTH + ? Buffer.from(data, "utf16le").toString("utf16le") + : data, + ); + this.trim(); + offset = end; + } + } + + private appendChunk(data: string): void { + const byteLength = Buffer.byteLength(data); + let lineBreaks = 0; + for (let index = data.indexOf("\n"); index !== -1; index = data.indexOf("\n", index + 1)) { + lineBreaks += 1; + } + const previous = this.chunks.at(-1); + if (previous && previous.data.length + data.length <= MAX_HISTORY_CHUNK_LENGTH) { + previous.data += data; + previous.byteLength += byteLength; + previous.lineBreaks += lineBreaks; + } else { + this.chunks.push({ data, byteLength, lineBreaks }); + } + this.byteLength += byteLength; + this.lineBreaks += lineBreaks; + this.lastCodeUnit = data.charCodeAt(data.length - 1); + this.cachedValue = null; + } + + private discardChunk(): void { + const first = this.chunks[this.start]!; + this.byteLength -= first.byteLength; + this.lineBreaks -= first.lineBreaks; + this.chunks[this.start++] = undefined; + } + + private trimChunk(offset: number, byteLength: number, lineBreaks: number): void { + const first = this.chunks[this.start]!; + if (offset === first.data.length) { + this.discardChunk(); + return; + } + first.data = first.data.slice(offset); + first.byteLength -= byteLength; + first.lineBreaks -= lineBreaks; + this.byteLength -= byteLength; + this.lineBreaks -= lineBreaks; + } + + private trim(): void { + const trailingNewline = this.lastCodeUnit === 10; + let linesToDrop = this.lineBreaks + (trailingNewline ? 0 : 1) - this.maxLines; + while (linesToDrop > 0) { + const first = this.chunks[this.start]!; + if (first.lineBreaks < linesToDrop) { + linesToDrop -= first.lineBreaks; + this.discardChunk(); + continue; + } + let offset = 0; + for (let line = 0; line < linesToDrop; line += 1) { + offset = first.data.indexOf("\n", offset) + 1; + } + this.trimChunk(offset, Buffer.byteLength(first.data.slice(0, offset)), linesToDrop); + linesToDrop = 0; + } + + while (this.byteLength > this.maxBytes) { + const first = this.chunks[this.start]!; + const bytesToDrop = this.byteLength - this.maxBytes; + if (first.byteLength <= bytesToDrop) { + this.discardChunk(); + continue; + } + if (first.byteLength === first.data.length && first.lineBreaks === 0) { + // ASCII without newlines needs no scan to find the byte cutoff. + this.trimChunk(bytesToDrop, bytesToDrop, 0); + continue; + } + let offset = 0; + let bytes = 0; + let lineBreaks = 0; + // Scan only the discarded prefix of one small chunk, never all history. + while (bytes < bytesToDrop) { + const codePoint = first.data.codePointAt(offset)!; + bytes += codePoint <= 0x7f ? 1 : codePoint <= 0x7ff ? 2 : codePoint <= 0xffff ? 3 : 4; + offset += codePoint <= 0xffff ? 1 : 2; + if (codePoint === 10) lineBreaks += 1; + } + this.trimChunk(offset, bytes, lineBreaks); + } + if ( + this.start === this.chunks.length || + (this.start > 2_048 && this.start * 2 >= this.chunks.length) + ) { + this.chunks = this.chunks.slice(this.start); + this.start = 0; + if (this.chunks.length === 0) this.lastCodeUnit = undefined; + } + } + + clear(): void { + this.chunks = []; + this.start = 0; + this.byteLength = 0; + this.lineBreaks = 0; + this.lastCodeUnit = undefined; + this.cachedValue = ""; + } + + value(): string { + if (this.cachedValue !== null) return this.cachedValue; + this.cachedValue = this.chunks + .slice(this.start) + .map((chunk) => chunk!.data) + .join(""); + return this.cachedValue; } - if (lines.length <= maxLines) return history; - const capped = lines.slice(lines.length - maxLines).join("\n"); - return hasTrailingNewline ? `${capped}\n` : capped; } function isCsiFinalByte(codePoint: number): boolean { @@ -1096,6 +1270,10 @@ function createTerminalSpawnEnv( spawnEnv[key] = value; } } + // Both PTY backends feed truecolor-capable terminal clients. + if (spawnEnv.COLORTERM === undefined || spawnEnv.COLORTERM === "") { + spawnEnv.COLORTERM = "truecolor"; + } return stripAppImageRuntimeEnv(spawnEnv); } @@ -1111,6 +1289,7 @@ function normalizedRuntimeEnv( interface TerminalManagerOptions { logsDir: string; historyLineLimit?: number; + historyByteLimit?: number; ptyAdapter: PtyAdapter.PtyAdapter["Service"]; shellResolver?: () => string; env?: NodeJS.ProcessEnv; @@ -1151,6 +1330,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func const logsDir = options.logsDir; const historyLineLimit = options.historyLineLimit ?? DEFAULT_HISTORY_LINE_LIMIT; + const historyByteLimit = options.historyByteLimit ?? DEFAULT_HISTORY_BYTE_LIMIT; const platform = yield* HostProcessPlatform; // Terminals must inherit the user's full environment (minus the blocklist // applied in createTerminalSpawnEnv) β€” an allowlist here silently strips @@ -1372,22 +1552,24 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func return; } - yield* fileSystem.writeFileString(historyPath(threadId, terminalId), request.history).pipe( - Effect.catch((error) => - Effect.logWarning("failed to persist terminal history", { - threadId, - terminalId, - error, - }), - ), - ); + yield* fileSystem + .writeFileString(historyPath(threadId, terminalId), request.history.value()) + .pipe( + Effect.catch((error) => + Effect.logWarning("failed to persist terminal history", { + threadId, + terminalId, + error, + }), + ), + ); }), }); const queuePersist = Effect.fn("terminal.queuePersist")(function* ( threadId: string, terminalId: string, - history: string, + history: BoundedTerminalHistory, ) { yield* persistWorker.enqueue(toSessionKey(threadId, terminalId), { history, @@ -1405,7 +1587,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func const persistHistory = Effect.fn("terminal.persistHistory")(function* ( threadId: string, terminalId: string, - history: string, + history: BoundedTerminalHistory, ) { yield* persistWorker.enqueue(toSessionKey(threadId, terminalId), { history, @@ -1414,6 +1596,30 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func yield* flushPersist(threadId, terminalId); }); + const readHistoryTail = Effect.fn("terminal.readHistoryTail")(function* (filePath: string) { + const file = yield* fileSystem.open(filePath, { flag: "r" }); + const info = yield* file.stat; + const limit = BigInt(historyByteLimit); + const offset = info.size > limit ? info.size - limit : 0n; + yield* file.seek(offset, "start"); + const bytes = new Uint8Array(Number(info.size - offset)); + let length = 0; + while (length < bytes.length) { + const read = Number(yield* file.read(bytes.subarray(length))); + if (read === 0) break; + length += read; + } + let start = 0; + if (offset > 0n) { + // A tail read can start inside a UTF-8 code point. Skip its remaining bytes. + while (start < length && ((bytes[start] ?? 0) & 0xc0) === 0x80) start += 1; + } + return { + history: new TextDecoder("utf-8", { ignoreBOM: true }).decode(bytes.subarray(start, length)), + truncated: offset > 0n, + }; + }); + const readHistory = Effect.fn("terminal.readHistory")(function* ( threadId: string, terminalId: string, @@ -1428,15 +1634,15 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func ), ) ) { - const raw = yield* fileSystem - .readFileString(nextPath) - .pipe( - Effect.mapError( - (cause) => new TerminalHistoryError({ operation: "read", threadId, terminalId, cause }), - ), - ); - const capped = capHistory(raw, historyLineLimit); - if (capped !== raw) { + const { history: raw, truncated } = yield* readHistoryTail(nextPath).pipe( + Effect.scoped, + Effect.mapError( + (cause) => new TerminalHistoryError({ operation: "read", threadId, terminalId, cause }), + ), + ); + const history = new BoundedTerminalHistory(historyLineLimit, raw, historyByteLimit); + const capped = history.value(); + if (truncated || capped !== raw) { yield* fileSystem .writeFileString(nextPath, capped) .pipe( @@ -1446,11 +1652,11 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func ), ); } - return capped; + return history; } if (terminalId !== DEFAULT_TERMINAL_ID) { - return ""; + return new BoundedTerminalHistory(historyLineLimit, "", historyByteLimit); } const legacyPath = legacyHistoryPath(threadId); @@ -1464,18 +1670,17 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func ), )) ) { - return ""; + return new BoundedTerminalHistory(historyLineLimit, "", historyByteLimit); } - const raw = yield* fileSystem - .readFileString(legacyPath) - .pipe( - Effect.mapError( - (cause) => - new TerminalHistoryError({ operation: "migrate", threadId, terminalId, cause }), - ), - ); - const capped = capHistory(raw, historyLineLimit); + const { history: raw } = yield* readHistoryTail(legacyPath).pipe( + Effect.scoped, + Effect.mapError( + (cause) => new TerminalHistoryError({ operation: "migrate", threadId, terminalId, cause }), + ), + ); + const history = new BoundedTerminalHistory(historyLineLimit, raw, historyByteLimit); + const capped = history.value(); yield* fileSystem .writeFileString(nextPath, capped) .pipe( @@ -1492,7 +1697,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func }), ), ); - return capped; + return history; }); const deleteHistory = Effect.fn("terminal.deleteHistory")(function* ( @@ -1661,10 +1866,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func ); session.pendingHistoryControlSequence = sanitized.pendingControlSequence; if (sanitized.visibleText.length > 0) { - session.history = capHistory( - `${session.history}${sanitized.visibleText}`, - historyLineLimit, - ); + session.history.append(sanitized.visibleText); } const eventStamp = advanceEventSequence(session); @@ -2221,7 +2423,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func liveSession.cwd = input.cwd; liveSession.worktreePath = nextWorktreePath; liveSession.runtimeEnv = nextRuntimeEnv; - liveSession.history = ""; + liveSession.history.clear(); liveSession.pendingHistoryControlSequence = ""; liveSession.pendingProcessEvents = []; liveSession.pendingProcessEventIndex = 0; @@ -2230,7 +2432,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func } else if (liveSession.status === "exited" || liveSession.status === "error") { liveSession.runtimeEnv = nextRuntimeEnv; liveSession.worktreePath = nextWorktreePath; - liveSession.history = ""; + liveSession.history.clear(); liveSession.pendingHistoryControlSequence = ""; liveSession.pendingProcessEvents = []; liveSession.pendingProcessEventIndex = 0; @@ -2535,7 +2737,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func Effect.gen(function* () { const terminalId = input.terminalId; const session = yield* requireSession(input.threadId, terminalId); - session.history = ""; + session.history.clear(); session.pendingHistoryControlSequence = ""; session.pendingProcessEvents = []; session.pendingProcessEventIndex = 0; @@ -2572,7 +2774,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func worktreePath: input.worktreePath ?? null, status: "starting", pid: null, - history: "", + history: new BoundedTerminalHistory(historyLineLimit, "", historyByteLimit), pendingHistoryControlSequence: "", pendingProcessEvents: [], pendingProcessEventIndex: 0, @@ -2608,7 +2810,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func const cols = input.cols ?? session.cols; const rows = input.rows ?? session.rows; - session.history = ""; + session.history.clear(); session.pendingHistoryControlSequence = ""; session.pendingProcessEvents = []; session.pendingProcessEventIndex = 0; diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index 0eb99438e..13bb28fb3 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -3,7 +3,13 @@ import { isAtomCommandInterrupted, squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; -import { type TerminalSessionState } from "@t3tools/client-runtime/state/terminal"; +import { + INITIAL_TERMINAL_OUTPUT_CURSOR, + readTerminalOutputUpdate, + type TerminalOutputCursor, + type TerminalOutputUpdate, + type TerminalSessionState, +} from "@t3tools/client-runtime/state/terminal"; import { Plus, Square, @@ -104,8 +110,15 @@ function writeSystemMessage(terminal: GhosttyTerminalSurface, message: string): terminal.write(`\r\n[terminal] ${message}\r\n`); } -function writeTerminalBuffer(terminal: GhosttyTerminalSurface, buffer: string): void { - terminal.resetAndWrite(buffer); +export function writeTerminalOutputUpdate( + terminal: Pick, + update: TerminalOutputUpdate, +): void { + if (update.type === "reset") { + terminal.resetAndWrite(update.data); + } else if (update.type === "append") { + terminal.write(update.data); + } } function parseTerminalColor(value: string, fallback: GhosttyColor): GhosttyColor { @@ -395,9 +408,10 @@ export function TerminalViewport({ input: { threadId, terminalId, cols, rows }, }), ); - const terminalBuffer = terminalSession.buffer; + const terminalOutput = terminalSession.output; const terminalError = terminalSession.error; const terminalStatus = terminalSession.status; + const outputCursorRef = useRef(INITIAL_TERMINAL_OUTPUT_CURSOR); const synchronizedStatusRef = useRef("closed"); const synchronizeTerminalStatus = useEffectEvent( (terminal: GhosttyTerminalSurface, status: TerminalSessionState["status"]) => { @@ -418,14 +432,14 @@ export function TerminalViewport({ ); const terminalVersion = terminalSession.version; const previousSessionRef = useRef({ - buffer: terminalBuffer, + output: terminalOutput, error: terminalError, status: terminalStatus, version: terminalVersion, }); const latestSessionRef = useRef(previousSessionRef.current); latestSessionRef.current = { - buffer: terminalBuffer, + output: terminalOutput, error: terminalError, status: terminalStatus, version: terminalVersion, @@ -489,7 +503,14 @@ export function TerminalViewport({ } const latestSession = latestSessionRef.current; previousSessionRef.current = latestSession; - if (latestSession.buffer.length > 0) terminal.resetAndWrite(latestSession.buffer); + const initialOutput = readTerminalOutputUpdate( + latestSession.output, + INITIAL_TERMINAL_OUTPUT_CURSOR, + ); + if (initialOutput.type === "reset" && initialOutput.data.length > 0) { + writeTerminalOutputUpdate(terminal, initialOutput); + } + outputCursorRef.current = initialOutput.cursor; if (latestSession.error !== null) writeSystemMessage(terminal, latestSession.error); // Attaching to a session that already exited must still run exit handling // once, so mount synchronization starts from the empty "closed" state. @@ -862,7 +883,7 @@ export function TerminalViewport({ useEffect(() => { const terminal = terminalRef.current; const current = { - buffer: terminalBuffer, + output: terminalOutput, error: terminalError, status: terminalStatus, version: terminalVersion, @@ -874,18 +895,13 @@ export function TerminalViewport({ const previous = previousSessionRef.current; synchronizeTerminalStatus(terminal, current.status); - if (current.version === previous.version) { + if (current.version === previous.version && current.output === previous.output) { return; } - if ( - current.buffer.length >= previous.buffer.length && - current.buffer.startsWith(previous.buffer) - ) { - terminal.write(current.buffer.slice(previous.buffer.length)); - } else { - writeTerminalBuffer(terminal, current.buffer); - } + const outputUpdate = readTerminalOutputUpdate(current.output, outputCursorRef.current); + writeTerminalOutputUpdate(terminal, outputUpdate); + outputCursorRef.current = outputUpdate.cursor; terminal.clearSelection(); if (current.error !== null && current.error !== previous.error) { @@ -898,7 +914,7 @@ export function TerminalViewport({ }); } previousSessionRef.current = current; - }, [autoFocus, terminalBuffer, terminalError, terminalStatus, terminalVersion]); + }, [autoFocus, terminalOutput, terminalError, terminalStatus, terminalVersion]); useEffect(() => { if (!autoFocus) return; diff --git a/apps/web/src/terminal/ghostty/core.test.ts b/apps/web/src/terminal/ghostty/core.test.ts index 8048d2211..48cc4256d 100644 --- a/apps/web/src/terminal/ghostty/core.test.ts +++ b/apps/web/src/terminal/ghostty/core.test.ts @@ -1,6 +1,24 @@ -import { describe, expect, it } from "vite-plus/test"; +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; +import { + applyTerminalAttachStreamEvent, + DEFAULT_MAX_TERMINAL_BUFFER_BYTES, + INITIAL_TERMINAL_OUTPUT_CURSOR, + nextTerminalAttachSeedState, + readTerminalOutputUpdate, + terminalOutputText, + type TerminalBufferState, +} from "@t3tools/client-runtime/state/terminal"; -import { ghosttyCellText } from "./core"; +import { writeTerminalOutputUpdate } from "../../components/ThreadTerminalDrawer"; +import { GHOSTTY_CELL_WIDE, GhosttyTerminalCore, ghosttyCellText } from "./core"; +import { loadGhosttyRuntime } from "./runtime"; + +vi.mock("./vendor/ghostty-vt.wasm?url", async () => ({ + default: (await import("./vendor/ghostty-vt.wasm?inline")).default, +})); +vi.mock("./vendor/ghostty-write-pty.wasm?url&no-inline", async () => ({ + default: (await import("./vendor/ghostty-write-pty.wasm?inline")).default, +})); function codepointView(codepoints: ReadonlyArray): DataView { const view = new DataView(new ArrayBuffer(codepoints.length * 4)); @@ -30,7 +48,333 @@ describe("ghosttyCellText", () => { expect([...text]).toEqual(["\u{1F642}", "\u{20E3}"]); }); + it("converts a single astral codepoint", () => { + expect(ghosttyCellText(codepointView([0x1f642]), 1)).toBe("πŸ™‚"); + }); + it("returns an empty string for empty cells", () => { expect(ghosttyCellText(codepointView([]), 0)).toBe(""); }); }); + +describe("GhosttyTerminalCore snapshots", () => { + const cores = new Set(); + + async function createCore(onData: (data: string) => void = () => {}) { + const core = await GhosttyTerminalCore.create( + 12, + 3, + 8, + 16, + { + foreground: { r: 255, g: 255, b: 255 }, + background: { r: 0, g: 0, b: 0 }, + cursor: { r: 255, g: 255, b: 255 }, + }, + onData, + ); + cores.add(core); + return core; + } + + function createSession(history: string) { + return applyTerminalAttachStreamEvent(nextTerminalAttachSeedState(), { + type: "snapshot", + snapshot: { + threadId: "terminal-stream-test", + terminalId: "term-1", + cwd: "/repo", + worktreePath: null, + status: "running", + pid: 123, + history, + exitCode: null, + exitSignal: null, + label: "Terminal", + updatedAt: "2026-09-04T00:00:00.000Z", + }, + }); + } + + function append(state: TerminalBufferState, data: string) { + return applyTerminalAttachStreamEvent(state, { + type: "output", + threadId: "terminal-stream-test", + terminalId: "term-1", + data, + }); + } + + afterEach(() => { + for (const core of cores) core.dispose(); + cores.clear(); + vi.restoreAllMocks(); + }); + + it("preserves styles, wide cells, and selection after shared memory grows", async () => { + const core = await createCore(); + const runtime = await loadGhosttyRuntime(); + const grapheme = `e${"\u0301".repeat(64)}`; + core.write(`\x1b[1;3;4;8;9;53;38;2;123;45;67;48;2;9;8;7m${grapheme}\x1b[0mη•ŒπŸ™‚`); + const cells = core.snapshot().rowData[0]!.cells; + expect(cells[0]).toEqual({ + text: grapheme, + wide: 0, + foreground: { r: 123, g: 45, b: 67 }, + background: { r: 9, g: 8, b: 7 }, + bold: true, + italic: true, + invisible: true, + strikethrough: true, + overline: true, + underline: true, + selected: false, + }); + expect(cells.slice(1, 5).map(({ text, wide }) => ({ text, wide }))).toEqual([ + { text: "η•Œ", wide: 0 }, + { text: "", wide: GHOSTTY_CELL_WIDE.spacerTail }, + { text: "πŸ™‚", wide: 0 }, + { text: "", wide: GHOSTTY_CELL_WIDE.spacerTail }, + ]); + + runtime.memory.grow(1); + core.setSelection({ x: 0, y: 0 }, { x: 2, y: 0 }); + expect(core.snapshot().rowData[0]!.cells[0]).toEqual({ ...cells[0], selected: true }); + core.clearSelection(); + expect(core.snapshot().rowData[0]!.cells[0]).toEqual(cells[0]); + + core.resetAndWrite("\x1b[2;7;38;2;40;100;200;48;2;12;34;56mC\x1b[0m"); + expect(core.snapshot().rowData[0]!.cells[0]).toMatchObject({ + text: "C", + foreground: { r: 22, g: 59, b: 112 }, + background: { r: 40, g: 100, b: 200 }, + bold: false, + underline: false, + selected: false, + }); + }); + + it("reuses a grown grapheme buffer and releases it on disposal", async () => { + const core = await createCore(); + const runtime = await loadGhosttyRuntime(); + core.write("ASCII"); + core.snapshot(); + + const grapheme = `z${"\u0301".repeat(256)}`; + core.resetAndWrite(`${grapheme}X`); + const alloc = vi.spyOn(runtime, "alloc"); + const free = vi.spyOn(runtime, "free"); + expect( + core + .snapshot() + .rowData[0]!.cells.slice(0, 2) + .map((cell) => cell.text), + ).toEqual([grapheme, "X"]); + expect(alloc).toHaveBeenCalledTimes(1); + const allocation = alloc.mock.results[0]!; + if (allocation.type !== "return") throw new Error("Grapheme allocation did not return"); + const buffer = allocation.value; + const capacity = alloc.mock.calls[0]![0]; + + core.write("\rQ\u0301"); + alloc.mockClear(); + expect(core.snapshot().rowData[0]!.cells[0]!.text).toBe("Q\u0301"); + expect(alloc).not.toHaveBeenCalled(); + core.dispose(); + expect(free).toHaveBeenCalledWith(buffer, capacity); + }); + + it.each(["varied", "identical"] as const)( + "preserves Ghostty state through a full MiB of %s output without rollover resets", + async (kind) => { + const [core, reference] = await Promise.all([createCore(), createCore()]); + const initial = "\x1b[31m"; + let state = createSession(initial); + const first = readTerminalOutputUpdate(state.output, INITIAL_TERMINAL_OUTPUT_CURSOR); + writeTerminalOutputUpdate(core, first); + reference.resetAndWrite(initial); + let cursor = first.cursor; + const reset = vi.spyOn(core, "resetAndWrite"); + const inputs: string[] = []; + let receivedCharacters = 0; + + for (let index = 0; index < 128; index += 1) { + const data = + kind === "identical" + ? "x".repeat(8192) + : `${index.toString().padStart(4, "0")}\r\n${"x".repeat(8186)}`; + inputs.push(data); + state = append(state, data); + const update = readTerminalOutputUpdate(state.output, cursor); + if (update.type !== "append") throw new Error(`Expected append, received ${update.type}`); + receivedCharacters += update.data.length; + writeTerminalOutputUpdate(core, update); + cursor = update.cursor; + } + + reference.write(inputs.join("")); + expect(receivedCharacters).toBe(1024 * 1024); + expect(state.output.retainedBytes).toBe(DEFAULT_MAX_TERMINAL_BUFFER_BYTES); + expect(reset).not.toHaveBeenCalled(); + expect(core.snapshot()).toEqual(reference.snapshot()); + }, + ); + + it("preserves Unicode and split ANSI parser state across batched renderer reads", async () => { + const [core, reference] = await Promise.all([createCore(), createCore()]); + let state = createSession(""); + const first = readTerminalOutputUpdate(state.output, INITIAL_TERMINAL_OUTPUT_CURSOR); + writeTerminalOutputUpdate(core, first); + let cursor = first.cursor; + const reset = vi.spyOn(core, "resetAndWrite"); + const inputs = [ + `${"a".repeat(16_383)}πŸ™‚`, + "\x1b[3", + "1m", + "e", + "\u0301η•ŒπŸ™‚", + "\x1b[0", + "m\r\n", + "\x1b]8;;https://t3.codes\x1b", + "\\link", + "\x1b]8;;\x1b", + "\\\x1b[?1049h", + "alternate", + "\x1b[?1049l", + "\r\nend", + ]; + let received = ""; + for (const [index, data] of inputs.entries()) { + state = append(state, data); + if (index % 3 !== 0 && index !== inputs.length - 1) continue; + const update = readTerminalOutputUpdate(state.output, cursor); + if (update.type !== "append") throw new Error(`Expected append, received ${update.type}`); + received += update.data; + writeTerminalOutputUpdate(core, update); + cursor = update.cursor; + } + + reference.write(inputs.join("")); + expect(received).toBe(inputs.join("")); + expect(reset).not.toHaveBeenCalled(); + expect(core.snapshot()).toEqual(reference.snapshot()); + }); + + it("answers a live VT query when batched reads cross a chunk compaction", async () => { + const replies: string[] = []; + const core = await createCore((data) => replies.push(data)); + core.write("\x1b[5n"); + expect(replies).toEqual(["\x1b[0n"]); + replies.length = 0; + + let state = createSession(""); + const initial = readTerminalOutputUpdate(state.output, INITIAL_TERMINAL_OUTPUT_CURSOR); + writeTerminalOutputUpdate(core, initial); + let cursor = initial.cursor; + for (let index = 0; index < 1000; index += 1) { + state = append(state, "x"); + const update = readTerminalOutputUpdate(state.output, cursor); + writeTerminalOutputUpdate(core, update); + cursor = update.cursor; + } + for (let index = 0; index < 24; index += 1) state = append(state, "x"); + state = append(state, "\x1b[5n"); + const update = readTerminalOutputUpdate(state.output, cursor); + writeTerminalOutputUpdate(core, update); + + expect({ type: update.type, replies }).toEqual({ type: "append", replies: ["\x1b[0n"] }); + }); + + it("recovers a lagging renderer once from bounded output and resumes appending", async () => { + const [core, reference] = await Promise.all([createCore(), createCore()]); + let state = createSession("\x1b[31mold"); + const initial = readTerminalOutputUpdate(state.output, INITIAL_TERMINAL_OUTPUT_CURSOR); + writeTerminalOutputUpdate(core, initial); + const reset = vi.spyOn(core, "resetAndWrite"); + const data = "line\r\n".repeat(8192); + for (let index = 0; index < 16; index += 1) state = append(state, data); + + const recovery = readTerminalOutputUpdate(state.output, initial.cursor); + if (recovery.type !== "reset") throw new Error(`Expected reset, received ${recovery.type}`); + expect(new TextEncoder().encode(recovery.data).byteLength).toBe( + DEFAULT_MAX_TERMINAL_BUFFER_BYTES, + ); + writeTerminalOutputUpdate(core, recovery); + reference.resetAndWrite(recovery.data); + expect(core.snapshot()).toEqual(reference.snapshot()); + + state = append(state, "\r\nlatest"); + const next = readTerminalOutputUpdate(state.output, recovery.cursor); + expect(next.type).toBe("append"); + writeTerminalOutputUpdate(core, next); + reference.write("\r\nlatest"); + expect(reset).toHaveBeenCalledTimes(1); + expect(core.snapshot()).toEqual(reference.snapshot()); + }); + + it("replays the latest retained output when WASM arrives after several events", async () => { + const pendingCore = createCore(); + let state = createSession("before"); + state = append(state, "\r\nduring "); + state = append(state, "πŸ™‚ load"); + const core = await pendingCore; + const first = readTerminalOutputUpdate(state.output, INITIAL_TERMINAL_OUTPUT_CURSOR); + writeTerminalOutputUpdate(core, first); + const reference = await createCore(); + reference.resetAndWrite(terminalOutputText(state.output)); + const reset = vi.spyOn(core, "resetAndWrite"); + + state = append(state, "\r\nafter"); + const next = readTerminalOutputUpdate(state.output, first.cursor); + expect(next).toMatchObject({ type: "append", data: "\r\nafter" }); + writeTerminalOutputUpdate(core, next); + reference.write("\r\nafter"); + expect(reset).not.toHaveBeenCalled(); + expect(core.snapshot()).toEqual(reference.snapshot()); + }); + + it("resets real Ghostty for a repeated snapshot, clear, restart, and a fresh attach", async () => { + const [core, reference] = await Promise.all([createCore(), createCore()]); + let state = createSession("hello"); + const initial = readTerminalOutputUpdate(state.output, INITIAL_TERMINAL_OUTPUT_CURSOR); + writeTerminalOutputUpdate(core, initial); + let cursor = initial.cursor; + + const snapshot = { + threadId: "terminal-stream-test", + terminalId: "term-1", + cwd: "/repo", + worktreePath: null, + status: "running" as const, + pid: 456, + history: "hello", + exitCode: null, + exitSignal: null, + label: "Terminal", + updatedAt: "2026-09-04T00:00:01.000Z", + }; + const events = [ + { type: "snapshot", snapshot }, + { type: "cleared", threadId: snapshot.threadId, terminalId: snapshot.terminalId }, + { type: "restarted", threadId: snapshot.threadId, terminalId: snapshot.terminalId, snapshot }, + ] as const; + for (const event of events) { + core.write("\r\nstale local text"); + state = applyTerminalAttachStreamEvent(state, event); + const update = readTerminalOutputUpdate(state.output, cursor); + expect(update.type).toBe("reset"); + writeTerminalOutputUpdate(core, update); + cursor = update.cursor; + reference.resetAndWrite(event.type === "cleared" ? "" : "hello"); + expect(core.snapshot()).toEqual(reference.snapshot()); + } + + core.write("\r\nstale local text"); + state = createSession("hello"); + const reattached = readTerminalOutputUpdate(state.output, cursor); + expect(reattached.type).toBe("reset"); + writeTerminalOutputUpdate(core, reattached); + reference.resetAndWrite("hello"); + expect(core.snapshot()).toEqual(reference.snapshot()); + }); +}); diff --git a/apps/web/src/terminal/ghostty/core.ts b/apps/web/src/terminal/ghostty/core.ts index 6f6cbbe0a..d01e20529 100644 --- a/apps/web/src/terminal/ghostty/core.ts +++ b/apps/web/src/terminal/ghostty/core.ts @@ -174,6 +174,7 @@ function sameColor(left: GhosttyColor, right: GhosttyColor): boolean { * every codepoint into String.fromCodePoint at once. */ export function ghosttyCellText(codepointView: DataView, graphemeLength: number): string { + if (graphemeLength === 1) return String.fromCodePoint(codepointView.getUint32(0, true)); const CHUNK_SIZE = 4_096; let text = ""; for (let start = 0; start < graphemeLength; start += CHUNK_SIZE) { @@ -206,6 +207,8 @@ export class GhosttyTerminalCore { private ptyWriterId = 0; private ptyWriter: ((data: string) => void) | null = null; private scratch = 0; + private graphemes = 0; + private graphemeCapacity = 0; private style = 0; private scrollbar = 0; private rows: GhosttyRow[] = []; @@ -878,6 +881,7 @@ export class GhosttyTerminalCore { this.runtime.free(this.scrollbar, this.runtime.layout("GhosttyTerminalScrollbar").size); } if (this.scratch) this.runtime.free(this.scratch, 16); + if (this.graphemes) this.runtime.free(this.graphemes, this.graphemeCapacity); for (const slot of [ this.mouseEventSlot, this.mouseEncoderSlot, @@ -944,6 +948,7 @@ export class GhosttyTerminalCore { ), ); const cellsIterator = this.runtime.readPointer(this.rowCellsSlot); + const { size: styleSize, fields: styleFields } = this.runtime.layout("GhosttyStyle"); const cells: GhosttyCell[] = []; while ( cells.length < cols && @@ -951,7 +956,6 @@ export class GhosttyTerminalCore { ) { let foreground = this.getCellColor(cellsIterator, CELL_DATA.foreground, defaultForeground); let background = this.getCellColor(cellsIterator, CELL_DATA.background, defaultBackground); - const styleSize = this.runtime.layout("GhosttyStyle").size; this.runtime.bytes(this.style, styleSize).fill(0); this.runtime.setField(this.style, "GhosttyStyle", "size", styleSize); this.runtime.call( @@ -960,30 +964,30 @@ export class GhosttyTerminalCore { CELL_DATA.style, this.style, ); - const inverse = this.runtime.readField(this.style, "GhosttyStyle", "inverse") !== 0; - if (inverse) [foreground, background] = [background, foreground]; - if (this.runtime.readField(this.style, "GhosttyStyle", "faint") !== 0) { - foreground = blend(foreground, background); - } const graphemeLength = this.getCellU32(cellsIterator, CELL_DATA.graphemesLength); let text = ""; if (graphemeLength > 0) { const bufferSize = graphemeLength * 4; - const codepoints = this.runtime.alloc(bufferSize); + if (bufferSize > this.graphemeCapacity) { + const capacity = Math.max(bufferSize, this.graphemeCapacity * 2); + const buffer = this.runtime.alloc(capacity); + this.runtime.free(this.graphemes, this.graphemeCapacity); + this.graphemes = buffer; + this.graphemeCapacity = capacity; + } if ( this.runtime.call( "ghostty_render_state_row_cells_get", cellsIterator, CELL_DATA.graphemes, - codepoints, + this.graphemes, ) === GHOSTTY_SUCCESS ) { // Read through a DataView: the byte-array allocator guarantees no // 4-byte alignment, which a Uint32Array view would require. - const codepointView = this.runtime.view(codepoints, bufferSize); + const codepointView = this.runtime.view(this.graphemes, bufferSize); text = ghosttyCellText(codepointView, graphemeLength); } - this.runtime.free(codepoints, bufferSize); } let wide = 0; if (text.length === 0 && cells.at(-1)?.text.length) { @@ -1004,18 +1008,27 @@ export class GhosttyTerminalCore { ); wide = this.runtime.view(this.scratch + 8, 4).getUint32(0, true); } + const selected = this.getCellBool(cellsIterator, CELL_DATA.selected); + // Read the style after allocation and ABI calls, which can grow WASM memory. + const styleView = this.runtime.view(this.style, styleSize); + if (styleView.getUint8(styleFields.inverse!.offset) !== 0) { + [foreground, background] = [background, foreground]; + } + if (styleView.getUint8(styleFields.faint!.offset) !== 0) { + foreground = blend(foreground, background); + } cells.push({ text, wide, foreground, background, - bold: this.runtime.readField(this.style, "GhosttyStyle", "bold") !== 0, - italic: this.runtime.readField(this.style, "GhosttyStyle", "italic") !== 0, - invisible: this.runtime.readField(this.style, "GhosttyStyle", "invisible") !== 0, - strikethrough: this.runtime.readField(this.style, "GhosttyStyle", "strikethrough") !== 0, - overline: this.runtime.readField(this.style, "GhosttyStyle", "overline") !== 0, - underline: this.runtime.readField(this.style, "GhosttyStyle", "underline") !== 0, - selected: this.getCellBool(cellsIterator, CELL_DATA.selected), + bold: styleView.getUint8(styleFields.bold!.offset) !== 0, + italic: styleView.getUint8(styleFields.italic!.offset) !== 0, + invisible: styleView.getUint8(styleFields.invisible!.offset) !== 0, + strikethrough: styleView.getUint8(styleFields.strikethrough!.offset) !== 0, + overline: styleView.getUint8(styleFields.overline!.offset) !== 0, + underline: styleView.getInt32(styleFields.underline!.offset, true) !== 0, + selected, }); } while (cells.length < cols) cells.push(this.emptyCell(defaultForeground, defaultBackground)); diff --git a/apps/web/src/terminal/ghostty/runtime.ts b/apps/web/src/terminal/ghostty/runtime.ts index aca82e7cc..976900fa6 100644 --- a/apps/web/src/terminal/ghostty/runtime.ts +++ b/apps/web/src/terminal/ghostty/runtime.ts @@ -23,6 +23,7 @@ export class GhosttyRuntime { readonly memory: WebAssembly.Memory; readonly layouts: TypeLayouts; private readonly exports: WebAssembly.Exports; + private memoryView: DataView; private readonly ptyWriters = new Map void>(); private nextPtyWriterId = 1; private writePtyFunctionIndex = 0; @@ -34,6 +35,7 @@ export class GhosttyRuntime { throw new Error("libghostty-vt did not export WebAssembly memory"); } this.memory = memory; + this.memoryView = new DataView(memory.buffer); const jsonPointer = this.call("ghostty_type_json"); const bytes = new Uint8Array(memory.buffer); let end = jsonPointer; @@ -104,7 +106,7 @@ export class GhosttyRuntime { } readPointer(slot: number): number { - return new DataView(this.memory.buffer).getUint32(slot, true); + return this.currentMemoryView().getUint32(slot, true); } attachPtyWriter(terminal: number, writer: (data: string) => void): number { @@ -132,27 +134,36 @@ export class GhosttyRuntime { return new Uint8Array(this.memory.buffer, pointer, size); } + /** Reuse scalar reads across cells, refreshing after any terminal grows shared WASM memory. */ + private currentMemoryView(): DataView { + if (this.memoryView.buffer !== this.memory.buffer) { + this.memoryView = new DataView(this.memory.buffer); + } + return this.memoryView; + } + setField(pointer: number, structName: string, fieldName: string, value: number): void { const field = this.layout(structName).fields[fieldName]; if (!field) throw new Error(`libghostty-vt field is unavailable: ${structName}.${fieldName}`); - const view = this.view(pointer + field.offset, field.size); + const view = this.currentMemoryView(); + const offset = pointer + field.offset; switch (field.type) { case "bool": case "u8": - view.setUint8(0, value); + view.setUint8(offset, value); return; case "u16": - view.setUint16(0, value, true); + view.setUint16(offset, value, true); return; case "i32": - view.setInt32(0, value, true); + view.setInt32(offset, value, true); return; case "u32": case "enum": - view.setUint32(0, value, true); + view.setUint32(offset, value, true); return; case "u64": - view.setBigUint64(0, BigInt(value), true); + view.setBigUint64(offset, BigInt(value), true); return; default: throw new Error(`Unsupported libghostty-vt field type: ${field.type}`); @@ -162,20 +173,21 @@ export class GhosttyRuntime { readField(pointer: number, structName: string, fieldName: string): number { const field = this.layout(structName).fields[fieldName]; if (!field) throw new Error(`libghostty-vt field is unavailable: ${structName}.${fieldName}`); - const view = this.view(pointer + field.offset, field.size); + const view = this.currentMemoryView(); + const offset = pointer + field.offset; switch (field.type) { case "bool": case "u8": - return view.getUint8(0); + return view.getUint8(offset); case "u16": - return view.getUint16(0, true); + return view.getUint16(offset, true); case "i32": - return view.getInt32(0, true); + return view.getInt32(offset, true); case "u32": case "enum": - return view.getUint32(0, true); + return view.getUint32(offset, true); case "u64": - return Number(view.getBigUint64(0, true)); + return Number(view.getBigUint64(offset, true)); default: throw new Error(`Unsupported libghostty-vt field type: ${field.type}`); } diff --git a/docs/internals/terminal-runtime.md b/docs/internals/terminal-runtime.md new file mode 100644 index 000000000..addfa1ce0 --- /dev/null +++ b/docs/internals/terminal-runtime.md @@ -0,0 +1,52 @@ +# Terminal runtime + +The environment server owns terminal processes, retained output history, and +session lifecycle. Web, desktop, and mobile clients attach to the same +server-owned session over the environment RPC connection. The desktop renderer +does not own a separate PTY. Clients can reconnect to a running PTY or share +it with another client. + +## Output path + +PTY output follows this path: + +```text +PTY callback + -> ordered process-event drain + -> bounded retained-history append + -> live terminal output event + -> coalesced history persistence +``` + +Live output events contain only the new PTY data. Full retained history is +materialized only when the server returns a snapshot or when the coalescing +persistence worker writes the latest state. + +Retained history is limited to 5,000 lines and 8 MiB of UTF-8 text per terminal. +The server discards the oldest output when either limit is reached. A byte +cutoff can shorten the oldest retained line, but does not split a Unicode code +point. Live output is not truncated. + +History uses small chunks with byte and newline counts. Appending output scans +the new text and any removed chunk prefix, not the full retained history. +Empty lines, incomplete final lines, and trailing newlines remain unchanged +within the limits. Split surrogate pairs are joined before byte eviction. + +Discard each chunk's string reference as soon as it leaves retained history. +Array compaction can run later. Shared web and mobile client state retains at +most 512 KiB, so each client can display less scrollback than the server keeps. + +Measure sustained-output changes against a full retained history so terminal +throughput does not regress unnoticed. + +## Persistence + +History persistence is keyed by terminal session and coalesces pending writes. +The worker reads the newest bounded-history state after its debounce instead +of receiving a newly materialized full string for every PTY callback. Clear, +restart, close, and final flush operations still force the latest state to +disk before their lifecycle boundary completes. + +Restoration reads at most the last 8 MiB from current and legacy history files. +It skips an incomplete UTF-8 code point at the start and applies the line limit +before rewriting oversized files. File handles close before that rewrite. diff --git a/docs/user/terminal.md b/docs/user/terminal.md new file mode 100644 index 000000000..786e5a880 --- /dev/null +++ b/docs/user/terminal.md @@ -0,0 +1,8 @@ +# Terminal history + +Each terminal keeps up to 5,000 lines and 8 MiB of scrollback on its environment +server. Pylon removes the oldest output when either limit is reached. A long +line can be shortened at the start. New terminal output is not truncated. + +These limits apply when you reconnect and when Pylon restores saved terminal +history. A client can show less scrollback than the server keeps. diff --git a/packages/client-runtime/src/state/terminal.ts b/packages/client-runtime/src/state/terminal.ts index 028f7a8c6..3bc2bca78 100644 --- a/packages/client-runtime/src/state/terminal.ts +++ b/packages/client-runtime/src/state/terminal.ts @@ -13,7 +13,7 @@ import { subscribe, type EnvironmentRpcInput } from "../rpc/client.ts"; import { applyTerminalAttachStreamEvent, applyTerminalMetadataStreamEvent, - EMPTY_TERMINAL_BUFFER_STATE, + nextTerminalAttachSeedState, } from "./terminalSession.ts"; export function createTerminalEnvironmentAtoms( @@ -40,8 +40,10 @@ export function createTerminalEnvironmentAtoms( attach: createEnvironmentSubscriptionAtomFamily(runtime, { label: "environment-data:terminal:attach", subscribe: (input: EnvironmentRpcInput) => - subscribe(WS_METHODS.terminalAttach, input).pipe( - Stream.scan(EMPTY_TERMINAL_BUFFER_STATE, applyTerminalAttachStreamEvent), + Stream.suspend(() => + subscribe(WS_METHODS.terminalAttach, input).pipe( + Stream.scan(nextTerminalAttachSeedState(), applyTerminalAttachStreamEvent), + ), ), }), events: createEnvironmentRpcSubscriptionAtomFamily(runtime, { diff --git a/packages/client-runtime/src/state/terminalOutput.ts b/packages/client-runtime/src/state/terminalOutput.ts new file mode 100644 index 000000000..fcb0389c4 --- /dev/null +++ b/packages/client-runtime/src/state/terminalOutput.ts @@ -0,0 +1,320 @@ +export interface TerminalOutputChunk { + /** UTF-16 string offset within this generation and reset. */ + readonly startOffset: number; + readonly data: string; + readonly byteLength: number; +} + +export interface TerminalOutputState { + readonly generation: number; + readonly chunks: ReadonlyArray; + readonly retainedBytes: number; + readonly resetVersion: number; + readonly nextOffset: number; +} + +export interface TerminalOutputCursor { + readonly generation: number; + readonly resetVersion: number; + readonly offset: number; +} + +/** Forces the first `readTerminalOutputUpdate` to resynchronize from a reset. */ +export const INITIAL_TERMINAL_OUTPUT_CURSOR = Object.freeze({ + generation: -1, + resetVersion: -1, + offset: 0, +}); + +export type TerminalOutputUpdate = + | { + readonly type: "none"; + readonly cursor: TerminalOutputCursor; + } + | { + readonly type: "reset"; + readonly data: string; + readonly cursor: TerminalOutputCursor; + } + | { + readonly type: "append"; + readonly cursor: TerminalOutputCursor; + readonly data: string; + }; + +export const DEFAULT_MAX_TERMINAL_BUFFER_BYTES = 512 * 1024; +const DEFAULT_TERMINAL_CHUNK_BYTES = 16 * 1024; +const MAX_TERMINAL_OUTPUT_CHUNKS = 1_024; +const textEncoder = new TextEncoder(); +// A BOM at a retained chunk boundary is terminal data, not an encoding marker. +const textDecoder = new TextDecoder("utf-8", { ignoreBOM: true }); + +export const EMPTY_TERMINAL_OUTPUT_STATE = Object.freeze({ + generation: 0, + chunks: Object.freeze([]), + retainedBytes: 0, + resetVersion: 0, + nextOffset: 0, +}); + +interface Utf8Chunk { + readonly data: string; + readonly byteLength: number; +} + +/** + * Split a string into chunks of at most `maxBytes` UTF-8 bytes without cutting + * a code point in half. The retained-output budget always supplies a positive + * size. Only new output is encoded on live updates. + * + * A chunk that fits whole is returned as the original string, so the common + * small-write path pays one encode and no decode. + */ +function splitStringByUtf8Bytes(data: string, maxBytes: number): ReadonlyArray { + if (data.length === 0) return []; + + const encoded = textEncoder.encode(data); + if (encoded.byteLength <= maxBytes) { + return [{ data, byteLength: encoded.byteLength }]; + } + + const chunks: Utf8Chunk[] = []; + let offset = 0; + while (offset < encoded.byteLength) { + let end = Math.min(offset + maxBytes, encoded.byteLength); + while (end < encoded.byteLength && ((encoded[end] ?? 0) & 0xc0) === 0x80) { + end -= 1; + } + // A degenerate budget smaller than one code point still has to advance: + // include the whole code point rather than looping forever. + if (end === offset) { + end = Math.min(offset + maxBytes, encoded.byteLength); + while (end < encoded.byteLength && ((encoded[end] ?? 0) & 0xc0) === 0x80) { + end += 1; + } + } + const bytes = encoded.subarray(offset, end); + chunks.push({ data: textDecoder.decode(bytes), byteLength: bytes.byteLength }); + offset = end; + } + + return chunks; +} + +function trimBufferToBytes(buffer: string, maxBufferBytes: number): string { + if (maxBufferBytes <= 0) { + return ""; + } + + const encoded = textEncoder.encode(buffer); + if (encoded.byteLength <= maxBufferBytes) { + return buffer; + } + + let start = encoded.byteLength - maxBufferBytes; + while (start < encoded.length) { + const byte = encoded[start]; + if (byte === undefined || (byte & 0b1100_0000) !== 0b1000_0000) { + break; + } + start += 1; + } + + return textDecoder.decode(encoded.subarray(start)); +} + +function splitOutputChunks( + data: string, + firstOffset: number, + maxChunkBytes = DEFAULT_TERMINAL_CHUNK_BYTES, +): { + readonly chunks: ReadonlyArray; + readonly nextOffset: number; + readonly byteLength: number; +} { + const split = splitStringByUtf8Bytes(data, maxChunkBytes); + let byteLength = 0; + let nextOffset = firstOffset; + const chunks = split.map((chunk) => { + byteLength += chunk.byteLength; + const startOffset = nextOffset; + nextOffset += chunk.data.length; + return { + startOffset, + data: chunk.data, + byteLength: chunk.byteLength, + }; + }); + + return { + chunks, + nextOffset, + byteLength, + }; +} + +/** + * Merge adjacent chunks without changing their string positions. A reader can + * still append the unread suffix when its cursor falls inside a merged chunk. + */ +function compactRetainedChunks(chunks: ReadonlyArray) { + const compacted: TerminalOutputChunk[] = []; + for (const chunk of chunks) { + const previous = compacted.at(-1); + if ( + previous !== undefined && + previous.startOffset + previous.data.length === chunk.startOffset && + previous.byteLength + chunk.byteLength <= DEFAULT_TERMINAL_CHUNK_BYTES + ) { + compacted[compacted.length - 1] = { + startOffset: previous.startOffset, + data: `${previous.data}${chunk.data}`, + byteLength: previous.byteLength + chunk.byteLength, + }; + } else { + compacted.push(chunk); + } + } + return compacted; +} + +// Scan only the removed prefix instead of encoding retained output again. +function trimOutputChunkStart( + chunk: TerminalOutputChunk, + bytesToDrop: number, +): TerminalOutputChunk { + let offset = 0; + let droppedBytes = 0; + while (droppedBytes < bytesToDrop && offset < chunk.data.length) { + const codepoint = chunk.data.codePointAt(offset)!; + droppedBytes += codepoint <= 0x7f ? 1 : codepoint <= 0x7ff ? 2 : codepoint <= 0xffff ? 3 : 4; + offset += codepoint <= 0xffff ? 1 : 2; + } + return { + ...chunk, + startOffset: chunk.startOffset + offset, + data: chunk.data.slice(offset), + byteLength: chunk.byteLength - droppedBytes, + }; +} + +function appendOutput( + current: TerminalOutputState, + data: string, + maxBufferBytes: number, +): TerminalOutputState { + if (data.length === 0) return current; + if (maxBufferBytes <= 0) { + return { + generation: current.generation, + chunks: [], + retainedBytes: 0, + resetVersion: current.resetVersion + 1, + nextOffset: current.nextOffset + data.length, + }; + } + const appended = splitOutputChunks( + data, + current.nextOffset, + Math.min(DEFAULT_TERMINAL_CHUNK_BYTES, Math.max(1, maxBufferBytes)), + ); + + const chunks = [...current.chunks, ...appended.chunks]; + let retainedBytes = current.retainedBytes + appended.byteLength; + let firstRetainedIndex = 0; + while (retainedBytes > maxBufferBytes && firstRetainedIndex < chunks.length) { + const first = chunks[firstRetainedIndex]!; + const bytesToDrop = retainedBytes - maxBufferBytes; + if (bytesToDrop < first.byteLength) { + const trimmed = trimOutputChunkStart(first, bytesToDrop); + retainedBytes -= first.byteLength - trimmed.byteLength; + if (trimmed.byteLength > 0) { + chunks[firstRetainedIndex] = trimmed; + } else { + firstRetainedIndex += 1; + } + break; + } + retainedBytes -= first.byteLength; + firstRetainedIndex += 1; + } + + let retainedChunks = firstRetainedIndex === 0 ? chunks : chunks.slice(firstRetainedIndex); + if (retainedChunks.length > MAX_TERMINAL_OUTPUT_CHUNKS) { + retainedChunks = compactRetainedChunks(retainedChunks); + const excessChunks = retainedChunks.length - MAX_TERMINAL_OUTPUT_CHUNKS; + if (excessChunks > 0) { + for (const chunk of retainedChunks.slice(0, excessChunks)) { + retainedBytes -= chunk.byteLength; + } + retainedChunks = retainedChunks.slice(excessChunks); + } + } + + return { + generation: current.generation, + chunks: retainedChunks, + retainedBytes, + resetVersion: current.resetVersion, + nextOffset: appended.nextOffset, + }; +} + +function resetOutput( + current: TerminalOutputState, + data: string, + maxBufferBytes: number, +): TerminalOutputState { + const retained = trimBufferToBytes(data, maxBufferBytes); + const reset = splitOutputChunks( + retained, + 0, + Math.min(DEFAULT_TERMINAL_CHUNK_BYTES, Math.max(1, maxBufferBytes)), + ); + return { + generation: current.generation, + chunks: reset.chunks, + retainedBytes: reset.byteLength, + resetVersion: current.resetVersion + 1, + nextOffset: reset.nextOffset, + }; +} + +export function terminalOutputText(output: TerminalOutputState): string { + return output.chunks.map((chunk) => chunk.data).join(""); +} + +export function readTerminalOutputUpdate( + output: TerminalOutputState, + cursor: TerminalOutputCursor, +): TerminalOutputUpdate { + const nextCursor = { + generation: output.generation, + resetVersion: output.resetVersion, + offset: output.nextOffset, + }; + const firstChunk = output.chunks[0]; + if ( + cursor.generation !== output.generation || + cursor.resetVersion !== output.resetVersion || + cursor.offset < (firstChunk?.startOffset ?? output.nextOffset) + ) { + return { type: "reset", data: terminalOutputText(output), cursor: nextCursor }; + } + + const appended = output.chunks.filter( + (chunk) => chunk.startOffset + chunk.data.length > cursor.offset, + ); + if (appended.length === 0) { + return { type: "none", cursor: nextCursor }; + } + return { + type: "append", + data: appended + .map((chunk) => chunk.data.slice(Math.max(0, cursor.offset - chunk.startOffset))) + .join(""), + cursor: nextCursor, + }; +} + +export { appendOutput, resetOutput }; diff --git a/packages/client-runtime/src/state/terminalSession.test.ts b/packages/client-runtime/src/state/terminalSession.test.ts index 85c57592d..d9438860d 100644 --- a/packages/client-runtime/src/state/terminalSession.test.ts +++ b/packages/client-runtime/src/state/terminalSession.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vite-plus/test"; +import { describe, expect, it, vi } from "vite-plus/test"; import { EnvironmentId, TerminalSessionSnapshot, ThreadId } from "@t3tools/contracts"; @@ -6,8 +6,13 @@ import { applyTerminalAttachStreamEvent, applyTerminalMetadataStreamEvent, combineTerminalSessionState, + DEFAULT_MAX_TERMINAL_BUFFER_BYTES, EMPTY_TERMINAL_BUFFER_STATE, + INITIAL_TERMINAL_OUTPUT_CURSOR, + nextTerminalAttachSeedState, + readTerminalOutputUpdate, selectRunningSubprocessTerminalIds, + terminalOutputText, } from "./terminalSession.ts"; const TARGET = { @@ -126,11 +131,11 @@ describe("terminal session reducers", () => { ); expect(output).toMatchObject({ - buffer: "lo world", status: "running", error: null, version: 2, }); + expect(terminalOutputText(output.output)).toBe("lo world"); }); it("reduces terminal metadata snapshots, upserts, and removals", () => { @@ -182,6 +187,268 @@ describe("terminal session reducers", () => { 4, ); - expect(state.buffer).toBe("πŸ™‚"); + expect(terminalOutputText(state.output)).toBe("πŸ™‚"); + }); + + it("preserves a BOM code point at a new output chunk boundary", () => { + const initial = applyTerminalAttachStreamEvent(EMPTY_TERMINAL_BUFFER_STATE, { + type: "snapshot", + snapshot: { ...BASE_SNAPSHOT, history: "" }, + }); + const cursor = readTerminalOutputUpdate(initial.output, INITIAL_TERMINAL_OUTPUT_CURSOR).cursor; + const data = `${"x".repeat(16_384)}\uFEFF${"y".repeat(20)}`; + const state = applyTerminalAttachStreamEvent(initial, { + type: "output", + threadId: TARGET.threadId, + terminalId: TARGET.terminalId, + data, + }); + + expect(terminalOutputText(state.output)).toBe(data); + expect(readTerminalOutputUpdate(state.output, cursor)).toMatchObject({ type: "append", data }); + expect(state.output.retainedBytes).toBe(new TextEncoder().encode(data).byteLength); + }); + + it("preserves a leading BOM in the retained snapshot tail", () => { + const state = applyTerminalAttachStreamEvent( + EMPTY_TERMINAL_BUFFER_STATE, + { type: "snapshot", snapshot: { ...BASE_SNAPSHOT, history: "discard\uFEFFtail" } }, + 7, + ); + + expect(terminalOutputText(state.output)).toBe("\uFEFFtail"); + expect(state.output.retainedBytes).toBe(7); + }); + + it("trims whole Unicode code points from a partially retained chunk", () => { + let state = applyTerminalAttachStreamEvent( + EMPTY_TERMINAL_BUFFER_STATE, + { type: "snapshot", snapshot: { ...BASE_SNAPSHOT, history: "Γ©η•ŒπŸ™‚end" } }, + 12, + ); + let cursor = readTerminalOutputUpdate(state.output, INITIAL_TERMINAL_OUTPUT_CURSOR).cursor; + for (const [data, expected, retainedBytes] of [ + ["x", "η•ŒπŸ™‚endx", 11], + ["yz", "πŸ™‚endxyz", 10], + ["abc", "endxyzabc", 9], + ] as const) { + state = applyTerminalAttachStreamEvent( + state, + { type: "output", threadId: TARGET.threadId, terminalId: TARGET.terminalId, data }, + 12, + ); + const update = readTerminalOutputUpdate(state.output, cursor); + expect(update).toMatchObject({ type: "append", data }); + expect(terminalOutputText(state.output)).toBe(expected); + expect(state.output.retainedBytes).toBe(retainedBytes); + cursor = update.cursor; + } + }); + + it("delivers all output when several events arrive before a renderer reads", () => { + const initial = applyTerminalAttachStreamEvent(EMPTY_TERMINAL_BUFFER_STATE, { + type: "snapshot", + snapshot: BASE_SNAPSHOT, + }); + const cursor = readTerminalOutputUpdate(initial.output, INITIAL_TERMINAL_OUTPUT_CURSOR).cursor; + let state = initial; + for (const data of [" one", " two", " three"]) { + state = applyTerminalAttachStreamEvent(state, { + type: "output", + threadId: TARGET.threadId, + terminalId: TARGET.terminalId, + data, + }); + } + const update = readTerminalOutputUpdate(state.output, cursor); + expect(update).toMatchObject({ type: "append", data: " one two three" }); + expect(readTerminalOutputUpdate(state.output, update.cursor).type).toBe("none"); + }); + + it("preserves the byte-limited tail and resets a cursor before a partially trimmed chunk", () => { + const initial = applyTerminalAttachStreamEvent(EMPTY_TERMINAL_BUFFER_STATE, { + type: "snapshot", + snapshot: { ...BASE_SNAPSHOT, history: "" }, + }); + const staleCursor = readTerminalOutputUpdate( + initial.output, + INITIAL_TERMINAL_OUTPUT_CURSOR, + ).cursor; + const first = applyTerminalAttachStreamEvent(initial, { + type: "output", + threadId: TARGET.threadId, + terminalId: TARGET.terminalId, + data: "hello", + }); + const caughtUpCursor = readTerminalOutputUpdate(first.output, staleCursor).cursor; + const state = applyTerminalAttachStreamEvent( + first, + { type: "output", threadId: TARGET.threadId, terminalId: TARGET.terminalId, data: " world" }, + 8, + ); + + expect(readTerminalOutputUpdate(state.output, caughtUpCursor)).toMatchObject({ + type: "append", + data: " world", + }); + expect(readTerminalOutputUpdate(state.output, staleCursor)).toMatchObject({ + type: "reset", + data: "lo world", + }); + expect(state.output.retainedBytes).toBe(8); + }); + + it("does not encode retained history again when appending at the byte limit", () => { + let state = applyTerminalAttachStreamEvent(EMPTY_TERMINAL_BUFFER_STATE, { + type: "snapshot", + snapshot: { ...BASE_SNAPSHOT, history: "x".repeat(DEFAULT_MAX_TERMINAL_BUFFER_BYTES) }, + }); + const data = "y".repeat(8192); + const encode = vi.spyOn(TextEncoder.prototype, "encode"); + try { + for (let index = 0; index < 100; index += 1) { + state = applyTerminalAttachStreamEvent(state, { + type: "output", + threadId: TARGET.threadId, + terminalId: TARGET.terminalId, + data, + }); + } + expect(encode.mock.calls.reduce((total, [text]) => total + (text?.length ?? 0), 0)).toBe( + data.length * 100, + ); + expect(state.output.retainedBytes).toBe(DEFAULT_MAX_TERMINAL_BUFFER_BYTES); + } finally { + encode.mockRestore(); + } + }); + + it.each([0, -1])("discards output without encoding when the byte budget is %s", (maxBytes) => { + const initial = applyTerminalAttachStreamEvent(EMPTY_TERMINAL_BUFFER_STATE, { + type: "snapshot", + snapshot: BASE_SNAPSHOT, + }); + const cursor = readTerminalOutputUpdate(initial.output, INITIAL_TERMINAL_OUTPUT_CURSOR).cursor; + const data = "x".repeat(65_536); + const encode = vi.spyOn(TextEncoder.prototype, "encode"); + try { + const discarded = applyTerminalAttachStreamEvent( + initial, + { type: "output", threadId: TARGET.threadId, terminalId: TARGET.terminalId, data }, + maxBytes, + ); + expect(encode).not.toHaveBeenCalled(); + expect(discarded.output.nextOffset).toBe(initial.output.nextOffset + data.length); + expect(discarded.output.retainedBytes).toBe(0); + expect(readTerminalOutputUpdate(discarded.output, cursor)).toMatchObject({ + type: "reset", + data: "", + }); + + const empty = applyTerminalAttachStreamEvent( + initial, + { type: "output", threadId: TARGET.threadId, terminalId: TARGET.terminalId, data: "" }, + maxBytes, + ); + expect(empty.output).toBe(initial.output); + expect(encode).not.toHaveBeenCalled(); + } finally { + encode.mockRestore(); + } + }); + + it("resets for repeated snapshots, clear, and restart even when output text repeats", () => { + let state = applyTerminalAttachStreamEvent(EMPTY_TERMINAL_BUFFER_STATE, { + type: "snapshot", + snapshot: BASE_SNAPSHOT, + }); + let cursor = readTerminalOutputUpdate(state.output, INITIAL_TERMINAL_OUTPUT_CURSOR).cursor; + state = applyTerminalAttachStreamEvent(state, { type: "snapshot", snapshot: BASE_SNAPSHOT }); + const repeated = readTerminalOutputUpdate(state.output, cursor); + expect(repeated).toMatchObject({ type: "reset", data: "hello" }); + expect(state.version).toBe(2); + cursor = repeated.cursor; + + state = applyTerminalAttachStreamEvent(state, { + type: "cleared", + threadId: TARGET.threadId, + terminalId: TARGET.terminalId, + }); + const cleared = readTerminalOutputUpdate(state.output, cursor); + expect(cleared).toMatchObject({ type: "reset", data: "" }); + state = applyTerminalAttachStreamEvent(state, { + type: "restarted", + threadId: TARGET.threadId, + terminalId: TARGET.terminalId, + snapshot: BASE_SNAPSHOT, + }); + expect(readTerminalOutputUpdate(state.output, cleared.cursor)).toMatchObject({ + type: "reset", + data: "hello", + }); + }); + + it("does not reuse a renderer cursor when a fresh attach has matching counters", () => { + const first = applyTerminalAttachStreamEvent(nextTerminalAttachSeedState(), { + type: "snapshot", + snapshot: BASE_SNAPSHOT, + }); + const cursor = readTerminalOutputUpdate(first.output, INITIAL_TERMINAL_OUTPUT_CURSOR).cursor; + const next = applyTerminalAttachStreamEvent(nextTerminalAttachSeedState(), { + type: "snapshot", + snapshot: { ...BASE_SNAPSHOT, history: "other" }, + }); + expect(next.output.resetVersion).toBe(first.output.resetVersion); + expect(next.output.nextOffset).toBe(first.output.nextOffset); + expect(readTerminalOutputUpdate(next.output, cursor)).toMatchObject({ + type: "reset", + data: "other", + }); + }); + + it("keeps appending while compacting metadata for many small writes", () => { + let state = applyTerminalAttachStreamEvent(EMPTY_TERMINAL_BUFFER_STATE, { + type: "snapshot", + snapshot: { ...BASE_SNAPSHOT, history: "" }, + }); + let cursor = readTerminalOutputUpdate(state.output, INITIAL_TERMINAL_OUTPUT_CURSOR).cursor; + let received = ""; + for (let index = 0; index < 2500; index += 1) { + state = applyTerminalAttachStreamEvent(state, { + type: "output", + threadId: TARGET.threadId, + terminalId: TARGET.terminalId, + data: "x", + }); + const update = readTerminalOutputUpdate(state.output, cursor); + if (update.type !== "append") throw new Error(`Expected append, received ${update.type}`); + received += update.data; + cursor = update.cursor; + } + expect(received).toBe("x".repeat(2500)); + expect(state.output.chunks.length).toBeLessThan(1024); + expect(terminalOutputText(state.output)).toBe(received); + }); + + it("appends every unread character across a compaction boundary", () => { + let state = applyTerminalAttachStreamEvent(EMPTY_TERMINAL_BUFFER_STATE, { + type: "snapshot", + snapshot: { ...BASE_SNAPSHOT, history: "" }, + }); + let cursor = readTerminalOutputUpdate(state.output, INITIAL_TERMINAL_OUTPUT_CURSOR).cursor; + const writes = Array.from({ length: 1200 }, (_, index) => ["x", "Γ©", "η•Œ", "πŸ™‚"][index % 4]!); + for (const [index, data] of writes.entries()) { + state = applyTerminalAttachStreamEvent(state, { + type: "output", + threadId: TARGET.threadId, + terminalId: TARGET.terminalId, + data, + }); + if (index === 99) cursor = readTerminalOutputUpdate(state.output, cursor).cursor; + } + expect(readTerminalOutputUpdate(state.output, cursor)).toMatchObject({ + type: "append", + data: writes.slice(100).join(""), + }); }); }); diff --git a/packages/client-runtime/src/state/terminalSession.ts b/packages/client-runtime/src/state/terminalSession.ts index ee444e36d..70a7ea2bc 100644 --- a/packages/client-runtime/src/state/terminalSession.ts +++ b/packages/client-runtime/src/state/terminalSession.ts @@ -6,10 +6,27 @@ import type { TerminalSummary, ThreadId, } from "@t3tools/contracts"; +import { + appendOutput, + DEFAULT_MAX_TERMINAL_BUFFER_BYTES, + EMPTY_TERMINAL_OUTPUT_STATE, + resetOutput, + type TerminalOutputState, +} from "./terminalOutput.ts"; + +export { + DEFAULT_MAX_TERMINAL_BUFFER_BYTES, + INITIAL_TERMINAL_OUTPUT_CURSOR, + readTerminalOutputUpdate, + terminalOutputText, + type TerminalOutputCursor, + type TerminalOutputState, + type TerminalOutputUpdate, +} from "./terminalOutput.ts"; export interface TerminalSessionState { readonly summary: TerminalSummary | null; - readonly buffer: string; + readonly output: TerminalOutputState; readonly status: TerminalSessionSnapshot["status"] | "closed"; readonly error: string | null; readonly hasRunningSubprocess: boolean; @@ -18,7 +35,7 @@ export interface TerminalSessionState { } export interface TerminalBufferState { - readonly buffer: string; + readonly output: TerminalOutputState; readonly status: TerminalSessionSnapshot["status"] | "closed"; readonly error: string | null; readonly updatedAt: string | null; @@ -45,7 +62,7 @@ export function selectRunningSubprocessTerminalIds( } export const EMPTY_TERMINAL_BUFFER_STATE = Object.freeze({ - buffer: "", + output: EMPTY_TERMINAL_OUTPUT_STATE, status: "closed", error: null, updatedAt: null, @@ -54,7 +71,7 @@ export const EMPTY_TERMINAL_BUFFER_STATE = Object.freeze({ export const EMPTY_TERMINAL_SESSION_STATE = Object.freeze({ summary: null, - buffer: "", + output: EMPTY_TERMINAL_OUTPUT_STATE, status: "closed", error: null, hasRunningSubprocess: false, @@ -62,42 +79,30 @@ export const EMPTY_TERMINAL_SESSION_STATE = Object.freeze( version: 0, }); -export const DEFAULT_MAX_TERMINAL_BUFFER_BYTES = 512 * 1024; -const textEncoder = new TextEncoder(); -const textDecoder = new TextDecoder(); +let terminalAttachGeneration = 0; -function trimBufferToBytes(buffer: string, maxBufferBytes: number): string { - if (maxBufferBytes <= 0) { - return ""; - } - - const encoded = textEncoder.encode(buffer); - if (encoded.byteLength <= maxBufferBytes) { - return buffer; - } - - let start = encoded.byteLength - maxBufferBytes; - while (start < encoded.length) { - const byte = encoded[start]; - if (byte === undefined || (byte & 0b1100_0000) !== 0b1000_0000) { - break; - } - start += 1; - } - - return textDecoder.decode(encoded.subarray(start)); +/** A reinstalled attach stream must not reuse an old renderer's output cursor. */ +export function nextTerminalAttachSeedState(): TerminalBufferState { + return { + ...EMPTY_TERMINAL_BUFFER_STATE, + output: { + ...EMPTY_TERMINAL_OUTPUT_STATE, + generation: ++terminalAttachGeneration, + }, + }; } export function terminalBufferStateFromSnapshot( snapshot: TerminalSessionSnapshot, maxBufferBytes: number, + current: TerminalBufferState = EMPTY_TERMINAL_BUFFER_STATE, ): TerminalBufferState { return { - buffer: trimBufferToBytes(snapshot.history, maxBufferBytes), + output: resetOutput(current.output, snapshot.history, maxBufferBytes), status: snapshot.status, error: null, updatedAt: snapshot.updatedAt, - version: 1, + version: current.version + 1, }; } @@ -113,7 +118,7 @@ export function combineTerminalSessionState( ): TerminalSessionState { return { summary, - buffer: buffer.buffer, + output: buffer.output, status: buffer.version > 0 ? buffer.status : (summary?.status ?? buffer.status), error: buffer.error, hasRunningSubprocess: summary?.hasRunningSubprocess ?? false, @@ -130,11 +135,11 @@ export function applyTerminalAttachStreamEvent( switch (event.type) { case "snapshot": case "restarted": - return terminalBufferStateFromSnapshot(event.snapshot, maxBufferBytes); + return terminalBufferStateFromSnapshot(event.snapshot, maxBufferBytes, current); case "output": return { ...current, - buffer: trimBufferToBytes(`${current.buffer}${event.data}`, maxBufferBytes), + output: appendOutput(current.output, event.data, maxBufferBytes), status: current.status === "closed" ? "running" : current.status, error: null, version: current.version + 1, @@ -142,7 +147,7 @@ export function applyTerminalAttachStreamEvent( case "cleared": return { ...current, - buffer: "", + output: resetOutput(current.output, "", maxBufferBytes), error: null, version: current.version + 1, };