From ecf7b93f3fb41af7281ab0d4af494d838bfcddc3 Mon Sep 17 00:00:00 2001 From: Chris Wanstrath Date: Fri, 20 Feb 2026 12:21:12 -0800 Subject: [PATCH 1/2] Add OSC 8 terminal hyperlink rendering for markdown links --- src/markdown.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/markdown.ts b/src/markdown.ts index 064d22f..69e0425 100644 --- a/src/markdown.ts +++ b/src/markdown.ts @@ -24,6 +24,12 @@ export function renderMarkdown(text: string): string { // Italic: *text* result = result.replace(/\*(.+?)\*/g, "\x1b[3m$1\x1b[23m") + // Links: [text](url) → OSC 8 terminal hyperlink, underlined blue + 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` + }) + // Restore code spans as light blue result = result.replace(/\x00CODE(\d+)\x00/g, (_, i) => { return `\x1b[38;5;147m${codeSpans[parseInt(i)]}\x1b[39m` From b4b72e2c023c28ca8321039a1f722a25b5bea19b Mon Sep 17 00:00:00 2001 From: Chris Wanstrath Date: Fri, 20 Feb 2026 15:22:09 -0800 Subject: [PATCH 2/2] fix markdown rendering: protect code blocks/escapes, move links before bold/italic, add task lists and H2-H6 support --- src/markdown.ts | 45 +++++++++++++++++++++++++++++++++++---------- 1 file changed, 35 insertions(+), 10 deletions(-) diff --git a/src/markdown.ts b/src/markdown.ts index 69e0425..4644882 100644 --- a/src/markdown.ts +++ b/src/markdown.ts @@ -1,40 +1,65 @@ export function renderMarkdown(text: string): string { - // Strip code fences (``` and ```language) — we're already in a terminal - let result = text.replace(/^```\w*\n?/gm, "") + // 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 code spans first so their contents aren't processed as bold/italic + // 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,3} (.+)$/gm, "\x1b[1m$1\x1b[22m") + 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") - // Links: [text](url) → OSC 8 terminal hyperlink, underlined blue - 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` - }) - // 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)]) + + // 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--) {