mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-05-17 03:57:18 +00:00
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:
parent
826f9fd126
commit
f66427bea4
21 changed files with 331 additions and 85 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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' }],
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 '';
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
`;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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]) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue