diff --git a/docs/cli/settings.md b/docs/cli/settings.md index 9a60f89a53..07e8c986c6 100644 --- a/docs/cli/settings.md +++ b/docs/cli/settings.md @@ -43,6 +43,7 @@ they appear in the UI. | Auto Theme Switching | `ui.autoThemeSwitching` | Automatically switch between default light and dark themes based on terminal background color. | `true` | | Terminal Background Polling Interval | `ui.terminalBackgroundPollingInterval` | Interval in seconds to poll the terminal background color. | `60` | | Hide Window Title | `ui.hideWindowTitle` | Hide the window title bar | `false` | +| Inline Thinking | `ui.inlineThinkingMode` | Display model thinking inline: off or full. | `"off"` | | Show Thoughts in Title | `ui.showStatusInTitle` | Show Gemini CLI model thoughts in the terminal window title during the working phase | `false` | | Dynamic Window Title | `ui.dynamicWindowTitle` | Update the terminal window title with current status icons (Ready: ◇, Action Required: ✋, Working: ✦) | `true` | | Show Home Directory Warning | `ui.showHomeDirectoryWarning` | Show a warning when running Gemini CLI in the home directory. | `true` | diff --git a/docs/get-started/configuration.md b/docs/get-started/configuration.md index 28578ae364..33016840c7 100644 --- a/docs/get-started/configuration.md +++ b/docs/get-started/configuration.md @@ -188,6 +188,11 @@ their corresponding top-level category object in your `settings.json` file. - **Default:** `false` - **Requires restart:** Yes +- **`ui.inlineThinkingMode`** (enum): + - **Description:** Display model thinking inline: off or full. + - **Default:** `"off"` + - **Values:** `"off"`, `"full"` + - **`ui.showStatusInTitle`** (boolean): - **Description:** Show Gemini CLI model thoughts in the terminal window title during the working phase diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 2e53997a5d..1948960ac3 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -392,6 +392,19 @@ const SETTINGS_SCHEMA = { description: 'Hide the window title bar', showInDialog: true, }, + inlineThinkingMode: { + type: 'enum', + label: 'Inline Thinking', + category: 'UI', + requiresRestart: false, + default: 'off', + description: 'Display model thinking inline: off or full.', + showInDialog: true, + options: [ + { value: 'off', label: 'Off' }, + { value: 'full', label: 'Full' }, + ], + }, showStatusInTitle: { type: 'boolean', label: 'Show Thoughts in Title', diff --git a/packages/cli/src/test-utils/render.tsx b/packages/cli/src/test-utils/render.tsx index 6b013c16fb..01eb25cb50 100644 --- a/packages/cli/src/test-utils/render.tsx +++ b/packages/cli/src/test-utils/render.tsx @@ -44,6 +44,7 @@ vi.mock('../ui/utils/terminalUtils.js', () => ({ isLowColorDepth: vi.fn(() => false), getColorDepth: vi.fn(() => 24), isITerm2: vi.fn(() => false), + shouldUseEmoji: vi.fn(() => true), })); // Wrapper around ink-testing-library's render that ensures act() is called diff --git a/packages/cli/src/ui/components/AlternateBufferQuittingDisplay.tsx b/packages/cli/src/ui/components/AlternateBufferQuittingDisplay.tsx index fec35d46c3..bc54fd72db 100644 --- a/packages/cli/src/ui/components/AlternateBufferQuittingDisplay.tsx +++ b/packages/cli/src/ui/components/AlternateBufferQuittingDisplay.tsx @@ -6,6 +6,7 @@ import { Box, Text } from 'ink'; import { useUIState } from '../contexts/UIStateContext.js'; +import { useSettings } from '../contexts/SettingsContext.js'; import { AppHeader } from './AppHeader.js'; import { HistoryItemDisplay } from './HistoryItemDisplay.js'; import { QuittingDisplay } from './QuittingDisplay.js'; @@ -15,15 +16,18 @@ import { useConfirmingTool } from '../hooks/useConfirmingTool.js'; import { useConfig } from '../contexts/ConfigContext.js'; import { ToolStatusIndicator, ToolInfo } from './messages/ToolShared.js'; import { theme } from '../semantic-colors.js'; +import { getInlineThinkingMode } from '../utils/inlineThinkingMode.js'; export const AlternateBufferQuittingDisplay = () => { const { version } = useAppContext(); const uiState = useUIState(); + const settings = useSettings(); const config = useConfig(); const confirmingTool = useConfirmingTool(); const showPromptedTool = config.isEventDrivenSchedulerEnabled() && confirmingTool !== null; + const inlineThinkingMode = getInlineThinkingMode(settings); // We render the entire chat history and header here to ensure that the // conversation history is visible to the user after the app quits and the @@ -47,6 +51,7 @@ export const AlternateBufferQuittingDisplay = () => { item={h} isPending={false} commands={uiState.slashCommands} + inlineThinkingMode={inlineThinkingMode} /> ))} {uiState.pendingHistoryItems.map((item, i) => ( @@ -59,6 +64,7 @@ export const AlternateBufferQuittingDisplay = () => { isFocused={false} activeShellPtyId={uiState.activePtyId} embeddedShellFocused={uiState.embeddedShellFocused} + inlineThinkingMode={inlineThinkingMode} /> ))} {showPromptedTool && ( diff --git a/packages/cli/src/ui/components/Composer.test.tsx b/packages/cli/src/ui/components/Composer.test.tsx index 2e59d78772..72f1bc784b 100644 --- a/packages/cli/src/ui/components/Composer.test.tsx +++ b/packages/cli/src/ui/components/Composer.test.tsx @@ -31,9 +31,18 @@ import type { SessionMetrics } from '../contexts/SessionContext.js'; // Mock child components vi.mock('./LoadingIndicator.js', () => ({ - LoadingIndicator: ({ thought }: { thought?: string }) => ( - LoadingIndicator{thought ? `: ${thought}` : ''} - ), + LoadingIndicator: ({ + thought, + thoughtLabel, + }: { + thought?: { subject?: string } | string; + thoughtLabel?: string; + }) => { + const fallbackText = + typeof thought === 'string' ? thought : thought?.subject; + const text = thoughtLabel ?? fallbackText; + return LoadingIndicator{text ? `: ${text}` : ''}; + }, })); vi.mock('./ContextSummaryDisplay.js', () => ({ @@ -287,7 +296,25 @@ describe('Composer', () => { const { lastFrame } = renderComposer(uiState); const output = lastFrame(); - expect(output).toContain('LoadingIndicator'); + expect(output).toContain('LoadingIndicator: Processing'); + }); + + it('renders generic thinking text in loading indicator when full inline thinking is enabled', () => { + const uiState = createMockUIState({ + streamingState: StreamingState.Responding, + thought: { + subject: 'Detailed in-history thought', + description: 'Full text is already in history', + }, + }); + const settings = createMockSettings({ + ui: { inlineThinkingMode: 'full' }, + }); + + const { lastFrame } = renderComposer(uiState, settings); + + const output = lastFrame(); + expect(output).toContain('LoadingIndicator: Thinking ...'); }); it('keeps shortcuts hint visible while loading', () => { diff --git a/packages/cli/src/ui/components/Composer.tsx b/packages/cli/src/ui/components/Composer.tsx index 4ccca33e4f..fb9a274cd0 100644 --- a/packages/cli/src/ui/components/Composer.tsx +++ b/packages/cli/src/ui/components/Composer.tsx @@ -30,6 +30,7 @@ import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js'; import { StreamingState, ToolCallStatus } from '../types.js'; import { ConfigInitDisplay } from '../components/ConfigInitDisplay.js'; import { TodoTray } from './messages/Todo.js'; +import { getInlineThinkingMode } from '../utils/inlineThinkingMode.js'; export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => { const config = useConfig(); @@ -38,6 +39,7 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => { const uiState = useUIState(); const uiActions = useUIActions(); const { vimEnabled, vimMode } = useVimMode(); + const inlineThinkingMode = getInlineThinkingMode(settings); const terminalWidth = process.stdout.columns; const isNarrow = isNarrowWidth(terminalWidth); const debugConsoleMaxHeight = Math.floor(Math.max(terminalWidth * 0.2, 5)); @@ -117,6 +119,9 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => { ? undefined : uiState.currentLoadingPhrase } + thoughtLabel={ + inlineThinkingMode === 'full' ? 'Thinking ...' : undefined + } elapsedTime={uiState.elapsedTime} /> )} diff --git a/packages/cli/src/ui/components/HistoryItemDisplay.test.tsx b/packages/cli/src/ui/components/HistoryItemDisplay.test.tsx index 1aecb9a0ba..40c71fe327 100644 --- a/packages/cli/src/ui/components/HistoryItemDisplay.test.tsx +++ b/packages/cli/src/ui/components/HistoryItemDisplay.test.tsx @@ -232,6 +232,42 @@ describe('', () => { ); }); + describe('thinking items', () => { + it('renders thinking item when enabled', () => { + const item: HistoryItem = { + ...baseItem, + type: 'thinking', + thought: { 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', + thought: { 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 d7cd56d7cf..a5ee265f64 100644 --- a/packages/cli/src/ui/components/HistoryItemDisplay.tsx +++ b/packages/cli/src/ui/components/HistoryItemDisplay.tsx @@ -34,6 +34,8 @@ 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'; +import type { InlineThinkingMode } from '../utils/inlineThinkingMode.js'; interface HistoryItemDisplayProps { item: HistoryItem; @@ -45,6 +47,7 @@ interface HistoryItemDisplayProps { activeShellPtyId?: number | null; embeddedShellFocused?: boolean; availableTerminalHeightGemini?: number; + inlineThinkingMode?: InlineThinkingMode; } export const HistoryItemDisplay: React.FC = ({ @@ -57,12 +60,19 @@ export const HistoryItemDisplay: React.FC = ({ activeShellPtyId, embeddedShellFocused, availableTerminalHeightGemini, + inlineThinkingMode = 'off', }) => { const itemForDisplay = useMemo(() => escapeAnsiCtrlCodes(item), [item]); return ( {/* Render standard message types */} + {itemForDisplay.type === 'thinking' && inlineThinkingMode !== 'off' && ( + + )} {itemForDisplay.type === 'user' && ( )} diff --git a/packages/cli/src/ui/components/LoadingIndicator.test.tsx b/packages/cli/src/ui/components/LoadingIndicator.test.tsx index e76c4d49f3..3c13df6e41 100644 --- a/packages/cli/src/ui/components/LoadingIndicator.test.tsx +++ b/packages/cli/src/ui/components/LoadingIndicator.test.tsx @@ -12,6 +12,7 @@ import { StreamingContext } from '../contexts/StreamingContext.js'; import { StreamingState } from '../types.js'; import { vi } from 'vitest'; import * as useTerminalSize from '../hooks/useTerminalSize.js'; +import * as terminalUtils from '../utils/terminalUtils.js'; // Mock GeminiRespondingSpinner vi.mock('./GeminiRespondingSpinner.js', () => ({ @@ -34,7 +35,12 @@ vi.mock('../hooks/useTerminalSize.js', () => ({ useTerminalSize: vi.fn(), })); +vi.mock('../utils/terminalUtils.js', () => ({ + shouldUseEmoji: vi.fn(() => true), +})); + const useTerminalSizeMock = vi.mocked(useTerminalSize.useTerminalSize); +const shouldUseEmojiMock = vi.mocked(terminalUtils.shouldUseEmoji); const renderWithContext = ( ui: React.ReactElement, @@ -217,12 +223,33 @@ describe('', () => { const output = lastFrame(); expect(output).toBeDefined(); if (output) { + expect(output).toContain('💬'); expect(output).toContain('Thinking about something...'); expect(output).not.toContain('and other stuff.'); } unmount(); }); + it('should use ASCII fallback thought indicator when emoji is unavailable', () => { + shouldUseEmojiMock.mockReturnValue(false); + const props = { + thought: { + subject: 'Thinking with fallback', + description: 'details', + }, + elapsedTime: 5, + }; + const { lastFrame, unmount } = renderWithContext( + , + StreamingState.Responding, + ); + const output = lastFrame(); + expect(output).toContain('o Thinking with fallback'); + expect(output).not.toContain('💬'); + shouldUseEmojiMock.mockReturnValue(true); + unmount(); + }); + it('should prioritize thought.subject over currentLoadingPhrase', () => { const props = { thought: { @@ -237,11 +264,24 @@ describe('', () => { StreamingState.Responding, ); const output = lastFrame(); + expect(output).toContain('💬'); expect(output).toContain('This should be displayed'); expect(output).not.toContain('This should not be displayed'); unmount(); }); + it('should not display thought icon for non-thought loading phrases', () => { + const { lastFrame, unmount } = renderWithContext( + , + StreamingState.Responding, + ); + expect(lastFrame()).not.toContain('💬'); + unmount(); + }); + it('should truncate long primary text instead of wrapping', () => { const { lastFrame, unmount } = renderWithContext( = ({ inline = false, rightContent, thought, + thoughtLabel, showCancelAndTimer = true, }) => { const streamingState = useStreamingContext(); @@ -50,7 +53,15 @@ export const LoadingIndicator: React.FC = ({ const primaryText = currentLoadingPhrase === INTERACTIVE_SHELL_WAITING_PHRASE ? currentLoadingPhrase - : thought?.subject || currentLoadingPhrase; + : thought?.subject + ? (thoughtLabel ?? thought.subject) + : currentLoadingPhrase; + const hasThoughtIndicator = + currentLoadingPhrase !== INTERACTIVE_SHELL_WAITING_PHRASE && + Boolean(thought?.subject?.trim()); + const thinkingIndicator = hasThoughtIndicator + ? `${shouldUseEmoji() ? '💬' : 'o'} ` + : ''; const cancelAndTimerContent = showCancelAndTimer && @@ -72,6 +83,7 @@ export const LoadingIndicator: React.FC = ({ {primaryText && ( + {thinkingIndicator} {primaryText} )} @@ -105,6 +117,7 @@ export const LoadingIndicator: React.FC = ({ {primaryText && ( + {thinkingIndicator} {primaryText} )} diff --git a/packages/cli/src/ui/components/MainContent.test.tsx b/packages/cli/src/ui/components/MainContent.test.tsx index 0445b11b4b..3a9e363d69 100644 --- a/packages/cli/src/ui/components/MainContent.test.tsx +++ b/packages/cli/src/ui/components/MainContent.test.tsx @@ -16,6 +16,20 @@ import { SHELL_COMMAND_NAME } from '../constants.js'; import type { UIState } from '../contexts/UIStateContext.js'; // Mock dependencies +vi.mock('../contexts/SettingsContext.js', async () => { + const actual = await vi.importActual('../contexts/SettingsContext.js'); + return { + ...actual, + useSettings: () => ({ + merged: { + ui: { + inlineThinkingMode: 'off', + }, + }, + }), + }; +}); + vi.mock('../contexts/AppContext.js', async () => { const actual = await vi.importActual('../contexts/AppContext.js'); return { @@ -68,6 +82,7 @@ describe('MainContent', () => { availableTerminalHeight: 24, slashCommands: [], constrainHeight: false, + thought: null, isEditorDialogOpen: false, activePtyId: undefined, embeddedShellFocused: false, @@ -185,6 +200,7 @@ describe('MainContent', () => { terminalHeight: 50, terminalWidth: 100, mainAreaWidth: 100, + thought: null, embeddedShellFocused, activePtyId: embeddedShellFocused ? ptyId : undefined, constrainHeight, diff --git a/packages/cli/src/ui/components/MainContent.tsx b/packages/cli/src/ui/components/MainContent.tsx index 32c70e8cad..c8007df110 100644 --- a/packages/cli/src/ui/components/MainContent.tsx +++ b/packages/cli/src/ui/components/MainContent.tsx @@ -8,6 +8,7 @@ import { Box, Static } from 'ink'; import { HistoryItemDisplay } from './HistoryItemDisplay.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 { @@ -20,6 +21,7 @@ import { MAX_GEMINI_MESSAGE_LINES } from '../constants.js'; import { useConfirmingTool } from '../hooks/useConfirmingTool.js'; import { ToolConfirmationQueue } from './ToolConfirmationQueue.js'; import { useConfig } from '../contexts/ConfigContext.js'; +import { getInlineThinkingMode } from '../utils/inlineThinkingMode.js'; const MemoizedHistoryItemDisplay = memo(HistoryItemDisplay); const MemoizedAppHeader = memo(AppHeader); @@ -31,6 +33,7 @@ const MemoizedAppHeader = memo(AppHeader); export const MainContent = () => { const { version } = useAppContext(); const uiState = useUIState(); + const settings = useSettings(); const config = useConfig(); const isAlternateBuffer = useAlternateBuffer(); @@ -53,6 +56,8 @@ export const MainContent = () => { availableTerminalHeight, } = uiState; + const inlineThinkingMode = getInlineThinkingMode(settings); + const historyItems = useMemo( () => uiState.history.map((h) => ( @@ -64,6 +69,7 @@ export const MainContent = () => { item={h} isPending={false} commands={uiState.slashCommands} + inlineThinkingMode={inlineThinkingMode} /> )), [ @@ -71,6 +77,7 @@ export const MainContent = () => { mainAreaWidth, staticAreaMaxItemHeight, uiState.slashCommands, + inlineThinkingMode, ], ); @@ -92,6 +99,7 @@ export const MainContent = () => { isFocused={!uiState.isEditorDialogOpen} activeShellPtyId={uiState.activePtyId} embeddedShellFocused={uiState.embeddedShellFocused} + inlineThinkingMode={inlineThinkingMode} /> ))} {showConfirmationQueue && confirmingTool && ( @@ -105,6 +113,7 @@ export const MainContent = () => { isAlternateBuffer, availableTerminalHeight, mainAreaWidth, + inlineThinkingMode, uiState.isEditorDialogOpen, uiState.activePtyId, uiState.embeddedShellFocused, @@ -136,13 +145,20 @@ export const MainContent = () => { item={item.item} isPending={false} commands={uiState.slashCommands} + inlineThinkingMode={inlineThinkingMode} /> ); } else { return pendingItems; } }, - [version, mainAreaWidth, uiState.slashCommands, pendingItems], + [ + version, + mainAreaWidth, + uiState.slashCommands, + inlineThinkingMode, + pendingItems, + ], ); if (isAlternateBuffer) { diff --git a/packages/cli/src/ui/components/QuittingDisplay.test.tsx b/packages/cli/src/ui/components/QuittingDisplay.test.tsx index 79cc7e5d7b..dea08fd6bb 100644 --- a/packages/cli/src/ui/components/QuittingDisplay.test.tsx +++ b/packages/cli/src/ui/components/QuittingDisplay.test.tsx @@ -12,6 +12,15 @@ import { useUIState, type UIState } from '../contexts/UIStateContext.js'; import { useTerminalSize } from '../hooks/useTerminalSize.js'; vi.mock('../contexts/UIStateContext.js'); +vi.mock('../contexts/SettingsContext.js', () => ({ + useSettings: () => ({ + merged: { + ui: { + inlineThinkingMode: 'off', + }, + }, + }), +})); vi.mock('../hooks/useTerminalSize.js'); vi.mock('./HistoryItemDisplay.js', async () => { const { Text } = await vi.importActual('ink'); diff --git a/packages/cli/src/ui/components/QuittingDisplay.tsx b/packages/cli/src/ui/components/QuittingDisplay.tsx index ee81f92012..407b970ed7 100644 --- a/packages/cli/src/ui/components/QuittingDisplay.tsx +++ b/packages/cli/src/ui/components/QuittingDisplay.tsx @@ -6,14 +6,18 @@ import { Box } from 'ink'; import { useUIState } from '../contexts/UIStateContext.js'; +import { useSettings } from '../contexts/SettingsContext.js'; import { HistoryItemDisplay } from './HistoryItemDisplay.js'; import { useTerminalSize } from '../hooks/useTerminalSize.js'; +import { getInlineThinkingMode } from '../utils/inlineThinkingMode.js'; export const QuittingDisplay = () => { const uiState = useUIState(); + const settings = useSettings(); const { rows: terminalHeight, columns: terminalWidth } = useTerminalSize(); const availableTerminalHeight = terminalHeight; + const inlineThinkingMode = getInlineThinkingMode(settings); if (!uiState.quittingMessages) { return null; @@ -30,6 +34,7 @@ export const QuittingDisplay = () => { terminalWidth={terminalWidth} item={item} isPending={false} + inlineThinkingMode={inlineThinkingMode} /> ))} diff --git a/packages/cli/src/ui/components/ToolConfirmationQueue.tsx b/packages/cli/src/ui/components/ToolConfirmationQueue.tsx index e68affbf5e..f6fd51ae32 100644 --- a/packages/cli/src/ui/components/ToolConfirmationQueue.tsx +++ b/packages/cli/src/ui/components/ToolConfirmationQueue.tsx @@ -140,7 +140,7 @@ export const ToolConfirmationQueue: React.FC = ({ /> { + it('renders subject line', () => { + const { lastFrame } = renderWithProviders( + , + ); + + expect(lastFrame()).toContain('Planning'); + }); + + it('uses description when subject is empty', () => { + const { lastFrame } = renderWithProviders( + , + ); + + expect(lastFrame()).toContain('Processing details'); + }); + + it('renders full mode with left vertical rule and full text', () => { + const { lastFrame } = renderWithProviders( + , + ); + + expect(lastFrame()).toContain('│'); + expect(lastFrame()).not.toContain('┌'); + expect(lastFrame()).not.toContain('┐'); + expect(lastFrame()).not.toContain('└'); + expect(lastFrame()).not.toContain('┘'); + expect(lastFrame()).toContain('Planning'); + expect(lastFrame()).toContain('I am planning the solution.'); + }); + + it('starts left rule below the bold summary line in full mode', () => { + const { lastFrame } = renderWithProviders( + , + ); + + const lines = (lastFrame() ?? '').split('\n'); + expect(lines[0] ?? '').toContain('Summary line'); + expect(lines[0] ?? '').not.toContain('│'); + expect(lines.slice(1).join('\n')).toContain('│'); + }); + + it('normalizes escaped newline tokens so literal \\n\\n is not shown', () => { + const { lastFrame } = renderWithProviders( + , + ); + + expect(lastFrame()).toContain('Matching the Blocks'); + expect(lastFrame()).not.toContain('\\n\\n'); + }); + + it('renders empty state gracefully', () => { + const { lastFrame } = renderWithProviders( + , + ); + + expect(lastFrame()).not.toContain('Planning'); + }); +}); 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..f23addb0d7 --- /dev/null +++ b/packages/cli/src/ui/components/messages/ThinkingMessage.tsx @@ -0,0 +1,171 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { useMemo } from 'react'; +import { Box, Text } from 'ink'; +import type { ThoughtSummary } from '@google/gemini-cli-core'; +import { theme } from '../../semantic-colors.js'; + +interface ThinkingMessageProps { + thought: ThoughtSummary; + terminalWidth: number; +} + +const THINKING_LEFT_PADDING = 1; + +function splitGraphemes(value: string): string[] { + if (typeof Intl !== 'undefined' && 'Segmenter' in Intl) { + const segmenter = new Intl.Segmenter(undefined, { + granularity: 'grapheme', + }); + return Array.from(segmenter.segment(value), (segment) => segment.segment); + } + + return Array.from(value); +} + +function normalizeEscapedNewlines(value: string): string { + return value.replace(/\\r\\n/g, '\n').replace(/\\n/g, '\n'); +} + +function normalizeThoughtLines(thought: ThoughtSummary): string[] { + const subject = normalizeEscapedNewlines(thought.subject).trim(); + const description = normalizeEscapedNewlines(thought.description).trim(); + + if (!subject && !description) { + return []; + } + + if (!subject) { + return description + .split('\n') + .map((line) => line.trim()) + .filter(Boolean); + } + + const bodyLines = description + .split('\n') + .map((line) => line.trim()) + .filter(Boolean); + return [subject, ...bodyLines]; +} + +function graphemeLength(value: string): number { + return splitGraphemes(value).length; +} + +function chunkToWidth(value: string, width: number): string[] { + if (width <= 0) { + return ['']; + } + + const graphemes = splitGraphemes(value); + if (graphemes.length === 0) { + return ['']; + } + + const chunks: string[] = []; + for (let index = 0; index < graphemes.length; index += width) { + chunks.push(graphemes.slice(index, index + width).join('')); + } + return chunks; +} + +function wrapLineToWidth(line: string, width: number): string[] { + if (width <= 0) { + return ['']; + } + + const normalized = line.trim(); + if (!normalized) { + return ['']; + } + + const words = normalized.split(/\s+/); + const wrapped: string[] = []; + let current = ''; + + for (const word of words) { + const wordChunks = chunkToWidth(word, width); + + for (const wordChunk of wordChunks) { + if (!current) { + current = wordChunk; + continue; + } + + if (graphemeLength(current) + 1 + graphemeLength(wordChunk) <= width) { + current = `${current} ${wordChunk}`; + } else { + wrapped.push(current); + current = wordChunk; + } + } + } + + if (current) { + wrapped.push(current); + } + + return wrapped; +} + +export const ThinkingMessage: React.FC = ({ + thought, + terminalWidth, +}) => { + const fullLines = useMemo(() => normalizeThoughtLines(thought), [thought]); + const fullSummaryDisplayLines = useMemo(() => { + const contentWidth = Math.max(terminalWidth - THINKING_LEFT_PADDING - 2, 1); + return fullLines.length > 0 + ? wrapLineToWidth(fullLines[0], contentWidth) + : []; + }, [fullLines, terminalWidth]); + const fullBodyDisplayLines = useMemo(() => { + const contentWidth = Math.max(terminalWidth - THINKING_LEFT_PADDING - 2, 1); + return fullLines + .slice(1) + .flatMap((line) => wrapLineToWidth(line, contentWidth)); + }, [fullLines, terminalWidth]); + + if ( + fullSummaryDisplayLines.length === 0 && + fullBodyDisplayLines.length === 0 + ) { + return null; + } + + return ( + + {fullSummaryDisplayLines.map((line, index) => ( + + + + + + {line} + + + ))} + {fullBodyDisplayLines.map((line, index) => ( + + + + + + {line} + + + ))} + + ); +}; diff --git a/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx b/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx index 118b198edf..fdd81e7a5a 100644 --- a/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx +++ b/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx @@ -247,7 +247,7 @@ export const ToolGroupMessage: React.FC = ({ */ (visibleToolCalls.length > 0 || borderBottomOverride !== undefined) && ( { }); }); describe('Thought Reset', () => { + it('should keep full thinking entries in history when mode is full', async () => { + const fullThinkingSettings: LoadedSettings = { + ...mockLoadedSettings, + merged: { + ...mockLoadedSettings.merged, + ui: { inlineThinkingMode: 'full' }, + }, + } as unknown as LoadedSettings; + + mockSendMessageStream.mockReturnValue( + (async function* () { + yield { + type: ServerGeminiEventType.Thought, + value: { + subject: 'Full thought', + description: 'Detailed thinking', + }, + }; + yield { + type: ServerGeminiEventType.Content, + value: 'Response', + }; + })(), + ); + + const { result } = renderHookWithProviders(() => + useGeminiStream( + new MockedGeminiClientClass(mockConfig), + [], + mockAddItem, + mockConfig, + fullThinkingSettings, + mockOnDebugMessage, + mockHandleSlashCommand, + false, + () => 'vscode' as EditorType, + () => {}, + () => Promise.resolve(), + false, + () => {}, + () => {}, + () => {}, + 80, + 24, + ), + ); + + await act(async () => { + await result.current.submitQuery('Test query'); + }); + + expect(mockAddItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'thinking', + thought: expect.objectContaining({ subject: 'Full thought' }), + }), + expect.any(Number), + ); + }); + + it('keeps thought transient and clears it on first non-thought event', async () => { + mockSendMessageStream.mockReturnValue( + (async function* () { + yield { + type: ServerGeminiEventType.Thought, + value: { + subject: 'Assessing intent', + description: 'Inspecting context', + }, + }; + yield { + type: ServerGeminiEventType.Content, + value: 'Model response content', + }; + yield { + type: ServerGeminiEventType.Finished, + value: { reason: 'STOP', usageMetadata: undefined }, + }; + })(), + ); + + const { result } = renderTestHook(); + + await act(async () => { + await result.current.submitQuery('Test query'); + }); + + await waitFor(() => { + expect(mockAddItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'gemini', + text: 'Model response content', + }), + expect.any(Number), + ); + }); + + expect(result.current.thought).toBeNull(); + expect(mockAddItem).not.toHaveBeenCalledWith( + expect.objectContaining({ type: 'thinking' }), + expect.any(Number), + ); + }); + it('should reset thought to null when starting a new prompt', async () => { // First, simulate a response with a thought mockSendMessageStream.mockReturnValue( diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts index dc78c76a50..bba6977ffa 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.ts +++ b/packages/cli/src/ui/hooks/useGeminiStream.ts @@ -50,6 +50,7 @@ import type { import { type Part, type PartListUnion, FinishReason } from '@google/genai'; import type { HistoryItem, + HistoryItemThinking, HistoryItemWithoutId, HistoryItemToolGroup, IndividualToolCallDisplay, @@ -61,6 +62,7 @@ import { isAtCommand, isSlashCommand } from '../utils/commandUtils.js'; import { useShellCommandProcessor } from './shellCommandProcessor.js'; import { handleAtCommand } from './atCommandProcessor.js'; import { findLastSafeSplitPoint } from '../utils/markdownUtilities.js'; +import { getInlineThinkingMode } from '../utils/inlineThinkingMode.js'; import { useStateAndRef } from './useStateAndRef.js'; import type { UseHistoryManagerReturn } from './useHistoryManager.js'; import { useLogger } from './useLogger.js'; @@ -192,9 +194,11 @@ export const useGeminiStream = ( const turnCancelledRef = useRef(false); const activeQueryIdRef = useRef(null); const [isResponding, setIsResponding] = useState(false); - const [thought, setThought] = useState(null); + const [thought, thoughtRef, setThought] = + useStateAndRef(null); const [pendingHistoryItem, pendingHistoryItemRef, setPendingHistoryItem] = useStateAndRef(null); + const [lastGeminiActivityTime, setLastGeminiActivityTime] = useState(0); const [pushedToolCallIds, pushedToolCallIdsRef, setPushedToolCallIds] = @@ -753,6 +757,7 @@ export const useGeminiStream = ( pendingHistoryItemRef.current?.type !== 'gemini' && pendingHistoryItemRef.current?.type !== 'gemini_content' ) { + // Flush any pending item before starting gemini content if (pendingHistoryItemRef.current) { addItem(pendingHistoryItemRef.current, userMessageTimestamp); } @@ -798,6 +803,23 @@ export const useGeminiStream = ( [addItem, pendingHistoryItemRef, setPendingHistoryItem], ); + const handleThoughtEvent = useCallback( + (eventValue: ThoughtSummary, userMessageTimestamp: number) => { + setThought(eventValue); + + if (getInlineThinkingMode(settings) === 'full') { + addItem( + { + type: 'thinking', + thought: eventValue, + } as HistoryItemThinking, + userMessageTimestamp, + ); + } + }, + [addItem, settings, setThought], + ); + const handleUserCancelledEvent = useCallback( (userMessageTimestamp: number) => { if (turnCancelledRef.current) { @@ -1067,10 +1089,17 @@ export const useGeminiStream = ( let geminiMessageBuffer = ''; const toolCallRequests: ToolCallRequestInfo[] = []; for await (const event of stream) { + if ( + event.type !== ServerGeminiEventType.Thought && + thoughtRef.current !== null + ) { + setThought(null); + } + switch (event.type) { case ServerGeminiEventType.Thought: setLastGeminiActivityTime(Date.now()); - setThought(event.value); + handleThoughtEvent(event.value, userMessageTimestamp); break; case ServerGeminiEventType.Content: setLastGeminiActivityTime(Date.now()); @@ -1157,6 +1186,8 @@ export const useGeminiStream = ( }, [ handleContentEvent, + handleThoughtEvent, + thoughtRef, handleUserCancelledEvent, handleErrorEvent, scheduleToolCalls, @@ -1171,6 +1202,7 @@ export const useGeminiStream = ( addItem, pendingHistoryItemRef, setPendingHistoryItem, + setThought, ], ); const submitQuery = useCallback( @@ -1351,6 +1383,7 @@ export const useGeminiStream = ( config, startNewPrompt, getPromptCount, + setThought, ], ); diff --git a/packages/cli/src/ui/types.ts b/packages/cli/src/ui/types.ts index 73e5c3272b..c48b81bf9c 100644 --- a/packages/cli/src/ui/types.ts +++ b/packages/cli/src/ui/types.ts @@ -220,6 +220,11 @@ export interface ChatDetail { mtime: string; } +export type HistoryItemThinking = HistoryItemBase & { + type: 'thinking'; + thought: ThoughtSummary; +}; + export type HistoryItemChatList = HistoryItemBase & { type: 'chat_list'; chats: ChatDetail[]; @@ -343,6 +348,7 @@ export type HistoryItemWithoutId = | HistoryItemAgentsList | HistoryItemMcpStatus | HistoryItemChatList + | HistoryItemThinking | HistoryItemHooksList; export type HistoryItem = HistoryItemWithoutId & { id: number }; diff --git a/packages/cli/src/ui/utils/inlineThinkingMode.ts b/packages/cli/src/ui/utils/inlineThinkingMode.ts new file mode 100644 index 0000000000..16ca1a44a2 --- /dev/null +++ b/packages/cli/src/ui/utils/inlineThinkingMode.ts @@ -0,0 +1,15 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { LoadedSettings } from '../../config/settings.js'; + +export type InlineThinkingMode = 'off' | 'full'; + +export function getInlineThinkingMode( + settings: LoadedSettings, +): InlineThinkingMode { + return settings.merged.ui?.inlineThinkingMode ?? 'off'; +} diff --git a/packages/cli/src/ui/utils/terminalUtils.test.ts b/packages/cli/src/ui/utils/terminalUtils.test.ts index 814308ddbc..f12b3e03ba 100644 --- a/packages/cli/src/ui/utils/terminalUtils.test.ts +++ b/packages/cli/src/ui/utils/terminalUtils.test.ts @@ -5,11 +5,15 @@ */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { isITerm2, resetITerm2Cache } from './terminalUtils.js'; +import { isITerm2, resetITerm2Cache, shouldUseEmoji } from './terminalUtils.js'; describe('terminalUtils', () => { beforeEach(() => { vi.stubEnv('TERM_PROGRAM', ''); + vi.stubEnv('LC_ALL', ''); + vi.stubEnv('LC_CTYPE', ''); + vi.stubEnv('LANG', ''); + vi.stubEnv('TERM', ''); resetITerm2Cache(); }); @@ -18,25 +22,56 @@ describe('terminalUtils', () => { vi.restoreAllMocks(); }); - it('should detect iTerm2 via TERM_PROGRAM', () => { - vi.stubEnv('TERM_PROGRAM', 'iTerm.app'); - expect(isITerm2()).toBe(true); + describe('isITerm2', () => { + it('should detect iTerm2 via TERM_PROGRAM', () => { + vi.stubEnv('TERM_PROGRAM', 'iTerm.app'); + expect(isITerm2()).toBe(true); + }); + + it('should return false if not iTerm2', () => { + vi.stubEnv('TERM_PROGRAM', 'vscode'); + expect(isITerm2()).toBe(false); + }); + + it('should cache the result', () => { + vi.stubEnv('TERM_PROGRAM', 'iTerm.app'); + expect(isITerm2()).toBe(true); + + // Change env but should still be true due to cache + vi.stubEnv('TERM_PROGRAM', 'vscode'); + expect(isITerm2()).toBe(true); + + resetITerm2Cache(); + expect(isITerm2()).toBe(false); + }); }); - it('should return false if not iTerm2', () => { - vi.stubEnv('TERM_PROGRAM', 'vscode'); - expect(isITerm2()).toBe(false); - }); + describe('shouldUseEmoji', () => { + it('should return true when UTF-8 is supported', () => { + vi.stubEnv('LANG', 'en_US.UTF-8'); + expect(shouldUseEmoji()).toBe(true); + }); - it('should cache the result', () => { - vi.stubEnv('TERM_PROGRAM', 'iTerm.app'); - expect(isITerm2()).toBe(true); + it('should return true when utf8 (no hyphen) is supported', () => { + vi.stubEnv('LANG', 'en_US.utf8'); + expect(shouldUseEmoji()).toBe(true); + }); - // Change env but should still be true due to cache - vi.stubEnv('TERM_PROGRAM', 'vscode'); - expect(isITerm2()).toBe(true); + it('should check LC_ALL first', () => { + vi.stubEnv('LC_ALL', 'en_US.UTF-8'); + vi.stubEnv('LANG', 'C'); + expect(shouldUseEmoji()).toBe(true); + }); - resetITerm2Cache(); - expect(isITerm2()).toBe(false); + it('should return false when UTF-8 is not supported', () => { + vi.stubEnv('LANG', 'C'); + expect(shouldUseEmoji()).toBe(false); + }); + + it('should return false on linux console (TERM=linux)', () => { + vi.stubEnv('LANG', 'en_US.UTF-8'); + vi.stubEnv('TERM', 'linux'); + expect(shouldUseEmoji()).toBe(false); + }); }); }); diff --git a/packages/cli/src/ui/utils/terminalUtils.ts b/packages/cli/src/ui/utils/terminalUtils.ts index 18cd08f952..b0a3b93034 100644 --- a/packages/cli/src/ui/utils/terminalUtils.ts +++ b/packages/cli/src/ui/utils/terminalUtils.ts @@ -43,3 +43,25 @@ export function isITerm2(): boolean { export function resetITerm2Cache(): void { cachedIsITerm2 = undefined; } + +/** + * Returns true if the terminal likely supports emoji. + */ +export function shouldUseEmoji(): boolean { + const locale = ( + process.env['LC_ALL'] || + process.env['LC_CTYPE'] || + process.env['LANG'] || + '' + ).toLowerCase(); + const supportsUtf8 = locale.includes('utf-8') || locale.includes('utf8'); + if (!supportsUtf8) { + return false; + } + + if (process.env['TERM'] === 'linux') { + return false; + } + + return true; +} diff --git a/schemas/settings.schema.json b/schemas/settings.schema.json index 80bc484a3b..8434b61867 100644 --- a/schemas/settings.schema.json +++ b/schemas/settings.schema.json @@ -201,6 +201,14 @@ "default": false, "type": "boolean" }, + "inlineThinkingMode": { + "title": "Inline Thinking", + "description": "Display model thinking inline: off or full.", + "markdownDescription": "Display model thinking inline: off or full.\n\n- Category: `UI`\n- Requires restart: `no`\n- Default: `off`", + "default": "off", + "type": "string", + "enum": ["off", "full"] + }, "showStatusInTitle": { "title": "Show Thoughts in Title", "description": "Show Gemini CLI model thoughts in the terminal window title during the working phase",