feat: Show model thoughts while loading (#992)
This commit is contained in:
parent
b3d89a1075
commit
123ad20e9b
|
@ -300,6 +300,7 @@ const App = ({ config, settings, startupWarnings = [] }: AppProps) => {
|
||||||
submitQuery,
|
submitQuery,
|
||||||
initError,
|
initError,
|
||||||
pendingHistoryItems: pendingGeminiHistoryItems,
|
pendingHistoryItems: pendingGeminiHistoryItems,
|
||||||
|
thought,
|
||||||
} = useGeminiStream(
|
} = useGeminiStream(
|
||||||
config.getGeminiClient(),
|
config.getGeminiClient(),
|
||||||
history,
|
history,
|
||||||
|
@ -542,6 +543,12 @@ const App = ({ config, settings, startupWarnings = [] }: AppProps) => {
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<LoadingIndicator
|
<LoadingIndicator
|
||||||
|
thought={
|
||||||
|
streamingState === StreamingState.WaitingForConfirmation ||
|
||||||
|
config.getAccessibility()?.disableLoadingPhrases
|
||||||
|
? undefined
|
||||||
|
: thought
|
||||||
|
}
|
||||||
currentLoadingPhrase={
|
currentLoadingPhrase={
|
||||||
config.getAccessibility()?.disableLoadingPhrases
|
config.getAccessibility()?.disableLoadingPhrases
|
||||||
? undefined
|
? undefined
|
||||||
|
|
|
@ -159,4 +159,56 @@ describe('<LoadingIndicator />', () => {
|
||||||
);
|
);
|
||||||
expect(lastFrame()).toBe('');
|
expect(lastFrame()).toBe('');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should display fallback phrase if thought is empty', () => {
|
||||||
|
const props = {
|
||||||
|
thought: null,
|
||||||
|
currentLoadingPhrase: 'Loading...',
|
||||||
|
elapsedTime: 5,
|
||||||
|
};
|
||||||
|
const { lastFrame } = renderWithContext(
|
||||||
|
<LoadingIndicator {...props} />,
|
||||||
|
StreamingState.Responding,
|
||||||
|
);
|
||||||
|
const output = lastFrame();
|
||||||
|
expect(output).toContain('Loading...');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should display the subject of a thought', () => {
|
||||||
|
const props = {
|
||||||
|
thought: {
|
||||||
|
subject: 'Thinking about something...',
|
||||||
|
description: 'and other stuff.',
|
||||||
|
},
|
||||||
|
elapsedTime: 5,
|
||||||
|
};
|
||||||
|
const { lastFrame } = renderWithContext(
|
||||||
|
<LoadingIndicator {...props} />,
|
||||||
|
StreamingState.Responding,
|
||||||
|
);
|
||||||
|
const output = lastFrame();
|
||||||
|
expect(output).toBeDefined();
|
||||||
|
if (output) {
|
||||||
|
expect(output).toContain('Thinking about something...');
|
||||||
|
expect(output).not.toContain('and other stuff.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should prioritize thought.subject over currentLoadingPhrase', () => {
|
||||||
|
const props = {
|
||||||
|
thought: {
|
||||||
|
subject: 'This should be displayed',
|
||||||
|
description: 'A description',
|
||||||
|
},
|
||||||
|
currentLoadingPhrase: 'This should not be displayed',
|
||||||
|
elapsedTime: 5,
|
||||||
|
};
|
||||||
|
const { lastFrame } = renderWithContext(
|
||||||
|
<LoadingIndicator {...props} />,
|
||||||
|
StreamingState.Responding,
|
||||||
|
);
|
||||||
|
const output = lastFrame();
|
||||||
|
expect(output).toContain('This should be displayed');
|
||||||
|
expect(output).not.toContain('This should not be displayed');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -4,6 +4,7 @@
|
||||||
* SPDX-License-Identifier: Apache-2.0
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { ThoughtSummary } from '@gemini-cli/core';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Box, Text } from 'ink';
|
import { Box, Text } from 'ink';
|
||||||
import { Colors } from '../colors.js';
|
import { Colors } from '../colors.js';
|
||||||
|
@ -15,12 +16,14 @@ interface LoadingIndicatorProps {
|
||||||
currentLoadingPhrase?: string;
|
currentLoadingPhrase?: string;
|
||||||
elapsedTime: number;
|
elapsedTime: number;
|
||||||
rightContent?: React.ReactNode;
|
rightContent?: React.ReactNode;
|
||||||
|
thought?: ThoughtSummary | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const LoadingIndicator: React.FC<LoadingIndicatorProps> = ({
|
export const LoadingIndicator: React.FC<LoadingIndicatorProps> = ({
|
||||||
currentLoadingPhrase,
|
currentLoadingPhrase,
|
||||||
elapsedTime,
|
elapsedTime,
|
||||||
rightContent,
|
rightContent,
|
||||||
|
thought,
|
||||||
}) => {
|
}) => {
|
||||||
const streamingState = useStreamingContext();
|
const streamingState = useStreamingContext();
|
||||||
|
|
||||||
|
@ -28,18 +31,22 @@ export const LoadingIndicator: React.FC<LoadingIndicatorProps> = ({
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const primaryText = thought?.subject || currentLoadingPhrase;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box marginTop={1} paddingLeft={0}>
|
<Box marginTop={1} paddingLeft={0} flexDirection="column">
|
||||||
|
{/* Main loading line */}
|
||||||
|
<Box>
|
||||||
<Box marginRight={1}>
|
<Box marginRight={1}>
|
||||||
<GeminiRespondingSpinner
|
<GeminiRespondingSpinner
|
||||||
nonRespondingDisplay={
|
nonRespondingDisplay={
|
||||||
streamingState === StreamingState.WaitingForConfirmation ? '⠏' : ''
|
streamingState === StreamingState.WaitingForConfirmation
|
||||||
|
? '⠏'
|
||||||
|
: ''
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
{currentLoadingPhrase && (
|
{primaryText && <Text color={Colors.AccentPurple}>{primaryText}</Text>}
|
||||||
<Text color={Colors.AccentPurple}>{currentLoadingPhrase}</Text>
|
|
||||||
)}
|
|
||||||
<Text color={Colors.Gray}>
|
<Text color={Colors.Gray}>
|
||||||
{streamingState === StreamingState.WaitingForConfirmation
|
{streamingState === StreamingState.WaitingForConfirmation
|
||||||
? ''
|
? ''
|
||||||
|
@ -48,5 +55,6 @@ export const LoadingIndicator: React.FC<LoadingIndicatorProps> = ({
|
||||||
<Box flexGrow={1}>{/* Spacer */}</Box>
|
<Box flexGrow={1}>{/* Spacer */}</Box>
|
||||||
{rightContent && <Box>{rightContent}</Box>}
|
{rightContent && <Box>{rightContent}</Box>}
|
||||||
</Box>
|
</Box>
|
||||||
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -21,6 +21,7 @@ import {
|
||||||
logUserPrompt,
|
logUserPrompt,
|
||||||
GitService,
|
GitService,
|
||||||
EditorType,
|
EditorType,
|
||||||
|
ThoughtSummary,
|
||||||
} from '@gemini-cli/core';
|
} from '@gemini-cli/core';
|
||||||
import { type Part, type PartListUnion } from '@google/genai';
|
import { type Part, type PartListUnion } from '@google/genai';
|
||||||
import {
|
import {
|
||||||
|
@ -90,6 +91,7 @@ export const useGeminiStream = (
|
||||||
const [initError, setInitError] = useState<string | null>(null);
|
const [initError, setInitError] = useState<string | null>(null);
|
||||||
const abortControllerRef = useRef<AbortController | null>(null);
|
const abortControllerRef = useRef<AbortController | null>(null);
|
||||||
const [isResponding, setIsResponding] = useState<boolean>(false);
|
const [isResponding, setIsResponding] = useState<boolean>(false);
|
||||||
|
const [thought, setThought] = useState<ThoughtSummary | null>(null);
|
||||||
const [pendingHistoryItemRef, setPendingHistoryItem] =
|
const [pendingHistoryItemRef, setPendingHistoryItem] =
|
||||||
useStateAndRef<HistoryItemWithoutId | null>(null);
|
useStateAndRef<HistoryItemWithoutId | null>(null);
|
||||||
const logger = useLogger();
|
const logger = useLogger();
|
||||||
|
@ -393,6 +395,9 @@ export const useGeminiStream = (
|
||||||
const toolCallRequests: ToolCallRequestInfo[] = [];
|
const toolCallRequests: ToolCallRequestInfo[] = [];
|
||||||
for await (const event of stream) {
|
for await (const event of stream) {
|
||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
|
case ServerGeminiEventType.Thought:
|
||||||
|
setThought(event.value);
|
||||||
|
break;
|
||||||
case ServerGeminiEventType.Content:
|
case ServerGeminiEventType.Content:
|
||||||
geminiMessageBuffer = handleContentEvent(
|
geminiMessageBuffer = handleContentEvent(
|
||||||
event.value,
|
event.value,
|
||||||
|
@ -730,5 +735,6 @@ export const useGeminiStream = (
|
||||||
submitQuery,
|
submitQuery,
|
||||||
initError,
|
initError,
|
||||||
pendingHistoryItems,
|
pendingHistoryItems,
|
||||||
|
thought,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
|
@ -38,6 +38,11 @@ import {
|
||||||
import { ProxyAgent, setGlobalDispatcher } from 'undici';
|
import { ProxyAgent, setGlobalDispatcher } from 'undici';
|
||||||
import { DEFAULT_GEMINI_FLASH_MODEL } from '../config/models.js';
|
import { DEFAULT_GEMINI_FLASH_MODEL } from '../config/models.js';
|
||||||
|
|
||||||
|
function isThinkingSupported(model: string) {
|
||||||
|
if (model.startsWith('gemini-2.5')) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
export class GeminiClient {
|
export class GeminiClient {
|
||||||
private chat: Promise<GeminiChat>;
|
private chat: Promise<GeminiChat>;
|
||||||
private contentGenerator: Promise<ContentGenerator>;
|
private contentGenerator: Promise<ContentGenerator>;
|
||||||
|
@ -164,14 +169,21 @@ export class GeminiClient {
|
||||||
try {
|
try {
|
||||||
const userMemory = this.config.getUserMemory();
|
const userMemory = this.config.getUserMemory();
|
||||||
const systemInstruction = getCoreSystemPrompt(userMemory);
|
const systemInstruction = getCoreSystemPrompt(userMemory);
|
||||||
|
const generateContentConfigWithThinking = isThinkingSupported(this.model)
|
||||||
|
? {
|
||||||
|
...this.generateContentConfig,
|
||||||
|
thinkingConfig: {
|
||||||
|
includeThoughts: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: this.generateContentConfig;
|
||||||
return new GeminiChat(
|
return new GeminiChat(
|
||||||
this.config,
|
this.config,
|
||||||
await this.contentGenerator,
|
await this.contentGenerator,
|
||||||
this.model,
|
this.model,
|
||||||
{
|
{
|
||||||
systemInstruction,
|
systemInstruction,
|
||||||
...this.generateContentConfig,
|
...generateContentConfigWithThinking,
|
||||||
tools,
|
tools,
|
||||||
},
|
},
|
||||||
history,
|
history,
|
||||||
|
|
|
@ -417,6 +417,10 @@ export class GeminiChat {
|
||||||
chunks.push(chunk);
|
chunks.push(chunk);
|
||||||
const content = chunk.candidates?.[0]?.content;
|
const content = chunk.candidates?.[0]?.content;
|
||||||
if (content !== undefined) {
|
if (content !== undefined) {
|
||||||
|
if (this.isThoughtContent(content)) {
|
||||||
|
yield chunk;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
outputContent.push(content);
|
outputContent.push(content);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -452,12 +456,19 @@ export class GeminiChat {
|
||||||
modelOutput: Content[],
|
modelOutput: Content[],
|
||||||
automaticFunctionCallingHistory?: Content[],
|
automaticFunctionCallingHistory?: Content[],
|
||||||
) {
|
) {
|
||||||
|
const nonThoughtModelOutput = modelOutput.filter(
|
||||||
|
(content) => !this.isThoughtContent(content),
|
||||||
|
);
|
||||||
|
|
||||||
let outputContents: Content[] = [];
|
let outputContents: Content[] = [];
|
||||||
if (
|
if (
|
||||||
modelOutput.length > 0 &&
|
nonThoughtModelOutput.length > 0 &&
|
||||||
modelOutput.every((content) => content.role !== undefined)
|
nonThoughtModelOutput.every((content) => content.role !== undefined)
|
||||||
) {
|
) {
|
||||||
outputContents = modelOutput;
|
outputContents = nonThoughtModelOutput;
|
||||||
|
} else if (nonThoughtModelOutput.length === 0 && modelOutput.length > 0) {
|
||||||
|
// This case handles when the model returns only a thought.
|
||||||
|
// We don't want to add an empty model response in this case.
|
||||||
} else {
|
} else {
|
||||||
// When not a function response appends an empty content when model returns empty response, so that the
|
// When not a function response appends an empty content when model returns empty response, so that the
|
||||||
// history is always alternating between user and model.
|
// history is always alternating between user and model.
|
||||||
|
@ -486,7 +497,6 @@ export class GeminiChat {
|
||||||
if (this.isThoughtContent(content)) {
|
if (this.isThoughtContent(content)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const lastContent =
|
const lastContent =
|
||||||
consolidatedOutputContents[consolidatedOutputContents.length - 1];
|
consolidatedOutputContents[consolidatedOutputContents.length - 1];
|
||||||
if (this.isTextContent(lastContent) && this.isTextContent(content)) {
|
if (this.isTextContent(lastContent) && this.isTextContent(content)) {
|
||||||
|
|
|
@ -45,6 +45,7 @@ export enum GeminiEventType {
|
||||||
Error = 'error',
|
Error = 'error',
|
||||||
ChatCompressed = 'chat_compressed',
|
ChatCompressed = 'chat_compressed',
|
||||||
UsageMetadata = 'usage_metadata',
|
UsageMetadata = 'usage_metadata',
|
||||||
|
Thought = 'thought',
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GeminiErrorEventValue {
|
export interface GeminiErrorEventValue {
|
||||||
|
@ -69,11 +70,21 @@ export interface ServerToolCallConfirmationDetails {
|
||||||
details: ToolCallConfirmationDetails;
|
details: ToolCallConfirmationDetails;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ThoughtSummary = {
|
||||||
|
subject: string;
|
||||||
|
description: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type ServerGeminiContentEvent = {
|
export type ServerGeminiContentEvent = {
|
||||||
type: GeminiEventType.Content;
|
type: GeminiEventType.Content;
|
||||||
value: string;
|
value: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ServerGeminiThoughtEvent = {
|
||||||
|
type: GeminiEventType.Thought;
|
||||||
|
value: ThoughtSummary;
|
||||||
|
};
|
||||||
|
|
||||||
export type ServerGeminiToolCallRequestEvent = {
|
export type ServerGeminiToolCallRequestEvent = {
|
||||||
type: GeminiEventType.ToolCallRequest;
|
type: GeminiEventType.ToolCallRequest;
|
||||||
value: ToolCallRequestInfo;
|
value: ToolCallRequestInfo;
|
||||||
|
@ -122,7 +133,8 @@ export type ServerGeminiStreamEvent =
|
||||||
| ServerGeminiUserCancelledEvent
|
| ServerGeminiUserCancelledEvent
|
||||||
| ServerGeminiErrorEvent
|
| ServerGeminiErrorEvent
|
||||||
| ServerGeminiChatCompressedEvent
|
| ServerGeminiChatCompressedEvent
|
||||||
| ServerGeminiUsageMetadataEvent;
|
| ServerGeminiUsageMetadataEvent
|
||||||
|
| ServerGeminiThoughtEvent;
|
||||||
|
|
||||||
// A turn manages the agentic loop turn within the server context.
|
// A turn manages the agentic loop turn within the server context.
|
||||||
export class Turn {
|
export class Turn {
|
||||||
|
@ -160,6 +172,28 @@ export class Turn {
|
||||||
}
|
}
|
||||||
this.debugResponses.push(resp);
|
this.debugResponses.push(resp);
|
||||||
|
|
||||||
|
const thoughtPart = resp.candidates?.[0]?.content?.parts?.[0];
|
||||||
|
if (thoughtPart?.thought) {
|
||||||
|
// Thought always has a bold "subject" part enclosed in double asterisks
|
||||||
|
// (e.g., **Subject**). The rest of the string is considered the description.
|
||||||
|
const rawText = thoughtPart.text ?? '';
|
||||||
|
const subjectStringMatches = rawText.match(/\*\*(.*?)\*\*/s);
|
||||||
|
const subject = subjectStringMatches
|
||||||
|
? subjectStringMatches[1].trim()
|
||||||
|
: '';
|
||||||
|
const description = rawText.replace(/\*\*(.*?)\*\*/s, '').trim();
|
||||||
|
const thought: ThoughtSummary = {
|
||||||
|
subject,
|
||||||
|
description,
|
||||||
|
};
|
||||||
|
|
||||||
|
yield {
|
||||||
|
type: GeminiEventType.Thought,
|
||||||
|
value: thought,
|
||||||
|
};
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const text = getResponseText(resp);
|
const text = getResponseText(resp);
|
||||||
if (text) {
|
if (text) {
|
||||||
yield { type: GeminiEventType.Content, value: text };
|
yield { type: GeminiEventType.Content, value: text };
|
||||||
|
|
Loading…
Reference in New Issue