fix(tool-rendering): wrap expanded result bodies #17

Merged
eleith merged 1 commits from fix/tool-rendering-expanded-wrapping into main 2026-11-05 19:51:29 +00:00
14 changed files with 349 additions and 81 deletions

No files matched your search

@@ -0,0 +1,6 @@
---
"@eleith-pi/tool-rendering": patch
"@eleith-pi/web-search-codex": patch
---
Wrap long expanded tool-result lines with Pi's public ANSI-aware wrapping helper, including code, diffs, and errors. Keep compact clipping, logical counts, and execution-level truncation unchanged. Web-search errors now rely on the shared error-frame wrapping rather than pre-wrapping the same text.
+4
View File
@@ -29,6 +29,10 @@ and model-facing results remain unchanged. old sessions' display details still w
frames target these seven tool names; custom tools with the same names may have
output formats that do not match the summaries. hide the frames to use their own rendering.
collapsed result previews clip long lines; expanded results wrap them to reveal all
available text, including code, diffs, and errors. counts describe logical lines,
files, or matches rather than wrapped screen rows.
the working label counts model time, not time spent running tools.
## how to configure
+54 -1
View File
@@ -1,7 +1,7 @@
import { rmSync } from "node:fs";
import * as agent from "@earendil-works/pi-coding-agent";
import type { ThemeColor } from "@earendil-works/pi-coding-agent";
import { visibleWidth } from "@earendil-works/pi-tui";
import { stripTerminalSequences, visibleWidth } from "@earendil-works/pi-tui";
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { buildBashRendering } from "./bash.ts";
import { FramedText } from "./frame.ts";
@@ -147,6 +147,59 @@ describe("bash renderers", () => {
expect(expanded.render(100).at(-1)).toContain("✓ exit 0 · expanded");
});
it.each([
{ isError: false, isPartial: false, summary: "✓ exit 0" },
{ isError: false, isPartial: true, summary: "running" },
{ isError: true, isPartial: false, summary: "✗ exit 7" },
])(
"wraps full expanded bash output (error=$isError, partial=$isPartial)",
({ isError, isPartial, summary }) => {
const { render } = fixture();
const payload = "abcdefghij".repeat(25) + "TAIL";
const sourceLines = Array.from({ length: 7 }, (_, index) => `${index + 1}:${payload}`);
const body = sourceLines.join("\n");
const raw = isError ? `${body}\n\nCommand exited with code 7` : body;
const output = result(raw);
const original = structuredClone(output);
const ctx = context({ isError, isPartial });
const compactOptions = { expanded: false, isPartial };
const compact = render(output, compactOptions, theme, ctx);
const compactLines = compact.render(100);
expect(compactLines).toHaveLength(6);
const compactFooter = compactLines.at(-1)!;
expect(compactFooter).toContain(`${summary} · collapsed · 2 hidden`);
const compactText = compactLines.join("\n");
expect(compactText).not.toContain("TAIL");
const expandedOptions = { expanded: true, isPartial };
const expandedContext = { ...ctx, expanded: true, lastComponent: compact };
const expanded = render(output, expandedOptions, theme, expandedContext);
expect(expanded).toBe(compact);
const expected = sourceLines.join("");
for (const width of [1, 5, 40, 100, 250, 40]) {
const rendered = expanded.render(width);
const content = rendered.slice(0, -1);
const plain = content.map((line) => stripTerminalSequences(line));
const restored = plain.map((line) => line.trimEnd()).join("");
expect(restored).toBe(expected);
for (const line of rendered) {
const columns = visibleWidth(line);
expect(columns).toBeLessThanOrEqual(width);
}
if (width >= 100) {
const footer = rendered.at(-1)!;
expect(footer).toContain(`${summary} · expanded`);
expect(footer).not.toContain("hidden");
}
}
const restoredContext = { ...ctx, lastComponent: expanded };
const collapsedAgain = render(output, compactOptions, theme, restoredContext);
const restoredLines = collapsedAgain.render(100);
const restoredText = restoredLines.join("\n");
expect(restoredText).toBe(compactText);
expect(output).toEqual(original);
},
);
it.each(["Command exited with code 9", "Command timed out after 10 seconds", "Command aborted"])(
"preserves status-looking stdout in success and partial: %s",
(status) => {
+1 -1
View File
@@ -95,7 +95,7 @@ function renderBashResult(
: bashSummaryLabel(parsed, theme) || (context.isError ? theme.fg("error", "✗ error") : "");
const summary = [duration, runStatus].filter(Boolean).join(theme.fg("dim", " · "));
const label = resultLabel(summary, options.expanded, hidden, theme);
return frameResultWithBottomLabel(output, label, status, theme, width);
return frameResultWithBottomLabel(output, label, status, theme, width, options.expanded);
}
interface ParsedBashOutput {
+4 -8
View File
@@ -57,19 +57,15 @@ function renderEditResult(
const sanitizedDiff = sanitizeDisplayText(rawDiff);
const diff = normalizeLineEndings(sanitizedDiff);
const stats = diffStats(diff);
const label = `+${stats.added} -${stats.removed}`;
const summary = `+${stats.added} -${stats.removed}`;
const lines = diff
.replace(/\n$/, "")
.split("\n")
.filter((line) => line && !line.startsWith("***"));
const { shown, hidden } = previewLines(lines, expanded, 8);
return frameResultWithBottomLabel(
renderDiff(shown, theme),
resultLabel(label, expanded, hidden, theme),
status,
theme,
width,
);
const body = renderDiff(shown, theme);
const label = resultLabel(summary, expanded, hidden, theme);
return frameResultWithBottomLabel(body, label, status, theme, width, expanded);
}
function renderDiff(lines: string[], theme: EditTheme): string {
@@ -10,7 +10,7 @@ import {
type Theme,
type ToolRenderers,
} from "@earendil-works/pi-coding-agent";
import { visibleWidth, type Component } from "@earendil-works/pi-tui";
import { stripTerminalSequences, visibleWidth, type Component } from "@earendil-works/pi-tui";
import { afterAll, expect, it, vi } from "vitest";
import { buildReadRendering } from "./read.ts";
import { buildWriteRendering } from "./write.ts";
@@ -225,6 +225,132 @@ it.each(names)("keeps bounded collapsed %s previews and full expanded content",
);
});
it.each(names)(
"wraps expanded %s bodies without changing logical counts or compact clipping",
(name) => {
const captured = capture(name);
const tool = erased(captured.tool);
const payload = "abcdefghij".repeat(15) + "TAIL";
const written = `${payload}\nsecond`;
const contents: Record<Name, string> = {
read: written,
write: "written",
edit: `+${payload}\n-${payload}`,
grep: `src/a.ts:1:${payload}\nsrc/b.ts:2:second`,
find: `src/${payload}.ts\nsrc/second.ts`,
ls: written,
};
const expectedBodies: Record<Name, string> = {
read: `995 │ ${payload}\n996 │ second`,
write: ` 1 │ ${payload}\n 2 │ second`,
edit: `+${payload}\n-${payload}`,
grep: `src/a.ts\n 1 │ ${payload}\n\nsrc/b.ts\n 2 │ second`,
find: `src/\n ├── ${payload}.ts\n └── second.ts`,
ls: `├── ${payload}\n└── second`,
};
const summaries: Record<Name, string> = {
read: "2 lines",
write: "2 lines",
edit: "+1 -1",
grep: "2 matches",
find: "2 files",
ls: "2 entries",
};
const args = { ...presets[name].args, content: written };
const result = {
content: [{ type: "text" as const, text: contents[name] }],
details: name === "edit" ? { diff: contents.edit } : undefined,
};
const original = structuredClone({ args, result });
const ctx = context(args);
const compact = tool.renderResult!(result, options, theme, ctx);
const compactLines = compact.render(40);
const compactText = compactLines.join("\n");
const expectedLines = expectedBodies[name].split("\n");
expect(compactLines).toHaveLength(expectedLines.length + 1);
expect(compactText).not.toContain("TAIL");
expect(compactText).toContain("…");
const expandedOptions = { ...options, expanded: true };
const expandedContext = { ...ctx, lastComponent: compact, expanded: true };
const expanded = tool.renderResult!(result, expandedOptions, theme, expandedContext);
expect(expanded).toBe(compact);
const expected = expectedBodies[name].replace(/\s+/g, "");
for (const width of [8, 40, 80, 250, 40]) {
const rendered = expanded.render(width);
const body = rendered.slice(0, -1);
const plain = body.map((line) => stripTerminalSequences(line));
const restored = plain.join("").replace(/\s+/g, "");
expect(restored).toBe(expected);
for (const line of rendered) {
const columns = visibleWidth(line);
expect(columns).toBeLessThanOrEqual(width);
}
if (width >= 80) {
const footer = rendered.at(-1)!;
expect(footer).toContain(`${summaries[name]} · expanded`);
expect(footer).not.toContain("hidden");
}
}
const restoredContext = { ...ctx, lastComponent: expanded };
const collapsedAgain = tool.renderResult!(result, options, theme, restoredContext);
const restoredLines = collapsedAgain.render(40);
const restoredText = restoredLines.join("\n");
expect(restoredText).toBe(compactText);
expect({ args, result }).toEqual(original);
},
);
it("preserves code indentation and token spacing while wrapping expanded write content", () => {
const { tool } = capture("write");
const source = ' const message = "hello world";\n return message + "!";';
const args = { path: "example.ts", content: source };
const result = { content: [{ type: "text" as const, text: "written" }], details: undefined };
const ctx = context(args, { expanded: true });
const expandedOptions = { ...options, expanded: true };
const component = tool.renderResult!(result, expandedOptions, theme, ctx);
const rendered = component.render(32);
const content = rendered.slice(0, -1);
const plain = content.map((line) => stripTerminalSequences(line));
const actual = plain.map((line) => line.trimEnd());
expect(actual).toEqual([
' 1 │ const message = "hello',
'world";',
' 2 │ return message + "!";',
]);
const footer = rendered.at(-1)!;
expect(footer).toContain("2 lines · expanded");
});
it.each(names)("wraps expanded %s errors and retains logical hidden counts", (name) => {
const captured = capture(name);
const tool = erased(captured.tool);
const payload = "failure".repeat(40);
const message = `${payload}\nsecond\nthird\nfourth\nfifth\nsixth\nseventh`;
const result = { content: [{ type: "text" as const, text: message }], details: undefined };
const ctx = context(presets[name].args, { isError: true });
const compact = tool.renderResult!(result, options, theme, ctx);
const compactLines = compact.render(80);
expect(compactLines).toHaveLength(6);
const compactFooter = compactLines.at(-1)!;
expect(compactFooter).toContain("✗ error · collapsed · 2 hidden");
const expandedOptions = { ...options, expanded: true };
const expandedContext = { ...ctx, lastComponent: compact, expanded: true };
const expanded = tool.renderResult!(result, expandedOptions, theme, expandedContext);
expect(expanded).toBe(compact);
const expected = message.replace(/\n/g, "");
for (const width of [1, 5, 40, 80]) {
const rendered = expanded.render(width);
const body = rendered.slice(0, -1);
const plain = body.map((line) => stripTerminalSequences(line));
const restored = plain.map((line) => line.trimEnd()).join("");
expect(restored).toBe(expected);
for (const line of rendered) {
const columns = visibleWidth(line);
expect(columns).toBeLessThanOrEqual(width);
}
}
});
it.each([
{ offset: 10, limit: 2, title: "read file.txt from 10 (2 lines)", first: 10 },
{ offset: "10", limit: "2", title: "read file.txt from 10 (2 lines)", first: 10 },
@@ -501,9 +627,12 @@ it.each(["ls", "find"] as const)(
},
);
it("keeps unfamiliar grep output visible with a bounded collapsed preview", () => {
const tool = erased(capture("grep").tool);
const result = { content: [{ type: "text" as const, text: rows }], details: undefined };
it("keeps unfamiliar grep output bounded when collapsed and wraps every expanded line", () => {
const captured = capture("grep");
const tool = erased(captured.tool);
const longLine = "unfamiliar".repeat(30) + "TAIL";
const fallbackText = rows.replace("row-14", `row-14:${longLine}`);
const result = { content: [{ type: "text" as const, text: fallbackText }], details: undefined };
const ctx = context(presets.grep.args);
const collapsed = tool.renderResult!(result, options, theme, ctx);
const output = text(collapsed);
@@ -511,12 +640,19 @@ it("keeps unfamiliar grep output visible with a bounded collapsed preview", () =
expect(output).not.toContain("row-4");
expect(output).not.toContain("0 matches");
expect(output).toContain("output · collapsed · 11 hidden");
expect(collapsed.render(180)).toHaveLength(4);
const expanded = tool.renderResult!(result, { ...options, expanded: true }, theme, {
...ctx,
lastComponent: collapsed,
});
expect(text(expanded)).toContain("row-14");
const collapsedLines = collapsed.render(180);
expect(collapsedLines).toHaveLength(4);
const expandedOptions = { ...options, expanded: true };
const expandedContext = { ...ctx, lastComponent: collapsed, expanded: true };
const expanded = tool.renderResult!(result, expandedOptions, theme, expandedContext);
const rendered = expanded.render(40);
const content = rendered.slice(0, -1);
const plain = content.map((line) => stripTerminalSequences(line));
const restored = plain.map((line) => line.trimEnd()).join("");
const expected = fallbackText.replace(/\n/g, "");
expect(restored).toBe(expected);
const footer = rendered.at(-1)!;
expect(footer).toContain("output · expanded");
});
it("keeps empty searches and directories distinct from errors", () => {
+1 -1
View File
@@ -96,7 +96,7 @@ function renderFindResult(
const truncated = truncation?.truncated ? " · truncated" : "";
const summary = `${paths.length} files${limit}${truncated}`;
const label = resultLabel(summary, expanded, hidden, theme);
return frameResultWithBottomLabel(body, label, status, theme, width);
return frameResultWithBottomLabel(body, label, status, theme, width, expanded);
}
function renderGroupedPaths(paths: string[], theme: FindTheme): string {
@@ -5,6 +5,7 @@ import {
frameComponent,
frameTop,
frameBottomWithLabel,
frameResultWithBottomLabel,
frameToolError,
frameStatus,
resultLabel,
@@ -125,6 +126,93 @@ it("errors take precedence over partial state, and collapsed errors show five li
expect(stripTerminalSequences(frameToolError(message, true, theme, 80))).toContain("error6");
});
it("clips bodies by default but wraps all expanded text, preserving styles and the frame cap", () => {
const payload = "界🙂éabcdef".repeat(45);
const body = theme.fg("toolOutput", payload);
const compact = frameResultWithBottomLabel(body, "collapsed", "success", theme, 20);
const compactLines = compact.split("\n");
expect(compactLines).toHaveLength(2);
const compactBody = stripTerminalSequences(compactLines[0]);
expect(compactBody).toContain("…");
const explicitlyCollapsed = frameResultWithBottomLabel(
body,
"collapsed",
"success",
theme,
20,
false,
);
expect(explicitlyCollapsed).toBe(compact);
const component = frameComponent({ lastComponent: undefined }, (width) =>
frameResultWithBottomLabel(body, "expanded", "success", theme, width, true),
);
for (const width of [2, 5, 20, 80, 250, 20]) {
const lines = component.render(width);
const content = lines.slice(0, -1);
const unstyled = content.map((line) => stripTerminalSequences(line));
const restored = unstyled.map((line) => line.trimEnd()).join("");
expect(restored).toBe(payload);
const cap = Math.min(width, 210);
for (const line of lines) {
const plain = stripTerminalSequences(line);
const unpadded = plain.trimEnd();
const columns = visibleWidth(unpadded);
expect(columns).toBeLessThanOrEqual(cap);
}
for (const line of content) expect(line).toContain("\x1b[36m");
const repeated = component.render(width);
expect(repeated).toBe(lines);
}
});
it("retains narrow glyphs and marks wide glyphs that cannot fit in one column", () => {
const body = theme.fg("toolOutput", "A界é🙂Z");
const component = frameComponent({ lastComponent: undefined }, (width) =>
frameResultWithBottomLabel(body, "expanded", "success", theme, width, true),
);
const narrow = component.render(1);
const content = narrow.slice(0, -1);
const plain = content.map((line) => stripTerminalSequences(line));
const restored = plain.map((line) => line.trimEnd()).join("");
expect(restored).toBe("A…é…Z");
for (const line of narrow) {
const columns = visibleWidth(line);
expect(columns).toBeLessThanOrEqual(1);
}
});
it("preserves explicit blank lines when wrapping an expanded body", () => {
const body = "first\n\nlast\n";
const frame = frameResultWithBottomLabel(body, "expanded", "success", theme, 40, true);
const lines = frame.split("\n");
const content = lines.slice(0, -1);
const restored = content.join("\n");
expect(restored).toBe(body);
expect(lines).toHaveLength(5);
});
it("wraps expanded errors while counting hidden logical lines in compact errors", () => {
const first = "failure".repeat(40);
const message = `${first}\nsecond\nthird\nfourth\nfifth\nsixth\nseventh`;
const compact = frameToolError(message, false, theme, 80);
const compactLines = compact.split("\n");
expect(compactLines).toHaveLength(6);
const compactText = stripTerminalSequences(compact);
expect(compactText).toContain("2 hidden");
const expanded = frameToolError(message, true, theme, 40);
const expandedLines = expanded.split("\n");
const content = expandedLines.slice(0, -1);
const restored = content.map((line) => stripTerminalSequences(line)).join("");
const expected = message.replace(/\n/g, "");
expect(restored).toBe(expected);
for (const line of content) expect(line).toContain("\x1b[36m");
const footer = expandedLines.at(-1)!;
const plainFooter = stripTerminalSequences(footer);
expect(plainFooter).toContain("✗ error · expanded");
expect(plainFooter).not.toContain("hidden");
});
it("records hidden lines without adding a notice to expanded output", () => {
expect(previewLines([1, 2, 3], false, 2)).toEqual({ shown: [1, 2], hidden: 1 });
expect(previewLines([1, 2, 3], true, 2)).toEqual({ shown: [1, 2, 3], hidden: 0 });
+8 -15
View File
@@ -68,20 +68,18 @@ function renderGrepResult(
expanded,
3,
);
return frameResultWithBottomLabel(
shown.join("\n"),
resultLabel(empty ? `0 matches${limit}${truncated}` : "output", expanded, hidden, theme),
status,
theme,
width,
);
const body = shown.join("\n");
const summary = empty ? `0 matches${limit}${truncated}` : "output";
const label = resultLabel(summary, expanded, hidden, theme);
return frameResultWithBottomLabel(body, label, status, theme, width, expanded);
}
// Three matches across three files use at most eight grouped lines (headings and spacing included).
// Context and truncation notices remain available when expanded, but cannot grow the preview.
const shown = expanded ? lines : matches.slice(0, 3);
const displayText = shown.join("\n");
const rendered = renderGroupedMatches(
shown.join("\n"),
displayText,
context.args.pattern,
context.args.literal ?? false,
context.args.ignoreCase ?? false,
@@ -89,13 +87,8 @@ function renderGrepResult(
);
const hidden = expanded ? 0 : lines.length - shown.length;
const summary = `${matches.length} ${matches.length === 1 ? "match" : "matches"}${limit}${truncated}`;
return frameResultWithBottomLabel(
rendered,
resultLabel(summary, expanded, hidden, theme),
status,
theme,
width,
);
const label = resultLabel(summary, expanded, hidden, theme);
return frameResultWithBottomLabel(rendered, label, status, theme, width, expanded);
}
function renderGroupedMatches(
+1 -1
View File
@@ -91,7 +91,7 @@ function renderLsResult(
const truncated = truncation?.truncated ? " · truncated" : "";
const summary = `${lines.length} entries${limit}${truncated}`;
const label = resultLabel(summary, expanded, hidden, theme);
return frameResultWithBottomLabel(body, label, status, theme, width);
return frameResultWithBottomLabel(body, label, status, theme, width, expanded);
}
function renderEntries(lines: string[], theme: LsTheme): string {
+6 -15
View File
@@ -63,13 +63,8 @@ function renderReadResult(
if (context.isError) return frameToolError(textFromResult(result), expanded, theme, width);
if (result.content.some((item) => item.type === "image")) {
return frameResultWithBottomLabel(
"",
resultLabel("image", expanded, 0, theme),
status,
theme,
width,
);
const label = resultLabel("image", expanded, 0, theme);
return frameResultWithBottomLabel("", label, status, theme, width, expanded);
}
const rawText = normalizeLineEndings(textFromResult(result));
const text = rawText.replace(/\n$/, "");
@@ -77,14 +72,10 @@ function renderReadResult(
const { shown, hidden } = previewLines(lines, expanded, 10);
const truncation = result.details?.truncation ? " · truncated" : "";
const summary = `${lines.length} lines${truncation}`;
const body = renderNumberedLines(shown, positiveInteger(context.args.offset) ?? 1, theme);
return frameResultWithBottomLabel(
body,
resultLabel(summary, expanded, hidden, theme),
status,
theme,
width,
);
const offset = positiveInteger(context.args.offset) ?? 1;
const body = renderNumberedLines(shown, offset, theme);
const label = resultLabel(summary, expanded, hidden, theme);
return frameResultWithBottomLabel(body, label, status, theme, width, expanded);
}
function renderNumberedLines(lines: string[], offset: number, theme: ReadTheme): string {
+3 -7
View File
@@ -63,11 +63,7 @@ function renderWriteResult(
return `${theme.fg("dim", number)} ${theme.fg("borderMuted", "│")} ${line}`;
})
.join("\n");
return frameResultWithBottomLabel(
body,
resultLabel(`${lines.length} lines`, expanded, hidden, theme),
status,
theme,
width,
);
const summary = `${lines.length} lines`;
const label = resultLabel(summary, expanded, hidden, theme);
return frameResultWithBottomLabel(body, label, status, theme, width, expanded);
}
+4 -5
View File
@@ -33,11 +33,10 @@ export function buildSearchRendering(): Required<ToolRenderers> {
const text = resultText(result.content);
const queryLines = queryBlock(queryText(context.args), theme, width);
if (context.isError || metadata?.status === "error") {
const message = options.expanded ? wrapTextWithAnsi(text, width).join("\n") : text;
return [
...queryLines,
frameToolError(message, options.expanded, theme, width, { showHidden: false }),
].join("\n");
const error = frameToolError(text, options.expanded, theme, width, {
showHidden: false,
});
return [...queryLines, error].join("\n");
}
const partial = options.isPartial || context.isPartial;
+23 -17
View File
@@ -1,5 +1,11 @@
import type { Theme, ThemeColor } from "@earendil-works/pi-coding-agent";
import { Text, truncateToWidth, visibleWidth, type Component } from "@earendil-works/pi-tui";
import {
Text,
truncateToWidth,
visibleWidth,
wrapTextWithAnsi,
type Component,
} from "@earendil-works/pi-tui";
export class FramedText extends Text {
private painter: (width: number) => string = () => "";
@@ -98,15 +104,18 @@ export function frameBottomWithLabel(
return `${border("─".repeat(fillWidth))} ${fittedLabel} ${border("─".repeat(trailingBorderWidth))}`;
}
/** Clip compact bodies; wrap expanded bodies without changing the summary label. */
export function frameResultWithBottomLabel(
body: string,
label: string,
status: FrameStatus,
theme: Theme,
width: number,
expanded = false,
): string {
const lines = body ? frameBody(body, width) : [];
return [...lines, frameBottomWithLabel(label, status, theme, width)].join("\n");
const lines = body ? frameBody(body, width, expanded) : [];
const footer = frameBottomWithLabel(label, status, theme, width);
return [...lines, footer].join("\n");
}
/** Keep presentation state in the frame, separate from tool output. */
@@ -133,18 +142,12 @@ export function frameToolError(
const lines = (message || "Error").replace(/\r?\n$/, "").split("\n");
const shown = expanded ? lines : lines.slice(0, 5);
const hidden = lines.length - shown.length;
return frameResultWithBottomLabel(
theme.fg("error", shown.join("\n")),
resultLabel(
theme.fg("error", "✗ error"),
expanded,
options.showHidden === false ? 0 : hidden,
theme,
),
"error",
theme,
width,
);
const messageBody = shown.join("\n");
const body = theme.fg("error", messageBody);
const summary = theme.fg("error", "✗ error");
const hiddenCount = options.showHidden === false ? 0 : hidden;
const label = resultLabel(summary, expanded, hiddenCount, theme);
return frameResultWithBottomLabel(body, label, "error", theme, width, expanded);
}
export function formatDuration(ms: number): string {
@@ -158,8 +161,11 @@ export function formatDuration(ms: number): string {
return minutes > 0 ? `${hours}h${minutes}m` : `${hours}h`;
}
function frameBody(body: string, width: number): string[] {
return body.split("\n").map((line) => truncateToWidth(line, Math.max(1, width), "…"));
function frameBody(body: string, width: number, expanded: boolean): string[] {
const safeWidth = Math.max(1, width);
const lines = expanded ? wrapTextWithAnsi(body, safeWidth) : body.split("\n");
// A wide grapheme cannot fit in one column, even after native wrapping.
return lines.map((line) => truncateToWidth(line, safeWidth, "…"));
}
function borderPainter(status: FrameStatus, theme: Theme): (text: string) => string {