From bcdcf314616562092375e7d7db372e9ab62e6593 Mon Sep 17 00:00:00 2001 From: eleith Date: Thu, 5 Nov 2026 11:29:12 -0800 Subject: [PATCH] fix(tool-rendering): wrap expanded result bodies --- .../tool-rendering-expanded-wrapping.md | 6 + extensions/tool-rendering/README.md | 4 + extensions/tool-rendering/src/bash.test.ts | 55 +++++- extensions/tool-rendering/src/bash.ts | 2 +- extensions/tool-rendering/src/edit.ts | 12 +- .../tool-rendering/src/file-renderers.test.ts | 156 ++++++++++++++++-- extensions/tool-rendering/src/find.ts | 2 +- extensions/tool-rendering/src/frame.test.ts | 88 ++++++++++ extensions/tool-rendering/src/grep.ts | 23 +-- extensions/tool-rendering/src/ls.ts | 2 +- extensions/tool-rendering/src/read.ts | 21 +-- extensions/tool-rendering/src/write.ts | 10 +- extensions/web-search-codex/src/render.ts | 9 +- packages/internal/src/frame.ts | 40 +++-- 14 files changed, 349 insertions(+), 81 deletions(-) create mode 100644 .changeset/tool-rendering-expanded-wrapping.md diff --git a/.changeset/tool-rendering-expanded-wrapping.md b/.changeset/tool-rendering-expanded-wrapping.md new file mode 100644 index 0000000..204e4a7 --- /dev/null +++ b/.changeset/tool-rendering-expanded-wrapping.md @@ -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. diff --git a/extensions/tool-rendering/README.md b/extensions/tool-rendering/README.md index 6a00bf7..c79697e 100644 --- a/extensions/tool-rendering/README.md +++ b/extensions/tool-rendering/README.md @@ -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 diff --git a/extensions/tool-rendering/src/bash.test.ts b/extensions/tool-rendering/src/bash.test.ts index 16eec1a..95f1879 100644 --- a/extensions/tool-rendering/src/bash.test.ts +++ b/extensions/tool-rendering/src/bash.test.ts @@ -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) => { diff --git a/extensions/tool-rendering/src/bash.ts b/extensions/tool-rendering/src/bash.ts index 94aaa04..beec54a 100644 --- a/extensions/tool-rendering/src/bash.ts +++ b/extensions/tool-rendering/src/bash.ts @@ -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 { diff --git a/extensions/tool-rendering/src/edit.ts b/extensions/tool-rendering/src/edit.ts index bc3b510..d4a74e7 100644 --- a/extensions/tool-rendering/src/edit.ts +++ b/extensions/tool-rendering/src/edit.ts @@ -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 { diff --git a/extensions/tool-rendering/src/file-renderers.test.ts b/extensions/tool-rendering/src/file-renderers.test.ts index d221d5c..8a4f37d 100644 --- a/extensions/tool-rendering/src/file-renderers.test.ts +++ b/extensions/tool-rendering/src/file-renderers.test.ts @@ -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 = { + 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 = { + 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 = { + 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", () => { diff --git a/extensions/tool-rendering/src/find.ts b/extensions/tool-rendering/src/find.ts index cfc57c2..8f27466 100644 --- a/extensions/tool-rendering/src/find.ts +++ b/extensions/tool-rendering/src/find.ts @@ -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 { diff --git a/extensions/tool-rendering/src/frame.test.ts b/extensions/tool-rendering/src/frame.test.ts index 0e4c422..1c14e23 100644 --- a/extensions/tool-rendering/src/frame.test.ts +++ b/extensions/tool-rendering/src/frame.test.ts @@ -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 }); diff --git a/extensions/tool-rendering/src/grep.ts b/extensions/tool-rendering/src/grep.ts index e8af09a..f7d9e83 100644 --- a/extensions/tool-rendering/src/grep.ts +++ b/extensions/tool-rendering/src/grep.ts @@ -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( diff --git a/extensions/tool-rendering/src/ls.ts b/extensions/tool-rendering/src/ls.ts index 7c370b1..5dbb413 100644 --- a/extensions/tool-rendering/src/ls.ts +++ b/extensions/tool-rendering/src/ls.ts @@ -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 { diff --git a/extensions/tool-rendering/src/read.ts b/extensions/tool-rendering/src/read.ts index 6616b2b..f4317c0 100644 --- a/extensions/tool-rendering/src/read.ts +++ b/extensions/tool-rendering/src/read.ts @@ -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 { diff --git a/extensions/tool-rendering/src/write.ts b/extensions/tool-rendering/src/write.ts index f843528..768b37b 100644 --- a/extensions/tool-rendering/src/write.ts +++ b/extensions/tool-rendering/src/write.ts @@ -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); } diff --git a/extensions/web-search-codex/src/render.ts b/extensions/web-search-codex/src/render.ts index c3ecb3d..6950176 100644 --- a/extensions/web-search-codex/src/render.ts +++ b/extensions/web-search-codex/src/render.ts @@ -33,11 +33,10 @@ export function buildSearchRendering(): Required { 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; diff --git a/packages/internal/src/frame.ts b/packages/internal/src/frame.ts index f49486b..f72f705 100644 --- a/packages/internal/src/frame.ts +++ b/packages/internal/src/frame.ts @@ -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 { -- 2.54.0