Handle OSC and ascii

This commit is contained in:
Jared Miller 2026-01-28 16:38:25 -05:00
parent 9fff95e786
commit 45b232bcc7
Signed by: shmup
GPG key ID: 22B5C6D66A38B06C
3 changed files with 144 additions and 54 deletions

View file

@ -424,3 +424,61 @@ test("handles only ANSI codes with no text", () => {
// Opens and closes span even with no text // Opens and closes span even with no text
expect(result).toBe('<span style="color:#f85149"></span>'); 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

@ -189,60 +189,88 @@ export function ansiToHtml(text: string): string {
while (i < text.length) { while (i < text.length) {
// Check for ESC character (char code 27) // Check for ESC character (char code 27)
if (text.charCodeAt(i) === 27 && text[i + 1] === "[") { if (text.charCodeAt(i) === 27) {
// Parse CSI sequence: ESC [ params command // OSC sequence: ESC ] ... BEL or ESC ] ... ESC \
let j = i + 2; // Used for terminal title, hyperlinks, etc.
let params = ""; if (text[i + 1] === "]") {
while (j < text.length && /[0-9;]/.test(text[j] || "")) { let j = i + 2;
params += text[j]; // Skip until BEL (0x07) or ST (ESC \)
j++; while (j < text.length) {
} if (text.charCodeAt(j) === 0x07) {
const command = text[j] || ""; j++;
j++; break;
if (command === "m") {
// 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];
} }
if (text.charCodeAt(j) === 27 && text[j + 1] === "\\") {
j += 2;
break;
}
j++;
} }
i = j;
applyStyle(); continue;
} }
// Skip other escape sequences (H, f, J, K, etc.) - they don't affect our line-based output
i = j; // 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; continue;
} }

View file

@ -12,9 +12,13 @@ initDb();
const existing = getDeviceBySecret(secret); const existing = getDeviceBySecret(secret);
if (existing) { if (existing) {
console.log(`device already exists: id=${existing.id} secret=${existing.secret} name=${existing.name}`); console.log(
process.exit(0); `device already exists: id=${existing.id} secret=${existing.secret} name=${existing.name}`,
);
process.exit(0);
} }
const device = createDevice(secret, name); const device = createDevice(secret, name);
console.log(`created device: id=${device.id} secret=${device.secret} name=${device.name}`); console.log(
`created device: id=${device.id} secret=${device.secret} name=${device.name}`,
);