Count ask option descriptions in selector height

This commit is contained in:
rimless-casualty
2026-06-01 17:58:49 +08:00
parent 3c7d50d292
commit f7b157675c
2 changed files with 76 additions and 6 deletions
@@ -215,14 +215,58 @@ export class HookSelectorComponent extends Container {
this.#updateList();
}
#optionRowCount(option: HookSelectorOption): number {
return option.description ? 2 : 1;
}
#totalOptionRows(options: HookSelectorOption[]): number {
let rows = 0;
for (const option of options) {
rows += this.#optionRowCount(option);
}
return rows;
}
#getVisibleOptionRange(total: number): { startIndex: number; endIndex: number } {
if (total === 0) return { startIndex: 0, endIndex: 0 };
const rowBudget = Math.max(1, this.#maxVisible);
const selectedIndex = Math.max(0, Math.min(this.#selectedIndex, total - 1));
let startIndex = selectedIndex;
let endIndex = selectedIndex + 1;
let rows = this.#optionRowCount(this.#filteredOptions[selectedIndex]!);
let beforeRows = 0;
const targetBeforeRows = Math.max(0, Math.floor((rowBudget - rows) / 2));
while (startIndex > 0) {
const cost = this.#optionRowCount(this.#filteredOptions[startIndex - 1]!);
if (beforeRows + cost > targetBeforeRows || rows + cost > rowBudget) break;
startIndex--;
beforeRows += cost;
rows += cost;
}
while (endIndex < total) {
const cost = this.#optionRowCount(this.#filteredOptions[endIndex]!);
if (rows + cost > rowBudget) break;
endIndex++;
rows += cost;
}
while (startIndex > 0) {
const cost = this.#optionRowCount(this.#filteredOptions[startIndex - 1]!);
if (rows + cost > rowBudget) break;
startIndex--;
rows += cost;
}
return { startIndex, endIndex };
}
#updateList(): void {
const lines: string[] = [];
const total = this.#filteredOptions.length;
const startIndex = Math.max(
0,
Math.min(this.#selectedIndex - Math.floor(this.#maxVisible / 2), total - this.#maxVisible),
);
const endIndex = Math.min(startIndex + this.#maxVisible, total);
const { startIndex, endIndex } = this.#getVisibleOptionRange(total);
const mdTheme = getMarkdownTheme();
for (let i = startIndex; i < endIndex; i++) {
@@ -293,7 +337,7 @@ export class HookSelectorComponent extends Container {
}
#isSearchEnabled(): boolean {
return this.#options.length > this.#maxVisible;
return this.#totalOptionRows(this.#options) > this.#maxVisible;
}
#shouldRenderSearchStatus(): boolean {
@@ -95,6 +95,32 @@ describe("HookSelectorComponent", () => {
}
});
it("counts description rows toward the visible row cap", () => {
const component = new HookSelectorComponent(
"Which setup path should be used?",
[
{ label: "Path A", description: "Reuse existing credentials." },
{ label: "Path B", description: "Authorize a provider in the browser." },
{ label: "Path C", description: "Edit provider keys manually." },
{ label: "Path D", description: "Continue with offline-only tools." },
],
() => {},
() => {},
{ outline: true, initialIndex: 0, maxVisible: 4 },
);
const plain = component
.render(76)
.map(line => Bun.stripANSI(line))
.join("\n");
expect(plain).toContain("Path A");
expect(plain).toContain("Reuse existing credentials.");
expect(plain).toContain("Path B");
expect(plain).toContain("Authorize a provider in the browser.");
expect(plain).not.toContain("Path C");
expect(plain).toContain("(1/4)");
});
it("filters options by description text", () => {
const component = new HookSelectorComponent(
"Which setup path should be used?",