mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-11 13:41:53 +00:00
opentui(v5b/item2+3): fix streaming flicker + native markdown tables
#2 (flicker regression): my item-7 AssistantText wrapped text in <For each={segmentMarkdown(text)}> — segmentMarkdown returns NEW objects per delta, so <For> (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 <markdown> (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 <markdown tableOptions={{style:grid}}> 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 <code filetype=markdown> to <markdown> (the former re-measured the whole buffer each delta and never aligned tables). 85 pass; verified live (smooth stream, boxed table, concealed **/* markers styled).
This commit is contained in:
parent
48d0c70f61
commit
4407fee49f
5 changed files with 26 additions and 285 deletions
|
|
@ -1,131 +0,0 @@
|
|||
/**
|
||||
* 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)))
|
||||
}
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -1,11 +1,18 @@
|
|||
/**
|
||||
* Markdown — assistant/reasoning text rendered with the NATIVE renderable, never
|
||||
* a hand-rolled parser (spec v4 §7). Uses `<code filetype="markdown" streaming>`
|
||||
* (`CodeRenderable`) — opencode's v2 text path (`session-v2.tsx:358` AssistantText)
|
||||
* — backed by the same markdown tokenizer + Tree-sitter as `<markdown>`, 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 `<markdown>` renderable
|
||||
* (`MarkdownRenderable`), exactly as opencode's TextPart (`routes/session/index.tsx`
|
||||
* :1687 `<markdown streaming internalBlockMode="top-level" tableOptions conceal>`).
|
||||
*
|
||||
* Why `<markdown>` (not `<code filetype="markdown">`): 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 `<code>` 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
|
||||
// <code filetype="markdown" streaming>. `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 (
|
||||
<code
|
||||
filetype="markdown"
|
||||
<markdown
|
||||
content={props.text}
|
||||
syntaxStyle={syntaxStyleFor(theme())}
|
||||
streaming={props.streaming ?? false}
|
||||
drawUnstyledText={false}
|
||||
internalBlockMode="top-level"
|
||||
tableOptions={{ style: 'grid', borderColor: theme().color.border }}
|
||||
conceal
|
||||
fg={props.fg ?? theme().color.text}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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 <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,34 +8,16 @@
|
|||
* 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 { 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 (
|
||||
<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
|
||||
|
|
@ -87,10 +69,11 @@ export function MessageLine(props: { message: Message }) {
|
|||
{r => <ReasoningPart text={r().text} streaming={m().streaming ?? false} />}
|
||||
</Match>
|
||||
<Match when={part.type === 'text' && part}>
|
||||
{/* 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} />}
|
||||
{/* ONE stable native <markdown> 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 => <Markdown text={t().text.replace(/^\n+|\n+$/g, '')} streaming={m().streaming ?? false} />}
|
||||
</Match>
|
||||
</Switch>
|
||||
)}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue