Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/ctrl-n-ctrl-p-navigation.md
Original file line number Diff line number Diff line change
@@ -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).
8 changes: 5 additions & 3 deletions packages/core/src/prompts/autocomplete.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -148,9 +149,10 @@ export default class AutocompletePrompt<T extends OptionLike> 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
Expand Down
7 changes: 7 additions & 0 deletions packages/core/src/prompts/multi-line.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -95,6 +96,12 @@ export default class MultiLinePrompt extends Prompt<string> {
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;
Expand Down
8 changes: 6 additions & 2 deletions packages/core/src/prompts/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type { Action } from '../utils/index.js';
import {
CANCEL_SYMBOL,
diffLines,
getActionForKey,
getRows,
isActionKey,
setRawMode,
Expand Down Expand Up @@ -222,8 +223,11 @@ export default class Prompt<TValue> {
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);
Expand Down
40 changes: 38 additions & 2 deletions packages/core/src/utils/settings.ts
Original file line number Diff line number Diff line change
@@ -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];

Expand Down Expand Up @@ -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: {
Expand All @@ -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<string, Action>;

Expand Down Expand Up @@ -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<string | undefined>): 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
Expand Down
22 changes: 22 additions & 0 deletions packages/core/test/prompts/autocomplete.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
27 changes: 27 additions & 0 deletions packages/core/test/prompts/multi-line.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
46 changes: 46 additions & 0 deletions packages/core/test/prompts/prompt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down
122 changes: 122 additions & 0 deletions packages/prompts/test/__snapshots__/select.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,67 @@ exports[`select (isCI = false) > correctly limits options with explicit multilin
]
`;

exports[`select (isCI = false) > ctrl+n selects next option 1`] = `
[
"<cursor.hide>",
"│
◆ foo
│ ● opt0
│ ○ opt1
└
",
"<cursor.backward count=999><cursor.up count=5>",
"<cursor.down count=2>",
"<erase.down>",
"│ ○ opt0
│ ● opt1
└
",
"<cursor.backward count=999><cursor.up count=5>",
"<cursor.down count=1>",
"<erase.down>",
"◇ foo
│ opt1",
"
",
"<cursor.show>",
]
`;

exports[`select (isCI = false) > ctrl+p selects previous option 1`] = `
[
"<cursor.hide>",
"│
◆ foo
│ ● opt0
│ ○ opt1
└
",
"<cursor.backward count=999><cursor.up count=5>",
"<cursor.down count=2>",
"<erase.down>",
"│ ○ opt0
│ ● opt1
└
",
"<cursor.backward count=999><cursor.up count=5>",
"<cursor.down count=2>",
"<erase.down>",
"│ ● opt0
│ ○ opt1
└
",
"<cursor.backward count=999><cursor.up count=5>",
"<cursor.down count=1>",
"<erase.down>",
"◇ foo
│ opt0",
"
",
"<cursor.show>",
]
`;

exports[`select (isCI = false) > down arrow selects next option 1`] = `
[
"<cursor.hide>",
Expand Down Expand Up @@ -633,6 +694,67 @@ exports[`select (isCI = true) > correctly limits options with explicit multiline
]
`;

exports[`select (isCI = true) > ctrl+n selects next option 1`] = `
[
"<cursor.hide>",
"│
◆ foo
│ ● opt0
│ ○ opt1
└
",
"<cursor.backward count=999><cursor.up count=5>",
"<cursor.down count=2>",
"<erase.down>",
"│ ○ opt0
│ ● opt1
└
",
"<cursor.backward count=999><cursor.up count=5>",
"<cursor.down count=1>",
"<erase.down>",
"◇ foo
│ opt1",
"
",
"<cursor.show>",
]
`;

exports[`select (isCI = true) > ctrl+p selects previous option 1`] = `
[
"<cursor.hide>",
"│
◆ foo
│ ● opt0
│ ○ opt1
└
",
"<cursor.backward count=999><cursor.up count=5>",
"<cursor.down count=2>",
"<erase.down>",
"│ ○ opt0
│ ● opt1
└
",
"<cursor.backward count=999><cursor.up count=5>",
"<cursor.down count=2>",
"<erase.down>",
"│ ● opt0
│ ○ opt1
└
",
"<cursor.backward count=999><cursor.up count=5>",
"<cursor.down count=1>",
"<erase.down>",
"◇ foo
│ opt0",
"
",
"<cursor.show>",
]
`;

exports[`select (isCI = true) > down arrow selects next option 1`] = `
[
"<cursor.hide>",
Expand Down
Loading
Loading