Repository navigation
Description
Summary
In the TUI, the first press of a message-navigation command (session.message.user.previous / session.message.next etc.) in a long session jumps to a message near the top of the transcript when the view is scrolled away from the bottom at the moment of the press. Subsequent presses navigate correctly.
Environment
- opencode version: 2.0.26 (release/installer); code paths verified identical in v2.0.32 and current
dev - OS: Linux 7.0.0-38-generic (linux x64)
- Terminal: Ptyxis 50.1, TERM=xterm-256color, COLORTERM=truecolor
- Shell: zsh
- Install/channel: latest
- Active plugins: none
Reproduction
- Bind a key to
session.message.user.previousincli.json(I usealt+up; the default keybind table leaves it unbound). Reproduces the same way when running the command from the palette. - Open a session whose transcript is long enough that older rows are unmounted (tail-first mounting keeps only
TRANSCRIPT_TAIL_ROWS = 40rendered rows while following the bottom). - Scroll up any amount with the mouse wheel (or page up) and stay away from the bottom.
- Press the
session.message.user.previouskey for the first time since the transcript was tail-mounted.
Expected Behavior
The viewport jumps to the previous user message relative to the current position.
Actual Behavior
The view lands on a message near the very top of the transcript. The next presses of the same key navigate correctly, because all rows are mounted by then. Returning to the bottom re-shrinks the mount window, so the bug can trigger again on the next "first" press.
Root cause
Traced through packages/tui/src/routes/session/index.tsx and @opentui/core@0.5.17 ScrollBox:
scrollToMessagewraps its work inensureAllRows, which callssetHiddenRows(0)to mount the entire transcript at once. UnlikerevealOlderRows(which compensates the viewport withscrollBy(scrollHeight - before + scrollBy)after backfill),ensureAllRowsperforms no scroll compensation after the content grows.- In
ScrollBox.recalculateBarProps()(@opentui/core), when the content grows,scrollTopis only re-pinned to the new maximum while sticky bottom is engaged and_hasManualScrollis false. After any manual scroll,_hasManualScrollis true andscrollTopkeeps its old numeric value while rows are inserted above, so the viewport shifts up by the height of the entire newly mounted history. findMessageBoundary(packages/tui/src/routes/session/message-navigation.ts) then computes the target from the now-shiftedscrollTop(visible.findLast(child => child.y < input.scrollTop)), selecting an anchor near the top of the transcript, andjumpToMessagescrolls there.
This explains the exact trigger conditions: the session must have unmounted history above (transcript beyond the 40-row tail window), and the first navigation press must happen while the ScrollBox is not holding the sticky bottom (e.g. after any manual scroll up). Both first-press-at-bottom and every subsequent press work fine.
Suggested fix
Mirror revealOlderRows inside ensureAllRows: capture scroll.scrollHeight before setHiddenRows(0) / setVisibleRowsEnd(undefined), and after the layout settles scroll by scroll.scrollHeight - before (when the reader was away from the sticky bottom) so the viewport stays anchored on the same content before running the queued continuations.
Additional Context
message-navigation.tsis byte-identical between v2.0.26, v2.0.32, and currentdev;ensureAllRowshas no compensation in any of them, so this is not fixed upstream yet as of v2.0.32.- Related history: Feature request: Support Alt+↑ / Alt+↓ to navigate between user commands within the current session #34727 added the Alt+↑/↓ user-message navigation this report is about.
Activity
opencode-agent commentedon Oct 19, 2026
Thanks for the detailed report and root-cause analysis. I reproduced this on v2.0.26 and it still happens on the latest
dev(cec70f3), run from source.Steps:
alt+uptosession.message.user.previousincli.json.alt+up.The first press jumps to Prompt 11 instead of Prompt 29. The second press goes to Prompt 10, one step back from there. The recording and screenshots below show it, and the script below reproduces it.
The code matches your diagnosis:
ensureAllRowsmounts every row without moving the scroll position to keep the same content in view, asrevealOlderRowsdoes.recording.mp4
Reproduction script (drive.ts)
Run it with opencode-drive:
opencode-drive run ./drive.ts. It drivesopencodefrom yourPATH; setOC_DEVto the path of an OpenCode checkout to run that from source instead.