diff --git a/packages/coding-agent/src/tools/bash.ts b/packages/coding-agent/src/tools/bash.ts index fc138fb6..836e8a79 100644 --- a/packages/coding-agent/src/tools/bash.ts +++ b/packages/coding-agent/src/tools/bash.ts @@ -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 = { 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 | 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 = { }, 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 = { 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 }); } }); diff --git a/packages/coding-agent/src/tools/find.ts b/packages/coding-agent/src/tools/find.ts index 36838890..cb8a7336 100644 --- a/packages/coding-agent/src/tools/find.ts +++ b/packages/coding-agent/src/tools/find.ts @@ -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 = { 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 = { 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 = { } 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); diff --git a/packages/coding-agent/src/tools/grep.ts b/packages/coding-agent/src/tools/grep.ts index 38b8d9df..185a12dd 100644 --- a/packages/coding-agent/src/tools/grep.ts +++ b/packages/coding-agent/src/tools/grep.ts @@ -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 = { 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 = { 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)); diff --git a/packages/coding-agent/src/tools/ls.ts b/packages/coding-agent/src/tools/ls.ts index 18c88ebe..6dcb4749 100644 --- a/packages/coding-agent/src/tools/ls.ts +++ b/packages/coding-agent/src/tools/ls.ts @@ -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 = { 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 = { 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); diff --git a/packages/coding-agent/src/tools/read.ts b/packages/coding-agent/src/tools/read.ts index 26128af0..13c3daca 100644 --- a/packages/coding-agent/src/tools/read.ts +++ b/packages/coding-agent/src/tools/read.ts @@ -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 = { 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 = { 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); + } + } + })(); + }, + ); }, }; diff --git a/packages/coding-agent/src/tools/truncate.ts b/packages/coding-agent/src/tools/truncate.ts new file mode 100644 index 00000000..9f8be37e --- /dev/null +++ b/packages/coding-agent/src/tools/truncate.ts @@ -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"); +} diff --git a/packages/coding-agent/src/tui/tool-execution.ts b/packages/coding-agent/src/tui/tool-execution.ts index 8f7c66d6..576c3669 100644 --- a/packages/coding-agent/src/tui/tool-execution.ts +++ b/packages/coding-agent/src/tui/tool-execution.ts @@ -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