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)) {