diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 89a88a09f..0cf20c4c1 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed selector dialogs (the `ask` tool, hook prompts) collapsing to a single visible option on shorter terminals when options carried long descriptions: the highlighted option's wrapped description consumed the entire row budget, hiding every other option and making the menu feel unnavigable (down moved the lone visible entry, left/right did nothing). When the fully-expanded list overflows, `HookSelectorComponent` now renders a compact list — every option label stays on screen and only the highlighted option expands its description, truncated to the remaining rows — so the whole menu is always visible and the detail pane follows the cursor. + ## [15.8.3] - 2026-06-03 ### Fixed diff --git a/packages/coding-agent/src/modes/components/hook-selector.ts b/packages/coding-agent/src/modes/components/hook-selector.ts index b1d87a2ba..9a4716838 100644 --- a/packages/coding-agent/src/modes/components/hook-selector.ts +++ b/packages/coding-agent/src/modes/components/hook-selector.ts @@ -4,6 +4,7 @@ */ import { Container, + Ellipsis, extractPrintableText, fuzzyFilter, Markdown, @@ -15,6 +16,7 @@ import { Spacer, Text, type TUI, + truncateToWidth, visibleWidth, wrapTextWithAnsi, } from "@oh-my-pi/pi-tui"; @@ -278,20 +280,51 @@ export class HookSelectorComponent extends Container { isSelected: boolean, isDisabled: boolean, mdTheme: MarkdownTheme, + descRows: number | "full", + renderWidth?: 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 lines = [prefix + label]; - if (option.description) { - const descriptionColor = isDisabled ? "dim" : "muted"; - const description = renderInlineMarkdown(option.description, mdTheme, t => theme.fg(descriptionColor, t)); - lines.push(` ${description}`); + if (option.description && descRows !== 0) { + const descriptionColor: ThemeColor = isDisabled ? "dim" : "muted"; + if (descRows === "full") { + const description = renderInlineMarkdown(option.description, mdTheme, t => theme.fg(descriptionColor, t)); + lines.push(` ${description}`); + } else { + lines.push( + ...this.#wrapDescriptionRows(option.description, descRows, descriptionColor, mdTheme, renderWidth), + ); + } } return lines; } + /** 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 + * detail is shown, so every option label stays on screen on short terminals. */ + #wrapDescriptionRows( + description: string, + maxRows: number, + color: ThemeColor, + mdTheme: MarkdownTheme, + renderWidth = this.#lastRenderWidth, + ): string[] { + if (maxRows <= 0) return []; + const indent = " "; + const innerWidth = Math.max(1, (renderWidth ?? 80) - 2); + const bodyWidth = Math.max(1, innerWidth - indent.length); + const colored = renderInlineMarkdown(description, mdTheme, t => theme.fg(color, t)); + const wrapped = wrapTextWithAnsi(colored, bodyWidth); + if (wrapped.length <= maxRows) return wrapped.map(row => indent + row); + const kept = wrapped.slice(0, maxRows); + kept[maxRows - 1] = truncateToWidth(wrapped.slice(maxRows - 1).join(" "), bodyWidth, Ellipsis.Unicode); + return kept.map(row => indent + row); + } + #renderedLineRowCount(line: string, renderWidth: number): number { const normalized = replaceTabs(line); if (this.#outlinedList) { @@ -309,10 +342,11 @@ export class HookSelectorComponent extends Container { renderWidth: number | undefined, isSelected: boolean, mdTheme: MarkdownTheme, + descRows: number | "full", ): number { - if (renderWidth === undefined) return option.description ? 2 : 1; + if (renderWidth === undefined) return option.description && descRows !== 0 ? 2 : 1; let rows = 0; - for (const line of this.#renderOptionLines(option, isSelected, false, mdTheme)) { + for (const line of this.#renderOptionLines(option, isSelected, false, mdTheme, descRows, renderWidth)) { rows += this.#renderedLineRowCount(line, renderWidth); } return rows; @@ -322,7 +356,7 @@ export class HookSelectorComponent extends Container { const themeForRows = mdTheme ?? getMarkdownTheme(); let rows = 0; for (const option of options) { - rows += this.#optionRowCount(option, renderWidth, false, themeForRows); + rows += this.#optionRowCount(option, renderWidth, false, themeForRows, "full"); } return rows; } @@ -331,19 +365,37 @@ export class HookSelectorComponent extends Container { total: number, renderWidth?: number, mdTheme: MarkdownTheme = getMarkdownTheme(), + compact = false, ): { startIndex: number; endIndex: number } { if (total === 0) return { startIndex: 0, endIndex: 0 }; + // In compact mode every option contributes only its label rows; the + // highlighted option's description is layered on afterwards (see + // #updateList), so the window is sized to keep as many labels visible as + // possible rather than letting one long description swallow the budget. + const descMode: number | "full" = compact ? 0 : "full"; 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]!.option, renderWidth, true, mdTheme); + let rows = this.#optionRowCount( + this.#filteredOptions[selectedIndex]!.option, + renderWidth, + true, + mdTheme, + descMode, + ); let beforeRows = 0; const targetBeforeRows = Math.max(0, Math.floor((rowBudget - rows) / 2)); while (startIndex > 0) { - const cost = this.#optionRowCount(this.#filteredOptions[startIndex - 1]!.option, renderWidth, false, mdTheme); + const cost = this.#optionRowCount( + this.#filteredOptions[startIndex - 1]!.option, + renderWidth, + false, + mdTheme, + descMode, + ); if (beforeRows + cost > targetBeforeRows || rows + cost > rowBudget) break; startIndex--; beforeRows += cost; @@ -351,14 +403,26 @@ export class HookSelectorComponent extends Container { } while (endIndex < total) { - const cost = this.#optionRowCount(this.#filteredOptions[endIndex]!.option, renderWidth, false, mdTheme); + const cost = this.#optionRowCount( + this.#filteredOptions[endIndex]!.option, + renderWidth, + false, + mdTheme, + descMode, + ); if (rows + cost > rowBudget) break; endIndex++; rows += cost; } while (startIndex > 0) { - const cost = this.#optionRowCount(this.#filteredOptions[startIndex - 1]!.option, renderWidth, false, mdTheme); + const cost = this.#optionRowCount( + this.#filteredOptions[startIndex - 1]!.option, + renderWidth, + false, + mdTheme, + descMode, + ); if (rows + cost > rowBudget) break; startIndex--; rows += cost; @@ -371,13 +435,42 @@ export class HookSelectorComponent extends Container { const lines: string[] = []; const total = this.#filteredOptions.length; const mdTheme = getMarkdownTheme(); - const { startIndex, endIndex } = this.#getVisibleOptionRange(total, renderWidth, mdTheme); + // Compact mode kicks in exactly when the fully-expanded list (all + // descriptions) would overflow the row budget — the same condition that + // enables search. There we collapse every option to its label and show + // only the highlighted option's description, so the whole menu stays + // visible on short terminals instead of collapsing to a single entry. + const compact = this.#isSearchEnabled(renderWidth, mdTheme); + const { startIndex, endIndex } = this.#getVisibleOptionRange(total, renderWidth, mdTheme, compact); + + let selectedDescRows = 0; + if (compact && renderWidth !== undefined) { + let labelRows = 0; + for (let i = startIndex; i < endIndex; i++) { + const filtered = this.#filteredOptions[i]; + if (filtered === undefined) continue; + labelRows += this.#optionRowCount(filtered.option, renderWidth, i === this.#selectedIndex, mdTheme, 0); + } + // Reserve one row for the status line; give the remainder to the + // highlighted option's description. + selectedDescRows = Math.max(0, Math.max(1, this.#maxVisible) - labelRows - 1); + } for (let i = startIndex; i < endIndex; i++) { const filtered = this.#filteredOptions[i]; if (filtered === undefined) continue; const isSelected = i === this.#selectedIndex; - lines.push(...this.#renderOptionLines(filtered.option, isSelected, this.#isDisabled(filtered.index), mdTheme)); + const descMode: number | "full" = compact ? (isSelected ? selectedDescRows : 0) : "full"; + lines.push( + ...this.#renderOptionLines( + filtered.option, + isSelected, + this.#isDisabled(filtered.index), + mdTheme, + descMode, + renderWidth, + ), + ); } if (total === 0) { diff --git a/packages/coding-agent/test/hook-selector-overflow.test.ts b/packages/coding-agent/test/hook-selector-overflow.test.ts index 672ebcd81..5a7719b89 100644 --- a/packages/coding-agent/test/hook-selector-overflow.test.ts +++ b/packages/coding-agent/test/hook-selector-overflow.test.ts @@ -95,30 +95,48 @@ describe("HookSelectorComponent", () => { } }); - it("counts description rows toward the visible row cap", () => { + it("collapses to labels with only the highlighted description when descriptions overflow", () => { + const options = [ + { 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." }, + ]; 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." }, - ], + options, () => {}, () => {}, - { outline: true, initialIndex: 0, maxVisible: 4 }, + { outline: true, initialIndex: 0, maxVisible: 6 }, ); const plain = component .render(76) .map(line => Bun.stripANSI(line)) .join("\n"); + // Every option label stays on screen so the user can see the whole menu... 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("Path C"); + expect(plain).toContain("Path D"); + // ...but only the highlighted option expands its description. + expect(plain).toContain("Reuse existing credentials."); + expect(plain).not.toContain("Authorize a provider in the browser."); + expect(plain).not.toContain("Edit provider keys manually."); expect(plain).toContain("(1/4)"); + + // The detail pane follows the cursor: moving down expands Path B and + // collapses Path A's description. + component.handleInput("\x1b[B"); + const afterDown = component + .render(76) + .map(line => Bun.stripANSI(line)) + .join("\n"); + expect(afterDown).toContain("Path A"); + expect(afterDown).toContain("Path D"); + expect(afterDown).toContain("Authorize a provider in the browser."); + expect(afterDown).not.toContain("Reuse existing credentials."); + expect(afterDown).toContain("(2/4)"); }); it("counts wrapped outlined rows toward the visible row cap", () => {