feat(cli): readline Ctrl+P/N for history and selection navigation

Adds GNU-readline-style Ctrl+P (previous) and Ctrl+N (next) shortcuts
to the qwen-code TUI so users coming from bash/zsh, Emacs, or Claude
Code feel at home. The change has three orthogonal behavior groups:

1. Input prompt, history-versus-line-motion two-step edge

   Ctrl+P / Ctrl+N and the arrow keys behave identically and apply a
   two-step edge transition that matches GNU readline and Claude Code:
   inside a multi-line buffer they move the cursor between visual
   rows; on the top row with the cursor away from column 0 the first
   Up press snaps the cursor to column 0 without changing history, and
   only the second press walks one entry back. The mirror rule holds
   for Down at the last row (snap to end of line, then advance). After
   navigateUp the buffer is parked at offset 0 (the "start of older
   entry" landing position); after navigateDown setText's default
   end-of-text positioning keeps the cursor at the end. The same
   two-step rule applies to single-line buffers so the
   reverse-direction case the issue called out works: pressing Ctrl+N
   immediately after Ctrl+P loaded a single-line older entry (cursor
   at col 0) first snaps the cursor to end-of-line, and only the next
   Ctrl+N moves forward through the history. Bare k/j inside the
   input prompt remain ordinary typed letters — the vim aliases are
   selection-list shortcuts, not text-editing ones.

2. Selection lists: arrows, k/j, and Ctrl+P/N are interchangeable

   A new pair of Command bindings, SELECTION_UP and SELECTION_DOWN, is
   wired into the shared useSelectionList hook and every dialog that
   used to hand-roll an "up/down arrow only" or "up/k arrow + vim
   only" navigation check. Covered surfaces: the main selection-list
   hook itself, the MCP / extensions / agents / hooks / background-
   tasks / rewind / plugin-choice / ask-user-question dialogs, the
   memory dialog (both its file list and the auto-memory and
   auto-cleanup toggle panel above the list), the settings dialog
   list (with the in-place value editor's "block other keys while
   editing" guard preserved), and the manage-models dialog's top
   tabs row. The auth-provider wizard's Advanced Config focus rows
   and the resume-session picker's cross-mode arrows are extended
   with the readline Ctrl+P / Ctrl+N synonyms while keeping their
   existing arrow-key and (for the session picker) vim k/j semantics
   intact.

3. Selection surfaces that wrap an active text input

   AskUserQuestionDialog's "Other / type a custom answer" field,
   manage-models' search input, the resume-session picker's search
   field, and the auth-wizard's Context-window number input all
   coexist with the selection list on the same screen. In those
   surfaces typing k or j has to land in the text buffer, not scroll
   the surrounding list. The fix is to scope the input-aware handler
   to unambiguous non-letter shortcuts only — arrow keys plus
   readline-style Ctrl+P / Ctrl+N escape the text field, while bare
   letters (including k / j / p / n) are delivered to the active
   input. The keyBinding-level fix that backs this is the
   `{ key: 'k', ctrl: false }` / `{ key: 'j', ctrl: false }` clauses
   on SELECTION_UP / SELECTION_DOWN, which prevent Ctrl+K from
   accidentally matching SELECTION_UP and thereby firing both the
   list-up handler and the KILL_LINE_RIGHT handler in the same
   keystroke (the P0 finding the quality-gate review surfaced).
   Focus-traversal tokens (the agent tab bar and the background-task
   pill) and chord shortcuts (Ctrl+Shift+Up/Down for embedded-shell
   history) are deliberately left untouched because their existing
   "any printable letter yields focus back to the composer" UX would
   break under the new vim-style letter bindings, and the Help
   viewer's scroll is a viewer rather than a selection list and is
   out of this PR's scope.

Documentation: docs/users/reference/keyboard-shortcuts.md is updated
so the Ctrl+P / Ctrl+N entries describe the two-step edge rule and
the radio-button-select table mentions the new k/j and Ctrl+P/N
aliases. Per-dialog on-screen hints (which still read "↑↓ to
navigate") are intentionally not touched so the i18n string surface
stays unchanged; the global reference doc is the authoritative source
for the new shortcuts.

Tests:
 - packages/cli/src/ui/keyMatchers.test.ts adds positive cases
   covering ↑ / ↓ / bare k / bare j / Ctrl+P / Ctrl+N matching
   SELECTION_UP / SELECTION_DOWN and negative cases asserting that
   Ctrl+K and Ctrl+J do NOT match (the conflict guard).
 - packages/cli/src/ui/components/InputPrompt.test.tsx adds a
   "two-step edge transition for history navigation" describe block
   with four cases: a mid-line Ctrl+P snaps to col 0 without invoking
   navigateUp; an at-col-0 Ctrl+P does invoke navigateUp and then
   parks the cursor via moveToOffset(0); a not-at-end Ctrl+N snaps to
   end-of-line without invoking navigateDown; and arrow Up obeys the
   same rule as Ctrl+P for keyboard-parity. The test file's mock
   buffer's setText was also corrected to mirror the real buffer's
   "cursor lands at the end of the new text" semantic so the cursor
   field is internally consistent during keypress assertions; the
   small InputPrompt render-frame snapshot in the same file's
   __snapshots__/ directory was regenerated to reflect the now-
   accurate cursor render position. Three pre-existing arrow-key
   navigation tests were updated to pre-position the mock cursor at
   the relevant edge before pressing the arrow, because the new
   two-step rule means the first arrow press at a non-edge position
   is a cursor snap, not a history step. Multi-line cursor-between-
   rows movement is covered indirectly by the keyBinding-level
   matcher tests plus the end-to-end manual demo plan.

The work landed in three rounds against the planner's gate: round 1
added the unified SELECTION_UP / SELECTION_DOWN Command binding and
the cursor-first dispatch in the input prompt; round 2 picked up the
quality-gate review's P0 (the Ctrl+K double-fire in the "Other"
custom-input field) and the user's hand-test feedback on the missing
two-step edge in the reverse direction plus the MemoryDialog
top-panel sections that weren't wired through SELECTION_*; round 3
swept the remaining adjacent dialogs (SettingsDialog list,
ManageModelsDialog tabs and search transitions, ProviderSetupSteps
advancedConfig, useSessionPicker's cross-mode arrows) so the
keyboard model is uniform across the TUI.

The original issue also asks for Meta+B / Meta+F word motion and
smarter Ctrl+H token-aware backspace among other readline
conveniences. The user explicitly scoped this PR down to Ctrl+P /
Ctrl+N at the planner approval gate; the remaining wish-list items
are deferred to follow-up issues.

Closes #3821
This commit is contained in:
维斌 2026-05-12 15:58:32 +08:00
parent 826f9fd126
commit f66427bea4
21 changed files with 331 additions and 85 deletions

View file

@ -19,35 +19,35 @@ This document lists the available keyboard shortcuts in Qwen Code.
## Input Prompt
| Shortcut | Description |
| -------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| `!` | Toggle shell mode when the input is empty. |
| `?` | Toggle keyboard shortcuts display when the input is empty. |
| `\` (at end of line) + `Enter` | Insert a newline. |
| `Down Arrow` | Navigate down through the input history. |
| `Enter` | Submit the current prompt. |
| `Meta+Delete` / `Ctrl+Delete` | Delete the word to the right of the cursor. |
| `Tab` | Autocomplete the current suggestion if one exists. |
| `Up Arrow` | Navigate up through the input history. |
| `Ctrl+A` / `Home` | Move the cursor to the beginning of the line. |
| `Ctrl+B` / `Left Arrow` | Move the cursor one character to the left. |
| `Ctrl+C` | Clear the input prompt |
| `Esc` (double press) | Clear the input prompt. |
| `Ctrl+D` / `Delete` | Delete the character to the right of the cursor. |
| `Ctrl+E` / `End` | Move the cursor to the end of the line. |
| `Ctrl+F` / `Right Arrow` | Move the cursor one character to the right. |
| `Ctrl+H` / `Backspace` | Delete the character to the left of the cursor. |
| `Ctrl+K` | Delete from the cursor to the end of the line. |
| `Ctrl+Left Arrow` / `Meta+Left Arrow` / `Meta+B` | Move the cursor one word to the left. |
| `Ctrl+N` | Navigate down through the input history. |
| `Ctrl+P` | Navigate up through the input history. |
| `Ctrl+R` | Reverse search through input/shell history. |
| `Ctrl+Y` | Retry the last failed request. |
| `Ctrl+Right Arrow` / `Meta+Right Arrow` / `Meta+F` | Move the cursor one word to the right. |
| `Ctrl+U` | Delete from the cursor to the beginning of the line. |
| `Ctrl+V` (Windows: `Alt+V`) | Paste clipboard content. If the clipboard contains an image, it will be saved and a reference to it will be inserted in the prompt. |
| `Ctrl+W` / `Meta+Backspace` / `Ctrl+Backspace` | Delete the word to the left of the cursor. |
| `Ctrl+X` / `Meta+Enter` | Open the current input in an external editor. |
| Shortcut | Description |
| -------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `!` | Toggle shell mode when the input is empty. |
| `?` | Toggle keyboard shortcuts display when the input is empty. |
| `\` (at end of line) + `Enter` | Insert a newline. |
| `Down Arrow` | Navigate down through the input history. |
| `Enter` | Submit the current prompt. |
| `Meta+Delete` / `Ctrl+Delete` | Delete the word to the right of the cursor. |
| `Tab` | Autocomplete the current suggestion if one exists. |
| `Up Arrow` | Navigate up through the input history. |
| `Ctrl+A` / `Home` | Move the cursor to the beginning of the line. |
| `Ctrl+B` / `Left Arrow` | Move the cursor one character to the left. |
| `Ctrl+C` | Clear the input prompt |
| `Esc` (double press) | Clear the input prompt. |
| `Ctrl+D` / `Delete` | Delete the character to the right of the cursor. |
| `Ctrl+E` / `End` | Move the cursor to the end of the line. |
| `Ctrl+F` / `Right Arrow` | Move the cursor one character to the right. |
| `Ctrl+H` / `Backspace` | Delete the character to the left of the cursor. |
| `Ctrl+K` | Delete from the cursor to the end of the line. |
| `Ctrl+Left Arrow` / `Meta+Left Arrow` / `Meta+B` | Move the cursor one word to the left. |
| `Ctrl+N` | In single-line input: navigate down through the input history. In multi-line input: move the cursor down one line; fall back to history navigation when already on the last line. |
| `Ctrl+P` | In single-line input: navigate up through the input history. In multi-line input: move the cursor up one line; fall back to history navigation when already on the first line. |
| `Ctrl+R` | Reverse search through input/shell history. |
| `Ctrl+Y` | Retry the last failed request. |
| `Ctrl+Right Arrow` / `Meta+Right Arrow` / `Meta+F` | Move the cursor one word to the right. |
| `Ctrl+U` | Delete from the cursor to the beginning of the line. |
| `Ctrl+V` (Windows: `Alt+V`) | Paste clipboard content. If the clipboard contains an image, it will be saved and a reference to it will be inserted in the prompt. |
| `Ctrl+W` / `Meta+Backspace` / `Ctrl+Backspace` | Delete the word to the left of the cursor. |
| `Ctrl+X` / `Meta+Enter` | Open the current input in an external editor. |
## Suggestions
@ -59,13 +59,13 @@ This document lists the available keyboard shortcuts in Qwen Code.
## Radio Button Select
| Shortcut | Description |
| ------------------ | ------------------------------------------------------------------------------------------------------------- |
| `Down Arrow` / `j` | Move selection down. |
| `Enter` | Confirm selection. |
| `Up Arrow` / `k` | Move selection up. |
| `1-9` | Select an item by its number. |
| (multi-digit) | For items with numbers greater than 9, press the digits in quick succession to select the corresponding item. |
| Shortcut | Description |
| ----------------------------- | ------------------------------------------------------------------------------------------------------------- |
| `Down Arrow` / `j` / `Ctrl+N` | Move selection down. |
| `Enter` | Confirm selection. |
| `Up Arrow` / `k` / `Ctrl+P` | Move selection up. |
| `1-9` | Select an item by its number. |
| (multi-digit) | For items with numbers greater than 9, press the digits in quick succession to select the corresponding item. |
## IDE Integration

View file

@ -31,6 +31,10 @@ export enum Command {
NAVIGATION_UP = 'navigationUp',
NAVIGATION_DOWN = 'navigationDown',
// Selection list navigation (dialogs, menus)
SELECTION_UP = 'selectionUp',
SELECTION_DOWN = 'selectionDown',
// Auto-completion
ACCEPT_SUGGESTION = 'acceptSuggestion',
COMPLETION_UP = 'completionUp',
@ -131,10 +135,22 @@ export const defaultKeyBindings: KeyBindingConfig = {
[Command.NAVIGATION_UP]: [{ key: 'up' }],
[Command.NAVIGATION_DOWN]: [{ key: 'down' }],
// Selection list navigation — up/k/Ctrl+P move selection up; down/j/Ctrl+N move selection down
// ctrl: false on k/j ensures Ctrl+K (kill-line) and Ctrl+N (history-down) are not captured here
[Command.SELECTION_UP]: [
{ key: 'up' },
{ key: 'k', ctrl: false },
{ key: 'p', ctrl: true },
],
[Command.SELECTION_DOWN]: [
{ key: 'down' },
{ key: 'j', ctrl: false },
{ key: 'n', ctrl: true },
],
// Auto-completion
[Command.ACCEPT_SUGGESTION]: [{ key: 'tab' }, { key: 'return', ctrl: false }],
// Completion navigation uses only arrow keys
// Ctrl+P/N are reserved for history navigation (HISTORY_UP/DOWN)
[Command.COMPLETION_UP]: [{ key: 'up' }],
[Command.COMPLETION_DOWN]: [{ key: 'down' }],

View file

@ -396,11 +396,18 @@ export function ProviderSetupSteps({
useKeypress(
(key) => {
if (step === 'advancedConfig') {
if (key.name === 'up') {
// The context-window row has an embedded TextInput that's conditionally
// active. Restrict the focus-row navigation to unambiguous shortcuts —
// arrow keys and the readline-style Ctrl+P/Ctrl+N — so typing a letter
// into the context-window field never simultaneously moves the focus.
const isFocusUp = key.name === 'up' || (key.ctrl && key.name === 'p');
const isFocusDown =
key.name === 'down' || (key.ctrl && key.name === 'n');
if (isFocusUp) {
flow.moveAdvancedFocusUp();
return;
}
if (key.name === 'down') {
if (isFocusDown) {
flow.moveAdvancedFocusDown();
return;
}

View file

@ -144,6 +144,8 @@ describe('InputPrompt', () => {
mockBuffer.viewportVisualLines = [newText];
mockBuffer.allVisualLines = [newText];
mockBuffer.visualToLogicalMap = [[0, 0]];
// Mirror real buffer: setText positions cursor at end of last visual line
mockBuffer.visualCursor = [0, newText.length];
}),
replaceRangeByOffset: vi.fn(),
viewportVisualLines: [''],
@ -375,8 +377,12 @@ describe('InputPrompt', () => {
const { stdin, unmount } = renderWithProviders(<InputPrompt {...props} />);
await wait();
// Two-step edge: pre-position cursor at col 0 so Up directly triggers history
mockBuffer.visualCursor = [0, 0];
stdin.write('\u001B[A'); // Up arrow
await wait();
// Pre-position cursor at end so Down directly triggers history
mockBuffer.visualCursor = [0, 'some text'.length];
stdin.write('\u001B[B'); // Down arrow
await wait();
stdin.write('\r'); // Enter
@ -414,6 +420,8 @@ describe('InputPrompt', () => {
expect(mockCommandCompletion.navigateDown).not.toHaveBeenCalled();
// Ctrl+P should navigate history, not completion
// Two-step edge: pre-position cursor at col 0 so Ctrl+P directly triggers history
mockBuffer.visualCursor = [0, 0];
stdin.write('\u0010'); // Ctrl+P
await wait();
expect(mockCommandCompletion.navigateUp).toHaveBeenCalledTimes(1);
@ -1270,6 +1278,8 @@ describe('InputPrompt', () => {
const TestHarness = () => {
const buffer = useTextBuffer({
initialText: '/export md',
// Two-step edge: position cursor at end so Down directly triggers history
initialCursorOffset: '/export md'.length,
viewport: { width: 80, height: 20 },
isValidPath: () => false,
onChange: () => {},
@ -3663,6 +3673,96 @@ describe('InputPrompt', () => {
unmount();
});
});
// Two-step edge transition: Ctrl+P / Ctrl+N (and arrow ↑/↓) in a non-empty
// buffer first snap the cursor to col 0 (Up) or end-of-line (Down) when the
// cursor isn't already at that edge, and only on a *second* press do they
// walk the input history. This mirrors readline / Claude Code parity called
// out in issue #3821. Multi-line transition between visual rows is covered
// end-to-end via manual testing (see contributions/3821-readline-shortcuts/
// demo.md cases 2 & 3); the unit tests below exercise the single-visual-row
// edges where the mock buffer's view of the world is self-consistent.
describe('two-step edge transition for history navigation', () => {
it('Ctrl+P with cursor mid-line snaps to col 0 without touching history', async () => {
// setText('hello') puts the mock's visualCursor at the end of 'hello'.
// From that position pressing Ctrl+P should first move cursor to col 0;
// it must NOT navigate history on this press.
mockBuffer.setText('hello');
const { stdin, unmount } = renderWithProviders(
<InputPrompt {...props} />,
);
await wait();
stdin.write('\u0010'); // Ctrl+P
await wait();
expect(mockBuffer.move).toHaveBeenCalledWith('home');
expect(mockInputHistory.navigateUp).not.toHaveBeenCalled();
unmount();
});
it('Ctrl+N with cursor not at end-of-line snaps to end without touching history', async () => {
// Manually park the cursor at col 0 (as if a prior Ctrl+P just loaded a
// history entry, which lands cursor at offset 0). Pressing Ctrl+N now
// should first jump cursor to end-of-line, not navigate history.
mockBuffer.setText('hello');
mockBuffer.visualCursor = [0, 0]; // simulate "just loaded older history"
const { stdin, unmount } = renderWithProviders(
<InputPrompt {...props} />,
);
await wait();
stdin.write('\u000E'); // Ctrl+N
await wait();
expect(mockBuffer.move).toHaveBeenCalledWith('end');
expect(mockInputHistory.navigateDown).not.toHaveBeenCalled();
unmount();
});
it('Ctrl+P at col 0 walks history and parks the cursor at offset 0', async () => {
// navigateUp returns boolean true on a real history move. We model that
// here so the post-navigation moveToOffset(0) side-effect (the "cursor
// at start of the restored older entry" rule) is observable.
mockInputHistory.navigateUp.mockReturnValue(true);
mockBuffer.setText('current draft');
mockBuffer.visualCursor = [0, 0];
const { stdin, unmount } = renderWithProviders(
<InputPrompt {...props} />,
);
await wait();
stdin.write('\u0010'); // Ctrl+P
await wait();
expect(mockInputHistory.navigateUp).toHaveBeenCalled();
// Readline "previous-history" lands the cursor at the start of the
// restored entry.
expect(mockBuffer.moveToOffset).toHaveBeenCalledWith(0);
unmount();
});
it('arrow Up applies the same two-step rule as Ctrl+P (snap before navigate)', async () => {
// The arrow-key history path lives alongside Ctrl+P in InputPrompt.tsx
// and the two must stay in lock-step. This test pins the parity so a
// future refactor that diverges them will fail.
mockBuffer.setText('hello'); // cursor at end via patched setText mock
const { stdin, unmount } = renderWithProviders(
<InputPrompt {...props} />,
);
await wait();
stdin.write('\u001B[A'); // Up arrow
await wait();
expect(mockBuffer.move).toHaveBeenCalledWith('home');
expect(mockInputHistory.navigateUp).not.toHaveBeenCalled();
unmount();
});
});
});
function clean(str: string | undefined): string {
if (!str) return '';

View file

@ -968,10 +968,41 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
}
if (keyMatchers[Command.HISTORY_UP](key)) {
inputHistory.navigateUp();
// Two-step edge transition (matches Claude Code):
// 1. If not on first visual row → move cursor up one row
// 2. Else if cursor not at col 0 → snap to col 0 (no history change)
// 3. Else → navigate to older history; cursor lands at offset 0
const onFirstRow =
buffer.visualCursor[0] === 0 && buffer.visualScrollRow === 0;
if (!onFirstRow) {
buffer.move('up');
return true;
}
if (buffer.visualCursor[1] > 0) {
buffer.move('home');
return true;
}
if (inputHistory.navigateUp()) {
buffer.moveToOffset(0);
}
return true;
}
if (keyMatchers[Command.HISTORY_DOWN](key)) {
// Two-step edge transition (matches Claude Code):
// 1. If not on last visual row → move cursor down one row
// 2. Else if cursor not at end of line → snap to end (no history change)
// 3. Else → navigate to newer history; cursor lands at end (setText default)
const lastRowIdx = buffer.allVisualLines.length - 1;
const onLastRow = buffer.visualCursor[0] === lastRowIdx;
if (!onLastRow) {
buffer.move('down');
return true;
}
const lastRowLen = cpLen(buffer.allVisualLines[lastRowIdx] ?? '');
if (buffer.visualCursor[1] < lastRowLen) {
buffer.move('end');
return true;
}
inputHistory.navigateDown();
return true;
}
@ -981,7 +1012,14 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
(buffer.allVisualLines.length === 1 ||
(buffer.visualCursor[0] === 0 && buffer.visualScrollRow === 0))
) {
inputHistory.navigateUp();
// Two-step edge transition: snap cursor to col 0 before triggering history
if (buffer.visualCursor[1] > 0) {
buffer.move('home');
return true;
}
if (inputHistory.navigateUp()) {
buffer.moveToOffset(0);
}
return true;
}
if (
@ -989,6 +1027,13 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
(buffer.allVisualLines.length === 1 ||
buffer.visualCursor[0] === buffer.allVisualLines.length - 1)
) {
// Two-step edge transition: snap cursor to end of line before triggering history
const lastRowIdx = buffer.allVisualLines.length - 1;
const lastRowLen = cpLen(buffer.allVisualLines[lastRowIdx] ?? '');
if (buffer.visualCursor[1] < lastRowLen) {
buffer.move('end');
return true;
}
if (inputHistory.navigateDown()) {
return true;
}

View file

@ -13,6 +13,7 @@ import {
} from '@qwen-code/qwen-code-core';
import { useSettings } from '../contexts/SettingsContext.js';
import { useKeypress } from '../hooks/useKeypress.js';
import { keyMatchers, Command } from '../keyMatchers.js';
import { theme } from '../semantic-colors.js';
import { TextInput } from './shared/TextInput.js';
import type { LoadedSettings } from '../../config/settings.js';
@ -397,13 +398,20 @@ export function ManageModelsDialog({
);
return;
}
if (key.name === 'down') {
// tabs row has no active TextInput — accept ↓/j/Ctrl+N uniformly
if (keyMatchers[Command.SELECTION_DOWN](key)) {
setFocusMode('search');
}
return;
}
if (focusMode === 'search') {
// The search TextInput is active in this mode, so only accept
// unambiguous non-letter shortcuts (arrows + Ctrl+P/Ctrl+N) for
// transitions — bare k/j must reach the TextInput as typed characters.
const isSearchUp = key.name === 'up' || (key.ctrl && key.name === 'p');
const isSearchDown =
key.name === 'down' || (key.ctrl && key.name === 'n');
if (key.name === 'left') {
setFilterMode((current) => cycleFilter(current, 'left'));
return;
@ -412,22 +420,22 @@ export function ManageModelsDialog({
setFilterMode((current) => cycleFilter(current, 'right'));
return;
}
if (key.name === 'up') {
if (isSearchUp) {
setFocusMode('tabs');
return;
}
if (key.name === 'down' && filteredEntries.length > 0) {
if (isSearchDown && filteredEntries.length > 0) {
setFocusMode('list');
}
return;
}
if (focusMode === 'list') {
if (key.name === 'up') {
if (keyMatchers[Command.SELECTION_UP](key)) {
moveHighlight('up');
return;
}
if (key.name === 'down') {
if (keyMatchers[Command.SELECTION_DOWN](key)) {
moveHighlight('down');
return;
}

View file

@ -21,6 +21,7 @@ import { useSettings } from '../contexts/SettingsContext.js';
import { SettingScope } from '../../config/settings.js';
import { useLaunchEditor } from '../hooks/useLaunchEditor.js';
import { useKeypress } from '../hooks/useKeypress.js';
import { keyMatchers, Command } from '../keyMatchers.js';
import { theme } from '../semantic-colors.js';
import { formatRelativeTime } from '../utils/formatters.js';
import { t } from '../../i18n/index.js';
@ -278,7 +279,8 @@ export function MemoryDialog({ onClose }: MemoryDialogProps) {
}
if (focusedSection === 'autoMemory') {
if (key.name === 'down') {
// No "up" target above autoMemory; only handle down → autoDream.
if (keyMatchers[Command.SELECTION_DOWN](key)) {
setFocusedSection('autoDream');
return;
}
@ -290,11 +292,11 @@ export function MemoryDialog({ onClose }: MemoryDialogProps) {
}
if (focusedSection === 'autoDream') {
if (key.name === 'up') {
if (keyMatchers[Command.SELECTION_UP](key)) {
setFocusedSection('autoMemory');
return;
}
if (key.name === 'down') {
if (keyMatchers[Command.SELECTION_DOWN](key)) {
setFocusedSection('list');
setHighlightedIndex(0);
return;
@ -307,7 +309,7 @@ export function MemoryDialog({ onClose }: MemoryDialogProps) {
}
// focusedSection === 'list'
if (key.name === 'up') {
if (keyMatchers[Command.SELECTION_UP](key)) {
if (highlightedIndex === 0) {
setFocusedSection('autoDream');
} else {
@ -316,7 +318,7 @@ export function MemoryDialog({ onClose }: MemoryDialogProps) {
return;
}
if (key.name === 'down') {
if (keyMatchers[Command.SELECTION_DOWN](key)) {
setHighlightedIndex((current) => (current + 1) % items.length);
return;
}

View file

@ -9,6 +9,7 @@ import { useState, useCallback, useMemo } from 'react';
import { theme } from '../semantic-colors.js';
import { t } from '../../i18n/index.js';
import { useKeypress, type Key } from '../hooks/useKeypress.js';
import { keyMatchers, Command } from '../keyMatchers.js';
interface PluginChoice {
name: string;
@ -51,13 +52,13 @@ export const PluginChoicePrompt = (props: PluginChoicePromptProps) => {
}
// Navigate up
if (name === 'up' || sequence === 'k') {
if (keyMatchers[Command.SELECTION_UP](key)) {
setSelectedIndex((prev) => (prev > 0 ? prev - 1 : plugins.length - 1));
return;
}
// Navigate down
if (name === 'down' || sequence === 'j') {
if (keyMatchers[Command.SELECTION_DOWN](key)) {
setSelectedIndex((prev) => (prev < plugins.length - 1 ? prev + 1 : 0));
return;
}

View file

@ -10,6 +10,7 @@ import type { HistoryItem } from '../types.js';
import { theme } from '../semantic-colors.js';
import { useTerminalSize } from '../hooks/useTerminalSize.js';
import { useKeypress } from '../hooks/useKeypress.js';
import { keyMatchers, Command } from '../keyMatchers.js';
import { truncateText } from '../utils/sessionPickerUtils.js';
import { isRealUserTurn } from '../utils/historyMapping.js';
import { t } from '../../i18n/index.js';
@ -156,12 +157,12 @@ export function RewindSelector({
return;
}
if (name === 'up' || name === 'k') {
if (keyMatchers[Command.SELECTION_UP](key)) {
setSelectedIndex((prev) => Math.max(0, prev - 1));
return;
}
if (name === 'down' || name === 'j') {
if (keyMatchers[Command.SELECTION_DOWN](key)) {
setSelectedIndex((prev) => Math.min(userTurns.length - 1, prev + 1));
return;
}

View file

@ -33,6 +33,7 @@ import { useCompactMode } from '../contexts/CompactModeContext.js';
import { useUIActions } from '../contexts/UIActionsContext.js';
import { createDebugLogger, type Config } from '@qwen-code/qwen-code-core';
import { useKeypress } from '../hooks/useKeypress.js';
import { keyMatchers, Command } from '../keyMatchers.js';
import chalk from 'chalk';
import { cpSlice, cpLen, stripUnsafeCharacters } from '../utils/textUtils.js';
import {
@ -546,8 +547,8 @@ export function SettingsDialog({
// Block other keys while editing
return;
}
if (name === 'up' || name === 'k') {
// If editing, commit first
if (keyMatchers[Command.SELECTION_UP](key)) {
// ↑/k/Ctrl+P all move selection up. If editing, commit first.
if (editingKey) {
commitEdit(editingKey);
}
@ -562,8 +563,8 @@ export function SettingsDialog({
} else if (newIndex < scrollOffset) {
setScrollOffset(newIndex);
}
} else if (name === 'down' || name === 'j') {
// If editing, commit first
} else if (keyMatchers[Command.SELECTION_DOWN](key)) {
// ↓/j/Ctrl+N all move selection down. If editing, commit first.
if (editingKey) {
commitEdit(editingKey);
}

View file

@ -20,14 +20,14 @@ exports[`InputPrompt > command search (Ctrl+R when not in shell) > expands and c
exports[`InputPrompt > command search (Ctrl+R when not in shell) > renders match window and expanded view (snapshots) > command-search-collapsed-match 1`] = `
"────────────────────────────────────────────────────────────────────────────────
(r:) commit
(r:) commit
────────────────────────────────────────────────────────────────────────────────────────────────────
git commit -m "feat: add search" in src/app"
`;
exports[`InputPrompt > command search (Ctrl+R when not in shell) > renders match window and expanded view (snapshots) > command-search-expanded-match 1`] = `
"────────────────────────────────────────────────────────────────────────────────
(r:) commit
(r:) commit
────────────────────────────────────────────────────────────────────────────────────────────────────
git commit -m "feat: add search" in src/app"
`;

View file

@ -19,6 +19,7 @@ import {
useBackgroundTaskViewActions,
} from '../../contexts/BackgroundTaskViewContext.js';
import { useKeypress } from '../../hooks/useKeypress.js';
import { keyMatchers, Command } from '../../keyMatchers.js';
import { MaxSizedBox } from '../shared/MaxSizedBox.js';
import { theme } from '../../semantic-colors.js';
import { useConfig } from '../../contexts/ConfigContext.js';
@ -1053,12 +1054,12 @@ export const BackgroundTasksDialog: React.FC<BackgroundTasksDialogProps> = ({
if (!dialogOpen) return;
if (dialogMode === 'list') {
if (key.name === 'up') {
if (keyMatchers[Command.SELECTION_UP](key)) {
moveSelectionUp();
setPendingCancelEntryId(null);
return;
}
if (key.name === 'down') {
if (keyMatchers[Command.SELECTION_DOWN](key)) {
moveSelectionDown();
setPendingCancelEntryId(null);
return;

View file

@ -8,6 +8,7 @@ import { useState, useEffect, useMemo } from 'react';
import { Box, Text } from 'ink';
import { theme } from '../../../semantic-colors.js';
import { useKeypress } from '../../../hooks/useKeypress.js';
import { keyMatchers, Command } from '../../../keyMatchers.js';
import { type Extension } from '@qwen-code/qwen-code-core';
import { t } from '../../../../i18n/index.js';
import { ExtensionUpdateState } from '../../../state/extensions.js';
@ -55,11 +56,11 @@ export const ExtensionListStep = ({
// Keyboard navigation
useKeypress(
(key) => {
if (key.name === 'up' || key.name === 'k') {
if (keyMatchers[Command.SELECTION_UP](key)) {
setSelectedIndex((prev) =>
prev > 0 ? prev - 1 : extensions.length - 1,
);
} else if (key.name === 'down' || key.name === 'j') {
} else if (keyMatchers[Command.SELECTION_DOWN](key)) {
setSelectedIndex((prev) =>
prev < extensions.length - 1 ? prev + 1 : 0,
);

View file

@ -9,6 +9,7 @@ import { Box, Text } from 'ink';
import { theme } from '../../semantic-colors.js';
import { useTerminalSize } from '../../hooks/useTerminalSize.js';
import { useKeypress } from '../../hooks/useKeypress.js';
import { keyMatchers, Command } from '../../keyMatchers.js';
import { useConfig } from '../../contexts/ConfigContext.js';
import { loadSettings, SettingScope } from '../../../config/settings.js';
import {
@ -174,9 +175,9 @@ export function HooksManagementDialog({
break;
case HOOKS_MANAGEMENT_STEPS.HOOKS_LIST:
if (key.name === 'up') {
if (keyMatchers[Command.SELECTION_UP](key)) {
setListSelectedIndex((prev) => Math.max(0, prev - 1));
} else if (key.name === 'down') {
} else if (keyMatchers[Command.SELECTION_DOWN](key)) {
setListSelectedIndex((prev) =>
Math.min(hooks.length - 1, prev + 1),
);
@ -199,9 +200,9 @@ export function HooksManagementDialog({
if (key.name === 'escape') {
handleNavigateBack();
} else if (selectedHook && selectedHook.configs.length > 0) {
if (key.name === 'up') {
if (keyMatchers[Command.SELECTION_UP](key)) {
setDetailSelectedIndex((prev) => Math.max(0, prev - 1));
} else if (key.name === 'down') {
} else if (keyMatchers[Command.SELECTION_DOWN](key)) {
setDetailSelectedIndex((prev) =>
Math.min(selectedHook.configs.length - 1, prev + 1),
);

View file

@ -8,6 +8,7 @@ import { useState, useMemo } from 'react';
import { Box, Text } from 'ink';
import { theme } from '../../../semantic-colors.js';
import { useKeypress } from '../../../hooks/useKeypress.js';
import { keyMatchers, Command } from '../../../keyMatchers.js';
import { t } from '../../../../i18n/index.js';
import type { ServerListStepProps, MCPServerDisplayInfo } from '../types.js';
import {
@ -44,9 +45,9 @@ export const ServerListStep: React.FC<ServerListStepProps> = ({
useKeypress(
(key) => {
if (key.name === 'up') {
if (keyMatchers[Command.SELECTION_UP](key)) {
setSelectedIndex((prev) => Math.max(0, prev - 1));
} else if (key.name === 'down') {
} else if (keyMatchers[Command.SELECTION_DOWN](key)) {
setSelectedIndex((prev) => Math.min(flatServers.length - 1, prev + 1));
} else if (key.name === 'return') {
onSelect(selectedIndex);

View file

@ -8,6 +8,7 @@ import { useState, useMemo } from 'react';
import { Box, Text } from 'ink';
import { theme } from '../../../semantic-colors.js';
import { useKeypress } from '../../../hooks/useKeypress.js';
import { keyMatchers, Command } from '../../../keyMatchers.js';
import { t } from '../../../../i18n/index.js';
import type { ToolListStepProps, MCPToolDisplayInfo } from '../types.js';
import { VISIBLE_TOOLS_COUNT } from '../constants.js';
@ -52,9 +53,9 @@ export const ToolListStep: React.FC<ToolListStepProps> = ({
(key) => {
if (key.name === 'escape') {
onBack();
} else if (key.name === 'up') {
} else if (keyMatchers[Command.SELECTION_UP](key)) {
setSelectedIndex((prev) => Math.max(0, prev - 1));
} else if (key.name === 'down') {
} else if (keyMatchers[Command.SELECTION_DOWN](key)) {
setSelectedIndex((prev) => Math.min(tools.length - 1, prev + 1));
} else if (key.name === 'return') {
if (tools[selectedIndex]) {

View file

@ -14,6 +14,7 @@ import {
} from '@qwen-code/qwen-code-core';
import { theme } from '../../semantic-colors.js';
import { useKeypress } from '../../hooks/useKeypress.js';
import { keyMatchers, Command } from '../../keyMatchers.js';
import { TextInput } from '../shared/TextInput.js';
import { t } from '../../../i18n/index.js';
@ -150,13 +151,20 @@ export const AskUserQuestionDialog: React.FC<AskUserQuestionDialogProps> = ({
// Handle navigation and selection
useKeypress(
(key) => {
// When custom input is focused, still allow up/down navigation and escape
// When the custom-input TextInput is focused, we must NOT match bare
// letter keys (k/j) for option navigation — those characters are being
// typed into the input. Only honor unambiguous shortcuts: arrow keys
// and the readline-style Ctrl+P/Ctrl+N. TextInput itself doesn't bind
// those, so there's no double-fire.
if (isCustomInputSelected) {
if (key.name === 'up') {
const isOptionUp = key.name === 'up' || (key.ctrl && key.name === 'p');
const isOptionDown =
key.name === 'down' || (key.ctrl && key.name === 'n');
if (isOptionUp) {
setSelectedIndex(Math.max(0, selectedIndex - 1));
return;
}
if (key.name === 'down') {
if (isOptionDown) {
setSelectedIndex(Math.min(totalOptions - 1, selectedIndex + 1));
return;
}
@ -185,12 +193,12 @@ export const AskUserQuestionDialog: React.FC<AskUserQuestionDialogProps> = ({
return;
}
// Option navigation (up/down arrows)
if (key.name === 'up') {
// Option navigation (up/down arrows and Ctrl+P/N)
if (keyMatchers[Command.SELECTION_UP](key)) {
setSelectedIndex(Math.max(0, selectedIndex - 1));
return;
}
if (key.name === 'down') {
if (keyMatchers[Command.SELECTION_DOWN](key)) {
setSelectedIndex(Math.min(totalOptions - 1, selectedIndex + 1));
return;
}

View file

@ -8,6 +8,7 @@ import { useState, useEffect, useMemo } from 'react';
import { Box, Text } from 'ink';
import { theme } from '../../../semantic-colors.js';
import { useKeypress } from '../../../hooks/useKeypress.js';
import { keyMatchers, Command } from '../../../keyMatchers.js';
import { type SubagentConfig } from '@qwen-code/qwen-code-core';
import { t } from '../../../../i18n/index.js';
@ -76,7 +77,7 @@ export const AgentSelectionStep = ({
(key) => {
const { name } = key;
if (name === 'up' || name === 'k') {
if (keyMatchers[Command.SELECTION_UP](key)) {
setNavigation((prev) => {
if (prev.currentBlock === 'project') {
if (prev.projectIndex > 0) {
@ -194,7 +195,7 @@ export const AgentSelectionStep = ({
}
}
});
} else if (name === 'down' || name === 'j') {
} else if (keyMatchers[Command.SELECTION_DOWN](key)) {
setNavigation((prev) => {
if (prev.currentBlock === 'project') {
if (prev.projectIndex < projectAgents.length - 1) {

View file

@ -7,6 +7,7 @@
import { useReducer, useRef, useEffect } from 'react';
import { createDebugLogger } from '@qwen-code/qwen-code-core';
import { useKeypress } from './useKeypress.js';
import { keyMatchers, Command } from '../keyMatchers.js';
export interface SelectionListItem<T> {
key: string;
@ -324,12 +325,12 @@ export function useSelectionList<T>({
numberInputRef.current = '';
}
if (name === 'k' || name === 'up') {
if (keyMatchers[Command.SELECTION_UP](key)) {
dispatch({ type: 'MOVE_UP', payload: { items } });
return;
}
if (name === 'j' || name === 'down') {
if (keyMatchers[Command.SELECTION_DOWN](key)) {
dispatch({ type: 'MOVE_DOWN', payload: { items } });
return;
}

View file

@ -326,8 +326,17 @@ export function useSessionPicker({
return;
}
if (name === 'up' || name === 'down') {
const delta = name === 'up' ? -1 : +1;
// Arrow keys are mode-aware (cross between search and list).
// Hoist Ctrl+P/Ctrl+N alongside as their readline-style equivalents so
// they escape the search input the same way arrows do. Bare `k`/`j`
// remain list-only further below (they would otherwise seed the search
// query with the letter and the design intent — see the comment near
// the `name === 'k'` branch — is that vim-style keys do not cross
// modes).
const isNavUp = name === 'up' || (ctrl && name === 'p');
const isNavDown = name === 'down' || (ctrl && name === 'n');
if (isNavUp || isNavDown) {
const delta = isNavUp ? -1 : +1;
const inSearch = viewMode === 'search';
if (inSearch) {
if (filteredSessions.length === 0) return;

View file

@ -73,6 +73,15 @@ describe('keyMatchers', () => {
key.ctrl && key.name === 'f',
[Command.EXPAND_SUGGESTION]: (key: Key) => key.name === 'right',
[Command.COLLAPSE_SUGGESTION]: (key: Key) => key.name === 'left',
// Selection list navigation: up/k (ctrl=false)/Ctrl+P move up; down/j (ctrl=false)/Ctrl+N move down
[Command.SELECTION_UP]: (key: Key) =>
key.name === 'up' ||
(key.name === 'k' && !key.ctrl) ||
(key.ctrl && key.name === 'p'),
[Command.SELECTION_DOWN]: (key: Key) =>
key.name === 'down' ||
(key.name === 'j' && !key.ctrl) ||
(key.ctrl && key.name === 'n'),
};
// Test data for each command with positive and negative test cases
@ -268,6 +277,38 @@ describe('keyMatchers', () => {
negative: [createKey('o'), createKey('p', { ctrl: true })],
},
// Selection list navigation
{
command: Command.SELECTION_UP,
positive: [
createKey('up'),
createKey('k'),
createKey('p', { ctrl: true }),
],
negative: [
createKey('p'),
createKey('n', { ctrl: true }),
createKey('u'),
// ctrl: false on k — Ctrl+K must NOT match (would conflict with KILL_LINE_RIGHT)
createKey('k', { ctrl: true }),
],
},
{
command: Command.SELECTION_DOWN,
positive: [
createKey('down'),
createKey('j'),
createKey('n', { ctrl: true }),
],
negative: [
createKey('n'),
createKey('p', { ctrl: true }),
createKey('d'),
// ctrl: false on j — Ctrl+J must NOT match (preserves Ctrl+J = newline in some terminals)
createKey('j', { ctrl: true }),
],
},
// Shell commands
{
command: Command.REVERSE_SEARCH,