diff --git a/packages/coding-agent/src/extensibility/extensions/types.ts b/packages/coding-agent/src/extensibility/extensions/types.ts index 056bd96df..beeae8e39 100644 --- a/packages/coding-agent/src/extensibility/extensions/types.ts +++ b/packages/coding-agent/src/extensibility/extensions/types.ts @@ -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. */ diff --git a/packages/coding-agent/src/modes/components/hook-selector.ts b/packages/coding-agent/src/modes/components/hook-selector.ts index 9a4716838..6d2ded83f 100644 --- a/packages/coding-agent/src/modes/components/hook-selector.ts +++ b/packages/coding-agent/src/modes/components/hook-selector.ts @@ -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; + #selectionMarker: "radio" | "checkbox" | undefined; + #checkedIndices: Set; + #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 (`" "`) 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, ), ); } diff --git a/packages/coding-agent/src/modes/controllers/extension-ui-controller.ts b/packages/coding-agent/src/modes/controllers/extension-ui-controller.ts index 843047249..e2b994fce 100644 --- a/packages/coding-agent/src/modes/controllers/extension-ui-controller.ts +++ b/packages/coding-agent/src/modes/controllers/extension-ui-controller.ts @@ -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, }, diff --git a/packages/coding-agent/src/tools/ask.ts b/packages/coding-agent/src/tools/ask.ts index 6948c8c35..edf7371c3 100644 --- a/packages/coding-agent/src/tools/ask.ts +++ b/packages/coding-agent/src/tools/ask.ts @@ -172,6 +172,9 @@ interface UIContext { onLeft?: () => void; onRight?: () => void; helpText?: string; + selectionMarker?: "radio" | "checkbox"; + checkedIndices?: readonly number[]; + markableCount?: number; }, ): Promise; 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) { diff --git a/packages/coding-agent/test/hook-selector-overflow.test.ts b/packages/coding-agent/test/hook-selector-overflow.test.ts index 5a7719b89..ee20c689a 100644 --- a/packages/coding-agent/test/hook-selector-overflow.test.ts +++ b/packages/coding-agent/test/hook-selector-overflow.test.ts @@ -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); + }); }); diff --git a/packages/coding-agent/test/tools/ask.test.ts b/packages/coding-agent/test/tools/ask.test.ts index 3655511b2..e189676d6 100644 --- a/packages/coding-agent/test/tools/ask.test.ts +++ b/packages/coding-agent/test/tools/ask.test.ts @@ -31,6 +31,9 @@ function createContext(args: { onTimeout?: () => void; onLeft?: () => void; onRight?: () => void; + selectionMarker?: "radio" | "checkbox"; + checkedIndices?: readonly number[]; + markableCount?: number; }, ) => Promise; editor?: (