/** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { Box, Static } from 'ink'; import { HistoryItemDisplay } from './HistoryItemDisplay.js'; import { useUIState } from '../contexts/UIStateContext.js'; import { useSettings } from '../contexts/SettingsContext.js'; import { useAppContext } from '../contexts/AppContext.js'; import { AppHeader } from './AppHeader.js'; import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js'; import { useConfig } from '../contexts/ConfigContext.js'; import { SCROLL_TO_ITEM_END, type VirtualizedListRef, } from './shared/VirtualizedList.js'; import { ScrollableList } from './shared/ScrollableList.js'; import { useMemo, memo, useCallback, useEffect, useRef } from 'react'; import { MAX_GEMINI_MESSAGE_LINES } from '../constants.js'; import { useConfirmingTool } from '../hooks/useConfirmingTool.js'; import { ToolConfirmationQueue } from './ToolConfirmationQueue.js'; import { isTopicTool } from './messages/TopicMessage.js'; import { appEvents, AppEvent } from '../../utils/events.js'; const MemoizedHistoryItemDisplay = memo(HistoryItemDisplay); const MemoizedAppHeader = memo(AppHeader); // Limit Gemini messages to a very high number of lines to mitigate performance // issues in the worst case if we somehow get an enormous response from Gemini. // This threshold is arbitrary but should be high enough to never impact normal // usage. export const MainContent = () => { const { version } = useAppContext(); const uiState = useUIState(); const isAlternateBufferOrTerminalBuffer = useAlternateBuffer(); const config = useConfig(); const useTerminalBuffer = config.getUseTerminalBuffer(); const isAlternateBuffer = config.getUseAlternateBuffer(); const confirmingTool = useConfirmingTool(); const showConfirmationQueue = confirmingTool !== null; const confirmingToolCallId = confirmingTool?.tool.callId; const scrollableListRef = useRef>(null); useEffect(() => { if (showConfirmationQueue) { scrollableListRef.current?.scrollToEnd(); } }, [showConfirmationQueue, confirmingToolCallId]); useEffect(() => { const handleScroll = () => { scrollableListRef.current?.scrollToEnd(); }; appEvents.on(AppEvent.ScrollToBottom, handleScroll); return () => { appEvents.off(AppEvent.ScrollToBottom, handleScroll); }; }, []); const { pendingHistoryItems, mainAreaWidth, staticAreaMaxItemHeight, availableTerminalHeight, cleanUiDetailsVisible, mouseMode, } = uiState; const showHeaderDetails = cleanUiDetailsVisible; const lastUserPromptIndex = useMemo(() => { for (let i = uiState.history.length - 1; i >= 0; i--) { const type = uiState.history[i].type; if (type === 'user' || type === 'user_shell') { return i; } } return -1; }, [uiState.history]); const settings = useSettings(); const topicUpdateNarrationEnabled = settings.merged.experimental?.topicUpdateNarration === true; const suppressNarrationFlags = useMemo(() => { const combinedHistory = [...uiState.history, ...pendingHistoryItems]; const flags = new Array(combinedHistory.length).fill(false); if (topicUpdateNarrationEnabled) { let turnIsIntermediate = false; let hasTopicToolInTurn = false; for (let i = combinedHistory.length - 1; i >= 0; i--) { const item = combinedHistory[i]; if (item.type === 'user' || item.type === 'user_shell') { turnIsIntermediate = false; hasTopicToolInTurn = false; } else if (item.type === 'tool_group') { const hasTopic = item.tools.some((t) => isTopicTool(t.name)); const hasNonTopic = item.tools.some((t) => !isTopicTool(t.name)); if (hasTopic) { hasTopicToolInTurn = true; } if (hasNonTopic) { turnIsIntermediate = true; } } else if ( item.type === 'thinking' || item.type === 'gemini' || item.type === 'gemini_content' ) { // Rule 1: Always suppress thinking when narration is enabled to avoid // "flashing" as the model starts its response, and because the Topic // UI provides the necessary high-level intent. if (item.type === 'thinking') { flags[i] = true; continue; } // Rule 2: Suppress text in intermediate turns (turns containing non-topic // tools) to hide mechanical narration. if (turnIsIntermediate) { flags[i] = true; } // Rule 3: Suppress text that precedes a topic tool in the same turn, // as the topic tool "replaces" it. if (hasTopicToolInTurn) { flags[i] = true; } } } } return flags; }, [uiState.history, pendingHistoryItems, topicUpdateNarrationEnabled]); const augmentedHistory = useMemo( () => uiState.history.map((item, i) => { const prevType = i > 0 ? uiState.history[i - 1]?.type : undefined; const isFirstThinking = item.type === 'thinking' && prevType !== 'thinking'; const isFirstAfterThinking = item.type !== 'thinking' && prevType === 'thinking'; const isToolGroupBoundary = (item.type !== 'tool_group' && prevType === 'tool_group') || (item.type === 'tool_group' && prevType !== 'tool_group'); return { item, isExpandable: i > lastUserPromptIndex, isFirstThinking, isFirstAfterThinking, isToolGroupBoundary, suppressNarration: suppressNarrationFlags[i] ?? false, }; }), [uiState.history, lastUserPromptIndex, suppressNarrationFlags], ); const historyItems = useMemo( () => augmentedHistory.map( ({ item, isExpandable, isFirstThinking, isFirstAfterThinking, isToolGroupBoundary, suppressNarration, }) => ( ), ), [ augmentedHistory, mainAreaWidth, staticAreaMaxItemHeight, uiState.slashCommands, uiState.constrainHeight, ], ); const staticHistoryItems = useMemo( () => historyItems.slice(0, lastUserPromptIndex + 1), [historyItems, lastUserPromptIndex], ); const lastResponseHistoryItems = useMemo( () => historyItems.slice(lastUserPromptIndex + 1), [historyItems, lastUserPromptIndex], ); const pendingItems = useMemo( () => ( {pendingHistoryItems.map((item, i) => { const prevType = i === 0 ? uiState.history.at(-1)?.type : pendingHistoryItems[i - 1]?.type; const isFirstThinking = item.type === 'thinking' && prevType !== 'thinking'; const isFirstAfterThinking = item.type !== 'thinking' && prevType === 'thinking'; const isToolGroupBoundary = (item.type !== 'tool_group' && prevType === 'tool_group') || (item.type === 'tool_group' && prevType !== 'tool_group'); const suppressNarration = suppressNarrationFlags[uiState.history.length + i] ?? false; return ( ); })} {showConfirmationQueue && confirmingTool && ( )} ), [ pendingHistoryItems, uiState.constrainHeight, availableTerminalHeight, mainAreaWidth, showConfirmationQueue, confirmingTool, uiState.history, suppressNarrationFlags, ], ); const virtualizedData = useMemo( () => [ { type: 'header' as const }, ...augmentedHistory.map((data, index) => ({ type: 'history' as const, item: data.item, element: historyItems[index], })), { type: 'pending' as const }, ], [augmentedHistory, historyItems], ); const renderItem = useCallback( ({ item }: { item: (typeof virtualizedData)[number] }) => { if (item.type === 'header') { return ( ); } else if (item.type === 'history') { return item.element; } else { return pendingItems; } }, [showHeaderDetails, version, pendingItems], ); const estimatedItemHeight = useCallback(() => 100, []); const keyExtractor = useCallback( (item: (typeof virtualizedData)[number], _index: number) => { if (item.type === 'header') return 'header'; if (item.type === 'history') return item.item.id.toString(); return 'pending'; }, [], ); // TODO(jacobr): we should return true for all messages that are not // interactive. Gemini messages and Tool results that are not scrollable, // collapsible, or clickable should also be tagged as static in the future. const isStaticItem = useCallback( (item: (typeof virtualizedData)[number]) => item.type === 'header', [], ); const scrollableList = useMemo(() => { if (isAlternateBufferOrTerminalBuffer) { return ( // TODO(jacobr): consider adding stableScrollback={!config.getUseAlternateBuffer()} // as that will reduce the # of cases where we will have to clear the // scrollback buffer due to the scrollback size changing but we need to // work out ensuring we only attempt it within a smaller range of // scrollback vals. Right now it sometimes triggers adding more white // space than it should. ); } return null; }, [ isAlternateBufferOrTerminalBuffer, uiState.isEditorDialogOpen, uiState.embeddedShellFocused, uiState.terminalWidth, virtualizedData, renderItem, estimatedItemHeight, keyExtractor, useTerminalBuffer, isStaticItem, mouseMode, isAlternateBuffer, ]); if (!uiState.isConfigInitialized) { return null; } if (isAlternateBufferOrTerminalBuffer) { return scrollableList; } return ( <> , ...staticHistoryItems, ...lastResponseHistoryItems, ]} > {(item) => item} {pendingItems} ); };