[READ-ONLY] Mirror of https://github.com/bombshell-dev/clack. Effortlessly build beautiful command-line apps bomb.sh/docs/clack/basics/getting-started/
cli command-line command-line-app node prompt prompts
5

Configure Feed

Select the types of activity you want to include in your feed.

feat: improve types event emitter & global aliases

authored by

Christian Preston and committed by
Nate Moore
(Dec 13, 2024, 10:38 PM -0600) 85cc1d55 2bbd33ee

+156 -52
+8
examples/basic/index.ts
··· 7 7 8 8 await setTimeout(1000); 9 9 10 + p.setGlobalAliases([ 11 + ['w', 'up'], 12 + ['s', 'down'], 13 + ['a', 'left'], 14 + ['d', 'right'], 15 + ['escape', 'cancel'], 16 + ]); 17 + 10 18 p.intro(`${color.bgCyan(color.black(' create-app '))}`); 11 19 12 20 const project = await p.group(
+2 -3
packages/core/src/index.ts
··· 2 2 export { default as GroupMultiSelectPrompt } from './prompts/group-multiselect'; 3 3 export { default as MultiSelectPrompt } from './prompts/multi-select'; 4 4 export { default as PasswordPrompt } from './prompts/password'; 5 - export { default as Prompt, isCancel } from './prompts/prompt'; 6 - export type { State } from './prompts/prompt'; 5 + export { default as Prompt, isCancel, type State } from './prompts/prompt'; 7 6 export { default as SelectPrompt } from './prompts/select'; 8 7 export { default as SelectKeyPrompt } from './prompts/select-key'; 9 8 export { default as TextPrompt } from './prompts/text'; 10 - export { block } from './utils'; 9 + export { block, setGlobalAliases } from './utils';
+93 -44
packages/core/src/prompts/prompt.ts
··· 7 7 import { cursor, erase } from 'sisteransi'; 8 8 import wrap from 'wrap-ansi'; 9 9 10 + import { type InferSetType, aliases, keys, hasAliasKey } from '../utils'; 11 + 10 12 function diffLines(a: string, b: string) { 11 13 if (a === b) return; 12 14 ··· 30 32 if ((input as typeof stdin).isTTY) (input as typeof stdin).setRawMode(value); 31 33 } 32 34 33 - const aliases = new Map([ 34 - ['k', 'up'], 35 - ['j', 'down'], 36 - ['h', 'left'], 37 - ['l', 'right'], 38 - ]); 39 - const keys = new Set(['up', 'down', 'left', 'right', 'space', 'enter']); 40 - 41 35 export interface PromptOptions<Self extends Prompt> { 42 36 render(this: Omit<Self, 'prompt'>): string | void; 43 37 placeholder?: string; ··· 50 44 51 45 export type State = 'initial' | 'active' | 'cancel' | 'submit' | 'error'; 52 46 47 + /** 48 + * Typed event emitter for clack 49 + */ 50 + interface ClackHooks { 51 + 'initial': (value?: any) => void; 52 + 'active': (value?: any) => void; 53 + 'cancel': (value?: any) => void; 54 + 'submit': (value?: any) => void; 55 + 'error': (value?: any) => void; 56 + 'cursor': (key?: InferSetType<typeof keys>) => void; 57 + 'key': (key?: string) => void; 58 + 'value': (value?: string) => void; 59 + 'confirm': (value?: boolean) => void; 60 + 'finalize': () => void; 61 + } 62 + 53 63 export default class Prompt { 54 64 protected input: Readable; 55 65 protected output: Writable; 66 + 56 67 private rl!: ReadLine; 57 68 private opts: Omit<PromptOptions<Prompt>, 'render' | 'input' | 'output'>; 58 - private _track: boolean = false; 59 69 private _render: (context: Omit<Prompt, 'prompt'>) => string | void; 60 - protected _cursor: number = 0; 70 + private _track = false; 71 + private _prevFrame = ''; 72 + private _subscribers = new Map<string, { cb: (...args: any) => any; once?: boolean }[]>(); 73 + protected _cursor = 0; 61 74 62 75 public state: State = 'initial'; 76 + public error = ''; 63 77 public value: any; 64 - public error: string = ''; 65 78 66 79 constructor( 67 - { render, input = stdin, output = stdout, ...opts }: PromptOptions<Prompt>, 80 + options: PromptOptions<Prompt>, 68 81 trackValue: boolean = true 69 82 ) { 83 + const { 84 + input = stdin, 85 + output = stdout, 86 + render, 87 + ...opts 88 + } = options; 89 + 70 90 this.opts = opts; 71 91 this.onKeypress = this.onKeypress.bind(this); 72 92 this.close = this.close.bind(this); ··· 78 98 this.output = output; 79 99 } 80 100 101 + /** 102 + * Unsubscribe all listeners 103 + */ 104 + protected unsubscribe() { 105 + this._subscribers.clear(); 106 + } 107 + 108 + /** 109 + * Set a subscriber with opts 110 + * @param event - The event name 111 + */ 112 + private setSubscriber<T extends keyof ClackHooks>(event: T, opts: { cb: ClackHooks[T]; once?: boolean }) { 113 + const params = this._subscribers.get(event) ?? []; 114 + params.push(opts); 115 + this._subscribers.set(event, params); 116 + } 117 + 118 + /** 119 + * Subscribe to an event 120 + * @param event - The event name 121 + * @param cb - The callback 122 + */ 123 + public on<T extends keyof ClackHooks>(event: T, cb: ClackHooks[T]) { 124 + this.setSubscriber(event, { cb }); 125 + } 126 + 127 + /** 128 + * Subscribe to an event once 129 + * @param event - The event name 130 + * @param cb - The callback 131 + */ 132 + public once<T extends keyof ClackHooks>(event: T, cb: ClackHooks[T]) { 133 + this.setSubscriber(event, { cb, once: true }); 134 + } 135 + 136 + /** 137 + * Emit an event with data 138 + * @param event - The event name 139 + * @param data - The data to pass to the callback 140 + */ 141 + public emit<T extends keyof ClackHooks>(event: T, ...data: Parameters<ClackHooks[T]>) { 142 + const cbs = this._subscribers.get(event) ?? []; 143 + const cleanup: (() => void)[] = []; 144 + 145 + for (const subscriber of cbs) { 146 + subscriber.cb(...data); 147 + 148 + if (subscriber.once) { 149 + cleanup.push(() => cbs.splice(cbs.indexOf(subscriber), 1)); 150 + } 151 + } 152 + 153 + for (const cb of cleanup) { 154 + cb(); 155 + } 156 + } 157 + 81 158 public prompt() { 82 159 const sink = new WriteStream(0); 83 160 sink._write = (chunk, encoding, done) => { ··· 125 202 }); 126 203 } 127 204 128 - private subscribers = new Map<string, { cb: (...args: any) => any; once?: boolean }[]>(); 129 - public on(event: string, cb: (...args: any) => any) { 130 - const arr = this.subscribers.get(event) ?? []; 131 - arr.push({ cb }); 132 - this.subscribers.set(event, arr); 133 - } 134 - public once(event: string, cb: (...args: any) => any) { 135 - const arr = this.subscribers.get(event) ?? []; 136 - arr.push({ cb, once: true }); 137 - this.subscribers.set(event, arr); 138 - } 139 - public emit(event: string, ...data: any[]) { 140 - const cbs = this.subscribers.get(event) ?? []; 141 - const cleanup: (() => void)[] = []; 142 - for (const subscriber of cbs) { 143 - subscriber.cb(...data); 144 - if (subscriber.once) { 145 - cleanup.push(() => cbs.splice(cbs.indexOf(subscriber), 1)); 146 - } 147 - } 148 - for (const cb of cleanup) { 149 - cb(); 150 - } 151 - } 152 - private unsubscribe() { 153 - this.subscribers.clear(); 154 - } 155 - 156 205 private onKeypress(char: string, key?: Key) { 157 206 if (this.state === 'error') { 158 207 this.state = 'active'; ··· 160 209 if (key?.name && !this._track && aliases.has(key.name)) { 161 210 this.emit('cursor', aliases.get(key.name)); 162 211 } 163 - if (key?.name && keys.has(key.name)) { 164 - this.emit('cursor', key.name); 212 + if (key?.name && keys.has(key.name as InferSetType<typeof keys>)) { 213 + this.emit('cursor', key.name as InferSetType<typeof keys>); 165 214 } 166 215 if (char && (char.toLowerCase() === 'y' || char.toLowerCase() === 'n')) { 167 216 this.emit('confirm', char.toLowerCase() === 'y'); ··· 189 238 this.state = 'submit'; 190 239 } 191 240 } 192 - if (char === '\x03') { 241 + 242 + if (hasAliasKey([key?.name, key?.sequence], 'cancel')) { 193 243 this.state = 'cancel'; 194 244 } 195 245 if (this.state === 'submit' || this.state === 'cancel') { ··· 217 267 this.output.write(cursor.move(-999, lines * -1)); 218 268 } 219 269 220 - private _prevFrame = ''; 221 270 private render() { 222 271 const frame = wrap(this._render(this) ?? '', process.stdout.columns, { hard: true }); 223 272 if (frame === this._prevFrame) return;
+7 -4
packages/core/src/utils.ts packages/core/src/utils/index.ts
··· 3 3 import { stdin, stdout } from 'node:process'; 4 4 import * as readline from 'node:readline'; 5 5 import { cursor } from 'sisteransi'; 6 + import { hasAliasKey } from './aliases'; 6 7 7 8 const isWindows = globalThis.process.platform.startsWith('win'); 8 9 ··· 21 22 readline.emitKeypressEvents(input, rl); 22 23 if (input.isTTY) input.setRawMode(true); 23 24 24 - const clear = (data: Buffer, { name }: Key) => { 25 + const clear = (data: Buffer, { name, sequence }: Key) => { 25 26 const str = String(data); 26 - if (str === '\x03') { 27 + if (hasAliasKey([str, name, sequence], 'cancel')) { 27 28 if (hideCursor) output.write(cursor.show); 28 29 process.exit(0); 29 30 return; 30 31 } 31 32 if (!overwrite) return; 32 - let dx = name === 'return' ? 0 : -1; 33 - let dy = name === 'return' ? -1 : 0; 33 + const dx = name === 'return' ? 0 : -1; 34 + const dy = name === 'return' ? -1 : 0; 34 35 35 36 readline.moveCursor(output, dx, dy, () => { 36 37 readline.clearLine(output, 1, () => { ··· 53 54 rl.close(); 54 55 }; 55 56 } 57 + 58 + export * from './aliases';
+45
packages/core/src/utils/aliases.ts
··· 1 + 2 + export type InferSetType<T> = T extends Set<infer U> ? U : never; 3 + 4 + const DEFAULT_KEYS = ['up', 'down', 'left', 'right', 'space', 'enter', 'cancel'] as const; 5 + export const keys = new Set(DEFAULT_KEYS); 6 + 7 + export const aliases = new Map<string, InferSetType<typeof keys>>([ 8 + ['k', 'up'], 9 + ['j', 'down'], 10 + ['h', 'left'], 11 + ['l', 'right'], 12 + ['\x03', 'cancel'], 13 + ]); 14 + 15 + /** 16 + * Set custom global aliases for the default keys - This will not overwrite existing aliases just add new ones 17 + * 18 + * @param aliases - A map of aliases to keys 19 + * @default 20 + * new Map([['k', 'up'], ['j', 'down'], ['h', 'left'], ['l', 'right'], ['\x03', 'cancel'],]) 21 + */ 22 + export function setGlobalAliases(alias: Array<[string, InferSetType<typeof keys>]>) { 23 + for (const [newAlias, key] of alias) { 24 + if (!aliases.has(newAlias)) { 25 + aliases.set(newAlias, key); 26 + } 27 + } 28 + } 29 + 30 + /** 31 + * Check if a key is an alias for a default key 32 + * @param key - The key to check for 33 + * @param type - The type of key to check for 34 + * @returns boolean 35 + */ 36 + export function hasAliasKey(key: string | Array<string | undefined>, type: InferSetType<typeof keys>) { 37 + if (typeof key === 'string') { 38 + return aliases.has(key) && aliases.get(key) === type; 39 + } 40 + 41 + return key.map((n) => { 42 + if (n !== undefined && aliases.has(n) && aliases.get(n) === type) return true; 43 + return false; 44 + }).includes(true); 45 + }
+1 -1
packages/prompts/src/index.ts
··· 14 14 import color from 'picocolors'; 15 15 import { cursor, erase } from 'sisteransi'; 16 16 17 - export { isCancel } from '@clack/core'; 17 + export { isCancel, setGlobalAliases } from '@clack/core'; 18 18 19 19 const unicode = isUnicodeSupported(); 20 20 const s = (c: string, fallback: string) => (unicode ? c : fallback);