From a7c095c0e96d844439eda8de58047019cc6c3b25 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Thu, 6 Aug 2026 21:01:11 -0700 Subject: [PATCH 1/4] Route wheel scroll landing on the prompt to the chat transcript The prompt textarea holds keyboard focus for the whole session and has its own scrollable buffer, so OpenTUI's wheel dispatch (hit-test, or fall back to the focused renderable when the hit misses) kept handing scroll events to the prompt instead of the transcript. Override the prompt's scroll handling to forward wheel/trackpad events to the transcript instead of scrolling its own buffer. Arrow-key history cycling in the prompt was already implemented and tested; verified it end to end via real key-event dispatch. --- src/tui-opentui/shell.test.ts | 44 ++++++++++++++++++++++++++++++++++- src/tui-opentui/shell.ts | 29 +++++++++++++++++++++++ 2 files changed, 72 insertions(+), 1 deletion(-) diff --git a/src/tui-opentui/shell.test.ts b/src/tui-opentui/shell.test.ts index 04ae65ca4..f5ce91855 100644 --- a/src/tui-opentui/shell.test.ts +++ b/src/tui-opentui/shell.test.ts @@ -2,7 +2,7 @@ * Integration: app shell product skin — sticky, queue/steer/interrupt, overlay Esc. */ import { describe, expect, test } from "bun:test" -import type { KeyEvent } from "@opentui/core" +import { MouseEvent, type KeyEvent } from "@opentui/core" import { IDLE_TRANSCRIPT_FLOOR } from "./geometry/index" import { focusOwner, scrollLease } from "./focus/index" import { @@ -159,6 +159,48 @@ describe("createAppShell", () => { ) }) + test("wheel scroll landing on the prompt moves the transcript, not the prompt", async () => { + await withTestRenderer( + async (h) => { + const shell = createAppShell(h.renderer, { + terminal: { columns: 80, rows: 24 }, + wireKeys: false, + }) + try { + for (let i = 0; i < 50; i++) { + appendTranscript(shell, `seed-${i}`) + } + await h.renderOnce() + await h.renderOnce() + expect(isTranscriptFollowing(shell)).toBe(true) + const followingTop = shell.transcript.scrollTop + + const wheelUp = new MouseEvent(shell.prompt, { + type: "scroll", + button: 0, + x: 0, + y: 0, + modifiers: { shift: false, alt: false, ctrl: false }, + scroll: { direction: "up", delta: 3 }, + }) + ;( + shell.prompt as unknown as { onMouseEvent: (event: MouseEvent) => void } + ).onMouseEvent(wheelUp) + await h.renderOnce() + + // The wheel event was dispatched at the prompt, but the transcript + // moved and pinned — the prompt's own (empty) buffer never scrolled. + expect(shell.transcript.scrollTop).toBeLessThan(followingTop) + expect(isTranscriptFollowing(shell)).toBe(false) + expect(stickyMode(shell)).toBe("PINNED") + } finally { + shell.dispose() + } + }, + { width: 80, height: 24 }, + ) + }) + test("focus lease: prompt vs transcript", async () => { await withTestRenderer( async (h) => { diff --git a/src/tui-opentui/shell.ts b/src/tui-opentui/shell.ts index c35fd1eaf..e1bb95213 100644 --- a/src/tui-opentui/shell.ts +++ b/src/tui-opentui/shell.ts @@ -20,6 +20,7 @@ import { type BaseRenderable, type CliRenderer, type KeyEvent, + type MouseEvent, type TextChunk, } from "@opentui/core" @@ -4497,6 +4498,33 @@ export function handleCtrlC( }) } +/** + * Wheel/trackpad scroll landing on the prompt scrolls the chat instead. + * + * The prompt textarea is an editable buffer with its own `scrollY`, so + * OpenTUI's default routing — whichever renderable the wheel event hits, or + * the focused renderable when the hit misses — happily scrolls the prompt's + * own (usually one-screen, nothing-to-scroll) content. The prompt also holds + * keyboard focus for the whole session, so it is the fallback target for any + * wheel event that lands off the transcript's hit-tested rows. Overriding the + * scroll case here — rather than teaching the transcript's own scroll lease + * about wheel events — keeps the fix to exactly where wheel input actually + * arrives, without touching transcript viewport internals. + */ +function routePromptWheelToTranscript( + prompt: BaseRenderable, + transcript: ScrollBoxRenderable, +): void { + ;(prompt as unknown as { onMouseEvent: (event: MouseEvent) => void }).onMouseEvent = ( + event: MouseEvent, + ) => { + if (event.type !== "scroll") return + ;( + transcript as unknown as { onMouseEvent: (event: MouseEvent) => void } + ).onMouseEvent(event) + } +} + /** * Build the app shell frame on an OpenTUI renderer. * Mounts sticky transcript / overlay host / transient notice / prompt box. @@ -4727,6 +4755,7 @@ export function createAppShell( cursorColor: UI.text, placeholderColor: UI.textFaint, }) + routePromptWheelToTranscript(prompt, transcript) promptField.add(prompt) promptBox.add(promptTopRule) promptBox.add(promptField) From f3a307133f26422079a0fcc33d1a514ba165f91d Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Thu, 6 Aug 2026 21:37:08 -0700 Subject: [PATCH 2/4] Exercise the real renderer dispatch in the wheel-to-transcript test Calling the prompt's overridden onMouseEvent directly skipped the renderer's SGR-mouse parse and hit-test, so the test proved the forwarding function works without proving the renderer ever calls it on a genuine wheel scroll. Driving the same bytes through the mock mouse at the prompt's actual screen position closes that gap. --- src/tui-opentui/shell.test.ts | 29 +++++++++++++++-------------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/src/tui-opentui/shell.test.ts b/src/tui-opentui/shell.test.ts index f5ce91855..34374c120 100644 --- a/src/tui-opentui/shell.test.ts +++ b/src/tui-opentui/shell.test.ts @@ -2,7 +2,7 @@ * Integration: app shell product skin — sticky, queue/steer/interrupt, overlay Esc. */ import { describe, expect, test } from "bun:test" -import { MouseEvent, type KeyEvent } from "@opentui/core" +import type { KeyEvent } from "@opentui/core" import { IDLE_TRANSCRIPT_FLOOR } from "./geometry/index" import { focusOwner, scrollLease } from "./focus/index" import { @@ -175,21 +175,22 @@ describe("createAppShell", () => { expect(isTranscriptFollowing(shell)).toBe(true) const followingTop = shell.transcript.scrollTop - const wheelUp = new MouseEvent(shell.prompt, { - type: "scroll", - button: 0, - x: 0, - y: 0, - modifiers: { shift: false, alt: false, ctrl: false }, - scroll: { direction: "up", delta: 3 }, - }) - ;( - shell.prompt as unknown as { onMouseEvent: (event: MouseEvent) => void } - ).onMouseEvent(wheelUp) + // Locate the prompt's interior on screen and scroll through the + // renderer's real SGR-mouse parse + hit-test dispatch, the same + // path a live terminal drives — not a direct method call, which + // would pass even if the renderer never routed the event here. + const rows = h.captureCharFrame().split("\n") + const borderRow = rows.findIndex((r) => r.includes("╭")) + const promptX = rows[borderRow]!.indexOf("╭") + 2 + const promptY = borderRow + 1 + + for (let i = 0; i < 5; i++) { + await h.mockMouse.scroll(promptX, promptY, "up") + } await h.renderOnce() - // The wheel event was dispatched at the prompt, but the transcript - // moved and pinned — the prompt's own (empty) buffer never scrolled. + // The wheel event landed on the prompt, but the transcript moved + // and pinned — the prompt's own (empty) buffer never scrolled. expect(shell.transcript.scrollTop).toBeLessThan(followingTop) expect(isTranscriptFollowing(shell)).toBe(false) expect(stickyMode(shell)).toBe("PINNED") From c2f8101b5d7263f7817c535ad11b32d54db4c51d Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Thu, 6 Aug 2026 22:43:37 -0700 Subject: [PATCH 3/4] Turn mouse reporting on by default in the main shell Wheel/trackpad scroll only reaches OpenTUI when the terminal is told to report it; otherwise the terminal's own alternate-scroll mode resends it as arrow keys, which the prompt reads as history navigation. Flipping the default lets routePromptWheelToTranscript run for real scroll instead of only after Alt+M. Trade accepted: this suppresses the terminal's native drag-select in the main shell, which a separate ticket recorded wanting the opposite default. Alt+M still hands the mouse back for drag-select and copy. enableMouseMovement stays off; only clicks and wheel need reporting. --- src/tui-opentui/keybindings.ts | 2 +- src/tui-opentui/palette.ts | 2 +- src/tui-opentui/product-host.ts | 21 +++++++++++++-------- src/tui-opentui/shell.ts | 4 ++-- 4 files changed, 17 insertions(+), 12 deletions(-) diff --git a/src/tui-opentui/keybindings.ts b/src/tui-opentui/keybindings.ts index 2e04c7889..4d7f1f7c0 100644 --- a/src/tui-opentui/keybindings.ts +++ b/src/tui-opentui/keybindings.ts @@ -25,7 +25,7 @@ export const SHELL_SHORTCUTS: readonly ShellShortcut[] = [ { keys: "Ctrl+C", description: "interrupt the run, or clear the prompt when idle; press twice to exit" }, { keys: "Ctrl+O", description: "open the command palette; press again to close it" }, { keys: "Alt+C", description: "copy mode: pick a message, tool output, or diff; press again to close it" }, - { keys: "Alt+M", description: "take the mouse for click-to-expand and drag-scroll; off by default so drag-select and copy work" }, + { keys: "Alt+M", description: "release the mouse to the terminal for native drag-select and copy; on by default for wheel scroll and click-to-expand" }, { keys: "Alt+E", description: "expand or collapse every collapsible row (tool call, diff, skill, reasoning)" }, { keys: "Tab", description: "move focus between the prompt and the transcript" }, { keys: "Esc", description: "close the open overlay, or leave subagent observe" }, diff --git a/src/tui-opentui/palette.ts b/src/tui-opentui/palette.ts index 3b672f068..f052cc72f 100644 --- a/src/tui-opentui/palette.ts +++ b/src/tui-opentui/palette.ts @@ -171,7 +171,7 @@ export const DEFAULT_PALETTE_COMMANDS: readonly PaletteCommand[] = [ }, { id: "toggle_mouse", - label: "Toggle mouse capture (off by default so you can drag-select)", + label: "Toggle mouse capture (on by default; release it to drag-select)", keywords: ["mouse", "select", "selection", "copy", "drag"], dispatch: "residual", category: "view", diff --git a/src/tui-opentui/product-host.ts b/src/tui-opentui/product-host.ts index b2a7f78f1..2dc83beda 100644 --- a/src/tui-opentui/product-host.ts +++ b/src/tui-opentui/product-host.ts @@ -116,9 +116,10 @@ export type ProductHostConfig = { /** First-run telemetry disclosure, shown on the landing screen. */ readonly telemetryNotice?: string /** - * Take DEC mouse reporting. Default false: while it is on the terminal hands - * drags to us and cannot select text, which breaks copy with the mouse. - * Alt+M flips it at runtime for click-to-expand and drag-scroll. + * Take DEC mouse reporting. Default true: wheel/trackpad scroll only + * reaches OpenTUI when the terminal is told to report it, otherwise the + * terminal's own alternate-scroll mode resends it as arrow keys. Alt+M + * hands the mouse back to the terminal for native drag-select. */ readonly useMouse?: boolean } @@ -206,11 +207,15 @@ export async function mountProductHost( : await createCliRenderer({ exitOnCtrlC: false, targetFps: 30, - // Mouse reporting off by default: any of DEC 1000/1002/1003/1006 makes - // the terminal forward drags to us instead of selecting text, so the - // user cannot copy with the mouse. Alt+M takes the mouse when - // click-to-expand or drag-scroll is wanted. - useMouse: config.useMouse ?? false, + // Mouse reporting on by default: without it, wheel/trackpad scroll + // never reaches OpenTUI — the terminal's own alternate-scroll mode + // swallows it and resends it as arrow keys, which the prompt then + // reads as history navigation instead of the transcript scrolling. + // Cost accepted: this suppresses the terminal's native drag-select + // in the main shell. Alt+M hands the mouse back when that is wanted. + // enableMouseMovement stays off (no ?1003): only clicks and wheel + // are needed. + useMouse: config.useMouse ?? true, enableMouseMovement: false, // A plain terminal sends a bare CR for both Enter and Shift+Enter, so // the modifier only arrives once the kitty keyboard protocol is diff --git a/src/tui-opentui/shell.ts b/src/tui-opentui/shell.ts index e1bb95213..cfcd12f6e 100644 --- a/src/tui-opentui/shell.ts +++ b/src/tui-opentui/shell.ts @@ -3999,8 +3999,8 @@ export function copyAllTargets(shell: AppShell): boolean { /** * Alt+M: take DEC mouse reporting, or hand it back to the terminal. - * Reporting is off by default so drag-select and the terminal's own copy keep - * working; taking it enables click-to-expand and drag-scroll at that cost. + * Reporting is on by default so wheel scroll and click-to-expand work; + * releasing it restores the terminal's own drag-select and copy. * Returns the new enabled state, or null when the host exposes no control. */ export function toggleMouseCapture(shell: AppShell): boolean | null { From 59317dcb957956c3e94e1e7bc2f76382cf245b6a Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Thu, 6 Aug 2026 23:04:04 -0700 Subject: [PATCH 4/4] Correct comments left stale by the main-shell mouse default flip These four spots described mouse reporting as off by default, matching the main shell's old behavior. The satellite pickers still keep reporting off on purpose, but the main shell now defaults it on, so the comments read backwards. Reword them to state each surface's actual behavior instead of claiming they match, and update the readiness doc to describe the decision as settled with current line references. --- docs/tui-cutover-readiness.md | 14 ++++++++------ src/tui-opentui/list-modal.ts | 5 ++--- src/tui-opentui/mouse-reporting-disabled.test.ts | 9 +++++---- src/tui-opentui/provider-setup.ts | 4 ++-- 4 files changed, 17 insertions(+), 15 deletions(-) diff --git a/docs/tui-cutover-readiness.md b/docs/tui-cutover-readiness.md index a0bcf5c4f..30151ae33 100644 --- a/docs/tui-cutover-readiness.md +++ b/docs/tui-cutover-readiness.md @@ -99,12 +99,14 @@ performed. the shell's key handler), but the reported real-terminal failure is not reproduced or explained. Until someone pastes into a real TTY, treat text paste as unverified. -2. **Mouse selection policy (CL-5540).** DEC mouse reporting is moving to - off-by-default (`mouseCapture` in `src/tui-opentui/product-host.ts:101`) so the - terminal owns drag-select and copy. The cost is that click-to-expand and - drag-scroll are off unless the user presses `Alt+M` - (`src/tui-opentui/shell.ts:3392`). This is a deliberate trade, but it is a - visible regression for mouse users and the default is not settled. +2. **Mouse selection policy (CL-5540).** DEC mouse reporting defaults on in + the main shell (`useMouse` in `src/tui-opentui/product-host.ts:218`), so + wheel scroll and click-to-expand work out of the box. The cost is native + text selection, which the terminal cannot perform while reporting is on; + `Alt+M` (`toggleMouseCapture` in `src/tui-opentui/shell.ts:4006`) hands the + mouse back for that. The satellite pickers (`list-modal.ts`, + `provider-setup.ts`) keep reporting off and are unaffected. This is the + settled decision, not a pending tradeoff. 3. **Shift+Enter does not insert a newline on terminals that do not report the modifier.** `Ctrl+Enter` and `Ctrl+J` are the working newline chords and the help catalog says so (`src/tui-opentui/keybindings.ts`). The kitty keyboard diff --git a/src/tui-opentui/list-modal.ts b/src/tui-opentui/list-modal.ts index ffc8984b4..5d07a9e5c 100644 --- a/src/tui-opentui/list-modal.ts +++ b/src/tui-opentui/list-modal.ts @@ -44,9 +44,8 @@ export async function runListModal( : await createCliRenderer({ exitOnCtrlC: false, targetFps: 30, - // Same trade as the product host (CL-5540): reporting off by default - // so the terminal owns drag-select and its own copy in these satellite - // pickers too. + // Reporting stays off in this satellite picker, unlike the main + // shell, so the terminal owns drag-select and its own copy here. useMouse: false, enableMouseMovement: false, }) diff --git a/src/tui-opentui/mouse-reporting-disabled.test.ts b/src/tui-opentui/mouse-reporting-disabled.test.ts index 05c02423b..47ef8a8c9 100644 --- a/src/tui-opentui/mouse-reporting-disabled.test.ts +++ b/src/tui-opentui/mouse-reporting-disabled.test.ts @@ -1,10 +1,11 @@ /** * CL-5540: the onboarding provider picker and the satellite list modals * (session resume, session mode) mount their own renderer and must disable - * DEC mouse reporting the same way the product host does, or the terminal - * never gets button-1 drags to run its own text selection. These tests mock - * `@opentui/core` so the real (non-test-injected) `createCliRenderer` branch - * runs, and assert on the options it was actually called with. + * DEC mouse reporting, unlike the main shell, or the terminal never gets + * button-1 drags to run its own text selection in these pickers. These + * tests mock `@opentui/core` so the real (non-test-injected) + * `createCliRenderer` branch runs, and assert on the options it was + * actually called with. */ import { afterAll, describe, expect, mock, test } from "bun:test" import type { Harness } from "./harness.js" diff --git a/src/tui-opentui/provider-setup.ts b/src/tui-opentui/provider-setup.ts index f368ff3ea..e82cc3081 100644 --- a/src/tui-opentui/provider-setup.ts +++ b/src/tui-opentui/provider-setup.ts @@ -586,8 +586,8 @@ export async function runProviderSetup( : await createCliRenderer({ exitOnCtrlC: false, targetFps: 30, - // Same trade as the product host (CL-5540): reporting off by default - // so the terminal owns drag-select and its own copy during onboarding. + // Reporting stays off during onboarding, unlike the main shell, so + // the terminal owns drag-select and its own copy here. useMouse: false, enableMouseMovement: false, })