How do they avoid scrollback/re-rendering issues?
how do they avoid scrollback/re-rendering issues? like claude code has? what's the trick?
Perfect! Now I have enough information to answer your question. Let me provide a comprehensive answer:
##Summary: How Gemini CLI Avoids Scrollback/Re-rendering Issues
Based on my exploration of the source code, the gemini-cli uses several key strategies to avoid the kind of scrollback and re-rendering issues you see in Claude Code:
###1. Message Splitting for Streaming (useGeminiStream.ts)
The most critical technique is splitting large streaming messages at safe points:
// Split large messages for better rendering performance. Ideally,
// we should maximize the amount of output sent to <Static />.
const splitPoint = findLastSafeSplitPoint(newGeminiMessageBuffer);
When a Gemini message gets too large, instead of re-rendering the entire message repeatedly as it streams in, the CLI:
- Splits the message at a safe Markdown boundary (using
findLastSafeSplitPoint) - Commits the first part to history (making it static)
- Keeps only the latest partial message as a "pending" item that updates
Why this matters: This prevents the entire message history from flickering on every character that arrives. Old messages stay static while only the last chunk gets re-rendered.
###2. Batched Scroll Updates (useBatchedScroll.ts)
Scroll operations are batched to prevent thrashing:
const pendingScrollTopRef = useRef<number | null>(null);
const setPendingScrollTop = useCallback((newScrollTop: number) => {
pendingScrollTopRef.current = newScrollTop;
}, []);
Multiple scroll operations within the same event tick accumulate and are applied together after the render, reducing excessive scroll position changes.
###3. Delayed Rendering with Debounce (shell execution)
For terminal output rendering, the CLI uses a ~68ms debounce on render updates:
renderTimeout = setTimeout(() => {
renderFn();
renderTimeout = null;
}, 68);
This batches rapid output chunks together so the UI doesn't re-render on every single byte received from the shell.
###4. Flicker Detection (useFlickerDetector.ts)
The CLI actively detects when rendering causes the UI to grow taller than the terminal (flickering):
if (measurement.height > terminalHeight) {
if (!constrainHeight) {
return;
}
recordFlickerFrame(config);
appEvents.emit(AppEvent.Flicker);
}
This is both a quality metric and a trigger to adjust rendering behavior (with an incrementalRendering setting).
###5. Smart Content Trimming
For shell output, the CLI trims trailing empty lines and only sends updates when content actually changes:
const trimmedOutput = newOutput.slice(0, lastNonEmptyLine + 1);
if (JSON.stringify(output) !== JSON.stringify(finalOutput)) {
output = finalOutput;
onOutputEvent({
type: 'data',
chunk: finalOutput,
});
}
###6. Alternate Buffer Final Render
When exiting, the entire history is rendered all at once outside of the incremental update loop, ensuring a clean final display.
The Key Insight: Unlike systems that re-render the entire history on every update, gemini-cli commits completed content to "static" history and only updates the trailing pending message. Combined with debounced rendering and message splitting, this dramatically reduces flickering and re-rendering overhead.
where do they use
Perfect! Now let me create a comprehensive answer with all the details:
##Complete Answer: How Gemini CLI Commits to History/Static
Now I can show you the complete picture! They use Ink's <Static> component combined with smart message splitting and history management.
###1. The <Static> Component (MainContent.tsx)
In normal buffer mode (non-alternate-buffer), they use Ink's Static component:
return (
<>
<Static
key={uiState.historyRemountKey}
items={[
<AppHeader key="app-header" version={version} />,
...historyItems, // All completed history items
]}
>
{(item) => item}
</Static>
{pendingItems} {/* Only this updates on each render */}
</>
);
Why this works:
<Static>renders items once and never re-renders them- Only
pendingItems(the currently streaming message) re-renders on each update - This is Ink's built-in feature to prevent flickering
###2. The History Manager Helper (useHistoryManager.ts)
This is the main helper that manages "committing" items to history:
export function useHistory({
chatRecordingService,
}: {
chatRecordingService?: ChatRecordingService | null;
} = {}): UseHistoryManagerReturn {
const [history, setHistory] = useState<HistoryItem[]>([]);
const addItem = useCallback(
(itemData: Omit<HistoryItem, 'id'>, baseTimestamp: number): number => {
const id = getNextMessageId(baseTimestamp);
const newItem: HistoryItem = { ...itemData, id };
setHistory((prevHistory) => [...prevHistory, newItem]);
// Also record in chat history for persistence
if (chatRecordingService) {
chatRecordingService?.recordMessage({...});
}
return id;
},
[getNextMessageId, chatRecordingService],
);
return { history, addItem, updateItem, clearItems, loadHistory };
}
Key points:
addItem()is called to move something from "pending" to "history"- Each item gets a unique ID based on timestamp + counter
- Items are immutable once added (hence the deprecation warning on
updateItem) - Provides both UI state management AND persistence recording
###3. The Smart Split Logic (useGeminiStream.ts)
When streaming a response, the code runs this flow:
const handleContentEvent = useCallback(
(eventValue: string, currentGeminiMessageBuffer: string) => {
let newGeminiMessageBuffer = currentGeminiMessageBuffer + eventValue;
// Step 1: If previous pending item exists and is not Gemini type, commit it
if (pendingHistoryItemRef.current?.type !== 'gemini') {
if (pendingHistoryItemRef.current) {
addItem(pendingHistoryItemRef.current, timestamp); // COMMIT to history
}
setPendingHistoryItem({ type: 'gemini', text: '' });
newGeminiMessageBuffer = eventValue;
}
// Step 2: Find safe split point in the Markdown
const splitPoint = findLastSafeSplitPoint(newGeminiMessageBuffer);
// Step 3: If it all fits, keep as pending (will re-render)
if (splitPoint === newGeminiMessageBuffer.length) {
setPendingHistoryItem((item) => ({
type: item?.type as 'gemini' | 'gemini_content',
text: newGeminiMessageBuffer,
}));
}
// Step 4: If it's too big, split it
else {
const beforeText = newGeminiMessageBuffer.substring(0, splitPoint);
const afterText = newGeminiMessageBuffer.substring(splitPoint);
// COMMIT the "done" part to history (goes to <Static>)
addItem(
{
type: pendingHistoryItemRef.current?.type as 'gemini',
text: beforeText, // This won't change anymore
},
timestamp,
);
// Keep streaming in pending state (keeps re-rendering)
setPendingHistoryItem({ type: 'gemini_content', text: afterText });
newGeminiMessageBuffer = afterText;
}
return newGeminiMessageBuffer;
},
[addItem, pendingHistoryItemRef, setPendingHistoryItem],
);
###4. The Safe Split Helper (markdownUtilities.ts)
This is the magic that prevents breaking Markdown:
export const findLastSafeSplitPoint = (content: string) => {
// 1. Check if content ends in a code block
const enclosingBlockStart = findEnclosingCodeBlockStart(content, content.length);
if (enclosingBlockStart !== -1) {
// Split BEFORE the code block to keep it intact
return enclosingBlockStart;
}
// 2. Search for last double newline (paragraph break) not in code block
let searchStartIndex = content.length;
while (searchStartIndex >= 0) {
const dnlIndex = content.lastIndexOf('\n\n', searchStartIndex);
if (dnlIndex === -1) break;
const potentialSplitPoint = dnlIndex + 2;
if (!isIndexInsideCodeBlock(content, potentialSplitPoint)) {
return potentialSplitPoint; // Safe to split here!
}
searchStartIndex = dnlIndex - 1;
}
// 3. If no safe split, return full length (don't split)
return content.length;
};
How it's code-block-aware:
- Scans the string counting fence markers (
```) - If a potential split is inside a code block (odd number of fences before it), skip it
- Prefers paragraph breaks (
\n\n) over random character limits - Keeps entire code blocks intact
###5. State Management Pattern (useStateAndRef.ts)
They use a special hook to keep both state AND ref in sync:
const [pendingHistoryItem, pendingHistoryItemRef, setPendingHistoryItem] =
useStateAndRef<HistoryItemWithoutId | null>(null);
This gives you:
pendingHistoryItem- the React state (triggers re-renders)pendingHistoryItemRef- instant access to the value (no closure issues)setPendingHistoryItem()- update function
###6. The Flow Diagram
Streaming Gemini response arrives (chunks):
↓
handleContentEvent() in useGeminiStream
↓
Append to buffer: currentBuffer + newChunk
↓
Split check: findLastSafeSplitPoint(buffer)
↓
┌─────────────────────────────────┐
│ Fits in pending? (no split) │
│ → setPendingHistoryItem(all) │
│ → Pending area re-renders │
│ → <Static> unchanged │
└─────────────────────────────────┘
OR
┌─────────────────────────────────┐
│ Too big? (has split point) │
│ → addItem(before) ✓ COMMITTED │
│ → Added to <Static> │
│ → setPendingHistoryItem(after) │
│ → Pending area re-renders │
│ → History part is frozen │
└─────────────────────────────────┘
###7. Alternate Buffer Mode Uses Virtualization
When in alternate buffer mode, they use a virtualized list instead:
if (isAlternateBuffer) {
return (
<ScrollableList
data={virtualizedData}
renderItem={renderItem}
estimatedItemHeight={() => 100}
initialScrollIndex={SCROLL_TO_ITEM_END}
/>
);
}
This only renders visible items instead of all items.
##Summary of the "Trick"
The key is three-fold:
- Ink's
<Static>component - Completed history never re-renders - Smart message splitting - Break at Markdown boundaries, not mid-code-block
- Pending-only updates - Only the actively-streaming message re-renders on each chunk
This means as a 5000-line response streams in, the first 4900 lines are committed to <Static> and frozen, while only the last 100 lines in "pending" state re-render with each new chunk. Zero flickering, zero cascading re-renders of history.
what's uiState.historyRemountKey? what is it used for? where do pendingItems come from? who's in charge of adding stuff to history and what, removing pending items?
Perfect! Now I have enough information to give a comprehensive answer. Let me write it out:
##Complete Answer: How History and Pending Items Are Managed
Let me break down the complete flow:
###1. historyRemountKey - The React Key Trick
In AppContainer.tsx, there's a simple state variable:
const [historyRemountKey, setHistoryRemountKey] = useState(0);
const refreshStatic = useCallback(() => {
if (!isAlternateBuffer) {
stdout.write(ansiEscapes.clearTerminal);
}
setHistoryRemountKey((prev) => prev + 1); // Increment it
}, [setHistoryRemountKey, isAlternateBuffer, stdout]);
And it's used as the React key in MainContent.tsx:
<Static
key={uiState.historyRemountKey} // When this changes, Static re-mounts
items={[...historyItems]}
>
Why this matters:
- When you change the
keyon a React component, React treats it as a completely new component instance - It remounts with fresh state
- This forces a full re-render of the
<Static>component and all its items - Used when editors close, banners show, or terminal mode changes
###2. pendingHistoryItems - Where They Come From
They come from TWO sources in AppContainer.tsx:
const {
streamingState,
submitQuery,
initError,
pendingHistoryItems: pendingGeminiHistoryItems, // From useGeminiStream
...
} = useGeminiStream(...);
const {
pendingHistoryItems: pendingSlashCommandHistoryItems, // From useSlashCommandProcessor
...
} = useSlashCommandProcessor(...);
// Combined into one
const pendingHistoryItems = useMemo(
() => [...pendingSlashCommandHistoryItems, ...pendingGeminiHistoryItems],
[pendingSlashCommandHistoryItems, pendingGeminiHistoryItems],
);
From useGeminiStream.ts:
const [pendingHistoryItem, pendingHistoryItemRef, setPendingHistoryItem] =
useStateAndRef<HistoryItemWithoutId | null>(null);
// ... also has toolCalls that generate pendingToolCallGroupDisplay
const pendingHistoryItems = useMemo(
() =>
[pendingHistoryItem, pendingToolCallGroupDisplay].filter(
(i) => i !== undefined && i !== null,
),
[pendingHistoryItem, pendingToolCallGroupDisplay],
);
// Returned to AppContainer
return {
pendingHistoryItems,
...
};
So pendingHistoryItems is either:
- A Gemini message currently streaming (type: 'gemini' or 'gemini_content')
- A Tool group being executed (type: 'tool_group')
- A Slash command result (from useSlashCommandProcessor)
- Or null if nothing is pending
###3. Who Adds to History & Removes Pending?
Adding to history is done via addItem() from the history manager:
const historyManager = useHistory({
chatRecordingService: config.getGeminiClient()?.getChatRecordingService(),
});
Then passed to all hooks as a prop. When you call addItem(itemData, timestamp), it:
- Adds the item to the history array (immutably)
- Records it to the persistent chat recording service
- Returns the generated unique ID
Removing from pending happens when the item is committed to history:
// In many event handlers across useGeminiStream:
if (pendingHistoryItemRef.current) {
addItem(pendingHistoryItemRef.current, userMessageTimestamp); // Commit!
setPendingHistoryItem(null); // Clear pending
}
###4. Complete Lifecycle Example: Gemini Streaming
Here's what happens step by step:
// STEP 1: User submits query
submitQuery(userMessage) {
// Adds user message to history immediately
addItem({ type: 'user', text: userMessage }, timestamp);
// Starts responding
setIsResponding(true);
// Creates empty pending Gemini message
setPendingHistoryItem({ type: 'gemini', text: '' });
}
// STEP 2: Stream arrives chunk by chunk
handleContentEvent(chunk) {
newGeminiMessageBuffer = currentBuffer + chunk;
const splitPoint = findLastSafeSplitPoint(newGeminiMessageBuffer);
if (splitPoint === newGeminiMessageBuffer.length) {
// Fits! Keep in pending (re-renders every chunk)
setPendingHistoryItem({ type: 'gemini', text: newGeminiMessageBuffer });
} else {
// Too big! Split it
const beforeText = newGeminiMessageBuffer.substring(0, splitPoint);
const afterText = newGeminiMessageBuffer.substring(splitPoint);
// COMMIT the first part to history (goes to <Static>, never changes)
addItem({ type: 'gemini', text: beforeText }, timestamp);
// Keep rest in pending (continues to re-render)
setPendingHistoryItem({ type: 'gemini_content', text: afterText });
}
}
// STEP 3: Stream ends (with Finished event)
handleFinishedEvent(event) {
// No explicit clear here - the stream ending and setIsResponding(false)
// just means we stop rendering updates
// Add any warning message if needed (e.g., MAX_TOKENS)
if (message) {
addItem({ type: 'info', text: message }, timestamp);
}
setIsResponding(false);
}
// STEP 4: Tools execute after stream (if any)
// Tool status updates are shown in pendingToolCallGroupDisplay
// When ALL tools complete:
onToolsComplete() {
// Convert pending tool calls to history
addItem(mapTrackedToolCallsToDisplay(completedToolCalls), timestamp);
// Clear the live-updating display
setToolCallsForDisplay([]);
}
###5. The useStateAndRef Pattern (Why Both State AND Ref?)
const [pendingHistoryItem, pendingHistoryItemRef, setPendingHistoryItem] =
useStateAndRef<HistoryItemWithoutId | null>(null);
This gives you:
pendingHistoryItem- The state value (triggers re-renders when it changes)pendingHistoryItemRef- A ref to the current value (instant access, no stale closure)setPendingHistoryItem()- Update function
Why both? Because you need:
- State to trigger React re-renders of the pending area
- Ref to access the current value in callbacks without closure issues
###6. Key State Management Points
| State | What It Holds | When It Updates | Where It's Used |
|---|---|---|---|
history | All completed messages | When addItem() is called | <Static> component |
pendingHistoryItems | Current streaming/executing items | As stream chunks arrive | Re-renders every tick (not Static) |
historyRemountKey | Number that increments | When editor closes, banner shows | React key on <Static> to force remount |
streamingState | Enum: Idle/Responding/Waiting | Throughout request lifecycle | Controls loading indicator, input focus |
isResponding | Boolean | Set true on submit, false on completion | Prevents duplicate submissions |
###7. Who Clears Pending?
Pending items are cleared by setting them to null in several scenarios:
// When stream finishes normally
handleFinishedEvent() {
// No explicit clear needed, just stop updating
}
// When user cancels
handleUserCancelledEvent() {
// First commit pending to history
if (pendingHistoryItemRef.current) {
addItem(pendingHistoryItemRef.current, timestamp);
}
// Then clear it
setPendingHistoryItem(null);
}
// When error occurs
handleErrorEvent() {
// Same pattern: commit then clear
if (pendingHistoryItemRef.current) {
addItem(pendingHistoryItemRef.current, timestamp);
setPendingHistoryItem(null);
}
}
// When tool completes
handleCompletedTools() {
// Clear the tool display
setToolCallsForDisplay([]); // This makes pendingHistoryItems empty
}
###8. Complete Data Flow Diagram
User Input
↓
addItem(userMessage) → history.append()
↓
setPendingHistoryItem({type: 'gemini', text: ''})
↓
for await (chunk of stream) {
├─ handleContentEvent(chunk)
│ ├─ Check split point
│ ├─ If small: setPendingHistoryItem({text: all}) → re-render
│ └─ If big:
│ ├─ addItem({text: first part}) → move to history + <Static>
│ └─ setPendingHistoryItem({text: rest}) → re-render
│
└─ [UI updates pending area every chunk]
(history stays frozen in <Static>)
}
stream ends
↓
handleFinishedEvent() / handleErrorEvent()
↓
addItem(error/warning info if needed)
↓
setPendingHistoryItem(null) [or happens implicitly]
↓
setIsResponding(false) → ready for next input
###9. Refreshing Static Manually
Sometimes you need to force a full <Static> remount:
refreshStatic() {
setHistoryRemountKey((prev) => prev + 1); // Increment key
}
// Used when:
- Editor closes (line 442)
- Banner visibility changes (line 462)
- Terminal capability changes
This is a nuclear option - it unmounts the entire <Static> and remounts it fresh.
##TL;DR
pendingHistoryItems= array of items currently being streamed/executed (re-renders every frame)history= array of completed items (frozen in<Static>, never re-renders)addItem()= moves an item from pending to historysetPendingHistoryItem(null)= clears pending (happens on error, cancel, or stream end)historyRemountKey= React key that forces<Static>to remount when changed- Sources of pending items: Gemini streaming (useGeminiStream) + Slash commands (useSlashCommandProcessor)
when does refreshStatic get called? isn't clearning the whole terminal expensive?
Perfect! Now let me create a comprehensive answer about when and why refreshStatic is called:
##When & Why refreshStatic Gets Called (And No, It's Not That Expensive!)
Great observation! The key is in this line:
const refreshStatic = useCallback(() => {
if (!isAlternateBuffer) {
stdout.write(ansiEscapes.clearTerminal); // Only in normal mode!
}
setHistoryRemountKey((prev) => prev + 1);
}, [setHistoryRemountKey, isAlternateBuffer, stdout]);
###The Critical Condition: !isAlternateBuffer
clearTerminal is ONLY called when NOT in alternate buffer mode (normal mode). In alternate buffer mode (which is where the interactive UI lives), it skips the clear entirely!
###All Places refreshStatic() Gets Called:
-
When external editor closes (line 441)
- User opens file in external editor (VSCode, Vim, etc.)
- Editor modifies the file
- Editor closes
- → Need to refresh to show updated state
-
When banner becomes visible (line 459)
useEffect(() => { if ( !(settings.merged.ui?.hideBanner || config.getScreenReader()) && bannerVisible && bannerText ) { // The header should show a banner but the Header is rendered in static // so we must trigger a static refresh for it to be visible. refreshStatic(); } }, [bannerVisible, bannerText, settings, config, refreshStatic]); -
When theme is changed (line 176, 193)
const handleThemeSelect = useCallback( (themeName: string) => { onSelect(themeName, selectedScope); refreshStatic(); }, [onSelect, selectedScope, refreshStatic], ); -
When Markdown rendering is toggled (line 1254)
} else if (keyMatchers[Command.TOGGLE_MARKDOWN](key)) { setRenderMarkdown((prev) => { const newValue = !prev; // Force re-render of static content refreshStatic(); return newValue; }); } -
When screen is cleared via
/clearcommand (line 887)const handleClearScreen = useCallback(() => { historyManager.clearItems(); clearConsoleMessagesState(); refreshStatic(); }, [historyManager, clearConsoleMessagesState, refreshStatic]); -
When slash command processor clears history (line 213)
ui: { addItem, clear: () => { clearItems(); refreshStatic(); setBannerVisible(false); }, } -
On window size change (debounced 300ms) (line 1108)
const handler = setTimeout(() => { refreshStatic(); }, 300);
###Why It's NOT Expensive In Practice
-
Only in normal mode - Most interactive sessions use alternate buffer mode where the clear is skipped
if (!isAlternateBuffer) { stdout.write(ansiEscapes.clearTerminal); } -
Debounced on resize - When terminal window resizes, it waits 300ms before refreshing
const handler = setTimeout(() => { refreshStatic(); }, 300); -
Conditional on banner changes - Only refreshes if:
- Banner is actually visible
- Settings don't hide it
- Screen reader is disabled
- And the banner text actually changed
if ( !(settings.merged.ui?.hideBanner || config.getScreenReader()) && bannerVisible && bannerText ) { refreshStatic(); } -
Rare user actions - Theme changes, Markdown toggling, screen clearing - these aren't frequent operations
-
clearTerminalis just 1 ANSI escape code - It's literally:stdout.write(ansiEscapes.clearTerminal);Which sends something like
\x1b[2J\x1b[H(about 6-7 bytes). This is super fast - just clears the screen buffer and moves cursor to origin.
###The Real Cost
The expensive part isn't clearTerminal - it's the React re-mount:
<Static
key={uiState.historyRemountKey} // When this increments, React re-mounts everything
items={[...historyItems]}
>
When the key changes:
- React unmounts the old
<Static>instance - React mounts a fresh
<Static>instance - All child components re-render
- All history items get re-rendered
But this is intentional because:
- You're changing something visual (theme, markdown mode, header banner)
- You need the visual refresh to take effect
- It's rare enough that it doesn't matter
###Example: Alternate Buffer Mode (The Common Case)
In most interactive sessions, you're in alternate buffer mode:
const refreshStatic = useCallback(() => {
if (!isAlternateBuffer) { // ← This is FALSE!
stdout.write(ansiEscapes.clearTerminal); // ← Never executes
}
setHistoryRemountKey((prev) => prev + 1); // ← Only this runs
}, ...);
So in normal interactive use, refreshStatic() just increments the key. No terminal clearing at all!
###Test Verifying This
There's literally a regression test for it:
it('does not refresh static on startup if banner text is empty', async () => {
// Mock banner text to be empty strings
vi.spyOn(mockConfig, 'getBannerTextNoCapacityIssues').mockResolvedValue('');
vi.spyOn(mockConfig, 'getBannerTextCapacityIssues').mockResolvedValue('');
// ...
// Check that clearTerminal was NOT written to stdout
const clearTerminalCalls = mocks.mockStdout.write.mock.calls.filter(
(call: unknown[]) => call[0] === ansiEscapes.clearTerminal,
);
expect(clearTerminalCalls).toHaveLength(0); // ✓ Verify no clears!
});
##TL;DR
refreshStatic()is called rarely - only on theme changes, editor close, banner show, markdown toggle, screen resize (debounced), and/clearcommandclearTerminalis only in normal mode - in alternate buffer (interactive), it's skipped- It's super cheap - just a 6-7 byte ANSI escape code
- The real cost is the React key remount - which only happens when you actually need it (visual changes)
- It's highly intentional - the team has tests to ensure unnecessary clears don't happen
cool. give me a tl;dr of how they avoid performance issues with ink. talk especially about how they "commit stuff to Static" while avoiding lots of duplicate code and layout issues.
Perfect question - let me give you the architectural overview:
##TL;DR: How Gemini CLI Avoids Ink Performance Issues
###The Core Problem They're Solving
Ink re-renders everything on every state change. With a long conversation history, re-rendering 100+ messages every time a new character streams in = massive flickering and slowdown.
###The Solution: Static + Pending Split Architecture
They split the UI into two zones:
<Static key={remountKey} items={staticItems}>
{(item) => item}
</Static>
{pendingItems} {/* Re-renders every frame */}
Static Zone = immutable history (never changes, never re-renders) Pending Zone = current streaming/executing content (re-renders frequently)
###How They Avoid Code Duplication
Instead of rendering history items one way and pending items another way, they use the same component (HistoryItemDisplay) for both:
// In MainContent.tsx
const staticItems = uiState.history.map((h) => (
<HistoryItemDisplay key={h.id} item={h} isPending={false} />
));
const pendingItems = (
<Box>
{pendingHistoryItems.map((item, i) => (
<HistoryItemDisplay key={i} item={item} isPending={true} />
))}
</Box>
);
// Same component, different props!
// HistoryItemDisplay handles both cases
Result: One HistoryItemDisplay component, two uses. Zero duplication.
###How They Avoid Layout Issues
The killer insight: both zones render the exact same way
// Static rendering
const staticItems = uiState.history.map((h) => (
<HistoryItemDisplay
item={h}
isPending={false}
terminalWidth={mainAreaWidth}
availableTerminalHeight={staticAreaMaxItemHeight}
/>
));
// Pending rendering
const pendingItems = (
<Box>
{pendingHistoryItems.map((item, i) => (
<HistoryItemDisplay
key={i}
item={item}
isPending={true}
terminalWidth={mainAreaWidth}
availableTerminalHeight={availableTerminalHeight}
/>
))}
</Box>
);
Because they use the same component with the same props, layout is always consistent. The pending message looks exactly like it will look once it's committed to static.
Result: No layout shift when a message moves from pending to history.
###The Smart Message Splitting Trick
Instead of keeping the entire response in pending (which gets re-rendered constantly), they split large messages at safe points and commit chunks to Static:
const splitPoint = findLastSafeSplitPoint(newGeminiMessageBuffer);
if (splitPoint === newGeminiMessageBuffer.length) {
// Fits! Keep in pending (will re-render)
setPendingHistoryItem({ type: 'gemini', text: newGeminiMessageBuffer });
} else {
// Too big! Commit the first part
addItem(
{ type: 'gemini', text: beforeText }, // → Goes to Static immediately
timestamp,
);
setPendingHistoryItem({ type: 'gemini_content', text: afterText }); // → Stays pending
}
Example flow:
- Message has 5000 characters
- Every 50 characters, they check if it's a good split point (paragraph break, not mid-code-block)
- Once 4800 chars are added, they find the last
\n\nand split - Commit 4800 chars to Static (frozen forever, never re-renders again)
- Keep 200 chars in pending (small, re-renders smoothly)
Result: Histor grows in Static without re-rendering. Only the small pending part updates frequently.
###How They Handle Tool Calls
Same pattern for tools:
const pendingToolCallGroupDisplay = useMemo(
() => toolCalls.length ? mapTrackedToolCallsToDisplay(toolCalls) : undefined,
[toolCalls],
);
// When ALL tools complete:
addItem(
mapTrackedToolCallsToDisplay(completedToolCalls), // → Commit to history
timestamp,
);
setToolCallsForDisplay([]); // → Clear pending
Tools show live progress in the pending zone, then when done, move to static. Same layout, no flicker.
###The Key Performance Techniques
| Technique | What It Solves | How |
|---|---|---|
| Static + Pending split | Prevent re-rendering history | Completed items in <Static>, only pending zone updates |
| Same component for both | Avoid code duplication | HistoryItemDisplay used for both static and pending |
| Same props for both | Avoid layout shifts | Static and pending render identically, so no layout jump when moved |
| Smart message splitting | Reduce pending re-render cost | Commit chunks to Static as they arrive, keep small tail in pending |
| Debounced renders | Batch updates | Shell output renders every 68ms, not every byte |
| Batched scroll updates | Prevent scroll thrashing | Scroll operations accumulate within same tick, apply after render |
| Memoization | Prevent unnecessary re-renders | useMemo on pending items, tool calls, etc. |
| Flicker detection | Catch rendering bugs | Monitor if UI grows taller than terminal, emit telemetry |
###What This Achieves
Traditional Approach:
User types → New message
→ Re-render entire history (100 messages)
→ Layout calculations for 100 messages
→ Terminal flickers
→ Cursor position jumps
→ Performance tanks as history grows
Gemini CLI Approach:
User types → New message
→ Add to history (committed, frozen)
→ Only pending zone re-renders (1 message)
→ Layout calc for 1 message
→ Smooth, no flicker
→ Performance is constant (doesn't degrade with history size!)
###The Architecture in One Diagram
┌─────────────────────────────────────────────────────────┐
│ MainContent │
├─────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────────────────────────────────────┐ │
│ │ <Static key={remountKey}> │ │
│ │ │ │
│ │ AppHeader (frozen) │ │
│ │ HistoryItemDisplay (id: 1) - frozen │ │
│ │ HistoryItemDisplay (id: 2) - frozen │ │
│ │ HistoryItemDisplay (id: 3) - frozen │ │
│ │ ... │ │
│ │ HistoryItemDisplay (id: N) - frozen │ │
│ │ │ │
│ │ (Never re-renders, grows as you addItem()) │ │
│ └──────────────────────────────────────────────┘ │
│ │
│ ┌──────────────────────────────────────────────┐ │
│ │ Pending Zone (Re-renders every frame) │ │
│ │ │ │
│ │ HistoryItemDisplay (streaming message) │ │
│ │ HistoryItemDisplay (executing tool) │ │
│ │ │ │
│ │ (Re-renders constantly as stream updates) │ │
│ └──────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────┘
When message gets too big:
1. Find safe split point (paragraph break, not mid-code-block)
2. addItem(firstPart) → moves to Static ✓ Frozen forever
3. setPendingHistoryItem(secondPart) → stays in pending ✓ Keeps re-rendering
Result: Static grows without re-rendering, pending stays small!
###Code Architecture Benefit
No duplication because:
- One
HistoryItemDisplaycomponent - Rendered in two places (Static, Pending)
- Same logic for rendering, display, interaction
- Layout differences handled by props (
isPending,availableTerminalHeight) - When an item moves from pending to history, it goes through the same component
// Before: would need two separate components
// HistoryItemDisplayStatic.tsx
// HistoryItemDisplayPending.tsx
// (duplicated code, hard to maintain)
// After: one component
function HistoryItemDisplay({ item, isPending, ...props }) {
// Render logic works for both cases
// Minor differences handled by isPending prop
}
##The Genius of This Design
- Performance scales with active content, not total content - History size doesn't matter, only pending size matters for re-render cost
- Zero layout shifts - same component, same props = identical rendering
- No code duplication - one HistoryItemDisplay, two uses
- Streaming works naturally - smart splitting keeps pending small while Static grows
- Can add thousands of messages - Static zone is frozen, adding is O(1) conceptually
It's basically: "Make history immutable, keep only the actively-changing part dynamic, use the same component for both so they look identical."
END OF POST