mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-05-01 05:00:46 +00:00
* feat: add Ctrl+Y shortcut to retry failed requests
- Add Ctrl+Y keyboard shortcut for retrying the last failed request
- Add isNetworkError() to detect transient network failures (ECONNREFUSED, ETIMEDOUT, etc.)
- Add DashScope 1305 error code to rate limit detection
- Add error hint \"Press Ctrl+Y to retry\" in error messages
- Support user-defined error codes for retry via config
- Add retryLastPrompt() hook in useGeminiStream
- Update keyboard shortcuts documentation
* feat: improve Ctrl+Y retry feature with tests, docs, and rate limit config
- Add comprehensive tests for Ctrl+Y retry shortcut in InputPrompt
- Add unit tests for retryLastPrompt in useGeminiStream hook
- Add detailed JSDoc comments for retryLastPrompt function and Ctrl+Y shortcut
- Extend isRateLimitError to support custom error codes via retryErrorCodes config
- Fix rate limit retry log variable reference (RATE_LIMIT_RETRY_OPTIONS → maxRateLimitRetries)
- Add Eclipse IDE files to .gitignore
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
* refactor(ui): consolidate retry countdown as inline hint in error messages
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
* feat(cli): enhance error handling with improved retry mechanism
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
- Modify ErrorMessage component to remove dim color from hint text
- Update useGeminiStream hook to improve retry countdown behavior with option to preserve or clear hints
- Adjust tests to match new error handling implementation
* feat: add Ctrl+Y shortcut to retry the last failed request
When a request errors out, the error message shows an inline hint
"(Press Ctrl+Y to retry.)" in secondary color. Pressing Ctrl+Y
re-submits the same prompt, commits the error text to history
(without the hint), and clears the hint from the UI.
- Add retryLastPrompt action wired to Ctrl+Y via keyBindings and InputPrompt
- Track last submitted prompt and error state in useGeminiStream refs
- Show retry hint inline with error text in ErrorMessage component,
wrapping naturally on narrow terminals while preserving hint color
- Expose retryLastPrompt through UIActionsContext
- Add keyboard shortcut entry in KeyboardShortcuts display
- Add i18n strings for hint and no-retry-available message
- Document Ctrl+Y in keyboard-shortcuts.md
* docs(configuration): Update model provider configuration document
* chore: remove YOLO mode code from core
* fix: prevent Ctrl+Y hint from overriding auto-retry countdown
When an auto-retry countdown is active (retryCountdownTimerRef is set),
handleErrorEvent should not overwrite it with the Ctrl+Y hint. The auto-retry
hint ("retrying in Xs...") and manual retry hint ("Press Ctrl+Y to retry.")
are mutually exclusive:
- Auto-retry errors (e.g., rate limits): show countdown hint
- Other errors: show Ctrl+Y hint
Also removed retryErrorCodes from ContentGeneratorConfig as it's not part
of the minimal Ctrl+Y feature scope.
* simplify: remove complex options from clearRetryCountdown
Revert clearRetryCountdown to simplest form without options parameter.
The function now just clears the timer and pending item without any
automatic history commit logic.
* fix: restore pendingRetryCountdownItem as separate state from pendingRetryErrorItem
Auto-retry countdown and manual retry hint are now independent:
- pendingRetryErrorItem: displays error message with optional hint
- pendingRetryCountdownItem: displays separate countdown line for auto-retry
This ensures both can be shown simultaneously without overriding each other.
* fix: restore RetryCountdownMessage rendering in HistoryItemDisplay
The retry_countdown type should be rendered as a separate message,
not inline in ErrorMessage. This allows auto-retry countdown and
manual retry hint to coexist properly.
* fix(cli): properly commit retry error item to history before clearing
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
* fix(cli): remove trailing period from retry hint translations
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Remove unnecessary period from 'Press Ctrl+Y to retry' translation strings in both en.js and zh.js locales. Also update the corresponding usage in useGeminiStream hook.
* chore(sdk-java): add Eclipse project configuration files
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Add .project configuration files for client and qwencode modules to support Eclipse IDE development environment.
* feat(cli): add retry countdown hint to error message
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
* Revert "chore(sdk-java): add Eclipse project configuration files"
This reverts commit da83b5e571.
---------
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
1598 lines
51 KiB
TypeScript
1598 lines
51 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright 2025 Google LLC
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
import { useState, useRef, useCallback, useEffect, useMemo } from 'react';
|
|
import type {
|
|
Config,
|
|
EditorType,
|
|
GeminiClient,
|
|
ServerGeminiChatCompressedEvent,
|
|
ServerGeminiContentEvent as ContentEvent,
|
|
ServerGeminiFinishedEvent,
|
|
ServerGeminiStreamEvent as GeminiEvent,
|
|
ThoughtSummary,
|
|
ToolCallRequestInfo,
|
|
GeminiErrorEventValue,
|
|
} from '@qwen-code/qwen-code-core';
|
|
import {
|
|
GeminiEventType as ServerGeminiEventType,
|
|
createDebugLogger,
|
|
getErrorMessage,
|
|
isNodeError,
|
|
MessageSenderType,
|
|
logUserPrompt,
|
|
GitService,
|
|
UnauthorizedError,
|
|
UserPromptEvent,
|
|
logConversationFinishedEvent,
|
|
ConversationFinishedEvent,
|
|
ApprovalMode,
|
|
parseAndFormatApiError,
|
|
promptIdContext,
|
|
ToolConfirmationOutcome,
|
|
logApiCancel,
|
|
ApiCancelEvent,
|
|
isSupportedImageMimeType,
|
|
getUnsupportedImageFormatWarning,
|
|
} from '@qwen-code/qwen-code-core';
|
|
import { type Part, type PartListUnion, FinishReason } from '@google/genai';
|
|
import type {
|
|
HistoryItem,
|
|
HistoryItemWithoutId,
|
|
HistoryItemToolGroup,
|
|
SlashCommandProcessorResult,
|
|
} from '../types.js';
|
|
import { StreamingState, MessageType, ToolCallStatus } from '../types.js';
|
|
import { isAtCommand, isSlashCommand } from '../utils/commandUtils.js';
|
|
import { useShellCommandProcessor } from './shellCommandProcessor.js';
|
|
import { handleAtCommand } from './atCommandProcessor.js';
|
|
import { findLastSafeSplitPoint } from '../utils/markdownUtilities.js';
|
|
import { useStateAndRef } from './useStateAndRef.js';
|
|
import type { UseHistoryManagerReturn } from './useHistoryManager.js';
|
|
import { useLogger } from './useLogger.js';
|
|
import {
|
|
useReactToolScheduler,
|
|
mapToDisplay as mapTrackedToolCallsToDisplay,
|
|
type TrackedToolCall,
|
|
type TrackedCompletedToolCall,
|
|
type TrackedCancelledToolCall,
|
|
type TrackedWaitingToolCall,
|
|
} from './useReactToolScheduler.js';
|
|
import { promises as fs } from 'node:fs';
|
|
import path from 'node:path';
|
|
import { useSessionStats } from '../contexts/SessionContext.js';
|
|
import type { LoadedSettings } from '../../config/settings.js';
|
|
import { t } from '../../i18n/index.js';
|
|
|
|
const debugLogger = createDebugLogger('GEMINI_STREAM');
|
|
|
|
/**
|
|
* Checks if image parts have supported formats and returns unsupported ones
|
|
*/
|
|
function checkImageFormatsSupport(parts: PartListUnion): {
|
|
hasImages: boolean;
|
|
hasUnsupportedFormats: boolean;
|
|
unsupportedMimeTypes: string[];
|
|
} {
|
|
const unsupportedMimeTypes: string[] = [];
|
|
let hasImages = false;
|
|
|
|
if (typeof parts === 'string') {
|
|
return {
|
|
hasImages: false,
|
|
hasUnsupportedFormats: false,
|
|
unsupportedMimeTypes: [],
|
|
};
|
|
}
|
|
|
|
const partsArray = Array.isArray(parts) ? parts : [parts];
|
|
|
|
for (const part of partsArray) {
|
|
if (typeof part === 'string') continue;
|
|
|
|
let mimeType: string | undefined;
|
|
|
|
// Check inlineData
|
|
if (
|
|
'inlineData' in part &&
|
|
part.inlineData?.mimeType?.startsWith('image/')
|
|
) {
|
|
hasImages = true;
|
|
mimeType = part.inlineData.mimeType;
|
|
}
|
|
|
|
// Check fileData
|
|
if ('fileData' in part && part.fileData?.mimeType?.startsWith('image/')) {
|
|
hasImages = true;
|
|
mimeType = part.fileData.mimeType;
|
|
}
|
|
|
|
// Check if the mime type is supported
|
|
if (mimeType && !isSupportedImageMimeType(mimeType)) {
|
|
unsupportedMimeTypes.push(mimeType);
|
|
}
|
|
}
|
|
|
|
return {
|
|
hasImages,
|
|
hasUnsupportedFormats: unsupportedMimeTypes.length > 0,
|
|
unsupportedMimeTypes,
|
|
};
|
|
}
|
|
|
|
enum StreamProcessingStatus {
|
|
Completed,
|
|
UserCancelled,
|
|
Error,
|
|
}
|
|
|
|
const EDIT_TOOL_NAMES = new Set(['replace', 'write_file']);
|
|
|
|
function showCitations(settings: LoadedSettings): boolean {
|
|
const enabled = settings?.merged?.ui?.showCitations;
|
|
if (enabled !== undefined) {
|
|
return enabled;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Manages the Gemini stream, including user input, command processing,
|
|
* API interaction, and tool call lifecycle.
|
|
*/
|
|
export const useGeminiStream = (
|
|
geminiClient: GeminiClient,
|
|
history: HistoryItem[],
|
|
addItem: UseHistoryManagerReturn['addItem'],
|
|
config: Config,
|
|
settings: LoadedSettings,
|
|
onDebugMessage: (message: string) => void,
|
|
handleSlashCommand: (
|
|
cmd: PartListUnion,
|
|
) => Promise<SlashCommandProcessorResult | false>,
|
|
shellModeActive: boolean,
|
|
getPreferredEditor: () => EditorType | undefined,
|
|
onAuthError: (error: string) => void,
|
|
performMemoryRefresh: () => Promise<void>,
|
|
modelSwitchedFromQuotaError: boolean,
|
|
setModelSwitchedFromQuotaError: React.Dispatch<React.SetStateAction<boolean>>,
|
|
onEditorClose: () => void,
|
|
onCancelSubmit: () => void,
|
|
setShellInputFocused: (value: boolean) => void,
|
|
terminalWidth: number,
|
|
terminalHeight: number,
|
|
) => {
|
|
const [initError, setInitError] = useState<string | null>(null);
|
|
const abortControllerRef = useRef<AbortController | null>(null);
|
|
const turnCancelledRef = useRef(false);
|
|
const isSubmittingQueryRef = useRef(false);
|
|
const lastPromptRef = useRef<PartListUnion | null>(null);
|
|
const lastPromptErroredRef = useRef(false);
|
|
const [isResponding, setIsResponding] = useState<boolean>(false);
|
|
const [thought, setThought] = useState<ThoughtSummary | null>(null);
|
|
const [pendingHistoryItem, pendingHistoryItemRef, setPendingHistoryItem] =
|
|
useStateAndRef<HistoryItemWithoutId | null>(null);
|
|
const [
|
|
pendingRetryErrorItem,
|
|
pendingRetryErrorItemRef,
|
|
setPendingRetryErrorItem,
|
|
] = useStateAndRef<HistoryItemWithoutId | null>(null);
|
|
const [
|
|
pendingRetryCountdownItem,
|
|
pendingRetryCountdownItemRef,
|
|
setPendingRetryCountdownItem,
|
|
] = useStateAndRef<HistoryItemWithoutId | null>(null);
|
|
const retryCountdownTimerRef = useRef<ReturnType<typeof setInterval> | null>(
|
|
null,
|
|
);
|
|
const processedMemoryToolsRef = useRef<Set<string>>(new Set());
|
|
const {
|
|
startNewPrompt,
|
|
getPromptCount,
|
|
stats: sessionStates,
|
|
} = useSessionStats();
|
|
const storage = config.storage;
|
|
const logger = useLogger(storage, sessionStates.sessionId);
|
|
const gitService = useMemo(() => {
|
|
if (!config.getProjectRoot()) {
|
|
return;
|
|
}
|
|
return new GitService(config.getProjectRoot(), storage);
|
|
}, [config, storage]);
|
|
|
|
const [toolCalls, scheduleToolCalls, markToolsAsSubmitted] =
|
|
useReactToolScheduler(
|
|
async (completedToolCallsFromScheduler) => {
|
|
// This onComplete is called when ALL scheduled tools for a given batch are done.
|
|
if (completedToolCallsFromScheduler.length > 0) {
|
|
// Add the final state of these tools to the history for display.
|
|
addItem(
|
|
mapTrackedToolCallsToDisplay(
|
|
completedToolCallsFromScheduler as TrackedToolCall[],
|
|
),
|
|
Date.now(),
|
|
);
|
|
|
|
// Handle tool response submission immediately when tools complete
|
|
await handleCompletedTools(
|
|
completedToolCallsFromScheduler as TrackedToolCall[],
|
|
);
|
|
}
|
|
},
|
|
config,
|
|
getPreferredEditor,
|
|
onEditorClose,
|
|
);
|
|
|
|
const pendingToolCallGroupDisplay = useMemo(
|
|
() =>
|
|
toolCalls.length ? mapTrackedToolCallsToDisplay(toolCalls) : undefined,
|
|
[toolCalls],
|
|
);
|
|
|
|
const activeToolPtyId = useMemo(() => {
|
|
const executingShellTool = toolCalls?.find(
|
|
(tc) =>
|
|
tc.status === 'executing' && tc.request.name === 'run_shell_command',
|
|
);
|
|
if (executingShellTool) {
|
|
return (executingShellTool as { pid?: number }).pid;
|
|
}
|
|
return undefined;
|
|
}, [toolCalls]);
|
|
|
|
const loopDetectedRef = useRef(false);
|
|
const [
|
|
loopDetectionConfirmationRequest,
|
|
setLoopDetectionConfirmationRequest,
|
|
] = useState<{
|
|
onComplete: (result: { userSelection: 'disable' | 'keep' }) => void;
|
|
} | null>(null);
|
|
|
|
const stopRetryCountdownTimer = useCallback(() => {
|
|
if (retryCountdownTimerRef.current) {
|
|
clearInterval(retryCountdownTimerRef.current);
|
|
retryCountdownTimerRef.current = null;
|
|
}
|
|
}, []);
|
|
|
|
/**
|
|
* Clears the retry countdown timer and pending retry items.
|
|
*/
|
|
const clearRetryCountdown = useCallback(() => {
|
|
stopRetryCountdownTimer();
|
|
setPendingRetryErrorItem(null);
|
|
setPendingRetryCountdownItem(null);
|
|
}, [
|
|
setPendingRetryErrorItem,
|
|
setPendingRetryCountdownItem,
|
|
stopRetryCountdownTimer,
|
|
]);
|
|
|
|
const startRetryCountdown = useCallback(
|
|
(retryInfo: {
|
|
message?: string;
|
|
attempt: number;
|
|
maxRetries: number;
|
|
delayMs: number;
|
|
}) => {
|
|
stopRetryCountdownTimer();
|
|
const startTime = Date.now();
|
|
const { message, attempt, maxRetries, delayMs } = retryInfo;
|
|
const retryReasonText =
|
|
message ?? t('Rate limit exceeded. Please wait and try again.');
|
|
|
|
// Countdown line updates every second (dim/secondary color)
|
|
const updateCountdown = () => {
|
|
const elapsedMs = Date.now() - startTime;
|
|
const remainingMs = Math.max(0, delayMs - elapsedMs);
|
|
const remainingSec = Math.ceil(remainingMs / 1000);
|
|
|
|
// Update error item with hint containing countdown info (short format)
|
|
const hintText = `Retrying in ${remainingSec}s… (attempt ${attempt}/${maxRetries})`;
|
|
|
|
setPendingRetryErrorItem({
|
|
type: MessageType.ERROR,
|
|
text: retryReasonText,
|
|
hint: hintText,
|
|
});
|
|
|
|
setPendingRetryCountdownItem({
|
|
type: 'retry_countdown',
|
|
text: t(
|
|
'Retrying in {{seconds}} seconds… (attempt {{attempt}}/{{maxRetries}})',
|
|
{
|
|
seconds: String(remainingSec),
|
|
attempt: String(attempt),
|
|
maxRetries: String(maxRetries),
|
|
},
|
|
),
|
|
} as HistoryItemWithoutId);
|
|
|
|
if (remainingMs <= 0) {
|
|
stopRetryCountdownTimer();
|
|
}
|
|
};
|
|
|
|
updateCountdown();
|
|
retryCountdownTimerRef.current = setInterval(updateCountdown, 1000);
|
|
},
|
|
[
|
|
setPendingRetryErrorItem,
|
|
setPendingRetryCountdownItem,
|
|
stopRetryCountdownTimer,
|
|
],
|
|
);
|
|
|
|
useEffect(() => () => stopRetryCountdownTimer(), [stopRetryCountdownTimer]);
|
|
|
|
const onExec = useCallback(async (done: Promise<void>) => {
|
|
setIsResponding(true);
|
|
await done;
|
|
setIsResponding(false);
|
|
}, []);
|
|
const { handleShellCommand, activeShellPtyId } = useShellCommandProcessor(
|
|
addItem,
|
|
setPendingHistoryItem,
|
|
onExec,
|
|
onDebugMessage,
|
|
config,
|
|
geminiClient,
|
|
setShellInputFocused,
|
|
terminalWidth,
|
|
terminalHeight,
|
|
);
|
|
|
|
const activePtyId = activeShellPtyId || activeToolPtyId;
|
|
|
|
useEffect(() => {
|
|
if (!activePtyId) {
|
|
setShellInputFocused(false);
|
|
}
|
|
}, [activePtyId, setShellInputFocused]);
|
|
|
|
const streamingState = useMemo(() => {
|
|
if (toolCalls.some((tc) => tc.status === 'awaiting_approval')) {
|
|
return StreamingState.WaitingForConfirmation;
|
|
}
|
|
if (
|
|
isResponding ||
|
|
toolCalls.some(
|
|
(tc) =>
|
|
tc.status === 'executing' ||
|
|
tc.status === 'scheduled' ||
|
|
tc.status === 'validating' ||
|
|
((tc.status === 'success' ||
|
|
tc.status === 'error' ||
|
|
tc.status === 'cancelled') &&
|
|
!(tc as TrackedCompletedToolCall | TrackedCancelledToolCall)
|
|
.responseSubmittedToGemini),
|
|
)
|
|
) {
|
|
return StreamingState.Responding;
|
|
}
|
|
return StreamingState.Idle;
|
|
}, [isResponding, toolCalls]);
|
|
|
|
useEffect(() => {
|
|
if (
|
|
config.getApprovalMode() === ApprovalMode.YOLO &&
|
|
streamingState === StreamingState.Idle
|
|
) {
|
|
const lastUserMessageIndex = history.findLastIndex(
|
|
(item: HistoryItem) => item.type === MessageType.USER,
|
|
);
|
|
|
|
const turnCount =
|
|
lastUserMessageIndex === -1 ? 0 : history.length - lastUserMessageIndex;
|
|
|
|
if (turnCount > 0) {
|
|
logConversationFinishedEvent(
|
|
config,
|
|
new ConversationFinishedEvent(config.getApprovalMode(), turnCount),
|
|
);
|
|
}
|
|
}
|
|
}, [streamingState, config, history]);
|
|
|
|
const cancelOngoingRequest = useCallback(() => {
|
|
if (streamingState !== StreamingState.Responding) {
|
|
return;
|
|
}
|
|
if (turnCancelledRef.current) {
|
|
return;
|
|
}
|
|
turnCancelledRef.current = true;
|
|
isSubmittingQueryRef.current = false;
|
|
abortControllerRef.current?.abort();
|
|
|
|
// Log API cancellation
|
|
const prompt_id = config.getSessionId() + '########' + getPromptCount();
|
|
const cancellationEvent = new ApiCancelEvent(
|
|
config.getModel(),
|
|
prompt_id,
|
|
config.getContentGeneratorConfig()?.authType,
|
|
);
|
|
logApiCancel(config, cancellationEvent);
|
|
|
|
if (pendingHistoryItemRef.current) {
|
|
addItem(pendingHistoryItemRef.current, Date.now());
|
|
}
|
|
addItem(
|
|
{
|
|
type: MessageType.INFO,
|
|
text: 'Request cancelled.',
|
|
},
|
|
Date.now(),
|
|
);
|
|
setPendingHistoryItem(null);
|
|
clearRetryCountdown();
|
|
onCancelSubmit();
|
|
setIsResponding(false);
|
|
setShellInputFocused(false);
|
|
}, [
|
|
streamingState,
|
|
addItem,
|
|
setPendingHistoryItem,
|
|
onCancelSubmit,
|
|
pendingHistoryItemRef,
|
|
setShellInputFocused,
|
|
clearRetryCountdown,
|
|
config,
|
|
getPromptCount,
|
|
]);
|
|
|
|
const prepareQueryForGemini = useCallback(
|
|
async (
|
|
query: PartListUnion,
|
|
userMessageTimestamp: number,
|
|
abortSignal: AbortSignal,
|
|
prompt_id: string,
|
|
): Promise<{
|
|
queryToSend: PartListUnion | null;
|
|
shouldProceed: boolean;
|
|
}> => {
|
|
if (turnCancelledRef.current) {
|
|
return { queryToSend: null, shouldProceed: false };
|
|
}
|
|
if (typeof query === 'string' && query.trim().length === 0) {
|
|
return { queryToSend: null, shouldProceed: false };
|
|
}
|
|
|
|
let localQueryToSendToGemini: PartListUnion | null = null;
|
|
|
|
if (typeof query === 'string') {
|
|
const trimmedQuery = query.trim();
|
|
onDebugMessage(`Received user query (${trimmedQuery.length} chars)`);
|
|
await logger?.logMessage(MessageSenderType.USER, trimmedQuery);
|
|
|
|
// Handle UI-only commands first
|
|
const slashCommandResult = isSlashCommand(trimmedQuery)
|
|
? await handleSlashCommand(trimmedQuery)
|
|
: false;
|
|
|
|
if (slashCommandResult) {
|
|
switch (slashCommandResult.type) {
|
|
case 'schedule_tool': {
|
|
const { toolName, toolArgs } = slashCommandResult;
|
|
const toolCallRequest: ToolCallRequestInfo = {
|
|
callId: `${toolName}-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
|
name: toolName,
|
|
args: toolArgs,
|
|
isClientInitiated: true,
|
|
prompt_id,
|
|
};
|
|
scheduleToolCalls([toolCallRequest], abortSignal);
|
|
return { queryToSend: null, shouldProceed: false };
|
|
}
|
|
case 'submit_prompt': {
|
|
localQueryToSendToGemini = slashCommandResult.content;
|
|
|
|
return {
|
|
queryToSend: localQueryToSendToGemini,
|
|
shouldProceed: true,
|
|
};
|
|
}
|
|
case 'handled': {
|
|
return { queryToSend: null, shouldProceed: false };
|
|
}
|
|
default: {
|
|
const unreachable: never = slashCommandResult;
|
|
throw new Error(
|
|
`Unhandled slash command result type: ${unreachable}`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (shellModeActive && handleShellCommand(trimmedQuery, abortSignal)) {
|
|
return { queryToSend: null, shouldProceed: false };
|
|
}
|
|
|
|
localQueryToSendToGemini = trimmedQuery;
|
|
|
|
addItem(
|
|
{ type: MessageType.USER, text: trimmedQuery },
|
|
userMessageTimestamp,
|
|
);
|
|
|
|
// Handle @-commands (which might involve tool calls)
|
|
if (isAtCommand(trimmedQuery)) {
|
|
const atCommandResult = await handleAtCommand({
|
|
query: trimmedQuery,
|
|
config,
|
|
onDebugMessage,
|
|
messageId: userMessageTimestamp,
|
|
signal: abortSignal,
|
|
addItem,
|
|
});
|
|
|
|
if (!atCommandResult.shouldProceed) {
|
|
return { queryToSend: null, shouldProceed: false };
|
|
}
|
|
localQueryToSendToGemini = atCommandResult.processedQuery;
|
|
}
|
|
} else {
|
|
// It's a function response (PartListUnion that isn't a string)
|
|
localQueryToSendToGemini = query;
|
|
}
|
|
|
|
if (localQueryToSendToGemini === null) {
|
|
onDebugMessage(
|
|
'Query processing resulted in null, not sending to Gemini.',
|
|
);
|
|
return { queryToSend: null, shouldProceed: false };
|
|
}
|
|
return { queryToSend: localQueryToSendToGemini, shouldProceed: true };
|
|
},
|
|
[
|
|
config,
|
|
addItem,
|
|
onDebugMessage,
|
|
handleShellCommand,
|
|
handleSlashCommand,
|
|
logger,
|
|
shellModeActive,
|
|
scheduleToolCalls,
|
|
],
|
|
);
|
|
|
|
// --- Stream Event Handlers ---
|
|
|
|
const handleContentEvent = useCallback(
|
|
(
|
|
eventValue: ContentEvent['value'],
|
|
currentGeminiMessageBuffer: string,
|
|
userMessageTimestamp: number,
|
|
): string => {
|
|
if (turnCancelledRef.current) {
|
|
// Prevents additional output after a user initiated cancel.
|
|
return '';
|
|
}
|
|
let newGeminiMessageBuffer = currentGeminiMessageBuffer + eventValue;
|
|
if (
|
|
pendingHistoryItemRef.current?.type !== 'gemini' &&
|
|
pendingHistoryItemRef.current?.type !== 'gemini_content'
|
|
) {
|
|
if (pendingHistoryItemRef.current) {
|
|
addItem(pendingHistoryItemRef.current, userMessageTimestamp);
|
|
}
|
|
setPendingHistoryItem({ type: 'gemini', text: '' });
|
|
newGeminiMessageBuffer = eventValue;
|
|
}
|
|
// Split large messages for better rendering performance. Ideally,
|
|
// we should maximize the amount of output sent to <Static />.
|
|
const splitPoint = findLastSafeSplitPoint(newGeminiMessageBuffer);
|
|
if (splitPoint === newGeminiMessageBuffer.length) {
|
|
// Update the existing message with accumulated content
|
|
setPendingHistoryItem((item) => ({
|
|
type: item?.type as 'gemini' | 'gemini_content',
|
|
text: newGeminiMessageBuffer,
|
|
}));
|
|
} else {
|
|
// This indicates that we need to split up this Gemini Message.
|
|
// Splitting a message is primarily a performance consideration. There is a
|
|
// <Static> component at the root of App.tsx which takes care of rendering
|
|
// content statically or dynamically. Everything but the last message is
|
|
// treated as static in order to prevent re-rendering an entire message history
|
|
// multiple times per-second (as streaming occurs). Prior to this change you'd
|
|
// see heavy flickering of the terminal. This ensures that larger messages get
|
|
// broken up so that there are more "statically" rendered.
|
|
const beforeText = newGeminiMessageBuffer.substring(0, splitPoint);
|
|
const afterText = newGeminiMessageBuffer.substring(splitPoint);
|
|
addItem(
|
|
{
|
|
type: pendingHistoryItemRef.current?.type as
|
|
| 'gemini'
|
|
| 'gemini_content',
|
|
text: beforeText,
|
|
},
|
|
userMessageTimestamp,
|
|
);
|
|
setPendingHistoryItem({ type: 'gemini_content', text: afterText });
|
|
newGeminiMessageBuffer = afterText;
|
|
}
|
|
return newGeminiMessageBuffer;
|
|
},
|
|
[addItem, pendingHistoryItemRef, setPendingHistoryItem],
|
|
);
|
|
|
|
const mergeThought = useCallback(
|
|
(incoming: ThoughtSummary) => {
|
|
setThought((prev) => {
|
|
if (!prev) {
|
|
return incoming;
|
|
}
|
|
const subject = incoming.subject || prev.subject;
|
|
const description = `${prev.description ?? ''}${incoming.description ?? ''}`;
|
|
return { subject, description };
|
|
});
|
|
},
|
|
[setThought],
|
|
);
|
|
|
|
const handleThoughtEvent = useCallback(
|
|
(
|
|
eventValue: ThoughtSummary,
|
|
currentThoughtBuffer: string,
|
|
userMessageTimestamp: number,
|
|
): string => {
|
|
if (turnCancelledRef.current) {
|
|
return '';
|
|
}
|
|
|
|
// Extract the description text from the thought summary
|
|
const thoughtText = eventValue.description ?? '';
|
|
if (!thoughtText) {
|
|
return currentThoughtBuffer;
|
|
}
|
|
|
|
let newThoughtBuffer = currentThoughtBuffer + thoughtText;
|
|
|
|
const pendingType = pendingHistoryItemRef.current?.type;
|
|
const isPendingThought =
|
|
pendingType === 'gemini_thought' ||
|
|
pendingType === 'gemini_thought_content';
|
|
|
|
// If we're not already showing a thought, start a new one
|
|
if (!isPendingThought) {
|
|
// If there's a pending non-thought item, finalize it first
|
|
if (pendingHistoryItemRef.current) {
|
|
addItem(pendingHistoryItemRef.current, userMessageTimestamp);
|
|
}
|
|
setPendingHistoryItem({ type: 'gemini_thought', text: '' });
|
|
}
|
|
|
|
// Split large thought messages for better rendering performance (same rationale
|
|
// as regular content streaming). This helps avoid terminal flicker caused by
|
|
// constantly re-rendering an ever-growing "pending" block.
|
|
const splitPoint = findLastSafeSplitPoint(newThoughtBuffer);
|
|
const nextPendingType: 'gemini_thought' | 'gemini_thought_content' =
|
|
isPendingThought && pendingType === 'gemini_thought_content'
|
|
? 'gemini_thought_content'
|
|
: 'gemini_thought';
|
|
|
|
if (splitPoint === newThoughtBuffer.length) {
|
|
// Update the existing thought message with accumulated content
|
|
setPendingHistoryItem({
|
|
type: nextPendingType,
|
|
text: newThoughtBuffer,
|
|
});
|
|
} else {
|
|
const beforeText = newThoughtBuffer.substring(0, splitPoint);
|
|
const afterText = newThoughtBuffer.substring(splitPoint);
|
|
addItem(
|
|
{
|
|
type: nextPendingType,
|
|
text: beforeText,
|
|
},
|
|
userMessageTimestamp,
|
|
);
|
|
setPendingHistoryItem({
|
|
type: 'gemini_thought_content',
|
|
text: afterText,
|
|
});
|
|
newThoughtBuffer = afterText;
|
|
}
|
|
|
|
// Also update the thought state for the loading indicator
|
|
mergeThought(eventValue);
|
|
|
|
return newThoughtBuffer;
|
|
},
|
|
[addItem, pendingHistoryItemRef, setPendingHistoryItem, mergeThought],
|
|
);
|
|
|
|
const handleUserCancelledEvent = useCallback(
|
|
(userMessageTimestamp: number) => {
|
|
if (turnCancelledRef.current) {
|
|
return;
|
|
}
|
|
|
|
lastPromptErroredRef.current = false;
|
|
if (pendingHistoryItemRef.current) {
|
|
if (pendingHistoryItemRef.current.type === 'tool_group') {
|
|
const updatedTools = pendingHistoryItemRef.current.tools.map(
|
|
(tool) =>
|
|
tool.status === ToolCallStatus.Pending ||
|
|
tool.status === ToolCallStatus.Confirming ||
|
|
tool.status === ToolCallStatus.Executing
|
|
? { ...tool, status: ToolCallStatus.Canceled }
|
|
: tool,
|
|
);
|
|
const pendingItem: HistoryItemToolGroup = {
|
|
...pendingHistoryItemRef.current,
|
|
tools: updatedTools,
|
|
};
|
|
addItem(pendingItem, userMessageTimestamp);
|
|
} else {
|
|
addItem(pendingHistoryItemRef.current, userMessageTimestamp);
|
|
}
|
|
setPendingHistoryItem(null);
|
|
}
|
|
addItem(
|
|
{ type: MessageType.INFO, text: 'User cancelled the request.' },
|
|
userMessageTimestamp,
|
|
);
|
|
clearRetryCountdown();
|
|
setIsResponding(false);
|
|
setThought(null); // Reset thought when user cancels
|
|
},
|
|
[
|
|
addItem,
|
|
pendingHistoryItemRef,
|
|
setPendingHistoryItem,
|
|
setThought,
|
|
clearRetryCountdown,
|
|
],
|
|
);
|
|
|
|
const handleErrorEvent = useCallback(
|
|
(eventValue: GeminiErrorEventValue, userMessageTimestamp: number) => {
|
|
lastPromptErroredRef.current = true;
|
|
if (pendingHistoryItemRef.current) {
|
|
addItem(pendingHistoryItemRef.current, userMessageTimestamp);
|
|
setPendingHistoryItem(null);
|
|
}
|
|
// Only show Ctrl+Y hint if not already showing an auto-retry countdown
|
|
// (auto-retry countdown is shown when retryCountdownTimerRef is active)
|
|
const isShowingAutoRetry = retryCountdownTimerRef.current !== null;
|
|
clearRetryCountdown();
|
|
if (!isShowingAutoRetry) {
|
|
const retryHint = t('Press Ctrl+Y to retry');
|
|
// Store error with hint as a pending item (not in history).
|
|
// This allows the hint to be removed when the user retries with Ctrl+Y,
|
|
// since pending items are in the dynamic rendering area (not <Static>).
|
|
setPendingRetryErrorItem({
|
|
type: 'error' as const,
|
|
text: parseAndFormatApiError(
|
|
eventValue.error,
|
|
config.getContentGeneratorConfig()?.authType,
|
|
),
|
|
hint: retryHint,
|
|
});
|
|
}
|
|
setThought(null); // Reset thought when there's an error
|
|
},
|
|
[
|
|
addItem,
|
|
pendingHistoryItemRef,
|
|
setPendingHistoryItem,
|
|
setPendingRetryErrorItem,
|
|
config,
|
|
setThought,
|
|
clearRetryCountdown,
|
|
],
|
|
);
|
|
|
|
const handleCitationEvent = useCallback(
|
|
(text: string, userMessageTimestamp: number) => {
|
|
if (!showCitations(settings)) {
|
|
return;
|
|
}
|
|
|
|
if (pendingHistoryItemRef.current) {
|
|
addItem(pendingHistoryItemRef.current, userMessageTimestamp);
|
|
setPendingHistoryItem(null);
|
|
}
|
|
addItem({ type: MessageType.INFO, text }, userMessageTimestamp);
|
|
},
|
|
[addItem, pendingHistoryItemRef, setPendingHistoryItem, settings],
|
|
);
|
|
|
|
const handleFinishedEvent = useCallback(
|
|
(event: ServerGeminiFinishedEvent, userMessageTimestamp: number) => {
|
|
const finishReason = event.value.reason;
|
|
if (!finishReason) {
|
|
return;
|
|
}
|
|
|
|
const finishReasonMessages: Record<FinishReason, string | undefined> = {
|
|
[FinishReason.FINISH_REASON_UNSPECIFIED]: undefined,
|
|
[FinishReason.STOP]: undefined,
|
|
[FinishReason.MAX_TOKENS]: 'Response truncated due to token limits.',
|
|
[FinishReason.SAFETY]: 'Response stopped due to safety reasons.',
|
|
[FinishReason.RECITATION]: 'Response stopped due to recitation policy.',
|
|
[FinishReason.LANGUAGE]:
|
|
'Response stopped due to unsupported language.',
|
|
[FinishReason.BLOCKLIST]: 'Response stopped due to forbidden terms.',
|
|
[FinishReason.PROHIBITED_CONTENT]:
|
|
'Response stopped due to prohibited content.',
|
|
[FinishReason.SPII]:
|
|
'Response stopped due to sensitive personally identifiable information.',
|
|
[FinishReason.OTHER]: 'Response stopped for other reasons.',
|
|
[FinishReason.MALFORMED_FUNCTION_CALL]:
|
|
'Response stopped due to malformed function call.',
|
|
[FinishReason.IMAGE_SAFETY]:
|
|
'Response stopped due to image safety violations.',
|
|
[FinishReason.UNEXPECTED_TOOL_CALL]:
|
|
'Response stopped due to unexpected tool call.',
|
|
[FinishReason.IMAGE_PROHIBITED_CONTENT]:
|
|
'Response stopped due to image prohibited content.',
|
|
[FinishReason.NO_IMAGE]: 'Response stopped due to no image.',
|
|
};
|
|
|
|
const message = finishReasonMessages[finishReason];
|
|
if (message) {
|
|
addItem(
|
|
{
|
|
type: 'info',
|
|
text: `⚠️ ${message}`,
|
|
},
|
|
userMessageTimestamp,
|
|
);
|
|
}
|
|
// Only clear auto-retry countdown errors (those with active timer)
|
|
if (retryCountdownTimerRef.current) {
|
|
clearRetryCountdown();
|
|
}
|
|
},
|
|
[addItem, clearRetryCountdown],
|
|
);
|
|
|
|
const handleChatCompressionEvent = useCallback(
|
|
(
|
|
eventValue: ServerGeminiChatCompressedEvent['value'],
|
|
userMessageTimestamp: number,
|
|
) => {
|
|
if (pendingHistoryItemRef.current) {
|
|
addItem(pendingHistoryItemRef.current, userMessageTimestamp);
|
|
setPendingHistoryItem(null);
|
|
}
|
|
return addItem(
|
|
{
|
|
type: 'info',
|
|
text:
|
|
`IMPORTANT: This conversation approached the input token limit for ${config.getModel()}. ` +
|
|
`A compressed context will be sent for future messages (compressed from: ` +
|
|
`${eventValue?.originalTokenCount ?? 'unknown'} to ` +
|
|
`${eventValue?.newTokenCount ?? 'unknown'} tokens).`,
|
|
},
|
|
Date.now(),
|
|
);
|
|
},
|
|
[addItem, config, pendingHistoryItemRef, setPendingHistoryItem],
|
|
);
|
|
|
|
const handleMaxSessionTurnsEvent = useCallback(
|
|
() =>
|
|
addItem(
|
|
{
|
|
type: 'info',
|
|
text:
|
|
`The session has reached the maximum number of turns: ${config.getMaxSessionTurns()}. ` +
|
|
`Please update this limit in your setting.json file.`,
|
|
},
|
|
Date.now(),
|
|
),
|
|
[addItem, config],
|
|
);
|
|
|
|
const handleSessionTokenLimitExceededEvent = useCallback(
|
|
(value: { currentTokens: number; limit: number; message: string }) =>
|
|
addItem(
|
|
{
|
|
type: 'error',
|
|
text:
|
|
`🚫 Session token limit exceeded: ${value.currentTokens.toLocaleString()} tokens > ${value.limit.toLocaleString()} limit.\n\n` +
|
|
`💡 Solutions:\n` +
|
|
` • Start a new session: Use /clear command\n` +
|
|
` • Increase limit: Add "sessionTokenLimit": (e.g., 128000) to your settings.json\n` +
|
|
` • Compress history: Use /compress command to compress history`,
|
|
},
|
|
Date.now(),
|
|
),
|
|
[addItem],
|
|
);
|
|
|
|
const handleLoopDetectionConfirmation = useCallback(
|
|
(result: { userSelection: 'disable' | 'keep' }) => {
|
|
setLoopDetectionConfirmationRequest(null);
|
|
|
|
if (result.userSelection === 'disable') {
|
|
config.getGeminiClient().getLoopDetectionService().disableForSession();
|
|
addItem(
|
|
{
|
|
type: 'info',
|
|
text: `Loop detection has been disabled for this session. Please try your request again.`,
|
|
},
|
|
Date.now(),
|
|
);
|
|
} else {
|
|
addItem(
|
|
{
|
|
type: 'info',
|
|
text: `A potential loop was detected. This can happen due to repetitive tool calls or other model behavior. The request has been halted.`,
|
|
},
|
|
Date.now(),
|
|
);
|
|
}
|
|
},
|
|
[config, addItem],
|
|
);
|
|
|
|
const handleLoopDetectedEvent = useCallback(() => {
|
|
// Show the confirmation dialog to choose whether to disable loop detection
|
|
setLoopDetectionConfirmationRequest({
|
|
onComplete: handleLoopDetectionConfirmation,
|
|
});
|
|
}, [handleLoopDetectionConfirmation]);
|
|
|
|
const processGeminiStreamEvents = useCallback(
|
|
async (
|
|
stream: AsyncIterable<GeminiEvent>,
|
|
userMessageTimestamp: number,
|
|
signal: AbortSignal,
|
|
): Promise<StreamProcessingStatus> => {
|
|
let geminiMessageBuffer = '';
|
|
let thoughtBuffer = '';
|
|
const toolCallRequests: ToolCallRequestInfo[] = [];
|
|
for await (const event of stream) {
|
|
switch (event.type) {
|
|
case ServerGeminiEventType.Thought:
|
|
// If the thought has a subject, it's a discrete status update rather than
|
|
// a streamed textual thought, so we update the thought state directly.
|
|
if (event.value.subject) {
|
|
setThought(event.value);
|
|
} else {
|
|
thoughtBuffer = handleThoughtEvent(
|
|
event.value,
|
|
thoughtBuffer,
|
|
userMessageTimestamp,
|
|
);
|
|
}
|
|
break;
|
|
case ServerGeminiEventType.Content:
|
|
geminiMessageBuffer = handleContentEvent(
|
|
event.value,
|
|
geminiMessageBuffer,
|
|
userMessageTimestamp,
|
|
);
|
|
break;
|
|
case ServerGeminiEventType.ToolCallRequest:
|
|
toolCallRequests.push(event.value);
|
|
break;
|
|
case ServerGeminiEventType.UserCancelled:
|
|
handleUserCancelledEvent(userMessageTimestamp);
|
|
break;
|
|
case ServerGeminiEventType.Error:
|
|
handleErrorEvent(event.value, userMessageTimestamp);
|
|
break;
|
|
case ServerGeminiEventType.ChatCompressed:
|
|
handleChatCompressionEvent(event.value, userMessageTimestamp);
|
|
break;
|
|
case ServerGeminiEventType.ToolCallConfirmation:
|
|
case ServerGeminiEventType.ToolCallResponse:
|
|
// do nothing
|
|
break;
|
|
case ServerGeminiEventType.MaxSessionTurns:
|
|
handleMaxSessionTurnsEvent();
|
|
break;
|
|
case ServerGeminiEventType.SessionTokenLimitExceeded:
|
|
handleSessionTokenLimitExceededEvent(event.value);
|
|
break;
|
|
case ServerGeminiEventType.Finished:
|
|
handleFinishedEvent(
|
|
event as ServerGeminiFinishedEvent,
|
|
userMessageTimestamp,
|
|
);
|
|
break;
|
|
case ServerGeminiEventType.Citation:
|
|
handleCitationEvent(event.value, userMessageTimestamp);
|
|
break;
|
|
case ServerGeminiEventType.LoopDetected:
|
|
// handle later because we want to move pending history to history
|
|
// before we add loop detected message to history
|
|
loopDetectedRef.current = true;
|
|
break;
|
|
case ServerGeminiEventType.Retry:
|
|
// Clear any pending partial content from the failed attempt
|
|
if (pendingHistoryItemRef.current) {
|
|
setPendingHistoryItem(null);
|
|
}
|
|
// Show retry info if available (rate-limit / throttling errors)
|
|
if (event.retryInfo) {
|
|
startRetryCountdown(event.retryInfo);
|
|
} else if (!pendingRetryCountdownItemRef.current) {
|
|
clearRetryCountdown();
|
|
}
|
|
break;
|
|
default: {
|
|
// enforces exhaustive switch-case
|
|
const unreachable: never = event;
|
|
return unreachable;
|
|
}
|
|
}
|
|
}
|
|
if (toolCallRequests.length > 0) {
|
|
scheduleToolCalls(toolCallRequests, signal);
|
|
}
|
|
return StreamProcessingStatus.Completed;
|
|
},
|
|
[
|
|
handleContentEvent,
|
|
handleThoughtEvent,
|
|
handleUserCancelledEvent,
|
|
handleErrorEvent,
|
|
scheduleToolCalls,
|
|
handleChatCompressionEvent,
|
|
handleFinishedEvent,
|
|
handleMaxSessionTurnsEvent,
|
|
handleSessionTokenLimitExceededEvent,
|
|
handleCitationEvent,
|
|
startRetryCountdown,
|
|
clearRetryCountdown,
|
|
setThought,
|
|
pendingHistoryItemRef,
|
|
setPendingHistoryItem,
|
|
pendingRetryCountdownItemRef,
|
|
],
|
|
);
|
|
|
|
const submitQuery = useCallback(
|
|
async (
|
|
query: PartListUnion,
|
|
options?: { isContinuation: boolean; skipPreparation?: boolean },
|
|
prompt_id?: string,
|
|
) => {
|
|
// Prevent concurrent executions of submitQuery, but allow continuations
|
|
// which are part of the same logical flow (tool responses)
|
|
if (isSubmittingQueryRef.current && !options?.isContinuation) {
|
|
return;
|
|
}
|
|
|
|
if (
|
|
(streamingState === StreamingState.Responding ||
|
|
streamingState === StreamingState.WaitingForConfirmation) &&
|
|
!options?.isContinuation
|
|
)
|
|
return;
|
|
|
|
// Set the flag to indicate we're now executing
|
|
isSubmittingQueryRef.current = true;
|
|
|
|
const userMessageTimestamp = Date.now();
|
|
|
|
// Reset quota error flag when starting a new query (not a continuation)
|
|
if (!options?.isContinuation) {
|
|
setModelSwitchedFromQuotaError(false);
|
|
// Commit any pending retry error to history (without hint) since the
|
|
// user is starting a new conversation turn
|
|
if (pendingRetryCountdownItemRef.current) {
|
|
clearRetryCountdown();
|
|
}
|
|
}
|
|
|
|
abortControllerRef.current = new AbortController();
|
|
const abortSignal = abortControllerRef.current.signal;
|
|
turnCancelledRef.current = false;
|
|
|
|
if (!prompt_id) {
|
|
prompt_id = config.getSessionId() + '########' + getPromptCount();
|
|
}
|
|
|
|
return promptIdContext.run(prompt_id, async () => {
|
|
const { queryToSend, shouldProceed } = options?.skipPreparation
|
|
? { queryToSend: query, shouldProceed: true }
|
|
: await prepareQueryForGemini(
|
|
query,
|
|
userMessageTimestamp,
|
|
abortSignal,
|
|
prompt_id!,
|
|
);
|
|
|
|
if (!shouldProceed || queryToSend === null) {
|
|
isSubmittingQueryRef.current = false;
|
|
return;
|
|
}
|
|
|
|
// Check image format support for non-continuations
|
|
if (!options?.isContinuation) {
|
|
const formatCheck = checkImageFormatsSupport(queryToSend);
|
|
if (formatCheck.hasUnsupportedFormats) {
|
|
addItem(
|
|
{
|
|
type: MessageType.INFO,
|
|
text: getUnsupportedImageFormatWarning(),
|
|
},
|
|
userMessageTimestamp,
|
|
);
|
|
}
|
|
}
|
|
|
|
const finalQueryToSend = queryToSend;
|
|
lastPromptRef.current = finalQueryToSend;
|
|
lastPromptErroredRef.current = false;
|
|
|
|
if (!options?.isContinuation) {
|
|
// trigger new prompt event for session stats in CLI
|
|
startNewPrompt();
|
|
|
|
// log user prompt event for telemetry, only text prompts for now
|
|
if (typeof queryToSend === 'string') {
|
|
logUserPrompt(
|
|
config,
|
|
new UserPromptEvent(
|
|
queryToSend.length,
|
|
prompt_id,
|
|
config.getContentGeneratorConfig()?.authType,
|
|
queryToSend,
|
|
),
|
|
);
|
|
}
|
|
|
|
// Reset thought when starting a new prompt
|
|
setThought(null);
|
|
}
|
|
|
|
setIsResponding(true);
|
|
setInitError(null);
|
|
|
|
try {
|
|
const stream = geminiClient.sendMessageStream(
|
|
finalQueryToSend,
|
|
abortSignal,
|
|
prompt_id!,
|
|
options,
|
|
);
|
|
|
|
const processingStatus = await processGeminiStreamEvents(
|
|
stream,
|
|
userMessageTimestamp,
|
|
abortSignal,
|
|
);
|
|
|
|
if (processingStatus === StreamProcessingStatus.UserCancelled) {
|
|
isSubmittingQueryRef.current = false;
|
|
return;
|
|
}
|
|
|
|
if (pendingHistoryItemRef.current) {
|
|
addItem(pendingHistoryItemRef.current, userMessageTimestamp);
|
|
setPendingHistoryItem(null);
|
|
}
|
|
// Only clear auto-retry countdown errors (those with an active timer).
|
|
// Do NOT clear static error+hint from handleErrorEvent — those should
|
|
// remain visible until the user presses Ctrl+Y to retry.
|
|
if (retryCountdownTimerRef.current) {
|
|
clearRetryCountdown();
|
|
}
|
|
if (loopDetectedRef.current) {
|
|
loopDetectedRef.current = false;
|
|
handleLoopDetectedEvent();
|
|
}
|
|
} catch (error: unknown) {
|
|
if (error instanceof UnauthorizedError) {
|
|
onAuthError('Session expired or is unauthorized.');
|
|
} else if (!isNodeError(error) || error.name !== 'AbortError') {
|
|
lastPromptErroredRef.current = true;
|
|
const retryHint = t('Press Ctrl+Y to retry');
|
|
// Store error with hint as a pending item (same as handleErrorEvent)
|
|
setPendingRetryErrorItem({
|
|
type: 'error' as const,
|
|
text: parseAndFormatApiError(
|
|
getErrorMessage(error) || 'Unknown error',
|
|
config.getContentGeneratorConfig()?.authType,
|
|
),
|
|
hint: retryHint,
|
|
});
|
|
}
|
|
} finally {
|
|
setIsResponding(false);
|
|
isSubmittingQueryRef.current = false;
|
|
}
|
|
});
|
|
},
|
|
[
|
|
streamingState,
|
|
setModelSwitchedFromQuotaError,
|
|
prepareQueryForGemini,
|
|
processGeminiStreamEvents,
|
|
pendingHistoryItemRef,
|
|
addItem,
|
|
setPendingHistoryItem,
|
|
setInitError,
|
|
geminiClient,
|
|
onAuthError,
|
|
config,
|
|
startNewPrompt,
|
|
getPromptCount,
|
|
handleLoopDetectedEvent,
|
|
clearRetryCountdown,
|
|
pendingRetryCountdownItemRef,
|
|
setPendingRetryErrorItem,
|
|
],
|
|
);
|
|
|
|
/**
|
|
* Retries the last failed prompt when the user presses Ctrl+Y.
|
|
*
|
|
* Activation conditions for Ctrl+Y shortcut:
|
|
* 1. ✅ The last request must have failed (lastPromptErroredRef.current === true)
|
|
* 2. ✅ Current streaming state must NOT be "Responding" (avoid interrupting ongoing stream)
|
|
* 3. ✅ Current streaming state must NOT be "WaitingForConfirmation" (avoid conflicting with tool confirmation flow)
|
|
* 4. ✅ There must be a stored lastPrompt in lastPromptRef.current
|
|
*
|
|
* When conditions are not met:
|
|
* - If streaming is active (Responding/WaitingForConfirmation): silently return without action
|
|
* - If no failed request exists: display "No failed request to retry." info message
|
|
*
|
|
* When conditions are met:
|
|
* - Clears any pending auto-retry countdown to avoid duplicate retries
|
|
* - Re-submits the last query with skipPreparation: true for faster retry
|
|
*
|
|
* This function is exposed via UIActionsContext and triggered by InputPrompt
|
|
* when the user presses Ctrl+Y (bound to Command.RETRY_LAST in keyBindings.ts).
|
|
*/
|
|
const retryLastPrompt = useCallback(async () => {
|
|
if (
|
|
streamingState === StreamingState.Responding ||
|
|
streamingState === StreamingState.WaitingForConfirmation
|
|
) {
|
|
return;
|
|
}
|
|
|
|
const lastPrompt = lastPromptRef.current;
|
|
if (!lastPrompt || !lastPromptErroredRef.current) {
|
|
addItem(
|
|
{
|
|
type: MessageType.INFO,
|
|
text: t('No failed request to retry.'),
|
|
},
|
|
Date.now(),
|
|
);
|
|
return;
|
|
}
|
|
|
|
// Commit the error to history (without hint) before clearing
|
|
const errorItem = pendingRetryErrorItemRef.current;
|
|
if (errorItem) {
|
|
addItem({ type: errorItem.type, text: errorItem.text }, Date.now());
|
|
}
|
|
clearRetryCountdown();
|
|
|
|
await submitQuery(lastPrompt, {
|
|
isContinuation: false,
|
|
skipPreparation: true,
|
|
});
|
|
}, [
|
|
streamingState,
|
|
addItem,
|
|
clearRetryCountdown,
|
|
submitQuery,
|
|
pendingRetryErrorItemRef,
|
|
]);
|
|
|
|
const handleApprovalModeChange = useCallback(
|
|
async (newApprovalMode: ApprovalMode) => {
|
|
// Auto-approve pending tool calls when switching to auto-approval modes
|
|
if (
|
|
newApprovalMode === ApprovalMode.YOLO ||
|
|
newApprovalMode === ApprovalMode.AUTO_EDIT
|
|
) {
|
|
let awaitingApprovalCalls = toolCalls.filter(
|
|
(call): call is TrackedWaitingToolCall =>
|
|
call.status === 'awaiting_approval',
|
|
);
|
|
|
|
// For AUTO_EDIT mode, only approve edit tools (replace, write_file)
|
|
if (newApprovalMode === ApprovalMode.AUTO_EDIT) {
|
|
awaitingApprovalCalls = awaitingApprovalCalls.filter((call) =>
|
|
EDIT_TOOL_NAMES.has(call.request.name),
|
|
);
|
|
}
|
|
|
|
// Process pending tool calls sequentially to reduce UI chaos
|
|
for (const call of awaitingApprovalCalls) {
|
|
if (call.confirmationDetails?.onConfirm) {
|
|
try {
|
|
await call.confirmationDetails.onConfirm(
|
|
ToolConfirmationOutcome.ProceedOnce,
|
|
);
|
|
} catch (error) {
|
|
debugLogger.error(
|
|
`Failed to auto-approve tool call ${call.request.callId}:`,
|
|
error,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
},
|
|
[toolCalls],
|
|
);
|
|
|
|
const handleCompletedTools = useCallback(
|
|
async (completedToolCallsFromScheduler: TrackedToolCall[]) => {
|
|
if (isResponding) {
|
|
return;
|
|
}
|
|
|
|
const completedAndReadyToSubmitTools =
|
|
completedToolCallsFromScheduler.filter(
|
|
(
|
|
tc: TrackedToolCall,
|
|
): tc is TrackedCompletedToolCall | TrackedCancelledToolCall => {
|
|
const isTerminalState =
|
|
tc.status === 'success' ||
|
|
tc.status === 'error' ||
|
|
tc.status === 'cancelled';
|
|
|
|
if (isTerminalState) {
|
|
const completedOrCancelledCall = tc as
|
|
| TrackedCompletedToolCall
|
|
| TrackedCancelledToolCall;
|
|
return (
|
|
completedOrCancelledCall.response?.responseParts !== undefined
|
|
);
|
|
}
|
|
return false;
|
|
},
|
|
);
|
|
|
|
// Finalize any client-initiated tools as soon as they are done.
|
|
const clientTools = completedAndReadyToSubmitTools.filter(
|
|
(t) => t.request.isClientInitiated,
|
|
);
|
|
if (clientTools.length > 0) {
|
|
markToolsAsSubmitted(clientTools.map((t) => t.request.callId));
|
|
}
|
|
|
|
// Identify new, successful save_memory calls that we haven't processed yet.
|
|
const newSuccessfulMemorySaves = completedAndReadyToSubmitTools.filter(
|
|
(t) =>
|
|
t.request.name === 'save_memory' &&
|
|
t.status === 'success' &&
|
|
!processedMemoryToolsRef.current.has(t.request.callId),
|
|
);
|
|
|
|
if (newSuccessfulMemorySaves.length > 0) {
|
|
// Perform the refresh only if there are new ones.
|
|
void performMemoryRefresh();
|
|
// Mark them as processed so we don't do this again on the next render.
|
|
newSuccessfulMemorySaves.forEach((t) =>
|
|
processedMemoryToolsRef.current.add(t.request.callId),
|
|
);
|
|
}
|
|
|
|
const geminiTools = completedAndReadyToSubmitTools.filter(
|
|
(t) => !t.request.isClientInitiated,
|
|
);
|
|
|
|
if (geminiTools.length === 0) {
|
|
return;
|
|
}
|
|
|
|
// If all the tools were cancelled, don't submit a response to Gemini.
|
|
const allToolsCancelled = geminiTools.every(
|
|
(tc) => tc.status === 'cancelled',
|
|
);
|
|
|
|
if (allToolsCancelled) {
|
|
if (geminiClient) {
|
|
// We need to manually add the function responses to the history
|
|
// so the model knows the tools were cancelled.
|
|
const combinedParts = geminiTools.flatMap(
|
|
(toolCall) => toolCall.response.responseParts,
|
|
);
|
|
geminiClient.addHistory({
|
|
role: 'user',
|
|
parts: combinedParts,
|
|
});
|
|
}
|
|
|
|
const callIdsToMarkAsSubmitted = geminiTools.map(
|
|
(toolCall) => toolCall.request.callId,
|
|
);
|
|
markToolsAsSubmitted(callIdsToMarkAsSubmitted);
|
|
return;
|
|
}
|
|
|
|
const responsesToSend: Part[] = geminiTools.flatMap(
|
|
(toolCall) => toolCall.response.responseParts,
|
|
);
|
|
const callIdsToMarkAsSubmitted = geminiTools.map(
|
|
(toolCall) => toolCall.request.callId,
|
|
);
|
|
|
|
const prompt_ids = geminiTools.map(
|
|
(toolCall) => toolCall.request.prompt_id,
|
|
);
|
|
|
|
markToolsAsSubmitted(callIdsToMarkAsSubmitted);
|
|
|
|
// Don't continue if model was switched due to quota error
|
|
if (modelSwitchedFromQuotaError) {
|
|
return;
|
|
}
|
|
|
|
submitQuery(
|
|
responsesToSend,
|
|
{
|
|
isContinuation: true,
|
|
},
|
|
prompt_ids[0],
|
|
);
|
|
},
|
|
[
|
|
isResponding,
|
|
submitQuery,
|
|
markToolsAsSubmitted,
|
|
geminiClient,
|
|
performMemoryRefresh,
|
|
modelSwitchedFromQuotaError,
|
|
],
|
|
);
|
|
|
|
const pendingHistoryItems = useMemo(
|
|
() =>
|
|
[
|
|
pendingHistoryItem,
|
|
pendingRetryErrorItem,
|
|
pendingRetryCountdownItem,
|
|
pendingToolCallGroupDisplay,
|
|
].filter((i) => i !== undefined && i !== null),
|
|
[
|
|
pendingHistoryItem,
|
|
pendingRetryErrorItem,
|
|
pendingRetryCountdownItem,
|
|
pendingToolCallGroupDisplay,
|
|
],
|
|
);
|
|
|
|
useEffect(() => {
|
|
const saveRestorableToolCalls = async () => {
|
|
if (!config.getCheckpointingEnabled()) {
|
|
return;
|
|
}
|
|
const restorableToolCalls = toolCalls.filter(
|
|
(toolCall) =>
|
|
EDIT_TOOL_NAMES.has(toolCall.request.name) &&
|
|
toolCall.status === 'awaiting_approval',
|
|
);
|
|
|
|
if (restorableToolCalls.length > 0) {
|
|
const checkpointDir = storage.getProjectTempCheckpointsDir();
|
|
|
|
if (!checkpointDir) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await fs.mkdir(checkpointDir, { recursive: true });
|
|
} catch (error) {
|
|
if (!isNodeError(error) || error.code !== 'EEXIST') {
|
|
onDebugMessage(
|
|
`Failed to create checkpoint directory: ${getErrorMessage(error)}`,
|
|
);
|
|
return;
|
|
}
|
|
}
|
|
|
|
for (const toolCall of restorableToolCalls) {
|
|
const filePath = toolCall.request.args['file_path'] as string;
|
|
if (!filePath) {
|
|
onDebugMessage(
|
|
`Skipping restorable tool call due to missing file_path: ${toolCall.request.name}`,
|
|
);
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
if (!gitService) {
|
|
onDebugMessage(
|
|
`Checkpointing is enabled but Git service is not available. Failed to create snapshot for ${filePath}. Ensure Git is installed and working properly.`,
|
|
);
|
|
continue;
|
|
}
|
|
|
|
let commitHash: string | undefined;
|
|
try {
|
|
commitHash = await gitService.createFileSnapshot(
|
|
`Snapshot for ${toolCall.request.name}`,
|
|
);
|
|
} catch (error) {
|
|
onDebugMessage(
|
|
`Failed to create new snapshot: ${getErrorMessage(error)}. Attempting to use current commit.`,
|
|
);
|
|
}
|
|
|
|
if (!commitHash) {
|
|
commitHash = await gitService.getCurrentCommitHash();
|
|
}
|
|
|
|
if (!commitHash) {
|
|
onDebugMessage(
|
|
`Failed to create snapshot for ${filePath}. Checkpointing may not be working properly. Ensure Git is installed and the project directory is accessible.`,
|
|
);
|
|
continue;
|
|
}
|
|
|
|
const timestamp = new Date()
|
|
.toISOString()
|
|
.replace(/:/g, '-')
|
|
.replace(/\./g, '_');
|
|
const toolName = toolCall.request.name;
|
|
const fileName = path.basename(filePath);
|
|
const toolCallWithSnapshotFileName = `${timestamp}-${fileName}-${toolName}.json`;
|
|
const clientHistory = await geminiClient?.getHistory();
|
|
const toolCallWithSnapshotFilePath = path.join(
|
|
checkpointDir,
|
|
toolCallWithSnapshotFileName,
|
|
);
|
|
|
|
await fs.writeFile(
|
|
toolCallWithSnapshotFilePath,
|
|
JSON.stringify(
|
|
{
|
|
history,
|
|
clientHistory,
|
|
toolCall: {
|
|
name: toolCall.request.name,
|
|
args: toolCall.request.args,
|
|
},
|
|
commitHash,
|
|
filePath,
|
|
},
|
|
null,
|
|
2,
|
|
),
|
|
);
|
|
} catch (error) {
|
|
onDebugMessage(
|
|
`Failed to create checkpoint for ${filePath}: ${getErrorMessage(
|
|
error,
|
|
)}. This may indicate a problem with Git or file system permissions.`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
};
|
|
saveRestorableToolCalls();
|
|
}, [
|
|
toolCalls,
|
|
config,
|
|
onDebugMessage,
|
|
gitService,
|
|
history,
|
|
geminiClient,
|
|
storage,
|
|
]);
|
|
|
|
return {
|
|
streamingState,
|
|
submitQuery,
|
|
initError,
|
|
pendingHistoryItems,
|
|
thought,
|
|
cancelOngoingRequest,
|
|
retryLastPrompt,
|
|
pendingToolCalls: toolCalls,
|
|
handleApprovalModeChange,
|
|
activePtyId,
|
|
loopDetectionConfirmationRequest,
|
|
};
|
|
};
|