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:
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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, "");
|
||||
}
|
||||
Reference in New Issue
Block a user