Context
We evaluated @clack/prompts as a replacement for enquirer in pnpm. The overall DX was good, but we hit several gaps during the migration. Below is a summary of what we found — some already have open PRs, the rest are proposals for discussion.
Summary
1. onCancel callback option (per-prompt)
Problem: Every call site that handles Ctrl+C needs the same boilerplate:
const result = await confirm({ message: 'Continue?' })
if (isCancel(result)) {
cancel('Operation cancelled.')
process.exit(0)
}
Proposal: Add an optional onCancel callback to all prompt options. When the callback's return type is never (e.g. process.exit() or throw), the prompt's return type narrows to exclude the cancel symbol:
const result = await confirm({
message: 'Continue?',
onCancel: () => {
cancel('Operation cancelled.')
process.exit(0)
},
})
// result is `boolean` — no isCancel guard needed
Status: Issue #83, PR #544
2. Separator / header rows in select and multiselect
Problem: groupMultiselect supports named groups, but select and regular multiselect have no way to add non-selectable separator or header rows within the options list. We had to drop column header rows (like "Package | Current | Target") that appeared at the top of each group in the old enquirer prompts.
Proposal: Support a separator or header option type:
const options = [
{ type: 'separator', label: 'Package Current Target' },
{ value: 'lodash', label: 'lodash 1.0.0 2.0.0' },
{ value: 'chalk', label: 'chalk 4.0.0 5.0.0' },
]
Separator rows would be rendered but skipped by the cursor and excluded from the result.
Status: PR #547
3. groupMultiselect option type with { value, label, hint }
Problem: groupMultiselect currently only accepts { value, label, hint? } for options. There's no way to mark individual options as disabled within a group, or to add a separator within a group's options.
Proposal: Support disabled: true and { type: 'separator' } within group option arrays, consistent with multiselect.
Status: PR #547
4. Export CANCEL_SYMBOL and add CancelSymbol type
Problem: isCancel(value) checks for typeof value === 'symbol', but the actual symbol (clack:cancel) is not exported from @clack/prompts. This means:
- Tests can't easily create cancel values that pass the
isCancel check
- The
isCancel type guard narrows to the broad symbol type instead of the specific cancel symbol
Proposal: Export CANCEL_SYMBOL and a CancelSymbol type from both @clack/core and @clack/prompts:
import { CANCEL_SYMBOL, isCancel } from '@clack/prompts'
Additionally, update all prompt return types from Promise<T | symbol> to Promise<T | CancelSymbol>. This enables proper TypeScript narrowing:
const result = await text({ message: 'hi' })
if (isCancel(result)) {
// result is CancelSymbol
} else {
// result is string (not string | symbol)
}
5. Per-prompt validate error styling control
Problem: multiselect has a required option with a built-in error message ("Please select at least one option"). But the error message isn't customizable, and there's no general validate function like enquirer had.
For example, pnpm's old enquirer prompts had:
validate(value) {
if (value.length === 0) return 'You must choose at least one package.'
return true
}
Proposal: Add a validate option to multiselect and groupMultiselect:
const selected = await multiselect({
options: [...],
validate: (values) => values.length === 0 ? 'Select at least one package.' : true,
})
6. footer / hint text below the options list
Problem: We previously showed instructional text below the options list:
Enter to start updating. Ctrl-c to cancel.
With clack, there's no footer option on multiselect or groupMultiselect. The workaround is to fold this into the message text, which works but places instructions above the list rather than below.
Proposal: Add a footer option to multiselect and groupMultiselect:
const selected = await multiselect({
message: 'Choose packages to update',
footer: 'Enter to start updating. Ctrl-c to cancel.',
options: [...],
})
7. Header / separator rows in groupMultiselect
Problem: pnpm's old enquirer-based pnpm update --interactive and pnpm audit --fix showed a column header row at the top of each group:
Package Current Target
[dependencies]
typescript-eslint 8.59.3 ❯ 8.59.4 @fathom-frontend/source
vitest 4.1.6 ❯ 4.1.7 @fathom-frontend/source
The header row was a disabled: true choice in enquirer — rendered as non-selectable text. groupMultiselect has no concept of separator, header, or disabled rows within a group. Every option is selectable.
Proposal: Support disabled or { type: 'separator' } entries within group option arrays. Disabled/separator rows are rendered but skipped by the cursor and excluded from the result:
const options: Record<string, OptionValue[]> = {
dependencies: [
{ type: 'header', label: 'Package Current Target' },
{ value: 'lodash', label: 'lodash 1.0.0 2.0.0' },
],
}
Status: PR #547
8. a / i keybindings for groupMultiselect
Problem: The flat multiselect prompt supports a to toggle all and i to invert selection. groupMultiselect does not — only space to toggle individual items (or a whole group via its header). There is no way to select/deselect all items across all groups at once.
Proposal: Add the same a and i keybindings to GroupMultiSelectPrompt, consistent with MultiSelectPrompt. The a key should toggle all items across all groups. The i key should invert all items across all groups.
The relevant code in clack's MultiSelectPrompt (packages/core/src/prompts/multi-select.ts) already has this:
this.on('key', (_char, key) => {
if (key.name === 'a') {
this.toggleAll();
}
if (key.name === 'i') {
this.toggleInvert();
}
});
GroupMultiSelectPrompt just needs the same listener, with toggleAll/toggleInvert operating on all items regardless of group.
9. Label wrapping / column alignment control
Problem: pnpm builds aligned table-style labels using fixed-width columns with padding. The total label width can exceed the terminal width, especially with long URLs:
typescript-eslint 8.59.3 ❯ 8.59.4 @fathom-frontend/source https://typescript-eslint.io/...
When clack renders these labels, wrapTextWithPrefix hard-wraps to terminalWidth - prefixLength, causing the URL (last column) to break onto the next line inconsistently:
│ ◻ typescript-eslint 8.59.3 ❯ 8.59.4 @fathom-frontend/source
│ https://typescript-eslint.io/packages/typescript-eslint
│ ◻ vitest 4.1.6 ❯ 4.1.7 @fathom-frontend/source https://vitest.dev
Some labels fit on one line, others wrap — alignment is visually broken.
Proposal (options):
- Add a
wrap: false option to disable hard wrapping on labels (let content overflow or truncate instead)
- Add a
maxLabelWidth option to truncate labels at a configurable width
- Support a
hint field on groupMultiselect options that renders in a separate column (like multiselect does), so the URL can be separated from the main label
Context
We evaluated
@clack/promptsas a replacement forenquirerin pnpm. The overall DX was good, but we hit several gaps during the migration. Below is a summary of what we found — some already have open PRs, the rest are proposals for discussion.Summary
onCancelcallback option (per-prompt) — [Request] Improve cancellation API #83 / PR feat(prompts): add onCancel callback option to all prompts #544selectandmultiselect— PR fix(prompts): harden separator support in group-multiselect and multi-select #547groupMultiselectoption type with{ value, label, hint }— PR fix(prompts): harden separator support in group-multiselect and multi-select #547CANCEL_SYMBOLand addCancelSymboltypevalidateerror styling controlfooter/hinttext below the options listgroupMultiselect— PR fix(prompts): harden separator support in group-multiselect and multi-select #547a/ikeybindings forgroupMultiselect1.
onCancelcallback option (per-prompt)Problem: Every call site that handles Ctrl+C needs the same boilerplate:
Proposal: Add an optional
onCancelcallback to all prompt options. When the callback's return type isnever(e.g.process.exit()orthrow), the prompt's return type narrows to exclude the cancel symbol:Status: Issue #83, PR #544
2. Separator / header rows in
selectandmultiselectProblem:
groupMultiselectsupports named groups, butselectand regularmultiselecthave no way to add non-selectable separator or header rows within the options list. We had to drop column header rows (like "Package | Current | Target") that appeared at the top of each group in the oldenquirerprompts.Proposal: Support a
separatororheaderoption type:Separator rows would be rendered but skipped by the cursor and excluded from the result.
Status: PR #547
3.
groupMultiselectoption type with{ value, label, hint }Problem:
groupMultiselectcurrently only accepts{ value, label, hint? }for options. There's no way to mark individual options as disabled within a group, or to add a separator within a group's options.Proposal: Support
disabled: trueand{ type: 'separator' }within group option arrays, consistent withmultiselect.Status: PR #547
4. Export
CANCEL_SYMBOLand addCancelSymboltypeProblem:
isCancel(value)checks fortypeof value === 'symbol', but the actual symbol (clack:cancel) is not exported from@clack/prompts. This means:isCancelcheckisCanceltype guard narrows to the broadsymboltype instead of the specific cancel symbolProposal: Export
CANCEL_SYMBOLand aCancelSymboltype from both@clack/coreand@clack/prompts:Additionally, update all prompt return types from
Promise<T | symbol>toPromise<T | CancelSymbol>. This enables proper TypeScript narrowing:5. Per-prompt
validateerror styling controlProblem:
multiselecthas arequiredoption with a built-in error message ("Please select at least one option"). But the error message isn't customizable, and there's no generalvalidatefunction likeenquirerhad.For example, pnpm's old enquirer prompts had:
Proposal: Add a
validateoption tomultiselectandgroupMultiselect:6.
footer/hinttext below the options listProblem: We previously showed instructional text below the options list:
With clack, there's no
footeroption onmultiselectorgroupMultiselect. The workaround is to fold this into themessagetext, which works but places instructions above the list rather than below.Proposal: Add a
footeroption tomultiselectandgroupMultiselect:7. Header / separator rows in
groupMultiselectProblem: pnpm's old
enquirer-basedpnpm update --interactiveandpnpm audit --fixshowed a column header row at the top of each group:The header row was a
disabled: truechoice in enquirer — rendered as non-selectable text.groupMultiselecthas no concept of separator, header, or disabled rows within a group. Every option is selectable.Proposal: Support
disabledor{ type: 'separator' }entries within group option arrays. Disabled/separator rows are rendered but skipped by the cursor and excluded from the result:Status: PR #547
8.
a/ikeybindings forgroupMultiselectProblem: The flat
multiselectprompt supportsato toggle all andito invert selection.groupMultiselectdoes not — onlyspaceto toggle individual items (or a whole group via its header). There is no way to select/deselect all items across all groups at once.Proposal: Add the same
aandikeybindings toGroupMultiSelectPrompt, consistent withMultiSelectPrompt. Theakey should toggle all items across all groups. Theikey should invert all items across all groups.The relevant code in clack's
MultiSelectPrompt(packages/core/src/prompts/multi-select.ts) already has this:GroupMultiSelectPromptjust needs the same listener, withtoggleAll/toggleInvertoperating on all items regardless of group.9. Label wrapping / column alignment control
Problem: pnpm builds aligned table-style labels using fixed-width columns with padding. The total label width can exceed the terminal width, especially with long URLs:
When clack renders these labels,
wrapTextWithPrefixhard-wraps toterminalWidth - prefixLength, causing the URL (last column) to break onto the next line inconsistently:Some labels fit on one line, others wrap — alignment is visually broken.
Proposal (options):
wrap: falseoption to disable hard wrapping on labels (let content overflow or truncate instead)maxLabelWidthoption to truncate labels at a configurable widthhintfield ongroupMultiselectoptions that renders in a separate column (likemultiselectdoes), so the URL can be separated from the main label