Skip to content

Commit ef7d22a

Browse files
Merge wheel-scroll routing and mouse default
2 parents e90e73f + 59317dc commit ef7d22a

9 files changed

Lines changed: 106 additions & 27 deletions

File tree

docs/tui-cutover-readiness.md

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -99,12 +99,14 @@ performed.
9999
the shell's key handler), but the reported real-terminal failure is not
100100
reproduced or explained. Until someone pastes into a real TTY, treat text paste
101101
as unverified.
102-
2. **Mouse selection policy (CL-5540).** DEC mouse reporting is moving to
103-
off-by-default (`mouseCapture` in `src/tui-opentui/product-host.ts:101`) so the
104-
terminal owns drag-select and copy. The cost is that click-to-expand and
105-
drag-scroll are off unless the user presses `Alt+M`
106-
(`src/tui-opentui/shell.ts:3392`). This is a deliberate trade, but it is a
107-
visible regression for mouse users and the default is not settled.
102+
2. **Mouse selection policy (CL-5540).** DEC mouse reporting defaults on in
103+
the main shell (`useMouse` in `src/tui-opentui/product-host.ts:218`), so
104+
wheel scroll and click-to-expand work out of the box. The cost is native
105+
text selection, which the terminal cannot perform while reporting is on;
106+
`Alt+M` (`toggleMouseCapture` in `src/tui-opentui/shell.ts:4006`) hands the
107+
mouse back for that. The satellite pickers (`list-modal.ts`,
108+
`provider-setup.ts`) keep reporting off and are unaffected. This is the
109+
settled decision, not a pending tradeoff.
108110
3. **Shift+Enter does not insert a newline on terminals that do not report the
109111
modifier.** `Ctrl+Enter` and `Ctrl+J` are the working newline chords and the
110112
help catalog says so (`src/tui-opentui/keybindings.ts`). The kitty keyboard

src/tui-opentui/keybindings.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ export const SHELL_SHORTCUTS: readonly ShellShortcut[] = [
2525
{ keys: "Ctrl+C", description: "interrupt the run, or clear the prompt when idle; press twice to exit" },
2626
{ keys: "Ctrl+O", description: "open the command palette; press again to close it" },
2727
{ keys: "Alt+C", description: "copy mode: pick a message, tool output, or diff; press again to close it" },
28-
{ keys: "Alt+M", description: "take the mouse for click-to-expand and drag-scroll; off by default so drag-select and copy work" },
28+
{ 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" },
2929
{ keys: "Alt+E", description: "expand or collapse every collapsible row (tool call, diff, skill, reasoning)" },
3030
{ keys: "Tab", description: "move focus between the prompt and the transcript" },
3131
{ keys: "Esc", description: "close the open overlay, or leave subagent observe" },

src/tui-opentui/list-modal.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,9 +44,8 @@ export async function runListModal(
4444
: await createCliRenderer({
4545
exitOnCtrlC: false,
4646
targetFps: 30,
47-
// Same trade as the product host (CL-5540): reporting off by default
48-
// so the terminal owns drag-select and its own copy in these satellite
49-
// pickers too.
47+
// Reporting stays off in this satellite picker, unlike the main
48+
// shell, so the terminal owns drag-select and its own copy here.
5049
useMouse: false,
5150
enableMouseMovement: false,
5251
})

src/tui-opentui/mouse-reporting-disabled.test.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
/**
22
* CL-5540: the onboarding provider picker and the satellite list modals
33
* (session resume, session mode) mount their own renderer and must disable
4-
* DEC mouse reporting the same way the product host does, or the terminal
5-
* never gets button-1 drags to run its own text selection. These tests mock
6-
* `@opentui/core` so the real (non-test-injected) `createCliRenderer` branch
7-
* runs, and assert on the options it was actually called with.
4+
* DEC mouse reporting, unlike the main shell, or the terminal never gets
5+
* button-1 drags to run its own text selection in these pickers. These
6+
* tests mock `@opentui/core` so the real (non-test-injected)
7+
* `createCliRenderer` branch runs, and assert on the options it was
8+
* actually called with.
89
*/
910
import { afterAll, describe, expect, mock, test } from "bun:test"
1011
import type { Harness } from "./harness.js"

src/tui-opentui/palette.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -171,7 +171,7 @@ export const DEFAULT_PALETTE_COMMANDS: readonly PaletteCommand[] = [
171171
},
172172
{
173173
id: "toggle_mouse",
174-
label: "Toggle mouse capture (off by default so you can drag-select)",
174+
label: "Toggle mouse capture (on by default; release it to drag-select)",
175175
keywords: ["mouse", "select", "selection", "copy", "drag"],
176176
dispatch: "residual",
177177
category: "view",

src/tui-opentui/product-host.ts

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -116,9 +116,10 @@ export type ProductHostConfig = {
116116
/** First-run telemetry disclosure, shown on the landing screen. */
117117
readonly telemetryNotice?: string
118118
/**
119-
* Take DEC mouse reporting. Default false: while it is on the terminal hands
120-
* drags to us and cannot select text, which breaks copy with the mouse.
121-
* Alt+M flips it at runtime for click-to-expand and drag-scroll.
119+
* Take DEC mouse reporting. Default true: wheel/trackpad scroll only
120+
* reaches OpenTUI when the terminal is told to report it, otherwise the
121+
* terminal's own alternate-scroll mode resends it as arrow keys. Alt+M
122+
* hands the mouse back to the terminal for native drag-select.
122123
*/
123124
readonly useMouse?: boolean
124125
}
@@ -206,11 +207,15 @@ export async function mountProductHost(
206207
: await createCliRenderer({
207208
exitOnCtrlC: false,
208209
targetFps: 30,
209-
// Mouse reporting off by default: any of DEC 1000/1002/1003/1006 makes
210-
// the terminal forward drags to us instead of selecting text, so the
211-
// user cannot copy with the mouse. Alt+M takes the mouse when
212-
// click-to-expand or drag-scroll is wanted.
213-
useMouse: config.useMouse ?? false,
210+
// Mouse reporting on by default: without it, wheel/trackpad scroll
211+
// never reaches OpenTUI — the terminal's own alternate-scroll mode
212+
// swallows it and resends it as arrow keys, which the prompt then
213+
// reads as history navigation instead of the transcript scrolling.
214+
// Cost accepted: this suppresses the terminal's native drag-select
215+
// in the main shell. Alt+M hands the mouse back when that is wanted.
216+
// enableMouseMovement stays off (no ?1003): only clicks and wheel
217+
// are needed.
218+
useMouse: config.useMouse ?? true,
214219
enableMouseMovement: false,
215220
// A plain terminal sends a bare CR for both Enter and Shift+Enter, so
216221
// the modifier only arrives once the kitty keyboard protocol is

src/tui-opentui/provider-setup.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -586,8 +586,8 @@ export async function runProviderSetup(
586586
: await createCliRenderer({
587587
exitOnCtrlC: false,
588588
targetFps: 30,
589-
// Same trade as the product host (CL-5540): reporting off by default
590-
// so the terminal owns drag-select and its own copy during onboarding.
589+
// Reporting stays off during onboarding, unlike the main shell, so
590+
// the terminal owns drag-select and its own copy here.
591591
useMouse: false,
592592
enableMouseMovement: false,
593593
})

src/tui-opentui/shell.test.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,49 @@ describe("createAppShell", () => {
159159
)
160160
})
161161

162+
test("wheel scroll landing on the prompt moves the transcript, not the prompt", async () => {
163+
await withTestRenderer(
164+
async (h) => {
165+
const shell = createAppShell(h.renderer, {
166+
terminal: { columns: 80, rows: 24 },
167+
wireKeys: false,
168+
})
169+
try {
170+
for (let i = 0; i < 50; i++) {
171+
appendTranscript(shell, `seed-${i}`)
172+
}
173+
await h.renderOnce()
174+
await h.renderOnce()
175+
expect(isTranscriptFollowing(shell)).toBe(true)
176+
const followingTop = shell.transcript.scrollTop
177+
178+
// Locate the prompt's interior on screen and scroll through the
179+
// renderer's real SGR-mouse parse + hit-test dispatch, the same
180+
// path a live terminal drives — not a direct method call, which
181+
// would pass even if the renderer never routed the event here.
182+
const rows = h.captureCharFrame().split("\n")
183+
const borderRow = rows.findIndex((r) => r.includes("╭"))
184+
const promptX = rows[borderRow]!.indexOf("╭") + 2
185+
const promptY = borderRow + 1
186+
187+
for (let i = 0; i < 5; i++) {
188+
await h.mockMouse.scroll(promptX, promptY, "up")
189+
}
190+
await h.renderOnce()
191+
192+
// The wheel event landed on the prompt, but the transcript moved
193+
// and pinned — the prompt's own (empty) buffer never scrolled.
194+
expect(shell.transcript.scrollTop).toBeLessThan(followingTop)
195+
expect(isTranscriptFollowing(shell)).toBe(false)
196+
expect(stickyMode(shell)).toBe("PINNED")
197+
} finally {
198+
shell.dispose()
199+
}
200+
},
201+
{ width: 80, height: 24 },
202+
)
203+
})
204+
162205
test("focus lease: prompt vs transcript", async () => {
163206
await withTestRenderer(
164207
async (h) => {

src/tui-opentui/shell.ts

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
type BaseRenderable,
2121
type CliRenderer,
2222
type KeyEvent,
23+
type MouseEvent,
2324
type TextChunk,
2425
} from "@opentui/core"
2526

@@ -3998,8 +3999,8 @@ export function copyAllTargets(shell: AppShell): boolean {
39983999

39994000
/**
40004001
* Alt+M: take DEC mouse reporting, or hand it back to the terminal.
4001-
* Reporting is off by default so drag-select and the terminal's own copy keep
4002-
* working; taking it enables click-to-expand and drag-scroll at that cost.
4002+
* Reporting is on by default so wheel scroll and click-to-expand work;
4003+
* releasing it restores the terminal's own drag-select and copy.
40034004
* Returns the new enabled state, or null when the host exposes no control.
40044005
*/
40054006
export function toggleMouseCapture(shell: AppShell): boolean | null {
@@ -4497,6 +4498,33 @@ export function handleCtrlC(
44974498
})
44984499
}
44994500

4501+
/**
4502+
* Wheel/trackpad scroll landing on the prompt scrolls the chat instead.
4503+
*
4504+
* The prompt textarea is an editable buffer with its own `scrollY`, so
4505+
* OpenTUI's default routing — whichever renderable the wheel event hits, or
4506+
* the focused renderable when the hit misses — happily scrolls the prompt's
4507+
* own (usually one-screen, nothing-to-scroll) content. The prompt also holds
4508+
* keyboard focus for the whole session, so it is the fallback target for any
4509+
* wheel event that lands off the transcript's hit-tested rows. Overriding the
4510+
* scroll case here — rather than teaching the transcript's own scroll lease
4511+
* about wheel events — keeps the fix to exactly where wheel input actually
4512+
* arrives, without touching transcript viewport internals.
4513+
*/
4514+
function routePromptWheelToTranscript(
4515+
prompt: BaseRenderable,
4516+
transcript: ScrollBoxRenderable,
4517+
): void {
4518+
;(prompt as unknown as { onMouseEvent: (event: MouseEvent) => void }).onMouseEvent = (
4519+
event: MouseEvent,
4520+
) => {
4521+
if (event.type !== "scroll") return
4522+
;(
4523+
transcript as unknown as { onMouseEvent: (event: MouseEvent) => void }
4524+
).onMouseEvent(event)
4525+
}
4526+
}
4527+
45004528
/**
45014529
* Build the app shell frame on an OpenTUI renderer.
45024530
* Mounts sticky transcript / overlay host / transient notice / prompt box.
@@ -4727,6 +4755,7 @@ export function createAppShell(
47274755
cursorColor: UI.text,
47284756
placeholderColor: UI.textFaint,
47294757
})
4758+
routePromptWheelToTranscript(prompt, transcript)
47304759
promptField.add(prompt)
47314760
promptBox.add(promptTopRule)
47324761
promptBox.add(promptField)

0 commit comments

Comments
 (0)