diff --git a/ui-tui-opentui-v2/src/logic/mdTable.ts b/ui-tui-opentui-v2/src/logic/mdTable.ts new file mode 100644 index 00000000000..34540849eac --- /dev/null +++ b/ui-tui-opentui-v2/src/logic/mdTable.ts @@ -0,0 +1,131 @@ +/** + * GFM table support (item 7). The native `` 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(/(? 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))) +} diff --git a/ui-tui-opentui-v2/src/test/mdTable.test.ts b/ui-tui-opentui-v2/src/test/mdTable.test.ts new file mode 100644 index 00000000000..bcd39abf182 --- /dev/null +++ b/ui-tui-opentui-v2/src/test/mdTable.test.ts @@ -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) + }) +}) diff --git a/ui-tui-opentui-v2/src/view/mdTable.tsx b/ui-tui-opentui-v2/src/view/mdTable.tsx new file mode 100644 index 00000000000..ceb8f9931dc --- /dev/null +++ b/ui-tui-opentui-v2/src/view/mdTable.tsx @@ -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 inside , which doesn't render inline spans reliably). + const rowSpans = (cells: readonly string[], bold: boolean): JSX.Element[] => { + const out: JSX.Element[] = [] + const bar = () => + out.push(bar()) + widths().forEach((_, i) => { + const text = cellText(cells[i] ?? '', i) + out.push( + bold ? ( + + {text} + + ) : ( + {text} + ) + ) + out.push({' │ '}) + }) + return out + } + + const sepLine = () => `│ ${widths().map(w => '─'.repeat(w)).join('─┼─')} │` + + return ( + + {rowSpans(props.seg.header, true)} + + {sepLine()} + + {props.seg.rows.map(r => ( + {rowSpans(r, false)} + ))} + + ) +} diff --git a/ui-tui-opentui-v2/src/view/messageLine.tsx b/ui-tui-opentui-v2/src/view/messageLine.tsx index dce01678dc5..199936a5d00 100644 --- a/ui-tui-opentui-v2/src/view/messageLine.tsx +++ b/ui-tui-opentui-v2/src/view/messageLine.tsx @@ -8,16 +8,34 @@ * Stable `id` per part as the key so a new tool part below a streaming text * part doesn't remount it. Native 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 ( + + {seg => + seg.kind === 'table' ? ( + + ) : ( + + ) + } + + ) +} + export function MessageLine(props: { message: Message }) { const theme = useTheme() const m = () => props.message @@ -66,10 +84,10 @@ export function MessageLine(props: { message: Message }) { {r => } - {/* 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 => } + {/* 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 => } )}