feat(coding-agent): added native selection markers to ask dialogs

- Added `selectionMarker`, `checkedIndices`, and `markableCount` options to render radio/checkbox glyphs per row.
- Moved checkbox rendering from inline label prefixes into the selector component.
- Kept trailing control rows like "Other"/"Done" on the plain cursor.
This commit is contained in:
can1357
2026-06-04 03:43:09 +02:00
parent 7530832b42
commit 7f271f9b9d
6 changed files with 146 additions and 24 deletions
@@ -124,6 +124,17 @@ export interface ExtensionUIDialogOptions {
onExternalEditor?: () => void;
/** Optional footer hint text rendered by interactive selector */
helpText?: string;
/** Render a leading radio/checkbox marker before each markable option in
* select dialogs (matches the ask transcript). "radio" fills the cursor row
* for single-choice; "checkbox" reflects `checkedIndices` per row for
* multi-select. Options beyond `markableCount` keep the plain cursor. */
selectionMarker?: "radio" | "checkbox";
/** For `selectionMarker: "checkbox"`: option indices currently checked. */
checkedIndices?: readonly number[];
/** Number of leading options that receive a selection marker; the remaining
* trailing options (e.g. "Other"/"Done" actions) keep the plain cursor.
* Defaults to all options when `selectionMarker` is set. */
markableCount?: number;
}
/** Raw terminal input listener for extensions. */
@@ -73,6 +73,17 @@ export interface HookSelectorOptions {
/** Indices into the original options that cannot be selected: they render
* dimmed, are skipped during navigation, and reject enter/timeout. */
disabledIndices?: readonly number[];
/** Render a leading radio/checkbox marker before each markable option,
* matching the ask transcript. "radio" fills the marker on the cursor row
* (single-choice); "checkbox" reflects {@link checkedIndices} per row
* (multi-select). Options at or beyond {@link markableCount} keep the plain
* cursor prefix — used for trailing control rows like "Other"/"Done". */
selectionMarker?: "radio" | "checkbox";
/** For `selectionMarker: "checkbox"`: original-indices currently checked. */
checkedIndices?: readonly number[];
/** Number of leading options (original order) that receive a selection
* marker. Defaults to every option when {@link selectionMarker} is set. */
markableCount?: number;
}
export interface HookSelectorOption {
@@ -142,6 +153,9 @@ export class HookSelectorComponent extends Container {
#searchQuery = "";
#selectedIndex: number;
#disabledIndices: Set<number>;
#selectionMarker: "radio" | "checkbox" | undefined;
#checkedIndices: Set<number>;
#markableCount: number;
#maxVisible: number;
#listContainer: Container | undefined;
#outlinedList: OutlinedList | undefined;
@@ -173,6 +187,13 @@ export class HookSelectorComponent extends Container {
index => Number.isInteger(index) && index >= 0 && index < this.#options.length,
),
);
this.#selectionMarker = opts?.selectionMarker;
this.#checkedIndices = new Set(
(opts?.checkedIndices ?? []).filter(
index => Number.isInteger(index) && index >= 0 && index < this.#options.length,
),
);
this.#markableCount = Math.max(0, Math.min(opts?.markableCount ?? this.#options.length, this.#options.length));
this.#selectedIndex = this.#coerceSelectedIndex(opts?.initialIndex ?? 0);
this.#maxVisible = Math.max(3, opts?.maxVisible ?? 12);
this.#onSelectCallback = onSelect;
@@ -282,11 +303,13 @@ export class HookSelectorComponent extends Container {
mdTheme: MarkdownTheme,
descRows: number | "full",
renderWidth?: number,
index?: number,
): string[] {
const textColor = isDisabled ? "dim" : isSelected ? "accent" : "text";
const prefixColor = isDisabled ? "dim" : "accent";
const label = renderInlineMarkdown(option.label, mdTheme, t => theme.fg(textColor, t));
const prefix = isSelected ? theme.fg(prefixColor, `${theme.nav.cursor} `) : " ";
const marker = index !== undefined ? this.#renderMarkerPrefix(index, isSelected, isDisabled) : undefined;
const prefix = marker ?? (isSelected ? theme.fg(prefixColor, `${theme.nav.cursor} `) : " ");
const lines = [prefix + label];
if (option.description && descRows !== 0) {
const descriptionColor: ThemeColor = isDisabled ? "dim" : "muted";
@@ -302,6 +325,24 @@ export class HookSelectorComponent extends Container {
return lines;
}
/** Styled leading marker (`"<glyph> "`) for a markable option row, or
* `undefined` when no marker applies (control rows beyond `markableCount`,
* or when {@link selectionMarker} is unset) so the caller falls back to the
* classic cursor prefix. Radio fills on the cursor row; checkbox reflects
* the per-row checked state, with the cursor row drawn in accent. */
#renderMarkerPrefix(index: number, isSelected: boolean, isDisabled: boolean): string | undefined {
if (this.#selectionMarker === undefined || index >= this.#markableCount) return undefined;
if (this.#selectionMarker === "radio") {
const glyph = isSelected ? theme.radio.selected : theme.radio.unselected;
const color = isDisabled ? "dim" : isSelected ? "accent" : "dim";
return theme.fg(color, `${glyph} `);
}
const checked = this.#checkedIndices.has(index);
const glyph = checked ? theme.checkbox.checked : theme.checkbox.unchecked;
const color = isDisabled ? "dim" : isSelected ? "accent" : checked ? "success" : "dim";
return theme.fg(color, `${glyph} `);
}
/** Wrap an option description into indented rows, truncating to `maxRows`
* with an ellipsis. Pre-wrapping (rather than emitting one long line that the
* list re-wraps) lets compact mode bound how much of the highlighted option's
@@ -469,6 +510,7 @@ export class HookSelectorComponent extends Container {
mdTheme,
descMode,
renderWidth,
filtered.index,
),
);
}
@@ -625,6 +625,9 @@ export class ExtensionUiController {
tui: this.ctx.ui,
outline: dialogOptions?.outline,
disabledIndices: dialogOptions?.disabledIndices,
selectionMarker: dialogOptions?.selectionMarker,
checkedIndices: dialogOptions?.checkedIndices,
markableCount: dialogOptions?.markableCount,
maxVisible,
slider: extra?.slider,
},
+25 -23
View File
@@ -172,6 +172,9 @@ interface UIContext {
onLeft?: () => void;
onRight?: () => void;
helpText?: string;
selectionMarker?: "radio" | "checkbox";
checkedIndices?: readonly number[];
markableCount?: number;
},
): Promise<string | undefined>;
editor(
@@ -199,6 +202,7 @@ async function askSingleQuestion(
prompt: string,
optionsToShow: ExtensionUISelectItem[],
initialIndex?: number,
marker?: { selectionMarker: "radio" | "checkbox"; checkedIndices?: readonly number[]; markableCount: number },
): Promise<{ choice: string | undefined; timedOut: boolean; navigation?: "back" | "forward" }> => {
let timeoutTriggered = false;
const onTimeout = () => {
@@ -215,6 +219,9 @@ async function askSingleQuestion(
outline: true,
onTimeout,
helpText,
selectionMarker: marker?.selectionMarker,
checkedIndices: marker?.checkedIndices,
markableCount: marker?.markableCount,
onLeft: navigation?.allowBack
? () => {
navigationAction = "back";
@@ -253,25 +260,27 @@ async function askSingleQuestion(
if (selectedIndex >= 0) cursorIndex = selectedIndex;
}
while (true) {
const opts: ExtensionUISelectItem[] = [];
for (const opt of questionOptions) {
const checkbox = selected.has(opt.label) ? theme.checkbox.checked : theme.checkbox.unchecked;
const displayLabel = `${checkbox} ${opt.label}`;
opts.push(toSelectOption(opt, displayLabel));
}
const opts: ExtensionUISelectItem[] = questionOptions.map(opt => toSelectOption(opt));
if (!navigation?.allowForward && selected.size > 0) {
opts.push(doneLabel);
}
opts.push(OTHER_OPTION);
const checkedIndices: number[] = [];
for (let i = 0; i < questionOptions.length; i++) {
if (selected.has(questionOptions[i]!.label)) checkedIndices.push(i);
}
const prefix = selected.size > 0 ? `(${selected.size} selected) ` : "";
const {
choice,
timedOut: selectTimedOut,
navigation: arrowNavigation,
} = await selectOption(`${prefix}${promptWithProgress}`, opts, cursorIndex);
} = await selectOption(`${prefix}${promptWithProgress}`, opts, cursorIndex, {
selectionMarker: "checkbox",
checkedIndices,
markableCount: questionOptions.length,
});
if (arrowNavigation) {
return { selectedOptions: Array.from(selected), customInput, timedOut, navigation: arrowNavigation };
@@ -303,20 +312,10 @@ async function askSingleQuestion(
cursorIndex = selectedIdx;
}
const checkedPrefix = `${theme.checkbox.checked} `;
const uncheckedPrefix = `${theme.checkbox.unchecked} `;
let opt: string | undefined;
if (choice.startsWith(checkedPrefix)) {
opt = choice.slice(checkedPrefix.length);
} else if (choice.startsWith(uncheckedPrefix)) {
opt = choice.slice(uncheckedPrefix.length);
}
if (opt) {
if (selected.has(opt)) {
selected.delete(opt);
} else {
selected.add(opt);
}
if (selected.has(choice)) {
selected.delete(choice);
} else {
selected.add(choice);
}
if (selectTimedOut) {
@@ -346,7 +345,10 @@ async function askSingleQuestion(
choice,
timedOut: selectTimedOut,
navigation: arrowNavigation,
} = await selectOption(promptWithProgress, optionsWithNavigation, initialIndex);
} = await selectOption(promptWithProgress, optionsWithNavigation, initialIndex, {
selectionMarker: "radio",
markableCount: displayOptions.length,
});
timedOut = selectTimedOut;
if (arrowNavigation) {
@@ -237,4 +237,65 @@ describe("HookSelectorComponent", () => {
expect(component.render(80).join("\n")).toContain(theme.fg("dim", "Disabled"));
});
it("renders radio markers instead of a cursor arrow for single-choice markable rows", () => {
const component = new HookSelectorComponent(
"Pick one",
["Apple", "Banana", "Other (type your own)"],
() => {},
() => {},
{ selectionMarker: "radio", markableCount: 2, initialIndex: 0 },
);
const lines = Bun.stripANSI(component.render(80).join("\n")).split("\n");
const apple = lines.find(line => line.includes("Apple"));
const banana = lines.find(line => line.includes("Banana"));
expect(apple).toBeDefined();
expect(banana).toBeDefined();
// Cursor row shows the filled radio; the legacy cursor arrow is gone.
expect(apple).toContain(theme.radio.selected);
expect(apple).not.toContain(theme.nav.cursor);
// Non-cursor markable row shows the empty radio.
expect(banana).toContain(theme.radio.unselected);
});
it("keeps the cursor arrow on control rows beyond markableCount", () => {
const component = new HookSelectorComponent(
"Pick one",
["Apple", "Banana", "Other (type your own)"],
() => {},
() => {},
{ selectionMarker: "radio", markableCount: 2, initialIndex: 2 },
);
const lines = Bun.stripANSI(component.render(80).join("\n")).split("\n");
const other = lines.find(line => line.includes("Other"));
expect(other).toBeDefined();
// The trailing action keeps the classic cursor and gets no radio marker.
expect(other).toContain(theme.nav.cursor);
expect(other).not.toContain(theme.radio.selected);
});
it("renders checkbox markers reflecting checked state and exempts control rows", () => {
const component = new HookSelectorComponent(
"Pick many",
["Apple", "Banana", "Done selecting", "Other (type your own)"],
() => {},
() => {},
{ selectionMarker: "checkbox", markableCount: 2, checkedIndices: [0], initialIndex: 1 },
);
const lines = Bun.stripANSI(component.render(80).join("\n")).split("\n");
const apple = lines.find(line => line.includes("Apple"));
const banana = lines.find(line => line.includes("Banana"));
const done = lines.find(line => line.includes("Done selecting"));
expect(apple).toBeDefined();
expect(banana).toBeDefined();
expect(done).toBeDefined();
expect(apple).toContain(theme.checkbox.checked);
expect(banana).toContain(theme.checkbox.unchecked);
// Control rows beyond markableCount carry no checkbox marker.
expect(done).not.toContain(theme.checkbox.checked);
expect(done).not.toContain(theme.checkbox.unchecked);
});
});
@@ -31,6 +31,9 @@ function createContext(args: {
onTimeout?: () => void;
onLeft?: () => void;
onRight?: () => void;
selectionMarker?: "radio" | "checkbox";
checkedIndices?: readonly number[];
markableCount?: number;
},
) => Promise<string | undefined>;
editor?: (