Skip to content

Commit 78d5d98

Browse files
committed
Paint the full retained transcript instead of a smaller window
CL-5551 already caps shell.streamLog at MAX_RETAINED_STREAM_ROWS with an absolute streamLogBase, so a second, smaller paint window on top of that cap was redundant: painting every retained row is what makes all of it reachable by scrolling, and it composes with the retention cap instead of duplicating it. repaintTranscriptWindow now paints streamLog directly (no windowSlice or collapse marker). paintAppendStreamRow adds one node per append and removes only what trimRetainedLog evicted. replaceStreamRowAt's cheap single-node retext is no longer gated off past 500 rows. Evicted rows still get a notice above the oldest retained one, updated in place rather than rebuilt, so the boundary reads as "dropped" and not as the true start of history. Deleted long-log.ts's windowing exports (LONG_LOG_WINDOW, windowSlice, mustWindow, collapseMarker) and their dedicated test file: CL-5551 left them with zero production callers once this landed.
1 parent 95b4267 commit 78d5d98

5 files changed

Lines changed: 204 additions & 263 deletions

File tree

src/tui-opentui/long-log.test.ts

Lines changed: 0 additions & 111 deletions
This file was deleted.

src/tui-opentui/long-log.ts

Lines changed: 5 additions & 107 deletions
Original file line numberDiff line numberDiff line change
@@ -1,120 +1,18 @@
11
/**
2-
* Long-log window strategy — keep multi-thousand-line sessions interactive.
3-
* Pure slice math; shell paints only the window, not the full history.
4-
*
5-
* Budget (Wave 6 defaults; CL-5399 may refine):
6-
* - Painted window: last N rows (or pin around offset)
7-
* - Collapse threshold: when history exceeds this, older rows stay in the
8-
* model but drop from the render tree until scrolled into the window
2+
* Retention budget for a long-running transcript. The paint tree tracks
3+
* `streamLog` 1:1 (see shell.ts's repaintTranscriptWindow/paintAppendStreamRow)
4+
* so every retained row stays reachable by scrolling; this cap is what keeps
5+
* that array — and so the paint tree — bounded over a long session.
96
*/
107

11-
import type { StreamRow } from "./stream.js"
12-
13-
/** Rows kept in the paint tree under normal follow-tail. */
14-
export const LONG_LOG_WINDOW = 200
15-
16-
/**
17-
* When total rows exceed this, append/scroll paths must use windowSlice
18-
* (never re-paint the full history).
19-
*/
20-
export const LONG_LOG_COLLAPSE_THRESHOLD = 500
21-
228
/**
239
* Retained tail of a stream log. Display-only state — the agent's own context
2410
* 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.
11+
* snapshot/diff work on every append over a long, tool-heavy session.
2712
*/
2813
export const MAX_RETAINED_STREAM_ROWS = 600
2914

3015
/** Rows to drop from the front of a log of this length to fit the cap. */
3116
export function retentionOverflow(length: number): number {
3217
return Math.max(0, length - MAX_RETAINED_STREAM_ROWS)
3318
}
34-
35-
export type LongLogWindow = {
36-
/** Inclusive start index into the full row log. */
37-
readonly start: number
38-
/** Exclusive end index. */
39-
readonly end: number
40-
/** Slice of rows to paint. */
41-
readonly rows: readonly StreamRow[]
42-
/** True when older rows exist above the window. */
43-
readonly truncatedAbove: boolean
44-
/** True when newer rows exist below the window (pinned). */
45-
readonly truncatedBelow: boolean
46-
/** Full log length. */
47-
readonly total: number
48-
}
49-
50-
export type WindowSliceOpts = {
51-
/** Max rows to include (default LONG_LOG_WINDOW). */
52-
readonly windowSize?: number
53-
/**
54-
* Pin the window so this index is visible (keep-active-visible style).
55-
* When omitted, follow the tail (last windowSize rows).
56-
*/
57-
readonly pinIndex?: number
58-
}
59-
60-
/**
61-
* Compute which rows to paint for a long log.
62-
* Follow-tail by default; pinIndex keeps a historical row in view.
63-
*/
64-
export function windowSlice(
65-
log: readonly StreamRow[],
66-
opts?: WindowSliceOpts,
67-
): LongLogWindow {
68-
const total = log.length
69-
const windowSize = Math.max(1, Math.floor(opts?.windowSize ?? LONG_LOG_WINDOW))
70-
71-
if (total === 0) {
72-
return {
73-
start: 0,
74-
end: 0,
75-
rows: [],
76-
truncatedAbove: false,
77-
truncatedBelow: false,
78-
total: 0,
79-
}
80-
}
81-
82-
let end: number
83-
let start: number
84-
85-
if (opts?.pinIndex !== undefined) {
86-
const pin = Math.max(0, Math.min(total - 1, Math.floor(opts.pinIndex)))
87-
// Center-ish: keep pin in window; prefer showing context after pin when possible.
88-
start = Math.max(0, pin - Math.floor(windowSize / 2))
89-
end = Math.min(total, start + windowSize)
90-
start = Math.max(0, end - windowSize)
91-
} else {
92-
// Follow tail
93-
end = total
94-
start = Math.max(0, total - windowSize)
95-
}
96-
97-
return {
98-
start,
99-
end,
100-
rows: log.slice(start, end),
101-
truncatedAbove: start > 0,
102-
truncatedBelow: end < total,
103-
total,
104-
}
105-
}
106-
107-
/** Whether the log is large enough that windowing is mandatory. */
108-
export function mustWindow(totalRows: number): boolean {
109-
return totalRows > LONG_LOG_COLLAPSE_THRESHOLD
110-
}
111-
112-
/**
113-
* Collapse marker line for the paint tree when truncatedAbove.
114-
* Pure string — shell styles it as system chrome.
115-
*/
116-
export function collapseMarker(above: number): string {
117-
const n = Math.max(0, Math.floor(above))
118-
if (n <= 0) return ""
119-
return `… ${n} earlier line${n === 1 ? "" : "s"} collapsed`
120-
}

src/tui-opentui/shell.ts

Lines changed: 68 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -110,13 +110,7 @@ import {
110110
visibleSlice,
111111
type ListViewportState,
112112
} from "./list-viewport.js"
113-
import {
114-
LONG_LOG_WINDOW,
115-
collapseMarker,
116-
mustWindow,
117-
retentionOverflow,
118-
windowSlice,
119-
} from "./long-log.js"
113+
import { retentionOverflow } from "./long-log.js"
120114
import {
121115
DEFAULT_PALETTE_COMMANDS,
122116
filterPaletteCommands,
@@ -1985,34 +1979,70 @@ function labelBefore(shell: AppShell, index: number): string | null {
19851979
return blockLabel(rowBefore(shell, index), row, transcriptRowLayout(shell))
19861980
}
19871981

1988-
/** Paint + push onto the visible streamLog (child while observing, parent otherwise). */
1982+
/**
1983+
* Notice painted above the oldest retained row once the cap has evicted
1984+
* anything. Unlike the pre-CL-5551 collapse marker it replaces, scrolling
1985+
* never reveals more — these rows are gone, not merely out of the window.
1986+
*/
1987+
function evictedRowsNotice(evicted: number): string {
1988+
return ` … ${evicted} earlier row${evicted === 1 ? "" : "s"} dropped (past the retention limit)`
1989+
}
1990+
1991+
/**
1992+
* Paint + push onto the visible streamLog (child while observing, parent
1993+
* otherwise). The paint tree stays 1:1 with the (retention-capped) log —
1994+
* CL-5551 already bounds `streamLog` to `MAX_RETAINED_STREAM_ROWS`, so there
1995+
* is no separate, smaller window to maintain on top of it: every retained
1996+
* row gets a node, which is also what makes all of it reachable by
1997+
* scrolling (CL-5553). A trim past the cap costs one node removal here, not
1998+
* a rebuild.
1999+
*/
19892000
function paintAppendStreamRow(shell: AppShell, row: StreamRow): void {
19902001
clearLandingMark(shell)
19912002
const gainedVoice = noteAgentVoice(shell, row)
19922003
shell.streamLog.push(row)
2004+
const baseBefore = shell.streamLogBase
19932005
shell.streamLogBase = trimRetainedLog(shell.streamLog, shell.streamLogBase)
19942006
shell.lineCount = shell.streamLog.length
19952007

1996-
// Under collapse threshold: append one paint node (cheap).
1997-
// Over threshold: rebuild the windowed paint tree only. The retention cap
1998-
// sits above the collapse threshold, so a trim never lands here — by the
1999-
// time eviction starts, appends are already windowed.
2000-
if (!gainedVoice && !mustWindow(shell.streamLog.length)) {
2001-
const index = shell.streamLog.length - 1
2002-
shell.transcript.add(
2003-
createStreamRowRenderable(
2004-
shell,
2005-
row,
2006-
gapBefore(shell, index),
2007-
labelBefore(shell, index),
2008-
shell.streamLogBase + index,
2009-
),
2010-
)
2008+
if (gainedVoice) {
2009+
repaintTranscriptWindow(shell)
20112010
paintChrome(shell)
20122011
return
20132012
}
20142013

2015-
repaintTranscriptWindow(shell)
2014+
const dropped = shell.streamLogBase - baseBefore
2015+
if (dropped > 0) {
2016+
const hadMarker = baseBefore > 0
2017+
const children = transcriptRowChildren(shell)
2018+
for (const evicted of children.slice(hadMarker ? 1 : 0, (hadMarker ? 1 : 0) + dropped)) {
2019+
shell.transcript.remove(evicted)
2020+
destroySubtree(evicted)
2021+
}
2022+
const marker = hadMarker ? transcriptRowChildren(shell)[0] : undefined
2023+
if (marker instanceof TextRenderable) {
2024+
marker.content = evictedRowsNotice(shell.streamLogBase)
2025+
} else {
2026+
shell.transcript.add(
2027+
new TextRenderable(shell.renderer as CliRenderer, {
2028+
content: evictedRowsNotice(shell.streamLogBase),
2029+
fg: UI.textDim,
2030+
}),
2031+
1,
2032+
)
2033+
}
2034+
}
2035+
2036+
const index = shell.streamLog.length - 1
2037+
shell.transcript.add(
2038+
createStreamRowRenderable(
2039+
shell,
2040+
row,
2041+
gapBefore(shell, index),
2042+
labelBefore(shell, index),
2043+
shell.streamLogBase + index,
2044+
),
2045+
)
20162046
paintChrome(shell)
20172047
}
20182048

@@ -2104,8 +2134,8 @@ export function replaceStreamRowAt(
21042134

21052135
const children = transcriptRowChildren(shell)
21062136
// A raw appendTranscript line breaks the 1:1 node↔row mapping; fall back to
2107-
// the windowed rebuild, which derives every node from the log.
2108-
if (mustWindow(shell.streamLog.length) || children.length !== shell.streamLog.length) {
2137+
// a full repaint, which derives every node from the log.
2138+
if (children.length !== shell.streamLog.length) {
21092139
repaintTranscriptWindow(shell)
21102140
paintChrome(shell)
21112141
return
@@ -2214,7 +2244,12 @@ function retextStreamRowBody(
22142244
return true
22152245
}
22162246

2217-
/** Rebuild transcript paint tree from the long-log window (O(window), not O(total)). */
2247+
/**
2248+
* Rebuild the transcript paint tree from `streamLog` — every retained row,
2249+
* not a smaller window of it. `streamLog` is already capped at
2250+
* `MAX_RETAINED_STREAM_ROWS`, so this is O(cap), and painting all of it is
2251+
* what makes the full retained history reachable by scrolling.
2252+
*/
22182253
export function repaintTranscriptWindow(shell: AppShell): void {
22192254
clearLandingMark(shell)
22202255
shell.agentVoices = new Set(agentVoicesIn(shell.streamLog))
@@ -2225,17 +2260,18 @@ export function repaintTranscriptWindow(shell: AppShell): void {
22252260
destroySubtree(child)
22262261
}
22272262

2228-
const win = windowSlice(shell.streamLog, { windowSize: LONG_LOG_WINDOW })
2229-
if (win.truncatedAbove) {
2263+
// Rows evicted by the retention cap are gone for good, not just scrolled
2264+
// past — say so, or the boundary reads as the true start of history.
2265+
if (shell.streamLogBase > 0) {
22302266
shell.transcript.add(
22312267
new TextRenderable(shell.renderer as CliRenderer, {
2232-
content: ` ${collapseMarker(win.start)}`,
2268+
content: evictedRowsNotice(shell.streamLogBase),
22332269
fg: UI.textDim,
22342270
}),
22352271
)
22362272
}
2237-
win.rows.forEach((row, offset) => {
2238-
const local = win.start + offset
2273+
2274+
shell.streamLog.forEach((row, local) => {
22392275
shell.transcript.add(
22402276
createStreamRowRenderable(
22412277
shell,

0 commit comments

Comments
 (0)