├── .changeset
├── README.md
├── afraid-socks-deny.md
├── bright-hornets-destroy.md
├── config.json
├── curvy-seals-sit.md
├── dirty-papayas-happen.md
├── empty-buses-wonder.md
├── famous-turkeys-burn.md
├── five-chairs-poke.md
├── free-wasps-decide.md
├── happy-parents-explain.md
├── healthy-candles-admire.md
├── honest-singers-cough.md
├── hot-turkeys-knock.md
├── legal-bags-tie.md
├── lemon-monkeys-help.md
├── mean-mice-train.md
├── mean-turkeys-help.md
├── moody-baboons-greet.md
├── nasty-parrots-laugh.md
├── orange-deers-battle.md
├── pre.json
├── slimy-roses-own.md
├── strong-ravens-greet.md
├── sweet-deers-smell.md
├── tall-cows-fold.md
└── thin-socks-travel.md
├── .editorconfig
├── .gitattributes
├── .github
├── ISSUE_TEMPLATE
│ ├── bug_report.md
│ └── feature_request.md
├── assets
│ ├── clack-dark.gif
│ ├── clack-demo.gif
│ ├── clack-light.gif
│ ├── clack-logs.png
│ └── clack.gif
└── workflows
│ ├── ci.yml
│ ├── format.yml
│ ├── issue-edited.yml
│ ├── issue-opened.yml
│ ├── lint.yml
│ ├── preview.yml
│ ├── release.yml
│ └── require-allow-edits.yml
├── .gitignore
├── .npmrc
├── .nvmrc
├── .vscode
└── settings.json
├── CONTRIBUTING.md
├── LICENSE
├── README.md
├── biome.json
├── build.preset.ts
├── examples
├── basic
│ ├── autocomplete-multiselect.ts
│ ├── autocomplete.ts
│ ├── default-value.ts
│ ├── index.ts
│ ├── package.json
│ ├── path.ts
│ ├── progress.ts
│ ├── spinner-cancel-advanced.ts
│ ├── spinner-cancel.ts
│ ├── spinner-ci.ts
│ ├── spinner-timer.ts
│ ├── spinner.ts
│ ├── stream.ts
│ ├── task-log.ts
│ ├── text-validation.ts
│ └── tsconfig.json
└── changesets
│ ├── index.ts
│ ├── package.json
│ └── tsconfig.json
├── knip.json
├── package.json
├── packages
├── core
│ ├── CHANGELOG.md
│ ├── LICENSE
│ ├── README.md
│ ├── build.config.ts
│ ├── package.json
│ ├── src
│ │ ├── index.ts
│ │ ├── prompts
│ │ │ ├── autocomplete.ts
│ │ │ ├── confirm.ts
│ │ │ ├── group-multiselect.ts
│ │ │ ├── multi-select.ts
│ │ │ ├── password.ts
│ │ │ ├── prompt.ts
│ │ │ ├── select-key.ts
│ │ │ ├── select.ts
│ │ │ ├── suggestion.ts
│ │ │ └── text.ts
│ │ ├── types.ts
│ │ └── utils
│ │ │ ├── index.ts
│ │ │ ├── settings.ts
│ │ │ └── string.ts
│ ├── test
│ │ ├── mock-readable.ts
│ │ ├── mock-writable.ts
│ │ ├── prompts
│ │ │ ├── autocomplete.test.ts
│ │ │ ├── confirm.test.ts
│ │ │ ├── password.test.ts
│ │ │ ├── prompt.test.ts
│ │ │ ├── select.test.ts
│ │ │ ├── suggestion.test.ts
│ │ │ └── text.test.ts
│ │ └── utils.test.ts
│ └── tsconfig.json
└── prompts
│ ├── CHANGELOG.md
│ ├── LICENSE
│ ├── README.md
│ ├── __mocks__
│ └── fs.cjs
│ ├── build.config.ts
│ ├── package.json
│ ├── src
│ ├── autocomplete.ts
│ ├── common.ts
│ ├── confirm.ts
│ ├── group-multi-select.ts
│ ├── group.ts
│ ├── index.ts
│ ├── limit-options.ts
│ ├── log.ts
│ ├── messages.ts
│ ├── multi-select.ts
│ ├── note.ts
│ ├── password.ts
│ ├── path.ts
│ ├── progress-bar.ts
│ ├── select-key.ts
│ ├── select.ts
│ ├── spinner.ts
│ ├── stream.ts
│ ├── suggestion.ts
│ ├── task-log.ts
│ ├── task.ts
│ └── text.ts
│ ├── test
│ ├── __snapshots__
│ │ ├── autocomplete.test.ts.snap
│ │ ├── confirm.test.ts.snap
│ │ ├── group-multi-select.test.ts.snap
│ │ ├── multi-select.test.ts.snap
│ │ ├── note.test.ts.snap
│ │ ├── password.test.ts.snap
│ │ ├── path.test.ts.snap
│ │ ├── progress-bar.test.ts.snap
│ │ ├── select.test.ts.snap
│ │ ├── spinner.test.ts.snap
│ │ ├── suggestion.test.ts.snap
│ │ ├── task-log.test.ts.snap
│ │ └── text.test.ts.snap
│ ├── autocomplete.test.ts
│ ├── confirm.test.ts
│ ├── group-multi-select.test.ts
│ ├── multi-select.test.ts
│ ├── note.test.ts
│ ├── password.test.ts
│ ├── path.test.ts
│ ├── progress-bar.test.ts
│ ├── select.test.ts
│ ├── spinner.test.ts
│ ├── suggestion.test.ts
│ ├── task-log.test.ts
│ ├── test-utils.ts
│ └── text.test.ts
│ ├── tsconfig.json
│ └── vitest.config.ts
├── pnpm-lock.yaml
├── pnpm-workspace.yaml
└── tsconfig.json
/.changeset/README.md:
--------------------------------------------------------------------------------
1 | # Changesets
2 |
3 | Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works
4 | with multi-package repos, or single-package repos to help you version and publish your code. You can
5 | find the full documentation for it [in our repository](https://github.com/changesets/changesets)
6 |
7 | We have a quick list of common questions to get you started engaging with this project in
8 | [our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md)
9 |
--------------------------------------------------------------------------------
/.changeset/afraid-socks-deny.md:
--------------------------------------------------------------------------------
1 | ---
2 | "@clack/prompts": patch
3 | ---
4 |
5 | Fixes multiselect only shows hints on the first item in the options list. Now correctly shows hints for all selected options with hint property.
6 |
--------------------------------------------------------------------------------
/.changeset/bright-hornets-destroy.md:
--------------------------------------------------------------------------------
1 | ---
2 | "@clack/prompts": patch
3 | "@clack/core": patch
4 | ---
5 |
6 | Prevents placeholder from being used as input value in text prompts
7 |
--------------------------------------------------------------------------------
/.changeset/config.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://unpkg.com/@changesets/config@2.3.0/schema.json",
3 | "changelog": "@changesets/cli/changelog",
4 | "commit": false,
5 | "fixed": [],
6 | "linked": [],
7 | "access": "public",
8 | "baseBranch": "main",
9 | "updateInternalDependencies": "patch",
10 | "ignore": ["@example/*"]
11 | }
12 |
--------------------------------------------------------------------------------
/.changeset/curvy-seals-sit.md:
--------------------------------------------------------------------------------
1 | ---
2 | "@clack/prompts": minor
3 | "@clack/core": minor
4 | ---
5 |
6 | Adds suggestion and path prompts
7 |
--------------------------------------------------------------------------------
/.changeset/dirty-papayas-happen.md:
--------------------------------------------------------------------------------
1 | ---
2 | "@clack/prompts": patch
3 | ---
4 |
5 | Exposes a new `SpinnerResult` type to describe the return type of `spinner`
6 |
--------------------------------------------------------------------------------
/.changeset/empty-buses-wonder.md:
--------------------------------------------------------------------------------
1 | ---
2 | "@clack/prompts": minor
3 | ---
4 |
5 | Adds `format` option to the note prompt to allow formatting of individual lines
6 |
--------------------------------------------------------------------------------
/.changeset/famous-turkeys-burn.md:
--------------------------------------------------------------------------------
1 | ---
2 | "@clack/prompts": minor
3 | ---
4 |
5 | Added new `taskLog` prompt for log output which is cleared on success
6 |
--------------------------------------------------------------------------------
/.changeset/five-chairs-poke.md:
--------------------------------------------------------------------------------
1 | ---
2 | "@clack/prompts": minor
3 | "@clack/core": minor
4 | ---
5 |
6 | Add support for customizable spinner cancel and error messages. Users can now customize these messages either per spinner instance or globally via the `updateSettings` function to support multilingual CLIs.
7 |
8 | This update also improves the architecture by exposing the core settings to the prompts package, enabling more consistent default message handling across the codebase.
9 |
10 | ```ts
11 | // Per-instance customization
12 | const spinner = prompts.spinner({
13 | cancelMessage: 'Operación cancelada', // "Operation cancelled" in Spanish
14 | errorMessage: 'Se produjo un error' // "An error occurred" in Spanish
15 | });
16 |
17 | // Global customization via updateSettings
18 | prompts.updateSettings({
19 | messages: {
20 | cancel: 'Operación cancelada', // "Operation cancelled" in Spanish
21 | error: 'Se produjo un error' // "An error occurred" in Spanish
22 | }
23 | });
24 |
25 | // Settings can now be accessed directly
26 | console.log(prompts.settings.messages.cancel); // "Operación cancelada"
27 |
28 | // Direct options take priority over global settings
29 | const spinner = prompts.spinner({
30 | cancelMessage: 'Cancelled', // This will be used instead of the global setting
31 | });
32 | ```
33 |
--------------------------------------------------------------------------------
/.changeset/free-wasps-decide.md:
--------------------------------------------------------------------------------
1 | ---
2 | "@clack/prompts": patch
3 | "@clack/core": patch
4 | ---
5 |
6 | Adds a new `selectableGroups` boolean to the group multi-select prompt. Using `selectableGroups: false` will disable the ability to select a top-level group, but still allow every child to be selected individually.
7 |
--------------------------------------------------------------------------------
/.changeset/happy-parents-explain.md:
--------------------------------------------------------------------------------
1 | ---
2 | "@clack/prompts": minor
3 | ---
4 |
5 | Adds a new `groupSpacing` option to grouped multi-select prompts. If set to an integer greater than 0, it will add that number of new lines between each group.
6 |
--------------------------------------------------------------------------------
/.changeset/healthy-candles-admire.md:
--------------------------------------------------------------------------------
1 | ---
2 | "@clack/prompts": patch
3 | ---
4 |
5 | Updates all prompts to accept a custom `output` and `input` stream
6 |
--------------------------------------------------------------------------------
/.changeset/honest-singers-cough.md:
--------------------------------------------------------------------------------
1 | ---
2 | "@clack/prompts": patch
3 | ---
4 |
5 | Messages passed to the `stop` method of a spinner no longer have dots stripped.
6 |
--------------------------------------------------------------------------------
/.changeset/hot-turkeys-knock.md:
--------------------------------------------------------------------------------
1 | ---
2 | "@clack/core": patch
3 | ---
4 |
5 | Fixes an edge case for placeholder values. Previously, when pressing `enter` on an empty prompt, placeholder values would be ignored. Now, placeholder values are treated as the prompt value.
6 |
--------------------------------------------------------------------------------
/.changeset/legal-bags-tie.md:
--------------------------------------------------------------------------------
1 | ---
2 | "@clack/prompts": major
3 | "@clack/core": major
4 | ---
5 |
6 | The package is now distributed as ESM-only. In `v0` releases, the package was dual-published as CJS and ESM.
7 |
8 | For existing CJS projects using Node v20+, please see Node's guide on [Loading ECMAScript modules using `require()`](https://nodejs.org/docs/latest-v20.x/api/modules.html#loading-ecmascript-modules-using-require).
9 |
--------------------------------------------------------------------------------
/.changeset/lemon-monkeys-help.md:
--------------------------------------------------------------------------------
1 | ---
2 | "@clack/core": patch
3 | ---
4 |
5 | Fix "TTY initialization failed: uv_tty_init returned EBADF (bad file descriptor)" error happening on Windows for non-tty terminals.
6 |
--------------------------------------------------------------------------------
/.changeset/mean-mice-train.md:
--------------------------------------------------------------------------------
1 | ---
2 | "@clack/core": patch
3 | ---
4 |
5 | Validates initial values immediately when using text prompts with initialValue and validate props.
6 |
--------------------------------------------------------------------------------
/.changeset/mean-turkeys-help.md:
--------------------------------------------------------------------------------
1 | ---
2 | "@clack/prompts": patch
3 | "@clack/core": patch
4 | ---
5 |
6 | Changes `placeholder` to be a visual hint rather than a tabbable value.
7 |
--------------------------------------------------------------------------------
/.changeset/moody-baboons-greet.md:
--------------------------------------------------------------------------------
1 | ---
2 | "@clack/core": patch
3 | ---
4 |
5 | Set initial values of auto complete prompt to first option when multiple is false.
6 |
--------------------------------------------------------------------------------
/.changeset/nasty-parrots-laugh.md:
--------------------------------------------------------------------------------
1 | ---
2 | "@clack/prompts": minor
3 | "@clack/core": minor
4 | ---
5 |
6 | Adds `AutocompletePrompt` to core with comprehensive tests and implement both `autocomplete` and `autocomplete-multiselect` components in prompts package.
7 |
--------------------------------------------------------------------------------
/.changeset/orange-deers-battle.md:
--------------------------------------------------------------------------------
1 | ---
2 | "@clack/prompts": minor
3 | ---
4 |
5 | Adds support for detecting spinner cancellation via CTRL+C. This allows for graceful handling of user interruptions during long-running operations.
6 |
--------------------------------------------------------------------------------
/.changeset/pre.json:
--------------------------------------------------------------------------------
1 | {
2 | "mode": "pre",
3 | "tag": "alpha",
4 | "initialVersions": {
5 | "@example/basic": "0.0.0",
6 | "@example/changesets": "0.0.0",
7 | "@clack/core": "0.4.1",
8 | "@clack/prompts": "0.10.0"
9 | },
10 | "changesets": [
11 | "afraid-socks-deny",
12 | "dirty-papayas-happen",
13 | "empty-buses-wonder",
14 | "famous-turkeys-burn",
15 | "five-chairs-poke",
16 | "free-wasps-decide",
17 | "happy-parents-explain",
18 | "healthy-candles-admire",
19 | "honest-singers-cough",
20 | "hot-turkeys-knock",
21 | "legal-bags-tie",
22 | "lemon-monkeys-help",
23 | "nasty-parrots-laugh",
24 | "orange-deers-battle",
25 | "slimy-roses-own",
26 | "tall-cows-fold",
27 | "thin-socks-travel"
28 | ]
29 | }
30 |
--------------------------------------------------------------------------------
/.changeset/slimy-roses-own.md:
--------------------------------------------------------------------------------
1 | ---
2 | "@clack/prompts": minor
3 | ---
4 |
5 | Adds new `progress` prompt to display a progess-bar
6 |
--------------------------------------------------------------------------------
/.changeset/strong-ravens-greet.md:
--------------------------------------------------------------------------------
1 | ---
2 | "@clack/prompts": minor
3 | ---
4 |
5 | Add a `required` option to autocomplete multiselect.
6 |
--------------------------------------------------------------------------------
/.changeset/sweet-deers-smell.md:
--------------------------------------------------------------------------------
1 | ---
2 | "@clack/core": patch
3 | ---
4 |
5 | Avoid passing initial values to core when using auto complete prompt
6 |
--------------------------------------------------------------------------------
/.changeset/tall-cows-fold.md:
--------------------------------------------------------------------------------
1 | ---
2 | "@clack/prompts": patch
3 | ---
4 |
5 | Fix spinner's dots behavior with custom frames
6 |
--------------------------------------------------------------------------------
/.changeset/thin-socks-travel.md:
--------------------------------------------------------------------------------
1 | ---
2 | "@clack/prompts": minor
3 | ---
4 |
5 | Added support for custom frames in spinner prompt
6 |
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | root = true
2 |
3 | [*]
4 | indent_style = tab
5 | end_of_line = lf
6 | charset = utf-8
7 | trim_trailing_whitespace = true
8 | insert_final_newline = true
9 |
10 | [*.yml]
11 | indent_style = space
12 | indent_size = 2
13 |
--------------------------------------------------------------------------------
/.gitattributes:
--------------------------------------------------------------------------------
1 | * text=auto eol=lf
2 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/bug_report.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Bug report
3 | about: Create a report to help us improve
4 | title: '[Bug] '
5 | labels: bug
6 | assignees: ''
7 | ---
8 |
9 | **Environment**
10 |
11 | - OS: [e.g. macOS, Windows]
12 | - Node Version: [e.g. v18.14.0]
13 | - Package: [e.g. `@clack/prompts`, `@clack/core`]
14 | - Package Version: [e.g. v0.2.0]
15 |
16 | **Describe the bug**
17 | A clear and concise description of what the bug is.
18 |
19 | **To Reproduce**
20 | Include a link to a minimal reproduction using [`node.new`](https://node.new/)
21 |
22 | Steps to reproduce the behavior:
23 |
24 | - Include reproduction steps
25 |
26 | **Expected behavior**
27 | A clear and concise description of what you expected to happen.
28 |
29 | **Additional Information**
30 | If applicable, add screenshots to help explain your problem.
31 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/feature_request.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Feature request
3 | about: Suggest an idea for this project
4 | title: '[Request]'
5 | labels: enhancement
6 | assignees: ''
7 | ---
8 |
9 | **Is your feature request related to a problem? Please describe.**
10 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
11 |
12 | **Describe the solution you'd like**
13 | A clear and concise description of what you want to happen.
14 |
15 | **Describe alternatives you've considered**
16 | A clear and concise description of any alternative solutions or features you've considered.
17 |
18 | **Additional context**
19 | Add any other context or screenshots about the feature request here.
20 |
--------------------------------------------------------------------------------
/.github/assets/clack-dark.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bombshell-dev/clack/94fee2a90148c5c09503e9b6d7179fa740f08d7e/.github/assets/clack-dark.gif
--------------------------------------------------------------------------------
/.github/assets/clack-demo.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bombshell-dev/clack/94fee2a90148c5c09503e9b6d7179fa740f08d7e/.github/assets/clack-demo.gif
--------------------------------------------------------------------------------
/.github/assets/clack-light.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bombshell-dev/clack/94fee2a90148c5c09503e9b6d7179fa740f08d7e/.github/assets/clack-light.gif
--------------------------------------------------------------------------------
/.github/assets/clack-logs.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bombshell-dev/clack/94fee2a90148c5c09503e9b6d7179fa740f08d7e/.github/assets/clack-logs.png
--------------------------------------------------------------------------------
/.github/assets/clack.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bombshell-dev/clack/94fee2a90148c5c09503e9b6d7179fa740f08d7e/.github/assets/clack.gif
--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | name: CI
2 |
3 | on:
4 | push:
5 | branches:
6 | - main
7 | pull_request_target:
8 | types: [assigned, opened, synchronize, reopened]
9 | branches:
10 | - main
11 |
12 | # Automatically cancel in-progress actions on the same branch
13 | concurrency:
14 | group: ${{ github.workflow }}-${{ github.event_name == 'pull_request_target' && github.head_ref || github.ref }}
15 | cancel-in-progress: true
16 |
17 | jobs:
18 | build:
19 | name: Build Packages
20 | runs-on: ubuntu-latest
21 | steps:
22 | - uses: actions/checkout@v4
23 | - uses: pnpm/action-setup@v4
24 | - uses: actions/setup-node@v4
25 | with:
26 | node-version: 20
27 | cache: "pnpm"
28 | - run: pnpm install
29 | - run: pnpm run build
30 | - run: pnpm run types
31 | - run: pnpm run test
32 |
--------------------------------------------------------------------------------
/.github/workflows/format.yml:
--------------------------------------------------------------------------------
1 | name: Format
2 |
3 | on:
4 | workflow_dispatch:
5 | push:
6 | branches:
7 | - main
8 |
9 | jobs:
10 | format:
11 | if: github.repository_owner == 'bombshell-dev'
12 | uses: bombshell-dev/automation/.github/workflows/format.yml@main
13 | secrets: inherit
14 |
--------------------------------------------------------------------------------
/.github/workflows/issue-edited.yml:
--------------------------------------------------------------------------------
1 | name: issue edited
2 |
3 | on:
4 | issues:
5 | types:
6 | - edited
7 | - labeled
8 |
9 | jobs:
10 | move-issue-to-backlog:
11 | name: move issue to backlog
12 | uses: bombshell-dev/automation/.github/workflows/move-issue-to-backlog.yml@main
13 | secrets:
14 | BOT_APP_ID: ${{ secrets.BOT_APP_ID }}
15 | BOT_PRIVATE_KEY: ${{ secrets.BOT_PRIVATE_KEY }}
16 |
--------------------------------------------------------------------------------
/.github/workflows/issue-opened.yml:
--------------------------------------------------------------------------------
1 | name: Issue opened
2 |
3 | on:
4 | issues:
5 | types:
6 | - reopened
7 | - opened
8 |
9 | jobs:
10 | add-issue-to-project:
11 | name: add issue to project
12 | uses: bombshell-dev/automation/.github/workflows/add-issue-to-project.yml@main
13 | secrets:
14 | BOT_APP_ID: ${{ secrets.BOT_APP_ID }}
15 | BOT_PRIVATE_KEY: ${{ secrets.BOT_PRIVATE_KEY }}
16 |
--------------------------------------------------------------------------------
/.github/workflows/lint.yml:
--------------------------------------------------------------------------------
1 | name: Lint
2 |
3 | on:
4 | push:
5 | branches:
6 | - main
7 | pull_request:
8 |
9 | jobs:
10 | lint:
11 | name: Lint
12 | runs-on: ubuntu-latest
13 | steps:
14 | - uses: actions/checkout@v4
15 | - uses: pnpm/action-setup@v4
16 | - uses: actions/setup-node@v4
17 | with:
18 | node-version: 20
19 | cache: "pnpm"
20 | - run: pnpm install
21 | - run: pnpm run build
22 | - run: pnpm run types
23 | - run: pnpm run deps
24 | env:
25 | NODE_ENV: production
26 |
--------------------------------------------------------------------------------
/.github/workflows/preview.yml:
--------------------------------------------------------------------------------
1 | name: Preview
2 | on: [push, pull_request]
3 |
4 | jobs:
5 | build:
6 | if: github.repository == 'bombshell-dev/clack'
7 | runs-on: ubuntu-latest
8 |
9 | steps:
10 | - name: Checkout
11 | uses: actions/checkout@v4
12 |
13 | - name: Install pnpm
14 | uses: pnpm/action-setup@v4
15 |
16 | - name: Setup Node
17 | uses: actions/setup-node@v4
18 | with:
19 | node-version: 20
20 | registry-url: https://registry.npmjs.org/
21 | cache: "pnpm"
22 |
23 | - name: Install dependencies
24 | run: pnpm install
25 |
26 | - run: pnpx pkg-pr-new publish './packages/*' --template './examples/*'
27 |
--------------------------------------------------------------------------------
/.github/workflows/release.yml:
--------------------------------------------------------------------------------
1 | name: Release
2 |
3 | on:
4 | push:
5 | branches: [main, v0]
6 |
7 | permissions:
8 | contents: write
9 | pull-requests: write
10 | packages: write
11 |
12 | # Automatically cancel in-progress actions on the same branch
13 | concurrency:
14 | group: ${{ github.workflow }}-${{ github.event_name == 'pull_request_target' && github.head_ref || github.ref }}
15 | cancel-in-progress: true
16 |
17 | jobs:
18 | release:
19 | name: Release
20 | runs-on: ubuntu-latest
21 | steps:
22 | - uses: actions/checkout@v4
23 | - uses: pnpm/action-setup@v4
24 | - uses: actions/setup-node@v4
25 | with:
26 | node-version: 20
27 | cache: "pnpm"
28 | - run: pnpm run ci:install
29 | - run: pnpm run ci:prepublish
30 | - name: Create Release Pull Request or Publish to npm
31 | id: changesets
32 | uses: changesets/action@v1
33 | with:
34 | version: pnpm run ci:version
35 | publish: pnpm run ci:publish
36 | commit: "[ci] release"
37 | title: "[ci] release"
38 | env:
39 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
40 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
41 |
--------------------------------------------------------------------------------
/.github/workflows/require-allow-edits.yml:
--------------------------------------------------------------------------------
1 | name: Require “Allow Edits”
2 |
3 | on: [pull_request_target]
4 |
5 | permissions:
6 | contents: read
7 |
8 | jobs:
9 | _:
10 | permissions:
11 | pull-requests: read
12 |
13 | name: "Require “Allow Edits”"
14 |
15 | runs-on: ubuntu-latest
16 |
17 | steps:
18 | - uses: ljharb/require-allow-edits@v2
19 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 |
3 | # Logs
4 | logs
5 | *.log
6 | npm-debug.log*
7 | yarn-debug.log*
8 | yarn-error.log*
9 | lerna-debug.log*
10 | .pnpm-debug.log*
11 |
12 | # Diagnostic reports (https://nodejs.org/api/report.html)
13 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
14 |
15 | # Runtime data
16 | pids
17 | *.pid
18 | *.seed
19 | *.pid.lock
20 |
21 | # Directory for instrumented libs generated by jscoverage/JSCover
22 | lib-cov
23 |
24 | # Coverage directory used by tools like istanbul
25 | coverage
26 | *.lcov
27 |
28 | # nyc test coverage
29 | .nyc_output
30 |
31 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
32 | .grunt
33 |
34 | # Bower dependency directory (https://bower.io/)
35 | bower_components
36 |
37 | # node-waf configuration
38 | .lock-wscript
39 |
40 | # Compiled binary addons (https://nodejs.org/api/addons.html)
41 | build/Release
42 |
43 | # Dependency directories
44 | node_modules/
45 | jspm_packages/
46 |
47 | # Snowpack dependency directory (https://snowpack.dev/)
48 | web_modules/
49 |
50 | # TypeScript cache
51 | *.tsbuildinfo
52 |
53 | # Optional npm cache directory
54 | .npm
55 |
56 | # Optional eslint cache
57 | .eslintcache
58 |
59 | # Optional stylelint cache
60 | .stylelintcache
61 |
62 | # Microbundle cache
63 | .rpt2_cache/
64 | .rts2_cache_cjs/
65 | .rts2_cache_es/
66 | .rts2_cache_umd/
67 |
68 | # Optional REPL history
69 | .node_repl_history
70 |
71 | # Output of 'npm pack'
72 | *.tgz
73 |
74 | # Yarn Integrity file
75 | .yarn-integrity
76 |
77 | # dotenv environment variable files
78 | .env
79 | .env.development.local
80 | .env.test.local
81 | .env.production.local
82 | .env.local
83 |
84 | # parcel-bundler cache (https://parceljs.org/)
85 | .cache
86 | .parcel-cache
87 |
88 | # Next.js build output
89 | .next
90 | out
91 |
92 | # Nuxt.js build / generate output
93 | .nuxt
94 | dist
95 |
96 | # Gatsby files
97 | .cache/
98 | # Comment in the public line in if your project uses Gatsby and not Next.js
99 | # https://nextjs.org/blog/next-9-1#public-directory-support
100 | # public
101 |
102 | # vuepress build output
103 | .vuepress/dist
104 |
105 | # vuepress v2.x temp and cache directory
106 | .temp
107 | .cache
108 |
109 | # Docusaurus cache and generated files
110 | .docusaurus
111 |
112 | # Serverless directories
113 | .serverless/
114 |
115 | # FuseBox cache
116 | .fusebox/
117 |
118 | # DynamoDB Local files
119 | .dynamodb/
120 |
121 | # TernJS port file
122 | .tern-port
123 |
124 | # Stores VSCode versions used for testing VSCode extensions
125 | .vscode-test
126 |
127 | # yarn v2
128 | .yarn/cache
129 | .yarn/unplugged
130 | .yarn/build-state.yml
131 | .yarn/install-state.gz
132 | .pnp.*
133 |
--------------------------------------------------------------------------------
/.npmrc:
--------------------------------------------------------------------------------
1 | # Important! Never install from registry even when new version is available
2 | prefer-workspace-packages=true
3 | link-workspace-packages=true
4 | save-workspace-protocol=false
5 | auto-install-peers=false
6 |
--------------------------------------------------------------------------------
/.nvmrc:
--------------------------------------------------------------------------------
1 | 20.18.1
2 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "typescript.tsdk": "node_modules/typescript/lib"
3 | }
4 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | packages/core/LICENSE
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 | Effortlessly build beautiful command-line apps 🪄
11 |
12 | @clack/core
: unstyled, extensible primitives for CLIs
13 | @clack/prompts
: beautiful, ready-to-use CLI prompt components
14 |
15 | > [!WARNING]
16 | > Clack's `main` branch is tracking the [`alpha` release line for `v1.0.0+`](https://github.com/bombshell-dev/clack/pull/250). To view the relatively stable `v0` line, please browse the [v0](https://github.com/bombshell-dev/clack/tree/v0) branch.
17 |
18 |
19 |
20 |
21 | @clack/prompts
in action
22 |
23 |
24 |
25 | https://user-images.githubusercontent.com/7118177/219649990-7afcb64a-246d-4ad2-9767-325298959790.mp4
26 |
27 |
28 |
--------------------------------------------------------------------------------
/biome.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
3 | "vcs": { "enabled": false, "clientKind": "git", "useIgnoreFile": false },
4 | "files": { "ignoreUnknown": false, "ignore": ["**/dist/**"] },
5 | "formatter": {
6 | "enabled": true,
7 | "useEditorconfig": true,
8 | "formatWithErrors": false,
9 | "indentStyle": "tab",
10 | "indentWidth": 2,
11 | "lineEnding": "lf",
12 | "lineWidth": 100,
13 | "attributePosition": "auto",
14 | "bracketSpacing": true,
15 | "ignore": [".github/workflows/**/*.yml", ".changeset/**/*.md", "**/pnpm-lock.yaml"]
16 | },
17 | "organizeImports": { "enabled": true },
18 | "linter": {
19 | "enabled": true,
20 | "rules": { "recommended": true, "suspicious": { "noExplicitAny": "off" } }
21 | },
22 | "javascript": {
23 | "formatter": {
24 | "jsxQuoteStyle": "double",
25 | "quoteProperties": "asNeeded",
26 | "trailingCommas": "es5",
27 | "semicolons": "always",
28 | "arrowParentheses": "always",
29 | "bracketSameLine": false,
30 | "quoteStyle": "single",
31 | "attributePosition": "auto",
32 | "bracketSpacing": true
33 | }
34 | },
35 | "overrides": [
36 | {
37 | "include": ["*.json", "*.toml", "*.yml"],
38 | "formatter": { "indentStyle": "space" }
39 | }
40 | ]
41 | }
42 |
--------------------------------------------------------------------------------
/build.preset.ts:
--------------------------------------------------------------------------------
1 | import { definePreset } from 'unbuild';
2 |
3 | // @see https://github.com/unjs/unbuild
4 | export default definePreset({
5 | clean: true,
6 | declaration: 'node16',
7 | sourcemap: true,
8 | rollup: {
9 | emitCJS: false,
10 | inlineDependencies: true,
11 | esbuild: {
12 | minify: true,
13 | },
14 | },
15 | });
16 |
--------------------------------------------------------------------------------
/examples/basic/autocomplete-multiselect.ts:
--------------------------------------------------------------------------------
1 | import * as p from '@clack/prompts';
2 | import color from 'picocolors';
3 |
4 | /**
5 | * Example demonstrating the integrated autocomplete multiselect component
6 | * Which combines filtering and selection in a single interface
7 | */
8 |
9 | async function main() {
10 | console.clear();
11 |
12 | p.intro(`${color.bgCyan(color.black(' Integrated Autocomplete Multiselect Example '))}`);
13 |
14 | p.note(
15 | `
16 | ${color.cyan('Filter and select multiple items in a single interface:')}
17 | - ${color.yellow('Type')} to filter the list in real-time
18 | - Use ${color.yellow('up/down arrows')} to navigate with improved stability
19 | - Press ${color.yellow('Space')} to select/deselect the highlighted item ${color.green('(multiple selections allowed)')}
20 | - Use ${color.yellow('Backspace')} to modify your filter text when searching for different options
21 | - Press ${color.yellow('Enter')} when done selecting all items
22 | - Press ${color.yellow('Ctrl+C')} to cancel
23 | `,
24 | 'Instructions'
25 | );
26 |
27 | // Frameworks in alphabetical order
28 | const frameworks = [
29 | { value: 'angular', label: 'Angular', hint: 'Frontend/UI' },
30 | { value: 'django', label: 'Django', hint: 'Python Backend' },
31 | { value: 'dotnet', label: '.NET Core', hint: 'C# Backend' },
32 | { value: 'electron', label: 'Electron', hint: 'Desktop' },
33 | { value: 'express', label: 'Express', hint: 'Node.js Backend' },
34 | { value: 'flask', label: 'Flask', hint: 'Python Backend' },
35 | { value: 'flutter', label: 'Flutter', hint: 'Mobile' },
36 | { value: 'laravel', label: 'Laravel', hint: 'PHP Backend' },
37 | { value: 'nestjs', label: 'NestJS', hint: 'Node.js Backend' },
38 | { value: 'nextjs', label: 'Next.js', hint: 'React Framework' },
39 | { value: 'nuxt', label: 'Nuxt.js', hint: 'Vue Framework' },
40 | { value: 'rails', label: 'Ruby on Rails', hint: 'Ruby Backend' },
41 | { value: 'react', label: 'React', hint: 'Frontend/UI' },
42 | { value: 'reactnative', label: 'React Native', hint: 'Mobile' },
43 | { value: 'spring', label: 'Spring Boot', hint: 'Java Backend' },
44 | { value: 'svelte', label: 'Svelte', hint: 'Frontend/UI' },
45 | { value: 'tauri', label: 'Tauri', hint: 'Desktop' },
46 | { value: 'vue', label: 'Vue.js', hint: 'Frontend/UI' },
47 | ];
48 |
49 | // Use the new integrated autocompleteMultiselect component
50 | const result = await p.autocompleteMultiselect({
51 | message: 'Select frameworks (type to filter)',
52 | options: frameworks,
53 | placeholder: 'Type to filter...',
54 | maxItems: 8,
55 | });
56 |
57 | if (p.isCancel(result)) {
58 | p.cancel('Operation cancelled.');
59 | process.exit(0);
60 | }
61 |
62 | // Type guard: if not a cancel symbol, result must be a string array
63 | function isStringArray(value: unknown): value is string[] {
64 | return Array.isArray(value) && value.every((item) => typeof item === 'string');
65 | }
66 |
67 | // We can now use the type guard to ensure type safety
68 | if (!isStringArray(result)) {
69 | throw new Error('Unexpected result type');
70 | }
71 |
72 | const selectedFrameworks = result;
73 |
74 | // If no items selected, show a message
75 | if (selectedFrameworks.length === 0) {
76 | p.note('No frameworks were selected', 'Empty Selection');
77 | process.exit(0);
78 | }
79 |
80 | // Display selected frameworks with detailed information
81 | p.note(
82 | `You selected ${color.green(selectedFrameworks.length)} frameworks:`,
83 | 'Selection Complete'
84 | );
85 |
86 | // Show each selected framework with its details
87 | const selectedDetails = selectedFrameworks
88 | .map((value) => {
89 | const framework = frameworks.find((f) => f.value === value);
90 | return framework
91 | ? `${color.cyan(framework.label)} ${color.dim(`- ${framework.hint}`)}`
92 | : value;
93 | })
94 | .join('\n');
95 |
96 | p.log.message(selectedDetails);
97 | p.outro(`Successfully selected ${color.green(selectedFrameworks.length)} frameworks.`);
98 | }
99 |
100 | main().catch(console.error);
101 |
--------------------------------------------------------------------------------
/examples/basic/autocomplete.ts:
--------------------------------------------------------------------------------
1 | import * as p from '@clack/prompts';
2 | import color from 'picocolors';
3 |
4 | async function main() {
5 | console.clear();
6 |
7 | p.intro(`${color.bgCyan(color.black(' Autocomplete Example '))}`);
8 |
9 | p.note(
10 | `
11 | ${color.cyan('This example demonstrates the type-ahead autocomplete feature:')}
12 | - ${color.yellow('Type')} to filter the list in real-time
13 | - Use ${color.yellow('up/down arrows')} to navigate the filtered results
14 | - Press ${color.yellow('Enter')} to select the highlighted option
15 | - Press ${color.yellow('Ctrl+C')} to cancel
16 | `,
17 | 'Instructions'
18 | );
19 |
20 | const countries = [
21 | { value: 'us', label: 'United States', hint: 'NA' },
22 | { value: 'ca', label: 'Canada', hint: 'NA' },
23 | { value: 'mx', label: 'Mexico', hint: 'NA' },
24 | { value: 'br', label: 'Brazil', hint: 'SA' },
25 | { value: 'ar', label: 'Argentina', hint: 'SA' },
26 | { value: 'uk', label: 'United Kingdom', hint: 'EU' },
27 | { value: 'fr', label: 'France', hint: 'EU' },
28 | { value: 'de', label: 'Germany', hint: 'EU' },
29 | { value: 'it', label: 'Italy', hint: 'EU' },
30 | { value: 'es', label: 'Spain', hint: 'EU' },
31 | { value: 'pt', label: 'Portugal', hint: 'EU' },
32 | { value: 'ru', label: 'Russia', hint: 'EU/AS' },
33 | { value: 'cn', label: 'China', hint: 'AS' },
34 | { value: 'jp', label: 'Japan', hint: 'AS' },
35 | { value: 'in', label: 'India', hint: 'AS' },
36 | { value: 'kr', label: 'South Korea', hint: 'AS' },
37 | { value: 'au', label: 'Australia', hint: 'OC' },
38 | { value: 'nz', label: 'New Zealand', hint: 'OC' },
39 | { value: 'za', label: 'South Africa', hint: 'AF' },
40 | { value: 'eg', label: 'Egypt', hint: 'AF' },
41 | ];
42 |
43 | const result = await p.autocomplete({
44 | message: 'Select a country',
45 | options: countries,
46 | placeholder: 'Type to search countries...',
47 | maxItems: 8,
48 | });
49 |
50 | if (p.isCancel(result)) {
51 | p.cancel('Operation cancelled.');
52 | process.exit(0);
53 | }
54 |
55 | const selected = countries.find((c) => c.value === result);
56 | p.outro(`You selected: ${color.cyan(selected?.label)} (${color.yellow(selected?.hint)})`);
57 | }
58 |
59 | main().catch(console.error);
60 |
--------------------------------------------------------------------------------
/examples/basic/default-value.ts:
--------------------------------------------------------------------------------
1 | import * as p from '@clack/prompts';
2 | import color from 'picocolors';
3 |
4 | async function main() {
5 | const defaultPath = 'my-project';
6 |
7 | const result = await p.text({
8 | message: 'Enter the directory to bootstrap the project',
9 | placeholder: ` (hit Enter to use '${defaultPath}')`,
10 | defaultValue: defaultPath,
11 | validate: (value) => {
12 | if (!value) {
13 | return 'Directory is required';
14 | }
15 | if (value.includes(' ')) {
16 | return 'Directory cannot contain spaces';
17 | }
18 | return undefined;
19 | },
20 | });
21 |
22 | if (p.isCancel(result)) {
23 | p.cancel('Operation cancelled.');
24 | process.exit(0);
25 | }
26 |
27 | p.outro(`Let's bootstrap the project in ${color.cyan(result)}`);
28 | }
29 |
30 | main().catch(console.error);
31 |
--------------------------------------------------------------------------------
/examples/basic/index.ts:
--------------------------------------------------------------------------------
1 | import { setTimeout } from 'node:timers/promises';
2 | import * as p from '@clack/prompts';
3 | import color from 'picocolors';
4 |
5 | async function main() {
6 | console.clear();
7 |
8 | await setTimeout(1000);
9 |
10 | p.updateSettings({
11 | aliases: {
12 | w: 'up',
13 | s: 'down',
14 | a: 'left',
15 | d: 'right',
16 | },
17 | });
18 |
19 | p.intro(`${color.bgCyan(color.black(' create-app '))}`);
20 |
21 | const project = await p.group(
22 | {
23 | path: () =>
24 | p.text({
25 | message: 'Where should we create your project?',
26 | placeholder: './sparkling-solid',
27 | validate: (value) => {
28 | if (!value) return 'Please enter a path.';
29 | if (value[0] !== '.') return 'Please enter a relative path.';
30 | },
31 | }),
32 | password: () =>
33 | p.password({
34 | message: 'Provide a password',
35 | validate: (value) => {
36 | if (!value) return 'Please enter a password.';
37 | if (value.length < 5) return 'Password should have at least 5 characters.';
38 | },
39 | }),
40 | type: ({ results }) =>
41 | p.select({
42 | message: `Pick a project type within "${results.path}"`,
43 | initialValue: 'ts',
44 | maxItems: 5,
45 | options: [
46 | { value: 'ts', label: 'TypeScript' },
47 | { value: 'js', label: 'JavaScript' },
48 | { value: 'rust', label: 'Rust' },
49 | { value: 'go', label: 'Go' },
50 | { value: 'python', label: 'Python' },
51 | { value: 'coffee', label: 'CoffeeScript', hint: 'oh no' },
52 | ],
53 | }),
54 | tools: () =>
55 | p.multiselect({
56 | message: 'Select additional tools.',
57 | initialValues: ['prettier', 'eslint'],
58 | options: [
59 | { value: 'prettier', label: 'Prettier', hint: 'recommended' },
60 | { value: 'eslint', label: 'ESLint', hint: 'recommended' },
61 | { value: 'stylelint', label: 'Stylelint' },
62 | { value: 'gh-action', label: 'GitHub Action' },
63 | ],
64 | }),
65 | install: () =>
66 | p.confirm({
67 | message: 'Install dependencies?',
68 | initialValue: false,
69 | }),
70 | },
71 | {
72 | onCancel: () => {
73 | p.cancel('Operation cancelled.');
74 | process.exit(0);
75 | },
76 | }
77 | );
78 |
79 | if (project.install) {
80 | const s = p.spinner();
81 | s.start('Installing via pnpm');
82 | await setTimeout(2500);
83 | s.stop('Installed via pnpm');
84 | }
85 |
86 | const nextSteps = `cd ${project.path} \n${project.install ? '' : 'pnpm install\n'}pnpm dev`;
87 |
88 | p.note(nextSteps, 'Next steps.');
89 |
90 | p.outro(`Problems? ${color.underline(color.cyan('https://example.com/issues'))}`);
91 | }
92 |
93 | main().catch(console.error);
94 |
--------------------------------------------------------------------------------
/examples/basic/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@example/basic",
3 | "private": true,
4 | "version": "0.0.0",
5 | "type": "module",
6 | "dependencies": {
7 | "@clack/prompts": "workspace:*",
8 | "picocolors": "^1.0.0",
9 | "jiti": "^1.17.0"
10 | },
11 | "scripts": {
12 | "start": "jiti ./index.ts",
13 | "stream": "jiti ./stream.ts",
14 | "progress": "jiti ./progress.ts",
15 | "spinner": "jiti ./spinner.ts",
16 | "path": "jiti ./path.ts",
17 | "spinner-ci": "npx cross-env CI=\"true\" jiti ./spinner-ci.ts",
18 | "spinner-timer": "jiti ./spinner-timer.ts",
19 | "task-log": "jiti ./task-log.ts"
20 | },
21 | "devDependencies": {
22 | "cross-env": "^7.0.3"
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/examples/basic/path.ts:
--------------------------------------------------------------------------------
1 | import * as p from '@clack/prompts';
2 |
3 | async function demo() {
4 | p.intro('path start...');
5 |
6 | const path = await p.path({
7 | message: 'Read file',
8 | });
9 |
10 | p.outro('path stop...');
11 | }
12 |
13 | void demo();
14 |
--------------------------------------------------------------------------------
/examples/basic/progress.ts:
--------------------------------------------------------------------------------
1 | import { setTimeout } from 'node:timers/promises';
2 | import * as p from '@clack/prompts';
3 | import type { ProgressResult } from '@clack/prompts';
4 |
5 | async function fakeProgress(progressbar: ProgressResult): Promise {
6 | await setTimeout(1000);
7 | for (const i in Array(10).fill(1)) {
8 | progressbar.advance();
9 | await setTimeout(100 + Math.random() * 500);
10 | }
11 | }
12 |
13 | async function demo() {
14 | p.intro('progress start...');
15 |
16 | const download = p.progress({ style: 'block', max: 10, size: 30 });
17 | download.start('Downloading package');
18 | await fakeProgress(download);
19 | download.stop('Download completed');
20 |
21 | const unarchive = p.progress({ style: 'heavy', max: 10, size: 30, indicator: undefined });
22 | unarchive.start('Un-archiving');
23 | await fakeProgress(unarchive);
24 | unarchive.stop('Un-archiving completed');
25 |
26 | const linking = p.progress({ style: 'light', max: 10, size: 30, indicator: 'timer' });
27 | linking.start('Linking');
28 | await fakeProgress(linking);
29 | linking.stop('Package linked');
30 |
31 | p.outro('progress stop...');
32 | }
33 |
34 | void demo();
35 |
--------------------------------------------------------------------------------
/examples/basic/spinner-cancel-advanced.ts:
--------------------------------------------------------------------------------
1 | import { setTimeout as sleep } from 'node:timers/promises';
2 | import * as p from '@clack/prompts';
3 |
4 | async function main() {
5 | p.intro('Advanced Spinner Cancellation Demo');
6 |
7 | // First demonstrate a visible spinner with no user input needed
8 | p.note('First, we will show a basic spinner (press CTRL+C to cancel)', 'Demo Part 1');
9 |
10 | const demoSpinner = p.spinner({
11 | indicator: 'dots',
12 | onCancel: () => {
13 | p.note('Initial spinner was cancelled with CTRL+C', 'Demo Cancelled');
14 | },
15 | });
16 |
17 | demoSpinner.start('Loading demo resources');
18 |
19 | // Update spinner message a few times to show activity
20 | for (let i = 0; i < 5; i++) {
21 | if (demoSpinner.isCancelled) break;
22 | await sleep(1000);
23 | demoSpinner.message(`Loading demo resources (${i + 1}/5)`);
24 | }
25 |
26 | if (!demoSpinner.isCancelled) {
27 | demoSpinner.stop('Demo resources loaded successfully');
28 | }
29 |
30 | // Only continue with the rest of the demo if the initial spinner wasn't cancelled
31 | if (!demoSpinner.isCancelled) {
32 | // Stage 1: Get user input with multiselect
33 | p.note("Now let's select some languages to process", 'Demo Part 2');
34 |
35 | const languages = await p.multiselect({
36 | message: 'Select programming languages to process:',
37 | options: [
38 | { value: 'typescript', label: 'TypeScript' },
39 | { value: 'javascript', label: 'JavaScript' },
40 | { value: 'python', label: 'Python' },
41 | { value: 'rust', label: 'Rust' },
42 | { value: 'go', label: 'Go' },
43 | ],
44 | required: true,
45 | });
46 |
47 | // Handle cancellation of the multiselect
48 | if (p.isCancel(languages)) {
49 | p.cancel('Operation cancelled during language selection.');
50 | process.exit(0);
51 | }
52 |
53 | // Stage 2: Show a spinner that can be cancelled
54 | const processSpinner = p.spinner({
55 | indicator: 'dots',
56 | onCancel: () => {
57 | p.note(
58 | 'You cancelled during processing. Any completed work will be saved.',
59 | 'Processing Cancelled'
60 | );
61 | },
62 | });
63 |
64 | processSpinner.start('Starting to process selected languages...');
65 |
66 | // Process each language with individual progress updates
67 | let completedCount = 0;
68 | const totalLanguages = languages.length;
69 |
70 | for (const language of languages) {
71 | // Skip the rest if cancelled
72 | if (processSpinner.isCancelled) break;
73 |
74 | // Update spinner message with current language
75 | processSpinner.message(`Processing ${language} (${completedCount + 1}/${totalLanguages})`);
76 |
77 | try {
78 | // Simulate work - longer pause to give time to test CTRL+C
79 | await sleep(2000);
80 | completedCount++;
81 | } catch (error) {
82 | // Handle errors but continue if not cancelled
83 | if (!processSpinner.isCancelled) {
84 | p.note(`Error processing ${language}: ${error.message}`, 'Error');
85 | }
86 | }
87 | }
88 |
89 | // Stage 3: Handle completion based on cancellation status
90 | if (!processSpinner.isCancelled) {
91 | processSpinner.stop(`Processed ${completedCount}/${totalLanguages} languages successfully`);
92 |
93 | // Stage 4: Additional user input based on processing results
94 | if (completedCount > 0) {
95 | const action = await p.select({
96 | message: 'What would you like to do with the processed data?',
97 | options: [
98 | { value: 'save', label: 'Save results', hint: 'Write to disk' },
99 | { value: 'share', label: 'Share results', hint: 'Upload to server' },
100 | { value: 'analyze', label: 'Further analysis', hint: 'Generate reports' },
101 | ],
102 | });
103 |
104 | if (p.isCancel(action)) {
105 | p.cancel('Operation cancelled at final stage.');
106 | process.exit(0);
107 | }
108 |
109 | // Stage 5: Final action with a timer spinner
110 | p.note('Now demonstrating a timer-style spinner', 'Final Stage');
111 |
112 | const finalSpinner = p.spinner({
113 | indicator: 'timer', // Use timer indicator for variety
114 | onCancel: () => {
115 | p.note(
116 | 'Final operation was cancelled, but processing results are still valid.',
117 | 'Final Stage Cancelled'
118 | );
119 | },
120 | });
121 |
122 | finalSpinner.start(`Performing ${action} operation...`);
123 |
124 | try {
125 | // Simulate final action with incremental updates
126 | for (let i = 0; i < 3; i++) {
127 | if (finalSpinner.isCancelled) break;
128 | await sleep(1500);
129 | finalSpinner.message(`Performing ${action} operation... Step ${i + 1}/3`);
130 | }
131 |
132 | if (!finalSpinner.isCancelled) {
133 | finalSpinner.stop(`${action} operation completed successfully`);
134 | }
135 | } catch (error) {
136 | if (!finalSpinner.isCancelled) {
137 | finalSpinner.stop(`Error during ${action}: ${error.message}`);
138 | }
139 | }
140 | }
141 | }
142 | }
143 |
144 | p.outro('Advanced demo completed. Thanks for trying out the spinner cancellation features!');
145 | }
146 |
147 | main().catch((error) => {
148 | console.error('Unexpected error:', error);
149 | process.exit(1);
150 | });
151 |
--------------------------------------------------------------------------------
/examples/basic/spinner-cancel.ts:
--------------------------------------------------------------------------------
1 | import * as p from '@clack/prompts';
2 |
3 | p.intro('Spinner with cancellation detection');
4 |
5 | // Example 1: Using onCancel callback
6 | const spin1 = p.spinner({
7 | indicator: 'dots',
8 | onCancel: () => {
9 | p.note('You cancelled the spinner with CTRL-C!', 'Callback detected');
10 | },
11 | });
12 |
13 | spin1.start('Press CTRL-C to cancel this spinner (using callback)');
14 |
15 | // Sleep for 10 seconds, allowing time for user to press CTRL-C
16 | await sleep(10000).then(() => {
17 | // Only show success message if not cancelled
18 | if (!spin1.isCancelled) {
19 | spin1.stop('Spinner completed without cancellation');
20 | }
21 | });
22 |
23 | // Example 2: Checking the isCancelled property
24 | p.note('Starting second example...', 'Example 2');
25 |
26 | const spin2 = p.spinner({ indicator: 'timer' });
27 | spin2.start('Press CTRL-C to cancel this spinner (polling isCancelled)');
28 |
29 | await sleep(10000).then(() => {
30 | if (spin2.isCancelled) {
31 | p.note('Spinner was cancelled by the user!', 'Property check');
32 | } else {
33 | spin2.stop('Spinner completed without cancellation');
34 | }
35 | });
36 |
37 | p.outro('Example completed');
38 |
39 | // Helper function
40 | function sleep(ms: number) {
41 | return new Promise((resolve) => setTimeout(resolve, ms));
42 | }
43 |
--------------------------------------------------------------------------------
/examples/basic/spinner-ci.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * This example addresses a issue reported in GitHub Actions where `spinner` was excessively writing messages,
3 | * leading to confusion and cluttered output.
4 | * To enhance the CI workflow and provide a smoother experience,
5 | * the following changes have been made only for CI environment:
6 | * - Messages will now only be written when a `spinner` method is called and the message updated, preventing unnecessary message repetition.
7 | * - There will be no loading dots animation, instead it will be always `...`
8 | * - Instead of erase the previous message, action that is blocked during CI, it will just write a new one.
9 | *
10 | * Issue: https://github.com/bombshell-dev/clack/issues/168
11 | */
12 | import * as p from '@clack/prompts';
13 |
14 | const s = p.spinner();
15 | let progress = 0;
16 | let counter = 0;
17 | let loop: NodeJS.Timer;
18 |
19 | p.intro('Running spinner in CI environment');
20 | s.start('spinner.start');
21 | new Promise((resolve) => {
22 | loop = setInterval(() => {
23 | if (progress % 1000 === 0) {
24 | counter++;
25 | }
26 | progress += 100;
27 | s.message(`spinner.message [${counter}]`);
28 | if (counter > 6) {
29 | clearInterval(loop);
30 | resolve(true);
31 | }
32 | }, 100);
33 | }).then(() => {
34 | s.stop('spinner.stop');
35 | p.outro('Done');
36 | });
37 |
--------------------------------------------------------------------------------
/examples/basic/spinner-timer.ts:
--------------------------------------------------------------------------------
1 | import * as p from '@clack/prompts';
2 |
3 | p.intro('spinner start...');
4 |
5 | async function main() {
6 | const spin = p.spinner({ indicator: 'timer' });
7 |
8 | spin.start('First spinner');
9 |
10 | await sleep(3_000);
11 |
12 | spin.stop('Done first spinner');
13 |
14 | spin.start('Second spinner');
15 | await sleep(5_000);
16 |
17 | spin.stop('Done second spinner');
18 |
19 | p.outro('spinner stop.');
20 | }
21 |
22 | function sleep(ms: number) {
23 | return new Promise((resolve) => setTimeout(resolve, ms));
24 | }
25 |
26 | main();
27 |
--------------------------------------------------------------------------------
/examples/basic/spinner.ts:
--------------------------------------------------------------------------------
1 | import * as p from '@clack/prompts';
2 |
3 | p.intro('spinner start...');
4 |
5 | const spin = p.spinner();
6 | const total = 6000;
7 | let progress = 0;
8 | spin.start();
9 |
10 | new Promise((resolve) => {
11 | const timer = setInterval(() => {
12 | progress = Math.min(total, progress + 100);
13 | if (progress >= total) {
14 | clearInterval(timer);
15 | resolve(true);
16 | }
17 | spin.message(`Loading packages [${progress}/${total}]`); // <===
18 | }, 100);
19 | }).then(() => {
20 | spin.stop('Done');
21 | p.outro('spinner stop...');
22 | });
23 |
--------------------------------------------------------------------------------
/examples/basic/stream.ts:
--------------------------------------------------------------------------------
1 | import { setTimeout } from 'node:timers/promises';
2 | import * as p from '@clack/prompts';
3 | import color from 'picocolors';
4 |
5 | async function main() {
6 | console.clear();
7 |
8 | await setTimeout(1000);
9 |
10 | p.intro(`${color.bgCyan(color.black(' create-app '))}`);
11 |
12 | await p.stream.step(
13 | (async function* () {
14 | for (const line of lorem) {
15 | for (const word of line.split(' ')) {
16 | yield word;
17 | yield ' ';
18 | await setTimeout(200);
19 | }
20 | yield '\n';
21 | if (line !== lorem.at(-1)) {
22 | await setTimeout(1000);
23 | }
24 | }
25 | })()
26 | );
27 |
28 | p.outro(`Problems? ${color.underline(color.cyan('https://example.com/issues'))}`);
29 | }
30 |
31 | const lorem = [
32 | 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.',
33 | 'Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.',
34 | ];
35 |
36 | main().catch(console.error);
37 |
--------------------------------------------------------------------------------
/examples/basic/task-log.ts:
--------------------------------------------------------------------------------
1 | import { setTimeout } from 'node:timers/promises';
2 | import * as p from '@clack/prompts';
3 |
4 | async function main() {
5 | p.intro('task log start...');
6 |
7 | const log = p.taskLog({
8 | title: 'Running npm install',
9 | limit: 5,
10 | });
11 |
12 | for await (const line of fakeCommand()) {
13 | log.message(line);
14 | }
15 |
16 | log.success('Done!');
17 |
18 | p.outro('task log stop...');
19 | }
20 |
21 | async function* fakeCommand() {
22 | for (let i = 0; i < 100; i++) {
23 | yield `line \x1b[32m${i}\x1b[39m...`;
24 | await setTimeout(80);
25 | }
26 | }
27 |
28 | main();
29 |
--------------------------------------------------------------------------------
/examples/basic/text-validation.ts:
--------------------------------------------------------------------------------
1 | import { setTimeout } from 'node:timers/promises';
2 | import { isCancel, note, text } from '@clack/prompts';
3 |
4 | async function main() {
5 | console.clear();
6 |
7 | // Example demonstrating the issue with initial value validation
8 | const name = await text({
9 | message: 'Enter your name (letters and spaces only)',
10 | initialValue: 'John123', // Invalid initial value with numbers
11 | validate: (value) => {
12 | if (!/^[a-zA-Z\s]+$/.test(value)) return 'Name can only contain letters and spaces';
13 | return undefined;
14 | },
15 | });
16 |
17 | if (!isCancel(name)) {
18 | note(`Valid name: ${name}`, 'Success');
19 | }
20 |
21 | await setTimeout(1000);
22 |
23 | // Example with a valid initial value for comparison
24 | const validName = await text({
25 | message: 'Enter another name (letters and spaces only)',
26 | initialValue: 'John Doe', // Valid initial value
27 | validate: (value) => {
28 | if (!/^[a-zA-Z\s]+$/.test(value)) return 'Name can only contain letters and spaces';
29 | return undefined;
30 | },
31 | });
32 |
33 | if (!isCancel(validName)) {
34 | note(`Valid name: ${validName}`, 'Success');
35 | }
36 |
37 | await setTimeout(1000);
38 | }
39 |
40 | main().catch(console.error);
41 |
--------------------------------------------------------------------------------
/examples/basic/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../../tsconfig.json"
3 | }
4 |
--------------------------------------------------------------------------------
/examples/changesets/index.ts:
--------------------------------------------------------------------------------
1 | import { setTimeout } from 'node:timers/promises';
2 | import * as p from '@clack/prompts';
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(
59 | (pkg) => !major.includes(pkg) && !minor.includes(pkg)
60 | );
61 | if (possiblePackages.length === 0) return;
62 | const note = possiblePackages.join(color.dim(', '));
63 |
64 | p.log.step(`These packages will have a ${color.green('patch')} bump.\n${color.dim(note)}`);
65 | return possiblePackages;
66 | },
67 | },
68 | {
69 | onCancel,
70 | }
71 | );
72 |
73 | const message = await p.text({
74 | placeholder: 'Summary',
75 | message: 'Please enter a summary for this change',
76 | });
77 |
78 | if (p.isCancel(message)) {
79 | return onCancel();
80 | }
81 |
82 | p.outro(`Changeset added! ${color.underline(color.cyan('.changeset/orange-crabs-sing.md'))}`);
83 | }
84 |
85 | main().catch(console.error);
86 |
--------------------------------------------------------------------------------
/examples/changesets/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@example/changesets",
3 | "private": true,
4 | "version": "0.0.0",
5 | "type": "module",
6 | "dependencies": {
7 | "jiti": "^1.17.0",
8 | "@clack/prompts": "workspace:*",
9 | "picocolors": "^1.0.0"
10 | },
11 | "scripts": {
12 | "start": "jiti ./index.ts"
13 | },
14 | "devDependencies": {}
15 | }
16 |
--------------------------------------------------------------------------------
/examples/changesets/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../../tsconfig.json"
3 | }
4 |
--------------------------------------------------------------------------------
/knip.json:
--------------------------------------------------------------------------------
1 | {
2 | "workspaces": {
3 | ".": {
4 | "ignore": ["build.preset.ts"]
5 | },
6 | "examples/*": {
7 | "entry": "*.ts!",
8 | "project": "*.ts"
9 | },
10 | "packages/*": {
11 | "entry": "src/index.ts!",
12 | "project": ["src/**/*.ts!", "test/**/*.ts"]
13 | }
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@clack/root",
3 | "private": true,
4 | "type": "module",
5 | "scripts": {
6 | "stub": "pnpm -r run build --stub",
7 | "build": "pnpm --filter \"@clack/*\" run build",
8 | "start": "pnpm run dev",
9 | "dev": "pnpm --filter @example/changesets run start",
10 | "format": "biome check --write",
11 | "lint": "biome lint --write --unsafe",
12 | "types": "biome lint --write --unsafe",
13 | "deps": "pnpm exec knip --production",
14 | "test": "pnpm --color -r run test",
15 | "ci:install": "pnpm install --no-frozen-lockfile",
16 | "ci:version": "changeset version",
17 | "ci:publish": "changeset publish",
18 | "ci:prepublish": "pnpm build"
19 | },
20 | "devDependencies": {
21 | "@biomejs/biome": "1.9.4",
22 | "@changesets/cli": "^2.26.2",
23 | "@types/node": "^18.16.0",
24 | "knip": "^5.50.4",
25 | "typescript": "^5.8.3",
26 | "unbuild": "^2.0.0",
27 | "jsr": "^0.13.4"
28 | },
29 | "packageManager": "pnpm@9.14.2",
30 | "volta": {
31 | "node": "20.18.1"
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/packages/core/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) Nate Moore
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
6 |
7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
8 |
9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
10 |
--------------------------------------------------------------------------------
/packages/core/README.md:
--------------------------------------------------------------------------------
1 | # `@clack/core`
2 |
3 | Clack contains low-level primitives for implementing your own command-line applications.
4 |
5 | Currently, `TextPrompt`, `SelectPrompt`, and `ConfirmPrompt` are exposed as well as the base `Prompt` class.
6 |
7 | Each `Prompt` accepts a `render` function.
8 |
9 | ```js
10 | import { TextPrompt, isCancel } from '@clack/core';
11 |
12 | const p = new TextPrompt({
13 | render() {
14 | return `What's your name?\n${this.valueWithCursor}`;
15 | },
16 | });
17 |
18 | const name = await p.prompt();
19 | if (isCancel(name)) {
20 | process.exit(0);
21 | }
22 | ```
23 |
--------------------------------------------------------------------------------
/packages/core/build.config.ts:
--------------------------------------------------------------------------------
1 | import { defineBuildConfig } from 'unbuild';
2 |
3 | // @see https://github.com/unjs/unbuild
4 | export default defineBuildConfig({
5 | preset: '../../build.preset',
6 | entries: ['src/index'],
7 | });
8 |
--------------------------------------------------------------------------------
/packages/core/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@clack/core",
3 | "version": "1.0.0-alpha.0",
4 | "type": "module",
5 | "main": "./dist/index.mjs",
6 | "module": "./dist/index.mjs",
7 | "exports": {
8 | ".": {
9 | "types": "./dist/index.d.mts",
10 | "default": "./dist/index.mjs"
11 | },
12 | "./package.json": "./package.json"
13 | },
14 | "types": "./dist/index.d.mts",
15 | "repository": {
16 | "type": "git",
17 | "url": "git+https://github.com/bombshell-dev/clack.git",
18 | "directory": "packages/core"
19 | },
20 | "bugs": {
21 | "url": "https://github.com/bombshell-dev/clack/issues"
22 | },
23 | "homepage": "https://github.com/bombshell-dev/clack/tree/main/packages/core#readme",
24 | "files": ["dist", "CHANGELOG.md"],
25 | "keywords": [
26 | "ask",
27 | "clack",
28 | "cli",
29 | "command-line",
30 | "command",
31 | "input",
32 | "interact",
33 | "interface",
34 | "menu",
35 | "prompt",
36 | "prompts",
37 | "stdin",
38 | "ui"
39 | ],
40 | "author": {
41 | "name": "Nate Moore",
42 | "email": "nate@natemoo.re",
43 | "url": "https://twitter.com/n_moore"
44 | },
45 | "license": "MIT",
46 | "packageManager": "pnpm@8.6.12",
47 | "scripts": {
48 | "build": "unbuild",
49 | "prepack": "pnpm build",
50 | "test": "vitest run"
51 | },
52 | "dependencies": {
53 | "picocolors": "^1.0.0",
54 | "sisteransi": "^1.0.5"
55 | },
56 | "devDependencies": {
57 | "vitest": "^3.1.1",
58 | "wrap-ansi": "^8.1.0"
59 | }
60 | }
61 |
--------------------------------------------------------------------------------
/packages/core/src/index.ts:
--------------------------------------------------------------------------------
1 | export type { ClackState as State, ValueWithCursorPart } from './types.js';
2 | export type { ClackSettings } from './utils/settings.js';
3 |
4 | export { default as ConfirmPrompt } from './prompts/confirm.js';
5 | export { default as GroupMultiSelectPrompt } from './prompts/group-multiselect.js';
6 | export { default as MultiSelectPrompt } from './prompts/multi-select.js';
7 | export { default as PasswordPrompt } from './prompts/password.js';
8 | export { default as Prompt } from './prompts/prompt.js';
9 | export { default as SelectPrompt } from './prompts/select.js';
10 | export { default as SelectKeyPrompt } from './prompts/select-key.js';
11 | export { default as TextPrompt } from './prompts/text.js';
12 | export { default as AutocompletePrompt } from './prompts/autocomplete.js';
13 | export { default as SuggestionPrompt } from './prompts/suggestion.js';
14 | export { block, isCancel, getColumns } from './utils/index.js';
15 | export { updateSettings, settings } from './utils/settings.js';
16 |
--------------------------------------------------------------------------------
/packages/core/src/prompts/autocomplete.ts:
--------------------------------------------------------------------------------
1 | import type { Key } from 'node:readline';
2 | import color from 'picocolors';
3 | import Prompt, { type PromptOptions } from './prompt.js';
4 |
5 | interface OptionLike {
6 | value: unknown;
7 | label?: string;
8 | }
9 |
10 | type FilterFunction = (search: string, opt: T) => boolean;
11 |
12 | function getCursorForValue(
13 | selected: T['value'] | undefined,
14 | items: T[]
15 | ): number {
16 | if (selected === undefined) {
17 | return 0;
18 | }
19 |
20 | const currLength = items.length;
21 |
22 | // If filtering changed the available options, update cursor
23 | if (currLength === 0) {
24 | return 0;
25 | }
26 |
27 | // Try to maintain the same selected item
28 | const index = items.findIndex((item) => item.value === selected);
29 | return index !== -1 ? index : 0;
30 | }
31 |
32 | function defaultFilter(input: string, option: T): boolean {
33 | const label = option.label ?? String(option.value);
34 | return label.toLowerCase().includes(input.toLowerCase());
35 | }
36 |
37 | function normalisedValue(multiple: boolean, values: T[] | undefined): T | T[] | undefined {
38 | if (!values) {
39 | return undefined;
40 | }
41 | if (multiple) {
42 | return values;
43 | }
44 | return values[0];
45 | }
46 |
47 | interface AutocompleteOptions extends PromptOptions> {
48 | options: T[];
49 | filter?: FilterFunction;
50 | multiple?: boolean;
51 | }
52 |
53 | export default class AutocompletePrompt extends Prompt {
54 | options: T[];
55 | filteredOptions: T[];
56 | multiple: boolean;
57 | isNavigating = false;
58 | selectedValues: Array = [];
59 |
60 | focusedValue: T['value'] | undefined;
61 | #cursor = 0;
62 | #lastValue: T['value'] | undefined;
63 | #filterFn: FilterFunction;
64 |
65 | get cursor(): number {
66 | return this.#cursor;
67 | }
68 |
69 | get valueWithCursor() {
70 | if (!this.value) {
71 | return color.inverse(color.hidden('_'));
72 | }
73 | if (this._cursor >= this.value.length) {
74 | return `${this.value}█`;
75 | }
76 | const s1 = this.value.slice(0, this._cursor);
77 | const [s2, ...s3] = this.value.slice(this._cursor);
78 | return `${s1}${color.inverse(s2)}${s3.join('')}`;
79 | }
80 |
81 | constructor(opts: AutocompleteOptions) {
82 | super({
83 | ...opts,
84 | initialValue: undefined,
85 | });
86 |
87 | this.options = opts.options;
88 | this.filteredOptions = [...this.options];
89 | this.multiple = opts.multiple === true;
90 | this.#filterFn = opts.filter ?? defaultFilter;
91 | let initialValues: unknown[] | undefined;
92 | if (opts.initialValue && Array.isArray(opts.initialValue)) {
93 | if (this.multiple) {
94 | initialValues = opts.initialValue;
95 | } else {
96 | initialValues = opts.initialValue.slice(0, 1);
97 | }
98 | } else {
99 | if (!this.multiple && this.options.length > 0) {
100 | initialValues = [this.options[0].value];
101 | }
102 | }
103 |
104 | if (initialValues) {
105 | this.selectedValues = initialValues;
106 | for (const selectedValue of initialValues) {
107 | const selectedIndex = this.options.findIndex((opt) => opt.value === selectedValue);
108 | if (selectedIndex !== -1) {
109 | this.toggleSelected(selectedValue);
110 | this.#cursor = selectedIndex;
111 | this.focusedValue = this.options[this.#cursor]?.value;
112 | }
113 | }
114 | }
115 |
116 | this.on('finalize', () => {
117 | if (!this.value) {
118 | this.value = normalisedValue(this.multiple, initialValues);
119 | }
120 |
121 | if (this.state === 'submit') {
122 | this.value = normalisedValue(this.multiple, this.selectedValues);
123 | }
124 | });
125 |
126 | this.on('key', (char, key) => this.#onKey(char, key));
127 | this.on('value', (value) => this.#onValueChanged(value));
128 | }
129 |
130 | protected override _isActionKey(char: string | undefined, key: Key): boolean {
131 | return (
132 | char === '\t' ||
133 | (this.multiple &&
134 | this.isNavigating &&
135 | key.name === 'space' &&
136 | char !== undefined &&
137 | char !== '')
138 | );
139 | }
140 |
141 | #onKey(_char: string | undefined, key: Key): void {
142 | const isUpKey = key.name === 'up';
143 | const isDownKey = key.name === 'down';
144 |
145 | // Start navigation mode with up/down arrows
146 | if (isUpKey || isDownKey) {
147 | this.#cursor = Math.max(
148 | 0,
149 | Math.min(this.#cursor + (isUpKey ? -1 : 1), this.filteredOptions.length - 1)
150 | );
151 | this.focusedValue = this.filteredOptions[this.#cursor]?.value;
152 | if (!this.multiple) {
153 | this.selectedValues = [this.focusedValue];
154 | }
155 | this.isNavigating = true;
156 | } else {
157 | if (this.multiple) {
158 | if (
159 | this.focusedValue !== undefined &&
160 | (key.name === 'tab' || (this.isNavigating && key.name === 'space'))
161 | ) {
162 | this.toggleSelected(this.focusedValue);
163 | } else {
164 | this.isNavigating = false;
165 | }
166 | } else {
167 | if (this.focusedValue) {
168 | this.selectedValues = [this.focusedValue];
169 | }
170 | }
171 | }
172 | }
173 |
174 | toggleSelected(value: T['value']) {
175 | if (this.filteredOptions.length === 0) {
176 | return;
177 | }
178 |
179 | if (this.multiple) {
180 | if (this.selectedValues.includes(value)) {
181 | this.selectedValues = this.selectedValues.filter((v) => v !== value);
182 | } else {
183 | this.selectedValues = [...this.selectedValues, value];
184 | }
185 | } else {
186 | this.selectedValues = [value];
187 | }
188 | }
189 |
190 | #onValueChanged(value: string | undefined): void {
191 | if (value !== this.#lastValue) {
192 | this.#lastValue = value;
193 |
194 | if (value) {
195 | this.filteredOptions = this.options.filter((opt) => this.#filterFn(value, opt));
196 | } else {
197 | this.filteredOptions = [...this.options];
198 | }
199 | this.#cursor = getCursorForValue(this.focusedValue, this.filteredOptions);
200 | this.focusedValue = this.filteredOptions[this.#cursor]?.value;
201 | }
202 | }
203 | }
204 |
--------------------------------------------------------------------------------
/packages/core/src/prompts/confirm.ts:
--------------------------------------------------------------------------------
1 | import { cursor } from 'sisteransi';
2 | import Prompt, { type PromptOptions } from './prompt.js';
3 |
4 | interface ConfirmOptions extends PromptOptions {
5 | active: string;
6 | inactive: string;
7 | initialValue?: boolean;
8 | }
9 | export default class ConfirmPrompt extends Prompt {
10 | get cursor() {
11 | return this.value ? 0 : 1;
12 | }
13 |
14 | private get _value() {
15 | return this.cursor === 0;
16 | }
17 |
18 | constructor(opts: ConfirmOptions) {
19 | super(opts, false);
20 | this.value = !!opts.initialValue;
21 |
22 | this.on('value', () => {
23 | this.value = this._value;
24 | });
25 |
26 | this.on('confirm', (confirm) => {
27 | this.output.write(cursor.move(0, -1));
28 | this.value = confirm;
29 | this.state = 'submit';
30 | this.close();
31 | });
32 |
33 | this.on('cursor', () => {
34 | this.value = !this.value;
35 | });
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/packages/core/src/prompts/group-multiselect.ts:
--------------------------------------------------------------------------------
1 | import Prompt, { type PromptOptions } from './prompt.js';
2 |
3 | interface GroupMultiSelectOptions
4 | extends PromptOptions> {
5 | options: Record;
6 | initialValues?: T['value'][];
7 | required?: boolean;
8 | cursorAt?: T['value'];
9 | selectableGroups?: boolean;
10 | }
11 | export default class GroupMultiSelectPrompt extends Prompt {
12 | options: (T & { group: string | boolean })[];
13 | cursor = 0;
14 | #selectableGroups: boolean;
15 |
16 | getGroupItems(group: string): T[] {
17 | return this.options.filter((o) => o.group === group);
18 | }
19 |
20 | isGroupSelected(group: string) {
21 | const items = this.getGroupItems(group);
22 | return items.every((i) => this.value.includes(i.value));
23 | }
24 |
25 | private toggleValue() {
26 | const item = this.options[this.cursor];
27 | if (item.group === true) {
28 | const group = item.value;
29 | const groupedItems = this.getGroupItems(group);
30 | if (this.isGroupSelected(group)) {
31 | this.value = this.value.filter(
32 | (v: string) => groupedItems.findIndex((i) => i.value === v) === -1
33 | );
34 | } else {
35 | this.value = [...this.value, ...groupedItems.map((i) => i.value)];
36 | }
37 | this.value = Array.from(new Set(this.value));
38 | } else {
39 | const selected = this.value.includes(item.value);
40 | this.value = selected
41 | ? this.value.filter((v: T['value']) => v !== item.value)
42 | : [...this.value, item.value];
43 | }
44 | }
45 |
46 | constructor(opts: GroupMultiSelectOptions) {
47 | super(opts, false);
48 | const { options } = opts;
49 | this.#selectableGroups = opts.selectableGroups !== false;
50 | this.options = Object.entries(options).flatMap(([key, option]) => [
51 | { value: key, group: true, label: key },
52 | ...option.map((opt) => ({ ...opt, group: key })),
53 | ]) as any;
54 | this.value = [...(opts.initialValues ?? [])];
55 | this.cursor = Math.max(
56 | this.options.findIndex(({ value }) => value === opts.cursorAt),
57 | this.#selectableGroups ? 0 : 1
58 | );
59 |
60 | this.on('cursor', (key) => {
61 | switch (key) {
62 | case 'left':
63 | case 'up': {
64 | this.cursor = this.cursor === 0 ? this.options.length - 1 : this.cursor - 1;
65 | const currentIsGroup = this.options[this.cursor]?.group === true;
66 | if (!this.#selectableGroups && currentIsGroup) {
67 | this.cursor = this.cursor === 0 ? this.options.length - 1 : this.cursor - 1;
68 | }
69 | break;
70 | }
71 | case 'down':
72 | case 'right': {
73 | this.cursor = this.cursor === this.options.length - 1 ? 0 : this.cursor + 1;
74 | const currentIsGroup = this.options[this.cursor]?.group === true;
75 | if (!this.#selectableGroups && currentIsGroup) {
76 | this.cursor = this.cursor === this.options.length - 1 ? 0 : this.cursor + 1;
77 | }
78 | break;
79 | }
80 | case 'space':
81 | this.toggleValue();
82 | break;
83 | }
84 | });
85 | }
86 | }
87 |
--------------------------------------------------------------------------------
/packages/core/src/prompts/multi-select.ts:
--------------------------------------------------------------------------------
1 | import Prompt, { type PromptOptions } from './prompt.js';
2 |
3 | interface MultiSelectOptions extends PromptOptions> {
4 | options: T[];
5 | initialValues?: T['value'][];
6 | required?: boolean;
7 | cursorAt?: T['value'];
8 | }
9 | export default class MultiSelectPrompt extends Prompt {
10 | options: T[];
11 | cursor = 0;
12 |
13 | private get _value() {
14 | return this.options[this.cursor].value;
15 | }
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 |
22 | private toggleValue() {
23 | const selected = this.value.includes(this._value);
24 | this.value = selected
25 | ? this.value.filter((value: T['value']) => value !== this._value)
26 | : [...this.value, this._value];
27 | }
28 |
29 | constructor(opts: MultiSelectOptions) {
30 | super(opts, false);
31 |
32 | this.options = opts.options;
33 | this.value = [...(opts.initialValues ?? [])];
34 | this.cursor = Math.max(
35 | this.options.findIndex(({ value }) => value === opts.cursorAt),
36 | 0
37 | );
38 | this.on('key', (char) => {
39 | if (char === 'a') {
40 | this.toggleAll();
41 | }
42 | });
43 |
44 | this.on('cursor', (key) => {
45 | switch (key) {
46 | case 'left':
47 | case 'up':
48 | this.cursor = this.cursor === 0 ? this.options.length - 1 : this.cursor - 1;
49 | break;
50 | case 'down':
51 | case 'right':
52 | this.cursor = this.cursor === this.options.length - 1 ? 0 : this.cursor + 1;
53 | break;
54 | case 'space':
55 | this.toggleValue();
56 | break;
57 | }
58 | });
59 | }
60 | }
61 |
--------------------------------------------------------------------------------
/packages/core/src/prompts/password.ts:
--------------------------------------------------------------------------------
1 | import color from 'picocolors';
2 | import Prompt, { type PromptOptions } from './prompt.js';
3 |
4 | interface PasswordOptions extends PromptOptions {
5 | mask?: string;
6 | }
7 | export default class PasswordPrompt extends Prompt {
8 | private _mask = '•';
9 | get cursor() {
10 | return this._cursor;
11 | }
12 | get masked() {
13 | return this.value?.replaceAll(/./g, this._mask) ?? '';
14 | }
15 | get valueWithCursor() {
16 | if (this.state === 'submit' || this.state === 'cancel') {
17 | return this.masked;
18 | }
19 | const value = this.value ?? '';
20 | if (this.cursor >= value.length) {
21 | return `${this.masked}${color.inverse(color.hidden('_'))}`;
22 | }
23 | const s1 = this.masked.slice(0, this.cursor);
24 | const s2 = this.masked.slice(this.cursor);
25 | return `${s1}${color.inverse(s2[0])}${s2.slice(1)}`;
26 | }
27 | constructor({ mask, ...opts }: PasswordOptions) {
28 | super(opts);
29 | this._mask = mask ?? '•';
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/packages/core/src/prompts/select-key.ts:
--------------------------------------------------------------------------------
1 | import Prompt, { type PromptOptions } from './prompt.js';
2 |
3 | interface SelectKeyOptions extends PromptOptions> {
4 | options: T[];
5 | }
6 | export default class SelectKeyPrompt extends Prompt {
7 | options: T[];
8 | cursor = 0;
9 |
10 | constructor(opts: SelectKeyOptions) {
11 | super(opts, false);
12 |
13 | this.options = opts.options;
14 | const keys = this.options.map(({ value: [initial] }) => initial?.toLowerCase());
15 | this.cursor = Math.max(keys.indexOf(opts.initialValue), 0);
16 |
17 | this.on('key', (key) => {
18 | if (!keys.includes(key)) return;
19 | const value = this.options.find(({ value: [initial] }) => initial?.toLowerCase() === key);
20 | if (value) {
21 | this.value = value.value;
22 | this.state = 'submit';
23 | this.emit('submit');
24 | }
25 | });
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/packages/core/src/prompts/select.ts:
--------------------------------------------------------------------------------
1 | import Prompt, { type PromptOptions } from './prompt.js';
2 |
3 | interface SelectOptions extends PromptOptions> {
4 | options: T[];
5 | initialValue?: T['value'];
6 | }
7 | export default class SelectPrompt extends Prompt {
8 | options: T[];
9 | cursor = 0;
10 |
11 | private get _value() {
12 | return this.options[this.cursor];
13 | }
14 |
15 | private changeValue() {
16 | this.value = this._value.value;
17 | }
18 |
19 | constructor(opts: SelectOptions) {
20 | super(opts, false);
21 |
22 | this.options = opts.options;
23 | this.cursor = this.options.findIndex(({ value }) => value === opts.initialValue);
24 | if (this.cursor === -1) this.cursor = 0;
25 | this.changeValue();
26 |
27 | this.on('cursor', (key) => {
28 | switch (key) {
29 | case 'left':
30 | case 'up':
31 | this.cursor = this.cursor === 0 ? this.options.length - 1 : this.cursor - 1;
32 | break;
33 | case 'down':
34 | case 'right':
35 | this.cursor = this.cursor === this.options.length - 1 ? 0 : this.cursor + 1;
36 | break;
37 | }
38 | this.changeValue();
39 | });
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/packages/core/src/prompts/suggestion.ts:
--------------------------------------------------------------------------------
1 | import color from 'picocolors';
2 | import type { ValueWithCursorPart } from '../types.js';
3 | import Prompt, { type PromptOptions } from './prompt.js';
4 |
5 | interface SuggestionOptions extends PromptOptions {
6 | suggest: (value: string) => Array;
7 | initialValue: string;
8 | }
9 |
10 | export default class SuggestionPrompt extends Prompt {
11 | value: string;
12 | protected suggest: (value: string) => Array;
13 | private selectionIndex = 0;
14 | private nextItems: Array = [];
15 |
16 | constructor(opts: SuggestionOptions) {
17 | super(opts);
18 |
19 | this.value = opts.initialValue;
20 | this.suggest = opts.suggest;
21 | this.getNextItems();
22 | this.selectionIndex = 0;
23 | this._cursor = this.value.length;
24 |
25 | this.on('cursor', (key) => {
26 | switch (key) {
27 | case 'up':
28 | this.selectionIndex = Math.max(
29 | 0,
30 | this.selectionIndex === 0 ? this.nextItems.length - 1 : this.selectionIndex - 1
31 | );
32 | break;
33 | case 'down':
34 | this.selectionIndex =
35 | this.nextItems.length === 0 ? 0 : (this.selectionIndex + 1) % this.nextItems.length;
36 | break;
37 | }
38 | });
39 | this.on('key', (key, info) => {
40 | if (info.name === 'tab' && this.nextItems.length > 0) {
41 | const delta = this.nextItems[this.selectionIndex].substring(this.value.length);
42 | this.value = this.nextItems[this.selectionIndex];
43 | this.rl?.write(delta);
44 | this._cursor = this.value.length;
45 | this.selectionIndex = 0;
46 | this.getNextItems();
47 | }
48 | });
49 | this.on('value', () => {
50 | this.getNextItems();
51 | });
52 | }
53 |
54 | get displayValue(): Array {
55 | const result: Array = [];
56 | if (this._cursor > 0) {
57 | result.push({
58 | text: this.value.substring(0, this._cursor),
59 | type: 'value',
60 | });
61 | }
62 | if (this._cursor < this.value.length) {
63 | result.push({
64 | text: this.value.substring(this._cursor, this._cursor + 1),
65 | type: 'cursor_on_value',
66 | });
67 | const left = this.value.substring(this._cursor + 1);
68 | if (left.length > 0) {
69 | result.push({
70 | text: left,
71 | type: 'value',
72 | });
73 | }
74 | if (this.suggestion.length > 0) {
75 | result.push({
76 | text: this.suggestion,
77 | type: 'suggestion',
78 | });
79 | }
80 | return result;
81 | }
82 | if (this.suggestion.length === 0) {
83 | result.push({
84 | text: '\u00A0',
85 | type: 'cursor_on_value',
86 | });
87 | return result;
88 | }
89 | result.push(
90 | {
91 | text: this.suggestion[0],
92 | type: 'cursor_on_suggestion',
93 | },
94 | {
95 | text: this.suggestion.substring(1),
96 | type: 'suggestion',
97 | }
98 | );
99 | return result;
100 | }
101 |
102 | get suggestion(): string {
103 | return this.nextItems[this.selectionIndex]?.substring(this.value.length) ?? '';
104 | }
105 |
106 | private getNextItems(): void {
107 | this.nextItems = this.suggest(this.value).filter((item) => {
108 | return item.startsWith(this.value) && item !== this.value;
109 | });
110 | if (this.selectionIndex > this.nextItems.length) {
111 | this.selectionIndex = 0;
112 | }
113 | }
114 | }
115 |
--------------------------------------------------------------------------------
/packages/core/src/prompts/text.ts:
--------------------------------------------------------------------------------
1 | import color from 'picocolors';
2 | import Prompt, { type PromptOptions } from './prompt.js';
3 |
4 | interface TextOptions extends PromptOptions {
5 | placeholder?: string;
6 | defaultValue?: string;
7 | }
8 |
9 | export default class TextPrompt extends Prompt {
10 | get valueWithCursor() {
11 | if (this.state === 'submit') {
12 | return this.value;
13 | }
14 | const value = this.value ?? '';
15 | if (this.cursor >= value.length) {
16 | return `${this.value}█`;
17 | }
18 | const s1 = value.slice(0, this.cursor);
19 | const [s2, ...s3] = value.slice(this.cursor);
20 | return `${s1}${color.inverse(s2)}${s3.join('')}`;
21 | }
22 | get cursor() {
23 | return this._cursor;
24 | }
25 | constructor(opts: TextOptions) {
26 | super(opts);
27 |
28 | this.on('finalize', () => {
29 | if (!this.value) {
30 | this.value = opts.defaultValue;
31 | }
32 | if (this.value === undefined) {
33 | this.value = '';
34 | }
35 | });
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/packages/core/src/types.ts:
--------------------------------------------------------------------------------
1 | import type { Key } from 'node:readline';
2 | import type { Action } from './utils/settings.js';
3 |
4 | /**
5 | * The state of the prompt
6 | */
7 | export type ClackState = 'initial' | 'active' | 'cancel' | 'submit' | 'error';
8 |
9 | /**
10 | * Typed event emitter for clack
11 | */
12 | export interface ClackEvents {
13 | initial: (value?: any) => void;
14 | active: (value?: any) => void;
15 | cancel: (value?: any) => void;
16 | submit: (value?: any) => void;
17 | error: (value?: any) => void;
18 | cursor: (key?: Action) => void;
19 | key: (key: string | undefined, info: Key) => void;
20 | value: (value?: string) => void;
21 | confirm: (value?: boolean) => void;
22 | finalize: () => void;
23 | }
24 |
25 | /**
26 | * Display a value
27 | */
28 | export interface ValueWithCursorPart {
29 | text: string;
30 | type: 'value' | 'cursor_on_value' | 'suggestion' | 'cursor_on_suggestion';
31 | }
32 |
--------------------------------------------------------------------------------
/packages/core/src/utils/index.ts:
--------------------------------------------------------------------------------
1 | import { stdin, stdout } from 'node:process';
2 | import type { Key } from 'node:readline';
3 | import * as readline from 'node:readline';
4 | import type { Readable, Writable } from 'node:stream';
5 | import { ReadStream, WriteStream } from 'node:tty';
6 | import { cursor } from 'sisteransi';
7 | import { isActionKey } from './settings.js';
8 |
9 | export * from './string.js';
10 | export * from './settings.js';
11 |
12 | const isWindows = globalThis.process.platform.startsWith('win');
13 |
14 | export const CANCEL_SYMBOL = Symbol('clack:cancel');
15 |
16 | export function isCancel(value: unknown): value is symbol {
17 | return value === CANCEL_SYMBOL;
18 | }
19 |
20 | export function setRawMode(input: Readable, value: boolean) {
21 | const i = input as typeof stdin;
22 |
23 | if (i.isTTY) i.setRawMode(value);
24 | }
25 |
26 | interface BlockOptions {
27 | input?: Readable;
28 | output?: Writable;
29 | overwrite?: boolean;
30 | hideCursor?: boolean;
31 | }
32 |
33 | export function block({
34 | input = stdin,
35 | output = stdout,
36 | overwrite = true,
37 | hideCursor = true,
38 | }: BlockOptions = {}) {
39 | const rl = readline.createInterface({
40 | input,
41 | output,
42 | prompt: '',
43 | tabSize: 1,
44 | });
45 | readline.emitKeypressEvents(input, rl);
46 |
47 | if (input instanceof ReadStream && input.isTTY) {
48 | input.setRawMode(true);
49 | }
50 |
51 | const clear = (data: Buffer, { name, sequence }: Key) => {
52 | const str = String(data);
53 | if (isActionKey([str, name, sequence], 'cancel')) {
54 | if (hideCursor) output.write(cursor.show);
55 | process.exit(0);
56 | return;
57 | }
58 | if (!overwrite) return;
59 | const dx = name === 'return' ? 0 : -1;
60 | const dy = name === 'return' ? -1 : 0;
61 |
62 | readline.moveCursor(output, dx, dy, () => {
63 | readline.clearLine(output, 1, () => {
64 | input.once('keypress', clear);
65 | });
66 | });
67 | };
68 | if (hideCursor) output.write(cursor.hide);
69 | input.once('keypress', clear);
70 |
71 | return () => {
72 | input.off('keypress', clear);
73 | if (hideCursor) output.write(cursor.show);
74 |
75 | // Prevent Windows specific issues: https://github.com/bombshell-dev/clack/issues/176
76 | if (input instanceof ReadStream && input.isTTY && !isWindows) {
77 | input.setRawMode(false);
78 | }
79 |
80 | // @ts-expect-error fix for https://github.com/nodejs/node/issues/31762#issuecomment-1441223907
81 | rl.terminal = false;
82 | rl.close();
83 | };
84 | }
85 |
86 | export const getColumns = (output: Writable): number => {
87 | if (output instanceof WriteStream && output.columns) {
88 | return output.columns;
89 | }
90 | return 80;
91 | };
92 |
--------------------------------------------------------------------------------
/packages/core/src/utils/settings.ts:
--------------------------------------------------------------------------------
1 | const actions = ['up', 'down', 'left', 'right', 'space', 'enter', 'cancel'] as const;
2 | export type Action = (typeof actions)[number];
3 |
4 | /** Global settings for Clack programs, stored in memory */
5 | interface InternalClackSettings {
6 | actions: Set;
7 | aliases: Map;
8 | messages: {
9 | cancel: string;
10 | error: string;
11 | };
12 | }
13 |
14 | export const settings: InternalClackSettings = {
15 | actions: new Set(actions),
16 | aliases: new Map([
17 | // vim support
18 | ['k', 'up'],
19 | ['j', 'down'],
20 | ['h', 'left'],
21 | ['l', 'right'],
22 | ['\x03', 'cancel'],
23 | // opinionated defaults!
24 | ['escape', 'cancel'],
25 | ]),
26 | messages: {
27 | cancel: 'Canceled',
28 | error: 'Something went wrong',
29 | },
30 | };
31 |
32 | export interface ClackSettings {
33 | /**
34 | * Set custom global aliases for the default actions.
35 | * This will not overwrite existing aliases, it will only add new ones!
36 | *
37 | * @param aliases - An object that maps aliases to actions
38 | * @default { k: 'up', j: 'down', h: 'left', l: 'right', '\x03': 'cancel', 'escape': 'cancel' }
39 | */
40 | aliases?: Record;
41 |
42 | /**
43 | * Custom messages for prompts
44 | */
45 | messages?: {
46 | /**
47 | * Custom message to display when a spinner is cancelled
48 | * @default "Canceled"
49 | */
50 | cancel?: string;
51 | /**
52 | * Custom message to display when a spinner encounters an error
53 | * @default "Something went wrong"
54 | */
55 | error?: string;
56 | };
57 | }
58 |
59 | export function updateSettings(updates: ClackSettings) {
60 | // Handle each property in the updates
61 | if (updates.aliases !== undefined) {
62 | const aliases = updates.aliases;
63 | for (const alias in aliases) {
64 | if (!Object.hasOwn(aliases, alias)) continue;
65 |
66 | const action = aliases[alias];
67 | if (!settings.actions.has(action)) continue;
68 |
69 | if (!settings.aliases.has(alias)) {
70 | settings.aliases.set(alias, action);
71 | }
72 | }
73 | }
74 |
75 | if (updates.messages !== undefined) {
76 | const messages = updates.messages;
77 | if (messages.cancel !== undefined) {
78 | settings.messages.cancel = messages.cancel;
79 | }
80 | if (messages.error !== undefined) {
81 | settings.messages.error = messages.error;
82 | }
83 | }
84 | }
85 |
86 | /**
87 | * Check if a key is an alias for a default action
88 | * @param key - The raw key which might match to an action
89 | * @param action - The action to match
90 | * @returns boolean
91 | */
92 | export function isActionKey(key: string | Array, action: Action) {
93 | if (typeof key === 'string') {
94 | return settings.aliases.get(key) === action;
95 | }
96 |
97 | for (const value of key) {
98 | if (value === undefined) continue;
99 | if (isActionKey(value, action)) {
100 | return true;
101 | }
102 | }
103 | return false;
104 | }
105 |
--------------------------------------------------------------------------------
/packages/core/src/utils/string.ts:
--------------------------------------------------------------------------------
1 | export function diffLines(a: string, b: string) {
2 | if (a === b) return;
3 |
4 | const aLines = a.split('\n');
5 | const bLines = b.split('\n');
6 | const diff: number[] = [];
7 |
8 | for (let i = 0; i < Math.max(aLines.length, bLines.length); i++) {
9 | if (aLines[i] !== bLines[i]) diff.push(i);
10 | }
11 |
12 | return diff;
13 | }
14 |
--------------------------------------------------------------------------------
/packages/core/test/mock-readable.ts:
--------------------------------------------------------------------------------
1 | import { Readable } from 'node:stream';
2 |
3 | export class MockReadable extends Readable {
4 | protected _buffer: unknown[] | null = [];
5 |
6 | _read() {
7 | if (this._buffer === null) {
8 | this.push(null);
9 | return;
10 | }
11 |
12 | for (const val of this._buffer) {
13 | this.push(val);
14 | }
15 |
16 | this._buffer = [];
17 | }
18 |
19 | pushValue(val: unknown): void {
20 | this._buffer?.push(val);
21 | }
22 |
23 | close(): void {
24 | this._buffer = null;
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/packages/core/test/mock-writable.ts:
--------------------------------------------------------------------------------
1 | import { Writable } from 'node:stream';
2 |
3 | export class MockWritable extends Writable {
4 | public buffer: string[] = [];
5 |
6 | _write(
7 | chunk: any,
8 | encoding: BufferEncoding,
9 | callback: (error?: Error | null | undefined) => void
10 | ): void {
11 | this.buffer.push(chunk.toString());
12 | callback();
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/packages/core/test/prompts/autocomplete.test.ts:
--------------------------------------------------------------------------------
1 | import { cursor } from 'sisteransi';
2 | import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
3 | import { default as AutocompletePrompt } from '../../src/prompts/autocomplete.js';
4 | import { MockReadable } from '../mock-readable.js';
5 | import { MockWritable } from '../mock-writable.js';
6 |
7 | describe('AutocompletePrompt', () => {
8 | let input: MockReadable;
9 | let output: MockWritable;
10 | const testOptions = [
11 | { value: 'apple', label: 'Apple' },
12 | { value: 'banana', label: 'Banana' },
13 | { value: 'cherry', label: 'Cherry' },
14 | { value: 'grape', label: 'Grape' },
15 | { value: 'orange', label: 'Orange' },
16 | ];
17 |
18 | beforeEach(() => {
19 | input = new MockReadable();
20 | output = new MockWritable();
21 | });
22 |
23 | afterEach(() => {
24 | vi.restoreAllMocks();
25 | });
26 |
27 | test('renders render() result', () => {
28 | const instance = new AutocompletePrompt({
29 | input,
30 | output,
31 | render: () => 'foo',
32 | options: testOptions,
33 | });
34 | instance.prompt();
35 | expect(output.buffer).to.deep.equal([cursor.hide, 'foo']);
36 | });
37 |
38 | test('initial options match provided options', () => {
39 | const instance = new AutocompletePrompt({
40 | input,
41 | output,
42 | render: () => 'foo',
43 | options: testOptions,
44 | });
45 |
46 | instance.prompt();
47 |
48 | // Initial state should have all options
49 | expect(instance.filteredOptions.length).to.equal(testOptions.length);
50 | expect(instance.cursor).to.equal(0);
51 | });
52 |
53 | test('cursor navigation with event emitter', () => {
54 | const instance = new AutocompletePrompt({
55 | input,
56 | output,
57 | render: () => 'foo',
58 | options: testOptions,
59 | });
60 |
61 | instance.prompt();
62 |
63 | // Initial cursor should be at 0
64 | expect(instance.cursor).to.equal(0);
65 |
66 | // Directly trigger the cursor event with 'down'
67 | instance.emit('key', '', { name: 'down' });
68 |
69 | // After down event, cursor should be 1
70 | expect(instance.cursor).to.equal(1);
71 |
72 | // Trigger cursor event with 'up'
73 | instance.emit('key', '', { name: 'up' });
74 |
75 | // After up event, cursor should be back to 0
76 | expect(instance.cursor).to.equal(0);
77 | });
78 |
79 | test('initialValue selects correct option', () => {
80 | const instance = new AutocompletePrompt({
81 | input,
82 | output,
83 | render: () => 'foo',
84 | options: testOptions,
85 | initialValue: ['cherry'],
86 | });
87 |
88 | // The cursor should be initialized to the cherry index
89 | const cherryIndex = testOptions.findIndex((opt) => opt.value === 'cherry');
90 | expect(instance.cursor).to.equal(cherryIndex);
91 |
92 | // The selectedValue should be cherry
93 | expect(instance.selectedValues).to.deep.equal(['cherry']);
94 | });
95 |
96 | test('initialValue defaults to first option when non-multiple', () => {
97 | const instance = new AutocompletePrompt({
98 | input,
99 | output,
100 | render: () => 'foo',
101 | options: testOptions,
102 | });
103 |
104 | expect(instance.cursor).to.equal(0);
105 | expect(instance.selectedValues).to.deep.equal(['apple']);
106 | });
107 |
108 | test('initialValue is empty when multiple', () => {
109 | const instance = new AutocompletePrompt({
110 | input,
111 | output,
112 | render: () => 'foo',
113 | options: testOptions,
114 | multiple: true,
115 | });
116 |
117 | expect(instance.cursor).to.equal(0);
118 | expect(instance.selectedValues).to.deep.equal([]);
119 | });
120 |
121 | test('filtering through value event', () => {
122 | const instance = new AutocompletePrompt({
123 | input,
124 | output,
125 | render: () => 'foo',
126 | options: testOptions,
127 | });
128 |
129 | instance.prompt();
130 |
131 | // Initial state should have all options
132 | expect(instance.filteredOptions.length).to.equal(testOptions.length);
133 |
134 | // Simulate typing 'a' by emitting value event
135 | instance.emit('value', 'a');
136 |
137 | // Check that filtered options are updated to include options with 'a'
138 | expect(instance.filteredOptions.length).to.be.lessThan(testOptions.length);
139 |
140 | // Check that 'apple' is in the filtered options
141 | const hasApple = instance.filteredOptions.some((opt) => opt.value === 'apple');
142 | expect(hasApple).to.equal(true);
143 | });
144 |
145 | test('default filter function works correctly', () => {
146 | const instance = new AutocompletePrompt({
147 | input,
148 | output,
149 | render: () => 'foo',
150 | options: testOptions,
151 | });
152 |
153 | instance.emit('value', 'ap');
154 |
155 | expect(instance.filteredOptions).toEqual([
156 | { value: 'apple', label: 'Apple' },
157 | { value: 'grape', label: 'Grape' },
158 | ]);
159 |
160 | instance.emit('value', 'z');
161 |
162 | expect(instance.filteredOptions).toEqual([]);
163 | });
164 |
165 | test('submit without nav resolves to first option in non-multiple', async () => {
166 | const instance = new AutocompletePrompt({
167 | input,
168 | output,
169 | render: () => 'foo',
170 | options: testOptions,
171 | });
172 |
173 | const promise = instance.prompt();
174 | input.emit('keypress', '', { name: 'return' });
175 | const result = await promise;
176 |
177 | expect(instance.selectedValues).to.deep.equal(['apple']);
178 | expect(result).to.equal('apple');
179 | });
180 |
181 | test('submit without nav resolves to [] in multiple', async () => {
182 | const instance = new AutocompletePrompt({
183 | input,
184 | output,
185 | render: () => 'foo',
186 | options: testOptions,
187 | multiple: true,
188 | });
189 |
190 | const promise = instance.prompt();
191 | input.emit('keypress', '', { name: 'return' });
192 | const result = await promise;
193 |
194 | expect(instance.selectedValues).to.deep.equal([]);
195 | expect(result).to.deep.equal([]);
196 | });
197 | });
198 |
--------------------------------------------------------------------------------
/packages/core/test/prompts/confirm.test.ts:
--------------------------------------------------------------------------------
1 | import color from 'picocolors';
2 | import { cursor } from 'sisteransi';
3 | import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
4 | import { default as ConfirmPrompt } from '../../src/prompts/confirm.js';
5 | import { MockReadable } from '../mock-readable.js';
6 | import { MockWritable } from '../mock-writable.js';
7 |
8 | describe('ConfirmPrompt', () => {
9 | let input: MockReadable;
10 | let output: MockWritable;
11 |
12 | beforeEach(() => {
13 | input = new MockReadable();
14 | output = new MockWritable();
15 | });
16 |
17 | afterEach(() => {
18 | vi.restoreAllMocks();
19 | });
20 |
21 | test('renders render() result', () => {
22 | const instance = new ConfirmPrompt({
23 | input,
24 | output,
25 | render: () => 'foo',
26 | active: 'yes',
27 | inactive: 'no',
28 | });
29 | instance.prompt();
30 | expect(output.buffer).to.deep.equal([cursor.hide, 'foo']);
31 | });
32 |
33 | test('sets value and submits on confirm (y)', () => {
34 | const instance = new ConfirmPrompt({
35 | input,
36 | output,
37 | render: () => 'foo',
38 | active: 'yes',
39 | inactive: 'no',
40 | initialValue: true,
41 | });
42 |
43 | instance.prompt();
44 | input.emit('keypress', 'y', { name: 'y' });
45 |
46 | expect(instance.value).to.equal(true);
47 | expect(instance.state).to.equal('submit');
48 | });
49 |
50 | test('sets value and submits on confirm (n)', () => {
51 | const instance = new ConfirmPrompt({
52 | input,
53 | output,
54 | render: () => 'foo',
55 | active: 'yes',
56 | inactive: 'no',
57 | initialValue: true,
58 | });
59 |
60 | instance.prompt();
61 | input.emit('keypress', 'n', { name: 'n' });
62 |
63 | expect(instance.value).to.equal(false);
64 | expect(instance.state).to.equal('submit');
65 | });
66 |
67 | describe('cursor', () => {
68 | test('cursor is 1 when inactive', () => {
69 | const instance = new ConfirmPrompt({
70 | input,
71 | output,
72 | render: () => 'foo',
73 | active: 'yes',
74 | inactive: 'no',
75 | initialValue: false,
76 | });
77 |
78 | instance.prompt();
79 | input.emit('keypress', '', { name: 'return' });
80 | expect(instance.cursor).to.equal(1);
81 | });
82 |
83 | test('cursor is 0 when active', () => {
84 | const instance = new ConfirmPrompt({
85 | input,
86 | output,
87 | render: () => 'foo',
88 | active: 'yes',
89 | inactive: 'no',
90 | initialValue: true,
91 | });
92 |
93 | instance.prompt();
94 | input.emit('keypress', '', { name: 'return' });
95 | expect(instance.cursor).to.equal(0);
96 | });
97 | });
98 | });
99 |
--------------------------------------------------------------------------------
/packages/core/test/prompts/password.test.ts:
--------------------------------------------------------------------------------
1 | import color from 'picocolors';
2 | import { cursor } from 'sisteransi';
3 | import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
4 | import { default as PasswordPrompt } from '../../src/prompts/password.js';
5 | import { MockReadable } from '../mock-readable.js';
6 | import { MockWritable } from '../mock-writable.js';
7 |
8 | describe('PasswordPrompt', () => {
9 | let input: MockReadable;
10 | let output: MockWritable;
11 |
12 | beforeEach(() => {
13 | input = new MockReadable();
14 | output = new MockWritable();
15 | });
16 |
17 | afterEach(() => {
18 | vi.restoreAllMocks();
19 | });
20 |
21 | test('renders render() result', () => {
22 | const instance = new PasswordPrompt({
23 | input,
24 | output,
25 | render: () => 'foo',
26 | });
27 | // leave the promise hanging since we don't want to submit in this test
28 | instance.prompt();
29 | expect(output.buffer).to.deep.equal([cursor.hide, 'foo']);
30 | });
31 |
32 | describe('cursor', () => {
33 | test('can get cursor', () => {
34 | const instance = new PasswordPrompt({
35 | input,
36 | output,
37 | render: () => 'foo',
38 | });
39 |
40 | expect(instance.cursor).to.equal(0);
41 | });
42 | });
43 |
44 | describe('valueWithCursor', () => {
45 | test('returns masked value on submit', () => {
46 | const instance = new PasswordPrompt({
47 | input,
48 | output,
49 | render: () => 'foo',
50 | });
51 | instance.prompt();
52 | const keys = 'foo';
53 | for (let i = 0; i < keys.length; i++) {
54 | input.emit('keypress', keys[i], { name: keys[i] });
55 | }
56 | input.emit('keypress', '', { name: 'return' });
57 | expect(instance.valueWithCursor).to.equal('•••');
58 | });
59 |
60 | test('renders marker at end', () => {
61 | const instance = new PasswordPrompt({
62 | input,
63 | output,
64 | render: () => 'foo',
65 | });
66 | instance.prompt();
67 | input.emit('keypress', 'x', { name: 'x' });
68 | expect(instance.valueWithCursor).to.equal(`•${color.inverse(color.hidden('_'))}`);
69 | });
70 |
71 | test('renders cursor inside value', () => {
72 | const instance = new PasswordPrompt({
73 | input,
74 | output,
75 | render: () => 'foo',
76 | });
77 | instance.prompt();
78 | input.emit('keypress', 'x', { name: 'x' });
79 | input.emit('keypress', 'y', { name: 'y' });
80 | input.emit('keypress', 'z', { name: 'z' });
81 | input.emit('keypress', 'left', { name: 'left' });
82 | input.emit('keypress', 'left', { name: 'left' });
83 | expect(instance.valueWithCursor).to.equal(`•${color.inverse('•')}•`);
84 | });
85 |
86 | test('renders custom mask', () => {
87 | const instance = new PasswordPrompt({
88 | input,
89 | output,
90 | render: () => 'foo',
91 | mask: 'X',
92 | });
93 | instance.prompt();
94 | input.emit('keypress', 'x', { name: 'x' });
95 | expect(instance.valueWithCursor).to.equal(`X${color.inverse(color.hidden('_'))}`);
96 | });
97 | });
98 | });
99 |
--------------------------------------------------------------------------------
/packages/core/test/prompts/select.test.ts:
--------------------------------------------------------------------------------
1 | import color from 'picocolors';
2 | import { cursor } from 'sisteransi';
3 | import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
4 | import { default as SelectPrompt } from '../../src/prompts/select.js';
5 | import { MockReadable } from '../mock-readable.js';
6 | import { MockWritable } from '../mock-writable.js';
7 |
8 | describe('SelectPrompt', () => {
9 | let input: MockReadable;
10 | let output: MockWritable;
11 |
12 | beforeEach(() => {
13 | input = new MockReadable();
14 | output = new MockWritable();
15 | });
16 |
17 | afterEach(() => {
18 | vi.restoreAllMocks();
19 | });
20 |
21 | test('renders render() result', () => {
22 | const instance = new SelectPrompt({
23 | input,
24 | output,
25 | render: () => 'foo',
26 | options: [{ value: 'foo' }, { value: 'bar' }],
27 | });
28 | instance.prompt();
29 | expect(output.buffer).to.deep.equal([cursor.hide, 'foo']);
30 | });
31 |
32 | describe('cursor', () => {
33 | test('cursor is index of selected item', () => {
34 | const instance = new SelectPrompt({
35 | input,
36 | output,
37 | render: () => 'foo',
38 | options: [{ value: 'foo' }, { value: 'bar' }],
39 | });
40 |
41 | instance.prompt();
42 |
43 | expect(instance.cursor).to.equal(0);
44 | input.emit('keypress', 'down', { name: 'down' });
45 | expect(instance.cursor).to.equal(1);
46 | });
47 |
48 | test('cursor loops around', () => {
49 | const instance = new SelectPrompt({
50 | input,
51 | output,
52 | render: () => 'foo',
53 | options: [{ value: 'foo' }, { value: 'bar' }, { value: 'baz' }],
54 | });
55 |
56 | instance.prompt();
57 |
58 | expect(instance.cursor).to.equal(0);
59 | input.emit('keypress', 'up', { name: 'up' });
60 | expect(instance.cursor).to.equal(2);
61 | input.emit('keypress', 'down', { name: 'down' });
62 | expect(instance.cursor).to.equal(0);
63 | });
64 |
65 | test('left behaves as up', () => {
66 | const instance = new SelectPrompt({
67 | input,
68 | output,
69 | render: () => 'foo',
70 | options: [{ value: 'foo' }, { value: 'bar' }, { value: 'baz' }],
71 | });
72 |
73 | instance.prompt();
74 |
75 | input.emit('keypress', 'left', { name: 'left' });
76 | expect(instance.cursor).to.equal(2);
77 | });
78 |
79 | test('right behaves as down', () => {
80 | const instance = new SelectPrompt({
81 | input,
82 | output,
83 | render: () => 'foo',
84 | options: [{ value: 'foo' }, { value: 'bar' }],
85 | });
86 |
87 | instance.prompt();
88 |
89 | input.emit('keypress', 'left', { name: 'left' });
90 | expect(instance.cursor).to.equal(1);
91 | });
92 |
93 | test('initial value is selected', () => {
94 | const instance = new SelectPrompt({
95 | input,
96 | output,
97 | render: () => 'foo',
98 | options: [{ value: 'foo' }, { value: 'bar' }],
99 | initialValue: 'bar',
100 | });
101 | instance.prompt();
102 | expect(instance.cursor).to.equal(1);
103 | });
104 | });
105 | });
106 |
--------------------------------------------------------------------------------
/packages/core/test/prompts/text.test.ts:
--------------------------------------------------------------------------------
1 | import color from 'picocolors';
2 | import { cursor } from 'sisteransi';
3 | import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
4 | import { default as TextPrompt } from '../../src/prompts/text.js';
5 | import { MockReadable } from '../mock-readable.js';
6 | import { MockWritable } from '../mock-writable.js';
7 |
8 | describe('TextPrompt', () => {
9 | let input: MockReadable;
10 | let output: MockWritable;
11 |
12 | beforeEach(() => {
13 | input = new MockReadable();
14 | output = new MockWritable();
15 | });
16 |
17 | afterEach(() => {
18 | vi.restoreAllMocks();
19 | });
20 |
21 | test('renders render() result', () => {
22 | const instance = new TextPrompt({
23 | input,
24 | output,
25 | render: () => 'foo',
26 | });
27 | // leave the promise hanging since we don't want to submit in this test
28 | instance.prompt();
29 | expect(output.buffer).to.deep.equal([cursor.hide, 'foo']);
30 | });
31 |
32 | test('sets default value on finalize if no value', async () => {
33 | const instance = new TextPrompt({
34 | input,
35 | output,
36 | render: () => 'foo',
37 | defaultValue: 'bleep bloop',
38 | });
39 | const resultPromise = instance.prompt();
40 | input.emit('keypress', '', { name: 'return' });
41 | const result = await resultPromise;
42 | expect(result).to.equal('bleep bloop');
43 | });
44 |
45 | test('keeps value on finalize', async () => {
46 | const instance = new TextPrompt({
47 | input,
48 | output,
49 | render: () => 'foo',
50 | defaultValue: 'bleep bloop',
51 | });
52 | const resultPromise = instance.prompt();
53 | input.emit('keypress', 'x', { name: 'x' });
54 | input.emit('keypress', '', { name: 'return' });
55 | const result = await resultPromise;
56 | expect(result).to.equal('x');
57 | });
58 |
59 | describe('cursor', () => {
60 | test('can get cursor', () => {
61 | const instance = new TextPrompt({
62 | input,
63 | output,
64 | render: () => 'foo',
65 | });
66 |
67 | expect(instance.cursor).to.equal(0);
68 | });
69 | });
70 |
71 | describe('valueWithCursor', () => {
72 | test('returns value on submit', () => {
73 | const instance = new TextPrompt({
74 | input,
75 | output,
76 | render: () => 'foo',
77 | });
78 | instance.prompt();
79 | input.emit('keypress', 'x', { name: 'x' });
80 | input.emit('keypress', '', { name: 'return' });
81 | expect(instance.valueWithCursor).to.equal('x');
82 | });
83 |
84 | test('highlights cursor position', () => {
85 | const instance = new TextPrompt({
86 | input,
87 | output,
88 | render: () => 'foo',
89 | });
90 | instance.prompt();
91 | const keys = 'foo';
92 | for (let i = 0; i < keys.length; i++) {
93 | input.emit('keypress', keys[i], { name: keys[i] });
94 | }
95 | input.emit('keypress', 'left', { name: 'left' });
96 | expect(instance.valueWithCursor).to.equal(`fo${color.inverse('o')}`);
97 | });
98 |
99 | test('shows cursor at end if beyond value', () => {
100 | const instance = new TextPrompt({
101 | input,
102 | output,
103 | render: () => 'foo',
104 | });
105 | instance.prompt();
106 | const keys = 'foo';
107 | for (let i = 0; i < keys.length; i++) {
108 | input.emit('keypress', keys[i], { name: keys[i] });
109 | }
110 | input.emit('keypress', 'right', { name: 'right' });
111 | expect(instance.valueWithCursor).to.equal('foo█');
112 | });
113 |
114 | test('does not use placeholder as value when pressing enter', async () => {
115 | const instance = new TextPrompt({
116 | input,
117 | output,
118 | render: () => 'foo',
119 | placeholder: ' (hit Enter to use default)',
120 | defaultValue: 'default-value',
121 | });
122 | const resultPromise = instance.prompt();
123 | input.emit('keypress', '', { name: 'return' });
124 | const result = await resultPromise;
125 | expect(result).to.equal('default-value');
126 | });
127 |
128 | test('returns empty string when no value and no default', async () => {
129 | const instance = new TextPrompt({
130 | input,
131 | output,
132 | render: () => 'foo',
133 | placeholder: ' (hit Enter to use default)',
134 | });
135 | const resultPromise = instance.prompt();
136 | input.emit('keypress', '', { name: 'return' });
137 | const result = await resultPromise;
138 | expect(result).to.equal('');
139 | });
140 | });
141 | });
142 |
--------------------------------------------------------------------------------
/packages/core/test/utils.test.ts:
--------------------------------------------------------------------------------
1 | import type { Key } from 'node:readline';
2 | import { cursor } from 'sisteransi';
3 | import { afterEach, describe, expect, test, vi } from 'vitest';
4 | import { block } from '../src/utils/index.js';
5 | import { MockReadable } from './mock-readable.js';
6 | import { MockWritable } from './mock-writable.js';
7 |
8 | describe('utils', () => {
9 | afterEach(() => {
10 | vi.restoreAllMocks();
11 | });
12 |
13 | describe('block', () => {
14 | test('clears output on keypress', () => {
15 | const input = new MockReadable();
16 | const output = new MockWritable();
17 | // @ts-ignore
18 | const callback = block({ input, output });
19 |
20 | const event: Key = {
21 | name: 'x',
22 | };
23 | const eventData = Buffer.from('bloop');
24 | input.emit('keypress', eventData, event);
25 | callback();
26 | expect(output.buffer).to.deep.equal([cursor.hide, cursor.move(-1, 0), cursor.show]);
27 | });
28 |
29 | test('clears output vertically when return pressed', () => {
30 | const input = new MockReadable();
31 | const output = new MockWritable();
32 | // @ts-ignore
33 | const callback = block({ input, output });
34 |
35 | const event: Key = {
36 | name: 'return',
37 | };
38 | const eventData = Buffer.from('bloop');
39 | input.emit('keypress', eventData, event);
40 | callback();
41 | expect(output.buffer).to.deep.equal([cursor.hide, cursor.move(0, -1), cursor.show]);
42 | });
43 |
44 | test('ignores additional keypresses after dispose', () => {
45 | const input = new MockReadable();
46 | const output = new MockWritable();
47 | // @ts-ignore
48 | const callback = block({ input, output });
49 |
50 | const event: Key = {
51 | name: 'x',
52 | };
53 | const eventData = Buffer.from('bloop');
54 | input.emit('keypress', eventData, event);
55 | callback();
56 | input.emit('keypress', eventData, event);
57 | expect(output.buffer).to.deep.equal([cursor.hide, cursor.move(-1, 0), cursor.show]);
58 | });
59 |
60 | test('exits on ctrl-c', () => {
61 | const input = new MockReadable();
62 | const output = new MockWritable();
63 | // purposely don't keep the callback since we would exit the process
64 | // @ts-ignore
65 | block({ input, output });
66 | // @ts-ignore
67 | const spy = vi.spyOn(process, 'exit').mockImplementation(() => {
68 | return;
69 | });
70 |
71 | const event: Key = {
72 | name: 'c',
73 | };
74 | const eventData = Buffer.from('\x03');
75 | input.emit('keypress', eventData, event);
76 | expect(spy).toHaveBeenCalled();
77 | expect(output.buffer).to.deep.equal([cursor.hide, cursor.show]);
78 | });
79 |
80 | test('does not clear if overwrite=false', () => {
81 | const input = new MockReadable();
82 | const output = new MockWritable();
83 | // @ts-ignore
84 | const callback = block({ input, output, overwrite: false });
85 |
86 | const event: Key = {
87 | name: 'c',
88 | };
89 | const eventData = Buffer.from('bloop');
90 | input.emit('keypress', eventData, event);
91 | callback();
92 | expect(output.buffer).to.deep.equal([cursor.hide, cursor.show]);
93 | });
94 | });
95 | });
96 |
--------------------------------------------------------------------------------
/packages/core/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../../tsconfig.json"
3 | }
4 |
--------------------------------------------------------------------------------
/packages/prompts/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) Nate Moore
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
6 |
7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
8 |
9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
10 |
--------------------------------------------------------------------------------
/packages/prompts/__mocks__/fs.cjs:
--------------------------------------------------------------------------------
1 | const { fs } = require('memfs');
2 | module.exports = fs;
3 |
--------------------------------------------------------------------------------
/packages/prompts/build.config.ts:
--------------------------------------------------------------------------------
1 | import { defineBuildConfig } from 'unbuild';
2 |
3 | export default defineBuildConfig({
4 | preset: '../../build.preset',
5 | entries: ['src/index'],
6 | });
7 |
--------------------------------------------------------------------------------
/packages/prompts/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@clack/prompts",
3 | "version": "1.0.0-alpha.0",
4 | "type": "module",
5 | "main": "./dist/index.mjs",
6 | "module": "./dist/index.mjs",
7 | "exports": {
8 | ".": {
9 | "types": "./dist/index.d.mts",
10 | "default": "./dist/index.mjs"
11 | },
12 | "./package.json": "./package.json"
13 | },
14 | "types": "./dist/index.d.mts",
15 | "repository": {
16 | "type": "git",
17 | "url": "git+https://github.com/bombshell-dev/clack.git",
18 | "directory": "packages/prompts"
19 | },
20 | "bugs": {
21 | "url": "https://github.com/bombshell-dev/clack/issues"
22 | },
23 | "homepage": "https://github.com/bombshell-dev/clack/tree/main/packages/prompts#readme",
24 | "files": ["dist", "CHANGELOG.md"],
25 | "author": {
26 | "name": "Nate Moore",
27 | "email": "nate@natemoo.re",
28 | "url": "https://twitter.com/n_moore"
29 | },
30 | "license": "MIT",
31 | "keywords": [
32 | "ask",
33 | "clack",
34 | "cli",
35 | "command-line",
36 | "command",
37 | "input",
38 | "interact",
39 | "interface",
40 | "menu",
41 | "prompt",
42 | "prompts",
43 | "stdin",
44 | "ui"
45 | ],
46 | "packageManager": "pnpm@8.6.12",
47 | "scripts": {
48 | "build": "unbuild",
49 | "prepack": "pnpm build",
50 | "test": "FORCE_COLOR=1 vitest run"
51 | },
52 | "dependencies": {
53 | "@clack/core": "workspace:*",
54 | "picocolors": "^1.0.0",
55 | "sisteransi": "^1.0.5"
56 | },
57 | "devDependencies": {
58 | "is-unicode-supported": "^1.3.0",
59 | "memfs": "^4.17.1",
60 | "vitest": "^3.1.1",
61 | "vitest-ansi-serializer": "^0.1.2"
62 | }
63 | }
64 |
--------------------------------------------------------------------------------
/packages/prompts/src/common.ts:
--------------------------------------------------------------------------------
1 | import type { Readable, Writable } from 'node:stream';
2 | import type { State } from '@clack/core';
3 | import isUnicodeSupported from 'is-unicode-supported';
4 | import color from 'picocolors';
5 |
6 | export const unicode = isUnicodeSupported();
7 | export const isCI = (): boolean => process.env.CI === 'true';
8 | export const unicodeOr = (c: string, fallback: string) => (unicode ? c : fallback);
9 | export const S_STEP_ACTIVE = unicodeOr('◆', '*');
10 | export const S_STEP_CANCEL = unicodeOr('■', 'x');
11 | export const S_STEP_ERROR = unicodeOr('▲', 'x');
12 | export const S_STEP_SUBMIT = unicodeOr('◇', 'o');
13 |
14 | export const S_BAR_START = unicodeOr('┌', 'T');
15 | export const S_BAR = unicodeOr('│', '|');
16 | export const S_BAR_END = unicodeOr('└', '—');
17 |
18 | export const S_RADIO_ACTIVE = unicodeOr('●', '>');
19 | export const S_RADIO_INACTIVE = unicodeOr('○', ' ');
20 | export const S_CHECKBOX_ACTIVE = unicodeOr('◻', '[•]');
21 | export const S_CHECKBOX_SELECTED = unicodeOr('◼', '[+]');
22 | export const S_CHECKBOX_INACTIVE = unicodeOr('◻', '[ ]');
23 | export const S_PASSWORD_MASK = unicodeOr('▪', '•');
24 |
25 | export const S_BAR_H = unicodeOr('─', '-');
26 | export const S_CORNER_TOP_RIGHT = unicodeOr('╮', '+');
27 | export const S_CONNECT_LEFT = unicodeOr('├', '+');
28 | export const S_CORNER_BOTTOM_RIGHT = unicodeOr('╯', '+');
29 |
30 | export const S_INFO = unicodeOr('●', '•');
31 | export const S_SUCCESS = unicodeOr('◆', '*');
32 | export const S_WARN = unicodeOr('▲', '!');
33 | export const S_ERROR = unicodeOr('■', 'x');
34 |
35 | export const symbol = (state: State) => {
36 | switch (state) {
37 | case 'initial':
38 | case 'active':
39 | return color.cyan(S_STEP_ACTIVE);
40 | case 'cancel':
41 | return color.red(S_STEP_CANCEL);
42 | case 'error':
43 | return color.yellow(S_STEP_ERROR);
44 | case 'submit':
45 | return color.green(S_STEP_SUBMIT);
46 | }
47 | };
48 |
49 | export interface CommonOptions {
50 | input?: Readable;
51 | output?: Writable;
52 | }
53 |
--------------------------------------------------------------------------------
/packages/prompts/src/confirm.ts:
--------------------------------------------------------------------------------
1 | import { ConfirmPrompt } from '@clack/core';
2 | import color from 'picocolors';
3 | import {
4 | type CommonOptions,
5 | S_BAR,
6 | S_BAR_END,
7 | S_RADIO_ACTIVE,
8 | S_RADIO_INACTIVE,
9 | symbol,
10 | } from './common.js';
11 |
12 | export interface ConfirmOptions extends CommonOptions {
13 | message: string;
14 | active?: string;
15 | inactive?: string;
16 | initialValue?: boolean;
17 | }
18 | export const confirm = (opts: ConfirmOptions) => {
19 | const active = opts.active ?? 'Yes';
20 | const inactive = opts.inactive ?? 'No';
21 | return new ConfirmPrompt({
22 | active,
23 | inactive,
24 | input: opts.input,
25 | output: opts.output,
26 | initialValue: opts.initialValue ?? true,
27 | render() {
28 | const title = `${color.gray(S_BAR)}\n${symbol(this.state)} ${opts.message}\n`;
29 | const value = this.value ? active : inactive;
30 |
31 | switch (this.state) {
32 | case 'submit':
33 | return `${title}${color.gray(S_BAR)} ${color.dim(value)}`;
34 | case 'cancel':
35 | return `${title}${color.gray(S_BAR)} ${color.strikethrough(
36 | color.dim(value)
37 | )}\n${color.gray(S_BAR)}`;
38 | default: {
39 | return `${title}${color.cyan(S_BAR)} ${
40 | this.value
41 | ? `${color.green(S_RADIO_ACTIVE)} ${active}`
42 | : `${color.dim(S_RADIO_INACTIVE)} ${color.dim(active)}`
43 | } ${color.dim('/')} ${
44 | !this.value
45 | ? `${color.green(S_RADIO_ACTIVE)} ${inactive}`
46 | : `${color.dim(S_RADIO_INACTIVE)} ${color.dim(inactive)}`
47 | }\n${color.cyan(S_BAR_END)}\n`;
48 | }
49 | }
50 | },
51 | }).prompt() as Promise;
52 | };
53 |
--------------------------------------------------------------------------------
/packages/prompts/src/group-multi-select.ts:
--------------------------------------------------------------------------------
1 | import { GroupMultiSelectPrompt } from '@clack/core';
2 | import color from 'picocolors';
3 | import {
4 | type CommonOptions,
5 | S_BAR,
6 | S_BAR_END,
7 | S_CHECKBOX_ACTIVE,
8 | S_CHECKBOX_INACTIVE,
9 | S_CHECKBOX_SELECTED,
10 | symbol,
11 | } from './common.js';
12 | import type { Option } from './select.js';
13 |
14 | export interface GroupMultiSelectOptions extends CommonOptions {
15 | message: string;
16 | options: Record[]>;
17 | initialValues?: Value[];
18 | required?: boolean;
19 | cursorAt?: Value;
20 | selectableGroups?: boolean;
21 | groupSpacing?: number;
22 | }
23 | export const groupMultiselect = (opts: GroupMultiSelectOptions) => {
24 | const { selectableGroups = true, groupSpacing = 0 } = opts;
25 | const opt = (
26 | option: Option,
27 | state:
28 | | 'inactive'
29 | | 'active'
30 | | 'selected'
31 | | 'active-selected'
32 | | 'group-active'
33 | | 'group-active-selected'
34 | | 'submitted'
35 | | 'cancelled',
36 | options: Option[] = []
37 | ) => {
38 | const label = option.label ?? String(option.value);
39 | const isItem = typeof (option as any).group === 'string';
40 | const next = isItem && (options[options.indexOf(option) + 1] ?? { group: true });
41 | const isLast = isItem && (next as any).group === true;
42 | const prefix = isItem ? (selectableGroups ? `${isLast ? S_BAR_END : S_BAR} ` : ' ') : '';
43 | const spacingPrefix =
44 | groupSpacing > 0 && !isItem ? `\n${color.cyan(S_BAR)} `.repeat(groupSpacing) : '';
45 |
46 | if (state === 'active') {
47 | return `${spacingPrefix}${color.dim(prefix)}${color.cyan(S_CHECKBOX_ACTIVE)} ${label} ${
48 | option.hint ? color.dim(`(${option.hint})`) : ''
49 | }`;
50 | }
51 | if (state === 'group-active') {
52 | return `${spacingPrefix}${prefix}${color.cyan(S_CHECKBOX_ACTIVE)} ${color.dim(label)}`;
53 | }
54 | if (state === 'group-active-selected') {
55 | return `${spacingPrefix}${prefix}${color.green(S_CHECKBOX_SELECTED)} ${color.dim(label)}`;
56 | }
57 | if (state === 'selected') {
58 | const selectedCheckbox = isItem || selectableGroups ? color.green(S_CHECKBOX_SELECTED) : '';
59 | return `${spacingPrefix}${color.dim(prefix)}${selectedCheckbox} ${color.dim(label)} ${
60 | option.hint ? color.dim(`(${option.hint})`) : ''
61 | }`;
62 | }
63 | if (state === 'cancelled') {
64 | return `${color.strikethrough(color.dim(label))}`;
65 | }
66 | if (state === 'active-selected') {
67 | return `${spacingPrefix}${color.dim(prefix)}${color.green(S_CHECKBOX_SELECTED)} ${label} ${
68 | option.hint ? color.dim(`(${option.hint})`) : ''
69 | }`;
70 | }
71 | if (state === 'submitted') {
72 | return `${color.dim(label)}`;
73 | }
74 | const unselectedCheckbox = isItem || selectableGroups ? color.dim(S_CHECKBOX_INACTIVE) : '';
75 | return `${spacingPrefix}${color.dim(prefix)}${unselectedCheckbox} ${color.dim(label)}`;
76 | };
77 |
78 | return new GroupMultiSelectPrompt({
79 | options: opts.options,
80 | input: opts.input,
81 | output: opts.output,
82 | initialValues: opts.initialValues,
83 | required: opts.required ?? true,
84 | cursorAt: opts.cursorAt,
85 | selectableGroups,
86 | validate(selected: Value[]) {
87 | if (this.required && selected.length === 0)
88 | return `Please select at least one option.\n${color.reset(
89 | color.dim(
90 | `Press ${color.gray(color.bgWhite(color.inverse(' space ')))} to select, ${color.gray(
91 | color.bgWhite(color.inverse(' enter '))
92 | )} to submit`
93 | )
94 | )}`;
95 | },
96 | render() {
97 | const title = `${color.gray(S_BAR)}\n${symbol(this.state)} ${opts.message}\n`;
98 |
99 | switch (this.state) {
100 | case 'submit': {
101 | return `${title}${color.gray(S_BAR)} ${this.options
102 | .filter(({ value }) => this.value.includes(value))
103 | .map((option) => opt(option, 'submitted'))
104 | .join(color.dim(', '))}`;
105 | }
106 | case 'cancel': {
107 | const label = this.options
108 | .filter(({ value }) => this.value.includes(value))
109 | .map((option) => opt(option, 'cancelled'))
110 | .join(color.dim(', '));
111 | return `${title}${color.gray(S_BAR)} ${
112 | label.trim() ? `${label}\n${color.gray(S_BAR)}` : ''
113 | }`;
114 | }
115 | case 'error': {
116 | const footer = this.error
117 | .split('\n')
118 | .map((ln, i) =>
119 | i === 0 ? `${color.yellow(S_BAR_END)} ${color.yellow(ln)}` : ` ${ln}`
120 | )
121 | .join('\n');
122 | return `${title}${color.yellow(S_BAR)} ${this.options
123 | .map((option, i, options) => {
124 | const selected =
125 | this.value.includes(option.value) ||
126 | (option.group === true && this.isGroupSelected(`${option.value}`));
127 | const active = i === this.cursor;
128 | const groupActive =
129 | !active &&
130 | typeof option.group === 'string' &&
131 | this.options[this.cursor].value === option.group;
132 | if (groupActive) {
133 | return opt(option, selected ? 'group-active-selected' : 'group-active', options);
134 | }
135 | if (active && selected) {
136 | return opt(option, 'active-selected', options);
137 | }
138 | if (selected) {
139 | return opt(option, 'selected', options);
140 | }
141 | return opt(option, active ? 'active' : 'inactive', options);
142 | })
143 | .join(`\n${color.yellow(S_BAR)} `)}\n${footer}\n`;
144 | }
145 | default: {
146 | return `${title}${color.cyan(S_BAR)} ${this.options
147 | .map((option, i, options) => {
148 | const selected =
149 | this.value.includes(option.value) ||
150 | (option.group === true && this.isGroupSelected(`${option.value}`));
151 | const active = i === this.cursor;
152 | const groupActive =
153 | !active &&
154 | typeof option.group === 'string' &&
155 | this.options[this.cursor].value === option.group;
156 | if (groupActive) {
157 | return opt(option, selected ? 'group-active-selected' : 'group-active', options);
158 | }
159 | if (active && selected) {
160 | return opt(option, 'active-selected', options);
161 | }
162 | if (selected) {
163 | return opt(option, 'selected', options);
164 | }
165 | return opt(option, active ? 'active' : 'inactive', options);
166 | })
167 | .join(`\n${color.cyan(S_BAR)} `)}\n${color.cyan(S_BAR_END)}\n`;
168 | }
169 | }
170 | },
171 | }).prompt() as Promise;
172 | };
173 |
--------------------------------------------------------------------------------
/packages/prompts/src/group.ts:
--------------------------------------------------------------------------------
1 | import { isCancel } from '@clack/core';
2 |
3 | type Prettify = {
4 | [P in keyof T]: T[P];
5 | } & {};
6 |
7 | export type PromptGroupAwaitedReturn = {
8 | [P in keyof T]: Exclude, symbol>;
9 | };
10 |
11 | export interface PromptGroupOptions {
12 | /**
13 | * Control how the group can be canceled
14 | * if one of the prompts is canceled.
15 | */
16 | onCancel?: (opts: {
17 | results: Prettify>>;
18 | }) => void;
19 | }
20 |
21 | export type PromptGroup = {
22 | [P in keyof T]: (opts: {
23 | results: Prettify>>>;
24 | }) => undefined | Promise;
25 | };
26 |
27 | /**
28 | * Define a group of prompts to be displayed
29 | * and return a results of objects within the group
30 | */
31 | export const group = async (
32 | prompts: PromptGroup,
33 | opts?: PromptGroupOptions
34 | ): Promise>> => {
35 | const results = {} as any;
36 | const promptNames = Object.keys(prompts);
37 |
38 | for (const name of promptNames) {
39 | const prompt = prompts[name as keyof T];
40 | const result = await prompt({ results })?.catch((e) => {
41 | throw e;
42 | });
43 |
44 | // Pass the results to the onCancel function
45 | // so the user can decide what to do with the results
46 | // TODO: Switch to callback within core to avoid isCancel Fn
47 | if (typeof opts?.onCancel === 'function' && isCancel(result)) {
48 | results[name] = 'canceled';
49 | opts.onCancel({ results });
50 | continue;
51 | }
52 |
53 | results[name] = result;
54 | }
55 |
56 | return results;
57 | };
58 |
--------------------------------------------------------------------------------
/packages/prompts/src/index.ts:
--------------------------------------------------------------------------------
1 | export { isCancel, updateSettings, settings, type ClackSettings } from '@clack/core';
2 |
3 | export * from './autocomplete.js';
4 | export * from './common.js';
5 | export * from './confirm.js';
6 | export * from './group-multi-select.js';
7 | export * from './group.js';
8 | export * from './limit-options.js';
9 | export * from './log.js';
10 | export * from './messages.js';
11 | export * from './multi-select.js';
12 | export * from './note.js';
13 | export * from './password.js';
14 | export * from './path.js';
15 | export * from './progress-bar.js';
16 | export * from './select-key.js';
17 | export * from './select.js';
18 | export * from './spinner.js';
19 | export * from './stream.js';
20 | export * from './suggestion.js';
21 | export * from './task.js';
22 | export * from './task-log.js';
23 | export * from './text.js';
24 |
--------------------------------------------------------------------------------
/packages/prompts/src/limit-options.ts:
--------------------------------------------------------------------------------
1 | import type { Writable } from 'node:stream';
2 | import { WriteStream } from 'node:tty';
3 | import color from 'picocolors';
4 | import type { CommonOptions } from './common.js';
5 |
6 | export interface LimitOptionsParams extends CommonOptions {
7 | options: TOption[];
8 | maxItems: number | undefined;
9 | cursor: number;
10 | style: (option: TOption, active: boolean) => string;
11 | }
12 |
13 | export const limitOptions = (params: LimitOptionsParams): string[] => {
14 | const { cursor, options, style } = params;
15 | const output: Writable = params.output ?? process.stdout;
16 | const rows = output instanceof WriteStream && output.rows !== undefined ? output.rows : 10;
17 | const overflowFormat = color.dim('...');
18 |
19 | const paramMaxItems = params.maxItems ?? Number.POSITIVE_INFINITY;
20 | const outputMaxItems = Math.max(rows - 4, 0);
21 | // We clamp to minimum 5 because anything less doesn't make sense UX wise
22 | const maxItems = Math.min(outputMaxItems, Math.max(paramMaxItems, 5));
23 | let slidingWindowLocation = 0;
24 |
25 | if (cursor >= slidingWindowLocation + maxItems - 3) {
26 | slidingWindowLocation = Math.max(Math.min(cursor - maxItems + 3, options.length - maxItems), 0);
27 | } else if (cursor < slidingWindowLocation + 2) {
28 | slidingWindowLocation = Math.max(cursor - 2, 0);
29 | }
30 |
31 | const shouldRenderTopEllipsis = maxItems < options.length && slidingWindowLocation > 0;
32 | const shouldRenderBottomEllipsis =
33 | maxItems < options.length && slidingWindowLocation + maxItems < options.length;
34 |
35 | return options
36 | .slice(slidingWindowLocation, slidingWindowLocation + maxItems)
37 | .map((option, i, arr) => {
38 | const isTopLimit = i === 0 && shouldRenderTopEllipsis;
39 | const isBottomLimit = i === arr.length - 1 && shouldRenderBottomEllipsis;
40 | return isTopLimit || isBottomLimit
41 | ? overflowFormat
42 | : style(option, i + slidingWindowLocation === cursor);
43 | });
44 | };
45 |
--------------------------------------------------------------------------------
/packages/prompts/src/log.ts:
--------------------------------------------------------------------------------
1 | import color from 'picocolors';
2 | import {
3 | type CommonOptions,
4 | S_BAR,
5 | S_ERROR,
6 | S_INFO,
7 | S_STEP_SUBMIT,
8 | S_SUCCESS,
9 | S_WARN,
10 | } from './common.js';
11 |
12 | export interface LogMessageOptions extends CommonOptions {
13 | symbol?: string;
14 | spacing?: number;
15 | secondarySymbol?: string;
16 | }
17 |
18 | export const log = {
19 | message: (
20 | message: string | string[] = [],
21 | {
22 | symbol = color.gray(S_BAR),
23 | secondarySymbol = color.gray(S_BAR),
24 | output = process.stdout,
25 | spacing = 1,
26 | }: LogMessageOptions = {}
27 | ) => {
28 | const parts: string[] = [];
29 | for (let i = 0; i < spacing; i++) {
30 | parts.push(`${secondarySymbol}`);
31 | }
32 | const messageParts = Array.isArray(message) ? message : message.split('\n');
33 | if (messageParts.length > 0) {
34 | const [firstLine, ...lines] = messageParts;
35 | if (firstLine.length > 0) {
36 | parts.push(`${symbol} ${firstLine}`);
37 | } else {
38 | parts.push(symbol);
39 | }
40 | for (const ln of lines) {
41 | if (ln.length > 0) {
42 | parts.push(`${secondarySymbol} ${ln}`);
43 | } else {
44 | parts.push(secondarySymbol);
45 | }
46 | }
47 | }
48 | output.write(`${parts.join('\n')}\n`);
49 | },
50 | info: (message: string, opts?: LogMessageOptions) => {
51 | log.message(message, { ...opts, symbol: color.blue(S_INFO) });
52 | },
53 | success: (message: string, opts?: LogMessageOptions) => {
54 | log.message(message, { ...opts, symbol: color.green(S_SUCCESS) });
55 | },
56 | step: (message: string, opts?: LogMessageOptions) => {
57 | log.message(message, { ...opts, symbol: color.green(S_STEP_SUBMIT) });
58 | },
59 | warn: (message: string, opts?: LogMessageOptions) => {
60 | log.message(message, { ...opts, symbol: color.yellow(S_WARN) });
61 | },
62 | /** alias for `log.warn()`. */
63 | warning: (message: string, opts?: LogMessageOptions) => {
64 | log.warn(message, opts);
65 | },
66 | error: (message: string, opts?: LogMessageOptions) => {
67 | log.message(message, { ...opts, symbol: color.red(S_ERROR) });
68 | },
69 | };
70 |
--------------------------------------------------------------------------------
/packages/prompts/src/messages.ts:
--------------------------------------------------------------------------------
1 | import type { Writable } from 'node:stream';
2 | import color from 'picocolors';
3 | import { type CommonOptions, S_BAR, S_BAR_END, S_BAR_START } from './common.js';
4 |
5 | export const cancel = (message = '', opts?: CommonOptions) => {
6 | const output: Writable = opts?.output ?? process.stdout;
7 | output.write(`${color.gray(S_BAR_END)} ${color.red(message)}\n\n`);
8 | };
9 |
10 | export const intro = (title = '', opts?: CommonOptions) => {
11 | const output: Writable = opts?.output ?? process.stdout;
12 | output.write(`${color.gray(S_BAR_START)} ${title}\n`);
13 | };
14 |
15 | export const outro = (message = '', opts?: CommonOptions) => {
16 | const output: Writable = opts?.output ?? process.stdout;
17 | output.write(`${color.gray(S_BAR)}\n${color.gray(S_BAR_END)} ${message}\n\n`);
18 | };
19 |
--------------------------------------------------------------------------------
/packages/prompts/src/multi-select.ts:
--------------------------------------------------------------------------------
1 | import { MultiSelectPrompt } from '@clack/core';
2 | import color from 'picocolors';
3 | import {
4 | type CommonOptions,
5 | S_BAR,
6 | S_BAR_END,
7 | S_CHECKBOX_ACTIVE,
8 | S_CHECKBOX_INACTIVE,
9 | S_CHECKBOX_SELECTED,
10 | symbol,
11 | } from './common.js';
12 | import { limitOptions } from './limit-options.js';
13 | import type { Option } from './select.js';
14 |
15 | export interface MultiSelectOptions extends CommonOptions {
16 | message: string;
17 | options: Option[];
18 | initialValues?: Value[];
19 | maxItems?: number;
20 | required?: boolean;
21 | cursorAt?: Value;
22 | }
23 | export const multiselect = (opts: MultiSelectOptions) => {
24 | const opt = (
25 | option: Option,
26 | state: 'inactive' | 'active' | 'selected' | 'active-selected' | 'submitted' | 'cancelled'
27 | ) => {
28 | const label = option.label ?? String(option.value);
29 | if (state === 'active') {
30 | return `${color.cyan(S_CHECKBOX_ACTIVE)} ${label} ${
31 | option.hint ? color.dim(`(${option.hint})`) : ''
32 | }`;
33 | }
34 | if (state === 'selected') {
35 | return `${color.green(S_CHECKBOX_SELECTED)} ${color.dim(label)} ${
36 | option.hint ? color.dim(`(${option.hint})`) : ''
37 | }`;
38 | }
39 | if (state === 'cancelled') {
40 | return `${color.strikethrough(color.dim(label))}`;
41 | }
42 | if (state === 'active-selected') {
43 | return `${color.green(S_CHECKBOX_SELECTED)} ${label} ${
44 | option.hint ? color.dim(`(${option.hint})`) : ''
45 | }`;
46 | }
47 | if (state === 'submitted') {
48 | return `${color.dim(label)}`;
49 | }
50 | return `${color.dim(S_CHECKBOX_INACTIVE)} ${color.dim(label)}`;
51 | };
52 |
53 | return new MultiSelectPrompt({
54 | options: opts.options,
55 | input: opts.input,
56 | output: opts.output,
57 | initialValues: opts.initialValues,
58 | required: opts.required ?? true,
59 | cursorAt: opts.cursorAt,
60 | validate(selected: Value[]) {
61 | if (this.required && selected.length === 0)
62 | return `Please select at least one option.\n${color.reset(
63 | color.dim(
64 | `Press ${color.gray(color.bgWhite(color.inverse(' space ')))} to select, ${color.gray(
65 | color.bgWhite(color.inverse(' enter '))
66 | )} to submit`
67 | )
68 | )}`;
69 | },
70 | render() {
71 | const title = `${color.gray(S_BAR)}\n${symbol(this.state)} ${opts.message}\n`;
72 |
73 | const styleOption = (option: Option, active: boolean) => {
74 | const selected = this.value.includes(option.value);
75 | if (active && selected) {
76 | return opt(option, 'active-selected');
77 | }
78 | if (selected) {
79 | return opt(option, 'selected');
80 | }
81 | return opt(option, active ? 'active' : 'inactive');
82 | };
83 |
84 | switch (this.state) {
85 | case 'submit': {
86 | return `${title}${color.gray(S_BAR)} ${
87 | this.options
88 | .filter(({ value }) => this.value.includes(value))
89 | .map((option) => opt(option, 'submitted'))
90 | .join(color.dim(', ')) || color.dim('none')
91 | }`;
92 | }
93 | case 'cancel': {
94 | const label = this.options
95 | .filter(({ value }) => this.value.includes(value))
96 | .map((option) => opt(option, 'cancelled'))
97 | .join(color.dim(', '));
98 | return `${title}${color.gray(S_BAR)} ${
99 | label.trim() ? `${label}\n${color.gray(S_BAR)}` : ''
100 | }`;
101 | }
102 | case 'error': {
103 | const footer = this.error
104 | .split('\n')
105 | .map((ln, i) =>
106 | i === 0 ? `${color.yellow(S_BAR_END)} ${color.yellow(ln)}` : ` ${ln}`
107 | )
108 | .join('\n');
109 | return `${title + color.yellow(S_BAR)} ${limitOptions({
110 | output: opts.output,
111 | options: this.options,
112 | cursor: this.cursor,
113 | maxItems: opts.maxItems,
114 | style: styleOption,
115 | }).join(`\n${color.yellow(S_BAR)} `)}\n${footer}\n`;
116 | }
117 | default: {
118 | return `${title}${color.cyan(S_BAR)} ${limitOptions({
119 | output: opts.output,
120 | options: this.options,
121 | cursor: this.cursor,
122 | maxItems: opts.maxItems,
123 | style: styleOption,
124 | }).join(`\n${color.cyan(S_BAR)} `)}\n${color.cyan(S_BAR_END)}\n`;
125 | }
126 | }
127 | },
128 | }).prompt() as Promise;
129 | };
130 |
--------------------------------------------------------------------------------
/packages/prompts/src/note.ts:
--------------------------------------------------------------------------------
1 | import type { Writable } from 'node:stream';
2 | import { stripVTControlCharacters as strip } from 'node:util';
3 | import color from 'picocolors';
4 | import {
5 | type CommonOptions,
6 | S_BAR,
7 | S_BAR_H,
8 | S_CONNECT_LEFT,
9 | S_CORNER_BOTTOM_RIGHT,
10 | S_CORNER_TOP_RIGHT,
11 | S_STEP_SUBMIT,
12 | } from './common.js';
13 |
14 | export interface NoteOptions extends CommonOptions {
15 | format?: (line: string) => string;
16 | }
17 |
18 | const defaultNoteFormatter = (line: string): string => color.dim(line);
19 |
20 | export const note = (message = '', title = '', opts?: NoteOptions) => {
21 | const format = opts?.format ?? defaultNoteFormatter;
22 | const lines = ['', ...message.split('\n').map(format), ''];
23 | const titleLen = strip(title).length;
24 | const output: Writable = opts?.output ?? process.stdout;
25 | const len =
26 | Math.max(
27 | lines.reduce((sum, ln) => {
28 | const line = strip(ln);
29 | return line.length > sum ? line.length : sum;
30 | }, 0),
31 | titleLen
32 | ) + 2;
33 | const msg = lines
34 | .map(
35 | (ln) => `${color.gray(S_BAR)} ${ln}${' '.repeat(len - strip(ln).length)}${color.gray(S_BAR)}`
36 | )
37 | .join('\n');
38 | output.write(
39 | `${color.gray(S_BAR)}\n${color.green(S_STEP_SUBMIT)} ${color.reset(title)} ${color.gray(
40 | S_BAR_H.repeat(Math.max(len - titleLen - 1, 1)) + S_CORNER_TOP_RIGHT
41 | )}\n${msg}\n${color.gray(S_CONNECT_LEFT + S_BAR_H.repeat(len + 2) + S_CORNER_BOTTOM_RIGHT)}\n`
42 | );
43 | };
44 |
--------------------------------------------------------------------------------
/packages/prompts/src/password.ts:
--------------------------------------------------------------------------------
1 | import { PasswordPrompt } from '@clack/core';
2 | import color from 'picocolors';
3 | import { type CommonOptions, S_BAR, S_BAR_END, S_PASSWORD_MASK, symbol } from './common.js';
4 |
5 | export interface PasswordOptions extends CommonOptions {
6 | message: string;
7 | mask?: string;
8 | validate?: (value: string) => string | Error | undefined;
9 | }
10 | export const password = (opts: PasswordOptions) => {
11 | return new PasswordPrompt({
12 | validate: opts.validate,
13 | mask: opts.mask ?? S_PASSWORD_MASK,
14 | input: opts.input,
15 | output: opts.output,
16 | render() {
17 | const title = `${color.gray(S_BAR)}\n${symbol(this.state)} ${opts.message}\n`;
18 | const value = this.valueWithCursor;
19 | const masked = this.masked;
20 |
21 | switch (this.state) {
22 | case 'error':
23 | return `${title.trim()}\n${color.yellow(S_BAR)} ${masked}\n${color.yellow(
24 | S_BAR_END
25 | )} ${color.yellow(this.error)}\n`;
26 | case 'submit':
27 | return `${title}${color.gray(S_BAR)} ${color.dim(masked)}`;
28 | case 'cancel':
29 | return `${title}${color.gray(S_BAR)} ${color.strikethrough(color.dim(masked ?? ''))}${
30 | masked ? `\n${color.gray(S_BAR)}` : ''
31 | }`;
32 | default:
33 | return `${title}${color.cyan(S_BAR)} ${value}\n${color.cyan(S_BAR_END)}\n`;
34 | }
35 | },
36 | }).prompt() as Promise;
37 | };
38 |
--------------------------------------------------------------------------------
/packages/prompts/src/path.ts:
--------------------------------------------------------------------------------
1 | import { existsSync, lstatSync, readdirSync } from 'node:fs';
2 | import { join } from 'node:path';
3 | import { dirname } from 'knip/dist/util/path.js';
4 | import { type CommonOptions, S_BAR, S_BAR_END, symbol } from './common.js';
5 | import { suggestion } from './suggestion.js';
6 |
7 | export interface PathOptions extends CommonOptions {
8 | root?: string;
9 | directory?: boolean;
10 | initialValue?: string;
11 | message: string;
12 | validate?: (value: string) => string | Error | undefined;
13 | }
14 |
15 | export const path = (opts: PathOptions) => {
16 | return suggestion({
17 | ...opts,
18 | initialValue: opts.initialValue ?? opts.root ?? process.cwd(),
19 | suggest: (value: string) => {
20 | try {
21 | const searchPath = !existsSync(value) ? dirname(value) : value;
22 | if (!lstatSync(searchPath).isDirectory()) {
23 | return [];
24 | }
25 | const items = readdirSync(searchPath)
26 | .map((item) => {
27 | const path = join(searchPath, item);
28 | const stats = lstatSync(path);
29 | return {
30 | name: item,
31 | path,
32 | isDirectory: stats.isDirectory(),
33 | };
34 | })
35 | .filter(({ path }) => path.startsWith(value));
36 | return ((opts.directory ?? false) ? items.filter((item) => item.isDirectory) : items).map(
37 | ({ path }) => path
38 | );
39 | } catch (e) {
40 | return [];
41 | }
42 | },
43 | });
44 | };
45 |
--------------------------------------------------------------------------------
/packages/prompts/src/progress-bar.ts:
--------------------------------------------------------------------------------
1 | import type { State } from '@clack/core';
2 | import color from 'picocolors';
3 | import { unicodeOr } from './common.js';
4 | import { type SpinnerOptions, type SpinnerResult, spinner } from './spinner.js';
5 |
6 | const S_PROGRESS_CHAR: Record, string> = {
7 | light: unicodeOr('─', '-'),
8 | heavy: unicodeOr('━', '='),
9 | block: unicodeOr('█', '#'),
10 | };
11 |
12 | export interface ProgressOptions extends SpinnerOptions {
13 | style?: 'light' | 'heavy' | 'block';
14 | max?: number;
15 | size?: number;
16 | }
17 |
18 | export interface ProgressResult extends SpinnerResult {
19 | advance(step?: number, msg?: string): void;
20 | }
21 |
22 | export function progress({
23 | style = 'heavy',
24 | max: userMax = 100,
25 | size: userSize = 40,
26 | ...spinnerOptions
27 | }: ProgressOptions = {}): ProgressResult {
28 | const spin = spinner(spinnerOptions);
29 | let value = 0;
30 | let previousMessage = '';
31 |
32 | const max = Math.max(1, userMax);
33 | const size = Math.max(1, userSize);
34 |
35 | const activeStyle = (state: State) => {
36 | switch (state) {
37 | case 'initial':
38 | case 'active':
39 | return color.magenta;
40 | case 'error':
41 | case 'cancel':
42 | return color.red;
43 | case 'submit':
44 | return color.green;
45 | default:
46 | return color.magenta;
47 | }
48 | };
49 | const drawProgress = (state: State, msg: string) => {
50 | const active = Math.floor((value / max) * size);
51 | return `${activeStyle(state)(S_PROGRESS_CHAR[style].repeat(active))}${color.dim(S_PROGRESS_CHAR[style].repeat(size - active))} ${msg}`;
52 | };
53 |
54 | const start = (msg = '') => {
55 | previousMessage = msg;
56 | return spin.start(drawProgress('initial', msg));
57 | };
58 | const advance = (step = 1, msg?: string): void => {
59 | value = Math.min(max, step + value);
60 | spin.message(drawProgress('active', msg ?? previousMessage));
61 | previousMessage = msg ?? previousMessage;
62 | };
63 | return {
64 | start,
65 | stop: spin.stop,
66 | advance,
67 | isCancelled: spin.isCancelled,
68 | message: (msg: string) => advance(0, msg),
69 | };
70 | }
71 |
--------------------------------------------------------------------------------
/packages/prompts/src/select-key.ts:
--------------------------------------------------------------------------------
1 | import { SelectKeyPrompt } from '@clack/core';
2 | import color from 'picocolors';
3 | import { S_BAR, S_BAR_END, symbol } from './common.js';
4 | import type { Option, SelectOptions } from './select.js';
5 |
6 | export const selectKey = (opts: SelectOptions) => {
7 | const opt = (
8 | option: Option,
9 | state: 'inactive' | 'active' | 'selected' | 'cancelled' = 'inactive'
10 | ) => {
11 | const label = option.label ?? String(option.value);
12 | if (state === 'selected') {
13 | return `${color.dim(label)}`;
14 | }
15 | if (state === 'cancelled') {
16 | return `${color.strikethrough(color.dim(label))}`;
17 | }
18 | if (state === 'active') {
19 | return `${color.bgCyan(color.gray(` ${option.value} `))} ${label} ${
20 | option.hint ? color.dim(`(${option.hint})`) : ''
21 | }`;
22 | }
23 | return `${color.gray(color.bgWhite(color.inverse(` ${option.value} `)))} ${label} ${
24 | option.hint ? color.dim(`(${option.hint})`) : ''
25 | }`;
26 | };
27 |
28 | return new SelectKeyPrompt({
29 | options: opts.options,
30 | input: opts.input,
31 | output: opts.output,
32 | initialValue: opts.initialValue,
33 | render() {
34 | const title = `${color.gray(S_BAR)}\n${symbol(this.state)} ${opts.message}\n`;
35 |
36 | switch (this.state) {
37 | case 'submit':
38 | return `${title}${color.gray(S_BAR)} ${opt(
39 | this.options.find((opt) => opt.value === this.value) ?? opts.options[0],
40 | 'selected'
41 | )}`;
42 | case 'cancel':
43 | return `${title}${color.gray(S_BAR)} ${opt(this.options[0], 'cancelled')}\n${color.gray(
44 | S_BAR
45 | )}`;
46 | default: {
47 | return `${title}${color.cyan(S_BAR)} ${this.options
48 | .map((option, i) => opt(option, i === this.cursor ? 'active' : 'inactive'))
49 | .join(`\n${color.cyan(S_BAR)} `)}\n${color.cyan(S_BAR_END)}\n`;
50 | }
51 | }
52 | },
53 | }).prompt() as Promise;
54 | };
55 |
--------------------------------------------------------------------------------
/packages/prompts/src/select.ts:
--------------------------------------------------------------------------------
1 | import { SelectPrompt } from '@clack/core';
2 | import color from 'picocolors';
3 | import {
4 | type CommonOptions,
5 | S_BAR,
6 | S_BAR_END,
7 | S_RADIO_ACTIVE,
8 | S_RADIO_INACTIVE,
9 | symbol,
10 | } from './common.js';
11 | import { limitOptions } from './limit-options.js';
12 |
13 | type Primitive = Readonly;
14 |
15 | export type Option = Value extends Primitive
16 | ? {
17 | /**
18 | * Internal data for this option.
19 | */
20 | value: Value;
21 | /**
22 | * The optional, user-facing text for this option.
23 | *
24 | * By default, the `value` is converted to a string.
25 | */
26 | label?: string;
27 | /**
28 | * An optional hint to display to the user when
29 | * this option might be selected.
30 | *
31 | * By default, no `hint` is displayed.
32 | */
33 | hint?: string;
34 | }
35 | : {
36 | /**
37 | * Internal data for this option.
38 | */
39 | value: Value;
40 | /**
41 | * Required. The user-facing text for this option.
42 | */
43 | label: string;
44 | /**
45 | * An optional hint to display to the user when
46 | * this option might be selected.
47 | *
48 | * By default, no `hint` is displayed.
49 | */
50 | hint?: string;
51 | };
52 |
53 | export interface SelectOptions extends CommonOptions {
54 | message: string;
55 | options: Option[];
56 | initialValue?: Value;
57 | maxItems?: number;
58 | }
59 |
60 | export const select = (opts: SelectOptions) => {
61 | const opt = (option: Option, state: 'inactive' | 'active' | 'selected' | 'cancelled') => {
62 | const label = option.label ?? String(option.value);
63 | switch (state) {
64 | case 'selected':
65 | return `${color.dim(label)}`;
66 | case 'active':
67 | return `${color.green(S_RADIO_ACTIVE)} ${label} ${
68 | option.hint ? color.dim(`(${option.hint})`) : ''
69 | }`;
70 | case 'cancelled':
71 | return `${color.strikethrough(color.dim(label))}`;
72 | default:
73 | return `${color.dim(S_RADIO_INACTIVE)} ${color.dim(label)}`;
74 | }
75 | };
76 |
77 | return new SelectPrompt({
78 | options: opts.options,
79 | input: opts.input,
80 | output: opts.output,
81 | initialValue: opts.initialValue,
82 | render() {
83 | const title = `${color.gray(S_BAR)}\n${symbol(this.state)} ${opts.message}\n`;
84 |
85 | switch (this.state) {
86 | case 'submit':
87 | return `${title}${color.gray(S_BAR)} ${opt(this.options[this.cursor], 'selected')}`;
88 | case 'cancel':
89 | return `${title}${color.gray(S_BAR)} ${opt(
90 | this.options[this.cursor],
91 | 'cancelled'
92 | )}\n${color.gray(S_BAR)}`;
93 | default: {
94 | return `${title}${color.cyan(S_BAR)} ${limitOptions({
95 | output: opts.output,
96 | cursor: this.cursor,
97 | options: this.options,
98 | maxItems: opts.maxItems,
99 | style: (item, active) => opt(item, active ? 'active' : 'inactive'),
100 | }).join(`\n${color.cyan(S_BAR)} `)}\n${color.cyan(S_BAR_END)}\n`;
101 | }
102 | }
103 | },
104 | }).prompt() as Promise;
105 | };
106 |
--------------------------------------------------------------------------------
/packages/prompts/src/spinner.ts:
--------------------------------------------------------------------------------
1 | import { block, settings } from '@clack/core';
2 | import color from 'picocolors';
3 | import { cursor, erase } from 'sisteransi';
4 | import {
5 | type CommonOptions,
6 | S_BAR,
7 | S_STEP_CANCEL,
8 | S_STEP_ERROR,
9 | S_STEP_SUBMIT,
10 | isCI as isCIFn,
11 | unicode,
12 | } from './common.js';
13 |
14 | export interface SpinnerOptions extends CommonOptions {
15 | indicator?: 'dots' | 'timer';
16 | onCancel?: () => void;
17 | cancelMessage?: string;
18 | errorMessage?: string;
19 | frames?: string[];
20 | delay?: number;
21 | }
22 |
23 | export interface SpinnerResult {
24 | start(msg?: string): void;
25 | stop(msg?: string, code?: number): void;
26 | message(msg?: string): void;
27 | readonly isCancelled: boolean;
28 | }
29 |
30 | export const spinner = ({
31 | indicator = 'dots',
32 | onCancel,
33 | output = process.stdout,
34 | cancelMessage,
35 | errorMessage,
36 | frames = unicode ? ['◒', '◐', '◓', '◑'] : ['•', 'o', 'O', '0'],
37 | delay = unicode ? 80 : 120,
38 | }: SpinnerOptions = {}): SpinnerResult => {
39 | const isCI = isCIFn();
40 |
41 | let unblock: () => void;
42 | let loop: NodeJS.Timeout;
43 | let isSpinnerActive = false;
44 | let isCancelled = false;
45 | let _message = '';
46 | let _prevMessage: string | undefined = undefined;
47 | let _origin: number = performance.now();
48 |
49 | const handleExit = (code: number) => {
50 | const msg =
51 | code > 1
52 | ? (errorMessage ?? settings.messages.error)
53 | : (cancelMessage ?? settings.messages.cancel);
54 | isCancelled = code === 1;
55 | if (isSpinnerActive) {
56 | stop(msg, code);
57 | if (isCancelled && typeof onCancel === 'function') {
58 | onCancel();
59 | }
60 | }
61 | };
62 |
63 | const errorEventHandler = () => handleExit(2);
64 | const signalEventHandler = () => handleExit(1);
65 |
66 | const registerHooks = () => {
67 | // Reference: https://nodejs.org/api/process.html#event-uncaughtexception
68 | process.on('uncaughtExceptionMonitor', errorEventHandler);
69 | // Reference: https://nodejs.org/api/process.html#event-unhandledrejection
70 | process.on('unhandledRejection', errorEventHandler);
71 | // Reference Signal Events: https://nodejs.org/api/process.html#signal-events
72 | process.on('SIGINT', signalEventHandler);
73 | process.on('SIGTERM', signalEventHandler);
74 | process.on('exit', handleExit);
75 | };
76 |
77 | const clearHooks = () => {
78 | process.removeListener('uncaughtExceptionMonitor', errorEventHandler);
79 | process.removeListener('unhandledRejection', errorEventHandler);
80 | process.removeListener('SIGINT', signalEventHandler);
81 | process.removeListener('SIGTERM', signalEventHandler);
82 | process.removeListener('exit', handleExit);
83 | };
84 |
85 | const clearPrevMessage = () => {
86 | if (_prevMessage === undefined) return;
87 | if (isCI) output.write('\n');
88 | const prevLines = _prevMessage.split('\n');
89 | output.write(cursor.move(-999, prevLines.length - 1));
90 | output.write(erase.down(prevLines.length));
91 | };
92 |
93 | const removeTrailingDots = (msg: string): string => {
94 | return msg.replace(/\.+$/, '');
95 | };
96 |
97 | const formatTimer = (origin: number): string => {
98 | const duration = (performance.now() - origin) / 1000;
99 | const min = Math.floor(duration / 60);
100 | const secs = Math.floor(duration % 60);
101 | return min > 0 ? `[${min}m ${secs}s]` : `[${secs}s]`;
102 | };
103 |
104 | const start = (msg = ''): void => {
105 | isSpinnerActive = true;
106 | unblock = block({ output });
107 | _message = removeTrailingDots(msg);
108 | _origin = performance.now();
109 | output.write(`${color.gray(S_BAR)}\n`);
110 | let frameIndex = 0;
111 | let indicatorTimer = 0;
112 | registerHooks();
113 | loop = setInterval(() => {
114 | if (isCI && _message === _prevMessage) {
115 | return;
116 | }
117 | clearPrevMessage();
118 | _prevMessage = _message;
119 | const frame = color.magenta(frames[frameIndex]);
120 |
121 | if (isCI) {
122 | output.write(`${frame} ${_message}...`);
123 | } else if (indicator === 'timer') {
124 | output.write(`${frame} ${_message} ${formatTimer(_origin)}`);
125 | } else {
126 | const loadingDots = '.'.repeat(Math.floor(indicatorTimer)).slice(0, 3);
127 | output.write(`${frame} ${_message}${loadingDots}`);
128 | }
129 |
130 | frameIndex = frameIndex + 1 < frames.length ? frameIndex + 1 : 0;
131 | // indicator increase by 1 every 8 frames
132 | indicatorTimer = indicatorTimer < 4 ? indicatorTimer + 0.125 : 0;
133 | }, delay);
134 | };
135 |
136 | const stop = (msg = '', code = 0): void => {
137 | isSpinnerActive = false;
138 | clearInterval(loop);
139 | clearPrevMessage();
140 | const step =
141 | code === 0
142 | ? color.green(S_STEP_SUBMIT)
143 | : code === 1
144 | ? color.red(S_STEP_CANCEL)
145 | : color.red(S_STEP_ERROR);
146 | _message = msg ?? _message;
147 | if (indicator === 'timer') {
148 | output.write(`${step} ${_message} ${formatTimer(_origin)}\n`);
149 | } else {
150 | output.write(`${step} ${_message}\n`);
151 | }
152 | clearHooks();
153 | unblock();
154 | };
155 |
156 | const message = (msg = ''): void => {
157 | _message = removeTrailingDots(msg ?? _message);
158 | };
159 |
160 | return {
161 | start,
162 | stop,
163 | message,
164 | get isCancelled() {
165 | return isCancelled;
166 | },
167 | };
168 | };
169 |
--------------------------------------------------------------------------------
/packages/prompts/src/stream.ts:
--------------------------------------------------------------------------------
1 | import { stripVTControlCharacters as strip } from 'node:util';
2 | import color from 'picocolors';
3 | import { S_BAR, S_ERROR, S_INFO, S_STEP_SUBMIT, S_SUCCESS, S_WARN } from './common.js';
4 | import type { LogMessageOptions } from './log.js';
5 |
6 | const prefix = `${color.gray(S_BAR)} `;
7 |
8 | // TODO (43081j): this currently doesn't support custom `output` writables
9 | // because we rely on `columns` existing (i.e. `process.stdout.columns).
10 | //
11 | // If we want to support `output` being passed in, we will need to use
12 | // a condition like `if (output insance Writable)` to check if it has columns
13 | export const stream = {
14 | message: async (
15 | iterable: Iterable | AsyncIterable,
16 | { symbol = color.gray(S_BAR) }: LogMessageOptions = {}
17 | ) => {
18 | process.stdout.write(`${color.gray(S_BAR)}\n${symbol} `);
19 | let lineWidth = 3;
20 | for await (let chunk of iterable) {
21 | chunk = chunk.replace(/\n/g, `\n${prefix}`);
22 | if (chunk.includes('\n')) {
23 | lineWidth = 3 + strip(chunk.slice(chunk.lastIndexOf('\n'))).length;
24 | }
25 | const chunkLen = strip(chunk).length;
26 | if (lineWidth + chunkLen < process.stdout.columns) {
27 | lineWidth += chunkLen;
28 | process.stdout.write(chunk);
29 | } else {
30 | process.stdout.write(`\n${prefix}${chunk.trimStart()}`);
31 | lineWidth = 3 + strip(chunk.trimStart()).length;
32 | }
33 | }
34 | process.stdout.write('\n');
35 | },
36 | info: (iterable: Iterable | AsyncIterable) => {
37 | return stream.message(iterable, { symbol: color.blue(S_INFO) });
38 | },
39 | success: (iterable: Iterable | AsyncIterable) => {
40 | return stream.message(iterable, { symbol: color.green(S_SUCCESS) });
41 | },
42 | step: (iterable: Iterable | AsyncIterable) => {
43 | return stream.message(iterable, { symbol: color.green(S_STEP_SUBMIT) });
44 | },
45 | warn: (iterable: Iterable | AsyncIterable) => {
46 | return stream.message(iterable, { symbol: color.yellow(S_WARN) });
47 | },
48 | /** alias for `log.warn()`. */
49 | warning: (iterable: Iterable | AsyncIterable) => {
50 | return stream.warn(iterable);
51 | },
52 | error: (iterable: Iterable | AsyncIterable) => {
53 | return stream.message(iterable, { symbol: color.red(S_ERROR) });
54 | },
55 | };
56 |
--------------------------------------------------------------------------------
/packages/prompts/src/suggestion.ts:
--------------------------------------------------------------------------------
1 | import { SuggestionPrompt, type ValueWithCursorPart } from '@clack/core';
2 | import color from 'picocolors';
3 | import { type CommonOptions, S_BAR, S_BAR_END, symbol } from './common.js';
4 |
5 | export interface SuggestionOptions extends CommonOptions {
6 | initialValue?: string;
7 | message: string;
8 | validate?: (value: string) => string | Error | undefined;
9 | suggest: (value: string) => Array;
10 | }
11 |
12 | export const suggestion = (opts: SuggestionOptions) => {
13 | return new SuggestionPrompt({
14 | initialValue: opts.initialValue ?? '',
15 | output: opts.output,
16 | input: opts.input,
17 | validate: opts.validate,
18 | suggest: opts.suggest,
19 | render() {
20 | const title = `${color.gray(S_BAR)}\n${symbol(this.state)} ${opts.message}\n`;
21 | const value = this.displayValue.reduce((text: string, line: ValueWithCursorPart) => {
22 | switch (line.type) {
23 | case 'value':
24 | return text + line.text;
25 | case 'cursor_on_value':
26 | return text + color.inverse(line.text);
27 | case 'suggestion':
28 | return text + color.gray(line.text);
29 | case 'cursor_on_suggestion':
30 | return text + color.inverse(color.gray(line.text));
31 | }
32 | }, '');
33 |
34 | switch (this.state) {
35 | case 'error':
36 | return `${title.trim()}\n${color.yellow(S_BAR)} ${value}\n${color.yellow(
37 | S_BAR_END
38 | )} ${color.yellow(this.error)}\n`;
39 | case 'submit':
40 | return `${title}${color.gray(S_BAR)} ${color.dim(this.value)}`;
41 | case 'cancel':
42 | return `${title}${color.gray(S_BAR)} ${color.strikethrough(
43 | color.dim(this.value ?? '')
44 | )}${this.value?.trim() ? `\n${color.gray(S_BAR)}` : ''}`;
45 | default:
46 | return `${title}${color.cyan(S_BAR)} ${value}\n${color.cyan(S_BAR_END)}\n`;
47 | }
48 | },
49 | }).prompt() as Promise;
50 | };
51 |
--------------------------------------------------------------------------------
/packages/prompts/src/task-log.ts:
--------------------------------------------------------------------------------
1 | import type { Writable } from 'node:stream';
2 | import { getColumns } from '@clack/core';
3 | import * as color from 'picocolors';
4 | import { erase } from 'sisteransi';
5 | import { type CommonOptions, S_BAR, S_STEP_SUBMIT, isCI as isCIFn } from './common.js';
6 | import { log } from './log.js';
7 |
8 | export interface TaskLogOptions extends CommonOptions {
9 | title: string;
10 | limit?: number;
11 | spacing?: number;
12 | retainLog?: boolean;
13 | }
14 |
15 | export interface TaskLogMessageOptions {
16 | raw?: boolean;
17 | }
18 |
19 | export interface TaskLogCompletionOptions {
20 | showLog?: boolean;
21 | }
22 |
23 | /**
24 | * Renders a log which clears on success and remains on failure
25 | */
26 | export const taskLog = (opts: TaskLogOptions) => {
27 | const output: Writable = opts.output ?? process.stdout;
28 | const columns = getColumns(output);
29 | const secondarySymbol = color.gray(S_BAR);
30 | const spacing = opts.spacing ?? 1;
31 | const barSize = 3;
32 | const retainLog = opts.retainLog === true;
33 | const isCI = isCIFn();
34 |
35 | output.write(`${secondarySymbol}\n`);
36 | output.write(`${color.green(S_STEP_SUBMIT)} ${opts.title}\n`);
37 | for (let i = 0; i < spacing; i++) {
38 | output.write(`${secondarySymbol}\n`);
39 | }
40 |
41 | let buffer = '';
42 | let fullBuffer = '';
43 | let lastMessageWasRaw = false;
44 |
45 | const clear = (clearTitle: boolean): void => {
46 | if (buffer.length === 0) {
47 | return;
48 | }
49 | const bufferHeight = buffer.split('\n').reduce((count, line) => {
50 | if (line === '') {
51 | return count + 1;
52 | }
53 | return count + Math.ceil((line.length + barSize) / columns);
54 | }, 0);
55 | const lines = bufferHeight + 1 + (clearTitle ? spacing + 2 : 0);
56 | output.write(erase.lines(lines));
57 | };
58 | const printBuffer = (buf: string, messageSpacing?: number): void => {
59 | log.message(buf.split('\n').map(color.dim), {
60 | output,
61 | secondarySymbol,
62 | symbol: secondarySymbol,
63 | spacing: messageSpacing ?? spacing,
64 | });
65 | };
66 | const renderBuffer = (): void => {
67 | if (retainLog === true && fullBuffer.length > 0) {
68 | printBuffer(`${fullBuffer}\n${buffer}`);
69 | } else {
70 | printBuffer(buffer);
71 | }
72 | };
73 |
74 | return {
75 | message(msg: string, mopts?: TaskLogMessageOptions) {
76 | clear(false);
77 | if ((mopts?.raw !== true || !lastMessageWasRaw) && buffer !== '') {
78 | buffer += '\n';
79 | }
80 | buffer += msg;
81 | lastMessageWasRaw = mopts?.raw === true;
82 | if (opts.limit !== undefined) {
83 | const lines = buffer.split('\n');
84 | const linesToRemove = lines.length - opts.limit;
85 | if (linesToRemove > 0) {
86 | const removedLines = lines.splice(0, linesToRemove);
87 | if (retainLog) {
88 | fullBuffer += (fullBuffer === '' ? '' : '\n') + removedLines.join('\n');
89 | }
90 | }
91 | buffer = lines.join('\n');
92 | }
93 | if (!isCI) {
94 | printBuffer(buffer, 0);
95 | }
96 | },
97 | error(message: string, opts?: TaskLogCompletionOptions): void {
98 | clear(true);
99 | log.error(message, { output, secondarySymbol, spacing: 1 });
100 | if (opts?.showLog !== false) {
101 | renderBuffer();
102 | }
103 | // clear buffer since error is an end state
104 | buffer = fullBuffer = '';
105 | },
106 | success(message: string, opts?: TaskLogCompletionOptions): void {
107 | clear(true);
108 | log.success(message, { output, secondarySymbol, spacing: 1 });
109 | if (opts?.showLog === true) {
110 | renderBuffer();
111 | }
112 | // clear buffer since success is an end state
113 | buffer = fullBuffer = '';
114 | },
115 | };
116 | };
117 |
--------------------------------------------------------------------------------
/packages/prompts/src/task.ts:
--------------------------------------------------------------------------------
1 | import type { CommonOptions } from './common.js';
2 | import { spinner } from './spinner.js';
3 |
4 | export type Task = {
5 | /**
6 | * Task title
7 | */
8 | title: string;
9 | /**
10 | * Task function
11 | */
12 | task: (message: (string: string) => void) => string | Promise | void | Promise;
13 |
14 | /**
15 | * If enabled === false the task will be skipped
16 | */
17 | enabled?: boolean;
18 | };
19 |
20 | /**
21 | * Define a group of tasks to be executed
22 | */
23 | export const tasks = async (tasks: Task[], opts?: CommonOptions) => {
24 | for (const task of tasks) {
25 | if (task.enabled === false) continue;
26 |
27 | const s = spinner(opts);
28 | s.start(task.title);
29 | const result = await task.task(s.message);
30 | s.stop(result || task.title);
31 | }
32 | };
33 |
--------------------------------------------------------------------------------
/packages/prompts/src/text.ts:
--------------------------------------------------------------------------------
1 | import { TextPrompt } from '@clack/core';
2 | import color from 'picocolors';
3 | import { type CommonOptions, S_BAR, S_BAR_END, symbol } from './common.js';
4 |
5 | export interface TextOptions extends CommonOptions {
6 | message: string;
7 | placeholder?: string;
8 | defaultValue?: string;
9 | initialValue?: string;
10 | validate?: (value: string) => string | Error | undefined;
11 | }
12 |
13 | export const text = (opts: TextOptions) => {
14 | return new TextPrompt({
15 | validate: opts.validate,
16 | placeholder: opts.placeholder,
17 | defaultValue: opts.defaultValue,
18 | initialValue: opts.initialValue,
19 | output: opts.output,
20 | input: opts.input,
21 | render() {
22 | const title = `${color.gray(S_BAR)}\n${symbol(this.state)} ${opts.message}\n`;
23 | const placeholder = opts.placeholder
24 | ? color.inverse(opts.placeholder[0]) + color.dim(opts.placeholder.slice(1))
25 | : color.inverse(color.hidden('_'));
26 | const value = !this.value ? placeholder : this.valueWithCursor;
27 |
28 | switch (this.state) {
29 | case 'error':
30 | return `${title.trim()}\n${color.yellow(S_BAR)} ${value}\n${color.yellow(
31 | S_BAR_END
32 | )} ${color.yellow(this.error)}\n`;
33 | case 'submit': {
34 | const displayValue = this.value === undefined ? '' : this.value;
35 | return `${title}${color.gray(S_BAR)} ${color.dim(displayValue)}`;
36 | }
37 | case 'cancel':
38 | return `${title}${color.gray(S_BAR)} ${color.strikethrough(
39 | color.dim(this.value ?? '')
40 | )}${this.value?.trim() ? `\n${color.gray(S_BAR)}` : ''}`;
41 | default:
42 | return `${title}${color.cyan(S_BAR)} ${value}\n${color.cyan(S_BAR_END)}\n`;
43 | }
44 | },
45 | }).prompt() as Promise;
46 | };
47 |
--------------------------------------------------------------------------------
/packages/prompts/test/__snapshots__/note.test.ts.snap:
--------------------------------------------------------------------------------
1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2 |
3 | exports[`note (isCI = false) > formatter which adds colors works 1`] = `
4 | [
5 | "[90m│[39m
6 | [32m◇[39m [0mtitle[0m [90m──╮[39m
7 | [90m│[39m [90m│[39m
8 | [90m│[39m [31mline 0[39m [90m│[39m
9 | [90m│[39m [31mline 1[39m [90m│[39m
10 | [90m│[39m [31mline 2[39m [90m│[39m
11 | [90m│[39m [90m│[39m
12 | [90m├──────────╯[39m
13 | ",
14 | ]
15 | `;
16 |
17 | exports[`note (isCI = false) > formatter which adds length works 1`] = `
18 | [
19 | "[90m│[39m
20 | [32m◇[39m [0mtitle[0m [90m──────╮[39m
21 | [90m│[39m [90m│[39m
22 | [90m│[39m * line 0 * [90m│[39m
23 | [90m│[39m * line 1 * [90m│[39m
24 | [90m│[39m * line 2 * [90m│[39m
25 | [90m│[39m [90m│[39m
26 | [90m├──────────────╯[39m
27 | ",
28 | ]
29 | `;
30 |
31 | exports[`note (isCI = false) > renders as wide as longest line 1`] = `
32 | [
33 | "[90m│[39m
34 | [32m◇[39m [0mtitle[0m [90m───────────────────────────╮[39m
35 | [90m│[39m [90m│[39m
36 | [90m│[39m [2mshort[22m [90m│[39m
37 | [90m│[39m [2msomewhat questionably long line[22m [90m│[39m
38 | [90m│[39m [90m│[39m
39 | [90m├───────────────────────────────────╯[39m
40 | ",
41 | ]
42 | `;
43 |
44 | exports[`note (isCI = false) > renders message with title 1`] = `
45 | [
46 | "[90m│[39m
47 | [32m◇[39m [0mtitle[0m [90m───╮[39m
48 | [90m│[39m [90m│[39m
49 | [90m│[39m [2mmessage[22m [90m│[39m
50 | [90m│[39m [90m│[39m
51 | [90m├───────────╯[39m
52 | ",
53 | ]
54 | `;
55 |
56 | exports[`note (isCI = true) > formatter which adds colors works 1`] = `
57 | [
58 | "[90m│[39m
59 | [32m◇[39m [0mtitle[0m [90m──╮[39m
60 | [90m│[39m [90m│[39m
61 | [90m│[39m [31mline 0[39m [90m│[39m
62 | [90m│[39m [31mline 1[39m [90m│[39m
63 | [90m│[39m [31mline 2[39m [90m│[39m
64 | [90m│[39m [90m│[39m
65 | [90m├──────────╯[39m
66 | ",
67 | ]
68 | `;
69 |
70 | exports[`note (isCI = true) > formatter which adds length works 1`] = `
71 | [
72 | "[90m│[39m
73 | [32m◇[39m [0mtitle[0m [90m──────╮[39m
74 | [90m│[39m [90m│[39m
75 | [90m│[39m * line 0 * [90m│[39m
76 | [90m│[39m * line 1 * [90m│[39m
77 | [90m│[39m * line 2 * [90m│[39m
78 | [90m│[39m [90m│[39m
79 | [90m├──────────────╯[39m
80 | ",
81 | ]
82 | `;
83 |
84 | exports[`note (isCI = true) > renders as wide as longest line 1`] = `
85 | [
86 | "[90m│[39m
87 | [32m◇[39m [0mtitle[0m [90m───────────────────────────╮[39m
88 | [90m│[39m [90m│[39m
89 | [90m│[39m [2mshort[22m [90m│[39m
90 | [90m│[39m [2msomewhat questionably long line[22m [90m│[39m
91 | [90m│[39m [90m│[39m
92 | [90m├───────────────────────────────────╯[39m
93 | ",
94 | ]
95 | `;
96 |
97 | exports[`note (isCI = true) > renders message with title 1`] = `
98 | [
99 | "[90m│[39m
100 | [32m◇[39m [0mtitle[0m [90m───╮[39m
101 | [90m│[39m [90m│[39m
102 | [90m│[39m [2mmessage[22m [90m│[39m
103 | [90m│[39m [90m│[39m
104 | [90m├───────────╯[39m
105 | ",
106 | ]
107 | `;
108 |
--------------------------------------------------------------------------------
/packages/prompts/test/__snapshots__/select.test.ts.snap:
--------------------------------------------------------------------------------
1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2 |
3 | exports[`select (isCI = false) > can cancel 1`] = `
4 | [
5 | "",
6 | "[90m│[39m
7 | [36m◆[39m foo
8 | [36m│[39m [32m●[39m opt0
9 | [36m│[39m [2m○[22m [2mopt1[22m
10 | [36m└[39m
11 | ",
12 | "",
13 | "",
14 | "",
15 | "[31m■[39m foo
16 | [90m│[39m [9m[2mopt0[22m[29m
17 | [90m│[39m",
18 | "
19 | ",
20 | "",
21 | ]
22 | `;
23 |
24 | exports[`select (isCI = false) > down arrow selects next option 1`] = `
25 | [
26 | "",
27 | "[90m│[39m
28 | [36m◆[39m foo
29 | [36m│[39m [32m●[39m opt0
30 | [36m│[39m [2m○[22m [2mopt1[22m
31 | [36m└[39m
32 | ",
33 | "",
34 | "",
35 | "",
36 | "[36m│[39m [2m○[22m [2mopt0[22m
37 | [36m│[39m [32m●[39m opt1
38 | [36m└[39m
39 | ",
40 | "",
41 | "",
42 | "",
43 | "[32m◇[39m foo
44 | [90m│[39m [2mopt1[22m",
45 | "
46 | ",
47 | "",
48 | ]
49 | `;
50 |
51 | exports[`select (isCI = false) > renders option hints 1`] = `
52 | [
53 | "",
54 | "[90m│[39m
55 | [36m◆[39m foo
56 | [36m│[39m [32m●[39m opt0 [2m(Hint 0)[22m
57 | [36m│[39m [2m○[22m [2mopt1[22m
58 | [36m└[39m
59 | ",
60 | "",
61 | "",
62 | "",
63 | "[32m◇[39m foo
64 | [90m│[39m [2mopt0[22m",
65 | "
66 | ",
67 | "",
68 | ]
69 | `;
70 |
71 | exports[`select (isCI = false) > renders option labels 1`] = `
72 | [
73 | "",
74 | "[90m│[39m
75 | [36m◆[39m foo
76 | [36m│[39m [32m●[39m Option 0
77 | [36m│[39m [2m○[22m [2mOption 1[22m
78 | [36m└[39m
79 | ",
80 | "",
81 | "",
82 | "",
83 | "[32m◇[39m foo
84 | [90m│[39m [2mOption 0[22m",
85 | "
86 | ",
87 | "",
88 | ]
89 | `;
90 |
91 | exports[`select (isCI = false) > renders options and message 1`] = `
92 | [
93 | "",
94 | "[90m│[39m
95 | [36m◆[39m foo
96 | [36m│[39m [32m●[39m opt0
97 | [36m│[39m [2m○[22m [2mopt1[22m
98 | [36m└[39m
99 | ",
100 | "",
101 | "",
102 | "",
103 | "[32m◇[39m foo
104 | [90m│[39m [2mopt0[22m",
105 | "
106 | ",
107 | "",
108 | ]
109 | `;
110 |
111 | exports[`select (isCI = false) > up arrow selects previous option 1`] = `
112 | [
113 | "",
114 | "[90m│[39m
115 | [36m◆[39m foo
116 | [36m│[39m [32m●[39m opt0
117 | [36m│[39m [2m○[22m [2mopt1[22m
118 | [36m└[39m
119 | ",
120 | "",
121 | "",
122 | "",
123 | "[36m│[39m [2m○[22m [2mopt0[22m
124 | [36m│[39m [32m●[39m opt1
125 | [36m└[39m
126 | ",
127 | "",
128 | "",
129 | "",
130 | "[36m│[39m [32m●[39m opt0
131 | [36m│[39m [2m○[22m [2mopt1[22m
132 | [36m└[39m
133 | ",
134 | "",
135 | "",
136 | "",
137 | "[32m◇[39m foo
138 | [90m│[39m [2mopt0[22m",
139 | "
140 | ",
141 | "",
142 | ]
143 | `;
144 |
145 | exports[`select (isCI = true) > can cancel 1`] = `
146 | [
147 | "",
148 | "[90m│[39m
149 | [36m◆[39m foo
150 | [36m│[39m [32m●[39m opt0
151 | [36m│[39m [2m○[22m [2mopt1[22m
152 | [36m└[39m
153 | ",
154 | "",
155 | "",
156 | "",
157 | "[31m■[39m foo
158 | [90m│[39m [9m[2mopt0[22m[29m
159 | [90m│[39m",
160 | "
161 | ",
162 | "",
163 | ]
164 | `;
165 |
166 | exports[`select (isCI = true) > down arrow selects next option 1`] = `
167 | [
168 | "",
169 | "[90m│[39m
170 | [36m◆[39m foo
171 | [36m│[39m [32m●[39m opt0
172 | [36m│[39m [2m○[22m [2mopt1[22m
173 | [36m└[39m
174 | ",
175 | "",
176 | "",
177 | "",
178 | "[36m│[39m [2m○[22m [2mopt0[22m
179 | [36m│[39m [32m●[39m opt1
180 | [36m└[39m
181 | ",
182 | "",
183 | "",
184 | "",
185 | "[32m◇[39m foo
186 | [90m│[39m [2mopt1[22m",
187 | "
188 | ",
189 | "",
190 | ]
191 | `;
192 |
193 | exports[`select (isCI = true) > renders option hints 1`] = `
194 | [
195 | "",
196 | "[90m│[39m
197 | [36m◆[39m foo
198 | [36m│[39m [32m●[39m opt0 [2m(Hint 0)[22m
199 | [36m│[39m [2m○[22m [2mopt1[22m
200 | [36m└[39m
201 | ",
202 | "",
203 | "",
204 | "",
205 | "[32m◇[39m foo
206 | [90m│[39m [2mopt0[22m",
207 | "
208 | ",
209 | "",
210 | ]
211 | `;
212 |
213 | exports[`select (isCI = true) > renders option labels 1`] = `
214 | [
215 | "",
216 | "[90m│[39m
217 | [36m◆[39m foo
218 | [36m│[39m [32m●[39m Option 0
219 | [36m│[39m [2m○[22m [2mOption 1[22m
220 | [36m└[39m
221 | ",
222 | "",
223 | "",
224 | "",
225 | "[32m◇[39m foo
226 | [90m│[39m [2mOption 0[22m",
227 | "
228 | ",
229 | "",
230 | ]
231 | `;
232 |
233 | exports[`select (isCI = true) > renders options and message 1`] = `
234 | [
235 | "",
236 | "[90m│[39m
237 | [36m◆[39m foo
238 | [36m│[39m [32m●[39m opt0
239 | [36m│[39m [2m○[22m [2mopt1[22m
240 | [36m└[39m
241 | ",
242 | "",
243 | "",
244 | "",
245 | "[32m◇[39m foo
246 | [90m│[39m [2mopt0[22m",
247 | "
248 | ",
249 | "",
250 | ]
251 | `;
252 |
253 | exports[`select (isCI = true) > up arrow selects previous option 1`] = `
254 | [
255 | "",
256 | "[90m│[39m
257 | [36m◆[39m foo
258 | [36m│[39m [32m●[39m opt0
259 | [36m│[39m [2m○[22m [2mopt1[22m
260 | [36m└[39m
261 | ",
262 | "",
263 | "",
264 | "",
265 | "[36m│[39m [2m○[22m [2mopt0[22m
266 | [36m│[39m [32m●[39m opt1
267 | [36m└[39m
268 | ",
269 | "",
270 | "",
271 | "",
272 | "[36m│[39m [32m●[39m opt0
273 | [36m│[39m [2m○[22m [2mopt1[22m
274 | [36m└[39m
275 | ",
276 | "",
277 | "",
278 | "",
279 | "[32m◇[39m foo
280 | [90m│[39m [2mopt0[22m",
281 | "
282 | ",
283 | "",
284 | ]
285 | `;
286 |
--------------------------------------------------------------------------------
/packages/prompts/test/autocomplete.test.ts:
--------------------------------------------------------------------------------
1 | import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
2 | import { autocomplete, autocompleteMultiselect } from '../src/autocomplete.js';
3 | import { MockReadable, MockWritable } from './test-utils.js';
4 |
5 | describe('autocomplete', () => {
6 | let input: MockReadable;
7 | let output: MockWritable;
8 | const testOptions = [
9 | { value: 'apple', label: 'Apple' },
10 | { value: 'banana', label: 'Banana' },
11 | { value: 'cherry', label: 'Cherry' },
12 | { value: 'grape', label: 'Grape' },
13 | { value: 'orange', label: 'Orange' },
14 | ];
15 |
16 | beforeEach(() => {
17 | input = new MockReadable();
18 | output = new MockWritable();
19 | });
20 |
21 | afterEach(() => {
22 | vi.restoreAllMocks();
23 | });
24 |
25 | test('renders initial UI with message and instructions', async () => {
26 | const result = autocomplete({
27 | message: 'Select a fruit',
28 | options: testOptions,
29 | input,
30 | output,
31 | });
32 |
33 | input.emit('keypress', '', { name: 'return' });
34 | await result;
35 | expect(output.buffer).toMatchSnapshot();
36 | });
37 |
38 | test('limits displayed options when maxItems is set', async () => {
39 | const options = [];
40 | for (let i = 0; i < 10; i++) {
41 | options.push({ value: `option ${i}`, label: `Option ${i}` });
42 | }
43 |
44 | const result = autocomplete({
45 | message: 'Select an option',
46 | options,
47 | maxItems: 6,
48 | input,
49 | output,
50 | });
51 |
52 | input.emit('keypress', '', { name: 'return' });
53 | await result;
54 | expect(output.buffer).toMatchSnapshot();
55 | });
56 |
57 | test('shows no matches message when search has no results', async () => {
58 | const result = autocomplete({
59 | message: 'Select a fruit',
60 | options: testOptions,
61 | input,
62 | output,
63 | });
64 |
65 | // Type something that won't match
66 | input.emit('keypress', 'z', { name: 'z' });
67 | input.emit('keypress', '', { name: 'return' });
68 | await result;
69 | expect(output.buffer).toMatchSnapshot();
70 | });
71 |
72 | test('shows hint when option has hint and is focused', async () => {
73 | const result = autocomplete({
74 | message: 'Select a fruit',
75 | options: [...testOptions, { value: 'kiwi', label: 'Kiwi', hint: 'New Zealand' }],
76 | input,
77 | output,
78 | });
79 |
80 | // Navigate to the option with hint
81 | input.emit('keypress', '', { name: 'down' });
82 | input.emit('keypress', '', { name: 'down' });
83 | input.emit('keypress', '', { name: 'down' });
84 | input.emit('keypress', '', { name: 'down' });
85 | input.emit('keypress', '', { name: 'down' });
86 | input.emit('keypress', '', { name: 'return' });
87 | await result;
88 | expect(output.buffer).toMatchSnapshot();
89 | });
90 |
91 | test('shows selected value in submit state', async () => {
92 | const result = autocomplete({
93 | message: 'Select a fruit',
94 | options: testOptions,
95 | input,
96 | output,
97 | });
98 |
99 | // Select an option and submit
100 | input.emit('keypress', '', { name: 'down' });
101 | input.emit('keypress', '', { name: 'return' });
102 |
103 | const value = await result;
104 | expect(value).toBe('banana');
105 | expect(output.buffer).toMatchSnapshot();
106 | });
107 |
108 | test('shows strikethrough in cancel state', async () => {
109 | const result = autocomplete({
110 | message: 'Select a fruit',
111 | options: testOptions,
112 | input,
113 | output,
114 | });
115 |
116 | // Cancel with Ctrl+C
117 | input.emit('keypress', '\x03', { name: 'c' });
118 |
119 | const value = await result;
120 | expect(typeof value === 'symbol').toBe(true);
121 | expect(output.buffer).toMatchSnapshot();
122 | });
123 |
124 | test('renders placeholder if set', async () => {
125 | const result = autocomplete({
126 | message: 'Select a fruit',
127 | placeholder: 'Type to search...',
128 | options: testOptions,
129 | input,
130 | output,
131 | });
132 |
133 | input.emit('keypress', '', { name: 'return' });
134 | const value = await result;
135 | expect(output.buffer).toMatchSnapshot();
136 | expect(value).toBe('apple');
137 | });
138 |
139 | test('supports initialValue', async () => {
140 | const result = autocomplete({
141 | message: 'Select a fruit',
142 | options: testOptions,
143 | initialValue: 'cherry',
144 | input,
145 | output,
146 | });
147 |
148 | input.emit('keypress', '', { name: 'return' });
149 | const value = await result;
150 |
151 | expect(value).toBe('cherry');
152 | expect(output.buffer).toMatchSnapshot();
153 | });
154 | });
155 |
156 | describe('autocompleteMultiselect', () => {
157 | let input: MockReadable;
158 | let output: MockWritable;
159 | const testOptions = [
160 | { value: 'apple', label: 'Apple' },
161 | { value: 'banana', label: 'Banana' },
162 | { value: 'cherry', label: 'Cherry' },
163 | { value: 'grape', label: 'Grape' },
164 | { value: 'orange', label: 'Orange' },
165 | ];
166 |
167 | beforeEach(() => {
168 | input = new MockReadable();
169 | output = new MockWritable();
170 | });
171 |
172 | afterEach(() => {
173 | vi.restoreAllMocks();
174 | });
175 |
176 | test('renders error when empty selection & required is true', async () => {
177 | const result = autocompleteMultiselect({
178 | message: 'Select a fruit',
179 | options: testOptions,
180 | required: true,
181 | input,
182 | output,
183 | });
184 |
185 | input.emit('keypress', '', { name: 'return' });
186 | input.emit('keypress', '', { name: 'tab' });
187 | input.emit('keypress', '', { name: 'return' });
188 | await result;
189 | expect(output.buffer).toMatchSnapshot();
190 | });
191 | });
192 |
--------------------------------------------------------------------------------
/packages/prompts/test/confirm.test.ts:
--------------------------------------------------------------------------------
1 | import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test, vi } from 'vitest';
2 | import * as prompts from '../src/index.js';
3 | import { MockReadable, MockWritable } from './test-utils.js';
4 |
5 | describe.each(['true', 'false'])('confirm (isCI = %s)', (isCI) => {
6 | let originalCI: string | undefined;
7 | let output: MockWritable;
8 | let input: MockReadable;
9 |
10 | beforeAll(() => {
11 | originalCI = process.env.CI;
12 | process.env.CI = isCI;
13 | });
14 |
15 | afterAll(() => {
16 | process.env.CI = originalCI;
17 | });
18 |
19 | beforeEach(() => {
20 | output = new MockWritable();
21 | input = new MockReadable();
22 | });
23 |
24 | afterEach(() => {
25 | vi.restoreAllMocks();
26 | });
27 |
28 | test('renders message with choices', async () => {
29 | const result = prompts.confirm({
30 | message: 'foo',
31 | input,
32 | output,
33 | });
34 |
35 | input.emit('keypress', '', { name: 'return' });
36 |
37 | const value = await result;
38 |
39 | expect(value).toBe(true);
40 | expect(output.buffer).toMatchSnapshot();
41 | });
42 |
43 | test('renders custom active choice', async () => {
44 | const result = prompts.confirm({
45 | message: 'foo',
46 | active: 'bleep',
47 | input,
48 | output,
49 | });
50 |
51 | input.emit('keypress', '', { name: 'return' });
52 |
53 | const value = await result;
54 |
55 | expect(value).toBe(true);
56 | expect(output.buffer).toMatchSnapshot();
57 | });
58 |
59 | test('renders custom inactive choice', async () => {
60 | const result = prompts.confirm({
61 | message: 'foo',
62 | inactive: 'bleep',
63 | input,
64 | output,
65 | });
66 |
67 | input.emit('keypress', '', { name: 'return' });
68 |
69 | const value = await result;
70 |
71 | expect(value).toBe(true);
72 | expect(output.buffer).toMatchSnapshot();
73 | });
74 |
75 | test('right arrow moves to next choice', async () => {
76 | const result = prompts.confirm({
77 | message: 'foo',
78 | input,
79 | output,
80 | });
81 |
82 | input.emit('keypress', 'right', { name: 'right' });
83 | input.emit('keypress', '', { name: 'return' });
84 |
85 | const value = await result;
86 |
87 | expect(value).toBe(false);
88 | expect(output.buffer).toMatchSnapshot();
89 | });
90 |
91 | test('left arrow moves to previous choice', async () => {
92 | const result = prompts.confirm({
93 | message: 'foo',
94 | input,
95 | output,
96 | });
97 |
98 | input.emit('keypress', 'right', { name: 'right' });
99 | input.emit('keypress', 'left', { name: 'left' });
100 | input.emit('keypress', '', { name: 'return' });
101 |
102 | const value = await result;
103 |
104 | expect(value).toBe(true);
105 | expect(output.buffer).toMatchSnapshot();
106 | });
107 |
108 | test('can cancel', async () => {
109 | const result = prompts.confirm({
110 | message: 'foo',
111 | input,
112 | output,
113 | });
114 |
115 | input.emit('keypress', 'escape', { name: 'escape' });
116 |
117 | const value = await result;
118 |
119 | expect(prompts.isCancel(value)).toBe(true);
120 | expect(output.buffer).toMatchSnapshot();
121 | });
122 |
123 | test('can set initialValue', async () => {
124 | const result = prompts.confirm({
125 | message: 'foo',
126 | initialValue: false,
127 | input,
128 | output,
129 | });
130 |
131 | input.emit('keypress', '', { name: 'return' });
132 |
133 | const value = await result;
134 |
135 | expect(value).toBe(false);
136 | expect(output.buffer).toMatchSnapshot();
137 | });
138 | });
139 |
--------------------------------------------------------------------------------
/packages/prompts/test/note.test.ts:
--------------------------------------------------------------------------------
1 | import colors from 'picocolors';
2 | import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test, vi } from 'vitest';
3 | import * as prompts from '../src/index.js';
4 | import { MockReadable, MockWritable } from './test-utils.js';
5 |
6 | describe.each(['true', 'false'])('note (isCI = %s)', (isCI) => {
7 | let originalCI: string | undefined;
8 | let output: MockWritable;
9 | let input: MockReadable;
10 |
11 | beforeAll(() => {
12 | originalCI = process.env.CI;
13 | process.env.CI = isCI;
14 | });
15 |
16 | afterAll(() => {
17 | process.env.CI = originalCI;
18 | });
19 |
20 | beforeEach(() => {
21 | output = new MockWritable();
22 | input = new MockReadable();
23 | });
24 |
25 | afterEach(() => {
26 | vi.restoreAllMocks();
27 | });
28 |
29 | test('renders message with title', () => {
30 | prompts.note('message', 'title', {
31 | input,
32 | output,
33 | });
34 |
35 | expect(output.buffer).toMatchSnapshot();
36 | });
37 |
38 | test('renders as wide as longest line', () => {
39 | prompts.note('short\nsomewhat questionably long line', 'title', {
40 | input,
41 | output,
42 | });
43 |
44 | expect(output.buffer).toMatchSnapshot();
45 | });
46 |
47 | test('formatter which adds length works', () => {
48 | prompts.note('line 0\nline 1\nline 2', 'title', {
49 | format: (line) => `* ${line} *`,
50 | input,
51 | output,
52 | });
53 |
54 | expect(output.buffer).toMatchSnapshot();
55 | });
56 |
57 | test('formatter which adds colors works', () => {
58 | prompts.note('line 0\nline 1\nline 2', 'title', {
59 | format: (line) => colors.red(line),
60 | input,
61 | output,
62 | });
63 |
64 | expect(output.buffer).toMatchSnapshot();
65 | });
66 | });
67 |
--------------------------------------------------------------------------------
/packages/prompts/test/password.test.ts:
--------------------------------------------------------------------------------
1 | import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test, vi } from 'vitest';
2 | import * as prompts from '../src/index.js';
3 | import { MockReadable, MockWritable } from './test-utils.js';
4 |
5 | describe.each(['true', 'false'])('password (isCI = %s)', (isCI) => {
6 | let originalCI: string | undefined;
7 | let output: MockWritable;
8 | let input: MockReadable;
9 |
10 | beforeAll(() => {
11 | originalCI = process.env.CI;
12 | process.env.CI = isCI;
13 | });
14 |
15 | afterAll(() => {
16 | process.env.CI = originalCI;
17 | });
18 |
19 | beforeEach(() => {
20 | output = new MockWritable();
21 | input = new MockReadable();
22 | });
23 |
24 | afterEach(() => {
25 | vi.restoreAllMocks();
26 | });
27 |
28 | test('renders message', async () => {
29 | const result = prompts.password({
30 | message: 'foo',
31 | input,
32 | output,
33 | });
34 |
35 | input.emit('keypress', '', { name: 'return' });
36 |
37 | await result;
38 |
39 | expect(output.buffer).toMatchSnapshot();
40 | });
41 |
42 | test('renders masked value', async () => {
43 | const result = prompts.password({
44 | message: 'foo',
45 | input,
46 | output,
47 | });
48 |
49 | input.emit('keypress', 'x', { name: 'x' });
50 | input.emit('keypress', 'y', { name: 'y' });
51 | input.emit('keypress', '', { name: 'return' });
52 |
53 | const value = await result;
54 |
55 | expect(value).toBe('xy');
56 | expect(output.buffer).toMatchSnapshot();
57 | });
58 |
59 | test('renders custom mask', async () => {
60 | const result = prompts.password({
61 | message: 'foo',
62 | mask: '*',
63 | input,
64 | output,
65 | });
66 |
67 | input.emit('keypress', 'x', { name: 'x' });
68 | input.emit('keypress', 'y', { name: 'y' });
69 | input.emit('keypress', '', { name: 'return' });
70 |
71 | await result;
72 |
73 | expect(output.buffer).toMatchSnapshot();
74 | });
75 |
76 | test('renders and clears validation errors', async () => {
77 | const result = prompts.password({
78 | message: 'foo',
79 | validate: (value) => {
80 | if (value.length < 2) {
81 | return 'Password must be at least 2 characters';
82 | }
83 |
84 | return undefined;
85 | },
86 | input,
87 | output,
88 | });
89 |
90 | input.emit('keypress', 'x', { name: 'x' });
91 | input.emit('keypress', '', { name: 'return' });
92 | input.emit('keypress', 'y', { name: 'y' });
93 | input.emit('keypress', '', { name: 'return' });
94 |
95 | await result;
96 |
97 | expect(output.buffer).toMatchSnapshot();
98 | });
99 |
100 | test('renders cancelled value', async () => {
101 | const result = prompts.password({
102 | message: 'foo',
103 | input,
104 | output,
105 | });
106 |
107 | input.emit('keypress', 'x', { name: 'x' });
108 | input.emit('keypress', '', { name: 'escape' });
109 |
110 | const value = await result;
111 |
112 | expect(prompts.isCancel(value)).toBe(true);
113 | expect(output.buffer).toMatchSnapshot();
114 | });
115 | });
116 |
--------------------------------------------------------------------------------
/packages/prompts/test/path.test.ts:
--------------------------------------------------------------------------------
1 | import { fs, vol } from 'memfs';
2 | import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test, vi } from 'vitest';
3 | import * as prompts from '../src/index.js';
4 | import { MockReadable, MockWritable } from './test-utils.js';
5 |
6 | vi.mock('node:fs');
7 |
8 | describe.each(['true', 'false'])('text (isCI = %s)', (isCI) => {
9 | let originalCI: string | undefined;
10 | let output: MockWritable;
11 | let input: MockReadable;
12 |
13 | beforeAll(() => {
14 | originalCI = process.env.CI;
15 | process.env.CI = isCI;
16 | });
17 |
18 | afterAll(() => {
19 | process.env.CI = originalCI;
20 | });
21 |
22 | beforeEach(() => {
23 | output = new MockWritable();
24 | input = new MockReadable();
25 | vol.reset();
26 | vol.fromJSON(
27 | {
28 | './foo/bar.txt': '1',
29 | './foo/baz.text': '2',
30 | './hello/world.jpg': '3',
31 | './hello/john.jpg': '4',
32 | './hello/jeanne.png': '5',
33 | './root.zip': '6',
34 | },
35 | '/tmp'
36 | );
37 | });
38 |
39 | afterEach(() => {
40 | vi.restoreAllMocks();
41 | });
42 |
43 | test('renders message', async () => {
44 | const result = prompts.path({
45 | message: 'foo',
46 | input,
47 | output,
48 | root: '/tmp/',
49 | });
50 |
51 | input.emit('keypress', '', { name: 'return' });
52 |
53 | await result;
54 |
55 | expect(output.buffer).toMatchSnapshot();
56 | });
57 |
58 | test('renders and apply () suggestion', async () => {
59 | const result = prompts.path({
60 | message: 'foo',
61 | root: '/tmp',
62 | input,
63 | output,
64 | });
65 |
66 | input.emit('keypress', '\t', { name: 'tab' });
67 | input.emit('keypress', '', { name: 'return' });
68 |
69 | const value = await result;
70 |
71 | expect(output.buffer).toMatchSnapshot();
72 |
73 | expect(value).toBe('/tmp/foo');
74 | });
75 |
76 | test('can cancel', async () => {
77 | const result = prompts.path({
78 | message: 'foo',
79 | root: '/tmp/',
80 | input,
81 | output,
82 | });
83 |
84 | input.emit('keypress', 'escape', { name: 'escape' });
85 |
86 | const value = await result;
87 |
88 | expect(prompts.isCancel(value)).toBe(true);
89 | expect(output.buffer).toMatchSnapshot();
90 | });
91 |
92 | test('renders cancelled value if one set', async () => {
93 | const result = prompts.path({
94 | message: 'foo',
95 | input,
96 | output,
97 | root: '/tmp/',
98 | });
99 |
100 | input.emit('keypress', 'x', { name: 'x' });
101 | input.emit('keypress', 'y', { name: 'y' });
102 | input.emit('keypress', '', { name: 'escape' });
103 |
104 | const value = await result;
105 |
106 | expect(prompts.isCancel(value)).toBe(true);
107 | expect(output.buffer).toMatchSnapshot();
108 | });
109 |
110 | test('renders submitted value', async () => {
111 | const result = prompts.path({
112 | message: 'foo',
113 | root: '/tmp/',
114 | input,
115 | output,
116 | });
117 |
118 | input.emit('keypress', 'x', { name: 'x' });
119 | input.emit('keypress', 'y', { name: 'y' });
120 | input.emit('keypress', '', { name: 'return' });
121 |
122 | const value = await result;
123 |
124 | expect(value).toBe('/tmp/xy');
125 | expect(output.buffer).toMatchSnapshot();
126 | });
127 |
128 | test('initialValue sets the value', async () => {
129 | const result = prompts.path({
130 | message: 'foo',
131 | initialValue: '/tmp/bar',
132 | root: '/tmp/',
133 | input,
134 | output,
135 | });
136 |
137 | input.emit('keypress', '', { name: 'return' });
138 |
139 | const value = await result;
140 |
141 | expect(value).toBe('/tmp/bar');
142 | expect(output.buffer).toMatchSnapshot();
143 | });
144 |
145 | test('validation errors render and clear', async () => {
146 | const result = prompts.path({
147 | message: 'foo',
148 | root: '/tmp/',
149 | validate: (val) => (val !== '/tmp/bar' ? 'should be /tmp/bar' : undefined),
150 | input,
151 | output,
152 | });
153 |
154 | input.emit('keypress', 'b', { name: 'b' });
155 | input.emit('keypress', '', { name: 'return' });
156 | input.emit('keypress', 'a', { name: 'a' });
157 | input.emit('keypress', 'r', { name: 'r' });
158 | input.emit('keypress', '', { name: 'return' });
159 |
160 | const value = await result;
161 |
162 | expect(value).toBe('/tmp/bar');
163 | expect(output.buffer).toMatchSnapshot();
164 | });
165 |
166 | test('validation errors render and clear (using Error)', async () => {
167 | const result = prompts.path({
168 | message: 'foo',
169 | root: '/tmp/',
170 | validate: (val) => (val !== '/tmp/bar' ? new Error('should be /tmp/bar') : undefined),
171 | input,
172 | output,
173 | });
174 |
175 | input.emit('keypress', 'b', { name: 'b' });
176 | input.emit('keypress', '', { name: 'return' });
177 | input.emit('keypress', 'a', { name: 'a' });
178 | input.emit('keypress', 'r', { name: 'r' });
179 | input.emit('keypress', '', { name: 'return' });
180 |
181 | const value = await result;
182 |
183 | expect(value).toBe('/tmp/bar');
184 | expect(output.buffer).toMatchSnapshot();
185 | });
186 | });
187 |
--------------------------------------------------------------------------------
/packages/prompts/test/select.test.ts:
--------------------------------------------------------------------------------
1 | import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test, vi } from 'vitest';
2 | import * as prompts from '../src/index.js';
3 | import { MockReadable, MockWritable } from './test-utils.js';
4 |
5 | describe.each(['true', 'false'])('select (isCI = %s)', (isCI) => {
6 | let originalCI: string | undefined;
7 | let output: MockWritable;
8 | let input: MockReadable;
9 |
10 | beforeAll(() => {
11 | originalCI = process.env.CI;
12 | process.env.CI = isCI;
13 | });
14 |
15 | afterAll(() => {
16 | process.env.CI = originalCI;
17 | });
18 |
19 | beforeEach(() => {
20 | output = new MockWritable();
21 | input = new MockReadable();
22 | });
23 |
24 | afterEach(() => {
25 | vi.restoreAllMocks();
26 | });
27 |
28 | test('renders options and message', async () => {
29 | const result = prompts.select({
30 | message: 'foo',
31 | options: [{ value: 'opt0' }, { value: 'opt1' }],
32 | input,
33 | output,
34 | });
35 |
36 | input.emit('keypress', '', { name: 'return' });
37 |
38 | const value = await result;
39 |
40 | expect(value).toBe('opt0');
41 | expect(output.buffer).toMatchSnapshot();
42 | });
43 |
44 | test('down arrow selects next option', async () => {
45 | const result = prompts.select({
46 | message: 'foo',
47 | options: [{ value: 'opt0' }, { value: 'opt1' }],
48 | input,
49 | output,
50 | });
51 |
52 | input.emit('keypress', '', { name: 'down' });
53 | input.emit('keypress', '', { name: 'return' });
54 |
55 | const value = await result;
56 |
57 | expect(value).toBe('opt1');
58 | expect(output.buffer).toMatchSnapshot();
59 | });
60 |
61 | test('up arrow selects previous option', async () => {
62 | const result = prompts.select({
63 | message: 'foo',
64 | options: [{ value: 'opt0' }, { value: 'opt1' }],
65 | input,
66 | output,
67 | });
68 |
69 | input.emit('keypress', '', { name: 'down' });
70 | input.emit('keypress', '', { name: 'up' });
71 | input.emit('keypress', '', { name: 'return' });
72 |
73 | const value = await result;
74 |
75 | expect(value).toBe('opt0');
76 | expect(output.buffer).toMatchSnapshot();
77 | });
78 |
79 | test('can cancel', async () => {
80 | const result = prompts.select({
81 | message: 'foo',
82 | options: [{ value: 'opt0' }, { value: 'opt1' }],
83 | input,
84 | output,
85 | });
86 |
87 | input.emit('keypress', 'escape', { name: 'escape' });
88 |
89 | const value = await result;
90 |
91 | expect(prompts.isCancel(value)).toBe(true);
92 | expect(output.buffer).toMatchSnapshot();
93 | });
94 |
95 | test('renders option labels', async () => {
96 | const result = prompts.select({
97 | message: 'foo',
98 | options: [
99 | { value: 'opt0', label: 'Option 0' },
100 | { value: 'opt1', label: 'Option 1' },
101 | ],
102 | input,
103 | output,
104 | });
105 |
106 | input.emit('keypress', '', { name: 'return' });
107 |
108 | const value = await result;
109 |
110 | expect(value).toBe('opt0');
111 | expect(output.buffer).toMatchSnapshot();
112 | });
113 |
114 | test('renders option hints', async () => {
115 | const result = prompts.select({
116 | message: 'foo',
117 | options: [
118 | { value: 'opt0', hint: 'Hint 0' },
119 | { value: 'opt1', hint: 'Hint 1' },
120 | ],
121 | input,
122 | output,
123 | });
124 |
125 | input.emit('keypress', '', { name: 'return' });
126 |
127 | const value = await result;
128 |
129 | expect(value).toBe('opt0');
130 | expect(output.buffer).toMatchSnapshot();
131 | });
132 | });
133 |
--------------------------------------------------------------------------------
/packages/prompts/test/suggestion.test.ts:
--------------------------------------------------------------------------------
1 | import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test, vi } from 'vitest';
2 | import * as prompts from '../src/index.js';
3 | import { MockReadable, MockWritable } from './test-utils.js';
4 |
5 | describe.each(['true', 'false'])('text (isCI = %s)', (isCI) => {
6 | let originalCI: string | undefined;
7 | let output: MockWritable;
8 | let input: MockReadable;
9 |
10 | beforeAll(() => {
11 | originalCI = process.env.CI;
12 | process.env.CI = isCI;
13 | });
14 |
15 | afterAll(() => {
16 | process.env.CI = originalCI;
17 | });
18 |
19 | beforeEach(() => {
20 | output = new MockWritable();
21 | input = new MockReadable();
22 | });
23 |
24 | afterEach(() => {
25 | vi.restoreAllMocks();
26 | });
27 |
28 | test('renders message', async () => {
29 | const result = prompts.suggestion({
30 | message: 'foo',
31 | input,
32 | output,
33 | suggest: () => [],
34 | });
35 |
36 | input.emit('keypress', '', { name: 'return' });
37 |
38 | await result;
39 |
40 | expect(output.buffer).toMatchSnapshot();
41 | });
42 |
43 | test('renders and apply () suggestion', async () => {
44 | const result = prompts.suggestion({
45 | message: 'foo',
46 | suggest: () => ['bar'],
47 | input,
48 | output,
49 | });
50 |
51 | input.emit('keypress', '\t', { name: 'tab' });
52 | input.emit('keypress', '', { name: 'return' });
53 |
54 | const value = await result;
55 |
56 | expect(output.buffer).toMatchSnapshot();
57 |
58 | expect(value).toBe('bar');
59 | });
60 |
61 | test('can cancel', async () => {
62 | const result = prompts.suggestion({
63 | message: 'foo',
64 | suggest: () => [],
65 | input,
66 | output,
67 | });
68 |
69 | input.emit('keypress', 'escape', { name: 'escape' });
70 |
71 | const value = await result;
72 |
73 | expect(prompts.isCancel(value)).toBe(true);
74 | expect(output.buffer).toMatchSnapshot();
75 | });
76 |
77 | test('renders cancelled value if one set', async () => {
78 | const result = prompts.suggestion({
79 | message: 'foo',
80 | input,
81 | output,
82 | suggest: () => ['xyz'],
83 | });
84 |
85 | input.emit('keypress', 'x', { name: 'x' });
86 | input.emit('keypress', 'y', { name: 'y' });
87 | input.emit('keypress', '', { name: 'escape' });
88 |
89 | const value = await result;
90 |
91 | expect(prompts.isCancel(value)).toBe(true);
92 | expect(output.buffer).toMatchSnapshot();
93 | });
94 |
95 | test('renders submitted value', async () => {
96 | const result = prompts.suggestion({
97 | message: 'foo',
98 | suggest: () => ['xyz'],
99 | input,
100 | output,
101 | });
102 |
103 | input.emit('keypress', 'x', { name: 'x' });
104 | input.emit('keypress', 'y', { name: 'y' });
105 | input.emit('keypress', '', { name: 'return' });
106 |
107 | const value = await result;
108 |
109 | expect(value).toBe('xy');
110 | expect(output.buffer).toMatchSnapshot();
111 | });
112 |
113 | test('initialValue sets the value', async () => {
114 | const result = prompts.suggestion({
115 | message: 'foo',
116 | initialValue: 'bar',
117 | suggest: () => [],
118 | input,
119 | output,
120 | });
121 |
122 | input.emit('keypress', '', { name: 'return' });
123 |
124 | const value = await result;
125 |
126 | expect(value).toBe('bar');
127 | expect(output.buffer).toMatchSnapshot();
128 | });
129 |
130 | test('validation errors render and clear', async () => {
131 | const result = prompts.suggestion({
132 | message: 'foo',
133 | suggest: () => ['xyz'],
134 | validate: (val) => (val !== 'xy' ? 'should be xy' : undefined),
135 | input,
136 | output,
137 | });
138 |
139 | input.emit('keypress', 'x', { name: 'x' });
140 | input.emit('keypress', '', { name: 'return' });
141 | input.emit('keypress', 'y', { name: 'y' });
142 | input.emit('keypress', '', { name: 'return' });
143 |
144 | const value = await result;
145 |
146 | expect(value).toBe('xy');
147 | expect(output.buffer).toMatchSnapshot();
148 | });
149 |
150 | test('validation errors render and clear (using Error)', async () => {
151 | const result = prompts.suggestion({
152 | message: 'foo',
153 | suggest: () => ['xyz'],
154 | validate: (val) => (val !== 'xy' ? new Error('should be xy') : undefined),
155 | input,
156 | output,
157 | });
158 |
159 | input.emit('keypress', 'x', { name: 'x' });
160 | input.emit('keypress', '', { name: 'return' });
161 | input.emit('keypress', 'y', { name: 'y' });
162 | input.emit('keypress', '', { name: 'return' });
163 |
164 | const value = await result;
165 |
166 | expect(value).toBe('xy');
167 | expect(output.buffer).toMatchSnapshot();
168 | });
169 | });
170 |
--------------------------------------------------------------------------------
/packages/prompts/test/task-log.test.ts:
--------------------------------------------------------------------------------
1 | import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test, vi } from 'vitest';
2 | import * as prompts from '../src/index.js';
3 | import { MockReadable, MockWritable } from './test-utils.js';
4 |
5 | describe.each(['true', 'false'])('taskLog (isCI = %s)', (isCI) => {
6 | let originalCI: string | undefined;
7 | let output: MockWritable;
8 | let input: MockReadable;
9 |
10 | beforeAll(() => {
11 | originalCI = process.env.CI;
12 | process.env.CI = isCI;
13 | });
14 |
15 | afterAll(() => {
16 | process.env.CI = originalCI;
17 | });
18 |
19 | beforeEach(() => {
20 | output = new MockWritable();
21 | input = new MockReadable();
22 | });
23 |
24 | afterEach(() => {
25 | vi.restoreAllMocks();
26 | });
27 |
28 | test('writes message header', () => {
29 | prompts.taskLog({
30 | input,
31 | output,
32 | title: 'foo',
33 | });
34 |
35 | expect(output.buffer).toMatchSnapshot();
36 | });
37 |
38 | describe('message', () => {
39 | test('can write line by line', () => {
40 | const log = prompts.taskLog({
41 | input,
42 | output,
43 | title: 'foo',
44 | });
45 |
46 | log.message('line 0');
47 | log.message('line 1');
48 |
49 | expect(output.buffer).toMatchSnapshot();
50 | });
51 |
52 | test('can write multiple lines', () => {
53 | const log = prompts.taskLog({
54 | input,
55 | output,
56 | title: 'foo',
57 | });
58 |
59 | log.message('line 0\nline 1');
60 |
61 | expect(output.buffer).toMatchSnapshot();
62 | });
63 |
64 | test('enforces limit if set', () => {
65 | const log = prompts.taskLog({
66 | input,
67 | output,
68 | title: 'foo',
69 | limit: 2,
70 | });
71 |
72 | log.message('line 0');
73 | log.message('line 1');
74 | log.message('line 2');
75 |
76 | expect(output.buffer).toMatchSnapshot();
77 | });
78 |
79 | test('raw = true appends message text until newline', async () => {
80 | const log = prompts.taskLog({
81 | input,
82 | output,
83 | title: 'foo',
84 | });
85 |
86 | log.message('line 0', { raw: true });
87 | log.message('still line 0', { raw: true });
88 | log.message('\nline 1', { raw: true });
89 |
90 | expect(output.buffer).toMatchSnapshot();
91 | });
92 |
93 | test('raw = true works when mixed with non-raw messages', async () => {
94 | const log = prompts.taskLog({
95 | input,
96 | output,
97 | title: 'foo',
98 | });
99 |
100 | log.message('line 0', { raw: true });
101 | log.message('still line 0', { raw: true });
102 | log.message('line 1');
103 |
104 | expect(output.buffer).toMatchSnapshot();
105 | });
106 |
107 | test('raw = true works when started with non-raw messages', async () => {
108 | const log = prompts.taskLog({
109 | input,
110 | output,
111 | title: 'foo',
112 | });
113 |
114 | log.message('line 0');
115 | log.message('line 1', { raw: true });
116 | log.message('still line 1', { raw: true });
117 |
118 | expect(output.buffer).toMatchSnapshot();
119 | });
120 |
121 | test('prints empty lines', async () => {
122 | const log = prompts.taskLog({
123 | input,
124 | output,
125 | title: 'foo',
126 | });
127 |
128 | log.message('');
129 | log.message('line 1');
130 | log.message('');
131 | log.message('line 3');
132 |
133 | expect(output.buffer).toMatchSnapshot();
134 | });
135 | });
136 |
137 | describe('error', () => {
138 | test('renders output with message', () => {
139 | const log = prompts.taskLog({
140 | input,
141 | output,
142 | title: 'foo',
143 | });
144 |
145 | log.message('line 0');
146 | log.message('line 1');
147 |
148 | log.error('some error!');
149 |
150 | expect(output.buffer).toMatchSnapshot();
151 | });
152 |
153 | test('clears output if showLog = false', () => {
154 | const log = prompts.taskLog({
155 | input,
156 | output,
157 | title: 'foo',
158 | });
159 |
160 | log.message('line 0');
161 | log.message('line 1');
162 |
163 | log.error('some error!', { showLog: false });
164 |
165 | expect(output.buffer).toMatchSnapshot();
166 | });
167 | });
168 |
169 | describe('success', () => {
170 | test('clears output and renders message', () => {
171 | const log = prompts.taskLog({
172 | input,
173 | output,
174 | title: 'foo',
175 | });
176 |
177 | log.message('line 0');
178 | log.message('line 1');
179 |
180 | log.success('success!');
181 |
182 | expect(output.buffer).toMatchSnapshot();
183 | });
184 |
185 | test('renders output if showLog = true', () => {
186 | const log = prompts.taskLog({
187 | input,
188 | output,
189 | title: 'foo',
190 | });
191 |
192 | log.message('line 0');
193 | log.message('line 1');
194 |
195 | log.success('success!', { showLog: true });
196 |
197 | expect(output.buffer).toMatchSnapshot();
198 | });
199 | });
200 |
201 | describe('retainLog', () => {
202 | describe.each(['error', 'success'] as const)('%s', (method) => {
203 | test('retainLog = true outputs full log', () => {
204 | const log = prompts.taskLog({
205 | input,
206 | output,
207 | title: 'foo',
208 | retainLog: true,
209 | });
210 |
211 | for (let i = 0; i < 4; i++) {
212 | log.message(`line ${i}`);
213 | }
214 |
215 | log[method]('woo!', { showLog: true });
216 |
217 | expect(output.buffer).toMatchSnapshot();
218 | });
219 |
220 | test('retainLog = true outputs full log with limit', () => {
221 | const log = prompts.taskLog({
222 | input,
223 | output,
224 | title: 'foo',
225 | retainLog: true,
226 | limit: 2,
227 | });
228 |
229 | for (let i = 0; i < 4; i++) {
230 | log.message(`line ${i}`);
231 | }
232 |
233 | log[method]('woo!', { showLog: true });
234 |
235 | expect(output.buffer).toMatchSnapshot();
236 | });
237 |
238 | test('retainLog = false outputs full log without limit', () => {
239 | const log = prompts.taskLog({
240 | input,
241 | output,
242 | title: 'foo',
243 | retainLog: false,
244 | });
245 |
246 | for (let i = 0; i < 4; i++) {
247 | log.message(`line ${i}`);
248 | }
249 |
250 | log[method]('woo!', { showLog: true });
251 |
252 | expect(output.buffer).toMatchSnapshot();
253 | });
254 |
255 | test('retainLog = false outputs limited log with limit', () => {
256 | const log = prompts.taskLog({
257 | input,
258 | output,
259 | title: 'foo',
260 | retainLog: false,
261 | limit: 2,
262 | });
263 |
264 | for (let i = 0; i < 4; i++) {
265 | log.message(`line ${i}`);
266 | }
267 |
268 | log[method]('woo!', { showLog: true });
269 |
270 | expect(output.buffer).toMatchSnapshot();
271 | });
272 |
273 | test('outputs limited log with limit by default', () => {
274 | const log = prompts.taskLog({
275 | input,
276 | output,
277 | title: 'foo',
278 | limit: 2,
279 | });
280 |
281 | for (let i = 0; i < 4; i++) {
282 | log.message(`line ${i}`);
283 | }
284 |
285 | log[method]('woo!', { showLog: true });
286 |
287 | expect(output.buffer).toMatchSnapshot();
288 | });
289 | });
290 | });
291 | });
292 |
--------------------------------------------------------------------------------
/packages/prompts/test/test-utils.ts:
--------------------------------------------------------------------------------
1 | import { Readable, Writable } from 'node:stream';
2 |
3 | export class MockWritable extends Writable {
4 | public buffer: string[] = [];
5 |
6 | _write(
7 | chunk: any,
8 | _encoding: BufferEncoding,
9 | callback: (error?: Error | null | undefined) => void
10 | ): void {
11 | this.buffer.push(chunk.toString());
12 | callback();
13 | }
14 | }
15 |
16 | export class MockReadable extends Readable {
17 | protected _buffer: unknown[] | null = [];
18 |
19 | _read() {
20 | if (this._buffer === null) {
21 | this.push(null);
22 | return;
23 | }
24 |
25 | for (const val of this._buffer) {
26 | this.push(val);
27 | }
28 |
29 | this._buffer = [];
30 | }
31 |
32 | pushValue(val: unknown): void {
33 | this._buffer?.push(val);
34 | }
35 |
36 | close(): void {
37 | this._buffer = null;
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/packages/prompts/test/text.test.ts:
--------------------------------------------------------------------------------
1 | import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test, vi } from 'vitest';
2 | import * as prompts from '../src/index.js';
3 | import { MockReadable, MockWritable } from './test-utils.js';
4 |
5 | describe.each(['true', 'false'])('text (isCI = %s)', (isCI) => {
6 | let originalCI: string | undefined;
7 | let output: MockWritable;
8 | let input: MockReadable;
9 |
10 | beforeAll(() => {
11 | originalCI = process.env.CI;
12 | process.env.CI = isCI;
13 | });
14 |
15 | afterAll(() => {
16 | process.env.CI = originalCI;
17 | });
18 |
19 | beforeEach(() => {
20 | output = new MockWritable();
21 | input = new MockReadable();
22 | });
23 |
24 | afterEach(() => {
25 | vi.restoreAllMocks();
26 | });
27 |
28 | test('renders message', async () => {
29 | const result = prompts.text({
30 | message: 'foo',
31 | input,
32 | output,
33 | });
34 |
35 | input.emit('keypress', '', { name: 'return' });
36 |
37 | await result;
38 |
39 | expect(output.buffer).toMatchSnapshot();
40 | });
41 |
42 | test('renders placeholder if set', async () => {
43 | const result = prompts.text({
44 | message: 'foo',
45 | placeholder: 'bar',
46 | input,
47 | output,
48 | });
49 |
50 | input.emit('keypress', '', { name: 'return' });
51 |
52 | const value = await result;
53 |
54 | expect(output.buffer).toMatchSnapshot();
55 | expect(value).toBe('');
56 | });
57 |
58 | test('can cancel', async () => {
59 | const result = prompts.text({
60 | message: 'foo',
61 | input,
62 | output,
63 | });
64 |
65 | input.emit('keypress', 'escape', { name: 'escape' });
66 |
67 | const value = await result;
68 |
69 | expect(prompts.isCancel(value)).toBe(true);
70 | expect(output.buffer).toMatchSnapshot();
71 | });
72 |
73 | test('renders cancelled value if one set', async () => {
74 | const result = prompts.text({
75 | message: 'foo',
76 | input,
77 | output,
78 | });
79 |
80 | input.emit('keypress', 'x', { name: 'x' });
81 | input.emit('keypress', 'y', { name: 'y' });
82 | input.emit('keypress', '', { name: 'escape' });
83 |
84 | const value = await result;
85 |
86 | expect(prompts.isCancel(value)).toBe(true);
87 | expect(output.buffer).toMatchSnapshot();
88 | });
89 |
90 | test('renders submitted value', async () => {
91 | const result = prompts.text({
92 | message: 'foo',
93 | input,
94 | output,
95 | });
96 |
97 | input.emit('keypress', 'x', { name: 'x' });
98 | input.emit('keypress', 'y', { name: 'y' });
99 | input.emit('keypress', '', { name: 'return' });
100 |
101 | const value = await result;
102 |
103 | expect(value).toBe('xy');
104 | expect(output.buffer).toMatchSnapshot();
105 | });
106 |
107 | test('defaultValue sets the value but does not render', async () => {
108 | const result = prompts.text({
109 | message: 'foo',
110 | defaultValue: 'bar',
111 | input,
112 | output,
113 | });
114 |
115 | input.emit('keypress', '', { name: 'return' });
116 |
117 | const value = await result;
118 |
119 | expect(value).toBe('bar');
120 | expect(output.buffer).toMatchSnapshot();
121 | });
122 |
123 | test('validation errors render and clear', async () => {
124 | const result = prompts.text({
125 | message: 'foo',
126 | validate: (val) => (val !== 'xy' ? 'should be xy' : undefined),
127 | input,
128 | output,
129 | });
130 |
131 | input.emit('keypress', 'x', { name: 'x' });
132 | input.emit('keypress', '', { name: 'return' });
133 | input.emit('keypress', 'y', { name: 'y' });
134 | input.emit('keypress', '', { name: 'return' });
135 |
136 | const value = await result;
137 |
138 | expect(value).toBe('xy');
139 | expect(output.buffer).toMatchSnapshot();
140 | });
141 |
142 | test('validation errors render and clear (using Error)', async () => {
143 | const result = prompts.text({
144 | message: 'foo',
145 | validate: (val) => (val !== 'xy' ? new Error('should be xy') : undefined),
146 | input,
147 | output,
148 | });
149 |
150 | input.emit('keypress', 'x', { name: 'x' });
151 | input.emit('keypress', '', { name: 'return' });
152 | input.emit('keypress', 'y', { name: 'y' });
153 | input.emit('keypress', '', { name: 'return' });
154 |
155 | const value = await result;
156 |
157 | expect(value).toBe('xy');
158 | expect(output.buffer).toMatchSnapshot();
159 | });
160 |
161 | test('placeholder is not used as value when pressing enter', async () => {
162 | const result = prompts.text({
163 | message: 'foo',
164 | placeholder: ' (hit Enter to use default)',
165 | defaultValue: 'default-value',
166 | input,
167 | output,
168 | });
169 |
170 | input.emit('keypress', '', { name: 'return' });
171 |
172 | const value = await result;
173 |
174 | expect(value).toBe('default-value');
175 | expect(output.buffer).toMatchSnapshot();
176 | });
177 |
178 | test('empty string when no value and no default', async () => {
179 | const result = prompts.text({
180 | message: 'foo',
181 | placeholder: ' (hit Enter to use default)',
182 | input,
183 | output,
184 | });
185 |
186 | input.emit('keypress', '', { name: 'return' });
187 |
188 | const value = await result;
189 |
190 | expect(value).toBe('');
191 | expect(output.buffer).toMatchSnapshot();
192 | });
193 | });
194 |
--------------------------------------------------------------------------------
/packages/prompts/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../../tsconfig.json"
3 | }
4 |
--------------------------------------------------------------------------------
/packages/prompts/vitest.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'vitest/config';
2 |
3 | export default defineConfig({
4 | test: {
5 | snapshotSerializers: ['vitest-ansi-serializer'],
6 | },
7 | });
8 |
--------------------------------------------------------------------------------
/pnpm-workspace.yaml:
--------------------------------------------------------------------------------
1 | packages:
2 | - 'examples/*'
3 | - 'packages/*'
4 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "noEmit": true,
4 | "module": "node16",
5 | "target": "ESNext",
6 | "moduleResolution": "node16",
7 | "strict": true,
8 | "esModuleInterop": true,
9 | "forceConsistentCasingInFileNames": true,
10 | "erasableSyntaxOnly": true,
11 | "skipLibCheck": true,
12 | "isolatedModules": true,
13 | "verbatimModuleSyntax": true,
14 | "lib": ["ES2022"],
15 | "paths": {
16 | "@clack/core": ["./packages/core/src"]
17 | }
18 | },
19 | "include": ["packages"]
20 | }
21 |
--------------------------------------------------------------------------------