qwen-code/packages/cli/src/ui/keyMatchers.test.ts
易良 c353fbbfa3
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>
2026-03-02 17:59:18 +08:00

375 lines
12 KiB
TypeScript

/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import { keyMatchers, Command, createKeyMatchers } from './keyMatchers.js';
import type { KeyBindingConfig } from '../config/keyBindings.js';
import { defaultKeyBindings } from '../config/keyBindings.js';
import type { Key } from './hooks/useKeypress.js';
describe('keyMatchers', () => {
const isWindows = process.platform === 'win32';
const createKey = (name: string, mods: Partial<Key> = {}): Key => ({
name,
ctrl: false,
meta: false,
shift: false,
paste: false,
sequence: name,
...mods,
});
// Original hard-coded logic (for comparison)
const originalMatchers: Record<Command, (key: Key) => boolean> = {
[Command.RETURN]: (key: Key) => key.name === 'return',
[Command.HOME]: (key: Key) => key.ctrl && key.name === 'a',
[Command.END]: (key: Key) => key.ctrl && key.name === 'e',
[Command.KILL_LINE_RIGHT]: (key: Key) => key.ctrl && key.name === 'k',
[Command.KILL_LINE_LEFT]: (key: Key) => key.ctrl && key.name === 'u',
[Command.CLEAR_INPUT]: (key: Key) => key.ctrl && key.name === 'c',
[Command.DELETE_WORD_BACKWARD]: (key: Key) =>
(key.ctrl || key.meta) && key.name === 'backspace',
[Command.CLEAR_SCREEN]: (key: Key) => key.ctrl && key.name === 'l',
[Command.HISTORY_UP]: (key: Key) => key.ctrl && key.name === 'p',
[Command.HISTORY_DOWN]: (key: Key) => key.ctrl && key.name === 'n',
[Command.NAVIGATION_UP]: (key: Key) => key.name === 'up',
[Command.NAVIGATION_DOWN]: (key: Key) => key.name === 'down',
[Command.ACCEPT_SUGGESTION]: (key: Key) =>
key.name === 'tab' || (key.name === 'return' && !key.ctrl),
// Completion navigation only uses arrow keys (not Ctrl+P/N)
// to allow Ctrl+P/N to always navigate history
[Command.COMPLETION_UP]: (key: Key) => key.name === 'up',
[Command.COMPLETION_DOWN]: (key: Key) => key.name === 'down',
[Command.ESCAPE]: (key: Key) => key.name === 'escape',
[Command.SUBMIT]: (key: Key) =>
key.name === 'return' && !key.ctrl && !key.meta && !key.paste,
[Command.NEWLINE]: (key: Key) =>
key.name === 'return' && (key.ctrl || key.meta || key.paste),
[Command.OPEN_EXTERNAL_EDITOR]: (key: Key) =>
key.ctrl && (key.name === 'x' || key.sequence === '\x18'),
[Command.PASTE_CLIPBOARD_IMAGE]: (key: Key) =>
(isWindows ? key.meta : key.ctrl || key.meta) && key.name === 'v',
[Command.TOGGLE_TOOL_DESCRIPTIONS]: (key: Key) =>
key.ctrl && key.name === 't',
[Command.TOGGLE_IDE_CONTEXT_DETAIL]: (key: Key) =>
key.ctrl && key.name === 'g',
[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,
[Command.ACCEPT_SUGGESTION_REVERSE_SEARCH]: (key: Key) =>
key.name === 'tab',
[Command.TOGGLE_SHELL_INPUT_FOCUS]: (key: Key) =>
key.ctrl && key.name === 'f',
[Command.EXPAND_SUGGESTION]: (key: Key) => key.name === 'right',
[Command.COLLAPSE_SUGGESTION]: (key: Key) => key.name === 'left',
};
// Test data for each command with positive and negative test cases
const testCases = [
// Basic bindings
{
command: Command.RETURN,
positive: [createKey('return')],
negative: [createKey('r')],
},
{
command: Command.ESCAPE,
positive: [createKey('escape'), createKey('escape', { ctrl: true })],
negative: [createKey('e'), createKey('esc')],
},
// Cursor movement
{
command: Command.HOME,
positive: [createKey('a', { ctrl: true })],
negative: [
createKey('a'),
createKey('a', { shift: true }),
createKey('b', { ctrl: true }),
],
},
{
command: Command.END,
positive: [createKey('e', { ctrl: true })],
negative: [
createKey('e'),
createKey('e', { shift: true }),
createKey('a', { ctrl: true }),
],
},
// Text deletion
{
command: Command.KILL_LINE_RIGHT,
positive: [createKey('k', { ctrl: true })],
negative: [createKey('k'), createKey('l', { ctrl: true })],
},
{
command: Command.KILL_LINE_LEFT,
positive: [createKey('u', { ctrl: true })],
negative: [createKey('u'), createKey('k', { ctrl: true })],
},
{
command: Command.CLEAR_INPUT,
positive: [createKey('c', { ctrl: true })],
negative: [createKey('c'), createKey('k', { ctrl: true })],
},
{
command: Command.DELETE_WORD_BACKWARD,
positive: [
createKey('backspace', { ctrl: true }),
createKey('backspace', { meta: true }),
],
negative: [createKey('backspace'), createKey('delete', { ctrl: true })],
},
// Screen control
{
command: Command.CLEAR_SCREEN,
positive: [createKey('l', { ctrl: true })],
negative: [createKey('l'), createKey('k', { ctrl: true })],
},
// History navigation
{
command: Command.HISTORY_UP,
positive: [createKey('p', { ctrl: true })],
negative: [createKey('p'), createKey('up')],
},
{
command: Command.HISTORY_DOWN,
positive: [createKey('n', { ctrl: true })],
negative: [createKey('n'), createKey('down')],
},
{
command: Command.NAVIGATION_UP,
positive: [createKey('up'), createKey('up', { ctrl: true })],
negative: [createKey('p'), createKey('u')],
},
{
command: Command.NAVIGATION_DOWN,
positive: [createKey('down'), createKey('down', { ctrl: true })],
negative: [createKey('n'), createKey('d')],
},
// Auto-completion
{
command: Command.ACCEPT_SUGGESTION,
positive: [createKey('tab'), createKey('return')],
negative: [createKey('return', { ctrl: true }), createKey('space')],
},
{
// Completion navigation only uses arrow keys (not Ctrl+P/N)
// to allow Ctrl+P/N to always navigate history
command: Command.COMPLETION_UP,
positive: [createKey('up')],
negative: [
createKey('p'),
createKey('down'),
createKey('p', { ctrl: true }),
],
},
{
// Completion navigation only uses arrow keys (not Ctrl+P/N)
// to allow Ctrl+P/N to always navigate history
command: Command.COMPLETION_DOWN,
positive: [createKey('down')],
negative: [
createKey('n'),
createKey('up'),
createKey('n', { ctrl: true }),
],
},
// Text input
{
command: Command.SUBMIT,
positive: [createKey('return')],
negative: [
createKey('return', { ctrl: true }),
createKey('return', { meta: true }),
createKey('return', { paste: true }),
],
},
{
command: Command.NEWLINE,
positive: [
createKey('return', { ctrl: true }),
createKey('return', { meta: true }),
createKey('return', { paste: true }),
],
negative: [createKey('return'), createKey('n')],
},
// External tools
{
command: Command.OPEN_EXTERNAL_EDITOR,
positive: [
createKey('x', { ctrl: true }),
{ ...createKey('\x18'), sequence: '\x18', ctrl: true },
],
negative: [createKey('x'), createKey('c', { ctrl: true })],
},
{
command: Command.PASTE_CLIPBOARD_IMAGE,
positive: isWindows
? [createKey('v', { meta: true })]
: [createKey('v', { ctrl: true }), createKey('v', { meta: true })],
negative: isWindows
? [createKey('v', { ctrl: true }), createKey('v')]
: [createKey('v'), createKey('c', { ctrl: true })],
},
// App level bindings
{
command: Command.TOGGLE_TOOL_DESCRIPTIONS,
positive: [createKey('t', { ctrl: true })],
negative: [createKey('t'), createKey('s', { ctrl: true })],
},
{
command: Command.TOGGLE_IDE_CONTEXT_DETAIL,
positive: [createKey('g', { ctrl: true })],
negative: [createKey('g'), createKey('t', { ctrl: true })],
},
{
command: Command.QUIT,
positive: [createKey('c', { ctrl: true })],
negative: [createKey('c'), createKey('d', { ctrl: true })],
},
{
command: Command.EXIT,
positive: [createKey('d', { ctrl: true })],
negative: [createKey('d'), createKey('c', { ctrl: true })],
},
{
command: Command.SHOW_MORE_LINES,
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
{
command: Command.REVERSE_SEARCH,
positive: [createKey('r', { ctrl: true })],
negative: [createKey('r'), createKey('s', { ctrl: true })],
},
{
command: Command.SUBMIT_REVERSE_SEARCH,
positive: [createKey('return')],
negative: [createKey('return', { ctrl: true }), createKey('tab')],
},
{
command: Command.ACCEPT_SUGGESTION_REVERSE_SEARCH,
positive: [createKey('tab'), createKey('tab', { ctrl: true })],
negative: [createKey('return'), createKey('space')],
},
{
command: Command.TOGGLE_SHELL_INPUT_FOCUS,
positive: [createKey('f', { ctrl: true })],
negative: [createKey('f')],
},
];
describe('Data-driven key binding matches original logic', () => {
testCases.forEach(({ command, positive, negative }) => {
it(`should match ${command} correctly`, () => {
positive.forEach((key) => {
expect(
keyMatchers[command](key),
`Expected ${command} to match ${JSON.stringify(key)}`,
).toBe(true);
expect(
originalMatchers[command](key),
`Original matcher should also match ${JSON.stringify(key)}`,
).toBe(true);
});
negative.forEach((key) => {
expect(
keyMatchers[command](key),
`Expected ${command} to NOT match ${JSON.stringify(key)}`,
).toBe(false);
expect(
originalMatchers[command](key),
`Original matcher should also NOT match ${JSON.stringify(key)}`,
).toBe(false);
});
});
});
it('should properly handle ACCEPT_SUGGESTION_REVERSE_SEARCH cases', () => {
expect(
keyMatchers[Command.ACCEPT_SUGGESTION_REVERSE_SEARCH](
createKey('return', { ctrl: true }),
),
).toBe(false); // ctrl must be false
expect(
keyMatchers[Command.ACCEPT_SUGGESTION_REVERSE_SEARCH](createKey('tab')),
).toBe(true);
expect(
keyMatchers[Command.ACCEPT_SUGGESTION_REVERSE_SEARCH](
createKey('tab', { ctrl: true }),
),
).toBe(true); // modifiers ignored
});
});
describe('Custom key bindings', () => {
it('should work with custom configuration', () => {
const customConfig: KeyBindingConfig = {
...defaultKeyBindings,
[Command.HOME]: [{ key: 'h', ctrl: true }, { key: '0' }],
};
const customMatchers = createKeyMatchers(customConfig);
expect(customMatchers[Command.HOME](createKey('h', { ctrl: true }))).toBe(
true,
);
expect(customMatchers[Command.HOME](createKey('0'))).toBe(true);
expect(customMatchers[Command.HOME](createKey('a', { ctrl: true }))).toBe(
false,
);
});
it('should support multiple key bindings for same command', () => {
const config: KeyBindingConfig = {
...defaultKeyBindings,
[Command.QUIT]: [
{ key: 'q', ctrl: true },
{ key: 'q', command: true },
],
};
const matchers = createKeyMatchers(config);
expect(matchers[Command.QUIT](createKey('q', { ctrl: true }))).toBe(true);
expect(matchers[Command.QUIT](createKey('q', { meta: true }))).toBe(true);
});
});
describe('Edge Cases', () => {
it('should handle empty binding arrays', () => {
const config: KeyBindingConfig = {
...defaultKeyBindings,
[Command.HOME]: [],
};
const matchers = createKeyMatchers(config);
expect(matchers[Command.HOME](createKey('a', { ctrl: true }))).toBe(
false,
);
});
});
});