Skip to content

Findings from migrating pnpm from enquirer to @clack/prompts #550

@aqeelat

Description

@aqeelat

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    Status
    Needs triage

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions