Skip to content

bug(terminal): OpenCode terminal panes lose mouse tracking and cannot scroll or jump to latest #843

Description

@Juliusolsson05

Split out of PR #841, where two attempted fixes were written, reviewed and withdrawn. The diagnosis is solid and is recorded in full in docs/superpowers/research/2026-09-08-post-merge-regression-audit.md; this issue exists so the next attempt starts from the failures rather than repeating them.

Two symptoms, one family

The mouse wheel does nothing in an OpenCode raw terminal pane. Nothing swallows it. There is exactly one wheel handler in the renderer and it belongs to the feed, there is no capture-phase listener, no attachCustomWheelEventHandler, and the terminal container's parent is overflow-hidden.

The modes that make the wheel work are thrown away. attachAgentPty replays the trailing bytes of a capped buffer that evicts the oldest data, and a TUI writes its mode preamble exactly once at startup: 1049 alternate screen, 1000/1002/1003 mouse button/drag/any-event, 1006 SGR encoding. A TUI repainting at 60fps blows past the 512 KiB cap quickly, so a freshly-mounted xterm sits on the normal buffer with no mouse tracking while the application believes the opposite. It only attaches its wheel-to-mouse-report listener when the application has asked for wheel events, and its fallback returns early on a normal buffer. Meanwhile the TUI paints absolutely-addressed full frames that never push a line into scrollback, so viewport scrolling has nothing to scroll either. The pane still looks correct, because a full-screen repaint renders the same on either buffer.

Claude Code and Codex are unaffected: they render inline and push real scrollback.

Jump to Latest does nothing on the same pane, for the related reason that OpenTUI owns its transcript internally, so term.scrollToBottom() is a guaranteed no-op there.

Why the two obvious fixes were withdrawn

Tracking modes and prepending them to the replay. Reproduced against real xterm:

  1. Current modes cannot precede historical output. If the retained replay contains bytes written on the normal buffer and only later switches to the alternate screen, prefixing the current 1049h moves that earlier content onto the alternate buffer, where the following 1049l discards it. The correct input is the mode state at the replay's starting boundary, which means feeding a tracker the bytes the cap evicts, not the ones it keeps.
  2. A set of independent flags is not xterm's model. Mouse protocols are mutually exclusive: 1000h, 1003h, 1003l leaves reporting disabled, and 1003h then 1000h leaves VT200, not ANY. ESC c and the 1047/1048/1049 aliases matter too.

Doing it properly is a real terminal state machine. #766, which replaces the raw replay with a serialized screen, would remove the ordering problem entirely rather than sequencing around it — that is probably the right home for this.

Sending the TUI its own scroll chord. ESC + 0x07 is Ctrl+Alt+G, which OpenCode binds to messages_last; two independent reviews confirmed the binding and the encoding. But OpenCode keybinds are user-configurable, and a supported config can move messages_last elsewhere and put messages_undo — which aborts the session and reverts history — on that chord. A command called "Jump to Latest Message" must not be able to do that.

Reading the effective binding means reimplementing OpenCode's loader: JSONC, global plus per-project plus every .opencode directory up to home, variable substitution, a legacy migration, a win32 special case, and plugin-registered binds.

The route that is immune to both problems

OpenCode's server exposes POST /tui/execute-command, whose alias table maps messages_last to the stable command session.last and dispatches below the keybind layer. Unknown commands are inert, so it cannot be steered into messages_undo. It needs a known server URL, which means running opencode serve and using opencode attach <url> --session <id> instead of spawning the TUI directly. packages/opencode-headless/src/transport/SpawnedServer.ts already spawns a server and parses its listen line.

That transport change would fix scroll and jump together, and is worth doing before either symptom is attacked again.

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions