fix(coding-agent/modes): collapsed hook option descriptions in compact overflow mode

- Updated hook option rendering to support description modes for full text, bounded rows, or hidden detail output.
- Adjusted compact-mode row budgeting so list height is based on labels, preventing a single description from consuming the visible budget.
- Added pre-wrapping with ellipsis truncation for selected option descriptions and updated overflow tests to validate the collapsed label-first behavior.
This commit is contained in:
can1357
2026-06-03 21:33:52 +02:00
parent 37f8d4109d
commit a1a9176c19
3 changed files with 139 additions and 24 deletions
+4
View File
@@ -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
@@ -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) {
@@ -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", () => {