fix(tool-rendering): honor native output padding #18

Merged
eleith merged 2 commits from fix/tool-rendering-output-padding into main 2026-11-05 20:22:11 +00:00
8 changed files with 269 additions and 21 deletions

No files matched your search

@@ -0,0 +1,7 @@
---
"@eleith-pi/tool-rendering": patch
"@eleith-pi/web-fetch": patch
"@eleith-pi/web-search-codex": patch
---
Honor Pi's `outputPad` setting in shared tool frames using native text padding. Headers, bodies, and footers now align with the configured margins, and wrapping uses the remaining content width. Preserve the 210-column content cap and safely reduce padding in narrow terminals, including the clipping-marker fallback for wide glyphs in one-column content areas.
-17
View File
@@ -18,23 +18,6 @@ make a tool call and be impressed.
use `/eleith:tool-rendering toggle` to be unimpressed.
requires Pi 1.0.4 or newer.
`show` and `hide` immediately change the preference for future tool rows, even during a run.
existing rows keep their style; reconstructed history uses the current preference.
hiding delegates to Pi's existing renderer chain, including other extensions.
only rendering is registered: configured tools, execution, active tools, schemas,
and model-facing results remain unchanged. old sessions' display details still work.
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
update `~/.pi/agent/extensions/eleith.json` with the following:
@@ -46,6 +46,73 @@ it("uses actual component width again after resize, with a 210-column cap", () =
expect(stripTerminalSequences(component.render(80)[0])).toContain("write");
});
it.each([0, 1])("honors outputPad=%s within the native content width", (outputPad) => {
const payload = "A界é🙂Z";
const body = theme.fg("toolOutput", payload);
const painter = vi.fn((width: number) => {
const title = frameTop("padding", "success", theme, width);
const result = frameResultWithBottomLabel(body, "expanded", "success", theme, width, true);
return `${title}\n${result}`;
});
const component = frameComponent({ lastComponent: undefined, outputPad }, painter);
for (const width of [1, 2, 3, 4, 5, 20, 80, 250, 3, 80]) {
const lines = component.render(width);
const padding = width < 3 ? 0 : outputPad;
const contentWidth = width - padding * 2;
const expectedWidth = Math.min(contentWidth, 210);
const paintedWidth = painter.mock.lastCall?.[0];
expect(paintedWidth).toBe(expectedWidth);
const plain = lines.map((line) => stripTerminalSequences(line));
const prefix = " ".repeat(padding);
for (const border of [plain[0], plain.at(-1)!]) {
const margin = border.slice(0, padding);
expect(margin).toBe(prefix);
const content = border.slice(padding).trimEnd();
const columns = visibleWidth(content);
expect(columns).toBe(expectedWidth);
}
const content = plain.slice(1, -1);
const unpadded = content.map((line) => line.slice(padding).trimEnd());
const restored = unpadded.join("");
const expected = contentWidth === 1 ? "A…é…Z" : payload;
expect(restored).toBe(expected);
for (const line of lines) {
const columns = visibleWidth(line);
expect(columns).toBeLessThanOrEqual(width);
if (padding) {
const unstyled = stripTerminalSequences(line);
const left = unstyled[0];
const right = unstyled.at(-1);
expect(left).toBe(" ");
expect(right).toBe(" ");
}
}
const repeated = component.render(width);
expect(repeated).toBe(lines);
}
});
it.each([80, 250])("updates cached padding at width %s, including capped painters", (width) => {
const painter = vi.fn((paintWidth: number) => frameTop("padding", "success", theme, paintWidth));
const component = frameComponent({ lastComponent: undefined, outputPad: 0 }, painter);
const original = component.render(width);
component.setPaddingX(1);
const padded = component.render(width);
const first = stripTerminalSequences(padded[0]);
expect(first).toMatch(/^ ─/);
const unpaddedWidth = Math.min(width, 210);
const paddedWidth = Math.min(width - 2, 210);
const widths = painter.mock.calls.map(([paintWidth]) => paintWidth);
expect(widths).toEqual([unpaddedWidth, paddedWidth]);
component.setPaddingX(1);
const repeated = component.render(width);
expect(repeated).toBe(padded);
const reused = frameComponent({ lastComponent: component, outputPad: 0 }, painter);
expect(reused).toBe(component);
const restored = reused.render(width);
expect(restored).toEqual(original);
});
it("reuses painting and Text layout on unchanged transcript redraws", () => {
const painter = vi.fn((width: number) => frameTop("read", "success", theme, width));
const component = frameComponent({ lastComponent: undefined }, painter);
+76 -1
View File
@@ -10,8 +10,9 @@ import {
ToolExecutionComponent,
initTheme,
type ExtensionUIContext,
type ToolRenderers,
} from "@earendil-works/pi-coding-agent";
import { visibleWidth } from "@earendil-works/pi-tui";
import { stripTerminalSequences, visibleWidth } from "@earendil-works/pi-tui";
import toolRendering from "./index.ts";
const directory = await vi.hoisted(async () => {
@@ -171,6 +172,8 @@ it("uses the resolver chain and keeps streamed rows stable across toggles", asyn
expect(readRow.render(80).join("\n")).not.toBe(dark);
initTheme("dark", false);
checkOutputPadding(resolve, requestRender);
vi.useFakeTimers();
vi.setSystemTime(1000);
const definition = resolve("bash");
@@ -285,3 +288,75 @@ it("uses the resolver chain and keeps streamed rows stable across toggles", asyn
session.dispose();
}
});
function checkOutputPadding(
resolve: (name: string) => ToolRenderers | undefined,
requestRender: () => void,
): void {
const paddingCases = [
{ name: "bash", args: { command: "printf '界🙂A'" } },
{ name: "read", args: { path: "padding.txt" } },
{ name: "grep", args: { pattern: "padding" } },
{ name: "ls", args: {} },
{ name: "find", args: { pattern: "*" } },
{ name: "write", args: { path: "padding.txt", content: "padding-body" } },
{ name: "edit", args: { path: "padding.txt", edits: [{ oldText: "old", newText: "new" }] } },
];
const paddingResult = {
content: [{ type: "text" as const, text: "padding-body" }],
details: undefined,
isError: false,
};
for (const { name, args } of paddingCases) {
const definition = resolve(name);
const paddedRow = new ToolExecutionComponent(
name,
`padding-${name}`,
args,
{},
definition,
{ requestRender } as never,
directory,
);
paddedRow.updateResult(paddingResult);
paddedRow.setExpanded(true);
const initial = paddedRow.render(80);
const initialHeader = stripTerminalSequences(initial[1]);
expect(initialHeader, name).toMatch(/^ ─/);
for (const outputPad of [1, 0, 1]) {
paddedRow.setOutputPad(outputPad);
const rendered = paddedRow.render(80);
const plain = rendered.map((line) => stripTerminalSequences(line));
const expectedPrefix = " ".repeat(outputPad) + "─";
const headerPrefix = plain[1].slice(0, outputPad + 1);
const footerPrefix = plain.at(-1)!.slice(0, outputPad + 1);
expect(headerPrefix, name).toBe(expectedPrefix);
expect(footerPrefix, name).toBe(expectedPrefix);
const body = plain.find((line) => line.includes("padding-body"));
expect(body, name).toBeDefined();
if (outputPad) {
for (const line of plain) {
if (!line.trim()) continue;
const left = line[0];
const right = line.at(-1);
expect(left, name).toBe(" ");
expect(right, name).toBe(" ");
}
}
for (const width of [1, 2, 3, 5, 80, 250]) {
const resized = paddedRow.render(width);
for (const line of resized) {
const columns = visibleWidth(line);
expect(columns, name).toBeLessThanOrEqual(width);
}
}
}
const failed = { ...paddingResult, isError: true };
paddedRow.updateResult(failed);
const errorLines = paddedRow.render(80);
const lastErrorLine = errorLines.at(-1)!;
const errorFooter = stripTerminalSequences(lastErrorLine);
expect(errorFooter, name).toMatch(/^ ─/);
expect(errorFooter, name).toContain("✗");
}
}
+54
View File
@@ -59,6 +59,60 @@ function plain(component: Component, width = 180): string {
.map((line) => stripTerminalSequences(line).trimEnd())
.join("\n");
}
it.each(["web_fetch", "web_fetch_batch"] as const)(
"applies native padding to %s headers, previews, and expanded content",
(name) => {
const subject = buildFetchRendering(name);
const unicode = "模型🙂é".repeat(12);
const page = outcome({ content: unicode });
const value =
name === "web_fetch"
? page
: {
kind: "batch" as const,
total: 1,
succeeded: 1,
failed: 0,
cancelled: false,
truncated: false,
items: [page],
};
const data = presentResult(value);
const original = structuredClone(data);
const url = "https://example.test/article";
const args = name === "web_fetch" ? { url } : { requests: [{ url }] };
for (const expanded of [false, true]) {
const viewOptions = { ...options, expanded };
const flushContext = context({ args, expanded, outputPad: 0 });
const insetContext = context({ args, expanded, outputPad: 1 });
const flushCall = subject.renderCall(args, theme, flushContext);
const insetCall = subject.renderCall(args, theme, insetContext);
const flushResult = subject.renderResult(data, viewOptions, theme, flushContext);
const insetResult = subject.renderResult(data, viewOptions, theme, insetContext);
const pairs = [
{ flush: flushCall, inset: insetCall },
{ flush: flushResult, inset: insetResult },
];
for (const pair of pairs) {
for (const width of [1, 2, 3, 5, 40, 80, 250]) {
const padding = width < 3 ? 0 : 1;
const contentWidth = width - padding * 2;
const flushLines = pair.flush.render(contentWidth);
const insetLines = pair.inset.render(width);
const margin = " ".repeat(padding);
const expected = flushLines.map((line) => `${margin}${line}${margin}`);
expect(insetLines).toEqual(expected);
for (const line of insetLines) {
const columns = visibleWidth(line);
expect(columns).toBeLessThanOrEqual(width);
}
}
}
}
expect(data).toEqual(original);
},
);
it("owns shared framed chrome, caches redraws and reuses call/result components", () => {
const call = renderer.renderCall({ url: "https://example.test" }, theme, context());
expect(call).toBeInstanceOf(FramedText);
@@ -59,6 +59,47 @@ function plain(component: Component, width = 180) {
.join("\n");
}
it.each([false, true])(
"applies native padding to search calls and results (error=%s)",
(isError) => {
const args = { query: "padding query 界🙂" };
const unicode = "模型🙂é".repeat(12);
const status = isError ? "error" : "done";
const metadata = details({ status });
const data = result(unicode, { details: metadata });
const original = structuredClone(data);
for (const expanded of [false, true]) {
const viewOptions = { ...options, expanded };
const flushContext = context({ args, expanded, isError, outputPad: 0 });
const insetContext = context({ args, expanded, isError, outputPad: 1 });
const flushCall = renderer.renderCall(args, theme, flushContext);
const insetCall = renderer.renderCall(args, theme, insetContext);
const flushResult = renderer.renderResult(data, viewOptions, theme, flushContext);
const insetResult = renderer.renderResult(data, viewOptions, theme, insetContext);
const pairs = [
{ flush: flushCall, inset: insetCall },
{ flush: flushResult, inset: insetResult },
];
for (const pair of pairs) {
for (const width of [1, 2, 3, 5, 40, 80, 250]) {
const padding = width < 3 ? 0 : 1;
const contentWidth = width - padding * 2;
const flushLines = pair.flush.render(contentWidth);
const insetLines = pair.inset.render(width);
const margin = " ".repeat(padding);
const expected = flushLines.map((line) => `${margin}${line}${margin}`);
expect(insetLines).toEqual(expected);
for (const line of insetLines) {
const columns = visibleWidth(line);
expect(columns).toBeLessThanOrEqual(width);
}
}
}
}
expect(data).toEqual(original);
},
);
it("defaults result details only when the override is omitted", () => {
expect(result().details).toEqual(details());
expect(result("", {}).details).toEqual(details());
+3 -1
View File
@@ -13,4 +13,6 @@ settings object with defaults.
## frames
`frame` provides shared tool-rendering components.
`frame` provides shared tool-rendering components. `frameComponent()` applies
`context.outputPad` through native `Text` padding. Its painter receives the available
content width after padding, capped at 210 columns.
+21 -2
View File
@@ -11,6 +11,7 @@ export class FramedText extends Text {
private painter: (width: number) => string = () => "";
private paintedWidth: number | undefined;
private paintedText: string | undefined;
private outputPad = 0;
constructor() {
super("", 0, 0);
@@ -26,12 +27,28 @@ export class FramedText extends Text {
super.invalidate();
}
override setPaddingX(padding: number): void {
if (padding === this.outputPad) return;
this.outputPad = padding;
super.setPaddingX(padding);
}
override render(width: number): string[] {
const paintWidth = Math.max(1, Math.min(width, 210));
// Match Text's narrow-terminal clamp so the painter sees its actual content width.
const maxPadding = Math.floor((width - 1) / 2);
const availablePadding = Math.max(0, maxPadding);
const padding = Math.min(this.outputPad, availablePadding);
const contentWidth = Math.max(1, width - padding * 2);
const paintWidth = Math.min(contentWidth, 210);
if (this.paintedWidth !== paintWidth) {
let text: string;
try {
text = this.painter(paintWidth);
// Native Text cannot fit wide glyphs in a one-column content area.
if (paintWidth === 1) {
const narrowLines = frameBody(text, paintWidth, true);
text = narrowLines.join("\n");
}
} catch {
// Deferred painting runs outside Pi's guarded renderer callbacks.
text = "Tool output could not be rendered.";
@@ -48,11 +65,13 @@ export class FramedText extends Text {
}
export function frameComponent(
context: { lastComponent: Component | undefined },
context: { lastComponent: Component | undefined; outputPad?: number },
painter: (width: number) => string,
): FramedText {
const component =
context.lastComponent instanceof FramedText ? context.lastComponent : new FramedText();
const outputPad = context.outputPad ?? 0;
component.setPaddingX(outputPad);
component.setPainter(painter);
return component;
}