fix(tool-rendering): honor native output padding #18
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.
|
||||
@@ -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);
|
||||
|
||||
@@ -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("✗");
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user