opentui(v5/item7): render GFM markdown tables as aligned grids

The native <code filetype=markdown> colorizes pipes but never aligns tables.
Add a pure segmenter (segmentMarkdown) that splits assistant text into prose
runs (native renderable) and GFM table blocks, plus an MdTable grid renderer:
per-column widths (free-code's stringWidth+padAligned), :--- / :--: / ---:
alignment, bold header, dim │ separators, a ┼ header rule, width-aware column
shrink on resize. Incomplete tables (no separator yet, e.g. mid-stream) stay
prose until they close. +5 unit tests (85 pass); verified live with a 3-col table.
This commit is contained in:
alt-glitch 2026-06-09 03:48:02 +00:00
parent 180fe665cb
commit c6e72a8454
4 changed files with 270 additions and 5 deletions

View file

@ -0,0 +1,131 @@
/**
* GFM table support (item 7). The native `<code filetype="markdown">` colorizes
* pipes but never aligns tables into a grid, so we split assistant text into
* table blocks (rendered by view/mdTable.tsx as an aligned grid) and everything
* else (rendered by the native markdown renderable). Pure + unit-testable no
* OpenTUI/Solid imports. The column-width + alignment algorithm mirrors
* free-code's MarkdownTable (stringWidth + padAligned).
*/
export type Align = 'left' | 'center' | 'right'
/** A run of non-table markdown, rendered by the native renderable. */
export interface MdSegment {
readonly kind: 'md'
readonly text: string
}
/** A parsed GFM table, rendered as an aligned grid. */
export interface TableSegment {
readonly kind: 'table'
readonly header: readonly string[]
readonly rows: readonly (readonly string[])[]
readonly align: readonly Align[]
}
export type Segment = MdSegment | TableSegment
/** Display width of a string (monospace cells; ASCII-accurate, good enough for CJK). */
export function cellWidth(s: string): number {
return [...s].length
}
/** Pad `content` (which occupies `width` columns) to `target` columns per `align`. */
export function padAligned(content: string, target: number, align: Align): string {
const pad = Math.max(0, target - cellWidth(content))
if (align === 'right') return ' '.repeat(pad) + content
if (align === 'center') {
const left = Math.floor(pad / 2)
return ' '.repeat(left) + content + ' '.repeat(pad - left)
}
return content + ' '.repeat(pad)
}
/** Split a GFM row `| a | b |` into trimmed cells (drop the empty edge cells). */
function splitRow(line: string): string[] {
let s = line.trim()
if (s.startsWith('|')) s = s.slice(1)
if (s.endsWith('|')) s = s.slice(0, -1)
// split on unescaped pipes
return s.split(/(?<!\\)\|/).map(c => c.trim().replace(/\\\|/g, '|'))
}
/** Is `line` a GFM separator row (e.g. `| --- | :--: | ---: |`)? Returns the per-column align, or null. */
function parseSeparator(line: string): Align[] | null {
const s = line.trim()
if (!/^\|?[\s:|-]+\|?$/.test(s) || !s.includes('-')) return null
const cells = splitRow(line)
if (cells.length === 0) return null
const aligns: Align[] = []
for (const c of cells) {
const t = c.trim()
if (!/^:?-+:?$/.test(t)) return null // every cell must be a dash spec
const left = t.startsWith(':')
const right = t.endsWith(':')
aligns.push(left && right ? 'center' : right ? 'right' : 'left')
}
return aligns
}
const hasPipe = (line: string) => line.includes('|')
/**
* Split markdown `text` into ordered md/table segments. A table is a row with a
* pipe immediately followed by a separator row; data rows continue while they
* contain a pipe. Incomplete tables (no separator yet e.g. mid-stream) stay in
* the md segment and finalize once the separator arrives.
*/
export function segmentMarkdown(text: string): Segment[] {
const lines = (text ?? '').split('\n')
const segments: Segment[] = []
let buf: string[] = []
const flush = () => {
if (buf.length) segments.push({ kind: 'md', text: buf.join('\n') })
buf = []
}
for (let i = 0; i < lines.length; i++) {
const line = lines[i]!
const next = lines[i + 1]
const align = next !== undefined && hasPipe(line) ? parseSeparator(next) : null
if (align) {
const header = splitRow(line)
const cols = Math.max(header.length, align.length)
const rows: string[][] = []
let j = i + 2
for (; j < lines.length; j++) {
const r = lines[j]!
if (!hasPipe(r) || r.trim() === '') break
rows.push(splitRow(r))
}
flush()
segments.push({
kind: 'table',
header,
rows,
align: Array.from({ length: cols }, (_, k) => align[k] ?? 'left')
})
i = j - 1
continue
}
buf.push(line)
}
flush()
return segments
}
/** Column widths for a table, each capped so the whole grid fits `maxWidth`. */
export function tableColumnWidths(seg: TableSegment, maxWidth: number): number[] {
const cols = seg.align.length
const natural: number[] = []
for (let c = 0; c < cols; c++) {
let w = cellWidth(seg.header[c] ?? '')
for (const row of seg.rows) w = Math.max(w, cellWidth(row[c] ?? ''))
natural[c] = Math.max(3, w)
}
// budget: each col adds `width + 3` ("│ " + " ") of chrome; shrink proportionally if over.
const chrome = cols * 3 + 1
const total = natural.reduce((a, b) => a + b, 0) + chrome
if (total <= maxWidth) return natural
const avail = Math.max(cols * 3, maxWidth - chrome)
const scale = avail / natural.reduce((a, b) => a + b, 0)
return natural.map(w => Math.max(3, Math.floor(w * scale)))
}

View file

@ -0,0 +1,57 @@
/**
* GFM table segmenter test (item 7). segmentMarkdown splits prose from tables;
* padAligned/tableColumnWidths drive the aligned grid render.
*/
import { describe, expect, test } from 'bun:test'
import { padAligned, segmentMarkdown, tableColumnWidths, type TableSegment } from '../logic/mdTable.ts'
describe('segmentMarkdown', () => {
test('splits prose / table / prose and parses header, rows, alignment', () => {
const segs = segmentMarkdown(
['Here is a table:', '', '| Name | Age | City |', '|:-----|----:|:----:|', '| Ann | 30 | NYC |', '| Bo | 5 | LA |', '', 'Done.'].join(
'\n'
)
)
expect(segs.map(s => s.kind)).toEqual(['md', 'table', 'md'])
const t = segs[1] as TableSegment
expect(t.header).toEqual(['Name', 'Age', 'City'])
expect(t.rows).toEqual([
['Ann', '30', 'NYC'],
['Bo', '5', 'LA']
])
expect(t.align).toEqual(['left', 'right', 'center'])
expect((segs[0] as { text: string }).text).toContain('Here is a table:')
expect((segs[2] as { text: string }).text).toContain('Done.')
})
test('plain prose with no table is one md segment', () => {
const segs = segmentMarkdown('just text\nmore text')
expect(segs).toHaveLength(1)
expect(segs[0]!.kind).toBe('md')
})
test('a pipe line WITHOUT a separator row is NOT a table (e.g. mid-stream / code)', () => {
const segs = segmentMarkdown('| not | a | table |\nstill streaming')
expect(segs.every(s => s.kind === 'md')).toBe(true)
})
test('padAligned honors left/right/center', () => {
expect(padAligned('hi', 6, 'left')).toBe('hi ')
expect(padAligned('hi', 6, 'right')).toBe(' hi')
expect(padAligned('hi', 6, 'center')).toBe(' hi ')
})
test('tableColumnWidths fit within maxWidth (shrink when over budget)', () => {
const seg: TableSegment = {
kind: 'table',
header: ['aaaaaaaaaa', 'bbbbbbbbbb'],
rows: [['cccccccccc', 'dddddddddd']],
align: ['left', 'left']
}
const widths = tableColumnWidths(seg, 20)
const chrome = 2 * 3 + 1
expect(widths.reduce((a, b) => a + b, 0) + chrome).toBeLessThanOrEqual(20)
expect(Math.min(...widths)).toBeGreaterThanOrEqual(3)
})
})

View file

@ -0,0 +1,59 @@
/**
* MdTable renders a parsed GFM table as an aligned grid (item 7). The native
* markdown renderable leaves tables as raw pipes; this lays them out with
* per-column widths, alignment, a header rule, and dim `` separators. Width
* is reactive (useTerminalDimensions) so columns shrink to fit on resize.
*/
import { useTerminalDimensions } from '@opentui/solid'
import { createMemo, type JSX } from 'solid-js'
import { padAligned, tableColumnWidths, type TableSegment } from '../logic/mdTable.ts'
import { truncate } from '../logic/toolOutput.ts'
import { useTheme } from './theme.tsx'
const GUTTER = 2
export function MdTable(props: { seg: TableSegment }) {
const theme = useTheme()
const dims = useTerminalDimensions()
const widths = createMemo(() => tableColumnWidths(props.seg, Math.max(24, dims().width - GUTTER - 2)))
const cellText = (raw: string, i: number) =>
padAligned(truncate(raw ?? '', widths()[i]!), widths()[i]!, props.seg.align[i] ?? 'left')
// One row as alternating dim-border / content spans (built as an array so we
// avoid <For> inside <text>, which doesn't render inline spans reliably).
const rowSpans = (cells: readonly string[], bold: boolean): JSX.Element[] => {
const out: JSX.Element[] = []
const bar = () => <span style={{ fg: theme().color.border }}> </span>
out.push(bar())
widths().forEach((_, i) => {
const text = cellText(cells[i] ?? '', i)
out.push(
bold ? (
<span style={{ fg: theme().color.primary }}>
<b>{text}</b>
</span>
) : (
<span style={{ fg: theme().color.text }}>{text}</span>
)
)
out.push(<span style={{ fg: theme().color.border }}>{' │ '}</span>)
})
return out
}
const sepLine = () => `${widths().map(w => '─'.repeat(w)).join('─┼─')}`
return (
<box style={{ flexDirection: 'column', flexShrink: 0 }}>
<text>{rowSpans(props.seg.header, true)}</text>
<text>
<span style={{ fg: theme().color.border }}>{sepLine()}</span>
</text>
{props.seg.rows.map(r => (
<text>{rowSpans(r, false)}</text>
))}
</box>
)
}

View file

@ -8,16 +8,34 @@
* Stable `id` per part as the <For> key so a new tool part below a streaming text
* part doesn't remount it. Native <markdown> for text parts lands in 2b-ii.
*/
import { For, Match, Show, Switch } from 'solid-js'
import { createMemo, For, Match, Show, Switch } from 'solid-js'
import { segmentMarkdown } from '../logic/mdTable.ts'
import type { Message } from '../logic/store.ts'
import { Markdown } from './markdown.tsx'
import { MdTable } from './mdTable.tsx'
import { ReasoningPart } from './reasoningPart.tsx'
import { useTheme } from './theme.tsx'
import { ToolPart } from './toolPart.tsx'
const GUTTER = 2
/** A text part: native markdown for prose, an aligned grid for GFM tables (item 7). */
function AssistantText(props: { text: string; streaming: boolean }) {
const segments = createMemo(() => segmentMarkdown(props.text.replace(/^\n+|\n+$/g, '')))
return (
<For each={segments()}>
{seg =>
seg.kind === 'table' ? (
<MdTable seg={seg} />
) : (
<Markdown text={seg.text.replace(/^\n+|\n+$/g, '')} streaming={props.streaming} />
)
}
</For>
)
}
export function MessageLine(props: { message: Message }) {
const theme = useTheme()
const m = () => props.message
@ -66,10 +84,10 @@ export function MessageLine(props: { message: Message }) {
{r => <ReasoningPart text={r().text} streaming={m().streaming ?? false} />}
</Match>
<Match when={part.type === 'text' && part}>
{/* strip leading/trailing blank lines so the part's own spacing is
0 and the column `gap` is the SOLE source of inter-part spacing
no double gaps, no transient blank lines mid-stream (item 5). */}
{t => <Markdown text={t().text.replace(/^\n+|\n+$/g, '')} streaming={m().streaming ?? false} />}
{/* prose via the native renderable; GFM tables as an aligned grid
(item 7). Leading/trailing blanks are stripped so the column
`gap` is the sole inter-part spacing no jitter (item 5). */}
{t => <AssistantText text={t().text} streaming={m().streaming ?? false} />}
</Match>
</Switch>
)}