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
+46 -3
View File
@@ -22,7 +22,11 @@ import {
} from 'ink';
import { App } from './App.js';
import { AppContext } from './contexts/AppContext.js';
import { UIStateContext, type UIState } from './contexts/UIStateContext.js';
import {
UIStateContext,
type UIState,
type TopicInfo,
} from './contexts/UIStateContext.js';
import {
UIActionsContext,
type UIActions,
@@ -201,6 +205,16 @@ const SHELL_WIDTH_FRACTION = 0.89;
*/
const SHELL_HEIGHT_PADDING = 10;
/**
* Approximate height of the AppHeader (logo, banner, tips).
*/
const APP_HEADER_HEIGHT = 8;
/**
* Standard margin for various UI components.
*/
const UI_MARGIN = 2;
export const AppContainer = (props: AppContainerProps) => {
const isHelpDismissKey = useIsHelpDismissKey();
const keyMatchers = useKeyMatchers();
@@ -237,6 +251,24 @@ export const AppContainer = (props: AppContainerProps) => {
const isBackgroundTaskVisibleRef = useRef<boolean>(false);
const backgroundTasksRef = useRef<Map<number, BackgroundTask>>(new Map());
const [currentTopic, setCurrentTopic] = useState<TopicInfo | null>(() => {
const topic = config.topicState.getTopic();
const summary = config.topicState.getSummary();
return topic || summary ? { title: topic, summary } : null;
});
useEffect(() => {
const handleTopicUpdated = (payload: { title?: string; summary?: string }) => {
setCurrentTopic({ title: payload.title, summary: payload.summary });
refreshStatic();
};
coreEvents.on(CoreEvent.TopicUpdated, handleTopicUpdated);
return () => {
coreEvents.off(CoreEvent.TopicUpdated, handleTopicUpdated);
};
}, [refreshStatic]);
const [adminSettingsChanged, setAdminSettingsChanged] = useState(false);
const [expandedTools, setExpandedTools] = useState<Set<string>>(new Set());
@@ -1461,10 +1493,18 @@ Logging in with Google... Restarting Gemini CLI to continue.
}
}, []);
// Compute available terminal height based on stable controls measurement
// Compute available terminal height based on stable controls measurement.
// We subtract APP_HEADER_HEIGHT and UI_MARGIN as they are now dynamic
// components rendered above MainContent in alt-buffer mode.
// Topic header is now inside the measured controls box.
const availableTerminalHeight = Math.max(
0,
terminalHeight - stableControlsHeight - backgroundTaskHeight - 1,
terminalHeight -
stableControlsHeight -
backgroundTaskHeight -
APP_HEADER_HEIGHT -
UI_MARGIN -
1,
);
config.setShellExecutionConfig({
@@ -2293,8 +2333,10 @@ Logging in with Google... Restarting Gemini CLI to continue.
initError,
pendingGeminiHistoryItems,
thought,
currentTopic,
shellModeActive,
userMessages: inputHistory,
buffer,
inputWidth,
suggestionsWidth,
@@ -2419,6 +2461,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
initError,
pendingGeminiHistoryItems,
thought,
currentTopic,
shellModeActive,
inputHistory,
buffer,
+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} />;
};
@@ -102,6 +102,11 @@ export interface AccountSuspensionInfo {
appealLinkText?: string;
}
export interface TopicInfo {
title?: string;
summary?: string;
}
export interface UIState {
history: HistoryItem[];
historyManager: UseHistoryManagerReturn;
@@ -142,6 +147,7 @@ export interface UIState {
initError: string | null;
pendingGeminiHistoryItems: HistoryItemWithoutId[];
thought: ThoughtSummary | null;
currentTopic: TopicInfo | null;
shellModeActive: boolean;
userMessages: string[];
buffer: TextBuffer;
@@ -10,19 +10,23 @@ import { Notifications } from '../components/Notifications.js';
import { MainContent } from '../components/MainContent.js';
import { DialogManager } from '../components/DialogManager.js';
import { Composer } from '../components/Composer.js';
import { AppHeader } from '../components/AppHeader.js';
import { TopicStickyHeader } from '../components/TopicStickyHeader.js';
import { ExitWarning } from '../components/ExitWarning.js';
import { useUIState } from '../contexts/UIStateContext.js';
import { useFlickerDetector } from '../hooks/useFlickerDetector.js';
import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js';
import { useFlickerDetector } from '../hooks/useFlickerDetector.js';
import { CopyModeWarning } from '../components/CopyModeWarning.js';
import { BackgroundTaskDisplay } from '../components/BackgroundTaskDisplay.js';
import { StreamingState } from '../types.js';
import { useAppContext } from '../contexts/AppContext.js';
export const DefaultAppLayout: React.FC = () => {
const { version } = useAppContext();
const uiState = useUIState();
const isAlternateBuffer = useAlternateBuffer();
const { rootUiRef, terminalHeight } = uiState;
const { rootUiRef, terminalHeight, cleanUiDetailsVisible } = uiState;
useFlickerDetector(rootUiRef, terminalHeight);
// If in alternate buffer mode, need to leave room to draw the scrollbar on
// the right side of the terminal.
@@ -37,6 +41,7 @@ export const DefaultAppLayout: React.FC = () => {
overflow="hidden"
ref={uiState.rootUiRef}
>
<AppHeader version={version} showDetails={cleanUiDetailsVisible} />
<MainContent />
{uiState.isBackgroundTaskVisible &&
@@ -81,6 +86,8 @@ export const DefaultAppLayout: React.FC = () => {
<Composer isFocused={true} />
)}
<TopicStickyHeader />
<ExitWarning />
</Box>
</Box>
@@ -8,16 +8,20 @@ import type React from 'react';
import { Box } from 'ink';
import { Notifications } from '../components/Notifications.js';
import { MainContent } from '../components/MainContent.js';
import { AppHeader } from '../components/AppHeader.js';
import { TopicStickyHeader } from '../components/TopicStickyHeader.js';
import { DialogManager } from '../components/DialogManager.js';
import { Composer } from '../components/Composer.js';
import { Footer } from '../components/Footer.js';
import { ExitWarning } from '../components/ExitWarning.js';
import { useUIState } from '../contexts/UIStateContext.js';
import { useFlickerDetector } from '../hooks/useFlickerDetector.js';
import { useAppContext } from '../contexts/AppContext.js';
export const ScreenReaderAppLayout: React.FC = () => {
const { version } = useAppContext();
const uiState = useUIState();
const { rootUiRef, terminalHeight } = uiState;
const { rootUiRef, terminalHeight, cleanUiDetailsVisible } = uiState;
useFlickerDetector(rootUiRef, terminalHeight);
return (
@@ -27,6 +31,7 @@ export const ScreenReaderAppLayout: React.FC = () => {
height="100%"
ref={uiState.rootUiRef}
>
<AppHeader version={version} showDetails={cleanUiDetailsVisible} />
<Notifications />
<Footer />
<Box flexGrow={1} overflow="hidden">
@@ -41,6 +46,8 @@ export const ScreenReaderAppLayout: React.FC = () => {
<Composer />
)}
<TopicStickyHeader />
<ExitWarning />
</Box>
);