@@ -114,6 +114,7 @@ import {
114114 LONG_LOG_WINDOW ,
115115 collapseMarker ,
116116 mustWindow ,
117+ retentionOverflow ,
117118 windowSlice ,
118119} from "./long-log.js"
119120import {
@@ -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(
18571870export 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 {
19461987export 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 */
19572001export 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 */
19722022export 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 */
20022056export 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. */
32193284export 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
32263291export 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