Repository: bombshell-dev/clack
Branch: main
Commit: 8a96e2dcd7f8
Files: 148
Total size: 691.3 KB
Directory structure:
gitextract_95ui2zim/
├── .changeset/
│ ├── README.md
│ ├── afraid-donkeys-sin.md
│ ├── big-pants-invite.md
│ ├── config.json
│ ├── dirty-actors-find.md
│ ├── tangy-mirrors-hug.md
│ └── tricky-states-tease.md
├── .editorconfig
├── .gitattributes
├── .github/
│ ├── ISSUE_TEMPLATE/
│ │ ├── bug_report.md
│ │ └── feature_request.md
│ └── workflows/
│ ├── ci.yml
│ ├── detect-agent.yml
│ ├── format.yml
│ ├── issue.yml
│ ├── preview.yml
│ ├── publish.yml
│ └── require-allow-edits.yml
├── .gitignore
├── .npmrc
├── .nvmrc
├── .vscode/
│ └── settings.json
├── CONTRIBUTING.md
├── README.md
├── biome.json
├── build.preset.ts
├── examples/
│ ├── basic/
│ │ ├── autocomplete-multiselect.ts
│ │ ├── autocomplete.ts
│ │ ├── date.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
│ │ │ │ ├── date.ts
│ │ │ │ ├── group-multiselect.ts
│ │ │ │ ├── multi-select.ts
│ │ │ │ ├── password.ts
│ │ │ │ ├── prompt.ts
│ │ │ │ ├── select-key.ts
│ │ │ │ ├── select.ts
│ │ │ │ └── text.ts
│ │ │ ├── types.ts
│ │ │ └── utils/
│ │ │ ├── cursor.ts
│ │ │ ├── index.ts
│ │ │ ├── settings.ts
│ │ │ └── string.ts
│ │ ├── test/
│ │ │ ├── mock-readable.ts
│ │ │ ├── mock-writable.ts
│ │ │ ├── prompts/
│ │ │ │ ├── autocomplete.test.ts
│ │ │ │ ├── confirm.test.ts
│ │ │ │ ├── date.test.ts
│ │ │ │ ├── multi-select.test.ts
│ │ │ │ ├── password.test.ts
│ │ │ │ ├── prompt.test.ts
│ │ │ │ ├── select.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
│ │ ├── box.ts
│ │ ├── common.ts
│ │ ├── confirm.ts
│ │ ├── date.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
│ │ ├── task-log.ts
│ │ ├── task.ts
│ │ └── text.ts
│ ├── test/
│ │ ├── __snapshots__/
│ │ │ ├── autocomplete.test.ts.snap
│ │ │ ├── box.test.ts.snap
│ │ │ ├── confirm.test.ts.snap
│ │ │ ├── date.test.ts.snap
│ │ │ ├── group-multi-select.test.ts.snap
│ │ │ ├── log.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-key.test.ts.snap
│ │ │ ├── select.test.ts.snap
│ │ │ ├── spinner.test.ts.snap
│ │ │ ├── task-log.test.ts.snap
│ │ │ └── text.test.ts.snap
│ │ ├── autocomplete.test.ts
│ │ ├── box.test.ts
│ │ ├── confirm.test.ts
│ │ ├── date.test.ts
│ │ ├── group-multi-select.test.ts
│ │ ├── limit-options.test.ts
│ │ ├── log.test.ts
│ │ ├── multi-select.test.ts
│ │ ├── note.test.ts
│ │ ├── password.test.ts
│ │ ├── path.test.ts
│ │ ├── progress-bar.test.ts
│ │ ├── select-key.test.ts
│ │ ├── select.test.ts
│ │ ├── spinner.test.ts
│ │ ├── task-log.test.ts
│ │ ├── test-utils.ts
│ │ └── text.test.ts
│ ├── tsconfig.json
│ └── vitest.config.ts
├── pnpm-workspace.yaml
└── tsconfig.json
================================================
FILE CONTENTS
================================================
================================================
FILE: .changeset/README.md
================================================
# Changesets
Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works
with multi-package repos, or single-package repos to help you version and publish your code. You can
find the full documentation for it [in our repository](https://github.com/changesets/changesets)
We have a quick list of common questions to get you started engaging with this project in
[our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md)
You will be responsible for writing a **changeset** for your Astro PR. This is a Markdown file that states:
- which repository has changed
- the kind of change according to [Astro's semantic versioning](https://docs.astro.build/en/upgrade-astro/#semantic-versioning)
- a message describing the change that will be publicly displayed on the repo's CHANGELOG
```md title=".changeset/my-super-cool-changeset.md" wrap
---
"astro": patch
---
Fixes unexpected `undefined` value when calling an action from the client without a return value
```
You can generate a changeset using the `pnpm changeset` command, which will prompt you for the necessary information and create a randomly-named file in the `.changeset` folder. You can then edit the generated file to add more detail, or write your changeset from scratch.
## Format
Begin your changeset message with **a present tense verb** that completes a sentence in the style of: "This PR . . ." or "This contribution ..."
- Adds
- Removes
- Fixes
- Updates
- Refactors
- Improves
- Deprecates
Finish the introductory sentence with a message focusing on what has changed **about the codebase** (not what your feature itself does) that is meaningful to a **user** of Astro. It is usually more helpful to describe the change **as someone building an Astro site will experience it**, instead of describing **how you fixed it** or **what the code in the PR does**:
```markdown title="changeset.md" del={2} ins={5}
// What the code now does
Logs helpful errors if content is invalid
// The nature of the change to the Astro code base
Adds logging for content collections configuration errors.
```
You may then include additional paragraphs if necessary to describe the change in more detail. This may not be needed for a small bug fix, but is often helpful if the reader must make a change to their own code, or needs to understand how the change might affect them.
The level of detail of a changeset depends on the type of change (e.g. `patch` vs `minor`, breaking or not, only affects integration authors etc.) You may also use [`
` - `` Markdown headings](#using-markdown-section-headings) to break your longer content into sections.
:::tip[Don't hide the good stuff in the changeset!]
CHANGELOGS are often read only once, when someone is updating to the latest version of a package. Documentation is a constant reference that will be revisited and consulted frequently.
Make sure that the helpful explanations and examples for your fellow developers in both your changeset and your PR description are captured in the actual feature documentation, too!
:::
### Patch updates
These updates are often fixes, refactors or other small improvements. They are typically not user-facing, and do not require someone to update their own project code.
Verbs like "fixes" and "refactors" are helpful to let readers know this is an internal or implementation change that they do not need to worry about. At the same time, these messages are helpful to someone who is interested in keeping up with small changes to the codebase.
Often one line is enough to describe your change meaningfully to an Astro user:
```md title="my-patch-changeset.md" wrap
---
"astro": patch
---
Fixes a bug where the toolbar audit would incorrectly flag images as above the fold
```
```md title="my-patch-changeset.md" wrap
---
"astro": patch
---
Refactors internal handling of styles and scripts for content collections to improve build performance
```
```md title="my-patch-changeset.md" wrap
---
"astro": patch
---
Updates the `HTMLAttributes` type exported from `astro` to allow data attributes
```
These do not need to be full sentences and do not need end punctuation unless you write multiple sentences.
:::tip[Help your reader figure out if this is change important to them]
Even though these are small changes described by very short sentences, they still need to communicate a lot!
Check that you have clearly stated not just **what has changed** but also **who needs to know**. If your reader can identify that a change is important to them, they can always seek out further information if they need to know more.
:::
#### Tips and Examples
For a great changeset message:
Include the specific API changed (using inline code highlighting as appropriate) when your change might not be easy to identify so that your reader can easily tell whether it's something they are using and need to care about:
> Improves automatic fallbacks generation
>
> vs
>
> ✅ Improves automatic `fallbacks` generation **for the experimental Fonts API**
When the specific API change is not user-facing (e.g. a type not publicly exposed) and/or your reader will not recognize it by name, describe the use case or end result that will be meaningful to the reader instead:
> Adds `| (string & {})` for better autocomplete of `App.SessionData`
>
> vs
>
> ✅ Improves autocompletion for session keys
### New features
Begin your changeset with "Adds" to alert readers that there is something new and mention the names of any new items (options, functions) that have been added directly in the first sentence:
```md title="my-minor-changeset.md" wrap
---
"astro": minor
---
Adds a new `flamethrow` view transitions animation
```
Additionally, describe what people are now able to do because of these additions that they could not before.
The changeset is an opportunity to call people's attention to new things they might wish to try in their Astro project, and may include a code example showing basic usage of the new feature:
````md wrap title="my-minor-changeset.md"
---
"astro": minor
---
Adds a new, optional property `timeout` for the `client:idle` directive
This value allows you to specify a maximum time to wait, in milliseconds, before hydrating a UI framework component, even if the page is not yet done with its initial load.
This means you can delay hydration for lower-priority UI elements with more control to ensure your element is interactive within a specified time frame.
```
```
````
You can also use [`` - `` Markdown headings](#using-markdown-section-headings) to break your longer content into sections.
### Breaking changes
Changesets focus on what has changed, and **must include any breaking changes**, including changes to default behavior. Most users will have several default settings configured (often by not setting any value themselves), so changes to defaults can have a significant impact on someone's project!
Verbs like "removes", "changes", and "deprecates" call attention to something that might require attention. Unlike a new feature someone can choose not to use, changing default or expected behavior cannot be ignored.
You can also use [`` - `` Markdown headings](#using-markdown-section-headings) to break your content into sections, for example, to add headings like, "What should I do?" or "Migrating from the previous version."
```md title="my-major-changeset.md" wrap
---
"astro": major
---
Removes support for returning simple objects from endpoints. You must now return a `Response` instead.
```
Changeset messages for breaking changes must also provide clear guidance for updating. Diff code samples are encouraged when appropriate:
````md title="my-major-changeset.md" wrap
---
"astro": major
---
Removes support for Shiki custom language's `path` property. The language JSON file must now be imported and passed to the option instead.
```diff
// astro.config.js
+ import customLang from './custom.tmLanguage.json'
export default defineConfig({
markdown: {
shikiConfig: {
langs: [
- { path: './custom.tmLanguage.json' },
+ customLang,
],
},
},
})
```
````
If your contribution changes a default value, then it is helpful to describe how to revert back to the previous behavior. Readers may also appreciate being informed that they can remove configuration that is no longer needed.
````md title="my-major-changeset.md" wrap
---
"astro": major
---
Changes the default value of `security.checkOrigin` to true, which now enables Cross-Site Request Forgery (CSRF) protection by default for pages rendered on demand.
If you had previously configured `security.checkOrigin: true`, you no longer need this set in your Astro config. This is now the default and it is safe to remove.
To revert to the previous default behavior and opt out of automatically checking that the “origin” header matches the URL sent by each request, you must now explicitly set `security.checkOrigin: false`:
```diff
export default defineConfig({
+ security: {
+ checkOrigin: false
+ }
})
```
````
## Using Markdown section headings
For longer descriptions that you want to split into sections, always start at the `` level, using `####` where you would normally use `##` in a regular Markdown post. This ensures readability when your message is incorporated into the final CHANGELOG:
```md wrap title="my-long-changeset-with-sections.md"
---
"astro": minor
---
Adds a new Sessions API to store user state between requests for on-demand rendered pages.
#### Configuring session storage
#### Using sessions
##### In `.astro` pages
##### In API endpoints
#### Upgrading from the experimental API
```
================================================
FILE: .changeset/afraid-donkeys-sin.md
================================================
---
"@clack/prompts": minor
"@clack/core": minor
---
Externalize `fast-string-width` and `fast-wrap-ansi` to avoid double dependencies
================================================
FILE: .changeset/big-pants-invite.md
================================================
---
"@clack/prompts": patch
---
Fix the `path` prompt so `directory: true` correctly enforces directory-only selection while still allowing directory navigation, and add regression tests for both directory and default file selection behavior.
================================================
FILE: .changeset/config.json
================================================
{
"$schema": "https://unpkg.com/@changesets/config@2.3.0/schema.json",
"changelog": "@changesets/cli/changelog",
"commit": false,
"fixed": [],
"linked": [],
"access": "public",
"baseBranch": "main",
"updateInternalDependencies": "patch",
"ignore": ["@example/*"]
}
================================================
FILE: .changeset/dirty-actors-find.md
================================================
---
"@clack/prompts": patch
"@clack/core": patch
---
Adds `placeholder` option to `autocomplete`. When the placeholder is set and the input is empty, pressing `tab` will set the value to `placeholder`.
================================================
FILE: .changeset/tangy-mirrors-hug.md
================================================
---
"@clack/prompts": minor
"@clack/core": minor
---
Adds `date` prompt with `format` support (YMD, MDY, DMY)
================================================
FILE: .changeset/tricky-states-tease.md
================================================
---
"@clack/prompts": patch
---
Fix `path` directory mode so pressing Enter with an existing directory `initialValue` submits that current directory instead of the first child option, and add regression coverage for immediate submit and child-directory navigation.
================================================
FILE: .editorconfig
================================================
root = true
[*]
indent_style = tab
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[*.{yml,json,yaml}]
indent_style = space
indent_size = 2
================================================
FILE: .gitattributes
================================================
* text=auto eol=lf
================================================
FILE: .github/ISSUE_TEMPLATE/bug_report.md
================================================
---
name: Bug report
about: Create a report to help us improve
title: '[Bug] '
labels: bug
assignees: ''
---
**Environment**
- OS: [e.g. macOS, Windows]
- Node Version: [e.g. v18.14.0]
- Package: [e.g. `@clack/prompts`, `@clack/core`]
- Package Version: [e.g. v0.2.0]
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Include a link to a minimal reproduction using [`node.new`](https://node.new/)
Steps to reproduce the behavior:
- Include reproduction steps
**Expected behavior**
A clear and concise description of what you expected to happen.
**Additional Information**
If applicable, add screenshots to help explain your problem.
================================================
FILE: .github/ISSUE_TEMPLATE/feature_request.md
================================================
---
name: Feature request
about: Suggest an idea for this project
title: '[Request]'
labels: enhancement
assignees: ''
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
Add any other context or screenshots about the feature request here.
================================================
FILE: .github/workflows/ci.yml
================================================
name: CI
on:
push:
branches:
- main
pull_request:
# Automatically cancel in-progress actions on the same branch
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
jobs:
scripts:
if: github.repository_owner == 'bombshell-dev'
uses: bombshell-dev/automation/.github/workflows/run.yml@main
secrets: inherit
with:
commands: >
[
"build",
"types",
"test",
"deps"
]
================================================
FILE: .github/workflows/detect-agent.yml
================================================
name: Detect Agent
on:
pull_request_target:
types: [opened]
workflow_dispatch: {}
permissions:
issues: write
pull-requests: write
jobs:
detect:
if: github.event_name != 'workflow_dispatch'
uses: bombshell-dev/automation/.github/workflows/detect-agent.yml@main
backfill:
if: github.event_name == 'workflow_dispatch'
uses: bombshell-dev/automation/.github/workflows/detect-agent-backfill.yml@main
================================================
FILE: .github/workflows/format.yml
================================================
name: Format
on:
workflow_dispatch:
push:
branches:
- main
jobs:
format:
if: github.repository_owner == 'bombshell-dev'
uses: bombshell-dev/automation/.github/workflows/format.yml@main
permissions:
contents: write
pull-requests: write
secrets: inherit
================================================
FILE: .github/workflows/issue.yml
================================================
name: issue
on:
issues:
types: [opened, edited, labeled, reopened]
jobs:
backlog:
if: github.event.action == 'edited' || github.event.action == 'labeled'
uses: bombshell-dev/automation/.github/workflows/move-issue-to-backlog.yml@main
secrets: inherit
project:
if: github.event.action == 'opened' || github.event.action == 'reopened'
uses: bombshell-dev/automation/.github/workflows/add-issue-to-project.yml@main
secrets: inherit
================================================
FILE: .github/workflows/preview.yml
================================================
name: Preview
on:
push:
branches:
- main
pull_request:
workflow_dispatch:
jobs:
preview:
if: github.repository_owner == 'bombshell-dev'
uses: bombshell-dev/automation/.github/workflows/preview.yml@main
permissions:
contents: write
pull-requests: write
id-token: write
with:
publish: "./packages/*"
template: "./examples/*"
================================================
FILE: .github/workflows/publish.yml
================================================
name: Publish
on:
push:
branches: [main, v0]
workflow_dispatch:
permissions:
id-token: write
contents: write
pull-requests: write
packages: write
jobs:
publish:
if: github.repository_owner == 'bombshell-dev'
uses: bombshell-dev/automation/.github/workflows/publish.yml@main
secrets: inherit
================================================
FILE: .github/workflows/require-allow-edits.yml
================================================
name: Require “Allow Edits”
on: [pull_request_target]
permissions:
contents: read
jobs:
_:
permissions:
pull-requests: read
name: "Require “Allow Edits”"
runs-on: ubuntu-latest
steps:
- uses: ljharb/require-allow-edits@v2
================================================
FILE: .gitignore
================================================
.DS_Store
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
.cache
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# JetBrains IDEA
.idea
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
================================================
FILE: .npmrc
================================================
# Important! Never install from registry even when new version is available
prefer-workspace-packages=true
link-workspace-packages=true
save-workspace-protocol=false
auto-install-peers=false
================================================
FILE: .nvmrc
================================================
20.18.1
================================================
FILE: .vscode/settings.json
================================================
{
"typescript.tsdk": "node_modules/typescript/lib"
}
================================================
FILE: CONTRIBUTING.md
================================================
# Contributing to Clack
Thank you for your interest in contributing to Clack! This document provides detailed instructions for setting up your development environment, navigating the codebase, making changes, and submitting contributions.
> [!Tip]
> **For new contributors:** Take a look at [https://github.com/firstcontributions/first-contributions](https://github.com/firstcontributions/first-contributions) for helpful information on contributing to open source projects.
## Getting Started
### Prerequisites
- [Node.js](https://nodejs.org/) (version specified in `.nvmrc`, currently v20.18.1)
- [pnpm](https://pnpm.io/) (version 9.14.2 or higher)
If you use [volta](https://volta.sh/) or [nvm](https://github.com/nvm-sh/nvm), the correct Node.js version will be automatically selected based on the project's `.nvmrc` file.
### Local Development Setup
1. **Fork the repository**:
- Visit [https://github.com/bombshell-dev/clack](https://github.com/bombshell-dev/clack)
- Click the "Fork" button in the top right
- Clone your fork to your local machine
```bash
git clone https://github.com/YOUR_USERNAME/clack.git
cd clack
```
2. **Set up the upstream remote**:
```bash
git remote add upstream https://github.com/bombshell-dev/clack.git
```
3. **Install dependencies**:
```bash
pnpm install
```
4. **Build the packages**:
```bash
pnpm build
```
5. **Run the development server**:
```bash
pnpm dev
```
### Using Clack Packages in Your Own Projects During Development
If you want to test changes to Clack packages in your own project, you can use pnpm's linking capabilities:
1. **Build the Clack packages locally first**:
```bash
# In the clack repository
cd /path/to/clack
pnpm build
```
2. **Link the packages to your project using one of these methods**:
**Method 1: Using pnpm link**
```bash
# In your project
cd /path/to/your-project
# Link @clack/core
pnpm link --global /path/to/clack/packages/core
# Link @clack/prompts
pnpm link --global /path/to/clack/packages/prompts
```
**Method 2: Using local path in package.json**
In your project's package.json, reference the local paths:
```json
{
"dependencies": {
"@clack/core": "file:/path/to/clack/packages/core",
"@clack/prompts": "file:/path/to/clack/packages/prompts"
}
}
```
Then run `pnpm install` in your project.
3. **Watch for changes** (optional):
```bash
# In the clack repository
cd /path/to/clack
pnpm build --watch
```
4. **Refresh after changes**:
If you're making changes to Clack while testing in your project, you'll need to rebuild Clack and potentially reinstall in your project:
```bash
# In the clack repository
cd /path/to/clack
pnpm build
# In your project (if using Method 2)
cd /path/to/your-project
pnpm install
```
With this setup, you can develop and test your changes to Clack within the context of your own project. This is especially useful for implementing new features like filtering.
## Repository Structure
Clack is organized as a monorepo using pnpm workspaces. Understanding the structure will help you navigate and contribute effectively:
```
clack/
├── .changeset/ # Changeset files for versioning
├── .github/ # GitHub workflows and templates
├── examples/ # Example implementations of Clack
├── packages/ # Core packages
│ ├── core/ # Unstyled primitives (@clack/core)
│ └── prompts/ # Ready-to-use components (@clack/prompts)
├── biome.json # Biome configuration
├── package.json # Root package.json
├── pnpm-workspace.yaml # Workspace configuration
└── tsconfig.json # TypeScript configuration
```
### Key Packages
1. **@clack/core** (`packages/core/`):
- Contains the unstyled, extensible primitives for building CLI applications
- The foundation layer that provides the core functionality
2. **@clack/prompts** (`packages/prompts/`):
- Built on top of @clack/core
- Provides beautiful, ready-to-use CLI prompt components
- What most users will interact with directly
### Examples
The `examples/` directory contains sample projects that demonstrate how to use Clack. Examining these examples is a great way to understand how the library works in practice.
## Development Workflow
### Common Commands
- **Build all packages**:
```bash
pnpm build
```
- **Start development environment**:
```bash
pnpm dev
```
- **Run tests**:
```bash
pnpm test
```
- **Lint code**:
```bash
pnpm lint
```
- **Format code**:
```bash
pnpm format
```
- **Type check**:
```bash
pnpm run types
```
- **Build stubbed packages** (for faster development):
```bash
pnpm stub
```
### Making Changes
1. **Create a new branch**:
```bash
git checkout -b my-feature-branch
```
2. **Implement your changes**:
- For bug fixes, start by reproducing the issue
- For new features, consider how it fits into the existing architecture
- Maintain type safety with TypeScript
- Add or update tests as necessary
3. **Run local verification**:
```bash
# Ensure everything builds
pnpm build
# Check formatting and lint issues
pnpm format
pnpm lint
# Verify type correctness
pnpm types
# Run tests
pnpm test
```
4. **Create a changeset** (for changes that need versioning):
```bash
pnpm changeset
```
- Follow the prompts to select which packages have changed
- Choose the appropriate semver increment (patch, minor, major)
- Write a concise but descriptive message explaining the changes
### Testing Your Changes
For testing changes to the core functionality:
1. **Use the examples**:
```bash
# Run an example to test your changes
pnpm --filter @example/changesets run start
```
2. **Create a test-specific example** (if needed):
- Add a new directory in the `examples/` folder
- Implement a minimal reproduction that uses your new feature/fix
- Run it with `pnpm --filter @example/your-example run start`
### Debugging Tips
When encountering issues during development:
1. **Check for errors in the console** - Clack will often output helpful error messages
2. **Review the API documentation** - Ensure you're using components and functions as intended
3. **Look at existing examples** - See how similar features are implemented
4. **Inspect the packages individually** - Sometimes issues are isolated to either `core` or `prompts`
## Pull Request Process
1. **Commit your changes**:
```bash
git add .
git commit -m "feat: add new awesome feature"
```
2. **Push to your fork**:
```bash
git push origin my-feature-branch
```
3. **Create a pull request**:
- Go to your fork on GitHub
- Click "New pull request"
- Select your branch and add a descriptive title
- Fill in the PR template with details about your changes
- Reference any related issues
4. **Wait for the automated checks**:
- GitHub Actions will run tests, type checking, and lint validation
- Fix any issues that arise
5. **Address review feedback**:
- Make requested changes
- Push additional commits to your branch
- The PR will update automatically
### PR Previews
Clack uses [pkg.pr.new](https://pkg.pr.new) (provided by [bolt.new](https://bolt.new)) to create continuous preview releases of all PRs. This simplifies testing and makes verifying bug fixes easier for our dependents.
The workflow that builds a preview version and adds instructions for installation as a comment on your PR should run automatically if you have contributed to Clack before. First-time contributors may need to wait until a maintainer manually approves GitHub Actions running on your PR.
## Release Process
Clack uses [Changesets](https://github.com/changesets/changesets) to manage versions and releases.
1. **For contributors**:
- Create a changeset file with your PR as described above
- Maintainers will handle the actual release process
2. **For maintainers**:
- Merging PRs with changesets will queue them for the next release
- When ready to release, merge the `[ci] release` PR
### Backporting to v0 Branch
Clack maintains a stable `v0` branch alongside the main development branch. For maintainers who need to backport changes:
1. Label PRs that should be backported with the `backport` label
2. After the PR is merged to `main`, manually cherry-pick the squashed commit into the `v0` branch:
```bash
# Ensure you have the latest v0 branch
git checkout v0
git pull upstream v0
# Cherry-pick the squashed commit from main
git cherry-pick
# Push the changes
git push upstream v0
```
3. CI is configured to run changesets from the `v0` branch, so release PRs will be opened automatically
The GitHub Actions are configured to cut releases from both the `main` and `v0` branches.
## Filing Issues
When reporting bugs or requesting features:
1. **Check existing issues** to avoid duplicates
2. **Use the issue templates** to provide all necessary information
3. **Be specific and clear** about what's happening and what you expect
4. **Provide reproduction steps** - ideally a minimal example that demonstrates the issue
5. **Include environment details** like OS, Node.js version, etc.
### Issue Types
When opening an issue, consider which category it falls into:
- **Bug Report**: Something isn't working as documented or expected
- **Feature Request**: A suggestion for new functionality
- **Documentation Issue**: Improvements or corrections to documentation
- **Performance Issue**: Problems with speed or resource usage
## Style Guide
We use [Biome](https://biomejs.dev/) for linting and formatting. Your code should follow these standards:
```bash
# To check formatting
pnpm format
# To lint and fix issues automatically where possible
pnpm lint
```
The project follows standard TypeScript practices. If you're new to TypeScript:
- Use precise types whenever possible
- Avoid `any` unless absolutely necessary
- Look at existing code for patterns to follow
### Commit Message Format
We follow conventional commits for commit messages:
- `feat:` - A new feature
- `fix:` - A bug fix
- `docs:` - Documentation changes
- `style:` - Changes that don't affect code functionality (formatting, etc)
- `refactor:` - Code changes that neither fix bugs nor add features
- `perf:` - Performance improvements
- `test:` - Adding or correcting tests
- `chore:` - Changes to the build process, tools, etc
## License
By contributing, you agree that your contributions will be licensed under the project's MIT License.
Thank you for taking the time to contribute to Clack! Feel free to join our community Discord at [bomb.sh/chat](https://bomb.sh/chat). It's a great place to connect with other project contributors—we're chill!
## Acknowledgments
This contributing guide was inspired by and adapted from the [Astro Contributing Manual](https://github.com/withastro/astro/blob/main/CONTRIBUTING.md). We appreciate their excellent documentation and open source practices.
================================================
FILE: README.md
================================================
stylish interactive prompts for JavaScript CLIs
@clack/prompts : opinionated, ready-to-use prompt components
@clack/core : headless, unstyled prompt primitives
================================================
FILE: biome.json
================================================
{
"$schema": "https://biomejs.dev/schemas/2.1.2/schema.json",
"vcs": { "enabled": false, "clientKind": "git", "useIgnoreFile": false },
"files": { "ignoreUnknown": false, "includes": ["**", "!**/dist/**"] },
"formatter": {
"enabled": true,
"useEditorconfig": true,
"formatWithErrors": false,
"indentStyle": "tab",
"indentWidth": 2,
"lineEnding": "lf",
"lineWidth": 100,
"attributePosition": "auto",
"bracketSpacing": true,
"includes": [
"**",
"!**/.github/workflows/**/*.yml",
"!**/.changeset/**/*.md",
"!**/pnpm-lock.yaml",
"!**/package.json"
]
},
"assist": { "actions": { "source": { "organizeImports": "on" } } },
"linter": {
"enabled": true,
"rules": { "recommended": true, "suspicious": { "noExplicitAny": "off" } }
},
"javascript": {
"formatter": {
"jsxQuoteStyle": "double",
"quoteProperties": "asNeeded",
"trailingCommas": "es5",
"semicolons": "always",
"arrowParentheses": "always",
"bracketSameLine": false,
"quoteStyle": "single",
"attributePosition": "auto",
"bracketSpacing": true
}
},
"overrides": [
{
"includes": ["**/*.json", "**/*.toml", "**/*.yml"],
"formatter": { "indentStyle": "space" }
}
]
}
================================================
FILE: build.preset.ts
================================================
import { definePreset } from 'unbuild';
// @see https://github.com/unjs/unbuild
export default definePreset({
clean: true,
declaration: 'node16',
sourcemap: true,
rollup: {
emitCJS: false,
inlineDependencies: true,
esbuild: {
minify: true,
},
},
});
================================================
FILE: examples/basic/autocomplete-multiselect.ts
================================================
import * as p from '@clack/prompts';
import color from 'picocolors';
/**
* Example demonstrating the integrated autocomplete multiselect component
* Which combines filtering and selection in a single interface
*/
async function main() {
console.clear();
p.intro(`${color.bgCyan(color.black(' Integrated Autocomplete Multiselect Example '))}`);
p.note(
`
${color.cyan('Filter and select multiple items in a single interface:')}
- ${color.yellow('Type')} to filter the list in real-time
- Use ${color.yellow('up/down arrows')} to navigate with improved stability
- Press ${color.yellow('Space')} to select/deselect the highlighted item ${color.green('(multiple selections allowed)')}
- Use ${color.yellow('Backspace')} to modify your filter text when searching for different options
- Press ${color.yellow('Enter')} when done selecting all items
- Press ${color.yellow('Ctrl+C')} to cancel
`,
'Instructions'
);
// Frameworks in alphabetical order
const frameworks = [
{ value: 'angular', label: 'Angular', hint: 'Frontend/UI' },
{ value: 'django', label: 'Django', hint: 'Python Backend' },
{ value: 'dotnet', label: '.NET Core', hint: 'C# Backend' },
{ value: 'electron', label: 'Electron', hint: 'Desktop' },
{ value: 'express', label: 'Express', hint: 'Node.js Backend' },
{ value: 'flask', label: 'Flask', hint: 'Python Backend' },
{ value: 'flutter', label: 'Flutter', hint: 'Mobile' },
{ value: 'laravel', label: 'Laravel', hint: 'PHP Backend' },
{ value: 'nestjs', label: 'NestJS', hint: 'Node.js Backend' },
{ value: 'nextjs', label: 'Next.js', hint: 'React Framework' },
{ value: 'nuxt', label: 'Nuxt.js', hint: 'Vue Framework' },
{ value: 'rails', label: 'Ruby on Rails', hint: 'Ruby Backend' },
{ value: 'react', label: 'React', hint: 'Frontend/UI' },
{ value: 'reactnative', label: 'React Native', hint: 'Mobile' },
{ value: 'spring', label: 'Spring Boot', hint: 'Java Backend' },
{ value: 'svelte', label: 'Svelte', hint: 'Frontend/UI' },
{ value: 'tauri', label: 'Tauri', hint: 'Desktop' },
{ value: 'vue', label: 'Vue.js', hint: 'Frontend/UI' },
];
// Use the new integrated autocompleteMultiselect component
const result = await p.autocompleteMultiselect({
message: 'Select frameworks (type to filter)',
options: frameworks,
placeholder: 'Type to filter...',
maxItems: 8,
});
if (p.isCancel(result)) {
p.cancel('Operation cancelled.');
process.exit(0);
}
// Type guard: if not a cancel symbol, result must be a string array
function isStringArray(value: unknown): value is string[] {
return Array.isArray(value) && value.every((item) => typeof item === 'string');
}
// We can now use the type guard to ensure type safety
if (!isStringArray(result)) {
throw new Error('Unexpected result type');
}
const selectedFrameworks = result;
// If no items selected, show a message
if (selectedFrameworks.length === 0) {
p.note('No frameworks were selected', 'Empty Selection');
process.exit(0);
}
// Display selected frameworks with detailed information
p.note(
`You selected ${color.green(selectedFrameworks.length)} frameworks:`,
'Selection Complete'
);
// Show each selected framework with its details
const selectedDetails = selectedFrameworks
.map((value) => {
const framework = frameworks.find((f) => f.value === value);
return framework
? `${color.cyan(framework.label)} ${color.dim(`- ${framework.hint}`)}`
: value;
})
.join('\n');
p.log.message(selectedDetails);
p.outro(`Successfully selected ${color.green(selectedFrameworks.length)} frameworks.`);
}
main().catch(console.error);
================================================
FILE: examples/basic/autocomplete.ts
================================================
import * as p from '@clack/prompts';
import color from 'picocolors';
async function main() {
console.clear();
p.intro(`${color.bgCyan(color.black(' Autocomplete Example '))}`);
p.note(
`
${color.cyan('This example demonstrates the type-ahead autocomplete feature:')}
- ${color.yellow('Type')} to filter the list in real-time
- Use ${color.yellow('up/down arrows')} to navigate the filtered results
- Press ${color.yellow('Enter')} to select the highlighted option
- Press ${color.yellow('Ctrl+C')} to cancel
`,
'Instructions'
);
const countries = [
{ value: 'us', label: 'United States', hint: 'NA' },
{ value: 'ca', label: 'Canada', hint: 'NA' },
{ value: 'mx', label: 'Mexico', hint: 'NA' },
{ value: 'br', label: 'Brazil', hint: 'SA' },
{ value: 'ar', label: 'Argentina', hint: 'SA' },
{ value: 'uk', label: 'United Kingdom', hint: 'EU' },
{ value: 'fr', label: 'France', hint: 'EU' },
{ value: 'de', label: 'Germany', hint: 'EU' },
{ value: 'it', label: 'Italy', hint: 'EU' },
{ value: 'es', label: 'Spain', hint: 'EU' },
{ value: 'pt', label: 'Portugal', hint: 'EU' },
{ value: 'ru', label: 'Russia', hint: 'EU/AS' },
{ value: 'cn', label: 'China', hint: 'AS' },
{ value: 'jp', label: 'Japan', hint: 'AS' },
{ value: 'in', label: 'India', hint: 'AS' },
{ value: 'kr', label: 'South Korea', hint: 'AS' },
{ value: 'au', label: 'Australia', hint: 'OC' },
{ value: 'nz', label: 'New Zealand', hint: 'OC' },
{ value: 'za', label: 'South Africa', hint: 'AF' },
{ value: 'eg', label: 'Egypt', hint: 'AF' },
];
const result = await p.autocomplete({
message: 'Select a country',
options: countries,
placeholder: 'Type to search countries...',
maxItems: 8,
});
if (p.isCancel(result)) {
p.cancel('Operation cancelled.');
process.exit(0);
}
const selected = countries.find((c) => c.value === result);
p.outro(`You selected: ${color.cyan(selected?.label)} (${color.yellow(selected?.hint)})`);
}
main().catch(console.error);
================================================
FILE: examples/basic/date.ts
================================================
import * as p from '@clack/prompts';
import color from 'picocolors';
async function main() {
const result = (await p.date({
message: color.magenta('Pick a date'),
format: 'YMD',
minDate: new Date('2026-01-01'),
maxDate: new Date('2026-12-31'),
})) as Date;
if (p.isCancel(result)) {
p.cancel('Operation cancelled.');
process.exit(0);
}
const fmt = (d: Date) => d.toISOString().slice(0, 10);
p.outro(`Selected date: ${color.cyan(fmt(result))}`);
}
main().catch(console.error);
================================================
FILE: examples/basic/default-value.ts
================================================
import * as p from '@clack/prompts';
import color from 'picocolors';
async function main() {
const defaultPath = 'my-project';
const result = await p.text({
message: 'Enter the directory to bootstrap the project',
placeholder: ` (hit Enter to use '${defaultPath}')`,
defaultValue: defaultPath,
validate: (value) => {
if (!value) {
return 'Directory is required';
}
if (value.includes(' ')) {
return 'Directory cannot contain spaces';
}
return undefined;
},
});
if (p.isCancel(result)) {
p.cancel('Operation cancelled.');
process.exit(0);
}
p.outro(`Let's bootstrap the project in ${color.cyan(result)}`);
}
main().catch(console.error);
================================================
FILE: examples/basic/index.ts
================================================
import { setTimeout } from 'node:timers/promises';
import * as p from '@clack/prompts';
import color from 'picocolors';
async function main() {
console.clear();
await setTimeout(1000);
p.updateSettings({
aliases: {
w: 'up',
s: 'down',
a: 'left',
d: 'right',
},
});
p.intro(`${color.bgCyan(color.black(' create-app '))}`);
const project = await p.group(
{
path: () =>
p.text({
message: 'Where should we create your project?',
placeholder: './sparkling-solid',
validate: (value) => {
if (!value) return 'Please enter a path.';
if (value[0] !== '.') return 'Please enter a relative path.';
},
}),
password: () =>
p.password({
message: 'Provide a password',
validate: (value) => {
if (!value) return 'Please enter a password.';
if (value.length < 5) return 'Password should have at least 5 characters.';
},
}),
type: ({ results }) =>
p.select({
message: `Pick a project type within "${results.path}"`,
initialValue: 'ts',
maxItems: 5,
options: [
{ value: 'ts', label: 'TypeScript' },
{ value: 'js', label: 'JavaScript' },
{ value: 'rust', label: 'Rust' },
{ value: 'go', label: 'Go' },
{ value: 'python', label: 'Python' },
{ value: 'coffee', label: 'CoffeeScript', hint: 'oh no' },
],
}),
tools: () =>
p.multiselect({
message: 'Select additional tools.',
initialValues: ['prettier', 'eslint'],
options: [
{ value: 'prettier', label: 'Prettier', hint: 'recommended' },
{ value: 'eslint', label: 'ESLint', hint: 'recommended' },
{ value: 'stylelint', label: 'Stylelint' },
{ value: 'gh-action', label: 'GitHub Action' },
],
}),
install: () =>
p.confirm({
message: 'Install dependencies?',
initialValue: false,
}),
},
{
onCancel: () => {
p.cancel('Operation cancelled.');
process.exit(0);
},
}
);
if (project.install) {
const s = p.spinner();
s.start('Installing via pnpm');
await setTimeout(2500);
s.stop('Installed via pnpm');
}
const nextSteps = `cd ${project.path} \n${project.install ? '' : 'pnpm install\n'}pnpm dev`;
p.note(nextSteps, 'Next steps.');
p.outro(`Problems? ${color.underline(color.cyan('https://example.com/issues'))}`);
}
main().catch(console.error);
================================================
FILE: examples/basic/package.json
================================================
{
"name": "@example/basic",
"private": true,
"version": "0.0.0",
"type": "module",
"dependencies": {
"@clack/prompts": "workspace:*",
"picocolors": "^1.0.0",
"jiti": "^1.17.0"
},
"scripts": {
"start": "jiti ./index.ts",
"stream": "jiti ./stream.ts",
"progress": "jiti ./progress.ts",
"spinner": "jiti ./spinner.ts",
"path": "jiti ./path.ts",
"date": "jiti ./date.ts",
"spinner-ci": "npx cross-env CI=\"true\" jiti ./spinner-ci.ts",
"spinner-timer": "jiti ./spinner-timer.ts",
"task-log": "jiti ./task-log.ts"
},
"devDependencies": {
"cross-env": "^7.0.3"
}
}
================================================
FILE: examples/basic/path.ts
================================================
import * as p from '@clack/prompts';
async function demo() {
p.intro('path start...');
const _path = await p.path({
message: 'Read file',
});
p.outro('path stop...');
}
void demo();
================================================
FILE: examples/basic/progress.ts
================================================
import { setTimeout } from 'node:timers/promises';
import type { ProgressResult } from '@clack/prompts';
import * as p from '@clack/prompts';
async function fakeProgress(progressbar: ProgressResult): Promise {
await setTimeout(1000);
for (const _i in Array(10).fill(1)) {
progressbar.advance();
await setTimeout(100 + Math.random() * 500);
}
}
async function demo() {
p.intro('progress start...');
const download = p.progress({ style: 'block', max: 10, size: 30 });
download.start('Downloading package');
await fakeProgress(download);
download.stop('Download completed');
const unarchive = p.progress({ style: 'heavy', max: 10, size: 30, indicator: undefined });
unarchive.start('Un-archiving');
await fakeProgress(unarchive);
unarchive.stop('Un-archiving completed');
const linking = p.progress({ style: 'light', max: 10, size: 30, indicator: 'timer' });
linking.start('Linking');
await fakeProgress(linking);
linking.stop('Package linked');
p.outro('progress stop...');
}
void demo();
================================================
FILE: examples/basic/spinner-cancel-advanced.ts
================================================
import { setTimeout as sleep } from 'node:timers/promises';
import * as p from '@clack/prompts';
async function main() {
p.intro('Advanced Spinner Cancellation Demo');
// First demonstrate a visible spinner with no user input needed
p.note('First, we will show a basic spinner (press CTRL+C to cancel)', 'Demo Part 1');
const demoSpinner = p.spinner({
indicator: 'dots',
onCancel: () => {
p.note('Initial spinner was cancelled with CTRL+C', 'Demo Cancelled');
},
});
demoSpinner.start('Loading demo resources');
// Update spinner message a few times to show activity
for (let i = 0; i < 5; i++) {
if (demoSpinner.isCancelled) break;
await sleep(1000);
demoSpinner.message(`Loading demo resources (${i + 1}/5)`);
}
if (!demoSpinner.isCancelled) {
demoSpinner.stop('Demo resources loaded successfully');
}
// Only continue with the rest of the demo if the initial spinner wasn't cancelled
if (!demoSpinner.isCancelled) {
// Stage 1: Get user input with multiselect
p.note("Now let's select some languages to process", 'Demo Part 2');
const languages = await p.multiselect({
message: 'Select programming languages to process:',
options: [
{ value: 'typescript', label: 'TypeScript' },
{ value: 'javascript', label: 'JavaScript' },
{ value: 'python', label: 'Python' },
{ value: 'rust', label: 'Rust' },
{ value: 'go', label: 'Go' },
],
required: true,
});
// Handle cancellation of the multiselect
if (p.isCancel(languages)) {
p.cancel('Operation cancelled during language selection.');
process.exit(0);
}
// Stage 2: Show a spinner that can be cancelled
const processSpinner = p.spinner({
indicator: 'dots',
onCancel: () => {
p.note(
'You cancelled during processing. Any completed work will be saved.',
'Processing Cancelled'
);
},
});
processSpinner.start('Starting to process selected languages...');
// Process each language with individual progress updates
let completedCount = 0;
const totalLanguages = languages.length;
for (const language of languages) {
// Skip the rest if cancelled
if (processSpinner.isCancelled) break;
// Update spinner message with current language
processSpinner.message(`Processing ${language} (${completedCount + 1}/${totalLanguages})`);
try {
// Simulate work - longer pause to give time to test CTRL+C
await sleep(2000);
completedCount++;
} catch (error) {
// Handle errors but continue if not cancelled
if (!processSpinner.isCancelled) {
p.note(`Error processing ${language}: ${error.message}`, 'Error');
}
}
}
// Stage 3: Handle completion based on cancellation status
if (!processSpinner.isCancelled) {
processSpinner.stop(`Processed ${completedCount}/${totalLanguages} languages successfully`);
// Stage 4: Additional user input based on processing results
if (completedCount > 0) {
const action = await p.select({
message: 'What would you like to do with the processed data?',
options: [
{ value: 'save', label: 'Save results', hint: 'Write to disk' },
{ value: 'share', label: 'Share results', hint: 'Upload to server' },
{ value: 'analyze', label: 'Further analysis', hint: 'Generate reports' },
],
});
if (p.isCancel(action)) {
p.cancel('Operation cancelled at final stage.');
process.exit(0);
}
// Stage 5: Final action with a timer spinner
p.note('Now demonstrating a timer-style spinner', 'Final Stage');
const finalSpinner = p.spinner({
indicator: 'timer', // Use timer indicator for variety
onCancel: () => {
p.note(
'Final operation was cancelled, but processing results are still valid.',
'Final Stage Cancelled'
);
},
});
finalSpinner.start(`Performing ${action} operation...`);
try {
// Simulate final action with incremental updates
for (let i = 0; i < 3; i++) {
if (finalSpinner.isCancelled) break;
await sleep(1500);
finalSpinner.message(`Performing ${action} operation... Step ${i + 1}/3`);
}
if (!finalSpinner.isCancelled) {
finalSpinner.stop(`${action} operation completed successfully`);
}
} catch (error) {
if (!finalSpinner.isCancelled) {
finalSpinner.stop(`Error during ${action}: ${error.message}`);
}
}
}
}
}
p.outro('Advanced demo completed. Thanks for trying out the spinner cancellation features!');
}
main().catch((error) => {
console.error('Unexpected error:', error);
process.exit(1);
});
================================================
FILE: examples/basic/spinner-cancel.ts
================================================
import * as p from '@clack/prompts';
p.intro('Spinner with cancellation detection');
// Example 1: Using onCancel callback
const spin1 = p.spinner({
indicator: 'dots',
onCancel: () => {
p.note('You cancelled the spinner with CTRL-C!', 'Callback detected');
},
});
spin1.start('Press CTRL-C to cancel this spinner (using callback)');
// Sleep for 10 seconds, allowing time for user to press CTRL-C
await sleep(10000).then(() => {
// Only show success message if not cancelled
if (!spin1.isCancelled) {
spin1.stop('Spinner completed without cancellation');
}
});
// Example 2: Checking the isCancelled property
p.note('Starting second example...', 'Example 2');
const spin2 = p.spinner({ indicator: 'timer' });
spin2.start('Press CTRL-C to cancel this spinner (polling isCancelled)');
await sleep(10000).then(() => {
if (spin2.isCancelled) {
p.note('Spinner was cancelled by the user!', 'Property check');
} else {
spin2.stop('Spinner completed without cancellation');
}
});
p.outro('Example completed');
// Helper function
function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
================================================
FILE: examples/basic/spinner-ci.ts
================================================
/**
* This example addresses a issue reported in GitHub Actions where `spinner` was excessively writing messages,
* leading to confusion and cluttered output.
* To enhance the CI workflow and provide a smoother experience,
* the following changes have been made only for CI environment:
* - Messages will now only be written when a `spinner` method is called and the message updated, preventing unnecessary message repetition.
* - There will be no loading dots animation, instead it will be always `...`
* - Instead of erase the previous message, action that is blocked during CI, it will just write a new one.
*
* Issue: https://github.com/bombshell-dev/clack/issues/168
*/
import * as p from '@clack/prompts';
const s = p.spinner();
let progress = 0;
let counter = 0;
let loop: NodeJS.Timer;
p.intro('Running spinner in CI environment');
s.start('spinner.start');
new Promise((resolve) => {
loop = setInterval(() => {
if (progress % 1000 === 0) {
counter++;
}
progress += 100;
s.message(`spinner.message [${counter}]`);
if (counter > 6) {
clearInterval(loop);
resolve(true);
}
}, 100);
}).then(() => {
s.stop('spinner.stop');
p.outro('Done');
});
================================================
FILE: examples/basic/spinner-timer.ts
================================================
import * as p from '@clack/prompts';
p.intro('spinner start...');
async function main() {
const spin = p.spinner({ indicator: 'timer' });
spin.start('First spinner');
await sleep(3_000);
spin.stop('Done first spinner');
spin.start('Second spinner');
await sleep(5_000);
spin.stop('Done second spinner');
p.outro('spinner stop.');
}
function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
main();
================================================
FILE: examples/basic/spinner.ts
================================================
import * as p from '@clack/prompts';
p.intro('spinner start...');
const spin = p.spinner();
const total = 6000;
let progress = 0;
spin.start();
new Promise((resolve) => {
const timer = setInterval(() => {
progress = Math.min(total, progress + 100);
if (progress >= total) {
clearInterval(timer);
resolve(true);
}
spin.message(`Loading packages [${progress}/${total}]`); // <===
}, 100);
}).then(() => {
spin.stop('Done');
p.outro('spinner stop...');
});
================================================
FILE: examples/basic/stream.ts
================================================
import { setTimeout } from 'node:timers/promises';
import * as p from '@clack/prompts';
import color from 'picocolors';
async function main() {
console.clear();
await setTimeout(1000);
p.intro(`${color.bgCyan(color.black(' create-app '))}`);
await p.stream.step(
(async function* () {
for (const line of lorem) {
for (const word of line.split(' ')) {
yield word;
yield ' ';
await setTimeout(200);
}
yield '\n';
if (line !== lorem.at(-1)) {
await setTimeout(1000);
}
}
})()
);
p.outro(`Problems? ${color.underline(color.cyan('https://example.com/issues'))}`);
}
const lorem = [
'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.',
'Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.',
];
main().catch(console.error);
================================================
FILE: examples/basic/task-log.ts
================================================
import { setTimeout } from 'node:timers/promises';
import * as p from '@clack/prompts';
async function main() {
p.intro('task log start...');
const log = p.taskLog({
title: 'Running npm install',
limit: 5,
});
for await (const line of fakeCommand()) {
log.message(line);
}
log.success('Done!');
p.outro('task log stop...');
}
async function* fakeCommand() {
for (let i = 0; i < 100; i++) {
yield `line \x1b[32m${i}\x1b[39m...`;
await setTimeout(80);
}
}
main();
================================================
FILE: examples/basic/text-validation.ts
================================================
import { setTimeout } from 'node:timers/promises';
import { isCancel, note, text } from '@clack/prompts';
async function main() {
console.clear();
// Example demonstrating the issue with initial value validation
const name = await text({
message: 'Enter your name (letters and spaces only)',
initialValue: 'John123', // Invalid initial value with numbers
validate: (value) => {
if (!value || !/^[a-zA-Z\s]+$/.test(value)) return 'Name can only contain letters and spaces';
return undefined;
},
});
if (!isCancel(name)) {
note(`Valid name: ${name}`, 'Success');
}
await setTimeout(1000);
// Example with a valid initial value for comparison
const validName = await text({
message: 'Enter another name (letters and spaces only)',
initialValue: 'John Doe', // Valid initial value
validate: (value) => {
if (!value || !/^[a-zA-Z\s]+$/.test(value)) return 'Name can only contain letters and spaces';
return undefined;
},
});
if (!isCancel(validName)) {
note(`Valid name: ${validName}`, 'Success');
}
await setTimeout(1000);
}
main().catch(console.error);
================================================
FILE: examples/basic/tsconfig.json
================================================
{
"extends": "../../tsconfig.json"
}
================================================
FILE: examples/changesets/index.ts
================================================
import { setTimeout } from 'node:timers/promises';
import * as p from '@clack/prompts';
import color from 'picocolors';
function onCancel() {
p.cancel('Operation cancelled.');
process.exit(0);
}
async function main() {
console.clear();
await setTimeout(1000);
p.intro(`${color.bgCyan(color.black(' changesets '))}`);
const _changeset = await p.group(
{
packages: () =>
p.groupMultiselect({
message: 'Which packages would you like to include?',
options: {
'changed packages': [
{ value: '@scope/a' },
{ value: '@scope/b' },
{ value: '@scope/c' },
],
'unchanged packages': [
{ value: '@scope/x' },
{ value: '@scope/y' },
{ value: '@scope/z' },
],
},
}),
major: ({ results }) => {
const packages = results.packages ?? [];
return p.multiselect({
message: `Which packages should have a ${color.red('major')} bump?`,
options: packages.map((value) => ({ value })),
required: false,
});
},
minor: ({ results }) => {
const packages = results.packages ?? [];
const major = Array.isArray(results.major) ? results.major : [];
const possiblePackages = packages.filter((pkg) => !major.includes(pkg));
if (possiblePackages.length === 0) return;
return p.multiselect({
message: `Which packages should have a ${color.yellow('minor')} bump?`,
options: possiblePackages.map((value) => ({ value })),
required: false,
});
},
patch: async ({ results }) => {
const packages = results.packages ?? [];
const major = Array.isArray(results.major) ? results.major : [];
const minor = Array.isArray(results.minor) ? results.minor : [];
const possiblePackages = packages.filter(
(pkg) => !major.includes(pkg) && !minor.includes(pkg)
);
if (possiblePackages.length === 0) return;
const note = possiblePackages.join(color.dim(', '));
p.log.step(`These packages will have a ${color.green('patch')} bump.\n${color.dim(note)}`);
return possiblePackages;
},
},
{
onCancel,
}
);
const message = await p.text({
placeholder: 'Summary',
message: 'Please enter a summary for this change',
});
if (p.isCancel(message)) {
return onCancel();
}
p.outro(`Changeset added! ${color.underline(color.cyan('.changeset/orange-crabs-sing.md'))}`);
}
main().catch(console.error);
================================================
FILE: examples/changesets/package.json
================================================
{
"name": "@example/changesets",
"private": true,
"version": "0.0.0",
"type": "module",
"dependencies": {
"jiti": "^1.17.0",
"@clack/prompts": "workspace:*",
"picocolors": "^1.0.0"
},
"scripts": {
"start": "jiti ./index.ts"
},
"devDependencies": {}
}
================================================
FILE: examples/changesets/tsconfig.json
================================================
{
"extends": "../../tsconfig.json"
}
================================================
FILE: knip.json
================================================
{
"workspaces": {
".": {
"ignore": ["build.preset.ts"]
},
"examples/*": {
"entry": "*.ts!",
"project": "*.ts"
},
"packages/*": {
"entry": "src/index.ts!",
"project": ["src/**/*.ts!", "test/**/*.ts"]
}
}
}
================================================
FILE: package.json
================================================
{
"name": "@clack/root",
"private": true,
"type": "module",
"scripts": {
"stub": "pnpm -r run build --stub",
"build": "pnpm --filter \"@clack/*\" run build",
"start": "pnpm run dev",
"dev": "pnpm --filter @example/changesets run start",
"format": "biome check --write",
"lint": "biome lint --write --unsafe",
"types": "tsc --noEmit",
"deps": "pnpm exec knip --production",
"test": "pnpm --color -r run test",
"pretest": "pnpm run build"
},
"devDependencies": {
"@biomejs/biome": "^2.1.2",
"@changesets/cli": "^2.29.5",
"@types/node": "^24.1.0",
"jsr": "^0.13.4",
"knip": "^5.62.0",
"typescript": "^5.8.3",
"unbuild": "^3.6.0"
},
"packageManager": "pnpm@9.14.2",
"volta": {
"node": "20.18.1"
}
}
================================================
FILE: packages/core/CHANGELOG.md
================================================
# @clack/core
## 1.1.0
### Minor Changes
- e3333fb: Replaces `picocolors` with Node.js built-in `styleText`.
## 1.0.1
### Patch Changes
- 6404dc1: Disallows selection of `disabled` options in autocomplete.
- 2533180: Updates the documentation to mention `userInputWithCursor` when using the `TextPrompt` primitive.
## 1.0.0
### Major Changes
- c713fd5: The package is now distributed as ESM-only. In `v0` releases, the package was dual-published as CJS and ESM.
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).
### Minor Changes
- 7bc3301: Prompts now have a `userInput` stored separately from their `value`.
- 2837845: Adds suggestion and path prompts
- 729bbb6: 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.
This update also improves the architecture by exposing the core settings to the prompts package, enabling more consistent default message handling across the codebase.
```ts
// Per-instance customization
const spinner = prompts.spinner({
cancelMessage: "Operación cancelada", // "Operation cancelled" in Spanish
errorMessage: "Se produjo un error", // "An error occurred" in Spanish
});
// Global customization via updateSettings
prompts.updateSettings({
messages: {
cancel: "Operación cancelada", // "Operation cancelled" in Spanish
error: "Se produjo un error", // "An error occurred" in Spanish
},
});
// Settings can now be accessed directly
console.log(prompts.settings.messages.cancel); // "Operación cancelada"
// Direct options take priority over global settings
const spinner = prompts.spinner({
cancelMessage: "Cancelled", // This will be used instead of the global setting
});
```
- 55645c2: Support wrapping autocomplete and select prompts.
- f2c2b89: Adds `AutocompletePrompt` to core with comprehensive tests and implement both `autocomplete` and `autocomplete-multiselect` components in prompts package.
- df4eea1: Remove `suggestion` prompt and change `path` prompt to be an autocomplete prompt.
- 1604f97: Add `clearOnError` option to password prompt to automatically clear input when validation fails
### Patch Changes
- 0718b07: fix: export `*Options` types for prompts.
- bfe0dd3: Prevents placeholder from being used as input value in text prompts
- 6868c1c: 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.
- 7df841d: Removed all trailing space in prompt output and fixed various padding rendering bugs.
- a4f5034: 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.
- b103ad3: Allow disabled options in multi-select and select prompts.
- 71b5029: Add missing nullish checks around values.
- a36292b: Fix "TTY initialization failed: uv_tty_init returned EBADF (bad file descriptor)" error happening on Windows for non-tty terminals.
- 1a45f93: Switched from wrap-ansi to fast-wrap-ansi
- 4ba2d78: Support short terminal windows when re-rendering by accounting for off-screen lines
- 34f52fe: Validates initial values immediately when using text prompts with initialValue and validate props.
- 94fee2a: Changes `placeholder` to be a visual hint rather than a tabbable value.
- 4f6b3c2: Set initial values of auto complete prompt to first option when multiple is false.
- 8ead5d3: Avoid passing initial values to core when using auto complete prompt
- acc4c3a: Add a new `withGuide` option to all prompts to disable the default clack border
- 68dbf9b: select-key: Fixed wrapping and added new `caseSensitive` option
- 2310b43: Allow custom writables as output stream.
- d98e033: add invert selection for multiselect prompt
## 0.4.1
### Patch Changes
- 8093f3c: Adds `Error` support to the `validate` function
- e5ba09a: Fixes a cursor display bug in terminals that do not support the "hidden" escape sequence. See [Issue #127](https://github.com/bombshell-dev/clack/issues/127).
- 8cba8e3: Fixes a rendering bug with cursor positions for `TextPrompt`
## 0.4.0
### Minor Changes
- a83d2f8: Adds a new `updateSettings()` function to support new global keybindings.
`updateSettings()` accepts an `aliases` object that maps custom keys to an action (`up | down | left | right | space | enter | cancel`).
```ts
import { updateSettings } from "@clack/core";
// Support custom keybindings
updateSettings({
aliases: {
w: "up",
a: "left",
s: "down",
d: "right",
},
});
```
> [!WARNING]
> In order to enforce consistent, user-friendly defaults across the ecosystem, `updateSettings` does not support disabling Clack's default keybindings.
- 801246b: Adds a new `signal` option to support programmatic prompt cancellation with an [abort controller](https://kettanaito.com/blog/dont-sleep-on-abort-controller).
- a83d2f8: Updates default keybindings to support Vim motion shortcuts and map the `escape` key to cancel (`ctrl+c`).
| alias | action |
| ----- | ------ |
| `k` | up |
| `l` | right |
| `j` | down |
| `h` | left |
| `esc` | cancel |
### Patch Changes
- 51e12bc: Improves types for events and interaction states.
## 0.3.5
### Patch Changes
- 4845f4f: Fixes a bug which kept the terminal cursor hidden after a prompt is cancelled
- d7b2fb9: Adds missing `LICENSE` file. Since the `package.json` file has always included `"license": "MIT"`, please consider this a licensing clarification rather than a licensing change.
## 0.3.4
### Patch Changes
- a04e418: fix(@clack/core): keyboard input not working after await in spinner
- 4f6fcf5: feat(@clack/core): allow tab completion for placeholders
## 0.3.3
### Patch Changes
- cd79076: fix: restore raw mode on unblock
## 0.3.2
### Patch Changes
- c96eda5: Enable hard line-wrapping behavior for long words without spaces
## 0.3.1
### Patch Changes
- 58a1df1: Fix line duplication bug by automatically wrapping prompts to `process.stdout.columns`
## 0.3.0
### Minor Changes
- 8a4a12f: Add `GroupMultiSelect` prompt
### Patch Changes
- 8a4a12f: add `groupMultiselect` prompt
## 0.2.1
### Patch Changes
- ec812b6: fix `readline` hang on Windows
## 0.2.0
### Minor Changes
- d74dd05: Adds a `selectKey` prompt type
- 54c1bc3: **Breaking Change** `multiselect` has renamed `initialValue` to `initialValues`
## 0.1.9
### Patch Changes
- 1251132: Multiselect: return `Value[]` instead of `Option[]`.
- 8994382: Add a password prompt to `@clack/prompts`
## 0.1.8
### Patch Changes
- d96071c: Don't mutate `initialValue` in `multiselect`, fix parameter type for `validate()`.
Credits to @banjo for the bug report and initial PR!
## 0.1.7
### Patch Changes
- 6d9e675: Add support for neovim cursor motion (`hjkl`)
Thanks [@esau-morais](https://github.com/esau-morais) for the assist!
## 0.1.6
### Patch Changes
- 7fb5375: Adds a new `defaultValue` option to the text prompt, removes automatic usage of the placeholder value.
## 0.1.5
### Patch Changes
- de1314e: Support `required` option for multi-select
## 0.1.4
### Patch Changes
- ca77da1: Fix multiselect initial value logic
- 8aed606: Fix `MaxListenersExceededWarning` by detaching `stdin` listeners on close
## 0.1.3
### Patch Changes
- a99c458: Support `initialValue` option for text prompt
## 0.1.2
### Patch Changes
- Allow isCancel to type guard any unknown value
- 7dcad8f: Allow placeholder to be passed to TextPrompt
- 2242f13: Fix multiselect returning undefined
- b1341d6: Improved placeholder handling
## 0.1.1
### Patch Changes
- 4be7dbf: Ensure raw mode is unset on submit
- b480679: Preserve value if validation fails
## 0.1.0
### Minor Changes
- 7015ec9: Create new prompt: multi-select
## 0.0.12
### Patch Changes
- 9d371c3: Fix rendering bug when using y/n to confirm
## 0.0.11
### Patch Changes
- 441d5b7: fix select return undefined
- d20ef2a: Update keywords, URLs
- fe13c2f: fix cursor missing after submit
## 0.0.10
### Patch Changes
- a0cb382: Add `main` entrypoint
## 0.0.9
### Patch Changes
- Fix node@16 issue (cannot read "createInterface" of undefined)
## 0.0.8
### Patch Changes
- a4b5e13: Bug fixes, exposes `block` utility
## 0.0.7
### Patch Changes
- Fix cursor bug
## 0.0.6
### Patch Changes
- Fix error with character check
## 0.0.5
### Patch Changes
- 491f9e0: update readme
## 0.0.4
### Patch Changes
- 7372d5c: Fix bug with line deletion
## 0.0.3
### Patch Changes
- 5605d28: Do not bundle dependencies (take II)
## 0.0.2
### Patch Changes
- 2ee67cb: don't bundle deps
## 0.0.1
### Patch Changes
- 306598e: Initial publish, still WIP
================================================
FILE: packages/core/LICENSE
================================================
MIT License
Copyright (c) Nate Moore
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:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
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.
================================================
FILE: packages/core/README.md
================================================
# `@clack/core`
Clack contains low-level primitives for implementing your own command-line applications.
Currently, `TextPrompt`, `SelectPrompt`, and `ConfirmPrompt` are exposed as well as the base `Prompt` class.
Each `Prompt` accepts a `render` function.
```js
import { TextPrompt, isCancel } from '@clack/core';
const p = new TextPrompt({
render() {
return `What's your name?\n${this.userInputWithCursor}`;
},
});
const name = await p.prompt();
if (isCancel(name)) {
process.exit(0);
}
```
================================================
FILE: packages/core/build.config.ts
================================================
import { defineBuildConfig } from 'unbuild';
// @see https://github.com/unjs/unbuild
export default defineBuildConfig({
preset: '../../build.preset',
entries: ['src/index'],
});
================================================
FILE: packages/core/package.json
================================================
{
"name": "@clack/core",
"version": "1.1.0",
"type": "module",
"main": "./dist/index.mjs",
"module": "./dist/index.mjs",
"exports": {
".": {
"types": "./dist/index.d.mts",
"default": "./dist/index.mjs"
},
"./package.json": "./package.json"
},
"types": "./dist/index.d.mts",
"repository": {
"type": "git",
"url": "git+https://github.com/bombshell-dev/clack.git",
"directory": "packages/core"
},
"bugs": {
"url": "https://github.com/bombshell-dev/clack/issues"
},
"homepage": "https://github.com/bombshell-dev/clack/tree/main/packages/core#readme",
"files": [
"dist",
"CHANGELOG.md"
],
"keywords": [
"ask",
"clack",
"cli",
"command-line",
"command",
"input",
"interact",
"interface",
"menu",
"prompt",
"prompts",
"stdin",
"ui"
],
"author": {
"name": "Nate Moore",
"email": "nate@natemoo.re",
"url": "https://twitter.com/n_moore"
},
"license": "MIT",
"packageManager": "pnpm@9.14.2",
"scripts": {
"build": "unbuild",
"prepack": "pnpm build",
"test": "vitest run"
},
"dependencies": {
"fast-wrap-ansi": "^0.1.3",
"sisteransi": "^1.0.5"
},
"devDependencies": {
"vitest": "^3.2.4"
}
}
================================================
FILE: packages/core/src/index.ts
================================================
export type { AutocompleteOptions } from './prompts/autocomplete.js';
export { default as AutocompletePrompt } from './prompts/autocomplete.js';
export type { ConfirmOptions } from './prompts/confirm.js';
export { default as ConfirmPrompt } from './prompts/confirm.js';
export type { DateFormat, DateOptions, DateParts } from './prompts/date.js';
export { default as DatePrompt } from './prompts/date.js';
export type { GroupMultiSelectOptions } from './prompts/group-multiselect.js';
export { default as GroupMultiSelectPrompt } from './prompts/group-multiselect.js';
export type { MultiSelectOptions } from './prompts/multi-select.js';
export { default as MultiSelectPrompt } from './prompts/multi-select.js';
export type { PasswordOptions } from './prompts/password.js';
export { default as PasswordPrompt } from './prompts/password.js';
export type { PromptOptions } from './prompts/prompt.js';
export { default as Prompt } from './prompts/prompt.js';
export type { SelectOptions } from './prompts/select.js';
export { default as SelectPrompt } from './prompts/select.js';
export type { SelectKeyOptions } from './prompts/select-key.js';
export { default as SelectKeyPrompt } from './prompts/select-key.js';
export type { TextOptions } from './prompts/text.js';
export { default as TextPrompt } from './prompts/text.js';
export type { ClackState as State } from './types.js';
export { block, getColumns, getRows, isCancel, wrapTextWithPrefix } from './utils/index.js';
export type { ClackSettings } from './utils/settings.js';
export { settings, updateSettings } from './utils/settings.js';
================================================
FILE: packages/core/src/prompts/autocomplete.ts
================================================
import type { Key } from 'node:readline';
import { styleText } from 'node:util';
import { findCursor } from '../utils/cursor.js';
import Prompt, { type PromptOptions } from './prompt.js';
interface OptionLike {
value: unknown;
label?: string;
disabled?: boolean;
}
type FilterFunction = (search: string, opt: T) => boolean;
function getCursorForValue(
selected: T['value'] | undefined,
items: T[]
): number {
if (selected === undefined) {
return 0;
}
const currLength = items.length;
// If filtering changed the available options, update cursor
if (currLength === 0) {
return 0;
}
// Try to maintain the same selected item
const index = items.findIndex((item) => item.value === selected);
return index !== -1 ? index : 0;
}
function defaultFilter(input: string, option: T): boolean {
const label = option.label ?? String(option.value);
return label.toLowerCase().includes(input.toLowerCase());
}
function normalisedValue(multiple: boolean, values: T[] | undefined): T | T[] | undefined {
if (!values) {
return undefined;
}
if (multiple) {
return values;
}
return values[0];
}
export interface AutocompleteOptions
extends PromptOptions> {
options: T[] | ((this: AutocompletePrompt) => T[]);
filter?: FilterFunction;
multiple?: boolean;
/**
* When set (non-empty), pressing Tab with no input fills the field with this value
* and runs the normal filter/selection logic so the user can confirm with Enter.
* Tab only fills the input when the placeholder matches at least one option under
* the prompt's filter (so the value remains selectable).
*/
placeholder?: string;
}
export default class AutocompletePrompt extends Prompt<
T['value'] | T['value'][]
> {
filteredOptions: T[];
multiple: boolean;
isNavigating = false;
selectedValues: Array = [];
focusedValue: T['value'] | undefined;
#cursor = 0;
#lastUserInput = '';
#filterFn: FilterFunction;
#options: T[] | (() => T[]);
#placeholder: string | undefined;
get cursor(): number {
return this.#cursor;
}
get userInputWithCursor() {
if (!this.userInput) {
return styleText(['inverse', 'hidden'], '_');
}
if (this._cursor >= this.userInput.length) {
return `${this.userInput}█`;
}
const s1 = this.userInput.slice(0, this._cursor);
const [s2, ...s3] = this.userInput.slice(this._cursor);
return `${s1}${styleText('inverse', s2)}${s3.join('')}`;
}
get options(): T[] {
if (typeof this.#options === 'function') {
return this.#options();
}
return this.#options;
}
constructor(opts: AutocompleteOptions) {
super(opts);
this.#options = opts.options;
this.#placeholder = opts.placeholder;
const options = this.options;
this.filteredOptions = [...options];
this.multiple = opts.multiple === true;
this.#filterFn = opts.filter ?? defaultFilter;
let initialValues: unknown[] | undefined;
if (opts.initialValue && Array.isArray(opts.initialValue)) {
if (this.multiple) {
initialValues = opts.initialValue;
} else {
initialValues = opts.initialValue.slice(0, 1);
}
} else {
if (!this.multiple && this.options.length > 0) {
initialValues = [this.options[0].value];
}
}
if (initialValues) {
for (const selectedValue of initialValues) {
const selectedIndex = options.findIndex((opt) => opt.value === selectedValue);
if (selectedIndex !== -1) {
this.toggleSelected(selectedValue);
this.#cursor = selectedIndex;
}
}
}
this.focusedValue = this.options[this.#cursor]?.value;
this.on('key', (char, key) => this.#onKey(char, key));
this.on('userInput', (value) => this.#onUserInputChanged(value));
}
protected override _isActionKey(char: string | undefined, key: Key): boolean {
return (
char === '\t' ||
(this.multiple &&
this.isNavigating &&
key.name === 'space' &&
char !== undefined &&
char !== '')
);
}
#onKey(_char: string | undefined, key: Key): void {
const isUpKey = key.name === 'up';
const isDownKey = key.name === 'down';
const isReturnKey = key.name === 'return';
// Tab with empty input and placeholder: fill input with placeholder to trigger autocomplete
// Only when the placeholder matches at least one (non-disabled) option so the value remains selectable
const isEmptyOrOnlyTab = this.userInput === '' || this.userInput === '\t';
const placeholder = this.#placeholder;
const options = this.options;
const placeholderMatchesOption =
placeholder !== undefined &&
placeholder !== '' &&
options.some((opt) => !opt.disabled && this.#filterFn(placeholder, opt));
if (key.name === 'tab' && isEmptyOrOnlyTab && placeholderMatchesOption) {
if (this.userInput === '\t') {
this._clearUserInput();
}
this._setUserInput(placeholder, true);
this.isNavigating = false;
return;
}
// Start navigation mode with up/down arrows
if (isUpKey || isDownKey) {
this.#cursor = findCursor(this.#cursor, isUpKey ? -1 : 1, this.filteredOptions);
this.focusedValue = this.filteredOptions[this.#cursor]?.value;
if (!this.multiple) {
this.selectedValues = [this.focusedValue];
}
this.isNavigating = true;
} else if (isReturnKey) {
this.value = normalisedValue(this.multiple, this.selectedValues);
} else {
if (this.multiple) {
if (
this.focusedValue !== undefined &&
(key.name === 'tab' || (this.isNavigating && key.name === 'space'))
) {
this.toggleSelected(this.focusedValue);
} else {
this.isNavigating = false;
}
} else {
if (this.focusedValue) {
this.selectedValues = [this.focusedValue];
}
this.isNavigating = false;
}
}
}
deselectAll() {
this.selectedValues = [];
}
toggleSelected(value: T['value']) {
if (this.filteredOptions.length === 0) {
return;
}
if (this.multiple) {
if (this.selectedValues.includes(value)) {
this.selectedValues = this.selectedValues.filter((v) => v !== value);
} else {
this.selectedValues = [...this.selectedValues, value];
}
} else {
this.selectedValues = [value];
}
}
#onUserInputChanged(value: string): void {
if (value !== this.#lastUserInput) {
this.#lastUserInput = value;
const options = this.options;
if (value) {
this.filteredOptions = options.filter((opt) => this.#filterFn(value, opt));
} else {
this.filteredOptions = [...options];
}
const valueCursor = getCursorForValue(this.focusedValue, this.filteredOptions);
this.#cursor = findCursor(valueCursor, 0, this.filteredOptions);
const focusedOption = this.filteredOptions[this.#cursor];
if (focusedOption && !focusedOption.disabled) {
this.focusedValue = focusedOption.value;
} else {
this.focusedValue = undefined;
}
if (!this.multiple) {
if (this.focusedValue !== undefined) {
this.toggleSelected(this.focusedValue);
} else {
this.deselectAll();
}
}
}
}
}
================================================
FILE: packages/core/src/prompts/confirm.ts
================================================
import { cursor } from 'sisteransi';
import Prompt, { type PromptOptions } from './prompt.js';
export interface ConfirmOptions extends PromptOptions {
active: string;
inactive: string;
initialValue?: boolean;
}
export default class ConfirmPrompt extends Prompt {
get cursor() {
return this.value ? 0 : 1;
}
private get _value() {
return this.cursor === 0;
}
constructor(opts: ConfirmOptions) {
super(opts, false);
this.value = !!opts.initialValue;
this.on('userInput', () => {
this.value = this._value;
});
this.on('confirm', (confirm) => {
this.output.write(cursor.move(0, -1));
this.value = confirm;
this.state = 'submit';
this.close();
});
this.on('cursor', () => {
this.value = !this.value;
});
}
}
================================================
FILE: packages/core/src/prompts/date.ts
================================================
import type { Key } from 'node:readline';
import { settings } from '../utils/settings.js';
import Prompt, { type PromptOptions } from './prompt.js';
interface SegmentConfig {
type: 'year' | 'month' | 'day';
len: number;
}
export interface DateParts {
year: string;
month: string;
day: string;
}
export type DateFormat = 'YMD' | 'MDY' | 'DMY';
const SEGMENTS: Record = {
Y: { type: 'year', len: 4 },
M: { type: 'month', len: 2 },
D: { type: 'day', len: 2 },
} as const;
function segmentsFor(fmt: DateFormat): SegmentConfig[] {
return [...fmt].map((c) => SEGMENTS[c as keyof typeof SEGMENTS]);
}
function detectLocaleFormat(locale?: string): { segments: SegmentConfig[]; separator: string } {
const fmt = new Intl.DateTimeFormat(locale, {
year: 'numeric',
month: '2-digit',
day: '2-digit',
});
const parts = fmt.formatToParts(new Date(2000, 0, 15));
const segments: SegmentConfig[] = [];
let separator = '/';
for (const p of parts) {
if (p.type === 'literal') {
separator = p.value.trim() || p.value;
} else if (p.type === 'year' || p.type === 'month' || p.type === 'day') {
segments.push({ type: p.type, len: p.type === 'year' ? 4 : 2 });
}
}
return { segments, separator };
}
/** Parse string segment values to numbers, treating blanks as 0 */
function parseSegmentToNum(s: string): number {
return Number.parseInt((s || '0').replace(/_/g, '0'), 10) || 0;
}
function parse(parts: DateParts): { year: number; month: number; day: number } {
return {
year: parseSegmentToNum(parts.year),
month: parseSegmentToNum(parts.month),
day: parseSegmentToNum(parts.day),
};
}
function daysInMonth(year: number, month: number): number {
return new Date(year || 2001, month || 1, 0).getDate();
}
/** Validate and return calendar parts, or undefined if invalid */
function validParts(parts: DateParts): { year: number; month: number; day: number } | undefined {
const { year, month, day } = parse(parts);
if (!year || year < 0 || year > 9999) return undefined;
if (!month || month < 1 || month > 12) return undefined;
if (!day || day < 1) return undefined;
const d = new Date(Date.UTC(year, month - 1, day));
if (d.getUTCFullYear() !== year || d.getUTCMonth() !== month - 1 || d.getUTCDate() !== day)
return undefined;
return { year, month, day };
}
function toDate(parts: DateParts): Date | undefined {
const p = validParts(parts);
return p ? new Date(Date.UTC(p.year, p.month - 1, p.day)) : undefined;
}
function segmentBounds(
type: 'year' | 'month' | 'day',
ctx: { year: number; month: number },
minDate: Date | undefined,
maxDate: Date | undefined
): { min: number; max: number } {
const minP = minDate
? {
year: minDate.getUTCFullYear(),
month: minDate.getUTCMonth() + 1,
day: minDate.getUTCDate(),
}
: null;
const maxP = maxDate
? {
year: maxDate.getUTCFullYear(),
month: maxDate.getUTCMonth() + 1,
day: maxDate.getUTCDate(),
}
: null;
if (type === 'year') {
return { min: minP?.year ?? 1, max: maxP?.year ?? 9999 };
}
if (type === 'month') {
return {
min: minP && ctx.year === minP.year ? minP.month : 1,
max: maxP && ctx.year === maxP.year ? maxP.month : 12,
};
}
return {
min: minP && ctx.year === minP.year && ctx.month === minP.month ? minP.day : 1,
max:
maxP && ctx.year === maxP.year && ctx.month === maxP.month
? maxP.day
: daysInMonth(ctx.year, ctx.month),
};
}
export interface DateOptions extends PromptOptions {
format?: DateFormat;
locale?: string;
separator?: string;
defaultValue?: Date;
initialValue?: Date;
minDate?: Date;
maxDate?: Date;
}
export default class DatePrompt extends Prompt {
#segments: SegmentConfig[];
#separator: string;
#segmentValues: DateParts;
#minDate: Date | undefined;
#maxDate: Date | undefined;
#cursor = { segmentIndex: 0, positionInSegment: 0 };
#segmentSelected = true;
#pendingTensDigit: string | null = null;
inlineError = '';
get segmentCursor() {
return { ...this.#cursor };
}
get segmentValues(): DateParts {
return { ...this.#segmentValues };
}
get segments(): readonly SegmentConfig[] {
return this.#segments;
}
get separator(): string {
return this.#separator;
}
get formattedValue(): string {
return this.#format(this.#segmentValues);
}
#format(parts: DateParts): string {
return this.#segments.map((s) => parts[s.type]).join(this.#separator);
}
#refresh() {
this._setUserInput(this.#format(this.#segmentValues));
this._setValue(toDate(this.#segmentValues) ?? undefined);
}
constructor(opts: DateOptions) {
const detected = opts.format
? { segments: segmentsFor(opts.format), separator: opts.separator ?? '/' }
: detectLocaleFormat(opts.locale);
const sep = opts.separator ?? detected.separator;
const segments = opts.format ? segmentsFor(opts.format) : detected.segments;
const initialDate = opts.initialValue ?? opts.defaultValue;
const segmentValues: DateParts = initialDate
? {
year: String(initialDate.getUTCFullYear()).padStart(4, '0'),
month: String(initialDate.getUTCMonth() + 1).padStart(2, '0'),
day: String(initialDate.getUTCDate()).padStart(2, '0'),
}
: { year: '____', month: '__', day: '__' };
const initialDisplay = segments.map((s) => segmentValues[s.type]).join(sep);
super({ ...opts, initialUserInput: initialDisplay }, false);
this.#segments = segments;
this.#separator = sep;
this.#segmentValues = segmentValues;
this.#minDate = opts.minDate;
this.#maxDate = opts.maxDate;
this.#refresh();
this.on('cursor', (key) => this.#onCursor(key));
this.on('key', (char, key) => this.#onKey(char, key));
this.on('finalize', () => this.#onFinalize(opts));
}
#seg(): { segment: SegmentConfig; index: number } | undefined {
const index = Math.max(0, Math.min(this.#cursor.segmentIndex, this.#segments.length - 1));
const segment = this.#segments[index];
if (!segment) return undefined;
this.#cursor.positionInSegment = Math.max(
0,
Math.min(this.#cursor.positionInSegment, segment.len - 1)
);
return { segment, index };
}
#navigate(direction: 1 | -1) {
this.inlineError = '';
this.#pendingTensDigit = null;
const ctx = this.#seg();
if (!ctx) return;
this.#cursor.segmentIndex = Math.max(
0,
Math.min(this.#segments.length - 1, ctx.index + direction)
);
this.#cursor.positionInSegment = 0;
this.#segmentSelected = true;
}
#adjust(direction: 1 | -1) {
const ctx = this.#seg();
if (!ctx) return;
const { segment } = ctx;
const raw = this.#segmentValues[segment.type];
const isBlank = !raw || raw.replace(/_/g, '') === '';
const num = Number.parseInt((raw || '0').replace(/_/g, '0'), 10) || 0;
const bounds = segmentBounds(
segment.type,
parse(this.#segmentValues),
this.#minDate,
this.#maxDate
);
let next: number;
if (isBlank) {
next = direction === 1 ? bounds.min : bounds.max;
} else {
next = Math.max(Math.min(bounds.max, num + direction), bounds.min);
}
this.#segmentValues = {
...this.#segmentValues,
[segment.type]: next.toString().padStart(segment.len, '0'),
};
this.#segmentSelected = true;
this.#pendingTensDigit = null;
this.#refresh();
}
#onCursor(key?: string) {
if (!key) return;
switch (key) {
case 'right':
return this.#navigate(1);
case 'left':
return this.#navigate(-1);
case 'up':
return this.#adjust(1);
case 'down':
return this.#adjust(-1);
}
}
#onKey(char: string | undefined, key: Key) {
// Backspace
const isBackspace =
key?.name === 'backspace' ||
key?.sequence === '\x7f' ||
key?.sequence === '\b' ||
char === '\x7f' ||
char === '\b';
if (isBackspace) {
this.inlineError = '';
const ctx = this.#seg();
if (!ctx) return;
if (!this.#segmentValues[ctx.segment.type].replace(/_/g, '')) {
this.#navigate(-1);
return;
}
this.#segmentValues[ctx.segment.type] = '_'.repeat(ctx.segment.len);
this.#segmentSelected = true;
this.#cursor.positionInSegment = 0;
this.#refresh();
return;
}
// Tab navigation
if (key?.name === 'tab') {
this.inlineError = '';
const ctx = this.#seg();
if (!ctx) return;
const dir = key.shift ? -1 : 1;
const next = ctx.index + dir;
if (next >= 0 && next < this.#segments.length) {
this.#cursor.segmentIndex = next;
this.#cursor.positionInSegment = 0;
this.#segmentSelected = true;
}
return;
}
// Digit input
if (char && /^[0-9]$/.test(char)) {
const ctx = this.#seg();
if (!ctx) return;
const { segment } = ctx;
const isBlank = !this.#segmentValues[segment.type].replace(/_/g, '');
// Pending tens digit: complete the two-digit entry
if (this.#segmentSelected && this.#pendingTensDigit !== null && !isBlank) {
const newVal = this.#pendingTensDigit + char;
const newParts = { ...this.#segmentValues, [segment.type]: newVal };
const err = this.#validateSegment(newParts, segment);
if (err) {
this.inlineError = err;
this.#pendingTensDigit = null;
this.#segmentSelected = false;
return;
}
this.inlineError = '';
this.#segmentValues[segment.type] = newVal;
this.#pendingTensDigit = null;
this.#segmentSelected = false;
this.#refresh();
if (ctx.index < this.#segments.length - 1) {
this.#cursor.segmentIndex = ctx.index + 1;
this.#cursor.positionInSegment = 0;
this.#segmentSelected = true;
}
return;
}
// Clear-on-type: typing into a selected filled segment clears it first
if (this.#segmentSelected && !isBlank) {
this.#segmentValues[segment.type] = '_'.repeat(segment.len);
this.#cursor.positionInSegment = 0;
}
this.#segmentSelected = false;
this.#pendingTensDigit = null;
const display = this.#segmentValues[segment.type];
const firstBlank = display.indexOf('_');
const pos =
firstBlank >= 0 ? firstBlank : Math.min(this.#cursor.positionInSegment, segment.len - 1);
if (pos < 0 || pos >= segment.len) return;
let newVal = display.slice(0, pos) + char + display.slice(pos + 1);
// Smart digit placement
let shouldStaySelected = false;
if (pos === 0 && display === '__' && (segment.type === 'month' || segment.type === 'day')) {
const digit = Number.parseInt(char, 10);
newVal = `0${char}`;
shouldStaySelected = digit <= (segment.type === 'month' ? 1 : 2);
}
if (segment.type === 'year') {
const digits = display.replace(/_/g, '');
newVal = (digits + char).padStart(segment.len, '_');
}
if (!newVal.includes('_')) {
const newParts = { ...this.#segmentValues, [segment.type]: newVal };
const err = this.#validateSegment(newParts, segment);
if (err) {
this.inlineError = err;
return;
}
}
this.inlineError = '';
this.#segmentValues[segment.type] = newVal;
// Clamp only when the current segment is fully entered
const parsed = !newVal.includes('_') ? validParts(this.#segmentValues) : undefined;
if (parsed) {
const { year, month } = parsed;
const maxDay = daysInMonth(year, month);
this.#segmentValues = {
year: String(Math.max(0, Math.min(9999, year))).padStart(4, '0'),
month: String(Math.max(1, Math.min(12, month))).padStart(2, '0'),
day: String(Math.max(1, Math.min(maxDay, parsed.day))).padStart(2, '0'),
};
}
this.#refresh();
// Advance cursor
const nextBlank = newVal.indexOf('_');
if (shouldStaySelected) {
this.#segmentSelected = true;
this.#pendingTensDigit = char;
} else if (nextBlank >= 0) {
this.#cursor.positionInSegment = nextBlank;
} else if (firstBlank >= 0 && ctx.index < this.#segments.length - 1) {
this.#cursor.segmentIndex = ctx.index + 1;
this.#cursor.positionInSegment = 0;
this.#segmentSelected = true;
} else {
this.#cursor.positionInSegment = Math.min(pos + 1, segment.len - 1);
}
}
}
#validateSegment(parts: DateParts, seg: SegmentConfig): string | undefined {
const { month, day } = parse(parts);
if (seg.type === 'month' && (month < 0 || month > 12)) {
return settings.date.messages.invalidMonth;
}
if (seg.type === 'day' && (day < 0 || day > 31)) {
return settings.date.messages.invalidDay(31, 'any month');
}
return undefined;
}
#onFinalize(opts: DateOptions) {
const { year, month, day } = parse(this.#segmentValues);
if (year && month && day) {
const maxDay = daysInMonth(year, month);
this.#segmentValues = {
...this.#segmentValues,
day: String(Math.min(day, maxDay)).padStart(2, '0'),
};
}
this.value = toDate(this.#segmentValues) ?? opts.defaultValue ?? undefined;
}
}
================================================
FILE: packages/core/src/prompts/group-multiselect.ts
================================================
import Prompt, { type PromptOptions } from './prompt.js';
export interface GroupMultiSelectOptions
extends PromptOptions> {
options: Record;
initialValues?: T['value'][];
required?: boolean;
cursorAt?: T['value'];
selectableGroups?: boolean;
}
export default class GroupMultiSelectPrompt extends Prompt {
options: (T & { group: string | boolean })[];
cursor = 0;
#selectableGroups: boolean;
getGroupItems(group: string): T[] {
return this.options.filter((o) => o.group === group);
}
isGroupSelected(group: string) {
const items = this.getGroupItems(group);
const value = this.value;
if (value === undefined) {
return false;
}
return items.every((i) => value.includes(i.value));
}
private toggleValue() {
const item = this.options[this.cursor];
if (this.value === undefined) {
this.value = [];
}
if (item.group === true) {
const group = item.value;
const groupedItems = this.getGroupItems(group);
if (this.isGroupSelected(group)) {
this.value = this.value.filter(
(v: string) => groupedItems.findIndex((i) => i.value === v) === -1
);
} else {
this.value = [...this.value, ...groupedItems.map((i) => i.value)];
}
this.value = Array.from(new Set(this.value));
} else {
const selected = this.value.includes(item.value);
this.value = selected
? this.value.filter((v: T['value']) => v !== item.value)
: [...this.value, item.value];
}
}
constructor(opts: GroupMultiSelectOptions) {
super(opts, false);
const { options } = opts;
this.#selectableGroups = opts.selectableGroups !== false;
this.options = Object.entries(options).flatMap(([key, option]) => [
{ value: key, group: true, label: key },
...option.map((opt) => ({ ...opt, group: key })),
]) as any;
this.value = [...(opts.initialValues ?? [])];
this.cursor = Math.max(
this.options.findIndex(({ value }) => value === opts.cursorAt),
this.#selectableGroups ? 0 : 1
);
this.on('cursor', (key) => {
switch (key) {
case 'left':
case 'up': {
this.cursor = this.cursor === 0 ? this.options.length - 1 : this.cursor - 1;
const currentIsGroup = this.options[this.cursor]?.group === true;
if (!this.#selectableGroups && currentIsGroup) {
this.cursor = this.cursor === 0 ? this.options.length - 1 : this.cursor - 1;
}
break;
}
case 'down':
case 'right': {
this.cursor = this.cursor === this.options.length - 1 ? 0 : this.cursor + 1;
const currentIsGroup = this.options[this.cursor]?.group === true;
if (!this.#selectableGroups && currentIsGroup) {
this.cursor = this.cursor === this.options.length - 1 ? 0 : this.cursor + 1;
}
break;
}
case 'space':
this.toggleValue();
break;
}
});
}
}
================================================
FILE: packages/core/src/prompts/multi-select.ts
================================================
import { findCursor } from '../utils/cursor.js';
import Prompt, { type PromptOptions } from './prompt.js';
interface OptionLike {
value: any;
disabled?: boolean;
}
export interface MultiSelectOptions
extends PromptOptions> {
options: T[];
initialValues?: T['value'][];
required?: boolean;
cursorAt?: T['value'];
}
export default class MultiSelectPrompt extends Prompt {
options: T[];
cursor = 0;
private get _value(): T['value'] {
return this.options[this.cursor].value;
}
private get _enabledOptions(): T[] {
return this.options.filter((option) => option.disabled !== true);
}
private toggleAll() {
const enabledOptions = this._enabledOptions;
const allSelected = this.value !== undefined && this.value.length === enabledOptions.length;
this.value = allSelected ? [] : enabledOptions.map((v) => v.value);
}
private toggleInvert() {
const value = this.value;
if (!value) {
return;
}
const notSelected = this._enabledOptions.filter((v) => !value.includes(v.value));
this.value = notSelected.map((v) => v.value);
}
private toggleValue() {
if (this.value === undefined) {
this.value = [];
}
const selected = this.value.includes(this._value);
this.value = selected
? this.value.filter((value) => value !== this._value)
: [...this.value, this._value];
}
constructor(opts: MultiSelectOptions) {
super(opts, false);
this.options = opts.options;
this.value = [...(opts.initialValues ?? [])];
const cursor = Math.max(
this.options.findIndex(({ value }) => value === opts.cursorAt),
0
);
this.cursor = this.options[cursor].disabled ? findCursor(cursor, 1, this.options) : cursor;
this.on('key', (char) => {
if (char === 'a') {
this.toggleAll();
}
if (char === 'i') {
this.toggleInvert();
}
});
this.on('cursor', (key) => {
switch (key) {
case 'left':
case 'up':
this.cursor = findCursor(this.cursor, -1, this.options);
break;
case 'down':
case 'right':
this.cursor = findCursor(this.cursor, 1, this.options);
break;
case 'space':
this.toggleValue();
break;
}
});
}
}
================================================
FILE: packages/core/src/prompts/password.ts
================================================
import { styleText } from 'node:util';
import Prompt, { type PromptOptions } from './prompt.js';
export interface PasswordOptions extends PromptOptions {
mask?: string;
}
export default class PasswordPrompt extends Prompt {
private _mask = '•';
get cursor() {
return this._cursor;
}
get masked() {
return this.userInput.replaceAll(/./g, this._mask);
}
get userInputWithCursor() {
if (this.state === 'submit' || this.state === 'cancel') {
return this.masked;
}
const userInput = this.userInput;
if (this.cursor >= userInput.length) {
return `${this.masked}${styleText(['inverse', 'hidden'], '_')}`;
}
const masked = this.masked;
const s1 = masked.slice(0, this.cursor);
const s2 = masked.slice(this.cursor);
return `${s1}${styleText('inverse', s2[0])}${s2.slice(1)}`;
}
clear() {
this._clearUserInput();
}
constructor({ mask, ...opts }: PasswordOptions) {
super(opts);
this._mask = mask ?? '•';
this.on('userInput', (input) => {
this._setValue(input);
});
}
}
================================================
FILE: packages/core/src/prompts/prompt.ts
================================================
import { stdin, stdout } from 'node:process';
import readline, { type Key, type ReadLine } from 'node:readline';
import type { Readable, Writable } from 'node:stream';
import { wrapAnsi } from 'fast-wrap-ansi';
import { cursor, erase } from 'sisteransi';
import type { ClackEvents, ClackState } from '../types.js';
import type { Action } from '../utils/index.js';
import {
CANCEL_SYMBOL,
diffLines,
getRows,
isActionKey,
setRawMode,
settings,
} from '../utils/index.js';
export interface PromptOptions> {
render(this: Omit): string | undefined;
initialValue?: any;
initialUserInput?: string;
validate?: ((value: TValue | undefined) => string | Error | undefined) | undefined;
input?: Readable;
output?: Writable;
debug?: boolean;
signal?: AbortSignal;
}
export default class Prompt {
protected input: Readable;
protected output: Writable;
private _abortSignal?: AbortSignal;
private rl: ReadLine | undefined;
private opts: Omit>, 'render' | 'input' | 'output'>;
private _render: (context: Omit, 'prompt'>) => string | undefined;
private _track = false;
private _prevFrame = '';
private _subscribers = new Map any; once?: boolean }[]>();
protected _cursor = 0;
public state: ClackState = 'initial';
public error = '';
public value: TValue | undefined;
public userInput = '';
constructor(options: PromptOptions>, trackValue = true) {
const { input = stdin, output = stdout, render, signal, ...opts } = options;
this.opts = opts;
this.onKeypress = this.onKeypress.bind(this);
this.close = this.close.bind(this);
this.render = this.render.bind(this);
this._render = render.bind(this);
this._track = trackValue;
this._abortSignal = signal;
this.input = input;
this.output = output;
}
/**
* Unsubscribe all listeners
*/
protected unsubscribe() {
this._subscribers.clear();
}
/**
* Set a subscriber with opts
* @param event - The event name
*/
private setSubscriber>(
event: T,
opts: { cb: ClackEvents[T]; once?: boolean }
) {
const params = this._subscribers.get(event) ?? [];
params.push(opts);
this._subscribers.set(event, params);
}
/**
* Subscribe to an event
* @param event - The event name
* @param cb - The callback
*/
public on>(event: T, cb: ClackEvents[T]) {
this.setSubscriber(event, { cb });
}
/**
* Subscribe to an event once
* @param event - The event name
* @param cb - The callback
*/
public once>(event: T, cb: ClackEvents[T]) {
this.setSubscriber(event, { cb, once: true });
}
/**
* Emit an event with data
* @param event - The event name
* @param data - The data to pass to the callback
*/
public emit>(
event: T,
...data: Parameters[T]>
) {
const cbs = this._subscribers.get(event) ?? [];
const cleanup: (() => void)[] = [];
for (const subscriber of cbs) {
subscriber.cb(...data);
if (subscriber.once) {
cleanup.push(() => cbs.splice(cbs.indexOf(subscriber), 1));
}
}
for (const cb of cleanup) {
cb();
}
}
public prompt() {
return new Promise((resolve) => {
if (this._abortSignal) {
if (this._abortSignal.aborted) {
this.state = 'cancel';
this.close();
return resolve(CANCEL_SYMBOL);
}
this._abortSignal.addEventListener(
'abort',
() => {
this.state = 'cancel';
this.close();
},
{ once: true }
);
}
this.rl = readline.createInterface({
input: this.input,
tabSize: 2,
prompt: '',
escapeCodeTimeout: 50,
terminal: true,
});
this.rl.prompt();
if (this.opts.initialUserInput !== undefined) {
this._setUserInput(this.opts.initialUserInput, true);
}
this.input.on('keypress', this.onKeypress);
setRawMode(this.input, true);
this.output.on('resize', this.render);
this.render();
this.once('submit', () => {
this.output.write(cursor.show);
this.output.off('resize', this.render);
setRawMode(this.input, false);
resolve(this.value);
});
this.once('cancel', () => {
this.output.write(cursor.show);
this.output.off('resize', this.render);
setRawMode(this.input, false);
resolve(CANCEL_SYMBOL);
});
});
}
protected _isActionKey(char: string | undefined, _key: Key): boolean {
return char === '\t';
}
protected _setValue(value: TValue | undefined): void {
this.value = value;
this.emit('value', this.value);
}
protected _setUserInput(value: string | undefined, write?: boolean): void {
this.userInput = value ?? '';
this.emit('userInput', this.userInput);
if (write && this._track && this.rl) {
this.rl.write(this.userInput);
this._cursor = this.rl.cursor;
}
}
protected _clearUserInput(): void {
this.rl?.write(null, { ctrl: true, name: 'u' });
this._setUserInput('');
}
private onKeypress(char: string | undefined, key: Key) {
if (this._track && key.name !== 'return') {
if (key.name && this._isActionKey(char, key)) {
this.rl?.write(null, { ctrl: true, name: 'h' });
}
this._cursor = this.rl?.cursor ?? 0;
this._setUserInput(this.rl?.line);
}
if (this.state === 'error') {
this.state = 'active';
}
if (key?.name) {
if (!this._track && settings.aliases.has(key.name)) {
this.emit('cursor', settings.aliases.get(key.name));
}
if (settings.actions.has(key.name as Action)) {
this.emit('cursor', key.name as Action);
}
}
if (char && (char.toLowerCase() === 'y' || char.toLowerCase() === 'n')) {
this.emit('confirm', char.toLowerCase() === 'y');
}
// Call the key event handler and emit the key event
this.emit('key', char?.toLowerCase(), key);
if (key?.name === 'return') {
if (this.opts.validate) {
const problem = this.opts.validate(this.value);
if (problem) {
this.error = problem instanceof Error ? problem.message : problem;
this.state = 'error';
this.rl?.write(this.userInput);
}
}
if (this.state !== 'error') {
this.state = 'submit';
}
}
if (isActionKey([char, key?.name, key?.sequence], 'cancel')) {
this.state = 'cancel';
}
if (this.state === 'submit' || this.state === 'cancel') {
this.emit('finalize');
}
this.render();
if (this.state === 'submit' || this.state === 'cancel') {
this.close();
}
}
protected close() {
this.input.unpipe();
this.input.removeListener('keypress', this.onKeypress);
this.output.write('\n');
setRawMode(this.input, false);
this.rl?.close();
this.rl = undefined;
this.emit(`${this.state}`, this.value);
this.unsubscribe();
}
private restoreCursor() {
const lines =
wrapAnsi(this._prevFrame, process.stdout.columns, { hard: true, trim: false }).split('\n')
.length - 1;
this.output.write(cursor.move(-999, lines * -1));
}
private render() {
const frame = wrapAnsi(this._render(this) ?? '', process.stdout.columns, {
hard: true,
trim: false,
});
if (frame === this._prevFrame) return;
if (this.state === 'initial') {
this.output.write(cursor.hide);
} else {
const diff = diffLines(this._prevFrame, frame);
const rows = getRows(this.output);
this.restoreCursor();
if (diff) {
const diffOffsetAfter = Math.max(0, diff.numLinesAfter - rows);
const diffOffsetBefore = Math.max(0, diff.numLinesBefore - rows);
let diffLine = diff.lines.find((line) => line >= diffOffsetAfter);
if (diffLine === undefined) {
this._prevFrame = frame;
return;
}
// If a single line has changed, only update that line
if (diff.lines.length === 1) {
this.output.write(cursor.move(0, diffLine - diffOffsetBefore));
this.output.write(erase.lines(1));
const lines = frame.split('\n');
this.output.write(lines[diffLine]);
this._prevFrame = frame;
this.output.write(cursor.move(0, lines.length - diffLine - 1));
return;
// If many lines have changed, rerender everything past the first line
} else if (diff.lines.length > 1) {
if (diffOffsetAfter < diffOffsetBefore) {
diffLine = diffOffsetAfter;
} else {
const adjustedDiffLine = diffLine - diffOffsetBefore;
if (adjustedDiffLine > 0) {
this.output.write(cursor.move(0, adjustedDiffLine));
}
}
this.output.write(erase.down());
const lines = frame.split('\n');
const newLines = lines.slice(diffLine);
this.output.write(newLines.join('\n'));
this._prevFrame = frame;
return;
}
}
this.output.write(erase.down());
}
this.output.write(frame);
if (this.state === 'initial') {
this.state = 'active';
}
this._prevFrame = frame;
}
}
================================================
FILE: packages/core/src/prompts/select-key.ts
================================================
import Prompt, { type PromptOptions } from './prompt.js';
export interface SelectKeyOptions
extends PromptOptions> {
options: T[];
caseSensitive?: boolean;
}
export default class SelectKeyPrompt extends Prompt {
options: T[];
cursor = 0;
constructor(opts: SelectKeyOptions) {
super(opts, false);
this.options = opts.options;
const caseSensitive = opts.caseSensitive === true;
const keys = this.options.map(({ value: [initial] }) => {
return caseSensitive ? initial : initial?.toLowerCase();
});
this.cursor = Math.max(keys.indexOf(opts.initialValue), 0);
this.on('key', (key, keyInfo) => {
if (!key) {
return;
}
const casedKey = caseSensitive && keyInfo.shift ? key.toUpperCase() : key;
if (!keys.includes(casedKey)) {
return;
}
const value = this.options.find(({ value: [initial] }) => {
return caseSensitive ? initial === casedKey : initial?.toLowerCase() === key;
});
if (value) {
this.value = value.value;
this.state = 'submit';
this.emit('submit');
}
});
}
}
================================================
FILE: packages/core/src/prompts/select.ts
================================================
import { findCursor } from '../utils/cursor.js';
import Prompt, { type PromptOptions } from './prompt.js';
export interface SelectOptions
extends PromptOptions> {
options: T[];
initialValue?: T['value'];
}
export default class SelectPrompt extends Prompt<
T['value']
> {
options: T[];
cursor = 0;
private get _selectedValue() {
return this.options[this.cursor];
}
private changeValue() {
this.value = this._selectedValue.value;
}
constructor(opts: SelectOptions) {
super(opts, false);
this.options = opts.options;
const initialCursor = this.options.findIndex(({ value }) => value === opts.initialValue);
const cursor = initialCursor === -1 ? 0 : initialCursor;
this.cursor = this.options[cursor].disabled ? findCursor(cursor, 1, this.options) : cursor;
this.changeValue();
this.on('cursor', (key) => {
switch (key) {
case 'left':
case 'up':
this.cursor = findCursor(this.cursor, -1, this.options);
break;
case 'down':
case 'right':
this.cursor = findCursor(this.cursor, 1, this.options);
break;
}
this.changeValue();
});
}
}
================================================
FILE: packages/core/src/prompts/text.ts
================================================
import { styleText } from 'node:util';
import Prompt, { type PromptOptions } from './prompt.js';
export interface TextOptions extends PromptOptions {
placeholder?: string;
defaultValue?: string;
}
export default class TextPrompt extends Prompt {
get userInputWithCursor() {
if (this.state === 'submit') {
return this.userInput;
}
const userInput = this.userInput;
if (this.cursor >= userInput.length) {
return `${this.userInput}█`;
}
const s1 = userInput.slice(0, this.cursor);
const [s2, ...s3] = userInput.slice(this.cursor);
return `${s1}${styleText('inverse', s2)}${s3.join('')}`;
}
get cursor() {
return this._cursor;
}
constructor(opts: TextOptions) {
super({
...opts,
initialUserInput: opts.initialUserInput ?? opts.initialValue,
});
this.on('userInput', (input) => {
this._setValue(input);
});
this.on('finalize', () => {
if (!this.value) {
this.value = opts.defaultValue;
}
if (this.value === undefined) {
this.value = '';
}
});
}
}
================================================
FILE: packages/core/src/types.ts
================================================
import type { Key } from 'node:readline';
import type { Action } from './utils/settings.js';
/**
* The state of the prompt
*/
export type ClackState = 'initial' | 'active' | 'cancel' | 'submit' | 'error';
/**
* Typed event emitter for clack
*/
export interface ClackEvents {
initial: (value?: any) => void;
active: (value?: any) => void;
cancel: (value?: any) => void;
submit: (value?: any) => void;
error: (value?: any) => void;
cursor: (key?: Action) => void;
key: (key: string | undefined, info: Key) => void;
value: (value?: TValue) => void;
userInput: (value: string) => void;
confirm: (value?: boolean) => void;
finalize: () => void;
beforePrompt: () => void;
}
================================================
FILE: packages/core/src/utils/cursor.ts
================================================
export function findCursor(
cursor: number,
delta: number,
options: T[]
) {
const hasEnabledOptions = options.some((opt) => !opt.disabled);
if (!hasEnabledOptions) {
return cursor;
}
const newCursor = cursor + delta;
const maxCursor = Math.max(options.length - 1, 0);
const clampedCursor = newCursor < 0 ? maxCursor : newCursor > maxCursor ? 0 : newCursor;
const newOption = options[clampedCursor];
if (newOption.disabled) {
return findCursor(clampedCursor, delta < 0 ? -1 : 1, options);
}
return clampedCursor;
}
================================================
FILE: packages/core/src/utils/index.ts
================================================
import { stdin, stdout } from 'node:process';
import type { Key } from 'node:readline';
import * as readline from 'node:readline';
import type { Readable, Writable } from 'node:stream';
import { ReadStream } from 'node:tty';
import { wrapAnsi } from 'fast-wrap-ansi';
import { cursor } from 'sisteransi';
import { isActionKey } from './settings.js';
export * from './settings.js';
export * from './string.js';
const isWindows = globalThis.process.platform.startsWith('win');
export const CANCEL_SYMBOL = Symbol('clack:cancel');
export function isCancel(value: unknown): value is symbol {
return value === CANCEL_SYMBOL;
}
export function setRawMode(input: Readable, value: boolean) {
const i = input as typeof stdin;
if (i.isTTY) i.setRawMode(value);
}
interface BlockOptions {
input?: Readable;
output?: Writable;
overwrite?: boolean;
hideCursor?: boolean;
}
export function block({
input = stdin,
output = stdout,
overwrite = true,
hideCursor = true,
}: BlockOptions = {}) {
const rl = readline.createInterface({
input,
output,
prompt: '',
tabSize: 1,
});
readline.emitKeypressEvents(input, rl);
if (input instanceof ReadStream && input.isTTY) {
input.setRawMode(true);
}
const clear = (data: Buffer, { name, sequence }: Key) => {
const str = String(data);
if (isActionKey([str, name, sequence], 'cancel')) {
if (hideCursor) output.write(cursor.show);
process.exit(0);
return;
}
if (!overwrite) return;
const dx = name === 'return' ? 0 : -1;
const dy = name === 'return' ? -1 : 0;
readline.moveCursor(output, dx, dy, () => {
readline.clearLine(output, 1, () => {
input.once('keypress', clear);
});
});
};
if (hideCursor) output.write(cursor.hide);
input.once('keypress', clear);
return () => {
input.off('keypress', clear);
if (hideCursor) output.write(cursor.show);
// Prevent Windows specific issues: https://github.com/bombshell-dev/clack/issues/176
if (input instanceof ReadStream && input.isTTY && !isWindows) {
input.setRawMode(false);
}
// @ts-expect-error fix for https://github.com/nodejs/node/issues/31762#issuecomment-1441223907
rl.terminal = false;
rl.close();
};
}
export const getColumns = (output: Writable): number => {
if ('columns' in output && typeof output.columns === 'number') {
return output.columns;
}
return 80;
};
export const getRows = (output: Writable): number => {
if ('rows' in output && typeof output.rows === 'number') {
return output.rows;
}
return 20;
};
export function wrapTextWithPrefix(
output: Writable | undefined,
text: string,
prefix: string,
startPrefix: string = prefix
): string {
const columns = getColumns(output ?? stdout);
const wrapped = wrapAnsi(text, columns - prefix.length, {
hard: true,
trim: false,
});
const lines = wrapped
.split('\n')
.map((line, index) => {
return `${index === 0 ? startPrefix : prefix}${line}`;
})
.join('\n');
return lines;
}
================================================
FILE: packages/core/src/utils/settings.ts
================================================
const actions = ['up', 'down', 'left', 'right', 'space', 'enter', 'cancel'] as const;
export type Action = (typeof actions)[number];
const DEFAULT_MONTH_NAMES = [
'January',
'February',
'March',
'April',
'May',
'June',
'July',
'August',
'September',
'October',
'November',
'December',
];
/** Global settings for Clack programs, stored in memory */
interface InternalClackSettings {
actions: Set;
aliases: Map;
messages: {
cancel: string;
error: string;
};
withGuide: boolean;
date: {
monthNames: string[];
messages: {
invalidMonth: string;
required: string;
invalidDay: (days: number, month: string) => string;
afterMin: (min: Date) => string;
beforeMax: (max: Date) => string;
};
};
}
export const settings: InternalClackSettings = {
actions: new Set(actions),
aliases: new Map([
// vim support
['k', 'up'],
['j', 'down'],
['h', 'left'],
['l', 'right'],
['\x03', 'cancel'],
// opinionated defaults!
['escape', 'cancel'],
]),
messages: {
cancel: 'Canceled',
error: 'Something went wrong',
},
withGuide: true,
date: {
monthNames: [...DEFAULT_MONTH_NAMES],
messages: {
required: 'Please enter a valid date',
invalidMonth: 'There are only 12 months in a year',
invalidDay: (days, month) => `There are only ${days} days in ${month}`,
afterMin: (min) => `Date must be on or after ${min.toISOString().slice(0, 10)}`,
beforeMax: (max) => `Date must be on or before ${max.toISOString().slice(0, 10)}`,
},
},
};
export interface ClackSettings {
/**
* Set custom global aliases for the default actions.
* This will not overwrite existing aliases, it will only add new ones!
*
* @param aliases - An object that maps aliases to actions
* @default { k: 'up', j: 'down', h: 'left', l: 'right', '\x03': 'cancel', 'escape': 'cancel' }
*/
aliases?: Record;
/**
* Custom messages for prompts
*/
messages?: {
/**
* Custom message to display when a spinner is cancelled
* @default "Canceled"
*/
cancel?: string;
/**
* Custom message to display when a spinner encounters an error
* @default "Something went wrong"
*/
error?: string;
};
withGuide?: boolean;
/**
* Date prompt localization
*/
date?: {
/** Month names for validation messages (January, February, ...) */
monthNames?: string[];
messages?: {
/** Shown when date is missing */
required?: string;
/** Shown when month > 12 */
invalidMonth?: string;
/** (days, monthName) => message for invalid day */
invalidDay?: (days: number, month: string) => string;
/** (min) => message when date is before minDate */
afterMin?: (min: Date) => string;
/** (max) => message when date is after maxDate */
beforeMax?: (max: Date) => string;
};
};
}
export function updateSettings(updates: ClackSettings) {
// Handle each property in the updates
if (updates.aliases !== undefined) {
const aliases = updates.aliases;
for (const alias in aliases) {
if (!Object.hasOwn(aliases, alias)) continue;
const action = aliases[alias];
if (!settings.actions.has(action)) continue;
if (!settings.aliases.has(alias)) {
settings.aliases.set(alias, action);
}
}
}
if (updates.messages !== undefined) {
const messages = updates.messages;
if (messages.cancel !== undefined) {
settings.messages.cancel = messages.cancel;
}
if (messages.error !== undefined) {
settings.messages.error = messages.error;
}
}
if (updates.withGuide !== undefined) {
settings.withGuide = updates.withGuide !== false;
}
if (updates.date !== undefined) {
const date = updates.date;
if (date.monthNames !== undefined) {
settings.date.monthNames = [...date.monthNames];
}
if (date.messages !== undefined) {
if (date.messages.required !== undefined) {
settings.date.messages.required = date.messages.required;
}
if (date.messages.invalidMonth !== undefined) {
settings.date.messages.invalidMonth = date.messages.invalidMonth;
}
if (date.messages.invalidDay !== undefined) {
settings.date.messages.invalidDay = date.messages.invalidDay;
}
if (date.messages.afterMin !== undefined) {
settings.date.messages.afterMin = date.messages.afterMin;
}
if (date.messages.beforeMax !== undefined) {
settings.date.messages.beforeMax = date.messages.beforeMax;
}
}
}
}
/**
* Check if a key is an alias for a default action
* @param key - The raw key which might match to an action
* @param action - The action to match
* @returns boolean
*/
export function isActionKey(key: string | Array, action: Action) {
if (typeof key === 'string') {
return settings.aliases.get(key) === action;
}
for (const value of key) {
if (value === undefined) continue;
if (isActionKey(value, action)) {
return true;
}
}
return false;
}
================================================
FILE: packages/core/src/utils/string.ts
================================================
export function diffLines(a: string, b: string) {
if (a === b) return;
const aLines = a.split('\n');
const bLines = b.split('\n');
const numLines = Math.max(aLines.length, bLines.length);
const diff: number[] = [];
for (let i = 0; i < numLines; i++) {
if (aLines[i] !== bLines[i]) diff.push(i);
}
return {
lines: diff,
numLinesBefore: aLines.length,
numLinesAfter: bLines.length,
numLines,
};
}
================================================
FILE: packages/core/test/mock-readable.ts
================================================
import { Readable } from 'node:stream';
export class MockReadable extends Readable {
protected _buffer: unknown[] | null = [];
_read() {
if (this._buffer === null) {
this.push(null);
return;
}
for (const val of this._buffer) {
this.push(val);
}
this._buffer = [];
}
pushValue(val: unknown): void {
this._buffer?.push(val);
}
close(): void {
this._buffer = null;
}
}
================================================
FILE: packages/core/test/mock-writable.ts
================================================
import { Writable } from 'node:stream';
export class MockWritable extends Writable {
public buffer: string[] = [];
_write(
chunk: any,
_encoding: BufferEncoding,
callback: (error?: Error | null | undefined) => void
): void {
this.buffer.push(chunk.toString());
callback();
}
}
================================================
FILE: packages/core/test/prompts/autocomplete.test.ts
================================================
import { cursor } from 'sisteransi';
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import { default as AutocompletePrompt } from '../../src/prompts/autocomplete.js';
import { MockReadable } from '../mock-readable.js';
import { MockWritable } from '../mock-writable.js';
describe('AutocompletePrompt', () => {
let input: MockReadable;
let output: MockWritable;
const testOptions = [
{ value: 'apple', label: 'Apple' },
{ value: 'banana', label: 'Banana' },
{ value: 'cherry', label: 'Cherry' },
{ value: 'grape', label: 'Grape' },
{ value: 'orange', label: 'Orange' },
];
beforeEach(() => {
input = new MockReadable();
output = new MockWritable();
});
afterEach(() => {
vi.restoreAllMocks();
});
test('renders render() result', () => {
const instance = new AutocompletePrompt({
input,
output,
render: () => 'foo',
options: testOptions,
});
instance.prompt();
expect(output.buffer).to.deep.equal([cursor.hide, 'foo']);
});
test('initial options match provided options', () => {
const instance = new AutocompletePrompt({
input,
output,
render: () => 'foo',
options: testOptions,
});
instance.prompt();
// Initial state should have all options
expect(instance.filteredOptions.length).to.equal(testOptions.length);
expect(instance.cursor).to.equal(0);
});
test('cursor navigation with event emitter', () => {
const instance = new AutocompletePrompt({
input,
output,
render: () => 'foo',
options: testOptions,
});
instance.prompt();
// Initial cursor should be at 0
expect(instance.cursor).to.equal(0);
// Directly trigger the cursor event with 'down'
instance.emit('key', '', { name: 'down' });
// After down event, cursor should be 1
expect(instance.cursor).to.equal(1);
// Trigger cursor event with 'up'
instance.emit('key', '', { name: 'up' });
// After up event, cursor should be back to 0
expect(instance.cursor).to.equal(0);
});
test('initialValue selects correct option', () => {
const instance = new AutocompletePrompt({
input,
output,
render: () => 'foo',
options: testOptions,
initialValue: ['cherry'],
});
// The cursor should be initialized to the cherry index
const cherryIndex = testOptions.findIndex((opt) => opt.value === 'cherry');
expect(instance.cursor).to.equal(cherryIndex);
// The selectedValue should be cherry
expect(instance.selectedValues).to.deep.equal(['cherry']);
});
test('initialValue defaults to first option when non-multiple', () => {
const instance = new AutocompletePrompt({
input,
output,
render: () => 'foo',
options: testOptions,
});
expect(instance.cursor).to.equal(0);
expect(instance.selectedValues).to.deep.equal(['apple']);
});
test('initialValue is empty when multiple', () => {
const instance = new AutocompletePrompt({
input,
output,
render: () => 'foo',
options: testOptions,
multiple: true,
});
expect(instance.cursor).to.equal(0);
expect(instance.selectedValues).to.deep.equal([]);
});
test('filtering through user input', () => {
const instance = new AutocompletePrompt({
input,
output,
render: () => 'foo',
options: testOptions,
});
instance.prompt();
// Initial state should have all options
expect(instance.filteredOptions.length).to.equal(testOptions.length);
// Simulate typing 'a' by emitting keypress event
input.emit('keypress', 'a', { name: 'a' });
// Check that filtered options are updated to include options with 'a'
expect(instance.filteredOptions.length).to.be.lessThan(testOptions.length);
// Check that 'apple' is in the filtered options
const hasApple = instance.filteredOptions.some((opt) => opt.value === 'apple');
expect(hasApple).to.equal(true);
});
test('default filter function works correctly', () => {
const instance = new AutocompletePrompt({
input,
output,
render: () => 'foo',
options: testOptions,
});
instance.prompt();
input.emit('keypress', 'a', { name: 'a' });
input.emit('keypress', 'p', { name: 'p' });
expect(instance.filteredOptions).toEqual([
{ value: 'apple', label: 'Apple' },
{ value: 'grape', label: 'Grape' },
]);
input.emit('keypress', 'z', { name: 'z' });
expect(instance.filteredOptions).toEqual([]);
});
test('submit without nav resolves to first option in non-multiple', async () => {
const instance = new AutocompletePrompt({
input,
output,
render: () => 'foo',
options: testOptions,
});
const promise = instance.prompt();
input.emit('keypress', '', { name: 'return' });
const result = await promise;
expect(instance.selectedValues).to.deep.equal(['apple']);
expect(result).to.equal('apple');
});
test('submit without nav resolves to [] in multiple', async () => {
const instance = new AutocompletePrompt({
input,
output,
render: () => 'foo',
options: testOptions,
multiple: true,
});
const promise = instance.prompt();
input.emit('keypress', '', { name: 'return' });
const result = await promise;
expect(instance.selectedValues).to.deep.equal([]);
expect(result).to.deep.equal([]);
});
test('Tab with empty input and placeholder fills input and submit returns matching option', async () => {
const instance = new AutocompletePrompt({
input,
output,
render: () => 'foo',
options: testOptions,
placeholder: 'apple',
});
const promise = instance.prompt();
input.emit('keypress', '\t', { name: 'tab' });
input.emit('keypress', '', { name: 'return' });
const result = await promise;
expect(instance.userInput).to.equal('apple');
expect(result).to.equal('apple');
});
test('Tab with non-matching placeholder does not fill input', async () => {
const instance = new AutocompletePrompt({
input,
output,
render: () => 'foo',
options: testOptions,
placeholder: 'Type to search...',
});
instance.prompt();
input.emit('keypress', '\t', { name: 'tab' });
// Placeholder does not match any option, so input must not be filled with placeholder
expect(instance.userInput).not.to.equal('Type to search...');
});
});
================================================
FILE: packages/core/test/prompts/confirm.test.ts
================================================
import { cursor } from 'sisteransi';
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import { default as ConfirmPrompt } from '../../src/prompts/confirm.js';
import { MockReadable } from '../mock-readable.js';
import { MockWritable } from '../mock-writable.js';
describe('ConfirmPrompt', () => {
let input: MockReadable;
let output: MockWritable;
beforeEach(() => {
input = new MockReadable();
output = new MockWritable();
});
afterEach(() => {
vi.restoreAllMocks();
});
test('renders render() result', () => {
const instance = new ConfirmPrompt({
input,
output,
render: () => 'foo',
active: 'yes',
inactive: 'no',
});
instance.prompt();
expect(output.buffer).to.deep.equal([cursor.hide, 'foo']);
});
test('sets value and submits on confirm (y)', () => {
const instance = new ConfirmPrompt({
input,
output,
render: () => 'foo',
active: 'yes',
inactive: 'no',
initialValue: true,
});
instance.prompt();
input.emit('keypress', 'y', { name: 'y' });
expect(instance.value).to.equal(true);
expect(instance.state).to.equal('submit');
});
test('sets value and submits on confirm (n)', () => {
const instance = new ConfirmPrompt({
input,
output,
render: () => 'foo',
active: 'yes',
inactive: 'no',
initialValue: true,
});
instance.prompt();
input.emit('keypress', 'n', { name: 'n' });
expect(instance.value).to.equal(false);
expect(instance.state).to.equal('submit');
});
describe('cursor', () => {
test('cursor is 1 when inactive', () => {
const instance = new ConfirmPrompt({
input,
output,
render: () => 'foo',
active: 'yes',
inactive: 'no',
initialValue: false,
});
instance.prompt();
input.emit('keypress', '', { name: 'return' });
expect(instance.cursor).to.equal(1);
});
test('cursor is 0 when active', () => {
const instance = new ConfirmPrompt({
input,
output,
render: () => 'foo',
active: 'yes',
inactive: 'no',
initialValue: true,
});
instance.prompt();
input.emit('keypress', '', { name: 'return' });
expect(instance.cursor).to.equal(0);
});
});
});
================================================
FILE: packages/core/test/prompts/date.test.ts
================================================
import { cursor } from 'sisteransi';
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import { default as DatePrompt } from '../../src/prompts/date.js';
import { isCancel } from '../../src/utils/index.js';
import { MockReadable } from '../mock-readable.js';
import { MockWritable } from '../mock-writable.js';
const d = (iso: string) => {
const [y, m, day] = iso.slice(0, 10).split('-').map(Number);
return new Date(Date.UTC(y, m - 1, day));
};
describe('DatePrompt', () => {
let input: MockReadable;
let output: MockWritable;
beforeEach(() => {
input = new MockReadable();
output = new MockWritable();
});
afterEach(() => {
vi.restoreAllMocks();
});
test('renders render() result', () => {
const instance = new DatePrompt({
input,
output,
render: () => 'foo',
format: 'YMD',
});
instance.prompt();
expect(output.buffer).to.deep.equal([cursor.hide, 'foo']);
});
test('initial value displays correctly', () => {
const instance = new DatePrompt({
input,
output,
render: () => 'foo',
format: 'YMD',
initialValue: d('2025-01-15'),
});
instance.prompt();
expect(instance.userInput).to.equal('2025/01/15');
expect(instance.value).toBeInstanceOf(Date);
expect(instance.value!.toISOString().slice(0, 10)).to.equal('2025-01-15');
});
test('left/right navigates between segments', () => {
const instance = new DatePrompt({
input,
output,
render: () => 'foo',
format: 'YMD',
initialValue: d('2025-01-15'),
});
instance.prompt();
expect(instance.segmentCursor).to.deep.equal({ segmentIndex: 0, positionInSegment: 0 });
input.emit('keypress', undefined, { name: 'right' });
expect(instance.segmentCursor).to.deep.equal({ segmentIndex: 1, positionInSegment: 0 });
input.emit('keypress', undefined, { name: 'right' });
expect(instance.segmentCursor).to.deep.equal({ segmentIndex: 2, positionInSegment: 0 });
input.emit('keypress', undefined, { name: 'left' });
expect(instance.segmentCursor).to.deep.equal({ segmentIndex: 1, positionInSegment: 0 });
});
test('up/down increments and decrements segment', () => {
const instance = new DatePrompt({
input,
output,
render: () => 'foo',
format: 'YMD',
initialValue: d('2025-01-15'),
});
instance.prompt();
input.emit('keypress', undefined, { name: 'right' }); // move to month
input.emit('keypress', undefined, { name: 'up' });
expect(instance.userInput).to.equal('2025/02/15');
input.emit('keypress', undefined, { name: 'down' });
expect(instance.userInput).to.equal('2025/01/15');
});
test('up/down on one segment leaves other segments blank', () => {
const instance = new DatePrompt({
input,
output,
render: () => 'foo',
format: 'YMD',
});
instance.prompt();
expect(instance.userInput).to.equal('____/__/__');
input.emit('keypress', undefined, { name: 'up' }); // up on year (first segment)
expect(instance.userInput).to.equal('0001/__/__');
input.emit('keypress', undefined, { name: 'right' }); // move to month
input.emit('keypress', undefined, { name: 'up' });
expect(instance.userInput).to.equal('0001/01/__');
});
test('with minDate/maxDate, up on blank segment starts at min', () => {
const instance = new DatePrompt({
input,
output,
render: () => 'foo',
format: 'YMD',
minDate: d('2025-03-10'),
maxDate: d('2025-11-20'),
});
instance.prompt();
expect(instance.userInput).to.equal('____/__/__');
input.emit('keypress', undefined, { name: 'up' });
expect(instance.userInput).to.equal('2025/__/__');
input.emit('keypress', undefined, { name: 'right' });
input.emit('keypress', undefined, { name: 'up' });
expect(instance.userInput).to.equal('2025/03/__');
input.emit('keypress', undefined, { name: 'right' });
input.emit('keypress', undefined, { name: 'up' });
expect(instance.userInput).to.equal('2025/03/10');
});
test('with minDate/maxDate, down on blank segment starts at max', () => {
const instance = new DatePrompt({
input,
output,
render: () => 'foo',
format: 'YMD',
minDate: d('2025-03-10'),
maxDate: d('2025-11-20'),
});
instance.prompt();
input.emit('keypress', undefined, { name: 'down' });
expect(instance.userInput).to.equal('2025/__/__');
input.emit('keypress', undefined, { name: 'right' });
input.emit('keypress', undefined, { name: 'down' });
expect(instance.userInput).to.equal('2025/11/__');
input.emit('keypress', undefined, { name: 'right' });
input.emit('keypress', undefined, { name: 'down' });
expect(instance.userInput).to.equal('2025/11/20');
});
test('digit-by-digit editing from left to right', () => {
const instance = new DatePrompt({
input,
output,
render: () => 'foo',
format: 'YMD',
initialValue: d('2025-01-15'),
});
instance.prompt();
expect(instance.segmentCursor).to.deep.equal({ segmentIndex: 0, positionInSegment: 0 });
// Type 2,0,2,3 to change year to 2023 (right-to-left fill)
input.emit('keypress', '2', { name: undefined, sequence: '2' });
expect(instance.userInput).to.equal('___2/01/15');
input.emit('keypress', '0', { name: undefined, sequence: '0' });
expect(instance.userInput).to.equal('__20/01/15');
input.emit('keypress', '2', { name: undefined, sequence: '2' });
expect(instance.userInput).to.equal('_202/01/15');
input.emit('keypress', '3', { name: undefined, sequence: '3' });
expect(instance.userInput).to.equal('2023/01/15');
});
test('backspace clears entire segment at any cursor position', () => {
const instance = new DatePrompt({
input,
output,
render: () => 'foo',
format: 'YMD',
initialValue: d('2025-12-21'),
});
instance.prompt();
expect(instance.userInput).to.equal('2025/12/21');
input.emit('keypress', undefined, { name: 'backspace', sequence: '\x7f' });
expect(instance.userInput).to.equal('____/12/21');
expect(instance.segmentCursor).to.deep.equal({ segmentIndex: 0, positionInSegment: 0 });
});
test('backspace clears segment when cursor at first char (2___)', () => {
const instance = new DatePrompt({
input,
output,
render: () => 'foo',
format: 'YMD',
});
instance.prompt();
input.emit('keypress', '2', { name: undefined, sequence: '2' });
expect(instance.userInput).to.equal('___2/__/__');
input.emit('keypress', '\x7f', { name: undefined, sequence: '\x7f' });
expect(instance.userInput).to.equal('____/__/__');
expect(instance.segmentCursor).to.deep.equal({ segmentIndex: 0, positionInSegment: 0 });
});
test('digit input updates segment and jumps to next when complete', () => {
const instance = new DatePrompt({
input,
output,
render: () => 'foo',
format: 'YMD',
});
instance.prompt();
for (const c of '2025') {
input.emit('keypress', c, { name: undefined, sequence: c });
}
expect(instance.userInput).to.equal('2025/__/__');
expect(instance.segmentCursor).to.deep.equal({ segmentIndex: 1, positionInSegment: 0 });
});
test('submit returns Date for valid date', async () => {
const instance = new DatePrompt({
input,
output,
render: () => 'foo',
format: 'YMD',
initialValue: d('2025-01-31'),
});
const resultPromise = instance.prompt();
input.emit('keypress', undefined, { name: 'return' });
const result = await resultPromise;
expect(result).toBeInstanceOf(Date);
expect((result as Date).toISOString().slice(0, 10)).to.equal('2025-01-31');
});
test('can cancel', async () => {
const instance = new DatePrompt({
input,
output,
render: () => 'foo',
format: 'YMD',
initialValue: d('2025-01-15'),
});
const resultPromise = instance.prompt();
input.emit('keypress', 'escape', { name: 'escape' });
const result = await resultPromise;
expect(isCancel(result)).toBe(true);
});
test('defaultValue used when invalid date submitted', async () => {
const instance = new DatePrompt({
input,
output,
render: () => 'foo',
format: 'YMD',
defaultValue: d('2025-06-15'),
});
const resultPromise = instance.prompt();
input.emit('keypress', undefined, { name: 'return' });
const result = await resultPromise;
expect(result).toBeInstanceOf(Date);
expect((result as Date).toISOString().slice(0, 10)).to.equal('2025-06-15');
});
test('supports MDY format', () => {
const instance = new DatePrompt({
input,
output,
render: () => 'foo',
format: 'MDY',
initialValue: d('2025-01-15'),
});
instance.prompt();
expect(instance.userInput).to.equal('01/15/2025');
});
test('supports DMY format', () => {
const instance = new DatePrompt({
input,
output,
render: () => 'foo',
format: 'DMY',
initialValue: d('2025-01-15'),
});
instance.prompt();
expect(instance.userInput).to.equal('15/01/2025');
});
test('rejects invalid month via pending tens digit', () => {
const instance = new DatePrompt({
input,
output,
render: () => 'foo',
format: 'YMD',
});
instance.prompt();
// Navigate to month
input.emit('keypress', undefined, { name: 'right' });
// Type '1' → '01' with pending tens digit (since 1 <= 1)
input.emit('keypress', '1', { name: undefined, sequence: '1' });
expect(instance.segmentValues.month).to.equal('01');
// Type '3' → tries '13' which is > 12 → inline error
input.emit('keypress', '3', { name: undefined, sequence: '3' });
expect(instance.inlineError).to.equal('There are only 12 months in a year');
});
test('rejects invalid day via pending tens digit', () => {
const instance = new DatePrompt({
input,
output,
render: () => 'foo',
format: 'YMD',
});
instance.prompt();
// Navigate to day
input.emit('keypress', undefined, { name: 'right' });
input.emit('keypress', undefined, { name: 'right' });
// Type '2' → '02' with pending (2 <= 2)
input.emit('keypress', '2', { name: undefined, sequence: '2' });
input.emit('keypress', '0', { name: undefined, sequence: '0' });
expect(instance.inlineError).to.equal('');
});
describe('segmentValues and segmentCursor', () => {
test('segmentValues reflects current input', () => {
const instance = new DatePrompt({
input,
output,
render: () => 'foo',
format: 'YMD',
initialValue: d('2025-01-15'),
});
instance.prompt();
const segmentValues = instance.segmentValues;
expect(segmentValues.year).to.equal('2025');
expect(segmentValues.month).to.equal('01');
expect(segmentValues.day).to.equal('15');
});
test('segmentCursor tracks cursor position', () => {
const instance = new DatePrompt({
input,
output,
render: () => 'foo',
format: 'YMD',
initialValue: d('2025-01-15'),
});
instance.prompt();
input.emit('keypress', undefined, { name: 'right' }); // move to month
const cursor = instance.segmentCursor;
expect(cursor.segmentIndex).to.equal(1);
expect(cursor.positionInSegment).to.equal(0);
});
test('segmentValues updates on submit', () => {
const instance = new DatePrompt({
input,
output,
render: () => 'foo',
format: 'YMD',
initialValue: d('2025-01-15'),
});
instance.prompt();
input.emit('keypress', undefined, { name: 'return' });
const segmentValues = instance.segmentValues;
expect(segmentValues.year).to.equal('2025');
expect(segmentValues.month).to.equal('01');
expect(segmentValues.day).to.equal('15');
});
});
describe('formattedValue and segments', () => {
test('formattedValue returns formatted string', () => {
const instance = new DatePrompt({
input,
output,
render: () => 'foo',
format: 'MDY',
initialValue: d('2025-03-15'),
});
instance.prompt();
expect(instance.formattedValue).to.equal('03/15/2025');
});
test('segments exposes segment config', () => {
const instance = new DatePrompt({
input,
output,
render: () => 'foo',
format: 'DMY',
});
instance.prompt();
expect(instance.segments).to.deep.equal([
{ type: 'day', len: 2 },
{ type: 'month', len: 2 },
{ type: 'year', len: 4 },
]);
});
test('separator defaults to / for explicit format', () => {
const instance = new DatePrompt({
input,
output,
render: () => 'foo',
format: 'YMD',
});
instance.prompt();
expect(instance.separator).to.equal('/');
});
});
describe('locale detection', () => {
test('locale auto-detects format from Intl', () => {
const instance = new DatePrompt({
input,
output,
render: () => 'foo',
locale: 'en-US',
initialValue: d('2025-03-15'),
});
instance.prompt();
// en-US is MDY
expect(instance.segments[0].type).to.equal('month');
expect(instance.segments[1].type).to.equal('day');
expect(instance.segments[2].type).to.equal('year');
});
test('explicit format overrides locale', () => {
const instance = new DatePrompt({
input,
output,
render: () => 'foo',
format: 'YMD',
locale: 'en-US', // would be MDY, but format takes precedence
initialValue: d('2025-03-15'),
});
instance.prompt();
expect(instance.segments[0].type).to.equal('year');
});
});
});
================================================
FILE: packages/core/test/prompts/multi-select.test.ts
================================================
import { cursor } from 'sisteransi';
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import { default as MultiSelectPrompt } from '../../src/prompts/multi-select.js';
import { MockReadable } from '../mock-readable.js';
import { MockWritable } from '../mock-writable.js';
describe('MultiSelectPrompt', () => {
let input: MockReadable;
let output: MockWritable;
beforeEach(() => {
input = new MockReadable();
output = new MockWritable();
});
afterEach(() => {
vi.restoreAllMocks();
});
test('renders render() result', () => {
const instance = new MultiSelectPrompt({
input,
output,
render: () => 'foo',
options: [{ value: 'foo' }, { value: 'bar' }],
});
instance.prompt();
expect(output.buffer).to.deep.equal([cursor.hide, 'foo']);
});
describe('cursor', () => {
test('cursor is index of selected item', () => {
const instance = new MultiSelectPrompt({
input,
output,
render: () => 'foo',
options: [{ value: 'foo' }, { value: 'bar' }],
});
instance.prompt();
expect(instance.cursor).to.equal(0);
input.emit('keypress', 'down', { name: 'down' });
expect(instance.cursor).to.equal(1);
});
test('cursor loops around', () => {
const instance = new MultiSelectPrompt({
input,
output,
render: () => 'foo',
options: [{ value: 'foo' }, { value: 'bar' }, { value: 'baz' }],
});
instance.prompt();
expect(instance.cursor).to.equal(0);
input.emit('keypress', 'up', { name: 'up' });
expect(instance.cursor).to.equal(2);
input.emit('keypress', 'down', { name: 'down' });
expect(instance.cursor).to.equal(0);
});
test('left behaves as up', () => {
const instance = new MultiSelectPrompt({
input,
output,
render: () => 'foo',
options: [{ value: 'foo' }, { value: 'bar' }, { value: 'baz' }],
});
instance.prompt();
input.emit('keypress', 'left', { name: 'left' });
expect(instance.cursor).to.equal(2);
});
test('right behaves as down', () => {
const instance = new MultiSelectPrompt({
input,
output,
render: () => 'foo',
options: [{ value: 'foo' }, { value: 'bar' }],
});
instance.prompt();
input.emit('keypress', 'left', { name: 'left' });
expect(instance.cursor).to.equal(1);
});
test('initial values is selected', () => {
const instance = new MultiSelectPrompt({
input,
output,
render: () => 'foo',
options: [{ value: 'foo' }, { value: 'bar' }],
initialValues: ['bar'],
});
instance.prompt();
expect(instance.value).toEqual(['bar']);
});
test('select all when press "a" key', () => {
const instance = new MultiSelectPrompt({
input,
output,
render: () => 'foo',
options: [{ value: 'foo' }, { value: 'bar' }],
});
instance.prompt();
input.emit('keypress', 'down', { name: 'down' });
input.emit('keypress', 'space', { name: 'space' });
input.emit('keypress', 'a', { name: 'a' });
expect(instance.value).toEqual(['foo', 'bar']);
});
test('select invert when press "i" key', () => {
const instance = new MultiSelectPrompt({
input,
output,
render: () => 'foo',
options: [{ value: 'foo' }, { value: 'bar' }],
});
instance.prompt();
input.emit('keypress', 'down', { name: 'down' });
input.emit('keypress', 'space', { name: 'space' });
input.emit('keypress', 'i', { name: 'i' });
expect(instance.value).toEqual(['foo']);
});
test('disabled options are skipped', () => {
const instance = new MultiSelectPrompt({
input,
output,
render: () => 'foo',
options: [{ value: 'foo' }, { value: 'bar', disabled: true }, { value: 'baz' }],
});
instance.prompt();
expect(instance.cursor).to.equal(0);
input.emit('keypress', 'down', { name: 'down' });
expect(instance.cursor).to.equal(2);
input.emit('keypress', 'up', { name: 'up' });
expect(instance.cursor).to.equal(0);
});
test('initial cursorAt on disabled option', () => {
const instance = new MultiSelectPrompt({
input,
output,
render: () => 'foo',
options: [{ value: 'foo' }, { value: 'bar', disabled: true }, { value: 'baz' }],
cursorAt: 'bar',
});
instance.prompt();
expect(instance.cursor).to.equal(2);
});
});
describe('toggleAll', () => {
test('selects all enabled options', () => {
const instance = new MultiSelectPrompt({
input,
output,
render: () => 'foo',
options: [{ value: 'foo' }, { value: 'bar', disabled: true }, { value: 'baz' }],
});
instance.prompt();
input.emit('keypress', 'a', { name: 'a' });
expect(instance.value).toEqual(['foo', 'baz']);
});
test('unselects all enabled options if all selected', () => {
const instance = new MultiSelectPrompt({
input,
output,
render: () => 'foo',
options: [{ value: 'foo' }, { value: 'bar', disabled: true }, { value: 'baz' }],
initialValues: ['foo', 'baz'],
});
instance.prompt();
input.emit('keypress', 'a', { name: 'a' });
expect(instance.value).toEqual([]);
});
});
describe('toggleInvert', () => {
test('inverts selection of enabled options', () => {
const instance = new MultiSelectPrompt({
input,
output,
render: () => 'foo',
options: [
{ value: 'foo' },
{ value: 'bar', disabled: true },
{ value: 'baz' },
{ value: 'qux' },
],
initialValues: ['foo', 'baz'],
});
instance.prompt();
input.emit('keypress', 'i', { name: 'i' });
expect(instance.value).toEqual(['qux']);
});
});
});
================================================
FILE: packages/core/test/prompts/password.test.ts
================================================
import { styleText } from 'node:util';
import { cursor } from 'sisteransi';
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import { default as PasswordPrompt } from '../../src/prompts/password.js';
import { MockReadable } from '../mock-readable.js';
import { MockWritable } from '../mock-writable.js';
describe('PasswordPrompt', () => {
let input: MockReadable;
let output: MockWritable;
beforeEach(() => {
input = new MockReadable();
output = new MockWritable();
});
afterEach(() => {
vi.restoreAllMocks();
});
test('renders render() result', () => {
const instance = new PasswordPrompt({
input,
output,
render: () => 'foo',
});
// leave the promise hanging since we don't want to submit in this test
instance.prompt();
expect(output.buffer).to.deep.equal([cursor.hide, 'foo']);
});
describe('cursor', () => {
test('can get cursor', () => {
const instance = new PasswordPrompt({
input,
output,
render: () => 'foo',
});
expect(instance.cursor).to.equal(0);
});
});
describe('userInputWithCursor', () => {
test('returns masked value on submit', () => {
const instance = new PasswordPrompt({
input,
output,
render: () => 'foo',
});
instance.prompt();
const keys = 'foo';
for (let i = 0; i < keys.length; i++) {
input.emit('keypress', keys[i], { name: keys[i] });
}
input.emit('keypress', '', { name: 'return' });
expect(instance.userInputWithCursor).to.equal('•••');
});
test('renders marker at end', () => {
const instance = new PasswordPrompt({
input,
output,
render: () => 'foo',
});
instance.prompt();
input.emit('keypress', 'x', { name: 'x' });
expect(instance.userInputWithCursor).to.equal(`•${styleText(['inverse', 'hidden'], '_')}`);
});
test('renders cursor inside value', () => {
const instance = new PasswordPrompt({
input,
output,
render: () => 'foo',
});
instance.prompt();
input.emit('keypress', 'x', { name: 'x' });
input.emit('keypress', 'y', { name: 'y' });
input.emit('keypress', 'z', { name: 'z' });
input.emit('keypress', undefined, { name: 'left' });
input.emit('keypress', undefined, { name: 'left' });
expect(instance.userInputWithCursor).to.equal(`•${styleText('inverse', '•')}•`);
});
test('renders custom mask', () => {
const instance = new PasswordPrompt({
input,
output,
render: () => 'foo',
mask: 'X',
});
instance.prompt();
input.emit('keypress', 'x', { name: 'x' });
expect(instance.userInputWithCursor).to.equal(`X${styleText(['inverse', 'hidden'], '_')}`);
});
});
});
================================================
FILE: packages/core/test/prompts/prompt.test.ts
================================================
import { cursor } from 'sisteransi';
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import { default as Prompt } from '../../src/prompts/prompt.js';
import { isCancel } from '../../src/utils/index.js';
import { MockReadable } from '../mock-readable.js';
import { MockWritable } from '../mock-writable.js';
describe('Prompt', () => {
let input: MockReadable;
let output: MockWritable;
beforeEach(() => {
input = new MockReadable();
output = new MockWritable();
});
afterEach(() => {
vi.restoreAllMocks();
});
test('renders render() result', () => {
const instance = new Prompt({
input,
output,
render: () => 'foo',
});
// leave the promise hanging since we don't want to submit in this test
instance.prompt();
expect(output.buffer).to.deep.equal([cursor.hide, 'foo']);
});
test('submits on return key', async () => {
const instance = new Prompt({
input,
output,
render: () => 'foo',
});
const resultPromise = instance.prompt();
input.emit('keypress', '', { name: 'return' });
const result = await resultPromise;
expect(result).to.equal(undefined);
expect(isCancel(result)).to.equal(false);
expect(instance.state).to.equal('submit');
expect(output.buffer).to.deep.equal([cursor.hide, 'foo', '\n', cursor.show]);
});
test('cancels on ctrl-c', async () => {
const instance = new Prompt({
input,
output,
render: () => 'foo',
});
const resultPromise = instance.prompt();
input.emit('keypress', '\x03', { name: 'c' });
const result = await resultPromise;
expect(isCancel(result)).to.equal(true);
expect(instance.state).to.equal('cancel');
expect(output.buffer).to.deep.equal([cursor.hide, 'foo', '\n', cursor.show]);
});
test('does not write initialValue to value', () => {
const eventSpy = vi.fn();
const instance = new Prompt({
input,
output,
render: () => 'foo',
initialValue: 'bananas',
});
instance.on('value', eventSpy);
instance.prompt();
expect(instance.value).to.equal(undefined);
expect(eventSpy).not.toHaveBeenCalled();
});
test('re-renders on resize', () => {
const renderFn = vi.fn().mockImplementation(() => 'foo');
const instance = new Prompt({
input,
output,
render: renderFn,
});
instance.prompt();
expect(renderFn).toHaveBeenCalledTimes(1);
output.emit('resize');
expect(renderFn).toHaveBeenCalledTimes(2);
});
test('state is active after first render', async () => {
const instance = new Prompt({
input,
output,
render: () => 'foo',
});
expect(instance.state).to.equal('initial');
instance.prompt();
expect(instance.state).to.equal('active');
});
test('emits truthy confirm on y press', () => {
const eventFn = vi.fn();
const instance = new Prompt({
input,
output,
render: () => 'foo',
});
instance.on('confirm', eventFn);
instance.prompt();
input.emit('keypress', 'y', { name: 'y' });
expect(eventFn).toBeCalledWith(true);
});
test('emits falsey confirm on n press', () => {
const eventFn = vi.fn();
const instance = new Prompt({
input,
output,
render: () => 'foo',
});
instance.on('confirm', eventFn);
instance.prompt();
input.emit('keypress', 'n', { name: 'n' });
expect(eventFn).toBeCalledWith(false);
});
test('emits key event for unknown chars', () => {
const eventSpy = vi.fn();
const instance = new Prompt({
input,
output,
render: () => 'foo',
});
instance.on('key', eventSpy);
instance.prompt();
input.emit('keypress', 'z', { name: 'z' });
expect(eventSpy).toBeCalledWith('z', { name: 'z' });
});
test('emits cursor events for movement keys', () => {
const keys = ['up', 'down', 'left', 'right'];
const eventSpy = vi.fn();
const instance = new Prompt({
input,
output,
render: () => 'foo',
});
instance.on('cursor', eventSpy);
instance.prompt();
for (const key of keys) {
input.emit('keypress', key, { name: key });
expect(eventSpy).toBeCalledWith(key);
}
});
test('emits cursor events for movement key aliases when not tracking', () => {
const keys = [
['k', 'up'],
['j', 'down'],
['h', 'left'],
['l', 'right'],
];
const eventSpy = vi.fn();
const instance = new Prompt(
{
input,
output,
render: () => 'foo',
},
false
);
instance.on('cursor', eventSpy);
instance.prompt();
for (const [alias, key] of keys) {
input.emit('keypress', alias, { name: alias });
expect(eventSpy).toBeCalledWith(key);
}
});
test('aborts on abort signal', () => {
const abortController = new AbortController();
const instance = new Prompt({
input,
output,
render: () => 'foo',
signal: abortController.signal,
});
instance.prompt();
expect(instance.state).to.equal('active');
abortController.abort();
expect(instance.state).to.equal('cancel');
});
test('returns immediately if signal is already aborted', () => {
const abortController = new AbortController();
abortController.abort();
const instance = new Prompt({
input,
output,
render: () => 'foo',
signal: abortController.signal,
});
instance.prompt();
expect(instance.state).to.equal('cancel');
});
test('accepts invalid initial value', () => {
const instance = new Prompt({
input,
output,
render: () => 'foo',
initialValue: 'invalid',
validate: (value) => (value === 'valid' ? undefined : 'must be valid'),
});
instance.prompt();
expect(instance.state).to.equal('active');
expect(instance.error).to.equal('');
});
test('validates value on return', () => {
const instance = new Prompt({
input,
output,
render: () => 'foo',
validate: (value) => (value === 'valid' ? undefined : 'must be valid'),
});
instance.prompt();
instance.value = 'invalid';
input.emit('keypress', '', { name: 'return' });
expect(instance.state).to.equal('error');
expect(instance.error).to.equal('must be valid');
});
test('validates value with Error object', () => {
const instance = new Prompt({
input,
output,
render: () => 'foo',
validate: (value) => (value === 'valid' ? undefined : new Error('must be valid')),
});
instance.prompt();
instance.value = 'invalid';
input.emit('keypress', '', { name: 'return' });
expect(instance.state).to.equal('error');
expect(instance.error).to.equal('must be valid');
});
test('validates value with regex validation', () => {
const instance = new Prompt({
input,
output,
render: () => 'foo',
validate: (value) => (/^[A-Z]+$/.test(value ?? '') ? undefined : 'Invalid value'),
});
instance.prompt();
instance.value = 'Invalid Value $$$';
input.emit('keypress', '', { name: 'return' });
expect(instance.state).to.equal('error');
expect(instance.error).to.equal('Invalid value');
});
test('accepts valid value with regex validation', () => {
const instance = new Prompt({
input,
output,
render: () => 'foo',
validate: (value) => (/^[A-Z]+$/.test(value ?? '') ? undefined : 'Invalid value'),
});
instance.prompt();
instance.value = 'VALID';
input.emit('keypress', '', { name: 'return' });
expect(instance.state).to.equal('submit');
expect(instance.error).to.equal('');
});
});
================================================
FILE: packages/core/test/prompts/select.test.ts
================================================
import { cursor } from 'sisteransi';
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import { default as SelectPrompt } from '../../src/prompts/select.js';
import { MockReadable } from '../mock-readable.js';
import { MockWritable } from '../mock-writable.js';
describe('SelectPrompt', () => {
let input: MockReadable;
let output: MockWritable;
beforeEach(() => {
input = new MockReadable();
output = new MockWritable();
});
afterEach(() => {
vi.restoreAllMocks();
});
test('renders render() result', () => {
const instance = new SelectPrompt({
input,
output,
render: () => 'foo',
options: [{ value: 'foo' }, { value: 'bar' }],
});
instance.prompt();
expect(output.buffer).to.deep.equal([cursor.hide, 'foo']);
});
describe('cursor', () => {
test('cursor is index of selected item', () => {
const instance = new SelectPrompt({
input,
output,
render: () => 'foo',
options: [{ value: 'foo' }, { value: 'bar' }],
});
instance.prompt();
expect(instance.cursor).to.equal(0);
input.emit('keypress', 'down', { name: 'down' });
expect(instance.cursor).to.equal(1);
});
test('cursor loops around', () => {
const instance = new SelectPrompt({
input,
output,
render: () => 'foo',
options: [{ value: 'foo' }, { value: 'bar' }, { value: 'baz' }],
});
instance.prompt();
expect(instance.cursor).to.equal(0);
input.emit('keypress', 'up', { name: 'up' });
expect(instance.cursor).to.equal(2);
input.emit('keypress', 'down', { name: 'down' });
expect(instance.cursor).to.equal(0);
});
test('left behaves as up', () => {
const instance = new SelectPrompt({
input,
output,
render: () => 'foo',
options: [{ value: 'foo' }, { value: 'bar' }, { value: 'baz' }],
});
instance.prompt();
input.emit('keypress', 'left', { name: 'left' });
expect(instance.cursor).to.equal(2);
});
test('right behaves as down', () => {
const instance = new SelectPrompt({
input,
output,
render: () => 'foo',
options: [{ value: 'foo' }, { value: 'bar' }],
});
instance.prompt();
input.emit('keypress', 'left', { name: 'left' });
expect(instance.cursor).to.equal(1);
});
test('initial value is selected', () => {
const instance = new SelectPrompt({
input,
output,
render: () => 'foo',
options: [{ value: 'foo' }, { value: 'bar' }],
initialValue: 'bar',
});
instance.prompt();
expect(instance.cursor).to.equal(1);
});
test('cursor skips disabled options (down)', () => {
const instance = new SelectPrompt({
input,
output,
render: () => 'foo',
options: [{ value: 'foo' }, { value: 'bar', disabled: true }, { value: 'baz' }],
});
instance.prompt();
expect(instance.cursor).to.equal(0);
input.emit('keypress', 'down', { name: 'down' });
expect(instance.cursor).to.equal(2);
});
test('cursor skips disabled options (up)', () => {
const instance = new SelectPrompt({
input,
output,
render: () => 'foo',
initialValue: 'baz',
options: [{ value: 'foo' }, { value: 'bar', disabled: true }, { value: 'baz' }],
});
instance.prompt();
expect(instance.cursor).to.equal(2);
input.emit('keypress', 'up', { name: 'up' });
expect(instance.cursor).to.equal(0);
});
test('cursor skips initial disabled option', () => {
const instance = new SelectPrompt({
input,
output,
render: () => 'foo',
options: [{ value: 'foo', disabled: true }, { value: 'bar' }, { value: 'baz' }],
});
instance.prompt();
expect(instance.cursor).to.equal(1);
});
});
});
================================================
FILE: packages/core/test/prompts/text.test.ts
================================================
import { styleText } from 'node:util';
import { cursor } from 'sisteransi';
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import { default as TextPrompt } from '../../src/prompts/text.js';
import { MockReadable } from '../mock-readable.js';
import { MockWritable } from '../mock-writable.js';
describe('TextPrompt', () => {
let input: MockReadable;
let output: MockWritable;
beforeEach(() => {
input = new MockReadable();
output = new MockWritable();
});
afterEach(() => {
vi.restoreAllMocks();
});
test('renders render() result', () => {
const instance = new TextPrompt({
input,
output,
render: () => 'foo',
});
// leave the promise hanging since we don't want to submit in this test
instance.prompt();
expect(output.buffer).to.deep.equal([cursor.hide, 'foo']);
});
test('sets default value on finalize if no value', async () => {
const instance = new TextPrompt({
input,
output,
render: () => 'foo',
defaultValue: 'bleep bloop',
});
const resultPromise = instance.prompt();
input.emit('keypress', '', { name: 'return' });
const result = await resultPromise;
expect(result).to.equal('bleep bloop');
});
test('keeps value on finalize', async () => {
const instance = new TextPrompt({
input,
output,
render: () => 'foo',
defaultValue: 'bleep bloop',
});
const resultPromise = instance.prompt();
input.emit('keypress', 'x', { name: 'x' });
input.emit('keypress', '', { name: 'return' });
const result = await resultPromise;
expect(result).to.equal('x');
});
describe('cursor', () => {
test('can get cursor', () => {
const instance = new TextPrompt({
input,
output,
render: () => 'foo',
});
expect(instance.cursor).to.equal(0);
});
});
describe('userInputWithCursor', () => {
test('returns value on submit', () => {
const instance = new TextPrompt({
input,
output,
render: () => 'foo',
});
instance.prompt();
input.emit('keypress', 'x', { name: 'x' });
input.emit('keypress', '', { name: 'return' });
expect(instance.userInputWithCursor).to.equal('x');
});
test('highlights cursor position', () => {
const instance = new TextPrompt({
input,
output,
render: () => 'foo',
});
instance.prompt();
const keys = 'foo';
for (let i = 0; i < keys.length; i++) {
input.emit('keypress', keys[i], { name: keys[i] });
}
input.emit('keypress', undefined, { name: 'left' });
expect(instance.userInputWithCursor).to.equal(`fo${styleText('inverse', 'o')}`);
});
test('shows cursor at end if beyond value', () => {
const instance = new TextPrompt({
input,
output,
render: () => 'foo',
});
instance.prompt();
const keys = 'foo';
for (let i = 0; i < keys.length; i++) {
input.emit('keypress', keys[i], { name: keys[i] });
}
input.emit('keypress', undefined, { name: 'right' });
expect(instance.userInputWithCursor).to.equal('foo█');
});
test('does not use placeholder as value when pressing enter', async () => {
const instance = new TextPrompt({
input,
output,
render: () => 'foo',
placeholder: ' (hit Enter to use default)',
defaultValue: 'default-value',
});
const resultPromise = instance.prompt();
input.emit('keypress', '', { name: 'return' });
const result = await resultPromise;
expect(result).to.equal('default-value');
});
test('returns empty string when no value and no default', async () => {
const instance = new TextPrompt({
input,
output,
render: () => 'foo',
placeholder: ' (hit Enter to use default)',
});
const resultPromise = instance.prompt();
input.emit('keypress', '', { name: 'return' });
const result = await resultPromise;
expect(result).to.equal('');
});
});
});
================================================
FILE: packages/core/test/utils.test.ts
================================================
import type { Key } from 'node:readline';
import { cursor } from 'sisteransi';
import { afterEach, describe, expect, test, vi } from 'vitest';
import { block } from '../src/utils/index.js';
import { MockReadable } from './mock-readable.js';
import { MockWritable } from './mock-writable.js';
describe('utils', () => {
afterEach(() => {
vi.restoreAllMocks();
});
describe('block', () => {
test('clears output on keypress', () => {
const input = new MockReadable();
const output = new MockWritable();
const callback = block({ input, output });
const event: Key = {
name: 'x',
};
const eventData = Buffer.from('bloop');
input.emit('keypress', eventData, event);
callback();
expect(output.buffer).to.deep.equal([cursor.hide, cursor.move(-1, 0), cursor.show]);
});
test('clears output vertically when return pressed', () => {
const input = new MockReadable();
const output = new MockWritable();
const callback = block({ input, output });
const event: Key = {
name: 'return',
};
const eventData = Buffer.from('bloop');
input.emit('keypress', eventData, event);
callback();
expect(output.buffer).to.deep.equal([cursor.hide, cursor.move(0, -1), cursor.show]);
});
test('ignores additional keypresses after dispose', () => {
const input = new MockReadable();
const output = new MockWritable();
const callback = block({ input, output });
const event: Key = {
name: 'x',
};
const eventData = Buffer.from('bloop');
input.emit('keypress', eventData, event);
callback();
input.emit('keypress', eventData, event);
expect(output.buffer).to.deep.equal([cursor.hide, cursor.move(-1, 0), cursor.show]);
});
test('exits on ctrl-c', () => {
const input = new MockReadable();
const output = new MockWritable();
// purposely don't keep the callback since we would exit the process
block({ input, output });
const spy = vi.spyOn(process, 'exit').mockImplementation((() => {
return;
}) as () => never);
const event: Key = {
name: 'c',
};
const eventData = Buffer.from('\x03');
input.emit('keypress', eventData, event);
expect(spy).toHaveBeenCalled();
expect(output.buffer).to.deep.equal([cursor.hide, cursor.show]);
});
test('does not clear if overwrite=false', () => {
const input = new MockReadable();
const output = new MockWritable();
const callback = block({ input, output, overwrite: false });
const event: Key = {
name: 'c',
};
const eventData = Buffer.from('bloop');
input.emit('keypress', eventData, event);
callback();
expect(output.buffer).to.deep.equal([cursor.hide, cursor.show]);
});
});
});
================================================
FILE: packages/core/tsconfig.json
================================================
{
"extends": "../../tsconfig.json",
"include": ["src", "test"]
}
================================================
FILE: packages/prompts/CHANGELOG.md
================================================
# @clack/prompts
## 1.1.0
### Minor Changes
- e3333fb: Replaces `picocolors` with Node.js built-in `styleText`.
### Patch Changes
- c3666e2: destruct `limitOption` param for better code readability, tweak types definitions
- ba3df8e: Fixes withGuide support in intro, outro, and cancel messages.
- Updated dependencies [e3333fb]
- @clack/core@1.1.0
## 1.0.1
### Patch Changes
- 6404dc1: Disallows selection of `disabled` options in autocomplete.
- 86e36d8: Adds `withGuide` support to select prompt.
- c697439: Fixes line wrapping behavior in autocomplete.
- 0ded19c: Simplifies `withGuide` option checks.
- 0e4ddc9: Fixes `withGuide` support in password and path prompts.
- 76550d6: Adds `withGuide` support to selectKey prompt.
- f9b9953: Adds `withGuide` support to password prompt.
- 0e93ccb: Adds `vertical` arrangement option to `confirm` prompt.
- 4e9ae13: Adds `withGuide` support to confirm prompt.
- 0256238: Adds `withGuide` support to spinner prompt.
- Updated dependencies [6404dc1]
- Updated dependencies [2533180]
- @clack/core@1.0.1
## 1.0.0
### Major Changes
- c713fd5: The package is now distributed as ESM-only. In `v0` releases, the package was dual-published as CJS and ESM.
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).
### Minor Changes
- 415410b: This adds a custom filter function to autocompleteMultiselect. It could be used, for example, to support fuzzy searching logic.
- 7bc3301: Prompts now have a `userInput` stored separately from their `value`.
- 8409f2c: feat: add styleFrame option for spinner
- 2837845: Adds suggestion and path prompts
- 99c3530: Adds `format` option to the note prompt to allow formatting of individual lines
- 0aaee4c: Added new `taskLog` prompt for log output which is cleared on success
- 729bbb6: 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.
This update also improves the architecture by exposing the core settings to the prompts package, enabling more consistent default message handling across the codebase.
```ts
// Per-instance customization
const spinner = prompts.spinner({
cancelMessage: "Operación cancelada", // "Operation cancelled" in Spanish
errorMessage: "Se produjo un error", // "An error occurred" in Spanish
});
// Global customization via updateSettings
prompts.updateSettings({
messages: {
cancel: "Operación cancelada", // "Operation cancelled" in Spanish
error: "Se produjo un error", // "An error occurred" in Spanish
},
});
// Settings can now be accessed directly
console.log(prompts.settings.messages.cancel); // "Operación cancelada"
// Direct options take priority over global settings
const spinner = prompts.spinner({
cancelMessage: "Cancelled", // This will be used instead of the global setting
});
```
- 44df9af: 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.
- 55645c2: Support wrapping autocomplete and select prompts.
- 9e5bc6c: Add support for signals in prompts, allowing them to be aborted.
- f2c2b89: Adds `AutocompletePrompt` to core with comprehensive tests and implement both `autocomplete` and `autocomplete-multiselect` components in prompts package.
- 38019c7: Updates the API for stopping spinners and progress bars to be clearer
Previously, both the spinner and progress bar components used a single `stop` method that accepted a code to indicate success, cancellation, or error. This update separates these into distinct methods: `stop()`, `cancel()`, and `error()`:
```diff
const spinner = prompts.spinner();
spinner.start();
// Cancelling a spinner
- spinner.stop(undefined, 1);
+ spinner.cancel();
// Stopping with an error
- spinner.stop(undefined, 2);
+ spinner.error();
```
As before, you can pass a message to each method to customize the output displayed:
```js
spinner.cancel("Operation cancelled by user");
progressBar.error("An error occurred during processing");
```
- c45b9fb: Adds support for detecting spinner cancellation via CTRL+C. This allows for graceful handling of user interruptions during long-running operations.
- f10071e: Using the `group` method, task logs can now have groups which themselves can have scrolling windows of logs.
- df4eea1: Remove `suggestion` prompt and change `path` prompt to be an autocomplete prompt.
- 76fd17f: Added new `box` prompt for rendering boxed text, similar a note.
- 9a09318: Adds new `progress` prompt to display a progess-bar
- 1604f97: Add `clearOnError` option to password prompt to automatically clear input when validation fails
- 9bd8072: Add a `required` option to autocomplete multiselect.
- 19558b9: Added support for custom frames in spinner prompt
### Patch Changes
- 46dc0a4: Fixes multiselect only shows hints on the first item in the options list. Now correctly shows hints for all selected options with hint property.
- aea4573: Clamp scrolling windows to 5 rows.
- bfe0dd3: Prevents placeholder from being used as input value in text prompts
- 55eb280: Fix placeholder rendering when using autocomplete.
- 4d1d83b: Fixes rendering of multi-line messages and options in select prompt.
- 6176ced: Add withGuide support to note prompt
- 7b009df: Fix spinner clearing too many lines upwards when non-wrapping.
- 43aed55: Change styling of disabled multi-select options to have strikethrough.
- 17342d2: Exposes a new `SpinnerResult` type to describe the return type of `spinner`
- 282b39e: Wrap spinner output to allow for multi-line/wrapped messages.
- 2feaebb: Fix duplicated logs when scrolling through options with multiline messages by calculating `rowPadding` dynamically based on actual rendered lines instead of using a hardcoded value.
- 69681ea: Strip destructive ANSI codes from task log messages.
- b0fa7d8: Add support for wrapped messages in multi line prompts
- 9999adf: fix note component overflow bug
- 6868c1c: 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.
- 7df841d: Removed all trailing space in prompt output and fixed various padding rendering bugs.
- 2839c66: fix(note): hard wrap text to column limit
- 7a556ad: Updates all prompts to accept a custom `output` and `input` stream
- 17d3650: Use a default import for picocolors to avoid run time errors in some environments.
- 7cc8a55: Messages passed to the `stop` method of a spinner no longer have dots stripped.
- b103ad3: Allow disabled options in multi-select and select prompts.
- 71b5029: Add missing nullish checks around values.
- 1a45f93: Switched from wrap-ansi to fast-wrap-ansi
- f952592: Fixes missing guide when rendering empty log lines.
- 372b526: Add `clear` method to spinner for stopping and clearing.
- d25f6d0: fix(note, box): handle CJK correctly
- 94fee2a: Changes `placeholder` to be a visual hint rather than a tabbable value.
- 7530af0: Fixes wrapping of cancelled and success messages of select prompt
- 4c89dd7: chore: use more accurate type to replace any in group select
- 0b852e1: Handle `stop` calls on spinners which have not yet been started.
- 42adff8: fix: add missing guide line in autocomplete-multiselect
- 8e2e30a: fix: fix autocomplete bar color when validate
- 2048eb1: Fix spinner's dots behavior with custom frames
- acc4c3a: Add a new `withGuide` option to all prompts to disable the default clack border
- 9b92161: Show symbol when withGuide is true for log messages
- 68dbf9b: select-key: Fixed wrapping and added new `caseSensitive` option
- 09e596c: refactor(progress): remove unnecessary return statement in start function
- 2310b43: Allow custom writables as output stream.
- ae84dd0: Update key binding text to show tab/space when navigating, and tab otherwise.
- Updated dependency on `@clack/core` to `1.0.0`
## 0.10.0
### Minor Changes
- 613179d: Adds a new `indicator` option to `spinner`, which supports the original `"dots"` loading animation or a new `"timer"` loading animation.
```ts
import * as p from "@clack/prompts";
const spin = p.spinner({ indicator: "timer" });
spin.start("Loading");
await sleep(3000);
spin.stop("Loaded");
```
- a38b2bc: Adds `stream` API which provides the same methods as `log`, but for iterable (even async) message streams. This is particularly useful for AI responses which are dynamically generated by LLMs.
```ts
import * as p from "@clack/prompts";
await p.stream.step(
(async function* () {
yield* generateLLMResponse(question);
})()
);
```
## 0.9.1
### Patch Changes
- 8093f3c: Adds `Error` support to the `validate` function
- 98925e3: Exports the `Option` type and improves JSDocannotations
- 1904e57: Replace custom utility for stripping ANSI control sequences with Node's built-in [`stripVTControlCharacters`](https://nodejs.org/docs/latest/api/util.html#utilstripvtcontrolcharactersstr) utility.
- Updated dependencies [8093f3c]
- Updated dependencies [e5ba09a]
- Updated dependencies [8cba8e3]
- @clack/core@0.4.1
## 0.9.0
### Minor Changes
- a83d2f8: Adds a new `updateSettings()` function to support new global keybindings.
`updateSettings()` accepts an `aliases` object that maps custom keys to an action (`up | down | left | right | space | enter | cancel`).
```ts
import { updateSettings } from "@clack/prompts";
// Support custom keybindings
updateSettings({
aliases: {
w: "up",
a: "left",
s: "down",
d: "right",
},
});
```
> [!WARNING]
> In order to enforce consistent, user-friendly defaults across the ecosystem, `updateSettings` does not support disabling Clack's default keybindings.
- 801246b: Adds a new `signal` option to support programmatic prompt cancellation with an [abort controller](https://kettanaito.com/blog/dont-sleep-on-abort-controller).
One example use case is automatically cancelling a prompt after a timeout.
```ts
const shouldContinue = await confirm({
message: "This message will self destruct in 5 seconds",
signal: AbortSignal.timeout(5000),
});
```
Another use case is racing a long running task with a manual prompt.
```ts
const abortController = new AbortController();
const projectType = await Promise.race([
detectProjectType({
signal: abortController.signal,
}),
select({
message: "Pick a project type.",
options: [
{ value: "ts", label: "TypeScript" },
{ value: "js", label: "JavaScript" },
{ value: "coffee", label: "CoffeeScript", hint: "oh no" },
],
signal: abortController.signal,
}),
]);
abortController.abort();
```
- a83d2f8: Updates default keybindings to support Vim motion shortcuts and map the `escape` key to cancel (`ctrl+c`).
| alias | action |
| ----- | ------ |
| `k` | up |
| `l` | right |
| `j` | down |
| `h` | left |
| `esc` | cancel |
### Patch Changes
- f9f139d: Adapts `spinner` output for static CI environments
- Updated dependencies [a83d2f8]
- Updated dependencies [801246b]
- Updated dependencies [a83d2f8]
- Updated dependencies [51e12bc]
- @clack/core@0.4.0
## 0.8.2
### Patch Changes
- Updated dependencies [4845f4f]
- Updated dependencies [d7b2fb9]
- @clack/core@0.3.5
## 0.8.1
### Patch Changes
- 360afeb: feat: adaptative max items
## 0.8.0
### Minor Changes
- 9acccde: Add tasks function for executing tasks in spinners
### Patch Changes
- b5c6b9b: Feat multiselect maxItems option
- 50ed94a: fix: clear `spinner` hooks on `spinner.stop`
- Updated dependencies [a04e418]
- Updated dependencies [4f6fcf5]
- @clack/core@0.3.4
## 0.7.0
### Minor Changes
- b27a701: add maxItems option to select prompt
- 89371be: added a new method called `spinner.message(msg: string)`
### Patch Changes
- 52183c4: Fix `spinner` conflict with terminal on error between `spinner.start()` and `spinner.stop()`
- ab51d29: Fixes cases where the note title length was miscalculated due to ansi characters
- Updated dependencies [cd79076]
- @clack/core@0.3.3
## 0.6.3
### Patch Changes
- c96eda5: Enable hard line-wrapping behavior for long words without spaces
- Updated dependencies [c96eda5]
- @clack/core@0.3.2
## 0.6.2
### Patch Changes
- 58a1df1: Fix line duplication bug by automatically wrapping prompts to `process.stdout.columns`
- Updated dependencies [58a1df1]
- @clack/core@0.3.1
## 0.6.1
### Patch Changes
- ca08fb6: Support complex value types for `select`, `multiselect` and `groupMultiselect`.
## 0.6.0
### Minor Changes
- 8a4a12f: add `groupMultiselect` prompt
- 165a1b3: Add `log` APIs. Supports `log.info`, `log.success`, `log.warn`, and `log.error`. For low-level control, `log.message` is also exposed.
### Patch Changes
- Updated dependencies [8a4a12f]
- Updated dependencies [8a4a12f]
- @clack/core@0.3.0
## 0.5.1
### Patch Changes
- cc11917: Update default `password` mask
- Updated dependencies [ec812b6]
- @clack/core@0.2.1
## 0.5.0
### Minor Changes
- d74dd05: Adds a `selectKey` prompt type
- 54c1bc3: **Breaking Change** `multiselect` has renamed `initialValue` to `initialValues`
### Patch Changes
- Updated dependencies [d74dd05]
- Updated dependencies [54c1bc3]
- @clack/core@0.2.0
## 0.4.5
### Patch Changes
- 1251132: Multiselect: return `Value[]` instead of `Option[]`.
- 8994382: Add a password prompt to `@clack/prompts`
- Updated dependencies [1251132]
- Updated dependencies [8994382]
- @clack/core@0.1.9
## 0.4.4
### Patch Changes
- d96071c: Don't mutate `initialValue` in `multiselect`, fix parameter type for `validate()`.
Credits to @banjo for the bug report and initial PR!
- Updated dependencies [d96071c]
- @clack/core@0.1.8
## 0.4.3
### Patch Changes
- 83d890e: Fix text cancel display bug
## 0.4.2
### Patch Changes
- Update README
## 0.4.1
### Patch Changes
- 7fb5375: Adds a new `defaultValue` option to the text prompt, removes automatic usage of the placeholder value.
- Updated dependencies [7fb5375]
- @clack/core@0.1.6
## 0.4.0
### Minor Changes
- 61b88b6: Add `group` construct to group many prompts together
### Patch Changes
- de1314e: Support `required` option for multi-select
- Updated dependencies [de1314e]
- @clack/core@0.1.5
## 0.3.0
### Minor Changes
- 493c592: Improve types for select/multiselect prompts. Numbers and booleans are now supported as the `value` option.
- 15558e3: Improved Windows/non-unicode support
### Patch Changes
- ca77da1: Fix multiselect initial value logic
- Updated dependencies [ca77da1]
- Updated dependencies [8aed606]
- @clack/core@0.1.4
## 0.2.2
### Patch Changes
- 94b24d9: Fix CJS `ansi-regex` interop
## 0.2.1
### Patch Changes
- a99c458: Support `initialValue` option for text prompt
- Updated dependencies [a99c458]
- @clack/core@0.1.3
## 0.2.0
### Minor Changes
- Improved type safety
- b1341d6: Updated styles, new note component
### Patch Changes
- Updated dependencies [7dcad8f]
- Updated dependencies [2242f13]
- Updated dependencies [b1341d6]
- @clack/core@0.1.2
## 0.1.1
### Patch Changes
- fa09bf5: Use circle for radio, square for checkbox
- Updated dependencies [4be7dbf]
- Updated dependencies [b480679]
- @clack/core@0.1.1
## 0.1.0
### Minor Changes
- 7015ec9: Create new prompt: multi-select
### Patch Changes
- Updated dependencies [7015ec9]
- @clack/core@0.1.0
## 0.0.10
### Patch Changes
- e0b49e5: Update spinner so it actually spins
## 0.0.9
### Patch Changes
- Update README
## 0.0.8
### Patch Changes
- Updated dependencies [9d371c3]
- @clack/core@0.0.12
## 0.0.7
### Patch Changes
- Update README
## 0.0.6
### Patch Changes
- d20ef2a: Update keywords, URLs
- Updated dependencies [441d5b7]
- Updated dependencies [d20ef2a]
- Updated dependencies [fe13c2f]
- @clack/core@0.0.11
## 0.0.5
### Patch Changes
- Update README
## 0.0.4
### Patch Changes
- 80404ab: Update README
## 0.0.3
### Patch Changes
- a0cb382: Add `main` entrypoint
- Updated dependencies [a0cb382]
- @clack/core@0.0.10
## 0.0.2
### Patch Changes
- Updated dependencies
- @clack/core@0.0.9
## 0.0.1
### Patch Changes
- a4b5e13: Initial release
- Updated dependencies [a4b5e13]
- @clack/core@0.0.8
================================================
FILE: packages/prompts/LICENSE
================================================
MIT License
Copyright (c) Nate Moore
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:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
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.
================================================
FILE: packages/prompts/README.md
================================================
# `@clack/prompts`
Effortlessly build beautiful command-line apps 🪄 [Try the demo](https://stackblitz.com/edit/clack-prompts?file=index.js)

---
`@clack/prompts` is an opinionated, pre-styled wrapper around [`@clack/core`](https://www.npmjs.com/package/@clack/core).
- 🤏 80% smaller than other options
- 💎 Beautiful, minimal UI
- ✅ Simple API
- 🧱 Comes with `text`, `confirm`, `select`, `multiselect`, and `spinner` components
## Basics
### Setup
The `intro` and `outro` functions will print a message to begin or end a prompt session, respectively.
```js
import { intro, outro } from '@clack/prompts';
intro(`create-my-app`);
// Do stuff
outro(`You're all set!`);
```
### Cancellation
The `isCancel` function is a guard that detects when a user cancels a question with `CTRL + C`. You should handle this situation for each prompt, optionally providing a nice cancellation message with the `cancel` utility.
```js
import { isCancel, cancel, text } from '@clack/prompts';
const value = await text({
message: 'What is the meaning of life?',
});
if (isCancel(value)) {
cancel('Operation cancelled.');
process.exit(0);
}
```
## Components
### Text
The text component accepts a single line of text.
```js
import { text } from '@clack/prompts';
const meaning = await text({
message: 'What is the meaning of life?',
placeholder: 'Not sure',
initialValue: '42',
validate(value) {
if (value.length === 0) return `Value is required!`;
},
});
```
### Confirm
The confirm component accepts a yes or no answer. The result is a boolean value of `true` or `false`.
```js
import { confirm } from '@clack/prompts';
const shouldContinue = await confirm({
message: 'Do you want to continue?',
});
```
### Select
The select component allows a user to choose one value from a list of options. The result is the `value` prop of a given option.
```js
import { select } from '@clack/prompts';
const projectType = await select({
message: 'Pick a project type.',
options: [
{ value: 'ts', label: 'TypeScript' },
{ value: 'js', label: 'JavaScript', disabled: true },
{ value: 'coffee', label: 'CoffeeScript', hint: 'oh no' },
],
});
```
### Multi-Select
The `multiselect` component allows a user to choose many values from a list of options. The result is an array with all selected `value` props.
```js
import { multiselect } from '@clack/prompts';
const additionalTools = await multiselect({
message: 'Select additional tools.',
options: [
{ value: 'eslint', label: 'ESLint', hint: 'recommended' },
{ value: 'prettier', label: 'Prettier', disabled: true },
{ value: 'gh-action', label: 'GitHub Action' },
],
required: false,
});
```
It is also possible to select multiple items arranged into hierarchy by using `groupMultiselect`:
```js
import { groupMultiselect } from '@clack/prompts';
const basket = await groupMultiselect({
message: 'Select your favorite fruits and vegetables:',
options: {
fruits: [
{ value: 'apple', label: 'apple' },
{ value: 'banana', label: 'banana' },
{ value: 'cherry', label: 'cherry' },
],
vegetables: [
{ value: 'carrot', label: 'carrot' },
{ value: 'spinach', label: 'spinach' },
{ value: 'potato', label: 'potato' },
]
}
});
```
### Spinner
The spinner component surfaces a pending action, such as a long-running download or dependency installation.
```js
import { spinner } from '@clack/prompts';
const s = spinner();
s.start('Installing via npm');
// Do installation here
s.stop('Installed via npm');
```
### Progress
The progress component extends the spinner component to add a progress bar to visualize the progression of an action.
```js
import { progress } from '@clack/prompts';
const p = progress({ max: 10 });
p.start('Downloading archive');
// Do download here
p.advance(3, 'Downloading (30%)');
// ...
p.advance(8, 'Downloading (80%)');
// ...
p.stop('Archive downloaded');
```
## Utilities
### Grouping
Grouping prompts together is a great way to keep your code organized. This accepts a JSON object with a name that can be used to reference the group later. The second argument is an optional but has a `onCancel` callback that will be called if the user cancels one of the prompts in the group.
```js
import * as p from '@clack/prompts';
const group = await p.group(
{
name: () => p.text({ message: 'What is your name?' }),
age: () => p.text({ message: 'What is your age?' }),
color: ({ results }) =>
p.multiselect({
message: `What is your favorite color ${results.name}?`,
options: [
{ value: 'red', label: 'Red' },
{ value: 'green', label: 'Green' },
{ value: 'blue', label: 'Blue' },
],
}),
},
{
// On Cancel callback that wraps the group
// So if the user cancels one of the prompts in the group this function will be called
onCancel: ({ results }) => {
p.cancel('Operation cancelled.');
process.exit(0);
},
}
);
console.log(group.name, group.age, group.color);
```
### Tasks
Execute multiple tasks in spinners.
```js
import { tasks } from '@clack/prompts';
await tasks([
{
title: 'Installing via npm',
task: async (message) => {
// Do installation here
return 'Installed via npm';
},
},
]);
```
### Logs
```js
import { log } from '@clack/prompts';
log.info('Info!');
log.success('Success!');
log.step('Step!');
log.warn('Warn!');
log.error('Error!');
log.message('Hello, World', { symbol: color.cyan('~') });
```
### Stream
When interacting with dynamic LLMs or other streaming message providers, use the `stream` APIs to log messages from an iterable, even an async one.
```js
import { stream } from '@clack/prompts';
stream.info((function *() { yield 'Info!'; })());
stream.success((function *() { yield 'Success!'; })());
stream.step((function *() { yield 'Step!'; })());
stream.warn((function *() { yield 'Warn!'; })());
stream.error((function *() { yield 'Error!'; })());
stream.message((function *() { yield 'Hello'; yield ", World" })(), { symbol: color.cyan('~') });
```

### Task Log
When executing a sub-process or a similar sub-task, `taskLog` can be used to render the output continuously and clear it at the end if it was successful.
```js
import { taskLog } from '@clack/prompts';
const log = taskLog({
title: 'Running npm install'
});
for await (const line of npmInstall()) {
log.message(line);
}
if (success) {
log.success('Done!');
} else {
log.error('Failed!');
}
```
================================================
FILE: packages/prompts/__mocks__/fs.cjs
================================================
const { fs } = require('memfs');
module.exports = fs;
================================================
FILE: packages/prompts/build.config.ts
================================================
import { defineBuildConfig } from 'unbuild';
export default defineBuildConfig({
preset: '../../build.preset',
entries: ['src/index'],
});
================================================
FILE: packages/prompts/package.json
================================================
{
"name": "@clack/prompts",
"version": "1.1.0",
"type": "module",
"main": "./dist/index.mjs",
"module": "./dist/index.mjs",
"exports": {
".": {
"types": "./dist/index.d.mts",
"default": "./dist/index.mjs"
},
"./package.json": "./package.json"
},
"types": "./dist/index.d.mts",
"repository": {
"type": "git",
"url": "git+https://github.com/bombshell-dev/clack.git",
"directory": "packages/prompts"
},
"bugs": {
"url": "https://github.com/bombshell-dev/clack/issues"
},
"homepage": "https://github.com/bombshell-dev/clack/tree/main/packages/prompts#readme",
"files": [
"dist",
"CHANGELOG.md"
],
"author": {
"name": "Nate Moore",
"email": "nate@natemoo.re",
"url": "https://twitter.com/n_moore"
},
"license": "MIT",
"keywords": [
"ask",
"clack",
"cli",
"command-line",
"command",
"input",
"interact",
"interface",
"menu",
"prompt",
"prompts",
"stdin",
"ui"
],
"packageManager": "pnpm@9.14.2",
"scripts": {
"build": "unbuild",
"prepack": "pnpm build",
"test": "vitest run"
},
"dependencies": {
"@clack/core": "workspace:*",
"fast-string-width": "^1.1.0",
"fast-wrap-ansi": "^0.1.3",
"sisteransi": "^1.0.5"
},
"devDependencies": {
"is-unicode-supported": "^1.3.0",
"memfs": "^4.17.2",
"vitest": "^3.2.4",
"vitest-ansi-serializer": "^0.1.2"
}
}
================================================
FILE: packages/prompts/src/autocomplete.ts
================================================
import { styleText } from 'node:util';
import { AutocompletePrompt, settings } from '@clack/core';
import {
type CommonOptions,
S_BAR,
S_BAR_END,
S_CHECKBOX_INACTIVE,
S_CHECKBOX_SELECTED,
S_RADIO_ACTIVE,
S_RADIO_INACTIVE,
symbol,
} from './common.js';
import { limitOptions } from './limit-options.js';
import type { Option } from './select.js';
function getLabel(option: Option) {
return option.label ?? String(option.value ?? '');
}
function getFilteredOption(searchText: string, option: Option): boolean {
if (!searchText) {
return true;
}
const label = (option.label ?? String(option.value ?? '')).toLowerCase();
const hint = (option.hint ?? '').toLowerCase();
const value = String(option.value).toLowerCase();
const term = searchText.toLowerCase();
return label.includes(term) || hint.includes(term) || value.includes(term);
}
function getSelectedOptions(values: T[], options: Option[]): Option[] {
const results: Option[] = [];
for (const option of options) {
if (values.includes(option.value)) {
results.push(option);
}
}
return results;
}
interface AutocompleteSharedOptions extends CommonOptions {
/**
* The message to display to the user.
*/
message: string;
/**
* Available options for the autocomplete prompt.
*/
options: Option[] | ((this: AutocompletePrompt>) => Option[]);
/**
* Maximum number of items to display at once.
*/
maxItems?: number;
/**
* Placeholder text to display when no input is provided.
*/
placeholder?: string;
/**
* Validates the value
*/
validate?: (value: Value | Value[] | undefined) => string | Error | undefined;
/**
* Custom filter function to match options against search input.
* If not provided, a default filter that matches label, hint, and value is used.
*/
filter?: (search: string, option: Option) => boolean;
}
export interface AutocompleteOptions extends AutocompleteSharedOptions {
/**
* The initial selected value.
*/
initialValue?: Value;
/**
* The initial user input
*/
initialUserInput?: string;
}
export const autocomplete = (opts: AutocompleteOptions) => {
const prompt = new AutocompletePrompt({
options: opts.options,
initialValue: opts.initialValue ? [opts.initialValue] : undefined,
initialUserInput: opts.initialUserInput,
placeholder: opts.placeholder,
filter:
opts.filter ??
((search: string, opt: Option) => {
return getFilteredOption(search, opt);
}),
signal: opts.signal,
input: opts.input,
output: opts.output,
validate: opts.validate,
render() {
const hasGuide = opts.withGuide ?? settings.withGuide;
// Title and message display
const headings = hasGuide
? [`${styleText('gray', S_BAR)}`, `${symbol(this.state)} ${opts.message}`]
: [`${symbol(this.state)} ${opts.message}`];
const userInput = this.userInput;
const options = this.options;
const placeholder = opts.placeholder;
const showPlaceholder = userInput === '' && placeholder !== undefined;
const opt = (option: Option, state: 'inactive' | 'active' | 'disabled') => {
const label = getLabel(option);
const hint =
option.hint && option.value === this.focusedValue
? styleText('dim', ` (${option.hint})`)
: '';
switch (state) {
case 'active':
return `${styleText('green', S_RADIO_ACTIVE)} ${label}${hint}`;
case 'inactive':
return `${styleText('dim', S_RADIO_INACTIVE)} ${styleText('dim', label)}`;
case 'disabled':
return `${styleText('gray', S_RADIO_INACTIVE)} ${styleText(['strikethrough', 'gray'], label)}`;
}
};
// Handle different states
switch (this.state) {
case 'submit': {
// Show selected value
const selected = getSelectedOptions(this.selectedValues, options);
const label =
selected.length > 0 ? ` ${styleText('dim', selected.map(getLabel).join(', '))}` : '';
const submitPrefix = hasGuide ? styleText('gray', S_BAR) : '';
return `${headings.join('\n')}\n${submitPrefix}${label}`;
}
case 'cancel': {
const userInputText = userInput
? ` ${styleText(['strikethrough', 'dim'], userInput)}`
: '';
const cancelPrefix = hasGuide ? styleText('gray', S_BAR) : '';
return `${headings.join('\n')}\n${cancelPrefix}${userInputText}`;
}
default: {
const barStyle = this.state === 'error' ? 'yellow' : 'cyan';
const guidePrefix = hasGuide ? `${styleText(barStyle, S_BAR)} ` : '';
const guidePrefixEnd = hasGuide ? styleText(barStyle, S_BAR_END) : '';
// Display cursor position - show plain text in navigation mode
let searchText = '';
if (this.isNavigating || showPlaceholder) {
const searchTextValue = showPlaceholder ? placeholder : userInput;
searchText = searchTextValue !== '' ? ` ${styleText('dim', searchTextValue)}` : '';
} else {
searchText = ` ${this.userInputWithCursor}`;
}
// Show match count if filtered
const matches =
this.filteredOptions.length !== options.length
? styleText(
'dim',
` (${this.filteredOptions.length} match${this.filteredOptions.length === 1 ? '' : 'es'})`
)
: '';
// No matches message
const noResults =
this.filteredOptions.length === 0 && userInput
? [`${guidePrefix}${styleText('yellow', 'No matches found')}`]
: [];
const validationError =
this.state === 'error' ? [`${guidePrefix}${styleText('yellow', this.error)}`] : [];
if (hasGuide) {
headings.push(`${guidePrefix.trimEnd()}`);
}
headings.push(
`${guidePrefix}${styleText('dim', 'Search:')}${searchText}${matches}`,
...noResults,
...validationError
);
// Show instructions
const instructions = [
`${styleText('dim', '↑/↓')} to select`,
`${styleText('dim', 'Enter:')} confirm`,
`${styleText('dim', 'Type:')} to search`,
];
const footers = [`${guidePrefix}${instructions.join(' • ')}`, guidePrefixEnd];
// Render options with selection
const displayOptions =
this.filteredOptions.length === 0
? []
: limitOptions({
cursor: this.cursor,
options: this.filteredOptions,
columnPadding: hasGuide ? 3 : 0, // for `| ` when guide is shown
rowPadding: headings.length + footers.length,
style: (option, active) => {
return opt(
option,
option.disabled ? 'disabled' : active ? 'active' : 'inactive'
);
},
maxItems: opts.maxItems,
output: opts.output,
});
// Return the formatted prompt
return [
...headings,
...displayOptions.map((option) => `${guidePrefix}${option}`),
...footers,
].join('\n');
}
}
},
});
// Return the result or cancel symbol
return prompt.prompt() as Promise;
};
// Type definition for the autocompleteMultiselect component
export interface AutocompleteMultiSelectOptions extends AutocompleteSharedOptions {
/**
* The initial selected values
*/
initialValues?: Value[];
/**
* If true, at least one option must be selected
*/
required?: boolean;
}
/**
* Integrated autocomplete multiselect - combines type-ahead filtering with multiselect in one UI
*/
export const autocompleteMultiselect = (opts: AutocompleteMultiSelectOptions) => {
const formatOption = (
option: Option,
active: boolean,
selectedValues: Value[],
focusedValue: Value | undefined
) => {
const isSelected = selectedValues.includes(option.value);
const label = option.label ?? String(option.value ?? '');
const hint =
option.hint && focusedValue !== undefined && option.value === focusedValue
? styleText('dim', ` (${option.hint})`)
: '';
const checkbox = isSelected
? styleText('green', S_CHECKBOX_SELECTED)
: styleText('dim', S_CHECKBOX_INACTIVE);
if (option.disabled) {
return `${styleText('gray', S_CHECKBOX_INACTIVE)} ${styleText(['strikethrough', 'gray'], label)}`;
}
if (active) {
return `${checkbox} ${label}${hint}`;
}
return `${checkbox} ${styleText('dim', label)}`;
};
// Create text prompt which we'll use as foundation
const prompt = new AutocompletePrompt>({
options: opts.options,
multiple: true,
placeholder: opts.placeholder,
filter:
opts.filter ??
((search, opt) => {
return getFilteredOption(search, opt);
}),
validate: () => {
if (opts.required && prompt.selectedValues.length === 0) {
return 'Please select at least one item';
}
return undefined;
},
initialValue: opts.initialValues,
signal: opts.signal,
input: opts.input,
output: opts.output,
render() {
// Title and symbol
const title = `${styleText('gray', S_BAR)}\n${symbol(this.state)} ${opts.message}\n`;
// Selection counter
const userInput = this.userInput;
const placeholder = opts.placeholder;
const showPlaceholder = userInput === '' && placeholder !== undefined;
// Search input display
const searchText =
this.isNavigating || showPlaceholder
? styleText('dim', showPlaceholder ? placeholder : userInput) // Just show plain text when in navigation mode
: this.userInputWithCursor;
const options = this.options;
const matches =
this.filteredOptions.length !== options.length
? styleText(
'dim',
` (${this.filteredOptions.length} match${this.filteredOptions.length === 1 ? '' : 'es'})`
)
: '';
// Render prompt state
switch (this.state) {
case 'submit': {
return `${title}${styleText('gray', S_BAR)} ${styleText('dim', `${this.selectedValues.length} items selected`)}`;
}
case 'cancel': {
return `${title}${styleText('gray', S_BAR)} ${styleText(['strikethrough', 'dim'], userInput)}`;
}
default: {
const barStyle = this.state === 'error' ? 'yellow' : 'cyan';
// Instructions
const instructions = [
`${styleText('dim', '↑/↓')} to navigate`,
`${styleText('dim', this.isNavigating ? 'Space/Tab:' : 'Tab:')} select`,
`${styleText('dim', 'Enter:')} confirm`,
`${styleText('dim', 'Type:')} to search`,
];
// No results message
const noResults =
this.filteredOptions.length === 0 && userInput
? [`${styleText(barStyle, S_BAR)} ${styleText('yellow', 'No matches found')}`]
: [];
const errorMessage =
this.state === 'error'
? [`${styleText(barStyle, S_BAR)} ${styleText('yellow', this.error)}`]
: [];
// Calculate header and footer line counts for rowPadding
const headerLines = [
...`${title}${styleText(barStyle, S_BAR)}`.split('\n'),
`${styleText(barStyle, S_BAR)} ${styleText('dim', 'Search:')} ${searchText}${matches}`,
...noResults,
...errorMessage,
];
const footerLines = [
`${styleText(barStyle, S_BAR)} ${instructions.join(' • ')}`,
styleText(barStyle, S_BAR_END),
];
// Get limited options for display
const displayOptions = limitOptions({
cursor: this.cursor,
options: this.filteredOptions,
style: (option, active) =>
formatOption(option, active, this.selectedValues, this.focusedValue),
maxItems: opts.maxItems,
output: opts.output,
rowPadding: headerLines.length + footerLines.length,
});
// Build the prompt display
return [
...headerLines,
...displayOptions.map((option) => `${styleText(barStyle, S_BAR)} ${option}`),
...footerLines,
].join('\n');
}
}
},
});
// Return the result or cancel symbol
return prompt.prompt() as Promise;
};
================================================
FILE: packages/prompts/src/box.ts
================================================
import type { Writable } from 'node:stream';
import { getColumns, settings } from '@clack/core';
import stringWidth from 'fast-string-width';
import { wrapAnsi } from 'fast-wrap-ansi';
import {
type CommonOptions,
S_BAR,
S_BAR_END,
S_BAR_END_RIGHT,
S_BAR_H,
S_BAR_START,
S_BAR_START_RIGHT,
S_CORNER_BOTTOM_LEFT,
S_CORNER_BOTTOM_RIGHT,
S_CORNER_TOP_LEFT,
S_CORNER_TOP_RIGHT,
} from './common.js';
export type BoxAlignment = 'left' | 'center' | 'right';
type BoxSymbols = [topLeft: string, topRight: string, bottomLeft: string, bottomRight: string];
const roundedSymbols: BoxSymbols = [
S_CORNER_TOP_LEFT,
S_CORNER_TOP_RIGHT,
S_CORNER_BOTTOM_LEFT,
S_CORNER_BOTTOM_RIGHT,
];
const squareSymbols: BoxSymbols = [S_BAR_START, S_BAR_START_RIGHT, S_BAR_END, S_BAR_END_RIGHT];
export interface BoxOptions extends CommonOptions {
contentAlign?: BoxAlignment;
titleAlign?: BoxAlignment;
width?: number | 'auto';
titlePadding?: number;
contentPadding?: number;
rounded?: boolean;
formatBorder?: (text: string) => string;
}
function getPaddingForLine(
lineLength: number,
innerWidth: number,
padding: number,
contentAlign: BoxAlignment | undefined
): [number, number] {
let leftPadding = padding;
let rightPadding = padding;
if (contentAlign === 'center') {
leftPadding = Math.floor((innerWidth - lineLength) / 2);
} else if (contentAlign === 'right') {
leftPadding = innerWidth - lineLength - padding;
}
rightPadding = innerWidth - leftPadding - lineLength;
return [leftPadding, rightPadding];
}
const defaultFormatBorder = (text: string) => text;
export const box = (message = '', title = '', opts?: BoxOptions) => {
const output: Writable = opts?.output ?? process.stdout;
const columns = getColumns(output);
const borderWidth = 1;
const borderTotalWidth = borderWidth * 2;
const titlePadding = opts?.titlePadding ?? 1;
const contentPadding = opts?.contentPadding ?? 2;
const width = opts?.width === undefined || opts.width === 'auto' ? 1 : Math.min(1, opts.width);
const hasGuide = opts?.withGuide ?? settings.withGuide;
const linePrefix = !hasGuide ? '' : `${S_BAR} `;
const formatBorder = opts?.formatBorder ?? defaultFormatBorder;
const symbols = (opts?.rounded ? roundedSymbols : squareSymbols).map(formatBorder);
const hSymbol = formatBorder(S_BAR_H);
const vSymbol = formatBorder(S_BAR);
const linePrefixWidth = stringWidth(linePrefix);
const titleWidth = stringWidth(title);
const maxBoxWidth = columns - linePrefixWidth;
let boxWidth = Math.floor(columns * width) - linePrefixWidth;
if (opts?.width === 'auto') {
const lines = message.split('\n');
let longestLine = titleWidth + titlePadding * 2;
for (const line of lines) {
const lineWithPadding = stringWidth(line) + contentPadding * 2;
if (lineWithPadding > longestLine) {
longestLine = lineWithPadding;
}
}
const longestLineWidth = longestLine + borderTotalWidth;
if (longestLineWidth < boxWidth) {
boxWidth = longestLineWidth;
}
}
if (boxWidth % 2 !== 0) {
if (boxWidth < maxBoxWidth) {
boxWidth++;
} else {
boxWidth--;
}
}
const innerWidth = boxWidth - borderTotalWidth;
const maxTitleLength = innerWidth - titlePadding * 2;
const truncatedTitle =
titleWidth > maxTitleLength ? `${title.slice(0, maxTitleLength - 3)}...` : title;
const [titlePaddingLeft, titlePaddingRight] = getPaddingForLine(
stringWidth(truncatedTitle),
innerWidth,
titlePadding,
opts?.titleAlign
);
const wrappedMessage = wrapAnsi(message, innerWidth - contentPadding * 2, {
hard: true,
trim: false,
});
output.write(
`${linePrefix}${symbols[0]}${hSymbol.repeat(titlePaddingLeft)}${truncatedTitle}${hSymbol.repeat(titlePaddingRight)}${symbols[1]}\n`
);
const wrappedLines = wrappedMessage.split('\n');
for (const line of wrappedLines) {
const [leftLinePadding, rightLinePadding] = getPaddingForLine(
stringWidth(line),
innerWidth,
contentPadding,
opts?.contentAlign
);
output.write(
`${linePrefix}${vSymbol}${' '.repeat(leftLinePadding)}${line}${' '.repeat(rightLinePadding)}${vSymbol}\n`
);
}
output.write(`${linePrefix}${symbols[2]}${hSymbol.repeat(innerWidth)}${symbols[3]}\n`);
};
================================================
FILE: packages/prompts/src/common.ts
================================================
import type { Readable, Writable } from 'node:stream';
import { styleText } from 'node:util';
import type { State } from '@clack/core';
import isUnicodeSupported from 'is-unicode-supported';
export const unicode = isUnicodeSupported();
export const isCI = (): boolean => process.env.CI === 'true';
export const isTTY = (output: Writable): boolean => {
return (output as Writable & { isTTY?: boolean }).isTTY === true;
};
export const unicodeOr = (c: string, fallback: string) => (unicode ? c : fallback);
export const S_STEP_ACTIVE = unicodeOr('◆', '*');
export const S_STEP_CANCEL = unicodeOr('■', 'x');
export const S_STEP_ERROR = unicodeOr('▲', 'x');
export const S_STEP_SUBMIT = unicodeOr('◇', 'o');
export const S_BAR_START = unicodeOr('┌', 'T');
export const S_BAR = unicodeOr('│', '|');
export const S_BAR_END = unicodeOr('└', '—');
export const S_BAR_START_RIGHT = unicodeOr('┐', 'T');
export const S_BAR_END_RIGHT = unicodeOr('┘', '—');
export const S_RADIO_ACTIVE = unicodeOr('●', '>');
export const S_RADIO_INACTIVE = unicodeOr('○', ' ');
export const S_CHECKBOX_ACTIVE = unicodeOr('◻', '[•]');
export const S_CHECKBOX_SELECTED = unicodeOr('◼', '[+]');
export const S_CHECKBOX_INACTIVE = unicodeOr('◻', '[ ]');
export const S_PASSWORD_MASK = unicodeOr('▪', '•');
export const S_BAR_H = unicodeOr('─', '-');
export const S_CORNER_TOP_RIGHT = unicodeOr('╮', '+');
export const S_CONNECT_LEFT = unicodeOr('├', '+');
export const S_CORNER_BOTTOM_RIGHT = unicodeOr('╯', '+');
export const S_CORNER_BOTTOM_LEFT = unicodeOr('╰', '+');
export const S_CORNER_TOP_LEFT = unicodeOr('╭', '+');
export const S_INFO = unicodeOr('●', '•');
export const S_SUCCESS = unicodeOr('◆', '*');
export const S_WARN = unicodeOr('▲', '!');
export const S_ERROR = unicodeOr('■', 'x');
export const symbol = (state: State) => {
switch (state) {
case 'initial':
case 'active':
return styleText('cyan', S_STEP_ACTIVE);
case 'cancel':
return styleText('red', S_STEP_CANCEL);
case 'error':
return styleText('yellow', S_STEP_ERROR);
case 'submit':
return styleText('green', S_STEP_SUBMIT);
}
};
export const symbolBar = (state: State) => {
switch (state) {
case 'initial':
case 'active':
return styleText('cyan', S_BAR);
case 'cancel':
return styleText('red', S_BAR);
case 'error':
return styleText('yellow', S_BAR);
case 'submit':
return styleText('green', S_BAR);
}
};
export interface CommonOptions {
input?: Readable;
output?: Writable;
signal?: AbortSignal;
withGuide?: boolean;
}
================================================
FILE: packages/prompts/src/confirm.ts
================================================
import { styleText } from 'node:util';
import { ConfirmPrompt, settings } from '@clack/core';
import {
type CommonOptions,
S_BAR,
S_BAR_END,
S_RADIO_ACTIVE,
S_RADIO_INACTIVE,
symbol,
} from './common.js';
export interface ConfirmOptions extends CommonOptions {
message: string;
active?: string;
inactive?: string;
initialValue?: boolean;
vertical?: boolean;
}
export const confirm = (opts: ConfirmOptions) => {
const active = opts.active ?? 'Yes';
const inactive = opts.inactive ?? 'No';
return new ConfirmPrompt({
active,
inactive,
signal: opts.signal,
input: opts.input,
output: opts.output,
initialValue: opts.initialValue ?? true,
render() {
const hasGuide = opts.withGuide ?? settings.withGuide;
const title = `${hasGuide ? `${styleText('gray', S_BAR)}\n` : ''}${symbol(this.state)} ${opts.message}\n`;
const value = this.value ? active : inactive;
switch (this.state) {
case 'submit': {
const submitPrefix = hasGuide ? `${styleText('gray', S_BAR)} ` : '';
return `${title}${submitPrefix}${styleText('dim', value)}`;
}
case 'cancel': {
const cancelPrefix = hasGuide ? `${styleText('gray', S_BAR)} ` : '';
return `${title}${cancelPrefix}${styleText(['strikethrough', 'dim'], value)}${
hasGuide ? `\n${styleText('gray', S_BAR)}` : ''
}`;
}
default: {
const defaultPrefix = hasGuide ? `${styleText('cyan', S_BAR)} ` : '';
const defaultPrefixEnd = hasGuide ? styleText('cyan', S_BAR_END) : '';
return `${title}${defaultPrefix}${
this.value
? `${styleText('green', S_RADIO_ACTIVE)} ${active}`
: `${styleText('dim', S_RADIO_INACTIVE)} ${styleText('dim', active)}`
}${opts.vertical ? (hasGuide ? `\n${styleText('cyan', S_BAR)} ` : '\n') : ` ${styleText('dim', '/')} `}${
!this.value
? `${styleText('green', S_RADIO_ACTIVE)} ${inactive}`
: `${styleText('dim', S_RADIO_INACTIVE)} ${styleText('dim', inactive)}`
}\n${defaultPrefixEnd}\n`;
}
}
},
}).prompt() as Promise;
};
================================================
FILE: packages/prompts/src/date.ts
================================================
import { styleText } from 'node:util';
import type { DateFormat, State } from '@clack/core';
import { DatePrompt, settings } from '@clack/core';
import { type CommonOptions, S_BAR, S_BAR_END, symbol } from './common.js';
export type { DateFormat };
export interface DateOptions extends CommonOptions {
message: string;
format?: DateFormat;
locale?: string;
defaultValue?: Date;
initialValue?: Date;
minDate?: Date;
maxDate?: Date;
validate?: (value: Date | undefined) => string | Error | undefined;
}
export const date = (opts: DateOptions) => {
const validate = opts.validate;
return new DatePrompt({
...opts,
validate(value: Date | undefined) {
if (value === undefined) {
if (opts.defaultValue !== undefined) return undefined;
if (validate) return validate(value);
return settings.date.messages.required;
}
const iso = (d: Date) => d.toISOString().slice(0, 10);
if (opts.minDate && iso(value) < iso(opts.minDate)) {
return settings.date.messages.afterMin(opts.minDate);
}
if (opts.maxDate && iso(value) > iso(opts.maxDate)) {
return settings.date.messages.beforeMax(opts.maxDate);
}
if (validate) return validate(value);
return undefined;
},
render() {
const hasGuide = (opts?.withGuide ?? settings.withGuide) !== false;
const titlePrefix = `${hasGuide ? `${styleText('gray', S_BAR)}\n` : ''}${symbol(this.state)} `;
const title = `${titlePrefix}${opts.message}\n`;
const state = this.state !== 'initial' ? this.state : 'active';
const userInput = renderDate(this, state);
const value = this.value instanceof Date ? this.formattedValue : '';
switch (this.state) {
case 'error': {
const errorText = this.error ? ` ${styleText('yellow', this.error)}` : '';
const bar = hasGuide ? `${styleText('yellow', S_BAR)} ` : '';
const barEnd = hasGuide ? styleText('yellow', S_BAR_END) : '';
return `${title.trim()}\n${bar}${userInput}\n${barEnd}${errorText}\n`;
}
case 'submit': {
const valueText = value ? ` ${styleText('dim', value)}` : '';
const bar = hasGuide ? styleText('gray', S_BAR) : '';
return `${title}${bar}${valueText}`;
}
case 'cancel': {
const valueText = value ? ` ${styleText(['strikethrough', 'dim'], value)}` : '';
const bar = hasGuide ? styleText('gray', S_BAR) : '';
return `${title}${bar}${valueText}${value.trim() ? `\n${bar}` : ''}`;
}
default: {
const bar = hasGuide ? `${styleText('cyan', S_BAR)} ` : '';
const barEnd = hasGuide ? styleText('cyan', S_BAR_END) : '';
const inlineBar = hasGuide ? `${styleText('cyan', S_BAR)} ` : '';
const inlineError = this.inlineError
? `\n${inlineBar}${styleText('yellow', this.inlineError)}`
: '';
return `${title}${bar}${userInput}${inlineError}\n${barEnd}\n`;
}
}
},
}).prompt() as Promise;
};
function renderDate(prompt: Omit, 'prompt'>, state: State): string {
const parts = prompt.segmentValues;
const cursor = prompt.segmentCursor;
if (state === 'submit' || state === 'cancel') {
return prompt.formattedValue;
}
const sep = styleText('gray', prompt.separator);
return prompt.segments
.map((seg, i) => {
const isActive = i === cursor.segmentIndex && !['submit', 'cancel'].includes(state);
const label = DEFAULT_LABELS[seg.type];
return renderSegment(parts[seg.type], { isActive, label });
})
.join(sep);
}
interface SegmentOptions {
isActive: boolean;
label: string;
}
function renderSegment(value: string, opts: SegmentOptions): string {
const isBlank = !value || value.replace(/_/g, '') === '';
if (opts.isActive) return styleText('inverse', isBlank ? opts.label : value.replace(/_/g, ' '));
if (isBlank) return styleText('dim', opts.label);
return value.replace(/_/g, styleText('dim', ' '));
}
const DEFAULT_LABELS: Record<'year' | 'month' | 'day', string> = {
year: 'yyyy',
month: 'mm',
day: 'dd',
};
================================================
FILE: packages/prompts/src/group-multi-select.ts
================================================
import { styleText } from 'node:util';
import { GroupMultiSelectPrompt } from '@clack/core';
import {
type CommonOptions,
S_BAR,
S_BAR_END,
S_CHECKBOX_ACTIVE,
S_CHECKBOX_INACTIVE,
S_CHECKBOX_SELECTED,
symbol,
} from './common.js';
import type { Option } from './select.js';
export interface GroupMultiSelectOptions extends CommonOptions {
message: string;
options: Record[]>;
initialValues?: Value[];
required?: boolean;
cursorAt?: Value;
selectableGroups?: boolean;
groupSpacing?: number;
}
export const groupMultiselect = (opts: GroupMultiSelectOptions) => {
const { selectableGroups = true, groupSpacing = 0 } = opts;
const opt = (
option: Option & { group: string | boolean },
state:
| 'inactive'
| 'active'
| 'selected'
| 'active-selected'
| 'group-active'
| 'group-active-selected'
| 'submitted'
| 'cancelled',
options: (Option & { group: string | boolean })[] = []
) => {
const label = option.label ?? String(option.value);
const isItem = typeof option.group === 'string';
const next = isItem && (options[options.indexOf(option) + 1] ?? { group: true });
const isLast = isItem && next && next.group === true;
const prefix = isItem ? (selectableGroups ? `${isLast ? S_BAR_END : S_BAR} ` : ' ') : '';
let spacingPrefix = '';
if (groupSpacing > 0 && !isItem) {
const spacingPrefixText = `\n${styleText('cyan', S_BAR)}`;
spacingPrefix = `${spacingPrefixText.repeat(groupSpacing - 1)}${spacingPrefixText} `;
}
if (state === 'active') {
return `${spacingPrefix}${styleText('dim', prefix)}${styleText('cyan', S_CHECKBOX_ACTIVE)} ${label}${
option.hint ? ` ${styleText('dim', `(${option.hint})`)}` : ''
}`;
}
if (state === 'group-active') {
return `${spacingPrefix}${prefix}${styleText('cyan', S_CHECKBOX_ACTIVE)} ${styleText('dim', label)}`;
}
if (state === 'group-active-selected') {
return `${spacingPrefix}${prefix}${styleText('green', S_CHECKBOX_SELECTED)} ${styleText('dim', label)}`;
}
if (state === 'selected') {
const selectedCheckbox =
isItem || selectableGroups ? styleText('green', S_CHECKBOX_SELECTED) : '';
return `${spacingPrefix}${styleText('dim', prefix)}${selectedCheckbox} ${styleText('dim', label)}${
option.hint ? ` ${styleText('dim', `(${option.hint})`)}` : ''
}`;
}
if (state === 'cancelled') {
return `${styleText(['strikethrough', 'dim'], label)}`;
}
if (state === 'active-selected') {
return `${spacingPrefix}${styleText('dim', prefix)}${styleText('green', S_CHECKBOX_SELECTED)} ${label}${
option.hint ? ` ${styleText('dim', `(${option.hint})`)}` : ''
}`;
}
if (state === 'submitted') {
return `${styleText('dim', label)}`;
}
const unselectedCheckbox =
isItem || selectableGroups ? styleText('dim', S_CHECKBOX_INACTIVE) : '';
return `${spacingPrefix}${styleText('dim', prefix)}${unselectedCheckbox} ${styleText('dim', label)}`;
};
const required = opts.required ?? true;
return new GroupMultiSelectPrompt({
options: opts.options,
signal: opts.signal,
input: opts.input,
output: opts.output,
initialValues: opts.initialValues,
required,
cursorAt: opts.cursorAt,
selectableGroups,
validate(selected: Value[] | undefined) {
if (required && (selected === undefined || selected.length === 0))
return `Please select at least one option.\n${styleText(
'reset',
styleText(
'dim',
`Press ${styleText(['gray', 'bgWhite', 'inverse'], ' space ')} to select, ${styleText(
'gray',
styleText(['bgWhite', 'inverse'], ' enter ')
)} to submit`
)
)}`;
},
render() {
const title = `${styleText('gray', S_BAR)}\n${symbol(this.state)} ${opts.message}\n`;
const value = this.value ?? [];
switch (this.state) {
case 'submit': {
const selectedOptions = this.options
.filter(({ value: optionValue }) => value.includes(optionValue))
.map((option) => opt(option, 'submitted'));
const optionsText =
selectedOptions.length === 0 ? '' : ` ${selectedOptions.join(styleText('dim', ', '))}`;
return `${title}${styleText('gray', S_BAR)}${optionsText}`;
}
case 'cancel': {
const label = this.options
.filter(({ value: optionValue }) => value.includes(optionValue))
.map((option) => opt(option, 'cancelled'))
.join(styleText('dim', ', '));
return `${title}${styleText('gray', S_BAR)} ${
label.trim() ? `${label}\n${styleText('gray', S_BAR)}` : ''
}`;
}
case 'error': {
const footer = this.error
.split('\n')
.map((ln, i) =>
i === 0 ? `${styleText('yellow', S_BAR_END)} ${styleText('yellow', ln)}` : ` ${ln}`
)
.join('\n');
return `${title}${styleText('yellow', S_BAR)} ${this.options
.map((option, i, options) => {
const selected =
value.includes(option.value) ||
(option.group === true && this.isGroupSelected(`${option.value}`));
const active = i === this.cursor;
const groupActive =
!active &&
typeof option.group === 'string' &&
this.options[this.cursor].value === option.group;
if (groupActive) {
return opt(option, selected ? 'group-active-selected' : 'group-active', options);
}
if (active && selected) {
return opt(option, 'active-selected', options);
}
if (selected) {
return opt(option, 'selected', options);
}
return opt(option, active ? 'active' : 'inactive', options);
})
.join(`\n${styleText('yellow', S_BAR)} `)}\n${footer}\n`;
}
default: {
const optionsText = this.options
.map((option, i, options) => {
const selected =
value.includes(option.value) ||
(option.group === true && this.isGroupSelected(`${option.value}`));
const active = i === this.cursor;
const groupActive =
!active &&
typeof option.group === 'string' &&
this.options[this.cursor].value === option.group;
let optionText = '';
if (groupActive) {
optionText = opt(
option,
selected ? 'group-active-selected' : 'group-active',
options
);
} else if (active && selected) {
optionText = opt(option, 'active-selected', options);
} else if (selected) {
optionText = opt(option, 'selected', options);
} else {
optionText = opt(option, active ? 'active' : 'inactive', options);
}
const prefix = i !== 0 && !optionText.startsWith('\n') ? ' ' : '';
return `${prefix}${optionText}`;
})
.join(`\n${styleText('cyan', S_BAR)}`);
const optionsPrefix = optionsText.startsWith('\n') ? '' : ' ';
return `${title}${styleText('cyan', S_BAR)}${optionsPrefix}${optionsText}\n${styleText('cyan', S_BAR_END)}\n`;
}
}
},
}).prompt() as Promise;
};
================================================
FILE: packages/prompts/src/group.ts
================================================
import { isCancel } from '@clack/core';
type Prettify = {
[P in keyof T]: T[P];
} & {};
export type PromptGroupAwaitedReturn = {
[P in keyof T]: Exclude, symbol>;
};
export interface PromptGroupOptions {
/**
* Control how the group can be canceled
* if one of the prompts is canceled.
*/
onCancel?: (opts: { results: Prettify>> }) => void;
}
export type PromptGroup = {
[P in keyof T]: (opts: {
results: Prettify>>>;
}) => undefined | Promise;
};
/**
* Define a group of prompts to be displayed
* and return a results of objects within the group
*/
export const group = async (
prompts: PromptGroup,
opts?: PromptGroupOptions
): Promise>> => {
const results = {} as any;
const promptNames = Object.keys(prompts);
for (const name of promptNames) {
const prompt = prompts[name as keyof T];
const result = await prompt({ results })?.catch((e) => {
throw e;
});
// Pass the results to the onCancel function
// so the user can decide what to do with the results
// TODO: Switch to callback within core to avoid isCancel Fn
if (typeof opts?.onCancel === 'function' && isCancel(result)) {
results[name] = 'canceled';
opts.onCancel({ results });
continue;
}
results[name] = result;
}
return results;
};
================================================
FILE: packages/prompts/src/index.ts
================================================
export { type ClackSettings, isCancel, settings, updateSettings } from '@clack/core';
export * from './autocomplete.js';
export * from './box.js';
export * from './common.js';
export * from './confirm.js';
export * from './date.js';
export * from './group.js';
export * from './group-multi-select.js';
export * from './limit-options.js';
export * from './log.js';
export * from './messages.js';
export * from './multi-select.js';
export * from './note.js';
export * from './password.js';
export * from './path.js';
export * from './progress-bar.js';
export * from './select.js';
export * from './select-key.js';
export * from './spinner.js';
export * from './stream.js';
export * from './task.js';
export * from './task-log.js';
export * from './text.js';
================================================
FILE: packages/prompts/src/limit-options.ts
================================================
import { styleText } from 'node:util';
import { getColumns, getRows } from '@clack/core';
import { wrapAnsi } from 'fast-wrap-ansi';
import type { CommonOptions } from './common.js';
export interface LimitOptionsParams extends CommonOptions {
options: TOption[];
cursor: number;
style: (option: TOption, active: boolean) => string;
maxItems?: number;
columnPadding?: number;
rowPadding?: number;
}
const trimLines = (
groups: Array,
initialLineCount: number,
startIndex: number,
endIndex: number,
maxLines: number
) => {
let lineCount = initialLineCount;
let removals = 0;
for (let i = startIndex; i < endIndex; i++) {
const group = groups[i];
lineCount = lineCount - group.length;
removals++;
if (lineCount <= maxLines) {
break;
}
}
return { lineCount, removals };
};
export const limitOptions = ({
cursor,
options,
style,
output = process.stdout,
maxItems = Number.POSITIVE_INFINITY,
columnPadding = 0,
rowPadding = 4,
}: LimitOptionsParams): string[] => {
const columns = getColumns(output);
const maxWidth = columns - columnPadding;
const rows = getRows(output);
const overflowFormat = styleText('dim', '...');
const outputMaxItems = Math.max(rows - rowPadding, 0);
// We clamp to minimum 5 because anything less doesn't make sense UX wise
const computedMaxItems = Math.max(Math.min(maxItems, outputMaxItems), 5);
let slidingWindowLocation = 0;
if (cursor >= computedMaxItems - 3) {
slidingWindowLocation = Math.max(
Math.min(cursor - computedMaxItems + 3, options.length - computedMaxItems),
0
);
}
let shouldRenderTopEllipsis = computedMaxItems < options.length && slidingWindowLocation > 0;
let shouldRenderBottomEllipsis =
computedMaxItems < options.length && slidingWindowLocation + computedMaxItems < options.length;
const slidingWindowLocationEnd = Math.min(
slidingWindowLocation + computedMaxItems,
options.length
);
const lineGroups: Array = [];
let lineCount = 0;
if (shouldRenderTopEllipsis) {
lineCount++;
}
if (shouldRenderBottomEllipsis) {
lineCount++;
}
const slidingWindowLocationWithEllipsis =
slidingWindowLocation + (shouldRenderTopEllipsis ? 1 : 0);
const slidingWindowLocationEndWithEllipsis =
slidingWindowLocationEnd - (shouldRenderBottomEllipsis ? 1 : 0);
for (let i = slidingWindowLocationWithEllipsis; i < slidingWindowLocationEndWithEllipsis; i++) {
const wrappedLines = wrapAnsi(style(options[i], i === cursor), maxWidth, {
hard: true,
trim: false,
}).split('\n');
lineGroups.push(wrappedLines);
lineCount += wrappedLines.length;
}
if (lineCount > outputMaxItems) {
let precedingRemovals = 0;
let followingRemovals = 0;
let newLineCount = lineCount;
const cursorGroupIndex = cursor - slidingWindowLocationWithEllipsis;
const trimLinesLocal = (startIndex: number, endIndex: number) =>
trimLines(lineGroups, newLineCount, startIndex, endIndex, outputMaxItems);
if (shouldRenderTopEllipsis) {
({ lineCount: newLineCount, removals: precedingRemovals } = trimLinesLocal(
0,
cursorGroupIndex
));
if (newLineCount > outputMaxItems) {
({ lineCount: newLineCount, removals: followingRemovals } = trimLinesLocal(
cursorGroupIndex + 1,
lineGroups.length
));
}
} else {
({ lineCount: newLineCount, removals: followingRemovals } = trimLinesLocal(
cursorGroupIndex + 1,
lineGroups.length
));
if (newLineCount > outputMaxItems) {
({ lineCount: newLineCount, removals: precedingRemovals } = trimLinesLocal(
0,
cursorGroupIndex
));
}
}
if (precedingRemovals > 0) {
shouldRenderTopEllipsis = true;
lineGroups.splice(0, precedingRemovals);
}
if (followingRemovals > 0) {
shouldRenderBottomEllipsis = true;
lineGroups.splice(lineGroups.length - followingRemovals, followingRemovals);
}
}
const result: string[] = [];
if (shouldRenderTopEllipsis) {
result.push(overflowFormat);
}
for (const lineGroup of lineGroups) {
for (const line of lineGroup) {
result.push(line);
}
}
if (shouldRenderBottomEllipsis) {
result.push(overflowFormat);
}
return result;
};
================================================
FILE: packages/prompts/src/log.ts
================================================
import { styleText } from 'node:util';
import { settings } from '@clack/core';
import {
type CommonOptions,
S_BAR,
S_ERROR,
S_INFO,
S_STEP_SUBMIT,
S_SUCCESS,
S_WARN,
} from './common.js';
export interface LogMessageOptions extends CommonOptions {
symbol?: string;
spacing?: number;
secondarySymbol?: string;
}
export const log = {
message: (
message: string | string[] = [],
{
symbol = styleText('gray', S_BAR),
secondarySymbol = styleText('gray', S_BAR),
output = process.stdout,
spacing = 1,
withGuide,
}: LogMessageOptions = {}
) => {
const parts: string[] = [];
const hasGuide = withGuide ?? settings.withGuide;
const spacingString = !hasGuide ? '' : secondarySymbol;
const prefix = !hasGuide ? '' : `${symbol} `;
const secondaryPrefix = !hasGuide ? '' : `${secondarySymbol} `;
for (let i = 0; i < spacing; i++) {
parts.push(spacingString);
}
const messageParts = Array.isArray(message) ? message : message.split('\n');
if (messageParts.length > 0) {
const [firstLine, ...lines] = messageParts;
if (firstLine.length > 0) {
parts.push(`${prefix}${firstLine}`);
} else {
parts.push(hasGuide ? symbol : '');
}
for (const ln of lines) {
if (ln.length > 0) {
parts.push(`${secondaryPrefix}${ln}`);
} else {
parts.push(hasGuide ? secondarySymbol : '');
}
}
}
output.write(`${parts.join('\n')}\n`);
},
info: (message: string, opts?: LogMessageOptions) => {
log.message(message, { ...opts, symbol: styleText('blue', S_INFO) });
},
success: (message: string, opts?: LogMessageOptions) => {
log.message(message, { ...opts, symbol: styleText('green', S_SUCCESS) });
},
step: (message: string, opts?: LogMessageOptions) => {
log.message(message, { ...opts, symbol: styleText('green', S_STEP_SUBMIT) });
},
warn: (message: string, opts?: LogMessageOptions) => {
log.message(message, { ...opts, symbol: styleText('yellow', S_WARN) });
},
/** alias for `log.warn()`. */
warning: (message: string, opts?: LogMessageOptions) => {
log.warn(message, opts);
},
error: (message: string, opts?: LogMessageOptions) => {
log.message(message, { ...opts, symbol: styleText('red', S_ERROR) });
},
};
================================================
FILE: packages/prompts/src/messages.ts
================================================
import type { Writable } from 'node:stream';
import { styleText } from 'node:util';
import { settings } from '@clack/core';
import { type CommonOptions, S_BAR, S_BAR_END, S_BAR_START } from './common.js';
export const cancel = (message = '', opts?: CommonOptions) => {
const output: Writable = opts?.output ?? process.stdout;
const hasGuide = opts?.withGuide ?? settings.withGuide;
const prefix = hasGuide ? `${styleText('gray', S_BAR_END)} ` : '';
output.write(`${prefix}${styleText('red', message)}\n\n`);
};
export const intro = (title = '', opts?: CommonOptions) => {
const output: Writable = opts?.output ?? process.stdout;
const hasGuide = opts?.withGuide ?? settings.withGuide;
const prefix = hasGuide ? `${styleText('gray', S_BAR_START)} ` : '';
output.write(`${prefix}${title}\n`);
};
export const outro = (message = '', opts?: CommonOptions) => {
const output: Writable = opts?.output ?? process.stdout;
const hasGuide = opts?.withGuide ?? settings.withGuide;
const prefix = hasGuide ? `${styleText('gray', S_BAR)}\n${styleText('gray', S_BAR_END)} ` : '';
output.write(`${prefix}${message}\n\n`);
};
================================================
FILE: packages/prompts/src/multi-select.ts
================================================
import { styleText } from 'node:util';
import { MultiSelectPrompt, wrapTextWithPrefix } from '@clack/core';
import {
type CommonOptions,
S_BAR,
S_BAR_END,
S_CHECKBOX_ACTIVE,
S_CHECKBOX_INACTIVE,
S_CHECKBOX_SELECTED,
symbol,
symbolBar,
} from './common.js';
import { limitOptions } from './limit-options.js';
import type { Option } from './select.js';
export interface MultiSelectOptions extends CommonOptions {
message: string;
options: Option[];
initialValues?: Value[];
maxItems?: number;
required?: boolean;
cursorAt?: Value;
}
const computeLabel = (label: string, format: (text: string) => string) => {
return label
.split('\n')
.map((line) => format(line))
.join('\n');
};
export const multiselect = (opts: MultiSelectOptions) => {
const opt = (
option: Option,
state:
| 'inactive'
| 'active'
| 'selected'
| 'active-selected'
| 'submitted'
| 'cancelled'
| 'disabled'
) => {
const label = option.label ?? String(option.value);
if (state === 'disabled') {
return `${styleText('gray', S_CHECKBOX_INACTIVE)} ${computeLabel(label, (str) => styleText(['strikethrough', 'gray'], str))}${
option.hint ? ` ${styleText('dim', `(${option.hint ?? 'disabled'})`)}` : ''
}`;
}
if (state === 'active') {
return `${styleText('cyan', S_CHECKBOX_ACTIVE)} ${label}${
option.hint ? ` ${styleText('dim', `(${option.hint})`)}` : ''
}`;
}
if (state === 'selected') {
return `${styleText('green', S_CHECKBOX_SELECTED)} ${computeLabel(label, (text) => styleText('dim', text))}${
option.hint ? ` ${styleText('dim', `(${option.hint})`)}` : ''
}`;
}
if (state === 'cancelled') {
return `${computeLabel(label, (text) => styleText(['strikethrough', 'dim'], text))}`;
}
if (state === 'active-selected') {
return `${styleText('green', S_CHECKBOX_SELECTED)} ${label}${
option.hint ? ` ${styleText('dim', `(${option.hint})`)}` : ''
}`;
}
if (state === 'submitted') {
return `${computeLabel(label, (text) => styleText('dim', text))}`;
}
return `${styleText('dim', S_CHECKBOX_INACTIVE)} ${computeLabel(label, (text) => styleText('dim', text))}`;
};
const required = opts.required ?? true;
return new MultiSelectPrompt({
options: opts.options,
signal: opts.signal,
input: opts.input,
output: opts.output,
initialValues: opts.initialValues,
required,
cursorAt: opts.cursorAt,
validate(selected: Value[] | undefined) {
if (required && (selected === undefined || selected.length === 0))
return `Please select at least one option.\n${styleText(
'reset',
styleText(
'dim',
`Press ${styleText(['gray', 'bgWhite', 'inverse'], ' space ')} to select, ${styleText(
'gray',
styleText('bgWhite', styleText('inverse', ' enter '))
)} to submit`
)
)}`;
},
render() {
const wrappedMessage = wrapTextWithPrefix(
opts.output,
opts.message,
`${symbolBar(this.state)} `,
`${symbol(this.state)} `
);
const title = `${styleText('gray', S_BAR)}\n${wrappedMessage}\n`;
const value = this.value ?? [];
const styleOption = (option: Option, active: boolean) => {
if (option.disabled) {
return opt(option, 'disabled');
}
const selected = value.includes(option.value);
if (active && selected) {
return opt(option, 'active-selected');
}
if (selected) {
return opt(option, 'selected');
}
return opt(option, active ? 'active' : 'inactive');
};
switch (this.state) {
case 'submit': {
const submitText =
this.options
.filter(({ value: optionValue }) => value.includes(optionValue))
.map((option) => opt(option, 'submitted'))
.join(styleText('dim', ', ')) || styleText('dim', 'none');
const wrappedSubmitText = wrapTextWithPrefix(
opts.output,
submitText,
`${styleText('gray', S_BAR)} `
);
return `${title}${wrappedSubmitText}`;
}
case 'cancel': {
const label = this.options
.filter(({ value: optionValue }) => value.includes(optionValue))
.map((option) => opt(option, 'cancelled'))
.join(styleText('dim', ', '));
if (label.trim() === '') {
return `${title}${styleText('gray', S_BAR)}`;
}
const wrappedLabel = wrapTextWithPrefix(
opts.output,
label,
`${styleText('gray', S_BAR)} `
);
return `${title}${wrappedLabel}\n${styleText('gray', S_BAR)}`;
}
case 'error': {
const prefix = `${styleText('yellow', S_BAR)} `;
const footer = this.error
.split('\n')
.map((ln, i) =>
i === 0 ? `${styleText('yellow', S_BAR_END)} ${styleText('yellow', ln)}` : ` ${ln}`
)
.join('\n');
// Calculate rowPadding: title lines + footer lines (error message + trailing newline)
const titleLineCount = title.split('\n').length;
const footerLineCount = footer.split('\n').length + 1; // footer + trailing newline
return `${title}${prefix}${limitOptions({
output: opts.output,
options: this.options,
cursor: this.cursor,
maxItems: opts.maxItems,
columnPadding: prefix.length,
rowPadding: titleLineCount + footerLineCount,
style: styleOption,
}).join(`\n${prefix}`)}\n${footer}\n`;
}
default: {
const prefix = `${styleText('cyan', S_BAR)} `;
// Calculate rowPadding: title lines + footer lines (S_BAR_END + trailing newline)
const titleLineCount = title.split('\n').length;
const footerLineCount = 2; // S_BAR_END + trailing newline
return `${title}${prefix}${limitOptions({
output: opts.output,
options: this.options,
cursor: this.cursor,
maxItems: opts.maxItems,
columnPadding: prefix.length,
rowPadding: titleLineCount + footerLineCount,
style: styleOption,
}).join(`\n${prefix}`)}\n${styleText('cyan', S_BAR_END)}\n`;
}
}
},
}).prompt() as Promise;
};
================================================
FILE: packages/prompts/src/note.ts
================================================
import process from 'node:process';
import type { Writable } from 'node:stream';
import { styleText } from 'node:util';
import { getColumns, settings } from '@clack/core';
import stringWidth from 'fast-string-width';
import { type Options as WrapAnsiOptions, wrapAnsi } from 'fast-wrap-ansi';
import {
type CommonOptions,
S_BAR,
S_BAR_H,
S_CONNECT_LEFT,
S_CORNER_BOTTOM_LEFT,
S_CORNER_BOTTOM_RIGHT,
S_CORNER_TOP_RIGHT,
S_STEP_SUBMIT,
} from './common.js';
type FormatFn = (line: string) => string;
export interface NoteOptions extends CommonOptions {
format?: FormatFn;
}
const defaultNoteFormatter = (line: string): string => styleText('dim', line);
const wrapWithFormat = (message: string, width: number, format: FormatFn): string => {
const opts: WrapAnsiOptions = {
hard: true,
trim: false,
};
const wrapMsg = wrapAnsi(message, width, opts).split('\n');
const maxWidthNormal = wrapMsg.reduce((sum, ln) => Math.max(stringWidth(ln), sum), 0);
const maxWidthFormat = wrapMsg.map(format).reduce((sum, ln) => Math.max(stringWidth(ln), sum), 0);
const wrapWidth = width - (maxWidthFormat - maxWidthNormal);
return wrapAnsi(message, wrapWidth, opts);
};
export const note = (message = '', title = '', opts?: NoteOptions) => {
const output: Writable = opts?.output ?? process.stdout;
const hasGuide = opts?.withGuide ?? settings.withGuide;
const format = opts?.format ?? defaultNoteFormatter;
const wrapMsg = wrapWithFormat(message, getColumns(output) - 6, format);
const lines = ['', ...wrapMsg.split('\n').map(format), ''];
const titleLen = stringWidth(title);
const len =
Math.max(
lines.reduce((sum, ln) => {
const width = stringWidth(ln);
return width > sum ? width : sum;
}, 0),
titleLen
) + 2;
const msg = lines
.map(
(ln) =>
`${styleText('gray', S_BAR)} ${ln}${' '.repeat(len - stringWidth(ln))}${styleText('gray', S_BAR)}`
)
.join('\n');
const leadingBorder = hasGuide ? `${styleText('gray', S_BAR)}\n` : '';
const bottomLeft = hasGuide ? S_CONNECT_LEFT : S_CORNER_BOTTOM_LEFT;
output.write(
`${leadingBorder}${styleText('green', S_STEP_SUBMIT)} ${styleText('reset', title)} ${styleText(
'gray',
S_BAR_H.repeat(Math.max(len - titleLen - 1, 1)) + S_CORNER_TOP_RIGHT
)}\n${msg}\n${styleText('gray', bottomLeft + S_BAR_H.repeat(len + 2) + S_CORNER_BOTTOM_RIGHT)}\n`
);
};
================================================
FILE: packages/prompts/src/password.ts
================================================
import { styleText } from 'node:util';
import { PasswordPrompt, settings } from '@clack/core';
import { type CommonOptions, S_BAR, S_BAR_END, S_PASSWORD_MASK, symbol } from './common.js';
export interface PasswordOptions extends CommonOptions {
message: string;
mask?: string;
validate?: (value: string | undefined) => string | Error | undefined;
clearOnError?: boolean;
}
export const password = (opts: PasswordOptions) => {
return new PasswordPrompt({
validate: opts.validate,
mask: opts.mask ?? S_PASSWORD_MASK,
signal: opts.signal,
input: opts.input,
output: opts.output,
render() {
const hasGuide = opts.withGuide ?? settings.withGuide;
const title = `${hasGuide ? `${styleText('gray', S_BAR)}\n` : ''}${symbol(this.state)} ${opts.message}\n`;
const userInput = this.userInputWithCursor;
const masked = this.masked;
switch (this.state) {
case 'error': {
const errorPrefix = hasGuide ? `${styleText('yellow', S_BAR)} ` : '';
const errorPrefixEnd = hasGuide ? `${styleText('yellow', S_BAR_END)} ` : '';
const maskedText = masked ?? '';
if (opts.clearOnError) {
this.clear();
}
return `${title.trim()}\n${errorPrefix}${maskedText}\n${errorPrefixEnd}${styleText('yellow', this.error)}\n`;
}
case 'submit': {
const submitPrefix = hasGuide ? `${styleText('gray', S_BAR)} ` : '';
const maskedText = masked ? styleText('dim', masked) : '';
return `${title}${submitPrefix}${maskedText}`;
}
case 'cancel': {
const cancelPrefix = hasGuide ? `${styleText('gray', S_BAR)} ` : '';
const maskedText = masked ? styleText(['strikethrough', 'dim'], masked) : '';
return `${title}${cancelPrefix}${maskedText}${
masked && hasGuide ? `\n${styleText('gray', S_BAR)}` : ''
}`;
}
default: {
const defaultPrefix = hasGuide ? `${styleText('cyan', S_BAR)} ` : '';
const defaultPrefixEnd = hasGuide ? styleText('cyan', S_BAR_END) : '';
return `${title}${defaultPrefix}${userInput}\n${defaultPrefixEnd}\n`;
}
}
},
}).prompt() as Promise;
};
================================================
FILE: packages/prompts/src/path.ts
================================================
import { existsSync, lstatSync, readdirSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { autocomplete } from './autocomplete.js';
import type { CommonOptions } from './common.js';
export interface PathOptions extends CommonOptions {
root?: string;
directory?: boolean;
initialValue?: string;
message: string;
validate?: (value: string | undefined) => string | Error | undefined;
}
export const path = (opts: PathOptions) => {
const validate = opts.validate;
return autocomplete({
...opts,
initialUserInput: opts.initialValue ?? opts.root ?? process.cwd(),
maxItems: 5,
validate(value) {
if (Array.isArray(value)) {
// Shouldn't ever happen since we don't enable `multiple: true`
return undefined;
}
if (!value) {
return 'Please select a path';
}
if (validate) {
return validate(value);
}
return undefined;
},
options() {
const userInput = this.userInput;
if (userInput === '') {
return [];
}
try {
let searchPath: string;
if (!existsSync(userInput)) {
searchPath = dirname(userInput);
} else {
const stat = lstatSync(userInput);
if (stat.isDirectory() && (!opts.directory || userInput.endsWith('/'))) {
searchPath = userInput;
} else {
searchPath = dirname(userInput);
}
}
// Strip trailing slash so startsWith matches the directory itself among its siblings
const prefix =
userInput.length > 1 && userInput.endsWith('/') ? userInput.slice(0, -1) : userInput;
const items = readdirSync(searchPath)
.map((item) => {
const path = join(searchPath, item);
const stats = lstatSync(path);
return {
name: item,
path,
isDirectory: stats.isDirectory(),
};
})
.filter(
({ path, isDirectory }) => path.startsWith(prefix) && (isDirectory || !opts.directory)
);
return items.map((item) => ({
value: item.path,
}));
} catch (_e) {
return [];
}
},
});
};
================================================
FILE: packages/prompts/src/progress-bar.ts
================================================
import { styleText } from 'node:util';
import type { State } from '@clack/core';
import { unicodeOr } from './common.js';
import { type SpinnerOptions, type SpinnerResult, spinner } from './spinner.js';
const S_PROGRESS_CHAR: Record, string> = {
light: unicodeOr('─', '-'),
heavy: unicodeOr('━', '='),
block: unicodeOr('█', '#'),
};
export interface ProgressOptions extends SpinnerOptions {
style?: 'light' | 'heavy' | 'block';
max?: number;
size?: number;
}
export interface ProgressResult extends SpinnerResult {
advance(step?: number, msg?: string): void;
}
export function progress({
style = 'heavy',
max: userMax = 100,
size: userSize = 40,
...spinnerOptions
}: ProgressOptions = {}): ProgressResult {
const spin = spinner(spinnerOptions);
let value = 0;
let previousMessage = '';
const max = Math.max(1, userMax);
const size = Math.max(1, userSize);
const activeStyle = (state: State) => {
switch (state) {
case 'initial':
case 'active':
return (text: string) => styleText('magenta', text);
case 'error':
case 'cancel':
return (text: string) => styleText('red', text);
case 'submit':
return (text: string) => styleText('green', text);
default:
return (text: string) => styleText('magenta', text);
}
};
const drawProgress = (state: State, msg: string) => {
const active = Math.floor((value / max) * size);
return `${activeStyle(state)(S_PROGRESS_CHAR[style].repeat(active))}${styleText('dim', S_PROGRESS_CHAR[style].repeat(size - active))} ${msg}`;
};
const start = (msg = '') => {
previousMessage = msg;
spin.start(drawProgress('initial', msg));
};
const advance = (step = 1, msg?: string): void => {
value = Math.min(max, step + value);
spin.message(drawProgress('active', msg ?? previousMessage));
previousMessage = msg ?? previousMessage;
};
return {
start,
stop: spin.stop,
cancel: spin.cancel,
error: spin.error,
clear: spin.clear,
advance,
isCancelled: spin.isCancelled,
message: (msg: string) => advance(0, msg),
};
}
================================================
FILE: packages/prompts/src/select-key.ts
================================================
import { styleText } from 'node:util';
import { SelectKeyPrompt, settings, wrapTextWithPrefix } from '@clack/core';
import { type CommonOptions, S_BAR, S_BAR_END, symbol } from './common.js';
import type { Option } from './select.js';
export interface SelectKeyOptions extends CommonOptions {
message: string;
options: Option[];
initialValue?: Value;
caseSensitive?: boolean;
}
export const selectKey = (opts: SelectKeyOptions) => {
const opt = (
option: Option,
state: 'inactive' | 'active' | 'selected' | 'cancelled' = 'inactive'
) => {
const label = option.label ?? String(option.value);
if (state === 'selected') {
return `${styleText('dim', label)}`;
}
if (state === 'cancelled') {
return `${styleText(['strikethrough', 'dim'], label)}`;
}
if (state === 'active') {
return `${styleText(['bgCyan', 'gray'], ` ${option.value} `)} ${label}${
option.hint ? ` ${styleText('dim', `(${option.hint})`)}` : ''
}`;
}
return `${styleText(['gray', 'bgWhite', 'inverse'], ` ${option.value} `)} ${label}${
option.hint ? ` ${styleText('dim', `(${option.hint})`)}` : ''
}`;
};
return new SelectKeyPrompt({
options: opts.options,
signal: opts.signal,
input: opts.input,
output: opts.output,
initialValue: opts.initialValue,
caseSensitive: opts.caseSensitive,
render() {
const hasGuide = opts.withGuide ?? settings.withGuide;
const title = `${hasGuide ? `${styleText('gray', S_BAR)}\n` : ''}${symbol(this.state)} ${opts.message}\n`;
switch (this.state) {
case 'submit': {
const submitPrefix = hasGuide ? `${styleText('gray', S_BAR)} ` : '';
const selectedOption =
this.options.find((opt) => opt.value === this.value) ?? opts.options[0];
const wrapped = wrapTextWithPrefix(
opts.output,
opt(selectedOption, 'selected'),
submitPrefix
);
return `${title}${wrapped}`;
}
case 'cancel': {
const cancelPrefix = hasGuide ? `${styleText('gray', S_BAR)} ` : '';
const wrapped = wrapTextWithPrefix(
opts.output,
opt(this.options[0], 'cancelled'),
cancelPrefix
);
return `${title}${wrapped}${hasGuide ? `\n${styleText('gray', S_BAR)}` : ''}`;
}
default: {
const defaultPrefix = hasGuide ? `${styleText('cyan', S_BAR)} ` : '';
const defaultPrefixEnd = hasGuide ? styleText('cyan', S_BAR_END) : '';
const wrapped = this.options
.map((option, i) =>
wrapTextWithPrefix(
opts.output,
opt(option, i === this.cursor ? 'active' : 'inactive'),
defaultPrefix
)
)
.join('\n');
return `${title}${wrapped}\n${defaultPrefixEnd}\n`;
}
}
},
}).prompt() as Promise;
};
================================================
FILE: packages/prompts/src/select.ts
================================================
import { styleText } from 'node:util';
import { SelectPrompt, settings, wrapTextWithPrefix } from '@clack/core';
import {
type CommonOptions,
S_BAR,
S_BAR_END,
S_RADIO_ACTIVE,
S_RADIO_INACTIVE,
symbol,
symbolBar,
} from './common.js';
import { limitOptions } from './limit-options.js';
type Primitive = Readonly;
export type Option = Value extends Primitive
? {
/**
* Internal data for this option.
*/
value: Value;
/**
* The optional, user-facing text for this option.
*
* By default, the `value` is converted to a string.
*/
label?: string;
/**
* An optional hint to display to the user when
* this option might be selected.
*
* By default, no `hint` is displayed.
*/
hint?: string;
/**
* Whether this option is disabled.
* Disabled options are visible but cannot be selected.
*
* By default, options are not disabled.
*/
disabled?: boolean;
}
: {
/**
* Internal data for this option.
*/
value: Value;
/**
* Required. The user-facing text for this option.
*/
label: string;
/**
* An optional hint to display to the user when
* this option might be selected.
*
* By default, no `hint` is displayed.
*/
hint?: string;
/**
* Whether this option is disabled.
* Disabled options are visible but cannot be selected.
*
* By default, options are not disabled.
*/
disabled?: boolean;
};
export interface SelectOptions extends CommonOptions {
message: string;
options: Option[];
initialValue?: Value;
maxItems?: number;
}
const computeLabel = (label: string, format: (text: string) => string) => {
if (!label.includes('\n')) {
return format(label);
}
return label
.split('\n')
.map((line) => format(line))
.join('\n');
};
export const select = (opts: SelectOptions) => {
const opt = (
option: Option,
state: 'inactive' | 'active' | 'selected' | 'cancelled' | 'disabled'
) => {
const label = option.label ?? String(option.value);
switch (state) {
case 'disabled':
return `${styleText('gray', S_RADIO_INACTIVE)} ${computeLabel(label, (text) => styleText('gray', text))}${
option.hint ? ` ${styleText('dim', `(${option.hint ?? 'disabled'})`)}` : ''
}`;
case 'selected':
return `${computeLabel(label, (text) => styleText('dim', text))}`;
case 'active':
return `${styleText('green', S_RADIO_ACTIVE)} ${label}${
option.hint ? ` ${styleText('dim', `(${option.hint})`)}` : ''
}`;
case 'cancelled':
return `${computeLabel(label, (str) => styleText(['strikethrough', 'dim'], str))}`;
default:
return `${styleText('dim', S_RADIO_INACTIVE)} ${computeLabel(label, (text) => styleText('dim', text))}`;
}
};
return new SelectPrompt({
options: opts.options,
signal: opts.signal,
input: opts.input,
output: opts.output,
initialValue: opts.initialValue,
render() {
const hasGuide = opts.withGuide ?? settings.withGuide;
const titlePrefix = `${symbol(this.state)} `;
const titlePrefixBar = `${symbolBar(this.state)} `;
const messageLines = wrapTextWithPrefix(
opts.output,
opts.message,
titlePrefixBar,
titlePrefix
);
const title = `${hasGuide ? `${styleText('gray', S_BAR)}\n` : ''}${messageLines}\n`;
switch (this.state) {
case 'submit': {
const submitPrefix = hasGuide ? `${styleText('gray', S_BAR)} ` : '';
const wrappedLines = wrapTextWithPrefix(
opts.output,
opt(this.options[this.cursor], 'selected'),
submitPrefix
);
return `${title}${wrappedLines}`;
}
case 'cancel': {
const cancelPrefix = hasGuide ? `${styleText('gray', S_BAR)} ` : '';
const wrappedLines = wrapTextWithPrefix(
opts.output,
opt(this.options[this.cursor], 'cancelled'),
cancelPrefix
);
return `${title}${wrappedLines}${hasGuide ? `\n${styleText('gray', S_BAR)}` : ''}`;
}
default: {
const prefix = hasGuide ? `${styleText('cyan', S_BAR)} ` : '';
const prefixEnd = hasGuide ? styleText('cyan', S_BAR_END) : '';
// Calculate rowPadding: title lines + footer lines (S_BAR_END + trailing newline)
const titleLineCount = title.split('\n').length;
const footerLineCount = hasGuide ? 2 : 1; // S_BAR_END + trailing newline (or just trailing newline)
return `${title}${prefix}${limitOptions({
output: opts.output,
cursor: this.cursor,
options: this.options,
maxItems: opts.maxItems,
columnPadding: prefix.length,
rowPadding: titleLineCount + footerLineCount,
style: (item, active) =>
opt(item, item.disabled ? 'disabled' : active ? 'active' : 'inactive'),
}).join(`\n${prefix}`)}\n${prefixEnd}\n`;
}
}
},
}).prompt() as Promise;
};
================================================
FILE: packages/prompts/src/spinner.ts
================================================
import { styleText } from 'node:util';
import { block, getColumns, settings } from '@clack/core';
import { wrapAnsi } from 'fast-wrap-ansi';
import { cursor, erase } from 'sisteransi';
import {
type CommonOptions,
isCI as isCIFn,
S_BAR,
S_STEP_CANCEL,
S_STEP_ERROR,
S_STEP_SUBMIT,
unicode,
} from './common.js';
export interface SpinnerOptions extends CommonOptions {
indicator?: 'dots' | 'timer';
onCancel?: () => void;
cancelMessage?: string;
errorMessage?: string;
frames?: string[];
delay?: number;
styleFrame?: (frame: string) => string;
}
export interface SpinnerResult {
start(msg?: string): void;
stop(msg?: string): void;
cancel(msg?: string): void;
error(msg?: string): void;
message(msg?: string): void;
clear(): void;
readonly isCancelled: boolean;
}
const defaultStyleFn: SpinnerOptions['styleFrame'] = (frame) => styleText('magenta', frame);
export const spinner = ({
indicator = 'dots',
onCancel,
output = process.stdout,
cancelMessage,
errorMessage,
frames = unicode ? ['◒', '◐', '◓', '◑'] : ['•', 'o', 'O', '0'],
delay = unicode ? 80 : 120,
signal,
...opts
}: SpinnerOptions = {}): SpinnerResult => {
const isCI = isCIFn();
let unblock: () => void;
let loop: NodeJS.Timeout;
let isSpinnerActive = false;
let isCancelled = false;
let _message = '';
let _prevMessage: string | undefined;
let _origin: number = performance.now();
const columns = getColumns(output);
const styleFn = opts?.styleFrame ?? defaultStyleFn;
const handleExit = (code: number) => {
const msg =
code > 1
? (errorMessage ?? settings.messages.error)
: (cancelMessage ?? settings.messages.cancel);
isCancelled = code === 1;
if (isSpinnerActive) {
_stop(msg, code);
if (isCancelled && typeof onCancel === 'function') {
onCancel();
}
}
};
const errorEventHandler = () => handleExit(2);
const signalEventHandler = () => handleExit(1);
const registerHooks = () => {
// Reference: https://nodejs.org/api/process.html#event-uncaughtexception
process.on('uncaughtExceptionMonitor', errorEventHandler);
// Reference: https://nodejs.org/api/process.html#event-unhandledrejection
process.on('unhandledRejection', errorEventHandler);
// Reference Signal Events: https://nodejs.org/api/process.html#signal-events
process.on('SIGINT', signalEventHandler);
process.on('SIGTERM', signalEventHandler);
process.on('exit', handleExit);
if (signal) {
signal.addEventListener('abort', signalEventHandler);
}
};
const clearHooks = () => {
process.removeListener('uncaughtExceptionMonitor', errorEventHandler);
process.removeListener('unhandledRejection', errorEventHandler);
process.removeListener('SIGINT', signalEventHandler);
process.removeListener('SIGTERM', signalEventHandler);
process.removeListener('exit', handleExit);
if (signal) {
signal.removeEventListener('abort', signalEventHandler);
}
};
const clearPrevMessage = () => {
if (_prevMessage === undefined) return;
if (isCI) output.write('\n');
const wrapped = wrapAnsi(_prevMessage, columns, {
hard: true,
trim: false,
});
const prevLines = wrapped.split('\n');
if (prevLines.length > 1) {
output.write(cursor.up(prevLines.length - 1));
}
output.write(cursor.to(0));
output.write(erase.down());
};
const removeTrailingDots = (msg: string): string => {
return msg.replace(/\.+$/, '');
};
const formatTimer = (origin: number): string => {
const duration = (performance.now() - origin) / 1000;
const min = Math.floor(duration / 60);
const secs = Math.floor(duration % 60);
return min > 0 ? `[${min}m ${secs}s]` : `[${secs}s]`;
};
const hasGuide = opts.withGuide ?? settings.withGuide;
const start = (msg = ''): void => {
isSpinnerActive = true;
unblock = block({ output });
_message = removeTrailingDots(msg);
_origin = performance.now();
if (hasGuide) {
output.write(`${styleText('gray', S_BAR)}\n`);
}
let frameIndex = 0;
let indicatorTimer = 0;
registerHooks();
loop = setInterval(() => {
if (isCI && _message === _prevMessage) {
return;
}
clearPrevMessage();
_prevMessage = _message;
const frame = styleFn(frames[frameIndex]);
let outputMessage: string;
if (isCI) {
outputMessage = `${frame} ${_message}...`;
} else if (indicator === 'timer') {
outputMessage = `${frame} ${_message} ${formatTimer(_origin)}`;
} else {
const loadingDots = '.'.repeat(Math.floor(indicatorTimer)).slice(0, 3);
outputMessage = `${frame} ${_message}${loadingDots}`;
}
const wrapped = wrapAnsi(outputMessage, columns, {
hard: true,
trim: false,
});
output.write(wrapped);
frameIndex = frameIndex + 1 < frames.length ? frameIndex + 1 : 0;
// indicator increase by 1 every 8 frames
indicatorTimer = indicatorTimer < 4 ? indicatorTimer + 0.125 : 0;
}, delay);
};
const _stop = (msg = '', code = 0, silent: boolean = false): void => {
if (!isSpinnerActive) return;
isSpinnerActive = false;
clearInterval(loop);
clearPrevMessage();
const step =
code === 0
? styleText('green', S_STEP_SUBMIT)
: code === 1
? styleText('red', S_STEP_CANCEL)
: styleText('red', S_STEP_ERROR);
_message = msg ?? _message;
if (!silent) {
if (indicator === 'timer') {
output.write(`${step} ${_message} ${formatTimer(_origin)}\n`);
} else {
output.write(`${step} ${_message}\n`);
}
}
clearHooks();
unblock();
};
const stop = (msg = ''): void => _stop(msg, 0);
const cancel = (msg = ''): void => _stop(msg, 1);
const error = (msg = ''): void => _stop(msg, 2);
// TODO (43081j): this will leave the initial S_BAR since we purposely
// don't erase that in `clearPrevMessage`. In future, we may want to treat
// `clear` as a special case and remove the bar too.
const clear = (): void => _stop('', 0, true);
const message = (msg = ''): void => {
_message = removeTrailingDots(msg ?? _message);
};
return {
start,
stop,
message,
cancel,
error,
clear,
get isCancelled() {
return isCancelled;
},
};
};
================================================
FILE: packages/prompts/src/stream.ts
================================================
import { stripVTControlCharacters as strip, styleText } from 'node:util';
import { S_BAR, S_ERROR, S_INFO, S_STEP_SUBMIT, S_SUCCESS, S_WARN } from './common.js';
import type { LogMessageOptions } from './log.js';
const prefix = `${styleText('gray', S_BAR)} `;
// TODO (43081j): this currently doesn't support custom `output` writables
// because we rely on `columns` existing (i.e. `process.stdout.columns).
//
// If we want to support `output` being passed in, we will need to use
// a condition like `if (output insance Writable)` to check if it has columns
export const stream = {
message: async (
iterable: Iterable | AsyncIterable,
{ symbol = styleText('gray', S_BAR) }: LogMessageOptions = {}
) => {
process.stdout.write(`${styleText('gray', S_BAR)}\n${symbol} `);
let lineWidth = 3;
for await (let chunk of iterable) {
chunk = chunk.replace(/\n/g, `\n${prefix}`);
if (chunk.includes('\n')) {
lineWidth = 3 + strip(chunk.slice(chunk.lastIndexOf('\n'))).length;
}
const chunkLen = strip(chunk).length;
if (lineWidth + chunkLen < process.stdout.columns) {
lineWidth += chunkLen;
process.stdout.write(chunk);
} else {
process.stdout.write(`\n${prefix}${chunk.trimStart()}`);
lineWidth = 3 + strip(chunk.trimStart()).length;
}
}
process.stdout.write('\n');
},
info: (iterable: Iterable | AsyncIterable) => {
return stream.message(iterable, { symbol: styleText('blue', S_INFO) });
},
success: (iterable: Iterable | AsyncIterable) => {
return stream.message(iterable, { symbol: styleText('green', S_SUCCESS) });
},
step: (iterable: Iterable | AsyncIterable) => {
return stream.message(iterable, { symbol: styleText('green', S_STEP_SUBMIT) });
},
warn: (iterable: Iterable | AsyncIterable) => {
return stream.message(iterable, { symbol: styleText('yellow', S_WARN) });
},
/** alias for `log.warn()`. */
warning: (iterable: Iterable | AsyncIterable) => {
return stream.warn(iterable);
},
error: (iterable: Iterable | AsyncIterable) => {
return stream.message(iterable, { symbol: styleText('red', S_ERROR) });
},
};
================================================
FILE: packages/prompts/src/task-log.ts
================================================
import type { Writable } from 'node:stream';
import { styleText } from 'node:util';
import { getColumns } from '@clack/core';
import { erase } from 'sisteransi';
import {
type CommonOptions,
isCI as isCIFn,
isTTY as isTTYFn,
S_BAR,
S_STEP_SUBMIT,
} from './common.js';
import { log } from './log.js';
export interface TaskLogOptions extends CommonOptions {
title: string;
limit?: number;
spacing?: number;
retainLog?: boolean;
}
export interface TaskLogMessageOptions {
raw?: boolean;
}
export interface TaskLogCompletionOptions {
showLog?: boolean;
}
interface BufferEntry {
header?: string;
value: string;
full: string;
result?: {
status: 'success' | 'error';
message: string;
};
}
const stripDestructiveANSI = (input: string): string => {
// biome-ignore lint/suspicious/noControlCharactersInRegex: intentional
return input.replace(/\x1b\[(?:\d+;)*\d*[ABCDEFGHfJKSTsu]|\x1b\[(s|u)/g, '');
};
/**
* Renders a log which clears on success and remains on failure
*/
export const taskLog = (opts: TaskLogOptions) => {
const output: Writable = opts.output ?? process.stdout;
const columns = getColumns(output);
const secondarySymbol = styleText('gray', S_BAR);
const spacing = opts.spacing ?? 1;
const barSize = 3;
const retainLog = opts.retainLog === true;
const isTTY = !isCIFn() && isTTYFn(output);
output.write(`${secondarySymbol}\n`);
output.write(`${styleText('green', S_STEP_SUBMIT)} ${opts.title}\n`);
for (let i = 0; i < spacing; i++) {
output.write(`${secondarySymbol}\n`);
}
const buffers: BufferEntry[] = [
{
value: '',
full: '',
},
];
let lastMessageWasRaw = false;
const clear = (clearTitle: boolean): void => {
if (buffers.length === 0) {
return;
}
let lines = 0;
if (clearTitle) {
lines += spacing + 2;
}
for (const buffer of buffers) {
const { value, result } = buffer;
let text = result?.message ?? value;
if (text.length === 0) {
continue;
}
if (result === undefined && buffer.header !== undefined && buffer.header !== '') {
text += `\n${buffer.header}`;
}
const bufferHeight = text.split('\n').reduce((count, line) => {
if (line === '') {
return count + 1;
}
return count + Math.ceil((line.length + barSize) / columns);
}, 0);
lines += bufferHeight;
}
if (lines > 0) {
lines += 1;
output.write(erase.lines(lines));
}
};
const printBuffer = (buffer: BufferEntry, messageSpacing?: number, full?: boolean): void => {
const messages = full ? `${buffer.full}\n${buffer.value}` : buffer.value;
if (buffer.header !== undefined && buffer.header !== '') {
log.message(
buffer.header.split('\n').map((line) => styleText('bold', line)),
{
output,
secondarySymbol,
symbol: secondarySymbol,
spacing: 0,
}
);
}
log.message(
messages.split('\n').map((line) => styleText('dim', line)),
{
output,
secondarySymbol,
symbol: secondarySymbol,
spacing: messageSpacing ?? spacing,
}
);
};
const renderBuffer = (): void => {
for (const buffer of buffers) {
const { header, value, full } = buffer;
if ((header === undefined || header.length === 0) && value.length === 0) {
continue;
}
printBuffer(buffer, undefined, retainLog === true && full.length > 0);
}
};
const message = (buffer: BufferEntry, msg: string, mopts?: TaskLogMessageOptions) => {
clear(false);
if ((mopts?.raw !== true || !lastMessageWasRaw) && buffer.value !== '') {
buffer.value += '\n';
}
buffer.value += stripDestructiveANSI(msg);
lastMessageWasRaw = mopts?.raw === true;
if (opts.limit !== undefined) {
const lines = buffer.value.split('\n');
const linesToRemove = lines.length - opts.limit;
if (linesToRemove > 0) {
const removedLines = lines.splice(0, linesToRemove);
if (retainLog) {
buffer.full += (buffer.full === '' ? '' : '\n') + removedLines.join('\n');
}
}
buffer.value = lines.join('\n');
}
if (isTTY) {
printBuffers();
}
};
const printBuffers = (): void => {
for (const buffer of buffers) {
if (buffer.result) {
if (buffer.result.status === 'error') {
log.error(buffer.result.message, { output, secondarySymbol, spacing: 0 });
} else {
log.success(buffer.result.message, { output, secondarySymbol, spacing: 0 });
}
} else if (buffer.value !== '') {
printBuffer(buffer, 0);
}
}
};
const completeBuffer = (buffer: BufferEntry, result: BufferEntry['result']): void => {
clear(false);
buffer.result = result;
if (isTTY) {
printBuffers();
}
};
return {
message(msg: string, mopts?: TaskLogMessageOptions) {
message(buffers[0], msg, mopts);
},
group(name: string) {
const buffer: BufferEntry = {
header: name,
value: '',
full: '',
};
buffers.push(buffer);
return {
message(msg: string, mopts?: TaskLogMessageOptions) {
message(buffer, msg, mopts);
},
error(message: string) {
completeBuffer(buffer, {
status: 'error',
message,
});
},
success(message: string) {
completeBuffer(buffer, {
status: 'success',
message,
});
},
};
},
error(message: string, opts?: TaskLogCompletionOptions): void {
clear(true);
log.error(message, { output, secondarySymbol, spacing: 1 });
if (opts?.showLog !== false) {
renderBuffer();
}
// clear buffer since error is an end state
buffers.splice(1, buffers.length - 1);
buffers[0].value = '';
buffers[0].full = '';
},
success(message: string, opts?: TaskLogCompletionOptions): void {
clear(true);
log.success(message, { output, secondarySymbol, spacing: 1 });
if (opts?.showLog === true) {
renderBuffer();
}
// clear buffer since success is an end state
buffers.splice(1, buffers.length - 1);
buffers[0].value = '';
buffers[0].full = '';
},
};
};
================================================
FILE: packages/prompts/src/task.ts
================================================
import type { CommonOptions } from './common.js';
import { spinner } from './spinner.js';
export type Task = {
/**
* Task title
*/
title: string;
/**
* Task function
*/
task: (message: (string: string) => void) => string | Promise | void | Promise;
/**
* If enabled === false the task will be skipped
*/
enabled?: boolean;
};
/**
* Define a group of tasks to be executed
*/
export const tasks = async (tasks: Task[], opts?: CommonOptions) => {
for (const task of tasks) {
if (task.enabled === false) continue;
const s = spinner(opts);
s.start(task.title);
const result = await task.task(s.message);
s.stop(result || task.title);
}
};
================================================
FILE: packages/prompts/src/text.ts
================================================
import { styleText } from 'node:util';
import { settings, TextPrompt } from '@clack/core';
import { type CommonOptions, S_BAR, S_BAR_END, symbol } from './common.js';
export interface TextOptions extends CommonOptions {
message: string;
placeholder?: string;
defaultValue?: string;
initialValue?: string;
validate?: (value: string | undefined) => string | Error | undefined;
}
export const text = (opts: TextOptions) => {
return new TextPrompt({
validate: opts.validate,
placeholder: opts.placeholder,
defaultValue: opts.defaultValue,
initialValue: opts.initialValue,
output: opts.output,
signal: opts.signal,
input: opts.input,
render() {
const hasGuide = opts?.withGuide ?? settings.withGuide;
const titlePrefix = `${hasGuide ? `${styleText('gray', S_BAR)}\n` : ''}${symbol(this.state)} `;
const title = `${titlePrefix}${opts.message}\n`;
const placeholder = opts.placeholder
? styleText('inverse', opts.placeholder[0]) + styleText('dim', opts.placeholder.slice(1))
: styleText(['inverse', 'hidden'], '_');
const userInput = !this.userInput ? placeholder : this.userInputWithCursor;
const value = this.value ?? '';
switch (this.state) {
case 'error': {
const errorText = this.error ? ` ${styleText('yellow', this.error)}` : '';
const errorPrefix = hasGuide ? `${styleText('yellow', S_BAR)} ` : '';
const errorPrefixEnd = hasGuide ? styleText('yellow', S_BAR_END) : '';
return `${title.trim()}\n${errorPrefix}${userInput}\n${errorPrefixEnd}${errorText}\n`;
}
case 'submit': {
const valueText = value ? ` ${styleText('dim', value)}` : '';
const submitPrefix = hasGuide ? styleText('gray', S_BAR) : '';
return `${title}${submitPrefix}${valueText}`;
}
case 'cancel': {
const valueText = value ? ` ${styleText(['strikethrough', 'dim'], value)}` : '';
const cancelPrefix = hasGuide ? styleText('gray', S_BAR) : '';
return `${title}${cancelPrefix}${valueText}${value.trim() ? `\n${cancelPrefix}` : ''}`;
}
default: {
const defaultPrefix = hasGuide ? `${styleText('cyan', S_BAR)} ` : '';
const defaultPrefixEnd = hasGuide ? styleText('cyan', S_BAR_END) : '';
return `${title}${defaultPrefix}${userInput}\n${defaultPrefixEnd}\n`;
}
}
},
}).prompt() as Promise;
};
================================================
FILE: packages/prompts/test/__snapshots__/autocomplete.test.ts.snap
================================================
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`autocomplete > can be aborted by a signal 1`] = `
[
"",
"[90m│[39m
[36m◆[39m foo
[36m│[39m
[36m│[39m [2mSearch:[22m [7m[8m_[28m[27m
[36m│[39m [32m●[39m Apple
[36m│[39m [2m○[22m [2mBanana[22m
[36m│[39m [2m○[22m [2mCherry[22m
[36m│[39m [2m○[22m [2mGrape[22m
[36m│[39m [2m○[22m [2mOrange[22m
[36m│[39m [2m↑/↓[22m to select • [2mEnter:[22m confirm • [2mType:[22m to search
[36m└[39m",
"
",
"",
]
`;
exports[`autocomplete > cannot select disabled options when only one left 1`] = `
[
"",
"[90m│[39m
[36m◆[39m Select a fruit
[36m│[39m
[36m│[39m [2mSearch:[22m [7m[8m_[28m[27m
[36m│[39m [32m●[39m Apple
[36m│[39m [2m○[22m [2mBanana[22m
[36m│[39m [2m○[22m [2mCherry[22m
[36m│[39m [2m○[22m [2mGrape[22m
[36m│[39m [2m○[22m [2mOrange[22m
[36m│[39m [90m○[39m [9m[90mKiwi[39m[29m
[36m│[39m [2m↑/↓[22m to select • [2mEnter:[22m confirm • [2mType:[22m to search
[36m└[39m",
"",
"",
"",
"[36m│[39m [2mSearch:[22m k█[2m (1 match)[22m
[36m│[39m [90m○[39m [9m[90mKiwi[39m[29m
[36m│[39m [2m↑/↓[22m to select • [2mEnter:[22m confirm • [2mType:[22m to search
[36m└[39m",
"",
"",
"",
"[32m◇[39m Select a fruit
[90m│[39m",
"
",
"",
]
`;
exports[`autocomplete > displays disabled options correctly 1`] = `
[
"",
"[90m│[39m
[36m◆[39m Select a fruit
[36m│[39m
[36m│[39m [2mSearch:[22m [7m[8m_[28m[27m
[36m│[39m [32m●[39m Apple
[36m│[39m [2m○[22m [2mBanana[22m
[36m│[39m [2m○[22m [2mCherry[22m
[36m│[39m [2m○[22m [2mGrape[22m
[36m│[39m [2m○[22m [2mOrange[22m
[36m│[39m [90m○[39m [9m[90mKiwi[39m[29m
[36m│[39m [2m↑/↓[22m to select • [2mEnter:[22m confirm • [2mType:[22m to search
[36m└[39m",
"",
"",
"",
"[36m│[39m [2mSearch:[22m
[36m│[39m [2m○[22m [2mApple[22m
[36m│[39m [32m●[39m Banana
[36m│[39m [2m○[22m [2mCherry[22m
[36m│[39m [2m○[22m [2mGrape[22m
[36m│[39m [2m○[22m [2mOrange[22m
[36m│[39m [90m○[39m [9m[90mKiwi[39m[29m
[36m│[39m [2m↑/↓[22m to select • [2mEnter:[22m confirm • [2mType:[22m to search
[36m└[39m",
"",
"",
"",
"[36m│[39m [2m○[22m [2mBanana[22m
[36m│[39m [32m●[39m Cherry
[36m│[39m [2m○[22m [2mGrape[22m
[36m│[39m [2m○[22m [2mOrange[22m
[36m│[39m [90m○[39m [9m[90mKiwi[39m[29m
[36m│[39m [2m↑/↓[22m to select • [2mEnter:[22m confirm • [2mType:[22m to search
[36m└[39m",
"",
"",
"",
"[36m│[39m [2m○[22m [2mCherry[22m
[36m│[39m [32m●[39m Grape
[36m│[39m [2m○[22m [2mOrange[22m
[36m│[39m [90m○[39m [9m[90mKiwi[39m[29m
[36m│[39m [2m↑/↓[22m to select • [2mEnter:[22m confirm • [2mType:[22m to search
[36m└[39m",
"",
"",
"",
"[36m│[39m [2m○[22m [2mGrape[22m
[36m│[39m [32m●[39m Orange
[36m│[39m [90m○[39m [9m[90mKiwi[39m[29m
[36m│[39m [2m↑/↓[22m to select • [2mEnter:[22m confirm • [2mType:[22m to search
[36m└[39m",
"",
"",
"",
"[36m│[39m [32m●[39m Apple
[36m│[39m [2m○[22m [2mBanana[22m
[36m│[39m [2m○[22m [2mCherry[22m
[36m│[39m [2m○[22m [2mGrape[22m
[36m│[39m [2m○[22m [2mOrange[22m
[36m│[39m [90m○[39m [9m[90mKiwi[39m[29m
[36m│[39m [2m↑/↓[22m to select • [2mEnter:[22m confirm • [2mType:[22m to search
[36m└[39m",
"",
"",
"",
"[32m◇[39m Select a fruit
[90m│[39m [2mApple[22m",
"
",
"",
]
`;
exports[`autocomplete > limits displayed options when maxItems is set 1`] = `
[
"",
"[90m│[39m
[36m◆[39m Select an option
[36m│[39m
[36m│[39m [2mSearch:[22m [7m[8m_[28m[27m
[36m│[39m [32m●[39m Option 0
[36m│[39m [2m○[22m [2mOption 1[22m
[36m│[39m [2m○[22m [2mOption 2[22m
[36m│[39m [2m○[22m [2mOption 3[22m
[36m│[39m [2m○[22m [2mOption 4[22m
[36m│[39m [2m...[22m
[36m│[39m [2m↑/↓[22m to select • [2mEnter:[22m confirm • [2mType:[22m to search
[36m└[39m",
"",
"",
"",
"[32m◇[39m Select an option
[90m│[39m [2mOption 0[22m",
"
",
"",
]
`;
exports[`autocomplete > placeholder is shown if set 1`] = `
[
"",
"[90m│[39m
[36m◆[39m Select a fruit
[36m│[39m
[36m│[39m [2mSearch:[22m [2mType to search...[22m
[36m│[39m [32m●[39m Apple
[36m│[39m [2m○[22m [2mBanana[22m
[36m│[39m [2m○[22m [2mCherry[22m
[36m│[39m [2m○[22m [2mGrape[22m
[36m│[39m [2m○[22m [2mOrange[22m
[36m│[39m [2m↑/↓[22m to select • [2mEnter:[22m confirm • [2mType:[22m to search
[36m└[39m",
"",
"",
"",
"[36m│[39m [2mSearch:[22m g█[2m (2 matches)[22m
[36m│[39m [32m●[39m Grape
[36m│[39m [2m○[22m [2mOrange[22m
[36m│[39m [2m↑/↓[22m to select • [2mEnter:[22m confirm • [2mType:[22m to search
[36m└[39m",
"",
"",
"",
"[32m◇[39m Select a fruit
[90m│[39m [2mGrape[22m",
"
",
"",
]
`;
exports[`autocomplete > renders bottom ellipsis when items do not fit 1`] = `
[
"",
"[90m│[39m
[36m◆[39m Select an option
[36m│[39m
[36m│[39m [2mSearch:[22m [7m[8m_[28m[27m
[36m│[39m [32m●[39m Line 0
[36m│[39m Line 1
[36m│[39m Line 2
[36m│[39m Line 3
[36m│[39m [2m...[22m
[36m│[39m [2m↑/↓[22m to select • [2mEnter:[22m confirm • [2mType:[22m to search
[36m└[39m",
"",
"",
"[32m◇[39m Select an option
[90m│[39m [2mLine 0
Line 1
Line 2
Line 3[22m",
"
",
"",
]
`;
exports[`autocomplete > renders initial UI with message and instructions 1`] = `
[
"",
"[90m│[39m
[36m◆[39m Select a fruit
[36m│[39m
[36m│[39m [2mSearch:[22m [7m[8m_[28m[27m
[36m│[39m [32m●[39m Apple
[36m│[39m [2m○[22m [2mBanana[22m
[36m│[39m [2m○[22m [2mCherry[22m
[36m│[39m [2m○[22m [2mGrape[22m
[36m│[39m [2m○[22m [2mOrange[22m
[36m│[39m [2m↑/↓[22m to select • [2mEnter:[22m confirm • [2mType:[22m to search
[36m└[39m",
"",
"",
"",
"[32m◇[39m Select a fruit
[90m│[39m [2mApple[22m",
"
",
"",
]
`;
exports[`autocomplete > renders placeholder if set 1`] = `
[
"",
"[90m│[39m
[36m◆[39m Select a fruit
[36m│[39m
[36m│[39m [2mSearch:[22m [2mType to search...[22m
[36m│[39m [32m●[39m Apple
[36m│[39m [2m○[22m [2mBanana[22m
[36m│[39m [2m○[22m [2mCherry[22m
[36m│[39m [2m○[22m [2mGrape[22m
[36m│[39m [2m○[22m [2mOrange[22m
[36m│[39m [2m↑/↓[22m to select • [2mEnter:[22m confirm • [2mType:[22m to search
[36m└[39m",
"",
"",
"",
"[32m◇[39m Select a fruit
[90m│[39m [2mApple[22m",
"
",
"",
]
`;
exports[`autocomplete > renders top ellipsis when scrolled down and its do not fit 1`] = `
[
"",
"[90m│[39m
[36m◆[39m Select an option
[36m│[39m
[36m│[39m [2mSearch:[22m [7m[8m_[28m[27m
[36m│[39m [2m...[22m
[36m│[39m [32m●[39m Option 2
[36m│[39m [2m↑/↓[22m to select • [2mEnter:[22m confirm • [2mType:[22m to search
[36m└[39m",
"",
"",
"[90m│[39m
[32m◇[39m Select an option
[90m│[39m [2mOption 2[22m",
"
",
"",
]
`;
exports[`autocomplete > shows hint when option has hint and is focused 1`] = `
[
"