fix(tool-rendering): separate listing notices from filenames #16

Merged
eleith merged 1 commits from fix/tool-rendering-listing-notices into main 2026-11-05 18:42:00 +00:00
7 changed files with 313 additions and 38 deletions

No files matched your search

@@ -0,0 +1,5 @@
---
"@eleith-pi/tool-rendering": patch
---
Separate native ls/find limit and byte-truncation notices from filenames when rendering. Keep actionable notices visible as warnings, exclude them from entry/file and hidden counts, and mark byte-truncated results in the summary. Execution, stored results, and legacy display overrides remain unchanged.
@@ -1,5 +1,15 @@
import { rmSync } from "node:fs";
import { truncateHead, type Theme, type ToolRenderers } from "@earendil-works/pi-coding-agent";
import {
createLsTool,
createFindTool,
DEFAULT_MAX_BYTES,
truncateHead,
type AgentToolResult,
type LsToolDetails,
type FindToolDetails,
type Theme,
type ToolRenderers,
} from "@earendil-works/pi-coding-agent";
import { visibleWidth, type Component } from "@earendil-works/pi-tui";
import { afterAll, expect, it, vi } from "vitest";
import { buildReadRendering } from "./read.ts";
@@ -544,3 +554,176 @@ it("keeps empty searches and directories distinct from errors", () => {
),
).toContain("0 entries");
});
const nativeListingCases = (["ls", "find"] as const).flatMap((name) => [
{ name, limit: 2, limitReached: true, truncated: false, mode: "many" },
{ name, limit: 1000, limitReached: false, truncated: true, mode: "many" },
{ name, limit: 300, limitReached: true, truncated: true, mode: "many" },
{ name, limit: 1000, limitReached: false, truncated: true, mode: "oversized" },
{ name, limit: 1, limitReached: true, truncated: false, mode: "sentinel" },
{ name, limit: 1, limitReached: true, truncated: false, mode: "unterminated" },
]);
it.each(nativeListingCases)(
"separates native $name notices (limit=$limit, mode=$mode) in both views",
async ({ name, limit, limitReached, truncated, mode }) => {
const sentinel = name === "ls" ? "(empty directory)" : "No files found matching pattern";
let entries: string[];
if (mode === "sentinel") entries = [sentinel, "z"];
else if (mode === "unterminated") entries = ["a\x1b]unfinished", "z"];
else if (mode === "oversized") entries = ["x".repeat(DEFAULT_MAX_BYTES + 1)];
else {
const suffix = "x".repeat(200);
entries = Array.from({ length: 400 }, (_, index) => `entry-${index}-${suffix}`);
}
const native =
name === "ls"
? createLsTool(directory, {
operations: {
exists: () => true,
stat: (path) => ({ isDirectory: () => path === directory }),
readdir: () => [...entries],
},
})
: createFindTool(directory, {
operations: {
exists: () => true,
glob: (_pattern, _cwd, options) => entries.slice(0, options.limit),
},
});
const args = { path: directory, pattern: "*", limit };
const result: AgentToolResult<LsToolDetails | FindToolDetails | undefined> =
await native.execute("native-listing", args);
const original = structuredClone(result);
const nativeContent = result.content[0];
if (nativeContent.type !== "text") throw new Error("Expected native listing text");
const noticeStart = nativeContent.text.lastIndexOf("\n\n[");
expect(noticeStart).toBeGreaterThanOrEqual(0);
const expectedNotice = nativeContent.text.slice(noticeStart + 2);
const limitKey = name === "ls" ? "entryLimitReached" : "resultLimitReached";
const hasLimit = result.details !== undefined && limitKey in result.details;
expect(hasLimit).toBe(limitReached);
const isTruncated = result.details?.truncation?.truncated ?? false;
expect(isTruncated).toBe(truncated);
const count = result.details?.truncation?.outputLines ?? limit;
const unit = name === "ls" ? "entries" : "files";
const limitLabel = limitReached ? " · limit reached" : "";
const truncatedLabel = truncated ? " · truncated" : "";
const summary = `${count} ${unit}${limitLabel}${truncatedLabel}`;
const captured = capture(name);
const tool = erased(captured.tool);
const fg = vi.fn(minimalTheme.fg);
const coloredTheme = { ...minimalTheme, fg } as unknown as Theme;
for (const expanded of [false, true]) {
fg.mockClear();
const renderOptions = { ...options, expanded };
const ctx = context(args, { expanded });
const component = tool.renderResult!(result, renderOptions, coloredTheme, ctx);
const output = text(component);
expect(output).toContain(summary);
expect(output).not.toMatch(/(?:├──|└──) \[/);
const warnings = fg.mock.calls.filter(([color]) => color === "warning");
expect(warnings).toEqual([["warning", expectedNotice]]);
const outputLines = output.split("\n");
const noticeIndex = outputLines.indexOf(expectedNotice);
expect(noticeIndex).toBe(outputLines.length - 2);
const footer = outputLines.at(-1);
expect(footer).toContain(summary);
if (mode === "sentinel") expect(output).toContain(`└── ${sentinel}`);
if (mode === "unterminated") {
expect(output).toContain("└── a");
expect(output).not.toContain("\x1b");
}
const previewSize = name === "ls" ? 10 : 3;
const hidden = expanded ? 0 : Math.max(0, count - previewSize);
if (hidden > 0) expect(output).toContain(`collapsed · ${hidden} hidden`);
else expect(output).not.toContain("hidden");
if (!expanded) {
const renderedLines = component.render(180);
const maxLines = name === "ls" ? 13 : 11;
expect(renderedLines.length).toBeLessThanOrEqual(maxLines);
}
expect(result).toEqual(original);
}
},
);
it("keeps the fd find suggestion outside directory groups", () => {
const { tool } = capture("find");
const notice = "[2 results limit reached. Use limit=4 for more, or refine pattern]";
const result = {
content: [{ type: "text" as const, text: `src/a.ts\nsrc/b.ts\n\n${notice}` }],
details: { resultLimitReached: 2 },
};
const ctx = context(presets.find.args);
const component = tool.renderResult!(result, options, theme, ctx);
const output = text(component);
expect(output).toContain(`src/\n ├── a.ts\n └── b.ts\n\n${notice}\n`);
expect(output).toContain("2 files · limit reached · collapsed");
expect(output).not.toContain("hidden");
});
it.each(["ls", "find"] as const)(
"preserves unrecognized %s bracketed filenames even with limit metadata",
(name) => {
const captured = capture(name);
const tool = erased(captured.tool);
const result = {
content: [{ type: "text" as const, text: "first\nsecond\n\n[not a native notice]" }],
details: { entryLimitReached: 2, resultLimitReached: 2 },
};
const ctx = context(presets[name].args);
const component = tool.renderResult!(result, options, theme, ctx);
const output = text(component);
const unit = name === "ls" ? "entries" : "files";
expect(output).toContain("└── [not a native notice]");
expect(output).toContain(`3 ${unit} · limit reached · collapsed`);
},
);
it.each(["ls", "find"] as const)(
"does not strip native-looking %s filenames when truncation metadata is false",
(name) => {
const captured = capture(name);
const tool = erased(captured.tool);
const rawText = "first\n\n[50.0KB limit reached]";
const truncation = truncateHead(rawText);
const result = {
content: [{ type: "text" as const, text: rawText }],
details: { truncation },
};
const ctx = context(presets[name].args);
const component = tool.renderResult!(result, options, theme, ctx);
const output = text(component);
const unit = name === "ls" ? "entries" : "files";
expect(output).toContain("└── [50.0KB limit reached]");
expect(output).toContain(`2 ${unit} · collapsed`);
expect(output).not.toContain("truncated");
},
);
it.each(["ls", "find"] as const)(
"keeps legacy %s display overrides authoritative when byte metadata is present",
(name) => {
const captured = capture(name);
const tool = erased(captured.tool);
const truncation = truncateHead("model-first\nmodel-second", { maxBytes: 12 });
const result = {
content: [{ type: "text" as const, text: "model-facing text" }],
details: {
truncation,
rendering: { text: "\x1b[31msrc/old.ts\x1b[0m\nsrc/next.ts" },
},
};
const original = structuredClone(result);
const ctx = context(presets[name].args);
const component = tool.renderResult!(result, options, theme, ctx);
const output = text(component);
const unit = name === "ls" ? "entries" : "files";
expect(output).toContain("old.ts");
expect(output).toContain("next.ts");
expect(output).toContain(`2 ${unit} · truncated · collapsed`);
expect(output).not.toContain("model-");
expect(output).not.toContain("\x1b");
expect(result).toEqual(original);
},
);
+34 -17
View File
@@ -13,8 +13,14 @@ import {
frameTop,
resultLabel,
} from "./frame.ts";
import { normalizeLineEndings, sanitizeDisplayText, textFromResult } from "./tool-result.ts";
import {
normalizeLineEndings,
rawTextFromResult,
sanitizeDisplayText,
textFromResult,
} from "./tool-result.ts";
import { previewLines } from "./preview.ts";
import { splitListingNotice } from "./listing-output.ts";
type BuiltinFindTool = ReturnType<typeof createFindToolDefinition>;
type FindRenderCall = NonNullable<BuiltinFindTool["renderCall"]>;
@@ -62,24 +68,35 @@ function renderFindResult(
const status = frameStatus(context);
if (context.isError) return frameToolError(textFromResult(result), expanded, theme, width);
const rawText = result.details?.rendering?.text ?? textFromResult(result);
const sanitizedText = sanitizeDisplayText(rawText);
const text = normalizeLineEndings(sanitizedText);
const paths =
text.trim() === "No files found matching pattern"
? []
: text.split("\n").filter((line) => line.trim().length > 0);
const rawText = result.details?.rendering?.text ?? rawTextFromResult(result);
const normalizedText = normalizeLineEndings(rawText);
const resultLimit = result.details?.resultLimitReached;
const truncation = result.details?.truncation;
const { text: listingText, notice: listingNotice } = splitListingNotice(
normalizedText,
"find",
resultLimit,
truncation,
);
const text = sanitizeDisplayText(listingText);
const notice = sanitizeDisplayText(listingNotice);
const empty =
text.trim() === "No files found matching pattern" &&
resultLimit === undefined &&
!truncation?.truncated;
const paths = empty ? [] : text.split("\n").filter((line) => line.trim().length > 0);
// Three paths in separate directories use at most eight grouped lines.
const { shown, hidden } = previewLines(paths, expanded, 3);
const body = renderGroupedPaths(shown, theme);
const limit = result.details?.resultLimitReached ? " · limit reached" : "";
return frameResultWithBottomLabel(
body,
resultLabel(`${paths.length} files${limit}`, expanded, hidden, theme),
status,
theme,
width,
);
let body = renderGroupedPaths(shown, theme);
if (notice) {
const warning = theme.fg("warning", notice);
body = body ? `${body}\n\n${warning}` : warning;
}
const limit = resultLimit === undefined ? "" : " · limit reached";
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);
}
function renderGroupedPaths(paths: string[], theme: FindTheme): string {
@@ -0,0 +1,48 @@
import {
DEFAULT_MAX_BYTES,
formatSize,
type TruncationResult,
} from "@earendil-works/pi-coding-agent";
/**
* Separate exact metadata-backed native suffixes, leaving other bracketed text alone.
* Call before sanitation so malformed filename controls cannot swallow Pi's notice.
*/
export function splitListingNotice(
text: string,
tool: "ls" | "find",
limitReached: number | undefined,
truncation: TruncationResult | undefined,
): { text: string; notice: string } {
let limitNotices = [""];
if (limitReached !== undefined) {
const unit = tool === "ls" ? "entries" : "results";
const limitNotice = `${limitReached} ${unit} limit reached`;
const nextLimit = limitReached * 2;
let suggestion = `Use limit=${nextLimit} for more`;
if (tool === "find") suggestion += ", or refine pattern";
limitNotices = [`${limitNotice}. ${suggestion}`];
// Custom FindOperations omit the follow-up suggestion that fd includes.
if (tool === "find") limitNotices.push(limitNotice);
}
let byteNotice = "";
if (truncation?.truncated) {
const maxBytes = truncation.maxBytes ?? DEFAULT_MAX_BYTES;
const size = formatSize(maxBytes);
byteNotice = `${size} limit reached`;
}
for (const limitNotice of limitNotices) {
const notices = [limitNotice, byteNotice].filter(Boolean);
if (notices.length === 0) continue;
const message = notices.join(". ");
const notice = `[${message}]`;
const suffix = `\n\n${notice}`;
if (text.endsWith(suffix)) {
const body = text.slice(0, -suffix.length);
return { text: body, notice };
}
}
return { text, notice: "" };
}
+32 -17
View File
@@ -12,8 +12,14 @@ import {
frameTop,
resultLabel,
} from "./frame.ts";
import { normalizeLineEndings, sanitizeDisplayText, textFromResult } from "./tool-result.ts";
import {
normalizeLineEndings,
rawTextFromResult,
sanitizeDisplayText,
textFromResult,
} from "./tool-result.ts";
import { previewLines } from "./preview.ts";
import { splitListingNotice } from "./listing-output.ts";
type BuiltinLsTool = ReturnType<typeof createLsToolDefinition>;
type LsRenderCall = NonNullable<BuiltinLsTool["renderCall"]>;
@@ -60,23 +66,32 @@ function renderLsResult(
const status = frameStatus(context);
if (context.isError) return frameToolError(textFromResult(result), expanded, theme, width);
const rawText = result.details?.rendering?.text ?? textFromResult(result);
const sanitizedText = sanitizeDisplayText(rawText);
const text = normalizeLineEndings(sanitizedText);
const lines =
text.trim() === "(empty directory)"
? []
: text.split("\n").filter((line) => line.trim().length > 0);
const { shown, hidden } = previewLines(lines, expanded, 10);
const body = renderEntries(shown, theme);
const limit = result.details?.entryLimitReached ? " · limit reached" : "";
return frameResultWithBottomLabel(
body,
resultLabel(`${lines.length} entries${limit}`, expanded, hidden, theme),
status,
theme,
width,
const rawText = result.details?.rendering?.text ?? rawTextFromResult(result);
const normalizedText = normalizeLineEndings(rawText);
const entryLimit = result.details?.entryLimitReached;
const truncation = result.details?.truncation;
const { text: listingText, notice: listingNotice } = splitListingNotice(
normalizedText,
"ls",
entryLimit,
truncation,
);
const text = sanitizeDisplayText(listingText);
const notice = sanitizeDisplayText(listingNotice);
const empty =
text.trim() === "(empty directory)" && entryLimit === undefined && !truncation?.truncated;
const lines = empty ? [] : text.split("\n").filter((line) => line.trim().length > 0);
const { shown, hidden } = previewLines(lines, expanded, 10);
let body = renderEntries(shown, theme);
if (notice) {
const warning = theme.fg("warning", notice);
body = body ? `${body}\n\n${warning}` : warning;
}
const limit = entryLimit === undefined ? "" : " · limit reached";
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);
}
function renderEntries(lines: string[], theme: LsTheme): string {
@@ -1,6 +1,6 @@
import { execFileSync } from "node:child_process";
import { expect, it } from "vitest";
import { sanitizeDisplayText, textFromResult } from "./tool-result.ts";
import { rawTextFromResult, sanitizeDisplayText, textFromResult } from "./tool-result.ts";
it.each([
["SGR styles", "\x1b[31mvisible\x1b[0m"],
@@ -79,6 +79,8 @@ it("sanitizes text blocks without changing model-facing results or image data",
for (const item of result.content) Object.freeze(item);
Object.freeze(result.details);
Object.freeze(result.structuredContent);
const raw = rawTextFromResult(result);
expect(raw).toBe("\x1b[31mone\x1b[0m\n\x1b]52;c;private\x07two\x00");
const actual = textFromResult(result);
expect(actual).toBe("one\ntwo");
expect(result).toEqual(original);
+7 -2
View File
@@ -41,11 +41,16 @@ function stripOscAndApcStrings(text: string): string {
return parts.join("");
}
export function textFromResult(result: AgentToolResult<unknown>): string {
const text = result.content
/** Read text blocks without sanitation so structured suffixes can be separated first. */
export function rawTextFromResult(result: AgentToolResult<unknown>): string {
return result.content
.filter((item): item is { type: "text"; text: string } => item.type === "text")
.map((item) => item.text)
.join("\n");
}
export function textFromResult(result: AgentToolResult<unknown>): string {
const text = rawTextFromResult(result);
return sanitizeDisplayText(text);
}