-
Notifications
You must be signed in to change notification settings - Fork 2.6k
fix(cli): smoother streaming table rendering #6345
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 3 commits
53db9cc
431fe27
77c5320
78f3fe3
02af5b8
fd89f85
9b39a2e
d52f4b4
fb06b16
30f1610
3c01a3f
76727ef
f05d64c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -191,6 +191,51 @@ const MarkdownDisplayInternal: React.FC<MarkdownDisplayProps> = ({ | |||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| // Hold back a still-forming table at the streaming frontier. A table is only | ||||||||||||||||||||||||||||||||
| // recognized once its separator line (matching the header's column count) has | ||||||||||||||||||||||||||||||||
| // arrived; until then the header — and any partial separator — would render as | ||||||||||||||||||||||||||||||||
| // raw `| a | b |` text and stream in character by character before snapping | ||||||||||||||||||||||||||||||||
| // into a table box. So while pending, trim a trailing run of pipe-lines that | ||||||||||||||||||||||||||||||||
| // does not yet contain a matching separator: nothing renders for it until it | ||||||||||||||||||||||||||||||||
| // can render as a table. (A run that IS a recognizable table is kept — the | ||||||||||||||||||||||||||||||||
| // per-row hold-back below then handles its unterminated frontier row.) | ||||||||||||||||||||||||||||||||
| if (isPending && renderVisualBlocks && lines.length > 0) { | ||||||||||||||||||||||||||||||||
| let start = lines.length; | ||||||||||||||||||||||||||||||||
| while (start > 0 && /^\s*\|/.test(lines[start - 1]!)) start--; | ||||||||||||||||||||||||||||||||
| if (start < lines.length) { | ||||||||||||||||||||||||||||||||
| // Don't touch pipe-lines that are actually fenced code-block content. | ||||||||||||||||||||||||||||||||
| let insideCodeFence = false; | ||||||||||||||||||||||||||||||||
| for (let i = 0; i < start; i++) { | ||||||||||||||||||||||||||||||||
| if (codeFenceRegex.test(lines[i]!)) insideCodeFence = !insideCodeFence; | ||||||||||||||||||||||||||||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Critical] The code fence tracking here uses a naive boolean toggle ( This causes content inside nested code blocks to silently vanish during streaming. For example, streaming this with | code example | The toggle sees line 0 (4 backticks) →
Suggested change
— qwen3.7-max via Qwen Code /review |
||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||
| // Only hold back a plausible forming TABLE, not arbitrary pipe text. A | ||||||||||||||||||||||||||||||||
| // table header has ≥2 columns; a single-pipe line (an un-fenced shell | ||||||||||||||||||||||||||||||||
| // pipeline `| grep foo`, a pipe-prefixed log line) has one cell and must | ||||||||||||||||||||||||||||||||
| // render. Count cells on the first line whether or not it is closed yet, | ||||||||||||||||||||||||||||||||
| // so a multi-column header held mid-type does not flash in cell by cell | ||||||||||||||||||||||||||||||||
| // before its separator arrives. (A header still typing its very first | ||||||||||||||||||||||||||||||||
| // cell — one cell so far — is indistinguishable from a single-pipe line, | ||||||||||||||||||||||||||||||||
| // so it renders briefly until the second column appears; that is the | ||||||||||||||||||||||||||||||||
| // narrowest flash we can allow without hiding real non-table text.) | ||||||||||||||||||||||||||||||||
| const headerCells = insideCodeFence | ||||||||||||||||||||||||||||||||
| ? 0 | ||||||||||||||||||||||||||||||||
| : splitMarkdownTableRow(lines[start]!).filter((c) => c.length > 0) | ||||||||||||||||||||||||||||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Suggestion] The pre-loop header column count disagrees with the main loop's counting method. Here For a header with an empty cell like
Suggested change
— qwen3.7-max via Qwen Code /review |
||||||||||||||||||||||||||||||||
| .length; | ||||||||||||||||||||||||||||||||
| if (headerCells >= 2) { | ||||||||||||||||||||||||||||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Suggestion] The Consider a line-count limit or heuristic to release non-table pipe content: if the trailing run exceeds N lines without a separator, assume it is not a forming table and stop holding it back. Alternatively, require the first line to more closely match a table header pattern (word-like cells only, no shell operators). — qwen3.7-max via Qwen Code /review |
||||||||||||||||||||||||||||||||
| const hasMatchingSeparator = lines.slice(start + 1).some((l) => { | ||||||||||||||||||||||||||||||||
| if (!tableSeparatorRegex.test(l)) return false; | ||||||||||||||||||||||||||||||||
| const cols = splitMarkdownTableRow(l).filter( | ||||||||||||||||||||||||||||||||
| (c) => c.length > 0, | ||||||||||||||||||||||||||||||||
| ).length; | ||||||||||||||||||||||||||||||||
| return cols === headerCells; | ||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||
| if (!hasMatchingSeparator) { | ||||||||||||||||||||||||||||||||
| lines = lines.slice(0, start); | ||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| /** Parse column alignments from a markdown table separator like `|:---|:---:|---:|` */ | ||||||||||||||||||||||||||||||||
| const parseTableAligns = (line: string): ColumnAlign[] => | ||||||||||||||||||||||||||||||||
| splitMarkdownTableRow(line) | ||||||||||||||||||||||||||||||||
|
|
@@ -346,6 +391,26 @@ const MarkdownDisplayInternal: React.FC<MarkdownDisplayProps> = ({ | |||||||||||||||||||||||||||||||
| } else if (inTable && tableSeparatorMatch) { | ||||||||||||||||||||||||||||||||
| // Parse alignment from separator line | ||||||||||||||||||||||||||||||||
| tableAligns = parseTableAligns(line); | ||||||||||||||||||||||||||||||||
| } else if ( | ||||||||||||||||||||||||||||||||
| isPending && | ||||||||||||||||||||||||||||||||
| inTable && | ||||||||||||||||||||||||||||||||
| index === lines.length - 1 && | ||||||||||||||||||||||||||||||||
| tableHeaders.length > 0 && | ||||||||||||||||||||||||||||||||
| /^\s*\|/.test(line) && | ||||||||||||||||||||||||||||||||
| (!tableRowMatch || | ||||||||||||||||||||||||||||||||
| splitMarkdownTableRow(tableRowMatch[1]).length < tableHeaders.length) | ||||||||||||||||||||||||||||||||
| ) { | ||||||||||||||||||||||||||||||||
| // Live streaming frontier: the final line is a row still being typed — | ||||||||||||||||||||||||||||||||
| // either mid-cell (`| a | b` with no closing `|` yet) or closed but with | ||||||||||||||||||||||||||||||||
| // fewer cells than the header (`| a |` while `| a | b | c |` is coming, an | ||||||||||||||||||||||||||||||||
| // intermediate that itself matches the row regex). Both would otherwise | ||||||||||||||||||||||||||||||||
| // render as a padded row that fills in cell by cell and flips as the | ||||||||||||||||||||||||||||||||
| // closing `|`/columns arrive, jittering the frame. Hold the row back until | ||||||||||||||||||||||||||||||||
| // it has all its columns: skip it so `inTable` stays set and the | ||||||||||||||||||||||||||||||||
| // end-of-content handler renders only the COMPLETE rows as a live table. | ||||||||||||||||||||||||||||||||
| // The whole row (border + all cells) then appears in one step. Guarded on | ||||||||||||||||||||||||||||||||
| // `tableHeaders.length > 0` so the header + separator never blank out with | ||||||||||||||||||||||||||||||||
| // a stray partial line beneath them while the first row is typed. | ||||||||||||||||||||||||||||||||
| } else if (inTable && tableRowMatch) { | ||||||||||||||||||||||||||||||||
| // Add table row | ||||||||||||||||||||||||||||||||
| const cells = splitMarkdownTableRow(tableRowMatch[1]); | ||||||||||||||||||||||||||||||||
|
|
@@ -357,24 +422,6 @@ const MarkdownDisplayInternal: React.FC<MarkdownDisplayProps> = ({ | |||||||||||||||||||||||||||||||
| cells.length = tableHeaders.length; | ||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||
| tableRows.push(cells); | ||||||||||||||||||||||||||||||||
| } else if ( | ||||||||||||||||||||||||||||||||
| isPending && | ||||||||||||||||||||||||||||||||
| inTable && | ||||||||||||||||||||||||||||||||
| !tableRowMatch && | ||||||||||||||||||||||||||||||||
| index === lines.length - 1 && | ||||||||||||||||||||||||||||||||
| tableHeaders.length > 0 && | ||||||||||||||||||||||||||||||||
| /^\s*\|/.test(line) | ||||||||||||||||||||||||||||||||
| ) { | ||||||||||||||||||||||||||||||||
| // Live streaming frontier: the final line is an unterminated table row or | ||||||||||||||||||||||||||||||||
| // separator (`| a | b` with no closing `|` yet). Rendering it as a plain | ||||||||||||||||||||||||||||||||
| // text line and then flipping it into the table once the closing `|` | ||||||||||||||||||||||||||||||||
| // arrives makes the frame height and column widths oscillate on every | ||||||||||||||||||||||||||||||||
| // token, which visibly jitters the footer/composer. Hold it back instead: | ||||||||||||||||||||||||||||||||
| // skip it so `inTable` stays set and the end-of-content handler renders | ||||||||||||||||||||||||||||||||
| // only the COMPLETE rows as a live table. A partial row never flips in; | ||||||||||||||||||||||||||||||||
| // the table appears once its first row terminates and grows one complete | ||||||||||||||||||||||||||||||||
| // row at a time. Until then the table is simply not drawn (no header + | ||||||||||||||||||||||||||||||||
| // separator with a stray partial line beneath it). | ||||||||||||||||||||||||||||||||
| } else if (inTable && !tableRowMatch) { | ||||||||||||||||||||||||||||||||
| // End of table — a following line closes it, so this table is COMPLETE | ||||||||||||||||||||||||||||||||
| // and renders in full (the rendered-aware slice guarantees a completed | ||||||||||||||||||||||||||||||||
|
|
@@ -577,7 +624,16 @@ const MarkdownDisplayInternal: React.FC<MarkdownDisplayProps> = ({ | |||||||||||||||||||||||||||||||
| // slice above keeps preceding content within budget, so it can never overflow | ||||||||||||||||||||||||||||||||
| // and lock the terminal. Renders in full once the message commits to <Static> | ||||||||||||||||||||||||||||||||
| // (isPending=false → no clamp). | ||||||||||||||||||||||||||||||||
| if (inTable && tableHeaders.length > 0 && tableRows.length > 0) { | ||||||||||||||||||||||||||||||||
| // | ||||||||||||||||||||||||||||||||
| // No `tableRows.length > 0` guard: once the header + separator are recognized | ||||||||||||||||||||||||||||||||
| // draw the empty table box right away (its data rows fill in as they complete | ||||||||||||||||||||||||||||||||
| // via the per-row hold-back). Otherwise the whole table area stays blank from | ||||||||||||||||||||||||||||||||
| // the moment it is recognized until the first row terminates — if generation | ||||||||||||||||||||||||||||||||
| // stalls in that window it looks like a hang (no box, no cue). The header does | ||||||||||||||||||||||||||||||||
| // NOT flash char by char: the pre-loop trim holds it until the separator lands, | ||||||||||||||||||||||||||||||||
| // so the box appears atomically. Committed (isPending=false) tables always have | ||||||||||||||||||||||||||||||||
| // rows, so this only affects the live frontier. | ||||||||||||||||||||||||||||||||
| if (inTable && tableHeaders.length > 0) { | ||||||||||||||||||||||||||||||||
| addContentBlock( | ||||||||||||||||||||||||||||||||
| <RenderTable | ||||||||||||||||||||||||||||||||
| key={`table-${contentBlocks.length}`} | ||||||||||||||||||||||||||||||||
|
|
@@ -587,6 +643,7 @@ const MarkdownDisplayInternal: React.FC<MarkdownDisplayProps> = ({ | |||||||||||||||||||||||||||||||
| aligns={tableAligns} | ||||||||||||||||||||||||||||||||
| enableInlineMath={renderVisualBlocks} | ||||||||||||||||||||||||||||||||
| isPending={isPending} | ||||||||||||||||||||||||||||||||
| isStreamingFrontier={true} | ||||||||||||||||||||||||||||||||
| availableTerminalHeight={availableTerminalHeight} | ||||||||||||||||||||||||||||||||
| />, | ||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||
|
|
@@ -901,6 +958,14 @@ interface RenderTableProps { | |||||||||||||||||||||||||||||||
| aligns?: ColumnAlign[]; | ||||||||||||||||||||||||||||||||
| enableInlineMath?: boolean; | ||||||||||||||||||||||||||||||||
| isPending?: boolean; | ||||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||||
| * True only for the table at the live streaming frontier — the one still | ||||||||||||||||||||||||||||||||
| * being written at the end of a pending message. A table closed earlier in | ||||||||||||||||||||||||||||||||
| * the same (still pending) message is already COMPLETE, so it must size its | ||||||||||||||||||||||||||||||||
| * columns from every row immediately (isStreaming false) instead of staying | ||||||||||||||||||||||||||||||||
| * frozen to the first row until the whole message finishes. | ||||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||||
| isStreamingFrontier?: boolean; | ||||||||||||||||||||||||||||||||
| availableTerminalHeight?: number; | ||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
|
|
@@ -911,6 +976,7 @@ const RenderTableInternal: React.FC<RenderTableProps> = ({ | |||||||||||||||||||||||||||||||
| aligns, | ||||||||||||||||||||||||||||||||
| enableInlineMath = false, | ||||||||||||||||||||||||||||||||
| isPending = false, | ||||||||||||||||||||||||||||||||
| isStreamingFrontier = false, | ||||||||||||||||||||||||||||||||
| availableTerminalHeight, | ||||||||||||||||||||||||||||||||
| }) => { | ||||||||||||||||||||||||||||||||
| const maxHeight = | ||||||||||||||||||||||||||||||||
|
|
@@ -925,6 +991,7 @@ const RenderTableInternal: React.FC<RenderTableProps> = ({ | |||||||||||||||||||||||||||||||
| aligns={aligns} | ||||||||||||||||||||||||||||||||
| enableInlineMath={enableInlineMath} | ||||||||||||||||||||||||||||||||
| maxHeight={maxHeight} | ||||||||||||||||||||||||||||||||
| isStreaming={isPending && isStreamingFrontier} | ||||||||||||||||||||||||||||||||
| /> | ||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[Critical] The backward scan captures any trailing run of pipe-leading lines, not just table content. When the first captured line doesn't match
tableRowRegex(no closing|),headerCellsis 0,hasMatchingSeparatoris unconditionally false (due toheaderCells > 0 &&), and the entire trailing run is trimmed. Non-table text lines starting with|— un-fenced shell pipelines (| grep foo), pipe-prefixed log output, etc. — vanish from the live preview until the message commits.— qwen3.7-max via Qwen Code /review