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
48 changes: 45 additions & 3 deletions packages/zcode-tui/src/choice-dialog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -399,13 +424,20 @@ 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<ChoiceItem | null> {
if (options.items.length === 0) return Promise.resolve(null);

return new Promise((resolve) => {
const choicesByValue = new Map<string, ChoiceItem>();
const detailsByValue = new Map<string, Component>();
// 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,
Expand All @@ -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
Expand All @@ -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,
Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions packages/zcode-tui/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" };
Expand Down
207 changes: 207 additions & 0 deletions test/choice-dialog-number-shortcuts.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
2 changes: 1 addition & 1 deletion test/choice-dialog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down