diff --git a/packages/cli/src/ui/components/Composer.test.tsx b/packages/cli/src/ui/components/Composer.test.tsx
index ced4d3497f..f52d887a79 100644
--- a/packages/cli/src/ui/components/Composer.test.tsx
+++ b/packages/cli/src/ui/components/Composer.test.tsx
@@ -372,11 +372,11 @@ describe('Composer', () => {
expect(output).toContain('LoadingIndicator: Processing');
});
- it('renders generic thinking text in loading indicator when full inline thinking is enabled', async () => {
+ it('renders actual thought subject in loading indicator even when full inline thinking is enabled', async () => {
const uiState = createMockUIState({
streamingState: StreamingState.Responding,
thought: {
- subject: 'Detailed in-history thought',
+ subject: 'Thinking about code',
description: 'Full text is already in history',
},
});
@@ -387,7 +387,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 () => {
diff --git a/packages/cli/src/ui/components/Composer.tsx b/packages/cli/src/ui/components/Composer.tsx
index 849187ce64..ed9d09791e 100644
--- a/packages/cli/src/ui/components/Composer.tsx
+++ b/packages/cli/src/ui/components/Composer.tsx
@@ -343,7 +343,7 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
}
thoughtLabel={
!isExperimentalLayout && inlineThinkingMode === 'full'
- ? 'Thinking ...'
+ ? 'Thinking...'
: undefined
}
elapsedTime={uiState.elapsedTime}
@@ -417,7 +417,7 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
: uiState.currentLoadingPhrase
}
thoughtLabel={
- inlineThinkingMode === 'full' ? 'Thinking ...' : undefined
+ inlineThinkingMode === 'full' ? 'Thinking...' : undefined
}
elapsedTime={uiState.elapsedTime}
/>
@@ -461,7 +461,7 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
}
thoughtLabel={
inlineThinkingMode === 'full'
- ? 'Thinking ...'
+ ? 'Thinking...'
: undefined
}
elapsedTime={uiState.elapsedTime}
diff --git a/packages/cli/src/ui/components/HistoryItemDisplay.test.tsx b/packages/cli/src/ui/components/HistoryItemDisplay.test.tsx
index f8c251fbfa..3cc6e06a9f 100644
--- a/packages/cli/src/ui/components/HistoryItemDisplay.test.tsx
+++ b/packages/cli/src/ui/components/HistoryItemDisplay.test.tsx
@@ -5,6 +5,7 @@
*/
import { describe, it, expect, vi } from 'vitest';
+import stripAnsi from 'strip-ansi';
import { HistoryItemDisplay } from './HistoryItemDisplay.js';
import { type HistoryItem } from '../types.js';
import { MessageType } from '../types.js';
@@ -290,6 +291,27 @@ describe('', () => {
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(
+ ,
+ {
+ settings: createMockSettings({
+ merged: { ui: { inlineThinkingMode: 'full' } },
+ }),
+ },
+ );
+ await waitUntilReady();
+
+ const output = stripAnsi(lastFrame());
+ expect(output).toContain(' Thinking...');
+ expect(output).toContain('Thinking');
+ unmount();
+ });
it('does not render thinking item when disabled', async () => {
const item: HistoryItem = {
...baseItem,
diff --git a/packages/cli/src/ui/components/HistoryItemDisplay.tsx b/packages/cli/src/ui/components/HistoryItemDisplay.tsx
index d3d7ea4596..da53b66659 100644
--- a/packages/cli/src/ui/components/HistoryItemDisplay.tsx
+++ b/packages/cli/src/ui/components/HistoryItemDisplay.tsx
@@ -47,6 +47,8 @@ interface HistoryItemDisplayProps {
commands?: readonly SlashCommand[];
availableTerminalHeightGemini?: number;
isExpandable?: boolean;
+ isFirstThinking?: boolean;
+ isLastThinking?: boolean;
}
export const HistoryItemDisplay: React.FC = ({
@@ -57,6 +59,8 @@ export const HistoryItemDisplay: React.FC = ({
commands,
availableTerminalHeightGemini,
isExpandable,
+ isFirstThinking = false,
+ isLastThinking = false,
}) => {
const settings = useSettings();
const inlineThinkingMode = getInlineThinkingMode(settings);
@@ -71,7 +75,12 @@ export const HistoryItemDisplay: React.FC = ({
>
{/* Render standard message types */}
{itemForDisplay.type === 'thinking' && inlineThinkingMode !== 'off' && (
-
+
)}
{itemForDisplay.type === 'hint' && (
diff --git a/packages/cli/src/ui/components/LoadingIndicator.test.tsx b/packages/cli/src/ui/components/LoadingIndicator.test.tsx
index 3cb671242b..9f037a572d 100644
--- a/packages/cli/src/ui/components/LoadingIndicator.test.tsx
+++ b/packages/cli/src/ui/components/LoadingIndicator.test.tsx
@@ -258,13 +258,32 @@ describe('', () => {
const output = lastFrame();
expect(output).toBeDefined();
if (output) {
- expect(output).toContain(''); // Replaced emoji expectation
+ // Should NOT contain "Thinking... 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(
+ ,
+ 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: {
@@ -280,13 +299,13 @@ describe('', () => {
);
await waitUntilReady();
const output = lastFrame();
- expect(output).toContain(''); // Replaced emoji expectation
+ 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(
', () => {
StreamingState.Responding,
);
await waitUntilReady();
- expect(lastFrame()).toContain(''); // Replaced emoji expectation
+ expect(lastFrame()).not.toContain('Thinking... ');
unmount();
});
diff --git a/packages/cli/src/ui/components/LoadingIndicator.tsx b/packages/cli/src/ui/components/LoadingIndicator.tsx
index 32e69f6496..baea93b04a 100644
--- a/packages/cli/src/ui/components/LoadingIndicator.tsx
+++ b/packages/cli/src/ui/components/LoadingIndicator.tsx
@@ -67,7 +67,16 @@ export const LoadingIndicator: React.FC = ({
(streamingState === StreamingState.Responding
? GENERIC_WORKING_LABEL
: undefined);
- const thinkingIndicator = '';
+
+ const hasThoughtIndicator =
+ currentLoadingPhrase !== INTERACTIVE_SHELL_WAITING_PHRASE &&
+ Boolean(thought?.subject?.trim());
+
+ // Avoid "Thinking... Thinking..." duplication if primaryText already starts with "Thinking"
+ const thinkingIndicator =
+ hasThoughtIndicator && !primaryText?.startsWith('Thinking')
+ ? 'Thinking... '
+ : '';
const cancelAndTimerContent =
showCancelAndTimer &&
diff --git a/packages/cli/src/ui/components/MainContent.tsx b/packages/cli/src/ui/components/MainContent.tsx
index fbcc962663..e36343161e 100644
--- a/packages/cli/src/ui/components/MainContent.tsx
+++ b/packages/cli/src/ui/components/MainContent.tsx
@@ -65,6 +65,14 @@ export const MainContent = () => {
() =>
uiState.history.map((h, index) => {
const isExpandable = index > lastUserPromptIndex;
+ const isFirstThinking =
+ h.type === 'thinking' &&
+ (index === 0 || uiState.history[index - 1]?.type !== 'thinking');
+ const isLastThinking =
+ h.type === 'thinking' &&
+ (index === uiState.history.length - 1 ||
+ uiState.history[index + 1]?.type !== 'thinking');
+
return (
{
isPending={false}
commands={uiState.slashCommands}
isExpandable={isExpandable}
+ isFirstThinking={isFirstThinking}
+ isLastThinking={isLastThinking}
/>
);
}),
@@ -105,18 +115,32 @@ export const MainContent = () => {
const pendingItems = useMemo(
() => (
- {pendingHistoryItems.map((item, i) => (
-
- ))}
+ {pendingHistoryItems.map((item, i) => {
+ const isFirstThinking =
+ item.type === 'thinking' &&
+ (i === 0 || pendingHistoryItems[i - 1]?.type !== 'thinking') &&
+ (uiState.history.length === 0 ||
+ uiState.history.at(-1)?.type !== 'thinking');
+ const isLastThinking =
+ item.type === 'thinking' &&
+ (i === pendingHistoryItems.length - 1 ||
+ pendingHistoryItems[i + 1]?.type !== 'thinking');
+
+ return (
+
+ );
+ })}
{showConfirmationQueue && confirmingTool && (
)}
@@ -129,17 +153,29 @@ export const MainContent = () => {
mainAreaWidth,
showConfirmationQueue,
confirmingTool,
+ uiState.history,
],
);
const virtualizedData = useMemo(
() => [
{ type: 'header' as const },
- ...uiState.history.map((item, index) => ({
- type: 'history' as const,
- item,
- isExpandable: index > lastUserPromptIndex,
- })),
+ ...uiState.history.map((item, index) => {
+ const isFirstThinking =
+ item.type === 'thinking' &&
+ (index === 0 || uiState.history[index - 1]?.type !== 'thinking');
+ const isLastThinking =
+ item.type === 'thinking' &&
+ (index === uiState.history.length - 1 ||
+ uiState.history[index + 1]?.type !== 'thinking');
+ return {
+ type: 'history' as const,
+ item,
+ isExpandable: index > lastUserPromptIndex,
+ isFirstThinking,
+ isLastThinking,
+ };
+ }),
{ type: 'pending' as const },
],
[uiState.history, lastUserPromptIndex],
@@ -170,6 +206,8 @@ export const MainContent = () => {
isPending={false}
commands={uiState.slashCommands}
isExpandable={item.isExpandable}
+ isFirstThinking={item.isFirstThinking}
+ isLastThinking={item.isLastThinking}
/>
);
} else {
diff --git a/packages/cli/src/ui/components/__snapshots__/HistoryItemDisplay.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/HistoryItemDisplay.test.tsx.snap
index 0a543357d4..6841294eda 100644
--- a/packages/cli/src/ui/components/__snapshots__/HistoryItemDisplay.test.tsx.snap
+++ b/packages/cli/src/ui/components/__snapshots__/HistoryItemDisplay.test.tsx.snap
@@ -389,7 +389,8 @@ exports[` > renders InfoMessage for "info" type with multi
`;
exports[` > thinking items > renders thinking item when enabled 1`] = `
-" Thinking
-│ test
+" │
+ │ Thinking
+ │ test
"
`;
diff --git a/packages/cli/src/ui/components/messages/ThinkingMessage.test.tsx b/packages/cli/src/ui/components/messages/ThinkingMessage.test.tsx
index a27923c014..5617a3b336 100644
--- a/packages/cli/src/ui/components/messages/ThinkingMessage.test.tsx
+++ b/packages/cli/src/ui/components/messages/ThinkingMessage.test.tsx
@@ -9,15 +9,20 @@ import { renderWithProviders } from '../../../test-utils/render.js';
import { ThinkingMessage } from './ThinkingMessage.js';
describe('ThinkingMessage', () => {
- it('renders subject line', async () => {
+ it('renders subject line with vertical rule and "Thinking..." header', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
,
);
await waitUntilReady();
- expect(lastFrame()).toMatchSnapshot();
+ const output = lastFrame();
+ expect(output).toContain(' Thinking...');
+ expect(output).toContain('│');
+ expect(output).toContain('Planning');
unmount();
});
@@ -25,11 +30,14 @@ describe('ThinkingMessage', () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
,
);
await waitUntilReady();
- expect(lastFrame()).toMatchSnapshot();
+ const output = lastFrame();
+ expect(output).toContain('Processing details');
+ expect(output).toContain('│');
unmount();
});
@@ -40,26 +48,35 @@ describe('ThinkingMessage', () => {
subject: 'Planning',
description: 'I am planning the solution.',
}}
+ terminalWidth={80}
/>,
);
await waitUntilReady();
- expect(lastFrame()).toMatchSnapshot();
+ const output = lastFrame();
+ expect(output).toContain('│');
+ expect(output).toContain('Planning');
+ expect(output).toContain('I am planning the solution.');
unmount();
});
- it('indents summary line correctly', async () => {
+ it('renders "Thinking..." header when isFirstThinking is true', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
,
);
await waitUntilReady();
- expect(lastFrame()).toMatchSnapshot();
+ const output = lastFrame();
+ expect(output).toContain(' Thinking...');
+ expect(output).toContain('Summary line');
+ expect(output).toContain('│');
unmount();
});
@@ -70,6 +87,7 @@ describe('ThinkingMessage', () => {
subject: 'Matching the Blocks',
description: '\\n\\nSome more text',
}}
+ terminalWidth={80}
/>,
);
await waitUntilReady();
@@ -80,7 +98,10 @@ describe('ThinkingMessage', () => {
it('renders empty state gracefully', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
- ,
+ ,
);
await waitUntilReady();
diff --git a/packages/cli/src/ui/components/messages/ThinkingMessage.tsx b/packages/cli/src/ui/components/messages/ThinkingMessage.tsx
index 7ff8c8a646..3cdc3e5bbf 100644
--- a/packages/cli/src/ui/components/messages/ThinkingMessage.tsx
+++ b/packages/cli/src/ui/components/messages/ThinkingMessage.tsx
@@ -13,6 +13,105 @@ import { normalizeEscapedNewlines } from '../../utils/textUtils.js';
interface ThinkingMessageProps {
thought: ThoughtSummary;
+ terminalWidth: number;
+ isFirstThinking?: boolean;
+ isLastThinking?: boolean;
+}
+
+const THINKING_LEFT_PADDING = 1;
+const VERTICAL_LINE_WIDTH = 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 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;
}
/**
@@ -21,60 +120,88 @@ interface ThinkingMessageProps {
*/
export const ThinkingMessage: React.FC = ({
thought,
+ terminalWidth,
+ isFirstThinking,
+ isLastThinking,
}) => {
- const { summary, body } = useMemo(() => {
- const subject = normalizeEscapedNewlines(thought.subject).trim();
- const description = normalizeEscapedNewlines(thought.description).trim();
+ const fullLines = useMemo(() => normalizeThoughtLines(thought), [thought]);
+ const contentWidth = Math.max(
+ terminalWidth - THINKING_LEFT_PADDING - VERTICAL_LINE_WIDTH - 2,
+ 1,
+ );
- if (!subject && !description) {
- return { summary: '', body: '' };
- }
+ const fullSummaryDisplayLines = useMemo(
+ () =>
+ fullLines.length > 0 ? wrapLineToWidth(fullLines[0], contentWidth) : [],
+ [fullLines, contentWidth],
+ );
- if (!subject) {
- const lines = description
- .split('\n')
- .map((l) => l.trim())
- .filter(Boolean);
- return {
- summary: lines[0] || '',
- body: lines.slice(1).join('\n'),
- };
- }
+ const fullBodyDisplayLines = useMemo(
+ () =>
+ fullLines.slice(1).flatMap((line) => wrapLineToWidth(line, contentWidth)),
+ [fullLines, contentWidth],
+ );
- return {
- summary: subject,
- body: description,
- };
- }, [thought]);
-
- if (!summary && !body) {
+ if (fullLines.length === 0) {
return null;
}
+ const verticalLine = (
+
+ │
+
+ );
+
return (
-
- {summary && (
-
-
- {summary}
+
+ {isFirstThinking && (
+ <>
+
+ {' '}
+ Thinking...{' '}
+
+
+ {verticalLine}
+
+
+ >
+ )}
+
+ {!isFirstThinking && (
+
+
+ {verticalLine}
+
)}
- {body && (
-
-
- {body}
-
+
+ {fullSummaryDisplayLines.map((line, index) => (
+
+
+ {verticalLine}
+
+
+ {line}
+
+
- )}
+ ))}
+ {fullBodyDisplayLines.map((line, index) => (
+
+
+ {verticalLine}
+
+
+ {line}
+
+
+
+ ))}
);
};
diff --git a/packages/cli/src/ui/components/messages/__snapshots__/ThinkingMessage.test.tsx.snap b/packages/cli/src/ui/components/messages/__snapshots__/ThinkingMessage.test.tsx.snap
index 9f8ae44a70..0651018957 100644
--- a/packages/cli/src/ui/components/messages/__snapshots__/ThinkingMessage.test.tsx.snap
+++ b/packages/cli/src/ui/components/messages/__snapshots__/ThinkingMessage.test.tsx.snap
@@ -1,30 +1,8 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
-exports[`ThinkingMessage > indents summary line correctly 1`] = `
-" Summary line
-│ First body line
-"
-`;
-
exports[`ThinkingMessage > normalizes escaped newline tokens 1`] = `
-" Matching the Blocks
-│ Some more text
-"
-`;
-
-exports[`ThinkingMessage > renders full mode with left border and full text 1`] = `
-" Planning
-│ I am planning the solution.
-"
-`;
-
-exports[`ThinkingMessage > renders subject line 1`] = `
-" Planning
-│ test
-"
-`;
-
-exports[`ThinkingMessage > uses description when subject is empty 1`] = `
-" Processing details
+" │
+ │ Matching the Blocks
+ │ Some more text
"
`;
diff --git a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx
index df8c17bd23..071352b5ad 100644
--- a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx
+++ b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx
@@ -2793,7 +2793,6 @@ describe('useGeminiStream', () => {
type: 'thinking',
thought: expect.objectContaining({ subject: 'Full thought' }),
}),
- expect.any(Number),
);
});
diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts
index 36374a5e20..5111bc6bb5 100644
--- a/packages/cli/src/ui/hooks/useGeminiStream.ts
+++ b/packages/cli/src/ui/hooks/useGeminiStream.ts
@@ -903,17 +903,14 @@ export const useGeminiStream = (
);
const handleThoughtEvent = useCallback(
- (eventValue: ThoughtSummary, userMessageTimestamp: number) => {
+ (eventValue: ThoughtSummary, _userMessageTimestamp: number) => {
setThought(eventValue);
if (getInlineThinkingMode(settings) === 'full') {
- addItem(
- {
- type: 'thinking',
- thought: eventValue,
- } as HistoryItemThinking,
- userMessageTimestamp,
- );
+ addItem({
+ type: 'thinking',
+ thought: eventValue,
+ } as HistoryItemThinking);
}
},
[addItem, settings, setThought],