mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-12 21:03:05 -07:00
Merge pull request #18725 from feat/thinking-ux-improvements
This commit is contained in:
@@ -372,11 +372,11 @@ describe('Composer', () => {
|
|||||||
expect(output).toContain('LoadingIndicator: Processing');
|
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({
|
const uiState = createMockUIState({
|
||||||
streamingState: StreamingState.Responding,
|
streamingState: StreamingState.Responding,
|
||||||
thought: {
|
thought: {
|
||||||
subject: 'Detailed in-history thought',
|
subject: 'Thinking about code',
|
||||||
description: 'Full text is already in history',
|
description: 'Full text is already in history',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -387,7 +387,7 @@ describe('Composer', () => {
|
|||||||
const { lastFrame } = await renderComposer(uiState, settings);
|
const { lastFrame } = await renderComposer(uiState, settings);
|
||||||
|
|
||||||
const output = lastFrame();
|
const output = lastFrame();
|
||||||
expect(output).toContain('LoadingIndicator: Thinking ...');
|
expect(output).toContain('LoadingIndicator: Thinking...');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('hides shortcuts hint while loading', async () => {
|
it('hides shortcuts hint while loading', async () => {
|
||||||
|
|||||||
@@ -343,7 +343,7 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
|
|||||||
}
|
}
|
||||||
thoughtLabel={
|
thoughtLabel={
|
||||||
!isExperimentalLayout && inlineThinkingMode === 'full'
|
!isExperimentalLayout && inlineThinkingMode === 'full'
|
||||||
? 'Thinking ...'
|
? 'Thinking...'
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
elapsedTime={uiState.elapsedTime}
|
elapsedTime={uiState.elapsedTime}
|
||||||
@@ -417,7 +417,7 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
|
|||||||
: uiState.currentLoadingPhrase
|
: uiState.currentLoadingPhrase
|
||||||
}
|
}
|
||||||
thoughtLabel={
|
thoughtLabel={
|
||||||
inlineThinkingMode === 'full' ? 'Thinking ...' : undefined
|
inlineThinkingMode === 'full' ? 'Thinking...' : undefined
|
||||||
}
|
}
|
||||||
elapsedTime={uiState.elapsedTime}
|
elapsedTime={uiState.elapsedTime}
|
||||||
/>
|
/>
|
||||||
@@ -461,7 +461,7 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
|
|||||||
}
|
}
|
||||||
thoughtLabel={
|
thoughtLabel={
|
||||||
inlineThinkingMode === 'full'
|
inlineThinkingMode === 'full'
|
||||||
? 'Thinking ...'
|
? 'Thinking...'
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
elapsedTime={uiState.elapsedTime}
|
elapsedTime={uiState.elapsedTime}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect, vi } from 'vitest';
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import stripAnsi from 'strip-ansi';
|
||||||
import { HistoryItemDisplay } from './HistoryItemDisplay.js';
|
import { HistoryItemDisplay } from './HistoryItemDisplay.js';
|
||||||
import { type HistoryItem } from '../types.js';
|
import { type HistoryItem } from '../types.js';
|
||||||
import { MessageType } from '../types.js';
|
import { MessageType } from '../types.js';
|
||||||
@@ -290,6 +291,27 @@ describe('<HistoryItemDisplay />', () => {
|
|||||||
unmount();
|
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();
|
||||||
|
|
||||||
|
const output = stripAnsi(lastFrame());
|
||||||
|
expect(output).toContain(' Thinking...');
|
||||||
|
expect(output).toContain('Thinking');
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
it('does not render thinking item when disabled', async () => {
|
it('does not render thinking item when disabled', async () => {
|
||||||
const item: HistoryItem = {
|
const item: HistoryItem = {
|
||||||
...baseItem,
|
...baseItem,
|
||||||
|
|||||||
@@ -47,6 +47,8 @@ interface HistoryItemDisplayProps {
|
|||||||
commands?: readonly SlashCommand[];
|
commands?: readonly SlashCommand[];
|
||||||
availableTerminalHeightGemini?: number;
|
availableTerminalHeightGemini?: number;
|
||||||
isExpandable?: boolean;
|
isExpandable?: boolean;
|
||||||
|
isFirstThinking?: boolean;
|
||||||
|
isLastThinking?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
|
export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
|
||||||
@@ -57,6 +59,8 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
|
|||||||
commands,
|
commands,
|
||||||
availableTerminalHeightGemini,
|
availableTerminalHeightGemini,
|
||||||
isExpandable,
|
isExpandable,
|
||||||
|
isFirstThinking = false,
|
||||||
|
isLastThinking = false,
|
||||||
}) => {
|
}) => {
|
||||||
const settings = useSettings();
|
const settings = useSettings();
|
||||||
const inlineThinkingMode = getInlineThinkingMode(settings);
|
const inlineThinkingMode = getInlineThinkingMode(settings);
|
||||||
@@ -71,7 +75,12 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
|
|||||||
>
|
>
|
||||||
{/* Render standard message types */}
|
{/* Render standard message types */}
|
||||||
{itemForDisplay.type === 'thinking' && inlineThinkingMode !== 'off' && (
|
{itemForDisplay.type === 'thinking' && inlineThinkingMode !== 'off' && (
|
||||||
<ThinkingMessage thought={itemForDisplay.thought} />
|
<ThinkingMessage
|
||||||
|
thought={itemForDisplay.thought}
|
||||||
|
terminalWidth={terminalWidth}
|
||||||
|
isFirstThinking={isFirstThinking}
|
||||||
|
isLastThinking={isLastThinking}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
{itemForDisplay.type === 'hint' && (
|
{itemForDisplay.type === 'hint' && (
|
||||||
<HintMessage text={itemForDisplay.text} />
|
<HintMessage text={itemForDisplay.text} />
|
||||||
|
|||||||
@@ -258,13 +258,32 @@ describe('<LoadingIndicator />', () => {
|
|||||||
const output = lastFrame();
|
const output = lastFrame();
|
||||||
expect(output).toBeDefined();
|
expect(output).toBeDefined();
|
||||||
if (output) {
|
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).toContain('Thinking about something...');
|
||||||
expect(output).not.toContain('and other stuff.');
|
expect(output).not.toContain('and other stuff.');
|
||||||
}
|
}
|
||||||
unmount();
|
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 () => {
|
it('should prioritize thought.subject over currentLoadingPhrase', async () => {
|
||||||
const props = {
|
const props = {
|
||||||
thought: {
|
thought: {
|
||||||
@@ -280,13 +299,13 @@ describe('<LoadingIndicator />', () => {
|
|||||||
);
|
);
|
||||||
await waitUntilReady();
|
await waitUntilReady();
|
||||||
const output = lastFrame();
|
const output = lastFrame();
|
||||||
expect(output).toContain(''); // Replaced emoji expectation
|
expect(output).toContain('Thinking... ');
|
||||||
expect(output).toContain('This should be displayed');
|
expect(output).toContain('This should be displayed');
|
||||||
expect(output).not.toContain('This should not be displayed');
|
expect(output).not.toContain('This should not be displayed');
|
||||||
unmount();
|
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(
|
const { lastFrame, unmount, waitUntilReady } = renderWithContext(
|
||||||
<LoadingIndicator
|
<LoadingIndicator
|
||||||
currentLoadingPhrase="some random tip..."
|
currentLoadingPhrase="some random tip..."
|
||||||
@@ -295,7 +314,7 @@ describe('<LoadingIndicator />', () => {
|
|||||||
StreamingState.Responding,
|
StreamingState.Responding,
|
||||||
);
|
);
|
||||||
await waitUntilReady();
|
await waitUntilReady();
|
||||||
expect(lastFrame()).toContain(''); // Replaced emoji expectation
|
expect(lastFrame()).not.toContain('Thinking... ');
|
||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -67,7 +67,16 @@ export const LoadingIndicator: React.FC<LoadingIndicatorProps> = ({
|
|||||||
(streamingState === StreamingState.Responding
|
(streamingState === StreamingState.Responding
|
||||||
? GENERIC_WORKING_LABEL
|
? GENERIC_WORKING_LABEL
|
||||||
: undefined);
|
: 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 =
|
const cancelAndTimerContent =
|
||||||
showCancelAndTimer &&
|
showCancelAndTimer &&
|
||||||
|
|||||||
@@ -65,6 +65,14 @@ export const MainContent = () => {
|
|||||||
() =>
|
() =>
|
||||||
uiState.history.map((h, index) => {
|
uiState.history.map((h, index) => {
|
||||||
const isExpandable = index > lastUserPromptIndex;
|
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 (
|
return (
|
||||||
<MemoizedHistoryItemDisplay
|
<MemoizedHistoryItemDisplay
|
||||||
terminalWidth={mainAreaWidth}
|
terminalWidth={mainAreaWidth}
|
||||||
@@ -79,6 +87,8 @@ export const MainContent = () => {
|
|||||||
isPending={false}
|
isPending={false}
|
||||||
commands={uiState.slashCommands}
|
commands={uiState.slashCommands}
|
||||||
isExpandable={isExpandable}
|
isExpandable={isExpandable}
|
||||||
|
isFirstThinking={isFirstThinking}
|
||||||
|
isLastThinking={isLastThinking}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
@@ -105,7 +115,18 @@ export const MainContent = () => {
|
|||||||
const pendingItems = useMemo(
|
const pendingItems = useMemo(
|
||||||
() => (
|
() => (
|
||||||
<Box flexDirection="column">
|
<Box flexDirection="column">
|
||||||
{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 (
|
||||||
<HistoryItemDisplay
|
<HistoryItemDisplay
|
||||||
key={i}
|
key={i}
|
||||||
availableTerminalHeight={
|
availableTerminalHeight={
|
||||||
@@ -115,8 +136,11 @@ export const MainContent = () => {
|
|||||||
item={{ ...item, id: 0 }}
|
item={{ ...item, id: 0 }}
|
||||||
isPending={true}
|
isPending={true}
|
||||||
isExpandable={true}
|
isExpandable={true}
|
||||||
|
isFirstThinking={isFirstThinking}
|
||||||
|
isLastThinking={isLastThinking}
|
||||||
/>
|
/>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
{showConfirmationQueue && confirmingTool && (
|
{showConfirmationQueue && confirmingTool && (
|
||||||
<ToolConfirmationQueue confirmingTool={confirmingTool} />
|
<ToolConfirmationQueue confirmingTool={confirmingTool} />
|
||||||
)}
|
)}
|
||||||
@@ -129,17 +153,29 @@ export const MainContent = () => {
|
|||||||
mainAreaWidth,
|
mainAreaWidth,
|
||||||
showConfirmationQueue,
|
showConfirmationQueue,
|
||||||
confirmingTool,
|
confirmingTool,
|
||||||
|
uiState.history,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const virtualizedData = useMemo(
|
const virtualizedData = useMemo(
|
||||||
() => [
|
() => [
|
||||||
{ type: 'header' as const },
|
{ type: 'header' as const },
|
||||||
...uiState.history.map((item, index) => ({
|
...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,
|
type: 'history' as const,
|
||||||
item,
|
item,
|
||||||
isExpandable: index > lastUserPromptIndex,
|
isExpandable: index > lastUserPromptIndex,
|
||||||
})),
|
isFirstThinking,
|
||||||
|
isLastThinking,
|
||||||
|
};
|
||||||
|
}),
|
||||||
{ type: 'pending' as const },
|
{ type: 'pending' as const },
|
||||||
],
|
],
|
||||||
[uiState.history, lastUserPromptIndex],
|
[uiState.history, lastUserPromptIndex],
|
||||||
@@ -170,6 +206,8 @@ export const MainContent = () => {
|
|||||||
isPending={false}
|
isPending={false}
|
||||||
commands={uiState.slashCommands}
|
commands={uiState.slashCommands}
|
||||||
isExpandable={item.isExpandable}
|
isExpandable={item.isExpandable}
|
||||||
|
isFirstThinking={item.isFirstThinking}
|
||||||
|
isLastThinking={item.isLastThinking}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -389,7 +389,8 @@ exports[`<HistoryItemDisplay /> > renders InfoMessage for "info" type with multi
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
exports[`<HistoryItemDisplay /> > thinking items > renders thinking item when enabled 1`] = `
|
exports[`<HistoryItemDisplay /> > thinking items > renders thinking item when enabled 1`] = `
|
||||||
" Thinking
|
" │
|
||||||
│ test
|
│ Thinking
|
||||||
|
│ test
|
||||||
"
|
"
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -9,15 +9,20 @@ import { renderWithProviders } from '../../../test-utils/render.js';
|
|||||||
import { ThinkingMessage } from './ThinkingMessage.js';
|
import { ThinkingMessage } from './ThinkingMessage.js';
|
||||||
|
|
||||||
describe('ThinkingMessage', () => {
|
describe('ThinkingMessage', () => {
|
||||||
it('renders subject line', async () => {
|
it('renders subject line with vertical rule and "Thinking..." header', async () => {
|
||||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||||
<ThinkingMessage
|
<ThinkingMessage
|
||||||
thought={{ subject: 'Planning', description: 'test' }}
|
thought={{ subject: 'Planning', description: 'test' }}
|
||||||
|
terminalWidth={80}
|
||||||
|
isFirstThinking={true}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
await waitUntilReady();
|
await waitUntilReady();
|
||||||
|
|
||||||
expect(lastFrame()).toMatchSnapshot();
|
const output = lastFrame();
|
||||||
|
expect(output).toContain(' Thinking...');
|
||||||
|
expect(output).toContain('│');
|
||||||
|
expect(output).toContain('Planning');
|
||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -25,11 +30,14 @@ describe('ThinkingMessage', () => {
|
|||||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||||
<ThinkingMessage
|
<ThinkingMessage
|
||||||
thought={{ subject: '', description: 'Processing details' }}
|
thought={{ subject: '', description: 'Processing details' }}
|
||||||
|
terminalWidth={80}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
await waitUntilReady();
|
await waitUntilReady();
|
||||||
|
|
||||||
expect(lastFrame()).toMatchSnapshot();
|
const output = lastFrame();
|
||||||
|
expect(output).toContain('Processing details');
|
||||||
|
expect(output).toContain('│');
|
||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -40,26 +48,35 @@ describe('ThinkingMessage', () => {
|
|||||||
subject: 'Planning',
|
subject: 'Planning',
|
||||||
description: 'I am planning the solution.',
|
description: 'I am planning the solution.',
|
||||||
}}
|
}}
|
||||||
|
terminalWidth={80}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
await waitUntilReady();
|
await waitUntilReady();
|
||||||
|
|
||||||
expect(lastFrame()).toMatchSnapshot();
|
const output = lastFrame();
|
||||||
|
expect(output).toContain('│');
|
||||||
|
expect(output).toContain('Planning');
|
||||||
|
expect(output).toContain('I am planning the solution.');
|
||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('indents summary line correctly', async () => {
|
it('renders "Thinking..." header when isFirstThinking is true', async () => {
|
||||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||||
<ThinkingMessage
|
<ThinkingMessage
|
||||||
thought={{
|
thought={{
|
||||||
subject: 'Summary line',
|
subject: 'Summary line',
|
||||||
description: 'First body line',
|
description: 'First body line',
|
||||||
}}
|
}}
|
||||||
|
terminalWidth={80}
|
||||||
|
isFirstThinking={true}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
await waitUntilReady();
|
await waitUntilReady();
|
||||||
|
|
||||||
expect(lastFrame()).toMatchSnapshot();
|
const output = lastFrame();
|
||||||
|
expect(output).toContain(' Thinking...');
|
||||||
|
expect(output).toContain('Summary line');
|
||||||
|
expect(output).toContain('│');
|
||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -70,6 +87,7 @@ describe('ThinkingMessage', () => {
|
|||||||
subject: 'Matching the Blocks',
|
subject: 'Matching the Blocks',
|
||||||
description: '\\n\\nSome more text',
|
description: '\\n\\nSome more text',
|
||||||
}}
|
}}
|
||||||
|
terminalWidth={80}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
await waitUntilReady();
|
await waitUntilReady();
|
||||||
@@ -80,7 +98,10 @@ describe('ThinkingMessage', () => {
|
|||||||
|
|
||||||
it('renders empty state gracefully', async () => {
|
it('renders empty state gracefully', async () => {
|
||||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||||
<ThinkingMessage thought={{ subject: '', description: '' }} />,
|
<ThinkingMessage
|
||||||
|
thought={{ subject: '', description: '' }}
|
||||||
|
terminalWidth={80}
|
||||||
|
/>,
|
||||||
);
|
);
|
||||||
await waitUntilReady();
|
await waitUntilReady();
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,105 @@ import { normalizeEscapedNewlines } from '../../utils/textUtils.js';
|
|||||||
|
|
||||||
interface ThinkingMessageProps {
|
interface ThinkingMessageProps {
|
||||||
thought: ThoughtSummary;
|
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<ThinkingMessageProps> = ({
|
export const ThinkingMessage: React.FC<ThinkingMessageProps> = ({
|
||||||
thought,
|
thought,
|
||||||
|
terminalWidth,
|
||||||
|
isFirstThinking,
|
||||||
|
isLastThinking,
|
||||||
}) => {
|
}) => {
|
||||||
const { summary, body } = useMemo(() => {
|
const fullLines = useMemo(() => normalizeThoughtLines(thought), [thought]);
|
||||||
const subject = normalizeEscapedNewlines(thought.subject).trim();
|
const contentWidth = Math.max(
|
||||||
const description = normalizeEscapedNewlines(thought.description).trim();
|
terminalWidth - THINKING_LEFT_PADDING - VERTICAL_LINE_WIDTH - 2,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
|
||||||
if (!subject && !description) {
|
const fullSummaryDisplayLines = useMemo(
|
||||||
return { summary: '', body: '' };
|
() =>
|
||||||
}
|
fullLines.length > 0 ? wrapLineToWidth(fullLines[0], contentWidth) : [],
|
||||||
|
[fullLines, contentWidth],
|
||||||
|
);
|
||||||
|
|
||||||
if (!subject) {
|
const fullBodyDisplayLines = useMemo(
|
||||||
const lines = description
|
() =>
|
||||||
.split('\n')
|
fullLines.slice(1).flatMap((line) => wrapLineToWidth(line, contentWidth)),
|
||||||
.map((l) => l.trim())
|
[fullLines, contentWidth],
|
||||||
.filter(Boolean);
|
);
|
||||||
return {
|
|
||||||
summary: lines[0] || '',
|
|
||||||
body: lines.slice(1).join('\n'),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
if (fullLines.length === 0) {
|
||||||
summary: subject,
|
|
||||||
body: description,
|
|
||||||
};
|
|
||||||
}, [thought]);
|
|
||||||
|
|
||||||
if (!summary && !body) {
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const verticalLine = (
|
||||||
|
<Box width={VERTICAL_LINE_WIDTH}>
|
||||||
|
<Text color={theme.text.secondary}>│</Text>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box width="100%" marginBottom={1} flexDirection="column">
|
|
||||||
{summary && (
|
|
||||||
<Box paddingLeft={1}>
|
|
||||||
<Text color={theme.text.primary} bold italic>
|
|
||||||
{summary}
|
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
{body && (
|
|
||||||
<Box
|
<Box
|
||||||
borderStyle="single"
|
width={terminalWidth}
|
||||||
borderLeft
|
flexDirection="column"
|
||||||
borderRight={false}
|
marginBottom={isLastThinking ? 1 : 0}
|
||||||
borderTop={false}
|
|
||||||
borderBottom={false}
|
|
||||||
borderColor={theme.border.default}
|
|
||||||
paddingLeft={1}
|
|
||||||
>
|
>
|
||||||
<Text color={theme.text.secondary} italic>
|
{isFirstThinking && (
|
||||||
{body}
|
<>
|
||||||
|
<Text color={theme.text.primary} italic>
|
||||||
|
{' '}
|
||||||
|
Thinking...{' '}
|
||||||
</Text>
|
</Text>
|
||||||
|
<Box flexDirection="row">
|
||||||
|
<Box width={THINKING_LEFT_PADDING} />
|
||||||
|
{verticalLine}
|
||||||
|
<Text> </Text>
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isFirstThinking && (
|
||||||
|
<Box flexDirection="row">
|
||||||
|
<Box width={THINKING_LEFT_PADDING} />
|
||||||
|
{verticalLine}
|
||||||
|
<Text> </Text>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{fullSummaryDisplayLines.map((line, index) => (
|
||||||
|
<Box key={`summary-line-row-${index}`} flexDirection="row">
|
||||||
|
<Box width={THINKING_LEFT_PADDING} />
|
||||||
|
{verticalLine}
|
||||||
|
<Box marginLeft={1}>
|
||||||
|
<Text color={theme.text.primary} bold italic wrap="truncate-end">
|
||||||
|
{line}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
{fullBodyDisplayLines.map((line, index) => (
|
||||||
|
<Box key={`body-line-row-${index}`} flexDirection="row">
|
||||||
|
<Box width={THINKING_LEFT_PADDING} />
|
||||||
|
{verticalLine}
|
||||||
|
<Box marginLeft={1}>
|
||||||
|
<Text color={theme.text.secondary} italic wrap="truncate-end">
|
||||||
|
{line}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
+3
-25
@@ -1,30 +1,8 @@
|
|||||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
// 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`] = `
|
exports[`ThinkingMessage > normalizes escaped newline tokens 1`] = `
|
||||||
" Matching the Blocks
|
" │
|
||||||
│ Some more text
|
│ 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
|
|
||||||
"
|
"
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -2793,7 +2793,6 @@ describe('useGeminiStream', () => {
|
|||||||
type: 'thinking',
|
type: 'thinking',
|
||||||
thought: expect.objectContaining({ subject: 'Full thought' }),
|
thought: expect.objectContaining({ subject: 'Full thought' }),
|
||||||
}),
|
}),
|
||||||
expect.any(Number),
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -903,17 +903,14 @@ export const useGeminiStream = (
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleThoughtEvent = useCallback(
|
const handleThoughtEvent = useCallback(
|
||||||
(eventValue: ThoughtSummary, userMessageTimestamp: number) => {
|
(eventValue: ThoughtSummary, _userMessageTimestamp: number) => {
|
||||||
setThought(eventValue);
|
setThought(eventValue);
|
||||||
|
|
||||||
if (getInlineThinkingMode(settings) === 'full') {
|
if (getInlineThinkingMode(settings) === 'full') {
|
||||||
addItem(
|
addItem({
|
||||||
{
|
|
||||||
type: 'thinking',
|
type: 'thinking',
|
||||||
thought: eventValue,
|
thought: eventValue,
|
||||||
} as HistoryItemThinking,
|
} as HistoryItemThinking);
|
||||||
userMessageTimestamp,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[addItem, settings, setThought],
|
[addItem, settings, setThought],
|
||||||
|
|||||||
Reference in New Issue
Block a user