fix(cli,core,web): stop ANSI color leakage into tool output (#187)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Yaowei Zheng
2026-08-04 19:12:21 +08:00
committed by GitHub
parent 5e90da2439
commit 5b6b8905b1
8 changed files with 345 additions and 57 deletions
@@ -15,6 +15,7 @@ import type { UpdateRunResponse } from "@prismshadow/penguin-server/api";
import * as api from "../../api/endpoints";
import { S } from "../../lib/strings";
import { apiErrorText } from "../../lib/api-error";
import { stripAnsi } from "../../lib/strip-ansi";
import { Button } from "../ui/button";
import { Modal } from "../ui/modal";
@@ -117,7 +118,8 @@ export function UpdateDialog({
)}
{result.output !== "" && (
<pre className="max-h-56 overflow-auto rounded-md bg-gray-100 p-3 text-xs leading-relaxed whitespace-pre-wrap text-gray-700 dark:bg-gray-800 dark:text-gray-300">
{result.output}
{/* The update runs a package manager whose output may carry ANSI color when the server env forces it. */}
{stripAnsi(result.output)}
</pre>
)}
</div>
@@ -14,9 +14,10 @@
* segment is shown; the wait itself is marked by the amber hourglass icon alone, since the
* approval block below the row is always on screen and names the tool and its arguments.
*/
import { useRef, useState } from "react";
import { useMemo, useRef, useState } from "react";
import { S } from "../../lib/strings";
import { humanizeDuration } from "../../lib/format";
import { stripAnsi } from "../../lib/strip-ansi";
import type { StopReason } from "@prismshadow/penguin-core/omnimessage";
import { approvalKey } from "../../lib/omni/stream-model";
import type { ToolCallItem } from "../../lib/omni/stream-model";
@@ -208,6 +209,11 @@ export function ToolCallCard({ item, ctx }: { item: ToolCallItem; ctx: StreamRen
const pending = ctx.pendingApprovals.get(approvalKey(ctx.origin, item.toolCallId));
const preview = previewArguments(item.name, item.argumentsText);
// Escape sequences are stripped at render time only (the stored stream/trace data keeps its
// raw bytes): hardened child envs should no longer produce any, but historical traces and
// force-color programs still can (#102). Memoized — the aggregated output can be large and
// grows on every streamed delta.
const output = useMemo(() => stripAnsi(item.output), [item.output]);
// Settled once argument streaming stopped (or the complete call arrived): the subtitle's
// completeness gate is lifted — whatever is there is final.
const subtitle = headerSubtitle(item.name, item.argumentsText, !item.callStreaming);
@@ -390,7 +396,7 @@ export function ToolCallCard({ item, ctx }: { item: ToolCallItem; ctx: StreamRen
)}
{(item.output || item.outputStreaming) && (
<pre className="max-h-72 overflow-auto whitespace-pre-wrap border-t border-gray-100 px-3 py-2 text-xs leading-5 text-gray-600 dark:border-gray-800 dark:text-gray-300">
{item.output}
{output}
{item.outputStreaming && <span className="animate-pulse">▌</span>}
</pre>
)}
+32
View File
@@ -0,0 +1,32 @@
/**
* ANSI escape stripping for raw command/tool output display.
*
* The command tools harden the child environment against color (`NO_COLOR=1`, `TERM=dumb`,
* inherited `FORCE_COLOR` removed — see core's command session-manager), but traces recorded
* before that fix and programs that force color unconditionally can still carry escape
* sequences (#102). The chat surface therefore strips them defensively at RENDER time only —
* stored trace/stream data is never mutated.
*/
/**
* One complete ANSI escape sequence:
* - CSI: `ESC [` + parameter bytes (0x30–0x3F) + intermediate bytes (0x20–0x2F) + one final
* byte (0x40–0x7E) — covers SGR color codes such as `\x1b[36m` and `\x1b[1;31m`.
* - OSC: `ESC ]` + payload, terminated by BEL or ST (`ESC \`) — window titles, hyperlinks.
* - Other Fe escapes: `ESC` + one byte in 0x40–0x5F (minus `[` / `]`, handled above).
*/
const ANSI_SEQUENCE = /\x1b(?:\[[0-9:;<=>?]*[ -/]*[@-~]|\][^\x07\x1b]*(?:\x07|\x1b\\)|[@-Z\\^_])/g;
/**
* An incomplete escape sequence at end-of-string: chunks reassemble into one aggregated
* string before rendering, but live output can still pause mid-sequence, so a trailing bare
* `ESC`, unfinished CSI (`ESC [ 3`) or unterminated OSC is dropped rather than shown as
* garbage; once the tail arrives the whole sequence is removed by the pattern above.
*/
const ANSI_TRAILER = /\x1b(?:\[[0-9:;<=>?]*[ -/]*|\][^\x07]*)?$/;
/** Strips ANSI escape sequences for display. Fast path: input without an ESC byte is returned as-is (same reference). */
export function stripAnsi(text: string): string {
if (!text.includes("\x1b")) return text;
return text.replace(ANSI_SEQUENCE, "").replace(ANSI_TRAILER, "");
}