diff --git a/.gitignore b/.gitignore
index 0e4afaca611..e7e956c2c78 100644
--- a/.gitignore
+++ b/.gitignore
@@ -127,4 +127,6 @@ tmp/
.venv
.codegraph
.qwen/computer-use/installed.json
+# Auto-generated computer-use marker can also appear under nested packages.
+**/.qwen/computer-use/
.playwright-mcp/
diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts
index 1041872aaaa..21aff933805 100644
--- a/packages/cli/src/ui/hooks/useGeminiStream.ts
+++ b/packages/cli/src/ui/hooks/useGeminiStream.ts
@@ -371,8 +371,8 @@ const STREAM_PENDING_ITEM_MAX_CHARS = 16_384;
// Rows kept in reserve below the commit budget so the incremental commit fires
// BEFORE MarkdownDisplay's safety-net clip (which reserves 2). Keeping the
// pending item's rendered height under the safety budget stops that clip from
-// engaging and flickering "generating more" / hiding a table in step with the
-// commit cycle.
+// engaging and hiding a table (or slicing the tail) in step with the commit
+// cycle.
const STREAM_PENDING_COMMIT_RESERVE_ROWS = 5;
// Conservative estimate of the rows the composer/footer occupy, used to derive
// a content-area height from terminalHeight before the live value is known.
diff --git a/packages/cli/src/ui/utils/MarkdownDisplay.test.tsx b/packages/cli/src/ui/utils/MarkdownDisplay.test.tsx
index c3c7f7604dc..efcb10b22b0 100644
--- a/packages/cli/src/ui/utils/MarkdownDisplay.test.tsx
+++ b/packages/cli/src/ui/utils/MarkdownDisplay.test.tsx
@@ -101,11 +101,11 @@ describe('', () => {
/>,
);
const output = lastFrame() ?? '';
- // Contiguous head + a "generating more" cue — NOT decimated (ink
- // overflow="hidden" would drop interspersed rows) and NOT blank.
+ // Clipped to budget: head lines present, tail dropped. No "generating more"
+ // cue — incremental scrollback commit (PR #6170) streams content in
+ // real-time, so clipped content is "still streaming" not "delayed output".
expect(output).toContain('line 1');
expect(output).toContain('line 2');
- expect(output).toContain('generating more');
// The tail is dropped (budget exceeded), so a late line is absent.
expect(output).not.toContain('line 60');
const lineCount = output.split('\n').length;
@@ -171,18 +171,14 @@ describe('', () => {
);
const output = lastFrame() ?? '';
expect(output.split('\n').length).toBeLessThanOrEqual(10);
- // The head-slice bounds code content to <= availableTerminalHeight - 2
- // (= RenderCodeBlock's own inner budget), so the inner "generating more"
- // never fires inside a slice — there is at most ONE cue, not two stacked.
- expect(
- (output.match(/generating more/g) ?? []).length,
- ).toBeLessThanOrEqual(1);
+ // No "generating more" cue — all cues removed (PR #6170 incremental commit
+ // handles real-time streaming).
+ expect(output.match(/generating more/g) ?? []).toHaveLength(0);
});
it('does not stack a double cue for a math block near the clip boundary', () => {
- // RenderMathBlock reserves more rows (RESERVED_LINES=3) than a code block;
- // the head-slice budget reserves 2 rows so a retained math block never
- // hits its own inner truncation cue on top of the outer one.
+ // No "generating more" cue — all cues removed (PR #6170 incremental commit
+ // handles real-time streaming).
const text = [
'$$',
...Array.from({ length: 40 }, (_, i) => `x_{${i}} +`),
@@ -197,9 +193,7 @@ describe('', () => {
/>,
);
const output = lastFrame() ?? '';
- expect(
- (output.match(/generating more/g) ?? []).length,
- ).toBeLessThanOrEqual(1);
+ expect(output.match(/generating more/g) ?? []).toHaveLength(0);
expect(output.split('\n').length).toBeLessThanOrEqual(8);
});
@@ -224,7 +218,7 @@ describe('', () => {
it('applies the minimum floor at a degenerate budget of 1', () => {
// Math.max(MIN_PENDING_CONTENT_LINES, 1 - 2) = 1 (floored): keep one
- // content line + the cue, never 0 or negative.
+ // content line, never 0 or negative. No "generating more" cue.
const text = Array.from({ length: 60 }, (_, i) => `line ${i + 1}`).join(
eol,
);
@@ -239,7 +233,7 @@ describe('', () => {
const output = lastFrame() ?? '';
expect(output).toContain('line 1');
expect(output).not.toContain('line 2');
- expect(output).toContain('generating more');
+ expect(output).not.toContain('generating more');
});
it('renders unordered lists with different markers', () => {
@@ -316,6 +310,87 @@ Some text before.
expect(lastFrame()).toMatchSnapshot();
});
+ it('holds back an unterminated trailing table row while streaming', () => {
+ const text = `| A | B |
+|---|---|
+| one | two |
+| three | fo`.replace(/\n/g, eol);
+ const { lastFrame } = renderWithProviders(
+ ,
+ );
+ const output = lastFrame() ?? '';
+ // The completed row renders inside the table…
+ expect(output).toContain('one');
+ expect(output).toContain('two');
+ // …but the still-typing frontier row is held back until its closing `|`,
+ // so it never flips between a stray text line and a table row (the source
+ // of per-token footer jitter).
+ expect(output).not.toContain('three');
+ expect(output).toContain('│');
+ });
+
+ it('renders the previously-held frontier row once it terminates', () => {
+ const text = `| A | B |
+|---|---|
+| one | two |
+| three | four |`.replace(/\n/g, eol);
+ const { lastFrame } = renderWithProviders(
+ ,
+ );
+ const output = lastFrame() ?? '';
+ expect(output).toContain('three');
+ expect(output).toContain('four');
+ });
+
+ it('holds back a partial first row, then pops the table in once it terminates', () => {
+ // Header + separator present but the first data row is still being typed.
+ // The partial row is held back and the table is not drawn yet, so there is
+ // no header+separator with a stray partial line flashing beneath it.
+ const partial = `| A | B |
+|---|---|
+| one | tw`.replace(/\n/g, eol);
+ const { lastFrame: partialFrame } = renderWithProviders(
+ ,
+ );
+ expect(partialFrame() ?? '').not.toContain('one');
+
+ // Once the row terminates, the table appears complete.
+ const complete = `| A | B |
+|---|---|
+| one | two |`.replace(/\n/g, eol);
+ const { lastFrame: completeFrame } = renderWithProviders(
+ ,
+ );
+ const out = completeFrame() ?? '';
+ expect(out).toContain('one');
+ expect(out).toContain('two');
+ });
+
+ it('does not hold back a partial row in committed (non-pending) output', () => {
+ const text = `| A | B |
+|---|---|
+| one | two |
+| three | fo`.replace(/\n/g, eol);
+ const { lastFrame } = renderWithProviders(
+ ,
+ );
+ // Committed transcript is final, not a streaming frontier — render as-is.
+ expect(lastFrame() ?? '').toContain('three');
+ });
+
+ it('still closes a streaming table when a non-pipe line follows', () => {
+ const text = `| A | B |
+|---|---|
+| one | two |
+Done.`.replace(/\n/g, eol);
+ const { lastFrame } = renderWithProviders(
+ ,
+ );
+ const output = lastFrame() ?? '';
+ expect(output).toContain('one');
+ expect(output).toContain('Done');
+ });
+
it('renders a single-column table', () => {
const text = `
| Name |
diff --git a/packages/cli/src/ui/utils/MarkdownDisplay.tsx b/packages/cli/src/ui/utils/MarkdownDisplay.tsx
index 997da2b0372..4a4585576a5 100644
--- a/packages/cli/src/ui/utils/MarkdownDisplay.tsx
+++ b/packages/cli/src/ui/utils/MarkdownDisplay.tsx
@@ -21,10 +21,18 @@ import {
TABLE_SEPARATOR_RE,
CODE_FENCE_RE,
} from './pending-rendered-height.js';
-// Minimum content lines to keep in a clipped live preview before the
-// "generating more" cue (own constant — not coupled to MaxSizedBox's floor).
+// Minimum content lines to keep in a clipped live preview (own constant — not
+// coupled to MaxSizedBox's floor).
const MIN_PENDING_CONTENT_LINES = 1;
+// Rows reserved from the viewport when clamping a streaming table's height:
+// marginY 2 + one row of wrapped-cell safety headroom. Tables under-estimate
+// their rendered height the most (a wrapped cell), so they keep one more
+// reserved row than the other blocks. Shared by the render-side clamp
+// (RenderTable's `maxHeight`) and the slice-side estimate (`tableClampRows`)
+// so the two never diverge and let a table overflow the render cap.
+const TABLE_PENDING_RESERVED_ROWS = 3;
+
interface MarkdownDisplayProps {
text: string;
isPending: boolean;
@@ -131,12 +139,13 @@ const MarkdownDisplayInternal: React.FC = ({
// top lock"). The rendered-height-aware slice below keeps a CONTIGUOUS head of
// source lines whose RENDERED height fits the budget; a contiguous slice
// (rather than ink `overflow="hidden"`) avoids decimating interspersed rows
- // and preserves a code block's own truncation cue. The full message still
+ // and preserves a code block's own truncation. The full message still
// renders once it commits to ``. Only while pending and when a budget
// is known (constrainHeight on — both non-VP and VP pending items pass one).
//
- // Reserve 2 rows: 1 for the "generating more" cue, 1 so a retained code/math
- // block's OWN inner truncation cue can't stack on top of the outer one.
+ // Reserve 2 rows of headroom below the viewport so the live frame stays bound
+ // even when a retained code/math block's own rendered height slightly exceeds
+ // the source-line estimate.
//
// This is a SAFETY NET on top of incremental scrollback commit: it guarantees
// the live frame never exceeds the viewport regardless of how the streaming
@@ -164,23 +173,20 @@ const MarkdownDisplayInternal: React.FC = ({
// exceeds the viewport, so ink cannot fall into its from-top full-redraw path
// (the scroll-to-top lock). Note keptLines can be 0 when even the first
// line/table alone overflows (e.g. a single very wide/CJK line that wraps past
- // the budget): render nothing plus the "generating more" cue rather than one
- // oversized row that would bypass the bound.
+ // the budget): render nothing rather than an oversized row.
let lines = allLines;
- let pendingClipped = false;
if (pendingRenderedBudget !== undefined) {
const tableClampRows =
availableTerminalHeight !== undefined
- ? Math.max(2, availableTerminalHeight - 3)
+ ? Math.max(2, availableTerminalHeight - TABLE_PENDING_RESERVED_ROWS)
: Number.MAX_SAFE_INTEGER;
- const { keptLines, clipped } = fitPendingSlice(
+ const { keptLines } = fitPendingSlice(
allLines,
contentWidth,
pendingRenderedBudget,
tableClampRows,
);
- if (clipped) {
- pendingClipped = true;
+ if (keptLines < allLines.length) {
lines = allLines.slice(0, keptLines);
}
}
@@ -351,6 +357,24 @@ const MarkdownDisplayInternal: React.FC = ({
cells.length = tableHeaders.length;
}
tableRows.push(cells);
+ } else if (
+ isPending &&
+ inTable &&
+ !tableRowMatch &&
+ index === lines.length - 1 &&
+ tableHeaders.length > 0 &&
+ /^\s*\|/.test(line)
+ ) {
+ // Live streaming frontier: the final line is an unterminated table row or
+ // separator (`| a | b` with no closing `|` yet). Rendering it as a plain
+ // text line and then flipping it into the table once the closing `|`
+ // arrives makes the frame height and column widths oscillate on every
+ // token, which visibly jitters the footer/composer. Hold it back instead:
+ // skip it so `inTable` stays set and the end-of-content handler renders
+ // only the COMPLETE rows as a live table. A partial row never flips in;
+ // the table appears once its first row terminates and grows one complete
+ // row at a time. Until then the table is simply not drawn (no header +
+ // separator with a stray partial line beneath it).
} else if (inTable && !tableRowMatch) {
// End of table — a following line closes it, so this table is COMPLETE
// and renders in full (the rendered-aware slice guarantees a completed
@@ -568,20 +592,12 @@ const MarkdownDisplayInternal: React.FC = ({
);
}
- // When the live message was clipped to a head slice (see pendingLineBudget
- // above), add a cue that more is streaming. Code blocks retained in the head
- // still render their own "... generating more ..." truncation.
- if (pendingClipped) {
- return (
-
- {contentBlocks}
-
- ... generating more ...
-
-
- );
- }
-
+ // Safety-net clip: when the pending preview exceeds the rendered-height
+ // budget, slice it to keep the live frame within the viewport. The clipping
+ // still happens (prevents scroll-to-top lock), but we no longer show the
+ // "... generating more ..." cue — incremental scrollback commit (PR #6170)
+ // already streams content to in real-time, so clipped content is
+ // not "delayed output" but rather "still streaming".
return <>{contentBlocks}>;
};
@@ -608,8 +624,13 @@ const RenderCodeBlockInternal: React.FC = ({
}) => {
const settings = useSettings();
const { renderMode } = useRenderMode();
- const MIN_LINES_FOR_MESSAGE = 1; // Minimum lines to show before the "generating more" message
- const RESERVED_LINES = 2; // Lines reserved for the message itself and potential padding
+ // Below this many usable rows there is no room for a meaningful code
+ // preview, so we fall back to the "... code is being written ..." notice.
+ const MIN_PREVIEW_LINES = 1;
+ // One row of headroom below availableTerminalHeight. This used to also hold a
+ // "... generating more ..." cue (removed with PR #6170's incremental commit),
+ // so the reclaimed row now shows an extra code line at the same total height.
+ const RESERVED_LINES = 1;
if (lang?.toLowerCase() === 'mermaid' && renderMode === 'render') {
if (isPending) {
@@ -642,8 +663,8 @@ const RenderCodeBlockInternal: React.FC = ({
);
if (content.length > MAX_CODE_LINES_WHEN_PENDING) {
- if (MAX_CODE_LINES_WHEN_PENDING < MIN_LINES_FOR_MESSAGE) {
- // Not enough space to even show the message meaningfully
+ if (MAX_CODE_LINES_WHEN_PENDING < MIN_PREVIEW_LINES) {
+ // Not enough space to even show a truncated preview meaningfully
return (
@@ -664,7 +685,6 @@ const RenderCodeBlockInternal: React.FC = ({
return (
{colorizedTruncatedCode}
- ... generating more ...
);
}
@@ -702,10 +722,13 @@ interface RenderPendingMermaidBlockProps {
const RenderPendingMermaidBlockInternal: React.FC<
RenderPendingMermaidBlockProps
> = ({ content, availableTerminalHeight, contentWidth }) => {
+ // Reserve one row for the "Mermaid diagram is being written..." header. The
+ // second reserved row used to hold a "... generating more ..." cue (removed
+ // with PR #6170); reclaiming it shows an extra preview line at the same height.
const maxPreviewLines =
availableTerminalHeight === undefined
? 6
- : Math.max(0, availableTerminalHeight - 2);
+ : Math.max(0, availableTerminalHeight - 1);
const previewLines = content.slice(0, maxPreviewLines);
return (
))}
- {content.length > previewLines.length && (
- ... generating more ...
- )}
);
};
@@ -744,7 +764,10 @@ const RenderMathBlockInternal: React.FC = ({
isPending,
availableTerminalHeight,
}) => {
- const RESERVED_LINES = 3;
+ // One row for the "LaTeX block · source:" header plus one row of headroom.
+ // The third row used to hold a "... generating more ..." cue (removed with PR
+ // #6170); reclaiming it shows an extra preview line at the same total height.
+ const RESERVED_LINES = 2;
if (isPending && availableTerminalHeight !== undefined) {
const maxPreviewLines = Math.max(
0,
@@ -767,7 +790,6 @@ const RenderMathBlockInternal: React.FC = ({
{line || ' '}
))}
- ... generating more ...
);
}
@@ -882,12 +904,6 @@ interface RenderTableProps {
availableTerminalHeight?: number;
}
-// Backstop only: the pending slice bounds a completed table's height assuming
-// single-line cells, but a cell that WRAPS renders taller and could still
-// overflow. While streaming, cap the table to the viewport (reserve 3 rows:
-// marginY 2 + the outer cue) so it can never trigger the scroll-to-top lock.
-const TABLE_PENDING_RESERVED_ROWS = 3;
-
const RenderTableInternal: React.FC = ({
headers,
rows,
diff --git a/packages/cli/src/ui/utils/pending-rendered-height.ts b/packages/cli/src/ui/utils/pending-rendered-height.ts
index 2d7a9d81eb1..cadaca4b15f 100644
--- a/packages/cli/src/ui/utils/pending-rendered-height.ts
+++ b/packages/cli/src/ui/utils/pending-rendered-height.ts
@@ -132,7 +132,7 @@ export interface PendingSliceResult {
/**
* Number of leading source lines whose combined RENDERED height fits within
* `budget`. May be 0 when even the first line/table alone overflows (the
- * caller then renders nothing plus a "more" cue rather than an oversized row).
+ * caller then renders nothing rather than an oversized row).
*/
keptLines: number;
/** True when some trailing source lines were dropped to fit the budget. */