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:
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user