fix(coding-agent): paint selectedBg band on focused HookSelector row

The multi-select (checkbox) ask picker signaled focus only by shifting the
label fg to `accent` and flipping the checkbox glyph between `accent`
and `dim`. On themes where `accent` is close to `text` (built-in
`light`, others) the focused row was effectively invisible: `↑/↓` would
change the toggle target with no perceptible cue. Radio pickers dodged this
because the glyph shape changes (`◉` vs `○`); checkboxes always render
`☑`/`☐` regardless of focus.

Root cause: HookSelectorComponent's option rendering picked focus via
`textColor = isSelected ? 'accent' : 'text'` and the fallback
`marker ?? cursorChevron` — so with any marker in play the chevron
disappeared, and the only remaining signal was fg color contrast.

Fix: route rendered lines through a `SelectorRow = { text, highlight }`
carrier. `OutlinedList` paints highlighted rows with
`theme.bg('selectedBg', wrappedLine + padding)` inside the border rails,
and the non-outlined plain list feeds the same painter as `Text`'s
`customBgFn` (which `applyBackgroundToLine` already extends across
wrap continuations). The band spans label plus wrapped description rows
so the focus reads as one continuous bar, independent of accent/text
contrast. Precedent: the Ctrl+R history overlay and plan-review overlay
use the same selectedBg-band pattern.

Regression tests cover both outlined and non-outlined lists, focus
movement, control rows past markableCount, and multi-line description
highlighting.

Fixes #4157
This commit is contained in:
roboomp
2026-07-01 15:15:40 +00:00
parent f70e4f1570
commit 8a5fb33464
3 changed files with 160 additions and 24 deletions
@@ -298,4 +298,115 @@ describe("HookSelectorComponent", () => {
expect(done).not.toContain(theme.checkbox.checked);
expect(done).not.toContain(theme.checkbox.unchecked);
});
it("paints a selectedBg focus band across the highlighted checkbox row (outlined)", () => {
// Bug #4157: multi-select checkbox picker used only an accent fg to signal
// focus, which vanished on themes where accent ≈ text. The focused row
// must now carry the selectedBg band regardless of accent/text contrast.
const component = new HookSelectorComponent(
"Pick many",
["Apple", "Banana", "Cherry", "Done selecting", "Other (type your own)"],
() => {},
() => {},
{
outline: true,
selectionMarker: "checkbox",
markableCount: 3,
checkedIndices: [],
initialIndex: 0,
},
);
const bgProbe = theme.bg("selectedBg", "|");
const openBg = bgProbe.slice(0, bgProbe.indexOf("|"));
const rendered = component.render(80);
const appleRow = rendered.find(line => Bun.stripANSI(line).includes("Apple"));
const bananaRow = rendered.find(line => Bun.stripANSI(line).includes("Banana"));
const otherRow = rendered.find(line => Bun.stripANSI(line).includes("Other"));
expect(appleRow).toBeDefined();
expect(bananaRow).toBeDefined();
expect(otherRow).toBeDefined();
expect(appleRow).toContain(openBg);
expect(bananaRow).not.toContain(openBg);
expect(otherRow).not.toContain(openBg);
});
it("moves the selectedBg band with the cursor and paints control rows too", () => {
const build = (initialIndex: number) =>
new HookSelectorComponent(
"Pick many",
["Apple", "Banana", "Cherry", "Done selecting", "Other (type your own)"],
() => {},
() => {},
{
outline: true,
selectionMarker: "checkbox",
markableCount: 3,
checkedIndices: [],
initialIndex,
},
);
const bgProbe = theme.bg("selectedBg", "|");
const openBg = bgProbe.slice(0, bgProbe.indexOf("|"));
const bandedLabel = (component: HookSelectorComponent) => {
const rendered = component.render(80);
const banded = rendered.filter(line => line.includes(openBg));
const labels = ["Apple", "Banana", "Cherry", "Done selecting", "Other"];
return labels.find(label => banded.some(line => Bun.stripANSI(line).includes(label)));
};
expect(bandedLabel(build(1))).toBe("Banana");
// Control rows past markableCount ("Other") still receive the focus band
// even though they carry no checkbox marker.
expect(bandedLabel(build(4))).toBe("Other");
});
it("highlights the whole selected block including wrapped description rows", () => {
const component = new HookSelectorComponent(
"Choose",
[
{
label: "Alpha",
description:
"Detailed first-choice explanation long enough to wrap across multiple rendered rows once outlined inside a tight width.",
},
{ label: "Beta", description: "Second." },
],
() => {},
() => {},
{ outline: true, initialIndex: 0 },
);
const bgProbe = theme.bg("selectedBg", "|");
const openBg = bgProbe.slice(0, bgProbe.indexOf("|"));
const rendered = component.render(60);
const bandedRows = rendered.filter(line => line.includes(openBg));
// Label row plus at least one wrapped description continuation, all under
// the Alpha option; Beta must stay unbanded.
expect(bandedRows.length).toBeGreaterThanOrEqual(2);
const bandedContainsBeta = bandedRows.some(line => Bun.stripANSI(line).includes("Beta"));
expect(bandedContainsBeta).toBe(false);
});
it("paints the selectedBg band on the non-outlined plain list too", () => {
const component = new HookSelectorComponent(
"Pick",
["Apple", "Banana", "Cherry"],
() => {},
() => {},
{ initialIndex: 1 },
);
const bgProbe = theme.bg("selectedBg", "|");
const openBg = bgProbe.slice(0, bgProbe.indexOf("|"));
const rendered = component.render(80);
const appleRow = rendered.find(line => Bun.stripANSI(line).includes("Apple"));
const bananaRow = rendered.find(line => Bun.stripANSI(line).includes("Banana"));
const cherryRow = rendered.find(line => Bun.stripANSI(line).includes("Cherry"));
expect(appleRow).toBeDefined();
expect(bananaRow).toBeDefined();
expect(cherryRow).toBeDefined();
expect(bananaRow).toContain(openBg);
expect(appleRow).not.toContain(openBg);
expect(cherryRow).not.toContain(openBg);
});
});