From 281ae6f8d90460190459fc0f9e3ade14021316db Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Thu, 6 Aug 2026 21:09:30 -0700 Subject: [PATCH] Cap the transcript's retained tail and thread an eviction offset through its indices MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Ink cutover deleted use-stream.ts's MAX_RETAINED_BLOCKS=600 cap without porting it, so shell.streamLog grows without bound over a long session. LONG_LOG_WINDOW only limits the paint tree, not the backing array. Cap streamLog (and the parent snapshot held during subagent observe) at 600 rows, evicting from the front. Every index the bridge holds across calls — tool-call rows, the open streaming row, the retry boundary, row-toggle closures — is absolute (streamLogBase + local position), so a trim only has to bump the base rather than rewrite stored indices. --- src/tui-opentui/long-log.ts | 13 +++ src/tui-opentui/shell.ts | 145 ++++++++++++++++++++++++++-------- src/tui-opentui/wave6.test.ts | 79 ++++++++++++++++++ 3 files changed, 202 insertions(+), 35 deletions(-) diff --git a/src/tui-opentui/long-log.ts b/src/tui-opentui/long-log.ts index b17eb3343..450bb9183 100644 --- a/src/tui-opentui/long-log.ts +++ b/src/tui-opentui/long-log.ts @@ -19,6 +19,19 @@ export const LONG_LOG_WINDOW = 200 */ export const LONG_LOG_COLLAPSE_THRESHOLD = 500 +/** + * Retained tail of a stream log. Display-only state — the agent's own context + * is kept separately — but an unbounded array still costs memory and O(n) + * snapshot/diff work on every append over a long, tool-heavy session. Set + * above the collapse threshold so eviction never fights the paint window. + */ +export const MAX_RETAINED_STREAM_ROWS = 600 + +/** Rows to drop from the front of a log of this length to fit the cap. */ +export function retentionOverflow(length: number): number { + return Math.max(0, length - MAX_RETAINED_STREAM_ROWS) +} + export type LongLogWindow = { /** Inclusive start index into the full row log. */ readonly start: number diff --git a/src/tui-opentui/shell.ts b/src/tui-opentui/shell.ts index e45110ad5..35335a839 100644 --- a/src/tui-opentui/shell.ts +++ b/src/tui-opentui/shell.ts @@ -114,6 +114,7 @@ import { LONG_LOG_WINDOW, collapseMarker, mustWindow, + retentionOverflow, windowSlice, } from "./long-log.js" import { @@ -565,8 +566,18 @@ export type AppShell = { pendingQueue: number /** Transcript line count (append counter / full log length). */ lineCount: number - /** Full stream log (windowed paint; never unbounded render tree). */ + /** + * Retained tail of the stream log — capped at MAX_RETAINED_STREAM_ROWS, so + * this is never the full session history on a long run. + */ streamLog: StreamRow[] + /** + * Absolute index of `streamLog[0]`. Every index the bridge holds onto + * across calls (tool-call rows, the open streaming row, the retry + * boundary) is absolute, so it stays valid once eviction has shifted the + * array itself. Bumped by the number of rows dropped on each trim. + */ + streamLogBase: number /** * Distinct writers in the visible transcript. Rows carry a name and icon only * once this holds more than one, so identity appears where it disambiguates. @@ -646,6 +657,8 @@ export type AppShell = { } | null /** Parent stream snapshot while observe is active. */ parentStreamLog: StreamRow[] | null + /** Absolute base for `parentStreamLog`, saved/restored across observe (see `streamLogBase`). */ + parentStreamLogBase: number | null /** * Readline kill ring backing Ctrl+Y/Alt+Y. Ctrl+K/U/W and Alt+D feed it; * the text widget itself has no concept of a kill ring (see @@ -1857,11 +1870,30 @@ export function appendTranscript( export function appendStreamRow(shell: AppShell, row: StreamRow): void { if (shell.observe !== null && shell.parentStreamLog !== null) { shell.parentStreamLog.push(row) + shell.parentStreamLogBase = trimRetainedLog( + shell.parentStreamLog, + shell.parentStreamLogBase ?? 0, + ) return } paintAppendStreamRow(shell, row) } +/** + * Evict the oldest rows once `log` exceeds the retention cap and return the + * new absolute base (the index `log[0]` now represents). + * + * Every index the bridge holds onto — tool-call rows, the open streaming + * row, the retry boundary — is absolute (base + local position), so eviction + * only has to bump the base; it never has to rewrite a stored index. + */ +function trimRetainedLog(log: StreamRow[], base: number): number { + const drop = retentionOverflow(log.length) + if (drop <= 0) return base + log.splice(0, drop) + return base + drop +} + /** * Append a child stream row while observing a subagent. * Host-pushed live events (not only fixture seed lines). No-op when not observing. @@ -1925,14 +1957,23 @@ function paintAppendStreamRow(shell: AppShell, row: StreamRow): void { clearLandingMark(shell) const gainedVoice = noteAgentVoice(shell, row) shell.streamLog.push(row) + shell.streamLogBase = trimRetainedLog(shell.streamLog, shell.streamLogBase) shell.lineCount = shell.streamLog.length // Under collapse threshold: append one paint node (cheap). - // Over threshold: rebuild the windowed paint tree only. + // Over threshold: rebuild the windowed paint tree only. The retention cap + // sits above the collapse threshold, so a trim never lands here — by the + // time eviction starts, appends are already windowed. if (!gainedVoice && !mustWindow(shell.streamLog.length)) { const index = shell.streamLog.length - 1 shell.transcript.add( - createStreamRowRenderable(shell, row, gapBefore(shell, index), labelBefore(shell, index), index), + createStreamRowRenderable( + shell, + row, + gapBefore(shell, index), + labelBefore(shell, index), + shell.streamLogBase + index, + ), ) paintChrome(shell) return @@ -1946,36 +1987,45 @@ function paintAppendStreamRow(shell: AppShell, row: StreamRow): void { export function streamRowCount(shell: AppShell): number { return shell.observe !== null && shell.parentStreamLog !== null ? shell.parentStreamLog.length - : shell.streamLog.length + : shell.streamLogBase + shell.streamLog.length } /** - * Row at `index` on the log `appendStreamRow` currently targets. A tool result - * rewrites the call row it answers rather than appending its own, and needs to - * read that row back to fold into it. + * Row at absolute `index` on the log `appendStreamRow` currently targets. A + * tool result rewrites the call row it answers rather than appending its + * own, and needs to read that row back to fold into it. + * + * `index` is absolute (see `streamLogBase`); a row already evicted by the + * retention cap reads back as undefined, same as one past the end. */ export function streamRowAt(shell: AppShell, index: number): StreamRow | undefined { - const log = - shell.observe !== null && shell.parentStreamLog !== null - ? shell.parentStreamLog - : shell.streamLog - return index >= 0 && index < log.length ? log[index] : undefined + if (shell.observe !== null && shell.parentStreamLog !== null) { + const local = index - (shell.parentStreamLogBase ?? 0) + return local >= 0 && local < shell.parentStreamLog.length + ? shell.parentStreamLog[local] + : undefined + } + const local = index - shell.streamLogBase + return local >= 0 && local < shell.streamLog.length ? shell.streamLog[local] : undefined } /** - * Drop every row from `length` onward on the log `appendStreamRow` targets. + * Drop every row from absolute `length` onward on the log `appendStreamRow` + * targets. * * A committed inference attempt that fails is re-streamed from scratch, so the * transcript has to retract what the failed attempt already painted instead of - * letting the replay pile up underneath it. + * letting the replay pile up underneath it. A boundary the retention cap has + * already evicted has nothing left to retract, so this is a no-op rather than + * mis-truncating the tail that replaced it. */ export function truncateStreamRows(shell: AppShell, length: number): void { - const log = - shell.observe !== null && shell.parentStreamLog !== null - ? shell.parentStreamLog - : shell.streamLog - if (length < 0 || length >= log.length) return - log.length = length + const observing = shell.observe !== null && shell.parentStreamLog !== null + const log = observing ? shell.parentStreamLog! : shell.streamLog + const base = observing ? shell.parentStreamLogBase ?? 0 : shell.streamLogBase + const local = length - base + if (local < 0 || local >= log.length) return + log.length = local if (log !== shell.streamLog) return shell.lineCount = shell.streamLog.length repaintTranscriptWindow(shell) @@ -1998,6 +2048,10 @@ function transcriptRowChildren(shell: AppShell): readonly BaseRenderable[] { * Streaming assistant and thinking bodies grow token by token; the bridge keeps * one open row and replaces it on every delta rather than appending a row per * token. Repaints only the affected node while the log fits without windowing. + * + * `index` is absolute (see `streamLogBase`); a row the retention cap has + * already evicted is a no-op rather than corrupting an unrelated row at the + * same array slot. */ export function replaceStreamRowAt( shell: AppShell, @@ -2005,13 +2059,15 @@ export function replaceStreamRowAt( row: StreamRow, ): void { if (shell.observe !== null && shell.parentStreamLog !== null) { - if (index >= 0 && index < shell.parentStreamLog.length) { - shell.parentStreamLog[index] = row + const parentLocal = index - (shell.parentStreamLogBase ?? 0) + if (parentLocal >= 0 && parentLocal < shell.parentStreamLog.length) { + shell.parentStreamLog[parentLocal] = row } return } - if (index < 0 || index >= shell.streamLog.length) return - shell.streamLog[index] = row + const local = index - shell.streamLogBase + if (local < 0 || local >= shell.streamLog.length) return + shell.streamLog[local] = row const children = transcriptRowChildren(shell) // A raw appendTranscript line breaks the 1:1 node↔row mapping; fall back to @@ -2022,8 +2078,8 @@ export function replaceStreamRowAt( return } - const stale = children[index] - if (stale && retextStreamRow(shell, stale, row, labelBefore(shell, index))) { + const stale = children[local] + if (stale && retextStreamRow(shell, stale, row, labelBefore(shell, local))) { paintChrome(shell) return } @@ -2034,8 +2090,8 @@ export function replaceStreamRowAt( // +1: index 0 in the transcript's own child list is the bottom-anchor // spacer, not a row (see `transcriptRowChildren`). shell.transcript.add( - createStreamRowRenderable(shell, row, gapBefore(shell, index), labelBefore(shell, index), index), - index + 1, + createStreamRowRenderable(shell, row, gapBefore(shell, local), labelBefore(shell, local), index), + local + 1, ) paintChrome(shell) } @@ -2127,9 +2183,15 @@ export function repaintTranscriptWindow(shell: AppShell): void { ) } win.rows.forEach((row, offset) => { - const index = win.start + offset + const local = win.start + offset shell.transcript.add( - createStreamRowRenderable(shell, row, gapBefore(shell, index), labelBefore(shell, index), index), + createStreamRowRenderable( + shell, + row, + gapBefore(shell, local), + labelBefore(shell, local), + shell.streamLogBase + local, + ), ) }) } @@ -2345,8 +2407,10 @@ export function createStreamRowRenderable( ): TextRenderable | BoxRenderable { const ctx = shell.renderer as CliRenderer const layout = transcriptRowLayout(shell) - // Rows are only ever appended, so an index taken at build time stays the - // row's index for as long as its node lives. + // `index` is absolute (see `streamLogBase`), so it stays the row's index + // for as long as its node lives even if the retention cap trims the array + // out from underneath it later. `toggleRowExpandedAt` converts it back to + // a local array position at click time, not here. const onToggle = index === undefined || !isCollapsibleRow(row) ? undefined @@ -3216,16 +3280,19 @@ export const OVERLAY_EXPAND_KEY = EXPAND_KEY * * False when that row hides nothing. */ +/** `index` is absolute (see `streamLogBase`), matching the index closures built off `createStreamRowRenderable` carry. */ export function toggleRowExpandedAt(shell: AppShell, index: number): boolean { - const row = shell.streamLog[index] + const row = shell.streamLog[index - shell.streamLogBase] if (row === undefined || !isCollapsibleRow(row)) return false replaceStreamRowAt(shell, index, { ...row, expanded: row.expanded !== true }) return true } export function toggleCollapsedRow(shell: AppShell): boolean { - const collapsible = shell.streamLog.flatMap((row, index) => - row !== undefined && isCollapsibleRow(row) ? [{ row, index }] : [], + const collapsible = shell.streamLog.flatMap((row, local) => + row !== undefined && isCollapsibleRow(row) + ? [{ row, index: shell.streamLogBase + local }] + : [], ) if (collapsible.length === 0) return false const expand = collapsible.some(({ row }) => row.expanded !== true) @@ -3740,6 +3807,7 @@ export function enterSubagentObserve( const seedLines = session.lines.slice() shell.parentStreamLog = shell.streamLog.slice() + shell.parentStreamLogBase = shell.streamLogBase shell.observe = { sessionId: session.sessionId, agentId: session.agentId, @@ -3747,7 +3815,10 @@ export function enterSubagentObserve( lines: seedLines.slice(), } + // A fresh log for the child view; its own indices start at zero regardless + // of how far the parent's retention cap has already trimmed. shell.streamLog = seedLines + shell.streamLogBase = 0 shell.lineCount = shell.streamLog.length repaintTranscriptWindow(shell) @@ -3773,7 +3844,9 @@ export function leaveSubagentObserve(shell: AppShell): void { if (shell.parentStreamLog) { shell.streamLog = shell.parentStreamLog + shell.streamLogBase = shell.parentStreamLogBase ?? 0 shell.parentStreamLog = null + shell.parentStreamLogBase = null } shell.lineCount = shell.streamLog.length repaintTranscriptWindow(shell) @@ -4878,6 +4951,7 @@ export function createAppShell( pendingQueue: badgeCount(session), lineCount: 0, streamLog: [], + streamLogBase: 0, agentVoices: new Set(), baseTitle: title, modelLabel: null, @@ -4901,6 +4975,7 @@ export function createAppShell( costContext: null, observe: null, parentStreamLog: null, + parentStreamLogBase: null, promptKillRing: emptyKillRing, pendingAttachments: [], sentHistory: createSentHistoryBrowse([]), diff --git a/src/tui-opentui/wave6.test.ts b/src/tui-opentui/wave6.test.ts index ce57676e5..dc7ff3cbf 100644 --- a/src/tui-opentui/wave6.test.ts +++ b/src/tui-opentui/wave6.test.ts @@ -8,6 +8,7 @@ import { withTestRenderer } from "./harness" import { LONG_LOG_COLLAPSE_THRESHOLD, LONG_LOG_WINDOW, + MAX_RETAINED_STREAM_ROWS, mustWindow, } from "./long-log" import { openPermissionsOverlay } from "./overlays" @@ -22,7 +23,10 @@ import { moveOverlaySelection, openInsetOverlay, openPalette, + replaceStreamRowAt, setChromeZones, + streamRowAt, + streamRowCount, } from "./shell" import { createRecordingClipboard } from "./copy-path" @@ -193,6 +197,81 @@ describe("Wave 6: long-log windowing", () => { { width: 80, height: 24 }, ) }) + + test( + "a long, tool-heavy session retains a bounded tail, not the whole history", + async () => { + await withTestRenderer( + async (h) => { + const shell = createAppShell(h.renderer, { + terminal: { columns: 80, rows: 24 }, + wireKeys: false, + }) + try { + const n = MAX_RETAINED_STREAM_ROWS + 200 + for (let i = 0; i < n; i++) { + appendStreamRow(shell, { role: "tool", text: `row-${i}`, meta: "bash" }) + } + + // Retention caps the backing array itself, not just the paint window. + expect(shell.streamLog.length).toBe(MAX_RETAINED_STREAM_ROWS) + // But the append count the bridge relies on for bookkeeping stays + // absolute — it must never appear to shrink just because rows were + // evicted underneath it. + expect(streamRowCount(shell)).toBe(n) + // The oldest surviving row is the one at the eviction boundary. + expect(shell.streamLog[0]).toMatchObject({ + text: `row-${n - MAX_RETAINED_STREAM_ROWS}`, + }) + // Evicted rows read back as gone, not as some other row's data. + expect(streamRowAt(shell, 0)).toBeUndefined() + expect(streamRowAt(shell, n - 1)).toMatchObject({ text: `row-${n - 1}` }) + } finally { + shell.dispose() + } + }, + { width: 80, height: 24 }, + ) + }, + 20_000, + ) + + test( + "replaceStreamRowAt keeps targeting the right row across an eviction (absolute index survives the trim)", + async () => { + await withTestRenderer( + async (h) => { + const shell = createAppShell(h.renderer, { + terminal: { columns: 80, rows: 24 }, + wireKeys: false, + }) + try { + appendStreamRow(shell, { role: "tool", text: "pinned call", meta: "bash" }) + const pinnedIndex = streamRowCount(shell) - 1 + + // Push the pinned row well past the retention cap. + for (let i = 0; i < MAX_RETAINED_STREAM_ROWS + 100; i++) { + appendStreamRow(shell, { role: "tool", text: `filler-${i}`, meta: "bash" }) + } + // The pinned row itself was evicted; a rewrite must be a safe no-op, + // not a write to whatever row now occupies that array slot. + const survivorAtSameSlot = streamRowAt(shell, pinnedIndex) + expect(survivorAtSameSlot).toBeUndefined() + + const recentIndex = streamRowCount(shell) - 1 + const before = streamRowAt(shell, recentIndex) + replaceStreamRowAt(shell, recentIndex, { role: "tool", text: "edited", meta: "bash" }) + expect(streamRowAt(shell, recentIndex)).toMatchObject({ text: "edited" }) + expect(before).not.toMatchObject({ text: "edited" }) + } finally { + shell.dispose() + } + }, + { width: 80, height: 24 }, + ) + }, + 20_000, + ) }) describe("Wave 6: chrome zones", () => {