@@ -1734,6 +1734,30 @@ type ShellInternals = {
17341734 task : string
17351735 agents : string
17361736 }
1737+ /**
1738+ * Live state of the painted transcript window, or null while every row in
1739+ * `streamLog` has its own node (`mustWindow` false). `size` only grows —
1740+ * scrolling near the collapsed-history marker widens it so history already
1741+ * in memory becomes reachable, rather than swapping in a different slice.
1742+ */
1743+ transcriptWindow : TranscriptWindowState | null
1744+ /**
1745+ * Set right after a scroll-triggered widen, consumed on the next frame once
1746+ * layout has measured the newly painted rows. Restores `scrollTop` so the
1747+ * rows the operator was already looking at do not jump.
1748+ */
1749+ transcriptScrollRestore : { distanceFromBottom : number } | null
1750+ }
1751+
1752+ type TranscriptWindowState = {
1753+ /** Index into `streamLog` of the first painted row. */
1754+ start : number
1755+ /** Target row count of the window; append eviction trims back to this. */
1756+ size : number
1757+ /** Whether a "N earlier lines collapsed" marker node is currently painted. */
1758+ hasMarker : boolean
1759+ /** The marker node itself, so eviction can retext it instead of rebuilding. */
1760+ markerNode : TextRenderable | null
17371761}
17381762
17391763const internals = new WeakMap < AppShell , ShellInternals > ( )
@@ -1927,21 +1951,78 @@ function paintAppendStreamRow(shell: AppShell, row: StreamRow): void {
19271951 shell . streamLog . push ( row )
19281952 shell . lineCount = shell . streamLog . length
19291953
1954+ const index = shell . streamLog . length - 1
1955+
19301956 // Under collapse threshold: append one paint node (cheap).
1931- // Over threshold: rebuild the windowed paint tree only.
19321957 if ( ! gainedVoice && ! mustWindow ( shell . streamLog . length ) ) {
1933- const index = shell . streamLog . length - 1
19341958 shell . transcript . add (
19351959 createStreamRowRenderable ( shell , row , gapBefore ( shell , index ) , labelBefore ( shell , index ) , index ) ,
19361960 )
19371961 paintChrome ( shell )
19381962 return
19391963 }
19401964
1941- repaintTranscriptWindow ( shell )
1965+ // Over threshold: a voice change relabels every already-painted row, so
1966+ // only that case needs the full rebuild. Otherwise append one node and
1967+ // trim the oldest — see `appendWindowedRow`.
1968+ if ( gainedVoice ) {
1969+ repaintTranscriptWindow ( shell )
1970+ paintChrome ( shell )
1971+ return
1972+ }
1973+
1974+ appendWindowedRow ( shell , row , index )
19421975 paintChrome ( shell )
19431976}
19441977
1978+ /**
1979+ * Append one row node to an already-windowed transcript without rebuilding
1980+ * the rest of the paint tree. Called on every streamed row once history
1981+ * passes `LONG_LOG_COLLAPSE_THRESHOLD`, so this is the hot path CL-5553
1982+ * exists to keep cheap: O(1) node churn per row, not O(window).
1983+ *
1984+ * Eviction of the oldest painted row only runs while the transcript is
1985+ * following the tail — an operator scrolled up into history keeps the rows
1986+ * they are looking at instead of having them trimmed out from under them.
1987+ */
1988+ function appendWindowedRow ( shell : AppShell , row : StreamRow , index : number ) : void {
1989+ const bag = internals . get ( shell )
1990+ const win = bag ?. transcriptWindow
1991+ if ( bag === undefined || win === undefined || win === null ) {
1992+ // First row to cross the threshold: no window tracked yet, one full build.
1993+ repaintTranscriptWindow ( shell )
1994+ return
1995+ }
1996+
1997+ shell . transcript . add (
1998+ createStreamRowRenderable ( shell , row , gapBefore ( shell , index ) , labelBefore ( shell , index ) , index ) ,
1999+ )
2000+
2001+ if ( ! isTranscriptFollowing ( shell ) ) return
2002+
2003+ const paintedCount = index + 1 - win . start
2004+ if ( paintedCount <= win . size ) return
2005+
2006+ const children = transcriptRowChildren ( shell )
2007+ const oldest = children [ win . hasMarker ? 1 : 0 ]
2008+ if ( oldest ) {
2009+ shell . transcript . remove ( oldest )
2010+ destroySubtree ( oldest )
2011+ }
2012+ win . start += 1
2013+
2014+ if ( win . hasMarker && win . markerNode ) {
2015+ win . markerNode . content = ` ${ collapseMarker ( win . start ) } `
2016+ } else {
2017+ win . hasMarker = true
2018+ win . markerNode = new TextRenderable ( shell . renderer as CliRenderer , {
2019+ content : ` ${ collapseMarker ( win . start ) } ` ,
2020+ fg : UI . textDim ,
2021+ } )
2022+ shell . transcript . add ( win . markerNode , 1 )
2023+ }
2024+ }
2025+
19452026/** Row count of the log `appendStreamRow` currently targets (parent or observe). */
19462027export function streamRowCount ( shell : AppShell ) : number {
19472028 return shell . observe !== null && shell . parentStreamLog !== null
@@ -2013,16 +2094,25 @@ export function replaceStreamRowAt(
20132094 if ( index < 0 || index >= shell . streamLog . length ) return
20142095 shell . streamLog [ index ] = row
20152096
2097+ const win = internals . get ( shell ) ?. transcriptWindow ?? null
2098+ // Streaming rewrites the same row repeatedly (token by token), so a windowed
2099+ // transcript still resolves this to a single-node retext when the row is
2100+ // in the painted range — the case CL-5553 needs cheap, since it is the hot
2101+ // path. Anything else (row outside the window, or the 1:1 mapping broken by
2102+ // a raw appendTranscript line) falls back to the full windowed rebuild.
2103+ const paintedIndex = win !== null ? index - win . start + ( win . hasMarker ? 1 : 0 ) : index
20162104 const children = transcriptRowChildren ( shell )
2017- // A raw appendTranscript line breaks the 1:1 node↔row mapping; fall back to
2018- // the windowed rebuild, which derives every node from the log.
2019- if ( mustWindow ( shell . streamLog . length ) || children . length !== shell . streamLog . length ) {
2105+ const outsideWindow = win !== null && ( index < win . start || paintedIndex >= children . length )
2106+ const brokenMapping = win === null && children . length !== shell . streamLog . length
2107+
2108+ if ( outsideWindow ) return
2109+ if ( brokenMapping ) {
20202110 repaintTranscriptWindow ( shell )
20212111 paintChrome ( shell )
20222112 return
20232113 }
20242114
2025- const stale = children [ index ]
2115+ const stale = children [ paintedIndex ]
20262116 if ( stale && retextStreamRow ( shell , stale , row , labelBefore ( shell , index ) ) ) {
20272117 paintChrome ( shell )
20282118 return
@@ -2035,7 +2125,7 @@ export function replaceStreamRowAt(
20352125 // spacer, not a row (see `transcriptRowChildren`).
20362126 shell . transcript . add (
20372127 createStreamRowRenderable ( shell , row , gapBefore ( shell , index ) , labelBefore ( shell , index ) , index ) ,
2038- index + 1 ,
2128+ paintedIndex + 1 ,
20392129 )
20402130 paintChrome ( shell )
20412131}
@@ -2117,23 +2207,68 @@ export function repaintTranscriptWindow(shell: AppShell): void {
21172207 destroySubtree ( child )
21182208 }
21192209
2120- const win = windowSlice ( shell . streamLog , { windowSize : LONG_LOG_WINDOW } )
2210+ const bag = internals . get ( shell )
2211+ // Window size only grows (see `maybeWidenTranscriptWindow`): once an
2212+ // operator has scrolled back to reach older rows, re-collapsing them would
2213+ // make the history they just reached unreachable again.
2214+ const size = bag ?. transcriptWindow ?. size ?? LONG_LOG_WINDOW
2215+ const win = windowSlice ( shell . streamLog , { windowSize : size } )
2216+
2217+ let markerNode : TextRenderable | null = null
21212218 if ( win . truncatedAbove ) {
2122- shell . transcript . add (
2123- new TextRenderable ( shell . renderer as CliRenderer , {
2124- content : ` ${ collapseMarker ( win . start ) } ` ,
2125- fg : UI . textDim ,
2126- } ) ,
2127- )
2219+ markerNode = new TextRenderable ( shell . renderer as CliRenderer , {
2220+ content : ` ${ collapseMarker ( win . start ) } ` ,
2221+ fg : UI . textDim ,
2222+ } )
2223+ shell . transcript . add ( markerNode )
21282224 }
21292225 win . rows . forEach ( ( row , offset ) => {
21302226 const index = win . start + offset
21312227 shell . transcript . add (
21322228 createStreamRowRenderable ( shell , row , gapBefore ( shell , index ) , labelBefore ( shell , index ) , index ) ,
21332229 )
21342230 } )
2231+
2232+ if ( bag !== undefined ) {
2233+ bag . transcriptWindow = mustWindow ( shell . streamLog . length )
2234+ ? { start : win . start , size, hasMarker : win . truncatedAbove , markerNode }
2235+ : null
2236+ }
2237+ }
2238+
2239+ /**
2240+ * Widen the painted window when the operator scrolls near the top of it and
2241+ * older rows are still collapsed above. Growing from the tail rather than
2242+ * pinning a historical slice keeps this a plain `windowSlice` call — the
2243+ * scrollbox's own `scrollTop` already tells us where the operator is.
2244+ */
2245+ function maybeWidenTranscriptWindow ( shell : AppShell ) : void {
2246+ const bag = internals . get ( shell )
2247+ const win = bag ?. transcriptWindow
2248+ if ( bag === undefined || win === null || win === undefined || ! win . hasMarker ) return
2249+ if ( shell . transcript . scrollTop > TRANSCRIPT_WIDEN_MARGIN ) return
2250+
2251+ const distanceFromBottom = shell . transcript . scrollHeight - shell . transcript . scrollTop
2252+ win . size = Math . min ( shell . streamLog . length , win . size + LONG_LOG_WINDOW )
2253+ repaintTranscriptWindow ( shell )
2254+ bag . transcriptScrollRestore = { distanceFromBottom }
21352255}
21362256
2257+ /**
2258+ * Apply a scroll restore queued by `maybeWidenTranscriptWindow` once layout
2259+ * has measured the newly painted rows — `scrollHeight` is stale until then.
2260+ */
2261+ function applyTranscriptScrollRestore ( shell : AppShell ) : void {
2262+ const bag = internals . get ( shell )
2263+ const pending = bag ?. transcriptScrollRestore
2264+ if ( bag === undefined || pending === null || pending === undefined ) return
2265+ bag . transcriptScrollRestore = null
2266+ shell . transcript . scrollTop = Math . max ( 0 , shell . transcript . scrollHeight - pending . distanceFromBottom )
2267+ }
2268+
2269+ /** Rows of headroom left before the widen kicks in — 0 waits for the exact top. */
2270+ const TRANSCRIPT_WIDEN_MARGIN = 1
2271+
21372272/**
21382273 * Tear the landing down on the first transcript row.
21392274 *
@@ -4829,6 +4964,10 @@ export function createAppShell(
48294964 // starves that pass of room to lay the row out in.
48304965 syncTranscriptSpacer ( shell )
48314966 syncNoticeAfterLayout ( shell )
4967+ // Restore first: a widen queued last frame needs this frame's layout to
4968+ // have measured the rows it added before scrollHeight is trustworthy.
4969+ applyTranscriptScrollRestore ( shell )
4970+ maybeWidenTranscriptWindow ( shell )
48324971 }
48334972
48344973 const onResize = ( width : number , height : number ) : void => {
@@ -4951,6 +5090,8 @@ export function createAppShell(
49515090 landingAnimating : false ,
49525091 landingNowMs : 0 ,
49535092 chrome : { goal : "" , task : "" , agents : "" } ,
5093+ transcriptWindow : null ,
5094+ transcriptScrollRestore : null ,
49545095 } )
49555096 transcriptSpacers . set ( shell , transcriptSpacer )
49565097 if ( onCommandOpt ) setPaletteOnCommand ( shell , onCommandOpt )
0 commit comments