Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions src/renderer/src/lib/echo-router.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -562,3 +562,72 @@ describe('echo-router — reseed capture timeout', () => {
expect(written).toEqual(['held'])
})
})

// pear#403: on the direct route the viewport re-pin must fire from the write
// COMPLETION callback (after xterm parses the chunk), not synchronously before
// it. term.write is async: a chunk can scroll the viewport off the bottom
// during its own parse (a TUI SIGWINCH full-screen redraw rewrites scrollback
// and resets ydisp). A synchronous scrollToBottom issued before that parse is
// undone by it; the viewport is then stranded off-bottom, isViewportPinned()
// reads false, and every later chunk stops re-pinning — the grid freezes in
// scrollback with byte-correct content (codex resize-mid-stream, viewport
// [0,baseY] at quiet). These lock the post-parse re-pin, gated on the
// pre-write follow intent so a deliberate scrollback read is never yanked down.
describe('echo-router — #403 direct-route viewport re-pin after parse', () => {
function makeRepinHarness() {
const writes: string[] = []
let pendingCb: (() => void) | null = null
let pinned = true
const scrollToBottom = vi.fn()
const router = createEchoRouter({
// Defer the completion callback so the test controls the parse boundary,
// exactly like xterm's async WriteBuffer.
write: (data, callback) => {
writes.push(data)
pendingCb = callback ?? null
},
getEngine: () => null,
buildModelSeed: () => '\x1bc',
getInputSrtt: () => null,
isViewportPinned: () => pinned,
scrollToBottom
})
return {
router,
writes,
scrollToBottom,
setPinned: (value: boolean) => {
pinned = value
},
completeParse: () => {
const cb = pendingCb
pendingCb = null
cb?.()
}
}
}

it('re-pins only AFTER the chunk is parsed, never synchronously before it', async () => {
const h = makeRepinHarness()
// A full-screen redraw: the kind of chunk whose parse moves the viewport.
h.router.onServerOutput('\x1b[2J\x1b[Hfull redraw at new size')
// Write issued, parse not yet complete → a synchronous (pre-parse) re-pin
// would already have fired here. It must not.
expect(h.writes).toHaveLength(1)
expect(h.scrollToBottom).not.toHaveBeenCalled()
// Parse completes (viewport may now be off-bottom); the completion callback
// re-pins, so the stranding cascade never starts.
h.completeParse()
expect(h.scrollToBottom).toHaveBeenCalledTimes(1)
await h.router.dispose()
})

it('does not re-pin when the user has scrolled into scrollback', async () => {
const h = makeRepinHarness()
h.setPinned(false)
h.router.onServerOutput('another streamed row\r\n')
h.completeParse()
expect(h.scrollToBottom).not.toHaveBeenCalled()
await h.router.dispose()
})
})
30 changes: 28 additions & 2 deletions src/renderer/src/lib/echo-router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,32 @@ export function createEchoRouter(deps: EchoRouterDeps): EchoRouter {
if (wasPinned) deps.scrollToBottom()
}

// Re-pin to the bottom AFTER xterm has parsed a direct-route chunk, not
// before it. `term.write` is asynchronous: a chunk can move the viewport off
// the bottom DURING its own parse — a TUI's SIGWINCH full-screen redraw
// rewrites scrollback and resets ydisp to the top — so a scrollToBottom
// issued synchronously (writePinnedAware, before the parse) is immediately
// undone by the parse. Once the viewport is stranded off-bottom,
// isViewportPinned() reads false and every subsequent chunk stops re-pinning:
// the grid freezes in scrollback showing byte-correct content forever
// (pear#403 — codex resize-mid-stream, viewport [0,baseY] at quiet). Firing
// the re-pin from the write-completion callback closes that race. Follow
// intent is still captured BEFORE the write, so a user who has deliberately
// scrolled up (viewportY < baseY) is never yanked back down.
const writeDirectRepinned = (data: string, callback?: () => void): void => {
if (!deps.isViewportPinned()) {
// Viewport is not at the bottom: the user has deliberately scrolled into
// scrollback. Leave it there — never yank a reader down — and don't
// manufacture a completion callback the caller didn't ask for.
deps.write(data, callback)
return
}
deps.write(data, () => {
deps.scrollToBottom()
callback?.()
})
}

// Engine route: always enqueued (the engine's tail is asynchronous).
const writeViaEngine = (engine: PredictiveEchoWithStatus, data: string): void => {
enqueueOp(() => {
Expand All @@ -159,11 +185,11 @@ export function createEchoRouter(deps: EchoRouterDeps): EchoRouter {
// zero overhead on local sessions), ordered behind it otherwise.
const writeDirectOrdered = (data: string, callback?: () => void): void => {
if (queuedOps === 0) {
writePinnedAware(data, (chunk) => deps.write(chunk, callback))
writeDirectRepinned(data, callback)
return
}
enqueueOp(() => {
writePinnedAware(data, (chunk) => deps.write(chunk, callback))
writeDirectRepinned(data, callback)
})
}

Expand Down
56 changes: 56 additions & 0 deletions src/renderer/src/lib/terminal-runtime-registry.dom.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -432,3 +432,59 @@ describe('terminal-runtime-registry — dispose cancels pending init rAF', () =>
}
})
})

// pear#403: a reflow (fitAddon.fit → term.resize) can scroll the viewport off
// the bottom — a narrower grid rewraps lines into scrollback, bumping baseY
// past viewportY. tryFit must re-pin a FOLLOWING viewport to the tail on every
// reflow, so a resize storm never strands the grid in scrollback with
// byte-correct content. The follow intent is sticky (flipped only by a real
// user wheel scroll), NOT the instantaneous viewportY===baseY, because that is
// exactly what a transient reflow poisons.
describe('terminal-runtime-registry — #403 follow-bottom re-pin across reflow', () => {
async function nextFrame(): Promise<void> {
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()))
}

it('re-pins on fit while following, suspends after a wheel-scroll into scrollback, resumes at the bottom', async () => {
const runtime = registry.acquireTerminalRuntime({
projectId: 'p',
agentName: 'a',
terminalMode: 'drive',
theme: 'dark',
getInputSrtt: () => null
})
const term = createdTerminals[0]
// Give the runtime's own host layout so tryFit runs its fit + re-pin.
Object.defineProperty(runtime.host, 'clientWidth', { configurable: true, value: 800 })
Object.defineProperty(runtime.host, 'clientHeight', { configurable: true, value: 600 })
runtime.mount(makeLayoutContainer())
await flushAsync()

const scrollSpy = vi.spyOn(term, 'scrollToBottom')

// Following by default: a reflow re-pins to the bottom.
scrollSpy.mockClear()
runtime.fitAndSync()
expect(scrollSpy).toHaveBeenCalled()

// User wheels up into scrollback (viewport off the bottom): following is
// suspended, so a subsequent reflow must NOT yank them down.
term.buffer.active.viewportY = 40
term.buffer.active.baseY = 100
runtime.host.dispatchEvent(new Event('wheel'))
await nextFrame()
scrollSpy.mockClear()
runtime.fitAndSync()
expect(scrollSpy).not.toHaveBeenCalled()

// User returns to the bottom: following resumes and reflow re-pins again.
term.buffer.active.viewportY = 100
runtime.host.dispatchEvent(new Event('wheel'))
await nextFrame()
scrollSpy.mockClear()
runtime.fitAndSync()
expect(scrollSpy).toHaveBeenCalled()

registry.disposeTerminalRuntime(runtime.key)
})
})
38 changes: 37 additions & 1 deletion src/renderer/src/lib/terminal-runtime-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,29 @@ function createRuntime(
term.loadAddon(fitAddon)
term.loadAddon(new WebLinksAddon())

// #403: sticky "user is following the bottom" intent. The instantaneous
// `viewportY === baseY` is NOT a reliable follow signal: a width/height
// reflow (resize) or a TUI's SIGWINCH full-screen redraw can transiently
// scroll the viewport off the bottom, and once it is off, that instantaneous
// check reads false, so every subsequent write/fit stops re-pinning and the
// grid freezes in scrollback with byte-correct content (codex
// resize-mid-stream: viewport ends at [0, baseY] though every cell matches
// the broker). `followBottom` is flipped ONLY by a real user wheel scroll, so
// reflow/redraw unpins are always corrected while a deliberate scrollback
// read is respected. The echo-router and tryFit re-pin against this intent.
let followBottom = true
host.addEventListener(
'wheel',
() => {
Comment on lines +308 to +310

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Track scrollbar-driven changes to follow intent

Listening only for wheel misses deliberate scrolling performed by dragging xterm's scrollbar. In that case followBottom remains true, so the next direct write completion or tryFit() calls scrollToBottom() and immediately yanks the user out of scrollback; dragging the scrollbar back to the bottom likewise cannot re-arm a previously cleared intent. Track the terminal viewport's actual user-driven scroll changes rather than only wheel events.

Useful? React with 👍 / 👎.

// Read after xterm has applied the scroll, so a wheel-to-bottom re-arms
// following and a wheel-up (into scrollback) suspends it.
requestAnimationFrame(() => {
if (term) followBottom = isViewportPinnedToBottom(term)
})
},
{ passive: true }
)

let onDataHandler: ((data: string) => void) | null = null
term.onData((data) => {
onDataHandler?.(data)
Expand Down Expand Up @@ -350,7 +373,12 @@ function createRuntime(
getEngine: () => predictiveEcho,
buildModelSeed: () => (term ? buildModelSeedFromTerminal(term) : '\x1bc'),
getInputSrtt: () => currentSrttGetter(),
isViewportPinned: () => (term ? isViewportPinnedToBottom(term) : false),
// Re-pin against the sticky follow intent, not the instantaneous viewport
// position: a chunk that scrolls the viewport off the bottom during its
// own async parse must still be re-pinned by the completion callback
// (pear#403). A user who scrolled into scrollback (wheel-up) clears the
// intent and is left alone.
isViewportPinned: () => followBottom,
scrollToBottom: () => term?.scrollToBottom()
})
// Quiet-time convergence to the broker's authoritative screen. Catches the
Expand Down Expand Up @@ -440,6 +468,14 @@ function createRuntime(
} catch {
return null
}
// #403: a width/height reflow can scroll the viewport off the bottom (a
// narrower grid rewraps lines into scrollback, bumping baseY past
// viewportY). Centralize the re-pin here so EVERY reflow site — the
// ResizeObserver fit, the reconciler's onPersistentDimsMismatch resync,
// init, and refreshOnShow — keeps a following viewport at the tail. Gated
// on the sticky follow intent, so a resize while the user is reading
// scrollback does not yank them down.
if (followBottom) term.scrollToBottom()
const { rows, cols } = term
if (rows > 0 && cols > 0) {
return { rows, cols }
Expand Down
Loading