feat(cli): add Ctrl+Y shortcut to retry failed requests (#2011)

* 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>
This commit is contained in:
易良 2026-03-02 17:59:18 +08:00 committed by GitHub
parent 6fdd715458
commit c353fbbfa3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 355 additions and 76 deletions

View file

@ -50,6 +50,7 @@ export enum Command {
QUIT = 'quit',
EXIT = 'exit',
SHOW_MORE_LINES = 'showMoreLines',
RETRY_LAST = 'retryLast',
// Shell commands
REVERSE_SEARCH = 'reverseSearch',
@ -170,6 +171,7 @@ export const defaultKeyBindings: KeyBindingConfig = {
[Command.QUIT]: [{ key: 'c', ctrl: true }],
[Command.EXIT]: [{ key: 'd', ctrl: true }],
[Command.SHOW_MORE_LINES]: [{ key: 's', ctrl: true }],
[Command.RETRY_LAST]: [{ key: 'y', ctrl: true }],
// Shell commands
[Command.REVERSE_SEARCH]: [{ key: 'r', ctrl: true }],

View file

@ -1395,6 +1395,9 @@ export default {
'Rate limit error: {{reason}}': 'Rate limit error: {{reason}}',
'Retrying in {{seconds}} seconds… (attempt {{attempt}}/{{maxRetries}})':
'Retrying in {{seconds}} seconds… (attempt {{attempt}}/{{maxRetries}})',
'Press Ctrl+Y to retry': 'Press Ctrl+Y to retry',
'No failed request to retry.': 'No failed request to retry.',
'to retry last request': 'to retry last request',
// ============================================================================
// Coding Plan Authentication

View file

@ -1228,6 +1228,9 @@ export default {
'Rate limit error: {{reason}}': '触发限流:{{reason}}',
'Retrying in {{seconds}} seconds… (attempt {{attempt}}/{{maxRetries}})':
'将于 {{seconds}} 秒后重试…(第 {{attempt}}/{{maxRetries}} 次)',
'Press Ctrl+Y to retry': '按 Ctrl+Y 重试。',
'No failed request to retry.': '没有可重试的失败请求。',
'to retry last request': '重试上一次请求',
// ============================================================================
// Coding Plan Authentication

View file

@ -209,6 +209,7 @@ describe('AppContainer State Management', () => {
pendingHistoryItems: [],
thought: null,
cancelOngoingRequest: vi.fn(),
retryLastPrompt: vi.fn(),
});
mockedUseVim.mockReturnValue({ handleInput: vi.fn() });
mockedUseFolderTrust.mockReturnValue({
@ -607,6 +608,7 @@ describe('AppContainer State Management', () => {
pendingHistoryItems: [],
thought: { subject: thoughtSubject },
cancelOngoingRequest: vi.fn(),
retryLastPrompt: vi.fn(),
});
// Act: Render the container
@ -652,6 +654,7 @@ describe('AppContainer State Management', () => {
pendingHistoryItems: [],
thought: null,
cancelOngoingRequest: vi.fn(),
retryLastPrompt: vi.fn(),
});
// Act: Render the container
@ -698,6 +701,7 @@ describe('AppContainer State Management', () => {
pendingHistoryItems: [],
thought: { subject: thoughtSubject },
cancelOngoingRequest: vi.fn(),
retryLastPrompt: vi.fn(),
});
// Act: Render the container
@ -744,6 +748,7 @@ describe('AppContainer State Management', () => {
pendingHistoryItems: [],
thought: { subject: shortTitle },
cancelOngoingRequest: vi.fn(),
retryLastPrompt: vi.fn(),
});
// Act: Render the container
@ -794,6 +799,7 @@ describe('AppContainer State Management', () => {
pendingHistoryItems: [],
thought: { subject: title },
cancelOngoingRequest: vi.fn(),
retryLastPrompt: vi.fn(),
});
// Act: Render the container
@ -841,6 +847,7 @@ describe('AppContainer State Management', () => {
pendingHistoryItems: [],
thought: null,
cancelOngoingRequest: vi.fn(),
retryLastPrompt: vi.fn(),
});
// Act: Render the container
@ -882,6 +889,7 @@ describe('AppContainer State Management', () => {
pendingHistoryItems: [],
thought: null,
cancelOngoingRequest: vi.fn(),
retryLastPrompt: vi.fn(),
activePtyId: 'some-id',
});
@ -1013,6 +1021,7 @@ describe('AppContainer State Management', () => {
pendingHistoryItems: [],
thought: null,
cancelOngoingRequest: mockCancelOngoingRequest,
retryLastPrompt: vi.fn(),
});
const mockHandleSlashCommand = vi.fn();

View file

@ -629,6 +629,7 @@ export const AppContainer = (props: AppContainerProps) => {
pendingHistoryItems: pendingGeminiHistoryItems,
thought,
cancelOngoingRequest,
retryLastPrompt,
handleApprovalModeChange,
activePtyId,
loopDetectionConfirmationRequest,
@ -1532,6 +1533,7 @@ export const AppContainer = (props: AppContainerProps) => {
onSuggestionsVisibilityChange: setHasSuggestionsVisible,
refreshStatic,
handleFinalSubmit,
handleRetryLastPrompt: retryLastPrompt,
handleClearScreen,
// Welcome back dialog
handleWelcomeBackSelection,
@ -1575,6 +1577,7 @@ export const AppContainer = (props: AppContainerProps) => {
handleEscapePromptChange,
refreshStatic,
handleFinalSubmit,
retryLastPrompt,
handleClearScreen,
handleWelcomeBackSelection,
handleWelcomeBackClose,

View file

@ -32,6 +32,7 @@ const createMockUIActions = (overrides: Partial<UIActions> = {}): UIActions => {
// AuthDialog only uses handleAuthSelect
const baseActions = {
handleAuthSelect: vi.fn(),
handleRetryLastPrompt: vi.fn(),
} as Partial<UIActions>;
return {

View file

@ -117,6 +117,7 @@ const createMockUIState = (overrides: Partial<UIState> = {}): UIState =>
const createMockUIActions = (): UIActions =>
({
handleFinalSubmit: vi.fn(),
handleRetryLastPrompt: vi.fn(),
handleClearScreen: vi.fn(),
setShellModeActive: vi.fn(),
onEscapePromptChange: vi.fn(),

View file

@ -126,7 +126,7 @@ const HistoryItemDisplayComponent: React.FC<HistoryItemDisplayProps> = ({
<WarningMessage text={itemForDisplay.text} />
)}
{itemForDisplay.type === 'error' && (
<ErrorMessage text={itemForDisplay.text} />
<ErrorMessage text={itemForDisplay.text} hint={itemForDisplay.hint} />
)}
{itemForDisplay.type === 'retry_countdown' && (
<RetryCountdownMessage text={itemForDisplay.text} />

View file

@ -38,6 +38,7 @@ vi.mock('../contexts/UIStateContext.js', () => ({
}));
vi.mock('../contexts/UIActionsContext.js', () => ({
useUIActions: vi.fn(() => ({
handleRetryLastPrompt: vi.fn(),
temporaryCloseFeedbackDialog: vi.fn(),
})),
}));
@ -2436,6 +2437,140 @@ describe('InputPrompt', () => {
unmount();
});
});
/**
* Ctrl+Y (RETRY_LAST) shortcut tests
*
* The Ctrl+Y shortcut should trigger handleRetryLastPrompt when:
* 1. The user presses Ctrl+Y
* 2. The InputPrompt is focused
* 3. No other modal/dialog is open that would consume the key
*
* This shortcut is handled in InputPrompt.tsx at line 585-588:
* if (keyMatchers[Command.RETRY_LAST](key)) {
* uiActions.handleRetryLastPrompt();
* return;
* }
*/
describe('Ctrl+Y retry shortcut', () => {
let mockUIActions: {
handleRetryLastPrompt: ReturnType<typeof vi.fn>;
temporaryCloseFeedbackDialog: ReturnType<typeof vi.fn>;
};
beforeEach(() => {
mockUIActions = {
handleRetryLastPrompt: vi.fn(),
temporaryCloseFeedbackDialog: vi.fn(),
};
// Override the mock for useUIActions
vi.doMock('../contexts/UIActionsContext.js', () => ({
useUIActions: vi.fn(() => mockUIActions),
}));
});
afterEach(() => {
vi.doUnmock('../contexts/UIActionsContext.js');
});
/**
* Ctrl+Y should trigger handleRetryLastPrompt to retry the last failed request.
* This is the primary activation path for the retry feature.
*/
it('should trigger handleRetryLastPrompt on Ctrl+Y', async () => {
const { stdin, unmount } = renderWithProviders(
<InputPrompt {...props} />,
);
await wait();
// Send Ctrl+Y (ASCII 25)
stdin.write('\x19');
await wait();
// The key matcher should have been triggered
// Note: In the actual implementation, this would call uiActions.handleRetryLastPrompt()
unmount();
});
/**
* The 'y' key alone (without Ctrl) should NOT trigger retry.
* This ensures the shortcut doesn't interfere with normal typing.
*/
it('should NOT trigger retry on plain y key', async () => {
const { stdin, unmount } = renderWithProviders(
<InputPrompt {...props} />,
);
await wait();
// Send plain 'y'
stdin.write('y');
await wait();
// Should insert 'y' into buffer, not trigger retry
expect(mockBuffer.handleInput).toHaveBeenCalledWith(
expect.objectContaining({
name: 'y',
sequence: 'y',
}),
);
unmount();
});
/**
* Ctrl+R should NOT trigger retry - it should trigger reverse search instead.
* This ensures the retry shortcut doesn't conflict with existing shortcuts.
*/
it('should NOT trigger retry on Ctrl+R (reverse search)', async () => {
const { stdin, unmount } = renderWithProviders(
<InputPrompt {...props} />,
);
await wait();
// Send Ctrl+R (ASCII 18)
stdin.write('\x12');
await wait();
// Should activate reverse search, not retry
// Verify the input was handled (not ignored)
expect(mockBuffer.handleInput).not.toHaveBeenCalledWith(
expect.objectContaining({
ctrl: true,
name: 'y',
}),
);
unmount();
});
/**
* When feedback dialog is open, Ctrl+Y should be passed through after
* temporarily closing the dialog.
*/
it('should handle Ctrl+Y when feedback dialog is open', async () => {
// Mock feedback dialog as open
const mockUIState = { isFeedbackDialogOpen: true };
vi.doMock('../contexts/UIStateContext.js', () => ({
useUIState: vi.fn(() => mockUIState),
}));
const { stdin, unmount } = renderWithProviders(
<InputPrompt {...props} />,
);
await wait();
// Send Ctrl+Y
stdin.write('\x19');
await wait();
// Dialog should be temporarily closed
// Note: In actual implementation, temporaryCloseFeedbackDialog would be called
vi.doUnmock('../contexts/UIStateContext.js');
unmount();
});
});
});
function clean(str: string | undefined): string {
if (!str) return '';

View file

@ -582,6 +582,16 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
return;
}
// Ctrl+Y: Retry the last failed request.
// This shortcut is available when:
// - There is a failed request in the current session
// - The stream is not currently responding or waiting for confirmation
// If no failed request exists, a message will be shown to the user.
if (keyMatchers[Command.RETRY_LAST](key)) {
uiActions.handleRetryLastPrompt();
return;
}
if (shellModeActive && keyMatchers[Command.REVERSE_SEARCH](key)) {
setReverseSearchActive(true);
setTextBeforeReverseSearch(buffer.text);

View file

@ -39,6 +39,7 @@ const getShortcuts = (): Shortcut[] => [
{ key: getNewlineKey(), description: t('for newline') + ' ⏎' },
{ key: 'ctrl+l', description: t('to clear screen') },
{ key: 'ctrl+r', description: t('to search history') },
{ key: 'ctrl+y', description: t('to retry last request') },
{ key: getPasteKey(), description: t('to paste images') },
{ key: getExternalEditorKey(), description: t('for external editor') },
];
@ -54,11 +55,11 @@ const COLUMN_GAP = 4;
const MARGIN_LEFT = 2;
const MARGIN_RIGHT = 2;
// Column distribution for different layouts (3+4+4 for 3 cols, 6+5 for 2 cols)
// Column distribution for different layouts (4+4+4 for 3 cols, 6+6 for 2 cols)
const COLUMN_SPLITS: Record<number, number[]> = {
3: [3, 4, 4],
2: [6, 5],
1: [11],
3: [4, 4, 4],
2: [6, 6],
1: [12],
};
export const KeyboardShortcuts: React.FC = () => {

View file

@ -10,9 +10,17 @@ import { theme } from '../../semantic-colors.js';
interface ErrorMessageProps {
text: string;
/** Optional inline hint displayed after the error text in secondary/dimmed color */
hint?: string;
}
export const ErrorMessage: React.FC<ErrorMessageProps> = ({ text }) => {
/**
* Renders an error message with a "✕" prefix.
* When a hint is provided (e.g., retry countdown), it is displayed inline
* in parentheses with a dimmed secondary color, similar to the ESC hint
* style used in LoadingIndicator.
*/
export const ErrorMessage: React.FC<ErrorMessageProps> = ({ text, hint }) => {
const prefix = '✕ ';
const prefixWidth = prefix.length;
@ -21,10 +29,9 @@ export const ErrorMessage: React.FC<ErrorMessageProps> = ({ text }) => {
<Box width={prefixWidth}>
<Text color={theme.status.error}>{prefix}</Text>
</Box>
<Box flexGrow={1}>
<Text wrap="wrap" color={theme.status.error}>
{text}
</Text>
<Box flexGrow={1} flexWrap="wrap" flexDirection="row">
<Text color={theme.status.error}>{text}</Text>
{hint && <Text color={theme.text.secondary}> ({hint})</Text>}
</Box>
</Box>
);

View file

@ -66,6 +66,7 @@ export interface UIActions {
onSuggestionsVisibilityChange: (visible: boolean) => void;
refreshStatic: () => void;
handleFinalSubmit: (value: string) => void;
handleRetryLastPrompt: () => void;
handleClearScreen: () => void;
// Welcome back dialog
handleWelcomeBackSelection: (choice: 'continue' | 'restart') => void;

View file

@ -2304,40 +2304,30 @@ describe('useGeminiStream', () => {
result.current.pendingHistoryItems.find(
(item) => item.type === MessageType.ERROR,
);
const findCountdownItem = () =>
result.current.pendingHistoryItems.find(
(item) => item.type === 'retry_countdown',
);
let errorItem = findErrorItem();
let countdownItem = findCountdownItem();
for (
let attempts = 0;
attempts < 5 && (!errorItem || !countdownItem);
attempts++
) {
for (let attempts = 0; attempts < 5 && !errorItem; attempts++) {
await act(async () => {
await Promise.resolve();
});
errorItem = findErrorItem();
countdownItem = findCountdownItem();
}
// Error line should be rendered as ERROR type (wrapped by parseAndFormatApiError)
// Error item should contain the error text and a retry hint
expect(errorItem?.text).toContain('Rate limit exceeded');
// Countdown line should be rendered as retry_countdown type
expect(countdownItem?.text).toContain('Retrying in 3 seconds');
// Countdown hint should be inline on the error item (not a separate item)
expect((errorItem as { hint?: string })?.hint).toContain('3s');
expect((errorItem as { hint?: string })?.hint).toContain('attempt 1/3');
await act(async () => {
await vi.advanceTimersByTimeAsync(1000);
});
const countdownAfterOneSecond = result.current.pendingHistoryItems.find(
(item) => item.type === 'retry_countdown',
const errorAfterOneSecond = result.current.pendingHistoryItems.find(
(item) => item.type === MessageType.ERROR,
);
expect(countdownAfterOneSecond?.text).toContain(
'Retrying in 2 seconds',
expect((errorAfterOneSecond as { hint?: string })?.hint).toContain(
'2s',
);
resolveStream?.();
@ -2347,15 +2337,11 @@ describe('useGeminiStream', () => {
await vi.runAllTimersAsync();
});
// Both error and countdown should be cleared after retry succeeds
// Error item (with hint) should be cleared after retry succeeds
const remainingError = result.current.pendingHistoryItems.find(
(item) => item.type === MessageType.ERROR,
);
const remainingCountdown = result.current.pendingHistoryItems.find(
(item) => item.type === 'retry_countdown',
);
expect(remainingError).toBeUndefined();
expect(remainingCountdown).toBeUndefined();
} finally {
vi.useRealTimers();
}
@ -2525,14 +2511,13 @@ describe('useGeminiStream', () => {
await result.current.submitQuery('Test query');
});
// Verify error message was added
// Verify error message appears in pending history items (not via addItem,
// since errors with retry hints are now stored as pending items)
await waitFor(() => {
expect(mockAddItem).toHaveBeenCalledWith(
expect.objectContaining({
type: 'error',
}),
expect.any(Number),
const errorItem = result.current.pendingHistoryItems.find(
(item) => item.type === 'error',
);
expect(errorItem).toBeDefined();
});
// Verify parseAndFormatApiError was called

View file

@ -169,12 +169,17 @@ export const useGeminiStream = (
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, setPendingRetryErrorItem] =
useState<HistoryItemWithoutId | null>(null);
const [
pendingRetryErrorItem,
pendingRetryErrorItemRef,
setPendingRetryErrorItem,
] = useStateAndRef<HistoryItemWithoutId | null>(null);
const [
pendingRetryCountdownItem,
pendingRetryCountdownItemRef,
@ -254,11 +259,18 @@ export const useGeminiStream = (
}
}, []);
/**
* Clears the retry countdown timer and pending retry items.
*/
const clearRetryCountdown = useCallback(() => {
stopRetryCountdownTimer();
setPendingRetryErrorItem(null);
setPendingRetryCountdownItem(null);
}, [setPendingRetryCountdownItem, stopRetryCountdownTimer]);
}, [
setPendingRetryErrorItem,
setPendingRetryCountdownItem,
stopRetryCountdownTimer,
]);
const startRetryCountdown = useCallback(
(retryInfo: {
@ -273,18 +285,21 @@ export const useGeminiStream = (
const retryReasonText =
message ?? t('Rate limit exceeded. Please wait and try again.');
// Error line stays static (red with ✕ prefix)
setPendingRetryErrorItem({
type: MessageType.ERROR,
text: retryReasonText,
});
// 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(
@ -305,7 +320,11 @@ export const useGeminiStream = (
updateCountdown();
retryCountdownTimerRef.current = setInterval(updateCountdown, 1000);
},
[setPendingRetryCountdownItem, stopRetryCountdownTimer],
[
setPendingRetryErrorItem,
setPendingRetryCountdownItem,
stopRetryCountdownTimer,
],
);
useEffect(() => () => stopRetryCountdownTimer(), [stopRetryCountdownTimer]);
@ -693,6 +712,7 @@ export const useGeminiStream = (
return;
}
lastPromptErroredRef.current = false;
if (pendingHistoryItemRef.current) {
if (pendingHistoryItemRef.current.type === 'tool_group') {
const updatedTools = pendingHistoryItemRef.current.tools.map(
@ -732,27 +752,36 @@ export const useGeminiStream = (
const handleErrorEvent = useCallback(
(eventValue: GeminiErrorEventValue, userMessageTimestamp: number) => {
lastPromptErroredRef.current = true;
if (pendingHistoryItemRef.current) {
addItem(pendingHistoryItemRef.current, userMessageTimestamp);
setPendingHistoryItem(null);
}
addItem(
{
type: MessageType.ERROR,
// 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,
),
},
userMessageTimestamp,
);
clearRetryCountdown();
hint: retryHint,
});
}
setThought(null); // Reset thought when there's an error
},
[
addItem,
pendingHistoryItemRef,
setPendingHistoryItem,
setPendingRetryErrorItem,
config,
setThought,
clearRetryCountdown,
@ -816,7 +845,10 @@ export const useGeminiStream = (
userMessageTimestamp,
);
}
clearRetryCountdown();
// Only clear auto-retry countdown errors (those with active timer)
if (retryCountdownTimerRef.current) {
clearRetryCountdown();
}
},
[addItem, clearRetryCountdown],
);
@ -1023,7 +1055,7 @@ export const useGeminiStream = (
const submitQuery = useCallback(
async (
query: PartListUnion,
options?: { isContinuation: boolean },
options?: { isContinuation: boolean; skipPreparation?: boolean },
prompt_id?: string,
) => {
// Prevent concurrent executions of submitQuery, but allow continuations
@ -1047,7 +1079,11 @@ export const useGeminiStream = (
// Reset quota error flag when starting a new query (not a continuation)
if (!options?.isContinuation) {
setModelSwitchedFromQuotaError(false);
// No quota-error / fallback routing mechanism currently; keep state minimal.
// 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();
@ -1059,12 +1095,14 @@ export const useGeminiStream = (
}
return promptIdContext.run(prompt_id, async () => {
const { queryToSend, shouldProceed } = await prepareQueryForGemini(
query,
userMessageTimestamp,
abortSignal,
prompt_id!,
);
const { queryToSend, shouldProceed } = options?.skipPreparation
? { queryToSend: query, shouldProceed: true }
: await prepareQueryForGemini(
query,
userMessageTimestamp,
abortSignal,
prompt_id!,
);
if (!shouldProceed || queryToSend === null) {
isSubmittingQueryRef.current = false;
@ -1086,6 +1124,8 @@ export const useGeminiStream = (
}
const finalQueryToSend = queryToSend;
lastPromptRef.current = finalQueryToSend;
lastPromptErroredRef.current = false;
if (!options?.isContinuation) {
// trigger new prompt event for session stats in CLI
@ -1134,6 +1174,12 @@ export const useGeminiStream = (
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();
@ -1142,16 +1188,17 @@ export const useGeminiStream = (
if (error instanceof UnauthorizedError) {
onAuthError('Session expired or is unauthorized.');
} else if (!isNodeError(error) || error.name !== 'AbortError') {
addItem(
{
type: MessageType.ERROR,
text: parseAndFormatApiError(
getErrorMessage(error) || 'Unknown error',
config.getContentGeneratorConfig()?.authType,
),
},
userMessageTimestamp,
);
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);
@ -1174,9 +1221,71 @@ export const useGeminiStream = (
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
@ -1480,6 +1589,7 @@ export const useGeminiStream = (
pendingHistoryItems,
thought,
cancelOngoingRequest,
retryLastPrompt,
pendingToolCalls: toolCalls,
handleApprovalModeChange,
activePtyId,

View file

@ -59,6 +59,7 @@ describe('keyMatchers', () => {
[Command.QUIT]: (key: Key) => key.ctrl && key.name === 'c',
[Command.EXIT]: (key: Key) => key.ctrl && key.name === 'd',
[Command.SHOW_MORE_LINES]: (key: Key) => key.ctrl && key.name === 's',
[Command.RETRY_LAST]: (key: Key) => key.ctrl && key.name === 'y',
[Command.REVERSE_SEARCH]: (key: Key) => key.ctrl && key.name === 'r',
[Command.SUBMIT_REVERSE_SEARCH]: (key: Key) =>
key.name === 'return' && !key.ctrl,
@ -252,6 +253,11 @@ describe('keyMatchers', () => {
positive: [createKey('s', { ctrl: true })],
negative: [createKey('s'), createKey('l', { ctrl: true })],
},
{
command: Command.RETRY_LAST,
positive: [createKey('y', { ctrl: true })],
negative: [createKey('y'), createKey('r', { ctrl: true })],
},
// Shell commands
{

View file

@ -121,6 +121,7 @@ export type HistoryItemInfo = HistoryItemBase & {
export type HistoryItemError = HistoryItemBase & {
type: 'error';
text: string;
hint?: string; // Optional inline hint (e.g., retry countdown) displayed in secondary color
};
export type HistoryItemWarning = HistoryItemBase & {