Files
gemini-cli/packages/cli/src/ui/components/MainContent.tsx
T
Abhijit Balaji f790f7f40b feat(ui): implement true sticky topic header in app layouts
- Relocated TopicStickyHeader from chat history to global AppLayouts (Default and ScreenReader) for permanent viewport stickiness.
- Fixed duplication issues by removing complex history injection logic in MainContent.tsx.
- Restored natural rendering of topic updates in chat history to ensure consistent scrollback context.
- Added persistent styling (background and separator) to TopicStickyHeader for better visual hierarchy.
- Simplified MainContent.tsx logic by removing redundant overlay and virtualization hacks.
- Updated unit tests and snapshots to align with the new stable layout architecture.
2026-04-02 14:32:43 -04:00

271 lines
8.3 KiB
TypeScript

/**
* @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 { useAlternateBuffer } from '../hooks/useAlternateBuffer.js';
import {
SCROLL_TO_ITEM_END,
type VirtualizedListRef,
} from './shared/VirtualizedList.js';
import { ScrollableList } from './shared/ScrollableList.js';
import { useMemo, memo, 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';
const MemoizedHistoryItemDisplay = memo(HistoryItemDisplay);
// 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 uiState = useUIState();
const isAlternateBuffer = useAlternateBuffer();
const confirmingTool = useConfirmingTool();
const showConfirmationQueue = confirmingTool !== null;
const confirmingToolCallId = confirmingTool?.tool.callId;
const scrollableListRef = useRef<VirtualizedListRef<unknown>>(null);
useEffect(() => {
if (showConfirmationQueue) {
scrollableListRef.current?.scrollToEnd();
}
}, [showConfirmationQueue, confirmingToolCallId]);
const {
pendingHistoryItems,
mainAreaWidth,
staticAreaMaxItemHeight,
availableTerminalHeight,
} = uiState;
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<boolean>(combinedHistory.length).fill(false);
if (topicUpdateNarrationEnabled) {
let toolGroupInTurn = false;
for (let i = combinedHistory.length - 1; i >= 0; i--) {
const item = combinedHistory[i];
if (item.type === 'user' || item.type === 'user_shell') {
toolGroupInTurn = false;
} else if (item.type === 'tool_group') {
toolGroupInTurn = item.tools.some((t) => isTopicTool(t.name));
} else if (
(item.type === 'thinking' ||
item.type === 'gemini' ||
item.type === 'gemini_content') &&
toolGroupInTurn
) {
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';
return {
item,
isExpandable: i > lastUserPromptIndex,
isFirstThinking,
isFirstAfterThinking,
suppressNarration: suppressNarrationFlags[i] ?? false,
};
}),
[uiState.history, lastUserPromptIndex, suppressNarrationFlags],
);
const historyItems = useMemo(
() =>
augmentedHistory.map(
({
item,
isExpandable,
isFirstThinking,
isFirstAfterThinking,
suppressNarration,
}) => (
<MemoizedHistoryItemDisplay
terminalWidth={mainAreaWidth}
availableTerminalHeight={
uiState.constrainHeight || !isExpandable
? staticAreaMaxItemHeight
: undefined
}
availableTerminalHeightGemini={MAX_GEMINI_MESSAGE_LINES}
key={item.id}
item={item}
isPending={false}
commands={uiState.slashCommands}
isExpandable={isExpandable}
isFirstThinking={isFirstThinking}
isFirstAfterThinking={isFirstAfterThinking}
suppressNarration={suppressNarration}
/>
),
),
[
augmentedHistory,
mainAreaWidth,
staticAreaMaxItemHeight,
uiState.slashCommands,
uiState.constrainHeight,
],
);
const pendingItems = useMemo(
() => (
<Box flexDirection="column" key="pending-items-group">
{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 suppressNarration =
suppressNarrationFlags[uiState.history.length + i] ?? false;
return (
<HistoryItemDisplay
key={`pending-${i}`}
availableTerminalHeight={
uiState.constrainHeight ? availableTerminalHeight : undefined
}
terminalWidth={mainAreaWidth}
item={{ ...item, id: -(i + 1) }}
isPending={true}
isExpandable={true}
isFirstThinking={isFirstThinking}
isFirstAfterThinking={isFirstAfterThinking}
suppressNarration={suppressNarration}
/>
);
})}
{showConfirmationQueue && confirmingTool && (
<ToolConfirmationQueue
key="confirmation-queue"
confirmingTool={confirmingTool}
/>
)}
</Box>
),
[
pendingHistoryItems,
uiState.constrainHeight,
availableTerminalHeight,
mainAreaWidth,
showConfirmationQueue,
confirmingTool,
uiState.history,
suppressNarrationFlags,
],
);
if (isAlternateBuffer) {
const virtualizedData = [
...augmentedHistory.map((ah) => ({ type: 'history' as const, ...ah })),
{ type: 'pending' as const },
];
const renderItem = ({
item,
}: {
item: (typeof virtualizedData)[number];
}) => {
if (item.type === 'history') {
return (
<MemoizedHistoryItemDisplay
terminalWidth={mainAreaWidth}
availableTerminalHeight={
uiState.constrainHeight || !item.isExpandable
? staticAreaMaxItemHeight
: undefined
}
availableTerminalHeightGemini={MAX_GEMINI_MESSAGE_LINES}
key={item.item.id}
item={item.item}
isPending={false}
commands={uiState.slashCommands}
isExpandable={item.isExpandable}
isFirstThinking={item.isFirstThinking}
isFirstAfterThinking={item.isFirstAfterThinking}
suppressNarration={item.suppressNarration}
/>
);
} else {
return pendingItems;
}
};
return (
<ScrollableList
ref={scrollableListRef}
hasFocus={!uiState.isEditorDialogOpen && !uiState.embeddedShellFocused}
width={uiState.terminalWidth}
data={virtualizedData}
renderItem={renderItem}
estimatedItemHeight={() => 100}
keyExtractor={(item, _index) => {
if (item.type === 'history') return item.item.id.toString();
return 'pending';
}}
initialScrollIndex={SCROLL_TO_ITEM_END}
initialScrollOffsetInIndex={SCROLL_TO_ITEM_END}
/>
);
}
const staticHistoryItems = historyItems.slice(0, lastUserPromptIndex + 1);
const lastResponseHistoryItems = historyItems.slice(lastUserPromptIndex + 1);
return (
<>
<Static
key={uiState.historyRemountKey}
items={[...staticHistoryItems, ...lastResponseHistoryItems]}
>
{(item) => item}
</Static>
{pendingItems}
</>
);
};