mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-04-30 12:40:44 +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>
347 lines
10 KiB
TypeScript
347 lines
10 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright 2025 Google LLC
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
import { describe, it, expect, vi } from 'vitest';
|
|
import { render } from 'ink-testing-library';
|
|
import { Text } from 'ink';
|
|
import { Composer } from './Composer.js';
|
|
import { UIStateContext, type UIState } from '../contexts/UIStateContext.js';
|
|
import {
|
|
UIActionsContext,
|
|
type UIActions,
|
|
} from '../contexts/UIActionsContext.js';
|
|
import { ConfigContext } from '../contexts/ConfigContext.js';
|
|
// Mock VimModeContext hook
|
|
vi.mock('../contexts/VimModeContext.js', () => ({
|
|
useVimMode: vi.fn(() => ({
|
|
vimEnabled: false,
|
|
vimMode: 'NORMAL',
|
|
})),
|
|
}));
|
|
import { ApprovalMode } from '@qwen-code/qwen-code-core';
|
|
import { StreamingState } from '../types.js';
|
|
|
|
// Mock child components
|
|
vi.mock('./LoadingIndicator.js', () => ({
|
|
LoadingIndicator: ({ thought }: { thought?: string }) => (
|
|
<Text>LoadingIndicator{thought ? `: ${thought}` : ''}</Text>
|
|
),
|
|
}));
|
|
|
|
vi.mock('./ContextSummaryDisplay.js', () => ({
|
|
ContextSummaryDisplay: () => <Text>ContextSummaryDisplay</Text>,
|
|
}));
|
|
|
|
vi.mock('./AutoAcceptIndicator.js', () => ({
|
|
AutoAcceptIndicator: () => <Text>AutoAcceptIndicator</Text>,
|
|
}));
|
|
|
|
vi.mock('./ShellModeIndicator.js', () => ({
|
|
ShellModeIndicator: () => <Text>ShellModeIndicator</Text>,
|
|
}));
|
|
|
|
vi.mock('./InputPrompt.js', () => ({
|
|
InputPrompt: () => <Text>InputPrompt</Text>,
|
|
calculatePromptWidths: vi.fn(() => ({
|
|
inputWidth: 80,
|
|
suggestionsWidth: 40,
|
|
containerWidth: 84,
|
|
})),
|
|
}));
|
|
|
|
vi.mock('./Footer.js', () => ({
|
|
Footer: () => <Text>Footer</Text>,
|
|
}));
|
|
|
|
vi.mock('./QueuedMessageDisplay.js', () => ({
|
|
QueuedMessageDisplay: ({ messageQueue }: { messageQueue: string[] }) => {
|
|
if (messageQueue.length === 0) {
|
|
return null;
|
|
}
|
|
return (
|
|
<>
|
|
{messageQueue.map((message, index) => (
|
|
<Text key={index}>{message}</Text>
|
|
))}
|
|
</>
|
|
);
|
|
},
|
|
}));
|
|
|
|
// Mock contexts
|
|
vi.mock('../contexts/OverflowContext.js', () => ({
|
|
OverflowProvider: ({ children }: { children: React.ReactNode }) => children,
|
|
}));
|
|
|
|
// Create mock context providers
|
|
const createMockUIState = (overrides: Partial<UIState> = {}): UIState =>
|
|
({
|
|
streamingState: null,
|
|
contextFileNames: [],
|
|
showAutoAcceptIndicator: ApprovalMode.DEFAULT,
|
|
messageQueue: [],
|
|
constrainHeight: false,
|
|
isInputActive: true,
|
|
buffer: '',
|
|
inputWidth: 80,
|
|
suggestionsWidth: 40,
|
|
userMessages: [],
|
|
slashCommands: [],
|
|
commandContext: null,
|
|
shellModeActive: false,
|
|
isFocused: true,
|
|
thought: '',
|
|
currentLoadingPhrase: '',
|
|
elapsedTime: 0,
|
|
ctrlCPressedOnce: false,
|
|
ctrlDPressedOnce: false,
|
|
showEscapePrompt: false,
|
|
ideContextState: null,
|
|
geminiMdFileCount: 0,
|
|
showToolDescriptions: false,
|
|
sessionStats: {
|
|
lastPromptTokenCount: 0,
|
|
sessionTokenCount: 0,
|
|
totalPrompts: 0,
|
|
},
|
|
branchName: 'main',
|
|
debugMessage: '',
|
|
nightly: false,
|
|
isTrustedFolder: true,
|
|
...overrides,
|
|
}) as UIState;
|
|
|
|
const createMockUIActions = (): UIActions =>
|
|
({
|
|
handleFinalSubmit: vi.fn(),
|
|
handleRetryLastPrompt: vi.fn(),
|
|
handleClearScreen: vi.fn(),
|
|
setShellModeActive: vi.fn(),
|
|
onEscapePromptChange: vi.fn(),
|
|
vimHandleInput: vi.fn(),
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
}) as any;
|
|
|
|
const createMockConfig = (overrides = {}) => ({
|
|
getModel: vi.fn(() => 'gemini-1.5-pro'),
|
|
getTargetDir: vi.fn(() => '/test/dir'),
|
|
getDebugMode: vi.fn(() => false),
|
|
getAccessibility: vi.fn(() => ({})),
|
|
getMcpServers: vi.fn(() => ({})),
|
|
getBlockedMcpServers: vi.fn(() => []),
|
|
...overrides,
|
|
});
|
|
|
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
const renderComposer = (
|
|
uiState: UIState,
|
|
config = createMockConfig(),
|
|
uiActions = createMockUIActions(),
|
|
) =>
|
|
render(
|
|
<ConfigContext.Provider value={config as any}>
|
|
<UIStateContext.Provider value={uiState}>
|
|
<UIActionsContext.Provider value={uiActions}>
|
|
<Composer />
|
|
</UIActionsContext.Provider>
|
|
</UIStateContext.Provider>
|
|
</ConfigContext.Provider>,
|
|
);
|
|
/* eslint-enable @typescript-eslint/no-explicit-any */
|
|
|
|
describe('Composer', () => {
|
|
describe('Footer Display', () => {
|
|
it('renders Footer by default', () => {
|
|
const uiState = createMockUIState();
|
|
|
|
const { lastFrame } = renderComposer(uiState);
|
|
|
|
// Smoke check that the Footer renders
|
|
expect(lastFrame()).toContain('Footer');
|
|
});
|
|
});
|
|
|
|
describe('Loading Indicator', () => {
|
|
it('renders LoadingIndicator with thought when streaming', () => {
|
|
const uiState = createMockUIState({
|
|
streamingState: StreamingState.Responding,
|
|
thought: {
|
|
subject: 'Processing',
|
|
description: 'Processing your request...',
|
|
},
|
|
currentLoadingPhrase: 'Analyzing',
|
|
elapsedTime: 1500,
|
|
});
|
|
|
|
const { lastFrame } = renderComposer(uiState);
|
|
|
|
const output = lastFrame();
|
|
expect(output).toContain('LoadingIndicator');
|
|
});
|
|
|
|
it('renders LoadingIndicator without thought when accessibility disables loading phrases', () => {
|
|
const uiState = createMockUIState({
|
|
streamingState: StreamingState.Responding,
|
|
thought: { subject: 'Hidden', description: 'Should not show' },
|
|
});
|
|
const config = createMockConfig({
|
|
getAccessibility: vi.fn(() => ({ disableLoadingPhrases: true })),
|
|
});
|
|
|
|
const { lastFrame } = renderComposer(uiState, config);
|
|
|
|
const output = lastFrame();
|
|
expect(output).toContain('LoadingIndicator');
|
|
expect(output).not.toContain('Should not show');
|
|
});
|
|
|
|
it('suppresses thought when waiting for confirmation', () => {
|
|
const uiState = createMockUIState({
|
|
streamingState: StreamingState.WaitingForConfirmation,
|
|
thought: {
|
|
subject: 'Confirmation',
|
|
description: 'Should not show during confirmation',
|
|
},
|
|
});
|
|
|
|
const { lastFrame } = renderComposer(uiState);
|
|
|
|
const output = lastFrame();
|
|
expect(output).toContain('LoadingIndicator');
|
|
expect(output).not.toContain('Should not show during confirmation');
|
|
});
|
|
});
|
|
|
|
describe('Message Queue Display', () => {
|
|
it('displays queued messages when present', () => {
|
|
const uiState = createMockUIState({
|
|
messageQueue: [
|
|
'First queued message',
|
|
'Second queued message',
|
|
'Third queued message',
|
|
],
|
|
});
|
|
|
|
const { lastFrame } = renderComposer(uiState);
|
|
|
|
const output = lastFrame();
|
|
expect(output).toContain('First queued message');
|
|
expect(output).toContain('Second queued message');
|
|
expect(output).toContain('Third queued message');
|
|
});
|
|
|
|
it('renders QueuedMessageDisplay with empty message queue', () => {
|
|
const uiState = createMockUIState({
|
|
messageQueue: [],
|
|
});
|
|
|
|
const { lastFrame } = renderComposer(uiState);
|
|
|
|
// The component should render but return null for empty queue
|
|
// This test verifies that the component receives the correct prop
|
|
const output = lastFrame();
|
|
expect(output).toContain('InputPrompt'); // Verify basic Composer rendering
|
|
});
|
|
});
|
|
|
|
describe('Context and Status Display', () => {
|
|
// Note: ContextSummaryDisplay and status prompts are now rendered in Footer, not Composer
|
|
it('shows empty space in normal state (ContextSummaryDisplay moved to Footer)', () => {
|
|
const uiState = createMockUIState({
|
|
ctrlCPressedOnce: false,
|
|
ctrlDPressedOnce: false,
|
|
showEscapePrompt: false,
|
|
});
|
|
|
|
const { lastFrame } = renderComposer(uiState);
|
|
|
|
// ContextSummaryDisplay is now in Footer, so we just verify normal state renders
|
|
expect(lastFrame()).toBeDefined();
|
|
});
|
|
|
|
// Note: Ctrl+C, Ctrl+D, and Escape prompts are now rendered in Footer component
|
|
// These are tested in Footer.test.tsx
|
|
it('renders Footer which handles Ctrl+C exit prompt', () => {
|
|
const uiState = createMockUIState({
|
|
ctrlCPressedOnce: true,
|
|
});
|
|
|
|
const { lastFrame } = renderComposer(uiState);
|
|
|
|
// Ctrl+C prompt is now inside Footer, verify Footer renders
|
|
expect(lastFrame()).toContain('Footer');
|
|
});
|
|
|
|
it('renders Footer which handles Ctrl+D exit prompt', () => {
|
|
const uiState = createMockUIState({
|
|
ctrlDPressedOnce: true,
|
|
});
|
|
|
|
const { lastFrame } = renderComposer(uiState);
|
|
|
|
// Ctrl+D prompt is now inside Footer, verify Footer renders
|
|
expect(lastFrame()).toContain('Footer');
|
|
});
|
|
|
|
it('renders Footer which handles escape prompt', () => {
|
|
const uiState = createMockUIState({
|
|
showEscapePrompt: true,
|
|
});
|
|
|
|
const { lastFrame } = renderComposer(uiState);
|
|
|
|
// Escape prompt is now inside Footer, verify Footer renders
|
|
expect(lastFrame()).toContain('Footer');
|
|
});
|
|
});
|
|
|
|
describe('Input and Indicators', () => {
|
|
it('renders InputPrompt when input is active', () => {
|
|
const uiState = createMockUIState({
|
|
isInputActive: true,
|
|
});
|
|
|
|
const { lastFrame } = renderComposer(uiState);
|
|
|
|
expect(lastFrame()).toContain('InputPrompt');
|
|
});
|
|
|
|
it('does not render InputPrompt when input is inactive', () => {
|
|
const uiState = createMockUIState({
|
|
isInputActive: false,
|
|
});
|
|
|
|
const { lastFrame } = renderComposer(uiState);
|
|
|
|
expect(lastFrame()).not.toContain('InputPrompt');
|
|
});
|
|
|
|
// Note: AutoAcceptIndicator and ShellModeIndicator are now rendered inside Footer component
|
|
// These are tested in Footer.test.tsx
|
|
it('renders Footer which contains AutoAcceptIndicator when approval mode is not default', () => {
|
|
const uiState = createMockUIState({
|
|
showAutoAcceptIndicator: ApprovalMode.YOLO,
|
|
shellModeActive: false,
|
|
});
|
|
|
|
const { lastFrame } = renderComposer(uiState);
|
|
|
|
// AutoAcceptIndicator is now inside Footer, verify Footer renders
|
|
expect(lastFrame()).toContain('Footer');
|
|
});
|
|
|
|
it('renders Footer which contains ShellModeIndicator when shell mode is active', () => {
|
|
const uiState = createMockUIState({
|
|
shellModeActive: true,
|
|
});
|
|
|
|
const { lastFrame } = renderComposer(uiState);
|
|
|
|
// ShellModeIndicator is now inside Footer, verify Footer renders
|
|
expect(lastFrame()).toContain('Footer');
|
|
});
|
|
});
|
|
});
|