Skip to content

Commit 6f9ce27

Browse files
committed
Stop the provider-setup and satellite pickers from turning on DEC mouse reporting
createCliRenderer defaults useMouse/enableMouseMovement to true, so the onboarding provider picker and satellite list modals (session resume, session mode) were emitting ?1000/?1002/?1003/?1006 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. Also add settings.mouseCapture so a user can opt back into click-to-expand and drag-scroll (the same trade Alt+M makes mid-session) without discovering a hidden default; unset keeps selection-on as the default.
1 parent cf3bb84 commit 6f9ce27

9 files changed

Lines changed: 80 additions & 3 deletions

File tree

src/config/settings.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,11 @@ export type Settings = {
166166
// breakdown on demand, so the border only needs to opt in to the running
167167
// total.
168168
showPromptCost?: boolean;
169+
// Whether the TUI captures DEC mouse reporting at startup (CL-5540).
170+
// Default false/unset: the terminal owns drag-select and its own copy.
171+
// true trades that away for click-to-expand and drag-to-scroll (the same
172+
// trade Alt+M makes mid-session) — both have keyboard equivalents either way.
173+
mouseCapture?: boolean;
169174
};
170175

171176
function modelRefKey(ref: ModelRef): string {
@@ -491,6 +496,7 @@ const SettingsSchema = type({
491496
"recentModels?": ModelRefSchema.array(),
492497
"favoriteModels?": ModelRefSchema.array(),
493498
"showPromptCost?": "boolean",
499+
"mouseCapture?": "boolean",
494500
});
495501

496502
// Per-entry MCP shape without the name key. The "exactly one transport" rule is
@@ -655,6 +661,7 @@ export const GLOBAL_SETTINGS_OPTIONAL_KEYS = [
655661
"otel",
656662
"recentModels",
657663
"favoriteModels",
664+
"mouseCapture",
658665
] as const satisfies readonly (keyof OptionalSettingsFields)[];
659666

660667
/** Optional local settings keys the load path is required to consider. */
@@ -774,6 +781,7 @@ export async function loadSettings(path: string): Promise<Settings | null> {
774781
recentModels: s.recentModels as Settings["recentModels"] | undefined,
775782
favoriteModels: s.favoriteModels as Settings["favoriteModels"] | undefined,
776783
showPromptCost: s.showPromptCost !== undefined ? Boolean(s.showPromptCost) : undefined,
784+
mouseCapture: s.mouseCapture !== undefined ? Boolean(s.mouseCapture) : undefined,
777785
};
778786
const settings: Settings = {
779787
providers: s.providers as Settings["providers"],

src/settings.test.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,10 @@ describe("validators", () => {
261261
expect(isSettings({ providers: firepass.providers, showPromptCost: true })).toBe(true);
262262
});
263263

264+
test("isSettings accepts mouseCapture", () => {
265+
expect(isSettings({ providers: firepass.providers, mouseCapture: true })).toBe(true);
266+
});
267+
264268
test("isLocalSettings rejects credentials", () => {
265269
expect(isLocalSettings({ provider: "a", apiKey: "leak" })).toBe(false);
266270
});
@@ -781,6 +785,17 @@ describe("maxConcurrentSubAgents", () => {
781785
}
782786
});
783787

788+
test("loadSettings round-trips mouseCapture", async () => {
789+
const dir = await mkdtemp(join(tmpdir(), "ic-settings-"));
790+
try {
791+
const path = join(dir, ".corbits", "settings.json");
792+
await saveGlobalSettings(path, { ...firepass, mouseCapture: true });
793+
expect(await loadSettings(path)).toEqual({ ...firepass, mouseCapture: true });
794+
} finally {
795+
await rm(dir, { recursive: true, force: true });
796+
}
797+
});
798+
784799
test("loadSettings round-trips maxConcurrentSubAgents", async () => {
785800
const dir = await mkdtemp(join(tmpdir(), "ic-settings-"));
786801
try {

src/tui-opentui/list-modal.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,15 @@ export async function runListModal(
4141
): Promise<string | null> {
4242
const renderer = config.createRenderer
4343
? await config.createRenderer()
44-
: await createCliRenderer({ exitOnCtrlC: false, targetFps: 30 })
44+
: await createCliRenderer({
45+
exitOnCtrlC: false,
46+
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.
50+
useMouse: false,
51+
enableMouseMovement: false,
52+
})
4553

4654
const shell = createAppShell(renderer, { title: config.title, run: "idle" })
4755

src/tui-opentui/product-host.test.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
mountProductHost,
1212
operatorResultFromSelection,
1313
permissionChoices,
14+
resolveInitialMouseCapture,
1415
type ProductHostConfig,
1516
} from "./product-host.js"
1617

@@ -151,6 +152,22 @@ describe("operatorResultFromSelection", () => {
151152
})
152153
})
153154

155+
describe("resolveInitialMouseCapture", () => {
156+
// CL-5540: the terminal must own drag-select by default, so an unset
157+
// preference (no CLI override, no settings.mouseCapture) resolves to false.
158+
test("defaults to false when unset", () => {
159+
expect(resolveInitialMouseCapture(undefined)).toBe(false)
160+
})
161+
162+
test("passes an explicit true through", () => {
163+
expect(resolveInitialMouseCapture(true)).toBe(true)
164+
})
165+
166+
test("passes an explicit false through", () => {
167+
expect(resolveInitialMouseCapture(false)).toBe(false)
168+
})
169+
})
170+
154171
describe("mountProductHost", () => {
155172
test("stream events emitted on the event emitter paint rows into the shell", async () => {
156173
const { host, emitter } = await mountHeadless()

src/tui-opentui/product-host.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,16 @@ export function operatorResultFromSelection(
194194
return { kind: "option", index: sel.index }
195195
}
196196

197+
/**
198+
* Whether OpenTUI should capture DEC mouse reporting at startup (CL-5540).
199+
* Undefined (no CLI override, no settings.mouseCapture) resolves to false so
200+
* the terminal owns drag-select and its own copy by default; a caller (or
201+
* settings.mouseCapture) can opt into click-to-expand/drag-scroll instead.
202+
*/
203+
export function resolveInitialMouseCapture(explicit: boolean | undefined): boolean {
204+
return explicit ?? false
205+
}
206+
197207
/**
198208
* Mount the OpenTUI shell as the production interactive UI.
199209
* Caller owns session lifecycle (agent, MCP, hooks); host owns paint + input.
@@ -210,7 +220,7 @@ export async function mountProductHost(
210220
// the terminal forward drags to us instead of selecting text, so the
211221
// user cannot copy with the mouse. Alt+M takes the mouse when
212222
// click-to-expand or drag-scroll is wanted.
213-
useMouse: config.useMouse ?? false,
223+
useMouse: resolveInitialMouseCapture(config.useMouse),
214224
enableMouseMovement: false,
215225
// A plain terminal sends a bare CR for both Enter and Shift+Enter, so
216226
// the modifier only arrives once the kitty keyboard protocol is

src/tui-opentui/provider-setup.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -583,7 +583,14 @@ export async function runProviderSetup(
583583
): Promise<boolean> {
584584
const renderer = config.createRenderer
585585
? await config.createRenderer()
586-
: await createCliRenderer({ exitOnCtrlC: false, targetFps: 30 })
586+
: await createCliRenderer({
587+
exitOnCtrlC: false,
588+
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.
591+
useMouse: false,
592+
enableMouseMovement: false,
593+
})
587594

588595
const choices = providerChoices()
589596
const values: ProviderFormValues = {

src/tui-opentui/runner-host.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,13 @@ export type RunnerHostDeps = {
111111
readonly createRenderer?: () => Promise<CliRenderer>
112112
/** First-run telemetry disclosure, shown on the landing screen. */
113113
readonly telemetryNotice?: string
114+
/**
115+
* settings.mouseCapture (CL-5540). Off (default/undefined) so the terminal
116+
* owns drag-select and its own copy; true opts a user into
117+
* click-to-expand/drag-scroll at that cost, the same trade Alt+M makes
118+
* mid-session.
119+
*/
120+
readonly useMouse?: boolean
114121
}
115122

116123
/** Product host plus the runner-owned subscriptions torn down with it. */
@@ -241,6 +248,7 @@ export async function mountRunnerHost(deps: RunnerHostDeps): Promise<RunnerHost>
241248
...(deps.telemetryNotice !== undefined
242249
? { telemetryNotice: deps.telemetryNotice }
243250
: {}),
251+
...(deps.useMouse !== undefined ? { useMouse: deps.useMouse } : {}),
244252
})
245253

246254
const pushChrome = (): void => {

src/tui/runner.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1888,6 +1888,9 @@ export async function runTUI(initialConfig: Config): Promise<number> {
18881888
`Connecting ${providerName} from the running session isn't wired up yet — run /model after restarting, or reconnect via onboarding.`,
18891889
);
18901890
},
1891+
...(config.settings?.mouseCapture !== undefined
1892+
? { useMouse: config.settings.mouseCapture }
1893+
: {}),
18911894
modelLabel: () => ({
18921895
profile: config.providerName,
18931896
model: config.model,

tests/unit/config.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,7 @@ test("loadSettings cannot silently drop a known optional key", async () => {
142142
otel: { endpoint: "http://localhost:4318", serviceName: "corbits-test" },
143143
recentModels: [{ provider: "p", model: "m" }],
144144
favoriteModels: [{ provider: "p", model: "m" }],
145+
mouseCapture: true,
145146
};
146147
await writeFile(globalPath, JSON.stringify(fixture));
147148
const loaded = await loadSettings(globalPath);

0 commit comments

Comments
 (0)