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:
parent
180fe665cb
commit
c6e72a8454
131
ui-tui-opentui-v2/src/logic/mdTable.ts
Normal file
131
ui-tui-opentui-v2/src/logic/mdTable.ts
Normal 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)))
|
||||||
|
}
|
||||||
57
ui-tui-opentui-v2/src/test/mdTable.test.ts
Normal file
57
ui-tui-opentui-v2/src/test/mdTable.test.ts
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
59
ui-tui-opentui-v2/src/view/mdTable.tsx
Normal file
59
ui-tui-opentui-v2/src/view/mdTable.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -8,16 +8,34 @@
|
|||||||
* Stable `id` per part as the <For> key so a new tool part below a streaming text
|
* 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.
|
* 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 type { Message } from '../logic/store.ts'
|
||||||
import { Markdown } from './markdown.tsx'
|
import { Markdown } from './markdown.tsx'
|
||||||
|
import { MdTable } from './mdTable.tsx'
|
||||||
import { ReasoningPart } from './reasoningPart.tsx'
|
import { ReasoningPart } from './reasoningPart.tsx'
|
||||||
import { useTheme } from './theme.tsx'
|
import { useTheme } from './theme.tsx'
|
||||||
import { ToolPart } from './toolPart.tsx'
|
import { ToolPart } from './toolPart.tsx'
|
||||||
|
|
||||||
const GUTTER = 2
|
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 }) {
|
export function MessageLine(props: { message: Message }) {
|
||||||
const theme = useTheme()
|
const theme = useTheme()
|
||||||
const m = () => props.message
|
const m = () => props.message
|
||||||
@ -66,10 +84,10 @@ export function MessageLine(props: { message: Message }) {
|
|||||||
{r => <ReasoningPart text={r().text} streaming={m().streaming ?? false} />}
|
{r => <ReasoningPart text={r().text} streaming={m().streaming ?? false} />}
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={part.type === 'text' && part}>
|
<Match when={part.type === 'text' && part}>
|
||||||
{/* strip leading/trailing blank lines so the part's own spacing is
|
{/* prose via the native renderable; GFM tables as an aligned grid
|
||||||
0 and the column `gap` is the SOLE source of inter-part spacing —
|
(item 7). Leading/trailing blanks are stripped so the column
|
||||||
no double gaps, no transient blank lines mid-stream (item 5). */}
|
`gap` is the sole inter-part spacing — no jitter (item 5). */}
|
||||||
{t => <Markdown text={t().text.replace(/^\n+|\n+$/g, '')} streaming={m().streaming ?? false} />}
|
{t => <AssistantText text={t().text} streaming={m().streaming ?? false} />}
|
||||||
</Match>
|
</Match>
|
||||||
</Switch>
|
</Switch>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user