Delete deprecated ANSI processing files

Remove ansi.ts, ansi-carryover.ts, and their test files. Terminal
emulation is now handled by @xterm/headless via terminal.ts.
This commit is contained in:
Jared Miller 2026-01-31 12:01:24 -05:00
parent 6342c7a699
commit baa6ef9d70
Signed by: shmup
GPG key ID: 22B5C6D66A38B06C
5 changed files with 7 additions and 979 deletions

View file

@ -24,3 +24,10 @@ IDEAS
- can it still @ file refs? - can it still @ file refs?
----
i sent a prompt still queued, wtf is "pending prompts" i nvr see anything appear there
http://cesspit.dungeon.red/i/YaACEY.png
----
i want to make the padding black too, the inside of the card, so the terminal bg doesnt stand out
https://cesspit.dungeon.red/i/uLfyxL.png

View file

@ -1,68 +0,0 @@
/**
* @deprecated This module is deprecated. Terminal output is now handled by
* the headless terminal emulator in terminal.ts. Kept for backward compatibility.
* See docs/terminal-emulation.md for details.
*/
/**
* Detect and split any incomplete ANSI control sequence at the end of a chunk.
*
* ANSI sequences can span multiple network packets. This function identifies
* incomplete sequences at the end of a chunk to prevent them from being
* rendered as partial/broken control codes.
*
* @param chunk - Raw output string potentially containing ANSI sequences
* @returns Tuple of [body, carry] where:
* - body: Complete portion safe to render
* - carry: Incomplete sequence to prepend to next chunk
*
* @example
* const [body, carry] = splitAnsiCarryover("hello\x1b[31");
* // body = "hello", carry = "\x1b[31"
*/
export function splitAnsiCarryover(chunk: string): [string, string] {
if (!chunk) return ["", ""];
const ESC = 0x1b;
const len = chunk.length;
// If last char is ESC, entire ESC starts a sequence we can't complete
if (chunk.charCodeAt(len - 1) === ESC) {
return [chunk.slice(0, -1), "\x1b"];
}
// Search from the last ESC backwards for a potentially incomplete sequence
for (let i = len - 2; i >= 0; i--) {
if (chunk.charCodeAt(i) !== ESC) continue;
const next = chunk[i + 1];
// OSC: ESC ] ... (terminated by BEL 0x07 or ST = ESC \
if (next === "]") {
const tail = chunk.slice(i + 2);
const hasBEL = tail.indexOf("\x07") !== -1;
const hasST = tail.indexOf("\x1b\\") !== -1;
if (!hasBEL && !hasST) {
return [chunk.slice(0, i), chunk.slice(i)];
}
// else complete; continue scanning
continue;
}
// CSI: ESC [ params final — ensure we have a final byte 0x40-0x7E
if (next === "[") {
let j = i + 2;
while (j < len && /[0-9;?]/.test(chunk[j] || "")) j++;
// If we reached end without a final byte, it's incomplete
if (j >= len) {
return [chunk.slice(0, i), chunk.slice(i)];
}
// Final byte exists at chunk[j]; sequence complete
continue;
}
// DCS/PM/APC and other ESC-prefixed two-char intros — if ESC is near end and likely incomplete, carry
// If ESC is the penultimate and we're at end, treat as incomplete unknown sequence
if (i >= len - 2) {
return [chunk.slice(0, i), chunk.slice(i)];
}
// Otherwise, unknown but complete; continue
}
return [chunk, ""];
}

View file

@ -1,505 +0,0 @@
import { expect, test } from "bun:test";
import { ansiToHtml, trimLineEndPreserveAnsi } from "./ansi";
// 1. XSS/HTML escaping tests
test("escapes < to &lt;", () => {
const result = ansiToHtml("<div>");
expect(result).toBe("&lt;div&gt;");
});
test("escapes > to &gt;", () => {
const result = ansiToHtml("a > b");
expect(result).toBe("a &gt; b");
});
test("escapes & to &amp;", () => {
const result = ansiToHtml("foo & bar");
expect(result).toBe("foo &amp; bar");
});
test("escapes <script> tags", () => {
const result = ansiToHtml("<script>alert('xss')</script>");
expect(result).toBe("&lt;script&gt;alert('xss')&lt;/script&gt;");
});
test("escapes HTML in styled text", () => {
const result = ansiToHtml("\x1b[31m<script>alert(1)</script>\x1b[0m");
expect(result).toContain("&lt;script&gt;");
expect(result).toContain("&lt;/script&gt;");
expect(result).not.toContain("<script>");
});
// 2. Basic 16 colors tests
test("red foreground (31)", () => {
const result = ansiToHtml("\x1b[31mred\x1b[0m");
expect(result).toBe('<span style="color:#f85149">red</span>');
});
test("green foreground (32)", () => {
const result = ansiToHtml("\x1b[32mgreen\x1b[0m");
expect(result).toBe('<span style="color:#3fb950">green</span>');
});
test("blue foreground (34)", () => {
const result = ansiToHtml("\x1b[34mblue\x1b[0m");
expect(result).toBe('<span style="color:#58a6ff">blue</span>');
});
test("yellow foreground (33)", () => {
const result = ansiToHtml("\x1b[33myellow\x1b[0m");
expect(result).toBe('<span style="color:#d29922">yellow</span>');
});
test("bright red foreground (91)", () => {
const result = ansiToHtml("\x1b[91mbright red\x1b[0m");
expect(result).toBe('<span style="color:#ff7b72">bright red</span>');
});
test("bright green foreground (92)", () => {
const result = ansiToHtml("\x1b[92mbright green\x1b[0m");
expect(result).toBe('<span style="color:#7ee787">bright green</span>');
});
test("red background (41)", () => {
const result = ansiToHtml("\x1b[41mred bg\x1b[0m");
expect(result).toBe('<span style="background-color:#f85149">red bg</span>');
});
test("green background (42)", () => {
const result = ansiToHtml("\x1b[42mgreen bg\x1b[0m");
expect(result).toBe('<span style="background-color:#3fb950">green bg</span>');
});
test("bright blue background (104)", () => {
const result = ansiToHtml("\x1b[104mbright blue bg\x1b[0m");
expect(result).toBe(
'<span style="background-color:#79c0ff">bright blue bg</span>',
);
});
test("foreground and background together", () => {
const result = ansiToHtml("\x1b[31;42mred on green\x1b[0m");
expect(result).toContain("color:#f85149");
expect(result).toContain("background-color:#3fb950");
});
// 3. 256-color palette tests
test("256-color basic colors (0-15): red", () => {
const result = ansiToHtml("\x1b[38;5;1mred\x1b[0m");
expect(result).toContain("color:#cd3131");
});
test("256-color basic colors (0-15): bright white", () => {
const result = ansiToHtml("\x1b[38;5;15mwhite\x1b[0m");
expect(result).toContain("color:#ffffff");
});
test("256-color 6x6x6 cube: first color (16)", () => {
const result = ansiToHtml("\x1b[38;5;16mcolor\x1b[0m");
expect(result).toContain("rgb(0,0,0)");
});
test("256-color 6x6x6 cube: mid-range color", () => {
// Color 196 = index 180 (196-16) in cube
// ri=5, gi=0, bi=0 -> r=255, g=0, b=0
const result = ansiToHtml("\x1b[38;5;196mred\x1b[0m");
expect(result).toContain("rgb(255,0,0)");
});
test("256-color grayscale ramp (232-255): dark gray", () => {
// Color 232: gray = (232-232)*10+8 = 8
const result = ansiToHtml("\x1b[38;5;232mgray\x1b[0m");
expect(result).toContain("rgb(8,8,8)");
});
test("256-color grayscale ramp (232-255): light gray", () => {
// Color 255: gray = (255-232)*10+8 = 238
const result = ansiToHtml("\x1b[38;5;255mgray\x1b[0m");
expect(result).toContain("rgb(238,238,238)");
});
test("256-color background", () => {
const result = ansiToHtml("\x1b[48;5;21mbg\x1b[0m");
expect(result).toContain("background-color");
});
// 4. 24-bit RGB tests
test("24-bit RGB foreground", () => {
const result = ansiToHtml("\x1b[38;2;255;128;64mrgb\x1b[0m");
expect(result).toBe('<span style="color:rgb(255,128,64)">rgb</span>');
});
test("24-bit RGB background", () => {
const result = ansiToHtml("\x1b[48;2;100;150;200mbg\x1b[0m");
expect(result).toBe(
'<span style="background-color:rgb(100,150,200)">bg</span>',
);
});
test("24-bit RGB foreground and background", () => {
const result = ansiToHtml("\x1b[38;2;255;0;0;48;2;0;255;0mtext\x1b[0m");
expect(result).toContain("color:rgb(255,0,0)");
expect(result).toContain("background-color:rgb(0,255,0)");
});
// 5. Text attributes tests
test("bold (1)", () => {
const result = ansiToHtml("\x1b[1mbold\x1b[0m");
expect(result).toBe('<span style="font-weight:bold">bold</span>');
});
test("dim (2)", () => {
const result = ansiToHtml("\x1b[2mdim\x1b[0m");
expect(result).toBe('<span style="opacity:0.5">dim</span>');
});
test("italic (3)", () => {
const result = ansiToHtml("\x1b[3mitalic\x1b[0m");
expect(result).toBe('<span style="font-style:italic">italic</span>');
});
test("underline (4)", () => {
const result = ansiToHtml("\x1b[4munderline\x1b[0m");
expect(result).toBe(
'<span style="text-decoration:underline">underline</span>',
);
});
test("inverse (7) swaps foreground and background", () => {
const result = ansiToHtml("\x1b[7minverse\x1b[0m");
expect(result).toContain("color:#ffffff");
expect(result).toContain("background-color:#0d1117");
});
test("inverse with colors swaps them", () => {
const result = ansiToHtml("\x1b[31;42;7minverse\x1b[0m");
// Original: fg=#f85149, bg=#3fb950
// Swapped: fg=#3fb950, bg=#f85149
expect(result).toContain("color:#3fb950");
expect(result).toContain("background-color:#f85149");
});
test("multiple attributes combined", () => {
const result = ansiToHtml("\x1b[1;3;4mtext\x1b[0m");
expect(result).toContain("font-weight:bold");
expect(result).toContain("font-style:italic");
expect(result).toContain("text-decoration:underline");
});
test("bold and color", () => {
const result = ansiToHtml("\x1b[1;31mbold red\x1b[0m");
expect(result).toContain("font-weight:bold");
expect(result).toContain("color:#f85149");
});
// 6. Reset codes tests
test("reset (0) clears all styles", () => {
const result = ansiToHtml("\x1b[1;31;42mbold red on green\x1b[0mplain");
expect(result).toBe(
'<span style="color:#f85149;background-color:#3fb950;font-weight:bold">bold red on green</span>plain',
);
});
test("reset bold/dim (22)", () => {
const result = ansiToHtml("\x1b[1mbold\x1b[22mnormal\x1b[0m");
expect(result).toBe('<span style="font-weight:bold">bold</span>normal');
});
test("reset italic (23)", () => {
const result = ansiToHtml("\x1b[3mitalic\x1b[23mnormal\x1b[0m");
expect(result).toBe('<span style="font-style:italic">italic</span>normal');
});
test("reset underline (24)", () => {
const result = ansiToHtml("\x1b[4munderline\x1b[24mnormal\x1b[0m");
expect(result).toBe(
'<span style="text-decoration:underline">underline</span>normal',
);
});
test("reset inverse (27)", () => {
const result = ansiToHtml("\x1b[7minverse\x1b[27mnormal\x1b[0m");
expect(result).toBe(
'<span style="color:#ffffff;background-color:#0d1117">inverse</span>normal',
);
});
test("reset foreground (39)", () => {
const result = ansiToHtml("\x1b[31mred\x1b[39mdefault\x1b[0m");
expect(result).toBe('<span style="color:#f85149">red</span>default');
});
test("reset background (49)", () => {
const result = ansiToHtml("\x1b[41mred bg\x1b[49mdefault\x1b[0m");
expect(result).toBe(
'<span style="background-color:#f85149">red bg</span>default',
);
});
// 7. Multiple codes in one sequence tests
test("bold and red in one sequence", () => {
const result = ansiToHtml("\x1b[1;31mbold red\x1b[0m");
expect(result).toContain("font-weight:bold");
expect(result).toContain("color:#f85149");
});
test("multiple attributes in one sequence", () => {
const result = ansiToHtml("\x1b[1;3;4;31;42mtext\x1b[0m");
expect(result).toContain("font-weight:bold");
expect(result).toContain("font-style:italic");
expect(result).toContain("text-decoration:underline");
expect(result).toContain("color:#f85149");
expect(result).toContain("background-color:#3fb950");
});
test("empty sequence defaults to reset", () => {
const result = ansiToHtml("\x1b[31mred\x1b[mdefault\x1b[0m");
expect(result).toBe('<span style="color:#f85149">red</span>default');
});
// 8. Newline handling tests
test("newline closes and reopens spans", () => {
const result = ansiToHtml("\x1b[31mred\nstill red\x1b[0m");
expect(result).toBe(
'<span style="color:#f85149">red</span>\n<span style="color:#f85149">still red</span>',
);
});
test("newline without styling", () => {
const result = ansiToHtml("line1\nline2");
expect(result).toBe("line1\nline2");
});
test("multiple newlines with styling", () => {
const result = ansiToHtml("\x1b[31mred\n\nstill red\x1b[0m");
expect(result).toBe(
'<span style="color:#f85149">red</span>\n<span style="color:#f85149"></span>\n<span style="color:#f85149">still red</span>',
);
});
test("carriage return in \\r\\n is treated as line ending", () => {
const result = ansiToHtml("line1\r\nline2");
expect(result).toBe("line1\nline2");
});
test("standalone carriage return overwrites from line start", () => {
const result = ansiToHtml("text\rmore");
expect(result).toBe("more");
});
test("multiple carriage returns overwrite progressively", () => {
const result = ansiToHtml("loading...\rloading...\rdone ");
// Note: trailing spaces are trimmed by trimLineEndPreserveAnsi
expect(result).toBe("done");
});
test("carriage return with newlines preserves lines", () => {
const result = ansiToHtml("line1\nloading...\rdone\nline3");
expect(result).toBe("line1\ndone\nline3");
});
test("carriage return with styling preserves style", () => {
const result = ansiToHtml("\x1b[31mloading...\rdone\x1b[0m");
expect(result).toBe('<span style="color:#f85149">done</span>');
});
test("carriage return at start of line", () => {
const result = ansiToHtml("\rtext");
expect(result).toBe("text");
});
// 9. trimLineEndPreserveAnsi tests
test("trimLineEndPreserveAnsi trims trailing spaces", () => {
const result = trimLineEndPreserveAnsi("hello world ");
expect(result).toBe("hello world");
});
test("trimLineEndPreserveAnsi preserves content", () => {
const result = trimLineEndPreserveAnsi("no trailing spaces");
expect(result).toBe("no trailing spaces");
});
test("trimLineEndPreserveAnsi preserves trailing ANSI reset", () => {
const result = trimLineEndPreserveAnsi("text \x1b[0m");
expect(result).toBe("text\x1b[0m");
});
test("trimLineEndPreserveAnsi preserves trailing ANSI color", () => {
const result = trimLineEndPreserveAnsi("text \x1b[31m");
expect(result).toBe("text\x1b[31m");
});
test("trimLineEndPreserveAnsi handles lines without ANSI codes", () => {
const result = trimLineEndPreserveAnsi("plain text ");
expect(result).toBe("plain text");
});
test("trimLineEndPreserveAnsi handles empty string", () => {
const result = trimLineEndPreserveAnsi("");
expect(result).toBe("");
});
test("trimLineEndPreserveAnsi handles only spaces", () => {
const result = trimLineEndPreserveAnsi(" ");
expect(result).toBe("");
});
test("trimLineEndPreserveAnsi preserves multiple trailing ANSI sequences", () => {
const result = trimLineEndPreserveAnsi("text \x1b[0m\x1b[31m");
expect(result).toBe("text\x1b[0m\x1b[31m");
});
test("ansiToHtml uses trimLineEndPreserveAnsi on each line", () => {
const result = ansiToHtml("\x1b[41mtext \x1b[0m\nmore ");
// Trailing spaces should be trimmed
expect(result).not.toContain("text </span>");
expect(result).not.toContain("more ");
});
// 10. Edge cases tests
test("empty string returns empty", () => {
const result = ansiToHtml("");
expect(result).toBe("");
});
test("no ANSI codes (plain text)", () => {
const result = ansiToHtml("plain text");
expect(result).toBe("plain text");
});
test("malformed ANSI sequence (incomplete)", () => {
const result = ansiToHtml("\x1b[31incomplete");
// Incomplete sequence consumes the 3, but outputs the rest
expect(result).toContain("ncomplete");
});
test("ANSI sequence without parameters", () => {
const result = ansiToHtml("\x1b[mtext");
expect(result).toBe("text");
});
test("unknown ANSI codes are ignored", () => {
const result = ansiToHtml("\x1b[999mtext\x1b[0m");
expect(result).toBe("text");
});
test("non-SGR escape sequences are skipped", () => {
// Cursor positioning sequences like ESC[H should be ignored
const result = ansiToHtml("\x1b[Htext");
expect(result).toBe("text");
});
test("consecutive ANSI codes", () => {
const result = ansiToHtml("\x1b[31m\x1b[1mred bold\x1b[0m");
expect(result).toContain("color:#f85149");
expect(result).toContain("font-weight:bold");
});
test("ANSI code at end without text", () => {
const result = ansiToHtml("text\x1b[31m");
// Opens a span even though there's no text after
expect(result).toBe('text<span style="color:#f85149"></span>');
});
test("ANSI code at start", () => {
const result = ansiToHtml("\x1b[31mtext");
expect(result).toBe('<span style="color:#f85149">text</span>');
});
test("multiple resets", () => {
const result = ansiToHtml("\x1b[31mred\x1b[0m\x1b[0m\x1b[0m");
expect(result).toBe('<span style="color:#f85149">red</span>');
});
test("style change without reset", () => {
const result = ansiToHtml("\x1b[31mred\x1b[32mgreen");
expect(result).toBe(
'<span style="color:#f85149">red</span><span style="color:#3fb950">green</span>',
);
});
test("mixed escaped characters and ANSI codes", () => {
const result = ansiToHtml("\x1b[31m<script>&alert</script>\x1b[0m");
expect(result).toContain("&lt;script&gt;");
expect(result).toContain("&amp;alert");
expect(result).toContain("color:#f85149");
});
test("preserves internal spaces", () => {
const result = ansiToHtml("hello world");
expect(result).toBe("hello world");
});
test("handles only ANSI codes with no text", () => {
const result = ansiToHtml("\x1b[31m\x1b[0m");
// Opens and closes span even with no text
expect(result).toBe('<span style="color:#f85149"></span>');
});
// OSC sequences (Operating System Command)
test("strips OSC terminal title with BEL", () => {
// ESC ] 0 ; title BEL
const result = ansiToHtml("\x1b]0;My Terminal Title\x07text after");
expect(result).toBe("text after");
});
test("strips OSC terminal title with ST", () => {
// ESC ] 0 ; title ESC \
const result = ansiToHtml("\x1b]0;My Terminal Title\x1b\\text after");
expect(result).toBe("text after");
});
test("strips OSC with emoji in title", () => {
const result = ansiToHtml("\x1b]0;★ Claude Code\x07hello");
expect(result).toBe("hello");
});
test("strips multiple OSC sequences", () => {
const result = ansiToHtml("\x1b]0;title1\x07text\x1b]0;title2\x07more");
expect(result).toBe("textmore");
});
// Private mode sequences (DEC)
test("strips bracketed paste mode enable", () => {
// ESC [ ? 2004 h
const result = ansiToHtml("\x1b[?2004htext");
expect(result).toBe("text");
});
test("strips bracketed paste mode disable", () => {
// ESC [ ? 2004 l
const result = ansiToHtml("\x1b[?2004ltext");
expect(result).toBe("text");
});
test("strips mixed private mode and SGR", () => {
const result = ansiToHtml("\x1b[?2004h\x1b[31mred\x1b[0m\x1b[?2004l");
expect(result).toBe('<span style="color:#f85149">red</span>');
});
test("strips cursor visibility sequences", () => {
// ESC [ ? 25 h (show cursor) and ESC [ ? 25 l (hide cursor)
const result = ansiToHtml("\x1b[?25lhidden cursor\x1b[?25h");
expect(result).toBe("hidden cursor");
});
test("handles Claude Code typical output", () => {
// Simulated Claude Code startup with title, bracketed paste, and colors
const input =
"\x1b]0;★ Claude Code\x07\x1b[?2004h\x1b[1;33mClaude Code\x1b[0m v2.1.15\x1b[?2004l";
const result = ansiToHtml(input);
expect(result).toContain("Claude Code");
expect(result).toContain("v2.1.15");
expect(result).not.toContain("2004");
expect(result).not.toContain("]0;");
});

View file

@ -1,344 +0,0 @@
/**
* @deprecated This module is deprecated. Terminal output is now handled by
* the headless terminal emulator in terminal.ts. Kept for backward compatibility.
* See docs/terminal-emulation.md for details.
*/
// ANSI to HTML converter - processes escape sequences and renders as inline styled spans
const colors: Record<number, string> = {
30: "#0d1117",
31: "#f85149",
32: "#3fb950",
33: "#d29922",
34: "#58a6ff",
35: "#bc8cff",
36: "#39c5cf",
37: "#c9d1d9",
90: "#6e7681",
91: "#ff7b72",
92: "#7ee787",
93: "#e3b341",
94: "#79c0ff",
95: "#d2a8ff",
96: "#56d4dd",
97: "#ffffff",
};
const bgColors: Record<number, string> = {
40: "#0d1117",
41: "#f85149",
42: "#3fb950",
43: "#d29922",
44: "#58a6ff",
45: "#bc8cff",
46: "#39c5cf",
47: "#c9d1d9",
100: "#6e7681",
101: "#ff7b72",
102: "#7ee787",
103: "#e3b341",
104: "#79c0ff",
105: "#d2a8ff",
106: "#56d4dd",
107: "#ffffff",
};
interface AnsiState {
fg: string | null;
bg: string | null;
bold: boolean;
dim: boolean;
italic: boolean;
underline: boolean;
inverse: boolean;
}
interface ExtendedColorResult {
color: string;
skip: number;
}
// Trim trailing whitespace from each line to prevent background color bleeding.
// Terminal buffers often pad lines with spaces. Preserve trailing ANSI resets.
export function trimLineEndPreserveAnsi(line: string): string {
let end = line.length;
let ansiSuffix = "";
while (end > 0) {
// biome-ignore lint/suspicious/noControlCharactersInRegex: ESC character is intentional for ANSI sequences
const match = line.slice(0, end).match(/\u001b\[[0-9;?]*[A-Za-z]$/);
if (!match) break;
ansiSuffix = match[0] + ansiSuffix;
end -= match[0].length;
}
return line.slice(0, end).trimEnd() + ansiSuffix;
}
// Parse extended color: 38;2;R;G;B (24-bit) or 38;5;N (256-color)
// Returns { color, skip } where skip is additional codes to skip
function parseExtendedColor(
codes: number[],
idx: number,
): ExtendedColorResult | null {
const mode = codes[idx + 1];
// 24-bit RGB: 38;2;R;G;B
if (mode === 2 && codes[idx + 4] !== undefined) {
const r = codes[idx + 2];
const g = codes[idx + 3];
const b = codes[idx + 4];
return { color: `rgb(${r},${g},${b})`, skip: 4 };
}
// 256-color palette: 38;5;N
if (mode === 5) {
const colorNum = codes[idx + 2];
if (colorNum === undefined) return null;
let color: string;
if (colorNum < 16) {
const basic = [
"#0d1117",
"#cd3131",
"#0dbc79",
"#e5e510",
"#2472c8",
"#bc3fbc",
"#11a8cd",
"#e5e5e5",
"#666666",
"#f14c4c",
"#23d18b",
"#f5f543",
"#3b8eea",
"#d670d6",
"#29b8db",
"#ffffff",
];
color = basic[colorNum] || "#ffffff";
} else if (colorNum < 232) {
const n = colorNum - 16;
const ri = Math.floor(n / 36);
const gi = Math.floor((n % 36) / 6);
const bi = n % 6;
const r = ri === 0 ? 0 : ri * 40 + 55;
const g = gi === 0 ? 0 : gi * 40 + 55;
const b = bi === 0 ? 0 : bi * 40 + 55;
color = `rgb(${r},${g},${b})`;
} else {
const gray = (colorNum - 232) * 10 + 8;
color = `rgb(${gray},${gray},${gray})`;
}
return { color, skip: 2 };
}
return null;
}
function resetState(): AnsiState {
return {
fg: null,
bg: null,
bold: false,
dim: false,
italic: false,
underline: false,
inverse: false,
};
}
function buildStyle(state: AnsiState): string {
let fg = state.fg;
let bg = state.bg;
// For inverse, swap fg and bg
if (state.inverse) {
const tmp = fg;
fg = bg || "#ffffff";
bg = tmp || "#0d1117";
}
const styles: string[] = [];
if (fg) styles.push(`color:${fg}`);
if (bg) styles.push(`background-color:${bg}`);
if (state.bold) styles.push("font-weight:bold");
if (state.dim) styles.push("opacity:0.5");
if (state.italic) styles.push("font-style:italic");
if (state.underline) styles.push("text-decoration:underline");
return styles.join(";");
}
export function ansiToHtml(text: string): string {
if (!text) return "";
// Trim trailing whitespace from each line to prevent background color bleeding
text = text.split("\n").map(trimLineEndPreserveAnsi).join("\n");
let result = "";
let inSpan = false;
let i = 0;
let currentStyle = "";
let state = resetState();
function applyStyle(): void {
const nextStyle = buildStyle(state);
if (nextStyle === currentStyle) return;
if (inSpan) {
result += "</span>";
inSpan = false;
}
if (nextStyle) {
result += `<span style="${nextStyle}">`;
inSpan = true;
}
currentStyle = nextStyle;
}
while (i < text.length) {
// Check for ESC character (char code 27)
if (text.charCodeAt(i) === 27) {
// OSC sequence: ESC ] ... BEL or ESC ] ... ESC \
// Used for terminal title, hyperlinks, etc.
if (text[i + 1] === "]") {
let j = i + 2;
// Skip until BEL (0x07) or ST (ESC \)
while (j < text.length) {
if (text.charCodeAt(j) === 0x07) {
j++;
break;
}
if (text.charCodeAt(j) === 27 && text[j + 1] === "\\") {
j += 2;
break;
}
j++;
}
i = j;
continue;
}
// CSI sequence: ESC [ params command
if (text[i + 1] === "[") {
let j = i + 2;
let params = "";
// Include ? for private mode sequences like ESC[?2004h (bracketed paste)
while (j < text.length && /[0-9;?]/.test(text[j] || "")) {
params += text[j];
j++;
}
const command = text[j] || "";
j++;
// Only process SGR (m command) for styling, skip others (cursor, clear, etc.)
if (command === "m" && !params.includes("?")) {
// SGR - Select Graphic Rendition
const codes = params ? params.split(";").map(Number) : [0];
for (let k = 0; k < codes.length; k++) {
const code = codes[k];
if (code === 0) {
state = resetState();
} else if (code === 1) state.bold = true;
else if (code === 2) state.dim = true;
else if (code === 3) state.italic = true;
else if (code === 4) state.underline = true;
else if (code === 7) state.inverse = true;
else if (code === 22) {
state.bold = false;
state.dim = false;
} else if (code === 23) state.italic = false;
else if (code === 24) state.underline = false;
else if (code === 27) state.inverse = false;
else if (code === 39) state.fg = null;
else if (code === 49) state.bg = null;
else if (code === 38) {
const extColor = parseExtendedColor(codes, k);
if (extColor) {
state.fg = extColor.color;
k += extColor.skip;
}
} else if (code === 48) {
const extColor = parseExtendedColor(codes, k);
if (extColor) {
state.bg = extColor.color;
k += extColor.skip;
}
} else if (code !== undefined && colors[code]) {
state.fg = colors[code];
} else if (code !== undefined && bgColors[code]) {
state.bg = bgColors[code];
}
}
applyStyle();
}
// Skip all CSI sequences (SGR handled above, others like cursor/clear ignored)
i = j;
continue;
}
// Unknown ESC sequence - skip the ESC character
i++;
continue;
}
// Backspace: drop it to avoid stray control characters in HTML output
if (text[i] === "\b") {
i++;
continue;
}
// Track newlines and carriage returns
if (text[i] === "\n") {
// Close span before newline to prevent background color bleeding
if (inSpan) {
result += "</span>";
}
result += "\n";
// Reopen span after newline if we had styling
if (inSpan) {
result += `<span style="${currentStyle}">`;
}
i++;
continue;
}
// Carriage return: overwrite from start of current line
if (text[i] === "\r") {
// Check if this is \r\n - if so, just skip the \r
if (text[i + 1] === "\n") {
i++;
continue;
}
// Standalone \r: truncate back to last newline (or start)
// Need to handle open spans carefully
const lastNewline = result.lastIndexOf("\n");
if (lastNewline >= 0) {
// Truncate after the last newline
result = result.substring(0, lastNewline + 1);
} else {
// No newline found, truncate everything
result = "";
}
// If we had a span open, we need to reopen it after truncation
if (inSpan) {
result += `<span style="${currentStyle}">`;
}
i++;
continue;
}
// Regular character - escape HTML
const ch = text[i];
if (ch === "<") result += "&lt;";
else if (ch === ">") result += "&gt;";
else if (ch === "&") result += "&amp;";
else result += ch;
i++;
}
if (inSpan) result += "</span>";
return result;
}

View file

@ -1,62 +0,0 @@
import { expect, test } from "bun:test";
import { splitAnsiCarryover } from "./ansi-carryover";
test("splitAnsiCarryover: complete chunk with no incomplete sequences", () => {
const [body, carry] = splitAnsiCarryover("hello world");
expect(body).toBe("hello world");
expect(carry).toBe("");
});
test("splitAnsiCarryover: ESC at end of chunk", () => {
const [body, carry] = splitAnsiCarryover("hello\x1b");
expect(body).toBe("hello");
expect(carry).toBe("\x1b");
});
test("splitAnsiCarryover: incomplete CSI sequence without final byte", () => {
const [body, carry] = splitAnsiCarryover("hello\x1b[31");
expect(body).toBe("hello");
expect(carry).toBe("\x1b[31");
});
test("splitAnsiCarryover: incomplete OSC without BEL terminator", () => {
const [body, carry] = splitAnsiCarryover("hello\x1b]0;title");
expect(body).toBe("hello");
expect(carry).toBe("\x1b]0;title");
});
test("splitAnsiCarryover: complete OSC with BEL terminator", () => {
const [body, carry] = splitAnsiCarryover("hello\x1b]0;title\x07world");
expect(body).toBe("hello\x1b]0;title\x07world");
expect(carry).toBe("");
});
test("splitAnsiCarryover: complete CSI sequence", () => {
const [body, carry] = splitAnsiCarryover("hello\x1b[31mworld");
expect(body).toBe("hello\x1b[31mworld");
expect(carry).toBe("");
});
test("splitAnsiCarryover: empty string", () => {
const [body, carry] = splitAnsiCarryover("");
expect(body).toBe("");
expect(carry).toBe("");
});
test("splitAnsiCarryover: multiple complete sequences", () => {
const [body, carry] = splitAnsiCarryover("\x1b[1m\x1b[31mhello\x1b[0m");
expect(body).toBe("\x1b[1m\x1b[31mhello\x1b[0m");
expect(carry).toBe("");
});
test("splitAnsiCarryover: incomplete CSI at end with complete CSI before", () => {
const [body, carry] = splitAnsiCarryover("\x1b[31mhello\x1b[");
expect(body).toBe("\x1b[31mhello");
expect(carry).toBe("\x1b[");
});
test("splitAnsiCarryover: ESC with unknown sequence near end", () => {
const [body, carry] = splitAnsiCarryover("hello\x1bP");
expect(body).toBe("hello");
expect(carry).toBe("\x1bP");
});