[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.

Add changeset-like prompts, changeset demo (#81)

authored by

Nate Moore and committed by
GitHub
(Feb 24, 2023, 7:14 PM -0600) e4dec126 a79733e9

+333 -6
+6
.changeset/dull-boats-drum.md
··· 1 + --- 2 + '@clack/prompts': minor 3 + '@clack/core': patch 4 + --- 5 + 6 + add `groupMultiselect` prompt
+5
.changeset/tasty-comics-warn.md
··· 1 + --- 2 + '@clack/core': minor 3 + --- 4 + 5 + Add `GroupMultiSelect` prompt
+83
examples/changesets/index.ts
··· 1 + import * as p from '@clack/prompts'; 2 + import { setTimeout } from 'node:timers/promises'; 3 + import color from 'picocolors'; 4 + 5 + function onCancel() { 6 + p.cancel('Operation cancelled.'); 7 + process.exit(0); 8 + } 9 + 10 + async function main() { 11 + console.clear(); 12 + 13 + await setTimeout(1000); 14 + 15 + p.intro(`${color.bgCyan(color.black(' changesets '))}`); 16 + 17 + const changeset = await p.group( 18 + { 19 + packages: () => 20 + p.groupMultiselect({ 21 + message: 'Which packages would you like to include?', 22 + options: { 23 + 'changed packages': [ 24 + { value: '@scope/a' }, 25 + { value: '@scope/b' }, 26 + { value: '@scope/c' }, 27 + ], 28 + 'unchanged packages': [ 29 + { value: '@scope/x' }, 30 + { value: '@scope/y' }, 31 + { value: '@scope/z' }, 32 + ] 33 + } 34 + }), 35 + major: ({ results }) => { 36 + const packages = results.packages ?? []; 37 + return p.multiselect({ 38 + message: `Which packages should have a ${color.red('major')} bump?`, 39 + options: packages.map(value => ({ value })), 40 + required: false, 41 + }) 42 + }, 43 + minor: ({ results }) => { 44 + const packages = results.packages ?? []; 45 + const major = Array.isArray(results.major) ? results.major : []; 46 + const possiblePackages = packages.filter(pkg => !major.includes(pkg)) 47 + if (possiblePackages.length === 0) return; 48 + return p.multiselect({ 49 + message: `Which packages should have a ${color.yellow('minor')} bump?`, 50 + options: possiblePackages.map(value => ({ value })), 51 + required: false 52 + }) 53 + }, 54 + patch: async ({ results }) => { 55 + const packages = results.packages ?? []; 56 + const major = Array.isArray(results.major) ? results.major : []; 57 + const minor = Array.isArray(results.minor) ? results.minor : []; 58 + const possiblePackages = packages.filter(pkg => !major.includes(pkg) && !minor.includes(pkg)); 59 + if (possiblePackages.length === 0) return; 60 + let note = possiblePackages.join('\n'); 61 + 62 + p.log.step(`These packages will have a ${color.green('patch')} bump.\n${color.dim(note)}`); 63 + return possiblePackages 64 + } 65 + }, 66 + { 67 + onCancel 68 + } 69 + ); 70 + 71 + const message = await p.text({ 72 + placeholder: 'Summary', 73 + message: 'Please enter a summary for this change' 74 + }) 75 + 76 + if (p.isCancel(message)) { 77 + return onCancel() 78 + } 79 + 80 + p.outro(`Changeset added! ${color.underline(color.cyan('.changeset/orange-crabs-sing.md'))}`); 81 + } 82 + 83 + main().catch(console.error);
+17
examples/changesets/package.json
··· 1 + { 2 + "name": "@example/changesets", 3 + "private": true, 4 + "version": "0.0.0", 5 + "type": "module", 6 + "dependencies": { 7 + "@clack/core": "workspace:*", 8 + "@clack/prompts": "workspace:*", 9 + "picocolors": "^1.0.0" 10 + }, 11 + "scripts": { 12 + "start": "jiti ./index.ts" 13 + }, 14 + "devDependencies": { 15 + "jiti": "^1.17.0" 16 + } 17 + }
+3
examples/changesets/tsconfig.json
··· 1 + { 2 + "extends": "../../tsconfig.json" 3 + }
+1
package.json
··· 8 8 "build:core": "pnpm --filter @clack/core run build", 9 9 "build:prompts": "pnpm --filter @clack/prompts run build", 10 10 "start": "pnpm --filter @example/basic run start", 11 + "dev": "pnpm --filter @example/changesets run start", 11 12 "format": "pnpm run format:code", 12 13 "format:code": "prettier -w . --cache", 13 14 "format:imports": "organize-imports-cli ./packages/*/tsconfig.json",
+1
packages/core/src/index.ts
··· 1 1 export { default as ConfirmPrompt } from './prompts/confirm'; 2 2 export { default as MultiSelectPrompt } from './prompts/multi-select'; 3 + export { default as GroupMultiSelectPrompt } from './prompts/group-multiselect'; 3 4 export { default as PasswordPrompt } from './prompts/password'; 4 5 export { default as Prompt, isCancel } from './prompts/prompt'; 5 6 export type { State } from './prompts/prompt';
+70
packages/core/src/prompts/group-multiselect.ts
··· 1 + import Prompt, { PromptOptions } from './prompt'; 2 + 3 + interface GroupMultiSelectOptions<T extends { value: any }> extends PromptOptions<GroupMultiSelectPrompt<T>> { 4 + options: Record<string, T[]>; 5 + initialValues?: T['value'][]; 6 + required?: boolean; 7 + cursorAt?: T['value']; 8 + } 9 + export default class GroupMultiSelectPrompt<T extends { value: any }> extends Prompt { 10 + options: (T & { group: string | boolean })[]; 11 + cursor: number = 0; 12 + 13 + getGroupItems(group: string): T[] { 14 + return this.options.filter(o => o.group === group); 15 + } 16 + 17 + isGroupSelected(group: string) { 18 + const items = this.getGroupItems(group); 19 + return items.every(i => this.value.includes(i.value)); 20 + } 21 + 22 + private toggleValue() { 23 + const item = this.options[this.cursor]; 24 + if (item.group === true) { 25 + const group = item.value; 26 + const groupedItems = this.getGroupItems(group); 27 + if (this.isGroupSelected(group)) { 28 + this.value = this.value.filter((v: string) => groupedItems.findIndex(i => i.value === v) === -1); 29 + } else { 30 + this.value = [...this.value, ...groupedItems.map(i => i.value)]; 31 + } 32 + this.value = Array.from(new Set(this.value)); 33 + } else { 34 + const selected = this.value.includes(item.value); 35 + this.value = selected 36 + ? this.value.filter((v: T['value']) => v !== item.value) 37 + : [...this.value, item.value]; 38 + } 39 + } 40 + 41 + constructor(opts: GroupMultiSelectOptions<T>) { 42 + super(opts, false); 43 + const { options } = opts; 44 + this.options = Object.entries(options).flatMap(([key, option]) => [ 45 + { value: key, group: true, label: key }, 46 + ...option.map((opt) => ({ ...opt, group: key })), 47 + ]) 48 + this.value = [...(opts.initialValues ?? [])]; 49 + this.cursor = Math.max( 50 + this.options.findIndex(({ value }) => value === opts.cursorAt), 51 + 0 52 + ); 53 + 54 + this.on('cursor', (key) => { 55 + switch (key) { 56 + case 'left': 57 + case 'up': 58 + this.cursor = this.cursor === 0 ? this.options.length - 1 : this.cursor - 1; 59 + break; 60 + case 'down': 61 + case 'right': 62 + this.cursor = this.cursor === this.options.length - 1 ? 0 : this.cursor + 1; 63 + break; 64 + case 'space': 65 + this.toggleValue(); 66 + break; 67 + } 68 + }); 69 + } 70 + }
+10
packages/core/src/prompts/multi-select.ts
··· 14 14 return this.options[this.cursor].value; 15 15 } 16 16 17 + private toggleAll() { 18 + const allSelected = this.value.length === this.options.length; 19 + this.value = allSelected ? [] : this.options.map(v => v.value); 20 + } 21 + 17 22 private toggleValue() { 18 23 const selected = this.value.includes(this._value); 19 24 this.value = selected ··· 30 35 this.options.findIndex(({ value }) => value === opts.cursorAt), 31 36 0 32 37 ); 38 + this.on('key', (char) => { 39 + if (char === 'a') { 40 + this.toggleAll() 41 + } 42 + }) 33 43 34 44 this.on('cursor', (key) => { 35 45 switch (key) {
+137 -6
packages/prompts/src/index.ts
··· 3 3 ConfirmPrompt, 4 4 isCancel, 5 5 MultiSelectPrompt, 6 + GroupMultiSelectPrompt, 6 7 PasswordPrompt, 7 8 SelectKeyPrompt, 8 9 SelectPrompt, ··· 327 328 return `${title}${color.gray(S_BAR)} ${this.options 328 329 .filter(({ value }) => this.value.includes(value)) 329 330 .map((option) => opt(option, 'submitted')) 330 - .join(color.dim(', '))}`; 331 + .join(color.dim(', ')) || color.dim('none')}`; 331 332 } 332 333 case 'cancel': { 333 334 const label = this.options ··· 387 388 }).prompt() as Promise<Options[number]['value'][] | symbol>; 388 389 }; 389 390 391 + export interface GroupMultiSelectOptions<Options extends Option<Value>[], Value extends Primitive> { 392 + message: string; 393 + options: Record<string, Options>; 394 + initialValues?: Options[number]['value'][]; 395 + required?: boolean; 396 + cursorAt?: Options[number]['value']; 397 + } 398 + export const groupMultiselect = <Options extends Option<Value>[], Value extends Primitive>( 399 + opts: GroupMultiSelectOptions<Options, Value> 400 + ) => { 401 + const opt = ( 402 + option: Options[number], 403 + state: 'inactive' | 'active' | 'selected' | 'active-selected' | 'group-active' | 'group-active-selected' | 'submitted' | 'cancelled', 404 + options: Options = [] as any, 405 + ) => { 406 + const label = option.label ?? String(option.value); 407 + const isItem = typeof option.group === 'string'; 408 + const next = isItem && (options[options.indexOf(option) + 1] ?? { group: true }); 409 + const isLast = isItem && next.group === true; 410 + const prefix = isItem ? `${isLast ? S_BAR_END : S_BAR} ` : ''; 411 + 412 + if (state === 'active') { 413 + return `${color.dim(prefix)}${color.cyan(S_CHECKBOX_ACTIVE)} ${label} ${ 414 + option.hint ? color.dim(`(${option.hint})`) : '' 415 + }`; 416 + } else if (state === 'group-active') { 417 + return `${prefix}${color.cyan(S_CHECKBOX_ACTIVE)} ${color.dim(label)}`; 418 + } else if (state === 'group-active-selected') { 419 + return `${prefix}${color.green(S_CHECKBOX_SELECTED)} ${color.dim(label)}`; 420 + } else if (state === 'selected') { 421 + return `${color.dim(prefix)}${color.green(S_CHECKBOX_SELECTED)} ${color.dim(label)}`; 422 + } else if (state === 'cancelled') { 423 + return `${color.strikethrough(color.dim(label))}`; 424 + } else if (state === 'active-selected') { 425 + return `${color.dim(prefix)}${color.green(S_CHECKBOX_SELECTED)} ${label} ${ 426 + option.hint ? color.dim(`(${option.hint})`) : '' 427 + }`; 428 + } else if (state === 'submitted') { 429 + return `${color.dim(label)}`; 430 + } 431 + return `${color.dim(prefix)}${color.dim(S_CHECKBOX_INACTIVE)} ${color.dim(label)}`; 432 + }; 433 + 434 + return new GroupMultiSelectPrompt({ 435 + options: opts.options, 436 + initialValues: opts.initialValues, 437 + required: opts.required ?? true, 438 + cursorAt: opts.cursorAt, 439 + validate(selected: Value[]) { 440 + if (this.required && selected.length === 0) 441 + return `Please select at least one option.\n${color.reset( 442 + color.dim( 443 + `Press ${color.gray(color.bgWhite(color.inverse(' space ')))} to select, ${color.gray( 444 + color.bgWhite(color.inverse(' enter ')) 445 + )} to submit` 446 + ) 447 + )}`; 448 + }, 449 + render() { 450 + let title = `${color.gray(S_BAR)}\n${symbol(this.state)} ${opts.message}\n`; 451 + 452 + switch (this.state) { 453 + case 'submit': { 454 + return `${title}${color.gray(S_BAR)} ${this.options 455 + .filter(({ value }) => this.value.includes(value)) 456 + .map((option) => opt(option, 'submitted')) 457 + .join(color.dim(', '))}`; 458 + } 459 + case 'cancel': { 460 + const label = this.options 461 + .filter(({ value }) => this.value.includes(value)) 462 + .map((option) => opt(option, 'cancelled')) 463 + .join(color.dim(', ')); 464 + return `${title}${color.gray(S_BAR)} ${ 465 + label.trim() ? `${label}\n${color.gray(S_BAR)}` : '' 466 + }`; 467 + } 468 + case 'error': { 469 + const footer = this.error 470 + .split('\n') 471 + .map((ln, i) => 472 + i === 0 ? `${color.yellow(S_BAR_END)} ${color.yellow(ln)}` : ` ${ln}` 473 + ) 474 + .join('\n'); 475 + return `${title}${color.yellow(S_BAR)} ${this.options 476 + .map((option, i, options) => { 477 + const selected = this.value.includes(option.value) || (option.group === true && this.isGroupSelected(option.value)); 478 + const active = i === this.cursor; 479 + const groupActive = !active && typeof option.group === 'string' && this.options[this.cursor].value === option.group; 480 + if (groupActive) { 481 + return opt(option, selected ? 'group-active-selected' : 'group-active', options); 482 + } 483 + if (active && selected) { 484 + return opt(option, 'active-selected', options); 485 + } 486 + if (selected) { 487 + return opt(option, 'selected', options); 488 + } 489 + return opt(option, active ? 'active' : 'inactive', options); 490 + }) 491 + .join(`\n${color.yellow(S_BAR)} `)}\n${footer}\n`; 492 + } 493 + default: { 494 + return `${title}${color.cyan(S_BAR)} ${this.options 495 + .map((option, i, options) => { 496 + const selected = this.value.includes(option.value) || (option.group === true && this.isGroupSelected(option.value)); 497 + const active = i === this.cursor; 498 + const groupActive = !active && typeof option.group === 'string' && this.options[this.cursor].value === option.group; 499 + if (groupActive) { 500 + return opt(option, selected ? 'group-active-selected' : 'group-active', options); 501 + } 502 + if (active && selected) { 503 + return opt(option, 'active-selected', options); 504 + } 505 + if (selected) { 506 + return opt(option, 'selected', options); 507 + } 508 + return opt(option, active ? 'active' : 'inactive', options); 509 + }) 510 + .join(`\n${color.cyan(S_BAR)} `)}\n${color.cyan(S_BAR_END)}\n`; 511 + } 512 + } 513 + }, 514 + }).prompt() as Promise<Options[number]['value'][] | symbol>; 515 + }; 516 + 390 517 const strip = (str: string) => str.replace(ansiRegex(), ''); 391 518 export const note = (message = '', title = '') => { 392 519 const lines = `\n${message}\n`.split('\n'); 393 - const len = 520 + const len = Math.max( 394 521 lines.reduce((sum, ln) => { 395 522 ln = strip(ln); 396 523 return ln.length > sum ? ln.length : sum; 397 - }, 0) + 2; 524 + }, 0), strip(title).length) + 2; 398 525 const msg = lines 399 526 .map( 400 527 (ln) => ··· 405 532 .join('\n'); 406 533 process.stdout.write( 407 534 `${color.gray(S_BAR)}\n${color.green(S_STEP_SUBMIT)} ${color.reset(title)} ${color.gray( 408 - S_BAR_H.repeat(len - title.length - 1) + S_CORNER_TOP_RIGHT 535 + S_BAR_H.repeat(Math.max(len - title.length - 1, 1)) + S_CORNER_TOP_RIGHT 409 536 )}\n${msg}\n${color.gray(S_CONNECT_LEFT + S_BAR_H.repeat(len + 2) + S_CORNER_BOTTOM_RIGHT)}\n` 410 537 ); 411 538 }; ··· 440 567 success: (message: string) => { 441 568 log.message(message, { symbol: color.green(S_SUCCESS) }); 442 569 }, 570 + step: (message: string) => { 571 + log.message(message, { symbol: color.green(S_STEP_SUBMIT) }); 572 + }, 443 573 warn: (message: string) => { 444 574 log.message(message, { symbol: color.yellow(S_WARN) }); 445 575 }, ··· 511 641 } 512 642 513 643 export type PromptGroup<T> = { 514 - [P in keyof T]: (opts: { results: Partial<PromptGroupAwaitedReturn<T>> }) => Promise<T[P]>; 644 + [P in keyof T]: (opts: { results: Partial<PromptGroupAwaitedReturn<T>> }) => void | Promise<T[P] | void>; 515 645 }; 516 646 517 647 /** ··· 526 656 const promptNames = Object.keys(prompts); 527 657 528 658 for (const name of promptNames) { 529 - const result = await prompts[name as keyof T]({ results }).catch((e) => { 659 + const prompt = prompts[name as keyof T]; 660 + const result = await prompt({ results })?.catch((e) => { 530 661 throw e; 531 662 }); 532 663