diff --git a/packages/coding-agent/src/modes/components/hook-selector.ts b/packages/coding-agent/src/modes/components/hook-selector.ts index e8a8f6067..d83c0ee59 100644 --- a/packages/coding-agent/src/modes/components/hook-selector.ts +++ b/packages/coding-agent/src/modes/components/hook-selector.ts @@ -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 { diff --git a/packages/coding-agent/test/hook-selector-overflow.test.ts b/packages/coding-agent/test/hook-selector-overflow.test.ts index 4049ffc21..2ca3f17f7 100644 --- a/packages/coding-agent/test/hook-selector-overflow.test.ts +++ b/packages/coding-agent/test/hook-selector-overflow.test.ts @@ -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?",