diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 7672b9e1c4..097caeeb18 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -374,6 +374,16 @@ const SETTINGS_SCHEMA = { description: 'Hide the window title bar', showInDialog: true, }, + showInlineThinking: { + type: 'boolean', + label: 'Show Inline Thinking', + category: 'UI', + requiresRestart: false, + default: false, + description: + 'Show model thinking summaries inline in the conversation.', + showInDialog: true, + }, showStatusInTitle: { type: 'boolean', label: 'Show Status in Title', diff --git a/packages/cli/src/ui/components/HistoryItemDisplay.test.tsx b/packages/cli/src/ui/components/HistoryItemDisplay.test.tsx index 8488a78dfb..a22fcd20a6 100644 --- a/packages/cli/src/ui/components/HistoryItemDisplay.test.tsx +++ b/packages/cli/src/ui/components/HistoryItemDisplay.test.tsx @@ -207,6 +207,34 @@ describe('', () => { ); }); + describe('thinking items', () => { + it('renders thinking item when enabled', () => { + const item: HistoryItem = { + ...baseItem, + type: 'thinking', + thoughts: [{ subject: 'Thinking', description: 'test' }], + }; + const { lastFrame } = renderWithProviders( + , + ); + + expect(lastFrame()).toContain('Thinking'); + }); + + it('does not render thinking item when disabled', () => { + const item: HistoryItem = { + ...baseItem, + type: 'thinking', + thoughts: [{ subject: 'Thinking', description: 'test' }], + }; + const { lastFrame } = renderWithProviders( + , + ); + + expect(lastFrame()).toBe(''); + }); + }); + describe.each([true, false])( 'gemini items (alternateBuffer=%s)', (useAlternateBuffer) => { diff --git a/packages/cli/src/ui/components/HistoryItemDisplay.tsx b/packages/cli/src/ui/components/HistoryItemDisplay.tsx index 5a7f769402..3f28b83535 100644 --- a/packages/cli/src/ui/components/HistoryItemDisplay.tsx +++ b/packages/cli/src/ui/components/HistoryItemDisplay.tsx @@ -33,6 +33,7 @@ import { McpStatus } from './views/McpStatus.js'; import { ChatList } from './views/ChatList.js'; import { HooksList } from './views/HooksList.js'; import { ModelMessage } from './messages/ModelMessage.js'; +import { ThinkingMessage } from './messages/ThinkingMessage.js'; interface HistoryItemDisplayProps { item: HistoryItem; @@ -44,6 +45,7 @@ interface HistoryItemDisplayProps { activeShellPtyId?: number | null; embeddedShellFocused?: boolean; availableTerminalHeightGemini?: number; + inlineEnabled?: boolean; } export const HistoryItemDisplay: React.FC = ({ @@ -56,12 +58,19 @@ export const HistoryItemDisplay: React.FC = ({ activeShellPtyId, embeddedShellFocused, availableTerminalHeightGemini, + inlineEnabled, }) => { const itemForDisplay = useMemo(() => escapeAnsiCtrlCodes(item), [item]); return ( {/* Render standard message types */} + {itemForDisplay.type === 'thinking' && inlineEnabled && ( + + )} {itemForDisplay.type === 'user' && ( )} diff --git a/packages/cli/src/ui/components/MainContent.tsx b/packages/cli/src/ui/components/MainContent.tsx index a60f782d8f..11c97e53a8 100644 --- a/packages/cli/src/ui/components/MainContent.tsx +++ b/packages/cli/src/ui/components/MainContent.tsx @@ -10,6 +10,7 @@ import { ShowMoreLines } from './ShowMoreLines.js'; import { OverflowProvider } from '../contexts/OverflowContext.js'; import { useUIState } from '../contexts/UIStateContext.js'; import { useAppContext } from '../contexts/AppContext.js'; +import { useSettings } from '../contexts/SettingsContext.js'; import { AppHeader } from './AppHeader.js'; import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js'; import { SCROLL_TO_ITEM_END } from './shared/VirtualizedList.js'; @@ -27,6 +28,7 @@ const MemoizedAppHeader = memo(AppHeader); export const MainContent = () => { const { version } = useAppContext(); const uiState = useUIState(); + const settings = useSettings(); const isAlternateBuffer = useAlternateBuffer(); const { @@ -36,6 +38,8 @@ export const MainContent = () => { availableTerminalHeight, } = uiState; + const inlineEnabled = settings.merged.ui?.showInlineThinking; + const historyItems = uiState.history.map((h) => ( { item={h} isPending={false} commands={uiState.slashCommands} + inlineEnabled={inlineEnabled} /> )); diff --git a/packages/cli/src/ui/components/messages/ThinkingMessage.test.tsx b/packages/cli/src/ui/components/messages/ThinkingMessage.test.tsx new file mode 100644 index 0000000000..18ad73bd07 --- /dev/null +++ b/packages/cli/src/ui/components/messages/ThinkingMessage.test.tsx @@ -0,0 +1,45 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect } from 'vitest'; +import { render } from 'ink-testing-library'; +import { ThinkingMessage } from './ThinkingMessage.js'; + +describe('ThinkingMessage', () => { + it('renders thinking header with count', () => { + const { lastFrame } = render( + , + ); + + expect(lastFrame()).toContain('Thinking'); + expect(lastFrame()).toContain('(2)'); + }); + + it('renders with single thought', () => { + const { lastFrame } = render( + , + ); + + expect(lastFrame()).toContain('(1)'); + }); + + it('renders empty state gracefully', () => { + const { lastFrame } = render( + , + ); + + expect(lastFrame()).toContain('(0)'); + }); +}); diff --git a/packages/cli/src/ui/components/messages/ThinkingMessage.tsx b/packages/cli/src/ui/components/messages/ThinkingMessage.tsx new file mode 100644 index 0000000000..a2eda6c375 --- /dev/null +++ b/packages/cli/src/ui/components/messages/ThinkingMessage.tsx @@ -0,0 +1,33 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { Box, Text } from 'ink'; +import type { ThoughtSummary } from '@google/gemini-cli-core'; + +interface ThinkingMessageProps { + thoughts: ThoughtSummary[]; + terminalWidth: number; +} + +export const ThinkingMessage: React.FC = ({ + thoughts, + terminalWidth, +}) => ( + + + + Thinking + + ({thoughts.length}) + +); diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts index d36d9f57ed..7791541fea 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.ts +++ b/packages/cli/src/ui/hooks/useGeminiStream.ts @@ -46,6 +46,7 @@ import type { HistoryItem, HistoryItemWithoutId, HistoryItemToolGroup, + HistoryItemThinking, SlashCommandProcessorResult, HistoryItemModel, } from '../types.js'; @@ -118,8 +119,29 @@ export const useGeminiStream = ( const activeQueryIdRef = useRef(null); const [isResponding, setIsResponding] = useState(false); const [thought, setThought] = useState(null); + const thoughtsBufferRef = useRef([]); const [pendingHistoryItem, pendingHistoryItemRef, setPendingHistoryItem] = useStateAndRef(null); + + const flushThoughts = useCallback( + (userMessageTimestamp: number) => { + if ( + thoughtsBufferRef.current.length > 0 && + settings.merged.ui?.showInlineThinking + ) { + addItem( + { + type: 'thinking', + thoughts: [...thoughtsBufferRef.current], + } as HistoryItemThinking, + userMessageTimestamp, + ); + thoughtsBufferRef.current = []; + } + }, + [addItem, settings], + ); + const processedMemoryToolsRef = useRef>(new Set()); const { startNewPrompt, getPromptCount } = useSessionStats(); const storage = config.storage; @@ -535,6 +557,7 @@ export const useGeminiStream = ( currentGeminiMessageBuffer: string, userMessageTimestamp: number, ): string => { + flushThoughts(userMessageTimestamp); if (turnCancelledRef.current) { // Prevents additional output after a user initiated cancel. return ''; @@ -584,7 +607,7 @@ export const useGeminiStream = ( } return newGeminiMessageBuffer; }, - [addItem, pendingHistoryItemRef, setPendingHistoryItem], + [addItem, pendingHistoryItemRef, setPendingHistoryItem, flushThoughts], ); const handleUserCancelledEvent = useCallback( @@ -805,6 +828,7 @@ export const useGeminiStream = ( switch (event.type) { case ServerGeminiEventType.Thought: setThought(event.value); + thoughtsBufferRef.current.push(event.value); break; case ServerGeminiEventType.Content: geminiMessageBuffer = handleContentEvent( @@ -814,12 +838,15 @@ export const useGeminiStream = ( ); break; case ServerGeminiEventType.ToolCallRequest: + flushThoughts(userMessageTimestamp); toolCallRequests.push(event.value); break; case ServerGeminiEventType.UserCancelled: + flushThoughts(userMessageTimestamp); handleUserCancelledEvent(userMessageTimestamp); break; case ServerGeminiEventType.Error: + flushThoughts(userMessageTimestamp); handleErrorEvent(event.value, userMessageTimestamp); break; case ServerGeminiEventType.ChatCompressed: @@ -839,6 +866,7 @@ export const useGeminiStream = ( ); break; case ServerGeminiEventType.Finished: + flushThoughts(userMessageTimestamp); handleFinishedEvent(event, userMessageTimestamp); break; case ServerGeminiEventType.Citation: @@ -879,6 +907,7 @@ export const useGeminiStream = ( handleContextWindowWillOverflowEvent, handleCitationEvent, handleChatModelEvent, + flushThoughts, ], ); const submitQuery = useCallback( @@ -943,6 +972,7 @@ export const useGeminiStream = ( } startNewPrompt(); setThought(null); // Reset thought when starting a new prompt + thoughtsBufferRef.current = []; } setIsResponding(true); diff --git a/packages/cli/src/ui/types.ts b/packages/cli/src/ui/types.ts index ede5ab5b84..d3c307978c 100644 --- a/packages/cli/src/ui/types.ts +++ b/packages/cli/src/ui/types.ts @@ -189,6 +189,11 @@ export interface ChatDetail { mtime: string; } +export type HistoryItemThinking = HistoryItemBase & { + type: 'thinking'; + thoughts: ThoughtSummary[]; +}; + export type HistoryItemChatList = HistoryItemBase & { type: 'chat_list'; chats: ChatDetail[]; @@ -299,6 +304,7 @@ export type HistoryItemWithoutId = | HistoryItemSkillsList | HistoryItemMcpStatus | HistoryItemChatList + | HistoryItemThinking | HistoryItemHooksList; export type HistoryItem = HistoryItemWithoutId & { id: number };