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

View File

@@ -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: {

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

View File

@@ -237,7 +237,7 @@ describe('<HistoryItemDisplay />', () => {
const item: HistoryItem = {
...baseItem,
type: 'thinking',
thoughts: [{ subject: 'Thinking', description: 'test' }],
thought: { subject: 'Thinking', description: 'test' },
};
const { lastFrame } = renderWithProviders(
<HistoryItemDisplay {...baseItem} item={item} inlineEnabled={true} />,
@@ -250,7 +250,7 @@ describe('<HistoryItemDisplay />', () => {
const item: HistoryItem = {
...baseItem,
type: 'thinking',
thoughts: [{ subject: 'Thinking', description: 'test' }],
thought: { subject: 'Thinking', description: 'test' },
};
const { lastFrame } = renderWithProviders(
<HistoryItemDisplay {...baseItem} item={item} inlineEnabled={false} />,

View File

@@ -68,7 +68,7 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
{/* Render standard message types */}
{itemForDisplay.type === 'thinking' && inlineEnabled && (
<ThinkingMessage
thoughts={itemForDisplay.thoughts}
thought={itemForDisplay.thought}
terminalWidth={terminalWidth}
availableTerminalHeight={
isPending ? availableTerminalHeight : undefined

View File

@@ -21,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);
@@ -55,7 +56,7 @@ export const MainContent = () => {
availableTerminalHeight,
} = uiState;
const inlineEnabled = settings.merged.ui?.showInlineThinking;
const inlineEnabled = getInlineThinkingMode(settings) !== 'off';
const historyItems = useMemo(
() =>

View File

@@ -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');

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 inlineEnabled = getInlineThinkingMode(settings) !== 'off';
if (!uiState.quittingMessages) {
return null;
@@ -30,6 +34,7 @@ export const QuittingDisplay = () => {
terminalWidth={terminalWidth}
item={item}
isPending={false}
inlineEnabled={inlineEnabled}
/>
))}
</Box>

View File

@@ -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(
<ThinkingMessage
thoughts={[
{ subject: 'Planning', description: 'test' },
{ subject: 'Analyzing', description: 'test' },
]}
thought={{ subject: 'Planning', description: 'test' }}
terminalWidth={80}
/>,
);
expect(lastFrame()).toContain('Thinking');
expect(lastFrame()).toContain('(2)');
});
it('renders with single thought', () => {
it('renders with thought subject', () => {
const { lastFrame } = render(
<ThinkingMessage
thoughts={[{ subject: 'Processing', description: 'test' }]}
thought={{ subject: 'Processing', description: 'test' }}
terminalWidth={80}
/>,
);
expect(lastFrame()).toContain('(1)');
expect(lastFrame()).toContain('Processing');
});
it('renders thought content', () => {
const { lastFrame } = render(
<ThinkingMessage
thoughts={[
{ subject: 'Planning', description: 'I am planning the solution.' },
]}
thought={{
subject: 'Planning',
description: 'I am planning the solution.',
}}
terminalWidth={80}
/>,
);
@@ -51,9 +48,12 @@ describe('ThinkingMessage', () => {
it('renders empty state gracefully', () => {
const { lastFrame } = render(
<ThinkingMessage thoughts={[]} terminalWidth={80} />,
<ThinkingMessage
thought={{ subject: '', description: '' }}
terminalWidth={80}
/>,
);
expect(lastFrame()).toContain('(0)');
expect(lastFrame()).toContain('Thinking');
});
});

View File

@@ -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<ThinkingMessageProps> = ({
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<ThinkingMessageProps> = ({
<Text bold color="magenta">
Thinking
</Text>
<Text dimColor> ({thoughts.length})</Text>
</Box>
<MaxSizedBox
maxHeight={contentMaxHeight}
maxWidth={terminalWidth - 2}
overflowDirection="top"
>
{thoughts.map((thought, index) => (
<Box key={index} marginTop={1} flexDirection="column">
{thought.subject && (
{(subject || description) && (
<Box marginTop={1} flexDirection="column">
{subject && (
<Text bold color="magenta">
{thought.subject}
{subject}
</Text>
)}
<Text>{thought.description || ' '}</Text>
{description && <Text>{description}</Text>}
</Box>
))}
)}
</MaxSizedBox>
</Box>
);

View File

@@ -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) => ({
const thoughtForDisplay =
inlineThinkingMode === 'summary'
? summarizeThought(eventValue)
: eventValue;
addItem(
{
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);
}
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);

View File

@@ -212,7 +212,7 @@ export interface ChatDetail {
export type HistoryItemThinking = HistoryItemBase & {
type: 'thinking';
thoughts: ThoughtSummary[];
thought: ThoughtSummary;
};
export type HistoryItemChatList = HistoryItemBase & {