From 3aefc66b7b45a6a18b2f728b6f543cd4f06e34ae Mon Sep 17 00:00:00 2001 From: Dmitry Lyalin Date: Wed, 31 Dec 2025 15:00:35 -0500 Subject: [PATCH 01/24] first commit --- packages/cli/src/config/settingsSchema.ts | 10 +++++ .../ui/components/HistoryItemDisplay.test.tsx | 28 ++++++++++++ .../src/ui/components/HistoryItemDisplay.tsx | 9 ++++ .../cli/src/ui/components/MainContent.tsx | 5 +++ .../messages/ThinkingMessage.test.tsx | 45 +++++++++++++++++++ .../components/messages/ThinkingMessage.tsx | 33 ++++++++++++++ packages/cli/src/ui/hooks/useGeminiStream.ts | 32 ++++++++++++- packages/cli/src/ui/types.ts | 6 +++ 8 files changed, 167 insertions(+), 1 deletion(-) create mode 100644 packages/cli/src/ui/components/messages/ThinkingMessage.test.tsx create mode 100644 packages/cli/src/ui/components/messages/ThinkingMessage.tsx diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 7672b9e1c46..097caeeb186 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 8488a78dfbf..a22fcd20a64 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 5a7f7694021..3f28b835351 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 a60f782d8fb..11c97e53a84 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 00000000000..18ad73bd075 --- /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 00000000000..a2eda6c3755 --- /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 d36d9f57ed3..7791541fea2 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 ede5ab5b84a..d3c307978c2 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 }; From 3a513f9890960ce6cf4977b55eb40131f32196cb Mon Sep 17 00:00:00 2001 From: Dmitry Lyalin Date: Wed, 31 Dec 2025 15:09:51 -0500 Subject: [PATCH 02/24] Fixing broken tests, adding UI setting to docs --- docs/get-started/configuration.md | 4 ++++ packages/cli/src/ui/components/MainContent.test.tsx | 10 ++++++++++ .../ui/components/messages/ThinkingMessage.test.tsx | 2 +- schemas/settings.schema.json | 7 +++++++ 4 files changed, 22 insertions(+), 1 deletion(-) diff --git a/docs/get-started/configuration.md b/docs/get-started/configuration.md index db9161aaf86..ee8ebb17647 100644 --- a/docs/get-started/configuration.md +++ b/docs/get-started/configuration.md @@ -179,6 +179,10 @@ their corresponding top-level category object in your `settings.json` file. - **Default:** `false` - **Requires restart:** Yes +- **`ui.showInlineThinking`** (boolean): + - **Description:** Show model thinking summaries inline in the conversation. + - **Default:** `false` + - **`ui.showStatusInTitle`** (boolean): - **Description:** Show Gemini CLI status and thoughts in the terminal window title diff --git a/packages/cli/src/ui/components/MainContent.test.tsx b/packages/cli/src/ui/components/MainContent.test.tsx index 4bd823503cc..8ee407159bf 100644 --- a/packages/cli/src/ui/components/MainContent.test.tsx +++ b/packages/cli/src/ui/components/MainContent.test.tsx @@ -11,6 +11,16 @@ import { Box, Text } from 'ink'; import type React from 'react'; // Mock dependencies +vi.mock('../contexts/SettingsContext.js', () => ({ + useSettings: () => ({ + merged: { + ui: { + showInlineThinking: false, + }, + }, + }), +})); + vi.mock('../contexts/AppContext.js', () => ({ useAppContext: () => ({ version: '1.0.0', diff --git a/packages/cli/src/ui/components/messages/ThinkingMessage.test.tsx b/packages/cli/src/ui/components/messages/ThinkingMessage.test.tsx index 18ad73bd075..6f924870641 100644 --- a/packages/cli/src/ui/components/messages/ThinkingMessage.test.tsx +++ b/packages/cli/src/ui/components/messages/ThinkingMessage.test.tsx @@ -5,7 +5,7 @@ */ import { describe, it, expect } from 'vitest'; -import { render } from 'ink-testing-library'; +import { render } from '../../../test-utils/render.js'; import { ThinkingMessage } from './ThinkingMessage.js'; describe('ThinkingMessage', () => { diff --git a/schemas/settings.schema.json b/schemas/settings.schema.json index d138c2bcd22..e9630b354ed 100644 --- a/schemas/settings.schema.json +++ b/schemas/settings.schema.json @@ -187,6 +187,13 @@ "default": false, "type": "boolean" }, + "showInlineThinking": { + "title": "Show Inline Thinking", + "description": "Show model thinking summaries inline in the conversation.", + "markdownDescription": "Show model thinking summaries inline in the conversation.\n\n- Category: `UI`\n- Requires restart: `no`\n- Default: `false`", + "default": false, + "type": "boolean" + }, "showStatusInTitle": { "title": "Show Status in Title", "description": "Show Gemini CLI status and thoughts in the terminal window title", From d58a1277539214cc7a14c18ba150f631f89b5c4f Mon Sep 17 00:00:00 2001 From: Dmitry Lyalin Date: Sat, 3 Jan 2026 19:00:15 -0500 Subject: [PATCH 03/24] test commit --- .../messages/ThinkingMessage.test.tsx | 14 ++++++++ .../components/messages/ThinkingMessage.tsx | 23 ++++++++++--- packages/cli/src/ui/hooks/useGeminiStream.ts | 32 ++++++++++++++----- scripts/generate-git-commit-info.js | 2 +- 4 files changed, 57 insertions(+), 14 deletions(-) diff --git a/packages/cli/src/ui/components/messages/ThinkingMessage.test.tsx b/packages/cli/src/ui/components/messages/ThinkingMessage.test.tsx index 6f924870641..582776bb82c 100644 --- a/packages/cli/src/ui/components/messages/ThinkingMessage.test.tsx +++ b/packages/cli/src/ui/components/messages/ThinkingMessage.test.tsx @@ -35,6 +35,20 @@ describe('ThinkingMessage', () => { expect(lastFrame()).toContain('(1)'); }); + it('renders thought content', () => { + const { lastFrame } = render( + , + ); + + expect(lastFrame()).toContain('Planning'); + expect(lastFrame()).toContain('I am planning the solution.'); + }); + it('renders empty state gracefully', () => { const { lastFrame } = render( , diff --git a/packages/cli/src/ui/components/messages/ThinkingMessage.tsx b/packages/cli/src/ui/components/messages/ThinkingMessage.tsx index a2eda6c3755..99e789f1c08 100644 --- a/packages/cli/src/ui/components/messages/ThinkingMessage.tsx +++ b/packages/cli/src/ui/components/messages/ThinkingMessage.tsx @@ -23,11 +23,24 @@ export const ThinkingMessage: React.FC = ({ width={terminalWidth} paddingX={1} marginBottom={1} + flexDirection="column" > - - - Thinking - - ({thoughts.length}) + + + + Thinking + + ({thoughts.length}) + + {thoughts.map((thought, index) => ( + + {thought.subject && ( + + {thought.subject} + + )} + {thought.description || ' '} + + ))} ); diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts index 7791541fea2..7202aa76dc0 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.ts +++ b/packages/cli/src/ui/hooks/useGeminiStream.ts @@ -119,7 +119,9 @@ export const useGeminiStream = ( const activeQueryIdRef = useRef(null); const [isResponding, setIsResponding] = useState(false); const [thought, setThought] = useState(null); - const thoughtsBufferRef = useRef([]); + const [thoughtsBuffer, thoughtsBufferRef, setThoughtsBuffer] = useStateAndRef< + ThoughtSummary[] + >([]); const [pendingHistoryItem, pendingHistoryItemRef, setPendingHistoryItem] = useStateAndRef(null); @@ -136,10 +138,10 @@ export const useGeminiStream = ( } as HistoryItemThinking, userMessageTimestamp, ); - thoughtsBufferRef.current = []; + setThoughtsBuffer([]); } }, - [addItem, settings], + [addItem, settings, setThoughtsBuffer, thoughtsBufferRef], ); const processedMemoryToolsRef = useRef>(new Set()); @@ -828,7 +830,7 @@ export const useGeminiStream = ( switch (event.type) { case ServerGeminiEventType.Thought: setThought(event.value); - thoughtsBufferRef.current.push(event.value); + setThoughtsBuffer((prev) => [...prev, event.value]); break; case ServerGeminiEventType.Content: geminiMessageBuffer = handleContentEvent( @@ -908,6 +910,7 @@ export const useGeminiStream = ( handleCitationEvent, handleChatModelEvent, flushThoughts, + setThoughtsBuffer, ], ); const submitQuery = useCallback( @@ -1085,6 +1088,7 @@ export const useGeminiStream = ( config, startNewPrompt, getPromptCount, + thoughtsBufferRef, ], ); @@ -1282,12 +1286,24 @@ export const useGeminiStream = ( ], ); + const pendingThinkingItem = useMemo(() => { + if (settings.merged.ui?.showInlineThinking && thoughtsBuffer.length > 0) { + return { + type: 'thinking', + thoughts: thoughtsBuffer, + } as HistoryItemWithoutId; + } + return null; + }, [settings.merged.ui?.showInlineThinking, thoughtsBuffer]); + const pendingHistoryItems = useMemo( () => - [pendingHistoryItem, pendingToolCallGroupDisplay].filter( - (i) => i !== undefined && i !== null, - ), - [pendingHistoryItem, pendingToolCallGroupDisplay], + [ + pendingThinkingItem, + pendingHistoryItem, + pendingToolCallGroupDisplay, + ].filter((i) => i !== undefined && i !== null), + [pendingThinkingItem, pendingHistoryItem, pendingToolCallGroupDisplay], ); useEffect(() => { diff --git a/scripts/generate-git-commit-info.js b/scripts/generate-git-commit-info.js index 75c8bb97ba3..dda65bede53 100644 --- a/scripts/generate-git-commit-info.js +++ b/scripts/generate-git-commit-info.js @@ -57,7 +57,7 @@ try { const fileContent = `/** * @license - * Copyright ${new Date().getFullYear()} Google LLC + * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ From 648fd5dadd2251099eafdbce58d4c626849790e4 Mon Sep 17 00:00:00 2001 From: Dmitry Lyalin Date: Sun, 4 Jan 2026 19:15:57 -0500 Subject: [PATCH 04/24] Unify thought handling into pendingHistoryItem by removing thoughtsBuffer and simplifying flushing logic via handleThoughtEvent --- packages/cli/src/ui/hooks/useGeminiStream.ts | 96 ++++++++++---------- 1 file changed, 46 insertions(+), 50 deletions(-) diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts index 7202aa76dc0..0790a24adf6 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.ts +++ b/packages/cli/src/ui/hooks/useGeminiStream.ts @@ -119,31 +119,9 @@ export const useGeminiStream = ( const activeQueryIdRef = useRef(null); const [isResponding, setIsResponding] = useState(false); const [thought, setThought] = useState(null); - const [thoughtsBuffer, thoughtsBufferRef, setThoughtsBuffer] = useStateAndRef< - ThoughtSummary[] - >([]); 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, - ); - setThoughtsBuffer([]); - } - }, - [addItem, settings, setThoughtsBuffer, thoughtsBufferRef], - ); - const processedMemoryToolsRef = useRef>(new Set()); const { startNewPrompt, getPromptCount } = useSessionStats(); const storage = config.storage; @@ -559,7 +537,6 @@ export const useGeminiStream = ( currentGeminiMessageBuffer: string, userMessageTimestamp: number, ): string => { - flushThoughts(userMessageTimestamp); if (turnCancelledRef.current) { // Prevents additional output after a user initiated cancel. return ''; @@ -569,6 +546,7 @@ export const useGeminiStream = ( pendingHistoryItemRef.current?.type !== 'gemini' && pendingHistoryItemRef.current?.type !== 'gemini_content' ) { + // Flush any pending item (including thinking items) before starting gemini content if (pendingHistoryItemRef.current) { addItem(pendingHistoryItemRef.current, userMessageTimestamp); } @@ -609,7 +587,41 @@ export const useGeminiStream = ( } return newGeminiMessageBuffer; }, - [addItem, pendingHistoryItemRef, setPendingHistoryItem, flushThoughts], + [addItem, pendingHistoryItemRef, setPendingHistoryItem], + ); + + const handleThoughtEvent = useCallback( + (eventValue: ThoughtSummary, userMessageTimestamp: number) => { + setThought(eventValue); + + // Only accumulate thoughts in history if inline thinking is enabled + if (!settings.merged.ui?.showInlineThinking) { + return; + } + + if (pendingHistoryItemRef.current?.type === 'thinking') { + // Accumulate thoughts in the existing thinking item + setPendingHistoryItem((prev) => ({ + type: 'thinking', + thoughts: [...(prev as HistoryItemThinking).thoughts, eventValue], + })); + } else { + // Flush any existing pending item and start a new thinking item + if (pendingHistoryItemRef.current) { + addItem(pendingHistoryItemRef.current, userMessageTimestamp); + } + setPendingHistoryItem({ + type: 'thinking', + thoughts: [eventValue], + } as HistoryItemThinking); + } + }, + [ + addItem, + pendingHistoryItemRef, + setPendingHistoryItem, + settings.merged.ui?.showInlineThinking, + ], ); const handleUserCancelledEvent = useCallback( @@ -829,8 +841,7 @@ export const useGeminiStream = ( for await (const event of stream) { switch (event.type) { case ServerGeminiEventType.Thought: - setThought(event.value); - setThoughtsBuffer((prev) => [...prev, event.value]); + handleThoughtEvent(event.value, userMessageTimestamp); break; case ServerGeminiEventType.Content: geminiMessageBuffer = handleContentEvent( @@ -840,15 +851,12 @@ 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: @@ -868,7 +876,6 @@ export const useGeminiStream = ( ); break; case ServerGeminiEventType.Finished: - flushThoughts(userMessageTimestamp); handleFinishedEvent(event, userMessageTimestamp); break; case ServerGeminiEventType.Citation: @@ -900,6 +907,7 @@ export const useGeminiStream = ( }, [ handleContentEvent, + handleThoughtEvent, handleUserCancelledEvent, handleErrorEvent, scheduleToolCalls, @@ -909,8 +917,6 @@ export const useGeminiStream = ( handleContextWindowWillOverflowEvent, handleCitationEvent, handleChatModelEvent, - flushThoughts, - setThoughtsBuffer, ], ); const submitQuery = useCallback( @@ -975,7 +981,10 @@ export const useGeminiStream = ( } startNewPrompt(); setThought(null); // Reset thought when starting a new prompt - thoughtsBufferRef.current = []; + // Clear any pending thinking item from previous prompt + if (pendingHistoryItemRef.current?.type === 'thinking') { + setPendingHistoryItem(null); + } } setIsResponding(true); @@ -1088,7 +1097,6 @@ export const useGeminiStream = ( config, startNewPrompt, getPromptCount, - thoughtsBufferRef, ], ); @@ -1286,24 +1294,12 @@ export const useGeminiStream = ( ], ); - const pendingThinkingItem = useMemo(() => { - if (settings.merged.ui?.showInlineThinking && thoughtsBuffer.length > 0) { - return { - type: 'thinking', - thoughts: thoughtsBuffer, - } as HistoryItemWithoutId; - } - return null; - }, [settings.merged.ui?.showInlineThinking, thoughtsBuffer]); - const pendingHistoryItems = useMemo( () => - [ - pendingThinkingItem, - pendingHistoryItem, - pendingToolCallGroupDisplay, - ].filter((i) => i !== undefined && i !== null), - [pendingThinkingItem, pendingHistoryItem, pendingToolCallGroupDisplay], + [pendingHistoryItem, pendingToolCallGroupDisplay].filter( + (i) => i !== undefined && i !== null, + ), + [pendingHistoryItem, pendingToolCallGroupDisplay], ); useEffect(() => { From c773ae4681f338f04b9cf95aff8c1d8208c7f2fc Mon Sep 17 00:00:00 2001 From: Dmitry Lyalin Date: Sat, 31 Jan 2026 10:44:30 -0500 Subject: [PATCH 05/24] Fix inline thinking in alternate buffer and pending renders --- packages/cli/src/ui/components/MainContent.tsx | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/ui/components/MainContent.tsx b/packages/cli/src/ui/components/MainContent.tsx index 7033217343e..76071504eca 100644 --- a/packages/cli/src/ui/components/MainContent.tsx +++ b/packages/cli/src/ui/components/MainContent.tsx @@ -97,6 +97,7 @@ export const MainContent = () => { isFocused={!uiState.isEditorDialogOpen} activeShellPtyId={uiState.activePtyId} embeddedShellFocused={uiState.embeddedShellFocused} + inlineEnabled={inlineEnabled} /> ))} {showConfirmationQueue && confirmingTool && ( @@ -110,6 +111,7 @@ export const MainContent = () => { isAlternateBuffer, availableTerminalHeight, mainAreaWidth, + inlineEnabled, uiState.isEditorDialogOpen, uiState.activePtyId, uiState.embeddedShellFocused, @@ -141,13 +143,20 @@ export const MainContent = () => { item={item.item} isPending={false} commands={uiState.slashCommands} + inlineEnabled={inlineEnabled} /> ); } else { return pendingItems; } }, - [version, mainAreaWidth, uiState.slashCommands, pendingItems], + [ + version, + mainAreaWidth, + uiState.slashCommands, + inlineEnabled, + pendingItems, + ], ); if (isAlternateBuffer) { From 66bbfa0dd45e6131e8ee63c398d9e2023bd2f81f Mon Sep 17 00:00:00 2001 From: Dmitry Lyalin Date: Sat, 31 Jan 2026 10:57:33 -0500 Subject: [PATCH 06/24] Fix missing bottom border in tool boxes --- packages/cli/src/ui/components/ToolConfirmationQueue.tsx | 2 +- packages/cli/src/ui/components/messages/ToolGroupMessage.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/ui/components/ToolConfirmationQueue.tsx b/packages/cli/src/ui/components/ToolConfirmationQueue.tsx index 0ee6fec05c3..ac85b5d70f4 100644 --- a/packages/cli/src/ui/components/ToolConfirmationQueue.tsx +++ b/packages/cli/src/ui/components/ToolConfirmationQueue.tsx @@ -137,7 +137,7 @@ export const ToolConfirmationQueue: React.FC = ({ /> = ({ */ (visibleToolCalls.length > 0 || borderBottomOverride !== undefined) && ( Date: Sat, 31 Jan 2026 11:07:06 -0500 Subject: [PATCH 07/24] Stabilize inline thinking box during streaming --- .../src/ui/components/HistoryItemDisplay.tsx | 3 + .../src/ui/components/MainContent.test.tsx | 20 +++--- .../components/messages/ThinkingMessage.tsx | 70 ++++++++++++------- 3 files changed, 58 insertions(+), 35 deletions(-) diff --git a/packages/cli/src/ui/components/HistoryItemDisplay.tsx b/packages/cli/src/ui/components/HistoryItemDisplay.tsx index 64b725a879d..f83c2d34d99 100644 --- a/packages/cli/src/ui/components/HistoryItemDisplay.tsx +++ b/packages/cli/src/ui/components/HistoryItemDisplay.tsx @@ -70,6 +70,9 @@ export const HistoryItemDisplay: React.FC = ({ )} {itemForDisplay.type === 'user' && ( diff --git a/packages/cli/src/ui/components/MainContent.test.tsx b/packages/cli/src/ui/components/MainContent.test.tsx index fc78c840340..06a7209d708 100644 --- a/packages/cli/src/ui/components/MainContent.test.tsx +++ b/packages/cli/src/ui/components/MainContent.test.tsx @@ -12,15 +12,19 @@ import { Box, Text } from 'ink'; import type React from 'react'; // Mock dependencies -vi.mock('../contexts/SettingsContext.js', () => ({ - useSettings: () => ({ - merged: { - ui: { - showInlineThinking: false, +vi.mock('../contexts/SettingsContext.js', async () => { + const actual = await vi.importActual('../contexts/SettingsContext.js'); + return { + ...actual, + useSettings: () => ({ + merged: { + ui: { + showInlineThinking: false, + }, }, - }, - }), -})); + }), + }; +}); vi.mock('../contexts/AppContext.js', async () => { const actual = await vi.importActual('../contexts/AppContext.js'); diff --git a/packages/cli/src/ui/components/messages/ThinkingMessage.tsx b/packages/cli/src/ui/components/messages/ThinkingMessage.tsx index 99e789f1c08..e1453add44f 100644 --- a/packages/cli/src/ui/components/messages/ThinkingMessage.tsx +++ b/packages/cli/src/ui/components/messages/ThinkingMessage.tsx @@ -7,40 +7,56 @@ import type React from 'react'; import { Box, Text } from 'ink'; import type { ThoughtSummary } from '@google/gemini-cli-core'; +import { MaxSizedBox, MINIMUM_MAX_HEIGHT } from '../shared/MaxSizedBox.js'; interface ThinkingMessageProps { thoughts: ThoughtSummary[]; terminalWidth: number; + availableTerminalHeight?: number; } export const ThinkingMessage: React.FC = ({ thoughts, terminalWidth, -}) => ( - - - - - Thinking - - ({thoughts.length}) - - {thoughts.map((thought, index) => ( - - {thought.subject && ( - - {thought.subject} - - )} - {thought.description || ' '} + availableTerminalHeight, +}) => { + const contentMaxHeight = + availableTerminalHeight !== undefined + ? Math.max(availableTerminalHeight - 4, MINIMUM_MAX_HEIGHT) + : undefined; + + return ( + + + + + Thinking + + ({thoughts.length}) - ))} - -); + + {thoughts.map((thought, index) => ( + + {thought.subject && ( + + {thought.subject} + + )} + {thought.description || ' '} + + ))} + + + ); +}; From 364954851a0293e7a415e88214deb4532d2657e2 Mon Sep 17 00:00:00 2001 From: Dmitry Lyalin Date: Sat, 31 Jan 2026 11:22:47 -0500 Subject: [PATCH 08/24] Show inline thoughts as individual bubbles --- packages/cli/src/config/settingsSchema.ts | 20 +++++- .../AlternateBufferQuittingDisplay.tsx | 6 ++ .../ui/components/HistoryItemDisplay.test.tsx | 4 +- .../src/ui/components/HistoryItemDisplay.tsx | 2 +- .../cli/src/ui/components/MainContent.tsx | 3 +- .../ui/components/QuittingDisplay.test.tsx | 11 +++ .../cli/src/ui/components/QuittingDisplay.tsx | 5 ++ .../messages/ThinkingMessage.test.tsx | 28 ++++---- .../components/messages/ThinkingMessage.tsx | 19 +++--- packages/cli/src/ui/hooks/useGeminiStream.ts | 67 +++++++++++-------- packages/cli/src/ui/types.ts | 2 +- 11 files changed, 110 insertions(+), 57 deletions(-) diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 300338f9916..21c7f96a1b3 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -380,7 +380,25 @@ const SETTINGS_SCHEMA = { requiresRestart: false, default: false, description: - 'Show model thinking summaries inline in the conversation.', + 'Show model thinking summaries inline in the conversation (deprecated; prefer the specific thinking modes).', + showInDialog: true, + }, + showInlineThinkingFull: { + type: 'boolean', + label: 'Show Inline Thinking (Full)', + category: 'UI', + requiresRestart: false, + default: false, + description: 'Show full model thinking details inline.', + showInDialog: true, + }, + showInlineThinkingSummary: { + type: 'boolean', + label: 'Show Inline Thinking (Summary)', + category: 'UI', + requiresRestart: false, + default: false, + description: 'Show a short summary of model thinking inline.', showInDialog: true, }, showStatusInTitle: { diff --git a/packages/cli/src/ui/components/AlternateBufferQuittingDisplay.tsx b/packages/cli/src/ui/components/AlternateBufferQuittingDisplay.tsx index fec35d46c39..abe68ebbf97 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 inlineEnabled = getInlineThinkingMode(settings) !== 'off'; // 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} + inlineEnabled={inlineEnabled} /> ))} {uiState.pendingHistoryItems.map((item, i) => ( @@ -59,6 +64,7 @@ export const AlternateBufferQuittingDisplay = () => { isFocused={false} activeShellPtyId={uiState.activePtyId} embeddedShellFocused={uiState.embeddedShellFocused} + inlineEnabled={inlineEnabled} /> ))} {showPromptedTool && ( diff --git a/packages/cli/src/ui/components/HistoryItemDisplay.test.tsx b/packages/cli/src/ui/components/HistoryItemDisplay.test.tsx index d213b489cbc..e2938be0b83 100644 --- a/packages/cli/src/ui/components/HistoryItemDisplay.test.tsx +++ b/packages/cli/src/ui/components/HistoryItemDisplay.test.tsx @@ -237,7 +237,7 @@ describe('', () => { const item: HistoryItem = { ...baseItem, type: 'thinking', - thoughts: [{ subject: 'Thinking', description: 'test' }], + thought: { subject: 'Thinking', description: 'test' }, }; const { lastFrame } = renderWithProviders( , @@ -250,7 +250,7 @@ describe('', () => { const item: HistoryItem = { ...baseItem, type: 'thinking', - thoughts: [{ subject: 'Thinking', description: 'test' }], + thought: { subject: 'Thinking', description: 'test' }, }; const { lastFrame } = renderWithProviders( , diff --git a/packages/cli/src/ui/components/HistoryItemDisplay.tsx b/packages/cli/src/ui/components/HistoryItemDisplay.tsx index f83c2d34d99..8b13a004a49 100644 --- a/packages/cli/src/ui/components/HistoryItemDisplay.tsx +++ b/packages/cli/src/ui/components/HistoryItemDisplay.tsx @@ -68,7 +68,7 @@ export const HistoryItemDisplay: React.FC = ({ {/* Render standard message types */} {itemForDisplay.type === 'thinking' && inlineEnabled && ( { availableTerminalHeight, } = uiState; - const inlineEnabled = settings.merged.ui?.showInlineThinking; + const inlineEnabled = getInlineThinkingMode(settings) !== 'off'; const historyItems = useMemo( () => diff --git a/packages/cli/src/ui/components/QuittingDisplay.test.tsx b/packages/cli/src/ui/components/QuittingDisplay.test.tsx index 79cc7e5d7b5..ab20a12d83c 100644 --- a/packages/cli/src/ui/components/QuittingDisplay.test.tsx +++ b/packages/cli/src/ui/components/QuittingDisplay.test.tsx @@ -12,6 +12,17 @@ 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: { + showInlineThinking: false, + showInlineThinkingFull: false, + showInlineThinkingSummary: false, + }, + }, + }), +})); 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 ee81f920128..f2770b998d1 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 inlineEnabled = getInlineThinkingMode(settings) !== 'off'; if (!uiState.quittingMessages) { return null; @@ -30,6 +34,7 @@ export const QuittingDisplay = () => { terminalWidth={terminalWidth} item={item} isPending={false} + inlineEnabled={inlineEnabled} /> ))} diff --git a/packages/cli/src/ui/components/messages/ThinkingMessage.test.tsx b/packages/cli/src/ui/components/messages/ThinkingMessage.test.tsx index 582776bb82c..59c62b3cb0d 100644 --- a/packages/cli/src/ui/components/messages/ThinkingMessage.test.tsx +++ b/packages/cli/src/ui/components/messages/ThinkingMessage.test.tsx @@ -9,38 +9,35 @@ import { render } from '../../../test-utils/render.js'; import { ThinkingMessage } from './ThinkingMessage.js'; describe('ThinkingMessage', () => { - it('renders thinking header with count', () => { + it('renders thinking header', () => { const { lastFrame } = render( , ); expect(lastFrame()).toContain('Thinking'); - expect(lastFrame()).toContain('(2)'); }); - it('renders with single thought', () => { + it('renders with thought subject', () => { const { lastFrame } = render( , ); - expect(lastFrame()).toContain('(1)'); + expect(lastFrame()).toContain('Processing'); }); it('renders thought content', () => { const { lastFrame } = render( , ); @@ -51,9 +48,12 @@ describe('ThinkingMessage', () => { it('renders empty state gracefully', () => { const { lastFrame } = render( - , + , ); - expect(lastFrame()).toContain('(0)'); + expect(lastFrame()).toContain('Thinking'); }); }); diff --git a/packages/cli/src/ui/components/messages/ThinkingMessage.tsx b/packages/cli/src/ui/components/messages/ThinkingMessage.tsx index e1453add44f..3c2426d918a 100644 --- a/packages/cli/src/ui/components/messages/ThinkingMessage.tsx +++ b/packages/cli/src/ui/components/messages/ThinkingMessage.tsx @@ -10,16 +10,18 @@ import type { ThoughtSummary } from '@google/gemini-cli-core'; import { MaxSizedBox, MINIMUM_MAX_HEIGHT } from '../shared/MaxSizedBox.js'; interface ThinkingMessageProps { - thoughts: ThoughtSummary[]; + thought: ThoughtSummary; terminalWidth: number; availableTerminalHeight?: number; } export const ThinkingMessage: React.FC = ({ - thoughts, + thought, terminalWidth, availableTerminalHeight, }) => { + const subject = thought.subject.trim(); + const description = thought.description.trim(); const contentMaxHeight = availableTerminalHeight !== undefined ? Math.max(availableTerminalHeight - 4, MINIMUM_MAX_HEIGHT) @@ -39,23 +41,22 @@ export const ThinkingMessage: React.FC = ({ Thinking - ({thoughts.length}) - {thoughts.map((thought, index) => ( - - {thought.subject && ( + {(subject || description) && ( + + {subject && ( - {thought.subject} + {subject} )} - {thought.description || ' '} + {description && {description}} - ))} + )} ); diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts index 66ffa35799e..aaa8d9c4403 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.ts +++ b/packages/cli/src/ui/hooks/useGeminiStream.ts @@ -63,6 +63,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'; @@ -78,6 +79,29 @@ import { } from './useToolScheduler.js'; import { promises as fs } from 'node:fs'; import path from 'node:path'; + +const MAX_THOUGHT_SUMMARY_LENGTH = 140; + +function summarizeThought(thought: ThoughtSummary): ThoughtSummary { + const subject = thought.subject.trim(); + if (subject) { + return { subject, description: '' }; + } + + const description = thought.description.trim(); + if (!description) { + return { subject: '', description: '' }; + } + + if (description.length <= MAX_THOUGHT_SUMMARY_LENGTH) { + return { subject: description, description: '' }; + } + + const trimmed = description + .slice(0, MAX_THOUGHT_SUMMARY_LENGTH - 3) + .trimEnd(); + return { subject: `${trimmed}...`, description: '' }; +} import { useSessionStats } from '../contexts/SessionContext.js'; import { useKeypress } from './useKeypress.js'; import type { LoadedSettings } from '../../config/settings.js'; @@ -762,7 +786,7 @@ export const useGeminiStream = ( pendingHistoryItemRef.current?.type !== 'gemini' && pendingHistoryItemRef.current?.type !== 'gemini_content' ) { - // Flush any pending item (including thinking items) before starting gemini content + // Flush any pending item before starting gemini content if (pendingHistoryItemRef.current) { addItem(pendingHistoryItemRef.current, userMessageTimestamp); } @@ -810,34 +834,25 @@ export const useGeminiStream = ( (eventValue: ThoughtSummary, userMessageTimestamp: number) => { setThought(eventValue); - // Only accumulate thoughts in history if inline thinking is enabled - if (!settings.merged.ui?.showInlineThinking) { + const inlineThinkingMode = getInlineThinkingMode(settings); + if (inlineThinkingMode === 'off') { return; } - if (pendingHistoryItemRef.current?.type === 'thinking') { - // Accumulate thoughts in the existing thinking item - setPendingHistoryItem((prev) => ({ - type: 'thinking', - thoughts: [...(prev as HistoryItemThinking).thoughts, eventValue], - })); - } else { - // Flush any existing pending item and start a new thinking item - if (pendingHistoryItemRef.current) { - addItem(pendingHistoryItemRef.current, userMessageTimestamp); - } - setPendingHistoryItem({ + const thoughtForDisplay = + inlineThinkingMode === 'summary' + ? summarizeThought(eventValue) + : eventValue; + + addItem( + { type: 'thinking', - thoughts: [eventValue], - } as HistoryItemThinking); - } + thought: thoughtForDisplay, + } as HistoryItemThinking, + userMessageTimestamp, + ); }, - [ - addItem, - pendingHistoryItemRef, - setPendingHistoryItem, - settings.merged.ui?.showInlineThinking, - ], + [addItem, settings], ); const handleUserCancelledEvent = useCallback( @@ -1279,10 +1294,6 @@ export const useGeminiStream = ( } startNewPrompt(); setThought(null); // Reset thought when starting a new prompt - // Clear any pending thinking item from previous prompt - if (pendingHistoryItemRef.current?.type === 'thinking') { - setPendingHistoryItem(null); - } } setIsResponding(true); diff --git a/packages/cli/src/ui/types.ts b/packages/cli/src/ui/types.ts index df9600705ae..9f99af1f0cf 100644 --- a/packages/cli/src/ui/types.ts +++ b/packages/cli/src/ui/types.ts @@ -212,7 +212,7 @@ export interface ChatDetail { export type HistoryItemThinking = HistoryItemBase & { type: 'thinking'; - thoughts: ThoughtSummary[]; + thought: ThoughtSummary; }; export type HistoryItemChatList = HistoryItemBase & { From 8b04b21648a49825ae302f6e3524f92bef72b19f Mon Sep 17 00:00:00 2001 From: Dmitry Lyalin Date: Sat, 31 Jan 2026 11:23:44 -0500 Subject: [PATCH 09/24] Add inline thinking mode helper --- .../cli/src/ui/utils/inlineThinkingMode.ts | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 packages/cli/src/ui/utils/inlineThinkingMode.ts diff --git a/packages/cli/src/ui/utils/inlineThinkingMode.ts b/packages/cli/src/ui/utils/inlineThinkingMode.ts new file mode 100644 index 00000000000..27c7ac8d046 --- /dev/null +++ b/packages/cli/src/ui/utils/inlineThinkingMode.ts @@ -0,0 +1,29 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { LoadedSettings } from '../../config/settings.js'; + +export type InlineThinkingMode = 'off' | 'summary' | 'full'; + +export function getInlineThinkingMode( + settings: LoadedSettings, +): InlineThinkingMode { + const ui = settings.merged.ui; + + if (ui?.showInlineThinkingFull) { + return 'full'; + } + + if (ui?.showInlineThinkingSummary) { + return 'summary'; + } + + if (ui?.showInlineThinking) { + return 'full'; + } + + return 'off'; +} From 4620d5bb151658de0021f070aa1b976ad2495db8 Mon Sep 17 00:00:00 2001 From: Dmitry Lyalin Date: Sat, 31 Jan 2026 11:29:12 -0500 Subject: [PATCH 10/24] Simplify inline thinking display --- packages/cli/src/config/settingsSchema.ts | 2 +- .../cli/src/ui/components/messages/ThinkingMessage.tsx | 10 ++-------- 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 21c7f96a1b3..ba461ecb0b4 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -381,7 +381,7 @@ const SETTINGS_SCHEMA = { default: false, description: 'Show model thinking summaries inline in the conversation (deprecated; prefer the specific thinking modes).', - showInDialog: true, + showInDialog: false, }, showInlineThinkingFull: { type: 'boolean', diff --git a/packages/cli/src/ui/components/messages/ThinkingMessage.tsx b/packages/cli/src/ui/components/messages/ThinkingMessage.tsx index 3c2426d918a..5dcad550580 100644 --- a/packages/cli/src/ui/components/messages/ThinkingMessage.tsx +++ b/packages/cli/src/ui/components/messages/ThinkingMessage.tsx @@ -36,22 +36,16 @@ export const ThinkingMessage: React.FC = ({ marginBottom={1} flexDirection="column" > - - - - Thinking - - {(subject || description) && ( - + {subject && ( - {subject} + ◆ {subject} )} {description && {description}} From a55d278905bc68b80bb4069f2fa8df4e8d707c14 Mon Sep 17 00:00:00 2001 From: Dmitry Lyalin Date: Sat, 31 Jan 2026 11:35:59 -0500 Subject: [PATCH 11/24] Add emoji thought bubble with fallback --- .../messages/ThinkingMessage.test.tsx | 6 ++-- .../components/messages/ThinkingMessage.tsx | 35 +++++++++++++++---- 2 files changed, 31 insertions(+), 10 deletions(-) diff --git a/packages/cli/src/ui/components/messages/ThinkingMessage.test.tsx b/packages/cli/src/ui/components/messages/ThinkingMessage.test.tsx index 59c62b3cb0d..20caec2971e 100644 --- a/packages/cli/src/ui/components/messages/ThinkingMessage.test.tsx +++ b/packages/cli/src/ui/components/messages/ThinkingMessage.test.tsx @@ -9,7 +9,7 @@ import { render } from '../../../test-utils/render.js'; import { ThinkingMessage } from './ThinkingMessage.js'; describe('ThinkingMessage', () => { - it('renders thinking header', () => { + it('renders thinking subject', () => { const { lastFrame } = render( { />, ); - expect(lastFrame()).toContain('Thinking'); + expect(lastFrame()).toContain('Planning'); }); it('renders with thought subject', () => { @@ -54,6 +54,6 @@ describe('ThinkingMessage', () => { />, ); - expect(lastFrame()).toContain('Thinking'); + 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 index 5dcad550580..8541f271109 100644 --- a/packages/cli/src/ui/components/messages/ThinkingMessage.tsx +++ b/packages/cli/src/ui/components/messages/ThinkingMessage.tsx @@ -6,6 +6,7 @@ import type React from 'react'; import { Box, Text } from 'ink'; +import process from 'node:process'; import type { ThoughtSummary } from '@google/gemini-cli-core'; import { MaxSizedBox, MINIMUM_MAX_HEIGHT } from '../shared/MaxSizedBox.js'; @@ -22,10 +23,13 @@ export const ThinkingMessage: React.FC = ({ }) => { const subject = thought.subject.trim(); const description = thought.description.trim(); + const headerText = subject || description; + const bodyText = subject ? description : ''; const contentMaxHeight = availableTerminalHeight !== undefined ? Math.max(availableTerminalHeight - 4, MINIMUM_MAX_HEIGHT) : undefined; + const bubbleIcon = shouldUseEmojiBubble() ? '💬' : '◆'; return ( = ({ maxWidth={terminalWidth - 2} overflowDirection="top" > - {(subject || description) && ( + {headerText && ( - {subject && ( - - ◆ {subject} - - )} - {description && {description}} + + {bubbleIcon} {headerText} + + {bodyText && {bodyText}} )} ); }; + +function shouldUseEmojiBubble(): 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; +} From c5d2c0b2e334466ec1cc4e625b15aaa9bb66481b Mon Sep 17 00:00:00 2001 From: Dmitry Lyalin Date: Sat, 31 Jan 2026 14:19:02 -0500 Subject: [PATCH 12/24] Add reusable IconText with emoji fallback --- .../components/messages/ThinkingMessage.tsx | 33 +++--------- .../cli/src/ui/components/shared/IconText.tsx | 51 +++++++++++++++++++ 2 files changed, 59 insertions(+), 25 deletions(-) create mode 100644 packages/cli/src/ui/components/shared/IconText.tsx diff --git a/packages/cli/src/ui/components/messages/ThinkingMessage.tsx b/packages/cli/src/ui/components/messages/ThinkingMessage.tsx index 8541f271109..d08a9c90e57 100644 --- a/packages/cli/src/ui/components/messages/ThinkingMessage.tsx +++ b/packages/cli/src/ui/components/messages/ThinkingMessage.tsx @@ -6,9 +6,9 @@ import type React from 'react'; import { Box, Text } from 'ink'; -import process from 'node:process'; import type { ThoughtSummary } from '@google/gemini-cli-core'; import { MaxSizedBox, MINIMUM_MAX_HEIGHT } from '../shared/MaxSizedBox.js'; +import { IconText } from '../shared/IconText.js'; interface ThinkingMessageProps { thought: ThoughtSummary; @@ -29,8 +29,6 @@ export const ThinkingMessage: React.FC = ({ availableTerminalHeight !== undefined ? Math.max(availableTerminalHeight - 4, MINIMUM_MAX_HEIGHT) : undefined; - const bubbleIcon = shouldUseEmojiBubble() ? '💬' : '◆'; - return ( = ({ > {headerText && ( - - {bubbleIcon} {headerText} - + {bodyText && {bodyText}} )} @@ -57,22 +59,3 @@ export const ThinkingMessage: React.FC = ({ ); }; - -function shouldUseEmojiBubble(): 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/packages/cli/src/ui/components/shared/IconText.tsx b/packages/cli/src/ui/components/shared/IconText.tsx new file mode 100644 index 00000000000..f23bb6a72d5 --- /dev/null +++ b/packages/cli/src/ui/components/shared/IconText.tsx @@ -0,0 +1,51 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { Text } from 'ink'; +import process from 'node:process'; + +interface IconTextProps { + icon: string; + fallbackIcon?: string; + text: string; + color?: string; + bold?: boolean; +} + +export const IconText: React.FC = ({ + icon, + fallbackIcon, + text, + color, + bold, +}) => { + const resolvedIcon = shouldUseEmoji() ? icon : (fallbackIcon ?? icon); + return ( + + {resolvedIcon} {text} + + ); +}; + +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; +} From f35143cdd30197d46b964c33b0e9669c8a7d8bf5 Mon Sep 17 00:00:00 2001 From: Dmitry Lyalin Date: Sat, 31 Jan 2026 19:16:00 -0500 Subject: [PATCH 13/24] Regenerate settings schema and docs --- docs/cli/settings.md | 2 ++ docs/get-started/configuration.md | 11 ++++++++++- schemas/settings.schema.json | 18 ++++++++++++++++-- 3 files changed, 28 insertions(+), 3 deletions(-) diff --git a/docs/cli/settings.md b/docs/cli/settings.md index ab637aed3e6..ae74dc0463b 100644 --- a/docs/cli/settings.md +++ b/docs/cli/settings.md @@ -42,6 +42,8 @@ they appear in the UI. | UI Label | Setting | Description | Default | | ------------------------------ | --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- | | Hide Window Title | `ui.hideWindowTitle` | Hide the window title bar | `false` | +| Show Inline Thinking (Full) | `ui.showInlineThinkingFull` | Show full model thinking details inline. | `false` | +| Show Inline Thinking (Summary) | `ui.showInlineThinkingSummary` | Show a short summary of model thinking inline. | `false` | | 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 5dc69128b25..381f6b657b2 100644 --- a/docs/get-started/configuration.md +++ b/docs/get-started/configuration.md @@ -180,7 +180,16 @@ their corresponding top-level category object in your `settings.json` file. - **Requires restart:** Yes - **`ui.showInlineThinking`** (boolean): - - **Description:** Show model thinking summaries inline in the conversation. + - **Description:** Show model thinking summaries inline in the conversation + (deprecated; prefer the specific thinking modes). + - **Default:** `false` + +- **`ui.showInlineThinkingFull`** (boolean): + - **Description:** Show full model thinking details inline. + - **Default:** `false` + +- **`ui.showInlineThinkingSummary`** (boolean): + - **Description:** Show a short summary of model thinking inline. - **Default:** `false` - **`ui.showStatusInTitle`** (boolean): diff --git a/schemas/settings.schema.json b/schemas/settings.schema.json index b4ae84210ba..6dc942afbff 100644 --- a/schemas/settings.schema.json +++ b/schemas/settings.schema.json @@ -189,8 +189,22 @@ }, "showInlineThinking": { "title": "Show Inline Thinking", - "description": "Show model thinking summaries inline in the conversation.", - "markdownDescription": "Show model thinking summaries inline in the conversation.\n\n- Category: `UI`\n- Requires restart: `no`\n- Default: `false`", + "description": "Show model thinking summaries inline in the conversation (deprecated; prefer the specific thinking modes).", + "markdownDescription": "Show model thinking summaries inline in the conversation (deprecated; prefer the specific thinking modes).\n\n- Category: `UI`\n- Requires restart: `no`\n- Default: `false`", + "default": false, + "type": "boolean" + }, + "showInlineThinkingFull": { + "title": "Show Inline Thinking (Full)", + "description": "Show full model thinking details inline.", + "markdownDescription": "Show full model thinking details inline.\n\n- Category: `UI`\n- Requires restart: `no`\n- Default: `false`", + "default": false, + "type": "boolean" + }, + "showInlineThinkingSummary": { + "title": "Show Inline Thinking (Summary)", + "description": "Show a short summary of model thinking inline.", + "markdownDescription": "Show a short summary of model thinking inline.\n\n- Category: `UI`\n- Requires restart: `no`\n- Default: `false`", "default": false, "type": "boolean" }, From 2490db72002dfe0032ea7816a8493715d87bb401 Mon Sep 17 00:00:00 2001 From: Dmitry Lyalin Date: Sun, 1 Feb 2026 15:05:36 -0500 Subject: [PATCH 14/24] Remove deprecated inline thinking setting --- docs/get-started/configuration.md | 11 ++++------- packages/cli/src/config/settingsSchema.ts | 10 ---------- .../cli/src/ui/components/MainContent.test.tsx | 3 ++- .../src/ui/components/QuittingDisplay.test.tsx | 1 - packages/cli/src/ui/hooks/useGeminiStream.ts | 17 +++++++++++++++-- packages/cli/src/ui/utils/inlineThinkingMode.ts | 4 ---- schemas/settings.schema.json | 7 ------- 7 files changed, 21 insertions(+), 32 deletions(-) diff --git a/docs/get-started/configuration.md b/docs/get-started/configuration.md index 381f6b657b2..b695651db1a 100644 --- a/docs/get-started/configuration.md +++ b/docs/get-started/configuration.md @@ -179,17 +179,14 @@ their corresponding top-level category object in your `settings.json` file. - **Default:** `false` - **Requires restart:** Yes -- **`ui.showInlineThinking`** (boolean): - - **Description:** Show model thinking summaries inline in the conversation - (deprecated; prefer the specific thinking modes). - - **Default:** `false` - - **`ui.showInlineThinkingFull`** (boolean): - - **Description:** Show full model thinking details inline. + - **Description:** Show full model thinking details inline. If both full and + summary modes are enabled, full mode takes precedence. - **Default:** `false` - **`ui.showInlineThinkingSummary`** (boolean): - - **Description:** Show a short summary of model thinking inline. + - **Description:** Show a short summary of model thinking inline. Summaries + truncate long content (about 140 characters) and append an ellipsis. - **Default:** `false` - **`ui.showStatusInTitle`** (boolean): diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index ba461ecb0b4..2589933feac 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -373,16 +373,6 @@ 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 (deprecated; prefer the specific thinking modes).', - showInDialog: false, - }, showInlineThinkingFull: { type: 'boolean', label: 'Show Inline Thinking (Full)', diff --git a/packages/cli/src/ui/components/MainContent.test.tsx b/packages/cli/src/ui/components/MainContent.test.tsx index 06a7209d708..284edff82ca 100644 --- a/packages/cli/src/ui/components/MainContent.test.tsx +++ b/packages/cli/src/ui/components/MainContent.test.tsx @@ -19,7 +19,8 @@ vi.mock('../contexts/SettingsContext.js', async () => { useSettings: () => ({ merged: { ui: { - showInlineThinking: false, + showInlineThinkingFull: false, + showInlineThinkingSummary: false, }, }, }), diff --git a/packages/cli/src/ui/components/QuittingDisplay.test.tsx b/packages/cli/src/ui/components/QuittingDisplay.test.tsx index ab20a12d83c..b004b95ca8b 100644 --- a/packages/cli/src/ui/components/QuittingDisplay.test.tsx +++ b/packages/cli/src/ui/components/QuittingDisplay.test.tsx @@ -16,7 +16,6 @@ vi.mock('../contexts/SettingsContext.js', () => ({ useSettings: () => ({ merged: { ui: { - showInlineThinking: false, showInlineThinkingFull: false, showInlineThinkingSummary: false, }, diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts index aaa8d9c4403..7d1ee32d894 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.ts +++ b/packages/cli/src/ui/hooks/useGeminiStream.ts @@ -82,6 +82,17 @@ import path from 'node:path'; const MAX_THOUGHT_SUMMARY_LENGTH = 140; +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 summarizeThought(thought: ThoughtSummary): ThoughtSummary { const subject = thought.subject.trim(); if (subject) { @@ -93,12 +104,14 @@ function summarizeThought(thought: ThoughtSummary): ThoughtSummary { return { subject: '', description: '' }; } - if (description.length <= MAX_THOUGHT_SUMMARY_LENGTH) { + const descriptionGraphemes = splitGraphemes(description); + if (descriptionGraphemes.length <= MAX_THOUGHT_SUMMARY_LENGTH) { return { subject: description, description: '' }; } - const trimmed = description + const trimmed = descriptionGraphemes .slice(0, MAX_THOUGHT_SUMMARY_LENGTH - 3) + .join('') .trimEnd(); return { subject: `${trimmed}...`, description: '' }; } diff --git a/packages/cli/src/ui/utils/inlineThinkingMode.ts b/packages/cli/src/ui/utils/inlineThinkingMode.ts index 27c7ac8d046..2afb5d6a9a1 100644 --- a/packages/cli/src/ui/utils/inlineThinkingMode.ts +++ b/packages/cli/src/ui/utils/inlineThinkingMode.ts @@ -21,9 +21,5 @@ export function getInlineThinkingMode( return 'summary'; } - if (ui?.showInlineThinking) { - return 'full'; - } - return 'off'; } diff --git a/schemas/settings.schema.json b/schemas/settings.schema.json index 6dc942afbff..5627193f15c 100644 --- a/schemas/settings.schema.json +++ b/schemas/settings.schema.json @@ -187,13 +187,6 @@ "default": false, "type": "boolean" }, - "showInlineThinking": { - "title": "Show Inline Thinking", - "description": "Show model thinking summaries inline in the conversation (deprecated; prefer the specific thinking modes).", - "markdownDescription": "Show model thinking summaries inline in the conversation (deprecated; prefer the specific thinking modes).\n\n- Category: `UI`\n- Requires restart: `no`\n- Default: `false`", - "default": false, - "type": "boolean" - }, "showInlineThinkingFull": { "title": "Show Inline Thinking (Full)", "description": "Show full model thinking details inline.", From bebc51982c9a1fa5cae12dff8b1a5f55a63707a9 Mon Sep 17 00:00:00 2001 From: Dmitry Lyalin Date: Sun, 1 Feb 2026 15:16:22 -0500 Subject: [PATCH 15/24] Fix inline thinking box width --- packages/cli/src/ui/components/messages/ThinkingMessage.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/ui/components/messages/ThinkingMessage.tsx b/packages/cli/src/ui/components/messages/ThinkingMessage.tsx index d08a9c90e57..6769e3c3a2d 100644 --- a/packages/cli/src/ui/components/messages/ThinkingMessage.tsx +++ b/packages/cli/src/ui/components/messages/ThinkingMessage.tsx @@ -25,6 +25,7 @@ export const ThinkingMessage: React.FC = ({ const description = thought.description.trim(); const headerText = subject || description; const bodyText = subject ? description : ''; + const contentMaxWidth = Math.max(terminalWidth - 4, 1); const contentMaxHeight = availableTerminalHeight !== undefined ? Math.max(availableTerminalHeight - 4, MINIMUM_MAX_HEIGHT) @@ -40,7 +41,7 @@ export const ThinkingMessage: React.FC = ({ > {headerText && ( From e2cc788134df17304c98292b377898b27c13fbab Mon Sep 17 00:00:00 2001 From: Dmitry Lyalin Date: Sun, 1 Feb 2026 15:44:54 -0500 Subject: [PATCH 16/24] misc. --- docs/get-started/configuration.md | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/docs/get-started/configuration.md b/docs/get-started/configuration.md index b695651db1a..b2064e89591 100644 --- a/docs/get-started/configuration.md +++ b/docs/get-started/configuration.md @@ -180,13 +180,11 @@ their corresponding top-level category object in your `settings.json` file. - **Requires restart:** Yes - **`ui.showInlineThinkingFull`** (boolean): - - **Description:** Show full model thinking details inline. If both full and - summary modes are enabled, full mode takes precedence. + - **Description:** Show full model thinking details inline. - **Default:** `false` - **`ui.showInlineThinkingSummary`** (boolean): - - **Description:** Show a short summary of model thinking inline. Summaries - truncate long content (about 140 characters) and append an ellipsis. + - **Description:** Show a short summary of model thinking inline. - **Default:** `false` - **`ui.showStatusInTitle`** (boolean): From 697bc921106525f93684e349e01e94f8590dabe3 Mon Sep 17 00:00:00 2001 From: Dmitry Lyalin Date: Mon, 2 Feb 2026 17:53:23 -0500 Subject: [PATCH 17/24] refactor: move emoji detection to terminalUtils and add tests --- packages/cli/src/test-utils/render.tsx | 1 + .../cli/src/ui/components/shared/IconText.tsx | 21 +----- .../cli/src/ui/utils/terminalUtils.test.ts | 75 ++++++++++++++----- packages/cli/src/ui/utils/terminalUtils.ts | 22 ++++++ 4 files changed, 79 insertions(+), 40 deletions(-) diff --git a/packages/cli/src/test-utils/render.tsx b/packages/cli/src/test-utils/render.tsx index a9e997a8594..4425e12568f 100644 --- a/packages/cli/src/test-utils/render.tsx +++ b/packages/cli/src/test-utils/render.tsx @@ -42,6 +42,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/shared/IconText.tsx b/packages/cli/src/ui/components/shared/IconText.tsx index f23bb6a72d5..1b7ecbde015 100644 --- a/packages/cli/src/ui/components/shared/IconText.tsx +++ b/packages/cli/src/ui/components/shared/IconText.tsx @@ -6,7 +6,7 @@ import type React from 'react'; import { Text } from 'ink'; -import process from 'node:process'; +import { shouldUseEmoji } from '../../utils/terminalUtils.js'; interface IconTextProps { icon: string; @@ -30,22 +30,3 @@ export const IconText: React.FC = ({ ); }; - -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/packages/cli/src/ui/utils/terminalUtils.test.ts b/packages/cli/src/ui/utils/terminalUtils.test.ts index 70b2a08f170..7cc3b23ad16 100644 --- a/packages/cli/src/ui/utils/terminalUtils.test.ts +++ b/packages/cli/src/ui/utils/terminalUtils.test.ts @@ -5,12 +5,16 @@ */ 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('ITERM_SESSION_ID', ''); + vi.stubEnv('LC_ALL', ''); + vi.stubEnv('LC_CTYPE', ''); + vi.stubEnv('LANG', ''); + vi.stubEnv('TERM', ''); resetITerm2Cache(); }); @@ -19,30 +23,61 @@ 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 detect iTerm2 via ITERM_SESSION_ID', () => { - vi.stubEnv('ITERM_SESSION_ID', 'w0t0p0:6789...'); - expect(isITerm2()).toBe(true); - }); + it('should detect iTerm2 via ITERM_SESSION_ID', () => { + vi.stubEnv('ITERM_SESSION_ID', 'w0t0p0:6789...'); + 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); - it('should return false if not iTerm2', () => { - vi.stubEnv('TERM_PROGRAM', 'vscode'); - expect(isITerm2()).toBe(false); + // 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 cache the result', () => { - vi.stubEnv('TERM_PROGRAM', 'iTerm.app'); - expect(isITerm2()).toBe(true); + describe('shouldUseEmoji', () => { + it('should return true when UTF-8 is supported', () => { + vi.stubEnv('LANG', 'en_US.UTF-8'); + 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 return true when utf8 (no hyphen) is supported', () => { + vi.stubEnv('LANG', 'en_US.utf8'); + expect(shouldUseEmoji()).toBe(true); + }); - resetITerm2Cache(); - expect(isITerm2()).toBe(false); + it('should check LC_ALL first', () => { + vi.stubEnv('LC_ALL', 'en_US.UTF-8'); + vi.stubEnv('LANG', 'C'); + expect(shouldUseEmoji()).toBe(true); + }); + + 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 5c03198f713..ea83c43e890 100644 --- a/packages/cli/src/ui/utils/terminalUtils.ts +++ b/packages/cli/src/ui/utils/terminalUtils.ts @@ -45,3 +45,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; +} From 933725e63e13efb6f9fe5f542c057d275273e466 Mon Sep 17 00:00:00 2001 From: Dmitry Lyalin Date: Tue, 3 Feb 2026 16:04:38 -0500 Subject: [PATCH 18/24] Fix review issues: copyright years, import ordering, test consistency --- .../ui/components/messages/ThinkingMessage.test.tsx | 10 +++++----- packages/cli/src/ui/components/shared/IconText.tsx | 2 +- packages/cli/src/ui/hooks/useGeminiStream.ts | 6 +++--- packages/cli/src/ui/utils/inlineThinkingMode.ts | 2 +- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/cli/src/ui/components/messages/ThinkingMessage.test.tsx b/packages/cli/src/ui/components/messages/ThinkingMessage.test.tsx index 20caec2971e..3dec53239a3 100644 --- a/packages/cli/src/ui/components/messages/ThinkingMessage.test.tsx +++ b/packages/cli/src/ui/components/messages/ThinkingMessage.test.tsx @@ -5,12 +5,12 @@ */ import { describe, it, expect } from 'vitest'; -import { render } from '../../../test-utils/render.js'; +import { renderWithProviders } from '../../../test-utils/render.js'; import { ThinkingMessage } from './ThinkingMessage.js'; describe('ThinkingMessage', () => { it('renders thinking subject', () => { - const { lastFrame } = render( + const { lastFrame } = renderWithProviders( { }); it('renders with thought subject', () => { - const { lastFrame } = render( + const { lastFrame } = renderWithProviders( { }); it('renders thought content', () => { - const { lastFrame } = render( + const { lastFrame } = renderWithProviders( { }); it('renders empty state gracefully', () => { - const { lastFrame } = render( + const { lastFrame } = renderWithProviders( Date: Thu, 5 Feb 2026 08:55:54 -0500 Subject: [PATCH 19/24] refactor(settings): consolidate inline thinking settings into single enum Replace separate showInlineThinkingFull and showInlineThinkingSummary boolean settings with a single inlineThinkingMode enum setting that accepts 'off', 'summary', or 'full' values. This simplifies configuration and provides a cleaner UI in the settings dialog with a single dropdown instead of two checkboxes. --- docs/cli/settings.md | 3 +-- docs/get-started/configuration.md | 11 +++----- packages/cli/src/config/settingsSchema.ts | 25 ++++++++----------- .../src/ui/components/MainContent.test.tsx | 3 +-- .../ui/components/QuittingDisplay.test.tsx | 3 +-- .../cli/src/ui/utils/inlineThinkingMode.ts | 12 +-------- schemas/settings.schema.json | 20 ++++++--------- 7 files changed, 26 insertions(+), 51 deletions(-) diff --git a/docs/cli/settings.md b/docs/cli/settings.md index b67d7123382..cd0c428ee8e 100644 --- a/docs/cli/settings.md +++ b/docs/cli/settings.md @@ -44,8 +44,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` | -| Show Inline Thinking (Full) | `ui.showInlineThinkingFull` | Show full model thinking details inline. | `false` | -| Show Inline Thinking (Summary) | `ui.showInlineThinkingSummary` | Show a short summary of model thinking inline. | `false` | +| Inline Thinking | `ui.inlineThinkingMode` | Display model thinking inline: off, summary (truncated), 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 63e6ffdaf21..8fc3b8d6f6d 100644 --- a/docs/get-started/configuration.md +++ b/docs/get-started/configuration.md @@ -188,13 +188,10 @@ their corresponding top-level category object in your `settings.json` file. - **Default:** `false` - **Requires restart:** Yes -- **`ui.showInlineThinkingFull`** (boolean): - - **Description:** Show full model thinking details inline. - - **Default:** `false` - -- **`ui.showInlineThinkingSummary`** (boolean): - - **Description:** Show a short summary of model thinking inline. - - **Default:** `false` +- **`ui.inlineThinkingMode`** (enum: `off`, `summary`, `full`): + - **Description:** Display model thinking inline: off, summary (truncated), or + full. + - **Default:** `off` - **`ui.showStatusInTitle`** (boolean): - **Description:** Show Gemini CLI model thoughts in the terminal window title diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 6025c5f338a..a866e98a939 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -393,23 +393,20 @@ const SETTINGS_SCHEMA = { description: 'Hide the window title bar', showInDialog: true, }, - showInlineThinkingFull: { - type: 'boolean', - label: 'Show Inline Thinking (Full)', - category: 'UI', - requiresRestart: false, - default: false, - description: 'Show full model thinking details inline.', - showInDialog: true, - }, - showInlineThinkingSummary: { - type: 'boolean', - label: 'Show Inline Thinking (Summary)', + inlineThinkingMode: { + type: 'enum', + label: 'Inline Thinking', category: 'UI', requiresRestart: false, - default: false, - description: 'Show a short summary of model thinking inline.', + default: 'off', + description: + 'Display model thinking inline: off, summary (truncated), or full.', showInDialog: true, + options: [ + { value: 'off', label: 'Off' }, + { value: 'summary', label: 'Summary' }, + { value: 'full', label: 'Full' }, + ], }, showStatusInTitle: { type: 'boolean', diff --git a/packages/cli/src/ui/components/MainContent.test.tsx b/packages/cli/src/ui/components/MainContent.test.tsx index 284edff82ca..a575a4c738d 100644 --- a/packages/cli/src/ui/components/MainContent.test.tsx +++ b/packages/cli/src/ui/components/MainContent.test.tsx @@ -19,8 +19,7 @@ vi.mock('../contexts/SettingsContext.js', async () => { useSettings: () => ({ merged: { ui: { - showInlineThinkingFull: false, - showInlineThinkingSummary: false, + inlineThinkingMode: 'off', }, }, }), diff --git a/packages/cli/src/ui/components/QuittingDisplay.test.tsx b/packages/cli/src/ui/components/QuittingDisplay.test.tsx index b004b95ca8b..dea08fd6bb5 100644 --- a/packages/cli/src/ui/components/QuittingDisplay.test.tsx +++ b/packages/cli/src/ui/components/QuittingDisplay.test.tsx @@ -16,8 +16,7 @@ vi.mock('../contexts/SettingsContext.js', () => ({ useSettings: () => ({ merged: { ui: { - showInlineThinkingFull: false, - showInlineThinkingSummary: false, + inlineThinkingMode: 'off', }, }, }), diff --git a/packages/cli/src/ui/utils/inlineThinkingMode.ts b/packages/cli/src/ui/utils/inlineThinkingMode.ts index e943d73404b..11f73312d86 100644 --- a/packages/cli/src/ui/utils/inlineThinkingMode.ts +++ b/packages/cli/src/ui/utils/inlineThinkingMode.ts @@ -11,15 +11,5 @@ export type InlineThinkingMode = 'off' | 'summary' | 'full'; export function getInlineThinkingMode( settings: LoadedSettings, ): InlineThinkingMode { - const ui = settings.merged.ui; - - if (ui?.showInlineThinkingFull) { - return 'full'; - } - - if (ui?.showInlineThinkingSummary) { - return 'summary'; - } - - return 'off'; + return settings.merged.ui?.inlineThinkingMode ?? 'off'; } diff --git a/schemas/settings.schema.json b/schemas/settings.schema.json index ee528502855..271b163c949 100644 --- a/schemas/settings.schema.json +++ b/schemas/settings.schema.json @@ -201,19 +201,13 @@ "default": false, "type": "boolean" }, - "showInlineThinkingFull": { - "title": "Show Inline Thinking (Full)", - "description": "Show full model thinking details inline.", - "markdownDescription": "Show full model thinking details inline.\n\n- Category: `UI`\n- Requires restart: `no`\n- Default: `false`", - "default": false, - "type": "boolean" - }, - "showInlineThinkingSummary": { - "title": "Show Inline Thinking (Summary)", - "description": "Show a short summary of model thinking inline.", - "markdownDescription": "Show a short summary of model thinking inline.\n\n- Category: `UI`\n- Requires restart: `no`\n- Default: `false`", - "default": false, - "type": "boolean" + "inlineThinkingMode": { + "title": "Inline Thinking", + "description": "Display model thinking inline: off, summary (truncated), or full.", + "markdownDescription": "Display model thinking inline: off, summary (truncated), or full.\n\n- Category: `UI`\n- Requires restart: `no`\n- Default: `off`", + "default": "off", + "type": "string", + "enum": ["off", "summary", "full"] }, "showStatusInTitle": { "title": "Show Thoughts in Title", From b0af75324b7bfa80bda8c2d6edad303795d16715 Mon Sep 17 00:00:00 2001 From: Dmitry Lyalin Date: Thu, 5 Feb 2026 09:13:17 -0500 Subject: [PATCH 20/24] misc. --- docs/cli/settings.md | 2 +- docs/get-started/configuration.md | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/cli/settings.md b/docs/cli/settings.md index cd0c428ee8e..13c7fd31f30 100644 --- a/docs/cli/settings.md +++ b/docs/cli/settings.md @@ -44,7 +44,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, summary (truncated), or full. | `off` | +| Inline Thinking | `ui.inlineThinkingMode` | Display model thinking inline: off, summary (truncated), 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 a80e9886282..ae10f28c5f9 100644 --- a/docs/get-started/configuration.md +++ b/docs/get-started/configuration.md @@ -188,10 +188,11 @@ their corresponding top-level category object in your `settings.json` file. - **Default:** `false` - **Requires restart:** Yes -- **`ui.inlineThinkingMode`** (enum: `off`, `summary`, `full`): +- **`ui.inlineThinkingMode`** (enum): - **Description:** Display model thinking inline: off, summary (truncated), or full. - - **Default:** `off` + - **Default:** `"off"` + - **Values:** `"off"`, `"summary"`, `"full"` - **`ui.showStatusInTitle`** (boolean): - **Description:** Show Gemini CLI model thoughts in the terminal window title From b93d6107ec9e4df60d60b795660891f7478b90ab Mon Sep 17 00:00:00 2001 From: Dmitry Lyalin Date: Sun, 8 Feb 2026 22:59:25 -0500 Subject: [PATCH 21/24] feat(cli): streamline inline thinking display --- .../AlternateBufferQuittingDisplay.tsx | 6 +- packages/cli/src/ui/components/Composer.tsx | 3 + .../ui/components/HistoryItemDisplay.test.tsx | 12 +- .../src/ui/components/HistoryItemDisplay.tsx | 11 +- .../ui/components/LoadingIndicator.test.tsx | 44 ++++ .../src/ui/components/LoadingIndicator.tsx | 12 + .../src/ui/components/MainContent.test.tsx | 2 + .../cli/src/ui/components/MainContent.tsx | 14 +- .../cli/src/ui/components/QuittingDisplay.tsx | 4 +- .../messages/ThinkingMessage.test.tsx | 55 +++- .../components/messages/ThinkingMessage.tsx | 243 +++++++++++++++--- .../cli/src/ui/components/shared/IconText.tsx | 32 --- .../cli/src/ui/hooks/useGeminiStream.test.tsx | 104 ++++++++ packages/cli/src/ui/hooks/useGeminiStream.ts | 79 ++---- 14 files changed, 476 insertions(+), 145 deletions(-) delete mode 100644 packages/cli/src/ui/components/shared/IconText.tsx diff --git a/packages/cli/src/ui/components/AlternateBufferQuittingDisplay.tsx b/packages/cli/src/ui/components/AlternateBufferQuittingDisplay.tsx index abe68ebbf97..bc54fd72db0 100644 --- a/packages/cli/src/ui/components/AlternateBufferQuittingDisplay.tsx +++ b/packages/cli/src/ui/components/AlternateBufferQuittingDisplay.tsx @@ -27,7 +27,7 @@ export const AlternateBufferQuittingDisplay = () => { const confirmingTool = useConfirmingTool(); const showPromptedTool = config.isEventDrivenSchedulerEnabled() && confirmingTool !== null; - const inlineEnabled = getInlineThinkingMode(settings) !== 'off'; + 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 @@ -51,7 +51,7 @@ export const AlternateBufferQuittingDisplay = () => { item={h} isPending={false} commands={uiState.slashCommands} - inlineEnabled={inlineEnabled} + inlineThinkingMode={inlineThinkingMode} /> ))} {uiState.pendingHistoryItems.map((item, i) => ( @@ -64,7 +64,7 @@ export const AlternateBufferQuittingDisplay = () => { isFocused={false} activeShellPtyId={uiState.activePtyId} embeddedShellFocused={uiState.embeddedShellFocused} - inlineEnabled={inlineEnabled} + inlineThinkingMode={inlineThinkingMode} /> ))} {showPromptedTool && ( diff --git a/packages/cli/src/ui/components/Composer.tsx b/packages/cli/src/ui/components/Composer.tsx index ee074c1c77c..c3267e1a081 100644 --- a/packages/cli/src/ui/components/Composer.tsx +++ b/packages/cli/src/ui/components/Composer.tsx @@ -128,6 +128,9 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => { } elapsedTime={uiState.elapsedTime} showCancelAndTimer={false} + showThoughtIndicator={ + settings.merged.ui.inlineThinkingMode !== 'off' + } /> )} diff --git a/packages/cli/src/ui/components/HistoryItemDisplay.test.tsx b/packages/cli/src/ui/components/HistoryItemDisplay.test.tsx index e2938be0b83..0a1cb8f5361 100644 --- a/packages/cli/src/ui/components/HistoryItemDisplay.test.tsx +++ b/packages/cli/src/ui/components/HistoryItemDisplay.test.tsx @@ -240,7 +240,11 @@ describe('', () => { thought: { subject: 'Thinking', description: 'test' }, }; const { lastFrame } = renderWithProviders( - , + , ); expect(lastFrame()).toContain('Thinking'); @@ -253,7 +257,11 @@ describe('', () => { thought: { subject: 'Thinking', description: 'test' }, }; const { lastFrame } = renderWithProviders( - , + , ); expect(lastFrame()).toBe(''); diff --git a/packages/cli/src/ui/components/HistoryItemDisplay.tsx b/packages/cli/src/ui/components/HistoryItemDisplay.tsx index 8b13a004a49..219498d26d3 100644 --- a/packages/cli/src/ui/components/HistoryItemDisplay.tsx +++ b/packages/cli/src/ui/components/HistoryItemDisplay.tsx @@ -35,6 +35,7 @@ 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; @@ -46,7 +47,7 @@ interface HistoryItemDisplayProps { activeShellPtyId?: number | null; embeddedShellFocused?: boolean; availableTerminalHeightGemini?: number; - inlineEnabled?: boolean; + inlineThinkingMode?: InlineThinkingMode; } export const HistoryItemDisplay: React.FC = ({ @@ -59,20 +60,18 @@ export const HistoryItemDisplay: React.FC = ({ activeShellPtyId, embeddedShellFocused, availableTerminalHeightGemini, - inlineEnabled, + inlineThinkingMode = 'off', }) => { const itemForDisplay = useMemo(() => escapeAnsiCtrlCodes(item), [item]); return ( {/* Render standard message types */} - {itemForDisplay.type === 'thinking' && inlineEnabled && ( + {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 e76c4d49f3b..d97a27c3e04 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, @@ -209,6 +215,7 @@ describe('', () => { description: 'and other stuff.', }, elapsedTime: 5, + showThoughtIndicator: true, }; const { lastFrame, unmount } = renderWithContext( , @@ -217,12 +224,34 @@ 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, + showThoughtIndicator: true, + }; + 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: { @@ -231,17 +260,32 @@ describe('', () => { }, currentLoadingPhrase: 'This should not be displayed', elapsedTime: 5, + showThoughtIndicator: true, }; const { lastFrame, unmount } = renderWithContext( , 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( = ({ @@ -32,6 +34,7 @@ export const LoadingIndicator: React.FC = ({ rightContent, thought, showCancelAndTimer = true, + showThoughtIndicator = false, }) => { const streamingState = useStreamingContext(); const { columns: terminalWidth } = useTerminalSize(); @@ -51,6 +54,13 @@ export const LoadingIndicator: React.FC = ({ currentLoadingPhrase === INTERACTIVE_SHELL_WAITING_PHRASE ? currentLoadingPhrase : thought?.subject || currentLoadingPhrase; + const hasThoughtIndicator = + showThoughtIndicator && + currentLoadingPhrase !== INTERACTIVE_SHELL_WAITING_PHRASE && + Boolean(thought?.subject?.trim()); + const thinkingIndicator = hasThoughtIndicator + ? `${shouldUseEmoji() ? '💬' : 'o'} ` + : ''; const cancelAndTimerContent = showCancelAndTimer && @@ -72,6 +82,7 @@ export const LoadingIndicator: React.FC = ({ {primaryText && ( + {thinkingIndicator} {primaryText} )} @@ -105,6 +116,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 6fa5a09a74d..3a9e363d691 100644 --- a/packages/cli/src/ui/components/MainContent.test.tsx +++ b/packages/cli/src/ui/components/MainContent.test.tsx @@ -82,6 +82,7 @@ describe('MainContent', () => { availableTerminalHeight: 24, slashCommands: [], constrainHeight: false, + thought: null, isEditorDialogOpen: false, activePtyId: undefined, embeddedShellFocused: false, @@ -199,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 17b2d7fe8c8..c8007df1101 100644 --- a/packages/cli/src/ui/components/MainContent.tsx +++ b/packages/cli/src/ui/components/MainContent.tsx @@ -56,7 +56,7 @@ export const MainContent = () => { availableTerminalHeight, } = uiState; - const inlineEnabled = getInlineThinkingMode(settings) !== 'off'; + const inlineThinkingMode = getInlineThinkingMode(settings); const historyItems = useMemo( () => @@ -69,7 +69,7 @@ export const MainContent = () => { item={h} isPending={false} commands={uiState.slashCommands} - inlineEnabled={inlineEnabled} + inlineThinkingMode={inlineThinkingMode} /> )), [ @@ -77,7 +77,7 @@ export const MainContent = () => { mainAreaWidth, staticAreaMaxItemHeight, uiState.slashCommands, - inlineEnabled, + inlineThinkingMode, ], ); @@ -99,7 +99,7 @@ export const MainContent = () => { isFocused={!uiState.isEditorDialogOpen} activeShellPtyId={uiState.activePtyId} embeddedShellFocused={uiState.embeddedShellFocused} - inlineEnabled={inlineEnabled} + inlineThinkingMode={inlineThinkingMode} /> ))} {showConfirmationQueue && confirmingTool && ( @@ -113,7 +113,7 @@ export const MainContent = () => { isAlternateBuffer, availableTerminalHeight, mainAreaWidth, - inlineEnabled, + inlineThinkingMode, uiState.isEditorDialogOpen, uiState.activePtyId, uiState.embeddedShellFocused, @@ -145,7 +145,7 @@ export const MainContent = () => { item={item.item} isPending={false} commands={uiState.slashCommands} - inlineEnabled={inlineEnabled} + inlineThinkingMode={inlineThinkingMode} /> ); } else { @@ -156,7 +156,7 @@ export const MainContent = () => { version, mainAreaWidth, uiState.slashCommands, - inlineEnabled, + inlineThinkingMode, pendingItems, ], ); diff --git a/packages/cli/src/ui/components/QuittingDisplay.tsx b/packages/cli/src/ui/components/QuittingDisplay.tsx index f2770b998d1..407b970ed74 100644 --- a/packages/cli/src/ui/components/QuittingDisplay.tsx +++ b/packages/cli/src/ui/components/QuittingDisplay.tsx @@ -17,7 +17,7 @@ export const QuittingDisplay = () => { const { rows: terminalHeight, columns: terminalWidth } = useTerminalSize(); const availableTerminalHeight = terminalHeight; - const inlineEnabled = getInlineThinkingMode(settings) !== 'off'; + const inlineThinkingMode = getInlineThinkingMode(settings); if (!uiState.quittingMessages) { return null; @@ -34,7 +34,7 @@ export const QuittingDisplay = () => { terminalWidth={terminalWidth} item={item} isPending={false} - inlineEnabled={inlineEnabled} + inlineThinkingMode={inlineThinkingMode} /> ))} diff --git a/packages/cli/src/ui/components/messages/ThinkingMessage.test.tsx b/packages/cli/src/ui/components/messages/ThinkingMessage.test.tsx index 3dec53239a3..d4ffd645430 100644 --- a/packages/cli/src/ui/components/messages/ThinkingMessage.test.tsx +++ b/packages/cli/src/ui/components/messages/ThinkingMessage.test.tsx @@ -9,29 +9,33 @@ import { renderWithProviders } from '../../../test-utils/render.js'; import { ThinkingMessage } from './ThinkingMessage.js'; describe('ThinkingMessage', () => { - it('renders thinking subject', () => { + it('renders summary mode text without icon chrome', () => { const { lastFrame } = renderWithProviders( , ); expect(lastFrame()).toContain('Planning'); + expect(lastFrame()).not.toContain('💬'); + expect(lastFrame()).not.toContain('╭'); }); - it('renders with thought subject', () => { + it('uses description when subject is empty in summary mode', () => { const { lastFrame } = renderWithProviders( , ); - expect(lastFrame()).toContain('Processing'); + expect(lastFrame()).toContain('Processing details'); }); - it('renders thought content', () => { + it('renders full mode with left vertical rule and full text', () => { const { lastFrame } = renderWithProviders( { description: 'I am planning the solution.', }} terminalWidth={80} + mode="full" />, ); + 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( , ); diff --git a/packages/cli/src/ui/components/messages/ThinkingMessage.tsx b/packages/cli/src/ui/components/messages/ThinkingMessage.tsx index 6769e3c3a2d..90da377aa0b 100644 --- a/packages/cli/src/ui/components/messages/ThinkingMessage.tsx +++ b/packages/cli/src/ui/components/messages/ThinkingMessage.tsx @@ -5,58 +5,235 @@ */ import type React from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { Box, Text } from 'ink'; import type { ThoughtSummary } from '@google/gemini-cli-core'; -import { MaxSizedBox, MINIMUM_MAX_HEIGHT } from '../shared/MaxSizedBox.js'; -import { IconText } from '../shared/IconText.js'; +import { theme } from '../../semantic-colors.js'; +import type { InlineThinkingMode } from '../../utils/inlineThinkingMode.js'; interface ThinkingMessageProps { thought: ThoughtSummary; terminalWidth: number; - availableTerminalHeight?: number; + mode: Exclude; +} + +const MAX_THOUGHT_SUMMARY_LENGTH = 140; +const SUMMARY_BLINK_INTERVAL_MS = 450; +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 summarizeThought(thought: ThoughtSummary): string { + const subject = normalizeEscapedNewlines(thought.subject).trim(); + if (subject) { + return subject; + } + + const description = normalizeEscapedNewlines(thought.description).trim(); + if (!description) { + return ''; + } + + const descriptionGraphemes = splitGraphemes(description); + if (descriptionGraphemes.length <= MAX_THOUGHT_SUMMARY_LENGTH) { + return description; + } + + const trimmed = descriptionGraphemes + .slice(0, MAX_THOUGHT_SUMMARY_LENGTH - 3) + .join('') + .trimEnd(); + return `${trimmed}...`; +} + +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, - availableTerminalHeight, + mode, }) => { - const subject = thought.subject.trim(); - const description = thought.description.trim(); - const headerText = subject || description; - const bodyText = subject ? description : ''; - const contentMaxWidth = Math.max(terminalWidth - 4, 1); - const contentMaxHeight = - availableTerminalHeight !== undefined - ? Math.max(availableTerminalHeight - 4, MINIMUM_MAX_HEIGHT) - : undefined; + const [isBlinkVisible, setIsBlinkVisible] = useState(true); + const summaryText = useMemo(() => summarizeThought(thought), [thought]); + 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]); + const shouldBlinkSummary = + mode === 'summary' && + summaryText.length > 0 && + process.env['NODE_ENV'] !== 'test'; + + useEffect(() => { + if (!shouldBlinkSummary) { + return; + } + + setIsBlinkVisible(true); + const interval = setInterval(() => { + setIsBlinkVisible((current) => !current); + }, SUMMARY_BLINK_INTERVAL_MS); + return () => clearInterval(interval); + }, [shouldBlinkSummary, summaryText]); + + if (mode === 'summary') { + if (!summaryText) { + return null; + } + + return ( + + + {isBlinkVisible ? '●' : ' '} + + + {summaryText} + + + ); + } + + if ( + fullSummaryDisplayLines.length === 0 && + fullBodyDisplayLines.length === 0 + ) { + return null; + } + return ( - - {headerText && ( - - - {bodyText && {bodyText}} + {fullSummaryDisplayLines.map((line, index) => ( + + + + + + {line} + + + ))} + {fullBodyDisplayLines.map((line, index) => ( + + + - )} - + + {line} + + + ))} ); }; diff --git a/packages/cli/src/ui/components/shared/IconText.tsx b/packages/cli/src/ui/components/shared/IconText.tsx deleted file mode 100644 index 2ad7728b673..00000000000 --- a/packages/cli/src/ui/components/shared/IconText.tsx +++ /dev/null @@ -1,32 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import type React from 'react'; -import { Text } from 'ink'; -import { shouldUseEmoji } from '../../utils/terminalUtils.js'; - -interface IconTextProps { - icon: string; - fallbackIcon?: string; - text: string; - color?: string; - bold?: boolean; -} - -export const IconText: React.FC = ({ - icon, - fallbackIcon, - text, - color, - bold, -}) => { - const resolvedIcon = shouldUseEmoji() ? icon : (fallbackIcon ?? icon); - return ( - - {resolvedIcon} {text} - - ); -}; diff --git a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx index 1c4434a34a5..294c537af4c 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx +++ b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx @@ -2505,6 +2505,110 @@ describe('useGeminiStream', () => { }); }); 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 cec03b92612..932d3c65fad 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.ts +++ b/packages/cli/src/ui/hooks/useGeminiStream.ts @@ -51,9 +51,9 @@ import type { import { type Part, type PartListUnion, FinishReason } from '@google/genai'; import type { HistoryItem, + HistoryItemThinking, HistoryItemWithoutId, HistoryItemToolGroup, - HistoryItemThinking, IndividualToolCallDisplay, SlashCommandProcessorResult, HistoryItemModel, @@ -83,42 +83,6 @@ import { useSessionStats } from '../contexts/SessionContext.js'; import { useKeypress } from './useKeypress.js'; import type { LoadedSettings } from '../../config/settings.js'; -const MAX_THOUGHT_SUMMARY_LENGTH = 140; - -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 summarizeThought(thought: ThoughtSummary): ThoughtSummary { - const subject = thought.subject.trim(); - if (subject) { - return { subject, description: '' }; - } - - const description = thought.description.trim(); - if (!description) { - return { subject: '', description: '' }; - } - - const descriptionGraphemes = splitGraphemes(description); - if (descriptionGraphemes.length <= MAX_THOUGHT_SUMMARY_LENGTH) { - return { subject: description, description: '' }; - } - - const trimmed = descriptionGraphemes - .slice(0, MAX_THOUGHT_SUMMARY_LENGTH - 3) - .join('') - .trimEnd(); - return { subject: `${trimmed}...`, description: '' }; -} - type ToolResponseWithParts = ToolCallResponseInfo & { llmContent?: PartListUnion; }; @@ -231,7 +195,8 @@ 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); @@ -839,25 +804,17 @@ export const useGeminiStream = ( (eventValue: ThoughtSummary, userMessageTimestamp: number) => { setThought(eventValue); - const inlineThinkingMode = getInlineThinkingMode(settings); - if (inlineThinkingMode === 'off') { - return; + if (getInlineThinkingMode(settings) === 'full') { + addItem( + { + type: 'thinking', + thought: eventValue, + } as HistoryItemThinking, + userMessageTimestamp, + ); } - - const thoughtForDisplay = - inlineThinkingMode === 'summary' - ? summarizeThought(eventValue) - : eventValue; - - addItem( - { - type: 'thinking', - thought: thoughtForDisplay, - } as HistoryItemThinking, - userMessageTimestamp, - ); }, - [addItem, settings], + [addItem, settings, setThought], ); const handleUserCancelledEvent = useCallback( @@ -1129,6 +1086,14 @@ export const useGeminiStream = ( let geminiMessageBuffer = ''; const toolCallRequests: ToolCallRequestInfo[] = []; for await (const event of stream) { + if ( + event.type !== ServerGeminiEventType.Thought && + getInlineThinkingMode(settings) === 'summary' && + thoughtRef.current !== null + ) { + setThought(null); + } + switch (event.type) { case ServerGeminiEventType.Thought: setLastGeminiActivityTime(Date.now()); @@ -1220,6 +1185,8 @@ export const useGeminiStream = ( [ handleContentEvent, handleThoughtEvent, + thoughtRef, + settings, handleUserCancelledEvent, handleErrorEvent, scheduleToolCalls, @@ -1234,6 +1201,7 @@ export const useGeminiStream = ( addItem, pendingHistoryItemRef, setPendingHistoryItem, + setThought, ], ); const submitQuery = useCallback( @@ -1414,6 +1382,7 @@ export const useGeminiStream = ( config, startNewPrompt, getPromptCount, + setThought, ], ); From 48811129ff29643827453bab263c9c2b99bb1fa1 Mon Sep 17 00:00:00 2001 From: Dmitry Lyalin Date: Sun, 8 Feb 2026 23:13:28 -0500 Subject: [PATCH 22/24] feat(cli): streamline inline thinking and status indicator --- packages/cli/src/config/settingsSchema.ts | 4 +- .../cli/src/ui/components/Composer.test.tsx | 35 ++++++++-- packages/cli/src/ui/components/Composer.tsx | 8 ++- .../ui/components/HistoryItemDisplay.test.tsx | 2 +- .../src/ui/components/HistoryItemDisplay.tsx | 1 - .../ui/components/LoadingIndicator.test.tsx | 4 -- .../src/ui/components/LoadingIndicator.tsx | 9 +-- .../messages/ThinkingMessage.test.tsx | 12 +--- .../components/messages/ThinkingMessage.tsx | 70 +------------------ packages/cli/src/ui/hooks/useGeminiStream.ts | 2 - .../cli/src/ui/utils/inlineThinkingMode.ts | 2 +- 11 files changed, 47 insertions(+), 102 deletions(-) diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index d3747da61d5..b41003dbd66 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -389,12 +389,10 @@ const SETTINGS_SCHEMA = { category: 'UI', requiresRestart: false, default: 'off', - description: - 'Display model thinking inline: off, summary (truncated), or full.', + description: 'Display model thinking inline: off or full.', showInDialog: true, options: [ { value: 'off', label: 'Off' }, - { value: 'summary', label: 'Summary' }, { value: 'full', label: 'Full' }, ], }, diff --git a/packages/cli/src/ui/components/Composer.test.tsx b/packages/cli/src/ui/components/Composer.test.tsx index 73765dcf045..d7867d77dbe 100644 --- a/packages/cli/src/ui/components/Composer.test.tsx +++ b/packages/cli/src/ui/components/Composer.test.tsx @@ -28,9 +28,18 @@ import { StreamingState, ToolCallStatus } from '../types.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', () => ({ @@ -276,7 +285,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 c3267e1a081..b6fa489c677 100644 --- a/packages/cli/src/ui/components/Composer.tsx +++ b/packages/cli/src/ui/components/Composer.tsx @@ -31,6 +31,7 @@ import { StreamingState, ToolCallStatus } from '../types.js'; import { ConfigInitDisplay } from '../components/ConfigInitDisplay.js'; import { TodoTray } from './messages/Todo.js'; import { theme } from '../semantic-colors.js'; +import { getInlineThinkingMode } from '../utils/inlineThinkingMode.js'; export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => { const config = useConfig(); @@ -39,6 +40,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)); @@ -126,11 +128,11 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => { ? undefined : uiState.currentLoadingPhrase } + thoughtLabel={ + inlineThinkingMode === 'full' ? 'Thinking ...' : undefined + } elapsedTime={uiState.elapsedTime} showCancelAndTimer={false} - showThoughtIndicator={ - settings.merged.ui.inlineThinkingMode !== 'off' - } /> )} diff --git a/packages/cli/src/ui/components/HistoryItemDisplay.test.tsx b/packages/cli/src/ui/components/HistoryItemDisplay.test.tsx index 0a1cb8f5361..40c71fe327b 100644 --- a/packages/cli/src/ui/components/HistoryItemDisplay.test.tsx +++ b/packages/cli/src/ui/components/HistoryItemDisplay.test.tsx @@ -243,7 +243,7 @@ describe('', () => { , ); diff --git a/packages/cli/src/ui/components/HistoryItemDisplay.tsx b/packages/cli/src/ui/components/HistoryItemDisplay.tsx index 219498d26d3..9613431d730 100644 --- a/packages/cli/src/ui/components/HistoryItemDisplay.tsx +++ b/packages/cli/src/ui/components/HistoryItemDisplay.tsx @@ -71,7 +71,6 @@ export const HistoryItemDisplay: React.FC = ({ )} {itemForDisplay.type === 'user' && ( diff --git a/packages/cli/src/ui/components/LoadingIndicator.test.tsx b/packages/cli/src/ui/components/LoadingIndicator.test.tsx index d97a27c3e04..3c13df6e411 100644 --- a/packages/cli/src/ui/components/LoadingIndicator.test.tsx +++ b/packages/cli/src/ui/components/LoadingIndicator.test.tsx @@ -215,7 +215,6 @@ describe('', () => { description: 'and other stuff.', }, elapsedTime: 5, - showThoughtIndicator: true, }; const { lastFrame, unmount } = renderWithContext( , @@ -239,7 +238,6 @@ describe('', () => { description: 'details', }, elapsedTime: 5, - showThoughtIndicator: true, }; const { lastFrame, unmount } = renderWithContext( , @@ -260,7 +258,6 @@ describe('', () => { }, currentLoadingPhrase: 'This should not be displayed', elapsedTime: 5, - showThoughtIndicator: true, }; const { lastFrame, unmount } = renderWithContext( , @@ -278,7 +275,6 @@ describe('', () => { , StreamingState.Responding, ); diff --git a/packages/cli/src/ui/components/LoadingIndicator.tsx b/packages/cli/src/ui/components/LoadingIndicator.tsx index 9d8be579d85..f2e9c4ff406 100644 --- a/packages/cli/src/ui/components/LoadingIndicator.tsx +++ b/packages/cli/src/ui/components/LoadingIndicator.tsx @@ -23,8 +23,8 @@ interface LoadingIndicatorProps { inline?: boolean; rightContent?: React.ReactNode; thought?: ThoughtSummary | null; + thoughtLabel?: string; showCancelAndTimer?: boolean; - showThoughtIndicator?: boolean; } export const LoadingIndicator: React.FC = ({ @@ -33,8 +33,8 @@ export const LoadingIndicator: React.FC = ({ inline = false, rightContent, thought, + thoughtLabel, showCancelAndTimer = true, - showThoughtIndicator = false, }) => { const streamingState = useStreamingContext(); const { columns: terminalWidth } = useTerminalSize(); @@ -53,9 +53,10 @@ export const LoadingIndicator: React.FC = ({ const primaryText = currentLoadingPhrase === INTERACTIVE_SHELL_WAITING_PHRASE ? currentLoadingPhrase - : thought?.subject || currentLoadingPhrase; + : thought?.subject + ? (thoughtLabel ?? thought.subject) + : currentLoadingPhrase; const hasThoughtIndicator = - showThoughtIndicator && currentLoadingPhrase !== INTERACTIVE_SHELL_WAITING_PHRASE && Boolean(thought?.subject?.trim()); const thinkingIndicator = hasThoughtIndicator diff --git a/packages/cli/src/ui/components/messages/ThinkingMessage.test.tsx b/packages/cli/src/ui/components/messages/ThinkingMessage.test.tsx index d4ffd645430..eab85866e6b 100644 --- a/packages/cli/src/ui/components/messages/ThinkingMessage.test.tsx +++ b/packages/cli/src/ui/components/messages/ThinkingMessage.test.tsx @@ -9,26 +9,22 @@ import { renderWithProviders } from '../../../test-utils/render.js'; import { ThinkingMessage } from './ThinkingMessage.js'; describe('ThinkingMessage', () => { - it('renders summary mode text without icon chrome', () => { + it('renders subject line', () => { const { lastFrame } = renderWithProviders( , ); expect(lastFrame()).toContain('Planning'); - expect(lastFrame()).not.toContain('💬'); - expect(lastFrame()).not.toContain('╭'); }); - it('uses description when subject is empty in summary mode', () => { + it('uses description when subject is empty', () => { const { lastFrame } = renderWithProviders( , ); @@ -43,7 +39,6 @@ describe('ThinkingMessage', () => { description: 'I am planning the solution.', }} terminalWidth={80} - mode="full" />, ); @@ -64,7 +59,6 @@ describe('ThinkingMessage', () => { description: 'First body line', }} terminalWidth={80} - mode="full" />, ); @@ -82,7 +76,6 @@ describe('ThinkingMessage', () => { description: '\\n\\n', }} terminalWidth={80} - mode="full" />, ); @@ -95,7 +88,6 @@ describe('ThinkingMessage', () => { , ); diff --git a/packages/cli/src/ui/components/messages/ThinkingMessage.tsx b/packages/cli/src/ui/components/messages/ThinkingMessage.tsx index 90da377aa0b..f23addb0d75 100644 --- a/packages/cli/src/ui/components/messages/ThinkingMessage.tsx +++ b/packages/cli/src/ui/components/messages/ThinkingMessage.tsx @@ -5,20 +5,16 @@ */ import type React from 'react'; -import { useEffect, useMemo, useState } 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'; -import type { InlineThinkingMode } from '../../utils/inlineThinkingMode.js'; interface ThinkingMessageProps { thought: ThoughtSummary; terminalWidth: number; - mode: Exclude; } -const MAX_THOUGHT_SUMMARY_LENGTH = 140; -const SUMMARY_BLINK_INTERVAL_MS = 450; const THINKING_LEFT_PADDING = 1; function splitGraphemes(value: string): string[] { @@ -32,29 +28,6 @@ function splitGraphemes(value: string): string[] { return Array.from(value); } -function summarizeThought(thought: ThoughtSummary): string { - const subject = normalizeEscapedNewlines(thought.subject).trim(); - if (subject) { - return subject; - } - - const description = normalizeEscapedNewlines(thought.description).trim(); - if (!description) { - return ''; - } - - const descriptionGraphemes = splitGraphemes(description); - if (descriptionGraphemes.length <= MAX_THOUGHT_SUMMARY_LENGTH) { - return description; - } - - const trimmed = descriptionGraphemes - .slice(0, MAX_THOUGHT_SUMMARY_LENGTH - 3) - .join('') - .trimEnd(); - return `${trimmed}...`; -} - function normalizeEscapedNewlines(value: string): string { return value.replace(/\\r\\n/g, '\n').replace(/\\n/g, '\n'); } @@ -144,10 +117,7 @@ function wrapLineToWidth(line: string, width: number): string[] { export const ThinkingMessage: React.FC = ({ thought, terminalWidth, - mode, }) => { - const [isBlinkVisible, setIsBlinkVisible] = useState(true); - const summaryText = useMemo(() => summarizeThought(thought), [thought]); const fullLines = useMemo(() => normalizeThoughtLines(thought), [thought]); const fullSummaryDisplayLines = useMemo(() => { const contentWidth = Math.max(terminalWidth - THINKING_LEFT_PADDING - 2, 1); @@ -161,44 +131,6 @@ export const ThinkingMessage: React.FC = ({ .slice(1) .flatMap((line) => wrapLineToWidth(line, contentWidth)); }, [fullLines, terminalWidth]); - const shouldBlinkSummary = - mode === 'summary' && - summaryText.length > 0 && - process.env['NODE_ENV'] !== 'test'; - - useEffect(() => { - if (!shouldBlinkSummary) { - return; - } - - setIsBlinkVisible(true); - const interval = setInterval(() => { - setIsBlinkVisible((current) => !current); - }, SUMMARY_BLINK_INTERVAL_MS); - return () => clearInterval(interval); - }, [shouldBlinkSummary, summaryText]); - - if (mode === 'summary') { - if (!summaryText) { - return null; - } - - return ( - - - {isBlinkVisible ? '●' : ' '} - - - {summaryText} - - - ); - } if ( fullSummaryDisplayLines.length === 0 && diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts index 932d3c65fad..70d90636b36 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.ts +++ b/packages/cli/src/ui/hooks/useGeminiStream.ts @@ -1088,7 +1088,6 @@ export const useGeminiStream = ( for await (const event of stream) { if ( event.type !== ServerGeminiEventType.Thought && - getInlineThinkingMode(settings) === 'summary' && thoughtRef.current !== null ) { setThought(null); @@ -1186,7 +1185,6 @@ export const useGeminiStream = ( handleContentEvent, handleThoughtEvent, thoughtRef, - settings, handleUserCancelledEvent, handleErrorEvent, scheduleToolCalls, diff --git a/packages/cli/src/ui/utils/inlineThinkingMode.ts b/packages/cli/src/ui/utils/inlineThinkingMode.ts index 11f73312d86..16ca1a44a25 100644 --- a/packages/cli/src/ui/utils/inlineThinkingMode.ts +++ b/packages/cli/src/ui/utils/inlineThinkingMode.ts @@ -6,7 +6,7 @@ import type { LoadedSettings } from '../../config/settings.js'; -export type InlineThinkingMode = 'off' | 'summary' | 'full'; +export type InlineThinkingMode = 'off' | 'full'; export function getInlineThinkingMode( settings: LoadedSettings, From 664812d5f81ddbec72f983b979636f6d96b99275 Mon Sep 17 00:00:00 2001 From: Dmitry Lyalin Date: Sun, 8 Feb 2026 23:30:06 -0500 Subject: [PATCH 23/24] chore(docs): regenerate settings schema and docs --- docs/cli/settings.md | 2 +- docs/get-started/configuration.md | 5 ++--- schemas/settings.schema.json | 6 +++--- 3 files changed, 6 insertions(+), 7 deletions(-) diff --git a/docs/cli/settings.md b/docs/cli/settings.md index bf148300081..07e8c986c6d 100644 --- a/docs/cli/settings.md +++ b/docs/cli/settings.md @@ -43,7 +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, summary (truncated), or full. | `"off"` | +| 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 01dabf1ce5f..f51907122e7 100644 --- a/docs/get-started/configuration.md +++ b/docs/get-started/configuration.md @@ -185,10 +185,9 @@ their corresponding top-level category object in your `settings.json` file. - **Requires restart:** Yes - **`ui.inlineThinkingMode`** (enum): - - **Description:** Display model thinking inline: off, summary (truncated), or - full. + - **Description:** Display model thinking inline: off or full. - **Default:** `"off"` - - **Values:** `"off"`, `"summary"`, `"full"` + - **Values:** `"off"`, `"full"` - **`ui.showStatusInTitle`** (boolean): - **Description:** Show Gemini CLI model thoughts in the terminal window title diff --git a/schemas/settings.schema.json b/schemas/settings.schema.json index ac1bdc1f9a7..81916cbf23c 100644 --- a/schemas/settings.schema.json +++ b/schemas/settings.schema.json @@ -196,11 +196,11 @@ }, "inlineThinkingMode": { "title": "Inline Thinking", - "description": "Display model thinking inline: off, summary (truncated), or full.", - "markdownDescription": "Display model thinking inline: off, summary (truncated), or full.\n\n- Category: `UI`\n- Requires restart: `no`\n- Default: `off`", + "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", "summary", "full"] + "enum": ["off", "full"] }, "showStatusInTitle": { "title": "Show Thoughts in Title", From 848712f128b15b7472aa003d7e582469a7d6df28 Mon Sep 17 00:00:00 2001 From: Dmitry Lyalin Date: Mon, 9 Feb 2026 18:02:58 -0500 Subject: [PATCH 24/24] fix: remove unused theme import in Composer.tsx --- packages/cli/src/ui/components/Composer.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/cli/src/ui/components/Composer.tsx b/packages/cli/src/ui/components/Composer.tsx index 0b72052af37..d0432cebb95 100644 --- a/packages/cli/src/ui/components/Composer.tsx +++ b/packages/cli/src/ui/components/Composer.tsx @@ -30,7 +30,6 @@ 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 { theme } from '../semantic-colors.js'; import { getInlineThinkingMode } from '../utils/inlineThinkingMode.js'; export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {