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:
Abhijit Balaji
2026-04-01 18:46:21 -04:00
parent 0d7e778e08
commit 0d50b16f69
13 changed files with 307 additions and 64 deletions
+3 -27
View File
@@ -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} />;
};