From 48abbb47a40b1e69c283031baa6de6857c47417e Mon Sep 17 00:00:00 2001 From: autoantohaki Date: Wed, 21 Oct 2026 20:55:56 -0300 Subject: [PATCH] feat: add text, number, confirm, select, and multiselect prompts --- CHANGELOG.md | 2 + README.md | 46 ++++-- docs/architecture.md | 3 +- docs/prompts.md | 58 +++++++ src/cli.tsx | 23 ++- src/demo/DemoForm.tsx | 93 ++++++++--- src/demo/index.ts | 2 +- src/index.ts | 1 + src/prompts/Prompt.tsx | 156 +++++++++++++++++++ src/prompts/ask.tsx | 102 ++++++++++++ src/prompts/choices.ts | 35 +++++ src/prompts/format.ts | 56 +++++++ src/prompts/index.ts | 18 +++ src/prompts/inputs.tsx | 298 ++++++++++++++++++++++++++++++++++++ src/prompts/text-editing.ts | 56 +++++++ src/prompts/types.ts | 92 +++++++++++ tests/choices.test.ts | 33 ++++ tests/demo.test.tsx | 30 ++++ tests/format.test.ts | 57 +++++++ tests/helpers.ts | 2 +- tests/prompt.test.tsx | 206 +++++++++++++++++++++++++ tests/text-editing.test.ts | 51 ++++++ 22 files changed, 1380 insertions(+), 40 deletions(-) create mode 100644 docs/prompts.md create mode 100644 src/prompts/Prompt.tsx create mode 100644 src/prompts/ask.tsx create mode 100644 src/prompts/choices.ts create mode 100644 src/prompts/format.ts create mode 100644 src/prompts/index.ts create mode 100644 src/prompts/inputs.tsx create mode 100644 src/prompts/text-editing.ts create mode 100644 src/prompts/types.ts create mode 100644 tests/choices.test.ts create mode 100644 tests/demo.test.tsx create mode 100644 tests/format.test.ts create mode 100644 tests/prompt.test.tsx create mode 100644 tests/text-editing.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a90576..728cd7b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,4 +2,6 @@ ## Unreleased +- Added `text`, `number`, `confirm`, `select`, and `multiselect` prompts with validation, including asynchronous checks, masked input, and typed answers through `defineFields`. +- Added `ask`, `askAll`, `Prompt`, and `PromptList`, and an interactive demonstration of a project setup flow. - Added `FormFrame`, the `runForm` renderer with cancellation, Relay themes, and the `relay-forms` demonstration command. diff --git a/README.md b/README.md index 51c716f..d01692a 100644 --- a/README.md +++ b/README.md @@ -9,32 +9,56 @@ npm install npm run demo ``` -To print the demonstration without waiting for input: +The demonstration asks the questions of a project setup flow and prints the answers. To print its first screen without waiting for input: ```sh npx tsx src/cli.tsx --demo --snapshot ``` -## Use it in a tool +## Ask questions -`runForm` renders a form where the cursor is and resolves with the value the form submits. Esc and Ctrl+C cancel it, and `runForm` then rejects with `FormCancelledError`. +`askAll` asks a list of fields in order and resolves with the answers, keyed by field name. Answered questions stay in the terminal as a summary. -```tsx -import {Text} from 'ink'; -import {FormCancelledError, FormFrame, runForm} from '@relay-cli/forms'; +```ts +import {FormCancelledError, askAll, defineFields} from '@relay-cli/forms'; + +const fields = defineFields([ + {kind: 'text', name: 'name', label: 'Project name', required: true}, + { + kind: 'select', + name: 'packageManager', + label: 'Package manager', + options: [{value: 'npm'}, {value: 'pnpm'}], + }, + {kind: 'text', name: 'token', label: 'Registry token', mask: true}, + {kind: 'confirm', name: 'git', label: 'Initialize a git repository?', initial: true}, +]); try { - const answer = await runForm(({submit}) => ( - - - - )); + const answers = await askAll(fields); + // answers.packageManager is typed as 'npm' | 'pnpm' } catch (error) { if (error instanceof FormCancelledError) process.exitCode = 130; else throw error; } ``` +`ask` asks a single field. Fields can be `text`, `number`, `confirm`, `select`, or `multiselect`, and each can validate its answer, including with an asynchronous check. See [the prompt guide](./docs/prompts.md). + +## Build your own form + +`runForm` renders any Ink component where the cursor is and resolves with the value the component submits. Ctrl+C cancels it, and `runForm` then rejects with `FormCancelledError`. `Prompt`, `PromptList`, and `FormFrame` are ordinary components, so they can be combined with your own. + +```tsx +import {FormFrame, PromptList, runForm} from '@relay-cli/forms'; + +const answers = await runForm>(({submit, cancel}) => ( + + + +)); +``` + ## Development ```sh diff --git a/docs/architecture.md b/docs/architecture.md index 46bbf56..14809f6 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -4,5 +4,6 @@ Relay Forms renders setup flows inline, below the command that started them, ins 1. `runForm` renders a form at the cursor and resolves with the value it submits. Ctrl+C and SIGTERM cancel the form, and `runForm` rejects with `FormCancelledError`. 2. `FormFrame` draws the title, a ruled body, and a footer for key hints. +3. A field describes one question. `Prompt` reads keys for it, validates the answer, and then collapses to a single answered line. `PromptList` asks fields in order. `ask` and `askAll` wrap both in `runForm`. See [prompts](./prompts.md). -Each part is a plain Ink component, so a form can also be rendered inside a larger Ink application. +The editing rules for text and lists are plain functions (`editText`, and the helpers in `src/prompts/choices.ts`), so they are tested without rendering. Each component is a plain Ink component, so a form can also be rendered inside a larger Ink application. diff --git a/docs/prompts.md b/docs/prompts.md new file mode 100644 index 0000000..b1c8f6d --- /dev/null +++ b/docs/prompts.md @@ -0,0 +1,58 @@ +# Prompts + +A prompt is one question described by a field. `ask` asks one field, `askAll` asks a list in order, and the `Prompt` and `PromptList` components do the same inside an Ink application. + +## Field kinds + +| Kind | Answer | Keys | Options | +| ------------- | ---------- | ---------------------------------------------------- | -------------------------------------------- | +| `text` | `string` | typing, ←→, Home/End, Ctrl+A/E/U/K/W | `initial`, `placeholder`, `mask`, `required` | +| `number` | `number` | as `text`, accepting only digits, `.`, `-`, `e`, `+` | `initial`, `min`, `max`, `integer` | +| `confirm` | `boolean` | `y`, `n`, ←→ or Space to switch, Enter | `initial` | +| `select` | the value | ↑↓ or `j`/`k`, Home/End, Enter | `options`, `initial`, `limit` | +| `multiselect` | the values | ↑↓, Space toggles, `a` toggles all, Enter | `options`, `initial`, `min`, `max`, `limit` | + +Every field has a `name`, a `label`, and an optional `hint` shown beneath the active question. Esc cancels, and `ask` and `askAll` then reject with `FormCancelledError`. + +Options of `select` and `multiselect` take a `value`, an optional `label` and `hint`, and `disabled`, which may be a string explaining why the option cannot be picked. Lists longer than `limit` (7 by default) scroll. + +## Validation + +Declared limits such as `required`, `min`, and `max` are checked first. A field's `validate` function runs next and returns a message to show, or `undefined` to accept: + +```ts +const name = { + kind: 'text', + name: 'name', + label: 'Project name', + required: true, + validate: (value: string) => + /^[a-z0-9-]+$/.test(value) ? undefined : 'Use lowercase letters, digits, and dashes.', +} as const; +``` + +`validate` may return a promise, for example to check a token against an API. The prompt shows `Checking…` until it settles, and a rejected promise is shown as the error message. + +## Typed answers + +Wrap a field list in `defineFields` to keep its names and option values, and `askAll` returns typed answers: + +```ts +const fields = defineFields([ + {kind: 'text', name: 'name', label: 'Name'}, + { + kind: 'select', + name: 'pm', + label: 'Package manager', + options: [{value: 'npm'}, {value: 'pnpm'}], + }, +]); +const answers = await askAll(fields); +// answers: {name: string; pm: 'npm' | 'pnpm'} +``` + +`askAll(fields, {initialValues})` starts fields from earlier answers, such as values already in a configuration file. + +## Masked values + +A `text` field with `mask: true` shows `•` for each character while typing and at most twelve once answered, so the length of a long token is not revealed. See [SECURITY.md](../SECURITY.md) for storing secrets. diff --git a/src/cli.tsx b/src/cli.tsx index d7bbd08..fd5b3d6 100644 --- a/src/cli.tsx +++ b/src/cli.tsx @@ -3,9 +3,10 @@ import {renderToString} from 'ink'; import {helpText, parseCliOptions} from './cli/options.js'; import {DemoForm} from './demo/index.js'; +import {FormCancelledError, runForm} from './run.js'; import {relayFormsVersion} from './version.js'; -const main = (): void => { +const main = async (): Promise => { let options; try { options = parseCliOptions(process.argv.slice(2)); @@ -23,8 +24,22 @@ const main = (): void => { process.exitCode = 2; return; } - const columns = process.stdout.columns || 80; - process.stdout.write(`${renderToString(, {columns})}\n`); + if (options.snapshot || !process.stdout.isTTY || !process.stdin.isTTY) { + const columns = process.stdout.columns || 80; + process.stdout.write(`${renderToString(, {columns})}\n`); + return; + } + + try { + const answers = await runForm>(({submit, cancel}) => ( + + )); + process.stdout.write(`\n${JSON.stringify(answers, undefined, 2)}\n`); + } catch (error) { + if (!(error instanceof FormCancelledError)) throw error; + process.stderr.write('Cancelled.\n'); + process.exitCode = 130; + } }; -main(); +await main(); diff --git a/src/demo/DemoForm.tsx b/src/demo/DemoForm.tsx index f672064..69ebc7b 100644 --- a/src/demo/DemoForm.tsx +++ b/src/demo/DemoForm.tsx @@ -1,26 +1,75 @@ -import {Box, Text} from 'ink'; +import {useState} from 'react'; +import {Text} from 'ink'; import {FormFrame} from '../frame/index.js'; +import {defineFields, keyHints, PromptList, type Field} from '../prompts/index.js'; import {relayTheme} from '../theme.js'; -const rows: ReadonlyArray = [ - ['Project name', 'relay-demo'], - ['Package manager', 'npm'], - ['Features', 'tests, lint'], -]; +export const demoFields = defineFields([ + { + kind: 'text', + name: 'name', + label: 'Project name', + placeholder: 'relay-demo', + required: true, + validate: (value) => + /^[a-z0-9-]+$/.test(value) ? undefined : 'Use lowercase letters, digits, and dashes.', + }, + { + kind: 'select', + name: 'packageManager', + label: 'Package manager', + options: [ + {value: 'npm', hint: 'bundled with Node.js'}, + {value: 'pnpm'}, + {value: 'yarn', disabled: 'not installed'}, + ], + }, + { + kind: 'multiselect', + name: 'features', + label: 'Features', + options: [ + {value: 'tests', label: 'Tests'}, + {value: 'lint', label: 'Linting'}, + {value: 'ci', label: 'GitHub Actions'}, + ], + initial: ['tests', 'lint'], + }, + {kind: 'number', name: 'port', label: 'Development port', initial: 3000, min: 1024, max: 65535}, + {kind: 'text', name: 'token', label: 'Registry token', mask: true, hint: 'Leave empty to skip.'}, + {kind: 'confirm', name: 'git', label: 'Initialize a git repository?', initial: true}, +]); -/** A static preview of a finished setup flow. */ -export const DemoForm = () => ( - enter continue · esc back · ctrl+c cancel} - > - {rows.map(([label, value]) => ( - - ✔ - {label} - {value} - - ))} - -); +export interface DemoFormProps { + /** Read keys. A snapshot renders the first question without waiting. */ + interactive?: boolean; + onSubmit?: (answers: Record) => void; + onCancel?: () => void; +} + +/** A project setup flow that shows each kind of prompt. */ +export const DemoForm = ({interactive = true, onSubmit, onCancel}: DemoFormProps) => { + const [current, setCurrent] = useState(demoFields[0]); + return ( + {keyHints(current)} · ctrl+c quit + ) + } + > + { + setCurrent(undefined); + onSubmit?.(answers); + }} + onAnswer={(_field, _value, next) => setCurrent(next)} + {...(onCancel === undefined ? {} : {onCancel})} + /> + + ); +}; diff --git a/src/demo/index.ts b/src/demo/index.ts index 7735e23..8f89e96 100644 --- a/src/demo/index.ts +++ b/src/demo/index.ts @@ -1 +1 @@ -export {DemoForm} from './DemoForm.js'; +export {DemoForm, demoFields, type DemoFormProps} from './DemoForm.js'; diff --git a/src/index.ts b/src/index.ts index 5a9b940..7385687 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,5 @@ export * from './frame/index.js'; +export * from './prompts/index.js'; export {FormCancelledError, runForm, type FormControls, type RunFormOptions} from './run.js'; export {formThemes, monoTheme, relayTheme, type FormTheme} from './theme.js'; export {relayFormsVersion} from './version.js'; diff --git a/src/prompts/Prompt.tsx b/src/prompts/Prompt.tsx new file mode 100644 index 0000000..1be8a3e --- /dev/null +++ b/src/prompts/Prompt.tsx @@ -0,0 +1,156 @@ +import {useEffect, useRef, useState} from 'react'; +import {Box, Text, useInput} from 'ink'; +import {relayTheme, type FormTheme} from '../theme.js'; +import {formatAnswer, validateAnswer} from './format.js'; +import {ConfirmInput, MultiSelectInput, NumberInput, SelectInput, TextInput} from './inputs.js'; +import type {Field, FieldValue} from './types.js'; + +export interface PromptProps { + field: F; + /** A value to start from instead of the field's `initial`, such as an earlier answer. */ + initialValue?: FieldValue | undefined; + /** Called with the answer once it passes validation. */ + onSubmit: (value: FieldValue) => void; + /** Called when the person presses Esc. */ + onCancel?: () => void; + /** Only the active prompt reads keys. Defaults to true. */ + isActive?: boolean; + theme?: FormTheme; +} + +type Phase = {name: 'editing'} | {name: 'checking'} | {name: 'done'; value: unknown}; + +/** The keys a field understands, for a footer beneath the prompt. */ +export const keyHints = (field: Field): string => { + switch (field.kind) { + case 'text': + case 'number': + return 'enter submit · esc cancel'; + case 'confirm': + return 'y yes · n no · enter submit'; + case 'select': + return '↑↓ move · enter pick · esc cancel'; + case 'multiselect': + return '↑↓ move · space toggle · a all · enter submit'; + } +}; + +/** A question that has been answered, as it stays in the terminal. */ +export const AnsweredLine = ({ + field, + value, + theme = relayTheme, +}: { + field: Field; + value: unknown; + theme?: FormTheme; +}) => ( + + ✔ + {field.label} + · {formatAnswer(field, value)} + +); + +/** + * One question. While active it reads keys and shows its input; once the + * answer passes validation it collapses to a single answered line. + */ +export const Prompt = ({ + field, + initialValue, + onSubmit, + onCancel, + isActive = true, + theme = relayTheme, +}: PromptProps) => { + const [phase, setPhase] = useState({name: 'editing'}); + const [error, setError] = useState(); + const attempt = useRef(0); + const editing = phase.name === 'editing'; + + useInput( + (_input, key) => { + if (key.escape) onCancel?.(); + }, + {isActive: isActive && phase.name !== 'done'}, + ); + + const submitted = useRef(false); + useEffect(() => { + if (phase.name !== 'done' || submitted.current) return; + submitted.current = true; + onSubmit(phase.value as FieldValue); + }, [phase, onSubmit]); + + const settle = (value: unknown, message: string | undefined): void => { + setError(message); + setPhase(message === undefined ? {name: 'done', value} : {name: 'editing'}); + }; + + const accept = (value: unknown): void => { + const current = (attempt.current += 1); + let result: ReturnType; + try { + result = validateAnswer(field, value); + } catch (failure) { + return settle(value, failure instanceof Error ? failure.message : String(failure)); + } + if (!(result instanceof Promise)) return settle(value, result); + setPhase({name: 'checking'}); + result.then( + (message) => { + if (attempt.current === current) settle(value, message); + }, + (failure: unknown) => { + if (attempt.current !== current) return; + settle(value, failure instanceof Error ? failure.message : String(failure)); + }, + ); + }; + + if (phase.name === 'done') + return ; + + const inputProps = { + initial: initialValue as never, + isActive: isActive && editing, + theme, + onAccept: accept, + onError: setError, + onEdit: () => setError(undefined), + }; + const listed = field.kind === 'select' || field.kind === 'multiselect'; + const input = + field.kind === 'text' ? ( + + ) : field.kind === 'number' ? ( + + ) : field.kind === 'confirm' ? ( + + ) : field.kind === 'select' ? ( + + ) : ( + + ); + + return ( + + + ? + + {field.label} + + {listed ? null : input} + + {listed ? {input} : null} + {phase.name === 'checking' ? ( + Checking… + ) : error !== undefined ? ( + ✖ {error} + ) : field.hint !== undefined && isActive ? ( + {field.hint} + ) : null} + + ); +}; diff --git a/src/prompts/ask.tsx b/src/prompts/ask.tsx new file mode 100644 index 0000000..9183494 --- /dev/null +++ b/src/prompts/ask.tsx @@ -0,0 +1,102 @@ +import {useState} from 'react'; +import {Box} from 'ink'; +import {runForm, type RunFormOptions} from '../run.js'; +import {relayTheme, type FormTheme} from '../theme.js'; +import {AnsweredLine, Prompt} from './Prompt.js'; +import type {Answers, Field, FieldValue} from './types.js'; + +export interface AskOptions extends RunFormOptions { + theme?: FormTheme; +} + +/** + * Ask one question in the terminal and resolve with the answer. Rejects with + * `FormCancelledError` when the person presses Esc or Ctrl+C. + */ +export const ask = ( + field: F, + options: AskOptions & {initialValue?: FieldValue} = {}, +): Promise> => + runForm>( + ({submit, cancel}) => ( + + ), + options, + ); + +export interface PromptListProps { + fields: readonly Field[]; + /** Answers to start each field from, keyed by field name. */ + initialValues?: Readonly>; + onSubmit: (answers: Record) => void; + /** Called after each answer with the field asked next, if any. */ + onAnswer?: (field: Field, value: unknown, next: Field | undefined) => void; + onCancel?: () => void; + isActive?: boolean; + theme?: FormTheme; +} + +/** Fields asked one after another, with earlier answers kept above the current one. */ +export const PromptList = ({ + fields, + initialValues = {}, + onSubmit, + onAnswer, + onCancel, + isActive = true, + theme = relayTheme, +}: PromptListProps) => { + const [answers, setAnswers] = useState>({}); + const index = Object.keys(answers).length; + const current = fields[index]; + return ( + + {fields.slice(0, index).map((field) => ( + + ))} + {current === undefined ? null : ( + { + const next = {...answers, [current.name]: value}; + setAnswers(next); + onAnswer?.(current, value, fields[index + 1]); + if (index + 1 === fields.length) onSubmit(next); + }} + {...(onCancel === undefined ? {} : {onCancel})} + isActive={isActive} + theme={theme} + /> + )} + + ); +}; + +/** + * Ask several questions in order and resolve with every answer, keyed by field + * name. Wrap the list in `defineFields` to type the answers. + */ +export const askAll = ( + fields: Fields, + options: AskOptions & {initialValues?: Partial>} = {}, +): Promise> => + runForm>( + ({submit, cancel}) => ( + submit(answers as Answers)} + onCancel={cancel} + theme={options.theme ?? relayTheme} + /> + ), + options, + ); diff --git a/src/prompts/choices.ts b/src/prompts/choices.ts new file mode 100644 index 0000000..41af296 --- /dev/null +++ b/src/prompts/choices.ts @@ -0,0 +1,35 @@ +import type {Choice} from './types.js'; + +export const isEnabled = (choice: Choice): boolean => + choice.disabled === undefined || choice.disabled === false; + +/** + * The index of the next enabled choice in a direction, wrapping at either end. + * Returns the current index when no other choice is enabled. + */ +export const moveIndex = (choices: readonly Choice[], index: number, step: 1 | -1): number => { + const length = choices.length; + for (let offset = 1; offset <= length; offset += 1) { + const next = (((index + step * offset) % length) + length) % length; + const choice = choices[next]; + if (choice !== undefined && isEnabled(choice)) return next; + } + return index; +}; + +/** The first enabled index at or after a preferred one. */ +export const firstEnabled = (choices: readonly Choice[], preferred = 0): number => { + const start = Math.max(0, Math.min(preferred, choices.length - 1)); + const choice = choices[start]; + if (choice !== undefined && isEnabled(choice)) return start; + return moveIndex(choices, start, 1); +}; + +/** The first row of a scrolled list so that the highlighted row stays visible. */ +export const windowStart = (length: number, index: number, limit: number): number => { + if (length <= limit) return 0; + const centered = index - Math.floor(limit / 2); + return Math.max(0, Math.min(centered, length - limit)); +}; + +export const choiceLabel = (choice: Choice): string => choice.label ?? choice.value; diff --git a/src/prompts/format.ts b/src/prompts/format.ts new file mode 100644 index 0000000..2067cc7 --- /dev/null +++ b/src/prompts/format.ts @@ -0,0 +1,56 @@ +import {choiceLabel} from './choices.js'; +import type {Field} from './types.js'; + +/** How an accepted answer is shown once a prompt is done. */ +export const formatAnswer = (field: Field, value: unknown): string => { + switch (field.kind) { + case 'text': + if (typeof value !== 'string') return ''; + return field.mask === true ? '•'.repeat(Math.min(Array.from(value).length, 12)) : value; + case 'number': + return typeof value === 'number' ? String(value) : ''; + case 'confirm': + return value === true ? 'Yes' : 'No'; + case 'select': { + const choice = field.options.find((option) => option.value === value); + return choice === undefined ? '' : choiceLabel(choice); + } + case 'multiselect': { + if (!Array.isArray(value)) return ''; + const picked = field.options.filter((option) => value.includes(option.value)); + return picked.length === 0 ? 'None' : picked.map(choiceLabel).join(', '); + } + } +}; + +/** Check the limits a field declares, before its own `validate`. */ +export const checkLimits = (field: Field, value: unknown): string | undefined => { + if (field.kind === 'text') { + if (field.required === true && typeof value === 'string' && value.trim() === '') + return 'Enter a value.'; + } else if (field.kind === 'number' && typeof value === 'number') { + if (field.integer === true && !Number.isInteger(value)) return 'Enter a whole number.'; + if (field.min !== undefined && value < field.min) return `Enter ${field.min} or more.`; + if (field.max !== undefined && value > field.max) return `Enter ${field.max} or less.`; + } else if (field.kind === 'multiselect' && Array.isArray(value)) { + if (field.min !== undefined && value.length < field.min) return `Pick at least ${field.min}.`; + if (field.max !== undefined && value.length > field.max) return `Pick at most ${field.max}.`; + } + return undefined; +}; + +type AnyValidate = (value: unknown) => string | undefined | Promise; + +/** + * Run a field's declared limits and then its own check. The result is only a + * promise when the field's check returns one, so synchronous checks answer at once. + */ +export const validateAnswer = ( + field: Field, + value: unknown, +): string | undefined | Promise => { + const limit = checkLimits(field, value); + if (limit !== undefined) return limit; + const validate = field.validate as AnyValidate | undefined; + return validate?.(value); +}; diff --git a/src/prompts/index.ts b/src/prompts/index.ts new file mode 100644 index 0000000..6528803 --- /dev/null +++ b/src/prompts/index.ts @@ -0,0 +1,18 @@ +export {ask, askAll, PromptList, type AskOptions, type PromptListProps} from './ask.js'; +export {choiceLabel} from './choices.js'; +export {checkLimits, formatAnswer, validateAnswer} from './format.js'; +export {AnsweredLine, keyHints, Prompt, type PromptProps} from './Prompt.js'; +export {editText, textState, type TextState} from './text-editing.js'; +export { + defineFields, + type Answers, + type Choice, + type ConfirmField, + type Field, + type FieldValue, + type MultiSelectField, + type NumberField, + type SelectField, + type TextField, + type Validate, +} from './types.js'; diff --git a/src/prompts/inputs.tsx b/src/prompts/inputs.tsx new file mode 100644 index 0000000..c2eb48c --- /dev/null +++ b/src/prompts/inputs.tsx @@ -0,0 +1,298 @@ +import {useState} from 'react'; +import {Box, Text, useInput} from 'ink'; +import type {FormTheme} from '../theme.js'; +import {choiceLabel, firstEnabled, isEnabled, moveIndex, windowStart} from './choices.js'; +import {editText, textState, type TextState} from './text-editing.js'; +import type {ConfirmField, MultiSelectField, NumberField, SelectField, TextField} from './types.js'; + +/** What every field input receives from `Prompt`. */ +export interface InputProps { + field: F; + initial: V | undefined; + isActive: boolean; + theme: FormTheme; + /** The person pressed Enter on a value. `Prompt` validates it next. */ + onAccept: (value: V) => void; + /** The value cannot be read, such as letters in a number. */ + onError: (message: string) => void; + /** The value changed, so any earlier error no longer applies. */ + onEdit: () => void; +} + +const LineWithCursor = ({ + state, + mask, + placeholder, + isActive, + theme, +}: { + state: TextState; + mask: boolean; + placeholder: string | undefined; + isActive: boolean; + theme: FormTheme; +}) => { + const list = Array.from(state.value).map((character) => (mask ? '•' : character)); + if (list.length === 0 && placeholder !== undefined && placeholder !== '') { + const [first = ' ', ...rest] = Array.from(placeholder); + return ( + + {isActive ? {first} : {first}} + {rest.join('')} + + ); + } + if (!isActive) return {list.join('')}; + const before = list.slice(0, state.cursor).join(''); + const under = list[state.cursor] ?? ' '; + const after = list.slice(state.cursor + 1).join(''); + return ( + + {before} + {under} + {after} + + ); +}; + +export const TextInput = ({ + field, + initial, + isActive, + theme, + onAccept, + onEdit, +}: InputProps) => { + const [state, setState] = useState(() => textState(initial ?? field.initial ?? '')); + useInput( + (input, key) => { + if (key.return) return onAccept(state.value); + const next = editText(state, input, key); + if (next === undefined || next === state) return; + setState(next); + onEdit(); + }, + {isActive}, + ); + return ( + + ); +}; + +const numberCharacter = /[\d.eE+-]/; + +export const NumberInput = ({ + field, + initial, + isActive, + theme, + onAccept, + onError, + onEdit, +}: InputProps) => { + const start = initial ?? field.initial; + const [state, setState] = useState(() => textState(start === undefined ? '' : String(start))); + useInput( + (input, key) => { + if (key.return) { + const text = state.value.trim(); + const value = text === '' ? Number.NaN : Number(text); + return Number.isFinite(value) ? onAccept(value) : onError('Enter a number.'); + } + const next = editText(state, input, key, (character) => numberCharacter.test(character)); + if (next === undefined || next === state) return; + setState(next); + onEdit(); + }, + {isActive}, + ); + return ( + + ); +}; + +export const ConfirmInput = ({ + field, + initial, + isActive, + theme, + onAccept, + onEdit, +}: InputProps) => { + const [value, setValue] = useState(initial ?? field.initial ?? false); + useInput( + (input, key) => { + const letter = input.toLowerCase(); + if (letter === 'y' || letter === 'n') return onAccept(letter === 'y'); + if (key.return) return onAccept(value); + if (key.leftArrow || key.rightArrow || key.tab || input === ' ') { + setValue(!value); + onEdit(); + } + }, + {isActive}, + ); + const option = (label: string, selected: boolean) => + selected ? ( + + {label} + + ) : ( + {label} + ); + return ( + + {option('Yes', value)} + / + {option('No', !value)} + + ); +}; + +const ChoiceRows = ({ + field, + index, + picked, + isActive, + theme, +}: { + field: SelectField | MultiSelectField; + index: number; + picked: ReadonlySet | undefined; + isActive: boolean; + theme: FormTheme; +}) => { + const limit = Math.max(1, field.limit ?? 7); + const start = windowStart(field.options.length, index, limit); + const visible = field.options.slice(start, start + limit); + const hiddenAbove = start; + const hiddenBelow = field.options.length - start - visible.length; + return ( + + {hiddenAbove > 0 ? ↑ {hiddenAbove} more : null} + {visible.map((choice, offset) => { + const highlighted = isActive && start + offset === index; + const enabled = isEnabled(choice); + const color = !enabled ? theme.muted : highlighted ? theme.focus : theme.text; + const reason = typeof choice.disabled === 'string' ? choice.disabled : undefined; + return ( + + {highlighted ? '›' : ' '} + {picked === undefined ? null : ( + + {' '} + {picked.has(choice.value) ? '◉' : '◯'} + + )} + + {' '} + {choiceLabel(choice)} + + {reason !== undefined ? ({reason}) : null} + {choice.hint !== undefined && highlighted ? ( + · {choice.hint} + ) : null} + + ); + })} + {hiddenBelow > 0 ? ↓ {hiddenBelow} more : null} + + ); +}; + +const moveKey = (input: string, key: {upArrow: boolean; downArrow: boolean}): 1 | -1 | 0 => { + if (key.upArrow || input === 'k') return -1; + if (key.downArrow || input === 'j') return 1; + return 0; +}; + +export const SelectInput = ({ + field, + initial, + isActive, + theme, + onAccept, + onEdit, +}: InputProps) => { + const [index, setIndex] = useState(() => { + const preferred = field.options.findIndex((o) => o.value === (initial ?? field.initial)); + return firstEnabled(field.options, Math.max(0, preferred)); + }); + useInput( + (input, key) => { + const step = moveKey(input, key); + if (step !== 0) { + setIndex(moveIndex(field.options, index, step)); + return onEdit(); + } + if (key.home) return setIndex(firstEnabled(field.options, 0)); + if (key.end) return setIndex(moveIndex(field.options, 0, -1)); + const choice = field.options[index]; + if (key.return && choice !== undefined && isEnabled(choice)) onAccept(choice.value); + }, + {isActive}, + ); + return ( + + ); +}; + +export const MultiSelectInput = ({ + field, + initial, + isActive, + theme, + onAccept, + onEdit, +}: InputProps) => { + const [index, setIndex] = useState(() => firstEnabled(field.options, 0)); + const [picked, setPicked] = useState>( + () => new Set(initial ?? field.initial ?? []), + ); + useInput( + (input, key) => { + const step = moveKey(input, key); + if (step !== 0) return setIndex(moveIndex(field.options, index, step)); + if (key.home) return setIndex(firstEnabled(field.options, 0)); + if (key.end) return setIndex(moveIndex(field.options, 0, -1)); + if (key.return) { + return onAccept(field.options.filter((o) => picked.has(o.value)).map((o) => o.value)); + } + const choice = field.options[index]; + if (input === ' ' && choice !== undefined && isEnabled(choice)) { + const next = new Set(picked); + if (next.has(choice.value)) next.delete(choice.value); + else next.add(choice.value); + setPicked(next); + return onEdit(); + } + if (input === 'a') { + const enabled = field.options.filter(isEnabled).map((o) => o.value); + const all = enabled.every((value) => picked.has(value)); + const next = new Set(picked); + for (const value of enabled) { + if (all) next.delete(value); + else next.add(value); + } + setPicked(next); + onEdit(); + } + }, + {isActive}, + ); + return ( + + ); +}; diff --git a/src/prompts/text-editing.ts b/src/prompts/text-editing.ts new file mode 100644 index 0000000..cf375ae --- /dev/null +++ b/src/prompts/text-editing.ts @@ -0,0 +1,56 @@ +import type {Key} from 'ink'; + +/** A single-line value and the cursor position within it, counted in characters. */ +export interface TextState { + value: string; + cursor: number; +} + +const chars = (value: string): string[] => Array.from(value); + +export const textState = (value = ''): TextState => ({value, cursor: chars(value).length}); + +const splice = (state: TextState, start: number, end: number, insert = ''): TextState => { + const list = chars(state.value); + const inserted = chars(insert); + list.splice(start, end - start, ...inserted); + return {value: list.join(''), cursor: start + inserted.length}; +}; + +/** Keep only characters that belong on one line. */ +const printable = (input: string): string => + input.replace(/\r\n|\r|\n|\t/g, ' ').replace(/\p{Cc}/gu, ''); + +const previousWordStart = (list: readonly string[], cursor: number): number => { + let index = cursor; + while (index > 0 && list[index - 1] === ' ') index -= 1; + while (index > 0 && list[index - 1] !== ' ') index -= 1; + return index; +}; + +/** + * Apply a key press to a line of text. Returns `undefined` when the key is not + * an editing key, so the caller can handle it, such as Enter or Esc. + */ +export const editText = ( + state: TextState, + input: string, + key: Key, + accept: (character: string) => boolean = () => true, +): TextState | undefined => { + const list = chars(state.value); + const {cursor} = state; + if (key.return || key.escape || key.tab || key.upArrow || key.downArrow) return undefined; + if (key.leftArrow) return {...state, cursor: Math.max(0, cursor - 1)}; + if (key.rightArrow) return {...state, cursor: Math.min(list.length, cursor + 1)}; + if (key.home || (key.ctrl && input === 'a')) return {...state, cursor: 0}; + if (key.end || (key.ctrl && input === 'e')) return {...state, cursor: list.length}; + if (key.backspace) return cursor === 0 ? state : splice(state, cursor - 1, cursor); + if (key.delete) return cursor === list.length ? state : splice(state, cursor, cursor + 1); + if (key.ctrl && input === 'u') return splice(state, 0, cursor); + if (key.ctrl && input === 'k') return splice(state, cursor, list.length); + if (key.ctrl && input === 'w') return splice(state, previousWordStart(list, cursor), cursor); + if (key.ctrl || key.meta) return undefined; + const text = chars(printable(input)).filter(accept).join(''); + return text === '' ? state : splice(state, cursor, cursor, text); +}; diff --git a/src/prompts/types.ts b/src/prompts/types.ts new file mode 100644 index 0000000..534ecc2 --- /dev/null +++ b/src/prompts/types.ts @@ -0,0 +1,92 @@ +/** + * Checks an answer before it is accepted. Return a message to show beneath the + * prompt, or `undefined` to accept. A promise lets the check reach the network. + */ +export type Validate = (value: V) => string | undefined | Promise; + +interface FieldBase { + /** The key the answer is stored under. */ + name: string; + /** The question shown to the person. */ + label: string; + /** A short explanation shown beneath the question while it is active. */ + hint?: string; + validate?: Validate; +} + +export interface TextField extends FieldBase { + kind: 'text'; + initial?: string; + /** Muted text shown while the value is empty. */ + placeholder?: string; + /** Hide the value on screen, for tokens and passwords. */ + mask?: boolean; + /** Reject an empty answer. */ + required?: boolean; +} + +export interface NumberField extends FieldBase { + kind: 'number'; + initial?: number; + min?: number; + max?: number; + /** Reject fractions. */ + integer?: boolean; +} + +export interface ConfirmField extends FieldBase { + kind: 'confirm'; + initial?: boolean; +} + +export interface Choice { + value: V; + /** Defaults to the value. */ + label?: string; + hint?: string; + /** A disabled choice is shown but cannot be picked. A string explains why. */ + disabled?: boolean | string; +} + +export interface SelectField extends FieldBase { + kind: 'select'; + options: readonly Choice[]; + initial?: V; + /** The most options shown at once before the list scrolls. Defaults to 7. */ + limit?: number; +} + +export interface MultiSelectField extends FieldBase { + kind: 'multiselect'; + options: readonly Choice[]; + initial?: readonly V[]; + /** The fewest options that must be picked. */ + min?: number; + /** The most options that may be picked. */ + max?: number; + limit?: number; +} + +export type Field = TextField | NumberField | ConfirmField | SelectField | MultiSelectField; + +/** The type of answer a field produces. */ +export type FieldValue = F extends TextField + ? string + : F extends NumberField + ? number + : F extends ConfirmField + ? boolean + : F extends {kind: 'select'; options: readonly Choice[]} + ? V + : F extends {kind: 'multiselect'; options: readonly Choice[]} + ? V[] + : never; + +/** The answers a list of fields produces, keyed by field name. */ +export type Answers = { + [F in Fields[number] as F['name']]: FieldValue; +}; + +/** Keep the literal names and option values of a field list so answers are typed. */ +export const defineFields = (fields: Fields): Fields => + fields; diff --git a/tests/choices.test.ts b/tests/choices.test.ts new file mode 100644 index 0000000..69ec8e2 --- /dev/null +++ b/tests/choices.test.ts @@ -0,0 +1,33 @@ +import {describe, expect, it} from 'vitest'; +import {firstEnabled, moveIndex, windowStart} from '../src/prompts/choices.js'; + +const choices = [ + {value: 'a'}, + {value: 'b', disabled: true}, + {value: 'c'}, + {value: 'd', disabled: 'coming soon'}, +]; + +describe('choice movement', () => { + it('skips disabled choices and wraps at either end', () => { + expect(moveIndex(choices, 0, 1)).toBe(2); + expect(moveIndex(choices, 2, 1)).toBe(0); + expect(moveIndex(choices, 0, -1)).toBe(2); + }); + + it('starts on the first enabled choice', () => { + expect(firstEnabled(choices, 1)).toBe(2); + expect(firstEnabled(choices, 0)).toBe(0); + }); + + it('stays put when nothing else can be picked', () => { + expect(moveIndex([{value: 'only'}], 0, 1)).toBe(0); + }); + + it('scrolls a long list so the highlighted row stays visible', () => { + expect(windowStart(3, 2, 7)).toBe(0); + expect(windowStart(20, 0, 5)).toBe(0); + expect(windowStart(20, 10, 5)).toBe(8); + expect(windowStart(20, 19, 5)).toBe(15); + }); +}); diff --git a/tests/demo.test.tsx b/tests/demo.test.tsx new file mode 100644 index 0000000..3bb1623 --- /dev/null +++ b/tests/demo.test.tsx @@ -0,0 +1,30 @@ +import React from 'react'; +import {describe, expect, it} from 'vitest'; +import {DemoForm} from '../src/demo/index.js'; +import {runForm} from '../src/run.js'; +import {fakeTerminal, tick} from './helpers.js'; + +describe('demo setup flow', () => { + it('walks through every kind of prompt', async () => { + const terminal = fakeTerminal(); + const pending = runForm>( + ({submit, cancel}) => , + terminal, + ); + await tick(); + const keys = ['my-tool', '\r', '\u001B[B', '\r', ' ', '\r', '\r', '\r', 'y']; + for (const key of keys) { + terminal.press(key); + await tick(); + } + await expect(pending).resolves.toEqual({ + name: 'my-tool', + packageManager: 'pnpm', + features: ['lint'], + port: 3000, + token: '', + git: true, + }); + expect(terminal.output()).toContain('✔ Features · Linting'); + }); +}); diff --git a/tests/format.test.ts b/tests/format.test.ts new file mode 100644 index 0000000..e9264e7 --- /dev/null +++ b/tests/format.test.ts @@ -0,0 +1,57 @@ +import {describe, expect, it} from 'vitest'; +import {formatAnswer, validateAnswer} from '../src/prompts/format.js'; +import type {Field} from '../src/prompts/types.js'; + +const features: Field = { + kind: 'multiselect', + name: 'features', + label: 'Features', + options: [{value: 'lint', label: 'Linting'}, {value: 'test'}], + min: 1, +}; + +describe('formatAnswer', () => { + it('shows answers the way a person picked them', () => { + expect(formatAnswer(features, ['lint', 'test'])).toBe('Linting, test'); + expect(formatAnswer(features, [])).toBe('None'); + expect(formatAnswer({kind: 'confirm', name: 'ok', label: 'OK'}, true)).toBe('Yes'); + }); + + it('never prints a masked value', () => { + const token: Field = {kind: 'text', name: 'token', label: 'Token', mask: true}; + expect(formatAnswer(token, 'secret')).toBe('••••••'); + }); +}); + +describe('validateAnswer', () => { + it('applies the limits a field declares', async () => { + const port: Field = { + kind: 'number', + name: 'port', + label: 'Port', + min: 1, + max: 65535, + integer: true, + }; + expect(await validateAnswer(port, 0)).toBe('Enter 1 or more.'); + expect(await validateAnswer(port, 80.5)).toBe('Enter a whole number.'); + expect(await validateAnswer(port, 8080)).toBeUndefined(); + expect(await validateAnswer(features, [])).toBe('Pick at least 1.'); + expect(await validateAnswer({kind: 'text', name: 'n', label: 'N', required: true}, ' ')).toBe( + 'Enter a value.', + ); + }); + + it('runs the field check after the limits, including async checks', async () => { + const name: Field = { + kind: 'text', + name: 'name', + label: 'Name', + required: true, + validate: (value) => Promise.resolve(value.includes(' ') ? 'No spaces.' : undefined), + }; + expect(await validateAnswer(name, '')).toBe('Enter a value.'); + expect(await validateAnswer(name, 'a b')).toBe('No spaces.'); + expect(await validateAnswer(name, 'ab')).toBeUndefined(); + }); +}); diff --git a/tests/helpers.ts b/tests/helpers.ts index d3e469d..a9a1d70 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -27,7 +27,7 @@ export const noKey: Key = { export const keyOf = (key: Partial = {}): Key => ({...noKey, ...key}); /** Give Ink a moment to read stdin and re-render. */ -export const tick = (): Promise => new Promise((resolve) => setTimeout(resolve, 20)); +export const tick = (): Promise => new Promise((resolve) => setTimeout(resolve, 50)); class FakeStdout extends EventEmitter { readonly columns = 80; diff --git a/tests/prompt.test.tsx b/tests/prompt.test.tsx new file mode 100644 index 0000000..9be26f6 --- /dev/null +++ b/tests/prompt.test.tsx @@ -0,0 +1,206 @@ +import React from 'react'; +import {render} from 'ink-testing-library'; +import {describe, expect, expectTypeOf, it, vi} from 'vitest'; +import {ask, askAll, defineFields, Prompt, type Field} from '../src/prompts/index.js'; +import {FormCancelledError} from '../src/run.js'; +import {fakeTerminal, tick} from './helpers.js'; + +const enter = '\r'; +const escape = '\u001B'; +const down = '\u001B[B'; +const backspace = '\u007F'; + +const press = async (stdin: {write: (data: string) => void}, ...keys: string[]) => { + for (const key of keys) { + stdin.write(key); + await tick(); + } +}; + +describe('Prompt', () => { + it('edits text and collapses to the answer once accepted', async () => { + const onSubmit = vi.fn(); + const field: Field = {kind: 'text', name: 'name', label: 'Project name', initial: 'demo'}; + const view = render(); + await tick(); + expect(view.lastFrame()).toContain('? Project name demo'); + await press(view.stdin, backspace, 'x', enter); + expect(onSubmit).toHaveBeenCalledWith('demx'); + expect(view.lastFrame()).toContain('✔ Project name · demx'); + }); + + it('shows a validation message and keeps the prompt open', async () => { + const onSubmit = vi.fn(); + const field: Field = { + kind: 'text', + name: 'name', + label: 'Name', + validate: (value) => (value.length < 3 ? 'Use at least 3 characters.' : undefined), + }; + const view = render(); + await tick(); + await press(view.stdin, 'ab', enter); + expect(view.lastFrame()).toContain('✖ Use at least 3 characters.'); + expect(onSubmit).not.toHaveBeenCalled(); + await press(view.stdin, 'c'); + expect(view.lastFrame()).not.toContain('✖'); + await press(view.stdin, enter); + expect(onSubmit).toHaveBeenCalledWith('abc'); + }); + + it('shows progress while an asynchronous check runs', async () => { + let finish: (message: string | undefined) => void = () => undefined; + const onSubmit = vi.fn(); + const field: Field = { + kind: 'text', + name: 'token', + label: 'Token', + validate: () => + new Promise((resolve) => { + finish = resolve; + }), + }; + const view = render(); + await tick(); + await press(view.stdin, 'abc', enter); + expect(view.lastFrame()).toContain('Checking…'); + finish('The registry rejected this token.'); + await tick(); + expect(view.lastFrame()).toContain('✖ The registry rejected this token.'); + expect(onSubmit).not.toHaveBeenCalled(); + }); + + it('masks secret text', async () => { + const field: Field = {kind: 'text', name: 'token', label: 'Token', mask: true}; + const view = render( undefined} />); + await tick(); + await press(view.stdin, 'hunter2'); + expect(view.lastFrame()).not.toContain('hunter2'); + expect(view.lastFrame()).toContain('•••••••'); + }); + + it('reads numbers and rejects text that is not one', async () => { + const onSubmit = vi.fn(); + const field: Field = {kind: 'number', name: 'port', label: 'Port', max: 9999}; + const view = render(); + await tick(); + await press(view.stdin, enter); + expect(view.lastFrame()).toContain('Enter a number.'); + await press(view.stdin, '12345', enter); + expect(view.lastFrame()).toContain('Enter 9999 or less.'); + await press(view.stdin, backspace, enter); + expect(onSubmit).toHaveBeenCalledWith(1234); + }); + + it('answers a confirmation with y or n', async () => { + const onSubmit = vi.fn(); + const field: Field = {kind: 'confirm', name: 'git', label: 'Initialize git?'}; + const view = render(); + await tick(); + await press(view.stdin, 'y'); + expect(onSubmit).toHaveBeenCalledWith(true); + expect(view.lastFrame()).toContain('Initialize git? · Yes'); + }); + + it('picks one option, skipping disabled ones', async () => { + const onSubmit = vi.fn(); + const field: Field = { + kind: 'select', + name: 'pm', + label: 'Package manager', + options: [{value: 'npm'}, {value: 'yarn', disabled: 'not installed'}, {value: 'pnpm'}], + }; + const view = render(); + await tick(); + expect(view.lastFrame()).toContain('yarn (not installed)'); + await press(view.stdin, down, enter); + expect(onSubmit).toHaveBeenCalledWith('pnpm'); + }); + + it('toggles several options and enforces a minimum', async () => { + const onSubmit = vi.fn(); + const field: Field = { + kind: 'multiselect', + name: 'features', + label: 'Features', + options: [{value: 'lint'}, {value: 'test'}, {value: 'docs'}], + min: 1, + }; + const view = render(); + await tick(); + await press(view.stdin, enter); + expect(view.lastFrame()).toContain('Pick at least 1.'); + await press(view.stdin, ' ', down, down, ' ', enter); + expect(onSubmit).toHaveBeenCalledWith(['lint', 'docs']); + }); + + it('scrolls a long list', async () => { + const field: Field = { + kind: 'select', + name: 'region', + label: 'Region', + limit: 3, + options: Array.from({length: 10}, (_, index) => ({value: `region-${index}`})), + }; + const view = render( undefined} />); + await tick(); + expect(view.lastFrame()).toContain('↓ 7 more'); + await press(view.stdin, down, down, down); + expect(view.lastFrame()).toContain('↑ 2 more'); + expect(view.lastFrame()).toContain('› region-3'); + }); + + it('calls onCancel on Esc', async () => { + const onCancel = vi.fn(); + const field: Field = {kind: 'text', name: 'n', label: 'N'}; + const view = render( undefined} onCancel={onCancel} />); + await tick(); + await press(view.stdin, escape); + await tick(); + expect(onCancel).toHaveBeenCalled(); + }); +}); + +describe('ask', () => { + it('resolves with the answer and leaves it in the terminal', async () => { + const terminal = fakeTerminal(); + const answer = ask({kind: 'confirm', name: 'ok', label: 'Continue?', initial: true}, terminal); + await tick(); + terminal.press(enter); + await expect(answer).resolves.toBe(true); + expect(terminal.output()).toContain('Continue? · Yes'); + }); + + it('rejects when cancelled', async () => { + const terminal = fakeTerminal(); + const answer = ask({kind: 'text', name: 'n', label: 'Name'}, terminal); + await tick(); + terminal.press(escape); + await expect(answer).rejects.toBeInstanceOf(FormCancelledError); + }); +}); + +describe('askAll', () => { + it('asks each field in order and types the answers', async () => { + const fields = defineFields([ + {kind: 'text', name: 'name', label: 'Name'}, + { + kind: 'select', + name: 'pm', + label: 'Package manager', + options: [{value: 'npm'}, {value: 'pnpm'}], + }, + {kind: 'confirm', name: 'git', label: 'Git?'}, + ]); + const terminal = fakeTerminal(); + const pending = askAll(fields, {...terminal, initialValues: {name: 'demo'}}); + await tick(); + for (const key of [enter, down, enter, 'n']) { + terminal.press(key); + await tick(); + } + const answers = await pending; + expect(answers).toEqual({name: 'demo', pm: 'pnpm', git: false}); + expectTypeOf(answers).toEqualTypeOf<{name: string; pm: 'npm' | 'pnpm'; git: boolean}>(); + }); +}); diff --git a/tests/text-editing.test.ts b/tests/text-editing.test.ts new file mode 100644 index 0000000..bb3f44c --- /dev/null +++ b/tests/text-editing.test.ts @@ -0,0 +1,51 @@ +import {describe, expect, it} from 'vitest'; +import {editText, textState} from '../src/prompts/text-editing.js'; +import {keyOf} from './helpers.js'; + +const type = (text: string) => editText(textState(), text, keyOf()); + +describe('editText', () => { + it('inserts typed and pasted text at the cursor', () => { + const start = {value: 'helo', cursor: 3}; + expect(editText(start, 'l', keyOf())).toEqual({value: 'hello', cursor: 4}); + expect(type('two\nlines')).toEqual({value: 'two lines', cursor: 9}); + }); + + it('moves and deletes by character, including wide characters', () => { + const state = textState('a😀b'); + const left = editText(state, '', keyOf({leftArrow: true})); + expect(left).toEqual({value: 'a😀b', cursor: 2}); + expect(editText({value: 'a😀b', cursor: 2}, '', keyOf({backspace: true}))).toEqual({ + value: 'ab', + cursor: 1, + }); + expect(editText({value: 'abc', cursor: 0}, '', keyOf({delete: true}))).toEqual({ + value: 'bc', + cursor: 0, + }); + }); + + it('supports the readline shortcuts for words and lines', () => { + const state = textState('npm run build'); + expect(editText(state, 'w', keyOf({ctrl: true}))).toEqual({value: 'npm run ', cursor: 8}); + expect(editText(state, 'u', keyOf({ctrl: true}))).toEqual({value: '', cursor: 0}); + expect(editText({value: 'abc', cursor: 1}, 'k', keyOf({ctrl: true}))).toEqual({ + value: 'a', + cursor: 1, + }); + expect(editText(state, 'a', keyOf({ctrl: true}))?.cursor).toBe(0); + }); + + it('leaves Enter, Esc, and Tab to the caller', () => { + expect(editText(textState('x'), '', keyOf({return: true}))).toBeUndefined(); + expect(editText(textState('x'), '', keyOf({escape: true}))).toBeUndefined(); + expect(editText(textState('x'), '', keyOf({tab: true}))).toBeUndefined(); + }); + + it('drops characters the field does not accept', () => { + expect(editText(textState(), '4a2', keyOf(), (c) => /\d/.test(c))).toEqual({ + value: '42', + cursor: 2, + }); + }); +});