function stripAnsi(s: string): string { return s.replace(/\x1b\]8;;[^\x07]*\x07/g, "").replace(/\x1b\[[0-9;]*m/g, "") } function renderTable(block: string): string { const lines = block.trim().split("\n") if (lines.length < 2) return block const parseRow = (line: string): string[] => line.replace(/^\|/, "").replace(/\|$/, "").split("|").map(c => c.trim()) const header = parseRow(lines[0]) const sepCells = parseRow(lines[1]) // Validate separator row if (!sepCells.every(s => /^:?-+:?$/.test(s.trim()))) return block const cols = header.length const align: ("left" | "center" | "right")[] = sepCells.map(s => { const t = s.trim() if (t.startsWith(":") && t.endsWith(":")) return "center" if (t.endsWith(":")) return "right" return "left" }) const rows = lines.slice(2).map(parseRow) // Column widths based on visible text (no ANSI codes) const widths = new Array(cols).fill(0) for (let c = 0; c < cols; c++) { widths[c] = Math.max(widths[c], stripAnsi(header[c] ?? "").length) for (const row of rows) { widths[c] = Math.max(widths[c], stripAnsi(row[c] ?? "").length) } } const pad = (text: string, width: number, a: "left" | "center" | "right"): string => { const needed = width - stripAnsi(text).length if (needed <= 0) return text if (a === "right") return " ".repeat(needed) + text if (a === "center") { const l = Math.floor(needed / 2) return " ".repeat(l) + text + " ".repeat(needed - l) } return text + " ".repeat(needed) } const D = "\x1b[2m" // dim const R = "\x1b[22m" // reset intensity const renderRow = (cells: string[], bold: boolean): string => { const parts = cells.map((c, i) => pad(c ?? "", widths[i] ?? 0, align[i] ?? "left")) if (bold) { return `${D}│${R} ${parts.map(p => `\x1b[1m${p}\x1b[22m`).join(` ${D}│${R} `)} ${D}│${R}` } return `${D}│${R} ${parts.join(` ${D}│${R} `)} ${D}│${R}` } const hline = (l: string, m: string, r: string): string => { return `${D}${l}${widths.map(w => "─".repeat(w + 2)).join(m)}${r}${R}` } const out: string[] = [] out.push(hline("┌", "┬", "┐")) out.push(renderRow(header, true)) out.push(hline("├", "┼", "┤")) for (const row of rows) out.push(renderRow(row, false)) out.push(hline("└", "┴", "┘")) return out.join("\n") } export function renderMarkdown(text: string): string { // Extract fenced code blocks before anything else const codeBlocks: string[] = [] let result = text.replace(/^```\w*\n([\s\S]*?)^```\s*$/gm, (_, content) => { codeBlocks.push(content) return `\x00CODEBLOCK${codeBlocks.length - 1}\x00` }) // Extract backslash escapes so they aren't processed as markdown const escapes: string[] = [] result = result.replace(/\\([\\`*_~\[\]()#>!-])/g, (_, ch) => { escapes.push(ch) return `\x00ESC${escapes.length - 1}\x00` }) // Extract code spans so their contents aren't processed as bold/italic const codeSpans: string[] = [] result = result.replace(/`([^`]+)`/g, (_, code) => { codeSpans.push(code) return `\x00CODE${codeSpans.length - 1}\x00` }) // Links: [text](url) → OSC 8 terminal hyperlink, underlined blue // Must run before H1/bold/italic so ANSI `[` chars don't confuse the link regex result = result.replace(/(? { const url = href.replace(/\s+"[^"]*"$/, "") // strip optional title return `\x1b]8;;${url}\x07\x1b[4;38;5;75m${text}\x1b[24;39m\x1b]8;;\x07` }) // H1: # Header → bold+italic+underline result = result.replace(/^# (.+)$/gm, "\x1b[1;3;4m$1\x1b[22;23;24m") // H2/H3: ## Header / ### Header → bold result = result.replace(/^#{2,6} (.+)$/gm, "\x1b[1m$1\x1b[22m") // Blockquotes: > text → dim+italic result = result.replace(/^> (.+)$/gm, "\x1b[2;3m$1\x1b[22;23m") // Bare blockquote lines: > with no text result = result.replace(/^>\s*$/gm, "") // Task lists: - [x] → ✓ green, - [ ] → ○ dim result = result.replace(/^(\s*)[-*] \[x\] (.+)$/gm, "$1\x1b[32m✓\x1b[39m $2") result = result.replace(/^(\s*)[-*] \[ \] (.+)$/gm, "$1\x1b[2m☐\x1b[22m $2") // Bold: **text** result = result.replace(/\*\*(.+?)\*\*/g, "\x1b[1m$1\x1b[22m") // Italic: *text* result = result.replace(/\*(.+?)\*/g, "\x1b[3m$1\x1b[23m") // Restore code spans as light blue result = result.replace(/\x00CODE(\d+)\x00/g, (_, i) => { return `\x1b[38;5;147m${codeSpans[parseInt(i)]}\x1b[39m` }) // Restore backslash escapes as literal characters result = result.replace(/\x00ESC(\d+)\x00/g, (_, i) => escapes[parseInt(i)]) // Tables: render pipe tables with box-drawing characters // Processed after inline formatting so cell contents are styled, // but before code block restoration so tables inside code blocks are ignored. result = result.replace( /^(\|[^\n]+\|\n)(\|[\s:|-]+\|\n)((?:\|[^\n]+\|\n?)*)/gm, (match) => renderTable(match), ) // Restore fenced code blocks as plain text result = result.replace(/\x00CODEBLOCK(\d+)\x00/g, (_, i) => codeBlocks[parseInt(i)]) // Breathe: add blank line before list starts when preceded by non-empty text const lines = result.split("\n") for (let i = lines.length - 1; i > 0; i--) { if (/^[\s]*[-*] /.test(lines[i]) && lines[i - 1].trim() !== "" && !/^[\s]*[-*] /.test(lines[i - 1])) { lines.splice(i, 0, "") } } result = lines.join("\n") return result }