Skip to content

Commit 6ff168d

Browse files
committed
fix(tui): keep the reader's place while scrolled up in a long session
The sync store keeps at most 100 messages per session and drops the oldest one whenever a new message arrives (context/sync.tsx). Each drop removes rows from the top of the transcript scrollbox while scrollTop stays put, so a reader scrolled up into history sees the text slide upward on every new assistant message, and once it reaches the bottom sticky scroll re-engages and follows the stream again. Short sessions never hit the cap, which is why the bug only shows up in long agentic runs. keepScrollAnchor() listens for the root's "layout-changed" event, which fires after the new layout is computed but before any renderable reads it. While the scrollbox is not at the bottom it takes the first child that was visible under the old layout and scrolls by the distance that child moved, so the view stays put before the frame is drawn. At the bottom it does nothing and sticky scroll keeps following as before. AI-Tool: opencode 1.18.33-vt-60-2fa3363c92 AI-Model: anthropic/claude-opus-5-5 AI-Variant: high AI-Platform: linux AI-Harness: Vibeterm 614d8d2 AI-Session-ID: ses_ef81ca107ffet5DwnFLaGWlUap
1 parent 0b0357e commit 6ff168d

3 files changed

Lines changed: 93 additions & 3 deletions

File tree

‎packages/tui/src/routes/session/index.tsx‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ import { setPreLayoutSiblingMargin } from "../../util/layout"
7373
import { useTuiConfig } from "../../config"
7474
import { useClipboard } from "../../context/clipboard"
7575
import { nextThinkingMode, reasoningSummary, useThinkingMode, type ThinkingMode } from "../../context/thinking"
76-
import { getScrollAcceleration } from "../../util/scroll"
76+
import { getScrollAcceleration, keepScrollAnchor } from "../../util/scroll"
7777
import { collapseToolOutput } from "../../util/collapse-tool-output"
7878
import { usePluginRuntime } from "../../plugin/runtime"
7979
import { DialogRetryAction } from "../../component/dialog-retry-action"
@@ -1249,7 +1249,10 @@ export function Session() {
12491249
<box flexGrow={1} minHeight={0} paddingBottom={1} paddingLeft={2} paddingRight={2} gap={1}>
12501250
<Show when={session()}>
12511251
<scrollbox
1252-
ref={(r) => (scroll = r)}
1252+
ref={(r) => {
1253+
scroll = r
1254+
keepScrollAnchor(renderer, r)
1255+
}}
12531256
viewportOptions={{
12541257
paddingRight: showScrollbar() ? 1 : 0,
12551258
}}

‎packages/tui/src/util/scroll.ts‎

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { MacOSScrollAccel, type ScrollAcceleration } from "@opentui/core"
1+
import { MacOSScrollAccel, type CliRenderer, type ScrollAcceleration, type ScrollBoxRenderable } from "@opentui/core"
22

33
export type ScrollConfig = {
44
scroll_acceleration?: { enabled?: boolean }
@@ -25,3 +25,21 @@ export function getScrollAcceleration(tuiConfig?: ScrollConfig): ScrollAccelerat
2525

2626
return new CustomSpeedScroll(3)
2727
}
28+
29+
// Keeps a scrolled-up reader's place when content above the viewport changes
30+
// height, e.g. when the session transcript drops its oldest message. Sticky
31+
// scroll only follows the bottom, so without this the text slides under a fixed
32+
// scrollTop until it reaches the bottom and starts following again. The root
33+
// emits "layout-changed" after computing the new layout but before any
34+
// renderable reads it, so the first child that was visible is moved back to
35+
// where it was before anything is drawn.
36+
export function keepScrollAnchor(renderer: CliRenderer, scroll: ScrollBoxRenderable) {
37+
const keep = () => {
38+
if (scroll.isDestroyed) return renderer.root.off("layout-changed", keep)
39+
if (scroll.scrollTop >= scroll.scrollHeight - scroll.viewport.height) return
40+
const anchor = scroll.getChildren().find((child) => child.y - scroll.content.y + child.height > scroll.scrollTop)
41+
if (!anchor) return
42+
scroll.scrollBy(anchor.getLayoutNode().getComputedLayout().top - (anchor.y - scroll.content.y))
43+
}
44+
renderer.root.on("layout-changed", keep)
45+
}
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
/** @jsxImportSource @opentui/solid */
2+
import { expect, test } from "bun:test"
3+
import type { ScrollBoxRenderable } from "@opentui/core"
4+
import { testRender, useRenderer } from "@opentui/solid"
5+
import { createSignal, For } from "solid-js"
6+
import { keepScrollAnchor } from "../../src/util/scroll"
7+
8+
async function mount() {
9+
const [items, setItems] = createSignal(Array.from({ length: 40 }, (_, i) => i))
10+
let scroll!: ScrollBoxRenderable
11+
const app = await testRender(
12+
() => {
13+
const renderer = useRenderer()
14+
return (
15+
<scrollbox
16+
ref={(r: ScrollBoxRenderable) => {
17+
scroll = r
18+
keepScrollAnchor(renderer, r)
19+
}}
20+
stickyScroll={true}
21+
stickyStart="bottom"
22+
flexGrow={1}
23+
>
24+
<For each={items()}>
25+
{(item) => (
26+
<box flexShrink={0} height={2}>
27+
<text>{`item ${item}`}</text>
28+
</box>
29+
)}
30+
</For>
31+
</scrollbox>
32+
)
33+
},
34+
{ width: 30, height: 10 },
35+
)
36+
await app.renderOnce()
37+
// Drop the oldest item and append a new one, as the session transcript does.
38+
const rotate = async () => {
39+
setItems((list) => [...list.slice(1), list[list.length - 1]! + 1])
40+
await app.renderOnce()
41+
}
42+
const firstLine = () => app.captureCharFrame().trim().split("\n")[0]!.trim()
43+
return { app, scroll: () => scroll, rotate, firstLine }
44+
}
45+
46+
test("keeps the reader's place when items above the viewport are dropped", async () => {
47+
const view = await mount()
48+
view.scroll().scrollBy(-20)
49+
await view.app.renderOnce()
50+
const before = view.firstLine()
51+
52+
await view.rotate()
53+
await view.rotate()
54+
await view.rotate()
55+
56+
expect(view.firstLine()).toBe(before)
57+
view.app.renderer.destroy()
58+
})
59+
60+
test("keeps following new items at the bottom", async () => {
61+
const view = await mount()
62+
expect(view.app.captureCharFrame()).toContain("item 39")
63+
64+
await view.rotate()
65+
await view.rotate()
66+
67+
expect(view.app.captureCharFrame()).toContain("item 41")
68+
view.app.renderer.destroy()
69+
})

0 commit comments

Comments
 (0)