From 4407fee49f5b12409118733677709fe33cc76efb Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Tue, 9 Jun 2026 04:57:57 +0000 Subject: [PATCH] opentui(v5b/item2+3): fix streaming flicker + native markdown tables MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #2 (flicker regression): my item-7 AssistantText wrapped text in — segmentMarkdown returns NEW objects per delta, so (keyed by reference) DISPOSED and re-created the markdown renderable on EVERY streamed delta. Each remount re-measured from zero → content height oscillated → the scrollbar grew/shrank (exactly the reported symptom). Fix (deep opencode parity): render assistant text as ONE stable native (MarkdownRenderable) fed the growing content in place, with internalBlockMode="top-level" — opencode's anti-flicker mode where settled top-level blocks aren't re-rendered per delta (_stableBlockCount, managed internally). This is opencode's TextPart verbatim (routes/session/index.tsx:1687). #3 (table inline formatting): the native renders GFM tables as a grid WITH inline bold/italic/code in cells — so the hand-rolled segmentMarkdown + MdTable grid are deleted (obsolete). Switched from to (the former re-measured the whole buffer each delta and never aligned tables). 85 pass; verified live (smooth stream, boxed table, concealed **/* markers styled). --- ui-tui-opentui-v2/src/logic/mdTable.ts | 131 --------------------- ui-tui-opentui-v2/src/test/mdTable.test.ts | 57 --------- ui-tui-opentui-v2/src/view/markdown.tsx | 35 +++--- ui-tui-opentui-v2/src/view/mdTable.tsx | 59 ---------- ui-tui-opentui-v2/src/view/messageLine.tsx | 29 +---- 5 files changed, 26 insertions(+), 285 deletions(-) delete mode 100644 ui-tui-opentui-v2/src/logic/mdTable.ts delete mode 100644 ui-tui-opentui-v2/src/test/mdTable.test.ts delete mode 100644 ui-tui-opentui-v2/src/view/mdTable.tsx diff --git a/ui-tui-opentui-v2/src/logic/mdTable.ts b/ui-tui-opentui-v2/src/logic/mdTable.ts deleted file mode 100644 index 34540849eac..00000000000 --- a/ui-tui-opentui-v2/src/logic/mdTable.ts +++ /dev/null @@ -1,131 +0,0 @@ -/** - * 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 deleted file mode 100644 index bcd39abf182..00000000000 --- a/ui-tui-opentui-v2/src/test/mdTable.test.ts +++ /dev/null @@ -1,57 +0,0 @@ -/** - * 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/markdown.tsx b/ui-tui-opentui-v2/src/view/markdown.tsx index 1e982ce3e1c..f2a88cf56d4 100644 --- a/ui-tui-opentui-v2/src/view/markdown.tsx +++ b/ui-tui-opentui-v2/src/view/markdown.tsx @@ -1,11 +1,18 @@ /** - * Markdown — assistant/reasoning text rendered with the NATIVE renderable, never - * a hand-rolled parser (spec v4 §7). Uses `` - * (`CodeRenderable`) — opencode's v2 text path (`session-v2.tsx:358` AssistantText) - * — backed by the same markdown tokenizer + Tree-sitter as ``, but it - * paints reliably (incl. headless): `drawUnstyledText` draws the raw text - * immediately while highlighting settles, `conceal` hides the `**`/backtick - * markers, `streaming` feeds incremental deltas. + * Markdown — assistant/reasoning text via the NATIVE `` renderable + * (`MarkdownRenderable`), exactly as opencode's TextPart (`routes/session/index.tsx` + * :1687 ``). + * + * Why `` (not ``): the anti-flicker mechanism + * is `internalBlockMode="top-level"` — each top-level block (heading/para/list/ + * table/fence) becomes its own child renderable and `_stableBlockCount` (managed + * internally) reports the settled head prefix, so stable blocks are NOT re-rendered + * per streamed delta. The old `` path re-measured the whole buffer each delta + * → the content height oscillated → the scrollbar grew/shrank (the streaming + * flicker regression). `tableOptions` renders GFM tables as an aligned grid WITH + * inline markdown (bold/italic/code) inside cells — so a separate table renderer + * is unnecessary. `streaming` keeps the trailing block open while chunks append and + * finalizes it (half-open tables/fences) when flipped false. * * The `SyntaxStyle` is derived from the active theme (no hardcoded styles — §7.5) * and cached by theme-object identity, so all text parts share ONE instance and @@ -53,18 +60,16 @@ function syntaxStyleFor(theme: Theme): SyntaxStyle { export function Markdown(props: { text: string; streaming?: boolean; fg?: string }) { const theme = useTheme() - // opencode's v2 text path (session-v2.tsx AssistantText): the markdown engine via - // . `drawUnstyledText={false}` avoids the - // raw→styled flash per delta (the streaming flicker); `streaming` re-tokenizes - // incrementally rather than reparsing the whole buffer each repaint. `fg` - // overrides the base text color (e.g. muted for reasoning bodies — item 6). + // `internalBlockMode="top-level"` is the anti-flicker mode (stable head blocks + // aren't re-rendered per delta); `tableOptions` gives native GFM tables with + // inline formatting; `fg` overrides the base text color (muted for reasoning). return ( - diff --git a/ui-tui-opentui-v2/src/view/mdTable.tsx b/ui-tui-opentui-v2/src/view/mdTable.tsx deleted file mode 100644 index 22cd11c51c0..00000000000 --- a/ui-tui-opentui-v2/src/view/mdTable.tsx +++ /dev/null @@ -1,59 +0,0 @@ -/** - * 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 (useDimensions) so columns shrink to fit on resize. - */ -import { useDimensions } from './dimensions.tsx' -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 = useDimensions() - 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 81b8de5ce7a..87aa8654717 100644 --- a/ui-tui-opentui-v2/src/view/messageLine.tsx +++ b/ui-tui-opentui-v2/src/view/messageLine.tsx @@ -8,34 +8,16 @@ * 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 { createMemo, For, Match, Show, Switch } from 'solid-js' +import { 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 @@ -87,10 +69,11 @@ export function MessageLine(props: { message: Message }) { {r => } - {/* 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 => } + {/* ONE stable native fed the growing text in place (no + per-delta remount → no scrollbar flicker, #2); it renders GFM + tables natively (#3). Leading/trailing blanks stripped so the + column `gap` is the sole inter-part spacing (item 5). */} + {t => } )}