Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions packages/cli/src/i18n/locales/en.js
Original file line number Diff line number Diff line change
Expand Up @@ -1370,6 +1370,13 @@ export default {
'Failed to open browser. Check out the extensions gallery at {{url}}':
'Failed to open browser. Check out the extensions gallery at {{url}}',

// ============================================================================
// Retry / Rate Limit
// ============================================================================
'Rate limit error: {{reason}}': 'Rate limit error: {{reason}}',
'Retrying in {{seconds}} seconds… (attempt {{attempt}}/{{maxRetries}})':
'Retrying in {{seconds}} seconds… (attempt {{attempt}}/{{maxRetries}})',

// ============================================================================
// Coding Plan Authentication
// ============================================================================
Expand Down
7 changes: 7 additions & 0 deletions packages/cli/src/i18n/locales/zh.js
Original file line number Diff line number Diff line change
Expand Up @@ -1204,6 +1204,13 @@ export default {
'Failed to open browser. Check out the extensions gallery at {{url}}':
'打开浏览器失败。请访问扩展市场:{{url}}',

// ============================================================================
// Retry / Rate Limit
// ============================================================================
'Rate limit error: {{reason}}': '触发限流:{{reason}}',
'Retrying in {{seconds}} seconds… (attempt {{attempt}}/{{maxRetries}})':
'将于 {{seconds}} 秒后重试…(第 {{attempt}}/{{maxRetries}} 次)',

// ============================================================================
// Coding Plan Authentication
// ============================================================================
Expand Down
4 changes: 4 additions & 0 deletions packages/cli/src/ui/components/HistoryItemDisplay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { GeminiThoughtMessageContent } from './messages/GeminiThoughtMessageCont
import { CompressionMessage } from './messages/CompressionMessage.js';
import { SummaryMessage } from './messages/SummaryMessage.js';
import { WarningMessage } from './messages/WarningMessage.js';
import { RetryCountdownMessage } from './messages/RetryCountdownMessage.js';
import { Box } from 'ink';
import { AboutBox } from './AboutBox.js';
import { StatsDisplay } from './StatsDisplay.js';
Expand Down Expand Up @@ -126,6 +127,9 @@ const HistoryItemDisplayComponent: React.FC<HistoryItemDisplayProps> = ({
{itemForDisplay.type === 'error' && (
<ErrorMessage text={itemForDisplay.text} />
)}
{itemForDisplay.type === 'retry_countdown' && (
<RetryCountdownMessage text={itemForDisplay.text} />
)}
{itemForDisplay.type === 'about' && (
<AboutBox {...itemForDisplay.systemInfo} width={boxWidth} />
)}
Expand Down
41 changes: 41 additions & 0 deletions packages/cli/src/ui/components/messages/RetryCountdownMessage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/

import type React from 'react';
import { Text, Box } from 'ink';
import { theme } from '../../semantic-colors.js';

interface RetryCountdownMessageProps {
text: string;
}

/**
* Displays a retry countdown message in a dimmed/secondary style
* to visually distinguish it from error messages.
*/
export const RetryCountdownMessage: React.FC<RetryCountdownMessageProps> = ({
text,
}) => {
if (!text || text.trim() === '') {
return null;
}

const prefix = '↻ ';
const prefixWidth = prefix.length;

return (
<Box flexDirection="row">
<Box width={prefixWidth}>
<Text color={theme.text.secondary}>{prefix}</Text>
</Box>
<Box flexGrow={1}>
<Text wrap="wrap" color={theme.text.secondary}>
{text}
</Text>
</Box>
</Box>
);
};
144 changes: 127 additions & 17 deletions packages/cli/src/ui/hooks/useGeminiStream.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,12 @@ const MockedUserPromptEvent = vi.hoisted(() =>
const MockedApiCancelEvent = vi.hoisted(() =>
vi.fn().mockImplementation(() => {}),
);
const mockParseAndFormatApiError = vi.hoisted(() => vi.fn());
const mockParseAndFormatApiError = vi.hoisted(() =>
vi.fn(
(msg: unknown) =>
`[API Error: ${typeof msg === 'string' ? msg : 'An unknown error occurred.'}]`,
),
);
const mockLogApiCancel = vi.hoisted(() => vi.fn());

// Vision auto-switch mocks (hoisted)
Expand Down Expand Up @@ -123,22 +128,6 @@ vi.mock('../utils/markdownUtilities.js', () => ({
findLastSafeSplitPoint: vi.fn((s: string) => s.length),
}));

vi.mock('./useStateAndRef.js', () => ({
useStateAndRef: vi.fn((initial) => {
let val = initial;
const ref = { current: val };
const setVal = vi.fn((updater) => {
if (typeof updater === 'function') {
val = updater(val);
} else {
val = updater;
}
ref.current = val;
});
return [val, ref, setVal];
}),
}));

vi.mock('./useLogger.js', () => ({
useLogger: vi.fn().mockReturnValue({
logMessage: vi.fn().mockResolvedValue(undefined),
Expand Down Expand Up @@ -2296,6 +2285,127 @@ describe('useGeminiStream', () => {
});
});

it('should show a retry countdown and update pending history over time', async () => {
vi.useFakeTimers();
try {
let resolveStream: (() => void) | undefined;
mockSendMessageStream.mockReturnValue(
(async function* () {
yield {
type: ServerGeminiEventType.Retry,
retryInfo: {
message: '[API Error: Rate limit exceeded]',
attempt: 1,
maxRetries: 3,
delayMs: 3000,
},
};
yield {
type: ServerGeminiEventType.Retry,
};
await new Promise<void>((resolve) => {
resolveStream = resolve;
});
yield {
type: ServerGeminiEventType.Finished,
value: { reason: 'STOP', usageMetadata: undefined },
};
})(),
);

const { result } = renderHook(() =>
useGeminiStream(
new MockedGeminiClientClass(mockConfig),
[],
mockAddItem,
mockConfig,
mockLoadedSettings,
mockOnDebugMessage,
mockHandleSlashCommand,
false,
() => 'vscode' as EditorType,
() => {},
() => Promise.resolve(),
false,
() => {},
() => {},
() => {},
false, // visionModelPreviewEnabled
() => {},
80,
24,
),
);

act(() => {
void result.current.submitQuery('Trigger retry');
});

await act(async () => {
await Promise.resolve();
});

const findErrorItem = () =>
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++
) {
await act(async () => {
await Promise.resolve();
});
errorItem = findErrorItem();
countdownItem = findCountdownItem();
}

// Error line should be rendered as ERROR type (wrapped by parseAndFormatApiError)
expect(errorItem?.text).toContain('Rate limit exceeded');

// Countdown line should be rendered as retry_countdown type
expect(countdownItem?.text).toContain('Retrying in 3 seconds');

await act(async () => {
await vi.advanceTimersByTimeAsync(1000);
});

const countdownAfterOneSecond = result.current.pendingHistoryItems.find(
(item) => item.type === 'retry_countdown',
);
expect(countdownAfterOneSecond?.text).toContain(
'Retrying in 2 seconds',
);

resolveStream?.();

await act(async () => {
await Promise.resolve();
await vi.runAllTimersAsync();
});

// Both error and countdown 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();
}
});

it('should memoize pendingHistoryItems', () => {
mockUseReactToolScheduler.mockReturnValue([
[],
Expand Down
Loading