clickprompt
v1.0.0
Published
Mouse-enabled interactive prompts for the terminal. Click to select. Drag to reorder. Drop-in for inquirer and prompts. Zero dependencies. ESM + CJS.
Maintainers
Readme
clickprompt
Mouse-enabled interactive prompts for the terminal. Click to select, scroll to navigate, drag to reorder. Zero runtime dependencies. ESM + CJS.
npm install clickpromptMost CLI prompt libraries ignore the mouse. clickprompt reads SGR mouse events
(modes ?1000 / ?1002 / ?1006) so users can click rows, scroll through long
lists, and drag items to reorder them. Keyboard always works too — mouse is
additive.
Node 18+. Works in iTerm2, Terminal.app, Windows Terminal, VS Code's terminal,
Alacritty, Kitty, WezTerm, GNOME Terminal, Konsole, tmux, GNU screen, and
modern Windows conhost. Legacy cmd.exe and CI environments are auto-detected
and fall back to keyboard.
Prompt lineup
| Prompt | Mouse | Notes |
|---|---|---|
| reorder | click + drag | Drag-to-reorder; optional per-row action chips |
| select | click + wheel | Single choice |
| multiselect | click + wheel | Multiple choice with min/max |
| autocomplete | click + wheel | Type to filter |
| confirm | click [Yes] / [No] | Y/N buttons |
| toggle | click on/off | Boolean switch |
| text | — | Line input + readline editing |
| password | — | Masked text |
| number | — | Numeric input with min/max/float |
| spinner | — | Long-running task indicator |
| form | (inherits) | Sequential multi-step wizard |
Decorations: intro, outro, note, log, logSuccess, logWarning,
logError for clack-style session framing.
Quick start
import {
intro, outro,
text, select, multiselect, confirm, autocomplete, spinner,
} from "clickprompt";
intro("create-app");
const name = await text({
message: "Project name",
placeholder: "my-app",
validate: (v) => v.length >= 3 || "At least 3 characters",
});
const framework = await select({
message: "Framework",
options: [
{ label: "Next.js", value: "next", hint: "React, full stack" },
{ label: "Astro", value: "astro", hint: "Content + islands" },
{ label: "SvelteKit", value: "sveltekit", hint: "Fast + small" },
],
});
const features = await multiselect({
message: "Add features",
options: [
{ label: "TypeScript", value: "ts" },
{ label: "ESLint + Prettier", value: "lint" },
{ label: "Tailwind CSS", value: "tw" },
{ label: "Vitest", value: "test" },
],
defaultSelected: ["ts", "lint"],
});
const region = await autocomplete({
message: "Region",
options: regions, // { label, value, hint? }[]
});
const ok = await confirm({ message: "Create project?", initial: true });
if (ok) {
const s = spinner();
s.start("Generating");
await scaffold({ name, framework, features, region });
s.stop("Done", true);
}
outro(`cd ${name} && npm install`);Drop-in adapters
- import { input, select, confirm, checkbox, password, number } from "@inquirer/prompts";
+ import { input, select, confirm, checkbox, password, number } from "clickprompt/inquirer";- import prompts from "prompts";
+ import prompts from "clickprompt/prompts";clickprompt/inquirerexports:input,select,checkbox,confirm,password,number. Thetransformeroption oninput()is unsupported and throws explicitly — every other common option is covered.clickprompt/promptsaccepts terkelg's question types:text,password,invisible,number,confirm,select,multiselect,autocomplete,toggle.onCancel/onSubmitcallbacks behave the same way (truthy return value stops the chain).
Controls
| Action | Mouse | Keyboard |
|---|---|---|
| Move highlight | Wheel scroll | ↑ / ↓ |
| Select / toggle | Click | Enter / Space |
| Page jump | — | PgUp / PgDn |
| Jump to top/bottom | — | Home / End |
| Select all (multiselect) | — | a |
| Filter (autocomplete) | — | type freely; Backspace, Ctrl-U, Ctrl-W |
| Cancel | — | Esc / Ctrl-C |
| Cursor moves (text) | — | ← / →, Home / End, Ctrl-A / Ctrl-E |
| Delete (text) | — | Backspace, Delete, Ctrl-D, Ctrl-U, Ctrl-W, Ctrl-K |
API reference
reorder(config) → Promise<T[] | { value, action }[]>
interface ReorderConfig<T> {
message: string;
options?: ReorderOption<T>[]; // alias: `items`
items?: ReorderOption<T>[];
pageSize?: number; // default 10
hideHint?: boolean;
actions?: ReorderAction[]; // optional per-row action chips
}
interface ReorderOption<T> {
label: string;
value: T;
hint?: string;
locked?: boolean; // can't be grabbed; blocks drag-through
action?: string; // initial action name (must match actions[])
}Returns items in their new order. With actions, returns
{ value, action }[] instead of T[] so you also get each row's tag.
Controls:
- Mouse: press to grab, drag to preview the drop position, release to commit. Items don't shuffle during the drag — only on release.
- Keyboard:
Spacegrabs/drops,↑/↓(orPgUp/PgDn) moves the grabbed item,Home/Endjumps to the first/last unlocked slot,rresets to the original order,Enterconfirms,Escreleases a grab (or cancels if nothing is held).
Actions example (git-rebase-style):
const plan = await reorder({
message: "Rebase plan",
items: commits.map((c) => ({ label: `${c.sha.slice(0, 7)} ${c.subject}`, value: c.sha })),
actions: [
{ key: "p", name: "pick", color: "green" },
{ key: "s", name: "squash", color: "yellow" },
{ key: "f", name: "fixup", color: "yellow" },
{ key: "r", name: "reword", color: "cyan" },
{ key: "e", name: "edit", color: "magenta" },
{ key: "d", name: "drop", color: "red", strike: true },
],
});
// → [{ value: "abc1234", action: "pick" }, { value: "def5678", action: "squash" }, ...]When actions[0].key collides with r (the default reset key), the action
takes precedence — only configs without an r action keep the reset binding.
Pure helpers (exported for tests and custom widgets):
import { reorderArray, reorderArrayWithIndex } from "clickprompt";
reorderArray(["a", "b", "c", "d"], 0, 2);
// → ["b", "c", "a", "d"]
reorderArrayWithIndex(["a", "b", "c"], 0, 2, (x) => x === "b");
// → { result: ["a", "b", "c"], newIndex: 0 } // locked "b" clamped the moveselect(config) → Promise<T>
interface SelectConfig<T> {
message: string;
options: SelectOption<T>[]; // { label, value, hint?, disabled? }
pageSize?: number; // default 10
initial?: number; // default 0
loop?: boolean; // default true
hideHint?: boolean; // default false
}multiselect(config) → Promise<T[]>
All of select's options plus:
{
defaultSelected?: T[]; // pre-checked values (matched with ===)
min?: number; // default 0
max?: number; // default Infinity
}a toggles all (selects up to max, or clears if anything is selected).
Submitting fewer than min or more than max shows an inline error instead
of resolving.
text(config) → Promise<string>
{
message: string;
initial?: string;
placeholder?: string;
validate?: (value: string) => true | string | null | Promise<...>;
mask?: string; // used internally by password()
}- Supports surrogate-pair-safe cursor movement (emoji don't get cut in half).
- Recognises pasted multi-byte chunks and inserts every printable codepoint
at once (raw mode delivers a whole paste as one
dataevent). - Async
validateis awaited; the input is ignored while validation is in flight to keep the displayed value in sync with the submitted value. - Mouse mode is forced OFF for text-based prompts so the terminal's native click-drag text selection (copy/paste) still works.
password(config) → Promise<string>
Thin wrapper around text with masking.
{
message: string;
initial?: string;
mask?: string; // default "•"; pass "" for fully hidden
validate?: Validator;
}number(config) → Promise<number>
{
message: string;
initial?: number;
min?: number; // default -Infinity
max?: number; // default Infinity
float?: boolean; // default true
placeholder?: string;
}Throws on construction if max < min. parseInt (integer mode) or Number
(float mode); whitespace-only input is rejected before parsing so it can't
sneak through as 0.
confirm(config) → Promise<boolean>
{
message: string;
initial?: boolean; // default true (Yes highlighted)
yesLabel?: string; // default "Yes"
noLabel?: string; // default "No"
}Mouse: click [ Yes ] or [ No ]. Keyboard: ←/→ or Tab/Shift-Tab to
move highlight, y/n to commit immediately, Enter to confirm the
current highlight, Esc/Ctrl-C/Ctrl-D to cancel.
toggle(config) → Promise<boolean>
{
message: string;
initial?: boolean; // default false
onLabel?: string; // default "on"
offLabel?: string; // default "off"
}Mouse: click either side. Keyboard: ←/→ to move, Tab/Shift-Tab/Space
to flip, y sets on, n sets off, Enter confirms.
autocomplete(config) → Promise<T>
{
message: string;
options: SelectOption<T>[];
pageSize?: number; // default 8
initial?: number;
placeholder?: string;
filter?: (query, option) => number | false | null; // higher score = better
}Default filter scores exact match (1000), prefix (500), substring (100), and
in-order fuzzy match (10). A custom filter that throws is treated as "no
match" rather than crashing the prompt.
spinner() → Spinner
interface Spinner {
start(message?: string): void;
message(message: string): void;
stop(finalMessage?: string, success?: boolean): void; // false → ✖ icon
isActive(): boolean;
}Process-exit safe: registers its own exit / uncaughtException / SIGINT
handlers so the cursor is restored even if the program dies mid-spin.
form(steps, config?) → Promise<Record<string, T>>
Sequential multi-step wizard. Each step is awaited in order:
import { form, text, select, confirm } from "clickprompt";
const result = await form({
name: text({ message: "Project name" }),
framework: select({ message: "Framework", options: [...] }),
confirm: confirm({ message: "Create?" }),
}, {
intro: "create-app",
outro: "All set",
showStep: true,
});
// result: { name: "...", framework: "...", confirm: true }Steps can be thunks that receive earlier answers:
await form({
name: text({ message: "Name" }),
greet: ({ name }) => confirm({ message: `Hi ${name} — proceed?` }),
});Cancelling any step rejects the whole form with CancelledError. Partial
answers are not returned; chain forms manually if you need rollback.
This works because every prompt function returns a LazyPrompt — a
thenable that defers terminal setup until first await. Multiple prompts
inside a form({...}) literal therefore don't race for stdin.
Decorations
import { intro, outro, note, log, logSuccess, logWarning, logError } from "clickprompt";
intro("create-app");
log("Reading config…");
note("This overwrites your config file.", "Heads up");
logWarning("Network is slow");
logSuccess("Wrote 12 files");
logError("Build failed");
outro("Done");Every decoration accepts an optional WriteStream as the last argument so
output can be routed somewhere other than process.stdout (test harnesses,
multiplexed terminals).
Cancellation
Every prompt rejects with CancelledError on Esc / Ctrl-C:
import { select, CancelledError } from "clickprompt";
try {
const value = await select({ ... });
} catch (err) {
if (err instanceof CancelledError) {
console.log("User cancelled");
process.exit(130);
}
throw err;
}For programmatic cancellation, pass an AbortSignal in the prompt config:
const ctrl = new AbortController();
setTimeout(() => ctrl.abort(), 5000);
await select({ ...config, signal: ctrl.signal });Pre-aborted signals are detected before any terminal state is touched, so you don't have to worry about cleaning up after a cancelled-before-start prompt.
Shared runtime options
Every prompt config extends PromptRuntimeOpts:
interface PromptRuntimeOpts {
useMouse?: boolean; // force mouse on/off (auto-detect by default)
signal?: AbortSignal;
hideCursor?: boolean; // default true (except text-based prompts)
output?: WriteStream; // default process.stdout
input?: NodeJS.ReadStream; // default process.stdin
}Low-level building blocks
For custom widgets:
import {
// Mouse
parseMouse, // (data) → MouseEvent | null
parseMouseAll, // (data) → MouseEvent[]
stripMouse, // remove mouse seqs, keep keys
MouseBuffer, // stateful, split-safe parser
// Terminal control
enableMouse, // optional { hover: true } for ?1003
disableMouse,
hideCursor,
showCursor,
clearLines,
queryCursorPosition, // → { row, col } via DSR (ESC[6n)
getTermSize,
// Detection
detectTerminal, // → TerminalInfo
getMouseHint, // hint string for tmux/screen, or null
// Keys
parseKey, // (data) → KeyEvent | null
// String width (zero-dep replacement for string-width / strip-ansi)
visualWidth, // counts terminal columns
styledWidth, // visualWidth(stripAnsi(s))
stripAnsi, // strip CSI escape sequences
} from "clickprompt";MouseBuffer matters when mouse sequences split across stdin chunks (SSH /
slow links). Each prompt allocates its own:
const buf = new MouseBuffer();
process.stdin.on("data", (chunk) => {
for (const ev of buf.feed(chunk)) {
// mouse event
}
const keyBytes = buf.consumeRemaining();
// pass keyBytes to parseKey()
});visualWidth handles Hangul / CJK / fullwidth forms, ZWJ-joined emoji
clusters (👨👩👧, 👨💻), regional indicator pairs (🇰🇷), skin tone modifiers
(👋🏻), VS16-promoted emoji (☎️ vs ☎), and VS15 (text presentation).
detectTerminal()
interface TerminalInfo {
isTTY: boolean;
isCI: boolean;
isLegacyWindowsCmd: boolean; // pre-Win10 conhost
canUseMouse: boolean; // isTTY && !isCI && !isLegacyWindowsCmd
isTmux: boolean;
isScreen: boolean;
isSSH: boolean; // SSH_CLIENT / SSH_TTY / SSH_CONNECTION
termProgram: string;
}CI detection respects explicit opt-out (CI=false, CI=0). Windows 10+
conhost gets mouse enabled; only pre-Win10 falls back.
Terminal support
| Terminal | Click | Wheel | Notes |
|---|---|---|---|
| iTerm2 (macOS) | ✅ | ✅ | |
| Terminal.app (macOS) | ✅ | ✅ | |
| Windows Terminal | ✅ | ✅ | |
| VS Code integrated terminal | ✅ | ✅ | |
| Cursor / JetBrains terminals | ✅ | ✅ | |
| Alacritty / Kitty / WezTerm | ✅ | ✅ | |
| GNOME Terminal / Konsole | ✅ | ✅ | |
| Windows conhost (Win 10+, powershell.exe) | ✅ | ✅ | clickprompt patches ENABLE_MOUSE_INPUT on first prompt via a one-time PowerShell pinvoke |
| tmux | ✅ | ✅ | needs set -g mouse on |
| GNU screen | ✅ | ✅ | needs mousetrack on |
| Legacy Windows cmd.exe (Win 7 / 8) | — | — | auto-detected, keyboard-only |
| CI / non-TTY | — | — | setupPrompt() throws a clear error |
Module system
clickprompt ships both ESM and CJS builds. Node picks the right one
based on your project's type and how you import:
// ESM
import { select, reorder } from "clickprompt";
import { input } from "clickprompt/inquirer";
// CommonJS
const { select, reorder } = require("clickprompt");
const { input } = require("clickprompt/inquirer");TypeScript types resolve identically in both. The exports map declares
import / require / types conditions for ., ./inquirer, ./prompts,
and ./package.json.
Stability
[email protected] follows semver:
- patch (
1.0.x) — bug fixes only, no API changes - minor (
1.x.0) — additive: new prompts, options, exports. Existing code keeps working - major (
2.0.0) — breaking changes, with at least one minor release of deprecation notice
{ "dependencies": { "clickprompt": "^1.0.0" } }The 0.x line is no longer maintained.
Design principles
- Zero runtime dependencies. Ships ESM + CJS dual build.
- Mouse is additive. Every prompt works keyboard-only over SSH, in CI, anywhere a terminal is interactive.
- Drop-in friendly. API mirrors
promptsand@inquirer/prompts. - TypeScript-first. Strict types, full
.d.tsoutput, noanyin the public surface. - Terminal-safe cleanup. Mouse mode, raw mode, cursor, and bracketed paste are always restored — on normal completion, on
Esc/Ctrl-C, onSIGINT/SIGTERM, onuncaughtException, and onprocess.exit.
Known limitations
- Legacy Windows
cmd.exe(pre-Win10 conhost) has no SGR mouse support. Auto-detected; falls back to keyboard. - tmux / GNU screen need their own mouse config (
set -g mouse on/mousetrack on) before clicks reach the inner process.getMouseHint()returns a one-line reminder when these are detected. - Non-TTY environments (CI, redirected stdin/stdout, headless containers) cannot run any prompt —
setupPrompt()throws. Gate withdetectTerminal().isTTYfirst. reordermouse drag smoothness is rate-limited by the terminal's motion event throttling, not by us. On very slow links the drag indicator may feel chunky; the final order is still correct.text()/password()paste filters out control characters (\n,\r, etc.) since these are single-line inputs. To collect multi-line input use a different mechanism.- String width edge cases: we follow the Unicode spec (
Hangul,CJK,Emoji_Presentation, ZWJ-joined clusters, regional indicator pairs, VS16-promoted emoji, skin-tone modifiers). Some older terminals render those at different widths regardless — that's a font/locale issue, not a bug here. - Inquirer adapter:
input()'stransformeroption is intentionally unsupported and throws explicitly, since clickprompt renders the input verbatim.
Tests
npm test # 195 unit tests, native node:test runner
npm run test:cjs # 13 CJS smoke checksThe suite covers mouse SGR parsing (including split-across-chunks via
MouseBuffer), key normalization, list cursor helpers, surrogate-pair-safe
text editing, paste handling, reorder lock-barrier movement,
duplicate-safe index tracking, multiselect min/max validation timing,
async-validator abort safety, runtime overshoot/scroll behavior,
listener-leak prevention, signal/abort handling, Number.isNaN clamping,
visual width (CJK / emoji / ZWJ / regional indicators / VS16), and the
adapter shapes (inquirer, terkelg prompts).
Contributing
See CONTRIBUTING.md.
License
MIT © Donggyu
