fix(tool-rendering): wrap expanded result bodies #17
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.
|
||||
@@ -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
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user