Skip to content

tui: terminal pane viewport is frozen during a selection drag, so a selection can never extend past one screen #61053

Open

Description

@mleivaDesarrollos

Summary

In the TUI's terminal pane, a mouse drag-selection locks the pane's viewport: while the button is held, wheel input neither scrolls the pane nor extends the selection, so anything copied is limited to the rows that were on screen when the drag started. Content that needs scrolling mid-selection cannot be copied at all, which breaks copying a command's output into another agent/message.

Environment

  • opencode version: 2.1.4
  • OS: Linux 7.2.11-arch1-1 (Arch Linux), x86_64
  • Terminal: WezTerm over SSH (TERM=xterm-256color, no TERM_PROGRAM inside the session)
  • Shell: /usr/bin/zsh
  • Install/channel: release binary at ~/.opencode/bin/opencode, latest
  • Active plugins: none

Reproduction

  1. Start the TUI and open the terminal pane (<leader>t) so the persistent pty pane is focused.
  2. Run seq 1 200 so the output is taller than the pane.
  3. Press and hold the left mouse button on a line in the pane, and while holding, scroll the wheel up and down.
  4. Observe: the pane's viewport does not move and the selection does not grow. The screen stays exactly as it was when the drag began.
  5. Release. With the default terminal.copy: "select" the clipboard receives only the pre-drag screen; with terminal.copy: "manual", ctrl+c copies the same limited block.

Any scrollback reproduces it — a plain seq is enough, no full-screen app (less, vim, htop) required.

Expected Behavior

Dragging a selection to the top or bottom edge of the pane scrolls the viewport (edge autoscroll), and wheel input during a drag scrolls and extends the selection, the way every terminal emulator behaves. A single selection should be able to span more than one screen.

Actual Behavior

The viewport is frozen for the duration of the drag, so a selection is bounded by the screen that was visible when the drag started. Copying a longer output degrades into copy → scroll → copy again, tracking by hand what was already copied.

Additional Context

Mechanism, from the code and the OpenTUI docs, in case it saves triage time:

  • The pane is EmbeddedTerminalRenderable (packages/tui/src/component/terminal-pane.tsx → packages/core/src/renderables/EmbeddedTerminal.ts).

  • Local scroll happens only when the child does not encode the wheel event: contextEmbeddedTerminalScroll. OpenTUI docs (components/embedded-terminal, "Mouse and local scroll"): "When the child does not encode a wheel event, the renderable scrolls its own viewport by 3 rows and stops the event."

  • Selection is screen-space by design: "The renderable joins the renderer selection and clamps drag endpoints to the visible grid", and "The renderer starts host selection before it dispatches the mouse event. … preventDefault() does not cancel that selection."

  • The clamp is visible in onSelectionChanged, which is why a drag can never address rows outside the pane:

    const point = (x: number, y: number) => {
      if (y < 0) return { x: 0, y: 0 }
      if (y >= this.height) return { x: this.width - 1, y: this.height - 1 }
      return { x: Math.max(0, Math.min(this.width - 1, x)), y }
    }
    const start = point(local.anchorX, local.anchorY)
    const end = point(local.focusX, local.focusY)
    this.lib.contextEmbeddedTerminalSetSelection(this.handle.context, this.handle, start, end)
  • Putting those together: once a drag begins the renderer owns the gesture (per the docs above), wheel events during the drag do not reach the local-scroll branch in forwardMouse, and the clamped endpoints cannot leave the visible grid. The freeze is those two behaviours meeting, and the fix has to address both.

Two changes would fix it:

  1. Keep forwarding wheel events (or implement edge autoscroll) while a host selection drag is active, so the viewport can move mid-drag.
  2. Anchor the selection to absolute scrollback lines instead of visible-grid cells, so a selection keeps referring to the same content when the viewport moves. This is the same root cause as TUI copy should preserve logical text instead of rendered layout #47165 ("TUI copy should preserve logical text instead of rendered layout").

Related issues: #47165 (logical vs rendered text on copy), #60942 (--no-mouse / --mouse overrides, a workaround people ask for), #47632 (selection cleared by messages_page_up/down, V1, closed).

Notes:

  • Retained scrollback in the pane is the renderable default maxScrollback of 10000 bytes — the TUI does not pass the option (terminal-pane.tsx has no reference to it). That cap is separate from this bug but worth knowing when a copy "loses" very old output.
  • Filed on 2.1.4; the newest release at the time of writing is 2.2.3 and this has not been re-tested there.
  • Workaround in use: read the pane text over the API instead of copying (GET /api/experimental/persistent-pty/{ptyID}/snapshot after listing panes with GET /api/experimental/session/{sessionID}/terminal), which returns the full retained buffer.

Activity

  1. opencodejr commented on Nov 1, 2026

    @opencodejr
    Contributor

    Thanks for the detailed write-up and for tracing it through the code and docs.

    To restate the request: in the TUI terminal pane, a drag-selection should be able to cover more than one screen. Scrolling the wheel during a drag, or dragging to the top or bottom edge, should scroll the pane and extend the selection, and the selection should stay attached to the same scrollback lines while the pane moves.

    As you found, the pane uses OpenTUI's embedded terminal, and its docs say the selection is clamped to the visible grid on purpose. So the current behaviour matches the documented design, and I'm treating this as a feature request rather than a bug. Most of the change would need to happen in OpenTUI. Maintainers will consider it.

    Related issues:

    Thanks also for sharing the snapshot API workaround and the note about the default scrollback limit.

  2. mleivaDesarrollos commented on Nov 1, 2026

    @mleivaDesarrollos
    Author

    Reopening the classification on this one, with the cause narrowed down: the wheel-during-drag part is a defect, not documented design.

    The docs' local-scroll rule is unconditional — "When the child does not encode a wheel event, the renderable scrolls its own viewport by 3 rows and stops the event." So if the wheel reached the renderable during a drag, the viewport would move. It never gets there: the event is misdecoded before any renderable sees it.

    packages/core/src/lib/parse.mouse.ts, decodeSgrEvent:

    const isScroll = (rawButtonCode & 64) !== 0
    const isMotion = (rawButtonCode & 32) !== 0
    …
    if (isMotion) {
      const isDragging = this.mouseButtonsPressed.size > 0
      if (button === 3) type = "move"
      else if (isDragging) type = "drag"        // ← wheel while a button is held lands here
      else type = "move"
    } else if (isScroll && pressRelease === "M") {
      type = "scroll"
      scrollInfo = { direction: scrollDirection!, delta: 1 }
    }

    A wheel event sent while a button is held carries the motion bit, so isMotion wins the branch: the decoded event is type: "drag" with scroll: undefined, and the renderer's scroll path (renderer.ts, the mouseEvent.type === "scroll" branch) is never reached. That is why the pane's viewport does not move. The legacy decoder has the same ordering — decodeBasicEvent tests if (isMotion) → move before else if (isScroll) — so it drops the wheel too.

    Minimal fix: test isScroll first in both decoders.

    if (isScroll && pressRelease === "M") {
      type = "scroll"
      scrollInfo = { direction: scrollDirection!, delta: 1 }
    } else if (isMotion) {
      …
    }

    That restores wheel scrolling during a drag. The other half — dragging past the pane edge, and keeping a selection anchored to the same scrollback lines while the viewport moves — is the part that needs the logical-anchor work from #47165, and that one is fairly a feature request.

    Happy to split this into two issues (parser wheel decoding vs selection anchoring) if that helps triage.

    One correction to my own write-up: I said the renderer's selection owns the gesture, so wheel events during a drag do not reach the local-scroll branch. The effect is right but the cause is upstream — the wheel is already dropped in the parser, before the renderer classifies the event at all.

  3. mleivaDesarrollos commented on Nov 1, 2026

    @mleivaDesarrollos
    Author

    Splitting this the way I offered, since the two halves have different owners and very different cost:

    Worth recording why this matters for a plain user, not just an agent workflow: copying a command's output to share it elsewhere (chat, ticket, another machine) is the single most common reason to touch that pane, and the current behaviour forces copy → scroll → copy while remembering what was already taken. The wheel fix alone removes most of that pain; the anchoring fix removes the rest.

  4. mleivaDesarrollos commented on Nov 2, 2026

    @mleivaDesarrollos
    Author

    Re-tested on 2.2.5 (upgraded from 2.1.4): still reproducible, unchanged behaviour.

    Two distinct symptoms, from the user's side of the pane:

    1. Wheel while the button is held does nothing — the pane neither scrolls nor extends the selection.
    2. Dragging to the top edge does not scroll the content — hold the button on a line and move up past the first visible row, and the viewport stays put, so a selection can't reach the line above the fold. This is the one that breaks the workflow: to copy a longer output you have to copy a screenful, scroll, copy the next screenful, and keep track of where you stopped.

    Plain wheel scrolling with no button held works as expected — the content moves normally. So the failure is specifically the combination of a held button and scroll input, which matches the decoder analysis in anomalyco/opentui#1911: the wheel arrives with the motion bit set, isMotion is tested before isScroll, and the event is delivered as drag with scroll: undefined. Worth noting the packaged 2.2.5 binary still contains the mouseButtonsPressed.size > 0 branch, so the code path is the same one described there.

    Symptom 2 is the host-level half that #1911 explicitly does not cover (even with the wheel decoded correctly, nothing in the pane scrolls on pointer position alone), so it stays here.

  5. mleivaDesarrollos commented on Nov 4, 2026

    @mleivaDesarrollos
    Author

    Captured the raw bytes of the failing gesture (WezTerm, opencode 2.2.5) — and it corrects my earlier diagnosis: the wheel is not dropped by the input parser. 120 mouse packets, 18 wheel ticks, every one Cb=64/65 with no motion bit, so they decode as scroll exactly as they should.

    What the capture shows instead, for press-at-bottom → drag-up-past-the-top:

     43  Cb=0   x=230 y=52 M   press
     44  Cb=32  x=230 y=51 M   drag ┐
     …                             │ 28 drags
     66  Cb=32  x=212 y=1  M   drag ┘   ← y clamped at row 1, outside the pane
     67  Cb=32  x=216 y=6  M   drag
     68  Cb=65  x=216 y=6  M   wheel down
     69  Cb=64  x=216 y=6  M   wheel up  (18 total, at the pane's top edge)
     90  Cb=0   x=215 y=9  m   release
    

    Two defects, both host-side:

    1. No edge autoscroll. Dragging past the first row leaves anchorY/focusY clamped to the visible grid (onSelectionChanged pins y to 0..height-1), and nothing moves the viewport, so the selection cannot reach the line above the fold. Every terminal scrolls here; this is the one that breaks the copy-a-long-output workflow.

    2. Scroll routing. renderer.ts dispatches a scroll event to hitTest(x, y) and only falls back to the focused renderable. During a selection drag the pointer is at or beyond the pane's edge by definition, so the wheel is delivered to whatever is under the pointer (tab strip / neighbouring pane) rather than to the renderable that owns currentSelection. Routing scroll to the selection owner while isDragging would fix wheel-scrolling mid-drag generically, for every host, not just this pane.

    Either one alone would make the gesture usable; the first is the one users actually perform. I have the full packet table if it helps, and I'm withdrawing the parser theory on anomalyco/opentui#1911.

  6. mleivaDesarrollos commented on Nov 4, 2026

    @mleivaDesarrollos
    Author

    Confirmed the last open question, and it narrows this to a single defect:

    Wheel with the button held, pointer inside the pane → the content scrolls. So local scroll in the embedded terminal and the renderer's scroll dispatch both work correctly during a drag. Routing is not broken; my previous "scroll routing" suggestion can be dropped.

    That leaves exactly one gap: nothing scrolls when the drag leaves the renderable's bounds, which is the normal way to select more than one screen.

    Why this cannot be fixed in the pane component alone:

    • EmbeddedTerminalRenderable.onSelectionChanged clamps the endpoint — point() pins y to 0..height-1 — so the selection can never address a row outside the grid.
    • renderer.processSingleMouseEvent sends drag events to hitTest(x, y), i.e. whatever is under the pointer, not to the renderable that owns currentSelection.
    • _latestPointer is private, so a host has no way to see where the pointer went and autoscroll on its own.

    So the fix needs a small addition in the renderer: while currentSelection.isDragging and the pointer is outside the owner's bounds, scroll that renderable on a timer through a minimal contract (something like scrollBy(delta), which the embedded terminal already has internally as contextEmbeddedTerminalScroll), keeping the endpoint pinned to the edge row. Filed as anomalyco/opentui#1930 — it also covers scrollboxes, which have the same problem.

    With that, the copy-a-long-output workflow works: drag past the top row, the viewport follows, and the selection grows into the history.

  7. mleivaDesarrollos commented on Nov 13, 2026

    @mleivaDesarrollos
    Author

    Not a bump — a triage request. This issue carries no labels while newer TUI reports get area/tui + type/bug at filing (for example #63753 today). Could this one be given the same, so it sits in the TUI triage flow rather than the general pool?

    State of the report: it was re-tested on 2.2.5 after upgrade, the mechanism is now byte-level (/tmp capture of the gesture showed the wheel is decoded correctly and the drag simply leaves the pane's bounds), and the remaining fix lives in anomalyco/opentui#1930, which a maintainer has already linked from the closure of anomalyco/opentui#1911.

    One scoping note so it is not merged into the wrong bucket: this is the terminal pane (embedded pty, its own buffer and scroll handling), not the transcript selection failures in #61398 / #62675 / #63753. Different buffer, different code path — those were about selecting chat text, this is about selecting a command's output.

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

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions