···11+---
22+"@clack/prompts": minor
33+"@clack/core": minor
44+---
55+66+Adds a new `updateSettings()` function to support new global keybindings.
77+88+`updateSettings()` accepts an `aliases` object that maps custom keys to an action (`up | down | left | right | space | enter | cancel`).
99+1010+```ts
1111+import { updateSettings } from "@clack/prompts";
1212+1313+// Support custom keybindings
1414+updateSettings({
1515+ aliases: {
1616+ w: "up",
1717+ a: "left",
1818+ s: "down",
1919+ d: "right",
2020+ },
2121+});
2222+```
2323+2424+> [!WARNING]
2525+> In order to enforce consistent, user-friendly defaults across the ecosystem, `updateSettings` does not support disabling Clack's default keybindings.
+14
.changeset/quiet-actors-wink.md
···11+---
22+"@clack/prompts": minor
33+"@clack/core": minor
44+---
55+66+Updates default keybindings to support Vim motion shortcuts and map the `escape` key to cancel (`ctrl+c`).
77+88+| alias | action |
99+|------- |-------- |
1010+| `k` | up |
1111+| `l` | right |
1212+| `j` | down |
1313+| `h` | left |
1414+| `esc` | cancel |
+5-2
packages/core/src/index.ts
···11+export type { ClackState as State } from './types';
22+export type { ClackSettings } from './utils/settings';
33+14export { default as ConfirmPrompt } from './prompts/confirm';
25export { default as GroupMultiSelectPrompt } from './prompts/group-multiselect';
36export { default as MultiSelectPrompt } from './prompts/multi-select';
···69export { default as SelectPrompt } from './prompts/select';
710export { default as SelectKeyPrompt } from './prompts/select-key';
811export { default as TextPrompt } from './prompts/text';
99-export type { ClackState as State } from './types';
1010-export { block, isCancel, setGlobalAliases } from './utils';
1212+export { block, isCancel } from './utils';
1313+export { updateSettings } from './utils/settings';
+11-8
packages/core/src/prompts/prompt.ts
···55import { cursor, erase } from 'sisteransi';
66import wrap from 'wrap-ansi';
7788-import { ALIASES, CANCEL_SYMBOL, KEYS, diffLines, hasAliasKey, setRawMode } from '../utils';
88+import { CANCEL_SYMBOL, diffLines, isActionKey, setRawMode, settings } from '../utils';
991010-import type { ClackEvents, ClackState, InferSetType } from '../types';
1010+import type { ClackEvents, ClackState } from '../types';
1111+import type { Action } from '../utils';
11121213export interface PromptOptions<Self extends Prompt> {
1314 render(this: Omit<Self, 'prompt'>): string | undefined;
···181182 if (this.state === 'error') {
182183 this.state = 'active';
183184 }
184184- if (key?.name && !this._track && ALIASES.has(key.name)) {
185185- this.emit('cursor', ALIASES.get(key.name));
186186- }
187187- if (key?.name && KEYS.has(key.name as InferSetType<typeof KEYS>)) {
188188- this.emit('cursor', key.name as InferSetType<typeof KEYS>);
185185+ if (key?.name) {
186186+ if (!this._track && settings.aliases.has(key.name)) {
187187+ this.emit('cursor', settings.aliases.get(key.name));
188188+ }
189189+ if (settings.actions.has(key.name as Action)) {
190190+ this.emit('cursor', key.name as Action);
191191+ }
189192 }
190193 if (char && (char.toLowerCase() === 'y' || char.toLowerCase() === 'n')) {
191194 this.emit('confirm', char.toLowerCase() === 'y');
···214217 }
215218 }
216219217217- if (hasAliasKey([char, key?.name, key?.sequence], 'cancel')) {
220220+ if (isActionKey([char, key?.name, key?.sequence], 'cancel')) {
218221 this.state = 'cancel';
219222 }
220223 if (this.state === 'submit' || this.state === 'cancel') {
+2-4
packages/core/src/types.ts
···11-import type { KEYS } from './utils';
22-33-export type InferSetType<T> = T extends Set<infer U> ? U : never;
11+import type { Action } from "./utils/settings";
4253/**
64 * The state of the prompt
···1614 cancel: (value?: any) => void;
1715 submit: (value?: any) => void;
1816 error: (value?: any) => void;
1919- cursor: (key?: InferSetType<typeof KEYS>) => void;
1717+ cursor: (key?: Action) => void;
2018 key: (key?: string) => void;
2119 value: (value?: string) => void;
2220 confirm: (value?: boolean) => void;
-49
packages/core/src/utils/aliases.ts
···11-import type { InferSetType } from '../types';
22-33-const DEFAULT_KEYS = ['up', 'down', 'left', 'right', 'space', 'enter', 'cancel'] as const;
44-export const KEYS = new Set(DEFAULT_KEYS);
55-66-export const ALIASES = new Map<string, InferSetType<typeof KEYS>>([
77- ['k', 'up'],
88- ['j', 'down'],
99- ['h', 'left'],
1010- ['l', 'right'],
1111- ['\x03', 'cancel'],
1212-]);
1313-1414-/**
1515- * Set custom global aliases for the default keys - This will not overwrite existing aliases just add new ones
1616- *
1717- * @param aliases - A map of aliases to keys
1818- * @default
1919- * new Map([['k', 'up'], ['j', 'down'], ['h', 'left'], ['l', 'right'], ['\x03', 'cancel'],])
2020- */
2121-export function setGlobalAliases(alias: Array<[string, InferSetType<typeof KEYS>]>) {
2222- for (const [newAlias, key] of alias) {
2323- if (!ALIASES.has(newAlias)) {
2424- ALIASES.set(newAlias, key);
2525- }
2626- }
2727-}
2828-2929-/**
3030- * Check if a key is an alias for a default key
3131- * @param key - The key to check for
3232- * @param type - The type of key to check for
3333- * @returns boolean
3434- */
3535-export function hasAliasKey(
3636- key: string | Array<string | undefined>,
3737- type: InferSetType<typeof KEYS>
3838-) {
3939- if (typeof key === 'string') {
4040- return ALIASES.has(key) && ALIASES.get(key) === type;
4141- }
4242-4343- return key
4444- .map((n) => {
4545- if (n !== undefined && ALIASES.has(n) && ALIASES.get(n) === type) return true;
4646- return false;
4747- })
4848- .includes(true);
4949-}
+3-3
packages/core/src/utils/index.ts
···33import * as readline from 'node:readline';
44import type { Readable } from 'node:stream';
55import { cursor } from 'sisteransi';
66-import { hasAliasKey } from './aliases';
66+import { isActionKey } from './settings';
7788-export * from './aliases';
98export * from './string';
99+export * from './settings';
10101111const isWindows = globalThis.process.platform.startsWith('win');
1212···39394040 const clear = (data: Buffer, { name, sequence }: Key) => {
4141 const str = String(data);
4242- if (hasAliasKey([str, name, sequence], 'cancel')) {
4242+ if (isActionKey([str, name, sequence], 'cancel')) {
4343 if (hideCursor) output.write(cursor.show);
4444 process.exit(0);
4545 return;
+73
packages/core/src/utils/settings.ts
···11+const actions = ['up', 'down', 'left', 'right', 'space', 'enter', 'cancel'] as const;
22+export type Action = (typeof actions)[number];
33+44+/** Global settings for Clack programs, stored in memory */
55+interface InternalClackSettings {
66+ actions: Set<Action>;
77+ aliases: Map<string, Action>;
88+}
99+1010+export const settings: InternalClackSettings = {
1111+ actions: new Set(actions),
1212+ aliases: new Map<string, Action>([
1313+ // vim support
1414+ ['k', 'up'],
1515+ ['j', 'down'],
1616+ ['h', 'left'],
1717+ ['l', 'right'],
1818+ ['\x03', 'cancel'],
1919+ // opinionated defaults!
2020+ ['escape', 'cancel'],
2121+ ]),
2222+};
2323+2424+export interface ClackSettings {
2525+ /**
2626+ * Set custom global aliases for the default actions.
2727+ * This will not overwrite existing aliases, it will only add new ones!
2828+ *
2929+ * @param aliases - An object that maps aliases to actions
3030+ * @default { k: 'up', j: 'down', h: 'left', l: 'right', '\x03': 'cancel', 'escape': 'cancel' }
3131+ */
3232+ aliases: Record<string, Action>;
3333+}
3434+3535+export function updateSettings(updates: ClackSettings) {
3636+ for (const _key in updates) {
3737+ const key = _key as keyof ClackSettings;
3838+ if (!Object.hasOwn(updates, key)) continue;
3939+ const value = updates[key];
4040+4141+ switch (key) {
4242+ case 'aliases': {
4343+ for (const alias in value) {
4444+ if (!Object.hasOwn(value, alias)) continue;
4545+ if (!settings.aliases.has(alias)) {
4646+ settings.aliases.set(alias, value[alias]);
4747+ }
4848+ }
4949+ break;
5050+ }
5151+ }
5252+ }
5353+}
5454+5555+/**
5656+ * Check if a key is an alias for a default action
5757+ * @param key - The raw key which might match to an action
5858+ * @param action - The action to match
5959+ * @returns boolean
6060+ */
6161+export function isActionKey(key: string | Array<string | undefined>, action: Action) {
6262+ if (typeof key === 'string') {
6363+ return settings.aliases.get(key) === action;
6464+ }
6565+6666+ for (const value of key) {
6767+ if (value === undefined) continue;
6868+ if (isActionKey(value, action)) {
6969+ return true;
7070+ }
7171+ }
7272+ return false;
7373+}
+2-1
packages/prompts/src/index.ts
···1414import color from 'picocolors';
1515import { cursor, erase } from 'sisteransi';
16161717-export { isCancel, setGlobalAliases } from '@clack/core';
1717+export { isCancel } from '@clack/core';
1818+export { updateSettings, type ClackSettings } from '@clack/core';
18191920const unicode = isUnicodeSupported();
2021const s = (c: string, fallback: string) => (unicode ? c : fallback);