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/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/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/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/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, }) diff --git a/src/tui-opentui/shell.test.ts b/src/tui-opentui/shell.test.ts index 04ae65ca4..34374c120 100644 --- a/src/tui-opentui/shell.test.ts +++ b/src/tui-opentui/shell.test.ts @@ -159,6 +159,49 @@ 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 + + // 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 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") + } 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..cfcd12f6e 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" @@ -3998,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 { @@ -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)