mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-12 19:10:55 -07:00
feat(ui): implement sticky topic header below the prompt (checkpoint)
This commit saves the current state of the sticky topic header implementation. - Introduced 'TopicUpdated' event to CoreEventEmitter to track topic changes. - Updated TopicState to store topic summaries and emit update events. - Refactored TopicMessage into a shared TopicDisplay component for UI consistency. - Implemented TopicStickyHeader to persistently show the current topic and summary. - Positioned the sticky header immediately below the prompt (Composer) in both Default and ScreenReader layouts. - Removed redundant topic messages from the chat history to prevent duplicate display. - Adjusted terminal height calculations to account for the new persistent UI element. - Added unit tests for the new sticky header component and updated core tests. - Addressed some React Hook dependency warnings in AppContainer and MainContent.
This commit is contained in:
@@ -9,7 +9,6 @@ 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 {
|
||||
@@ -24,7 +23,6 @@ import { ToolConfirmationQueue } from './ToolConfirmationQueue.js';
|
||||
import { isTopicTool } from './messages/TopicMessage.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.
|
||||
@@ -217,7 +215,6 @@ export const MainContent = () => {
|
||||
|
||||
const virtualizedData = useMemo(
|
||||
() => [
|
||||
{ type: 'header' as const },
|
||||
...augmentedHistory.map(
|
||||
({
|
||||
item,
|
||||
@@ -241,15 +238,7 @@ export const MainContent = () => {
|
||||
|
||||
const renderItem = useCallback(
|
||||
({ item }: { item: (typeof virtualizedData)[number] }) => {
|
||||
if (item.type === 'header') {
|
||||
return (
|
||||
<MemoizedAppHeader
|
||||
key="app-header"
|
||||
version={version}
|
||||
showDetails={showHeaderDetails}
|
||||
/>
|
||||
);
|
||||
} else if (item.type === 'history') {
|
||||
if (item.type === 'history') {
|
||||
return (
|
||||
<MemoizedHistoryItemDisplay
|
||||
terminalWidth={mainAreaWidth}
|
||||
@@ -273,15 +262,7 @@ export const MainContent = () => {
|
||||
return pendingItems;
|
||||
}
|
||||
},
|
||||
[
|
||||
showHeaderDetails,
|
||||
version,
|
||||
mainAreaWidth,
|
||||
uiState.slashCommands,
|
||||
pendingItems,
|
||||
uiState.constrainHeight,
|
||||
staticAreaMaxItemHeight,
|
||||
],
|
||||
[mainAreaWidth, uiState.slashCommands, pendingItems, uiState.constrainHeight, staticAreaMaxItemHeight]
|
||||
);
|
||||
|
||||
if (isAlternateBuffer) {
|
||||
@@ -294,7 +275,6 @@ export const MainContent = () => {
|
||||
renderItem={renderItem}
|
||||
estimatedItemHeight={() => 100}
|
||||
keyExtractor={(item, _index) => {
|
||||
if (item.type === 'header') return 'header';
|
||||
if (item.type === 'history') return item.item.id.toString();
|
||||
return 'pending';
|
||||
}}
|
||||
@@ -308,11 +288,7 @@ export const MainContent = () => {
|
||||
<>
|
||||
<Static
|
||||
key={uiState.historyRemountKey}
|
||||
items={[
|
||||
<AppHeader key="app-header" version={version} />,
|
||||
...staticHistoryItems,
|
||||
...lastResponseHistoryItems,
|
||||
]}
|
||||
items={[...staticHistoryItems, ...lastResponseHistoryItems]}
|
||||
>
|
||||
{(item) => item}
|
||||
</Static>
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { renderWithProviders } from '../../test-utils/render.js';
|
||||
import { TopicStickyHeader } from './TopicStickyHeader.js';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
describe('<TopicStickyHeader />', () => {
|
||||
it('should render nothing when currentTopic is null', async () => {
|
||||
const uiState = {
|
||||
currentTopic: null,
|
||||
terminalWidth: 100,
|
||||
};
|
||||
|
||||
const { lastFrame, unmount } = await renderWithProviders(
|
||||
<TopicStickyHeader />,
|
||||
{
|
||||
uiState,
|
||||
},
|
||||
);
|
||||
|
||||
expect(lastFrame({ allowEmpty: true })).toBe('');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should render nothing when currentTopic has no title and no summary', async () => {
|
||||
const uiState = {
|
||||
currentTopic: { title: undefined, summary: undefined },
|
||||
terminalWidth: 100,
|
||||
};
|
||||
|
||||
const { lastFrame, unmount } = await renderWithProviders(
|
||||
<TopicStickyHeader />,
|
||||
{
|
||||
uiState,
|
||||
},
|
||||
);
|
||||
|
||||
expect(lastFrame({ allowEmpty: true })).toBe('');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should render topic title', async () => {
|
||||
const uiState = {
|
||||
currentTopic: { title: 'My Awesome Topic' },
|
||||
terminalWidth: 100,
|
||||
};
|
||||
|
||||
const { lastFrame, unmount } = await renderWithProviders(
|
||||
<TopicStickyHeader />,
|
||||
{
|
||||
uiState,
|
||||
},
|
||||
);
|
||||
|
||||
expect(lastFrame()).toContain('My Awesome Topic');
|
||||
expect(lastFrame()).not.toContain('Topic:');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should render topic title and summary', async () => {
|
||||
const uiState = {
|
||||
currentTopic: {
|
||||
title: 'My Awesome Topic',
|
||||
summary: 'This is a brief summary'
|
||||
},
|
||||
terminalWidth: 100,
|
||||
};
|
||||
|
||||
const { lastFrame, unmount } = await renderWithProviders(
|
||||
<TopicStickyHeader />,
|
||||
{
|
||||
uiState,
|
||||
},
|
||||
);
|
||||
|
||||
expect(lastFrame()).toContain('My Awesome Topic');
|
||||
expect(lastFrame()).toContain(': This is a brief summary');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should render default title when only summary is present', async () => {
|
||||
const uiState = {
|
||||
currentTopic: {
|
||||
summary: 'Just a summary'
|
||||
},
|
||||
terminalWidth: 100,
|
||||
};
|
||||
|
||||
const { lastFrame, unmount } = await renderWithProviders(
|
||||
<TopicStickyHeader />,
|
||||
{
|
||||
uiState,
|
||||
},
|
||||
);
|
||||
|
||||
expect(lastFrame()).toContain('Topic');
|
||||
expect(lastFrame()).toContain(': Just a summary');
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { Box } from 'ink';
|
||||
import { useUIState } from '../contexts/UIStateContext.js';
|
||||
import { TopicDisplay } from './messages/TopicMessage.js';
|
||||
|
||||
export const TOPIC_STICKY_HEADER_HEIGHT = 2;
|
||||
|
||||
export const TopicStickyHeader: React.FC = () => {
|
||||
const { currentTopic } = useUIState();
|
||||
|
||||
if (!currentTopic || (!currentTopic.title && !currentTopic.summary)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
<TopicDisplay
|
||||
title={currentTopic.title}
|
||||
summary={currentTopic.summary}
|
||||
marginLeft={2}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -15,7 +15,7 @@ import type {
|
||||
import { ToolCallStatus, mapCoreStatusToDisplayStatus } from '../../types.js';
|
||||
import { ToolMessage } from './ToolMessage.js';
|
||||
import { ShellToolMessage } from './ShellToolMessage.js';
|
||||
import { TopicMessage, isTopicTool } from './TopicMessage.js';
|
||||
import { isTopicTool } from './TopicMessage.js';
|
||||
import { SubagentGroupDisplay } from './SubagentGroupDisplay.js';
|
||||
import { DenseToolMessage } from './DenseToolMessage.js';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
@@ -143,7 +143,14 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Hide topic tools from history as they are now sticky headers
|
||||
if (isTopicTool(t.name)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Standard hiding logic (e.g. Plan Mode internal edits)
|
||||
|
||||
if (
|
||||
shouldHideToolCall({
|
||||
displayName: t.name,
|
||||
@@ -458,8 +465,6 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
|
||||
>
|
||||
{isCompact ? (
|
||||
<DenseToolMessage {...commonProps} />
|
||||
) : isTopicToolCall ? (
|
||||
<TopicMessage {...commonProps} />
|
||||
) : isShellToolCall ? (
|
||||
<ShellToolMessage {...commonProps} config={config} />
|
||||
) : (
|
||||
|
||||
@@ -11,11 +11,36 @@ import {
|
||||
UPDATE_TOPIC_DISPLAY_NAME,
|
||||
TOPIC_PARAM_TITLE,
|
||||
TOPIC_PARAM_SUMMARY,
|
||||
TOPIC_PARAM_STRATEGIC_INTENT,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type { IndividualToolCallDisplay } from '../../types.js';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
|
||||
interface TopicDisplayProps {
|
||||
title?: string;
|
||||
summary?: string;
|
||||
marginLeft?: number;
|
||||
}
|
||||
|
||||
export const TopicDisplay: React.FC<TopicDisplayProps> = ({
|
||||
title,
|
||||
summary,
|
||||
marginLeft = 2,
|
||||
}) => {
|
||||
return (
|
||||
<Box flexDirection="row" marginLeft={marginLeft} flexWrap="wrap">
|
||||
<Text color={theme.text.primary} bold wrap="wrap">
|
||||
{title || 'Topic'}
|
||||
{summary && <Text>: </Text>}
|
||||
</Text>
|
||||
{summary && (
|
||||
<Text color={theme.text.secondary} wrap="wrap">
|
||||
{summary}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
interface TopicMessageProps extends IndividualToolCallDisplay {
|
||||
terminalWidth: number;
|
||||
}
|
||||
@@ -26,21 +51,8 @@ export const isTopicTool = (name: string): boolean =>
|
||||
export const TopicMessage: React.FC<TopicMessageProps> = ({ args }) => {
|
||||
const rawTitle = args?.[TOPIC_PARAM_TITLE];
|
||||
const title = typeof rawTitle === 'string' ? rawTitle : undefined;
|
||||
const rawIntent =
|
||||
args?.[TOPIC_PARAM_STRATEGIC_INTENT] || args?.[TOPIC_PARAM_SUMMARY];
|
||||
const intent = typeof rawIntent === 'string' ? rawIntent : undefined;
|
||||
const rawSummary = args?.[TOPIC_PARAM_SUMMARY];
|
||||
const summary = typeof rawSummary === 'string' ? rawSummary : undefined;
|
||||
|
||||
return (
|
||||
<Box flexDirection="row" marginLeft={2} flexWrap="wrap">
|
||||
<Text color={theme.text.primary} bold wrap="truncate-end">
|
||||
{title || 'Topic'}
|
||||
{intent && <Text>: </Text>}
|
||||
</Text>
|
||||
{intent && (
|
||||
<Text color={theme.text.secondary} wrap="wrap">
|
||||
{intent}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
return <TopicDisplay title={title} summary={summary} />;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user