Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 8 additions & 6 deletions docs/tui-cutover-readiness.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/tui-opentui/keybindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down
5 changes: 2 additions & 3 deletions src/tui-opentui/list-modal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
})
Expand Down
9 changes: 5 additions & 4 deletions src/tui-opentui/mouse-reporting-disabled.test.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
2 changes: 1 addition & 1 deletion src/tui-opentui/palette.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
21 changes: 13 additions & 8 deletions src/tui-opentui/product-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions src/tui-opentui/provider-setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
})
Expand Down
43 changes: 43 additions & 0 deletions src/tui-opentui/shell.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
33 changes: 31 additions & 2 deletions src/tui-opentui/shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
type BaseRenderable,
type CliRenderer,
type KeyEvent,
type MouseEvent,
type TextChunk,
} from "@opentui/core"

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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)
Expand Down
Loading