feat: switched to native text sanitization, removed Bun.stripANSI

- Added `sanitizeText` function to pi-natives that strips ANSI escape sequences, removes control characters and lone surrogates, and normalizes line endings.
- Moved `sanitizeText` function from `@oh-my-pi/pi-utils` to `@oh-my-pi/pi-natives` for better code organization and native performance.
- Added line length clamping (4000 characters) to bash and Python execution output to prevent excessively long lines.
- Replaced internal `#normalizeOutput` methods with `sanitizeText` utility function in bash and Python execution components.
- Fixed bash interactive tool to gracefully handle malformed output chunks by normalizing them with `sanitizeText`.
- Simplified documentation by removing WASM terminology from package descriptions and comments.
This commit is contained in:
can1357
2026-02-14 03:25:13 +01:00
parent 2aa156060b
commit 2e33e436b4
19 changed files with 290 additions and 135 deletions
+10 -30
View File
@@ -8,16 +8,16 @@ This repo contains multiple packages, but **`packages/coding-agent/`** is the pr
### Package Structure
| Package | Description |
| ----------------------- | ------------------------------------------------------ |
| `packages/ai` | Multi-provider LLM client with streaming support |
| `packages/agent` | Agent runtime with tool calling and state management |
| `packages/coding-agent` | Main CLI application (primary focus) |
| `packages/tui` | Terminal UI library with differential rendering |
| `packages/natives` | WASM bindings for native text/image/grep operations |
| `packages/stats` | Local observability dashboard (`omp stats`) |
| `packages/utils` | Shared utilities (logger, streams, temp files) |
| `crates/pi-natives` | Rust WASM crate for performance-critical text/grep ops |
| Package | Description |
| ----------------------- | ---------------------------------------------------- |
| `packages/ai` | Multi-provider LLM client with streaming support |
| `packages/agent` | Agent runtime with tool calling and state management |
| `packages/coding-agent` | Main CLI application (primary focus) |
| `packages/tui` | Terminal UI library with differential rendering |
| `packages/natives` | bindings for native text/image/grep operations |
| `packages/stats` | Local observability dashboard (`omp stats`) |
| `packages/utils` | Shared utilities (logger, streams, temp files) |
| `crates/pi-natives` | Rust crate for performance-critical text/grep ops |
## Code Quality
@@ -79,26 +79,6 @@ This repo contains multiple packages, but **`packages/coding-agent/`** is the pr
const { promise, resolve, reject } = Promise.withResolvers<string>();
```
## WASM Bindgen Resources
**Use `using` declarations** for wasm-bindgen objects — they implement `Symbol.dispose`:
```typescript
// BAD: Manual try/finally with .free()
const image = PhotonImage.new_from_byteslice(bytes);
try {
return image.get_bytes();
} finally {
image.free();
}
// GOOD: Automatic cleanup via Symbol.dispose
using image = PhotonImage.new_from_byteslice(bytes);
return image.get_bytes();
```
This applies to all wasm-bindgen generated classes (`CompiledPattern`, `PhotonImage`, etc.).
## Bun Over Node
This project uses Bun. Use Bun APIs where they provide a cleaner alternative; use `node:fs` for operations Bun doesn't cover.
+102
View File
@@ -322,6 +322,25 @@ fn ansi_seq_len_u16(data: &[u16], pos: usize) -> Option<usize> {
}
None
},
0x50 | 0x58 | 0x5e | 0x5f => {
// 'P' DCS, 'X' SOS, '^' PM, '_' APC (terminated by ST)
for (i, &b) in data[pos + 2..].iter().enumerate() {
if b == ESC && data.get(pos + 2 + i + 1) == Some(&0x5c) {
return Some(i + 4);
}
}
None
},
0x20..=0x2f => {
// ESC + intermediates + final byte
for (i, b) in data[pos + 2..].iter().enumerate() {
if (0x30..=0x7e).contains(b) {
return Some(i + 3);
}
}
None
},
0x40..=0x7e => Some(2),
_ => None,
}
}
@@ -1191,6 +1210,89 @@ pub fn extract_segments(
})
}
// ============================================================================
// sanitizeText
// ============================================================================
/// Strip ANSI escape sequences, remove control characters / lone surrogates,
/// and normalize line endings.
#[napi(js_name = "sanitizeText")]
pub fn sanitize_text(text: JsString<'_>) -> Result<Either<JsString<'_>, Utf16String>> {
let original = text;
let text_u16 = text.into_utf16()?;
let data = text_u16.as_slice();
let mut did_change = false;
let mut out: Vec<u16> = Vec::new();
let mut last = 0usize;
let mut i = 0usize;
let len = data.len();
while i < len {
let u = data[i];
// Allow tab + newline; normalize CR by removing it.
if u == 0x09 || u == 0x0a {
i += 1;
continue;
}
let mut remove_len = if u == ESC
&& let Some(seq_len) = ansi_seq_len_u16(data, i)
{
seq_len
} else {
0usize
};
if remove_len == 0 {
// Drop CR to normalize line endings.
if u == 0x0d {
remove_len = 1;
} else if u <= 0x1f || u == 0x7f || (0x80..=0x9f).contains(&u) {
// C0 + DEL + C1 controls.
remove_len = 1;
} else if (0xd800..=0xdbff).contains(&u) {
// High surrogate: keep only if followed by a valid low surrogate.
if i + 1 < len {
let lo = data[i + 1];
if (0xdc00..=0xdfff).contains(&lo) {
i += 2;
continue;
}
}
remove_len = 1;
} else if (0xdc00..=0xdfff).contains(&u) {
// Lone low surrogate.
remove_len = 1;
}
}
if remove_len == 0 {
i += 1;
continue;
}
if !did_change {
did_change = true;
out = Vec::with_capacity(len);
}
if last != i {
out.extend_from_slice(&data[last..i]);
}
i += remove_len;
last = i;
}
if !did_change {
return Ok(Either::A(original));
}
if last < len {
out.extend_from_slice(&data[last..]);
}
Ok(Either::B(build_utf16_string(out)))
}
// ============================================================================
// visibleWidth
// ============================================================================
+4 -1
View File
@@ -1,13 +1,16 @@
# Changelog
## [Unreleased]
### Changed
- Moved `sanitizeText` function from `@oh-my-pi/pi-utils` to `@oh-my-pi/pi-natives` for better code organization
- Replaced internal `#normalizeOutput` methods with `sanitizeText` utility function in bash and Python execution components
- Added line length clamping (4000 characters) to bash and Python execution output to prevent display of excessively long lines
- Modified memory storage to isolate memories by project working directory, preventing cross-project memory contamination
### Fixed
- Fixed bash interactive tool to gracefully handle malformed output chunks by normalizing them before display
- Fixed fetch tool incorrectly treating HTML content as plain text or markdown
- Fixed output truncation notice displaying incorrect byte limit when maxBytes differs from outputBytes
- Fixed Cloudflare returning corrupted bytes when compression is negotiated in web scraper requests
+1 -1
View File
@@ -282,7 +282,7 @@ function renderHover(
}
/**
* Syntax highlight code using native WASM highlighter.
* Syntax highlight code using native highlighter.
*/
function highlightCode(codeText: string, language: string, theme: Theme): string[] {
const validLang = language && supportsLanguage(language) ? language : undefined;
@@ -1,6 +1,8 @@
/**
* Component for displaying bash command execution with streaming output.
*/
import { sanitizeText } from "@oh-my-pi/pi-natives";
import { Container, Loader, Spacer, Text, type TUI } from "@oh-my-pi/pi-tui";
import { getSymbolTheme, theme } from "../../modes/theme/theme";
import type { TruncationMeta } from "../../tools/output-meta";
@@ -10,6 +12,7 @@ import { truncateToVisualLines } from "./visual-truncate";
// Preview line limit when not expanded (matches tool execution behavior)
const PREVIEW_LINES = 20;
const MAX_DISPLAY_LINE_CHARS = 4000;
export class BashExecutionComponent extends Container {
#outputLines: string[] = [];
@@ -73,13 +76,15 @@ export class BashExecutionComponent extends Container {
}
appendOutput(chunk: string): void {
const clean = this.#normalizeOutput(chunk);
const clean = sanitizeText(chunk);
// Append to output lines
const newLines = clean.split("\n");
const newLines = clean.split("\n").map(line => this.#clampDisplayLine(line));
if (this.#outputLines.length > 0 && newLines.length > 0) {
// Append first chunk to last line (incomplete line continuation)
this.#outputLines[this.#outputLines.length - 1] += newLines[0];
this.#outputLines[this.#outputLines.length - 1] = this.#clampDisplayLine(
`${this.#outputLines[this.#outputLines.length - 1]}${newLines[0]}`,
);
this.#outputLines.push(...newLines.slice(1));
} else {
this.#outputLines.push(...newLines);
@@ -184,15 +189,17 @@ export class BashExecutionComponent extends Container {
}
}
#normalizeOutput(text: string): string {
// Strip ANSI codes and normalize line endings
// Note: binary data is already sanitized in tui-renderer.ts executeBashCommand
return Bun.stripANSI(text).replace(/\r\n/g, "\n").replace(/\r/g, "\n");
#clampDisplayLine(line: string): string {
if (line.length <= MAX_DISPLAY_LINE_CHARS) {
return line;
}
const omitted = line.length - MAX_DISPLAY_LINE_CHARS;
return `${line.slice(0, MAX_DISPLAY_LINE_CHARS)}… [${omitted} chars omitted]`;
}
#setOutput(output: string): void {
const clean = this.#normalizeOutput(output);
this.#outputLines = clean ? clean.split("\n") : [];
const clean = sanitizeText(output);
this.#outputLines = clean ? clean.split("\n").map(line => this.#clampDisplayLine(line)) : [];
}
/**
@@ -2,6 +2,8 @@
* Component for displaying user-initiated Python execution with streaming output.
* Shares the same kernel session as the agent's Python tool.
*/
import { sanitizeText } from "@oh-my-pi/pi-natives";
import { Container, Loader, Spacer, Text, type TUI } from "@oh-my-pi/pi-tui";
import { getSymbolTheme, highlightCode, theme } from "../../modes/theme/theme";
import type { TruncationMeta } from "../../tools/output-meta";
@@ -10,6 +12,7 @@ import { DynamicBorder } from "./dynamic-border";
import { truncateToVisualLines } from "./visual-truncate";
const PREVIEW_LINES = 20;
const MAX_DISPLAY_LINE_CHARS = 4000;
export class PythonExecutionComponent extends Container {
#outputLines: string[] = [];
@@ -70,11 +73,13 @@ export class PythonExecutionComponent extends Container {
}
appendOutput(chunk: string): void {
const clean = this.#normalizeOutput(chunk);
const clean = sanitizeText(chunk);
const newLines = clean.split("\n");
const newLines = clean.split("\n").map(line => this.#clampDisplayLine(line));
if (this.#outputLines.length > 0 && newLines.length > 0) {
this.#outputLines[this.#outputLines.length - 1] += newLines[0];
this.#outputLines[this.#outputLines.length - 1] = this.#clampDisplayLine(
`${this.#outputLines[this.#outputLines.length - 1]}${newLines[0]}`,
);
this.#outputLines.push(...newLines.slice(1));
} else {
this.#outputLines.push(...newLines);
@@ -168,13 +173,17 @@ export class PythonExecutionComponent extends Container {
}
}
#normalizeOutput(text: string): string {
return Bun.stripANSI(text).replace(/\r\n/g, "\n").replace(/\r/g, "\n");
#clampDisplayLine(line: string): string {
if (line.length <= MAX_DISPLAY_LINE_CHARS) {
return line;
}
const omitted = line.length - MAX_DISPLAY_LINE_CHARS;
return `${line.slice(0, MAX_DISPLAY_LINE_CHARS)}… [${omitted} chars omitted]`;
}
#setOutput(output: string): void {
const clean = this.#normalizeOutput(output);
this.#outputLines = clean ? clean.split("\n") : [];
const clean = sanitizeText(output);
this.#outputLines = clean ? clean.split("\n").map(line => this.#clampDisplayLine(line)) : [];
}
getOutput(): string {
@@ -1,4 +1,5 @@
import type { AgentTool } from "@oh-my-pi/pi-agent-core";
import { sanitizeText } from "@oh-my-pi/pi-natives";
import {
Box,
type Component,
@@ -12,7 +13,7 @@ import {
Text,
type TUI,
} from "@oh-my-pi/pi-tui";
import { logger, sanitizeText } from "@oh-my-pi/pi-utils";
import { logger } from "@oh-my-pi/pi-utils";
import { getProjectDir } from "@oh-my-pi/pi-utils/dirs";
import type { Theme } from "../../modes/theme/theme";
import { theme } from "../../modes/theme/theme";
@@ -1,4 +1,4 @@
import { sanitizeText } from "@oh-my-pi/pi-utils";
import { sanitizeText } from "@oh-my-pi/pi-natives";
import { DEFAULT_MAX_BYTES } from "../tools/truncate";
export interface OutputSummary {
@@ -1,5 +1,5 @@
import type { AgentToolContext } from "@oh-my-pi/pi-agent-core";
import { type PtyRunResult, PtySession } from "@oh-my-pi/pi-natives";
import { type PtyRunResult, PtySession, sanitizeText } from "@oh-my-pi/pi-natives";
import {
type Component,
matchesKey,
@@ -23,9 +23,8 @@ export interface BashInteractiveResult extends OutputSummary {
}
function normalizeCaptureChunk(chunk: string): string {
const noAnsi = Bun.stripANSI(chunk);
const normalized = noAnsi.replace(/\r\n/gu, "\n").replace(/\r/gu, "\n");
return normalized.replace(/[\x00-\x08\x0B-\x1F\x7F]/gu, "");
const normalized = chunk.replace(/\r\n/gu, "\n").replace(/\r/gu, "\n");
return sanitizeText(normalized);
}
const XtermTerminal = xterm.Terminal;
@@ -168,7 +167,7 @@ class BashInteractiveOverlayComponent implements Component {
const visibleLines: string[] = [];
for (let i = 0; i < maxContentRows; i++) {
const line = buffer.getLine(viewportY + i)?.translateToString(true) ?? "";
visibleLines.push(truncateToWidth(replaceTabs(line), innerWidth));
visibleLines.push(truncateToWidth(replaceTabs(sanitizeText(line)), innerWidth));
}
return visibleLines;
}
@@ -350,7 +349,12 @@ export async function runInteractiveBashPty(
},
(err, chunk) => {
if (err || !chunk) return;
component.appendOutput(chunk);
try {
component.appendOutput(chunk);
} catch {
const normalizedChunk = normalizeCaptureChunk(chunk);
component.appendOutput(normalizedChunk);
}
const normalizedChunk = normalizeCaptureChunk(chunk);
pendingChunks = pendingChunks.then(() => sink.push(normalizedChunk)).catch(() => {});
tui.requestRender();
@@ -1,6 +1,7 @@
import { describe, expect, it } from "bun:test";
import { getThemeByName } from "@oh-my-pi/pi-coding-agent/modes/theme/theme";
import { pythonToolRenderer } from "@oh-my-pi/pi-coding-agent/tools/python";
import { sanitizeText } from "@oh-my-pi/pi-natives";
describe("pythonToolRenderer", () => {
it("renders truncated output when collapsed and full output when expanded", async () => {
@@ -31,13 +32,13 @@ describe("pythonToolRenderer", () => {
{ expanded: false, isPartial: false, renderContext: { previewLines: 2 } },
uiTheme,
);
const collapsedLines = Bun.stripANSI(collapsed.render(80).join("\n"));
const collapsedLines = sanitizeText(collapsed.render(80).join("\n"));
expect(collapsedLines).toContain("line 4");
expect(collapsedLines).not.toContain("line 1");
expect(collapsedLines).toContain("more lines");
const expanded = pythonToolRenderer.renderResult(result, { expanded: true, isPartial: false }, uiTheme);
const expandedLines = Bun.stripANSI(expanded.render(80).join("\n"));
const expandedLines = sanitizeText(expanded.render(80).join("\n"));
expect(expandedLines).toContain("line 1");
expect(expandedLines).toContain("line 4");
expect(expandedLines).not.toContain("more lines");
+3
View File
@@ -1,6 +1,9 @@
# Changelog
## [Unreleased]
### Added
- Exported `sanitizeText` function to strip ANSI codes, remove binary garbage, and normalize line endings in text output
## [12.1.0] - 2026-02-13
### Added
+1
View File
@@ -55,6 +55,7 @@ export {
type ExtractSegmentsResult,
extractSegments,
type SliceWithWidthResult,
sanitizeText,
sliceWithWidth,
truncateToWidth,
visibleWidth,
+2
View File
@@ -170,6 +170,8 @@ function validateNative(bindings: NativeBindings, source: string): void {
checkFn("supportsLanguage");
checkFn("getSupportedLanguages");
checkFn("truncateToWidth");
checkFn("sanitizeText");
checkFn("wrapTextWithAnsi");
checkFn("sliceWithWidth");
checkFn("extractSegments");
+1 -1
View File
@@ -41,4 +41,4 @@ export function sliceWithWidth(line: string, startCol: number, length: number, s
return native.sliceWithWidth(line, startCol, length, strict);
}
export const { wrapTextWithAnsi, visibleWidth, extractSegments } = native;
export const { wrapTextWithAnsi, visibleWidth, extractSegments, sanitizeText } = native;
+5
View File
@@ -42,6 +42,11 @@ declare module "../bindings" {
* @param pad Whether to pad the output to `maxWidth`.
*/
truncateToWidth(text: string, maxWidth: number, ellipsisKind: number, pad: boolean): string;
/**
* Sanitize text output: strip ANSI codes, remove binary garbage, normalize line endings.
*/
sanitizeText(text: string): string;
/**
* Wrap text to a visible width, preserving ANSI codes across line breaks.
* @param text UTF-16 input text with optional ANSI escapes.
+28 -1
View File
@@ -2,7 +2,16 @@ import { beforeAll, describe, expect, it } from "bun:test";
import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import { FileType, fuzzyFind, type GlobMatch, glob, grep, htmlToMarkdown, invalidateFsScanCache } from "../src/index";
import {
FileType,
fuzzyFind,
type GlobMatch,
glob,
grep,
htmlToMarkdown,
invalidateFsScanCache,
sanitizeText,
} from "../src/index";
let testDir: string;
@@ -215,4 +224,22 @@ describe("pi-natives", () => {
// Navigation/footer may or may not be removed depending on preprocessing
});
});
describe("sanitizeText", () => {
it("should strip ANSI, remove control chars and normalize CR", () => {
const input = "\x1b[31mred\x1b[0m\ra\u0000b\tline\ncarriage\r\u0001\u0085";
expect(sanitizeText(input)).toBe("redab\tline\ncarriage");
});
it("should remove lone surrogates but keep valid pairs", () => {
expect(sanitizeText(`a\ud800b\udc00c`)).toBe("abc");
const validPair = "a\u{1f600}b";
expect(sanitizeText(validPair)).toBe(validPair);
});
it("should strip OSC sequences", () => {
const input = "\x1b]0;title\x07hello";
expect(sanitizeText(input)).toBe("hello");
});
});
});
+83
View File
@@ -0,0 +1,83 @@
import { sanitizeText as nativeSanitizeText } from "@oh-my-pi/pi-natives";
function sanitizeBinaryOutput(str: string): string {
let out: string[] | undefined;
let last = 0;
for (let i = 0; i < str.length; ) {
const code = str.codePointAt(i)!;
const width = code > 0xffff ? 2 : 1;
const next = i + width;
// Allow tab, newline, carriage return.
const isAllowedControl = code === 0x09 || code === 0x0a || code === 0x0d;
if (isAllowedControl) {
i = next;
continue;
}
// Filter out characters that crash `Bun.stringWidth()` or cause display issues:
// - ASCII control chars (C0)
// - DEL + C1 control block
// - Lone surrogates
const isControl = code <= 0x1f || code === 0x7f || (code >= 0x80 && code <= 0x9f);
const isSurrogate = code >= 0xd800 && code <= 0xdfff;
if (isControl || isSurrogate) {
out ??= [];
if (last !== i) out.push(str.slice(last, i));
last = next;
}
i = next;
}
if (!out) return str;
if (last < str.length) out.push(str.slice(last));
return out.join("");
}
function jsSanitizeText(text: string): string {
return sanitizeBinaryOutput(Bun.stripANSI(text)).replaceAll("\r", "");
}
const ITERATIONS = 2000;
const samples = {
plain: "hello world this is a plain ASCII string with some words",
ansi: "\x1b[31mred text\x1b[0m and \x1b[4munderlined content\x1b[24m with emoji 😅😅",
links: "prefix \x1b]8;;https://example.com\x07link\x1b]8;;\x07 suffix",
wide: "日本語のテキストとemoji 🚀✨ mixed with ascii",
wrapped:
"This is a long line that should wrap multiple times when rendered with ANSI \x1b[32mcolors\x1b[0m and tabs\tbetween words.",
};
const wrapWidth = 40;
function bench(name: string, fn: () => void): number {
const start = Bun.nanoseconds();
for (let i = 0; i < ITERATIONS; i++) {
fn();
}
const elapsed = (Bun.nanoseconds() - start) / 1e6;
const perOp = (elapsed / ITERATIONS).toFixed(6);
console.log(`${name}: ${elapsed.toFixed(2)}ms total (${perOp}ms/op)`);
return elapsed;
}
console.log(`Text layout benchmark (${ITERATIONS} iterations)\n`);
for (const [name, text] of Object.entries(samples)) {
const jsResult = jsSanitizeText(text);
const nativeResult = nativeSanitizeText(text);
if (jsResult !== nativeResult) {
console.log(`MISMATCH ${name}: js="${jsResult}" native="${nativeResult}"`);
}
bench(`jsSanitizeText/${name}`, () => {
jsSanitizeText(text);
});
bench(`nativeSanitizeText/${name}`, () => {
nativeSanitizeText(text);
});
}
-50
View File
@@ -1,55 +1,5 @@
import { createAbortableStream } from "./abortable";
/**
* Sanitize binary output for display/storage.
* Removes characters that crash string-width or cause display issues:
* - Control characters (except tab, newline, carriage return)
* - Lone surrogates
* - Characters with undefined code points
*/
export function sanitizeBinaryOutput(str: string): string {
let out: string[] | undefined;
let last = 0;
for (let i = 0; i < str.length; ) {
const code = str.codePointAt(i)!;
const width = code > 0xffff ? 2 : 1;
const next = i + width;
// Allow tab, newline, carriage return.
const isAllowedControl = code === 0x09 || code === 0x0a || code === 0x0d;
if (isAllowedControl) {
i = next;
continue;
}
// Filter out characters that crash `Bun.stringWidth()` or cause display issues:
// - ASCII control chars (C0)
// - DEL + C1 control block
// - Lone surrogates
const isControl = code <= 0x1f || code === 0x7f || (code >= 0x80 && code <= 0x9f);
const isSurrogate = code >= 0xd800 && code <= 0xdfff;
if (isControl || isSurrogate) {
out ??= [];
if (last !== i) out.push(str.slice(last, i));
last = next;
}
i = next;
}
if (!out) return str;
if (last < str.length) out.push(str.slice(last));
return out.join("");
}
/**
* Sanitize text output: strip ANSI codes, remove binary garbage, normalize line endings.
*/
export function sanitizeText(text: string): string {
return sanitizeBinaryOutput(Bun.stripANSI(text)).replaceAll("\r", "");
}
const LF = 0x0a;
const CR = 0x0d;
const decoder = new TextDecoder();
+2 -25
View File
@@ -1,12 +1,6 @@
import { describe, expect, it } from "bun:test";
import {
parseJsonlLenient,
readJsonl,
readLines,
readSseJson,
sanitizeBinaryOutput,
sanitizeText,
} from "../src/stream";
import { sanitizeText } from "@oh-my-pi/pi-natives";
import { parseJsonlLenient, readJsonl, readLines, readSseJson } from "../src/stream";
const encoder = new TextEncoder();
@@ -34,23 +28,6 @@ async function collectAsync<T>(iter: AsyncIterable<T>): Promise<T[]> {
return output;
}
describe("sanitizeBinaryOutput", () => {
it("removes control characters but keeps tabs/newlines", () => {
const input = "a\u0000b\tline\ncarriage\r\u0001";
expect(sanitizeBinaryOutput(input)).toBe("ab\tline\ncarriage\r");
});
it("removes lone surrogates", () => {
const input = `a\ud800b\udc00c`;
expect(sanitizeBinaryOutput(input)).toBe("abc");
});
it("removes C1 control characters", () => {
const input = `a\u0085b`;
expect(sanitizeBinaryOutput(input)).toBe("ab");
});
});
describe("sanitizeText", () => {
it("strips ANSI and normalizes CR", () => {
const input = "\u001b[31mred\u001b[0m\r\n";