diff --git a/src/tui/components/tool-execution.ts b/src/tui/components/tool-execution.ts index e5d15fec204..afa86d0089b 100644 --- a/src/tui/components/tool-execution.ts +++ b/src/tui/components/tool-execution.ts @@ -1,6 +1,7 @@ import { Box, Container, Markdown, Spacer, Text } from "@mariozechner/pi-tui"; import { formatToolDetail, resolveToolDisplay } from "../../agents/tool-display.js"; import { markdownTheme, theme } from "../theme/theme.js"; +import { sanitizeRenderableText } from "../tui-formatters.js"; type ToolResultContent = { type?: string; @@ -21,13 +22,13 @@ function formatArgs(toolName: string, args: unknown): string { const display = resolveToolDisplay({ name: toolName, args }); const detail = formatToolDetail(display); if (detail) { - return detail; + return sanitizeRenderableText(detail); } if (!args || typeof args !== "object") { return ""; } try { - return JSON.stringify(args); + return sanitizeRenderableText(JSON.stringify(args)); } catch { return ""; } @@ -40,7 +41,7 @@ function extractText(result?: ToolResult): string { const lines: string[] = []; for (const entry of result.content) { if (entry.type === "text" && entry.text) { - lines.push(entry.text); + lines.push(sanitizeRenderableText(entry.text)); } else if (entry.type === "image") { const mime = entry.mimeType ?? "image"; const size = entry.bytes ? ` ${Math.round(entry.bytes / 1024)}kb` : ""; diff --git a/src/tui/tui-formatters.test.ts b/src/tui/tui-formatters.test.ts index 74d574c5124..d02a6a9413f 100644 --- a/src/tui/tui-formatters.test.ts +++ b/src/tui/tui-formatters.test.ts @@ -4,6 +4,7 @@ import { extractTextFromMessage, extractThinkingFromMessage, isCommandMessage, + sanitizeRenderableText, } from "./tui-formatters.js"; describe("extractTextFromMessage", () => { @@ -58,6 +59,24 @@ describe("extractTextFromMessage", () => { expect(text).toBe("[thinking]\nponder\n\nhello"); }); + + it("sanitizes ANSI and control chars from string content", () => { + const text = extractTextFromMessage({ + role: "assistant", + content: "Hello\x1b[31m red\x1b[0m\x00world", + }); + + expect(text).toBe("Hello redworld"); + }); + + it("redacts heavily corrupted binary-like lines", () => { + const text = extractTextFromMessage({ + role: "assistant", + content: [{ type: "text", text: "������������������������" }], + }); + + expect(text).toBe("[binary data omitted]"); + }); }); describe("extractThinkingFromMessage", () => { @@ -106,3 +125,13 @@ describe("isCommandMessage", () => { expect(isCommandMessage({})).toBe(false); }); }); + +describe("sanitizeRenderableText", () => { + it("breaks very long unbroken tokens to avoid overflow", () => { + const input = "a".repeat(140); + const sanitized = sanitizeRenderableText(input); + const longestSegment = Math.max(...sanitized.split(/\s+/).map((segment) => segment.length)); + + expect(longestSegment).toBeLessThanOrEqual(64); + }); +}); diff --git a/src/tui/tui-formatters.ts b/src/tui/tui-formatters.ts index 4c6693a6bcb..f2b1bc36f33 100644 --- a/src/tui/tui-formatters.ts +++ b/src/tui/tui-formatters.ts @@ -1,6 +1,48 @@ import { formatRawAssistantErrorForUi } from "../agents/pi-embedded-helpers.js"; +import { stripAnsi } from "../terminal/ansi.js"; import { formatTokenCount } from "../utils/usage-format.js"; +const CONTROL_CHARS_RE = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F]/g; +const REPLACEMENT_CHAR_RE = /\uFFFD/g; +const LONG_TOKEN_RE = /\S{97,}/g; +const MAX_TOKEN_CHARS = 64; +const BINARY_LINE_REPLACEMENT_THRESHOLD = 12; + +function chunkToken(token: string, maxChars: number): string[] { + if (token.length <= maxChars) { + return [token]; + } + const chunks: string[] = []; + for (let i = 0; i < token.length; i += maxChars) { + chunks.push(token.slice(i, i + maxChars)); + } + return chunks; +} + +function redactBinaryLikeLine(line: string): string { + const replacementCount = (line.match(REPLACEMENT_CHAR_RE) || []).length; + if ( + replacementCount >= BINARY_LINE_REPLACEMENT_THRESHOLD && + replacementCount * 2 >= line.length + ) { + return "[binary data omitted]"; + } + return line; +} + +export function sanitizeRenderableText(text: string): string { + if (!text) { + return text; + } + const withoutAnsi = stripAnsi(text); + const withoutControlChars = withoutAnsi.replace(CONTROL_CHARS_RE, ""); + const redacted = withoutControlChars + .split("\n") + .map((line) => redactBinaryLikeLine(line)) + .join("\n"); + return redacted.replace(LONG_TOKEN_RE, (token) => chunkToken(token, MAX_TOKEN_CHARS).join(" ")); +} + export function resolveFinalAssistantText(params: { finalText?: string | null; streamedText?: string | null; @@ -59,7 +101,7 @@ export function extractThinkingFromMessage(message: unknown): string { } const rec = block as Record; if (rec.type === "thinking" && typeof rec.thinking === "string") { - parts.push(rec.thinking); + parts.push(sanitizeRenderableText(rec.thinking)); } } return parts.join("\n").trim(); @@ -77,7 +119,7 @@ export function extractContentFromMessage(message: unknown): string { const content = record.content; if (typeof content === "string") { - return content.trim(); + return sanitizeRenderableText(content).trim(); } // Check for error BEFORE returning empty for non-array content @@ -97,7 +139,7 @@ export function extractContentFromMessage(message: unknown): string { } const rec = block as Record; if (rec.type === "text" && typeof rec.text === "string") { - parts.push(rec.text); + parts.push(sanitizeRenderableText(rec.text)); } } @@ -115,7 +157,7 @@ export function extractContentFromMessage(message: unknown): string { function extractTextBlocks(content: unknown, opts?: { includeThinking?: boolean }): string { if (typeof content === "string") { - return content.trim(); + return sanitizeRenderableText(content).trim(); } if (!Array.isArray(content)) { return ""; @@ -130,14 +172,14 @@ function extractTextBlocks(content: unknown, opts?: { includeThinking?: boolean } const record = block as Record; if (record.type === "text" && typeof record.text === "string") { - textParts.push(record.text); + textParts.push(sanitizeRenderableText(record.text)); } if ( opts?.includeThinking && record.type === "thinking" && typeof record.thinking === "string" ) { - thinkingParts.push(record.thinking); + thinkingParts.push(sanitizeRenderableText(record.thinking)); } }