Skip to content

Commit 281ae6f

Browse files
committed
Cap the transcript's retained tail and thread an eviction offset through its indices
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.
1 parent cf3bb84 commit 281ae6f

3 files changed

Lines changed: 202 additions & 35 deletions

File tree

src/tui-opentui/long-log.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,19 @@ export const LONG_LOG_WINDOW = 200
1919
*/
2020
export const LONG_LOG_COLLAPSE_THRESHOLD = 500
2121

22+
/**
23+
* Retained tail of a stream log. Display-only state — the agent's own context
24+
* is kept separately — but an unbounded array still costs memory and O(n)
25+
* snapshot/diff work on every append over a long, tool-heavy session. Set
26+
* above the collapse threshold so eviction never fights the paint window.
27+
*/
28+
export const MAX_RETAINED_STREAM_ROWS = 600
29+
30+
/** Rows to drop from the front of a log of this length to fit the cap. */
31+
export function retentionOverflow(length: number): number {
32+
return Math.max(0, length - MAX_RETAINED_STREAM_ROWS)
33+
}
34+
2235
export type LongLogWindow = {
2336
/** Inclusive start index into the full row log. */
2437
readonly start: number

src/tui-opentui/shell.ts

Lines changed: 110 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,7 @@ import {
114114
LONG_LOG_WINDOW,
115115
collapseMarker,
116116
mustWindow,
117+
retentionOverflow,
117118
windowSlice,
118119
} from "./long-log.js"
119120
import {
@@ -565,8 +566,18 @@ export type AppShell = {
565566
pendingQueue: number
566567
/** Transcript line count (append counter / full log length). */
567568
lineCount: number
568-
/** Full stream log (windowed paint; never unbounded render tree). */
569+
/**
570+
* Retained tail of the stream log — capped at MAX_RETAINED_STREAM_ROWS, so
571+
* this is never the full session history on a long run.
572+
*/
569573
streamLog: StreamRow[]
574+
/**
575+
* Absolute index of `streamLog[0]`. Every index the bridge holds onto
576+
* across calls (tool-call rows, the open streaming row, the retry
577+
* boundary) is absolute, so it stays valid once eviction has shifted the
578+
* array itself. Bumped by the number of rows dropped on each trim.
579+
*/
580+
streamLogBase: number
570581
/**
571582
* Distinct writers in the visible transcript. Rows carry a name and icon only
572583
* once this holds more than one, so identity appears where it disambiguates.
@@ -646,6 +657,8 @@ export type AppShell = {
646657
} | null
647658
/** Parent stream snapshot while observe is active. */
648659
parentStreamLog: StreamRow[] | null
660+
/** Absolute base for `parentStreamLog`, saved/restored across observe (see `streamLogBase`). */
661+
parentStreamLogBase: number | null
649662
/**
650663
* Readline kill ring backing Ctrl+Y/Alt+Y. Ctrl+K/U/W and Alt+D feed it;
651664
* the text widget itself has no concept of a kill ring (see
@@ -1857,11 +1870,30 @@ export function appendTranscript(
18571870
export function appendStreamRow(shell: AppShell, row: StreamRow): void {
18581871
if (shell.observe !== null && shell.parentStreamLog !== null) {
18591872
shell.parentStreamLog.push(row)
1873+
shell.parentStreamLogBase = trimRetainedLog(
1874+
shell.parentStreamLog,
1875+
shell.parentStreamLogBase ?? 0,
1876+
)
18601877
return
18611878
}
18621879
paintAppendStreamRow(shell, row)
18631880
}
18641881

1882+
/**
1883+
* Evict the oldest rows once `log` exceeds the retention cap and return the
1884+
* new absolute base (the index `log[0]` now represents).
1885+
*
1886+
* Every index the bridge holds onto — tool-call rows, the open streaming
1887+
* row, the retry boundary — is absolute (base + local position), so eviction
1888+
* only has to bump the base; it never has to rewrite a stored index.
1889+
*/
1890+
function trimRetainedLog(log: StreamRow[], base: number): number {
1891+
const drop = retentionOverflow(log.length)
1892+
if (drop <= 0) return base
1893+
log.splice(0, drop)
1894+
return base + drop
1895+
}
1896+
18651897
/**
18661898
* Append a child stream row while observing a subagent.
18671899
* 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 {
19251957
clearLandingMark(shell)
19261958
const gainedVoice = noteAgentVoice(shell, row)
19271959
shell.streamLog.push(row)
1960+
shell.streamLogBase = trimRetainedLog(shell.streamLog, shell.streamLogBase)
19281961
shell.lineCount = shell.streamLog.length
19291962

19301963
// Under collapse threshold: append one paint node (cheap).
1931-
// Over threshold: rebuild the windowed paint tree only.
1964+
// Over threshold: rebuild the windowed paint tree only. The retention cap
1965+
// sits above the collapse threshold, so a trim never lands here — by the
1966+
// time eviction starts, appends are already windowed.
19321967
if (!gainedVoice && !mustWindow(shell.streamLog.length)) {
19331968
const index = shell.streamLog.length - 1
19341969
shell.transcript.add(
1935-
createStreamRowRenderable(shell, row, gapBefore(shell, index), labelBefore(shell, index), index),
1970+
createStreamRowRenderable(
1971+
shell,
1972+
row,
1973+
gapBefore(shell, index),
1974+
labelBefore(shell, index),
1975+
shell.streamLogBase + index,
1976+
),
19361977
)
19371978
paintChrome(shell)
19381979
return
@@ -1946,36 +1987,45 @@ function paintAppendStreamRow(shell: AppShell, row: StreamRow): void {
19461987
export function streamRowCount(shell: AppShell): number {
19471988
return shell.observe !== null && shell.parentStreamLog !== null
19481989
? shell.parentStreamLog.length
1949-
: shell.streamLog.length
1990+
: shell.streamLogBase + shell.streamLog.length
19501991
}
19511992

19521993
/**
1953-
* Row at `index` on the log `appendStreamRow` currently targets. A tool result
1954-
* rewrites the call row it answers rather than appending its own, and needs to
1955-
* read that row back to fold into it.
1994+
* Row at absolute `index` on the log `appendStreamRow` currently targets. A
1995+
* tool result rewrites the call row it answers rather than appending its
1996+
* own, and needs to read that row back to fold into it.
1997+
*
1998+
* `index` is absolute (see `streamLogBase`); a row already evicted by the
1999+
* retention cap reads back as undefined, same as one past the end.
19562000
*/
19572001
export function streamRowAt(shell: AppShell, index: number): StreamRow | undefined {
1958-
const log =
1959-
shell.observe !== null && shell.parentStreamLog !== null
1960-
? shell.parentStreamLog
1961-
: shell.streamLog
1962-
return index >= 0 && index < log.length ? log[index] : undefined
2002+
if (shell.observe !== null && shell.parentStreamLog !== null) {
2003+
const local = index - (shell.parentStreamLogBase ?? 0)
2004+
return local >= 0 && local < shell.parentStreamLog.length
2005+
? shell.parentStreamLog[local]
2006+
: undefined
2007+
}
2008+
const local = index - shell.streamLogBase
2009+
return local >= 0 && local < shell.streamLog.length ? shell.streamLog[local] : undefined
19632010
}
19642011

19652012
/**
1966-
* Drop every row from `length` onward on the log `appendStreamRow` targets.
2013+
* Drop every row from absolute `length` onward on the log `appendStreamRow`
2014+
* targets.
19672015
*
19682016
* A committed inference attempt that fails is re-streamed from scratch, so the
19692017
* transcript has to retract what the failed attempt already painted instead of
1970-
* letting the replay pile up underneath it.
2018+
* letting the replay pile up underneath it. A boundary the retention cap has
2019+
* already evicted has nothing left to retract, so this is a no-op rather than
2020+
* mis-truncating the tail that replaced it.
19712021
*/
19722022
export function truncateStreamRows(shell: AppShell, length: number): void {
1973-
const log =
1974-
shell.observe !== null && shell.parentStreamLog !== null
1975-
? shell.parentStreamLog
1976-
: shell.streamLog
1977-
if (length < 0 || length >= log.length) return
1978-
log.length = length
2023+
const observing = shell.observe !== null && shell.parentStreamLog !== null
2024+
const log = observing ? shell.parentStreamLog! : shell.streamLog
2025+
const base = observing ? shell.parentStreamLogBase ?? 0 : shell.streamLogBase
2026+
const local = length - base
2027+
if (local < 0 || local >= log.length) return
2028+
log.length = local
19792029
if (log !== shell.streamLog) return
19802030
shell.lineCount = shell.streamLog.length
19812031
repaintTranscriptWindow(shell)
@@ -1998,20 +2048,26 @@ function transcriptRowChildren(shell: AppShell): readonly BaseRenderable[] {
19982048
* Streaming assistant and thinking bodies grow token by token; the bridge keeps
19992049
* one open row and replaces it on every delta rather than appending a row per
20002050
* token. Repaints only the affected node while the log fits without windowing.
2051+
*
2052+
* `index` is absolute (see `streamLogBase`); a row the retention cap has
2053+
* already evicted is a no-op rather than corrupting an unrelated row at the
2054+
* same array slot.
20012055
*/
20022056
export function replaceStreamRowAt(
20032057
shell: AppShell,
20042058
index: number,
20052059
row: StreamRow,
20062060
): void {
20072061
if (shell.observe !== null && shell.parentStreamLog !== null) {
2008-
if (index >= 0 && index < shell.parentStreamLog.length) {
2009-
shell.parentStreamLog[index] = row
2062+
const parentLocal = index - (shell.parentStreamLogBase ?? 0)
2063+
if (parentLocal >= 0 && parentLocal < shell.parentStreamLog.length) {
2064+
shell.parentStreamLog[parentLocal] = row
20102065
}
20112066
return
20122067
}
2013-
if (index < 0 || index >= shell.streamLog.length) return
2014-
shell.streamLog[index] = row
2068+
const local = index - shell.streamLogBase
2069+
if (local < 0 || local >= shell.streamLog.length) return
2070+
shell.streamLog[local] = row
20152071

20162072
const children = transcriptRowChildren(shell)
20172073
// A raw appendTranscript line breaks the 1:1 node↔row mapping; fall back to
@@ -2022,8 +2078,8 @@ export function replaceStreamRowAt(
20222078
return
20232079
}
20242080

2025-
const stale = children[index]
2026-
if (stale && retextStreamRow(shell, stale, row, labelBefore(shell, index))) {
2081+
const stale = children[local]
2082+
if (stale && retextStreamRow(shell, stale, row, labelBefore(shell, local))) {
20272083
paintChrome(shell)
20282084
return
20292085
}
@@ -2034,8 +2090,8 @@ export function replaceStreamRowAt(
20342090
// +1: index 0 in the transcript's own child list is the bottom-anchor
20352091
// spacer, not a row (see `transcriptRowChildren`).
20362092
shell.transcript.add(
2037-
createStreamRowRenderable(shell, row, gapBefore(shell, index), labelBefore(shell, index), index),
2038-
index + 1,
2093+
createStreamRowRenderable(shell, row, gapBefore(shell, local), labelBefore(shell, local), index),
2094+
local + 1,
20392095
)
20402096
paintChrome(shell)
20412097
}
@@ -2127,9 +2183,15 @@ export function repaintTranscriptWindow(shell: AppShell): void {
21272183
)
21282184
}
21292185
win.rows.forEach((row, offset) => {
2130-
const index = win.start + offset
2186+
const local = win.start + offset
21312187
shell.transcript.add(
2132-
createStreamRowRenderable(shell, row, gapBefore(shell, index), labelBefore(shell, index), index),
2188+
createStreamRowRenderable(
2189+
shell,
2190+
row,
2191+
gapBefore(shell, local),
2192+
labelBefore(shell, local),
2193+
shell.streamLogBase + local,
2194+
),
21332195
)
21342196
})
21352197
}
@@ -2345,8 +2407,10 @@ export function createStreamRowRenderable(
23452407
): TextRenderable | BoxRenderable {
23462408
const ctx = shell.renderer as CliRenderer
23472409
const layout = transcriptRowLayout(shell)
2348-
// Rows are only ever appended, so an index taken at build time stays the
2349-
// row's index for as long as its node lives.
2410+
// `index` is absolute (see `streamLogBase`), so it stays the row's index
2411+
// for as long as its node lives even if the retention cap trims the array
2412+
// out from underneath it later. `toggleRowExpandedAt` converts it back to
2413+
// a local array position at click time, not here.
23502414
const onToggle =
23512415
index === undefined || !isCollapsibleRow(row)
23522416
? undefined
@@ -3216,16 +3280,19 @@ export const OVERLAY_EXPAND_KEY = EXPAND_KEY
32163280
*
32173281
* False when that row hides nothing.
32183282
*/
3283+
/** `index` is absolute (see `streamLogBase`), matching the index closures built off `createStreamRowRenderable` carry. */
32193284
export function toggleRowExpandedAt(shell: AppShell, index: number): boolean {
3220-
const row = shell.streamLog[index]
3285+
const row = shell.streamLog[index - shell.streamLogBase]
32213286
if (row === undefined || !isCollapsibleRow(row)) return false
32223287
replaceStreamRowAt(shell, index, { ...row, expanded: row.expanded !== true })
32233288
return true
32243289
}
32253290

32263291
export function toggleCollapsedRow(shell: AppShell): boolean {
3227-
const collapsible = shell.streamLog.flatMap((row, index) =>
3228-
row !== undefined && isCollapsibleRow(row) ? [{ row, index }] : [],
3292+
const collapsible = shell.streamLog.flatMap((row, local) =>
3293+
row !== undefined && isCollapsibleRow(row)
3294+
? [{ row, index: shell.streamLogBase + local }]
3295+
: [],
32293296
)
32303297
if (collapsible.length === 0) return false
32313298
const expand = collapsible.some(({ row }) => row.expanded !== true)
@@ -3740,14 +3807,18 @@ export function enterSubagentObserve(
37403807

37413808
const seedLines = session.lines.slice()
37423809
shell.parentStreamLog = shell.streamLog.slice()
3810+
shell.parentStreamLogBase = shell.streamLogBase
37433811
shell.observe = {
37443812
sessionId: session.sessionId,
37453813
agentId: session.agentId,
37463814
description: session.description,
37473815
lines: seedLines.slice(),
37483816
}
37493817

3818+
// A fresh log for the child view; its own indices start at zero regardless
3819+
// of how far the parent's retention cap has already trimmed.
37503820
shell.streamLog = seedLines
3821+
shell.streamLogBase = 0
37513822
shell.lineCount = shell.streamLog.length
37523823
repaintTranscriptWindow(shell)
37533824

@@ -3773,7 +3844,9 @@ export function leaveSubagentObserve(shell: AppShell): void {
37733844

37743845
if (shell.parentStreamLog) {
37753846
shell.streamLog = shell.parentStreamLog
3847+
shell.streamLogBase = shell.parentStreamLogBase ?? 0
37763848
shell.parentStreamLog = null
3849+
shell.parentStreamLogBase = null
37773850
}
37783851
shell.lineCount = shell.streamLog.length
37793852
repaintTranscriptWindow(shell)
@@ -4878,6 +4951,7 @@ export function createAppShell(
48784951
pendingQueue: badgeCount(session),
48794952
lineCount: 0,
48804953
streamLog: [],
4954+
streamLogBase: 0,
48814955
agentVoices: new Set<string>(),
48824956
baseTitle: title,
48834957
modelLabel: null,
@@ -4901,6 +4975,7 @@ export function createAppShell(
49014975
costContext: null,
49024976
observe: null,
49034977
parentStreamLog: null,
4978+
parentStreamLogBase: null,
49044979
promptKillRing: emptyKillRing,
49054980
pendingAttachments: [],
49064981
sentHistory: createSentHistoryBrowse([]),

0 commit comments

Comments
 (0)