fix(ask): wrap long option labels in the rich ask dialog instead of truncating

The rich AskDialogComponent (merged in PR #4375) hard-truncated every
option row via truncateToWidth(firstLine, width, Ellipsis.Unicode),
clipping the disambiguating tail of long labels with no way to read it.
The legacy HookSelector dialog has wrapped labels since June 1
(3c7d50d292), so the rich dialog was the remaining truncation path.

renderRowLabel now wraps the label with wrapTextWithAnsi, indenting
continuation lines under the radio/checkbox marker so the cursor stays
visually anchored; the body ScrollView carries vertical overflow. The
note marker is reserved on the first line. Fixes #1243, #8594.
This commit is contained in:
Slava Zavadsky
2026-08-15 09:11:32 -04:00
parent ffd53ff92a
commit 00043ef3ba
3 changed files with 50 additions and 2 deletions
+4
View File
@@ -2,6 +2,10 @@
## [Unreleased]
### Fixed
- Fixed long `ask` option labels being hard-truncated at the terminal width with no way to read the clipped tail; the rich ask dialog now wraps labels onto indented continuation lines, matching the legacy selector ([#1243](https://github.com/can1357/oh-my-pi/issues/1243), [#8594](https://github.com/can1357/oh-my-pi/issues/8594)).
## [17.3.4] - 2026-08-14
### Changed
@@ -4,6 +4,7 @@ import {
Markdown,
type MarkdownTheme,
matchesKey,
padding,
renderInlineMarkdown,
replaceTabs,
ScrollView,
@@ -12,6 +13,7 @@ import {
Text,
type TUI,
truncateToWidth,
visibleWidth,
wrapTextWithAnsi,
} from "@oh-my-pi/pi-tui";
import type {
@@ -315,8 +317,20 @@ function renderRowLabel(
const cursor = selected ? theme.fg("accent", `${theme.nav.cursor} `) : " ";
const label = renderInlineMarkdown(rowItem.label, mdTheme, t => theme.fg(color, t));
const noteMarker = state.note && state.noteRowKey === rowItem.key ? theme.fg("success", " ✎ note") : "";
const firstLine = `${cursor}${marker}${label}${noteMarker}`;
const lines = [truncateToWidth(firstLine, width, Ellipsis.Unicode)];
// Wrap the label onto continuation lines indented under the marker so the
// cursor stays visually anchored and the full label stays readable (the
// body ScrollView carries vertical overflow). The note marker is reserved
// on the first line; the label wraps to the remaining width. The row()
// chrome (borders + insets) consumes 4 columns, so the label must fit
// `width - 4` or the outer fit() re-truncates it with an ellipsis.
const noteWidth = noteMarker ? visibleWidth(noteMarker) : 0;
const labelWidth = Math.max(1, width - 4 - visibleWidth(cursor) - visibleWidth(marker) - noteWidth);
const wrappedLabel = wrapTextWithAnsi(label, labelWidth);
const indent = padding(visibleWidth(cursor) + visibleWidth(marker));
const lines = [`${cursor}${marker}${wrappedLabel[0] ?? ""}${noteMarker}`];
for (let i = 1; i < wrappedLabel.length; i++) {
lines.push(`${indent}${wrappedLabel[i] ?? ""}`);
}
if (rowItem.kind === "option") {
const option = question.options[rowItem.optionIndex ?? -1];
if (option?.description?.trim()) {
@@ -1250,6 +1250,36 @@ describe("AskDialogComponent", () => {
expect(matches?.length ?? 0).toBeLessThan(10);
});
it("wraps long option labels onto indented continuation lines instead of truncating", () => {
const onSubmit = vi.fn();
const longLabel = "This is a deliberately long option label ".repeat(4).trim();
const questions: ExtensionAskDialogQuestion[] = [
{
id: "q1",
question: "Pick one?",
options: [{ label: longLabel }, { label: "Short" }],
},
];
const component = new AskDialogComponent(questions, {
onSubmit,
onCancel: vi.fn(),
onPrompt: vi.fn(),
});
const output = render(component);
// The label tail must be present — no ellipsis truncation.
expect(output).toContain("deliberately long option label");
expect(output).not.toContain("…");
// The first line carries the cursor glyph; continuation lines are
// indented under the marker so the cursor stays visually anchored.
const lines = output.split("\n");
const first = lines.find(line => line.includes("This is a deliberately")) ?? "";
const continuation = lines.find(line => line.includes("option label") && !line.includes("❯")) ?? "";
expect(first).toMatch(/│ ❯/);
expect(continuation).toMatch(/│ {3}/);
});
it("Other editor cancel returns to the option list without submitting", async () => {
const onPrompt = vi.fn().mockReturnValue(Promise.resolve(undefined));
const onSubmit = vi.fn();