diff --git a/extensions/ask-user-choice.ts b/extensions/ask-user-choice.ts index 9354ae18a..e36ad49d9 100644 --- a/extensions/ask-user-choice.ts +++ b/extensions/ask-user-choice.ts @@ -196,7 +196,7 @@ export default function askUserChoice(pi: ExtensionAPI): void { selectedText: (text) => theme.fg("accent", text), description: (text) => theme.fg("muted", text), hoverBackground: (text) => theme.bg("toolPendingBg", text), - }, keybindings); + }, keybindings, { confirmOnSecondClick: true }); let completed = false; const finish = (result: ChoiceResult | undefined) => { if (completed) return; diff --git a/lib/native-choice-list.ts b/lib/native-choice-list.ts index 69d8d3cf1..70ef60258 100644 --- a/lib/native-choice-list.ts +++ b/lib/native-choice-list.ts @@ -23,6 +23,11 @@ export interface NativeChoiceListTheme { hoverBackground(text: string): string; } +export interface NativeChoiceListOptions { + /** Require a prior completed click on the same row; keyboard confirmation is unchanged. */ + confirmOnSecondClick?: boolean; +} + interface Row { item: T; text: Text; @@ -37,16 +42,22 @@ export class NativeChoiceList extends Container { private readonly theme: NativeChoiceListTheme; private readonly keybindings: KeybindingsManager | undefined; private selected = 0; + private pointerSelected: string | undefined; + private readonly confirmOnSecondClick: boolean; private hovered: string | undefined; private disabled = false; private readonly pointerScope = new NativePointerScope(); private renderedWidth: number | undefined; - constructor(items: readonly T[], theme: NativeChoiceListTheme, keybindings?: KeybindingsManager) { + constructor( + items: readonly T[], theme: NativeChoiceListTheme, keybindings?: KeybindingsManager, + options: NativeChoiceListOptions = {}, + ) { super(); this.items = items; this.theme = theme; this.keybindings = keybindings; + this.confirmOnSecondClick = options.confirmOnSecondClick ?? false; for (const item of items) this.addRow(item); this.refreshRows(); } @@ -56,6 +67,7 @@ export class NativeChoiceList extends Container { } setSelectedIndex(index: number): void { + this.pointerSelected = undefined; const next = Math.max(0, Math.min(this.items.length - 1, index)); if (next === this.selected) return; this.selected = next; @@ -76,6 +88,7 @@ export class NativeChoiceList extends Container { setDisabled(disabled: boolean): void { this.disabled = disabled; + this.pointerSelected = undefined; this.pointerScope.setDisabled(disabled); if (disabled) this.clearHover(); } @@ -107,6 +120,7 @@ export class NativeChoiceList extends Container { if (this.matches(data, "tui.select.up")) this.setSelectedIndex(this.selected - 1); else if (this.matches(data, "tui.select.down")) this.setSelectedIndex(this.selected + 1); else if (this.matches(data, "tui.select.confirm")) { + this.pointerSelected = undefined; const item = this.getSelectedItem(); if (item) this.onSelect?.(item); } @@ -148,13 +162,16 @@ export class NativeChoiceList extends Container { } if (event.button !== "left") return undefined; if (event.type === "press") { + if (this.pointerSelected !== item.id) this.pointerSelected = undefined; const changed = this.selectItem(item.id); return { handled: true, focus: true, render: changed }; } if (event.type === "click") { - this.selectItem(item.id); - this.onSelect?.(item); - return { handled: true }; + const confirm = !this.confirmOnSecondClick || this.pointerSelected === item.id; + const changed = this.selectItem(item.id); + this.pointerSelected = confirm ? undefined : item.id; + if (confirm) this.onSelect?.(item); + return { handled: true, render: changed }; } return undefined; } diff --git a/tests/ask-user-choice.test.ts b/tests/ask-user-choice.test.ts index 467fae9ac..f219ead37 100644 --- a/tests/ask-user-choice.test.ts +++ b/tests/ask-user-choice.test.ts @@ -291,6 +291,8 @@ test("ask_user_choice retains native rendered hit testing for mouse selection", assert.equal(press?.focus, true); assert.equal(completed, undefined, "press focuses and selects but never answers"); component.handleMouse?.(event("click", "left", secondRow)); + assert.equal(completed, undefined, "first completed click only selects"); + component.handleMouse?.(event("press", "left", secondRow)); component.handleMouse?.(event("click", "left", secondRow)); component.handleInput("\r"); return completed; @@ -400,6 +402,19 @@ test("ask_user_choice activates an opt-in custom response from the Other row poi }); assert.equal(component.handleMouse?.(event("press"))?.focus, true); component.handleMouse?.(event("click")); + assert.doesNotMatch(component.render(80).join("\n"), /Custom response/); + component.handleInput("\r"); + assert.match(component.render(80).join("\n"), /Custom response/); + component.handleInput("\x1b"); + assert.match(component.render(80).join("\n"), /Other…/); + component.handleMouse?.(event("press")); + component.handleMouse?.(event("release")); + component.handleMouse?.(event("click")); + assert.doesNotMatch(component.render(80).join("\n"), /Custom response/, "keyboard confirmation consumes the prior pointer selection"); + assert.equal(completed, undefined); + component.handleMouse?.(event("press")); + component.handleMouse?.(event("click")); + assert.match(component.render(80).join("\n"), /Custom response/); component.handleInput("pointer response"); component.handleInput("\r"); return completed; diff --git a/tests/native-choice-list.test.ts b/tests/native-choice-list.test.ts index 611daff80..4d5b69524 100644 --- a/tests/native-choice-list.test.ts +++ b/tests/native-choice-list.test.ts @@ -177,6 +177,56 @@ test("root observer clears hover and disabled controls ignore every input", () = ); }); +for (const twoStep of [false, true]) { + test(`native choice completed clicks use ${twoStep ? "two-step" : "default single-click"} confirmation`, () => { + const list = new NativeChoiceList([ + { id: "first", label: "First" }, { id: "second", label: "Second" }, + ], theme, undefined, { confirmOnSecondClick: twoStep }); + const selected: string[] = []; + list.onSelect = (item) => selected.push(item.id); + const root = createNativeFullscreenInteraction({ keyboardTarget: list, requestRender() {} }); + root.addChild(new Text("Header", 0, 0)); + root.addChild(list); + const lines = root.render(40); + const send = (type: TuiMouseEvent["type"], label: string) => { + const row = lines.findIndex((line) => stripTerminalSequences(line).includes(label)); + assert.ok(row >= 0); + root.handleMouse(event(type, "left", row, 40, lines.length)); + }; + const click = (label: string) => { + send("press", label); + send("release", label); + send("click", label); + }; + send("press", "First"); + send("release", "First"); + assert.deepEqual(selected, [], "press and release do not confirm or arm a completed click"); + click("First"); + assert.deepEqual(selected, twoStep ? [] : ["first"], "initial highlighted row still needs a first click"); + click("Second"); + assert.equal(list.getSelectedItem()?.id, "second"); + assert.equal(selected.length, twoStep ? 0 : 2, "switching rows selects only in two-step mode"); + click("First"); + assert.equal(selected.length, twoStep ? 0 : 3, "returning to a prior row does not confirm"); + root.render(40); + click("First"); + assert.equal(selected.at(-1), "first", "same-row confirmation survives a render without a timer"); + selected.length = 0; + click("Second"); + root.handleInput("\r"); + assert.equal(selected.at(-1), "second", "Enter confirms pointer selection"); + selected.length = 0; + root.handleInput("\u001b[A"); + root.handleInput("\u001b[B"); + click("Second"); + assert.deepEqual(selected, twoStep ? [] : ["second"], "keyboard navigation clears pointer confirmation"); + let cancelled = false; + list.onCancel = () => { cancelled = true; }; + root.handleInput("\u001b"); + assert.equal(cancelled, true); + }); +} + test("empty native choice lists do not activate phantom selections", () => { const list = new NativeChoiceList([], theme); let selected = 0;