diff --git a/src/ansi.test.ts b/src/ansi.test.ts index 55eb5ba..0fa1611 100644 --- a/src/ansi.test.ts +++ b/src/ansi.test.ts @@ -424,3 +424,61 @@ test("handles only ANSI codes with no text", () => { // Opens and closes span even with no text expect(result).toBe(''); }); + +// 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('red'); +}); + +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;"); +}); diff --git a/src/ansi.ts b/src/ansi.ts index 6ee2b3d..d787844 100644 --- a/src/ansi.ts +++ b/src/ansi.ts @@ -189,60 +189,88 @@ export function ansiToHtml(text: string): string { while (i < text.length) { // Check for ESC character (char code 27) - if (text.charCodeAt(i) === 27 && text[i + 1] === "[") { - // Parse CSI sequence: ESC [ params command - let j = i + 2; - let params = ""; - while (j < text.length && /[0-9;]/.test(text[j] || "")) { - params += text[j]; - j++; - } - const command = text[j] || ""; - j++; - - 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(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++; } - - applyStyle(); + i = j; + 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; } diff --git a/src/seed.ts b/src/seed.ts index 6d3bd88..d9f31f0 100644 --- a/src/seed.ts +++ b/src/seed.ts @@ -12,9 +12,13 @@ initDb(); const existing = getDeviceBySecret(secret); if (existing) { - console.log(`device already exists: id=${existing.id} secret=${existing.secret} name=${existing.name}`); - process.exit(0); + console.log( + `device already exists: id=${existing.id} secret=${existing.secret} name=${existing.name}`, + ); + process.exit(0); } 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}`, +);