fix(ui): add SlicingMaxSizedBox to prevent terminal flickering on large tool outputs#3013
fix(ui): add SlicingMaxSizedBox to prevent terminal flickering on large tool outputs#3013chiga0 wants to merge 12 commits into
Conversation
📋 Review SummaryThis PR addresses terminal flickering issues when displaying large tool outputs in verbose mode by introducing a new 🔍 General Feedback
🎯 Specific Feedback🟡 High
🟢 Medium
🔵 Low
✅ Highlights
|
Phase 2: Height Stabilization (commit 845f197)Phase 1 (SlicingMaxSizedBox) solved flickering caused by large data volume — Ink no longer layouts 500 lines. However, a second flickering source remained: New Changes1. Caches
2. MIN_TOOL_OUTPUT_HEIGHT ( Added floor of 8 lines per tool (guarded for tiny terminals) to prevent dramatic height jumps when tool count changes (e.g., 2→3 tools would shrink each from 15→9 lines). 3. Shell PTY uses raw height ( Both Design DocFull analysis at
Summary of Both Phases
|
Phase 3: Hard Cap for All Tool Outputs (commit e1000a6)Completed tool outputs in the static (scrollback) area had no practical height limit — Changes
const availableHeight = availableTerminalHeight
? Math.min(MAX_TOOL_OUTPUT_LINES, Math.max(computed, MIN_LINES_SHOWN + 1))
: MAX_TOOL_OUTPUT_LINES; // Also caps when undefined (was unlimited before)Dead code cleanup — Since
All Three Phases Summary
Together, these three phases provide a comprehensive anti-flicker solution:
|
tanzhenxin
left a comment
There was a problem hiding this comment.
Review — fix(ui): add SlicingMaxSizedBox to prevent terminal flickering on large tool outputs
Files changed: 7 (+825 / -57)
The core slicing concept is sound — pre-render truncation is the right approach for this performance problem, and useStableHeight is a clever idea for streaming stabilization. A few issues to address:
1. Markdown rendering silently removed for all tool outputs
The PR removes MarkdownDisplay from StringResultRenderer entirely, with a comment saying it "does not respect availableTerminalHeight properly." This is a significant UX regression for tools like web_fetch that return formatted markdown — users will see raw syntax (#, **, [links](...)) instead of formatted output. The renderOutputAsMarkdown field is still populated elsewhere but is now dead code.
Suggestion: If MarkdownDisplay doesn't respect height limits, wrap it in SlicingMaxSizedBox. Don't remove markdown rendering wholesale. If this must ship without markdown support, gate it behind a config option and document prominently.
2. Double-counting of hidden lines when text wraps
SlicingMaxSizedBox reserves 1 line from maxLines for the hidden indicator and passes additionalHiddenLinesCount to MaxSizedBox. But when Text wrap="wrap" causes logical lines to soft-wrap into multiple visual rows, MaxSizedBox will independently detect overflow and add its own hiddenLinesCount. The total displayed count mixes data-level and visual-level hidden lines, producing an inaccurate number.
Suggestion: Either make MaxSizedBox purely a width limiter when pre-slicing is active (set maxHeight to undefined), or let MaxSizedBox be the sole source of the hidden indicator.
3. useStableHeight mutates refs during render
The hook reads Date.now() and mutates refs in the render body. The comment says this is safe under Ink's synchronous model, but it's a React anti-pattern — StrictMode double-invokes render, and each call sees a different timestamp. Should use useMemo or useEffect + state instead.
4. Copyright header says "Google LLC"
Both new files have @license Copyright 2025 Google LLC, likely copied from Gemini CLI reference. Should match this project's conventions.
|
Hey @chiga0 — since this PR significantly changes how tool outputs render (removing markdown formatting, adding truncation with hidden line counts), could you attach before/after screenshots or a short video showing the new behavior? Per the PR template:
Specifically it'd be great to see:
This will help reviewers assess the UX tradeoffs. Thanks! |
wenshao
left a comment
There was a problem hiding this comment.
[Critical] ToolMessage.test.tsx:147 — Test failure. The test asserts expect(output).toContain('MockMarkdown:Test result') but StringResultRenderer no longer uses MarkdownDisplay, so output renders as plain text (Test result). Fix: change assertion to expect(output).toContain('Test result').
— qwen3.6-plus via Qwen Code /review
| </Text> | ||
| </Box> | ||
| )} | ||
| </SlicingMaxSizedBox> |
There was a problem hiding this comment.
[Suggestion] StringResultRenderer unconditionally removes the markdown rendering path. Several tools set isOutputMarkdown: true (default in DeclarativeTool, explicitly in agent.ts, mcp-tool.ts, web_fetch). Their markdown-formatted output will now render as raw markdown syntax (**bold**, # headers) instead of formatted text.
The comment says this is intentional because "MarkdownDisplay does not respect availableTerminalHeight properly." If acceptable, consider making this explicit in the PR description as a behavioral change. Alternatively, wrap MarkdownDisplay with SlicingMaxSizedBox to retain markdown rendering with height limits.
— qwen3.6-plus via Qwen Code /review
There was a problem hiding this comment.
Will restore the markdown rendering path. SlicingMaxSizedBox is designed for plain text (render callback receives string), so for markdown outputs:
- Apply 20KB character truncation before passing to
MarkdownDisplay - Use
MAX_TOOL_OUTPUT_LINESto setavailableTerminalHeightonMarkdownDisplay MarkdownDisplay+MaxSizedBoxhandles the height limiting for formatted output
This keeps the anti-flicker benefit for plain text while preserving formatted rendering for markdown tools.
| text = lines.slice(0, targetLines).join('\n'); | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
[Suggestion] hiddenLineCount is computed from pre-sliced logical lines vs targetLines, then passed to MaxSizedBox as additionalHiddenLinesCount. MaxSizedBox also independently computes hiddenLinesCount from its layout (laidOutStyledText.length - visibleContentHeight). When long lines wrap within maxWidth, a single logical line becomes multiple rendered lines, causing over-counting in the hidden line indicator.
The "... first N lines hidden ..." indicator may show an incorrect count when tool output contains long wrapping lines.
— qwen3.6-plus via Qwen Code /review
There was a problem hiding this comment.
Confirmed — the count mixes logical (pre-sliced) and visual (wrap-overflow) lines. When pre-sliced text has long wrapping lines, `MaxSizedBox` adds its own `hiddenLinesCount` on top of `additionalHiddenLinesCount`, inflating the indicator.
Fix: increase the `maxHeight` passed to inner `MaxSizedBox` when pre-slicing is active (e.g., `maxHeight = maxLines * 2`) so wrapping doesn't trigger independent visual truncation. The pre-sliced count alone drives the indicator, and `MaxSizedBox` acts as a width limiter + safety net.
Addressing Review Feedback1. Markdown rendering removal (@tanzhenxin, @wenshao)Acknowledged — removing Plan: Restore the markdown rendering path:
2. Double-counting hidden lines (@tanzhenxin, @wenshao)When pre-sliced text contains lines that soft-wrap within Fix: When 3.
|
|
@tanzhenxin Recordings are in progress. Will update this comment with:
|
yiliang114
left a comment
There was a problem hiding this comment.
Thanks for addressing the earlier feedback. The overall direction looks much better now, but I still see two blocking issues in the current implementation: markdown output is still effectively disabled in the normal constrained-height path, and the hidden-line fix removes the visual height cap for wrapped content. I left the details inline.
| // for anti-flicker. Markdown rendering is used only when no terminal height | ||
| // constraint is provided (e.g., static area items without height info). | ||
| const effectiveRenderAsMarkdown = | ||
| renderOutputAsMarkdown && availableTerminalHeight === undefined; |
There was a problem hiding this comment.
One thing I noticed here: this still effectively disables markdown rendering for the default UI path. The effectiveRenderAsMarkdown flag only becomes true when availableTerminalHeight is undefined, but both the static history path and the constrained live tool path pass a height into ToolMessage. In practice, markdown-producing tools like web_fetch will still render raw markdown in normal constrained mode. Could we preserve markdown rendering when height is known and solve the flicker separately for that path?
| // additionalHiddenLinesCount, mixing logical and visual line counts. | ||
| // With maxHeight=undefined, MaxSizedBox handles width only and renders the | ||
| // indicator from additionalHiddenLinesCount alone. | ||
| const effectiveMaxHeight = hiddenLineCount > 0 ? undefined : maxHeight; |
There was a problem hiding this comment.
This avoids double-counting hidden lines, but it also removes the visual height cap entirely once pre-slicing is active. If the retained logical lines contain long wrapped content, MaxSizedBox will render all wrapped rows, so the 15-line cap no longer holds and layout cost can grow again. Could we keep a bounded fallback here instead of fully disabling height limiting?
…ge tool outputs When verboseMode is enabled, large tool outputs (npm install ~500 lines, git log ~200 lines) cause terminal flickering because MaxSizedBox lets Ink layout ALL content before visually cropping. SlicingMaxSizedBox uses useMemo() to slice data to maxLines BEFORE React rendering, reducing layout cost from O(output lines) to O(15) constant time. - New SlicingMaxSizedBox component with two-layer pre-render truncation (20KB char limit + line slicing) - ToolMessage StringResultRenderer now uses SlicingMaxSizedBox for plain text path - Markdown path also protected with 20KB character truncation guard Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…t flickering During tool execution, availableTerminalHeight fluctuates due to controlsHeight remeasurement, tool count changes, and tabBar toggles. These fluctuations cause the displayed line count to jump, creating flicker even with pre-render slicing. Add useStableHeight hook that caches height during streaming: - Height increases: always accepted (more space, no content jump) - Small decreases (<5 lines): absorbed during streaming, MaxSizedBox clips overflow - Large decreases (≥5 lines) or stale cache (>2s): accepted as real layout changes - Idle state: sync immediately for accuracy Also add MIN_TOOL_OUTPUT_HEIGHT=8 in ToolGroupMessage to prevent dramatic height jumps when tool count changes. Shell PTY uses raw (unstabilized) height to ensure the underlying process always sees real terminal dimensions. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…utputs Completed tool outputs in the static (scrollback) area had no practical height limit because staticAreaMaxItemHeight = terminalHeight * 4 (~96-160 lines). This meant git diff --stat with 40+ lines displayed entirely without truncation. Add MAX_TOOL_OUTPUT_LINES = 15 hard cap (matching Gemini CLI's ACTIVE_SHELL_MAX_LINES / COMPLETED_SHELL_MAX_LINES) that applies to all tool outputs regardless of whether they are pending or in scrollback history. Also clean up dead code: since availableHeight is now always defined, the markdown rendering path for tool outputs is unreachable. Remove MarkdownDisplay import, markdownData truncation, renderAsMarkdown prop from StringResultRenderer, and the renderOutputAsMarkdown override guard. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…ne counting - Restore MarkdownDisplay rendering path in StringResultRenderer for tools with renderOutputAsMarkdown=true (e.g., web_fetch, MCP tools). Markdown is used when availableTerminalHeight is undefined; plain text with SlicingMaxSizedBox is used when terminal height is known (anti-flicker). - Fix hidden line double-counting in SlicingMaxSizedBox: when pre-slicing is active, pass maxHeight=undefined to inner MaxSizedBox so it doesn't independently truncate wrapped lines and inflate the indicator count. - Update copyright headers from "Google LLC" to "Qwen" for new files (SlicingMaxSizedBox.tsx, useStableHeight.ts). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
AgentExecutionDisplay rendered content without height constraints, causing Ink to layout 60-90+ lines when multiple sub-agents ran in parallel — far exceeding the visible terminal area and producing severe flickering. Changes: - Add computeContentBudget() to dynamically limit task prompt lines and tool calls based on availableHeight, preventing Ink from laying out content that overflows the terminal - Add effectiveDisplayMode that auto-downgrades to compact when height is critically low (<5 lines), preventing expansion into flickering states - Fix Ctrl+E/F keyboard guard: restrict to running sub-agents only, so completed agents don't re-render on toggle (reduces churn in long sessions) and confirmation prompts don't conflict - Update TaskPromptSection, ToolCallsList, and ResultsSection to accept dynamic max limits instead of hardcoded constants Fixes QwenLM#2928, helps QwenLM#2950, QwenLM#2972, QwenLM#2912
…ht constraint - Unit tests for computeContentBudget: NaN, negative, zero, small, medium, large terminal heights; verifies min/max bounds always hold - Render tests: compact mode default, auto-downgrade at small heights, completed agent display
…act mode Two fixes: - Remove auto-downgrade to compact for default mode. The content budget already limits rendered content, and forcing compact overrode explicit Ctrl+E expansion (height per sub-agent is often <5 with parallel agents). Only verbose→default downgrade is kept for very small heights. - Force-expand tool group from compact mode when a subagent has a pending confirmation (pendingConfirmation). Previously only direct tool-level ToolCallStatus.Confirming triggered expansion, but subagent approvals use a different mechanism (AgentResultDisplay.pendingConfirmation) that was not checked, hiding the approval prompt in compact mode.
a249947 to
f9923d5
Compare
…ize output
Each sub-agent tool call update triggers a re-render of the entire
ToolGroupMessage. Without a fixed height container, the rendered height
fluctuates between re-renders, causing Ink's bordered-box rendering to
produce flickering and tearing (known Ink issue noted in ToolGroupMessage
comments). Wrapping in Box with height={availableHeight} and
overflow="hidden" keeps the terminal output height stable.
| taskPrompt: | ||
| 'Do something\nLine 2\nLine 3\nLine 4\nLine 5\nLine 6\nLine 7\nLine 8\nLine 9\nLine 10', | ||
| status: 'running', | ||
| toolCalls: Array.from({ length: 8 }, (_, i) => ({ |
There was a problem hiding this comment.
[Critical] toolCalls mock data is missing the required callId: string property. Each entry only has { name, status, description }, causing TS2322 error and breaking npm run build.
| toolCalls: Array.from({ length: 8 }, (_, i) => ({ | |
| toolCalls: Array.from({ length: 8 }, (_, i) => ({ | |
| callId: `call-${i + 1}`, | |
| name: `tool-${i + 1}`, | |
| status: 'success' as const, | |
| description: `Description ${i + 1}`, | |
| })), |
— qwen3.6-plus via Qwen Code /review
| />, | ||
| ); | ||
| const output = lastFrame(); | ||
| const lineCount = output.split('\n').length; |
There was a problem hiding this comment.
[Critical] lastFrame() returns string | undefined, but .split('\n') is called without a null check (TS18048). Same issue at line 160.
| const lineCount = output.split('\n').length; | |
| expect(output).toBeDefined(); | |
| const lineCount = output!.split('\n').length; |
— qwen3.6-plus via Qwen Code /review
| it('shows completed agent summary in compact mode', () => { | ||
| const data = makeAgentData({ | ||
| status: 'completed', | ||
| executionSummary: { |
There was a problem hiding this comment.
[Critical] executionSummary is missing 5 required AgentStatsSummary properties: inputTokens, outputTokens, thoughtTokens, cachedTokens, toolUsage (TS2739).
| executionSummary: { | |
| executionSummary: { | |
| totalToolCalls: 5, | |
| successfulToolCalls: 5, | |
| failedToolCalls: 0, | |
| successRate: 100, | |
| totalTokens: 1000, | |
| totalDurationMs: 5000, | |
| rounds: 3, | |
| inputTokens: 0, | |
| outputTokens: 0, | |
| thoughtTokens: 0, | |
| cachedTokens: 0, | |
| toolUsage: [], | |
| }, |
— qwen3.6-plus via Qwen Code /review
| // also truncates, its hiddenLinesCount would be added to our | ||
| // additionalHiddenLinesCount, mixing logical and visual line counts. | ||
| // With maxHeight=undefined, MaxSizedBox handles width only and renders the | ||
| // indicator from additionalHiddenLinesCount alone. |
There was a problem hiding this comment.
[Suggestion] When hiddenLineCount > 0, effectiveMaxHeight is set to undefined, disabling MaxSizedBox's visual cropping entirely. In narrow terminals where pre-sliced lines soft-wrap into multiple visual rows, content can overflow the allocated space with no safety net.
Consider maxHeight = maxLines * 2 as a bounded fallback — MaxSizedBox still catches soft-wrap overflow while the pre-sliced count alone drives the indicator.
— qwen3.6-plus via Qwen Code /review
| const targetLines = Math.max(1, maxLines - 1); | ||
| hidden = lines.length - targetLines; | ||
| if (overflowDirection === 'top') { | ||
| text = lines.slice(-targetLines).join('\n'); |
There was a problem hiding this comment.
[Suggestion] When overflowDirection === 'bottom', line slicing keeps the first N lines, but character truncation (line 68) always keeps the last 20KB. If data exceeds 20KB, the user sees the first N lines of the last 20KB — not the first N lines of the original content.
For overflowDirection === 'bottom', use text.slice(0, MAXIMUM_RESULT_DISPLAY_CHARACTERS) instead.
— qwen3.6-plus via Qwen Code /review
| if (text.length > MAXIMUM_RESULT_DISPLAY_CHARACTERS) { | ||
| text = '...' + text.slice(-MAXIMUM_RESULT_DISPLAY_CHARACTERS); | ||
| } | ||
|
|
There was a problem hiding this comment.
[Suggestion] When character truncation splits mid-line (the 20KB boundary falls within a line), the first "line" of the truncated text is a partial line. hiddenLineCount counts full lines only, so the indicator underreports by 1.
Consider incrementing hidden by 1 when character truncation is active.
— qwen3.6-plus via Qwen Code /review
| @@ -145,11 +151,17 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({ | |||
| const countOneLineToolCalls = toolCalls.length - countToolCallsWithResults; | |||
| const availableTerminalHeightPerToolMessage = availableTerminalHeight | |||
There was a problem hiding this comment.
[Suggestion] The height allocation formula can over-allocate per tool when there are many one-line tools. Example: availableTerminalHeight=30, countToolCallsWithResults=1, countOneLineToolCalls=25 → returns 8, but real available space after overhead is only 30-3-25=2 lines.
Apply MIN_TOOL_OUTPUT_HEIGHT as a cap on the final result, not as a competing term in Math.max:
Math.max(1, Math.min(MIN_TOOL_OUTPUT_HEIGHT, floor((total - staticHeight - oneLineTools) / countWithResults)))
— qwen3.6-plus via Qwen Code /review
| const timeSinceUpdate = Date.now() - lastUpdateRef.current; | ||
|
|
||
| if (delta > 0) { | ||
| // More space available — always safe to expand, no content jump |
There was a problem hiding this comment.
[Suggestion] The STALE_TIMEOUT_MS check only fires when the component re-renders for another reason. If nothing else triggers a re-render, the cached height could remain stale indefinitely.
Consider adding a useEffect with setTimeout that forces a re-check when isStreaming is true and the timeout has elapsed.
— qwen3.6-plus via Qwen Code /review
Dismissing — the author has addressed the markdown regression across multiple rounds and is still actively iterating. Re-reviewing is better handled fresh rather than keeping a stale changes-requested state in the way.
…le rendering Root cause: when streaming a long markdown table (e.g. 12 columns × 10 rows in vertical format = 130+ lines), the pending dynamic area exceeds the terminal viewport height. Open-source Ink's log-update clears the entire visible area on every chunk and rewrites it — the terminal briefly renders blank between clear and rewrite, producing high-frequency black-frame flicker. This commit applies a multi-layered fix inspired by Claude Code's architecture: **Layer 1 — Table layout stability (TableRenderer.tsx)** - Hysteresis thresholds (enter vertical on >5, exit on ≤3) with streaming monotonic lock (once vertical, stays vertical until stream ends) to prevent layout flip-flop between horizontal and vertical formats. - Headers-based React key for stable component identity across streaming chunks. **Layer 2 — Streaming row cap (TableRenderer.tsx, P2)** - During isPending, cap rendered table rows to fit within availableTerminalHeight. - Show last N rows + "... N earlier rows hidden during streaming ..." hint. - Format-specific overhead calculation (vertical: marginY; horizontal: borders + header + marginY) ensures the cap never overflows the viewport. - Final committed render (isPending=false) shows all rows. **Layer 3 — Streaming partial-row guard (MarkdownDisplay.tsx)** - Skip the last line during streaming if it starts with `|` but has no closing `|` — prevents per-chunk flicker as each character of a partial row toggles between "table row" and "raw text" rendering. - Detect bullet-wrapped tables (`+ | ID | Name |`) and emit as table blocks. - Emit tables with 0 rows as header-only skeletons during streaming to prevent height collapse. **Layer 4 — DEC 2026 synchronized output (synchronizedOutput.ts)** - Wrap each Ink frame's stdout burst in BSU/ESU sequences so supporting terminals (iTerm2, WezTerm, VSCode, ghostty, Warp, kitty, alacritty, etc.) commit the clear→redraw atomically — no intermediate blank state visible. - Terminal detection via env-var allowlist; no-op on unsupported terminals. - Exit handler ensures ESU is sent on process termination. **Layer 5 — Application-level render throttle (useRenderThrottledStateAndRef.ts)** - Drop-in replacement for useStateAndRef that caps React re-render frequency to ~60fps (16ms). Ref updates synchronously for finalize paths; only the React setState (which drives Ink frame writes) is throttled. - Null/undefined bypass throttle so finalize clears pending instantly. - Explicit flush() at commit boundaries (split, type transition) prevents duplicate-render artifacts where history shows committed content while pending still shows stale pre-split text. **Layer 6 — Width oscillation guard (AppContainer.tsx)** - Debounce terminalWidth changes with MIN_WIDTH_DELTA_FOR_REFRESH = 4 to prevent scrollbar show/hide (97↔95 columns) from triggering refreshStatic. - Diagnostic logging for refreshStatic calls (temporary, for verification). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
| taskPrompt: | ||
| 'Do something\nLine 2\nLine 3\nLine 4\nLine 5\nLine 6\nLine 7\nLine 8\nLine 9\nLine 10', | ||
| status: 'running', | ||
| toolCalls: Array.from({ length: 8 }, (_, i) => ({ |
There was a problem hiding this comment.
[Critical] This new test fixture no longer matches the current AgentResultDisplay / AgentStatsSummary types: toolCalls entries are missing required callId, later assertions call lastFrame().split(...) without guarding against undefined, and the completed executionSummary fixture is missing required fields. As written, this file breaks the CLI package build.
| toolCalls: Array.from({ length: 8 }, (_, i) => ({ | |
| toolCalls: Array.from({ length: 8 }, (_, i) => ({ | |
| callId: `call-${i + 1}`, | |
| name: `tool-${i + 1}`, | |
| status: 'success' as const, | |
| description: `Description ${i + 1}`, | |
| })), |
— gpt-5.4 via Qwen Code /review
|
|
||
| // Capture what the patched write forwards to the underlying stream. | ||
| const writes: string[] = []; | ||
| const fakeWrite = vi.fn<Parameters<typeof process.stdout.write>, boolean>( |
There was a problem hiding this comment.
[Critical] vi.fn<Parameters<typeof process.stdout.write>, boolean>(...) is not compatible with the Vitest typings currently used in this repo. That generic signature makes the new test fail type-checking, which in turn breaks npm run build for packages/cli.
| const fakeWrite = vi.fn<Parameters<typeof process.stdout.write>, boolean>( | |
| const fakeWrite = vi.fn((chunk: unknown) => { | |
| writes.push(typeof chunk === 'string' ? chunk : String(chunk)); | |
| return true; | |
| }); |
— gpt-5.4 via Qwen Code /review
| const [modelSwitchedFromQuotaError, setModelSwitchedFromQuotaError] = | ||
| useState<boolean>(false); | ||
| const [historyRemountKey, setHistoryRemountKey] = useState(0); | ||
| // TEMP: prove our diagnostic code path is actually loaded in this run. |
There was a problem hiding this comment.
[Critical] These TEMP diagnostics are still enabled in the normal interactive path and write directly to stderr on every mount. Combined with the stack-trace dump added in refreshStatic(), this makes the PR change user-visible CLI output outside the flicker fix itself and can interfere with stderr-based tooling/tests. Please remove these unconditional diagnostics before merge, or gate them behind an explicit debug flag that is off by default.
— gpt-5.4 via Qwen Code /review
450b11cfc5c551f5a6155971daa36329.mp404c7e6790ab11f28a554c8af8e93a1c6.mp4 |
thank you for your test report. I'm working on it now. But I find this PR contains many submit and issue fixing. I would cancel this PR and split it into more PR to fix all flicker issue. thank you again. |
|
I will close this PR, the new PR refer #3591 |
Co-authored-by: N. Taylor Mullen <ntaylormullen@google.com>
Background
When
verboseModeis enabled (the default), executing commands that produce large outputs (e.g.,npm install~500 lines,git log~200 lines,cat large-file.json~5000 lines) causes visible terminal screen flickering and stuttering, severely degrading user experience.Root Cause
The current rendering pipeline passes all output data to
MaxSizedBox, which relies on Ink's visual overflow cropping. However, Ink still needs to layout the entire content to determine what overflows — even though only ~15 lines are visible to the user. For a 500-line output, Ink computes layout for all 500 lines, and every new line triggers a full re-layout, causing the flicker.This is a known divergence from upstream Gemini CLI, which added
SlicingMaxSizedBoxin March 2026 (after the Qwen Code fork point of October 2025).Solution
Introduce
SlicingMaxSizedBox— a wrapper aroundMaxSizedBoxthat usesuseMemo()to truncate data BEFORE React rendering:.slice(-maxLines)retains only the last N lines before the React render tree. Ink only receives ~15 lines → layout is instant → no flicker.MaxSizedBoxstill provides overflow hidden as a safety net.The
additionalHiddenLinesCountprop is passed toMaxSizedBoxso the "... first N lines hidden ..." indicator correctly reflects the total hidden lines from both pre-render slicing and visual cropping.Changes
packages/cli/src/ui/components/shared/SlicingMaxSizedBox.tsxpackages/cli/src/ui/components/messages/ToolMessage.tsxMaxSizedBoxwithSlicingMaxSizedBoxinStringResultRenderer; add 20KB truncation guard for markdown pathdocs/design/fix-terminal-flickering/slicing-max-sized-box-design.mdBefore vs After
npm install(500 lines)git log(200 lines)cat large-file(5000 lines)Compatibility
AnsiOutputTextalready has its own independent.slice()logic.DiffRendereruses its own height management.ShowMoreLinesandconstrainHeightinfrastructure unchanged.Test plan
tsc --noEmit)npm install— no flickering, output capped at terminal heightgit log --oneline— no flickeringcata large file — no flickering or freeze🤖 Generated with Claude Code