Skip to content

fix(ui): add SlicingMaxSizedBox to prevent terminal flickering on large tool outputs#3013

Open
chiga0 wants to merge 3 commits intoQwenLM:mainfrom
chiga0:feat/fix-terminal-flickering
Open

fix(ui): add SlicingMaxSizedBox to prevent terminal flickering on large tool outputs#3013
chiga0 wants to merge 3 commits intoQwenLM:mainfrom
chiga0:feat/fix-terminal-flickering

Conversation

@chiga0
Copy link
Copy Markdown
Collaborator

@chiga0 chiga0 commented Apr 8, 2026

Background

When verboseMode is 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.

Current flow:  500 lines → Ink layouts 500 lines → visual crop → flicker
Desired flow:  500 lines → slice to 15 lines → Ink layouts 15 lines → no flicker

This is a known divergence from upstream Gemini CLI, which added SlicingMaxSizedBox in March 2026 (after the Qwen Code fork point of October 2025).

Solution

Introduce SlicingMaxSizedBox — a wrapper around MaxSizedBox that uses useMemo() to truncate data BEFORE React rendering:

  1. Layer 1 — Character truncation: Caps text at 20KB (down from 1MB), preventing Ink from parsing massive strings.
  2. Layer 2 — Line-level slicing: .slice(-maxLines) retains only the last N lines before the React render tree. Ink only receives ~15 lines → layout is instant → no flicker.
  3. Layer 3 — Visual cropping (fallback): Inner MaxSizedBox still provides overflow hidden as a safety net.

The additionalHiddenLinesCount prop is passed to MaxSizedBox so the "... first N lines hidden ..." indicator correctly reflects the total hidden lines from both pre-render slicing and visual cropping.

Changes

File Change
packages/cli/src/ui/components/shared/SlicingMaxSizedBox.tsx New — Pre-render slicing component (~97 lines)
packages/cli/src/ui/components/messages/ToolMessage.tsx Replace MaxSizedBox with SlicingMaxSizedBox in StringResultRenderer; add 20KB truncation guard for markdown path
docs/design/fix-terminal-flickering/slicing-max-sized-box-design.md Design document

Before vs After

Scenario Before After
npm install (500 lines) Ink layouts 500 lines per update → flicker Sliced to 15 lines → stable
git log (200 lines) Ink layouts 200 lines → flicker Sliced to 15 lines → stable
cat large-file (5000 lines) Ink layouts 5000 lines → freeze Sliced to 15 lines → stable
Layout cost O(output lines) O(15) = constant

Compatibility

  • compact mode: Unaffected — tool outputs are fully hidden (not rendered).
  • ANSI output: Unaffected — AnsiOutputText already has its own independent .slice() logic.
  • Diff output: Unaffected — DiffRenderer uses its own height management.
  • Ctrl+S expand: Still works — ShowMoreLines and constrainHeight infrastructure unchanged.
  • Short outputs (<15 lines): Displayed in full, no slicing applied.

Test plan

  • Build passes (tsc --noEmit)
  • Verbose mode: run npm install — no flickering, output capped at terminal height
  • Verbose mode: run git log --oneline — no flickering
  • Verbose mode: cat a large file — no flickering or freeze
  • Compact mode: tool outputs still hidden correctly
  • Short outputs (<15 lines): displayed in full
  • "... first N lines hidden ..." indicator shows correct count
  • Ctrl+S expand/collapse still works

🤖 Generated with Claude Code

…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>
@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Apr 8, 2026

📋 Review Summary

This PR addresses terminal flickering issues when displaying large tool outputs in verbose mode by introducing a new SlicingMaxSizedBox component that truncates data before React rendering, rather than relying solely on visual overflow cropping. The implementation is well-structured, follows existing patterns from Gemini CLI, and includes comprehensive documentation. The changes are focused and low-risk, affecting only the UI rendering layer.

🔍 General Feedback

  • Well-documented solution: The design document (slicing-max-sized-box-design.md) provides excellent context, including problem definition, root cause analysis, and before/after comparisons. The bilingual documentation (Chinese/English) is thorough.
  • Clean architectural approach: The three-layer defense strategy (character truncation → line slicing → visual cropping) is sound and follows the Gemini CLI reference implementation.
  • Minimal code changes: Only 3 files affected with ~100 lines of new code and ~20 lines of modifications—appropriately scoped for the problem.
  • Good separation of concerns: SlicingMaxSizedBox is a reusable shared component that properly wraps MaxSizedBox without duplicating its functionality.
  • TypeScript compliance: Build and type checks pass without errors.

🎯 Specific Feedback

🟡 High

  • File: packages/cli/src/ui/components/shared/SlicingMaxSizedBox.tsx:67-72 - The line slicing logic reserves 1 line for the "hidden" indicator with Math.max(1, maxLines - 1), but this assumes MaxSizedBox will always show the indicator. If additionalHiddenLinesCount is 0 and no visual overflow occurs, the indicator won't display, potentially causing confusion about why 1 line was reserved. Consider documenting this assumption or making the reservation conditional based on whether the indicator will actually render.

  • File: packages/cli/src/ui/components/messages/ToolMessage.tsx:197-202 - The markdown path now has character truncation protection, but unlike the plain text path which uses SlicingMaxSizedBox for line-level slicing, the markdown path relies solely on character truncation. For large markdown outputs (e.g., 500 lines of markdown), this could still cause performance issues since MarkdownDisplay will receive all lines up to 20KB. Consider whether MarkdownDisplay should also support a similar pre-render slicing mechanism.

🟢 Medium

  • File: packages/cli/src/ui/components/shared/SlicingMaxSizedBox.tsx:51 - The useMemo dependency array includes overflowDirection but not maxHeight. While maxHeight is only passed to MaxSizedBox and doesn't affect the truncation logic, adding a comment explaining why it's excluded would improve code clarity for future maintainers.

  • File: packages/cli/src/ui/components/messages/ToolMessage.tsx:44-46 - The comment explains the character limit was moved but doesn't mention that the old MAXIMUM_RESULT_DISPLAY_CHARACTERS constant (1,000,000 chars) is now unused in this file. Consider removing the stale comment about "Large threshold to ensure we don't cause performance issues" since that logic has been relocated.

  • File: packages/cli/src/ui/components/shared/SlicingMaxSizedBox.tsx:1 - The copyright header states "Google LLC" but this is a new file specific to Qwen Code's fork. While it originated from Gemini CLI, consider whether the copyright should reflect Qwen Code's contribution or include both Google and Qwen.

🔵 Low

  • File: packages/cli/src/ui/components/shared/SlicingMaxSizedBox.tsx:27-28 - The children prop uses a render callback pattern (truncatedData: string) => React.ReactNode. While this is correct for the use case, adding a JSDoc comment explaining this pattern (similar to the one for data) would improve API discoverability for other developers who might use this component.

  • File: docs/design/fix-terminal-flickering/slicing-max-sized-box-design.md:335 - The design document is in Chinese. While this is valuable for the team, consider adding an English summary or parallel English version for broader accessibility, especially since Qwen Code is an open-source project with international contributors.

  • File: packages/cli/src/ui/components/messages/ToolMessage.tsx:218-227 - The SlicingMaxSizedBox usage passes both maxLines and maxHeight with the same value (availableHeight). While this is intentional (pre-render slicing + visual fallback), a brief inline comment explaining this redundancy would help future reviewers understand the two-layer defense strategy.

✅ Highlights

  • Excellent problem analysis: The PR description clearly explains the root cause (Ink layout computation for all lines vs. visible lines) with a helpful diagram showing the before/after flow.
  • Comprehensive test plan: The PR includes a detailed test matrix covering verbose mode, compact mode, short outputs, ANSI output, diff output, and Ctrl+S expand functionality.
  • Proper fallback mechanism: The inner MaxSizedBox still provides visual cropping as a safety net, ensuring robustness even if the pre-render slicing has edge cases.
  • Correct integration with existing infrastructure: The additionalHiddenLinesCount prop correctly communicates pre-render truncation to MaxSizedBox so the "... first N lines hidden ..." indicator displays accurate counts.
  • Minimal blast radius: The changes are isolated to string result rendering—ANSI output, diff output, and compact mode remain unaffected as intended.

…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>
@chiga0
Copy link
Copy Markdown
Collaborator Author

chiga0 commented Apr 8, 2026

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: availableTerminalHeight fluctuates during streaming due to footer remeasurement, tool count changes, and tab bar toggles, causing the displayed line count to jump.

New Changes

1. useStableHeight hook (packages/cli/src/ui/hooks/useStableHeight.ts)

Caches availableTerminalHeight during streaming with state-aware rules:

  • Height increases: always accepted (more space = no content jump)
  • Small decreases (<5 lines) during streaming: absorbed, MaxSizedBox clips overflow visually
  • Large decreases (≥5 lines) or stale cache (>2s): accepted as real layout changes
  • Idle state: sync immediately for accuracy

2. MIN_TOOL_OUTPUT_HEIGHT (ToolGroupMessage.tsx)

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 (AppContainer.tsx)

Both setShellExecutionConfig and resizePty use the raw (unstabilized) height so the underlying shell process always sees real terminal dimensions.

Design Doc

Full analysis at docs/design/fix-terminal-flickering/height-stabilization-design.md, including:

  • Root cause analysis of all height fluctuation sources
  • Industry technique survey (height locking, monotonic increase, debounce, threshold filtering)
  • Before/after comparison with specific scenarios
  • Edge case and risk assessment

Summary of Both Phases

Phase Problem Solution Layout cost
Phase 1 500-line output → Ink layouts all 500 lines SlicingMaxSizedBox pre-render slicing O(n) → O(15)
Phase 2 Height fluctuates → line count jumps useStableHeight absorbs small fluctuations Stable during streaming

…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>
@chiga0
Copy link
Copy Markdown
Collaborator Author

chiga0 commented Apr 8, 2026

Phase 3: Hard Cap for All Tool Outputs (commit e1000a6)

Completed tool outputs in the static (scrollback) area had no practical height limit — staticAreaMaxItemHeight = terminalHeight * 4 gave them 96-160 lines. A git diff --stat with 40+ lines displayed entirely without truncation.

Changes

MAX_TOOL_OUTPUT_LINES = 15 in ToolMessage.tsx — Hard cap matching Gemini CLI's ACTIVE_SHELL_MAX_LINES / COMPLETED_SHELL_MAX_LINES:

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 availableHeight is now always defined (number, never undefined), the markdown rendering path for tool outputs became unreachable. Removed:

  • MarkdownDisplay import
  • markdownData truncation guard
  • renderAsMarkdown prop from StringResultRenderer
  • renderOutputAsMarkdown override guard

All Three Phases Summary

Phase Commit Problem Solution
1 f5ed185 500-line output → Ink layouts all 500 lines SlicingMaxSizedBox pre-render slicing to 15 lines
2 845f197 Height fluctuates → line count jumps useStableHeight absorbs small fluctuations during streaming
3 e1000a6 Static area has no practical height limit MAX_TOOL_OUTPUT_LINES = 15 hard cap for all tool outputs

Together, these three phases provide a comprehensive anti-flicker solution:

  • Phase 1: Reduces layout cost from O(n) to O(15)
  • Phase 2: Stabilizes height during streaming
  • Phase 3: Ensures consistent 15-line cap across all areas (pending + static)

Copy link
Copy Markdown
Collaborator

@tanzhenxin tanzhenxin left a comment

Choose a reason for hiding this comment

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

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.

@tanzhenxin
Copy link
Copy Markdown
Collaborator

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:

  • For bug fixes: show the before/after behavior.
  • For features: show the new functionality in use.

Specifically it'd be great to see:

  • A web_fetch result (or similar markdown-heavy tool output) before and after
  • The hidden lines indicator in action on a large tool output
  • The flickering fix in a short recording

This will help reviewers assess the UX tradeoffs. Thanks!

@tanzhenxin tanzhenxin added the type/bug Something isn't working as expected label Apr 9, 2026
Copy link
Copy Markdown
Collaborator

@wenshao wenshao left a comment

Choose a reason for hiding this comment

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

[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>
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] 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

text = lines.slice(0, targetLines).join('\n');
}
}
}
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] 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

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

Labels

type/bug Something isn't working as expected

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants