From 8f38a9d720ad4cfc3d5ff0b749e7d55198a2cefa Mon Sep 17 00:00:00 2001 From: Nate Moore Date: Wed, 22 Feb 2023 08:57:33 -0600 Subject: [PATCH 1/7] add changeset example --- examples/changesets/index.ts | 91 ++++++++++++ examples/changesets/package.json | 17 +++ examples/changesets/tsconfig.json | 3 + package.json | 1 + packages/core/src/index.ts | 1 + .../core/src/prompts/group-multiselect.ts | 70 +++++++++ packages/core/src/prompts/multi-select.ts | 10 ++ packages/prompts/src/index.ts | 140 +++++++++++++++++- 8 files changed, 327 insertions(+), 6 deletions(-) create mode 100644 examples/changesets/index.ts create mode 100644 examples/changesets/package.json create mode 100644 examples/changesets/tsconfig.json create mode 100644 packages/core/src/prompts/group-multiselect.ts diff --git a/examples/changesets/index.ts b/examples/changesets/index.ts new file mode 100644 index 00000000..386d78ba --- /dev/null +++ b/examples/changesets/index.ts @@ -0,0 +1,91 @@ +import * as p from '@clack/prompts'; +import { setTimeout } from 'node:timers/promises'; +import color from 'picocolors'; + +function onCancel() { + p.cancel('Operation cancelled.'); + process.exit(0); +} + +async function main() { + console.clear(); + + await setTimeout(1000); + + p.intro(`${color.bgCyan(color.black(' changesets '))}`); + + const changeset = await p.group( + { + packages: () => + p.groupMultiselect({ + message: 'Which packages would you like to include?', + options: { + 'changed packages': [ + { value: '@scope/a' }, + { value: '@scope/b' }, + { value: '@scope/c' }, + ], + 'unchanged packages': [ + { value: '@scope/x' }, + { value: '@scope/y' }, + { value: '@scope/z' }, + ] + } + }), + major: ({ results }) => { + const packages = results.packages ?? []; + return p.multiselect({ + message: `Which packages should have a ${color.red('major')} bump?`, + options: packages.map(value => ({ value })), + required: false, + }) + }, + minor: ({ results }) => { + const packages = results.packages ?? []; + const major = Array.isArray(results.major) ? results.major : []; + const possiblePackages = packages.filter(pkg => !major.includes(pkg)) + if (possiblePackages.length === 0) return; + return p.multiselect({ + message: `Which packages should have a ${color.yellow('minor')} bump?`, + options: possiblePackages.map(value => ({ value })), + required: false + }) + }, + patch: async ({ results }) => { + const packages = results.packages ?? []; + const major = Array.isArray(results.major) ? results.major : []; + const minor = Array.isArray(results.minor) ? results.minor : []; + const possiblePackages = packages.filter(pkg => !major.includes(pkg) && !minor.includes(pkg)); + if (possiblePackages.length === 0) return; + let note = possiblePackages.join('\n'); + + p.note(note, `These packages will have a ${color.green('patch')} bump.`); + return possiblePackages + } + }, + { + onCancel + } + ); + + const message = await p.text({ + placeholder: 'Summary', + message: 'Please enter a summary for this change' + }) + + if (p.isCancel(message)) { + return onCancel() + } + + const accept = await p.confirm({ + message: 'Is this your desired changeset?' + }) + + if (p.isCancel(accept)) { + return onCancel() + } + + p.outro(`Changeset added! ${color.underline(color.cyan('.changeset/orange-crabs-sing.md'))}`); +} + +main().catch(console.error); diff --git a/examples/changesets/package.json b/examples/changesets/package.json new file mode 100644 index 00000000..2c8cfd5b --- /dev/null +++ b/examples/changesets/package.json @@ -0,0 +1,17 @@ +{ + "name": "@example/changesets", + "private": true, + "version": "0.0.0", + "type": "module", + "dependencies": { + "@clack/core": "workspace:*", + "@clack/prompts": "workspace:*", + "picocolors": "^1.0.0" + }, + "scripts": { + "start": "jiti ./index.ts" + }, + "devDependencies": { + "jiti": "^1.17.0" + } +} diff --git a/examples/changesets/tsconfig.json b/examples/changesets/tsconfig.json new file mode 100644 index 00000000..4082f16a --- /dev/null +++ b/examples/changesets/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "../../tsconfig.json" +} diff --git a/package.json b/package.json index b2b0f6e7..16975460 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "build:core": "pnpm --filter @clack/core run build", "build:prompts": "pnpm --filter @clack/prompts run build", "start": "pnpm --filter @example/basic run start", + "dev": "pnpm --filter @example/changesets run start", "format": "pnpm run format:code", "format:code": "prettier -w . --cache", "format:imports": "organize-imports-cli ./packages/*/tsconfig.json", diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 747e27cf..25df057b 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,5 +1,6 @@ export { default as ConfirmPrompt } from './prompts/confirm'; export { default as MultiSelectPrompt } from './prompts/multi-select'; +export { default as GroupMultiSelectPrompt } from './prompts/group-multiselect'; export { default as PasswordPrompt } from './prompts/password'; export { default as Prompt, isCancel } from './prompts/prompt'; export type { State } from './prompts/prompt'; diff --git a/packages/core/src/prompts/group-multiselect.ts b/packages/core/src/prompts/group-multiselect.ts new file mode 100644 index 00000000..0ccadfbf --- /dev/null +++ b/packages/core/src/prompts/group-multiselect.ts @@ -0,0 +1,70 @@ +import Prompt, { PromptOptions } from './prompt'; + +interface GroupMultiSelectOptions extends PromptOptions> { + options: Record; + initialValues?: T['value'][]; + required?: boolean; + cursorAt?: T['value']; +} +export default class GroupMultiSelectPrompt extends Prompt { + options: (T & { group: string | boolean })[]; + cursor: number = 0; + + getGroupItems(group: string): T[] { + return this.options.filter(i => i.group === group); + } + + isGroupSelected(group: string) { + const items = this.getGroupItems(group); + return items.every(i => this.value.includes(i.value)); + } + + private toggleValue() { + const item = this.options[this.cursor]; + if (item.group === true) { + const group = item.value; + const groupedItems = this.getGroupItems(group); + if (this.isGroupSelected(group)) { + this.value = this.value.filter((v: string) => groupedItems.findIndex(i => i.value === v) === -1); + } else { + this.value = [...this.value, ...groupedItems.map(v => v.value)]; + } + this.value = Array.from(new Set(this.value)); + } else { + const selected = this.value.includes(item.value); + this.value = selected + ? this.value.filter((value: T['value']) => value !== item.value) + : [...this.value, item.value]; + } + } + + constructor(opts: GroupMultiSelectOptions) { + super(opts, false); + + this.options = Object.keys(opts.options).reduce((acc, key) => { + acc.push({ value: key, group: true, label: key }, ...opts.options[key].map(opt => ({ ...opt, group: key }))); + return acc; + }, [] as { value: any, [key: string]: any }[]); + this.value = [...(opts.initialValues ?? [])]; + this.cursor = Math.max( + this.options.findIndex(({ value }) => value === opts.cursorAt), + 0 + ); + + this.on('cursor', (key) => { + switch (key) { + case 'left': + case 'up': + this.cursor = this.cursor === 0 ? this.options.length - 1 : this.cursor - 1; + break; + case 'down': + case 'right': + this.cursor = this.cursor === this.options.length - 1 ? 0 : this.cursor + 1; + break; + case 'space': + this.toggleValue(); + break; + } + }); + } +} diff --git a/packages/core/src/prompts/multi-select.ts b/packages/core/src/prompts/multi-select.ts index df5ee52b..a8122257 100644 --- a/packages/core/src/prompts/multi-select.ts +++ b/packages/core/src/prompts/multi-select.ts @@ -14,6 +14,11 @@ export default class MultiSelectPrompt extends Prompt return this.options[this.cursor].value; } + private toggleAll() { + const allSelected = this.value.length === this.options.length; + this.value = allSelected ? [] : this.options.map(v => v.value); + } + private toggleValue() { const selected = this.value.includes(this._value); this.value = selected @@ -30,6 +35,11 @@ export default class MultiSelectPrompt extends Prompt this.options.findIndex(({ value }) => value === opts.cursorAt), 0 ); + this.on('key', (char) => { + if (char === 'a') { + this.toggleAll() + } + }) this.on('cursor', (key) => { switch (key) { diff --git a/packages/prompts/src/index.ts b/packages/prompts/src/index.ts index 67b2d2ea..8a62ff8f 100644 --- a/packages/prompts/src/index.ts +++ b/packages/prompts/src/index.ts @@ -3,6 +3,7 @@ import { ConfirmPrompt, isCancel, MultiSelectPrompt, + GroupMultiSelectPrompt, PasswordPrompt, SelectKeyPrompt, SelectPrompt, @@ -327,7 +328,7 @@ export const multiselect = [], Value extends Primi return `${title}${color.gray(S_BAR)} ${this.options .filter(({ value }) => this.value.includes(value)) .map((option) => opt(option, 'submitted')) - .join(color.dim(', '))}`; + .join(color.dim(', ')) || color.dim('none')}`; } case 'cancel': { const label = this.options @@ -387,14 +388,140 @@ export const multiselect = [], Value extends Primi }).prompt() as Promise; }; +export interface GroupMultiSelectOptions[], Value extends Primitive> { + message: string; + options: Record; + initialValues?: Options[number]['value'][]; + required?: boolean; + cursorAt?: Options[number]['value']; +} +export const groupMultiselect = [], Value extends Primitive>( + opts: GroupMultiSelectOptions +) => { + const opt = ( + option: Options[number], + state: 'inactive' | 'active' | 'selected' | 'active-selected' | 'group-active' | 'group-active-selected' | 'submitted' | 'cancelled', + options: Options = [] as any, + ) => { + const label = option.label ?? String(option.value); + const isItem = typeof option.group === 'string'; + const next = isItem && (options[options.indexOf(option) + 1] ?? { group: true }); + const isLast = isItem && next.group === true; + const prefix = typeof option.group === 'string' ? `${isLast ? S_BAR_END : S_BAR} ` : ''; + + if (state === 'active') { + return `${color.dim(prefix)}${color.cyan(S_CHECKBOX_ACTIVE)} ${label} ${ + option.hint ? color.dim(`(${option.hint})`) : '' + }`; + } else if (state === 'group-active') { + return `${prefix}${color.cyan(S_CHECKBOX_ACTIVE)} ${color.dim(label)}`; + } else if (state === 'group-active-selected') { + return `${prefix}${color.green(S_CHECKBOX_SELECTED)} ${color.dim(label)}`; + } else if (state === 'selected') { + return `${color.dim(prefix)}${color.green(S_CHECKBOX_SELECTED)} ${color.dim(label)}`; + } else if (state === 'cancelled') { + return `${color.strikethrough(color.dim(label))}`; + } else if (state === 'active-selected') { + return `${color.dim(prefix)}${color.green(S_CHECKBOX_SELECTED)} ${label} ${ + option.hint ? color.dim(`(${option.hint})`) : '' + }`; + } else if (state === 'submitted') { + return `${color.dim(label)}`; + } + return `${color.dim(prefix)}${color.dim(S_CHECKBOX_INACTIVE)} ${color.dim(label)}`; + }; + + return new GroupMultiSelectPrompt({ + options: opts.options, + initialValues: opts.initialValues, + required: opts.required ?? true, + cursorAt: opts.cursorAt, + validate(selected: Value[]) { + if (this.required && selected.length === 0) + return `Please select at least one option.\n${color.reset( + color.dim( + `Press ${color.gray(color.bgWhite(color.inverse(' space ')))} to select, ${color.gray( + color.bgWhite(color.inverse(' enter ')) + )} to submit` + ) + )}`; + }, + render() { + let title = `${color.gray(S_BAR)}\n${symbol(this.state)} ${opts.message}\n`; + + switch (this.state) { + case 'submit': { + return `${title}${color.gray(S_BAR)} ${this.options + .filter(({ value }) => this.value.includes(value)) + .map((option) => opt(option, 'submitted')) + .join(color.dim(', '))}`; + } + case 'cancel': { + const label = this.options + .filter(({ value }) => this.value.includes(value)) + .map((option) => opt(option, 'cancelled')) + .join(color.dim(', ')); + return `${title}${color.gray(S_BAR)} ${ + label.trim() ? `${label}\n${color.gray(S_BAR)}` : '' + }`; + } + case 'error': { + const footer = this.error + .split('\n') + .map((ln, i) => + i === 0 ? `${color.yellow(S_BAR_END)} ${color.yellow(ln)}` : ` ${ln}` + ) + .join('\n'); + return `${title}${color.yellow(S_BAR)} ${this.options + .map((option, i, options) => { + const selected = this.value.includes(option.value) || (option.group === true && this.isGroupSelected(option.value)); + const active = i === this.cursor; + const groupActive = !active && typeof option.group === 'string' && this.options[this.cursor].value === option.group; + if (groupActive) { + return opt(option, selected ? 'group-active-selected' : 'group-active', options); + } + if (active && selected) { + return opt(option, 'active-selected', options); + } + if (selected) { + return opt(option, 'selected', options); + } + return opt(option, active ? 'active' : 'inactive', options); + }) + .join(`\n${color.yellow(S_BAR)} `)}\n${footer}\n`; + } + default: { + return `${title}${color.cyan(S_BAR)} ${this.options + .map((option, i, options) => { + const selected = this.value.includes(option.value) || (option.group === true && this.isGroupSelected(option.value)); + const active = i === this.cursor; + const groupActive = !active && typeof option.group === 'string' && this.options[this.cursor].value === option.group; + if (groupActive) { + return opt(option, selected ? 'group-active-selected' : 'group-active', options); + } + if (active && selected) { + return opt(option, 'active-selected', options); + } + if (selected) { + return opt(option, 'selected', options); + } + return opt(option, active ? 'active' : 'inactive', options); + }) + .join(`\n${color.cyan(S_BAR)} `)}\n${color.cyan(S_BAR_END)}\n`; + } + } + }, + }).prompt() as Promise; +}; + const strip = (str: string) => str.replace(ansiRegex(), ''); export const note = (message = '', title = '') => { const lines = `\n${message}\n`.split('\n'); - const len = + const len = Math.max( lines.reduce((sum, ln) => { ln = strip(ln); return ln.length > sum ? ln.length : sum; - }, 0) + 2; + }, 0), strip(title).length) + 2; const msg = lines .map( (ln) => @@ -405,7 +532,7 @@ export const note = (message = '', title = '') => { .join('\n'); process.stdout.write( `${color.gray(S_BAR)}\n${color.green(S_STEP_SUBMIT)} ${color.reset(title)} ${color.gray( - S_BAR_H.repeat(len - title.length - 1) + S_CORNER_TOP_RIGHT + S_BAR_H.repeat(Math.max(len - title.length - 1, 1)) + S_CORNER_TOP_RIGHT )}\n${msg}\n${color.gray(S_CONNECT_LEFT + S_BAR_H.repeat(len + 2) + S_CORNER_BOTTOM_RIGHT)}\n` ); }; @@ -511,7 +638,7 @@ export interface PromptGroupOptions { } export type PromptGroup = { - [P in keyof T]: (opts: { results: Partial> }) => Promise; + [P in keyof T]: (opts: { results: Partial> }) => void | Promise; }; /** @@ -526,7 +653,8 @@ export const group = async ( const promptNames = Object.keys(prompts); for (const name of promptNames) { - const result = await prompts[name as keyof T]({ results }).catch((e) => { + const prompt = prompts[name as keyof T]; + const result = await prompt({ results })?.catch((e) => { throw e; }); From 3cfd262ca694f6502b91aa8948386503f08836c8 Mon Sep 17 00:00:00 2001 From: Nate Moore Date: Thu, 23 Feb 2023 06:07:35 -0600 Subject: [PATCH 2/7] update demo --- examples/changesets/index.ts | 8 -------- 1 file changed, 8 deletions(-) diff --git a/examples/changesets/index.ts b/examples/changesets/index.ts index 386d78ba..5994b26c 100644 --- a/examples/changesets/index.ts +++ b/examples/changesets/index.ts @@ -77,14 +77,6 @@ async function main() { return onCancel() } - const accept = await p.confirm({ - message: 'Is this your desired changeset?' - }) - - if (p.isCancel(accept)) { - return onCancel() - } - p.outro(`Changeset added! ${color.underline(color.cyan('.changeset/orange-crabs-sing.md'))}`); } From 1e647c13a277d350e189b2cdb5841fbaa7912e20 Mon Sep 17 00:00:00 2001 From: Nate Moore Date: Fri, 24 Feb 2023 17:04:59 -0600 Subject: [PATCH 3/7] Update packages/core/src/prompts/group-multiselect.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Oskar Löfgren <516549+ulken@users.noreply.github.com> --- packages/core/src/prompts/group-multiselect.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/prompts/group-multiselect.ts b/packages/core/src/prompts/group-multiselect.ts index 0ccadfbf..960e6621 100644 --- a/packages/core/src/prompts/group-multiselect.ts +++ b/packages/core/src/prompts/group-multiselect.ts @@ -11,7 +11,7 @@ export default class GroupMultiSelectPrompt extends Pr cursor: number = 0; getGroupItems(group: string): T[] { - return this.options.filter(i => i.group === group); + return this.options.filter(o => o.group === group); } isGroupSelected(group: string) { From 11552e21b545bce06a2f1373c605422d170464de Mon Sep 17 00:00:00 2001 From: Nate Moore Date: Fri, 24 Feb 2023 17:23:53 -0600 Subject: [PATCH 4/7] Apply suggestions from code review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Oskar Löfgren <516549+ulken@users.noreply.github.com> --- packages/core/src/prompts/group-multiselect.ts | 12 ++++++------ packages/prompts/src/index.ts | 4 ++-- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/core/src/prompts/group-multiselect.ts b/packages/core/src/prompts/group-multiselect.ts index 960e6621..cdb4cd9f 100644 --- a/packages/core/src/prompts/group-multiselect.ts +++ b/packages/core/src/prompts/group-multiselect.ts @@ -27,13 +27,13 @@ export default class GroupMultiSelectPrompt extends Pr if (this.isGroupSelected(group)) { this.value = this.value.filter((v: string) => groupedItems.findIndex(i => i.value === v) === -1); } else { - this.value = [...this.value, ...groupedItems.map(v => v.value)]; + this.value = [...this.value, ...groupedItems.map(i => i.value)]; } this.value = Array.from(new Set(this.value)); } else { const selected = this.value.includes(item.value); this.value = selected - ? this.value.filter((value: T['value']) => value !== item.value) + ? this.value.filter((v: T['value']) => v !== item.value) : [...this.value, item.value]; } } @@ -41,10 +41,10 @@ export default class GroupMultiSelectPrompt extends Pr constructor(opts: GroupMultiSelectOptions) { super(opts, false); - this.options = Object.keys(opts.options).reduce((acc, key) => { - acc.push({ value: key, group: true, label: key }, ...opts.options[key].map(opt => ({ ...opt, group: key }))); - return acc; - }, [] as { value: any, [key: string]: any }[]); + this.options = Object.entries(options).flatMap(([key, option]) => [ + { value: key, group: true, label: key }, + ...option.map((opt) => ({ ...opt, group: key })), +]) this.value = [...(opts.initialValues ?? [])]; this.cursor = Math.max( this.options.findIndex(({ value }) => value === opts.cursorAt), diff --git a/packages/prompts/src/index.ts b/packages/prompts/src/index.ts index 8a62ff8f..51672f2a 100644 --- a/packages/prompts/src/index.ts +++ b/packages/prompts/src/index.ts @@ -407,7 +407,7 @@ export const groupMultiselect = [], Value extends const isItem = typeof option.group === 'string'; const next = isItem && (options[options.indexOf(option) + 1] ?? { group: true }); const isLast = isItem && next.group === true; - const prefix = typeof option.group === 'string' ? `${isLast ? S_BAR_END : S_BAR} ` : ''; + const prefix = isItem ? `${isLast ? S_BAR_END : S_BAR} ` : ''; if (state === 'active') { return `${color.dim(prefix)}${color.cyan(S_CHECKBOX_ACTIVE)} ${label} ${ @@ -495,7 +495,7 @@ export const groupMultiselect = [], Value extends .map((option, i, options) => { const selected = this.value.includes(option.value) || (option.group === true && this.isGroupSelected(option.value)); const active = i === this.cursor; - const groupActive = !active && typeof option.group === 'string' && this.options[this.cursor].value === option.group; + const groupActive = !active && typeof option.group === 'string' && this.options[this.cursor].value === option.group; if (groupActive) { return opt(option, selected ? 'group-active-selected' : 'group-active', options); } From f9c7f83d4c86d6e421516dc6e1f15d851009a2cc Mon Sep 17 00:00:00 2001 From: Nate Moore Date: Fri, 24 Feb 2023 19:12:05 -0600 Subject: [PATCH 5/7] fix: undefined `options` --- packages/core/src/prompts/group-multiselect.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/prompts/group-multiselect.ts b/packages/core/src/prompts/group-multiselect.ts index cdb4cd9f..6918b1c5 100644 --- a/packages/core/src/prompts/group-multiselect.ts +++ b/packages/core/src/prompts/group-multiselect.ts @@ -40,7 +40,7 @@ export default class GroupMultiSelectPrompt extends Pr constructor(opts: GroupMultiSelectOptions) { super(opts, false); - + const { options } = opts; this.options = Object.entries(options).flatMap(([key, option]) => [ { value: key, group: true, label: key }, ...option.map((opt) => ({ ...opt, group: key })), From c23fff6dfb3e29f8cad0a1772e5ae8187a210797 Mon Sep 17 00:00:00 2001 From: Nate Moore Date: Fri, 24 Feb 2023 19:12:14 -0600 Subject: [PATCH 6/7] feat: add step log --- examples/changesets/index.ts | 2 +- packages/prompts/src/index.ts | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/examples/changesets/index.ts b/examples/changesets/index.ts index 5994b26c..0dd66534 100644 --- a/examples/changesets/index.ts +++ b/examples/changesets/index.ts @@ -59,7 +59,7 @@ async function main() { if (possiblePackages.length === 0) return; let note = possiblePackages.join('\n'); - p.note(note, `These packages will have a ${color.green('patch')} bump.`); + p.log.step(`These packages will have a ${color.green('patch')} bump.\n${color.dim(note)}`); return possiblePackages } }, diff --git a/packages/prompts/src/index.ts b/packages/prompts/src/index.ts index 51672f2a..7b8a690c 100644 --- a/packages/prompts/src/index.ts +++ b/packages/prompts/src/index.ts @@ -567,6 +567,9 @@ export const log = { success: (message: string) => { log.message(message, { symbol: color.green(S_SUCCESS) }); }, + step: (message: string) => { + log.message(message, { symbol: color.green(S_STEP_SUBMIT) }); + }, warn: (message: string) => { log.message(message, { symbol: color.yellow(S_WARN) }); }, From 8a4a12fbf0326ac1f103f8a90b9169f4528af1e4 Mon Sep 17 00:00:00 2001 From: Nate Moore Date: Fri, 24 Feb 2023 19:13:54 -0600 Subject: [PATCH 7/7] chore: add changesets --- .changeset/dull-boats-drum.md | 6 ++++++ .changeset/tasty-comics-warn.md | 5 +++++ 2 files changed, 11 insertions(+) create mode 100644 .changeset/dull-boats-drum.md create mode 100644 .changeset/tasty-comics-warn.md diff --git a/.changeset/dull-boats-drum.md b/.changeset/dull-boats-drum.md new file mode 100644 index 00000000..c8a03da9 --- /dev/null +++ b/.changeset/dull-boats-drum.md @@ -0,0 +1,6 @@ +--- +'@clack/prompts': minor +'@clack/core': patch +--- + +add `groupMultiselect` prompt diff --git a/.changeset/tasty-comics-warn.md b/.changeset/tasty-comics-warn.md new file mode 100644 index 00000000..db4736e1 --- /dev/null +++ b/.changeset/tasty-comics-warn.md @@ -0,0 +1,5 @@ +--- +'@clack/core': minor +--- + +Add `GroupMultiSelect` prompt