diff --git a/.changeset/kind-llamas-beam.md b/.changeset/kind-llamas-beam.md new file mode 100644 index 00000000..7aad706b --- /dev/null +++ b/.changeset/kind-llamas-beam.md @@ -0,0 +1,25 @@ +--- +"@clack/prompts": minor +"@clack/core": minor +--- + +Adds a new `updateSettings()` function to support new global keybindings. + +`updateSettings()` accepts an `aliases` object that maps custom keys to an action (`up | down | left | right | space | enter | cancel`). + +```ts +import { updateSettings } from "@clack/prompts"; + +// Support custom keybindings +updateSettings({ + aliases: { + w: "up", + a: "left", + s: "down", + d: "right", + }, +}); +``` + +> [!WARNING] +> In order to enforce consistent, user-friendly defaults across the ecosystem, `updateSettings` does not support disabling Clack's default keybindings. diff --git a/.changeset/quiet-actors-wink.md b/.changeset/quiet-actors-wink.md new file mode 100644 index 00000000..05bfe243 --- /dev/null +++ b/.changeset/quiet-actors-wink.md @@ -0,0 +1,14 @@ +--- +"@clack/prompts": minor +"@clack/core": minor +--- + +Updates default keybindings to support Vim motion shortcuts and map the `escape` key to cancel (`ctrl+c`). + +| alias | action | +|------- |-------- | +| `k` | up | +| `l` | right | +| `j` | down | +| `h` | left | +| `esc` | cancel | diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 25125f99..1264bbf6 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,3 +1,6 @@ +export type { ClackState as State } from './types'; +export type { ClackSettings } from './utils/settings'; + export { default as ConfirmPrompt } from './prompts/confirm'; export { default as GroupMultiSelectPrompt } from './prompts/group-multiselect'; export { default as MultiSelectPrompt } from './prompts/multi-select'; @@ -6,5 +9,5 @@ export { default as Prompt } from './prompts/prompt'; export { default as SelectPrompt } from './prompts/select'; export { default as SelectKeyPrompt } from './prompts/select-key'; export { default as TextPrompt } from './prompts/text'; -export type { ClackState as State } from './types'; -export { block, isCancel, setGlobalAliases } from './utils'; +export { block, isCancel } from './utils'; +export { updateSettings } from './utils/settings'; diff --git a/packages/core/src/prompts/prompt.ts b/packages/core/src/prompts/prompt.ts index 92a88d9f..16dcf80d 100644 --- a/packages/core/src/prompts/prompt.ts +++ b/packages/core/src/prompts/prompt.ts @@ -5,9 +5,10 @@ import { WriteStream } from 'node:tty'; import { cursor, erase } from 'sisteransi'; import wrap from 'wrap-ansi'; -import { ALIASES, CANCEL_SYMBOL, KEYS, diffLines, hasAliasKey, setRawMode } from '../utils'; +import { CANCEL_SYMBOL, diffLines, isActionKey, setRawMode, settings } from '../utils'; -import type { ClackEvents, ClackState, InferSetType } from '../types'; +import type { ClackEvents, ClackState } from '../types'; +import type { Action } from '../utils'; export interface PromptOptions { render(this: Omit): string | undefined; @@ -181,11 +182,13 @@ export default class Prompt { if (this.state === 'error') { this.state = 'active'; } - if (key?.name && !this._track && ALIASES.has(key.name)) { - this.emit('cursor', ALIASES.get(key.name)); - } - if (key?.name && KEYS.has(key.name as InferSetType)) { - this.emit('cursor', key.name as InferSetType); + if (key?.name) { + if (!this._track && settings.aliases.has(key.name)) { + this.emit('cursor', settings.aliases.get(key.name)); + } + if (settings.actions.has(key.name as Action)) { + this.emit('cursor', key.name as Action); + } } if (char && (char.toLowerCase() === 'y' || char.toLowerCase() === 'n')) { this.emit('confirm', char.toLowerCase() === 'y'); @@ -214,7 +217,7 @@ export default class Prompt { } } - if (hasAliasKey([char, key?.name, key?.sequence], 'cancel')) { + if (isActionKey([char, key?.name, key?.sequence], 'cancel')) { this.state = 'cancel'; } if (this.state === 'submit' || this.state === 'cancel') { diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 0a47c8d9..29b3c708 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -1,6 +1,4 @@ -import type { KEYS } from './utils'; - -export type InferSetType = T extends Set ? U : never; +import type { Action } from "./utils/settings"; /** * The state of the prompt @@ -16,7 +14,7 @@ export interface ClackEvents { cancel: (value?: any) => void; submit: (value?: any) => void; error: (value?: any) => void; - cursor: (key?: InferSetType) => void; + cursor: (key?: Action) => void; key: (key?: string) => void; value: (value?: string) => void; confirm: (value?: boolean) => void; diff --git a/packages/core/src/utils/aliases.ts b/packages/core/src/utils/aliases.ts deleted file mode 100644 index 819dec7d..00000000 --- a/packages/core/src/utils/aliases.ts +++ /dev/null @@ -1,49 +0,0 @@ -import type { InferSetType } from '../types'; - -const DEFAULT_KEYS = ['up', 'down', 'left', 'right', 'space', 'enter', 'cancel'] as const; -export const KEYS = new Set(DEFAULT_KEYS); - -export const ALIASES = new Map>([ - ['k', 'up'], - ['j', 'down'], - ['h', 'left'], - ['l', 'right'], - ['\x03', 'cancel'], -]); - -/** - * Set custom global aliases for the default keys - This will not overwrite existing aliases just add new ones - * - * @param aliases - A map of aliases to keys - * @default - * new Map([['k', 'up'], ['j', 'down'], ['h', 'left'], ['l', 'right'], ['\x03', 'cancel'],]) - */ -export function setGlobalAliases(alias: Array<[string, InferSetType]>) { - for (const [newAlias, key] of alias) { - if (!ALIASES.has(newAlias)) { - ALIASES.set(newAlias, key); - } - } -} - -/** - * Check if a key is an alias for a default key - * @param key - The key to check for - * @param type - The type of key to check for - * @returns boolean - */ -export function hasAliasKey( - key: string | Array, - type: InferSetType -) { - if (typeof key === 'string') { - return ALIASES.has(key) && ALIASES.get(key) === type; - } - - return key - .map((n) => { - if (n !== undefined && ALIASES.has(n) && ALIASES.get(n) === type) return true; - return false; - }) - .includes(true); -} diff --git a/packages/core/src/utils/index.ts b/packages/core/src/utils/index.ts index ef0707c8..58ef417b 100644 --- a/packages/core/src/utils/index.ts +++ b/packages/core/src/utils/index.ts @@ -3,10 +3,10 @@ import type { Key } from 'node:readline'; import * as readline from 'node:readline'; import type { Readable } from 'node:stream'; import { cursor } from 'sisteransi'; -import { hasAliasKey } from './aliases'; +import { isActionKey } from './settings'; -export * from './aliases'; export * from './string'; +export * from './settings'; const isWindows = globalThis.process.platform.startsWith('win'); @@ -39,7 +39,7 @@ export function block({ const clear = (data: Buffer, { name, sequence }: Key) => { const str = String(data); - if (hasAliasKey([str, name, sequence], 'cancel')) { + if (isActionKey([str, name, sequence], 'cancel')) { if (hideCursor) output.write(cursor.show); process.exit(0); return; diff --git a/packages/core/src/utils/settings.ts b/packages/core/src/utils/settings.ts new file mode 100644 index 00000000..2d785f7a --- /dev/null +++ b/packages/core/src/utils/settings.ts @@ -0,0 +1,73 @@ +const actions = ['up', 'down', 'left', 'right', 'space', 'enter', 'cancel'] as const; +export type Action = (typeof actions)[number]; + +/** Global settings for Clack programs, stored in memory */ +interface InternalClackSettings { + actions: Set; + aliases: Map; +} + +export const settings: InternalClackSettings = { + actions: new Set(actions), + aliases: new Map([ + // vim support + ['k', 'up'], + ['j', 'down'], + ['h', 'left'], + ['l', 'right'], + ['\x03', 'cancel'], + // opinionated defaults! + ['escape', 'cancel'], + ]), +}; + +export interface ClackSettings { + /** + * Set custom global aliases for the default actions. + * 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' } + */ + aliases: Record; +} + +export function updateSettings(updates: ClackSettings) { + for (const _key in updates) { + const key = _key as keyof ClackSettings; + if (!Object.hasOwn(updates, key)) continue; + const value = updates[key]; + + switch (key) { + case 'aliases': { + for (const alias in value) { + if (!Object.hasOwn(value, alias)) continue; + if (!settings.aliases.has(alias)) { + settings.aliases.set(alias, value[alias]); + } + } + break; + } + } + } +} + +/** + * Check if a key is an alias for a default action + * @param key - The raw key which might match to an action + * @param action - The action to match + * @returns boolean + */ +export function isActionKey(key: string | Array, action: Action) { + if (typeof key === 'string') { + return settings.aliases.get(key) === action; + } + + for (const value of key) { + if (value === undefined) continue; + if (isActionKey(value, action)) { + return true; + } + } + return false; +} diff --git a/packages/prompts/src/index.ts b/packages/prompts/src/index.ts index 2d753648..4f261c09 100644 --- a/packages/prompts/src/index.ts +++ b/packages/prompts/src/index.ts @@ -14,7 +14,8 @@ import isUnicodeSupported from 'is-unicode-supported'; import color from 'picocolors'; import { cursor, erase } from 'sisteransi'; -export { isCancel, setGlobalAliases } from '@clack/core'; +export { isCancel } from '@clack/core'; +export { updateSettings, type ClackSettings } from '@clack/core'; const unicode = isUnicodeSupported(); const s = (c: string, fallback: string) => (unicode ? c : fallback);