import type { FieldChoice } from '@orkestrel/form'; import type { FieldControl } from '@orkestrel/form'; import type { FieldValue } from '@orkestrel/form'; import type { FormField } from '@orkestrel/form'; import type { FormInterface } from '@orkestrel/form'; import type { FormValues } from '@orkestrel/form'; import type { PromptTheme } from '@orkestrel/terminal'; import type { PromptThemeOptions } from '@orkestrel/terminal'; import type { StreamTargetInterface } from '@orkestrel/console/server'; import type { StylerInterface } from '@orkestrel/console'; import type { TerminalInterface } from '@orkestrel/terminal'; import type { TextField } from '@orkestrel/form'; /** * Erases from the cursor down to the end of the screen (`ESC[J`) — wipes the whole previous view, * which a `select` or `checkbox` can spread over several lines, in one write before the new view is * rendered, so a redraw never leaves orphaned rows behind. */ export declare const CLEAR_DOWN: string; /** * Holds the format cue appended to a field's label for each control the walk reads as a line of text — * the terminal has no date picker, no color well, and no file chooser, so the accepted shape is * stated instead. A control with no entry needs none: `text` and `editor` accept any line, * `password` masks one, and `confirm`, `select`, and `checkbox` are answered by key rather than by * format. The form's own rules still decide whether the typed value is acceptable. */ export declare const CONTROL_HINTS: Readonly>>; /** * Creates the interactive terminal form driver — the local-keyboard arm of the terminal trio, * beside the core headless `createPrompt` broker and the SSE `createPromptClient` bridge. Where the * broker parks a live form until somebody elsewhere answers it, a `Terminal` answers one here: it * walks the form's fields in schema order, drives each control's pure reducer over raw-mode stdin, * binds every answer through the form's own `fill`, and submits. It is the only impure part of the * terminal stack. * * @param options - See {@link TerminalOptions} * @returns A {@link TerminalInterface} whose `ask` drives one whole form over the resolved streams * * @remarks * - **The form is the unit.** `ask` takes the caller's live form and returns its settled values. The * returned promise is that form's own `answer`, so a caller holding the form can await either one. * - **Every control renders.** The line-read controls are read as a line of text with their format cue, * `password` masks, `confirm` takes a key, `editor` takes a block, `select` and `checkbox` drive a * list, and an open `select` accepts a value its list does not offer. * - **The form decides.** A blank answer binds as absence, the form evaluates, and a refusal * re-walks only the fields the walk can still edit. * - **Injectable and guard-narrowed.** `input` and `output` default to `process.stdin` and * `process.stdout` but accept any stream of the declared minimal shape, resolved through their * guards rather than an assertion, so a test drives a whole form with a fake TTY that emits * scripted key chunks and records every rendered byte. * - **Non-TTY fallback.** A piped stream cannot enter raw mode, so the same walk runs over * `node:readline` line input. * * @example Ask one form at this keyboard * ```ts * import { createForm } from '@orkestrel/form' * import { isTerminalError } from '@orkestrel/terminal' * import { createTerminal } from '@orkestrel/terminal/server' * * const terminal = createTerminal() // process.stdin / process.stdout by default * const form = createForm({ * label: 'Sign up', * fields: [ * { control: 'text', name: 'name', label: 'Your name', rule: { required: true, minimum: 2 } }, * { control: 'text', name: 'email', label: 'Email', rule: { required: true, email: true } }, * { control: 'password', name: 'token', label: 'Token' }, * { control: 'confirm', name: 'terms', label: 'Accept the terms', rule: { required: true } }, * { * control: 'select', * name: 'role', * label: 'Role', * choices: [ * { value: 'admin', label: 'Admin' }, * { value: 'viewer', label: 'Viewer', help: 'read-only' }, * ], * }, * ], * }) * * try { * const values = await terminal.ask(form) * deploy(values) * } catch (error) { * // Ctrl-c: the walk ended, and the form is still `editing` for whoever owns it. * if (isTerminalError(error) && error.code === 'CANCEL') form.destroy() * } * ``` */ export declare function createTerminal(options?: TerminalOptions): TerminalInterface; /** * Holds the cursor-up sequence template (`ESC[{count}A`) — * {@link import('./helpers.js').renderCursorUp} interpolates the `{count}` placeholder with the * number of lines to climb. Kept as a template so the count stays out of the constant. */ export declare const CSI_UP: string; /** * Hides the cursor (`ESC[?25l`) — written before the driver starts redrawing a prompt so the cursor * does not flicker across the view during an in-place re-render; paired with {@link CURSOR_SHOW}. */ export declare const CURSOR_HIDE: string; /** Shows the cursor (`ESC[?25h`) — restores the cursor after a prompt resolves / cancels (the {@link CURSOR_HIDE} pair). */ export declare const CURSOR_SHOW: string; /** Holds the comma-separated multi-select hint the non-TTY `checkbox` fallback shows (the user types one or more numbers). */ export declare const FALLBACK_CHECKBOX_HINT = "Enter numbers separated by commas"; /** Holds the hint the non-TTY `confirm` fallback shows — a piped stream sends a whole line, so the answer is typed rather than pressed. */ export declare const FALLBACK_CONFIRM_HINT = "(y/n)"; /** Holds the hint the non-TTY `editor` fallback shows — a piped stream has no ctrl-d, so end of input finishes the block. */ export declare const FALLBACK_EDITOR_HINT = "(end of input finishes)"; /** * Holds the numbered-list prompt the non-TTY {@link import('./Terminal.js').Terminal} `select` fallback * appends — a piped (non-terminal) stream cannot navigate with arrow keys, so the choices are * printed numbered and the user types one number on a single readline line. */ export declare const FALLBACK_SELECT_HINT = "Enter a number"; /** * Projects any field the walk reads as a line of text into the {@link TextField} the text reducer * takes — `text` itself, and the controls a terminal has no widget for: `number`, `date`, `time`, * `datetime`, `color`, and one `file` entry. The label carries that control's format cue from * {@link CONTROL_HINTS}, and a declared `default` becomes the line a bare return submits. The * projection carries no rule, because the authoritative form still evaluates the answer this line * binds; it exists only so one reducer covers every one of them. * * @param field - The field being read * @returns The text field the reducer renders for it * * @example * ```ts * fieldToText({ control: 'date', name: 'born', label: 'Birthday' }) * // { control: 'text', name: 'born', label: 'Birthday (YYYY-MM-DD)' } * ``` */ export declare function fieldToText(field: FormField): TextField; /** Holds the instruction a `file` field with `multiple` shows before its entries — one path per line, and a blank line ends the list. */ export declare const FILE_HINT = "One path per line, blank to finish"; /** * Returns the choices a `select` or `checkbox` field shows but refuses — the complement of * {@link filterEnabled}, rendered by {@link renderUnavailableLine} above the list so a reader sees * why a declared choice is missing from it. * * @param choices - The field's declared choices * @returns The refused choices, in declared order */ export declare function filterDisabled(choices: readonly FieldChoice[]): readonly FieldChoice[]; /** * Returns the choices a `select` or `checkbox` field actually offers — the form refuses a disabled * choice's value at every door, including a fill, so the walk never puts one in front of the * cursor. Pair with {@link filterDisabled} to tell the reader what was withheld. * * @param choices - The field's declared choices * @returns The choices the walk offers, in declared order */ export declare function filterEnabled(choices: readonly FieldChoice[]): readonly FieldChoice[]; /** * Represents the minimal input-stream shape the driver reads — exactly the slice of a Node `tty.ReadStream` / * `process.stdin` it touches, and no more. A {@link TerminalOptions} `input` is narrowed to this * through {@link import('./helpers.js').isInputStream}, never an assertion, so a test drives a whole * form with a hand-built fake stream that emits scripted key chunks, never touches the real * `process.stdin`, and asserts that raw mode is entered once and always cleaned up. * * @remarks * - `on(event, listener)` / `off(event, listener)` — subscribe / unsubscribe a `'data'` chunk * listener (the irreducible event seam; a `Buffer`, string, or `Uint8Array` chunk arrives). The * required methods. * - `setRawMode(mode)` — switch the TTY in and out of raw mode (each keypress delivered * immediately, no line buffering, no echo). Present on a real `tty.ReadStream`; ABSENT on a * piped, non-TTY stream, and its absence (or `isTTY !== true`) selects the * {@link import('node:readline').Interface} line-input fallback. * - `resume()` / `pause()` — start / stop the flow of `'data'` events. Raw mode `resume()`s on * enter and `pause()`s on cleanup; both are optional, so a fake may omit them. * - `isTTY` — `true` on a real terminal, absent or `false` when piped to a file or another process. */ export declare interface InputStreamInterface { /** Subscribes a `'data'` chunk listener — the irreducible event seam. */ on(event: 'data', listener: (chunk: string | Uint8Array) => void): void; /** Unsubscribes that listener. The driver always pairs it, so no listener leaks. */ off(event: 'data', listener: (chunk: string | Uint8Array) => void): void; /** Switches the TTY in and out of raw mode. Absent on a piped stream. */ setRawMode?(mode: boolean): void; /** Starts the flow of `'data'` events. */ resume?(): void; /** Stops it again on cleanup. */ pause?(): void; /** Reports whether the stream is a real terminal; absent or false when piped. */ readonly isTTY?: boolean; } /** * Checks whether `value` is a usable {@link InputStreamInterface} — a record with callable `on` / * `off` `'data'` subscription methods. A total type guard: it never throws and returns `false` for * anything off-shape, so it narrows the one unavoidable input boundary (the real `process.stdin`, * or a fake TTY a test injects) to the exact slice the driver reads, never an assertion. * * @remarks * Only `on` / `off` are required (the irreducible event seam); `setRawMode` / `resume` / `pause` / * `isTTY` are optional on {@link InputStreamInterface}, so their absence does not disqualify a stream * — a piped, non-TTY stream is still a valid input, one the driver reads through the readline * fallback rather than raw mode. * * @param value - Any value crossing the boundary (a process stream, an injected fake, `unknown`) * @returns True if `value` has callable `on` and `off`; false otherwise */ export declare function isInputStream(value: unknown): value is InputStreamInterface; /** * Checks whether `value` is a Node {@link NodeJS.ReadableStream} — a total structural guard * checking for the callable `read` / `pipe` / `on` that `node:readline`'s `createInterface` * requires as its `input`. The non-TTY fallback narrows the resolved input stream through this * before handing it to readline, never through an assertion, so a real piped `process.stdin` (or a * `PassThrough` a test injects) crosses into the readline boundary honestly. Never throws; returns * `false` for a minimal fake that isn't a full readable. * * @param value - The resolved input stream (or any value crossing the boundary) * @returns True if `value` has the readable methods readline needs; false otherwise */ export declare function isReadable(value: unknown): value is NodeJS.ReadableStream; /** * Counts the terminal lines a rendered prompt `view` occupies — one more than its newline count, so * a view with no newline is a single line and a view with N newlines spans N+1 lines. The basis of * the in-place re-render: the driver records the line count of the view it wrote so the next redraw * knows how far up to move the cursor before overwriting. Total; an empty string is one empty line. * * @param view - The rendered (possibly multi-line, possibly ANSI-styled) view string * @returns The number of lines the view spans (always at least 1) */ export declare function lineCount(view: string): number; /** Holds the mark on a locked field's line — the walk renders its value and moves on, because the form refuses an edit there. */ export declare const LOCKED_MARK = "(locked)"; /** * Returns the full reposition-and-clear prefix to write before re-rendering a prompt view in place * — given the line count of the previous view, it moves the cursor up over those lines, returns it * to column 0, and erases everything from there to the end of the screen, so the next view is drawn * on a clean region and a taller previous view leaves no orphaned rows. Pure; the driver writes * this immediately followed by the new view. * * @remarks * For the first render `previousLines` is `1` (the cursor sits on the line the prompt opened on) so * the prefix is a carriage return + clear-down — the prompt draws from the current line. For a * subsequent render it climbs `previousLines - 1` lines (the cursor is on the last line of the prior * view) before clearing. Keeping the math here (not in the driver) makes the re-render unit-testable * without a real terminal. * * @param previousLines - The line count of the view on screen (from {@link lineCount}) * @returns The control-sequence prefix to write before the new view */ export declare function redrawPrefix(previousLines: number): string; /** * States what a field is told when the walk read an answer the control cannot hold — a word typed into a * `number`, an off-list value typed into an open `select` whose choice is refused. The value binds * as absence and this message is invalidated onto the field, so the walk re-asks it with the reason * on screen. */ export declare const REFUSAL_MESSAGE = "Enter a value this field accepts"; /** * Returns the cursor-up control sequence that moves the cursor up `count` lines (`ESC[{count}A`), * or the empty string when `count` is zero or negative, because no movement is needed and `ESC[0A` * is a wasted write. The pure step the in-place re-render uses to climb back over the previous view * before clearing it. Total. * * @param count - How many lines to move the cursor up * @returns The `ESC[{count}A` sequence, or `''` when `count <= 0` */ export declare function renderCursorUp(count: number): string; /** * Renders the section header the walk writes when it enters a new field group, painted by the * `message` role. * * @param styler - The console styler that renders each role * @param theme - The resolved prompt theme * @param label - The group's declared label, falling back to its own name * @returns The rendered section header */ export declare function renderGroupHeader(styler: StylerInterface, theme: PromptTheme, label: string): string; /** * Renders the read-only line a locked field shows — its label, the {@link LOCKED_MARK}, and the * answer the form already holds. The walk writes this instead of a prompt, because the field is * still validated and still submitted but must not be edited here. * * @param styler - The console styler that renders each role * @param theme - The resolved prompt theme * @param label - The field's label * @param value - The held answer, from {@link valueToText} * @returns The rendered line, with no trailing space when there is nothing to show */ export declare function renderLockedLine(styler: StylerInterface, theme: PromptTheme, label: string, value: string): string; /** * Renders the numbered choice list the non-TTY fallback prints — a piped stream cannot navigate * with arrow keys, so each offered choice is printed with the number the reader types back. One * line per choice, with no trailing newline. * * @param styler - The console styler that renders each role * @param theme - The resolved prompt theme * @param choices - The choices the walk offers, from {@link filterEnabled} * @returns The rendered list */ export declare function renderNumberedList(styler: StylerInterface, theme: PromptTheme, choices: readonly FieldChoice[]): string; /** * Renders the line listing an open select's offered values above its text prompt — a suggestion * list, because an open select admits an answer the list does not offer. * * @param styler - The console styler that renders each role * @param theme - The resolved prompt theme * @param choices - The choices the open select offers, from {@link filterEnabled} * @returns The rendered suggestion line */ export declare function renderSuggestionLine(styler: StylerInterface, theme: PromptTheme, choices: readonly FieldChoice[]): string; /** * Renders the line naming the choices a field shows but refuses, written above the list the walk * drives. * * @param styler - The console styler that renders each role * @param theme - The resolved prompt theme * @param choices - The refused choices, from {@link filterDisabled} * @returns The rendered unavailable line */ export declare function renderUnavailableLine(styler: StylerInterface, theme: PromptTheme, choices: readonly FieldChoice[]): string; /** * Holds the lead on the line listing an open `select`'s offered values, which a typed answer can * ignore. */ export declare const SUGGESTION_LEAD = "Suggestions"; /** * Checks whether an input stream can be driven in raw mode — it reports `isTTY === true` and * exposes a callable `setRawMode`. The {@link import('./Terminal.js').Terminal} probes this to * choose its path: true selects the interactive raw-mode fields, with arrow-key navigation and a * live re-render; false selects the `node:readline` line-input fallback, because a piped or * non-terminal stream cannot enter raw mode. Total — never throws. * * @param input - The resolved {@link InputStreamInterface} * @returns True if the stream is a TTY with `setRawMode`; false otherwise */ export declare function supportsRawMode(input: InputStreamInterface): boolean; /** * Implements {@link TerminalInterface} for a human at this machine's keyboard — the interactive * form driver, and the only impure part of the terminal stack. {@link ask} walks one form's fields * in schema order, feeds raw-mode stdin bytes through `parseKey` into the matching pure reducer, * renders each returned view in place, binds every answer through the form's own `fill`, and * re-asks what the form refused. It owns no form logic: the schema, the rules, the values, and the * settlement all belong to the form it is given, and this class owns only raw mode, the cursor, and * the re-render. * * @remarks * See {@link TerminalInterface} for the driving contract. The walk itself: * * - **Controls and reducers.** `text`, `number`, `date`, `time`, `datetime`, `color`, and * each `file` entry are read as one line of text through {@link fieldToText}, which appends that * control's format cue to the label. `password`, `confirm`, `editor`, `select`, and `checkbox` * each drive their own reducer. An open `select` is a suggestion list plus a typed line, because * `open` means the answer need not come from the list. * - **The binding projects through `matchesAnswer`.** Every answer is filled as * `fill(name, matchesAnswer(value) ? value : undefined)` after `parseValue` has coerced it to the * control's own shape, so a bare return on a field with no default binds as absence and the * form's `required` rule refuses it. A typed answer the control cannot hold binds as absence and * invalidates the field, so the walk asks again with the reason on screen. * - **Visibility is honored.** A `hidden` field and a field in `form.disabled` are skipped; a * `locked` field renders read-only; entering a new group writes its label as a section header. * - **Refusal re-asks.** After the walk the form is submitted. A refusal re-walks only the erroring * fields the walk can edit and submits again. When every erroring field is one the walk cannot * edit — hidden, locked, or disabled — the form is abandoned instead, because asking again could * not change the answer. * - **Raw-mode leak-free.** Raw mode is entered once per field and always cleaned up: on submit, on * cancel, on a throw, and when the form is abandoned under an active read. * - **Non-TTY fallback.** When `input` is not a TTY, the same walk runs over `node:readline` line * input: `select` and `checkbox` print a numbered list, and `editor` reads to end of input. */ export declare class Terminal implements TerminalInterface { #private; constructor(options?: TerminalOptions); ask(form: FormInterface): Promise; } /** * Configures {@link import('./factories.js').createTerminal} — every member optional, so a bare * `createTerminal()` walks a form over the real `process.stdin` / `process.stdout` with the default * theme. * * @remarks * - `input` — the stream keystrokes are read from; defaults to `process.stdin`. Any * {@link InputStreamInterface}-shaped stream is accepted, resolved through * {@link import('./helpers.js').isInputStream}, so a test injects a fake TTY that emits scripted * `'data'` chunks. A stream that is not a TTY falls back to `node:readline` line input. * - `output` — the stream each view is rendered to; defaults to `process.stdout`. Any * {@link import('@orkestrel/console/server').StreamTargetInterface}-shaped stream is accepted, * resolved through {@link import('@orkestrel/console/server').isStreamTarget}, so a test records * the rendered output. That is the console module's own boundary shape, so one target drives a * console sink and this driver alike. * - `theme` — the glyphs and role styles every rendered line is painted with, merged over * {@link import('@orkestrel/terminal').DEFAULT_PROMPT_THEME} leaf by leaf by * {@link import('@orkestrel/terminal').createPromptTheme}. Supplying one icon or one role leaves every * other slot at its default. */ export declare interface TerminalOptions { readonly input?: InputStreamInterface; readonly output?: StreamTargetInterface; readonly theme?: PromptThemeOptions; } /** * Holds the lead on the line listing the choices a `select` or `checkbox` shows but refuses, so a * reader sees why one is missing from the list it heads. */ export declare const UNAVAILABLE_LEAD = "Unavailable"; /** * Projects one held answer into the text a read-only line shows — a scalar as itself, a boolean as * `yes` / `no` (the word the confirm reducer commits), and a list joined by commas. Absence renders * as nothing, because a locked field nobody has answered has nothing to show. * * @param value - The answer the form holds for a field, or absence * @returns The text to render for it */ export declare function valueToText(value: FieldValue | undefined): string; export { }