Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions packages/cli/src/ui/components/Composer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -374,7 +374,7 @@ describe('Composer', () => {
const uiState = createMockUIState({
streamingState: StreamingState.Responding,
thought: {
subject: 'Detailed in-history thought',
subject: 'Thinking about code',
description: 'Full text is already in history',
},
});
Expand All @@ -385,7 +385,7 @@ describe('Composer', () => {
const { lastFrame } = await renderComposer(uiState, settings);

const output = lastFrame();
expect(output).toContain('LoadingIndicator: Thinking ...');
expect(output).toContain('LoadingIndicator: Thinking...');
});

it('hides shortcuts hint while loading', async () => {
Expand Down
6 changes: 3 additions & 3 deletions packages/cli/src/ui/components/Composer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -239,7 +239,7 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
: uiState.currentLoadingPhrase
}
thoughtLabel={
inlineThinkingMode === 'full' ? 'Thinking ...' : undefined
inlineThinkingMode === 'full' ? 'Thinking...' : undefined
}
elapsedTime={uiState.elapsedTime}
/>
Expand Down Expand Up @@ -282,7 +282,7 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
: uiState.currentLoadingPhrase
}
thoughtLabel={
inlineThinkingMode === 'full' ? 'Thinking ...' : undefined
inlineThinkingMode === 'full' ? 'Thinking...' : undefined
}
elapsedTime={uiState.elapsedTime}
/>
Expand Down Expand Up @@ -390,7 +390,7 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
marginTop={
(showApprovalIndicator ||
uiState.shellModeActive) &&
isNarrow
!isNarrow
? 1
: 0
}
Expand Down
20 changes: 20 additions & 0 deletions packages/cli/src/ui/components/HistoryItemDisplay.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,26 @@ describe('<HistoryItemDisplay />', () => {
unmount();
});

it('renders "Thinking..." header when isFirstThinking is true', async () => {
const item: HistoryItem = {
...baseItem,
type: 'thinking',
thought: { subject: 'Thinking', description: 'test' },
};
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<HistoryItemDisplay {...baseItem} item={item} isFirstThinking={true} />,
{
settings: createMockSettings({
merged: { ui: { inlineThinkingMode: 'full' } },
}),
},
);
await waitUntilReady();

expect(lastFrame()).toContain(' Thinking...');
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('does not render thinking item when disabled', async () => {
const item: HistoryItem = {
...baseItem,
Expand Down
20 changes: 18 additions & 2 deletions packages/cli/src/ui/components/HistoryItemDisplay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ interface HistoryItemDisplayProps {
commands?: readonly SlashCommand[];
availableTerminalHeightGemini?: number;
isExpandable?: boolean;
isFirstThinking?: boolean;
isFirstAfterThinking?: boolean;
}

export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
Expand All @@ -56,16 +58,30 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
commands,
availableTerminalHeightGemini,
isExpandable,
isFirstThinking = false,
isFirstAfterThinking = false,
}) => {
const settings = useSettings();
const inlineThinkingMode = getInlineThinkingMode(settings);
const itemForDisplay = useMemo(() => escapeAnsiCtrlCodes(item), [item]);

const needsTopMarginAfterThinking =
isFirstAfterThinking && inlineThinkingMode !== 'off';

return (
<Box flexDirection="column" key={itemForDisplay.id} width={terminalWidth}>
<Box
flexDirection="column"
key={itemForDisplay.id}
width={terminalWidth}
marginTop={needsTopMarginAfterThinking ? 1 : 0}
>
{/* Render standard message types */}
{itemForDisplay.type === 'thinking' && inlineThinkingMode !== 'off' && (
<ThinkingMessage thought={itemForDisplay.thought} />
<ThinkingMessage
thought={itemForDisplay.thought}
terminalWidth={terminalWidth}
isFirstThinking={isFirstThinking}
/>
)}
{itemForDisplay.type === 'hint' && (
<HintMessage text={itemForDisplay.text} />
Expand Down
27 changes: 23 additions & 4 deletions packages/cli/src/ui/components/LoadingIndicator.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -258,13 +258,32 @@ describe('<LoadingIndicator />', () => {
const output = lastFrame();
expect(output).toBeDefined();
if (output) {
expect(output).toContain('💬');
// Should NOT contain "Thinking... " prefix because the subject already starts with "Thinking"
expect(output).not.toContain('Thinking... Thinking');
expect(output).toContain('Thinking about something...');
expect(output).not.toContain('and other stuff.');
}
unmount();
});

it('should prepend "Thinking... " if the subject does not start with "Thinking"', async () => {
const props = {
thought: {
subject: 'Planning the response...',
description: 'details',
},
elapsedTime: 5,
};
const { lastFrame, unmount, waitUntilReady } = renderWithContext(
<LoadingIndicator {...props} />,
StreamingState.Responding,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('Thinking... Planning the response...');
unmount();
});

it('should prioritize thought.subject over currentLoadingPhrase', async () => {
const props = {
thought: {
Expand All @@ -280,13 +299,13 @@ describe('<LoadingIndicator />', () => {
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('💬');
expect(output).toContain('Thinking... ');
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', async () => {
it('should not display thought indicator for non-thought loading phrases', async () => {
const { lastFrame, unmount, waitUntilReady } = renderWithContext(
<LoadingIndicator
currentLoadingPhrase="some random tip..."
Expand All @@ -295,7 +314,7 @@ describe('<LoadingIndicator />', () => {
StreamingState.Responding,
);
await waitUntilReady();
expect(lastFrame()).not.toContain('💬');
expect(lastFrame()).not.toContain('Thinking... ');
unmount();
});

Expand Down
6 changes: 5 additions & 1 deletion packages/cli/src/ui/components/LoadingIndicator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,11 @@ export const LoadingIndicator: React.FC<LoadingIndicatorProps> = ({
const hasThoughtIndicator =
currentLoadingPhrase !== INTERACTIVE_SHELL_WAITING_PHRASE &&
Boolean(thought?.subject?.trim());
const thinkingIndicator = hasThoughtIndicator ? '💬 ' : '';
// Avoid "Thinking... Thinking..." duplication if primaryText already starts with "Thinking"
const thinkingIndicator =
hasThoughtIndicator && !primaryText?.startsWith('Thinking')
? 'Thinking... '
: '';

const cancelAndTimerContent =
showCancelAndTimer &&
Expand Down
81 changes: 74 additions & 7 deletions packages/cli/src/ui/components/MainContent.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,17 +22,19 @@ import { CoreToolCallStatus } from '@google/gemini-cli-core';
import { type IndividualToolCallDisplay } from '../types.js';

// Mock dependencies
const mockUseSettings = vi.fn().mockReturnValue({
merged: {
ui: {
inlineThinkingMode: 'off',
},
},
});

vi.mock('../contexts/SettingsContext.js', async () => {
const actual = await vi.importActual('../contexts/SettingsContext.js');
return {
...actual,
useSettings: () => ({
merged: {
ui: {
inlineThinkingMode: 'off',
},
},
}),
useSettings: () => mockUseSettings(),
};
});

Expand Down Expand Up @@ -333,6 +335,13 @@ describe('MainContent', () => {

beforeEach(() => {
vi.mocked(useAlternateBuffer).mockReturnValue(false);
mockUseSettings.mockReturnValue({
merged: {
ui: {
inlineThinkingMode: 'off',
},
},
});
});

afterEach(() => {
Expand Down Expand Up @@ -570,6 +579,64 @@ describe('MainContent', () => {
unmount();
});

it('renders multiple thinking messages sequentially correctly', async () => {
mockUseSettings.mockReturnValue({
merged: {
ui: {
inlineThinkingMode: 'expanded',
},
},
});
vi.mocked(useAlternateBuffer).mockReturnValue(true);

const uiState = {
...defaultMockUiState,
history: [
{ id: 0, type: 'user' as const, text: 'Plan a solution' },
{
id: 1,
type: 'thinking' as const,
thought: {
subject: 'Initial analysis',
description:
'This is a multiple line paragraph for the first thinking message of how the model analyzes the problem.',
},
},
{
id: 2,
type: 'thinking' as const,
thought: {
subject: 'Planning execution',
description:
'This a second multiple line paragraph for the second thinking message explaining the plan in detail so that it wraps around the terminal display.',
},
},
{
id: 3,
type: 'thinking' as const,
thought: {
subject: 'Refining approach',
description:
'And finally a third multiple line paragraph for the third thinking message to refine the solution.',
},
},
],
};

const renderResult = renderWithProviders(<MainContent />, {
uiState: uiState as Partial<UIState>,
});
await renderResult.waitUntilReady();

const output = renderResult.lastFrame();
expect(output).toContain('Initial analysis');
expect(output).toContain('Planning execution');
expect(output).toContain('Refining approach');
expect(output).toMatchSnapshot();
await expect(renderResult).toMatchSvgSnapshot();
renderResult.unmount();
});

describe('MainContent Tool Output Height Logic', () => {
const testCases = [
{
Expand Down
Loading
Loading