From c79e42137b5fe0cee1bace2152ae977f2a0c1b4e Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Thu, 6 Aug 2026 21:35:23 -0700 Subject: [PATCH] Stop pickers turning on DEC mouse reporting outside the product host createCliRenderer defaults useMouse and enableMouseMovement to true, so the onboarding provider picker and the satellite list modals (session resume, session mode) were emitting the DEC mouse-reporting sequences and stealing button-1 drags from the terminal before a session even starts, even though the main product host already disabled it. Native drag-select and copy now work with no modifier from the very first screen. --- src/tui-opentui/list-modal.ts | 10 ++- .../mouse-reporting-disabled.test.ts | 83 +++++++++++++++++++ src/tui-opentui/provider-setup.ts | 9 +- 3 files changed, 100 insertions(+), 2 deletions(-) create mode 100644 src/tui-opentui/mouse-reporting-disabled.test.ts diff --git a/src/tui-opentui/list-modal.ts b/src/tui-opentui/list-modal.ts index cae1e7a5e..ffc8984b4 100644 --- a/src/tui-opentui/list-modal.ts +++ b/src/tui-opentui/list-modal.ts @@ -41,7 +41,15 @@ export async function runListModal( ): Promise { const renderer = config.createRenderer ? await config.createRenderer() - : await createCliRenderer({ exitOnCtrlC: false, targetFps: 30 }) + : 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. + useMouse: false, + enableMouseMovement: false, + }) const shell = createAppShell(renderer, { title: config.title, run: "idle" }) diff --git a/src/tui-opentui/mouse-reporting-disabled.test.ts b/src/tui-opentui/mouse-reporting-disabled.test.ts new file mode 100644 index 000000000..05c02423b --- /dev/null +++ b/src/tui-opentui/mouse-reporting-disabled.test.ts @@ -0,0 +1,83 @@ +/** + * 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. + */ +import { afterAll, describe, expect, mock, test } from "bun:test" +import type { Harness } from "./harness.js" + +type CapturedRendererOptions = { + readonly useMouse?: boolean + readonly enableMouseMovement?: boolean +} + +const capturedOptions: CapturedRendererOptions[] = [] +const mountedHarnesses: Harness[] = [] + +// The mock must be registered before anything (including this file's own +// helpers) does a real `@opentui/core` import, or that import wins the module +// cache and the mock never takes effect. Every dependency below is loaded +// with a dynamic `import()` after `mock.module` for that reason. +const realCore = await import("@opentui/core") + +mock.module("@opentui/core", () => ({ + ...realCore, + createCliRenderer: async (options: CapturedRendererOptions) => { + capturedOptions.push(options) + const { createHarness } = await import("./harness.js") + const harness = await createHarness({ width: 80, height: 24 }) + mountedHarnesses.push(harness) + return harness.renderer + }, +})) + +// `mock.module` replaces the shared module cache for the whole test process, +// not just this file — every other test that imports `@opentui/core` runs in +// the same process. Put the real module back once this file is done so a +// later un-injected `createCliRenderer` caller does not silently get this +// fake harness renderer instead. +afterAll(() => { + mock.module("@opentui/core", () => realCore) +}) + +const { runListModal } = await import("./list-modal.js") +const { runProviderSetup } = await import("./provider-setup.js") + +async function waitForMount(): Promise { + for (let i = 0; i < 100 && capturedOptions.length === 0; i++) { + await new Promise((resolve) => setTimeout(resolve, 10)) + } +} + +describe("default renderer mount disables DEC mouse reporting (CL-5540)", () => { + test("runListModal", async () => { + capturedOptions.length = 0 + // Fire-and-forget: the mounted renderer never resolves this promise in + // this test (nothing presses a key), so only await the mount itself. + void runListModal({ + title: "resume session", + options: [{ id: "s-1", label: "First session" }], + }) + await waitForMount() + expect(capturedOptions).toHaveLength(1) + expect(capturedOptions[0]?.useMouse).toBe(false) + expect(capturedOptions[0]?.enableMouseMovement).toBe(false) + mountedHarnesses.pop()?.destroy() + }) + + test("runProviderSetup", async () => { + capturedOptions.length = 0 + void runProviderSetup({ + onSubmit: async () => undefined, + showTelemetryNotice: false, + }) + await waitForMount() + expect(capturedOptions).toHaveLength(1) + expect(capturedOptions[0]?.useMouse).toBe(false) + expect(capturedOptions[0]?.enableMouseMovement).toBe(false) + mountedHarnesses.pop()?.destroy() + }) +}) diff --git a/src/tui-opentui/provider-setup.ts b/src/tui-opentui/provider-setup.ts index eb6a6d41f..f368ff3ea 100644 --- a/src/tui-opentui/provider-setup.ts +++ b/src/tui-opentui/provider-setup.ts @@ -583,7 +583,14 @@ export async function runProviderSetup( ): Promise { const renderer = config.createRenderer ? await config.createRenderer() - : await createCliRenderer({ exitOnCtrlC: false, targetFps: 30 }) + : 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. + useMouse: false, + enableMouseMovement: false, + }) const choices = providerChoices() const values: ProviderFormValues = {