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:
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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?: (
|
||||
|
||||
Reference in New Issue
Block a user