Add tool output truncation with line/byte limits

- Add truncate.ts utility with truncateHead/truncateTail functions
- Both respect 2000 line and 30KB limits (whichever hits first)
- read: head truncation, returns truncation info in details
- bash: tail truncation, writes full output to temp file if large
- grep: head truncation + 100 match limit
- find: head truncation + 1000 result limit
- ls: head truncation + 500 entry limit
- tool-execution.ts displays truncation notices in warning color
- All tools return clean output + structured truncation in details
This commit is contained in:
Mario Zechner 2025-12-07 00:03:16 +01:00
parent 95eadb9ed7
commit de77cd1419
7 changed files with 611 additions and 219 deletions

View file

@ -1,8 +1,12 @@
import { randomBytes } from "node:crypto";
import { createWriteStream, existsSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { AgentTool } from "@mariozechner/pi-ai";
import { Type } from "@sinclair/typebox";
import { spawn, spawnSync } from "child_process";
import { existsSync } from "fs";
import { SettingsManager } from "../settings-manager.js";
import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, type TruncationResult, truncateTail } from "./truncate.js";
let cachedShellConfig: { shell: string; args: string[] } | null = null;
@ -118,31 +122,52 @@ function killProcessTree(pid: number): void {
}
}
/**
* Generate a unique temp file path for bash output
*/
function getTempFilePath(): string {
const id = randomBytes(8).toString("hex");
return join(tmpdir(), `pi-bash-${id}.log`);
}
const bashSchema = Type.Object({
command: Type.String({ description: "Bash command to execute" }),
timeout: Type.Optional(Type.Number({ description: "Timeout in seconds (optional, no default timeout)" })),
});
interface BashToolDetails {
truncation?: TruncationResult;
fullOutputPath?: string;
}
export const bashTool: AgentTool<typeof bashSchema> = {
name: "bash",
label: "bash",
description:
"Execute a bash command in the current working directory. Returns stdout and stderr. Optionally provide a timeout in seconds.",
description: `Execute a bash command in the current working directory. Returns stdout and stderr. Output is truncated to last ${DEFAULT_MAX_LINES} lines or ${DEFAULT_MAX_BYTES / 1024}KB (whichever is hit first). If truncated, full output is saved to a temp file. Optionally provide a timeout in seconds.`,
parameters: bashSchema,
execute: async (
_toolCallId: string,
{ command, timeout }: { command: string; timeout?: number },
signal?: AbortSignal,
) => {
return new Promise((resolve, _reject) => {
return new Promise((resolve, reject) => {
const { shell, args } = getShellConfig();
const child = spawn(shell, [...args, command], {
detached: true,
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
// We'll stream to a temp file if output gets large
let tempFilePath: string | undefined;
let tempFileStream: ReturnType<typeof createWriteStream> | undefined;
let totalBytes = 0;
// Keep a rolling buffer of the last chunk for tail truncation
const chunks: Buffer[] = [];
let chunksBytes = 0;
// Keep more than we need so we have enough for truncation
const maxChunksBytes = DEFAULT_MAX_BYTES * 2;
let timedOut = false;
// Set timeout if provided
@ -154,26 +179,41 @@ export const bashTool: AgentTool<typeof bashSchema> = {
}, timeout * 1000);
}
// Collect stdout
if (child.stdout) {
child.stdout.on("data", (data) => {
stdout += data.toString();
// Limit buffer size
if (stdout.length > 10 * 1024 * 1024) {
stdout = stdout.slice(0, 10 * 1024 * 1024);
}
});
}
const handleData = (data: Buffer) => {
totalBytes += data.length;
// Collect stderr
if (child.stderr) {
child.stderr.on("data", (data) => {
stderr += data.toString();
// Limit buffer size
if (stderr.length > 10 * 1024 * 1024) {
stderr = stderr.slice(0, 10 * 1024 * 1024);
// Start writing to temp file once we exceed the threshold
if (totalBytes > DEFAULT_MAX_BYTES && !tempFilePath) {
tempFilePath = getTempFilePath();
tempFileStream = createWriteStream(tempFilePath);
// Write all buffered chunks to the file
for (const chunk of chunks) {
tempFileStream.write(chunk);
}
});
}
// Write to temp file if we have one
if (tempFileStream) {
tempFileStream.write(data);
}
// Keep rolling buffer of recent data
chunks.push(data);
chunksBytes += data.length;
// Trim old chunks if buffer is too large
while (chunksBytes > maxChunksBytes && chunks.length > 1) {
const removed = chunks.shift()!;
chunksBytes -= removed.length;
}
};
// Collect stdout and stderr together
if (child.stdout) {
child.stdout.on("data", handleData);
}
if (child.stderr) {
child.stderr.on("data", handleData);
}
// Handle process exit
@ -185,44 +225,49 @@ export const bashTool: AgentTool<typeof bashSchema> = {
signal.removeEventListener("abort", onAbort);
}
// Close temp file stream
if (tempFileStream) {
tempFileStream.end();
}
// Combine all buffered chunks
const fullBuffer = Buffer.concat(chunks);
const fullOutput = fullBuffer.toString("utf-8");
if (signal?.aborted) {
let output = "";
if (stdout) output += stdout;
if (stderr) {
if (output) output += "\n";
output += stderr;
}
let output = fullOutput;
if (output) output += "\n\n";
output += "Command aborted";
_reject(new Error(output));
reject(new Error(output));
return;
}
if (timedOut) {
let output = "";
if (stdout) output += stdout;
if (stderr) {
if (output) output += "\n";
output += stderr;
}
let output = fullOutput;
if (output) output += "\n\n";
output += `Command timed out after ${timeout} seconds`;
_reject(new Error(output));
reject(new Error(output));
return;
}
let output = "";
if (stdout) output += stdout;
if (stderr) {
if (output) output += "\n";
output += stderr;
// Apply tail truncation
const truncation = truncateTail(fullOutput);
let outputText = truncation.content || "(no output)";
// Build details with truncation info
let details: BashToolDetails | undefined;
if (truncation.truncated) {
details = {
truncation,
fullOutputPath: tempFilePath,
};
}
if (code !== 0 && code !== null) {
if (output) output += "\n\n";
_reject(new Error(`${output}Command exited with code ${code}`));
outputText += `\n\nCommand exited with code ${code}`;
reject(new Error(outputText));
} else {
resolve({ content: [{ type: "text", text: output || "(no output)" }], details: undefined });
resolve({ content: [{ type: "text", text: outputText }], details });
}
});

View file

@ -6,6 +6,7 @@ import { globSync } from "glob";
import { homedir } from "os";
import path from "path";
import { ensureTool } from "../tools-manager.js";
import { DEFAULT_MAX_BYTES, type TruncationResult, truncateHead } from "./truncate.js";
/**
* Expand ~ to home directory
@ -30,11 +31,15 @@ const findSchema = Type.Object({
const DEFAULT_LIMIT = 1000;
interface FindToolDetails {
truncation?: TruncationResult;
resultLimitReached?: number;
}
export const findTool: AgentTool<typeof findSchema> = {
name: "find",
label: "find",
description:
"Search for files by glob pattern. Returns matching file paths relative to the search directory. Respects .gitignore.",
description: `Search for files by glob pattern. Returns matching file paths relative to the search directory. Respects .gitignore. Output is truncated to ${DEFAULT_LIMIT} results or ${DEFAULT_MAX_BYTES / 1024}KB (whichever is hit first).`,
parameters: findSchema,
execute: async (
_toolCallId: string,
@ -112,7 +117,7 @@ export const findTool: AgentTool<typeof findSchema> = {
return;
}
let output = result.stdout?.trim() || "";
const output = result.stdout?.trim() || "";
if (result.status !== 0) {
const errorMsg = result.stderr?.trim() || `fd exited with code ${result.status}`;
@ -124,41 +129,56 @@ export const findTool: AgentTool<typeof findSchema> = {
}
if (!output) {
output = "No files found matching pattern";
} else {
const lines = output.split("\n");
const relativized: string[] = [];
for (const rawLine of lines) {
const line = rawLine.replace(/\r$/, "").trim();
if (!line) {
continue;
}
const hadTrailingSlash = line.endsWith("/") || line.endsWith("\\");
let relativePath = line;
if (line.startsWith(searchPath)) {
relativePath = line.slice(searchPath.length + 1); // +1 for the /
} else {
relativePath = path.relative(searchPath, line);
}
if (hadTrailingSlash && !relativePath.endsWith("/")) {
relativePath += "/";
}
relativized.push(relativePath);
}
output = relativized.join("\n");
const count = relativized.length;
if (count >= effectiveLimit) {
output += `\n\n(truncated, ${effectiveLimit} results shown)`;
}
resolve({
content: [{ type: "text", text: "No files found matching pattern" }],
details: undefined,
});
return;
}
resolve({ content: [{ type: "text", text: output }], details: undefined });
const lines = output.split("\n");
const relativized: string[] = [];
for (const rawLine of lines) {
const line = rawLine.replace(/\r$/, "").trim();
if (!line) {
continue;
}
const hadTrailingSlash = line.endsWith("/") || line.endsWith("\\");
let relativePath = line;
if (line.startsWith(searchPath)) {
relativePath = line.slice(searchPath.length + 1); // +1 for the /
} else {
relativePath = path.relative(searchPath, line);
}
if (hadTrailingSlash && !relativePath.endsWith("/")) {
relativePath += "/";
}
relativized.push(relativePath);
}
const rawOutput = relativized.join("\n");
let details: FindToolDetails | undefined;
// Check if we hit the result limit
const hitResultLimit = relativized.length >= effectiveLimit;
// Apply byte truncation
const truncation = truncateHead(rawOutput, { maxLines: Number.MAX_SAFE_INTEGER });
const resultOutput = truncation.content;
// Include truncation info in details (result limit or byte limit)
if (hitResultLimit || truncation.truncated) {
details = {
truncation: truncation.truncated ? truncation : undefined,
resultLimitReached: hitResultLimit ? effectiveLimit : undefined,
};
}
resolve({ content: [{ type: "text", text: resultOutput }], details });
} catch (e: any) {
signal?.removeEventListener("abort", onAbort);
reject(e);

View file

@ -6,6 +6,7 @@ import { readFileSync, type Stats, statSync } from "fs";
import { homedir } from "os";
import path from "path";
import { ensureTool } from "../tools-manager.js";
import { DEFAULT_MAX_BYTES, type TruncationResult, truncateHead } from "./truncate.js";
/**
* Expand ~ to home directory
@ -36,11 +37,15 @@ const grepSchema = Type.Object({
const DEFAULT_LIMIT = 100;
interface GrepToolDetails {
truncation?: TruncationResult;
matchLimitReached?: number;
}
export const grepTool: AgentTool<typeof grepSchema> = {
name: "grep",
label: "grep",
description:
"Search file contents for a pattern. Returns matching lines with file paths and line numbers. Respects .gitignore.",
description: `Search file contents for a pattern. Returns matching lines with file paths and line numbers. Respects .gitignore. Output is truncated to ${DEFAULT_LIMIT} matches or ${DEFAULT_MAX_BYTES / 1024}KB (whichever is hit first).`,
parameters: grepSchema,
execute: async (
_toolCallId: string,
@ -251,12 +256,22 @@ export const grepTool: AgentTool<typeof grepSchema> = {
return;
}
let output = outputLines.join("\n");
if (truncated) {
output += `\n\n(truncated, limit of ${effectiveLimit} matches reached)`;
// Apply byte truncation
const rawOutput = outputLines.join("\n");
const truncation = truncateHead(rawOutput, { maxLines: Number.MAX_SAFE_INTEGER });
const output = truncation.content;
let details: GrepToolDetails | undefined;
// Include truncation info in details (match limit or byte limit)
if (truncated || truncation.truncated) {
details = {
truncation: truncation.truncated ? truncation : undefined,
matchLimitReached: truncated ? effectiveLimit : undefined,
};
}
settle(() => resolve({ content: [{ type: "text", text: output }], details: undefined }));
settle(() => resolve({ content: [{ type: "text", text: output }], details }));
});
} catch (err) {
settle(() => reject(err as Error));

View file

@ -3,6 +3,7 @@ import { Type } from "@sinclair/typebox";
import { existsSync, readdirSync, statSync } from "fs";
import { homedir } from "os";
import nodePath from "path";
import { DEFAULT_MAX_BYTES, type TruncationResult, truncateHead } from "./truncate.js";
/**
* Expand ~ to home directory
@ -24,11 +25,15 @@ const lsSchema = Type.Object({
const DEFAULT_LIMIT = 500;
interface LsToolDetails {
truncation?: TruncationResult;
entryLimitReached?: number;
}
export const lsTool: AgentTool<typeof lsSchema> = {
name: "ls",
label: "ls",
description:
"List directory contents. Returns entries sorted alphabetically, with '/' suffix for directories. Includes dotfiles.",
description: `List directory contents. Returns entries sorted alphabetically, with '/' suffix for directories. Includes dotfiles. Output is truncated to ${DEFAULT_LIMIT} entries or ${DEFAULT_MAX_BYTES / 1024}KB (whichever is hit first).`,
parameters: lsSchema,
execute: async (_toolCallId: string, { path, limit }: { path?: string; limit?: number }, signal?: AbortSignal) => {
return new Promise((resolve, reject) => {
@ -97,16 +102,27 @@ export const lsTool: AgentTool<typeof lsSchema> = {
signal?.removeEventListener("abort", onAbort);
let output = results.join("\n");
if (truncated) {
const remaining = entries.length - effectiveLimit;
output += `\n\n(truncated, ${remaining} more entries)`;
}
if (results.length === 0) {
output = "(empty directory)";
resolve({ content: [{ type: "text", text: "(empty directory)" }], details: undefined });
return;
}
resolve({ content: [{ type: "text", text: output }], details: undefined });
const rawOutput = results.join("\n");
let details: LsToolDetails | undefined;
// Apply byte truncation
const truncation = truncateHead(rawOutput, { maxLines: Number.MAX_SAFE_INTEGER });
const output = truncation.content;
// Include truncation info in details (entry limit or byte limit)
if (truncated || truncation.truncated) {
details = {
truncation: truncation.truncated ? truncation : undefined,
entryLimitReached: truncated ? effectiveLimit : undefined,
};
}
resolve({ content: [{ type: "text", text: output }], details });
} catch (e: any) {
signal?.removeEventListener("abort", onAbort);
reject(e);

View file

@ -4,6 +4,7 @@ import { Type } from "@sinclair/typebox";
import { constants } from "fs";
import { access, readFile } from "fs/promises";
import { extname, resolve as resolvePath } from "path";
import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, type TruncationResult, truncateHead } from "./truncate.js";
/**
* Expand ~ to home directory
@ -43,14 +44,14 @@ const readSchema = Type.Object({
limit: Type.Optional(Type.Number({ description: "Maximum number of lines to read" })),
});
const MAX_LINES = 2000;
const MAX_LINE_LENGTH = 2000;
interface ReadToolDetails {
truncation?: TruncationResult;
}
export const readTool: AgentTool<typeof readSchema> = {
name: "read",
label: "read",
description:
"Read the contents of a file. Supports text files and images (jpg, png, gif, webp). Images are sent as attachments. For text files, defaults to first 2000 lines. Use offset/limit for large files.",
description: `Read the contents of a file. Supports text files and images (jpg, png, gif, webp). Images are sent as attachments. For text files, output is truncated to ${DEFAULT_MAX_LINES} lines or ${DEFAULT_MAX_BYTES / 1024}KB (whichever is hit first). Use offset/limit for large files.`,
parameters: readSchema,
execute: async (
_toolCallId: string,
@ -60,119 +61,115 @@ export const readTool: AgentTool<typeof readSchema> = {
const absolutePath = resolvePath(expandPath(path));
const mimeType = isImageFile(absolutePath);
return new Promise<{ content: (TextContent | ImageContent)[]; details: undefined }>((resolve, reject) => {
// Check if already aborted
if (signal?.aborted) {
reject(new Error("Operation aborted"));
return;
}
let aborted = false;
// Set up abort handler
const onAbort = () => {
aborted = true;
reject(new Error("Operation aborted"));
};
if (signal) {
signal.addEventListener("abort", onAbort, { once: true });
}
// Perform the read operation
(async () => {
try {
// Check if file exists
await access(absolutePath, constants.R_OK);
// Check if aborted before reading
if (aborted) {
return;
}
// Read the file based on type
let content: (TextContent | ImageContent)[];
if (mimeType) {
// Read as image (binary)
const buffer = await readFile(absolutePath);
const base64 = buffer.toString("base64");
content = [
{ type: "text", text: `Read image file [${mimeType}]` },
{ type: "image", data: base64, mimeType },
];
} else {
// Read as text
const textContent = await readFile(absolutePath, "utf-8");
const lines = textContent.split("\n");
// Apply offset and limit (matching Claude Code Read tool behavior)
const startLine = offset ? Math.max(0, offset - 1) : 0; // 1-indexed to 0-indexed
const maxLines = limit || MAX_LINES;
const endLine = Math.min(startLine + maxLines, lines.length);
// Check if offset is out of bounds
if (startLine >= lines.length) {
throw new Error(`Offset ${offset} is beyond end of file (${lines.length} lines total)`);
}
// Get the relevant lines
const selectedLines = lines.slice(startLine, endLine);
// Truncate long lines and track which were truncated
let hadTruncatedLines = false;
const formattedLines = selectedLines.map((line) => {
if (line.length > MAX_LINE_LENGTH) {
hadTruncatedLines = true;
return line.slice(0, MAX_LINE_LENGTH);
}
return line;
});
let outputText = formattedLines.join("\n");
// Add notices
const notices: string[] = [];
if (hadTruncatedLines) {
notices.push(`Some lines were truncated to ${MAX_LINE_LENGTH} characters for display`);
}
if (endLine < lines.length) {
const remaining = lines.length - endLine;
notices.push(`${remaining} more lines not shown. Use offset=${endLine + 1} to continue reading`);
}
if (notices.length > 0) {
outputText += `\n\n... (${notices.join(". ")})`;
}
content = [{ type: "text", text: outputText }];
}
// Check if aborted after reading
if (aborted) {
return;
}
// Clean up abort handler
if (signal) {
signal.removeEventListener("abort", onAbort);
}
resolve({ content, details: undefined });
} catch (error: any) {
// Clean up abort handler
if (signal) {
signal.removeEventListener("abort", onAbort);
}
if (!aborted) {
reject(error);
}
return new Promise<{ content: (TextContent | ImageContent)[]; details: ReadToolDetails | undefined }>(
(resolve, reject) => {
// Check if already aborted
if (signal?.aborted) {
reject(new Error("Operation aborted"));
return;
}
})();
});
let aborted = false;
// Set up abort handler
const onAbort = () => {
aborted = true;
reject(new Error("Operation aborted"));
};
if (signal) {
signal.addEventListener("abort", onAbort, { once: true });
}
// Perform the read operation
(async () => {
try {
// Check if file exists
await access(absolutePath, constants.R_OK);
// Check if aborted before reading
if (aborted) {
return;
}
// Read the file based on type
let content: (TextContent | ImageContent)[];
let details: ReadToolDetails | undefined;
if (mimeType) {
// Read as image (binary)
const buffer = await readFile(absolutePath);
const base64 = buffer.toString("base64");
content = [
{ type: "text", text: `Read image file [${mimeType}]` },
{ type: "image", data: base64, mimeType },
];
} else {
// Read as text
const textContent = await readFile(absolutePath, "utf-8");
const lines = textContent.split("\n");
// Apply offset if specified (1-indexed to 0-indexed)
const startLine = offset ? Math.max(0, offset - 1) : 0;
// Check if offset is out of bounds
if (startLine >= lines.length) {
throw new Error(`Offset ${offset} is beyond end of file (${lines.length} lines total)`);
}
// If limit is specified by user, use it; otherwise we'll let truncateHead decide
let selectedContent: string;
if (limit !== undefined) {
const endLine = Math.min(startLine + limit, lines.length);
selectedContent = lines.slice(startLine, endLine).join("\n");
} else {
selectedContent = lines.slice(startLine).join("\n");
}
// Apply truncation (respects both line and byte limits)
const truncation = truncateHead(selectedContent);
let outputText = truncation.content;
// Add continuation hint if there's more content after our selection
// (only relevant when user specified limit and there's more in the file)
if (limit !== undefined && startLine + limit < lines.length && !truncation.truncated) {
const remaining = lines.length - (startLine + limit);
outputText += `\n\n[${remaining} more lines in file. Use offset=${startLine + limit + 1} to continue]`;
}
content = [{ type: "text", text: outputText }];
// Include truncation info in details if truncation occurred
if (truncation.truncated) {
details = { truncation };
}
}
// Check if aborted after reading
if (aborted) {
return;
}
// Clean up abort handler
if (signal) {
signal.removeEventListener("abort", onAbort);
}
resolve({ content, details });
} catch (error: any) {
// Clean up abort handler
if (signal) {
signal.removeEventListener("abort", onAbort);
}
if (!aborted) {
reject(error);
}
}
})();
},
);
},
};

View file

@ -0,0 +1,252 @@
/**
* Shared truncation utilities for tool outputs.
*
* Truncation is based on two independent limits - whichever is hit first wins:
* - Line limit (default: 2000 lines)
* - Byte limit (default: 30KB)
*/
export const DEFAULT_MAX_LINES = 2000;
export const DEFAULT_MAX_BYTES = 30 * 1024; // 30KB
export interface TruncationResult {
/** The truncated content */
content: string;
/** Whether truncation occurred */
truncated: boolean;
/** Which limit was hit: "lines", "bytes", or null if not truncated */
truncatedBy: "lines" | "bytes" | null;
/** Total number of lines in the original content */
totalLines: number;
/** Total number of bytes in the original content */
totalBytes: number;
/** Number of lines in the truncated output */
outputLines: number;
/** Number of bytes in the truncated output */
outputBytes: number;
/** Human-readable truncation notice (empty if not truncated) */
notice: string;
}
export interface TruncationOptions {
/** Maximum number of lines (default: 2000) */
maxLines?: number;
/** Maximum number of bytes (default: 30KB) */
maxBytes?: number;
}
/**
* Format bytes as human-readable size.
*/
function formatSize(bytes: number): string {
if (bytes < 1024) {
return `${bytes}B`;
} else if (bytes < 1024 * 1024) {
return `${(bytes / 1024).toFixed(1)}KB`;
} else {
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
}
}
/**
* Generate a truncation notice.
*/
function makeNotice(
direction: "head" | "tail",
truncatedBy: "lines" | "bytes",
totalLines: number,
totalBytes: number,
outputLines: number,
outputBytes: number,
): string {
const totalSize = formatSize(totalBytes);
const outputSize = formatSize(outputBytes);
const directionText = direction === "head" ? "first" : "last";
if (truncatedBy === "lines") {
return `[Truncated: ${totalLines} lines / ${totalSize} total, showing ${directionText} ${outputLines} lines]`;
} else {
return `[Truncated: ${totalLines} lines / ${totalSize} total, showing ${directionText} ${outputSize}]`;
}
}
/**
* Truncate content from the head (keep first N lines/bytes).
* Suitable for file reads where you want to see the beginning.
*/
export function truncateHead(content: string, options: TruncationOptions = {}): TruncationResult {
const maxLines = options.maxLines ?? DEFAULT_MAX_LINES;
const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
const totalBytes = Buffer.byteLength(content, "utf-8");
const lines = content.split("\n");
const totalLines = lines.length;
// Check if no truncation needed
if (totalLines <= maxLines && totalBytes <= maxBytes) {
return {
content,
truncated: false,
truncatedBy: null,
totalLines,
totalBytes,
outputLines: totalLines,
outputBytes: totalBytes,
notice: "",
};
}
// Determine which limit we'll hit first
const outputLinesArr: string[] = [];
let outputBytesCount = 0;
let truncatedBy: "lines" | "bytes" = "lines";
for (let i = 0; i < lines.length && i < maxLines; i++) {
const line = lines[i];
const lineBytes = Buffer.byteLength(line, "utf-8") + (i > 0 ? 1 : 0); // +1 for newline
if (outputBytesCount + lineBytes > maxBytes) {
truncatedBy = "bytes";
// If this is the first line and it alone exceeds maxBytes, include partial
if (i === 0) {
const truncatedLine = truncateStringToBytes(line, maxBytes);
outputLinesArr.push(truncatedLine);
outputBytesCount = Buffer.byteLength(truncatedLine, "utf-8");
}
break;
}
outputLinesArr.push(line);
outputBytesCount += lineBytes;
}
// If we exited due to line limit
if (outputLinesArr.length >= maxLines && outputBytesCount <= maxBytes) {
truncatedBy = "lines";
}
const outputContent = outputLinesArr.join("\n");
const finalOutputBytes = Buffer.byteLength(outputContent, "utf-8");
return {
content: outputContent,
truncated: true,
truncatedBy,
totalLines,
totalBytes,
outputLines: outputLinesArr.length,
outputBytes: finalOutputBytes,
notice: makeNotice("head", truncatedBy, totalLines, totalBytes, outputLinesArr.length, finalOutputBytes),
};
}
/**
* Truncate content from the tail (keep last N lines/bytes).
* Suitable for bash output where you want to see the end (errors, final results).
*/
export function truncateTail(content: string, options: TruncationOptions = {}): TruncationResult {
const maxLines = options.maxLines ?? DEFAULT_MAX_LINES;
const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
const totalBytes = Buffer.byteLength(content, "utf-8");
const lines = content.split("\n");
const totalLines = lines.length;
// Check if no truncation needed
if (totalLines <= maxLines && totalBytes <= maxBytes) {
return {
content,
truncated: false,
truncatedBy: null,
totalLines,
totalBytes,
outputLines: totalLines,
outputBytes: totalBytes,
notice: "",
};
}
// Work backwards from the end
const outputLinesArr: string[] = [];
let outputBytesCount = 0;
let truncatedBy: "lines" | "bytes" = "lines";
for (let i = lines.length - 1; i >= 0 && outputLinesArr.length < maxLines; i--) {
const line = lines[i];
const lineBytes = Buffer.byteLength(line, "utf-8") + (outputLinesArr.length > 0 ? 1 : 0); // +1 for newline
if (outputBytesCount + lineBytes > maxBytes) {
truncatedBy = "bytes";
// If this is the first line we're adding and it alone exceeds maxBytes, include partial
if (outputLinesArr.length === 0) {
// Take the end of the line
const truncatedLine = truncateStringToBytesFromEnd(line, maxBytes);
outputLinesArr.unshift(truncatedLine);
outputBytesCount = Buffer.byteLength(truncatedLine, "utf-8");
}
break;
}
outputLinesArr.unshift(line);
outputBytesCount += lineBytes;
}
// If we exited due to line limit
if (outputLinesArr.length >= maxLines && outputBytesCount <= maxBytes) {
truncatedBy = "lines";
}
const outputContent = outputLinesArr.join("\n");
const finalOutputBytes = Buffer.byteLength(outputContent, "utf-8");
return {
content: outputContent,
truncated: true,
truncatedBy,
totalLines,
totalBytes,
outputLines: outputLinesArr.length,
outputBytes: finalOutputBytes,
notice: makeNotice("tail", truncatedBy, totalLines, totalBytes, outputLinesArr.length, finalOutputBytes),
};
}
/**
* Truncate a string to fit within a byte limit (from the start).
* Handles multi-byte UTF-8 characters correctly.
*/
function truncateStringToBytes(str: string, maxBytes: number): string {
const buf = Buffer.from(str, "utf-8");
if (buf.length <= maxBytes) {
return str;
}
// Find a valid UTF-8 boundary
let end = maxBytes;
while (end > 0 && (buf[end] & 0xc0) === 0x80) {
end--;
}
return buf.slice(0, end).toString("utf-8");
}
/**
* Truncate a string to fit within a byte limit (from the end).
* Handles multi-byte UTF-8 characters correctly.
*/
function truncateStringToBytesFromEnd(str: string, maxBytes: number): string {
const buf = Buffer.from(str, "utf-8");
if (buf.length <= maxBytes) {
return str;
}
// Start from the end, skip maxBytes back
let start = buf.length - maxBytes;
// Find a valid UTF-8 boundary (start of a character)
while (start < buf.length && (buf[start] & 0xc0) === 0x80) {
start++;
}
return buf.slice(start).toString("utf-8");
}

View file

@ -115,7 +115,6 @@ export class ToolExecutionComponent extends Container {
text = theme.fg("toolTitle", theme.bold(`$ ${command || theme.fg("toolOutput", "...")}`));
if (this.result) {
// Show output without code fences - more minimal
const output = this.getTextOutput().trim();
if (output) {
const lines = output.split("\n");
@ -128,17 +127,28 @@ export class ToolExecutionComponent extends Container {
text += theme.fg("toolOutput", `\n... (${remaining} more lines)`);
}
}
// Show truncation notice at the bottom in warning color if present in details
const truncation = this.result.details?.truncation;
const fullOutputPath = this.result.details?.fullOutputPath;
if (truncation?.truncated) {
if (fullOutputPath) {
text += "\n" + theme.fg("warning", `[Full output: ${fullOutputPath}]`);
}
text += "\n" + theme.fg("warning", truncation.notice);
}
}
} else if (this.toolName === "read") {
const path = shortenPath(this.args?.file_path || this.args?.path || "");
const offset = this.args?.offset;
const limit = this.args?.limit;
// Build path display with offset/limit suffix
// Build path display with offset/limit suffix (in warning color if offset/limit used)
let pathDisplay = path ? theme.fg("accent", path) : theme.fg("toolOutput", "...");
if (offset !== undefined) {
const endLine = limit !== undefined ? offset + limit : "";
pathDisplay += theme.fg("toolOutput", `:${offset}${endLine ? `-${endLine}` : ""}`);
if (offset !== undefined || limit !== undefined) {
const startLine = offset ?? 1;
const endLine = limit !== undefined ? startLine + limit - 1 : "";
pathDisplay += theme.fg("warning", `:${startLine}${endLine ? `-${endLine}` : ""}`);
}
text = theme.fg("toolTitle", theme.bold("read")) + " " + pathDisplay;
@ -146,6 +156,7 @@ export class ToolExecutionComponent extends Container {
if (this.result) {
const output = this.getTextOutput();
const lines = output.split("\n");
const maxLines = this.expanded ? lines.length : 10;
const displayLines = lines.slice(0, maxLines);
const remaining = lines.length - maxLines;
@ -154,6 +165,12 @@ export class ToolExecutionComponent extends Container {
if (remaining > 0) {
text += theme.fg("toolOutput", `\n... (${remaining} more lines)`);
}
// Show truncation notice at the bottom in warning color if present in details
const truncation = this.result.details?.truncation;
if (truncation?.truncated) {
text += "\n" + theme.fg("warning", truncation.notice);
}
}
} else if (this.toolName === "write") {
const path = shortenPath(this.args?.file_path || this.args?.path || "");
@ -231,6 +248,16 @@ export class ToolExecutionComponent extends Container {
text += theme.fg("toolOutput", `\n... (${remaining} more lines)`);
}
}
// Show truncation notice from details
const entryLimit = this.result.details?.entryLimitReached;
const truncation = this.result.details?.truncation;
if (entryLimit) {
text += "\n" + theme.fg("warning", `[Truncated: ${entryLimit} entries limit reached]`);
}
if (truncation?.truncated) {
text += "\n" + theme.fg("warning", truncation.notice);
}
}
} else if (this.toolName === "find") {
const pattern = this.args?.pattern || "";
@ -259,6 +286,16 @@ export class ToolExecutionComponent extends Container {
text += theme.fg("toolOutput", `\n... (${remaining} more lines)`);
}
}
// Show truncation notice from details
const resultLimit = this.result.details?.resultLimitReached;
const truncation = this.result.details?.truncation;
if (resultLimit) {
text += "\n" + theme.fg("warning", `[Truncated: ${resultLimit} results limit reached]`);
}
if (truncation?.truncated) {
text += "\n" + theme.fg("warning", truncation.notice);
}
}
} else if (this.toolName === "grep") {
const pattern = this.args?.pattern || "";
@ -291,6 +328,16 @@ export class ToolExecutionComponent extends Container {
text += theme.fg("toolOutput", `\n... (${remaining} more lines)`);
}
}
// Show truncation notice from details
const matchLimit = this.result.details?.matchLimitReached;
const truncation = this.result.details?.truncation;
if (matchLimit) {
text += "\n" + theme.fg("warning", `[Truncated: ${matchLimit} matches limit reached]`);
}
if (truncation?.truncated) {
text += "\n" + theme.fg("warning", truncation.notice);
}
}
} else {
// Generic tool