diff --git a/packages/zcode-tui/src/choice-dialog.ts b/packages/zcode-tui/src/choice-dialog.ts index 07763eb..315238a 100644 --- a/packages/zcode-tui/src/choice-dialog.ts +++ b/packages/zcode-tui/src/choice-dialog.ts @@ -187,6 +187,8 @@ class ChoiceDialog implements Component { private contentOffset = 0; private contentLineCount = 0; private contentPageSize = 1; + /** Number of quick-selectable items; digits beyond this are ignored. */ + numberShortcutCount = 0; constructor( private readonly title: string, @@ -263,6 +265,29 @@ class ChoiceDialog implements Component { this.contentExpanded = !this.contentExpanded; return; } + // Number shortcut: pressing 1-9 confirms the option at that list position + // (1 = first item) in a single keystroke. Absolute index, not scroll + // position. The caller explicitly opts into this behavior; when it is not + // enabled, digits continue through the filter path below. + if ( + this.numberShortcutCount > 0 + && this.filter === "" + && !this.contentExpanded + && data.length === 1 + && data >= "1" + && data <= "9" + ) { + const index = data.charCodeAt(0) - "1".charCodeAt(0); + // setSelectedIndex clamps to the last item, so guard explicitly: + // a digit past the option count must be a no-op. + if (index >= this.numberShortcutCount) return; + this.list.setSelectedIndex(index); + const item = this.list.getSelectedItem(); + if (item) { + this.list.onSelect?.(item); + } + return; + } const contentInput = (this.content as (Component & { handleInput?: (input: string) => boolean; }) | undefined)?.handleInput; @@ -399,6 +424,8 @@ export function choose( selectedIndex?: number; signal?: AbortSignal; showSelectedItemDetails?: boolean; + /** Enable 1-9 label hints and quick-select. Only lists with at most 9 items support it. */ + numberShortcuts?: boolean; } ): Promise { if (options.items.length === 0) return Promise.resolve(null); @@ -406,6 +433,11 @@ export function choose( return new Promise((resolve) => { const choicesByValue = new Map(); const detailsByValue = new Map(); + // Number shortcuts are opt-in and intentionally limited to 1-9. Generic + // choice dialogs remain filter-first unless the caller explicitly enables + // this interaction. + const numberShortcutsEnabled = options.numberShortcuts === true + && options.items.length <= 9; const searchableItems = options.items.map((item, index): SelectItem => { const safeItem: ChoiceItem = { ...item, @@ -414,12 +446,17 @@ export function choose( ? sanitizeTerminalText(item.description, { preserveSgr: false }) : undefined }; + // Prefix labels when the caller opted into the 1-9 quick-select mode. + const showNumberHint = numberShortcutsEnabled; + const displayLabel = showNumberHint + ? `${index + 1}. ${safeItem.label}` + : safeItem.label; const value = `${safeItem.label}\u0000${index}`; choicesByValue.set(value, safeItem); if (options.showSelectedItemDetails) { detailsByValue.set(value, new ChoiceItemDetails(safeItem, theme)); } - return { value, label: safeItem.label, description: safeItem.description }; + return { value, label: displayLabel, description: safeItem.description }; }); const hasDetails = Boolean( options.content @@ -440,8 +477,12 @@ export function choose( sanitizeTerminalText(options.prompt, { preserveSgr: false }), sanitizeTerminalText( options.help ?? (hasDetails - ? "Type to filter · Up/Down choose · Ctrl+O details · ←/→ or PgUp/PgDn scroll · Enter confirm · Esc cancel" - : "Type to filter · Up/Down choose · Enter confirm · Esc cancel · Ctrl+U clear"), + ? (numberShortcutsEnabled + ? "Type to filter · number selects · Up/Down choose · Ctrl+O details · ←/→ or PgUp/PgDn scroll · Enter confirm · Esc cancel" + : "Type to filter · Up/Down choose · Ctrl+O details · ←/→ or PgUp/PgDn scroll · Enter confirm · Esc cancel") + : (numberShortcutsEnabled + ? "Type to filter · number selects · Up/Down choose · Enter confirm · Esc cancel · Ctrl+U clear" + : "Type to filter · Up/Down choose · Enter confirm · Esc cancel · Ctrl+U clear")), { preserveSgr: false } ), list, @@ -451,6 +492,7 @@ export function choose( maxContentLines, maxExpandedContentLines ); + dialog.numberShortcutCount = numberShortcutsEnabled ? options.items.length : 0; const previewFor = (item: SelectItem | null): Component | undefined => { if (!item) return undefined; return options.showSelectedItemDetails diff --git a/packages/zcode-tui/src/index.ts b/packages/zcode-tui/src/index.ts index 3eac537..1293b18 100644 --- a/packages/zcode-tui/src/index.ts +++ b/packages/zcode-tui/src/index.ts @@ -3327,6 +3327,7 @@ class ZCodeTui { prompt: asString(request.reason) ?? `${toolName} requests permission to continue.`, items, signal, + numberShortcuts: true, content: this.permissionPreview(toolName, request.input, asString(request.riskLevel)) }); if (!selected) return { decision: "deny", reason: "Cancelled by user" }; diff --git a/test/choice-dialog-number-shortcuts.test.ts b/test/choice-dialog-number-shortcuts.test.ts new file mode 100644 index 0000000..b8435e8 --- /dev/null +++ b/test/choice-dialog-number-shortcuts.test.ts @@ -0,0 +1,207 @@ +import { describe, expect, test } from "bun:test"; +import { Container, type Component, type TUI } from "@earendil-works/pi-tui"; + +import { choose } from "../packages/zcode-tui/src/choice-dialog.ts"; +import type { ChoiceItem } from "../packages/zcode-tui/src/choice-dialog.ts"; +import { createTheme } from "../packages/zcode-tui/src/theme.ts"; + +const theme = createTheme(false); + +// Inline-mode harness matching choice-dialog.test.ts: real pi-tui Container, +// dialog mounts into `host`, render `root` to assert on actual dialog output. +function makeHarness() { + const root = new Container(); + const host = new Container(); + const focusState: { current: Component | null } = { current: null }; + const ui = { + terminal: { rows: 24 }, + requestRender() {}, + setFocus(component: Component | null) { + focusState.current = component; + } + } as unknown as TUI; + root.addChild(host); + return { root, host, focusState, ui }; +} + +function rendered(root: Container): string { + return root.render(80).join("\n"); +} + +function items(count: number): ChoiceItem[] { + return Array.from({ length: count }, (_, i) => ({ + value: `opt${i + 1}`, + label: `Option ${i + 1}`, + description: `desc ${i + 1}` + })); +} + +describe("choice dialog number shortcuts", () => { + test("digit '1' confirms the first option in one keystroke", async () => { + const { host, focusState, ui } = makeHarness(); + const promise = choose(ui, host, theme, { + title: "T", + prompt: "P", + items: items(4), + numberShortcuts: true + }); + const dialog = focusState.current as Component; + expect(dialog).toBeTruthy(); + dialog.handleInput!("1"); + const result = await promise; + expect(result?.label).toBe("Option 1"); + }); + + test("digit '3' confirms the third option", async () => { + const { host, focusState, ui } = makeHarness(); + const promise = choose(ui, host, theme, { + title: "T", + prompt: "P", + items: items(4), + numberShortcuts: true + }); + (focusState.current as Component).handleInput!("3"); + const result = await promise; + expect(result?.label).toBe("Option 3"); + }); + + test("digits beyond the item count do nothing", async () => { + const { host, focusState, ui } = makeHarness(); + const promise = choose(ui, host, theme, { + title: "T", + prompt: "P", + items: items(4), + numberShortcuts: true + }); + const dialog = focusState.current as Component; + dialog.handleInput!("9"); // out of range -> no confirm + // settle the promise via Escape + dialog.handleInput!("\x1b"); + const result = await promise; + expect(result).toBeNull(); + }); + + test("digit is filter input when filter is active (no confirm)", async () => { + const { root, host, focusState, ui } = makeHarness(); + const promise = choose(ui, host, theme, { + title: "T", + prompt: "P", + items: items(4), + numberShortcuts: true + }); + const dialog = focusState.current as Component; + dialog.handleInput!("O"); // starts filter with "O" + dialog.handleInput!("2"); // continues filter, must NOT confirm option 2 + expect(rendered(root)).toContain("Filter: O2"); + // Cancel to settle the promise; result should be null (nothing was confirmed) + dialog.handleInput!("\x1b"); + const result = await promise; + expect(result).toBeNull(); + }); + + test("labels carry number hints and the number-selects help by default", async () => { + const { root, host, focusState, ui } = makeHarness(); + const promise = choose(ui, host, theme, { + title: "T", + prompt: "P", + items: items(4), + numberShortcuts: true + }); + const output = rendered(root); + expect(output).toContain("1. Option 1"); + expect(output).toContain("4. Option 4"); + expect(output).not.toContain("5. Option"); + expect(output.replace(/\n/g, " ")).toContain("number selects"); + focusState.current?.handleInput?.("\x1b"); + expect(await promise).toBeNull(); + }); + + test("number shortcuts are opt-in for generic choice dialogs", async () => { + const { root, host, focusState, ui } = makeHarness(); + const promise = choose(ui, host, theme, { + title: "T", + prompt: "P", + items: items(4) + }); + const output = rendered(root); + expect(output).not.toContain("1. Option 1"); + expect(output).not.toContain("number selects"); + focusState.current?.handleInput?.("1"); + expect(rendered(root)).toContain("Filter: 1"); + focusState.current?.handleInput?.("\x1b"); + expect(await promise).toBeNull(); + }); + + test("numberShortcuts: false omits hints and keeps digits as filter input", async () => { + const { root, host, focusState, ui } = makeHarness(); + const promise = choose(ui, host, theme, { + title: "T", + prompt: "P", + items: items(4), + numberShortcuts: false + }); + const output = rendered(root); + expect(output).not.toContain("1. Option 1"); + expect(output).toContain("Option 1"); + expect(output.replace(/\n/g, " ")).not.toContain("number selects"); + // The shortcut is off: the digit must remain filter input. + focusState.current?.handleInput?.("1"); + expect(rendered(root)).toContain("Filter: 1"); + focusState.current?.handleInput?.("\x1b"); + const result = await promise; + expect(result).toBeNull(); + }); + + test("more than 9 items: no hints, no shortcut, help line not advertised", async () => { + const { root, host, focusState, ui } = makeHarness(); + const promise = choose(ui, host, theme, { + title: "T", + prompt: "P", + items: items(12), + numberShortcuts: true + }); + const output = rendered(root); + expect(output).not.toContain("1. Option 1"); + expect(output.replace(/\n/g, " ")).not.toContain("number selects"); + // A digit must remain filter input because lists over 9 items never expose + // single-key numeric selection. + focusState.current?.handleInput?.("3"); + expect(rendered(root)).toContain("Filter: 3"); + focusState.current?.handleInput?.("\x1b"); + const result = await promise; + expect(result).toBeNull(); + }); + + test("custom help suppresses hints and the shortcut by default", async () => { + const { root, host, focusState, ui } = makeHarness(); + const promise = choose(ui, host, theme, { + title: "T", + prompt: "P", + help: "custom help line", + items: items(4) + }); + const output = rendered(root); + expect(output).toContain("custom help line"); + expect(output).not.toContain("1. Option 1"); + focusState.current?.handleInput?.("1"); + expect(rendered(root)).toContain("Filter: 1"); + focusState.current?.handleInput?.("\x1b"); + const result = await promise; + expect(result).toBeNull(); + }); + + test("numberShortcuts: true force-enables the shortcut despite custom help", async () => { + const { host, focusState, ui } = makeHarness(); + const promise = choose(ui, host, theme, { + title: "T", + prompt: "P", + help: "custom help line", + items: items(4), + numberShortcuts: true + }); + const dialog = focusState.current as Component; + dialog.handleInput!("2"); + const result = await promise; + expect(result?.label).toBe("Option 2"); + }); +}); diff --git a/test/choice-dialog.test.ts b/test/choice-dialog.test.ts index 7d3f454..caa4698 100644 --- a/test/choice-dialog.test.ts +++ b/test/choice-dialog.test.ts @@ -306,7 +306,7 @@ describe("TUI choice dialog", () => { expect(output).toContain("plan line 1"); expect(output).not.toContain("plan line 30"); expect(output).toContain("Plan 1–6 of 30"); - expect(output).toContain("Ctrl+O details"); + expect(output.replace(/\n/g, " ")).toContain("Ctrl+O details"); focusState.current?.handleInput?.("\x0f"); output = root.render(60).join("\n");