How do they avoid scrollback/re-rendering issues?

main
41e627aRefine Gemini 3 system instructions to reduce model verbosity (#16139)
This post might have stale content, as main is 30 commits ahead.

how do they avoid scrollback/re-rendering issues? like claude code has? what's the trick?

Avatar of Claude Haiku 4.5Claude Haiku 4.5commented

I'm not entirely sure what you're referring to - could you clarify what "scrollback/re-rendering issues" you mean? Are you asking about:

  1. How the gemini-cli avoids performance issues when dealing with long outputs or scrolling through results?
  2. Some specific rendering or UI issue you've observed?
  3. A comparison to how Claude handles similar situations?

Let me explore the codebase a bit to get context, but your clarification would help me search more effectively.

That's a lot of matches! I can see there's definitely rendering and scrolling logic in the codebase. Based on what I found, let me look at some specific areas that might relate to your question about avoiding scrollback/re-rendering issues:

Perfect! I found something very relevant. Let me get more context around this render strategy and also check if there's anything about avoiding full re-renders:

Excellent! Now I'm seeing the key insights. Let me check the App.tsx to see how Static is used:

Let me search for Static more broadly:

Let me check the rendering batching hook:

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.

Avatar of Julian BenegasJulian Benegascommented

where do they use ? how do they "commit stuff to history/static"? do they have helpers?

Avatar of Claude Haiku 4.5Claude Haiku 4.5commented

Great question! Let me search for how they actually implement the history/static pattern:

Let me search differently:

Let me check the hooks and context files more directly:

Let me check the history manager:

Great! Now let me check the useGeminiStream in detail:

Now let me see the specific part about splitting and committing to history:

Let me search for the split logic:

Perfect! Now let me check the actual flow by looking at that specific section:

Perfect! Now let me check the App.tsx to see how history is rendered:

Now let me check the DefaultAppLayout:

Now let me check MainContent:

Perfect! Now I have the complete picture. Let me also check the markdown utilities to understand the safe split logic:

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:

  1. Ink's <Static> component - Completed history never re-renders
  2. Smart message splitting - Break at Markdown boundaries, not mid-code-block
  3. 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.

Avatar of Julian BenegasJulian Benegascommented

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?

Avatar of Claude Haiku 4.5Claude Haiku 4.5commented

Great questions! Let me trace through the code to find where these come from:

Good! Now let me find where it's incremented and what triggers it:

Now let me find pendingHistoryItems and where they come from:

Let me search in the context file:

Let me check the AppContainer where these are being managed:

Let me search for where pendingHistoryItems is set:

Perfect! Now let me check where these pending items get cleared/moved to history:

Let me look for where useGeminiStream returns the pending items:

Now let me look at where the completion/clearing happens:

Now let me find the handleFinishedEvent function:

Perfect! Now let me check where the actual stream ends and pending is cleared:

Now let me see what happens after the stream ends:

Great! Let me also check how refreshStatic is used:

Perfect! Now I have all the pieces. Let me create a comprehensive answer:

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 key on 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:

  1. Adds the item to the history array (immutably)
  2. Records it to the persistent chat recording service
  3. 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

StateWhat It HoldsWhen It UpdatesWhere It's Used
historyAll completed messagesWhen addItem() is called<Static> component
pendingHistoryItemsCurrent streaming/executing itemsAs stream chunks arriveRe-renders every tick (not Static)
historyRemountKeyNumber that incrementsWhen editor closes, banner showsReact key on <Static> to force remount
streamingStateEnum: Idle/Responding/WaitingThroughout request lifecycleControls loading indicator, input focus
isRespondingBooleanSet true on submit, false on completionPrevents 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 history
  • setPendingHistoryItem(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)
Avatar of Julian BenegasJulian Benegascommented

when does refreshStatic get called? isn't clearning the whole terminal expensive?

Avatar of Claude Haiku 4.5Claude Haiku 4.5commented

Great question! Let me find all the places where refreshStatic is called:

Perfect! Now let me check what exactly happens in clearTerminal:

Let me look at the test to see when it's NOT called:

Now let me check the condition again more carefully:

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:

  1. 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
  2. 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]);
    
  3. When theme is changed (line 176, 193)

    const handleThemeSelect = useCallback(
      (themeName: string) => {
        onSelect(themeName, selectedScope);
        refreshStatic();
      },
      [onSelect, selectedScope, refreshStatic],
    );
    
  4. 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;
      });
    }
    
  5. When screen is cleared via /clear command (line 887)

    const handleClearScreen = useCallback(() => {
      historyManager.clearItems();
      clearConsoleMessagesState();
      refreshStatic();
    }, [historyManager, clearConsoleMessagesState, refreshStatic]);
    
  6. When slash command processor clears history (line 213)

    ui: {
      addItem,
      clear: () => {
        clearItems();
        refreshStatic();
        setBannerVisible(false);
      },
    }
    
  7. On window size change (debounced 300ms) (line 1108)

    const handler = setTimeout(() => {
      refreshStatic();
    }, 300);
    

###Why It's NOT Expensive In Practice

  1. Only in normal mode - Most interactive sessions use alternate buffer mode where the clear is skipped

    if (!isAlternateBuffer) {
      stdout.write(ansiEscapes.clearTerminal);
    }
    
  2. Debounced on resize - When terminal window resizes, it waits 300ms before refreshing

    const handler = setTimeout(() => {
      refreshStatic();
    }, 300);
    
  3. 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();
    }
    
  4. Rare user actions - Theme changes, Markdown toggling, screen clearing - these aren't frequent operations

  5. clearTerminal is 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 /clear command
  • clearTerminal is 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
Avatar of Julian BenegasJulian Benegascommented

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.

Avatar of Claude Haiku 4.5Claude Haiku 4.5commented

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\n and 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

TechniqueWhat It SolvesHow
Static + Pending splitPrevent re-rendering historyCompleted items in <Static>, only pending zone updates
Same component for bothAvoid code duplicationHistoryItemDisplay used for both static and pending
Same props for bothAvoid layout shiftsStatic and pending render identically, so no layout jump when moved
Smart message splittingReduce pending re-render costCommit chunks to Static as they arrive, keep small tail in pending
Debounced rendersBatch updatesShell output renders every 68ms, not every byte
Batched scroll updatesPrevent scroll thrashingScroll operations accumulate within same tick, apply after render
MemoizationPrevent unnecessary re-rendersuseMemo on pending items, tool calls, etc.
Flicker detectionCatch rendering bugsMonitor 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 HistoryItemDisplay component
  • 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

  1. Performance scales with active content, not total content - History size doesn't matter, only pending size matters for re-render cost
  2. Zero layout shifts - same component, same props = identical rendering
  3. No code duplication - one HistoryItemDisplay, two uses
  4. Streaming works naturally - smart splitting keeps pending small while Static grows
  5. 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

Log in to add a comment
Claude Haiku 4.5
How do they avoid scrollback/re-rendering issues? — google-gemini/gemini-cli