Skip to content

tui: PageUp/PageDown dismiss the /btw answer dialog instead of scrolling #59612

Open

Description

@pewpil

Summary

With the /btw answer dialog open, PageUp and PageDown close the dialog instead of scrolling the answer. The same happens with ctrl+alt+f, the other default binding for session.page.down. j/k, the arrow keys, Home and End scroll the dialog correctly.

The dialog already implements page scrolling (the Answer component's useKeyboard handler in packages/tui/src/feature-plugins/prompt/btw.tsx), but the session-level session.page.up / session.page.down bindings still fire while the modal dialog is open, and that handler dismisses the dialog. Because a /btw answer cannot be reopened after its dialog closes (#50879), pressing PageUp/PageDown loses the answer.

Environment

  • opencode version: 2.1.4 (/usr/bin/opencode; the updater offers 2.2.0, whose btw.tsx still has the same dialog handler)
  • OS: Linux 6.18.58-1-lts x86_64 (Arch)
  • Terminal: TERM=xterm-256color, COLORTERM=truecolor, TERM_PROGRAM unset
  • Shell: /usr/bin/bash
  • Install/channel: release binary
  • Active plugins: none (no plugin entries in ~/.config/opencode/opencode.json or ~/.config/opencode/cli.json)

Reproduction

  1. Open a session in the TUI.
  2. Run /btw Output the integers from 1 to 30, each on its own line, with no headings and no other prose. and wait for the answer dialog.
  3. Press PageUp or PageDown.

ctrl+alt+f (the other default binding for session.page.down) reproduces it too.

Expected Behavior

PageUp and PageDown scroll the answer by one page and the dialog stays open. End and Home keep jumping to the end and start of the answer. After the dialog closes, PageUp/PageDown scroll the session as before.

Actual Behavior

PageUp and PageDown dismiss the dialog, as if Escape were pressed. The answer is gone. j/k and the arrow keys scroll the answer, and Home/End jump to the start/end, all without closing it.

Additional Context

  • The dialog footer only advertises j/k ↑/↓, so the page keys are not discoverable even where they work.
  • The conflict is between the dialog's raw useKeyboard handler and the global session bindings. session.page.up / session.page.down default to pageup / pagedown (plus ctrl+alt+b / ctrl+alt+f), and those bindings dispatch while the modal dialog is open.
  • Verified by driving the real TUI in a 120x34 pty on 2.1.4:
    • PageDown -> dialog closed.
    • ctrl+alt+f -> dialog closed.
    • j / k -> answer scrolled, dialog stayed open.
    • End / Home -> answer jumped to bottom / top, dialog stayed open.
    • With session.page.up and session.page.down set to none in cli.json, PageUp/PageDown scrolled the dialog and it stayed open.
  • Workaround: set session.page.up and session.page.down to none (or move them off PageUp/PageDown) in cli.json. That restores page scrolling in /btw, but gives up PageUp/PageDown in the session, so it is not a satisfactory fix for users who page the transcript with those keys.
  • Related: [FEATURE]: a /btw answer is unrecoverable once its dialog closes #50879.

Activity

  1. opencode-agent commented on Oct 27, 2026

    @opencode-agent
    Contributor

    Thanks for the detailed report. I reproduced this on 2.1.4 and it still happens on the latest dev (8697644).

    What I did: open a session, run /btw with a question whose answer is longer than the dialog, then press a key.

    • j: the answer scrolls and the dialog stays open.
    • PageDown, PageUp and ctrl+alt+f: the dialog closes right away and the answer is gone.

    The recording and screenshots below show this, and the script below reproduces it.

    From the code, the cause appears to be that the session-wide session.page.up / session.page.down bindings still run while the /btw dialog is open, and their handler closes any open dialog, so the dialog's own page-scroll handling never takes effect. Your workaround (setting those bindings to none in cli.json) fits that. Related: #50879 (a /btw answer can't be recovered once its dialog closes), which is why this loses the answer.

    recording.mp4

    Screenshot

    Screenshot

    Reproduction script (drive.ts)
    // Reproduces anomalyco/opencode#59612: PageUp/PageDown close the /btw answer dialog
    // instead of scrolling it.
    import { Effect, Stream } from "effect"
    import { Llm, OpenCodeDriver } from "opencode-drive"
    
    const numbers = Array.from({ length: 60 }, (_, i) => `line ${i + 1}`).join("\n")
    
    type Oc = Parameters<Parameters<typeof OpenCodeDriver.use>[1]>[0]
    const ask = (ui: Oc["ui"], llm: Oc["llm"], label: string) =>
      Effect.gen(function* () {
        yield* ui.submit("/btw Output the integers from 1 to 60")
        yield* ui.waitFor(() => dialogOpen(ui).pipe(Effect.orDie), { timeout: 20000 })
        yield* Effect.sleep(800)
        yield* ui.screenshot(`${label}-dialog-open`)
      })
    
    const screenText = (ui: Oc["ui"]) =>
      ui.capture().pipe(
        Effect.map((f) => f.lines.map((l) => l.spans.map((s) => s.text).join("")).join("\n")),
      )
    // the /btw dialog is open when its header is on screen
    const dialogOpen = (ui: Oc["ui"]) =>
      screenText(ui).pipe(Effect.map((t) => t.includes("/btw  Output the integers")))
    
    export default OpenCodeDriver.use(
      {
        ...(process.env.OC_DEV ? { opencode: { dev: process.env.OC_DEV } } : {}),
        tui: { recording: true, keypressOverlay: true, viewport: { cols: 120, rows: 34 } },
      },
      ({ ui, llm }) =>
        Effect.gen(function* () {
          // establish a session first
          // simulated model: /btw questions get a 60-line answer, anything else a short reply
          yield* llm.serve((request) =>
            Stream.make(
              JSON.stringify(request).includes("integers from 1 to 60")
                ? Llm.text(numbers)
                : Llm.text("Hello from the main session."),
            ),
          )
          yield* ui.submit("hi")
          yield* ui.waitFor(() => screenText(ui).pipe(Effect.orDie, Effect.map((t) => t.includes("Build · Simulated Model ·"))), { timeout: 20000 })
    
          // control: j scrolls and keeps dialog open
          yield* ask(ui, llm, "a")
          for (let i = 0; i < 5; i++) yield* ui.press("j")
          yield* Effect.sleep(500)
          yield* ui.screenshot("a-after-j")
          yield* Effect.log(`after j, dialog open: ${yield* dialogOpen(ui)}`)
          yield* ui.press("escape")
          yield* Effect.sleep(800)
    
          // PageDown
          yield* ask(ui, llm, "b")
          // the simulated input has no named PageDown key, so send its terminal sequence (CSI 6 ~)
          yield* ui.press("\x1b[6~")
          yield* Effect.sleep(800)
          yield* ui.screenshot("b-after-pagedown")
          yield* Effect.log(`after pagedown, dialog open: ${yield* dialogOpen(ui)}`)
          if (yield* dialogOpen(ui)) yield* ui.press("escape")
          yield* Effect.sleep(800)
    
          // PageUp (CSI 5 ~)
          yield* ask(ui, llm, "b2")
          yield* ui.press("\x1b[5~")
          yield* Effect.sleep(800)
          yield* ui.screenshot("b2-after-pageup")
          yield* Effect.log(`after pageup, dialog open: ${yield* dialogOpen(ui)}`)
          if (yield* dialogOpen(ui)) yield* ui.press("escape")
          yield* Effect.sleep(800)
    
          // ctrl+alt+f
          yield* ask(ui, llm, "c")
          yield* ui.press("f", { ctrl: true, meta: true })
          yield* Effect.sleep(800)
          yield* ui.screenshot("c-after-ctrl-alt-f")
          yield* Effect.log(`after ctrl+alt+f, dialog open: ${yield* dialogOpen(ui)}`)
        }),
    )

    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.

  2. Aafimalek commented on Oct 27, 2026

    @Aafimalek

    I reproduced the Page Down dismissal on Windows against dev using the TUI test renderer. I'd like to work on a focused fix and regression test. Could a maintainer assign this to me?

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