mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-09 13:21:42 +00:00
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
4 changed files with 270 additions and 5 deletions
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
|
||||
* 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>
|
||||
)}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue