diff --git a/.changeset/ctrl-n-ctrl-p-navigation.md b/.changeset/ctrl-n-ctrl-p-navigation.md new file mode 100644 index 00000000..5ae23ecb --- /dev/null +++ b/.changeset/ctrl-n-ctrl-p-navigation.md @@ -0,0 +1,5 @@ +--- +'@clack/core': minor +--- + +Add emacs-style `ctrl+n` and `ctrl+p` keybindings for `down`/`up` navigation. They work as default aliases in every cursor-driven prompt (`select`, `multi-select`, `group-multiselect`, `date`, `confirm`), navigate the option list in `autocomplete`, and move the cursor between lines in `multi-line` (where the raw control bytes were previously inserted into the text). diff --git a/packages/core/src/prompts/autocomplete.ts b/packages/core/src/prompts/autocomplete.ts index e023462b..e76aa593 100644 --- a/packages/core/src/prompts/autocomplete.ts +++ b/packages/core/src/prompts/autocomplete.ts @@ -1,6 +1,7 @@ import type { Key } from 'node:readline'; import { styleText } from 'node:util'; import { findCursor } from '../utils/cursor.js'; +import { getActionForControlKey } from '../utils/index.js'; import Prompt, { type PromptOptions } from './prompt.js'; interface OptionLike { @@ -148,9 +149,10 @@ export default class AutocompletePrompt extends Prompt< ); } - #onKey(_char: string | undefined, key: Key): void { - const isUpKey = key.name === 'up'; - const isDownKey = key.name === 'down'; + #onKey(char: string | undefined, key: Key): void { + const aliasAction = getActionForControlKey(char, key); + const isUpKey = key.name === 'up' || aliasAction === 'up'; + const isDownKey = key.name === 'down' || aliasAction === 'down'; const isReturnKey = key.name === 'return'; // Tab with empty input and placeholder: fill input with placeholder to trigger autocomplete diff --git a/packages/core/src/prompts/multi-line.ts b/packages/core/src/prompts/multi-line.ts index dcec90af..63820749 100644 --- a/packages/core/src/prompts/multi-line.ts +++ b/packages/core/src/prompts/multi-line.ts @@ -1,6 +1,7 @@ import type { Key } from 'node:readline'; import { styleText } from 'node:util'; import { findTextCursor } from '../utils/cursor.js'; +import { getActionForControlKey } from '../utils/index.js'; import Prompt, { type PromptOptions } from './prompt.js'; type CursorAction = 'up' | 'down' | 'left' | 'right'; @@ -95,6 +96,12 @@ export default class MultiLinePrompt extends Prompt { this.#handleCursor(key.name as CursorAction); return; } + // resolve aliased control chords before they reach text insertion below + const aliasAction = getActionForControlKey(char, key); + if (aliasAction !== undefined && cursorActions.has(aliasAction as CursorAction)) { + this.#handleCursor(aliasAction as CursorAction); + return; + } if (char === '\t' && this.#showSubmit) { this.focused = this.focused === 'editor' ? 'submit' : 'editor'; return; diff --git a/packages/core/src/prompts/prompt.ts b/packages/core/src/prompts/prompt.ts index 2291544b..08aeb611 100644 --- a/packages/core/src/prompts/prompt.ts +++ b/packages/core/src/prompts/prompt.ts @@ -8,6 +8,7 @@ import type { Action } from '../utils/index.js'; import { CANCEL_SYMBOL, diffLines, + getActionForKey, getRows, isActionKey, setRawMode, @@ -222,8 +223,11 @@ export default class Prompt { this.state = 'active'; } if (key?.name) { - if (!this._track && settings.aliases.has(key.name)) { - this.emit('cursor', settings.aliases.get(key.name)); + if (!this._track) { + const action = getActionForKey([char, key.name, key.sequence]); + if (action !== undefined) { + this.emit('cursor', action); + } } if (settings.actions.has(key.name as Action)) { this.emit('cursor', key.name as Action); diff --git a/packages/core/src/utils/settings.ts b/packages/core/src/utils/settings.ts index b8b3222d..8f759777 100644 --- a/packages/core/src/utils/settings.ts +++ b/packages/core/src/utils/settings.ts @@ -1,3 +1,5 @@ +import type { Key } from 'node:readline'; + const actions = ['up', 'down', 'left', 'right', 'space', 'enter', 'cancel'] as const; export type Action = (typeof actions)[number]; @@ -45,8 +47,11 @@ export const settings: InternalClackSettings = { ['j', 'down'], ['h', 'left'], ['l', 'right'], - ['\x03', 'cancel'], + // emacs support + ['\x10', 'up'], // ctrl+p + ['\x0e', 'down'], // ctrl+n // opinionated defaults! + ['\x03', 'cancel'], // ctrl+c ['escape', 'cancel'], ]), messages: { @@ -72,7 +77,7 @@ export interface ClackSettings { * This will not overwrite existing aliases, it will only add new ones! * * @param aliases - An object that maps aliases to actions - * @default { k: 'up', j: 'down', h: 'left', l: 'right', '\x03': 'cancel', 'escape': 'cancel' } + * @default { k: 'up', j: 'down', h: 'left', l: 'right', '\x10': 'up', '\x0e': 'down', '\x03': 'cancel', 'escape': 'cancel' } */ aliases?: Record; @@ -170,6 +175,37 @@ export function updateSettings(updates: ClackSettings) { } } +/** + * Get the action aliased by a control-key chord (e.g. ctrl+n -> 'down'). + * Control chords arrive as raw bytes in `char`/`key.sequence`, so they can alias + * actions without clashing with typed input the way plain-letter aliases would. + * @param char - The raw character emitted alongside the keypress + * @param key - The parsed key + * @returns the aliased action, or undefined when the key is not an aliased control chord + */ +export function getActionForControlKey(char: string | undefined, key: Key): Action | undefined { + if (!key.ctrl) { + return undefined; + } + return getActionForKey([char, key.sequence]); +} + +/** + * Get the action aliased by a key, checking every representation of the key + * @param key - The raw key representations which might match to an action + * @returns the aliased action, or undefined when none matches + */ +export function getActionForKey(key: Array): Action | undefined { + for (const value of key) { + if (value === undefined) continue; + const action = settings.aliases.get(value); + if (action !== undefined) { + return action; + } + } + return undefined; +} + /** * Check if a key is an alias for a default action * @param key - The raw key which might match to an action diff --git a/packages/core/test/prompts/autocomplete.test.ts b/packages/core/test/prompts/autocomplete.test.ts index f1d98e24..621d1dbb 100644 --- a/packages/core/test/prompts/autocomplete.test.ts +++ b/packages/core/test/prompts/autocomplete.test.ts @@ -76,6 +76,28 @@ describe('AutocompletePrompt', () => { expect(instance.cursor).to.equal(0); }); + test('ctrl+n/ctrl+p navigate options', () => { + const instance = new AutocompletePrompt({ + input, + output, + render: () => 'foo', + options: testOptions, + }); + + instance.prompt(); + + expect(instance.cursor).to.equal(0); + + input.emit('keypress', '\x0e', { name: 'n', ctrl: true, sequence: '\x0e' }); + + expect(instance.cursor).to.equal(1); + + input.emit('keypress', '\x10', { name: 'p', ctrl: true, sequence: '\x10' }); + + expect(instance.cursor).to.equal(0); + expect(instance.userInput).to.equal(''); + }); + test('initialValue selects correct option', () => { const instance = new AutocompletePrompt({ input, diff --git a/packages/core/test/prompts/multi-line.test.ts b/packages/core/test/prompts/multi-line.test.ts index 2b92f804..5a7455da 100644 --- a/packages/core/test/prompts/multi-line.test.ts +++ b/packages/core/test/prompts/multi-line.test.ts @@ -115,6 +115,33 @@ describe('MultiLinePrompt', () => { }); describe('key', () => { + test('ctrl+p/ctrl+n move cursor between lines without inserting text', () => { + const instance = new MultiLinePrompt({ + input, + output, + render: () => 'foo', + }); + instance.prompt(); + for (const key of ['a', 'b']) { + input.emit('keypress', key, { name: key }); + } + input.emit('keypress', '', { name: 'return' }); + for (const key of ['c', 'd']) { + input.emit('keypress', key, { name: key }); + } + expect(instance.userInput).to.equal('ab\ncd'); + expect(instance.cursor).to.equal(5); + + input.emit('keypress', '\x10', { name: 'p', ctrl: true, sequence: '\x10' }); + + expect(instance.cursor).to.equal(2); + + input.emit('keypress', '\x0e', { name: 'n', ctrl: true, sequence: '\x0e' }); + + expect(instance.cursor).to.equal(5); + expect(instance.userInput).to.equal('ab\ncd'); + }); + test('return inserts newline', () => { const instance = new MultiLinePrompt({ input, diff --git a/packages/core/test/prompts/prompt.test.ts b/packages/core/test/prompts/prompt.test.ts index 89c1e37f..82893d9f 100644 --- a/packages/core/test/prompts/prompt.test.ts +++ b/packages/core/test/prompts/prompt.test.ts @@ -200,6 +200,52 @@ describe('Prompt', () => { } }); + test('emits cursor events for ctrl+p/ctrl+n aliases when not tracking', () => { + const keys = [ + ['\x10', 'p', 'up'], + ['\x0e', 'n', 'down'], + ]; + const eventSpy = vi.fn(); + const instance = new Prompt( + { + input, + output, + render: () => 'foo', + }, + false + ); + + instance.on('cursor', eventSpy); + + instance.prompt(); + + for (const [sequence, name, key] of keys) { + input.emit('keypress', sequence, { name, ctrl: true, sequence }); + expect(eventSpy).toBeCalledWith(key); + } + }); + + test('does not emit cursor events for plain n/p keys', () => { + const eventSpy = vi.fn(); + const instance = new Prompt( + { + input, + output, + render: () => 'foo', + }, + false + ); + + instance.on('cursor', eventSpy); + + instance.prompt(); + + input.emit('keypress', 'n', { name: 'n', sequence: 'n' }); + input.emit('keypress', 'p', { name: 'p', sequence: 'p' }); + + expect(eventSpy).not.toHaveBeenCalled(); + }); + test('aborts on abort signal', () => { const abortController = new AbortController(); diff --git a/packages/prompts/test/__snapshots__/select.test.ts.snap b/packages/prompts/test/__snapshots__/select.test.ts.snap index b213bff7..e902c762 100644 --- a/packages/prompts/test/__snapshots__/select.test.ts.snap +++ b/packages/prompts/test/__snapshots__/select.test.ts.snap @@ -154,6 +154,67 @@ exports[`select (isCI = false) > correctly limits options with explicit multilin ] `; +exports[`select (isCI = false) > ctrl+n selects next option 1`] = ` +[ + "", + "│ +◆ foo +│ ● opt0 +│ ○ opt1 +└ +", + "", + "", + "", + "│ ○ opt0 +│ ● opt1 +└ +", + "", + "", + "", + "◇ foo +│ opt1", + " +", + "", +] +`; + +exports[`select (isCI = false) > ctrl+p selects previous option 1`] = ` +[ + "", + "│ +◆ foo +│ ● opt0 +│ ○ opt1 +└ +", + "", + "", + "", + "│ ○ opt0 +│ ● opt1 +└ +", + "", + "", + "", + "│ ● opt0 +│ ○ opt1 +└ +", + "", + "", + "", + "◇ foo +│ opt0", + " +", + "", +] +`; + exports[`select (isCI = false) > down arrow selects next option 1`] = ` [ "", @@ -633,6 +694,67 @@ exports[`select (isCI = true) > correctly limits options with explicit multiline ] `; +exports[`select (isCI = true) > ctrl+n selects next option 1`] = ` +[ + "", + "│ +◆ foo +│ ● opt0 +│ ○ opt1 +└ +", + "", + "", + "", + "│ ○ opt0 +│ ● opt1 +└ +", + "", + "", + "", + "◇ foo +│ opt1", + " +", + "", +] +`; + +exports[`select (isCI = true) > ctrl+p selects previous option 1`] = ` +[ + "", + "│ +◆ foo +│ ● opt0 +│ ○ opt1 +└ +", + "", + "", + "", + "│ ○ opt0 +│ ● opt1 +└ +", + "", + "", + "", + "│ ● opt0 +│ ○ opt1 +└ +", + "", + "", + "", + "◇ foo +│ opt0", + " +", + "", +] +`; + exports[`select (isCI = true) > down arrow selects next option 1`] = ` [ "", diff --git a/packages/prompts/test/select.test.ts b/packages/prompts/test/select.test.ts index 447a0e1b..6f85d6e2 100644 --- a/packages/prompts/test/select.test.ts +++ b/packages/prompts/test/select.test.ts @@ -78,6 +78,41 @@ describe.each(['true', 'false'])('select (isCI = %s)', (isCI) => { expect(output.buffer).toMatchSnapshot(); }); + test('ctrl+n selects next option', async () => { + const result = prompts.select({ + message: 'foo', + options: [{ value: 'opt0' }, { value: 'opt1' }], + input, + output, + }); + + input.emit('keypress', '\x0e', { name: 'n', ctrl: true, sequence: '\x0e' }); + input.emit('keypress', '', { name: 'return' }); + + const value = await result; + + expect(value).toBe('opt1'); + expect(output.buffer).toMatchSnapshot(); + }); + + test('ctrl+p selects previous option', async () => { + const result = prompts.select({ + message: 'foo', + options: [{ value: 'opt0' }, { value: 'opt1' }], + input, + output, + }); + + input.emit('keypress', '', { name: 'down' }); + input.emit('keypress', '\x10', { name: 'p', ctrl: true, sequence: '\x10' }); + input.emit('keypress', '', { name: 'return' }); + + const value = await result; + + expect(value).toBe('opt0'); + expect(output.buffer).toMatchSnapshot(); + }); + test('can cancel', async () => { const result = prompts.select({ message: 'foo',