mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-08-06 15:25:34 +00:00
* fix(web-shell): parse 256-color and truecolor SGR sequences in parseAnsi parseAnsi read every ';'-separated SGR parameter as a standalone code, but the arguments of 38/48/58 (extended foreground/background/underline color) are not codes. `38;5;<n>` and `38;2;<r>;<g>;<b>` were fed back into the code loop, so: - `38;5;2` set dim (from color index 2) and produced no color; - any truecolor value with a zero channel hit the `code === 0` branch and wiped the color, bold and dim already set on the line; - a background such as `48;5;22` fed 22 to the reset-intensity branch, silently un-bolding the text. Consume the 38/48/58 arguments instead of reading them as codes, and resolve the foreground to hex: 0-15 map onto the existing themed palette, 16-231 onto the 6x6x6 cube, 232-255 onto the grayscale ramp. Background and underline color are parsed but not rendered (Segment has no such field) so their arguments still cannot leak into the code stream. parseAnsi feeds shell tool-output rendering in ToolGroup, so this affects any 256-color CLI. Adds ansi.test.ts (none existed); the four new cases fail on the previous parser. * fix(web-shell): keep the current color when an extended-color sequence is malformed An unreadable 38/48/58 sequence was assigning its undefined result straight to the color, so `\x1b[31m\x1b[38;5;300m` dropped the red that code 31 had already set. Ignore the sequence instead and leave the color as-is; a well-formed one still replaces it. Also widen toHex/xterm256 to accept `number | undefined` so the truncated arguments they are actually handed match their declared types, and drop the non-null assertions that were hiding that mismatch from the compiler. Add the regression case the existing malformed-sequence test could not catch: it started from no color, so a cleared color was invisible to it. * test(web-shell): pin the 58 branch and the truecolor channel guard The 58;5;2 case asserted only bold, so dropping 58 from the extended-color trio left the test green: the leaked 2 argument turns on dim, not bold. Assert the whole segment instead. No case used an out-of-range truecolor channel either, so toHex's 0-255 guard was unpinned. Add 38;2;999;0;0 to both malformed-sequence loops. Both gaps found by wenshao's mutation run in review.
143 lines
4.4 KiB
TypeScript
143 lines
4.4 KiB
TypeScript
const ANSI_COLORS: Record<number, string> = {
|
|
30: '#4a4a4a',
|
|
31: '#fc8181',
|
|
32: '#48bb78',
|
|
33: '#ecc94b',
|
|
34: '#4a9eff',
|
|
35: '#b794f4',
|
|
36: '#76e4f7',
|
|
37: '#e0e6f0',
|
|
90: '#5a6a8a',
|
|
91: '#feb2b2',
|
|
92: '#9ae6b4',
|
|
93: '#fefcbf',
|
|
94: '#90cdf4',
|
|
95: '#d6bcfa',
|
|
96: '#b2f5ea',
|
|
97: '#ffffff',
|
|
};
|
|
|
|
/** The six channel levels of the xterm 6x6x6 color cube (indices 16-231). */
|
|
const CUBE_LEVELS = [0, 95, 135, 175, 215, 255];
|
|
|
|
interface Segment {
|
|
text: string;
|
|
color?: string;
|
|
bold?: boolean;
|
|
dim?: boolean;
|
|
}
|
|
|
|
// The channel and index parameters are read straight out of the escape
|
|
// sequence, so a truncated one yields `undefined` — the types say so rather
|
|
// than asserting the value away, and the guards below are what reject it.
|
|
function toHex(
|
|
r: number | undefined,
|
|
g: number | undefined,
|
|
b: number | undefined,
|
|
): string | undefined {
|
|
let hex = '#';
|
|
for (const v of [r, g, b]) {
|
|
if (v === undefined || !Number.isInteger(v) || v < 0 || v > 255) {
|
|
return undefined;
|
|
}
|
|
hex += v.toString(16).padStart(2, '0');
|
|
}
|
|
return hex;
|
|
}
|
|
|
|
/** Resolve an xterm 256-color index to a hex string. */
|
|
function xterm256(index: number | undefined): string | undefined {
|
|
if (index === undefined || !Number.isInteger(index)) return undefined;
|
|
if (index < 0 || index > 255) return undefined;
|
|
// 0-15 stay on the palette above, so a tool emitting `38;5;2` and one
|
|
// emitting `32` render as the same green.
|
|
if (index < 8) return ANSI_COLORS[30 + index];
|
|
if (index < 16) return ANSI_COLORS[90 + (index - 8)];
|
|
if (index < 232) {
|
|
const v = index - 16;
|
|
return toHex(
|
|
CUBE_LEVELS[Math.floor(v / 36)],
|
|
CUBE_LEVELS[Math.floor(v / 6) % 6],
|
|
CUBE_LEVELS[v % 6],
|
|
);
|
|
}
|
|
const level = 8 + (index - 232) * 10;
|
|
return toHex(level, level, level);
|
|
}
|
|
|
|
export function parseAnsi(input: string): Segment[] {
|
|
const segments: Segment[] = [];
|
|
let color: string | undefined;
|
|
let bold = false;
|
|
let dim = false;
|
|
let pos = 0;
|
|
|
|
const re = new RegExp(String.raw`\x1b\[([0-9;]*)m`, 'g');
|
|
let match: RegExpExecArray | null;
|
|
|
|
while ((match = re.exec(input)) !== null) {
|
|
if (match.index > pos) {
|
|
segments.push({ text: input.slice(pos, match.index), color, bold, dim });
|
|
}
|
|
pos = match.index + match[0].length;
|
|
|
|
const codes = match[1].split(';').map(Number);
|
|
for (let i = 0; i < codes.length; i++) {
|
|
const code = codes[i];
|
|
// 38/48/58 (foreground/background/underline color) never stand alone:
|
|
// each is followed by `5;<index>` or `2;<r>;<g>;<b>`. Those arguments
|
|
// have to be consumed here, or the loop reads them as codes in their own
|
|
// right — `38;5;2` would take the color index for "dim", and a truecolor
|
|
// value with a zero channel would hit the reset below and drop the
|
|
// color, bold and dim state that was already correct.
|
|
if (code === 38 || code === 48 || code === 58) {
|
|
const mode = codes[i + 1];
|
|
let value: string | undefined;
|
|
if (mode === 5) {
|
|
value = xterm256(codes[i + 2]);
|
|
i += 2;
|
|
} else if (mode === 2) {
|
|
value = toHex(codes[i + 2], codes[i + 3], codes[i + 4]);
|
|
i += 4;
|
|
} else {
|
|
// An unrecognized form has an unknown argument count, so there is no
|
|
// safe place to resume; drop the rest of this sequence rather than
|
|
// guess where the color ends.
|
|
break;
|
|
}
|
|
// Only the foreground maps onto a Segment. Background and underline
|
|
// color are still parsed so their arguments cannot leak into the loop.
|
|
// A malformed value leaves the current color alone: an unreadable
|
|
// sequence is ignored, not treated as a reset.
|
|
if (code === 38 && value !== undefined) color = value;
|
|
} else if (code === 0) {
|
|
color = undefined;
|
|
bold = false;
|
|
dim = false;
|
|
} else if (code === 1) {
|
|
bold = true;
|
|
} else if (code === 2) {
|
|
dim = true;
|
|
} else if (code === 22) {
|
|
bold = false;
|
|
dim = false;
|
|
} else if (code >= 30 && code <= 37) {
|
|
color = ANSI_COLORS[code];
|
|
} else if (code >= 90 && code <= 97) {
|
|
color = ANSI_COLORS[code];
|
|
} else if (code === 39) {
|
|
color = undefined;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (pos < input.length) {
|
|
segments.push({ text: input.slice(pos), color, bold, dim });
|
|
}
|
|
|
|
return segments;
|
|
}
|
|
|
|
export function hasAnsi(input: string): boolean {
|
|
return new RegExp(String.raw`\x1b\[`).test(input);
|
|
}
|