Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions ui-tui/src/__tests__/markdown.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,52 @@ describe('Md wrapping', () => {

expect(lines.some(line => line.startsWith(' hi ok'))).toBe(true)
})

it('renders markdown tables as fixed-width wrapped columns', () => {
const text = [
'| Date | Paper | Authors |',
'|---|---|---|',
'| **2026-05-08** | **LLMs Improving LLMs: Agentic Discovery for Test-Time Scaling** <br><https://arxiv.org/abs/2605.08083v1> | Tong Zheng, Haolin Liu, Chengsong Huang, Huiwen Bao, Sheng Zhang, Rui Liu, et al. |',
'| **2026-05-08** | **AgentEscapeBench: Evaluating Out-of-Domain Tool-Grounded Reasoning in LLM Agents** | Zhengkang Guo, Yiyang Li, Lin Qiu, Xiaohua Wang, Jingwen Xv, Dongyu Ru, et al. |'
].join('\n')
const lines = renderPlain(
React.createElement(Box, { width: 80 }, React.createElement(Md, { t: DEFAULT_THEME, text, width: 80 }))
)
const output = lines.join('\n')

expect(output).toContain('│ Date │ Paper')
expect(output).toContain('│ Authors')
expect(output).toContain('│ 2026-05-08 │ LLMs Improving LLMs')
expect(output).toContain('│ Tong Zheng')
expect(output).toContain('│ │ Discovery for Test-Time')
expect(output).toContain('│ │ Scaling')
expect(output).toContain('│ │ https://arxiv.org/abs/2605.080')
expect(output).toContain('│ │ 83v1')
expect(output).toContain('├────────────┼')
expect(output).toContain('└────────────┴')
expect(lines.filter(line => line.includes('─'))).toHaveLength(4)
expect(output).not.toContain('AgentsZhengkang')
expect(output).not.toContain('2026-0LLMs')
expect(output).not.toContain('<br>')
})

it('keeps bordered tables inside the visible body when width is slightly optimistic', () => {
const text = [
'| Date | Paper | Authors |',
'|---|---|---|',
'| 2026-05-08 | Learning CLI Agents with Structured Action Credit under Selective Observation https://arxiv.org/abs/2605.08013v1 | Haoyang Su, Ying Wen |',
'| 2026-05-08 | GazeVLM: Active Vision via Internal Attention Control for Multimodal Reasoning https://arxiv.org/abs/2605.07817v1 | Brown Ebouky, Gabriele Carrino, Niccolo Avogaro, Christoph Studer, Andrea Bartezzaghi, Mattia Rigotti |'
].join('\n')
const lines = renderPlain(
React.createElement(Box, { width: 76 }, React.createElement(Md, { t: DEFAULT_THEME, text, width: 80 }))
)
const output = lines.join('\n')

expect(output).not.toContain('…')
expect(output).not.toContain('...')
expect(output).toContain('│ 2026-05-08 │ Learning CLI Agents')
expect(output).toContain('└────────────┴')
})
})

describe('Md link labels', () => {
Expand Down
208 changes: 168 additions & 40 deletions ui-tui/src/components/markdown.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Box, Link, stringWidth, Text } from '@hermes/ink'
import { Fragment, memo, type ReactNode, useMemo } from 'react'
import { memo, type ReactNode, useMemo } from 'react'

import { ensureEmojiPresentation } from '../lib/emoji.js'
import { normalizeExternalUrl, urlSlugTitleLabel, useLinkTitle } from '../lib/externalLink.js'
Expand Down Expand Up @@ -200,45 +200,172 @@ export const stripInlineMarkup = (v: string) =>
.replace(/(?<!\$)\$([^\s$](?:[^$\n]*?[^\s$])?)\$(?!\$)/g, '$1')
.replace(/\\\(([^\n]+?)\\\)/g, '$1')

const renderTable = (k: number, rows: string[][], t: Theme) => {
// Column widths in *display cells*, not UTF-16 code units. CJK
// glyphs and most emoji render as two cells but `String#length`
// counts them as one, which collapses Chinese / Japanese / Korean
// tables into drift across rows. `stringWidth` (Bun.stringWidth
// fast path + an East-Asian-width-aware fallback, memoised in
// @hermes/ink) returns the actual cell count.
const cellWidth = (raw: string) => stringWidth(stripInlineMarkup(raw))

const widths = rows[0]!.map((_, ci) => Math.max(...rows.map(r => cellWidth(r[ci] ?? ''))))

// Thin divider under the header. Without it tables look like prose
// with extra spacing because the header is just accent-coloured text
// (#15534). We avoid full borders on purpose — column widths come
// from `stringWidth(...)`, so the dividers and the row content stay
// in sync on CJK / emoji tables; tab-style column gaps still read
// cleanly without the boxed look.
const sep = widths.map(w => '─'.repeat(Math.max(1, w))).join(' ')
const tableCellText = (cell: string) =>
cell
.replace(/<br\s*\/?>/gi, ' ')
.replace(/\s+/g, ' ')
.trim()

const tableCellWidth = (cell: string) => stringWidth(stripInlineMarkup(tableCellText(cell)).replace(/\s+/g, ' ').trim())

const TABLE_SAFE_GUTTER = 4
const tableBorderWidth = (columnCount: number) => columnCount * 3 + 1

const tableWidths = (rows: string[][], availableWidth: number) => {
const preferred = rows[0]!.map((_, ci) => Math.max(...rows.map(r => tableCellWidth(r[ci] ?? ''))))

const contentWidth = Math.max(
preferred.length * 8,
availableWidth - TABLE_SAFE_GUTTER - 2 - tableBorderWidth(preferred.length)
)

const preferredTotal = preferred.reduce((sum, width) => sum + width, 0)

if (preferredTotal <= contentWidth) {
return preferred
}

const minimum = preferred.map((width, index) => Math.min(width, index === 0 ? 10 : 18))
let remaining = contentWidth - minimum.reduce((sum, width) => sum + width, 0)

if (remaining <= 0) {
const width = Math.max(6, Math.floor(contentWidth / preferred.length))

return preferred.map(() => width)
}

const extra = preferred.map((width, index) => Math.max(0, width - minimum[index]!))
let extraTotal = extra.reduce((sum, width) => sum + width, 0)
const widths = [...minimum]

while (remaining > 0 && extraTotal > 0) {
let used = 0

for (let i = 0; i < widths.length && remaining > 0; i++) {
if (extra[i]! <= 0) {
continue
}

const share = Math.max(1, Math.floor((remaining * extra[i]!) / extraTotal))
const add = Math.min(share, extra[i]!, remaining)
widths[i] += add
extra[i] -= add
remaining -= add
used += add
}

if (!used) {
break
}

extraTotal = extra.reduce((sum, width) => sum + width, 0)
}

return widths
}

const splitLongWord = (word: string, width: number) => {
const parts: string[] = []
let part = ''

for (const char of [...word]) {
if (part && stringWidth(part) + stringWidth(char) > width) {
parts.push(part)
part = char
} else {
part += char
}
}

if (part) {
parts.push(part)
}

return parts
}

const padDisplayEnd = (value: string, width: number) => `${value}${' '.repeat(Math.max(0, width - stringWidth(value)))}`

const wrapTableCell = (cell: string, width: number) => {
const words = stripInlineMarkup(cell).split(/\s+/).filter(Boolean)
const lines: string[] = []
let line = ''

for (const word of words) {
const parts = stringWidth(word) > width ? splitLongWord(word, width) : [word]

for (const part of parts) {
if (!line) {
line = part
} else if (stringWidth(line) + 1 + stringWidth(part) <= width) {
line += ` ${part}`
} else {
lines.push(line)
line = part
}
}
}

if (line) {
lines.push(line)
}

return lines.length ? lines : ['']
}

const tableLine = (row: string[][], widths: number[], line: number) => {
const parts: ReactNode[] = [
<Text color="white" key="start">
│{' '}
</Text>
]

widths.forEach((width, ci) => {
parts.push(<Text key={`cell-${ci}`}>{padDisplayEnd(row[ci]?.[line] ?? '', width)}</Text>)
parts.push(
<Text color="white" key={`border-${ci}`}>
{' '}
│{ci < widths.length - 1 ? ' ' : ''}
</Text>
)
})

return parts
}

const tableBorder = (widths: number[], left: string, join: string, right: string) =>
`${left}${widths.map(w => '─'.repeat(w + 2)).join(join)}${right}`

const renderTable = (k: number, rows: string[][], t: Theme, width?: number) => {
const displayRows = rows.map(row => row.map(tableCellText))
const widths = tableWidths(displayRows, width ?? 80)
const topBorder = tableBorder(widths, '┌', '┬', '┐')
const headerBorder = tableBorder(widths, '├', '┼', '┤')
const rowBorder = tableBorder(widths, '├', '┼', '┤')
const bottomBorder = tableBorder(widths, '└', '┴', '┘')
const wrappedRows = displayRows.map(row => row.map((cell, ci) => wrapTableCell(cell, widths[ci]!)))

return (
<Box flexDirection="column" key={k} paddingLeft={2}>
{rows.map((row, ri) => (
<Fragment key={ri}>
<Box>
{widths.map((w, ci) => (
<Text bold={ri === 0} color={ri === 0 ? t.color.accent : undefined} key={ci}>
<MdInline t={t} text={row[ci] ?? ''} />
{' '.repeat(Math.max(0, w - cellWidth(row[ci] ?? '')))}
{ci < widths.length - 1 ? ' ' : ''}
<Text color="white" wrap="truncate-end">
{topBorder}
</Text>
{wrappedRows.map((row, ri) => {
const rowHeight = Math.max(...row.map(cell => cell.length))

return (
<Box flexDirection="column" key={ri}>
{Array.from({ length: rowHeight }, (_, line) => (
<Text bold={ri === 0} key={line} wrap="truncate-end">
{tableLine(row, widths, line)}
</Text>
))}
</Box>
{ri === 0 && rows.length > 1 ? (
<Text color={t.color.muted} dimColor>
{sep}
<Text color="white" wrap="truncate-end">
{ri === wrappedRows.length - 1 ? bottomBorder : ri === 0 ? headerBorder : rowBorder}
</Text>
) : null}
</Fragment>
))}
</Box>
)
})}
</Box>
)
}
Expand Down Expand Up @@ -395,10 +522,10 @@ const cacheSet = (b: Map<string, ReactNode[]>, key: string, v: ReactNode[]) => {
}
}

function MdImpl({ compact, t, text }: MdProps) {
function MdImpl({ compact, t, text, width }: MdProps) {
const nodes = useMemo(() => {
const bucket = cacheBucket(t)
const cacheKey = `${compact ? '1' : '0'}|${text}`
const cacheKey = `${compact ? '1' : '0'}|${width ?? ''}|${text}`
const cached = cacheGet(bucket, cacheKey)

if (cached) {
Expand Down Expand Up @@ -490,7 +617,7 @@ function MdImpl({ compact, t, text }: MdProps) {

if (['md', 'markdown'].includes(lang)) {
start('paragraph')
nodes.push(<Md compact={compact} key={key} t={t} text={block.join('\n')} />)
nodes.push(<Md compact={compact} key={key} t={t} text={block.join('\n')} width={width} />)

continue
}
Expand Down Expand Up @@ -785,7 +912,7 @@ function MdImpl({ compact, t, text }: MdProps) {
rows.push(splitRow(lines[i]!))
}

nodes.push(renderTable(key, rows, t))
nodes.push(renderTable(key, rows, t, width))

continue
}
Expand Down Expand Up @@ -838,7 +965,7 @@ function MdImpl({ compact, t, text }: MdProps) {
}

if (rows.length) {
nodes.push(renderTable(key, rows, t))
nodes.push(renderTable(key, rows, t, width))
}

continue
Expand All @@ -852,7 +979,7 @@ function MdImpl({ compact, t, text }: MdProps) {
cacheSet(bucket, cacheKey, nodes)

return nodes
}, [compact, t, text])
}, [compact, t, text, width])

return <Box flexDirection="column">{nodes}</Box>
}
Expand All @@ -865,4 +992,5 @@ interface MdProps {
compact?: boolean
t: Theme
text: string
width?: number
}
12 changes: 9 additions & 3 deletions ui-tui/src/components/messageLine.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ export const MessageLine = memo(function MessageLine({

const { body, glyph, prefix } = ROLE[msg.role](t)
const gutterWidth = transcriptGutterWidth(msg.role, t.brand.prompt)
const bodyWidth = transcriptBodyWidth(cols, msg.role, t.brand.prompt)

const showDetails =
(toolsMode !== 'hidden' && Boolean(msg.tools?.length)) || (thinkingMode !== 'hidden' && Boolean(thinking))
Expand Down Expand Up @@ -143,9 +144,14 @@ export const MessageLine = memo(function MessageLine({
// Incremental markdown: split at the last stable block boundary so
// only the in-flight tail re-tokenizes per delta. See
// streamingMarkdown.tsx for the cost model.
<StreamingMd compact={compact} t={t} text={boundedLiveRenderText(msg.text)} />
<StreamingMd compact={compact} t={t} text={boundedLiveRenderText(msg.text)} width={bodyWidth} />
) : (
<Md compact={compact} t={t} text={limitHistoryRender ? boundedHistoryRenderText(msg.text) : msg.text} />
<Md
compact={compact}
t={t}
text={limitHistoryRender ? boundedHistoryRenderText(msg.text) : msg.text}
width={bodyWidth}
/>
)
}

Expand Down Expand Up @@ -199,7 +205,7 @@ export const MessageLine = memo(function MessageLine({
</Text>
</NoSelect>

<Box width={transcriptBodyWidth(cols, msg.role, t.brand.prompt)}>{content}</Box>
<Box width={bodyWidth}>{content}</Box>
</Box>
</Box>
)
Expand Down
11 changes: 6 additions & 5 deletions ui-tui/src/components/streamingMarkdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ export const findStableBoundary = (text: string) => {
return -1
}

export const StreamingMd = memo(function StreamingMd({ compact, t, text }: StreamingMdProps) {
export const StreamingMd = memo(function StreamingMd({ compact, t, text, width }: StreamingMdProps) {
const stablePrefixRef = useRef('')

// Reset if the text no longer starts with our recorded prefix (defensive;
Expand All @@ -151,17 +151,17 @@ export const StreamingMd = memo(function StreamingMd({ compact, t, text }: Strea
const unstableSuffix = text.slice(stablePrefix.length)

if (!stablePrefix) {
return <Md compact={compact} t={t} text={unstableSuffix} />
return <Md compact={compact} t={t} text={unstableSuffix} width={width} />
}

if (!unstableSuffix) {
return <Md compact={compact} t={t} text={stablePrefix} />
return <Md compact={compact} t={t} text={stablePrefix} width={width} />
}

return (
<Box flexDirection="column">
<Md compact={compact} t={t} text={stablePrefix} />
<Md compact={compact} t={t} text={unstableSuffix} />
<Md compact={compact} t={t} text={stablePrefix} width={width} />
<Md compact={compact} t={t} text={unstableSuffix} width={width} />
</Box>
)
})
Expand All @@ -170,4 +170,5 @@ interface StreamingMdProps {
compact?: boolean
t: Theme
text: string
width?: number
}