diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 4da8acfdb7..7d4cdfb335 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -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(false); const backgroundTasksRef = useRef>(new Map()); + const [currentTopic, setCurrentTopic] = useState(() => { + 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>(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, diff --git a/packages/cli/src/ui/components/MainContent.tsx b/packages/cli/src/ui/components/MainContent.tsx index c4e395c612..b3733eed13 100644 --- a/packages/cli/src/ui/components/MainContent.tsx +++ b/packages/cli/src/ui/components/MainContent.tsx @@ -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 ( - - ); - } else if (item.type === 'history') { + if (item.type === 'history') { return ( { 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 = () => { <> , - ...staticHistoryItems, - ...lastResponseHistoryItems, - ]} + items={[...staticHistoryItems, ...lastResponseHistoryItems]} > {(item) => item} diff --git a/packages/cli/src/ui/components/TopicStickyHeader.test.tsx b/packages/cli/src/ui/components/TopicStickyHeader.test.tsx new file mode 100644 index 0000000000..a6c73cc999 --- /dev/null +++ b/packages/cli/src/ui/components/TopicStickyHeader.test.tsx @@ -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('', () => { + it('should render nothing when currentTopic is null', async () => { + const uiState = { + currentTopic: null, + terminalWidth: 100, + }; + + const { lastFrame, unmount } = await renderWithProviders( + , + { + 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( + , + { + 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( + , + { + 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( + , + { + 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( + , + { + uiState, + }, + ); + + expect(lastFrame()).toContain('Topic'); + expect(lastFrame()).toContain(': Just a summary'); + unmount(); + }); +}); diff --git a/packages/cli/src/ui/components/TopicStickyHeader.tsx b/packages/cli/src/ui/components/TopicStickyHeader.tsx new file mode 100644 index 0000000000..28efd7dc55 --- /dev/null +++ b/packages/cli/src/ui/components/TopicStickyHeader.tsx @@ -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 ( + + + + ); +}; diff --git a/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx b/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx index ee740787c2..fefde02077 100644 --- a/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx +++ b/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx @@ -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 = ({ ) { 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 = ({ > {isCompact ? ( - ) : isTopicToolCall ? ( - ) : isShellToolCall ? ( ) : ( diff --git a/packages/cli/src/ui/components/messages/TopicMessage.tsx b/packages/cli/src/ui/components/messages/TopicMessage.tsx index 0aea7f5dbd..691b132dd3 100644 --- a/packages/cli/src/ui/components/messages/TopicMessage.tsx +++ b/packages/cli/src/ui/components/messages/TopicMessage.tsx @@ -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 = ({ + title, + summary, + marginLeft = 2, +}) => { + return ( + + + {title || 'Topic'} + {summary && : } + + {summary && ( + + {summary} + + )} + + ); +}; + interface TopicMessageProps extends IndividualToolCallDisplay { terminalWidth: number; } @@ -26,21 +51,8 @@ export const isTopicTool = (name: string): boolean => export const TopicMessage: React.FC = ({ 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 ( - - - {title || 'Topic'} - {intent && : } - - {intent && ( - - {intent} - - )} - - ); + return ; }; diff --git a/packages/cli/src/ui/contexts/UIStateContext.tsx b/packages/cli/src/ui/contexts/UIStateContext.tsx index a5d10820b2..1909a3c156 100644 --- a/packages/cli/src/ui/contexts/UIStateContext.tsx +++ b/packages/cli/src/ui/contexts/UIStateContext.tsx @@ -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; diff --git a/packages/cli/src/ui/layouts/DefaultAppLayout.tsx b/packages/cli/src/ui/layouts/DefaultAppLayout.tsx index aaa9e04632..bcde7c8990 100644 --- a/packages/cli/src/ui/layouts/DefaultAppLayout.tsx +++ b/packages/cli/src/ui/layouts/DefaultAppLayout.tsx @@ -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} > + {uiState.isBackgroundTaskVisible && @@ -81,6 +86,8 @@ export const DefaultAppLayout: React.FC = () => { )} + + diff --git a/packages/cli/src/ui/layouts/ScreenReaderAppLayout.tsx b/packages/cli/src/ui/layouts/ScreenReaderAppLayout.tsx index d88f3f1fb2..3255b73253 100644 --- a/packages/cli/src/ui/layouts/ScreenReaderAppLayout.tsx +++ b/packages/cli/src/ui/layouts/ScreenReaderAppLayout.tsx @@ -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} > +