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",