mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-10 10:00:53 -07:00
f790f7f40b
- 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.
271 lines
8.3 KiB
TypeScript
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}
|
|
</>
|
|
);
|
|
};
|