feat(cli): readline Ctrl+P/N for history and selection navigation#4082
Conversation
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
Code Coverage Summary
CLI Package - Full Text ReportCore Package - Full Text ReportFor detailed HTML reports, please see the 'coverage-reports-22.x-ubuntu-latest' artifact from the main CI run. |
yiliang114
left a comment
There was a problem hiding this comment.
Thanks for the detailed PR. I reviewed the keybinding/input-history changes and ran local checks. The implementation looks sound overall, and I did not find any blocking issues.
Two small non-blocking items I noticed:
docs/users/reference/keyboard-shortcuts.md: theCtrl+P/Ctrl+Nrows now describe multi-line behavior, but the single-line wording still says they navigate history directly. The implementation now applies the two-step edge transition in single-line input too: first snap to start/end when the cursor is not already there, then navigate history on the next press. Since Up/Down now share that same rule, those rows should probably be updated as well.packages/cli/src/config/keyBindings.ts: the comment aboveSELECTION_UP/SELECTION_DOWNsaysctrl: falsekeepsCtrl+Nfrom being captured, butCtrl+Nis intentionally captured as the selection-down alias. I think this meantCtrl+J.
Verification I ran locally:
git diff --check origin/main...origin/pr/4082cd packages/cli && npx vitest run src/ui/keyMatchers.test.ts src/config/keyBindings.test.ts src/ui/components/InputPrompt.test.tsxnpm run buildcd packages/cli && npx tsc --noEmit -p .
No request changes from me; these look like polish/docs follow-ups.
…n-list comment Both items came from a non-blocking COMMENTED review on PR #4082 (#4082 (review)), flagging two polish points in the readline Ctrl+P/Ctrl+N feature the parent commit `feat(cli): readline Ctrl+P/N for history and selection navigation` (f66427b) introduced. The `Up Arrow`, `Down Arrow`, `Ctrl+P`, and `Ctrl+N` rows of the Input Prompt table in `docs/users/reference/keyboard-shortcuts.md` are reworded to describe the three-phase keystroke sequence the implementation walks through — an intra-buffer visual-row step (a no-op in a single-line buffer, where there's exactly one visual row), a column-edge snap when the cursor reaches the buffer's first or last visual row with the cursor not already at column 0 (for the up-direction pair) or end-of-line (for the down-direction pair), and the readline-style previous-history or next-history walk on the press after the snap. The reviewer specifically pointed out that the prior wording described single-line input as "navigates the input history directly", which no longer matches the post-PR-#4082 behavior: single-line input also goes through the snap-then-walk two-press rule (the snap is a no-op when the cursor is already at the line's edge column, in which case the keystroke does the history walk on its first press). The new sentence covers the single-line and multi-line cases in one shape — single-line is the degenerate zero-row-walk-prefix instance of the same rule. The up-direction text is shared verbatim between the `Up Arrow` row (L31) and the `Ctrl+P` row (L43), and the down-direction text between the `Down Arrow` row (L27) and the `Ctrl+N` row (L42), so the keyboard- parity alias relationship is signaled by source-side text duplication rather than a prose cross-reference. The Input Prompt table's 234-byte canonical row width (the separator row's `| <50-dash> | <177-dash> |` template, which sets the column-1 and column-2 source-side widths the file's existing untouched rows already align to) is preserved by trailing-ASCII-space padding inside the description column. The comment above `[Command.SELECTION_UP]` and `[Command.SELECTION_DOWN]` in `packages/cli/src/config/keyBindings.ts` previously read // 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 The `Ctrl+N` half of the second line is wrong: `Ctrl+N` is intentionally matched here as the selection-down readline alias — the `{ key: 'n', ctrl: true }` entry in the `SELECTION_DOWN` array literal directly below the comment, mirroring the input-prompt-side `[Command.HISTORY_DOWN]: [{ key: 'n', ctrl: true }]` binding at L134 of the same file. The Ctrl-modified key the bare-letter `k` and `j` matchers actually guard against — the one already bound elsewhere whose double-match with the bare-letter selection-key the `ctrl: false` opt-out is preventing — is `Ctrl+J`, the ASCII line-feed (0x0A) encoding of the Enter family that appears as `{ key: 'j', ctrl: true }` inside the four-alternative `[Command.NEWLINE]` array a few lines below. The corrected one-liner is // Selection-list nav: arrows + k/j + Ctrl+P/Ctrl+N // ctrl: false on bare k/j skips Ctrl+K and Ctrl+J in the same terse no-trailing-period section-label style as the file's adjacent `// Screen control` (L129), `// History navigation` (L132), `// Auto-completion` (L213, post-edit numbering), and `// Text input` (L219) header comments. A 64-line block-comment that earlier in the review-fix cycle wrapped this same correct fact in dispatch-broadcast- model prose plus `keyMatchers.test.ts` backreferences was condensed to those two lines for cell-budget consistency with the rest of the file. No code behavior change. The local verification surface the reviewer named at the bottom of the review summary stays green: from `packages/cli`, npx vitest run \ src/ui/keyMatchers.test.ts \ src/config/keyBindings.test.ts \ src/ui/components/InputPrompt.test.tsx runs 178 cases with 177 passed and one unrelated skip (the implementation file `InputPrompt.tsx`'s feature flag for the keyboard- queue-input-editing case that was already skipped on the parent commit), including all four cases inside the `InputPrompt > two-step edge transition for history navigation` describe-block — `Ctrl+P with cursor mid-line snaps to col 0 without touching history`, `Ctrl+N with cursor not at end-of-line snaps to end without touching history`, `Ctrl+P at col 0 walks history and parks the cursor at offset 0`, and `arrow Up applies the same two-step rule as Ctrl+P (snap before navigate)`. Those four test-case names are the implementation-side anchors the new docs wording verbally mirrors. `npx tsc --noEmit -p .` in the same package directory reports zero diagnostics.
|
Thanks @yiliang114 — both points from the review are addressed in commit The Up Arrow / Down Arrow / Ctrl+P / Ctrl+N rows of the Input Prompt table in The |
wenshao
left a comment
There was a problem hiding this comment.
Review Summary
9 Critical findings — 6 tsc type errors (introduced by this PR, clean on base) + 3 test coverage gaps — must be fixed before merging.
Critical
-
6 tsc type errors in
InputPrompt.test.tsx—TS2741(midInputGhostText),TS2339(vimModeEnabled ×2, mockReturnValue ×2),TS2352(UIState cast). Base branch had zero errors. See inline comments for specific fixes. -
useSelectionList.test.ts— no Ctrl+P/N test coverage — thepressKeyhelper hardcodesctrl: false, so the PR's core selection-list feature is untested at the hook level. -
useSessionPicker.ts— no test file for Ctrl+P/N cross-mode navigation — complex state-machine logic (search/list boundary transitions) added with zero test fence. -
InputPrompt.test.tsx— missingNAVIGATION_DOWN(arrow ↓) two-step edge test — only arrow-Up is tested. The independent Down path (snap to end, navigateDown, fallthrough to AgentTabBar/BgPill) is untested.
Suggestions
- Duplicated snap-to-edge logic in
InputPrompt.tsxHISTORY/NAVIGATION handlers (4 places) - Tool confirmation arrow-key guard untested
- Focus chain:
AgentTabBarandBackgroundTasksPillonly recognize physical arrows, not Ctrl+P/N — user navigating with Ctrl+N from composer dead-ends at the tab bar
— DeepSeek/deepseek-v4-pro via Qwen Code /review
wenshao
left a comment
There was a problem hiding this comment.
No new issues found in incremental diff (ae07745..1b30a13). The reverse-search guard fix is correct and well-tested. All builds and tests pass (358 passed, 2 skipped). LGTM! ✅
Pre-existing note: tsc reports 4 type errors in InputPrompt.test.tsx (midInputGhostText, vimModeEnabled, UIState cast) — these predate this commit and are unchanged by it.
— DeepSeek/deepseek-v4-pro via Qwen Code /review
| if (!activeKeypressHandler) { | ||
| throw new Error('No active keypress handler'); | ||
| } | ||
| activeKeypressHandler(createKey(overrides)); |
There was a problem hiding this comment.
[Suggestion] pressKey calls activeKeypressHandler(createKey(overrides)) without wrapping in act(). All other new test files (RewindSelector, ServerListStep, ToolListStep, AgentSelectionStep) wrap the handler call in act() to ensure React state updates flush before assertions. While the current mock-based assertions work without it, the inconsistency creates a trap for future contributors who copy this pattern into tests that assert on Ink output.
| activeKeypressHandler(createKey(overrides)); | |
| act(() => { | |
| activeKeypressHandler(createKey(overrides)); | |
| }); |
— DeepSeek/deepseek-v4-pro via Qwen Code /review
| ); | ||
| }); | ||
|
|
||
| const pressKey = ( |
There was a problem hiding this comment.
[Suggestion] pressKey uses a 3-argument signature (name, sequence, overrides) while all other new test files use a single-argument (overrides: Partial<Key>) pattern. This prevents extracting a shared test helper and creates two conventions for the same operation.
| const pressKey = ( | |
| const pressKey = (overrides: Partial<Key>) => { | |
| if (!activeKeypressHandler) { | |
| throw new Error('No active keypress handler'); | |
| } | |
| activeKeypressHandler({ | |
| name: '', | |
| sequence: '', | |
| ctrl: false, | |
| meta: false, | |
| shift: false, | |
| paste: false, | |
| ...overrides, | |
| }); | |
| }; |
Call sites would change from pressKey('p', '\u0010', { ctrl: true }) to pressKey({ name: 'p', sequence: '\u0010', ctrl: true }).
— DeepSeek/deepseek-v4-pro via Qwen Code /review
| } else if (key.name === 'down' || (key.ctrl && key.name === 'n')) { | ||
| // Switch to main first — the footer pill only renders under the | ||
| // main view, so focusing it from an agent tab would strand focus | ||
| // on an offscreen surface. |
There was a problem hiding this comment.
[Suggestion] The if (hasBgAgents) branch on Ctrl+N/Down is only tested with hasBgAgents === true. The no-op path (when there are no background agents, hasBgAgents === false) is untested. If someone refactors this condition and accidentally removes it, Ctrl+N would try to focus a non-existent background pill. Add a test with useBackgroundTaskViewState mocked to return entries: [] and assert that switchToMain and setBgPillFocused are NOT called on Ctrl+N.
— DeepSeek/deepseek-v4-pro via Qwen Code /review
| expect(lastFrame()).toContain('qwen/qwen3-coder:free'); | ||
| }); | ||
|
|
||
| await pressDialogKey({ name: 'n', sequence: '\u000E', ctrl: true }); |
There was a problem hiding this comment.
[Suggestion] The test "keeps bare j in search mode" never verifies that Ctrl+N actually transitioned focus to search mode before pressing j. If Ctrl+N were broken and did nothing (focus stays in tabs mode), bare j would trigger SELECTION_DOWN which transitions tabs→search, and the negative assertion not.toContain('› [ ]') would still pass — a false positive. Add an intermediate assertion after the Ctrl+N press to confirm the dialog is in search mode (e.g., verify search placeholder text is visible).
— DeepSeek/deepseek-v4-pro via Qwen Code /review
| unmount(); | ||
| }); | ||
|
|
||
| it('uses selection shortcuts across tabs, search, and list', async () => { |
There was a problem hiding this comment.
[Suggestion] The "uses selection shortcuts" test covers tabs→search (j) and search→list (Ctrl+N) transitions, but does not verify the search→tabs return path via Ctrl+P. The production code has an isSearchUp branch (key.name === 'up' || (key.ctrl && key.name === 'p')) that calls setFocusMode('tabs') — this state-machine transition is untested. Add a test step: enter search mode, press Ctrl+P, and assert tabs mode is active.
— DeepSeek/deepseek-v4-pro via Qwen Code /review
本地真实场景测试报告(tmux 驱动真 TUI)Head: 驱动方式
input prompt 历史走查依次提交 LIFO 顺序正确 ✅ Two-step edge transition(PR 描述的关键 single-line 规则)C-p×3 后停在 完全符合 PR 描述:"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 press after that advances to the newer entry" ✅ selection list 导航(在 /settings 对话框中)
关键 conflict guard: Ctrl+K 不触发 SELECTION_UP在 "Language: Model" 行按 Ctrl+K: 裸 单测 & 构建
合计 282 pass + 2 skip。 LGTM ✅ |
Summary
/settings,/manage-models,/memory,/hooks,/mcp,/agents,/extensions, the rewind selector, the background-tasks dialog, the ask-user-question dialog's option list, the auth-provider advanced-config focus rows, and the resume-session picker's cross-mode arrows). A newCommand.SELECTION_UP/Command.SELECTION_DOWNpair is added topackages/cli/src/config/keyBindings.tsand the shareduseSelectionListhook is rewritten to consume it, so every dialog inherits the unified binding without hand-rolled checks; ~12 dialogs that previously matched arrow keys (or arrow + vim only) directly are converted in this PR.navigateUpthe cursor lands at offset 0 (readline's "previous-history" landing position); afternavigateDownthe standardsetTextend-positioning leaves the cursor at the end. This mirrors Claude Code exactly.Command.SELECTION_UPbinding for the bare-letter k vim alias is declared as{ key: 'k', ctrl: false }(not modifier-agnostic), so Ctrl+K continues to fireCommand.KILL_LINE_RIGHTonly and never additionally fires SELECTION_UP. Without this guard the broadcast keypress model inKeypressContextwould deliver Ctrl+K to both handlers in the same keystroke — for theAskUserQuestionDialog's "Other / type a custom answer" field that pre-PR-review-fix bug would have caused Ctrl+K to simultaneously kill the text and scroll the option list above. The symmetric{ key: 'j', ctrl: false }clause on SELECTION_DOWN does the same for Ctrl+J (which is the legacy ASCII line-feed and is the existing Command.NEWLINE alternative).AskUserQuestionDialog"Other" custom-answer field, the/manage-modelssearch box, the/resumesearch box, and the/authCustom-API-Key wizard's "Context window" number-entry on the Advanced Config step. Inside those input-active sub-states the dialog's outer key handler intentionally only recognises arrow keys and Ctrl+P / Ctrl+N as cross-section navigation, and bare letter aliases k/j fall through to the text input as typed characters. The session-picker's pre-existing vim k/j convention ("k/j are list-only, do not cross into the search box") is preserved, while the new Ctrl+P / Ctrl+N shortcuts inherit the arrow-key semantics in that picker (mode-aware), giving a clean triad: arrows = mode-aware, Ctrl+P/N = mode-aware (readline equivalent of arrows), k/j = list-only vim convention.Command.SELECTION_UP/SELECTION_DOWNbinding inkeyBindings.tswith thectrl: falseguard on the bare-letter aliases; (2) the InputPrompt two-step edge logic inInputPrompt.tsx(lines around theHISTORY_UP/HISTORY_DOWNandNAVIGATION_UP/NAVIGATION_DOWNmatchers, where the new "snap to home/end first, navigate history second" branching lives, including the post-navigateUpbuffer.moveToOffset(0)call that parks the cursor at the start of the restored older entry); (3) the text-input-adjacent dialogs (AskUserQuestionDialog,ManageModelsDialogsearch mode,ProviderSetupStepsadvanced-config) where the outer handler intentionally drops bare letter matches to avoid double-firing with the embedded TextInput; (4) thekeyMatchers.test.tsandInputPrompt.test.tsxtest additions which lock all of the above.Validation
Commands run (from the repo root):
Prompts / inputs used: N/A — this is a keyboard-input-handling change in the TUI layer, not a model-prompt change.
Expected result:
npm run buildsucceeds;tsc --noEmitis clean for the cli package (any pre-existing errors in the unrelatedtoml-to-markdownpackage are unchanged); the targeted vitest suite passes with all new tests green (177 passed, 1 pre-existing skip acrosskeyMatchers.test.ts,keyBindings.test.ts, andInputPrompt.test.tsx, including the four newtwo-step edge transition for history navigationcases and the newSELECTION_UP/SELECTION_DOWNmatcher cases with theCtrl+K does NOT match SELECTION_UPnegative guard);eslint --max-warnings 0is clean on all touched files.Observed result: All of the above pass on the contributor's local environment. The lint-staged pre-commit hook auto-applied prettier and eslint --fix on the staged files which were then re-staged into the commit; no remaining lint or format drift.
Quickest reviewer verification path:
cd packages/cli && npx vitest run src/ui/keyMatchers.test.ts src/ui/components/InputPrompt.test.tsxis the fastest check that all the new keybinding semantics and the two-step input-prompt rule are wired correctly. For an interactive check, the input-prompt behavior is visible from the momentqwenis started: in an empty input press Ctrl+P to walk history (cursor lands at the start of the restored entry); type a multi-line message via Shift+Enter, then with the cursor on the bottom row press Ctrl+P repeatedly — the cursor walks up between visual rows, then on the top row snaps to column 0, then the next Ctrl+P swaps in the previous history entry (cursor again at offset 0). For the selection-list side, opening/settingsand pressing Ctrl+P / Ctrl+N (alongside ↑ / ↓ and k / j) all move the highlighted setting.Evidence (output, logs, screenshots, video, JSON, before/after, etc.):
The three orthogonal behavior groups in this PR — the input-prompt two-step edge, the selection-list unified triad, and the text-input-coexistence asymmetry — each have a short reproducer below with a placeholder for an attached demo video. To attach a video, drag-and-drop the recorded
.mp4/.movfile onto the corresponding "Demo video" line in the GitHub PR-edit textarea; GitHub will replace the placeholder text with an embedded<video controls src="https://github.com/user-attachments/assets/…"></video>element. (The placeholders are kept as italicised text so an unfilled slot is visible at a glance.)Case A — Input prompt: two-step edge between line motion and history
In the main composer Ctrl+P / Ctrl+N and the arrow keys ↑ / ↓ apply the same rule:
inputHistory.navigateUp; the restored older text is loaded into the buffer with the cursor parked at offset 0 (the readline "previous-history" landing). The bottom-row mirror (Down / Ctrl+N first snaps to end-of-line, then on the next press walks forward throughnavigateDown, with the new text's cursor at end via the standardbuffer.setTextend-positioning) makes the round-trip self-consistent: after Ctrl+P loaded a single-line older entry, the very first Ctrl+N is "go to end-of-line", and only the second Ctrl+N advances to the newer entry.Reproducer: with at least three prior submitted messages in history, type a three-line draft via two Shift+Enter newlines, then press Ctrl+P repeatedly — the cursor crawls up through the visual rows of the draft, snaps to column 0 on the top row, and only then begins walking the history. The unit tests for this exact rule are in
packages/cli/src/ui/components/InputPrompt.test.tsxunder thetwo-step edge transition for history navigationdescribe.Demo video (Case A — input-prompt two-step edge):
case1.mp4
Case B — Selection lists: arrows, k/j, and Ctrl+P/N are interchangeable
Every selection list / radio-button menu / vertical-option panel in the TUI now treats the three modalities as exact synonyms for vertical navigation: arrow ↑/↓, vim-style bare k/j, and readline-style Ctrl+P/Ctrl+N. The unification is mediated by the new
Command.SELECTION_UPandCommand.SELECTION_DOWNbindings inpackages/cli/src/config/keyBindings.tsand the shareduseSelectionListhook; ~12 dialogs that hand-rolled their own arrow-key checks have been converted to the unifiedkeyMatchers[Command.SELECTION_*]matcher. The end-user-visible surfaces this affects (slash-command names where applicable): the model-name picker/model, the model-management dialog tabs row/manage-models(and the model-list pane beneath the search box once you drop into the list), the settings dialog/settings, the memory dialog/memory(both the file-pick list and the auto-memory / auto-cleanup toggle panel above it — the toggle panel is itself a two-row vertical-focus area and accepts the unified triad for moving between the two toggles), the hooks-management dialog/hooks, the mcp-server dialog's tool-list and server-list steps/mcp, the extensions list/extensions, the agents-management dialog's agent-selection step/agents, the rewind-snapshot selector, the plugin-choice prompt, the background-task dialog list mode, the ask-user-question multi-option dialog, the auth-provider wizard's Advanced Config focus rows (the "Enable thinking / Enable modality / Context window" panel reached via/auth→ Custom API Key), and the resume-session picker's cross-mode arrow rule/resume(arrow Up at the top of the list and arrow Down inside the search box jump between the search bar and the list — Ctrl+P / Ctrl+N now do the same, while the picker's existing vim k/j convention that k/j are "list-only and do not cross modes" is intentionally preserved).Reproducer: open
/settings, hold Ctrl, tappandnalternately — the highlighted setting moves up and down. Drop Ctrl and tapkandj— same movement. Drop the letters and tap the arrow keys — same movement. The behavior matches the three "Radio Button Select" rows indocs/users/reference/keyboard-shortcuts.md, which is updated in this PR. The data-driven matcher itself is locked by theSELECTION_UPandSELECTION_DOWNtest cases inpackages/cli/src/ui/keyMatchers.test.ts, including the explicit negative cases that Ctrl+K and Ctrl+J do not match the selection-up / -down bindings (thectrl: falseclauses on the bare-letter aliases).Demo video (Case B — selection-list unified triad):
case2.mp4
Case C — Selection surfaces wrapping an active text input
Four dialog surfaces in the TUI place a
<TextInput>inside the selection surface, so the user can be either "picking from the list" or "typing into the text field" at any given moment. The fix in this PR makes the outer dialog's keyboard handler stateful about which sub-mode is active: when the text input is the active focus, the outer handler accepts only unambiguous non-letter shortcuts — arrow keys ↑/↓ and the readline-style Ctrl+P / Ctrl+N — for escaping the text field back to the surrounding list or tab row, and bare letters (including bare k / j / p / n) fall through to the embedded TextInput as ordinary typed characters. The sameKeypressContextbroadcast that this PR'sctrl: falseguard on{ key: 'k' }and{ key: 'j' }neutralises for Ctrl+K / Ctrl+J would otherwise have caused the bare-letter aliases to fire on the inner TextInput and on the outer list-up handler simultaneously — the P0 finding the in-loop code review flagged on the very first round of the AskUserQuestionDialog "Other" custom-answer field, and the same shape applies to/manage-models's search box,/resume's search box, and the/authAdvanced Config step's Context-window number-entry. For the manage-models tabs row, which has no active TextInput in that focus mode, the full unified triad (arrows + k/j + Ctrl+P/N) is in effect; only the search and list focus modes — where a TextInput is currently active — narrow the matcher.The asymmetry between the resume-session picker's two letter conventions (vim k/j is list-only, readline Ctrl+P/N is mode-aware like arrows) is intentional: the existing source comment in
useSessionPicker.tsdocuments that the vim alias was historically a "stay-in-the-list" shortcut so that typingkwould not seed the search query with a stray letter, and we preserved that convention while adding the readline alias as a mode-aware peer of the arrows.Reproducer: in
/auth's Custom-API-Key wizard, walk through Protocol → Base URL → API Key → Model IDs to land on the Advanced Config step; tab down to the "Context window" row to activate its TextInput; type the digits4096— they appear in the field and the focus row does not jump. Now press Ctrl+P — the focus row jumps up to the "Enable modality" checkbox row (the TextInput releases focus). Press Ctrl+N — the focus row returns to the Context-window TextInput. The arrow keys and Ctrl+P/N behave identically here; bare letters are typed into the field. A parallel reproducer in/manage-models's search box and in theAskUserQuestionDialog's "Other" field (which is reached by selecting the last "Other" option in any ask-user-question prompt that exposes a free-form text fallback) shows the same rule.Demo video (Case C — text-input coexistence within a selection surface):
case3.mp4
Non-changes — surfaces deliberately left alone
Three surfaces in the TUI are intentionally not wired into the unified triad, with reasoning to head off "why isn't it consistent everywhere?" review questions:
Help.tsx— the help-output viewer's ↑/↓ scroll the visible lines and PageUp / PageDown scroll a page. It is a read-only viewer rather than a selection list (there is no "selected item" concept), so attaching the vim k/j and readline Ctrl+P / Ctrl+N to scrolling would be a separate UX decision and is out of this PR's scope. The keybinding layer would have to either reuseCommand.NAVIGATION_UP/Command.NAVIGATION_DOWN(which is wrong because those have history-walk semantics in the input prompt) or introduce a third pair (Command.SCROLL_UP/Command.SCROLL_DOWN) just for the help viewer — a future PR can do that without disturbing this one.BackgroundTasksPillandAgentTabBar— these are focus-traversal tokens rather than selection lists. The existing convention in both is "Down drills into the dialog underneath the pill / out from the agent tab bar to the background pill / into the model composer, and any printable letter yields focus back to the composer so the user can keep typing". Wiring bare k/j into "drill into" would break the "type-to-resume-typing" UX, since pressing the letterkwhile the agent tab bar has focus would no longer simply hand focus back to the input prompt with akqueued — it would instead drill out of the tab bar to the bg pill. The chained Down / Up traversal (Composer ↓ → AgentTabBar ↓ → BgPill ↓ → BgDialog and the symmetric Up to bubble back) stays bound to the bare arrow keys.ShellInputPrompt'sCtrl+Shift+Up/Ctrl+Shift+Down— these are chorded shortcuts for the embedded-shell mode's shell-history scrolling (separate from the input prompt's input-history walking). TheCtrl+Shiftchord-modifier rules them out of the unified-triad scope by construction: the new bindings declare actrl: falseon the bare letters and actrl: true(noshiftrequirement) on Ctrl+P / Ctrl+N, which doesn't intersect withCtrl+Shift+Up.packages/cli/src/ui/hooks/vim.ts— vim INSERT mode passes Up / Down / Tab / Enter / Ctrl+R through to the InputPrompt's completion-and-history layer, so the new Ctrl+P / Ctrl+N history-walk behaviour works under vim INSERT mode for free because vim doesn't claim those keys at the INSERT-mode layer. No vim-specific change is needed in this PR.Test coverage
Automated coverage (the file paths are relative to the repo root):
packages/cli/src/ui/keyMatchers.test.tsadds theSELECTION_UPandSELECTION_DOWNentries to theoriginalMatchersreference table (the data-driven test in this file checks that the newCommand-based matcher agrees with a hand-written reference for every command in the enum) and adds a positive-and-negative test-case row for each direction. The new positive cases cover arrow ↑/↓, bare k/j (with the implicitctrl: falseclause from the binding), and Ctrl+P / Ctrl+N. The new negative cases assert that Ctrl+K and Ctrl+J do not match the selection-up / -down matchers (the conflict guard forCommand.KILL_LINE_RIGHTandCommand.NEWLINE), and that the unrelated single letters (u,d,p,nwithout Ctrl) do not match either.packages/cli/src/ui/components/InputPrompt.test.tsxadds a newtwo-step edge transition for history navigationdescribe block with four cases that lock the input-prompt rule: (i) Ctrl+P with the cursor not at column 0 of a non-empty buffer callsbuffer.move('home')and does not callinputHistory.navigateUp; (ii) Ctrl+N with the cursor not at the end of the last line callsbuffer.move('end')and does not callinputHistory.navigateDown; (iii) Ctrl+P at column 0 of the last visible row of the buffer (the "edge after the snap") callsinputHistory.navigateUpand thenbuffer.moveToOffset(0)to park the cursor at the start of the restored older entry (the navigate-up return value is mocked totrueso the post-navigateif-branch is exercised); (iv) arrow ↑ obeys the same two-step rule as Ctrl+P. The mock buffer'ssetTextin the same test file was corrected to mirror the realtext-buffer's "after setText, cursor lands at the end of the new text" semantic so the new assertions see an internally consistentvisualCursorvalue (a small__snapshots__frame for the unrelated rendering test in the same file was regenerated for the same reason). Three pre-existing tests that fire Up / Down arrows in the test stdin and assertmockInputHistory.navigate{Up,Down}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 cursor is a buffer-move rather than a history step — without the pre-positioning the existing tests would assert a history call that no longer fires on the first press. The patches are minimal: amockBuffer.visualCursor = [0, 0]line before the Up arrow press and amockBuffer.visualCursor = [0, textLength]line before the Down arrow press, with comments cross-referencing the new two-step rule.Manual coverage: the contributor maintains an out-of-tree hand-test plan at
~/.claude/projects/-Users-menghuibin-qwen-code/contributions/3821-readline-shortcuts/demo.mdcovering twelve scenarios end-to-end (single-line and multi-line two-step edges in the composer, the selection-triad in/modeland/manage-modelsand/settingsand/memoryand/hooksand/mcpand/agents, the text-input-coexistence rules inAskUserQuestionDialog's "Other" field and in the/manage-modelsand/resumesearch boxes and the/authCustom-API-Key advanced-config Context-window field, the Ctrl+K kill-line regression, and the cursor-landing-position rule for history navigation). The full plan is internal contributor notes and is not shipped with the PR; the videos linked above are the user-facing reproduction of the same scenarios.Documentation
docs/users/reference/keyboard-shortcuts.mdis updated:Ctrl+PandCtrl+Nas additional aliases in the existing↑ / kand↓ / jrows respectively, so the docs now read "Down Arrow / j / Ctrl+N" and "Up Arrow / k / Ctrl+P". The per-dialog inline hint strings (which still read "↑↓ to navigate") are intentionally left untouched in this PR so the i18n message-bundle surface is unchanged; the central reference doc is the canonical single source of truth for the new shortcuts. A follow-up sweep of the inline hints could go in a separate PR if discoverability becomes a complaint.Scope / Risk
Command.SELECTION_UP/Command.SELECTION_DOWNCommands and the{ key: 'k' / 'j', ctrl: false }bindings are net-new entries in the configuration, and the per-dialog switches from hand-rolledkey.name === 'up'checks tokeyMatchers[Command.SELECTION_UP](key)widen the matched set (existing arrow-key paths still match; the additions are k/j and Ctrl+P/N). The one delicate piece is the InputPrompt's two-step edge rule, which changes existing behavior in the case where Ctrl+P was previously pressed at the first row but cursor not at column 0 of a multi-line buffer: pre-PR the keystroke walked the history; post-PR the same keystroke snaps the cursor to column 0 and only the next press walks. The change matches Claude Code and GNU-readline emacs-mode behaviour exactly and is intentionally aligned across the four equivalent ways to press "up" (arrow ↑, Ctrl+P, and — in lists — k and the vim-aware bindings); the unit tests pin this. The conflict-guardctrl: falseclause on the bare-letter SELECTION aliases is the second delicate piece — without it, Ctrl+K would simultaneously fire SELECTION_UP andCommand.KILL_LINE_RIGHTbecause theKeypressContextbroadcast model has no stop-propagation primitive (a structural property of the upstream keypress design that this PR does not change); the explicitctrl: falsein the binding sidesteps the issue by declining the match, which is the same pattern the existingCommand.ACCEPT_SUGGESTIONuses for{ key: 'return', ctrl: false }to avoid claiming Ctrl+Enter (which is theCommand.NEWLINEalternative).<TextInput>inside the selection surface. The conservative choice is to accept only the unambiguous non-letter modalities (arrow keys and Ctrl+P / Ctrl+N) for cross-section navigation in those sub-modes, on the principle that the user's intent when an input is active is "type" by default, so bare letters must reach the input buffer; the readline aliases are unambiguous because the project'sTextInputdoes not claim them. The pre-existing convention in the/resumepicker (vim k/j is list-only, never crosses into the search box, by source-level comment) is preserved on principle that established conventions are sticky.Keyobject thatink-keypressparses out of the terminal-input byte stream), so the platform matrix is a formality for the typical case; the one platform-specific binding in the file (Command.PASTE_CLIPBOARD_IMAGEdiffering between Windows'sMeta+Vand other platforms'Ctrl+VorCommand+V) is not touched by this PR.demo.mdhand-test plan but is not part of the automated test suite. The reproducers in the Evidence section above plus the linked Cases A/B/C in the demo videos cover the key behavior groups.Command.SELECTION_UP/Command.SELECTION_DOWNCommands are the recommended path for any new "vertical option list" dialog; the shareduseSelectionListhook picks them up automatically. The hand-rolledkey.name === 'up'patterns that this PR converts are now anti-patterns and should be migrated if any new dialog reaches in directly.Testing Matrix
Testing matrix notes:
npm run build,npm run lint(via the pre-commit lint-staged hook), and the targeted vitest suite all pass on macOS (Darwin 25.4, Node 22.17.1, npm bundled with that Node).Command-keyed matchers against theKeyobject that ink's keypress middleware produces from raw terminal byte sequences) and contains noprocess.platform-conditional logic that this PR introduces, so the platform-specific surface area is empty for the new code. The one pre-existing platform branch inkeyBindings.ts—Command.PASTE_CLIPBOARD_IMAGEhaving a Windows-specificMeta+Vversus aCtrl+V/Command+Velsewhere — is untouched by this PR. The terminal-byte-sequence layer thatink-keypressparses is also untouched. The Windows and Linux entries are markedLinked Issues / Bugs
This PR implements the Ctrl+P / Ctrl+N portion of the keyboard-shortcut wish-list in issue #3821 — Support macOS shortcuts. The issue lists a wider set of readline / Emacs / macOS conveniences (Meta+B / Meta+F word motion, Ctrl+H token-aware backspace, etc.) and the contributor and the issue's author explicitly scoped the present PR down to just Ctrl+P / Ctrl+N during the planning conversation captured in the per-issue design notes; the remaining wish-list entries are deferred to follow-up issues so each can be evaluated and reviewed independently. The issue is therefore linked but not auto-closed by this PR's merge: the maintainer can choose to close the issue manually once they consider the Ctrl+P / Ctrl+N piece sufficient resolution, or keep it open as a tracking issue for the remaining wish-list while spinning out per-shortcut sub-issues as separate PRs land.
Refs #3821
🤖 Generated with Qwen Code (initial template) — body content extended by the contributor.