diff --git a/.changeset/tool-rendering-output-padding.md b/.changeset/tool-rendering-output-padding.md new file mode 100644 index 0000000..f8a9a48 --- /dev/null +++ b/.changeset/tool-rendering-output-padding.md @@ -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. diff --git a/extensions/tool-rendering/README.md b/extensions/tool-rendering/README.md index c79697e..fbd29ed 100644 --- a/extensions/tool-rendering/README.md +++ b/extensions/tool-rendering/README.md @@ -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: diff --git a/extensions/tool-rendering/src/frame.test.ts b/extensions/tool-rendering/src/frame.test.ts index 1c14e23..ed8d11c 100644 --- a/extensions/tool-rendering/src/frame.test.ts +++ b/extensions/tool-rendering/src/frame.test.ts @@ -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็•Œeฬ๐Ÿ™‚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โ€ฆeฬโ€ฆ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); diff --git a/extensions/tool-rendering/src/sdk.test.ts b/extensions/tool-rendering/src/sdk.test.ts index b9e10e8..18cbef4 100644 --- a/extensions/tool-rendering/src/sdk.test.ts +++ b/extensions/tool-rendering/src/sdk.test.ts @@ -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("โœ—"); + } +} diff --git a/extensions/web-fetch/src/render.test.ts b/extensions/web-fetch/src/render.test.ts index 60c59a7..e568606 100644 --- a/extensions/web-fetch/src/render.test.ts +++ b/extensions/web-fetch/src/render.test.ts @@ -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 = "ๆจกๅž‹๐Ÿ™‚eฬ".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); diff --git a/extensions/web-search-codex/src/render.test.ts b/extensions/web-search-codex/src/render.test.ts index 5b1704d..97e1efe 100644 --- a/extensions/web-search-codex/src/render.test.ts +++ b/extensions/web-search-codex/src/render.test.ts @@ -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 = "ๆจกๅž‹๐Ÿ™‚eฬ".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()); diff --git a/packages/internal/README.md b/packages/internal/README.md index bfac3d2..13a4591 100644 --- a/packages/internal/README.md +++ b/packages/internal/README.md @@ -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. diff --git a/packages/internal/src/frame.ts b/packages/internal/src/frame.ts index f72f705..2ba857d 100644 --- a/packages/internal/src/frame.ts +++ b/packages/internal/src/frame.ts @@ -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; }