├── .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 | Clack logo 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 | "│ 6 | ◇ title ──╮ 7 | │ │ 8 | │ line 0 │ 9 | │ line 1 │ 10 | │ line 2 │ 11 | │ │ 12 | ├──────────╯ 13 | ", 14 | ] 15 | `; 16 | 17 | exports[`note (isCI = false) > formatter which adds length works 1`] = ` 18 | [ 19 | "│ 20 | ◇ title ──────╮ 21 | │ │ 22 | │ * line 0 * │ 23 | │ * line 1 * │ 24 | │ * line 2 * │ 25 | │ │ 26 | ├──────────────╯ 27 | ", 28 | ] 29 | `; 30 | 31 | exports[`note (isCI = false) > renders as wide as longest line 1`] = ` 32 | [ 33 | "│ 34 | ◇ title ───────────────────────────╮ 35 | │ │ 36 | │ short │ 37 | │ somewhat questionably long line │ 38 | │ │ 39 | ├───────────────────────────────────╯ 40 | ", 41 | ] 42 | `; 43 | 44 | exports[`note (isCI = false) > renders message with title 1`] = ` 45 | [ 46 | "│ 47 | ◇ title ───╮ 48 | │ │ 49 | │ message │ 50 | │ │ 51 | ├───────────╯ 52 | ", 53 | ] 54 | `; 55 | 56 | exports[`note (isCI = true) > formatter which adds colors works 1`] = ` 57 | [ 58 | "│ 59 | ◇ title ──╮ 60 | │ │ 61 | │ line 0 │ 62 | │ line 1 │ 63 | │ line 2 │ 64 | │ │ 65 | ├──────────╯ 66 | ", 67 | ] 68 | `; 69 | 70 | exports[`note (isCI = true) > formatter which adds length works 1`] = ` 71 | [ 72 | "│ 73 | ◇ title ──────╮ 74 | │ │ 75 | │ * line 0 * │ 76 | │ * line 1 * │ 77 | │ * line 2 * │ 78 | │ │ 79 | ├──────────────╯ 80 | ", 81 | ] 82 | `; 83 | 84 | exports[`note (isCI = true) > renders as wide as longest line 1`] = ` 85 | [ 86 | "│ 87 | ◇ title ───────────────────────────╮ 88 | │ │ 89 | │ short │ 90 | │ somewhat questionably long line │ 91 | │ │ 92 | ├───────────────────────────────────╯ 93 | ", 94 | ] 95 | `; 96 | 97 | exports[`note (isCI = true) > renders message with title 1`] = ` 98 | [ 99 | "│ 100 | ◇ title ───╮ 101 | │ │ 102 | │ message │ 103 | │ │ 104 | ├───────────╯ 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 | "│ 7 | ◆ foo 8 | │ ● opt0 9 | │ ○ opt1 10 | └ 11 | ", 12 | "", 13 | "", 14 | "", 15 | "■ foo 16 | │ opt0 17 | │", 18 | " 19 | ", 20 | "", 21 | ] 22 | `; 23 | 24 | exports[`select (isCI = false) > down arrow selects next option 1`] = ` 25 | [ 26 | "", 27 | "│ 28 | ◆ foo 29 | │ ● opt0 30 | │ ○ opt1 31 | └ 32 | ", 33 | "", 34 | "", 35 | "", 36 | "│ ○ opt0 37 | │ ● opt1 38 | └ 39 | ", 40 | "", 41 | "", 42 | "", 43 | "◇ foo 44 | │ opt1", 45 | " 46 | ", 47 | "", 48 | ] 49 | `; 50 | 51 | exports[`select (isCI = false) > renders option hints 1`] = ` 52 | [ 53 | "", 54 | "│ 55 | ◆ foo 56 | │ ● opt0 (Hint 0) 57 | │ ○ opt1 58 | └ 59 | ", 60 | "", 61 | "", 62 | "", 63 | "◇ foo 64 | │ opt0", 65 | " 66 | ", 67 | "", 68 | ] 69 | `; 70 | 71 | exports[`select (isCI = false) > renders option labels 1`] = ` 72 | [ 73 | "", 74 | "│ 75 | ◆ foo 76 | │ ● Option 0 77 | │ ○ Option 1 78 | └ 79 | ", 80 | "", 81 | "", 82 | "", 83 | "◇ foo 84 | │ Option 0", 85 | " 86 | ", 87 | "", 88 | ] 89 | `; 90 | 91 | exports[`select (isCI = false) > renders options and message 1`] = ` 92 | [ 93 | "", 94 | "│ 95 | ◆ foo 96 | │ ● opt0 97 | │ ○ opt1 98 | └ 99 | ", 100 | "", 101 | "", 102 | "", 103 | "◇ foo 104 | │ opt0", 105 | " 106 | ", 107 | "", 108 | ] 109 | `; 110 | 111 | exports[`select (isCI = false) > up arrow selects previous option 1`] = ` 112 | [ 113 | "", 114 | "│ 115 | ◆ foo 116 | │ ● opt0 117 | │ ○ opt1 118 | └ 119 | ", 120 | "", 121 | "", 122 | "", 123 | "│ ○ opt0 124 | │ ● opt1 125 | └ 126 | ", 127 | "", 128 | "", 129 | "", 130 | "│ ● opt0 131 | │ ○ opt1 132 | └ 133 | ", 134 | "", 135 | "", 136 | "", 137 | "◇ foo 138 | │ opt0", 139 | " 140 | ", 141 | "", 142 | ] 143 | `; 144 | 145 | exports[`select (isCI = true) > can cancel 1`] = ` 146 | [ 147 | "", 148 | "│ 149 | ◆ foo 150 | │ ● opt0 151 | │ ○ opt1 152 | └ 153 | ", 154 | "", 155 | "", 156 | "", 157 | "■ foo 158 | │ opt0 159 | │", 160 | " 161 | ", 162 | "", 163 | ] 164 | `; 165 | 166 | exports[`select (isCI = true) > down arrow selects next option 1`] = ` 167 | [ 168 | "", 169 | "│ 170 | ◆ foo 171 | │ ● opt0 172 | │ ○ opt1 173 | └ 174 | ", 175 | "", 176 | "", 177 | "", 178 | "│ ○ opt0 179 | │ ● opt1 180 | └ 181 | ", 182 | "", 183 | "", 184 | "", 185 | "◇ foo 186 | │ opt1", 187 | " 188 | ", 189 | "", 190 | ] 191 | `; 192 | 193 | exports[`select (isCI = true) > renders option hints 1`] = ` 194 | [ 195 | "", 196 | "│ 197 | ◆ foo 198 | │ ● opt0 (Hint 0) 199 | │ ○ opt1 200 | └ 201 | ", 202 | "", 203 | "", 204 | "", 205 | "◇ foo 206 | │ opt0", 207 | " 208 | ", 209 | "", 210 | ] 211 | `; 212 | 213 | exports[`select (isCI = true) > renders option labels 1`] = ` 214 | [ 215 | "", 216 | "│ 217 | ◆ foo 218 | │ ● Option 0 219 | │ ○ Option 1 220 | └ 221 | ", 222 | "", 223 | "", 224 | "", 225 | "◇ foo 226 | │ Option 0", 227 | " 228 | ", 229 | "", 230 | ] 231 | `; 232 | 233 | exports[`select (isCI = true) > renders options and message 1`] = ` 234 | [ 235 | "", 236 | "│ 237 | ◆ foo 238 | │ ● opt0 239 | │ ○ opt1 240 | └ 241 | ", 242 | "", 243 | "", 244 | "", 245 | "◇ foo 246 | │ opt0", 247 | " 248 | ", 249 | "", 250 | ] 251 | `; 252 | 253 | exports[`select (isCI = true) > up arrow selects previous option 1`] = ` 254 | [ 255 | "", 256 | "│ 257 | ◆ foo 258 | │ ● opt0 259 | │ ○ opt1 260 | └ 261 | ", 262 | "", 263 | "", 264 | "", 265 | "│ ○ opt0 266 | │ ● opt1 267 | └ 268 | ", 269 | "", 270 | "", 271 | "", 272 | "│ ● opt0 273 | │ ○ opt1 274 | └ 275 | ", 276 | "", 277 | "", 278 | "", 279 | "◇ foo 280 | │ opt0", 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 | --------------------------------------------------------------------------------