Skip to content

tui: first message-navigation jump can land at the top of the transcript #57059

Open

Description

@xuwangaa

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

  1. Bind a key to session.message.user.previous in cli.json (I use alt+up; the default keybind table leaves it unbound). Reproduces the same way when running the command from the palette.
  2. Open a session whose transcript is long enough that older rows are unmounted (tail-first mounting keeps only TRANSCRIPT_TAIL_ROWS = 40 rendered rows while following the bottom).
  3. Scroll up any amount with the mouse wheel (or page up) and stay away from the bottom.
  4. Press the session.message.user.previous key 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:

  1. scrollToMessage wraps its work in ensureAllRows, which calls setHiddenRows(0) to mount the entire transcript at once. Unlike revealOlderRows (which compensates the viewport with scrollBy(scrollHeight - before + scrollBy) after backfill), ensureAllRows performs no scroll compensation after the content grows.
  2. In ScrollBox.recalculateBarProps() (@opentui/core), when the content grows, scrollTop is only re-pinned to the new maximum while sticky bottom is engaged and _hasManualScroll is false. After any manual scroll, _hasManualScroll is true and scrollTop keeps its old numeric value while rows are inserted above, so the viewport shifts up by the height of the entire newly mounted history.
  3. findMessageBoundary (packages/tui/src/routes/session/message-navigation.ts) then computes the target from the now-shifted scrollTop (visible.findLast(child => child.y < input.scrollTop)), selecting an anchor near the top of the transcript, and jumpToMessage scrolls 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

Activity

  1. opencode-agent commented on Oct 19, 2026

    @opencode-agent
    Contributor

    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:

    1. Bind alt+up to session.message.user.previous in cli.json.
    2. Make a session with 30 prompt/reply turns.
    3. Scroll up a few mouse-wheel steps, so Prompt 29 is on screen.
    4. Press 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: ensureAllRows mounts every row without moving the scroll position to keep the same content in view, as revealOlderRows does.

    recording.mp4

    Screenshot

    Screenshot

    Reproduction script (drive.ts)
    // Reproduces anomalyco/opencode#57059: the first session.message.user.previous jump
    // after scrolling up in a long, tail-mounted transcript lands near the top.
    import { Effect, Stream } from "effect"
    import { Llm, OpenCodeDriver } from "opencode-drive"
    
    const TURNS = 30
    
    const frameText = (frame: { lines: ReadonlyArray<{ spans: ReadonlyArray<{ text: string }> }> }) =>
      frame.lines.map((l) => l.spans.map((s) => s.text).join("")).join("\n")
    
    const prompts = (text: string) => [...text.matchAll(/Prompt (\d+)\b/g)].map((m) => Number(m[1]))
    
    export default OpenCodeDriver.use(
      {
        ...(process.env.OC_DEV ? { opencode: { dev: process.env.OC_DEV } } : {}),
        tui: { recording: true, keypressOverlay: true, viewport: { cols: 120, rows: 40 } },
        tuiConfig: { keybinds: { "session.message.user.previous": "alt+up" } },
      },
      ({ ui, llm }) =>
        Effect.gen(function* () {
          yield* llm.serve((_req, index) =>
            Stream.make(
              Llm.text(
                Array.from({ length: 6 }, (_, i) => `Reply ${index + 1} line ${i + 1}.`).join("\n\n"),
                { delay: 0 },
              ),
            ),
          )
          for (let i = 1; i <= TURNS; i++) {
            yield* ui.submit(`Prompt ${i}`)
            yield* ui.waitFor(`Reply ${i} line 6.`, { timeout: 30000 })
          }
          yield* Effect.sleep(1000)
          yield* ui.screenshot("01-bottom")
          // Scroll up a little with the mouse wheel (well away from the top).
          for (let i = 0; i < 5; i++) {
            yield* ui.mouse({ action: "scroll", x: 40, y: 10, direction: "up" })
            yield* Effect.sleep(100)
          }
          yield* Effect.sleep(1000)
          const before = frameText(yield* ui.capture())
          yield* ui.screenshot("02-scrolled-up")
          yield* Effect.log(`BEFORE first press, visible prompts: ${prompts(before)}`)
          yield* ui.press("up", { meta: true })
          yield* Effect.sleep(1500)
          const after1 = frameText(yield* ui.capture())
          yield* ui.screenshot("03-after-first-press")
          yield* Effect.log(`AFTER first alt+up, visible prompts: ${prompts(after1)}`)
          yield* ui.press("up", { meta: true })
          yield* Effect.sleep(1500)
          const after2 = frameText(yield* ui.capture())
          yield* ui.screenshot("04-after-second-press")
          yield* Effect.log(`AFTER second alt+up, visible prompts: ${prompts(after2)}`)
        }),
    )

    Run it with opencode-drive: opencode-drive run ./drive.ts. It drives opencode from your PATH; set OC_DEV to the path of an OpenCode checkout to run that from source instead.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions