From cf9729d5ee9660c08556e823080d3bb19648ed28 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 4 Sep 2026 11:49:15 -0700 Subject: [PATCH] perf(server): bound terminal history by bytes (#9748) Keep at most 5,000 lines and 8 MiB of retained UTF-8 terminal history. Discard the oldest text at either limit while preserving complete live output. Track bytes and newlines in small chunks. Join split surrogates before eviction. Restore only the needed file tail, handle short reads, and close the file before rewriting current or legacy history. Created with GPT-6 Astra (preview) in Codex. Co-authored-by: will --- apps/server/src/terminal/Manager.test.ts | 151 +++++++++++-- apps/server/src/terminal/Manager.ts | 265 +++++++++++++++++------ docs/internals/terminal-runtime.md | 26 ++- docs/user/terminal.md | 8 + 4 files changed, 360 insertions(+), 90 deletions(-) create mode 100644 docs/user/terminal.md diff --git a/apps/server/src/terminal/Manager.test.ts b/apps/server/src/terminal/Manager.test.ts index 8663fe53d3a9ee642ab92e989160b4bd68b881c4..c99b5a0004387956981720091d276d2aa153cdae 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,16 +280,27 @@ const createManager = ( const withHostPlatform = (platform: NodeJS.Platform) => Layer.succeed(HostProcessPlatform, platform); -// The previous split/join algorithm is the reference for retained text. -function retainedHistory(text: string, maxLines: number): string { +// 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"); - return terminated ? `${retained}\n` : retained; + 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 history across arbitrary chunks, Unicode, ANSI sequences, and clear", () => { +it("preserves line and byte limits across arbitrary chunks, Unicode, ANSI sequences, and clear", () => { let randomSeed = 0x20260904; const fragments = [ "", @@ -308,25 +323,49 @@ it("preserves history across arbitrary chunks, Unicode, ANSI sequences, and clea return fragments[randomSeed % fragments.length]!; }; - for (const maxLines of [0, 1, 3, 5, 5_000]) { - let expected = retainedHistory("before\ninitial\n", maxLines); - const history = new TerminalManager.BoundedTerminalHistory(maxLines, expected); - expect(history.value()).toBe(expected); + 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 = ""; + 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); } - const chunk = nextFragment() + nextFragment(); - history.append(chunk); - expected = retainedHistory(expected + chunk, maxLines); - 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 = ""; @@ -1173,6 +1212,86 @@ it.layer( }), ); + 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(); diff --git a/apps/server/src/terminal/Manager.ts b/apps/server/src/terminal/Manager.ts index a89c6ae4eb783dbcda5646782faed9710f2dda5e..fcfdc2bb26cc1c17b37e065c77a4a72a6ccd360f 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; @@ -784,74 +786,186 @@ 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(); - } - if (lines.length <= maxLines) return history; - const capped = lines.slice(lines.length - maxLines).join("\n"); - return hasTrailingNewline ? `${capped}\n` : capped; +interface TerminalHistoryChunk { + data: string; + byteLength: number; + lineBreaks: number; } export class BoundedTerminalHistory { private readonly maxLines: number; - private lines: Array; + private readonly maxBytes: number; + private chunks: Array = []; private start = 0; - private trailingNewline: boolean; - private cachedValue: string | null = null; + 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) { + constructor(maxLines: number, initial: string, maxBytes = DEFAULT_HISTORY_BYTE_LIMIT) { this.maxLines = maxLines; - const capped = capHistory(initial, maxLines); - this.trailingNewline = capped.endsWith("\n"); - this.lines = capped.length === 0 ? [] : capped.split("\n"); - if (this.trailingNewline) this.lines.pop(); + 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; + } - const trailingNewline = text.endsWith("\n"); - const appendedLines = text.split("\n"); - if (trailingNewline) appendedLines.pop(); + 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(); + } - const activeLines = this.lines.length - this.start; - if (activeLines === 0 || this.trailingNewline) { - for (const line of appendedLines) this.lines.push(line); + 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.lines[this.lines.length - 1] += appendedLines[0] ?? ""; - for (let index = 1; index < appendedLines.length; index += 1) { - this.lines.push(appendedLines[index] ?? ""); + 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; } - this.trailingNewline = trailingNewline; - const overflow = this.lines.length - this.start - this.maxLines; - if (overflow > 0) { - this.lines.fill("", this.start, this.start + overflow); - this.start += overflow; + 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 > 2_048 && this.start * 2 >= this.lines.length) { - this.lines = this.lines.slice(this.start); + 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; } - this.cachedValue = null; } clear(): void { - this.lines = []; + this.chunks = []; this.start = 0; - this.trailingNewline = false; + this.byteLength = 0; + this.lineBreaks = 0; + this.lastCodeUnit = undefined; this.cachedValue = ""; } value(): string { if (this.cachedValue !== null) return this.cachedValue; - const joined = this.lines.slice(this.start).join("\n"); - this.cachedValue = this.trailingNewline ? `${joined}\n` : joined; + this.cachedValue = this.chunks + .slice(this.start) + .map((chunk) => chunk!.data) + .join(""); return this.cachedValue; } } @@ -1171,6 +1285,7 @@ function normalizedRuntimeEnv( interface TerminalManagerOptions { logsDir: string; historyLineLimit?: number; + historyByteLimit?: number; ptyAdapter: PtyAdapter.PtyAdapter["Service"]; shellResolver?: () => string; env?: NodeJS.ProcessEnv; @@ -1211,6 +1326,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 @@ -1476,6 +1592,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, @@ -1490,15 +1630,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( @@ -1508,11 +1648,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); @@ -1526,18 +1666,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( @@ -1554,7 +1693,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func }), ), ); - return capped; + return history; }); const deleteHistory = Effect.fn("terminal.deleteHistory")(function* ( @@ -2219,7 +2358,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func worktreePath: input.worktreePath ?? null, status: "starting", pid: null, - history: new BoundedTerminalHistory(historyLineLimit, history), + history, pendingHistoryControlSequence: "", pendingProcessEvents: [], pendingProcessEventIndex: 0, @@ -2631,7 +2770,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func worktreePath: input.worktreePath ?? null, status: "starting", pid: null, - history: new BoundedTerminalHistory(historyLineLimit, ""), + history: new BoundedTerminalHistory(historyLineLimit, "", historyByteLimit), pendingHistoryControlSequence: "", pendingProcessEvents: [], pendingProcessEventIndex: 0, diff --git a/docs/internals/terminal-runtime.md b/docs/internals/terminal-runtime.md index 01bbbb1a850867bf1f7945ce42d3e725c8f4ec97..addfa1ce093ab90bba3b6330e3a6c8a4e0d7e7f0 100644 --- a/docs/internals/terminal-runtime.md +++ b/docs/internals/terminal-runtime.md @@ -22,19 +22,19 @@ 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 uses an incremental bounded line buffer. Appending one PTY -chunk may scan and allocate for that new chunk, but it must not split, join, or -copy the entire retained history for every callback. Empty lines, incomplete -final lines, trailing newlines, and the configured line limit are preserved -exactly when history is materialized. +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. -Discard each line's string reference as soon as the line leaves retained -history. Array compaction can run later. +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. -The server's line limit does not limit the byte size of retained history. -Shared web and mobile client state currently retains at most 512 KiB. A server -byte limit would change persisted scrollback and needs a separate retention -decision. +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. @@ -46,3 +46,7 @@ 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 0000000000000000000000000000000000000000..2e31ddf3b1770b24c496bcc781d852644aacd42f --- /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. T3 Code 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 T3 Code restores saved terminal +history. A client can show less scrollback than the server keeps. -- 2.54.0