fix (tui): sanitize binary-heavy history text before render

This commit is contained in:
Vignesh Natarajan
2026-02-14 18:29:12 -08:00
parent 914b9d1e79
commit 750a7146e4
3 changed files with 81 additions and 9 deletions

View File

@@ -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` : "";

View File

@@ -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: "<22><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>" }],
});
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);
});
});

View File

@@ -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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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));
}
}