Skip to content

fix(cli): smoother streaming table rendering#6345

Open
MikeWang0316tw wants to merge 11 commits into
QwenLM:mainfrom
MikeWang0316tw:fix/streaming-table-jitter
Open

fix(cli): smoother streaming table rendering#6345
MikeWang0316tw wants to merge 11 commits into
QwenLM:mainfrom
MikeWang0316tw:fix/streaming-table-jitter

Conversation

@MikeWang0316tw

@MikeWang0316tw MikeWang0316tw commented Jul 5, 2026

Copy link
Copy Markdown
Contributor

What this PR does

Follow-up polish to how a live (streaming) markdown table renders in the non-VP TUI, so it streams predictably instead of jittering, flashing, or briefly hanging. All changes are display-only.

  • Atomic rows. A multi-column row passes through intermediate states that are themselves valid rows with fewer cells (| a |, | a | b | on the way to | a | b | c |), so it used to fill in cell by cell. A frontier row is now held back until it has all its columns and appears in one step. The hold-back keys on column count (a table header has ≥2 columns), so a single-pipe line — a shell pipeline | grep foo, a pipe-prefixed log line — still renders and does not vanish.
  • Widths track the current rows. A wider row redraws the whole table once; a narrower row changes nothing (widths are a max over all rows, so they only grow). No per-token reflow.
  • Consistent horizontal-vs-vertical format. The decision to render a table as a horizontal box or the vertical label: value fallback is the same while streaming and once committed, so a table never flips format mid-stream (which reads as a jump).
  • Hold a forming table until it is recognizable. A header (and any partial separator) is held while pending until a separator matching the header's column count arrives, so the header no longer streams in char by char as raw | a | b | text before snapping into a box. Fenced code-block content is left untouched.
  • No blank hang. Once a table is recognized, its header box is drawn immediately (before the first row completes), so the area does not sit blank and look like a hang. A zero-row box omits the header/body divider so it reads as a clean header, and stays horizontal (never the empty vertical list) even on a narrow terminal.

Why it's needed

Building on #6170 (incremental scrollback commit) and #6340 (drop the redundant cue + hold back partial rows), a streaming table still had visible rough edges: cells trickling in one at a time, the whole table re-widening on a wider cell, the header streaming in as raw pipe text, and — after the header/first-row hold-back — a window where the table area was blank until the first row completed (which looked like a hang). Each is a display-side artifact; none changes the model output, the markdown, or the table's data.

Reviewer Test Plan

How to verify

Ask the model for a long multi-column markdown table (~60 rows) and watch the live stream at two terminal sizes (e.g. 80×20 and a large window):

  • values no longer fill in cell by cell — each row appears whole once its last column arrives;
  • column widths track content: a wider row redraws the table once, a narrower row leaves it untouched;
  • the header does not stream in char by char as raw | … | text — the box appears once the separator lands;
  • a non-table pipe line (| grep foo) still renders (it is not held back);
  • no long blank while the first row streams: an empty header box is shown, then rows fill in;
  • a table renders the same format (horizontal or the vertical label: value list) while streaming and once committed — it does not flip;
  • scrolling up mid-stream does not jump to the top and lock (the fix(cli): stream long responses into scrollback to stop scroll-to-top lock #6170 guarantee still holds).

Unit tests: npx vitest run packages/cli/src/ui/utils/TableRenderer.test.tsx packages/cli/src/ui/utils/MarkdownDisplay.test.tsx → all pass.

Evidence (Before & After)

  • Before: a streaming table shows cells filling in one at a time; a wider row re-widens and repaints the whole table; the header appears as raw | a | b | text streaming in; and after the row hold-back there is a blank window (no box) until the first row completes.
  • After: rows appear whole one at a time; widths only grow; the header appears together with its box; a header box is shown immediately so the area never sits blank; and the horizontal/vertical format is stable between streaming and committed.

Tested on

OS Status
🍏 macOS
🪟 Windows ⚠️
🐧 Linux ⚠️

Environment (optional)

Local npm run dev on macOS, streamed against a live model.

Risk & Scope

  • Main risk or tradeoff: display-only, gated on isPending where relevant. The rendered-height clip / safety-net is unchanged, so there is no scroll-to-top regression.
  • Known limitation (documented in code): because column widths track content (redraw-on-wider), a table with very long cell text whose wrapped height sits right at the vertical-fallback threshold can still oscillate format while streaming. Only extreme wide/long-text tables hit it; normal tables stay well clear. Eliminating it would need a content-independent format decision (which mis-sizes common key/value tables) or frozen column widths (losing redraw-on-wider) — not worth it for the residual.
  • Not addressed here: a table whose separator is invalid markdown (e.g. non-ASCII dashes) is still — correctly — rendered as raw text. The rendered-height estimate for tables with wrapping cells (a separate scroll-to-top edge) is handled in a follow-up.
  • Not validated / out of scope: Windows and Linux rendering (macOS only locally; CI covers the rest).
  • Breaking changes: none.

Linked Issues

Relates to #6170 (incremental scrollback commit) and #6340 (cue removal + partial-row hold-back this builds on). No closing keyword.

中文说明

这个 PR 做了什么

延续对 non-VP TUI 中串流中 markdown 表格渲染的打磨,让它串流时更稳定,不再抖动、闪烁或短暂空白。全部是显示层改动。

  • 整列原子化:多栏的一列在打字途中会经过「栏数较少但本身合法」的中间态,原本逐格填入;现在整列凑满所有栏才一次出现。以「栏数 ≥2」判别是不是表头,所以单一 pipe 行(shell 管线 | grep foo、pipe 开头日志)仍会显示、不会消失。
  • 栏宽随当前列:更宽的列整张重画一次;较窄的列不变(栏宽只增不减)。无逐 token reflow。
  • 横式/直式格式一致:横式方框还是直式 label: value 清单的判定,在串流与提交后一致,表格不会中途翻转(翻转看起来像跳)。
  • 成形中的表格先不画:串流中表头(及部分分隔线)会被压住,直到出现「栏数相符的分隔线」才成表格——表头不再以 | a | b | 纯文字逐字冒出。Fenced code block 内容不受影响。
  • 不再空白卡住:表格一辨识出来就先画表头框(第一列还没到齐前),避免该区域空白像卡住。0 列的框省略表头/内文分隔线,读起来是干净表头,且在窄终端也保持横式(不会变空的直式清单)。

为什么需要

#6170#6340 的基础上,串流表格仍有可见粗糙处:储存格逐格冒、更宽储存格让整张重画、表头以纯文字逐字冒、以及行 hold-back 后第一列凑满前的一段空白(看起来像卡住)。这些都是显示层现象,不改变模型输出、markdown 或表格资料。

Reviewer 测试计划

请模型产生长的多栏 markdown 表格(约 60 列),在两种视窗大小观察串流:数值不再逐格冒、栏宽随内容(更宽重画一次、较窄不动)、表头不再纯文字逐字冒、| grep foo 类非表格文字仍显示、第一列串流时不再长时间空白(先显示空表头框)、同一张表串流与提交格式一致(不翻转)、往上滚不跳顶锁死。单元测试:npx vitest run packages/cli/src/ui/utils/TableRenderer.test.tsx packages/cli/src/ui/utils/MarkdownDisplay.test.tsx 全通过。

风险与范围

  • 主要风险:仅显示层。算绘高度裁切/安全网不变,无 scroll-to-top 回归。
  • 已知限制(程式码内已注明):因栏宽随内容(redraw-on-wider),内容极长、换行高度刚好卡在直式门槛的表格,串流中仍可能在直/横间抖动。只有极端宽/长文字表格会踩到;一般表格离门槛很远。要根治需「与内容无关的格式判定」(会误判常见 key/value 表)或「冻结栏宽」(失去 redraw-on-wider),不值得。
  • 未处理:分隔线是无效 markdown(如非 ASCII 破折号)的表格仍正确渲染为纯文字;含换行储存格的表格「算绘高度估算」(另一个 scroll-to-top 边界)在后续 PR 处理。
  • 未验证:Windows 与 Linux(本机仅 macOS,CI 覆盖)。
  • 破坏性变更:无。

关联 Issue

关联 #6170#6340。未使用关闭关键字。

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Thanks for the PR!

Template looks good ✓

Problem: real, observed UX issue. Streaming markdown tables in the interactive TUI exhibit five distinct visual artifacts — cell-by-cell fill-in, width oscillation, vertical-list-then-table flip, raw header text streaming, and a blank hang window before the first row. These are well-documented in prior PRs (#6170, #6340) and represent genuine user-facing polish gaps during live streaming.

Direction: aligned. This is incremental rendering polish for the interactive TUI, a core part of the user experience. Claude Code's CHANGELOG shows a long history of similar streaming/rendering fixes (scroll position jumping, layout jitter, streaming performance improvements), so this area is well within scope.

Approach: five focused refinements across four files, all in packages/cli/src/ui/utils/. Every change is gated on isPending/isStreaming so it only touches the live streaming frontier table — completed tables are unaffected. 308 additions / 32 deletions is reasonable for the scope. No unrelated changes or scope creep.

Moving on to code review. 🔍

中文说明

感谢贡献!

模板完整 ✓

问题:真实的、已观察到的 UX 问题。串流中的 markdown 表格在交互式 TUI 中有五种明显的视觉瑕疵——逐格填入、宽度抖动、直式清单再翻横式、表头以纯文字逐字冒出,以及第一列完成前的空白卡住。这些问题在之前的 PR(#6170#6340)中已有记录,确实是 live 串流过程中的用户体验问题。

方向:对齐。这是交互式 TUI 的渲染打磨,属于核心用户体验的一部分。Claude Code 的 CHANGELOG 中有大量类似的串流/渲染修复(滚动位置跳动、布局抖动、串流性能优化),这个方向在范围内。

方案:四个文件中的五项聚焦改进,全部在 packages/cli/src/ui/utils/ 中。所有改动都 gated 在 isPending/isStreaming 上,只影响 live 串流前缘表格——已完成的表格不受影响。308 行增加 / 32 行删除对范围来说是合理的。没有无关改动或范围蔓延。

进入代码审查 🔍

Qwen Code · qwen3.7-max

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Code Review

Reviewed the diff against my independent proposal. The PR's approach matches well — I'd have taken the same general strategy (hold back incomplete table state during streaming, bias toward horizontal format, show empty box early). The implementation is clean and well-scoped.

What I looked at:

  1. Pre-loop header hold-back (MarkdownDisplay.tsx ~L194–227): Walks backwards from the end to find the start of a pipe-line run, checks code fence state, verifies header has a matching separator before keeping the lines. Correct — the code fence guard prevents false positives on |-leading code block content.

  2. Per-row hold-back (MarkdownDisplay.tsx ~L383–403): Enhanced from the previous version to also catch closed-but-incomplete rows (| a | on a 3-column table). The !tableRowMatch || splitMarkdownTableRow(tableRowMatch[1]).length < tableHeaders.length condition correctly covers both unterminated and short-column intermediate states.

  3. Horizontal bias (TableRenderer.tsx ~L597–601): !isStreaming && maxRowLines > MAX_ROW_LINES — clean, minimal change that only affects the live frontier.

  4. Empty table box (MarkdownDisplay.tsx ~L625, TableRenderer.tsx ~L737–758): Removed tableRows.length > 0 guard for the frontier table, skip middle divider when no data rows. Well-commented rationale.

  5. isStreamingFrontier prop flow: Correctly chains through RenderTableRenderTableInternalTableRenderer as isStreaming = isPending && isStreamingFrontier. Only the live frontier table gets isStreamingFrontier={true} — completed tables in the same pending message use the normal path.

No critical bugs, no security concerns, no AGENTS.md violations. Code is focused — every change serves the stated goal.

Unit Tests

 ✓  @qwen-code/qwen-code  src/ui/utils/TableRenderer.test.tsx (67 tests) 267ms
 ✓  @qwen-code/qwen-code  src/ui/utils/MarkdownDisplay.test.tsx (144 tests) 642ms

 Test Files  2 passed (2)
      Tests  211 passed (211)

All 211 tests pass. New tests cover all five refinements: atomic rows, width tracking, horizontal bias, header hold-back, empty box rendering, and code block safety.

Tmux Real-Scenario Test

Both the installed build (qwen -p) and the PR code (npm run dev -- -p) were run in tmux with the same prompt asking for a 10-row, 4-column table.

Before (installed build, v0.19.1)

| Capital | Country | Population | Timezone |
|---|---|---|---|
| Tokyo | Japan | 13.96M | UTC+9 |
| Berlin | Germany | 3.75M | UTC+1 |
| Brasília | Brazil | 3.04M | UTC-3 |
| Ottawa | Canada | 1.02M | UTC-5 |
| Canberra | Australia | 457K | UTC+10 |
| Cairo | Egypt | 10.1M | UTC+2 |
| New Delhi | India | 329K | UTC+5:30 |
| Buenos Aires | Argentina | 3.08M | UTC-3 |
| Nairobi | Kenya | 4.73M | UTC+3 |
| Oslo | Norway | 709K | UTC+1 |

After (this PR via dev build)

| Capital | Country | Population | Timezone |
|---------|---------|------------|----------|
| Tokyo | Japan | 13,960,000 | UTC+9 |
| Paris | France | 2,161,000 | UTC+1 |
| Brasília | Brazil | 3,055,000 | UTC-3 |
| Nairobi | Kenya | 4,397,000 | UTC+3 |
| Canberra | Australia | 466,000 | UTC+10 |
| Ottawa | Canada | 1,017,000 | UTC-5 |
| Berlin | Germany | 3,645,000 | UTC+1 |
| New Delhi | India | 321,000 | UTC+5:30 |
| Cairo | Egypt | 10,100,000 | UTC+2 |
| Mexico City | Mexico | 9,209,000 | UTC-6 |

Note: Print mode (-p) outputs raw markdown — the TUI box-drawing rendering (the actual fix target) only appears in interactive mode. Both builds produce correct table data. The visual refinements (hold-back, horizontal bias, empty box, atomic rows) can only be verified in the interactive Ink-based TUI during live streaming, which requires an interactive model session. The unit tests comprehensively cover all five behaviors with 211 passing assertions.

Qwen Code · qwen3.7-max

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Reflection

This PR is a clean, well-scoped follow-up to #6170 and #6340 — each of the five refinements addresses a specific, documented streaming-table artifact. The implementation matches what I'd have done independently: hold back incomplete state at the frontier, bias toward horizontal format, show the empty box early to avoid the blank-hang illusion.

The code is straightforward. The isStreamingFrontier prop is a clean abstraction that correctly isolates the live frontier table from completed tables in the same pending message. The pre-loop header hold-back is the most intricate piece, but it's well-guarded (code fence check, column count match) and handles the edge cases.

211 unit tests pass, covering all five behaviors plus the code-block safety net. The tmux test in -p mode confirms both builds produce correct table data; the interactive TUI visual rendering (the actual fix target) isn't capturable in -p mode, but the unit tests exercise the rendering logic thoroughly.

No scope creep, no unrelated changes, no over-abstraction. Every line in the diff serves the stated goal. The comments explain why without being verbose. This is the kind of focused polish PR that's easy to review, revert if needed, and maintain.

Approved. ✅

中文说明

反思

这个 PR 是 #6170#6340 的干净、聚焦的后续——五项改进中的每一项都针对一个特定的、有记录的串流表格视觉瑕疵。实现方式与我的独立方案一致:在串流前缘压住不完整状态、偏向横式格式、提前显示空表框以避免空白卡住的错觉。

代码简洁明了。isStreamingFrontier prop 是一个干净的抽象,正确地将 live 前缘表格与同一 pending 消息中的已完成表格隔离开来。循环前的表头压住是最复杂的部分,但它有良好的防护(code fence 检查、栏数匹配)并处理了边缘情况。

211 个单元测试全部通过,涵盖了所有五种行为以及 code block 安全网。tmux 测试在 -p 模式下确认两个版本都能产生正确的表格数据;交互式 TUI 视觉渲染(实际的修复目标)在 -p 模式下无法捕获,但单元测试充分测试了渲染逻辑。

没有范围蔓延、没有无关改动、没有过度抽象。diff 中的每一行都服务于既定目标。注释解释了 为什么 而不冗长。这是一种聚焦的打磨 PR——易于审查、必要时易于回滚、易于维护。

已批准 ✅

Qwen Code · qwen3.7-max

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, looks ready to ship. ✅

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Downgraded from Approve to Comment: CI still running.

— qwen3.7-max via Qwen Code /review

// columns, NOT because an early row wraps tall. Otherwise a table that will
// end up horizontal briefly renders as a vertical `label: value` list and
// then flips (a visible jump) once more rows arrive / it completes.
const useVerticalFormat =

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The zero-row streaming table path produces invisible output on narrow terminals (contentWidth < minHorizontalTableWidth). useVerticalFormat is unconditionally true here regardless of isStreaming, and renderVerticalFormat() iterates empty rowMetrics, producing an empty string wrapped in <Box marginY={1}> — the user sees blank space instead of the intended header box.

The isStreaming flag only suppresses the maxRowLines > MAX_ROW_LINES trigger, not the width trigger. Consider short-circuiting: when rowMetrics.length === 0, always render the horizontal header-only box (header text is typically short enough to fit even on narrow terminals).

Suggested change
const useVerticalFormat =
const useVerticalFormat =
(rowMetrics.length > 0 && contentWidth < minHorizontalTableWidth) ||
(!isStreaming && maxRowLines > MAX_ROW_LINES);

— qwen3.7-max via Qwen Code /review

Follow-up to the streaming table hold-back, on its own branch so the
cue-removal PR (QwenLM#6340) can land undisturbed. Makes a live table stream
predictably instead of jittering, flashing, or hanging.

- Atomic rows: hold a frontier row back until it has ALL its columns. A
  multi-column row passes through intermediate states that are themselves
  valid rows with fewer cells (`| a |`, `| a | b |` toward `| a | b | c |`),
  so the old hold-back let it fill in cell by cell. Now the whole row
  (border + every cell) appears in one step.

- Widths track the current rows (no freeze): a wider row redraws the whole
  table once; a narrower row changes nothing (widths are a max over all
  rows, so they only ever grow). Redraw-on-wider only, never per token.

- Bias the streaming preview to the horizontal format: while a table is the
  live frontier it only falls back to the vertical `label: value` list when
  the terminal is genuinely too narrow, not because an early row wraps tall.
  This stops a table from briefly rendering as a vertical list and then
  flipping to a horizontal table (a visible jump).

- Hold a forming table back until it is recognizable: a header (and any
  partial separator) is trimmed while pending until a separator matching the
  header's column count arrives, so the header no longer streams in char by
  char as raw `| a | b |` text before snapping into a box. Fenced code-block
  content is left untouched.

- Draw the empty header box as soon as the table is recognized, before the
  first row completes, so the table area does not sit blank (no box, no cue)
  and look like a hang if generation stalls in that window. A zero-row box
  omits the header/body divider so it reads as a clean header, not an empty
  row.

Only the live frontier table is affected; completed and committed tables use
the normal logic. 211 tests pass (MarkdownDisplay + TableRenderer).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Jul 5, 2026

Copy link
Copy Markdown
Contributor

Please do not rebase or force-push to an active PR as it invalidates existing review comments. Note for future reference, the bots always squash all changes into a single commit automatically as part of the integration.

中文

请勿对活跃的 PR 执行 rebase 或 force-push,因为这会使已有的评审评论失效。另外,供日后参考:作为集成流程的一部分,机器人始终会自动将所有改动压缩(squash)为单个提交。

@MikeWang0316tw

Copy link
Copy Markdown
Contributor Author

Good catch — fixed in 53db9ccad.

Zero-row header box on a narrow terminal (TableRenderer.tsx): the width trigger (contentWidth < minHorizontalTableWidth) forced the vertical format, and renderVerticalFormat() over empty rowMetrics yields an empty string — a blank box, exactly the hang the empty-box was meant to avoid. Applied your suggestion so a zero-row table stays horizontal:

const useVerticalFormat =
  (rowMetrics.length > 0 && contentWidth < minHorizontalTableWidth) ||
  (!isStreaming && maxRowLines > MAX_ROW_LINES);

Added a regression test (keeps the zero-row header box horizontal on a narrow terminal, contentWidth 20) asserting the header + box render instead of blank. 212 tests pass, lint clean.

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] The downstream safety check at TableRenderer.tsx:767 (maxLineWidth > contentWidth - SAFETY_MARGIN) is a second path to renderVerticalFormat() that is NOT guarded for zero-row tables — the rowMetrics.length > 0 guard at line 604 only protects useVerticalFormat. When the safety fires on a zero-row streaming table (6+ columns on a ~25-col terminal), renderVerticalFormat() iterates empty rowMetrics and returns ''. The header box the PR aims to draw immediately vanishes entirely.

This is the same root cause as the useVerticalFormat guard at line 604 (raised in the prior review thread there), but the fix only covered one of the two fallback paths. The safety check needs the same rowMetrics.length > 0 guard:

if (maxLineWidth > contentWidth - SAFETY_MARGIN && rowMetrics.length > 0) {

if (isPending && renderVisualBlocks && lines.length > 0) {
let start = lines.length;
while (start > 0 && /^\s*\|/.test(lines[start - 1]!)) start--;
if (start < lines.length) {

Copy link
Copy Markdown
Collaborator

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 |), headerCells is 0, hasMatchingSeparator is unconditionally false (due to headerCells > 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.

Suggested change
if (start < lines.length) {
if (start < lines.length) {
// Don't touch pipe-lines that are really fenced code-block content.
let insideCodeFence = false;
for (let i = 0; i < start; i++) {
if (codeFenceRegex.test(lines[i]!)) insideCodeFence = !insideCodeFence;
}
if (!insideCodeFence) {
// Only hold back when the first pipe-line is a plausible table header
// (has a closing `|`). Otherwise non-table pipe-leading text (shell
// pipelines, log output) would vanish from the live preview.
const headerRowMatch = lines[start]!.match(tableRowRegex);
if (headerRowMatch) {
const headerCells = splitMarkdownTableRow(headerRowMatch[1])
.filter((c) => c.length > 0).length;
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);
}
}
}
}

— qwen3.7-max via Qwen Code /review

Review follow-up (two [Critical] findings).

- TableRenderer: the maxLineWidth safety check is a second path to the
  vertical format, unguarded for zero-row tables. On a very narrow terminal
  a zero-row streaming header box would fall through it and render an empty
  string — the box vanishes. Skip that fallback when there are no rows so the
  header stays visible even if it slightly overflows.

- MarkdownDisplay: the pre-loop header hold-back trimmed ANY trailing run of
  pipe-leading lines. When the first line is not a complete `| … |` row,
  headerCells was 0 and the run was trimmed anyway — so non-table pipe text
  (an un-fenced shell pipeline `| grep foo`, pipe-prefixed log output) would
  vanish from the live preview until commit. Only hold back when the first
  pipe-line is a plausible table header (a complete row).

Tests cover both. 215 pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@MikeWang0316tw

Copy link
Copy Markdown
Contributor Author

Both [Critical] findings fixed in 431fe2782 (pushed as a new commit, no force-push this time — thanks for the reminder).

  1. TableRenderer safety-check path (maxLineWidth > contentWidth - SAFETY_MARGIN): guarded with rowMetrics.length > 0, so a zero-row streaming header box no longer falls through to renderVerticalFormat() (empty string → blank). It stays horizontal even if it slightly overflows a very narrow terminal — visible beats vanished. Test: 6-column zero-row box at contentWidth 25 still renders the box.

  2. Pre-loop header hold-back capturing non-table text: now only trims when the first pipe-line is a plausible table header (a complete | … | row, per your suggestion). Un-fenced shell pipelines (| grep foo) and pipe-prefixed log output no longer vanish from the live preview. Test: | grep foo renders as text while pending.

215 tests pass, lint clean.

The previous commit (restricting the header hold-back to a complete `| … |`
row, to stop non-table pipe text from vanishing) reintroduced the cell-by-
cell header flash: while a header is typed (`| Alpha`, `| Alpha | Bet`, …)
it is not yet a complete row, so it rendered as raw text.

Discriminate by column count instead of closed-ness: a table header has ≥2
columns; a single-pipe line (shell pipeline `| grep foo`, pipe-prefixed log)
has one cell. Count cells on the first line whether or not it is closed, and
hold the run only when it has ≥2 columns and no matching separator yet. So a
multi-column header held mid-type no longer flashes, while single-pipe non-
table text still renders (the earlier [Critical] fix stands). A header still
typing its very first cell is indistinguishable from a single-pipe line, so
it shows briefly until the second column appears — the narrowest flash
possible without hiding real pipe text.

217 tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@MikeWang0316tw

Copy link
Copy Markdown
Contributor Author

Heads-up / self-correction: my previous commit 431fe2782 (restricting the header hold-back to a complete | … | row per the [Critical] non-table-text fix) reintroduced the cell-by-cell header flash — while a header is typed (| Alpha, | Alpha | Bet, …) it is not yet a complete row, so it rendered as raw text.

Fixed in 77c5320b8 by discriminating on column count instead of closed-ness: a table header has ≥2 columns; a single-pipe line (| grep foo, pipe-prefixed log) has one cell. The run is held only when its first line has ≥2 cells (closed or not) and no matching separator yet. Net result:

  • multi-column header held mid-type → no flash;
  • single-pipe non-table text still renders → the earlier [Critical] fix stands (test unchanged).

One residual by necessity: a header still typing its first cell is indistinguishable from a single-pipe line, so it shows briefly until the second column appears — the narrowest flash possible without hiding real pipe text. Added a test for the incomplete multi-column header. 217 tests pass, lint clean.

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Downgraded from Approve to Comment: CI still running.

No high-confidence issues found. Three low-confidence observations for human review:

  1. Multi-pipe shell commands (| grep error | sort | uniq) outside code fences are held back during streaming because they produce ≥2 non-empty cells, passing the table-header heuristic. Impact is narrow — multi-pipe commands in LLM output are almost always inside fenced code blocks, and content reappears when streaming ends. (MarkdownDisplay.tsx:221-233)

  2. Pre-loop and main-loop column counting use different methods (.filter(c => c.length > 0) on full line vs. unfiltered regex capture group). They disagree for headers with empty cells like | A | B | |. (MarkdownDisplay.tsx:220 vs :366)

  3. End-of-content handler removed the tableRows.length > 0 guard too broadly — a committed zero-row table at end-of-content now renders an empty header box, while the same table mid-message still requires rows. Suggested: if (inTable && tableHeaders.length > 0 && (tableRows.length > 0 || isPending)). (MarkdownDisplay.tsx:636)

Build passes. 217 tests pass (148 MarkdownDisplay + 69 TableRenderer). ESLint clean.

— qwen3.7-max via Qwen Code /review

MikeWang0316tw and others added 2 commits July 6, 2026 07:26
The horizontal-vs-vertical bias (force a live table horizontal while
streaming) backfired for tables that genuinely belong in the vertical
`label: value` format — a wide table with many columns of long, wrapping
text. It rendered horizontal (tall, clamped, looking stuck) while it was
the streaming frontier, then flipped to vertical the moment it stopped
being the frontier (the next block started) or committed — a visible
format flip, and worse than the vertical-list flash it was meant to avoid.

Drop the streaming bias: the horizontal-vs-vertical decision is now the
same while pending and once committed, so a table never flips format
between the two. Removes the now-unused isStreaming / isStreamingFrontier
plumbing.

Known residual (pre-existing, not from this change): because column widths
track content (redraw-on-wider), a borderline table's wrapped-row height
can still cross the vertical threshold mid-stream. Fully stabilizing that
needs a content-independent format decision — a separate change.

217 tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Document the accepted limitation next to the horizontal-vs-vertical
decision: because column widths track content (redraw-on-wider), a table
with very long cell text sitting right at MAX_ROW_LINES can still oscillate
format while streaming. Only extreme wide/long-text tables hit it; the
alternatives (content-independent decision, or frozen widths) each cost
more than the residual is worth.

Comment-only; no behaviour change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@MikeWang0316tw

Copy link
Copy Markdown
Contributor Author

Update since the earlier review (description refreshed to match):

  • Dropped the "horizontal bias" approach (force a live table horizontal while streaming). It backfired for tables that genuinely belong in the vertical label: value format (many columns of long wrapping text): they rendered horizontal while at the frontier, then flipped to vertical once the next block started or on commit — a visible format flip. The format decision is now the same while streaming and once committed, so a table never flips format mid-stream. Removed the now-unused isStreaming / isStreamingFrontier plumbing.
  • Also folded in the earlier fixes: multi-column header held mid-type without hiding non-table pipe text; the two zero-row / non-table [Critical] guards.
  • Documented one accepted limitation in code: with redraw-on-wider column widths, an extreme long-text table sitting right at the vertical threshold can still oscillate format while streaming (normal tables stay clear).

All commits will squash on merge. npx vitest run packages/cli/src/ui/utils/TableRenderer.test.tsx packages/cli/src/ui/utils/MarkdownDisplay.test.tsx → 217 pass.

// narrowest flash we can allow without hiding real non-table text.)
const headerCells = insideCodeFence
? 0
: splitMarkdownTableRow(lines[start]!).filter((c) => c.length > 0)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The 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 splitMarkdownTableRow(lines[start]!).filter((c) => c.length > 0) splits the full line (including outer pipes) and filters empty strings. The main loop at line 366 uses splitMarkdownTableRow(tableRowMatch[1]) on the captured group (outer pipes stripped) without filtering.

For a header with an empty cell like | A || B | (no space between pipes), the pre-loop counts 2 columns (the "" between || is filtered out), while the main loop counts 3. The pre-loop then fails to find a matching 3-column separator, and the table is held back for the entire streaming session.

Suggested change
: splitMarkdownTableRow(lines[start]!).filter((c) => c.length > 0)
let hdr = lines[start]!.replace(/^\s*\|/, '');
if (/\|\s*$/.test(hdr)) hdr = hdr.replace(/\|\s*$/, '');
const headerCells = insideCodeFence
? 0
: splitMarkdownTableRow(hdr).length;

— qwen3.7-max via Qwen Code /review

The streaming hold-back counted header columns on the full line with
empty cells filtered out, while the main table detector strips the outer
pipes and splits without filtering. For a header with an empty-named
column like `| A || B |` the two disagreed (2 vs 3), so the hold-back
never found the matching 3-column separator and hid the table for the
whole stream. Count columns the same way in both places.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
if (/\|\s*$/.test(hdr)) hdr = hdr.replace(/\|\s*$/, '');
headerCells = splitMarkdownTableRow(hdr).length;
}
if (headerCells >= 2) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The headerCells >= 2 threshold correctly exempts single-pipe lines like | grep foo, but multi-cell pipe-leading content — e.g. shell pipelines (| grep foo | wc -l), log excerpts (| 200 | OK | GET /api/users), or ASCII-art borders (|=====|======|) — is held back for the entire streaming duration when no matching separator ever arrives. After stripping the leading pipe, splitMarkdownTableRow("grep foo | wc -l") produces ["grep foo", "wc -l"] (2 cells), so headerCells >= 2 passes and the whole trailing run is trimmed.

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

The streaming hold-back keeps a run of pipe-lines back until a matching
separator arrives, so a real multi-column header does not flash in cell
by cell. But multi-cell non-table pipe content — a shell pipeline
(`| grep foo | wc -l`), a log excerpt (`| 200 | OK | GET /x`), an
ASCII-art border — also has >=2 cells, so it was held for the entire
stream and only appeared on commit.

A markdown table's separator is the line immediately after the header, so
once a line follows the header and does not even begin like a separator
(optional pipe, optional colon, then a dash), the run is decided: not a
forming table. Release it. A lone header still being typed (no line after
it yet) is still held, so the no-flash behavior is unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@MikeWang0316tw

Copy link
Copy Markdown
Contributor Author

Addressed the [Suggestion] on multi-cell non-table pipe content (commit — normal push, no force-push).

Rather than a line-count limit, I keyed on the markdown rule that a table's separator is the line immediately after the header. So once a line follows the held header and doesn't even begin like a separator (optional pipe, optional colon, then a dash), the run is decided as not-a-table and released:

  • shell pipelines (| grep foo | wc -l), log excerpts (| 200 | OK | GET /x) and ASCII-art borders now render live instead of being hidden until commit;
  • a lone header still being typed (no line after it yet) is still held, so the no-cell-by-cell-flash behavior is unchanged;
  • a header whose separator is still being typed (|--) also stays held (it does begin like a separator).

Added a test for the released log-excerpt case. 221 tests pass (152 MarkdownDisplay + 69 TableRenderer), lint clean.

The horizontal-vs-vertical choice used maxRowLines measured over EVERY row,
so a table that started horizontal (short first row) flipped to vertical the
moment a later, taller-wrapping row streamed in — a visible mid-stream format
change. Measure only the header + the first data row instead. The first row is
representative for the common case, so the format is decided once and stays
put as rows append. Column widths still track all rows (redraw-on-wider is
unchanged); only the format choice is anchored.

Trade-off: a table whose first row is short but a later row wraps very tall
stays a (taller) horizontal grid rather than flipping to vertical — rare, and
preferable to a visible flip.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@MikeWang0316tw

Copy link
Copy Markdown
Contributor Author

Follow-up (commit — normal push, no force-push): the horizontal-vs-vertical format choice no longer flips mid-stream.

Previously maxRowLines was measured over every row, so a table that started horizontal (short first row) flipped to vertical the moment a later, taller-wrapping row streamed in — a visible format change on a long-text table. It now measures only the header + first data row, so the format is decided once and stays put as rows append. Column widths still track all rows (redraw-on-wider is unchanged); only the format choice is anchored.

Trade-off: a table whose first row is short but a later row wraps very tall stays a (taller) horizontal grid rather than flipping — rare, and preferable to a visible flip. This tightens the earlier "format decision consistent, not streaming-biased" commit (it was consistent for a given row set, but the set grew during streaming).

Added a TableRenderer test (short first row + tall later row stays horizontal). 222 tests pass (70 TableRenderer + 152 MarkdownDisplay), lint clean.

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No high-confidence issues found. Downgraded from Approve to Comment: CI still running.

Summary: Well-scoped display-only improvements to streaming table rendering. 222 tests pass including 20 new test cases. The changes are thoughtfully implemented with honest documentation of accepted trade-offs.

Low-confidence observations for human review:

  1. couldStillBeTable regex (MarkdownDisplay.tsx:251) is slightly too permissive — it matches data rows whose first cell starts with a dash (e.g. | --verbose | Enable verbose output | after a multi-column header). Such a table would be held back for the entire streaming session. Consider tightening with tableSeparatorRegex.test(lineAfterHeader).

  2. calculateMaxRowLines (TableRenderer.tsx:556) measures the first row against columnWidths computed from all rows, so the vertical-format decision can still flip when a later wider row forces column redistribution. Acknowledged as an accepted limitation in the PR description.

— qwen3.7-max via Qwen Code /review

MikeWang0316tw and others added 2 commits July 6, 2026 13:02
The "could this pipe run still become a table?" check treated any line after
the header that merely started with a dash as a possible separator, so an
options table whose first data cell begins with a flag — `| --verbose | … |`
— was held back for the whole stream. Use tableSeparatorRegex instead: it
still matches a partial separator being typed (`|--`) so a real header is
held until its separator lands, but rejects a dash-led data cell (trailing
letters), which now renders live.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…lip)

A recognized table with no complete data row yet was drawn immediately as an
empty header box. A zero-row table can only render horizontally (the vertical
fallback needs rows), so once a long first row landed the box flipped to the
vertical label:value format — a visible format change that cannot be avoided
by looking at the header alone (column names are short; width comes from the
values). Defer the table while pending until its first row completes, so it
first appears already in its final format with no flip.

Cost: the table area stays blank while the header + first row stream (the
pre-loop trim already hid the header text, so this only extends that blank).
Committed tables always have rows, so their behavior is unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@MikeWang0316tw

Copy link
Copy Markdown
Contributor Author

Two follow-up commits (normal push, no force-push):

1. Release a dash-led data row from the hold-back — addresses observation ① from the last review. The couldStillBeTable check treated any post-header line starting with a dash as a possible separator, so an options table whose first data cell is a flag — | --verbose | … | — was held back for the whole stream. Now uses tableSeparatorRegex.test(lineAfterHeader): a partial separator being typed (|--) is still held, but a dash-led data cell (trailing letters) renders live. Added a test.

2. Defer a streaming table until its first row (no empty-box flip) — a recognized table with no complete data row yet was drawn immediately as an empty header box. A zero-row table can only render horizontally (the vertical fallback needs rows to lay out), so once a long first row landed the box flipped to the vertical label: value format — a visible format change. The format genuinely can't be decided from the header alone (column names are short; the width that forces vertical comes from the values). So while pending, defer the table until its first row completes: it first appears already in its final format, no flip. Cost: the table area stays blank while the header + first row stream (the pre-loop trim already hid the header text, so this only extends that blank). Committed tables are unchanged. Updated the empty-box test to assert the deferral.

Together with the earlier first-row-anchored format decision, a streaming table no longer flips horizontal↔vertical at any point. Tests: 156 MarkdownDisplay + 70 TableRenderer pass, lint clean.

// 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;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] The code fence tracking here uses a naive boolean toggle (insideCodeFence = !insideCodeFence) that only checks whether a line matches ANY fence pattern, without validating fence character or length. The main parser (lines 67-72) correctly validates that the closing fence uses the same character and has equal or greater length (codeFenceMatch[1].startsWith(activeCodeFence[0]) && codeFenceMatch[1].length >= activeCodeFence.length).

This causes content inside nested code blocks to silently vanish during streaming. For example, streaming this with isPending=true:

| code example |

| A | B |

The toggle sees line 0 (4 backticks) → true, line 2 (3 backticks) → false. So | A | B | at line 3 is treated as a forming table header and held back — invisible to the user until the message commits.

Suggested change
if (codeFenceRegex.test(lines[i]!)) insideCodeFence = !insideCodeFence;
let activeCodeFence = '';
for (let i = 0; i < start; i++) {
const fm = lines[i]!.match(codeFenceRegex);
if (fm) {
if (activeCodeFence) {
if (fm[1].startsWith(activeCodeFence[0]) && fm[1].length >= activeCodeFence.length) {
activeCodeFence = '';
}
} else {
activeCodeFence = fm[1];
}
}
}
const insideCodeFence = activeCodeFence !== '';

— qwen3.7-max via Qwen Code /review

if (
inTable &&
tableHeaders.length > 0 &&
(tableRows.length > 0 || !isPending)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] This condition allows committed zero-row tables to render at end-of-content (tableRows.length > 0 || !isPending), but the mid-content end-of-table handler (line 456) still requires tableRows.length > 0 unconditionally. A committed zero-row table mid-content (followed by non-table text) is silently dropped, while the same table at end-of-content renders a header-only box.

Before this PR both branches agreed on tableRows.length > 0. Consider updating line 456 to match, or adding a test documenting the intended difference.

— qwen3.7-max via Qwen Code /review

// yet) keep holding so a multi-column header does not flash in cell by
// cell before its separator arrives.
const lineAfterHeader = rest[0];
const couldStillBeTable =

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] When a header has 3 columns but the separator has 2, couldStillBeTable is true (the line matches tableSeparatorRegex) but hasMatchingSeparator is false (column count mismatch). Since the separator is already complete and won't gain columns, the entire pipe run is held back for the remainder of the stream.

Consider adding a check: if the separator is complete (has closing |) with wrong column count, release the content rather than holding it indefinitely:

if (lineAfterHeader !== undefined && /\|\s*$/.test(lineAfterHeader)) {
  const sepCols = splitMarkdownTableRow(lineAfterHeader).filter((c) => c.length > 0).length;
  if (sepCols !== headerCells) couldStillBeTable = false;
}

— qwen3.7-max via Qwen Code /review

// in. Column WIDTHS still track all rows (redraw-on-wider is unchanged); only
// the horizontal-vs-vertical CHOICE is anchored to the first row.
function calculateMaxRowLines(): number {
let maxLines = 1;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] calculateMaxRowLines was changed to measure only the header + first data row, but this applies to committed (non-streaming) tables as well — TableRenderer has no isPending/isStreaming prop. A committed table with a short first row and much taller later rows no longer triggers the vertical fallback, staying horizontal when vertical would be more readable.

The format-flip stability concern only applies during streaming. Consider passing an isStreaming flag and iterating all rows when not pending:

const rowsToMeasure = isPending ? (firstRow ? [firstRow] : []) : rowMetrics;

— qwen3.7-max via Qwen Code /review

});
});

describe('streaming table rendering (isStreaming)', () => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The describe block is named (isStreaming) but TableRenderer has no isStreaming prop — it has headers, rows, contentWidth, etc. The tests within don't pass any such prop either. Consider renaming to match what's actually tested:

Suggested change
describe('streaming table rendering (isStreaming)', () => {
describe('streaming table rendering (zero-row and format stability)', () => {

— qwen3.7-max via Qwen Code /review

… committed format

Five review findings:

- [Critical] The pre-loop hold-back's code-fence check used a naive boolean
  toggle that ignored fence char/length, so a nested fence (```` with an inner
  ```) mis-closed and a real code line like `| A | B |` was held back and
  vanished while streaming. Track the open fence's delimiter and validate the
  close (same char, >= length), mirroring the main parser.
- A COMPLETE separator whose column count already differs from the header can
  never match, so release the pipe run instead of holding it for the whole
  stream (the main parser treats it as text).
- The end-of-content table flush now uses the same `tableRows.length > 0` guard
  as the mid-content handler, so a degenerate zero-row table behaves the same
  whichever way it ends — no EOF-vs-mid asymmetry.
- TableRenderer's first-row-only maxRowLines (no-flip) applied to committed
  tables too; a committed short-first-row + tall-later-row table wrongly stayed
  horizontal. Gate on a new `isPending` prop: measure the first row only while
  streaming, every row once committed (most readable, no flip concern).
- Renamed the test block that claimed a nonexistent `isStreaming` prop; added
  committed-vs-streaming format tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@MikeWang0316tw

MikeWang0316tw commented Jul 6, 2026

Copy link
Copy Markdown
Contributor Author

Addressed all five findings from the last review (one commit, normal push):

  • [Critical] code-fence tracking (MarkdownDisplay.tsx): the pre-loop hold-back used a naive boolean toggle. It now tracks the open fence's delimiter and validates the close (same char, length ≥ open), mirroring the main parser — a nested ```` … ``` no longer mis-closes and hides a code | A | B | line while streaming. Added a nested-fence test.
  • Mismatched complete separator: a separator that is already complete (ends with |) but whose column count differs from the header can never match, so it is released instead of held for the whole stream (the main parser treats it as text). Added a test.
  • Zero-row EOF/mid asymmetry: the end-of-content flush now uses the same tableRows.length > 0 guard as the mid-content handler, so a degenerate zero-row table behaves identically either way.
  • First-row measurement leaked to committed tables: calculateMaxRowLines measured only the first row unconditionally, so a committed short-first-row + tall-later-row table wrongly stayed horizontal. Added an isPending prop to TableRenderer — measure the first row only while streaming (no mid-stream flip), every row once committed (most readable). Added committed-vs-streaming tests.
  • Test naming: renamed the block that referenced a nonexistent isStreaming prop.

231 tests pass (160 MarkdownDisplay + 71 TableRenderer), lint clean.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants