Show inline thoughts as individual bubbles

This commit is contained in:
Dmitry Lyalin
2026-01-31 11:22:47 -05:00
parent 8841361a6f
commit 364954851a
11 changed files with 110 additions and 57 deletions
+19 -1
View File
@@ -380,7 +380,25 @@ const SETTINGS_SCHEMA = {
requiresRestart: false, requiresRestart: false,
default: false, default: false,
description: 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, showInDialog: true,
}, },
showStatusInTitle: { showStatusInTitle: {
@@ -6,6 +6,7 @@
import { Box, Text } from 'ink'; import { Box, Text } from 'ink';
import { useUIState } from '../contexts/UIStateContext.js'; import { useUIState } from '../contexts/UIStateContext.js';
import { useSettings } from '../contexts/SettingsContext.js';
import { AppHeader } from './AppHeader.js'; import { AppHeader } from './AppHeader.js';
import { HistoryItemDisplay } from './HistoryItemDisplay.js'; import { HistoryItemDisplay } from './HistoryItemDisplay.js';
import { QuittingDisplay } from './QuittingDisplay.js'; import { QuittingDisplay } from './QuittingDisplay.js';
@@ -15,15 +16,18 @@ import { useConfirmingTool } from '../hooks/useConfirmingTool.js';
import { useConfig } from '../contexts/ConfigContext.js'; import { useConfig } from '../contexts/ConfigContext.js';
import { ToolStatusIndicator, ToolInfo } from './messages/ToolShared.js'; import { ToolStatusIndicator, ToolInfo } from './messages/ToolShared.js';
import { theme } from '../semantic-colors.js'; import { theme } from '../semantic-colors.js';
import { getInlineThinkingMode } from '../utils/inlineThinkingMode.js';
export const AlternateBufferQuittingDisplay = () => { export const AlternateBufferQuittingDisplay = () => {
const { version } = useAppContext(); const { version } = useAppContext();
const uiState = useUIState(); const uiState = useUIState();
const settings = useSettings();
const config = useConfig(); const config = useConfig();
const confirmingTool = useConfirmingTool(); const confirmingTool = useConfirmingTool();
const showPromptedTool = const showPromptedTool =
config.isEventDrivenSchedulerEnabled() && confirmingTool !== null; config.isEventDrivenSchedulerEnabled() && confirmingTool !== null;
const inlineEnabled = getInlineThinkingMode(settings) !== 'off';
// We render the entire chat history and header here to ensure that the // 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 // conversation history is visible to the user after the app quits and the
@@ -47,6 +51,7 @@ export const AlternateBufferQuittingDisplay = () => {
item={h} item={h}
isPending={false} isPending={false}
commands={uiState.slashCommands} commands={uiState.slashCommands}
inlineEnabled={inlineEnabled}
/> />
))} ))}
{uiState.pendingHistoryItems.map((item, i) => ( {uiState.pendingHistoryItems.map((item, i) => (
@@ -59,6 +64,7 @@ export const AlternateBufferQuittingDisplay = () => {
isFocused={false} isFocused={false}
activeShellPtyId={uiState.activePtyId} activeShellPtyId={uiState.activePtyId}
embeddedShellFocused={uiState.embeddedShellFocused} embeddedShellFocused={uiState.embeddedShellFocused}
inlineEnabled={inlineEnabled}
/> />
))} ))}
{showPromptedTool && ( {showPromptedTool && (
@@ -237,7 +237,7 @@ describe('<HistoryItemDisplay />', () => {
const item: HistoryItem = { const item: HistoryItem = {
...baseItem, ...baseItem,
type: 'thinking', type: 'thinking',
thoughts: [{ subject: 'Thinking', description: 'test' }], thought: { subject: 'Thinking', description: 'test' },
}; };
const { lastFrame } = renderWithProviders( const { lastFrame } = renderWithProviders(
<HistoryItemDisplay {...baseItem} item={item} inlineEnabled={true} />, <HistoryItemDisplay {...baseItem} item={item} inlineEnabled={true} />,
@@ -250,7 +250,7 @@ describe('<HistoryItemDisplay />', () => {
const item: HistoryItem = { const item: HistoryItem = {
...baseItem, ...baseItem,
type: 'thinking', type: 'thinking',
thoughts: [{ subject: 'Thinking', description: 'test' }], thought: { subject: 'Thinking', description: 'test' },
}; };
const { lastFrame } = renderWithProviders( const { lastFrame } = renderWithProviders(
<HistoryItemDisplay {...baseItem} item={item} inlineEnabled={false} />, <HistoryItemDisplay {...baseItem} item={item} inlineEnabled={false} />,
@@ -68,7 +68,7 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
{/* Render standard message types */} {/* Render standard message types */}
{itemForDisplay.type === 'thinking' && inlineEnabled && ( {itemForDisplay.type === 'thinking' && inlineEnabled && (
<ThinkingMessage <ThinkingMessage
thoughts={itemForDisplay.thoughts} thought={itemForDisplay.thought}
terminalWidth={terminalWidth} terminalWidth={terminalWidth}
availableTerminalHeight={ availableTerminalHeight={
isPending ? availableTerminalHeight : undefined isPending ? availableTerminalHeight : undefined
@@ -21,6 +21,7 @@ import { MAX_GEMINI_MESSAGE_LINES } from '../constants.js';
import { useConfirmingTool } from '../hooks/useConfirmingTool.js'; import { useConfirmingTool } from '../hooks/useConfirmingTool.js';
import { ToolConfirmationQueue } from './ToolConfirmationQueue.js'; import { ToolConfirmationQueue } from './ToolConfirmationQueue.js';
import { useConfig } from '../contexts/ConfigContext.js'; import { useConfig } from '../contexts/ConfigContext.js';
import { getInlineThinkingMode } from '../utils/inlineThinkingMode.js';
const MemoizedHistoryItemDisplay = memo(HistoryItemDisplay); const MemoizedHistoryItemDisplay = memo(HistoryItemDisplay);
const MemoizedAppHeader = memo(AppHeader); const MemoizedAppHeader = memo(AppHeader);
@@ -55,7 +56,7 @@ export const MainContent = () => {
availableTerminalHeight, availableTerminalHeight,
} = uiState; } = uiState;
const inlineEnabled = settings.merged.ui?.showInlineThinking; const inlineEnabled = getInlineThinkingMode(settings) !== 'off';
const historyItems = useMemo( const historyItems = useMemo(
() => () =>
@@ -12,6 +12,17 @@ import { useUIState, type UIState } from '../contexts/UIStateContext.js';
import { useTerminalSize } from '../hooks/useTerminalSize.js'; import { useTerminalSize } from '../hooks/useTerminalSize.js';
vi.mock('../contexts/UIStateContext.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('../hooks/useTerminalSize.js');
vi.mock('./HistoryItemDisplay.js', async () => { vi.mock('./HistoryItemDisplay.js', async () => {
const { Text } = await vi.importActual('ink'); const { Text } = await vi.importActual('ink');
@@ -6,14 +6,18 @@
import { Box } from 'ink'; import { Box } from 'ink';
import { useUIState } from '../contexts/UIStateContext.js'; import { useUIState } from '../contexts/UIStateContext.js';
import { useSettings } from '../contexts/SettingsContext.js';
import { HistoryItemDisplay } from './HistoryItemDisplay.js'; import { HistoryItemDisplay } from './HistoryItemDisplay.js';
import { useTerminalSize } from '../hooks/useTerminalSize.js'; import { useTerminalSize } from '../hooks/useTerminalSize.js';
import { getInlineThinkingMode } from '../utils/inlineThinkingMode.js';
export const QuittingDisplay = () => { export const QuittingDisplay = () => {
const uiState = useUIState(); const uiState = useUIState();
const settings = useSettings();
const { rows: terminalHeight, columns: terminalWidth } = useTerminalSize(); const { rows: terminalHeight, columns: terminalWidth } = useTerminalSize();
const availableTerminalHeight = terminalHeight; const availableTerminalHeight = terminalHeight;
const inlineEnabled = getInlineThinkingMode(settings) !== 'off';
if (!uiState.quittingMessages) { if (!uiState.quittingMessages) {
return null; return null;
@@ -30,6 +34,7 @@ export const QuittingDisplay = () => {
terminalWidth={terminalWidth} terminalWidth={terminalWidth}
item={item} item={item}
isPending={false} isPending={false}
inlineEnabled={inlineEnabled}
/> />
))} ))}
</Box> </Box>
@@ -9,38 +9,35 @@ import { render } from '../../../test-utils/render.js';
import { ThinkingMessage } from './ThinkingMessage.js'; import { ThinkingMessage } from './ThinkingMessage.js';
describe('ThinkingMessage', () => { describe('ThinkingMessage', () => {
it('renders thinking header with count', () => { it('renders thinking header', () => {
const { lastFrame } = render( const { lastFrame } = render(
<ThinkingMessage <ThinkingMessage
thoughts={[ thought={{ subject: 'Planning', description: 'test' }}
{ subject: 'Planning', description: 'test' },
{ subject: 'Analyzing', description: 'test' },
]}
terminalWidth={80} terminalWidth={80}
/>, />,
); );
expect(lastFrame()).toContain('Thinking'); expect(lastFrame()).toContain('Thinking');
expect(lastFrame()).toContain('(2)');
}); });
it('renders with single thought', () => { it('renders with thought subject', () => {
const { lastFrame } = render( const { lastFrame } = render(
<ThinkingMessage <ThinkingMessage
thoughts={[{ subject: 'Processing', description: 'test' }]} thought={{ subject: 'Processing', description: 'test' }}
terminalWidth={80} terminalWidth={80}
/>, />,
); );
expect(lastFrame()).toContain('(1)'); expect(lastFrame()).toContain('Processing');
}); });
it('renders thought content', () => { it('renders thought content', () => {
const { lastFrame } = render( const { lastFrame } = render(
<ThinkingMessage <ThinkingMessage
thoughts={[ thought={{
{ subject: 'Planning', description: 'I am planning the solution.' }, subject: 'Planning',
]} description: 'I am planning the solution.',
}}
terminalWidth={80} terminalWidth={80}
/>, />,
); );
@@ -51,9 +48,12 @@ describe('ThinkingMessage', () => {
it('renders empty state gracefully', () => { it('renders empty state gracefully', () => {
const { lastFrame } = render( const { lastFrame } = render(
<ThinkingMessage thoughts={[]} terminalWidth={80} />, <ThinkingMessage
thought={{ subject: '', description: '' }}
terminalWidth={80}
/>,
); );
expect(lastFrame()).toContain('(0)'); expect(lastFrame()).toContain('Thinking');
}); });
}); });
@@ -10,16 +10,18 @@ import type { ThoughtSummary } from '@google/gemini-cli-core';
import { MaxSizedBox, MINIMUM_MAX_HEIGHT } from '../shared/MaxSizedBox.js'; import { MaxSizedBox, MINIMUM_MAX_HEIGHT } from '../shared/MaxSizedBox.js';
interface ThinkingMessageProps { interface ThinkingMessageProps {
thoughts: ThoughtSummary[]; thought: ThoughtSummary;
terminalWidth: number; terminalWidth: number;
availableTerminalHeight?: number; availableTerminalHeight?: number;
} }
export const ThinkingMessage: React.FC<ThinkingMessageProps> = ({ export const ThinkingMessage: React.FC<ThinkingMessageProps> = ({
thoughts, thought,
terminalWidth, terminalWidth,
availableTerminalHeight, availableTerminalHeight,
}) => { }) => {
const subject = thought.subject.trim();
const description = thought.description.trim();
const contentMaxHeight = const contentMaxHeight =
availableTerminalHeight !== undefined availableTerminalHeight !== undefined
? Math.max(availableTerminalHeight - 4, MINIMUM_MAX_HEIGHT) ? Math.max(availableTerminalHeight - 4, MINIMUM_MAX_HEIGHT)
@@ -39,23 +41,22 @@ export const ThinkingMessage: React.FC<ThinkingMessageProps> = ({
<Text bold color="magenta"> <Text bold color="magenta">
Thinking Thinking
</Text> </Text>
<Text dimColor> ({thoughts.length})</Text>
</Box> </Box>
<MaxSizedBox <MaxSizedBox
maxHeight={contentMaxHeight} maxHeight={contentMaxHeight}
maxWidth={terminalWidth - 2} maxWidth={terminalWidth - 2}
overflowDirection="top" overflowDirection="top"
> >
{thoughts.map((thought, index) => ( {(subject || description) && (
<Box key={index} marginTop={1} flexDirection="column"> <Box marginTop={1} flexDirection="column">
{thought.subject && ( {subject && (
<Text bold color="magenta"> <Text bold color="magenta">
{thought.subject} {subject}
</Text> </Text>
)} )}
<Text>{thought.description || ' '}</Text> {description && <Text>{description}</Text>}
</Box> </Box>
))} )}
</MaxSizedBox> </MaxSizedBox>
</Box> </Box>
); );
+39 -28
View File
@@ -63,6 +63,7 @@ import { isAtCommand, isSlashCommand } from '../utils/commandUtils.js';
import { useShellCommandProcessor } from './shellCommandProcessor.js'; import { useShellCommandProcessor } from './shellCommandProcessor.js';
import { handleAtCommand } from './atCommandProcessor.js'; import { handleAtCommand } from './atCommandProcessor.js';
import { findLastSafeSplitPoint } from '../utils/markdownUtilities.js'; import { findLastSafeSplitPoint } from '../utils/markdownUtilities.js';
import { getInlineThinkingMode } from '../utils/inlineThinkingMode.js';
import { useStateAndRef } from './useStateAndRef.js'; import { useStateAndRef } from './useStateAndRef.js';
import type { UseHistoryManagerReturn } from './useHistoryManager.js'; import type { UseHistoryManagerReturn } from './useHistoryManager.js';
import { useLogger } from './useLogger.js'; import { useLogger } from './useLogger.js';
@@ -78,6 +79,29 @@ import {
} from './useToolScheduler.js'; } from './useToolScheduler.js';
import { promises as fs } from 'node:fs'; import { promises as fs } from 'node:fs';
import path from 'node:path'; 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 { useSessionStats } from '../contexts/SessionContext.js';
import { useKeypress } from './useKeypress.js'; import { useKeypress } from './useKeypress.js';
import type { LoadedSettings } from '../../config/settings.js'; import type { LoadedSettings } from '../../config/settings.js';
@@ -762,7 +786,7 @@ export const useGeminiStream = (
pendingHistoryItemRef.current?.type !== 'gemini' && pendingHistoryItemRef.current?.type !== 'gemini' &&
pendingHistoryItemRef.current?.type !== 'gemini_content' 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) { if (pendingHistoryItemRef.current) {
addItem(pendingHistoryItemRef.current, userMessageTimestamp); addItem(pendingHistoryItemRef.current, userMessageTimestamp);
} }
@@ -810,34 +834,25 @@ export const useGeminiStream = (
(eventValue: ThoughtSummary, userMessageTimestamp: number) => { (eventValue: ThoughtSummary, userMessageTimestamp: number) => {
setThought(eventValue); setThought(eventValue);
// Only accumulate thoughts in history if inline thinking is enabled const inlineThinkingMode = getInlineThinkingMode(settings);
if (!settings.merged.ui?.showInlineThinking) { if (inlineThinkingMode === 'off') {
return; return;
} }
if (pendingHistoryItemRef.current?.type === 'thinking') { const thoughtForDisplay =
// Accumulate thoughts in the existing thinking item inlineThinkingMode === 'summary'
setPendingHistoryItem((prev) => ({ ? summarizeThought(eventValue)
: eventValue;
addItem(
{
type: 'thinking', type: 'thinking',
thoughts: [...(prev as HistoryItemThinking).thoughts, eventValue], thought: thoughtForDisplay,
})); } as HistoryItemThinking,
} else { userMessageTimestamp,
// 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, settings],
addItem,
pendingHistoryItemRef,
setPendingHistoryItem,
settings.merged.ui?.showInlineThinking,
],
); );
const handleUserCancelledEvent = useCallback( const handleUserCancelledEvent = useCallback(
@@ -1279,10 +1294,6 @@ export const useGeminiStream = (
} }
startNewPrompt(); startNewPrompt();
setThought(null); // Reset thought when starting a new prompt 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); setIsResponding(true);
+1 -1
View File
@@ -212,7 +212,7 @@ export interface ChatDetail {
export type HistoryItemThinking = HistoryItemBase & { export type HistoryItemThinking = HistoryItemBase & {
type: 'thinking'; type: 'thinking';
thoughts: ThoughtSummary[]; thought: ThoughtSummary;
}; };
export type HistoryItemChatList = HistoryItemBase & { export type HistoryItemChatList = HistoryItemBase & {