Inline thinking bubbles with summary/full modes (#18033)

Co-authored-by: Jacob Richman <jacob314@gmail.com>
This commit is contained in:
Dmitry Lyalin
2026-02-09 19:24:41 -08:00
committed by GitHub
parent 7a132512cf
commit d3cfbdb3b7
26 changed files with 719 additions and 26 deletions

View File

@@ -392,6 +392,19 @@ const SETTINGS_SCHEMA = {
description: 'Hide the window title bar',
showInDialog: true,
},
inlineThinkingMode: {
type: 'enum',
label: 'Inline Thinking',
category: 'UI',
requiresRestart: false,
default: 'off',
description: 'Display model thinking inline: off or full.',
showInDialog: true,
options: [
{ value: 'off', label: 'Off' },
{ value: 'full', label: 'Full' },
],
},
showStatusInTitle: {
type: 'boolean',
label: 'Show Thoughts in Title',

View File

@@ -44,6 +44,7 @@ vi.mock('../ui/utils/terminalUtils.js', () => ({
isLowColorDepth: vi.fn(() => false),
getColorDepth: vi.fn(() => 24),
isITerm2: vi.fn(() => false),
shouldUseEmoji: vi.fn(() => true),
}));
// Wrapper around ink-testing-library's render that ensures act() is called

View File

@@ -6,6 +6,7 @@
import { Box, Text } from 'ink';
import { useUIState } from '../contexts/UIStateContext.js';
import { useSettings } from '../contexts/SettingsContext.js';
import { AppHeader } from './AppHeader.js';
import { HistoryItemDisplay } from './HistoryItemDisplay.js';
import { QuittingDisplay } from './QuittingDisplay.js';
@@ -15,15 +16,18 @@ import { useConfirmingTool } from '../hooks/useConfirmingTool.js';
import { useConfig } from '../contexts/ConfigContext.js';
import { ToolStatusIndicator, ToolInfo } from './messages/ToolShared.js';
import { theme } from '../semantic-colors.js';
import { getInlineThinkingMode } from '../utils/inlineThinkingMode.js';
export const AlternateBufferQuittingDisplay = () => {
const { version } = useAppContext();
const uiState = useUIState();
const settings = useSettings();
const config = useConfig();
const confirmingTool = useConfirmingTool();
const showPromptedTool =
config.isEventDrivenSchedulerEnabled() && confirmingTool !== null;
const inlineThinkingMode = getInlineThinkingMode(settings);
// We render the entire chat history and header here to ensure that the
// conversation history is visible to the user after the app quits and the
@@ -47,6 +51,7 @@ export const AlternateBufferQuittingDisplay = () => {
item={h}
isPending={false}
commands={uiState.slashCommands}
inlineThinkingMode={inlineThinkingMode}
/>
))}
{uiState.pendingHistoryItems.map((item, i) => (
@@ -59,6 +64,7 @@ export const AlternateBufferQuittingDisplay = () => {
isFocused={false}
activeShellPtyId={uiState.activePtyId}
embeddedShellFocused={uiState.embeddedShellFocused}
inlineThinkingMode={inlineThinkingMode}
/>
))}
{showPromptedTool && (

View File

@@ -31,9 +31,18 @@ import type { SessionMetrics } from '../contexts/SessionContext.js';
// Mock child components
vi.mock('./LoadingIndicator.js', () => ({
LoadingIndicator: ({ thought }: { thought?: string }) => (
<Text>LoadingIndicator{thought ? `: ${thought}` : ''}</Text>
),
LoadingIndicator: ({
thought,
thoughtLabel,
}: {
thought?: { subject?: string } | string;
thoughtLabel?: string;
}) => {
const fallbackText =
typeof thought === 'string' ? thought : thought?.subject;
const text = thoughtLabel ?? fallbackText;
return <Text>LoadingIndicator{text ? `: ${text}` : ''}</Text>;
},
}));
vi.mock('./ContextSummaryDisplay.js', () => ({
@@ -287,7 +296,25 @@ describe('Composer', () => {
const { lastFrame } = renderComposer(uiState);
const output = lastFrame();
expect(output).toContain('LoadingIndicator');
expect(output).toContain('LoadingIndicator: Processing');
});
it('renders generic thinking text in loading indicator when full inline thinking is enabled', () => {
const uiState = createMockUIState({
streamingState: StreamingState.Responding,
thought: {
subject: 'Detailed in-history thought',
description: 'Full text is already in history',
},
});
const settings = createMockSettings({
ui: { inlineThinkingMode: 'full' },
});
const { lastFrame } = renderComposer(uiState, settings);
const output = lastFrame();
expect(output).toContain('LoadingIndicator: Thinking ...');
});
it('keeps shortcuts hint visible while loading', () => {

View File

@@ -30,6 +30,7 @@ import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js';
import { StreamingState, ToolCallStatus } from '../types.js';
import { ConfigInitDisplay } from '../components/ConfigInitDisplay.js';
import { TodoTray } from './messages/Todo.js';
import { getInlineThinkingMode } from '../utils/inlineThinkingMode.js';
export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
const config = useConfig();
@@ -38,6 +39,7 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
const uiState = useUIState();
const uiActions = useUIActions();
const { vimEnabled, vimMode } = useVimMode();
const inlineThinkingMode = getInlineThinkingMode(settings);
const terminalWidth = process.stdout.columns;
const isNarrow = isNarrowWidth(terminalWidth);
const debugConsoleMaxHeight = Math.floor(Math.max(terminalWidth * 0.2, 5));
@@ -117,6 +119,9 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
? undefined
: uiState.currentLoadingPhrase
}
thoughtLabel={
inlineThinkingMode === 'full' ? 'Thinking ...' : undefined
}
elapsedTime={uiState.elapsedTime}
/>
)}

View File

@@ -232,6 +232,42 @@ describe('<HistoryItemDisplay />', () => {
);
});
describe('thinking items', () => {
it('renders thinking item when enabled', () => {
const item: HistoryItem = {
...baseItem,
type: 'thinking',
thought: { subject: 'Thinking', description: 'test' },
};
const { lastFrame } = renderWithProviders(
<HistoryItemDisplay
{...baseItem}
item={item}
inlineThinkingMode="full"
/>,
);
expect(lastFrame()).toContain('Thinking');
});
it('does not render thinking item when disabled', () => {
const item: HistoryItem = {
...baseItem,
type: 'thinking',
thought: { subject: 'Thinking', description: 'test' },
};
const { lastFrame } = renderWithProviders(
<HistoryItemDisplay
{...baseItem}
item={item}
inlineThinkingMode="off"
/>,
);
expect(lastFrame()).toBe('');
});
});
describe.each([true, false])(
'gemini items (alternateBuffer=%s)',
(useAlternateBuffer) => {

View File

@@ -34,6 +34,8 @@ import { McpStatus } from './views/McpStatus.js';
import { ChatList } from './views/ChatList.js';
import { HooksList } from './views/HooksList.js';
import { ModelMessage } from './messages/ModelMessage.js';
import { ThinkingMessage } from './messages/ThinkingMessage.js';
import type { InlineThinkingMode } from '../utils/inlineThinkingMode.js';
interface HistoryItemDisplayProps {
item: HistoryItem;
@@ -45,6 +47,7 @@ interface HistoryItemDisplayProps {
activeShellPtyId?: number | null;
embeddedShellFocused?: boolean;
availableTerminalHeightGemini?: number;
inlineThinkingMode?: InlineThinkingMode;
}
export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
@@ -57,12 +60,19 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
activeShellPtyId,
embeddedShellFocused,
availableTerminalHeightGemini,
inlineThinkingMode = 'off',
}) => {
const itemForDisplay = useMemo(() => escapeAnsiCtrlCodes(item), [item]);
return (
<Box flexDirection="column" key={itemForDisplay.id} width={terminalWidth}>
{/* Render standard message types */}
{itemForDisplay.type === 'thinking' && inlineThinkingMode !== 'off' && (
<ThinkingMessage
thought={itemForDisplay.thought}
terminalWidth={terminalWidth}
/>
)}
{itemForDisplay.type === 'user' && (
<UserMessage text={itemForDisplay.text} width={terminalWidth} />
)}

View File

@@ -12,6 +12,7 @@ import { StreamingContext } from '../contexts/StreamingContext.js';
import { StreamingState } from '../types.js';
import { vi } from 'vitest';
import * as useTerminalSize from '../hooks/useTerminalSize.js';
import * as terminalUtils from '../utils/terminalUtils.js';
// Mock GeminiRespondingSpinner
vi.mock('./GeminiRespondingSpinner.js', () => ({
@@ -34,7 +35,12 @@ vi.mock('../hooks/useTerminalSize.js', () => ({
useTerminalSize: vi.fn(),
}));
vi.mock('../utils/terminalUtils.js', () => ({
shouldUseEmoji: vi.fn(() => true),
}));
const useTerminalSizeMock = vi.mocked(useTerminalSize.useTerminalSize);
const shouldUseEmojiMock = vi.mocked(terminalUtils.shouldUseEmoji);
const renderWithContext = (
ui: React.ReactElement,
@@ -217,12 +223,33 @@ describe('<LoadingIndicator />', () => {
const output = lastFrame();
expect(output).toBeDefined();
if (output) {
expect(output).toContain('💬');
expect(output).toContain('Thinking about something...');
expect(output).not.toContain('and other stuff.');
}
unmount();
});
it('should use ASCII fallback thought indicator when emoji is unavailable', () => {
shouldUseEmojiMock.mockReturnValue(false);
const props = {
thought: {
subject: 'Thinking with fallback',
description: 'details',
},
elapsedTime: 5,
};
const { lastFrame, unmount } = renderWithContext(
<LoadingIndicator {...props} />,
StreamingState.Responding,
);
const output = lastFrame();
expect(output).toContain('o Thinking with fallback');
expect(output).not.toContain('💬');
shouldUseEmojiMock.mockReturnValue(true);
unmount();
});
it('should prioritize thought.subject over currentLoadingPhrase', () => {
const props = {
thought: {
@@ -237,11 +264,24 @@ describe('<LoadingIndicator />', () => {
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(
<LoadingIndicator
currentLoadingPhrase="some random tip..."
elapsedTime={3}
/>,
StreamingState.Responding,
);
expect(lastFrame()).not.toContain('💬');
unmount();
});
it('should truncate long primary text instead of wrapping', () => {
const { lastFrame, unmount } = renderWithContext(
<LoadingIndicator

View File

@@ -15,6 +15,7 @@ import { formatDuration } from '../utils/formatters.js';
import { useTerminalSize } from '../hooks/useTerminalSize.js';
import { isNarrowWidth } from '../utils/isNarrowWidth.js';
import { INTERACTIVE_SHELL_WAITING_PHRASE } from '../hooks/usePhraseCycler.js';
import { shouldUseEmoji } from '../utils/terminalUtils.js';
interface LoadingIndicatorProps {
currentLoadingPhrase?: string;
@@ -22,6 +23,7 @@ interface LoadingIndicatorProps {
inline?: boolean;
rightContent?: React.ReactNode;
thought?: ThoughtSummary | null;
thoughtLabel?: string;
showCancelAndTimer?: boolean;
}
@@ -31,6 +33,7 @@ export const LoadingIndicator: React.FC<LoadingIndicatorProps> = ({
inline = false,
rightContent,
thought,
thoughtLabel,
showCancelAndTimer = true,
}) => {
const streamingState = useStreamingContext();
@@ -50,7 +53,15 @@ export const LoadingIndicator: React.FC<LoadingIndicatorProps> = ({
const primaryText =
currentLoadingPhrase === INTERACTIVE_SHELL_WAITING_PHRASE
? currentLoadingPhrase
: thought?.subject || currentLoadingPhrase;
: thought?.subject
? (thoughtLabel ?? thought.subject)
: currentLoadingPhrase;
const hasThoughtIndicator =
currentLoadingPhrase !== INTERACTIVE_SHELL_WAITING_PHRASE &&
Boolean(thought?.subject?.trim());
const thinkingIndicator = hasThoughtIndicator
? `${shouldUseEmoji() ? '💬' : 'o'} `
: '';
const cancelAndTimerContent =
showCancelAndTimer &&
@@ -72,6 +83,7 @@ export const LoadingIndicator: React.FC<LoadingIndicatorProps> = ({
</Box>
{primaryText && (
<Text color={theme.text.accent} wrap="truncate-end">
{thinkingIndicator}
{primaryText}
</Text>
)}
@@ -105,6 +117,7 @@ export const LoadingIndicator: React.FC<LoadingIndicatorProps> = ({
</Box>
{primaryText && (
<Text color={theme.text.accent} wrap="truncate-end">
{thinkingIndicator}
{primaryText}
</Text>
)}

View File

@@ -16,6 +16,20 @@ import { SHELL_COMMAND_NAME } from '../constants.js';
import type { UIState } from '../contexts/UIStateContext.js';
// Mock dependencies
vi.mock('../contexts/SettingsContext.js', async () => {
const actual = await vi.importActual('../contexts/SettingsContext.js');
return {
...actual,
useSettings: () => ({
merged: {
ui: {
inlineThinkingMode: 'off',
},
},
}),
};
});
vi.mock('../contexts/AppContext.js', async () => {
const actual = await vi.importActual('../contexts/AppContext.js');
return {
@@ -68,6 +82,7 @@ describe('MainContent', () => {
availableTerminalHeight: 24,
slashCommands: [],
constrainHeight: false,
thought: null,
isEditorDialogOpen: false,
activePtyId: undefined,
embeddedShellFocused: false,
@@ -185,6 +200,7 @@ describe('MainContent', () => {
terminalHeight: 50,
terminalWidth: 100,
mainAreaWidth: 100,
thought: null,
embeddedShellFocused,
activePtyId: embeddedShellFocused ? ptyId : undefined,
constrainHeight,

View File

@@ -8,6 +8,7 @@ import { Box, Static } from 'ink';
import { HistoryItemDisplay } from './HistoryItemDisplay.js';
import { useUIState } from '../contexts/UIStateContext.js';
import { useAppContext } from '../contexts/AppContext.js';
import { useSettings } from '../contexts/SettingsContext.js';
import { AppHeader } from './AppHeader.js';
import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js';
import {
@@ -20,6 +21,7 @@ import { MAX_GEMINI_MESSAGE_LINES } from '../constants.js';
import { useConfirmingTool } from '../hooks/useConfirmingTool.js';
import { ToolConfirmationQueue } from './ToolConfirmationQueue.js';
import { useConfig } from '../contexts/ConfigContext.js';
import { getInlineThinkingMode } from '../utils/inlineThinkingMode.js';
const MemoizedHistoryItemDisplay = memo(HistoryItemDisplay);
const MemoizedAppHeader = memo(AppHeader);
@@ -31,6 +33,7 @@ const MemoizedAppHeader = memo(AppHeader);
export const MainContent = () => {
const { version } = useAppContext();
const uiState = useUIState();
const settings = useSettings();
const config = useConfig();
const isAlternateBuffer = useAlternateBuffer();
@@ -53,6 +56,8 @@ export const MainContent = () => {
availableTerminalHeight,
} = uiState;
const inlineThinkingMode = getInlineThinkingMode(settings);
const historyItems = useMemo(
() =>
uiState.history.map((h) => (
@@ -64,6 +69,7 @@ export const MainContent = () => {
item={h}
isPending={false}
commands={uiState.slashCommands}
inlineThinkingMode={inlineThinkingMode}
/>
)),
[
@@ -71,6 +77,7 @@ export const MainContent = () => {
mainAreaWidth,
staticAreaMaxItemHeight,
uiState.slashCommands,
inlineThinkingMode,
],
);
@@ -92,6 +99,7 @@ export const MainContent = () => {
isFocused={!uiState.isEditorDialogOpen}
activeShellPtyId={uiState.activePtyId}
embeddedShellFocused={uiState.embeddedShellFocused}
inlineThinkingMode={inlineThinkingMode}
/>
))}
{showConfirmationQueue && confirmingTool && (
@@ -105,6 +113,7 @@ export const MainContent = () => {
isAlternateBuffer,
availableTerminalHeight,
mainAreaWidth,
inlineThinkingMode,
uiState.isEditorDialogOpen,
uiState.activePtyId,
uiState.embeddedShellFocused,
@@ -136,13 +145,20 @@ export const MainContent = () => {
item={item.item}
isPending={false}
commands={uiState.slashCommands}
inlineThinkingMode={inlineThinkingMode}
/>
);
} else {
return pendingItems;
}
},
[version, mainAreaWidth, uiState.slashCommands, pendingItems],
[
version,
mainAreaWidth,
uiState.slashCommands,
inlineThinkingMode,
pendingItems,
],
);
if (isAlternateBuffer) {

View File

@@ -12,6 +12,15 @@ import { useUIState, type UIState } from '../contexts/UIStateContext.js';
import { useTerminalSize } from '../hooks/useTerminalSize.js';
vi.mock('../contexts/UIStateContext.js');
vi.mock('../contexts/SettingsContext.js', () => ({
useSettings: () => ({
merged: {
ui: {
inlineThinkingMode: 'off',
},
},
}),
}));
vi.mock('../hooks/useTerminalSize.js');
vi.mock('./HistoryItemDisplay.js', async () => {
const { Text } = await vi.importActual('ink');

View File

@@ -6,14 +6,18 @@
import { Box } from 'ink';
import { useUIState } from '../contexts/UIStateContext.js';
import { useSettings } from '../contexts/SettingsContext.js';
import { HistoryItemDisplay } from './HistoryItemDisplay.js';
import { useTerminalSize } from '../hooks/useTerminalSize.js';
import { getInlineThinkingMode } from '../utils/inlineThinkingMode.js';
export const QuittingDisplay = () => {
const uiState = useUIState();
const settings = useSettings();
const { rows: terminalHeight, columns: terminalWidth } = useTerminalSize();
const availableTerminalHeight = terminalHeight;
const inlineThinkingMode = getInlineThinkingMode(settings);
if (!uiState.quittingMessages) {
return null;
@@ -30,6 +34,7 @@ export const QuittingDisplay = () => {
terminalWidth={terminalWidth}
item={item}
isPending={false}
inlineThinkingMode={inlineThinkingMode}
/>
))}
</Box>

View File

@@ -140,7 +140,7 @@ export const ToolConfirmationQueue: React.FC<ToolConfirmationQueueProps> = ({
/>
</Box>
<Box
height={0}
height={1}
width={mainAreaWidth}
borderLeft={true}
borderRight={true}

View File

@@ -0,0 +1,96 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import { renderWithProviders } from '../../../test-utils/render.js';
import { ThinkingMessage } from './ThinkingMessage.js';
describe('ThinkingMessage', () => {
it('renders subject line', () => {
const { lastFrame } = renderWithProviders(
<ThinkingMessage
thought={{ subject: 'Planning', description: 'test' }}
terminalWidth={80}
/>,
);
expect(lastFrame()).toContain('Planning');
});
it('uses description when subject is empty', () => {
const { lastFrame } = renderWithProviders(
<ThinkingMessage
thought={{ subject: '', description: 'Processing details' }}
terminalWidth={80}
/>,
);
expect(lastFrame()).toContain('Processing details');
});
it('renders full mode with left vertical rule and full text', () => {
const { lastFrame } = renderWithProviders(
<ThinkingMessage
thought={{
subject: 'Planning',
description: 'I am planning the solution.',
}}
terminalWidth={80}
/>,
);
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(
<ThinkingMessage
thought={{
subject: 'Summary line',
description: 'First body line',
}}
terminalWidth={80}
/>,
);
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(
<ThinkingMessage
thought={{
subject: 'Matching the Blocks',
description: '\\n\\n',
}}
terminalWidth={80}
/>,
);
expect(lastFrame()).toContain('Matching the Blocks');
expect(lastFrame()).not.toContain('\\n\\n');
});
it('renders empty state gracefully', () => {
const { lastFrame } = renderWithProviders(
<ThinkingMessage
thought={{ subject: '', description: '' }}
terminalWidth={80}
/>,
);
expect(lastFrame()).not.toContain('Planning');
});
});

View File

@@ -0,0 +1,171 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { useMemo } from 'react';
import { Box, Text } from 'ink';
import type { ThoughtSummary } from '@google/gemini-cli-core';
import { theme } from '../../semantic-colors.js';
interface ThinkingMessageProps {
thought: ThoughtSummary;
terminalWidth: number;
}
const THINKING_LEFT_PADDING = 1;
function splitGraphemes(value: string): string[] {
if (typeof Intl !== 'undefined' && 'Segmenter' in Intl) {
const segmenter = new Intl.Segmenter(undefined, {
granularity: 'grapheme',
});
return Array.from(segmenter.segment(value), (segment) => segment.segment);
}
return Array.from(value);
}
function normalizeEscapedNewlines(value: string): string {
return value.replace(/\\r\\n/g, '\n').replace(/\\n/g, '\n');
}
function normalizeThoughtLines(thought: ThoughtSummary): string[] {
const subject = normalizeEscapedNewlines(thought.subject).trim();
const description = normalizeEscapedNewlines(thought.description).trim();
if (!subject && !description) {
return [];
}
if (!subject) {
return description
.split('\n')
.map((line) => line.trim())
.filter(Boolean);
}
const bodyLines = description
.split('\n')
.map((line) => line.trim())
.filter(Boolean);
return [subject, ...bodyLines];
}
function graphemeLength(value: string): number {
return splitGraphemes(value).length;
}
function chunkToWidth(value: string, width: number): string[] {
if (width <= 0) {
return [''];
}
const graphemes = splitGraphemes(value);
if (graphemes.length === 0) {
return [''];
}
const chunks: string[] = [];
for (let index = 0; index < graphemes.length; index += width) {
chunks.push(graphemes.slice(index, index + width).join(''));
}
return chunks;
}
function wrapLineToWidth(line: string, width: number): string[] {
if (width <= 0) {
return [''];
}
const normalized = line.trim();
if (!normalized) {
return [''];
}
const words = normalized.split(/\s+/);
const wrapped: string[] = [];
let current = '';
for (const word of words) {
const wordChunks = chunkToWidth(word, width);
for (const wordChunk of wordChunks) {
if (!current) {
current = wordChunk;
continue;
}
if (graphemeLength(current) + 1 + graphemeLength(wordChunk) <= width) {
current = `${current} ${wordChunk}`;
} else {
wrapped.push(current);
current = wordChunk;
}
}
}
if (current) {
wrapped.push(current);
}
return wrapped;
}
export const ThinkingMessage: React.FC<ThinkingMessageProps> = ({
thought,
terminalWidth,
}) => {
const fullLines = useMemo(() => normalizeThoughtLines(thought), [thought]);
const fullSummaryDisplayLines = useMemo(() => {
const contentWidth = Math.max(terminalWidth - THINKING_LEFT_PADDING - 2, 1);
return fullLines.length > 0
? wrapLineToWidth(fullLines[0], contentWidth)
: [];
}, [fullLines, terminalWidth]);
const fullBodyDisplayLines = useMemo(() => {
const contentWidth = Math.max(terminalWidth - THINKING_LEFT_PADDING - 2, 1);
return fullLines
.slice(1)
.flatMap((line) => wrapLineToWidth(line, contentWidth));
}, [fullLines, terminalWidth]);
if (
fullSummaryDisplayLines.length === 0 &&
fullBodyDisplayLines.length === 0
) {
return null;
}
return (
<Box
width={terminalWidth}
marginBottom={1}
paddingLeft={THINKING_LEFT_PADDING}
flexDirection="column"
>
{fullSummaryDisplayLines.map((line, index) => (
<Box key={`summary-line-row-${index}`} flexDirection="row">
<Box width={2}>
<Text> </Text>
</Box>
<Text color={theme.text.primary} bold italic wrap="truncate-end">
{line}
</Text>
</Box>
))}
{fullBodyDisplayLines.map((line, index) => (
<Box key={`body-line-row-${index}`} flexDirection="row">
<Box width={2}>
<Text color={theme.border.default}> </Text>
</Box>
<Text color={theme.text.secondary} italic wrap="truncate-end">
{line}
</Text>
</Box>
))}
</Box>
);
};

View File

@@ -247,7 +247,7 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
*/
(visibleToolCalls.length > 0 || borderBottomOverride !== undefined) && (
<Box
height={0}
height={1}
width={contentWidth}
borderLeft={true}
borderRight={true}

View File

@@ -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(

View File

@@ -50,6 +50,7 @@ import type {
import { type Part, type PartListUnion, FinishReason } from '@google/genai';
import type {
HistoryItem,
HistoryItemThinking,
HistoryItemWithoutId,
HistoryItemToolGroup,
IndividualToolCallDisplay,
@@ -61,6 +62,7 @@ import { isAtCommand, isSlashCommand } from '../utils/commandUtils.js';
import { useShellCommandProcessor } from './shellCommandProcessor.js';
import { handleAtCommand } from './atCommandProcessor.js';
import { findLastSafeSplitPoint } from '../utils/markdownUtilities.js';
import { getInlineThinkingMode } from '../utils/inlineThinkingMode.js';
import { useStateAndRef } from './useStateAndRef.js';
import type { UseHistoryManagerReturn } from './useHistoryManager.js';
import { useLogger } from './useLogger.js';
@@ -192,9 +194,11 @@ export const useGeminiStream = (
const turnCancelledRef = useRef(false);
const activeQueryIdRef = useRef<string | null>(null);
const [isResponding, setIsResponding] = useState<boolean>(false);
const [thought, setThought] = useState<ThoughtSummary | null>(null);
const [thought, thoughtRef, setThought] =
useStateAndRef<ThoughtSummary | null>(null);
const [pendingHistoryItem, pendingHistoryItemRef, setPendingHistoryItem] =
useStateAndRef<HistoryItemWithoutId | null>(null);
const [lastGeminiActivityTime, setLastGeminiActivityTime] =
useState<number>(0);
const [pushedToolCallIds, pushedToolCallIdsRef, setPushedToolCallIds] =
@@ -753,6 +757,7 @@ export const useGeminiStream = (
pendingHistoryItemRef.current?.type !== 'gemini' &&
pendingHistoryItemRef.current?.type !== 'gemini_content'
) {
// Flush any pending item before starting gemini content
if (pendingHistoryItemRef.current) {
addItem(pendingHistoryItemRef.current, userMessageTimestamp);
}
@@ -798,6 +803,23 @@ export const useGeminiStream = (
[addItem, pendingHistoryItemRef, setPendingHistoryItem],
);
const handleThoughtEvent = useCallback(
(eventValue: ThoughtSummary, userMessageTimestamp: number) => {
setThought(eventValue);
if (getInlineThinkingMode(settings) === 'full') {
addItem(
{
type: 'thinking',
thought: eventValue,
} as HistoryItemThinking,
userMessageTimestamp,
);
}
},
[addItem, settings, setThought],
);
const handleUserCancelledEvent = useCallback(
(userMessageTimestamp: number) => {
if (turnCancelledRef.current) {
@@ -1067,10 +1089,17 @@ export const useGeminiStream = (
let geminiMessageBuffer = '';
const toolCallRequests: ToolCallRequestInfo[] = [];
for await (const event of stream) {
if (
event.type !== ServerGeminiEventType.Thought &&
thoughtRef.current !== null
) {
setThought(null);
}
switch (event.type) {
case ServerGeminiEventType.Thought:
setLastGeminiActivityTime(Date.now());
setThought(event.value);
handleThoughtEvent(event.value, userMessageTimestamp);
break;
case ServerGeminiEventType.Content:
setLastGeminiActivityTime(Date.now());
@@ -1157,6 +1186,8 @@ export const useGeminiStream = (
},
[
handleContentEvent,
handleThoughtEvent,
thoughtRef,
handleUserCancelledEvent,
handleErrorEvent,
scheduleToolCalls,
@@ -1171,6 +1202,7 @@ export const useGeminiStream = (
addItem,
pendingHistoryItemRef,
setPendingHistoryItem,
setThought,
],
);
const submitQuery = useCallback(
@@ -1351,6 +1383,7 @@ export const useGeminiStream = (
config,
startNewPrompt,
getPromptCount,
setThought,
],
);

View File

@@ -220,6 +220,11 @@ export interface ChatDetail {
mtime: string;
}
export type HistoryItemThinking = HistoryItemBase & {
type: 'thinking';
thought: ThoughtSummary;
};
export type HistoryItemChatList = HistoryItemBase & {
type: 'chat_list';
chats: ChatDetail[];
@@ -343,6 +348,7 @@ export type HistoryItemWithoutId =
| HistoryItemAgentsList
| HistoryItemMcpStatus
| HistoryItemChatList
| HistoryItemThinking
| HistoryItemHooksList;
export type HistoryItem = HistoryItemWithoutId & { id: number };

View File

@@ -0,0 +1,15 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { LoadedSettings } from '../../config/settings.js';
export type InlineThinkingMode = 'off' | 'full';
export function getInlineThinkingMode(
settings: LoadedSettings,
): InlineThinkingMode {
return settings.merged.ui?.inlineThinkingMode ?? 'off';
}

View File

@@ -5,11 +5,15 @@
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { isITerm2, resetITerm2Cache } from './terminalUtils.js';
import { isITerm2, resetITerm2Cache, shouldUseEmoji } from './terminalUtils.js';
describe('terminalUtils', () => {
beforeEach(() => {
vi.stubEnv('TERM_PROGRAM', '');
vi.stubEnv('LC_ALL', '');
vi.stubEnv('LC_CTYPE', '');
vi.stubEnv('LANG', '');
vi.stubEnv('TERM', '');
resetITerm2Cache();
});
@@ -18,25 +22,56 @@ describe('terminalUtils', () => {
vi.restoreAllMocks();
});
it('should detect iTerm2 via TERM_PROGRAM', () => {
vi.stubEnv('TERM_PROGRAM', 'iTerm.app');
expect(isITerm2()).toBe(true);
describe('isITerm2', () => {
it('should detect iTerm2 via TERM_PROGRAM', () => {
vi.stubEnv('TERM_PROGRAM', 'iTerm.app');
expect(isITerm2()).toBe(true);
});
it('should return false if not iTerm2', () => {
vi.stubEnv('TERM_PROGRAM', 'vscode');
expect(isITerm2()).toBe(false);
});
it('should cache the result', () => {
vi.stubEnv('TERM_PROGRAM', 'iTerm.app');
expect(isITerm2()).toBe(true);
// Change env but should still be true due to cache
vi.stubEnv('TERM_PROGRAM', 'vscode');
expect(isITerm2()).toBe(true);
resetITerm2Cache();
expect(isITerm2()).toBe(false);
});
});
it('should return false if not iTerm2', () => {
vi.stubEnv('TERM_PROGRAM', 'vscode');
expect(isITerm2()).toBe(false);
});
describe('shouldUseEmoji', () => {
it('should return true when UTF-8 is supported', () => {
vi.stubEnv('LANG', 'en_US.UTF-8');
expect(shouldUseEmoji()).toBe(true);
});
it('should cache the result', () => {
vi.stubEnv('TERM_PROGRAM', 'iTerm.app');
expect(isITerm2()).toBe(true);
it('should return true when utf8 (no hyphen) is supported', () => {
vi.stubEnv('LANG', 'en_US.utf8');
expect(shouldUseEmoji()).toBe(true);
});
// Change env but should still be true due to cache
vi.stubEnv('TERM_PROGRAM', 'vscode');
expect(isITerm2()).toBe(true);
it('should check LC_ALL first', () => {
vi.stubEnv('LC_ALL', 'en_US.UTF-8');
vi.stubEnv('LANG', 'C');
expect(shouldUseEmoji()).toBe(true);
});
resetITerm2Cache();
expect(isITerm2()).toBe(false);
it('should return false when UTF-8 is not supported', () => {
vi.stubEnv('LANG', 'C');
expect(shouldUseEmoji()).toBe(false);
});
it('should return false on linux console (TERM=linux)', () => {
vi.stubEnv('LANG', 'en_US.UTF-8');
vi.stubEnv('TERM', 'linux');
expect(shouldUseEmoji()).toBe(false);
});
});
});

View File

@@ -43,3 +43,25 @@ export function isITerm2(): boolean {
export function resetITerm2Cache(): void {
cachedIsITerm2 = undefined;
}
/**
* Returns true if the terminal likely supports emoji.
*/
export function shouldUseEmoji(): boolean {
const locale = (
process.env['LC_ALL'] ||
process.env['LC_CTYPE'] ||
process.env['LANG'] ||
''
).toLowerCase();
const supportsUtf8 = locale.includes('utf-8') || locale.includes('utf8');
if (!supportsUtf8) {
return false;
}
if (process.env['TERM'] === 'linux') {
return false;
}
return true;
}