fix(cli): bound the live streaming-table pending height (fix scroll-to-top lock, stall-then-dump, header flash) (#6421)

* fix(cli): charge a streaming table's wrapped height so it doesn't jump

The rendered-height estimator charged a table `2 * dataRows + 5`, assuming
one line per data row. When cells wrap (a wide table — many columns, long
content — on a bounded content width), each row renders taller, so the live
frame briefly exceeds the viewport and ink repaints from the top (a jump to
the top; #6170's clip/commit recover it, so it does not lock, but the jump
is visible).

Charge each row (header + data) by its wrapped height instead: approximate
the column width as an equal share of the content area (TableRenderer
shrinks columns proportionally to fit; an equal share never gives a wide
cell more room than TableRenderer would, so it is a safe upper bound) and
sum the tallest wrapped cell per row plus the inter-row separators and
chrome. For a table that fits, every row is one line and the formula
reduces exactly to the previous `2 * dataRows + 5`.

Only the height estimate changes (shared by the render-side clip and the
incremental scrollback commit); no rendering behaviour changes.

28 estimator tests pass; MarkdownDisplay clip tests unaffected.

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

* fix(cli): charge a wide-terminal table's vertical fallback height

The pending-height estimator mirrored only TableRenderer's WIDTH-based
vertical-fallback trigger, not the maxRowLines one. On a wide terminal a
multi-column table whose cells wrap past MAX_ROW_LINES is laid out
vertically (label: value — much taller), but the estimator charged the
shorter horizontal height, so the live frame overflowed and Ink fell into
its from-top full-redraw path (the scroll-to-top lock).

Model both triggers: compute the tallest wrapped cell (maxRowLines) in the
same pass as the horizontal height, and when it exceeds MAX_ROW_LINES
charge the vertical height instead. The vertical estimate now also wraps
each label:value at contentWidth so a long value that wraps is not
under-counted. maxRowLines uses an equal column share (never wider than
TableRenderer's column), so it is an upper bound and a real vertical
table is never missed.

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

* fix(cli): cap the live pending region at the viewport height (non-VP)

The estimator's source-line slice is the primary bound on the live
(non-<Static>) pending frame, but it is disabled whenever
availableTerminalHeight is undefined — which is exactly what happens when
constrainHeight is off (ctrl-s "show more lines"). A tall pending item, e.g.
a long vertical-fallback table, then renders past the viewport; Ink cannot
update incrementally, clears the terminal and redraws from the top on every
repaint — the scroll-to-top lock.

Wrap the non-VP pending region in an Ink maxHeight={availableTerminalHeight}
overflow="hidden" box as a hard backstop. availableTerminalHeight already
excludes the footer/controls, so the live frame can never exceed the
viewport and Ink never trips clearTerminal. While constrained the estimator
keeps content well under this, so the clamp is inert and only engages on
residual overflow. ShowMoreLines stays outside the clamp (it renders only
while constrained, so the clamp is inert then, and must not be clipped).

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

* fix(cli): anchor the estimator's vertical trigger to the first row

Mirror TableRenderer's change to decide the horizontal-vs-vertical format
from the header + first data row only (not every row), so the estimator and
the renderer still agree on which format a streaming table uses. Row heights
above continue to sum every row; only the maxRowLines trigger is anchored.

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

* fix(cli): commit a completed table that alone fills the pending budget

The rendered-height-aware incremental commit stalled when a single block
(a long-text table modelled tall / vertical) charged more than the commit
budget on its own. fitPendingSlice returns kept = the block's trailing
blank line, so the safe split boundary sits exactly at keptLines, but the
boundary search started at keptLines - 1 and missed it. The table has no
internal blank line and the blank before it was already committed, so the
search found nothing and broke the loop. Every later block then appended
past keptLines, so the search window never again contained a blank line —
nothing committed until the stream finalized and dumped all remaining
tables at once (the "stream a few tables, pause, then dump the rest" bug).

Start the boundary search at keptLines so a completed over-tall block's
trailing blank is found and the block commits to <Static>. Committing an
over-tall completed block is fine — only the live pending frame must stay
within the viewport.

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

* fix(cli): don't flash the header while its separator streams in

The forming-table hold-back released the header the moment its separator
line ended with `|` at a column count different from the header — meant to
let a genuinely mismatched separator render as plain text. But a separator
is typed one group at a time and momentarily ends with `|` at every
intermediate count (`| --- | --- |` on the way to seven columns), so the
header flashed as raw `| … |` text on every closed-group frame while the
separator streamed in — a visible strobe for wide (7-column) tables.

A streaming separator only ever gains columns, so treat a mismatch as final
only when it can no longer become valid: it overshot the header's column
count, or a further line has already committed it (it is not the trailing
line). Also hold the header while the separator is still a bare-pipe prefix
(`|`, `| `) before its first dash. The only remaining flash is the
unavoidable one-cell header window (`| Foo |`), indistinguishable from a
single-pipe line.

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

* test: add a terminal-capture ratchet for the streaming-table scroll-to-top lock

Drives ten wide 7-column tables with ~200-char wrapping cells through the
real TUI via a chunked fake OpenAI server and counts the full-screen clears
(`\x1b[2J\x1b[3J\x1b[H`) the app emits while they stream. Each such clear
resets the terminal scroll position, so it is exactly the "jump to top" a
user hits when scrolling up mid-stream. With the pending-height estimator
fix the count is 0; without it the under-charged frame overflows and the
count is ~300. Ratchet fails if it exceeds 20.

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

* fix(cli): tighten the streaming-table vertical height estimate (review)

Two accuracy fixes to fitPendingSlice from PR review, both reducing residual
under-charge of the vertical fallback:

- Charge each vertical data cell as its rendered `label: value` line (parsing
  the header labels once), not the value alone — TableRenderer's
  renderVerticalFormat prefixes the header label, so a long label pushed the
  wrapped line count higher than the estimator accounted for.
- Only model the vertical layout once a data row exists (`dataRows > 0`). With
  just a header + separator, TableRenderer keeps the horizontal header box, so
  the estimator no longer charges the shorter 2-row vertical stub for that
  transient state on a narrow terminal.

Adds a test where the `label: value` line itself wraps (covering the wrapped,
label-inclusive formula) and a zero-data-row narrow-terminal test.

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

* fix(cli): correct the clamp margin and share MAX_ROW_LINES (review round 2)

- Charge tableClampRows + 2 when the clamp engages: TableRenderer wraps the
  height-clamped <Text> in <Box marginY={1}>, so a clamped table renders two
  margin rows beyond maxHeight that the estimator was dropping.
- Make TABLE_MAX_ROW_LINES the single source of truth (pending-rendered-height)
  and import it into TableRenderer as MAX_ROW_LINES, so the renderer and the
  estimator can never disagree on the wrap-to-vertical threshold. Direction is
  util→renderer so the pure height module stays free of the React/ink graph.
- Correct the perColWidth comment: an equal column share is exact for uniform
  columns but can under-count a heterogeneous table (the renderer shrinks a
  narrow column below the share); the MainContent maxHeight backstop is the
  hard cap for that residual case.
- Add a horizontal-layout test whose cells wrap within MAX_ROW_LINES, covering
  the per-row wrapped contentRows sum (not just the old flat 2*dataRows).

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

* fix(cli): estimator cleanups and stronger tests (review round 3)

- Charge the clamped-table margin: covered by a new test asserting a clamped
  table costs tableClampRows + 2 (the marginY the earlier fix added).
- Strengthen the "no stall-then-dump" regression: require all three tables to
  have committed (>= 3 items, each table marker present), so a partial stall
  no longer passes.
- Reuse headerCells instead of re-parsing the header row inside the loop, drop
  the redundant .trim() (splitMarkdownTableRow already trims), and start the
  data-row loop at i + 2.
- Refresh the stale TABLE_CHROME_ROWS JSDoc (no longer 2*dataRows+5) and
  document the estimator's known under-charge gaps (proportional column widths,
  word-aware wrapping, the renderer's post-layout width fallback) that the
  MainContent maxHeight backstop is the hard cap for.

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
MikeWang0316tw 2026-07-08 15:33:55 +08:00 committed by GitHub
parent d8dc8043d6
commit 5b43edcaf6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 765 additions and 54 deletions

View file

@ -0,0 +1,310 @@
#!/usr/bin/env npx tsx
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Deterministic TUI ratchet for the streaming-table pending-height fix.
*
* Streams ten horizontal 7-column tables whose cells hold ~200 chars of
* wrapping text (the user's repro) into the real TUI at a fixed terminal size,
* progressively, via a chunked OpenAI-compatible fake server. On a wide
* terminal these wrap tall, so TableRenderer falls back to the vertical
* layout. Without the pending-height estimator fix the live frame is
* under-charged, overflows the viewport, and Ink repaints from scratch by
* emitting a full-screen clear (`\x1b[2J\x1b[3J\x1b[H`). Each such clear
* resets the terminal's scroll position so a user who scrolled up to read
* gets yanked back to the top ("跳頂"). With the fix the estimator charges the
* table's real (wrapped / vertical) height, the frame stays inside the
* viewport budget, and the app updates in place instead of clearing.
*
* This script counts the full-screen clears the app writes to the PTY while
* the tables stream the same escape-sequence-ratchet methodology as
* `subagent-flicker-regression.ts` and `table-inline-code-wrap-regression.ts`.
*
* Reference numbers (100×28 terminal, 10 tables, ~200-char cells):
*
* With the pending-height fix (current branch):
* clearTerminalTriples=0, clear2J=0, eraseLine3270, ptyBytes0.32 MB
* Without it (estimator under-charges the wrapped/vertical height):
* clearTerminalTriples300 (172 mid-stream), eraseLine315, ptyBytes4.8 MB
*
* The signal is `clearTerminalTriples`: it collapses from ~300 to 0 with the
* fix. The default threshold (20) sits far below the no-fix count so a full
* regression trips the ratchet, and far above 0 so incidental start-up/exit
* clears never flake it.
*
* Usage:
* npm run build && npm run bundle
* cd integration-tests/terminal-capture
* npx tsx table-pending-height-scroll-lock-regression.ts
*
* Env:
* QWEN_TUI_E2E_MAX_FULL_CLEARS pass threshold on clearTerminalTriples (default 20)
* QWEN_TUI_E2E_EXPECT_PASS set to "false" to assert the ratchet FAILS
* (use when running against pre-fix code)
* QWEN_TUI_E2E_OUT output dir (default under os.tmpdir())
* QWEN_TUI_E2E_REPO repo root whose dist/cli.js is launched
*/
import { createServer, type AddressInfo } from 'node:http';
import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { basename, dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { TerminalCapture } from './terminal-capture.js';
const TERMINAL_COLS = 100;
const TERMINAL_ROWS = 28;
const NUM_TABLES = 10;
const DONE_MARKER = 'ALL_TABLES_DONE';
const MID_STREAM_MARKER = 'Table 5';
const PROMPT_TEXT = 'Print ten wide tables with long wrapping cells.';
const WORDS =
'lorem ipsum dolor sit amet consectetur adipiscing elit sed do eiusmod tempor incididunt ut labore et dolore magna aliqua enim ad minim veniam quis nostrud exercitation ullamco laboris nisi aliquip ex ea commodo consequat duis aute irure reprehenderit voluptate velit esse cillum'.split(
' ',
);
/** A deterministic ~200-char cell that wraps across several lines. */
function longCell(seed: number): string {
let s = '';
let i = seed;
while (s.length < 190) {
s += (s ? ' ' : '') + WORDS[i % WORDS.length];
i += 1;
}
return s;
}
function buildMarkdown(): string {
const lines: string[] = ['Here are the requested tables:', ''];
for (let t = 1; t <= NUM_TABLES; t++) {
lines.push(`### Table ${t}`, '');
lines.push('| C1 | C2 | C3 | C4 | C5 | C6 | C7 |');
lines.push('| --- | --- | --- | --- | --- | --- | --- |');
for (let r = 0; r < 2; r++) {
const cells: string[] = [];
for (let c = 0; c < 7; c++) cells.push(longCell(t * 100 + r * 7 + c));
lines.push(`| ${cells.join(' | ')} |`);
}
lines.push('');
}
lines.push(DONE_MARKER);
return lines.join('\n');
}
/** Minimal OpenAI-compatible server that streams `content` in small delayed
* chunks, so the TUI grows its pending frame progressively (like a live
* model) rather than receiving the whole reply in one delta. */
function startChunkedServer(content: string): Promise<{
baseUrl: string;
requestCount: () => number;
close: () => void;
}> {
let requests = 0;
const server = createServer(async (req, res) => {
if (req.method !== 'POST' || !req.url?.endsWith('/chat/completions')) {
res.writeHead(404);
res.end('not found');
return;
}
await new Promise<void>((r) => {
req.on('data', () => {});
req.on('end', () => r());
});
const index = requests++;
res.writeHead(200, {
'cache-control': 'no-cache',
connection: 'keep-alive',
'content-type': 'text/event-stream',
});
const id = `chatcmpl-${index}`;
const send = (delta: object, finish: string | null = null) =>
res.write(
`data: ${JSON.stringify({
id,
object: 'chat.completion.chunk',
created: Math.floor(Date.now() / 1000),
model: 'dummy',
choices: [{ index: 0, delta, finish_reason: finish }],
})}\n\n`,
);
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
send({ role: 'assistant' });
// Only the first turn streams the tables; any later turn ends fast.
const body = index === 0 ? content : 'OK.';
const PIECE = 55;
for (let i = 0; i < body.length; i += PIECE) {
send({ content: body.slice(i, i + PIECE) });
if (index === 0) await sleep(18);
}
send({}, 'stop');
res.write('data: [DONE]\n\n');
res.end();
});
return new Promise((resolveServer) => {
server.listen(0, '127.0.0.1', () => {
const port = (server.address() as AddressInfo).port;
resolveServer({
baseUrl: `http://127.0.0.1:${port}/v1`,
requestCount: () => requests,
close: () => server.close(),
});
});
});
}
function countAll(hay: string, needle: string): number {
let n = 0;
let i = hay.indexOf(needle);
while (i !== -1) {
n++;
i = hay.indexOf(needle, i + needle.length);
}
return n;
}
function envNumber(name: string, fallback: number): number {
const value = process.env[name];
if (value === undefined || value === '') return fallback;
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : fallback;
}
function qwenArgs(baseUrl: string): string[] {
return [
'dist/cli.js',
'--no-chat-recording',
'--approval-mode',
'yolo',
'--auth-type',
'openai',
'--openai-api-key',
'dummy',
'--openai-base-url',
baseUrl,
'--model',
'dummy',
];
}
async function main(): Promise<void> {
const scriptDir = dirname(fileURLToPath(import.meta.url));
const repoRoot = resolve(
process.env['QWEN_TUI_E2E_REPO'] ?? resolve(scriptDir, '../..'),
);
const outputDir = resolve(
process.env['QWEN_TUI_E2E_OUT'] ??
join(tmpdir(), 'qwen-table-scroll-lock', basename(repoRoot)),
);
const maxFullClears = envNumber('QWEN_TUI_E2E_MAX_FULL_CLEARS', 20);
const expectedPass = process.env['QWEN_TUI_E2E_EXPECT_PASS'] !== 'false';
if (existsSync(outputDir)) rmSync(outputDir, { recursive: true });
mkdirSync(outputDir, { recursive: true });
const server = await startChunkedServer(buildMarkdown());
const homeDir = join(outputDir, 'home');
mkdirSync(homeDir, { recursive: true });
const env: NodeJS.ProcessEnv = {
...process.env,
FORCE_COLOR: '1',
HOME: homeDir,
USERPROFILE: homeDir,
NODE_NO_WARNINGS: '1',
QWEN_CODE_DISABLE_SYNCHRONIZED_OUTPUT: '1',
QWEN_CODE_NO_RELAUNCH: '1',
QWEN_SANDBOX: 'false',
TERM: 'xterm-256color',
};
delete env['NO_COLOR'];
delete env['QWEN_CODE_SIMPLE'];
for (const key of [
'HTTP_PROXY',
'http_proxy',
'HTTPS_PROXY',
'https_proxy',
'ALL_PROXY',
'all_proxy',
]) {
delete env[key];
}
const terminal = await TerminalCapture.create({
chrome: false,
cols: TERMINAL_COLS,
rows: TERMINAL_ROWS,
cwd: repoRoot,
env,
fontSize: 14,
outputDir,
theme: 'github-dark',
title: 'table pending-height scroll lock regression',
});
try {
await terminal.spawn('node', qwenArgs(server.baseUrl));
// The TUI's input hint is localized; match on a stable ASCII marker of the
// ready prompt instead. The '' prompt glyph shows once input is ready.
await terminal.waitFor('YOLO', { timeout: 30000 });
await terminal.type(PROMPT_TEXT, { delay: 8, slow: true });
await terminal.idle(300, 4000);
await terminal.type('\n');
// Sample the clear count mid-stream (while tables are still arriving — the
// window where a scrolled-up viewport gets yanked).
await terminal.waitFor(MID_STREAM_MARKER, { timeout: 30000 });
const midStreamClears = countAll(terminal.getRawOutput(), '\x1b[2J');
await terminal.waitForAndIdle(DONE_MARKER, {
stableMs: 1500,
timeout: 45000,
});
const raw = terminal.getRawOutput();
const clearTerminalTriples = countAll(raw, '\x1b[2J\x1b[3J\x1b[H');
const pass = clearTerminalTriples <= maxFullClears;
const summary = {
repoRoot,
outputDir,
cols: TERMINAL_COLS,
rows: TERMINAL_ROWS,
requestCount: server.requestCount(),
ptyBytes: raw.length,
clearTerminalTriples,
clear2J: countAll(raw, '\x1b[2J'),
midStreamClear2J: midStreamClears,
eraseLine: countAll(raw, '\x1b[2K'),
maxFullClears,
pass,
expectedPass,
};
writeFileSync(join(outputDir, 'raw.ansi.log'), raw);
writeFileSync(
join(outputDir, 'summary.json'),
`${JSON.stringify(summary, null, 2)}\n`,
);
console.log(JSON.stringify(summary, null, 2));
if (pass !== expectedPass) {
throw new Error(
`Expected pass=${expectedPass} but observed pass=${pass} ` +
`(clearTerminalTriples=${clearTerminalTriples}, threshold=${maxFullClears}). ` +
`See ${join(outputDir, 'summary.json')}`,
);
}
} finally {
await terminal.close();
server.close();
}
}
main().catch((error) => {
console.error(error);
process.exit(1);
});

View file

@ -440,24 +440,49 @@ export const MainContent = () => {
</Static>
<OverflowProvider>
<Box flexDirection="column">
{pendingHistoryItemsWithSourceCopyOffsets.map(
({ item, sourceCopyIndexOffsets }, i) => (
<HistoryItemDisplay
key={i}
availableTerminalHeight={
uiState.constrainHeight ? availableTerminalHeight : undefined
}
terminalWidth={terminalWidth}
mainAreaWidth={mainAreaWidth}
item={{ ...item, id: 0 }}
isPending={true}
isFocused={!uiState.isEditorDialogOpen}
activeShellPtyId={uiState.activePtyId}
embeddedShellFocused={uiState.embeddedShellFocused}
sourceCopyIndexOffsets={sourceCopyIndexOffsets}
/>
),
)}
{/*
Hard Ink backstop on the live (non-<Static>) pending region. The
estimator's source-line slice (MarkdownDisplay's fitPendingSlice) is
the primary bound, but it is disabled whenever availableTerminalHeight
is undefined which is exactly what happens when constrainHeight is
off (ctrl-s "show more lines"). A tall pending item (e.g. a long
vertical-fallback table) then renders past the viewport, Ink cannot
update incrementally and clears the terminal, redrawing from the top
on every repaint the "scroll-to-top lock". Capping this region at
availableTerminalHeight (which already excludes the footer/controls)
keeps its measured height within the viewport so Ink never trips that
path. While constrained the estimator keeps content well under this,
so the clamp is a no-op there and only engages on residual overflow.
ShowMoreLines stays OUTSIDE the clamp; it only renders while
constrained (so the clamp is inert) and must not be clipped.
*/}
<Box
flexDirection="column"
flexShrink={0}
maxHeight={availableTerminalHeight || undefined}
overflow="hidden"
>
{pendingHistoryItemsWithSourceCopyOffsets.map(
({ item, sourceCopyIndexOffsets }, i) => (
<HistoryItemDisplay
key={i}
availableTerminalHeight={
uiState.constrainHeight
? availableTerminalHeight
: undefined
}
terminalWidth={terminalWidth}
mainAreaWidth={mainAreaWidth}
item={{ ...item, id: 0 }}
isPending={true}
isFocused={!uiState.isEditorDialogOpen}
activeShellPtyId={uiState.activePtyId}
embeddedShellFocused={uiState.embeddedShellFocused}
sourceCopyIndexOffsets={sourceCopyIndexOffsets}
/>
),
)}
</Box>
<ShowMoreLines constrainHeight={uiState.constrainHeight} />
</Box>
</OverflowProvider>

View file

@ -4615,6 +4615,62 @@ describe('useGeminiStream', () => {
});
});
it('commits each completed table when a single table fills the budget (no stall-then-dump)', async () => {
vi.useFakeTimers();
vi.mocked(findLastSafeSplitPoint).mockImplementation(
(s: string, cap?: number) =>
cap === undefined ? s.length : Math.min(cap, s.length),
);
// Three COMPLETE tables (each terminated by a blank line), each tall
// enough on its own to exceed the fallback commit budget (terminalHeight
// 24 → budget 7). fitPendingSlice charges the whole table and returns
// kept = its trailing blank line, so the safe boundary sits exactly at
// keptLines. The commit loop must still find it and commit each table as
// it completes — a regression here stalls after the first table and dumps
// every later table at once only when the stream finalizes.
const table = (t: number) =>
[
'| A | B |',
'| --- | --- |',
...Array.from({ length: 8 }, (_, r) => `| t${t}r${r} | v${t}r${r} |`),
].join('\n');
const content = [table(1), table(2), table(3), 'tail'].join('\n\n');
const { result } = renderTestHook();
const releaseStream = await streamContent(result, content);
// The completed tables committed incrementally rather than stalling. All
// three tables must have committed (a partial stall — one commits, the
// other two dump together — would leave fewer than three committed items).
const committed = geminiContentItems();
expect(committed.length).toBeGreaterThanOrEqual(3);
for (const marker of ['t1r0', 't2r0', 't3r0']) {
expect(committed.some((item) => item.text.includes(marker))).toBe(true);
}
// Nothing orphaned: any committed chunk with table rows carries its
// separator (a whole table, not a headerless tail).
for (const item of committed) {
const hasTableRow = item.text
.split('\n')
.some((l) => /^\s*\|.*\|\s*$/.test(l));
if (hasTableRow) {
expect(item.text).toMatch(/\|\s*:?-+/);
}
}
// The pending tail is bounded — the whole ~34-line message did not sit
// pending waiting for finalize.
const pendingText = result.current.pendingHistoryItems[0]?.text ?? '';
const pendingLines =
pendingText.length === 0 ? 0 : pendingText.split('\n').length;
expect(pendingLines).toBeLessThanOrEqual(12);
act(() => result.current.cancelOngoingRequest());
await act(async () => {
releaseStream();
});
});
it('does not render leading blank thought chunks as an empty thought item', async () => {
vi.useFakeTimers();

View file

@ -1317,10 +1317,19 @@ export const useGeminiStream = (
tableClampRows,
);
if (!clipped) break;
// Back up to the last blank line within the kept prefix — the only place
// it is safe to end a committed chunk without orphaning a block.
// Back up to the last blank line at or before the kept prefix — the only
// place it is safe to end a committed chunk without orphaning a block.
// Start AT keptLines (not keptLines - 1): when a single block is taller
// than the budget, fitPendingSlice charges the whole block and returns
// kept = the block's trailing blank line, so the boundary sits exactly at
// keptLines. Searching from keptLines - 1 misses it, finds no earlier
// blank (the block has none, and the blank before it was already
// committed), and stalls — every later block then appends past keptLines,
// so nothing ever commits until the stream finalizes and dumps it all at
// once. Committing an over-tall completed block to <Static> is fine; only
// the live pending frame must stay within the viewport.
let boundaryLine = -1;
for (let k = Math.min(keptLines, bufferLines.length) - 1; k >= 0; k--) {
for (let k = Math.min(keptLines, bufferLines.length - 1); k >= 0; k--) {
if (bufferLines[k]!.trim() === '') {
boundaryLine = k;
break;

View file

@ -537,18 +537,64 @@ Done.`.replace(/\n/g, eol);
expect((lastFrame() ?? '').includes('Alpha')).toBe(false);
});
it('releases a complete separator whose column count differs from the header', () => {
// Header has 3 columns, the separator is complete (ends with `|`) but has
// only 2 — it can never become a matching separator, and the main parser
// treats it as plain text. So it must render, not be held for the stream.
it('releases a mismatched separator once a line follows it (no longer growing)', () => {
// Header has 3 columns, the separator has only 2 AND a further line follows
// it — so the separator is committed (it will not gain a third column) and
// can never become a matching separator. The main parser treats it as plain
// text, so it must render, not be held for the stream.
const text = `| A | B | C |
| --- | --- |`.replace(/\n/g, eol);
| --- | --- |
next paragraph`.replace(/\n/g, eol);
const { lastFrame } = renderWithProviders(
<MarkdownDisplay {...baseProps} text={text} isPending={true} />,
);
expect(lastFrame() ?? '').toContain('A');
});
it('releases a separator with too many columns (overshot the header)', () => {
// Header has 2 columns, the trailing separator already has 3 — it overshot
// and can only gain more, so it can never match. Release it as plain text
// rather than holding the run for the rest of the stream.
const text = `| A | B |
| --- | --- | --- |`.replace(/\n/g, eol);
const { lastFrame } = renderWithProviders(
<MarkdownDisplay {...baseProps} text={text} isPending={true} />,
);
expect(lastFrame() ?? '').toContain('A');
});
it('keeps holding while a short separator is still the trailing line (may gain columns)', () => {
// Regression: a 7-column header whose separator is mid-type momentarily ends
// with `|` at an intermediate count (`| --- | --- |` on the way to seven).
// That is NOT a final mismatch — the separator can still gain columns — so
// the header must stay held, not flash as raw `| … |` text on every
// closed-group frame while the separator streams in.
const text = `intro
| C1 | C2 | C3 | C4 | C5 | C6 | C7 |
| --- | --- |`.replace(/\n/g, eol);
const { lastFrame } = renderWithProviders(
<MarkdownDisplay {...baseProps} text={text} isPending={true} />,
);
const output = lastFrame() ?? '';
expect(output).toContain('intro');
expect(output).not.toContain('C1');
});
it('keeps holding the header while the separator is just a bare pipe', () => {
// The frame between the header's newline and the separator's first dash: the
// trailing line is a bare `|` (a valid separator prefix, no dash yet). The
// header must stay held rather than flashing raw for that one frame.
const text = `intro
| C1 | C2 | C3 | C4 | C5 | C6 | C7 |
|`.replace(/\n/g, eol);
const { lastFrame } = renderWithProviders(
<MarkdownDisplay {...baseProps} text={text} isPending={true} />,
);
const output = lastFrame() ?? '';
expect(output).toContain('intro');
expect(output).not.toContain('C1');
});
it('does not hold a pipe line inside a nested (longer) code fence', () => {
// A ```` fence is still open; an inner ``` (shorter) does NOT close it, so a
// `| … |` line after it is code content and must render. A naive fence

View file

@ -283,14 +283,34 @@ const MarkdownDisplayInternal: React.FC<MarkdownDisplayProps> = ({
// yet) keep holding so a multi-column header does not flash in cell by
// cell before its separator arrives.
const lineAfterHeader = rest[0];
const separatorIsTrailing = rest.length === 1;
// The separator's first characters may arrive as a bare `|` or `| ` with
// no dash yet — not matched by tableSeparatorRegex, but a valid separator
// PREFIX. While it is the trailing line (still being typed) and contains
// only separator characters, treat it as a still-forming separator so the
// header does not flash raw for the frame between the header's newline and
// the separator's first dash.
const looksLikeSeparatorPrefix =
lineAfterHeader !== undefined &&
lineAfterHeader.includes('|') &&
/^[\s|:-]*$/.test(lineAfterHeader);
let couldStillBeTable =
lineAfterHeader === undefined ||
tableSeparatorRegex.test(lineAfterHeader);
// A COMPLETE separator (ends with `|`) whose column count already differs
// from the header will never become a valid table — the main parser
// treats it as plain text — so release it instead of holding the run for
// the rest of the stream. A still-forming separator (no closing `|`) can
// still gain columns, so keep holding it.
tableSeparatorRegex.test(lineAfterHeader) ||
(separatorIsTrailing && looksLikeSeparatorPrefix);
// A COMPLETE separator whose column count already differs from the header
// will never become a valid table — the main parser treats it as plain
// text — so release it instead of holding the run for the rest of the
// stream. The catch: a separator is typed one group at a time and, BETWEEN
// groups, momentarily ends with `|` at an intermediate count
// (`| --- | --- |` on the way to seven columns). "Ends with `|`" alone
// therefore does NOT mean "complete" while it is still streaming. A
// streaming separator only ever GAINS columns, so treat it as a final
// mismatch only when it can no longer become valid: it OVERSHOT the
// header's column count, or a further line has already committed it (it is
// not the trailing line, so it will not grow). While it is the trailing
// line and still short of the header, keep holding — releasing there makes
// the header flash as raw `| … |` text on every closed-group frame.
if (
couldStillBeTable &&
lineAfterHeader !== undefined &&
@ -299,7 +319,12 @@ const MarkdownDisplayInternal: React.FC<MarkdownDisplayProps> = ({
const sepCols = splitMarkdownTableRow(lineAfterHeader).filter(
(c) => c.length > 0,
).length;
if (sepCols !== headerCells) couldStillBeTable = false;
if (
sepCols !== headerCells &&
(sepCols > headerCells || !separatorIsTrailing)
) {
couldStillBeTable = false;
}
}
if (!hasMatchingSeparator && couldStillBeTable) {
lines = lines.slice(0, start);

View file

@ -9,6 +9,7 @@ import { Text, Box } from 'ink';
import wrapAnsi from 'wrap-ansi';
import stripAnsi from 'strip-ansi';
import { getCachedStringWidth } from './textUtils.js';
import { TABLE_MAX_ROW_LINES as MAX_ROW_LINES } from './pending-rendered-height.js';
import { theme } from '../semantic-colors.js';
import { renderInlineLatex } from './latexRenderer.js';
import {
@ -27,8 +28,9 @@ import {
/** Minimum column width to prevent degenerate layouts */
const MIN_COLUMN_WIDTH = 3;
/** Maximum number of lines per row before switching to vertical format */
const MAX_ROW_LINES = 4;
// MAX_ROW_LINES (the wrap-height threshold that switches a row to the vertical
// layout) is imported from pending-rendered-height so the renderer and the
// pending-height estimator can never disagree on the format decision.
/**
* Below this width the column-aware budget (see `minHorizontalTableWidth`

View file

@ -144,6 +144,26 @@ describe('fitPendingSlice', () => {
expect(keptLines).toBe(3); // stops before the table row (index 3)
});
it('charges a wide table by its wrapped row height, not a flat 2 rows/row', () => {
// Cells long enough to wrap make the table taller than `2*dataRows + 5`.
// Under-counting that lets the live frame overflow and jump to the top.
const longCell = 'x'.repeat(30);
const lines = [
'intro',
'| A | B |',
'| - | - |',
`| ${longCell} | ${longCell} |`,
];
// Wide terminal: cells fit on one line → table is 2*1 + 5 = 7, so
// intro (1) + table (7) = 8 fits the budget and the table is kept whole.
expect(fitPendingSlice(lines, 200, 8, CLAMP).keptLines).toBe(lines.length);
// Narrow terminal: each cell wraps to several lines → the table is taller
// than 7, so it no longer fits after intro and is cut before it.
const narrow = fitPendingSlice(lines, 30, 8, CLAMP);
expect(narrow.clipped).toBe(true);
expect(narrow.keptLines).toBe(1); // only 'intro' survives
});
it('caps a table cost at tableClampRows', () => {
// A huge first-block table with a small clamp: cost = clamp, so the walk
// keeps it and stops (kept = all lines), never exceeding the clamp.
@ -153,6 +173,19 @@ describe('fitPendingSlice', () => {
expect(keptLines).toBe(lines.length);
});
it('adds the marginY (2 rows) to a clamped table cost', () => {
// TableRenderer clamps the inner <Text> to `tableClampRows` but wraps it in
// <Box marginY={1}>, so a clamped table renders clampRows + 2. A non-first
// huge table with clamp 5 costs min(rows, 5+2)=7. intro(1)+7 = 8 > budget 7
// → cut before it. With the old `min(rows, clampRows)`=5 charge, 1+5 = 6 ≤ 7
// would keep it, dropping the two margin lines.
const rows = Array.from({ length: 40 }, () => '|1|2|');
const lines = ['intro', '| A | B |', '| - | - |', ...rows];
const { keptLines, clipped } = fitPendingSlice(lines, 80, 7, /* clamp */ 5);
expect(clipped).toBe(true);
expect(keptLines).toBe(1);
});
it('returns keptLines=0 when the first line alone overflows (wide/CJK line)', () => {
// One line of 200 CJK cols at width 10 → 20 rows > budget 5.
const lines = ['一'.repeat(100)];
@ -219,6 +252,119 @@ describe('fitPendingSlice', () => {
expect(clipped).toBe(false);
});
it('charges the vertical height on a WIDE terminal when cells wrap past MAX_ROW_LINES', () => {
// Regression: a wide terminal (80 ≥ minHorizontalWidth 47 for 7 cols) so the
// WIDTH trigger is off, but each data cell is 30 chars → at perColWidth
// floor((80-22-4)/7)=7 it wraps to ceil(30/7)=5 > MAX_ROW_LINES(4), so
// TableRenderer falls back to the taller vertical layout. The estimator must
// mirror that: vertical = 4 rows * 7 cells (each 30 chars → 1 line at width
// 80) + 3 separators + 2 margin = 33, not the horizontal 4*5+header+chrome.
// With intro(1) + 33 = 34 > budget 30, the table is cut before it. Modelling
// only the width trigger would charge the horizontal height and wrongly keep
// it, overflowing the live frame and locking the viewport to the top.
const wide = 'w'.repeat(30);
const lines = [
'intro',
'| A | B | C | D | E | F | G |',
'| - | - | - | - | - | - | - |',
...Array.from(
{ length: 4 },
() =>
`| ${wide} | ${wide} | ${wide} | ${wide} | ${wide} | ${wide} | ${wide} |`,
),
];
const { keptLines, clipped } = fitPendingSlice(lines, 80, 30, CLAMP);
expect(clipped).toBe(true);
expect(keptLines).toBe(1); // cut before the vertical-bound table
});
it('charges wrapped vertical rows when the `label: value` line itself wraps', () => {
// Narrow terminal (20 < minHorizontalWidth 24 for 2 cols) → vertical. Each
// data cell renders as `label: value`; here that is `Key: vvv…` = 45 chars,
// wrapping to ceil(45/20)=3 lines PER cell — not 1. So vertical = 3+3 data
// lines + 2 margin = 8. intro(1)+8 = 9 > budget 7 → cut before the table.
// The old value-only (or flat one-line-per-cell) charge would be 2+2+2 = 6,
// giving 1+6 = 7 ≤ 7 and wrongly keeping the table — under-charging a table
// whose vertical lines wrap, the exact miss that overflows the live frame.
const val = 'v'.repeat(40);
const lines = [
'intro',
'| Key | Val |',
'| --- | --- |',
`| ${val} | ${val} |`,
];
const { keptLines, clipped } = fitPendingSlice(lines, 20, 7, CLAMP);
expect(clipped).toBe(true);
expect(keptLines).toBe(1);
});
it('keeps a zero-data-row table horizontal on a narrow terminal (no vertical stub)', () => {
// Header + separator only (first data row still streaming). TableRenderer
// keeps the horizontal header box; renderVerticalFormat iterates data rows
// and would draw nothing. Even though the terminal is narrow (20 < 29), the
// estimator must charge horizontal chrome (5), not the 2-row vertical stub.
// intro(1)+5 = 6 > budget 4 → cut before it. Without the dataRows>0 guard it
// would charge 2, keep the table, and under-count the transient header box.
const lines = ['intro', '| A | B | C | D |', '| - | - | - | - |'];
const { keptLines, clipped } = fitPendingSlice(lines, 20, 4, CLAMP);
expect(clipped).toBe(true);
expect(keptLines).toBe(1);
});
it('charges wrapped HORIZONTAL rows when cells wrap within MAX_ROW_LINES', () => {
// 2 cols at width 40 → minHorizontalWidth 24, so horizontal. perColWidth =
// floor((40-7-4)/2)=14; a 15-char cell wraps to ceil(15/14)=2 lines, so the
// data row is 2 rows tall (not 1). horizontal = header(1)+row(2) + chrome(5)
// = 8. intro(1)+8 = 9 > budget 8 → cut before it. The old flat 2*dataRows+5
// = 7 charge would give 1+7 = 8 ≤ 8 and wrongly keep it — this guards the
// per-row wrapped `contentRows` sum against a regression to the flat count.
const cell = 'c'.repeat(15);
const lines = [
'intro',
'| A | B |',
'| --- | --- |',
`| ${cell} | ${cell} |`,
];
const { keptLines, clipped } = fitPendingSlice(lines, 40, 8, CLAMP);
expect(clipped).toBe(true);
expect(keptLines).toBe(1);
});
it('still uses the shorter horizontal height when wide cells stay within MAX_ROW_LINES', () => {
// Same wide terminal and shape, but short cells (1 line each) → maxRowLines 1,
// no vertical fallback → horizontal 4*1 + header + chrome = 13. intro(1)+13 =
// 14 ≤ budget 30, so the small table is NOT clipped early (guards against
// over-charging every multi-column table as vertical).
const lines = [
'intro',
'| A | B | C | D | E | F | G |',
'| - | - | - | - | - | - | - |',
...Array.from({ length: 4 }, () => '| 1 | 2 | 3 | 4 | 5 | 6 | 7 |'),
];
const { clipped } = fitPendingSlice(lines, 80, 30, CLAMP);
expect(clipped).toBe(false);
});
it('anchors the vertical trigger to the first row (a tall LATER row stays horizontal)', () => {
// Mirrors TableRenderer: the format is decided from the header + FIRST data
// row only, so a short first row keeps the table horizontal even when a
// later row wraps past MAX_ROW_LINES. Horizontal height sums every row:
// header(1)+row1(1)+row2(ceil(180/34)=6) + 1 sep + chrome 5 = 14. intro(1)+14
// = 15 > budget 12 → clipped. If the trigger looked at all rows it would
// charge the shorter vertical height (~9) and wrongly keep it.
const tall = 'z'.repeat(180);
const lines = [
'intro',
'| A | B |',
'| - | - |',
'| x | y |',
`| ${tall} | y |`,
];
const { keptLines, clipped } = fitPendingSlice(lines, 80, 12, CLAMP);
expect(clipped).toBe(true);
expect(keptLines).toBe(1);
});
it('accounts for wrapping of non-table lines', () => {
// Each line is 30 cols at width 10 → 3 rows. Budget 6 → 2 lines fit.
const lines = Array.from({ length: 5 }, () => 'a'.repeat(30));

View file

@ -18,11 +18,18 @@ import { getCachedStringWidth } from './textUtils.js';
* commit and flicker.
*/
/** TableRenderer draws `2 * dataRows + 5` rows: N-1 inter-row separators, plus
* top / header / header-separator / bottom borders and a `marginY` of 1 (2
* rows). */
/** The fixed chrome a horizontal table adds around its rows: the top,
* header-separator and bottom borders (3) plus a `marginY` of 1 (2 rows) = 5.
* fitPendingSlice adds this to the summed wrapped row heights (`contentRows`)
* and the N-1 inter-row separators it is no longer a flat `2 * dataRows + 5`. */
export const TABLE_CHROME_ROWS = 5;
/** The wrap-height threshold: when any cell wraps past this many lines,
* TableRenderer falls back to the (much taller) vertical layout. This is the
* single source of truth TableRenderer imports it as MAX_ROW_LINES so the
* renderer and this estimator can never disagree on the format decision. */
export const TABLE_MAX_ROW_LINES = 4;
/** A markdown table row: `| ... |`. Group 1 captures the inner cells. */
export const TABLE_ROW_RE = /^\s*\|(.+)\|\s*$/;
/** A markdown table separator: `| --- | :--: |` etc. */
@ -143,8 +150,11 @@ export interface PendingSliceResult {
* How many leading source lines of `allLines` fit within `budget` RENDERED
* terminal rows. Block-aware:
* - a non-table line costs {@link estimateWrappedRows};
* - a *completed* table costs `min(2*dataRows + TABLE_CHROME_ROWS, tableClampRows)`
* capped because a streaming table is height-clamped by TableRenderer;
* - a *completed* table costs the height of whichever layout TableRenderer will
* pick horizontal (wrapped rows + chrome) or, when the terminal is too
* narrow OR a cell wraps past {@link TABLE_MAX_ROW_LINES}, the taller vertical
* `label: value` layout capped at `tableClampRows` (TableRenderer clamps a
* streaming table's height to that);
* - a table that would overflow the remaining budget is cut *before* (kept for
* a later chunk / commit) unless it is the first block, in which case it is
* kept and the clamp bounds it, then the walk stops;
@ -198,26 +208,108 @@ export function fitPendingSlice(
let j = i + 2;
while (j < allLines.length && TABLE_ROW_RE.test(allLines[j]!)) j++;
const dataRows = j - (i + 2);
// TableRenderer renders EITHER the horizontal format (~2 rows per data
// row + chrome) OR, on a narrow terminal, the vertical key-value format
// TableRenderer renders EITHER the horizontal format (each row as tall as
// its tallest wrapped cell, + chrome) OR the vertical key-value format
// (colCount label:value lines per row + a separator between rows +
// marginY), which is much taller for multi-column tables. Charge the
// height it will ACTUALLY render by mirroring TableRenderer's width-based
// vertical decision — charging vertical unconditionally over-estimates
// and clips a small table early on a wide terminal; under-charging (always
// horizontal) lets a vertical render overflow and lock the viewport. (The
// cell-wrap → vertical trigger isn't modelled; the clamp is the backstop.)
const colCount = splitMarkdownTableRow(
// marginY), which is much taller for multi-column tables. It picks vertical
// when the terminal is too narrow OR any cell wraps past MAX_ROW_LINES.
// Charge the height it will ACTUALLY render by mirroring BOTH triggers —
// charging vertical unconditionally over-estimates and clips a small table
// early on a wide terminal, while modelling only the width trigger
// under-charges a wide terminal whose long-text cells wrap tall (the
// renderer goes vertical, the live frame overflows and locks to the top).
// splitMarkdownTableRow already trims each cell, so headerCells and the
// per-row cells below need no further trimming.
const headerCells = splitMarkdownTableRow(
TABLE_ROW_RE.exec(allLines[i]!)![1]!,
).length;
);
const colCount = headerCells.length;
// TableRenderer: borderOverhead = 1 + 3*colCount;
// minHorizontalTableWidth = max(24, colCount*3 + borderOverhead + 4).
const minHorizontalWidth = Math.max(24, 6 * colCount + 5);
const usesVertical = contentWidth < minHorizontalWidth;
// Per-row WRAPPED line counts, mirroring TableRenderer's per-cell wrap.
// TableRenderer shrinks columns PROPORTIONALLY to their content; we
// approximate that with an equal share of the content area. That is exact
// for uniform columns but can under-count a heterogeneous table — a narrow
// column the renderer shrinks below the equal share wraps taller than
// estimated here — in which case the MainContent maxHeight backstop is the
// hard cap that still prevents the overflow/lock. MIN_COLUMN_WIDTH mirrors
// its floor of 3.
const perColWidth = Math.max(
3,
Math.floor((contentWidth - (1 + 3 * colCount) - 4) / colCount),
);
// One pass over the header + data rows collects both layouts' heights:
// - horizontal `contentRows`: each row is as tall as its tallest wrapped
// cell (columns share `perColWidth`);
// - the tallest single cell `maxRowLines` (the vertical trigger);
// - vertical `verticalRows`: each DATA cell becomes its own `label: value`
// line (TableRenderer's renderVerticalFormat prefixes the header label)
// that wraps at ~`contentWidth`. Charging just the value, or one flat
// line per cell, would under-count a long `label: value` that wraps.
let contentRows = 0;
let verticalRows = 0;
// The header row's own wrapped height (reusing headerCells, not re-parsed).
let maxRowLines = 1;
for (const label of headerCells) {
maxRowLines = Math.max(
maxRowLines,
estimateWrappedRows(label, perColWidth),
);
}
contentRows += maxRowLines;
// Data rows: sum every row's wrapped height, but anchor the vertical
// trigger to the header + FIRST data row only (r === i + 2) so appending
// rows never flips the format mid-stream.
for (let r = i + 2; r < j; r++) {
const cells = splitMarkdownTableRow(
TABLE_ROW_RE.exec(allLines[r]!)![1]!,
);
let rowMax = 1;
for (let colIdx = 0; colIdx < cells.length; colIdx++) {
const value = cells[colIdx]!;
rowMax = Math.max(rowMax, estimateWrappedRows(value, perColWidth));
const label = headerCells[colIdx] ?? '';
verticalRows += estimateWrappedRows(
label ? `${label}: ${value}` : value,
contentWidth,
);
}
contentRows += rowMax;
if (r === i + 2) {
maxRowLines = Math.max(maxRowLines, rowMax);
}
}
// `maxRowLines` (header + first row) drives the format choice, mirroring
// TableRenderer. It is close to but NOT a guaranteed upper bound on the
// renderer's real first-row wrap count — three renderer behaviours can make
// a cell wrap taller than modelled here: (a) proportional (not equal-share)
// column widths shrink a narrow column below `perColWidth`; (b) word-aware
// wrapping (wrapAnsi) can break a line one row earlier than character-level
// `estimateWrappedRows`; (c) a post-layout width-safety check can force
// vertical. Each can only make the renderer go vertical/taller while the
// estimate stays horizontal/shorter — an under-charge — for which the
// `MainContent` maxHeight backstop is the hard cap that still prevents the
// overflow/lock. Deliberately NOT over-correcting here (e.g. shrinking every
// width or bumping the trigger), which would over-charge and clip common
// uniform tables early.
// With no data rows yet (a header+separator still streaming), TableRenderer
// keeps the horizontal header box — renderVerticalFormat iterates the data
// rows and would draw nothing — so only model vertical once a row exists.
// Otherwise a narrow terminal would charge the 2-row vertical stub instead
// of the taller horizontal header, under-charging the transient state.
const usesVertical =
dataRows > 0 &&
(contentWidth < minHorizontalWidth ||
maxRowLines > TABLE_MAX_ROW_LINES);
const rows = usesVertical
? dataRows * colCount + Math.max(0, dataRows - 1) + 2
: 2 * dataRows + TABLE_CHROME_ROWS;
const cost = Math.min(rows, tableClampRows);
? verticalRows + Math.max(0, dataRows - 1) + 2
: contentRows + Math.max(0, dataRows - 1) + TABLE_CHROME_ROWS;
// TableRenderer clamps only the inner <Text> to `tableClampRows`, but
// wraps it in <Box marginY={1}>, so a clamped table actually renders
// tableClampRows + 2 rows. Charge that so the two margin lines are never
// dropped when the clamp engages.
const cost = Math.min(rows, tableClampRows + 2);
if (rendered + cost > budget && i > 0) {
kept = i;
break;