Skip to content

Commit 2a89028

Browse files
committed
Let the transcript widen its painted window on scroll instead of always tailing
The long-log window called windowSlice with a fixed size and no feedback from scrollTop, so once history passed the collapse threshold everything before the last 200 rows was permanently replaced by a collapse marker and could never be scrolled back to. Widen the window (never shrink) when the operator scrolls near its top, and preserve their scroll position across the repaint. Also stop rebuilding the whole window on every appended or replaced row once windowed: append now adds one node and evicts the oldest only while following the tail, and replace resolves streaming token updates to a single-node retext when the row is still in the painted range, instead of tearing down and repainting the entire window on every token.
1 parent cf3bb84 commit 2a89028

2 files changed

Lines changed: 273 additions & 15 deletions

File tree

src/tui-opentui/shell.ts

Lines changed: 156 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -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

17391763
const 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). */
19462027
export 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)
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
/**
2+
* CL-5553: past the long-log collapse threshold, history above the painted
3+
* window has to stay reachable by scrolling, and appending a new row must
4+
* not rebuild the whole window to do it.
5+
*/
6+
import { describe, expect, test } from "bun:test"
7+
import { withTestRenderer } from "./harness"
8+
import { appendStreamRow, createAppShell, isTranscriptFollowing } from "./shell"
9+
import { LONG_LOG_COLLAPSE_THRESHOLD } from "./long-log"
10+
11+
async function settle(h: { renderOnce: () => Promise<void> }): Promise<void> {
12+
await new Promise((resolve) => setTimeout(resolve, 10))
13+
await h.renderOnce()
14+
}
15+
16+
describe("long-log transcript scrolling", () => {
17+
test("scrolling to the top reaches a row far above the collapse threshold", async () => {
18+
await withTestRenderer(
19+
async (h) => {
20+
const shell = createAppShell(h.renderer, {
21+
terminal: { columns: 80, rows: 24 },
22+
wireKeys: false,
23+
})
24+
try {
25+
const total = LONG_LOG_COLLAPSE_THRESHOLD + 50
26+
for (let i = 0; i < total; i++) {
27+
appendStreamRow(shell, { role: "assistant", text: `row-${i}` })
28+
}
29+
await settle(h)
30+
31+
// Repeatedly scroll to the current top and let the window widen —
32+
// each widen only reaches back one more chunk, same as a real
33+
// operator holding PageUp/scroll-up.
34+
for (let i = 0; i < 10; i++) {
35+
shell.transcript.scrollTop = 0
36+
await settle(h)
37+
if (h.captureCharFrame().includes("row-0")) break
38+
}
39+
40+
expect(h.captureCharFrame()).toContain("row-0")
41+
} finally {
42+
shell.dispose()
43+
}
44+
},
45+
{ width: 80, height: 24 },
46+
)
47+
})
48+
49+
test("appending a row in a long session does not rebuild the painted window", async () => {
50+
await withTestRenderer(
51+
async (h) => {
52+
const shell = createAppShell(h.renderer, {
53+
terminal: { columns: 80, rows: 24 },
54+
wireKeys: false,
55+
})
56+
try {
57+
for (let i = 0; i < LONG_LOG_COLLAPSE_THRESHOLD + 1; i++) {
58+
appendStreamRow(shell, { role: "assistant", text: `row-${i}` })
59+
}
60+
await settle(h)
61+
expect(isTranscriptFollowing(shell)).toBe(true)
62+
63+
// Node well inside the window, far from the eviction edge at the top.
64+
const children = shell.transcript.getChildren()
65+
const probe = children[children.length - 20]
66+
expect(probe).toBeDefined()
67+
68+
appendStreamRow(shell, { role: "assistant", text: "one-more" })
69+
await settle(h)
70+
71+
const childrenAfter = shell.transcript.getChildren()
72+
// Same node instance survives: a full window rebuild would have torn
73+
// every row down and painted fresh ones instead.
74+
expect(childrenAfter).toContain(probe!)
75+
} finally {
76+
shell.dispose()
77+
}
78+
},
79+
{ width: 80, height: 24 },
80+
)
81+
})
82+
83+
test("append churn is O(1), not O(window)", async () => {
84+
await withTestRenderer(
85+
async (h) => {
86+
const shell = createAppShell(h.renderer, {
87+
terminal: { columns: 80, rows: 24 },
88+
wireKeys: false,
89+
})
90+
try {
91+
for (let i = 0; i < LONG_LOG_COLLAPSE_THRESHOLD + 1; i++) {
92+
appendStreamRow(shell, { role: "assistant", text: `row-${i}` })
93+
}
94+
await settle(h)
95+
96+
let removed = 0
97+
const originalRemove = shell.transcript.remove.bind(shell.transcript)
98+
shell.transcript.remove = (child) => {
99+
removed += 1
100+
return originalRemove(child)
101+
}
102+
103+
appendStreamRow(shell, { role: "assistant", text: "one-more" })
104+
await settle(h)
105+
106+
// Old behavior: mustWindow tripped repaintTranscriptWindow on every
107+
// append, tearing down and rebuilding ~LONG_LOG_WINDOW (200) nodes.
108+
// Fixed behavior: one row evicted at the top to hold the window size.
109+
expect(removed).toBeLessThanOrEqual(1)
110+
} finally {
111+
shell.dispose()
112+
}
113+
},
114+
{ width: 80, height: 24 },
115+
)
116+
})
117+
})

0 commit comments

Comments
 (0)