From 24a498eb9027983aa93afb3e3b671e3d897f9311 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 11 Apr 2026 17:15:36 -0500 Subject: [PATCH] feat: better markdown --- ui-tui/src/__tests__/text.test.ts | 16 +- ui-tui/src/components/markdown.tsx | 432 ++++++++++++++++++++++++----- ui-tui/src/lib/text.ts | 33 ++- 3 files changed, 407 insertions(+), 74 deletions(-) diff --git a/ui-tui/src/__tests__/text.test.ts b/ui-tui/src/__tests__/text.test.ts index 55b6a272b3..d43f6d56f4 100644 --- a/ui-tui/src/__tests__/text.test.ts +++ b/ui-tui/src/__tests__/text.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' -import { fmtK, isToolTrailResultLine, lastCotTrailIndex, sameToolTrailGroup } from '../lib/text.js' +import { estimateRows, fmtK, isToolTrailResultLine, lastCotTrailIndex, sameToolTrailGroup } from '../lib/text.js' describe('isToolTrailResultLine', () => { it('detects completion markers', () => { @@ -49,3 +49,17 @@ describe('fmtK', () => { expect(fmtK(1_000_000_000)).toBe('1B') }) }) + +describe('estimateRows', () => { + it('handles tilde code fences', () => { + const md = ['~~~markdown', '# heading', '~~~'].join('\n') + + expect(estimateRows(md, 40)).toBeGreaterThanOrEqual(2) + }) + + it('handles checklist bullets as list rows', () => { + const md = ['- [x] done', '- [ ] todo'].join('\n') + + expect(estimateRows(md, 40)).toBe(2) + }) +}) diff --git a/ui-tui/src/components/markdown.tsx b/ui-tui/src/components/markdown.tsx index 64403c2977..8d5cf888fe 100644 --- a/ui-tui/src/components/markdown.tsx +++ b/ui-tui/src/components/markdown.tsx @@ -3,17 +3,104 @@ import type { ReactNode } from 'react' import type { Theme } from '../theme.js' -/** OSC 8 hyperlink — wrap-ansi / Ink keep the link active across soft line wraps. */ -const osc8 = (url: string) => '\x1b]8;;' + url + '\x1b\\' -const OSC8_END = '\x1b]8;;\x1b\\' +const FENCE_RE = /^\s*(`{3,}|~{3,})(.*)$/ +const HR_RE = /^ {0,3}([-*_])(?:\s*\1){2,}\s*$/ +const HEADING_RE = /^\s{0,3}(#{1,6})\s+(.*?)(?:\s+#+\s*)?$/ +const FOOTNOTE_RE = /^\[\^([^\]]+)\]:\s*(.*)$/ +const DEF_RE = /^\s*:\s+(.+)$/ +const TABLE_DIVIDER_CELL_RE = /^:?-{3,}:?$/ +const MD_URL_RE = '((?:[^\\s()]|\\([^\\s()]*\\))+?)' +const INLINE_RE = + new RegExp( + `(!\\[(.*?)\\]\\(${MD_URL_RE}\\)|\\[(.+?)\\]\\(${MD_URL_RE}\\)|<((?:https?:\\/\\/|mailto:)[^>\\s]+|[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,})>|~~(.+?)~~|\`([^\\\`]+)\`|\\*\\*(.+?)\\*\\*|__(.+?)__|\\*(.+?)\\*|_(.+?)_|==(.+?)==|\\[\\^([^\\]]+)\\]|\\^([^^\\s][^^]*?)\\^|~([^~\\s][^~]*?)~|(https?:\\/\\/[^\\s<]+))`, + 'g' + ) + +type Fence = { + char: '`' | '~' + lang: string + len: number +} + +const renderLink = (key: number, t: Theme, label: string) => ( + + {label} + +) + +const trimBareUrl = (value: string) => { + const trimmed = value.replace(/[),.;:!?]+$/g, '') + + return { + tail: value.slice(trimmed.length), + url: trimmed + } +} + +const renderAutolink = (key: number, t: Theme, raw: string) => ( + + {raw.replace(/^mailto:/, '')} + +) + +const indentDepth = (indent: string) => Math.floor(indent.replace(/\t/g, ' ').length / 2) + +const parseFence = (line: string): Fence | null => { + const m = line.match(FENCE_RE) + + if (!m) { + return null + } + + return { + char: m[1]![0] as '`' | '~', + lang: m[2]!.trim().toLowerCase(), + len: m[1]!.length + } +} + +const isFenceClose = (line: string, fence: Fence) => { + const end = line.match(/^\s*(`{3,}|~{3,})\s*$/) + + return Boolean(end && end[1]![0] === fence.char && end[1]!.length >= fence.len) +} + +const isMarkdownFence = (lang: string) => ['md', 'markdown'].includes(lang) + +const splitTableRow = (row: string) => + row + .trim() + .replace(/^\|/, '') + .replace(/\|$/, '') + .split('|') + .map(cell => cell.trim()) + +const isTableDivider = (row: string) => { + const cells = splitTableRow(row) + + return cells.length > 1 && cells.every(cell => TABLE_DIVIDER_CELL_RE.test(cell)) +} + +const renderTable = (key: number, rows: string[][], t: Theme) => { + const widths = rows[0]!.map((_, ci) => Math.max(...rows.map(r => (r[ci] ?? '').length))) + + return ( + + {rows.map((row, ri) => ( + + {row.map((cell, ci) => cell.padEnd(widths[ci] ?? 0)).join(' ')} + + ))} + + ) +} function MdInline({ t, text }: { t: Theme; text: string }) { const parts: ReactNode[] = [] - const re = /(\[(.+?)\]\((https?:\/\/[^\s)]+)\)|\*\*(.+?)\*\*|`([^`]+)`|\*(.+?)\*|(https?:\/\/[^\s]+))/g let last = 0 - for (const m of text.matchAll(re)) { + for (const m of text.matchAll(INLINE_RE)) { const i = m.index ?? 0 if (i > last) { @@ -22,43 +109,74 @@ function MdInline({ t, text }: { t: Theme; text: string }) { if (m[2] && m[3]) { parts.push( - - {osc8(m[3])} - - {m[2]} - - {OSC8_END} + + [image: {m[2]}] {m[3]} ) - } else if (m[4]) { + } else if (m[4] && m[5]) { + parts.push(renderLink(parts.length, t, m[4])) + } else if (m[6]) { + parts.push(renderAutolink(parts.length, t, m[6])) + } else if (m[7]) { parts.push( - - {m[4]} + + {m[7]} ) - } else if (m[5]) { + } else if (m[8]) { parts.push( - {m[5]} + {m[8]} ) - } else if (m[6]) { + } else if (m[9] || m[10]) { + parts.push( + + {m[9] ?? m[10]} + + ) + } else if (m[11] || m[12]) { parts.push( - {m[6]} + {m[11] ?? m[12]} ) - } else if (m[7]) { - const u = m[7] + } else if (m[13]) { parts.push( - - {osc8(u)} - - {u} - - {OSC8_END} + + {m[13]} ) + } else if (m[14]) { + parts.push( + + [{m[14]}] + + ) + } else if (m[15]) { + parts.push( + + ^{m[15]} + + ) + } else if (m[16]) { + parts.push( + + _{m[16]} + + ) + } else if (m[17]) { + const { tail, url } = trimBareUrl(m[17]) + + parts.push(renderAutolink(parts.length, t, url)) + + if (tail) { + parts.push( + + {tail} + + ) + } } last = i + m[0].length @@ -75,7 +193,16 @@ export function Md({ compact, t, text }: { compact?: boolean; t: Theme; text: st const lines = text.split('\n') const nodes: ReactNode[] = [] let i = 0 - let prevKind: 'blank' | 'code' | 'heading' | 'list' | 'paragraph' | 'quote' | 'table' | null = null + let prevKind: + | 'blank' + | 'code' + | 'heading' + | 'list' + | 'paragraph' + | 'quote' + | 'rule' + | 'table' + | null = null const gap = () => { if (nodes.length && prevKind !== 'blank') { @@ -109,16 +236,29 @@ export function Md({ compact, t, text }: { compact?: boolean; t: Theme; text: st continue } - if (line.startsWith('```')) { - start('code') - const lang = line.slice(3).trim() - const block: string[] = [] + const fence = parseFence(line) - for (i++; i < lines.length && !lines[i]!.startsWith('```'); i++) { + if (fence) { + const block: string[] = [] + const lang = fence.lang + + for (i++; i < lines.length && !isFenceClose(lines[i]!, fence); i++) { block.push(lines[i]!) } - i++ + if (i < lines.length) { + i++ + } + + if (isMarkdownFence(lang)) { + start('paragraph') + nodes.push() + + continue + } + + start('code') + const isDiff = lang === 'diff' nodes.push( @@ -146,13 +286,42 @@ export function Md({ compact, t, text }: { compact?: boolean; t: Theme; text: st continue } - const heading = line.match(/^#{1,3}\s+(.*)/) + if (line.trim().startsWith('$$')) { + start('code') + + const block: string[] = [] + + for (i++; i < lines.length; i++) { + if (lines[i]!.trim().startsWith('$$')) { + i++ + + break + } + + block.push(lines[i]!) + } + + nodes.push( + + ─ math + {block.map((l, j) => ( + + {l} + + ))} + + ) + + continue + } + + const heading = line.match(HEADING_RE) if (heading) { start('heading') nodes.push( - {heading[1]} + {heading[2]} ) i++ @@ -160,14 +329,103 @@ export function Md({ compact, t, text }: { compact?: boolean; t: Theme; text: st continue } - const bullet = line.match(/^\s*[-*]\s(.*)/) + if (i + 1 < lines.length && line.trim()) { + const setext = lines[i + 1]!.match(/^\s{0,3}(=+|-+)\s*$/) + + if (setext) { + start('heading') + nodes.push( + + {line.trim()} + + ) + i += 2 + + continue + } + } + + if (HR_RE.test(line)) { + start('rule') + nodes.push( + + {'─'.repeat(36)} + + ) + i++ + + continue + } + + const footnote = line.match(FOOTNOTE_RE) + + if (footnote) { + start('list') + nodes.push( + + [{footnote[1]}] + + ) + i++ + + while (i < lines.length && /^\s{2,}\S/.test(lines[i]!)) { + nodes.push( + + + + + + ) + i++ + } + + continue + } + + if (i + 1 < lines.length && DEF_RE.test(lines[i + 1]!)) { + start('list') + nodes.push( + + {line.trim()} + + ) + i++ + + while (i < lines.length) { + const def = lines[i]!.match(DEF_RE) + + if (!def) { + break + } + + nodes.push( + + · + + + ) + i++ + } + + continue + } + + const bullet = line.match(/^(\s*)[-+*]\s+(.*)$/) if (bullet) { start('list') + const depth = indentDepth(bullet[1]!) + const task = bullet[2]!.match(/^\[( |x|X)\]\s+(.*)$/) + const marker = task ? (task[1]!.toLowerCase() === 'x' ? '☑' : '☐') : '•' + const body = task ? task[2]! : bullet[2]! + nodes.push( - - + + {' '.repeat(depth * 2)} + {marker}{' '} + + ) i++ @@ -175,14 +433,19 @@ export function Md({ compact, t, text }: { compact?: boolean; t: Theme; text: st continue } - const numbered = line.match(/^\s*(\d+)\.\s(.*)/) + const numbered = line.match(/^(\s*)(\d+)[.)]\s+(.*)$/) if (numbered) { start('list') + const depth = indentDepth(numbered[1]!) + nodes.push( - {numbered[1]}. - + + {' '.repeat(depth * 2)} + {numbered[2]}.{' '} + + ) i++ @@ -190,12 +453,18 @@ export function Md({ compact, t, text }: { compact?: boolean; t: Theme; text: st continue } - if (line.match(/^>\s?/)) { + if (/^\s*(?:>\s*)+/.test(line)) { start('quote') - const quoteLines: string[] = [] + const quoteLines: Array<{ depth: number; text: string }> = [] - while (i < lines.length && lines[i]!.match(/^>\s?/)) { - quoteLines.push(lines[i]!.replace(/^>\s?/, '')) + while (i < lines.length && /^\s*(?:>\s*)+/.test(lines[i]!)) { + const raw = lines[i]! + const prefix = raw.match(/^\s*(?:>\s*)+/)?.[0] ?? '' + + quoteLines.push({ + depth: (prefix.match(/>/g) ?? []).length, + text: raw.slice(prefix.length) + }) i++ } @@ -203,8 +472,9 @@ export function Md({ compact, t, text }: { compact?: boolean; t: Theme; text: st {quoteLines.map((ql, qi) => ( - {' │ '} - + {' '.repeat(Math.max(0, ql.depth - 1) * 2)} + {'│ '} + ))} @@ -213,6 +483,55 @@ export function Md({ compact, t, text }: { compact?: boolean; t: Theme; text: st continue } + if (line.includes('|') && i + 1 < lines.length && isTableDivider(lines[i + 1]!)) { + start('table') + const tableRows: string[][] = [] + + tableRows.push(splitTableRow(line)) + i += 2 + + while (i < lines.length && lines[i]!.includes('|') && lines[i]!.trim()) { + tableRows.push(splitTableRow(lines[i]!)) + i++ + } + + nodes.push(renderTable(key, tableRows, t)) + + continue + } + + if (/^/i.test(line)) { + i++ + + continue + } + + const summary = line.match(/^(.*?)<\/summary>$/i) + + if (summary) { + start('paragraph') + nodes.push( + + ▶ {summary[1]} + + ) + i++ + + continue + } + + if (/^<\/?[^>]+>$/.test(line.trim())) { + start('paragraph') + nodes.push( + + {line.trim()} + + ) + i++ + + continue + } + if (line.includes('|') && line.trim().startsWith('|')) { start('table') const tableRows: string[][] = [] @@ -221,29 +540,14 @@ export function Md({ compact, t, text }: { compact?: boolean; t: Theme; text: st const row = lines[i]!.trim() if (!/^[|\s:-]+$/.test(row)) { - tableRows.push( - row - .split('|') - .filter(Boolean) - .map(c => c.trim()) - ) + tableRows.push(splitTableRow(row)) } i++ } if (tableRows.length) { - const widths = tableRows[0]!.map((_, ci) => Math.max(...tableRows.map(r => (r[ci] ?? '').length))) - - nodes.push( - - {tableRows.map((row, ri) => ( - - {row.map((cell, ci) => cell.padEnd(widths[ci] ?? 0)).join(' ')} - - ))} - - ) + nodes.push(renderTable(key, tableRows, t)) } continue diff --git a/ui-tui/src/lib/text.ts b/ui-tui/src/lib/text.ts index fb42943184..461fbc8b00 100644 --- a/ui-tui/src/lib/text.ts +++ b/ui-tui/src/lib/text.ts @@ -19,14 +19,21 @@ const renderEstimateLine = (line: string) => { } return line + .replace(/!\[(.*?)\]\(([^)\s]+)\)/g, '[image: $1]') .replace(/\[(.+?)\]\((https?:\/\/[^\s)]+)\)/g, '$1') .replace(/`([^`]+)`/g, '$1') .replace(/\*\*(.+?)\*\*/g, '$1') + .replace(/__(.+?)__/g, '$1') .replace(/\*(.+?)\*/g, '$1') - .replace(/^#{1,3}\s+/, '') - .replace(/^\s*[-*]\s+/, '• ') + .replace(/_(.+?)_/g, '$1') + .replace(/~~(.+?)~~/g, '$1') + .replace(/==(.+?)==/g, '$1') + .replace(/\[\^([^\]]+)\]/g, '[$1]') + .replace(/^#{1,6}\s+/, '') + .replace(/^\s*[-*+]\s+\[( |x|X)\]\s+/, (_m, checked: string) => `• [${checked.toLowerCase() === 'x' ? 'x' : ' '}] `) + .replace(/^\s*[-*+]\s+/, '• ') .replace(/^\s*(\d+)\.\s+/, '$1. ') - .replace(/^>\s?/, '│ ') + .replace(/^\s*(?:>\s*)+/, '│ ') } export const compactPreview = (s: string, max: number) => { @@ -79,26 +86,34 @@ export const scaleHex = (hex: string, k: number) => { } export const estimateRows = (text: string, w: number, compact = false) => { - let inCode = false + let fence: { char: '`' | '~'; len: number } | null = null let rows = 0 for (const raw of text.split('\n')) { const line = stripAnsi(raw) + const maybeFence = line.match(/^\s*(`{3,}|~{3,})(.*)$/) - if (line.startsWith('```')) { - if (!inCode) { - const lang = line.slice(3).trim() + if (maybeFence) { + const marker = maybeFence[1]! + const lang = maybeFence[2]!.trim() + + if (!fence) { + fence = { + char: marker[0] as '`' | '~', + len: marker.length + } if (lang) { rows += Math.ceil((`─ ${lang}`.length || 1) / w) } + } else if (marker[0] === fence.char && marker.length >= fence.len) { + fence = null } - inCode = !inCode - continue } + const inCode = Boolean(fence) const trimmed = line.trim() if (!inCode && trimmed.startsWith('|') && /^[|\s:-]+$/.test(trimmed)) {