EdgeAIG's picture
download
raw
122 kB
/**
* Builds interactive terminal prompts for CLI applications.
*
* A `Prompt<A>` describes a small terminal UI that renders frames, reads
* keyboard input, validates responses, and eventually produces an `A`. Prompts
* can ask for simple values, selections, lists, files, hidden text, or custom
* interactions. This module includes prompt constructors, tools for combining
* and transforming prompt output, and support for running prompts through the
* `Terminal` service.
*
* @since 4.0.0
*/
import * as Arr from "../../Array.ts"
import type { NoSuchElementError } from "../../Cause.ts"
import type * as Cause from "../../Cause.ts"
import * as Data from "../../Data.ts"
import * as Effect from "../../Effect.ts"
import * as Effectable from "../../Effectable.ts"
import * as FileSystem from "../../FileSystem.ts"
import { dual, pipe } from "../../Function.ts"
import * as EffectNumber from "../../Number.ts"
import * as Option from "../../Option.ts"
import * as Path from "../../Path.ts"
import * as Predicate from "../../Predicate.ts"
import * as Queue from "../../Queue.ts"
import * as Redacted from "../../Redacted.ts"
import * as Terminal from "../../Terminal.ts"
import type { Covariant } from "../../Types.ts"
import * as Ansi from "./internal/ansi.ts"
import type * as Primitive from "./Primitive.ts"
const TypeId = "~effect/cli/Prompt"
/**
* Represents an interactive terminal prompt that produces an `Output` value.
*
* **Details**
*
* A `Prompt` is an `Effect` that may fail with `Terminal.QuitError` and
* requires the prompt environment needed to render frames, read input, and
* access files or paths when a prompt uses them.
*
* @category models
* @since 4.0.0
*/
export interface Prompt<Output> extends Effect.Effect<Output, Terminal.QuitError, Environment> {
readonly [TypeId]: {
readonly _Output: Covariant<Output>
}
}
/**
* Returns `true` if the provided value is a `Prompt`.
*
* @category guards
* @since 4.0.0
*/
export const isPrompt = (u: unknown): u is Prompt<unknown> => Predicate.hasProperty(u, TypeId)
/**
* Represents the services available to a custom `Prompt`.
*
* @category models
* @since 4.0.0
*/
export type Environment = FileSystem.FileSystem | Path.Path | Terminal.Terminal
/**
* Represents the action that should be taken by a `Prompt` based upon user
* input or an external event received during the current frame.
*
* @category models
* @since 4.0.0
*/
export type Action<State, Output> = Data.TaggedEnum<{
readonly Beep: {}
readonly NextFrame: { readonly state: State }
readonly Submit: { readonly value: Output }
}>
/**
* Type-level definition for the tagged `Prompt.Action` variants.
*
* **Details**
*
* It connects the action state and output type parameters to the `Beep`,
* `NextFrame`, and `Submit` action cases.
*
* @category models
* @since 4.0.0
*/
export interface ActionDefinition extends Data.TaggedEnum.WithGenerics<2> {
readonly taggedEnum: Action<this["A"], this["B"]>
}
/**
* Represents the input that should be processed by a `Prompt` based upon user
* input or an external event received during the current frame.
*
* @category models
* @since 4.0.0
*/
export type ProcessInput<A> = Data.TaggedEnum<{
readonly Input: { readonly input: Terminal.UserInput }
readonly Event: { readonly value: A }
}>
/**
* Represents the set of handlers used by a `Prompt`.
*
* **Details**
*
* The handlers render the current frame, process user input into the next
* `Prompt.Action`, and clear the terminal screen before the next frame.
*
* @category models
* @since 4.0.0
*/
export interface Handlers<State, Output, Input = Terminal.UserInput> {
/**
* A function that is called to render the current frame of the `Prompt`.
*/
readonly render: (
state: State,
action: Action<State, Output>
) => Effect.Effect<string, never, Environment>
/**
* A function that is called to process user input and determine the next
* `Prompt.Action` that should be taken.
*/
readonly process: (
input: Input,
state: State
) => Effect.Effect<Action<State, Output>, never, Environment>
/**
* A function that is called to clear the terminal screen before rendering
* the next frame of the `Prompt`.
*/
readonly clear: (
state: State,
action: Action<State, Output>
) => Effect.Effect<string, never, Environment>
}
/**
* Options for a confirmation prompt that asks the user to choose a boolean
* yes/no value.
*
* @category options
* @since 4.0.0
*/
export interface ConfirmOptions {
/**
* The message to display in the prompt.
*/
readonly message: string
/**
* The initial value of the confirm prompt (defaults to `false`).
*/
readonly initial?: boolean
/**
* The label to display after a user has responded to the prompt.
*/
readonly label?: {
/**
* The label used if the prompt is confirmed (defaults to `"yes"`).
*/
readonly confirm: string
/**
* The label used if the prompt is not confirmed (defaults to `"no"`).
*/
readonly deny: string
}
/**
* The placeholder to display when a user is responding to the prompt.
*/
readonly placeholder?: {
/**
* The placeholder to use if the `initial` value of the prompt is `true`
* (defaults to `"(Y/n)"`).
*/
readonly defaultConfirm?: string
/**
* The placeholder to use if the `initial` value of the prompt is `false`
* (defaults to `"(y/N)"`).
*/
readonly defaultDeny?: string
}
}
/**
* Options for a date prompt, including the displayed message, initial value,
* format mask, validation, and locale labels.
*
* @category options
* @since 4.0.0
*/
export interface DateOptions {
/**
* The message to display in the prompt.
*/
readonly message: string
/**
* The initial date value to display in the prompt (defaults to the current
* date).
*/
readonly initial?: globalThis.Date
/**
* The format mask of the date (defaults to `YYYY-MM-DD HH:mm:ss`).
*/
readonly dateMask?: string
/**
* An effectful function that can be used to validate the value entered into
* the prompt before final submission.
*/
readonly validate?: (value: globalThis.Date) => Effect.Effect<globalThis.Date, string>
/**
* Custom locales that can be used in place of the defaults.
*/
readonly locales?: {
/**
* The full names of each month of the year.
*/
readonly months: [
string,
string,
string,
string,
string,
string,
string,
string,
string,
string,
string,
string
]
/**
* The short names of each month of the year.
*/
readonly monthsShort: [
string,
string,
string,
string,
string,
string,
string,
string,
string,
string,
string,
string
]
/**
* The full names of each day of the week.
*/
readonly weekdays: [string, string, string, string, string, string, string]
/**
* The short names of each day of the week.
*/
readonly weekdaysShort: [string, string, string, string, string, string, string]
}
}
/**
* Options for an integer prompt, including bounds, keyboard step sizes, and
* additional validation.
*
* @category options
* @since 4.0.0
*/
export interface IntegerOptions {
/**
* The message to display in the prompt.
*/
readonly message: string
/**
* The default value of the integer prompt.
*/
readonly default?: number
/**
* The minimum value that can be entered by the user (defaults to `-Infinity`).
*/
readonly min?: number
/**
* The maximum value that can be entered by the user (defaults to `Infinity`).
*/
readonly max?: number
/**
* The value that will be used to increment the prompt value when using the
* up arrow key (defaults to `1`).
*/
readonly incrementBy?: number
/**
* The value that will be used to decrement the prompt value when using the
* down arrow key (defaults to `1`).
*/
readonly decrementBy?: number
/**
* An effectful function that can be used to validate the value entered into
* the prompt before final submission.
*/
readonly validate?: (value: number) => Effect.Effect<number, string>
}
/**
* Options for a floating-point number prompt.
*
* **Details**
*
* In addition to the numeric bounds and step settings from `IntegerOptions`,
* the prompt can be configured with a display precision.
*
* @category options
* @since 4.0.0
*/
export interface FloatOptions extends IntegerOptions {
/**
* The precision to use for the floating point value (defaults to `2`).
*/
readonly precision?: number
}
/**
* Options for a text prompt that returns a list of strings by splitting the
* input on a delimiter.
*
* @category options
* @since 4.0.0
*/
export interface ListOptions extends TextOptions {
/**
* The delimiter that separates list entries.
*/
readonly delimiter?: string
}
/**
* Options for a file-system selection prompt.
*
* **Details**
*
* They control which path type can be selected, the starting directory, paging,
* and filtering of displayed entries.
*
* @category options
* @since 4.0.0
*/
export interface FileOptions {
/**
* The path type that will be selected, defaulting to `"file"`.
*/
readonly type?: Primitive.PathType
/**
* The message to display in the prompt, defaulting to `"Choose a file"`.
*/
readonly message?: string
/**
* Where the user will initially be prompted to select files from, defaulting
* to the current working directory.
*/
readonly startingPath?: string
/**
* The default path to select when the prompt is first displayed.
*/
readonly default?: string
/**
* The number of choices to display at one time, defaulting to `10`.
*/
readonly maxPerPage?: number
/**
* A predicate or effect that keeps a file in the prompt when it returns
* `true`, defaulting to returning all files.
*/
readonly filter?: (file: string) => boolean | Effect.Effect<boolean, never, Environment>
}
/**
* Options for a prompt that asks the user to select one value from a list of
* choices.
*
* @category options
* @since 4.0.0
*/
export interface SelectOptions<A> {
/**
* The message to display in the prompt.
*/
readonly message: string
/**
* The choices to display to the user.
*/
readonly choices: ReadonlyArray<SelectChoice<A>>
/**
* The number of choices to display at one time (defaults to `10`).
*/
readonly maxPerPage?: number
}
/**
* Options for an autocomplete prompt that lets the user filter selectable
* choices by typing.
*
* @category options
* @since 4.0.0
*/
export interface AutoCompleteOptions<A> extends SelectOptions<A> {
/**
* The label used for the filter display (defaults to "filter").
*/
readonly filterLabel?: string
/**
* The placeholder shown when the filter is empty (defaults to "type to filter").
*/
readonly filterPlaceholder?: string
/**
* The message displayed when no choices match (defaults to "No matches").
*/
readonly emptyMessage?: string
}
/**
* Options for a multi-select prompt, including bulk-selection labels and
* minimum or maximum selection counts.
*
* @category options
* @since 4.0.0
*/
export interface MultiSelectOptions {
/**
* Text for the "Select All" option (defaults to "Select All").
*/
readonly selectAll?: string
/**
* Text for the "Select None" option (defaults to "Select None").
*/
readonly selectNone?: string
/**
* Text for the "Inverse Selection" option (defaults to "Inverse Selection").
*/
readonly inverseSelection?: string
/**
* The minimum number of choices that must be selected.
*/
readonly min?: number
/**
* The maximum number of choices that can be selected.
*/
readonly max?: number
}
/**
* Represents one choice displayed by select, autocomplete, and multi-select
* prompts.
*
* @category models
* @since 4.0.0
*/
export interface SelectChoice<A> {
/**
* The name of the select option that is displayed to the user.
*/
readonly title: string
/**
* The underlying value of the select option.
*/
readonly value: A
/**
* An optional description for the select option which will be displayed
* to the user.
*/
readonly description?: string
/**
* Whether or not this select option is disabled.
*/
readonly disabled?: boolean
/**
* Whether this option should be selected by default (only used by MultiSelect).
*/
readonly selected?: boolean
}
/**
* Options for text-entry prompts, including the displayed message, default
* text, and effectful validation before submission.
*
* @category options
* @since 4.0.0
*/
export interface TextOptions {
/**
* The message to display in the prompt.
*/
readonly message: string
/**
* The default value of the text option.
*/
readonly default?: string
/**
* An effectful function that can be used to validate the value entered into
* the prompt before final submission.
*/
readonly validate?: (value: string) => Effect.Effect<string, string>
}
/**
* Options for a toggle prompt that lets the user switch between active and
* inactive boolean states.
*
* @category options
* @since 4.0.0
*/
export interface ToggleOptions {
/**
* The message to display in the prompt.
*/
readonly message: string
/**
* The intitial value of the toggle prompt (defaults to `false`).
*/
readonly initial?: boolean
/**
* The text to display when the toggle is in the active state (defaults to
* `on`).
*/
readonly active?: string
/**
* The text to display when the toggle is in the inactive state (defaults to
* `off`).
*/
readonly inactive?: string
}
const defaultFigures = {
arrowUp: "↑",
arrowDown: "↓",
arrowLeft: "←",
arrowRight: "→",
radioOn: "◉",
radioOff: "◯",
checkboxOn: "☒",
checkboxOff: "☐",
tick: "✔",
cross: "✖",
ellipsis: "…",
pointerSmall: "›",
line: "─",
pointer: "❯"
}
const windowsFigures = {
arrowUp: defaultFigures.arrowUp,
arrowDown: defaultFigures.arrowDown,
arrowLeft: defaultFigures.arrowLeft,
arrowRight: defaultFigures.arrowRight,
radioOn: "(*)",
radioOff: "( )",
checkboxOn: "[*]",
checkboxOff: "[ ]",
tick: "√",
cross: "×",
ellipsis: "...",
pointerSmall: "»",
line: "─",
pointer: ">"
}
/** @internal */
export const platformFigures = Effect.map(
Effect.sync(() => process.platform === "win32"),
(isWindows) => isWindows ? windowsFigures : defaultFigures
)
/**
* Type alias for any `Prompt`, regardless of its output type.
*
* @category utility types
* @since 4.0.0
*/
export type Any = Prompt<unknown>
/**
* Namespace containing return-type helpers for `Prompt.all`.
*
* @since 4.0.0
*/
export declare namespace All {
/**
* Computes the prompt returned by `Prompt.all` for an iterable of prompts.
*
* **Details**
*
* The resulting prompt produces an array of each prompt's output value.
*
* @category utility types
* @since 4.0.0
*/
export type ReturnIterable<T extends Iterable<Any>> = [T] extends [Iterable<Prompt<infer A>>] ? Prompt<Array<A>>
: never
/**
* Computes the prompt returned by `Prompt.all` for a readonly tuple or array
* of prompts, preserving tuple positions in the output type.
*
* @category utility types
* @since 4.0.0
*/
export type ReturnTuple<T extends ReadonlyArray<unknown>> = Prompt<
T[number] extends never ? []
: { -readonly [K in keyof T]: [T[K]] extends [Prompt<infer _A>] ? _A : never }
> extends infer X ? X : never
/**
* Computes the prompt returned by `Prompt.all` for a record of prompts,
* preserving the record keys and replacing each prompt with its output type.
*
* @category utility types
* @since 4.0.0
*/
export type ReturnObject<T> = [T] extends [{ [K: string]: Any }] ? Prompt<
{
-readonly [K in keyof T]: [T[K]] extends [Prompt<infer _A>] ? _A : never
}
>
: never
/**
* Computes the return prompt type for `Prompt.all` based on the input
* structure.
*
* @category constructors
* @since 4.0.0
*/
export type Return<
Arg extends Iterable<Any> | Record<string, Any>
> = [Arg] extends [ReadonlyArray<Any>] ? ReturnTuple<Arg>
: [Arg] extends [Iterable<Any>] ? ReturnIterable<Arg>
: [Arg] extends [Record<string, Any>] ? ReturnObject<Arg>
: never
}
/**
* Runs all the provided prompts in sequence respecting the structure provided
* in input.
*
* **Details**
*
* Supports either a tuple / iterable of prompts or a record / struct of prompts
* as an argument.
*
* **Example** (Collecting prompt results)
*
* ```ts
* import { Effect } from "effect"
* import { Prompt } from "effect/unstable/cli"
*
* const username = Prompt.text({
* message: "Enter your username: "
* })
*
* const password = Prompt.password({
* message: "Enter your password: ",
* validate: (value) =>
* value.length === 0
* ? Effect.fail("Password cannot be empty")
* : Effect.succeed(value)
* })
*
* const allWithTuple = Prompt.all([username, password])
*
* const allWithRecord = Prompt.all({ username, password })
* ```
*
* @category collecting & elements
* @since 4.0.0
*/
export const all: <
const Arg extends Iterable<Prompt<any>> | Record<string, Prompt<any>>
>(arg: Arg) => All.Return<Arg> = function() {
if (arguments.length === 1) {
if (isPrompt(arguments[0])) {
return map(arguments[0], (x) => [x]) as any
} else if (Array.isArray(arguments[0])) {
return allTupled(arguments[0]) as any
} else {
const entries = Object.entries(arguments[0] as Readonly<{ [K: string]: Prompt<any> }>)
let result = map(entries[0][1], (value) => ({ [entries[0][0]]: value }))
if (entries.length === 1) {
return result as any
}
const rest = entries.slice(1)
for (const [key, prompt] of rest) {
result = result.pipe(
flatMap((record) =>
prompt.pipe(map((value) => ({
...record,
[key]: value
})))
)
)
}
return result as any
}
}
return allTupled(arguments[0]) as any
}
const annotateLine = (line: string): string => Ansi.annotate(line, Ansi.bold)
const annotateErrorLine = (line: string): string => Ansi.annotate(line, Ansi.combine(Ansi.italicized, Ansi.red))
/**
* Creates a confirmation prompt that asks the user to choose a boolean yes/no
* value.
*
* **When to use**
*
* Use to ask for a yes/no answer that can be submitted directly.
*
* **Details**
*
* `initial` defaults to `false`. Enter submits the current default, yes-style
* input submits `true`, no-style input submits `false`, and other input beeps.
*
* @see {@link toggle} for an interactive switch-before-submit boolean prompt
*
* @category constructors
* @since 4.0.0
*/
export const confirm = (options: ConfirmOptions): Prompt<boolean> => {
const opts: Required<ConfirmOptions> = {
initial: false,
...options,
label: {
confirm: "yes",
deny: "no",
...options.label
},
placeholder: {
defaultConfirm: "(Y/n)",
defaultDeny: "(y/N)",
...options.placeholder
}
}
const initialState: ConfirmState = { value: opts.initial }
return custom(initialState, {
render: handleConfirmRender(opts),
process: (input) => handleConfirmProcess(input, opts.initial),
clear: handleConfirmClear(opts)
})
}
/**
* Creates a custom `Prompt` from the specified initial state and handlers.
*
* **Details**
*
* The initial state can either be a pure value or an `Effect`. This is
* particularly useful when the initial state of the `Prompt` must be computed
* by performing an effectful computation, such as reading data from the file
* system. A `Prompt` runs as a render loop: `render` returns ANSI output for
* the current frame, the `Terminal` obtains user input, `process` returns the
* next prompt action, and `clear` returns ANSI output used to clear the previous
* frame.
*
* Optionally, an external `events` dequeue can be provided as the third
* argument. When present, the render loop will race user input against events
* from the dequeue, allowing background events to trigger re-renders without
* waiting for a keypress. When an event is received from the dequeue, the
* `receive` handler is called instead of `process`.
*
* @category constructors
* @since 4.0.0
*/
export const custom: {
/**
* Creates a custom `Prompt` from the specified initial state and handlers.
*
* **Details**
*
* The initial state can either be a pure value or an `Effect`. This is
* particularly useful when the initial state of the `Prompt` must be computed
* by performing an effectful computation, such as reading data from the file
* system. A `Prompt` runs as a render loop: `render` returns ANSI output for
* the current frame, the `Terminal` obtains user input, `process` returns the
* next prompt action, and `clear` returns ANSI output used to clear the previous
* frame.
*
* Optionally, an external `events` dequeue can be provided as the third
* argument. When present, the render loop will race user input against events
* from the dequeue, allowing background events to trigger re-renders without
* waiting for a keypress. When an event is received from the dequeue, the
* `receive` handler is called instead of `process`.
*
* @category constructors
* @since 4.0.0
*/
<State, Output>(
initialState: State | Effect.Effect<State, never, Environment>,
handlers: Handlers<State, Output>
): Prompt<Output>
/**
* Creates a custom `Prompt` from the specified initial state and handlers.
*
* **Details**
*
* The initial state can either be a pure value or an `Effect`. This is
* particularly useful when the initial state of the `Prompt` must be computed
* by performing an effectful computation, such as reading data from the file
* system. A `Prompt` runs as a render loop: `render` returns ANSI output for
* the current frame, the `Terminal` obtains user input, `process` returns the
* next prompt action, and `clear` returns ANSI output used to clear the previous
* frame.
*
* Optionally, an external `events` dequeue can be provided as the third
* argument. When present, the render loop will race user input against events
* from the dequeue, allowing background events to trigger re-renders without
* waiting for a keypress. When an event is received from the dequeue, the
* `receive` handler is called instead of `process`.
*
* @category constructors
* @since 4.0.0
*/
<State, Output, A>(
initialState: State | Effect.Effect<State, never, Environment>,
events: Queue.Dequeue<A, never>,
handlers: Handlers<State, Output, ProcessInput<A>>
): Prompt<Output>
} = <State, Output, A>(
initialState: State | Effect.Effect<State, never, Environment>,
...args:
| [handlers: Handlers<State, Output, Terminal.UserInput>]
| [events: Queue.Dequeue<A, never>, handlers: Handlers<State, Output, ProcessInput<A>>]
): Prompt<Output> => {
const [events, handlers] = args.length === 1
? [undefined, args[0]] as const
: [args[0], args[1]] as const
const op = Object.create(proto)
op._tag = "Loop"
op.initialState = initialState
op.render = handlers.render
op.process = handlers.process
op.clear = handlers.clear
op.events = events
return op
}
/**
* Creates a date prompt that lets the user edit a formatted date value and
* validates the final `Date` before submission.
*
* **Details**
*
* `initial` defaults to the current `Date`, `dateMask` defaults to
* `YYYY-MM-DD HH:mm:ss`, mask parsing creates editable date parts plus literal
* tokens, `locales` customizes month and weekday labels, and `validate` runs on
* submission.
*
* **Gotchas**
*
* A supplied `initial` `Date` is edited in place during prompt interaction.
* Date edits use JavaScript `Date` setters, so out-of-range typed values can
* normalize before validation. If the prompt is meant to be editable,
* `dateMask` should contain at least one editable date token.
*
* @category constructors
* @since 4.0.0
*/
export const date = (options: DateOptions): Prompt<Date> => {
const opts: Required<DateOptions> = {
initial: new Date(),
dateMask: "YYYY-MM-DD HH:mm:ss",
validate: Effect.succeed,
...options,
locales: {
...defaultLocales,
...options.locales
}
}
const dateParts = makeDateParts(opts.dateMask, opts.initial, opts.locales)
const initialCursorPosition = dateParts.findIndex((part) => !part.isToken())
const initialState: DateState = {
dateParts,
typed: "",
cursor: initialCursorPosition,
value: opts.initial,
error: Option.none()
}
return custom(initialState, {
render: handleDateRender(opts),
process: handleDateProcess(opts),
clear: handleDateClear(opts)
})
}
/**
* Creates a file-system selection prompt and returns the selected path.
*
* **Details**
*
* The prompt can be configured to select files, directories, or either path
* type.
*
* @category constructors
* @since 4.0.0
*/
export const file = (options: FileOptions = {}): Prompt<string> => {
const opts: FileOptionsReq = {
type: options.type ?? "file",
message: options.message ?? `Choose a file`,
startingPath: Option.fromUndefinedOr(options.startingPath),
default: Option.fromUndefinedOr(options.default),
maxPerPage: options.maxPerPage ?? 10,
filter: options.filter ?? (() => Effect.succeed(true))
}
const initialState: Effect.Effect<
FileState,
never,
Environment
> = Effect.gen(function*() {
const currentPath = yield* resolveCurrentPath(Option.none(), opts)
const path = yield* Path.Path
const defaultPath = Option.map(opts.default, (defaultValue) => path.resolve(currentPath, defaultValue))
const initialPath = Option.match(defaultPath, {
onNone: () => currentPath,
onSome: (defaultPath) => path.dirname(defaultPath)
})
const files = yield* getFileList(initialPath, opts)
const cursor = Option.match(defaultPath, {
onNone: () => 0,
onSome: (defaultPath) => {
const index = files.indexOf(path.basename(defaultPath))
return index === -1 ? 0 : index
}
})
const confirm = Confirm.Hide()
return { cursor, files, allFiles: files, query: "", path: Option.map(defaultPath, path.dirname), confirm }
})
return custom(initialState, {
render: handleFileRender(opts),
process: handleFileProcess(opts),
clear: handleFileClear(opts)
})
}
/**
* Composes prompts by using the output of this prompt to create the next prompt.
*
* @category combinators
* @since 4.0.0
*/
export const flatMap: {
/**
* Composes prompts by using the output of this prompt to create the next prompt.
*
* @category combinators
* @since 4.0.0
*/
<Output, Output2>(f: (output: Output) => Prompt<Output2>): (self: Prompt<Output>) => Prompt<Output2>
/**
* Composes prompts by using the output of this prompt to create the next prompt.
*
* @category combinators
* @since 4.0.0
*/
<Output, Output2>(self: Prompt<Output>, f: (output: Output) => Prompt<Output2>): Prompt<Output2>
} = dual(2, <Output, Output2>(
self: Prompt<Output>,
f: (output: Output) => Prompt<Output2>
) => {
const op = Object.create(proto)
op._tag = "OnSuccess"
op.prompt = self
op.onSuccess = f
return op
})
/**
* Creates a floating-point number prompt.
*
* **Details**
*
* The prompt supports minimum and maximum bounds, keyboard step sizes, display
* precision, and additional validation before submission.
*
* @category constructors
* @since 4.0.0
*/
export const float = (options: FloatOptions): Prompt<number> => {
const opts: FloatOptionsReq = {
default: 0,
min: Number.NEGATIVE_INFINITY,
max: Number.POSITIVE_INFINITY,
incrementBy: 1,
decrementBy: 1,
precision: 2,
validate: (n) => {
if (n < opts.min) {
return Effect.fail(`${n} must be greater than or equal to ${opts.min}`)
}
if (n > opts.max) {
return Effect.fail(`${n} must be less than or equal to ${opts.max}`)
}
return Effect.succeed(n)
},
...options
}
const initialValue = options.default === undefined ? "" : `${opts.default}`
const initialState: NumberState = {
cursor: initialValue.length,
value: initialValue,
error: Option.none()
}
return custom(initialState, {
render: handleRenderFloat(opts),
process: handleProcessFloat(opts),
clear: handleNumberClear(opts)
})
}
/**
* Creates a text prompt that does not echo typed input and returns the
* submitted value wrapped in `Redacted`.
*
* @category constructors
* @since 4.0.0
*/
export const hidden = (
options: TextOptions
): Prompt<Redacted.Redacted> => basePrompt(options, "hidden").pipe(map(Redacted.make))
/**
* Creates an integer prompt.
*
* **Details**
*
* The prompt supports minimum and maximum bounds, keyboard step sizes, and
* additional validation before submission.
*
* @category constructors
* @since 4.0.0
*/
export const integer = (options: IntegerOptions): Prompt<number> => {
const opts: IntegerOptionsReq = {
default: 0,
min: Number.NEGATIVE_INFINITY,
max: Number.POSITIVE_INFINITY,
incrementBy: 1,
decrementBy: 1,
validate: (n) => {
if (n < opts.min) {
return Effect.fail(`${n} must be greater than or equal to ${opts.min}`)
}
if (n > opts.max) {
return Effect.fail(`${n} must be less than or equal to ${opts.max}`)
}
return Effect.succeed(n)
},
...options
}
const initialValue = options.default === undefined ? "" : `${opts.default}`
const initialState: NumberState = {
cursor: initialValue.length,
value: initialValue,
error: Option.none()
}
return custom(initialState, {
render: handleRenderInteger(opts),
process: handleProcessInteger(opts),
clear: handleNumberClear(opts)
})
}
/**
* Creates a text prompt that returns an array of strings by splitting the
* submitted input on the configured delimiter.
*
* @category constructors
* @since 4.0.0
*/
export const list = (options: ListOptions): Prompt<Array<string>> =>
text(options).pipe(
map((output) => output.split(options.delimiter || ","))
)
/**
* Transforms the output value produced by a prompt.
*
* @category combinators
* @since 4.0.0
*/
export const map: {
/**
* Transforms the output value produced by a prompt.
*
* @category combinators
* @since 4.0.0
*/
<Output, Output2>(f: (output: Output) => Output2): (self: Prompt<Output>) => Prompt<Output2>
/**
* Transforms the output value produced by a prompt.
*
* @category combinators
* @since 4.0.0
*/
<Output, Output2>(self: Prompt<Output>, f: (output: Output) => Output2): Prompt<Output2>
} = dual(2, <Output, Output2>(
self: Prompt<Output>,
f: (output: Output) => Output2
) => flatMap(self, (a) => succeed(f(a))))
/**
* Creates a password prompt that masks typed input and returns the submitted
* value wrapped in `Redacted`.
*
* @category constructors
* @since 4.0.0
*/
export const password = (
options: TextOptions
): Prompt<Redacted.Redacted> => basePrompt(options, "password").pipe(map(Redacted.make))
/**
* Runs a prompt by reading terminal input and rendering prompt frames until the
* prompt submits a value.
*
* **Gotchas**
*
* The returned effect may fail with `Terminal.QuitError` if terminal input ends
* or the prompt is quit.
*
* @category execution
* @since 4.0.0
*/
export const run: <Output>(
self: Prompt<Output>
) => Effect.Effect<
Output,
Terminal.QuitError,
Environment
> = Effect.fnUntraced(
function*<Output>(self: Prompt<Output>) {
const terminal = yield* Terminal.Terminal
const input = yield* terminal.readInput
return yield* runWithInput(self, terminal, input)
},
Effect.mapError(() => new Terminal.QuitError({})),
Effect.scoped
)
const getSelectInitialIndex = <A>(choices: ReadonlyArray<SelectChoice<A>>): number => {
let initialIndex = 0
let seenSelected = -1
for (let i = 0; i < choices.length; i++) {
const choice = choices[i] as SelectChoice<A>
if (choice.selected === true) {
if (seenSelected !== -1) {
throw new Error("InvalidArgumentException: only a single choice can be selected by default for Prompt.select")
}
seenSelected = i
}
}
if (seenSelected !== -1) {
initialIndex = seenSelected
}
return initialIndex
}
/**
* Creates a prompt that lets the user select a single value from a list of
* choices.
*
* **Gotchas**
*
* At most one choice may be marked as selected by default.
*
* @category constructors
* @since 4.0.0
*/
export const select = <const A>(options: SelectOptions<A>): Prompt<A> => {
const opts: SelectOptionsReq<A> = {
maxPerPage: 10,
...options
}
const initialIndex = getSelectInitialIndex(opts.choices)
return custom(initialIndex, {
render: handleSelectRender(opts),
process: handleSelectProcess(opts),
clear: handleSelectClear(opts)
})
}
/**
* Creates a prompt that lets users filter select choices by typing.
*
* **Example** (Filtering choices with autocomplete)
*
* ```ts
* import { Prompt } from "effect/unstable/cli"
*
* const language = Prompt.autoComplete({
* message: "Choose a language",
* choices: [
* { title: "TypeScript", value: "ts" },
* { title: "Rust", value: "rs" },
* { title: "Kotlin", value: "kt" }
* ]
* })
* ```
*
* @category constructors
* @since 4.0.0
*/
export const autoComplete = <const A>(options: AutoCompleteOptions<A>): Prompt<A> => {
const opts: AutoCompleteOptionsReq<A> = {
maxPerPage: 10,
filterLabel: "filter",
filterPlaceholder: "type to filter",
emptyMessage: "No matches",
...options
}
const initialIndex = getSelectInitialIndex(opts.choices)
const filtered = filterAutoCompleteChoices(opts.choices, "")
const index = filtered.length === 0
? 0
: filtered.includes(initialIndex)
? initialIndex
: filtered[0]
const initialState: AutoCompleteState = {
query: "",
index,
filtered
}
return custom(initialState, {
render: handleAutoCompleteRender(opts),
process: handleAutoCompleteProcess(opts),
clear: handleAutoCompleteClear(opts)
})
}
/**
* Creates a prompt that lets the user select multiple choices and returns their
* values as an array.
*
* **Details**
*
* The prompt supports default selected choices, bulk-selection commands, and
* minimum or maximum selection counts.
*
* @category constructors
* @since 4.0.0
*/
export const multiSelect = <const A>(
options: SelectOptions<A> & MultiSelectOptions
): Prompt<Array<A>> => {
const opts: SelectOptionsReq<A> & MultiSelectOptionsReq = {
maxPerPage: 10,
...options
}
// Seed initial selection from choices marked as selected: true
const initialSelected = new Set<number>()
for (let i = 0; i < opts.choices.length; i++) {
const choice = opts.choices[i] as SelectChoice<A>
if (choice.selected === true) {
initialSelected.add(i)
}
}
const initialState: MultiSelectState = { index: 0, selectedIndices: initialSelected, error: Option.none() }
return custom(initialState, {
render: handleMultiSelectRender(opts),
process: handleMultiSelectProcess(opts),
clear: handleMultiSelectClear(opts)
})
}
/**
* Creates a `Prompt` which immediately succeeds with the specified value.
*
* **Details**
*
* This prompt does not attempt to obtain user input or render anything to the
* screen.
*
* @category constructors
* @since 4.0.0
*/
export const succeed = <A>(value: A): Prompt<A> => {
const op = Object.create(proto)
op._tag = "Succeed"
op.value = value
return op
}
/**
* Creates a text-entry prompt that echoes input and returns the submitted
* string after validation.
*
* @category constructors
* @since 4.0.0
*/
export const text = (
options: TextOptions
): Prompt<string> => basePrompt(options, "text")
/**
* Creates a toggle prompt that lets the user switch between active and inactive
* states and returns the selected boolean value.
*
* @category constructors
* @since 4.0.0
*/
export const toggle = (options: ToggleOptions): Prompt<boolean> => {
const opts: ToggleOptionsReq = {
initial: false,
active: "on",
inactive: "off",
...options
}
return custom(opts.initial, {
render: handleToggleRender(opts),
process: handleToggleProcess,
clear: () => handleToggleClear(opts)
})
}
const proto = {
...Effectable.Prototype<Prompt<any>>({
label: "Prompt",
evaluate() {
return run(this)
}
}),
[TypeId]: {
_Output: (_: never) => _
}
}
type Op<Tag extends string, Body = {}> = Prompt<never> & Body & {
readonly _tag: Tag
}
type PromptPrimitive = Loop | OnSuccess | Succeed
interface Loop extends
Op<"Loop", {
readonly initialState: unknown | Effect.Effect<unknown, never, Environment>
readonly render: Handlers<unknown, unknown>["render"]
readonly process: (
input: unknown,
state: unknown
) => Effect.Effect<Action<unknown, unknown>, never, Environment>
readonly clear: Handlers<unknown, unknown>["clear"]
readonly events: Queue.Dequeue<unknown, never> | undefined
}>
{}
/** @internal */
export interface OnSuccess extends
Op<"OnSuccess", {
readonly prompt: PromptPrimitive
readonly onSuccess: (value: unknown) => Prompt<unknown>
}>
{}
interface Succeed extends
Op<"Succeed", {
readonly value: unknown
}>
{}
const allTupled = <const T extends ArrayLike<Prompt<any>>>(arg: T): Prompt<
{
[K in keyof T]: [T[K]] extends [Prompt<infer A>] ? A : never
}
> => {
if (arg.length === 0) {
return succeed([]) as any
}
if (arg.length === 1) {
return map(arg[0], (x) => [x]) as any
}
let result = map(arg[0], (x) => [x])
for (let i = 1; i < arg.length; i++) {
const curr = arg[i]
result = flatMap(result, (tuple) => map(curr, (a) => [...tuple, a]))
}
return result as any
}
const runWithInput = <Output>(
prompt: Prompt<Output>,
terminal: Terminal.Terminal,
input: Queue.Dequeue<Terminal.UserInput, Cause.Done>
): Effect.Effect<Output, NoSuchElementError, Environment> =>
Effect.suspend(() => {
const op = prompt as PromptPrimitive
switch (op._tag) {
case "Loop": {
return runLoop(op, terminal, input)
}
case "OnSuccess": {
return Effect.flatMap(
runWithInput(op.prompt, terminal, input),
(a) => runWithInput(op.onSuccess(a), terminal, input)
) as any
}
case "Succeed": {
return Effect.succeed(op.value)
}
}
})
const runLoop = Effect.fnUntraced(
function*(
loop: Loop,
terminal: Terminal.Terminal,
input: Queue.Dequeue<Terminal.UserInput, Cause.Done>
) {
let state = Effect.isEffect(loop.initialState) ? yield* loop.initialState : loop.initialState
let action: Action<unknown, unknown> = Action.NextFrame({ state })
while (true) {
const msg = yield* loop.render(state, action)
yield* Effect.orDie(terminal.display(msg))
if (loop.events) {
const takeInput = Queue.take(input).pipe(
Effect.map((event) => ({ _tag: "Input" as const, event }))
)
const result = yield* Effect.raceFirst(
takeInput,
Queue.take(loop.events).pipe(Effect.map((value) => ({ _tag: "Event" as const, value })))
)
action = yield* loop.process(result, state)
} else {
const result = yield* Queue.take(input)
action = yield* loop.process(result, state)
}
switch (action._tag) {
case "Beep":
continue
case "NextFrame": {
yield* Effect.orDie(terminal.display(yield* loop.clear(state, action)))
state = action.state
continue
}
case "Submit": {
yield* Effect.orDie(terminal.display(yield* loop.clear(state, action)))
const msg = yield* loop.render(state, action)
yield* Effect.orDie(terminal.display(msg))
return action.value
}
}
}
},
(effect, _, terminal) => Effect.ensuring(effect, Effect.orDie(terminal.display(Ansi.cursorShow)))
)
const Action = Data.taggedEnum<ActionDefinition>()
/**
* Clears all lines taken up by the specified `text`.
*/
const eraseText = (text: string, columns: number): string => {
if (columns === 0) {
return Ansi.eraseLine + Ansi.cursorTo(0)
}
let rows = 0
const lines = text.split(NEWLINE_REGEXP)
for (const line of lines) {
rows += 1 + Math.floor(Math.max(line.length - 1, 0) / columns)
}
return Ansi.eraseLines(rows)
}
const lines = (prompt: string, columns: number): number => {
const lines = prompt.split(NEWLINE_REGEXP)
return columns === 0
? lines.length
: pipe(
Arr.map(lines, (line) => Math.ceil(line.length / columns)),
Arr.reduce(0, (left, right) => left + right)
)
}
const clearOutputWithError = (outputText: string, columns: number, errorText?: string): string => {
if (errorText !== undefined && errorText.length > 0) {
return Ansi.cursorDown(lines(errorText, columns))
+ eraseText(`\n${errorText}`, columns)
+ eraseText(outputText, columns)
}
return eraseText(outputText, columns)
}
interface ConfirmOptionsReq extends Required<ConfirmOptions> {}
interface ConfirmState {
readonly value: boolean
}
const renderBeep = Ansi.beep
const NEWLINE_REGEXP = /\r?\n/
const handleConfirmClear = (options: ConfirmOptionsReq) => {
return Effect.fnUntraced(function*(state: ConfirmState, _: Action<ConfirmState, boolean>) {
const terminal = yield* Terminal.Terminal
const columns = yield* terminal.columns
const figures = yield* platformFigures
const confirmMessage = state.value
? options.placeholder.defaultConfirm!
: options.placeholder.defaultDeny!
const promptText = renderConfirmOutput(
confirmMessage,
"?",
figures.pointerSmall,
options,
{ plain: true }
)
const clearOutput = eraseText(promptText, columns)
const resetCurrentLine = Ansi.eraseLine + Ansi.cursorLeft
return clearOutput + resetCurrentLine
})
}
const renderConfirmOutput = (
confirm: string,
leadingSymbol: string,
trailingSymbol: string,
options: ConfirmOptionsReq,
renderOptions?: RenderOptions | undefined
) => renderPrompt(confirm, options.message, leadingSymbol, trailingSymbol, renderOptions)
const renderConfirmNextFrame = Effect.fnUntraced(function*(state: ConfirmState, options: ConfirmOptionsReq) {
const figures = yield* platformFigures
const leadingSymbol = Ansi.annotate("?", Ansi.cyanBright)
const trailingSymbol = Ansi.annotate(figures.pointerSmall, Ansi.blackBright)
// Marking these explicitly as present with `!` because they always will be
// and there is really no value in adding a `DeepRequired` type helper just
// for these internal cases
const confirmMessage = state.value
? options.placeholder.defaultConfirm!
: options.placeholder.defaultDeny!
const confirm = Ansi.annotate(confirmMessage, Ansi.blackBright)
const promptMsg = renderConfirmOutput(confirm, leadingSymbol, trailingSymbol, options)
return Ansi.cursorHide + promptMsg
})
const renderConfirmSubmission = Effect.fnUntraced(function*(value: boolean, options: ConfirmOptionsReq) {
const figures = yield* platformFigures
const leadingSymbol = Ansi.annotate(figures.tick, Ansi.green)
const trailingSymbol = Ansi.annotate(figures.ellipsis, Ansi.blackBright)
const confirmMessage = value ? options.label.confirm : options.label.deny
const promptMsg = renderConfirmOutput(confirmMessage, leadingSymbol, trailingSymbol, options)
return promptMsg + "\n"
})
const handleConfirmRender = (options: ConfirmOptionsReq) => {
return (_: ConfirmState, action: Action<ConfirmState, boolean>) => {
return Action.$match(action, {
Beep: () => Effect.succeed(renderBeep),
NextFrame: ({ state }) => renderConfirmNextFrame(state, options),
Submit: ({ value }) => renderConfirmSubmission(value, options)
})
}
}
const TRUE_VALUE_REGEXP = /^y|t$/
const FALSE_VALUE_REGEXP = /^n|f$/
const handleConfirmProcess = (input: Terminal.UserInput, defaultValue: boolean) => {
const value = Option.getOrElse(input.input, () => "")
if (input.key.name === "enter" || input.key.name === "return") {
return Effect.succeed(Action.Submit({ value: defaultValue }))
}
if (TRUE_VALUE_REGEXP.test(value.toLowerCase())) {
return Effect.succeed(Action.Submit({ value: true }))
}
if (FALSE_VALUE_REGEXP.test(value.toLowerCase())) {
return Effect.succeed(Action.Submit({ value: false }))
}
return Effect.succeed(Action.Beep())
}
interface DateOptionsReq extends Required<DateOptions> {}
interface DateState {
readonly typed: string
readonly cursor: number
readonly value: globalThis.Date
readonly dateParts: ReadonlyArray<DatePart>
readonly error: Option.Option<string>
}
const handleDateClear = (options: DateOptionsReq) => {
return Effect.fnUntraced(function*(state: DateState, _: Action<DateState, globalThis.Date>) {
const terminal = yield* Terminal.Terminal
const columns = yield* terminal.columns
const figures = yield* platformFigures
const resetCurrentLine = Ansi.eraseLine + Ansi.cursorLeft
const parts = Arr.reduce(state.dateParts, "", (doc, part) => doc + part.toString())
const promptText = renderDateOutput("?", figures.pointerSmall, parts, options, { plain: true })
const errorText = Option.isSome(state.error)
? Arr.match(state.error.value.split(NEWLINE_REGEXP), {
onEmpty: () => "",
onNonEmpty: (errorLines) => `${figures.pointerSmall} ${errorLines.join("\n")}`
})
: ""
const clearOutput = clearOutputWithError(promptText, columns, errorText)
return clearOutput + resetCurrentLine
})
}
const renderDateError = (state: DateState, pointer: string): string => {
if (Option.isSome(state.error)) {
const errorLines = state.error.value.split(NEWLINE_REGEXP)
if (Arr.isReadonlyArrayNonEmpty(errorLines)) {
const prefix = Ansi.annotate(pointer, Ansi.red) + " "
const lines = Arr.map(errorLines, (str) => annotateErrorLine(str))
return Ansi.cursorSavePosition + "\n" + prefix + lines.join("\n") + Ansi.cursorRestorePosition
}
}
return ""
}
const renderParts = (state: DateState, submitted: boolean = false) => {
return Arr.reduce(
state.dateParts,
"",
(doc, part, currentIndex) => {
const partDoc = part.toString()
if (currentIndex === state.cursor && !submitted) {
const annotation = Ansi.combine(Ansi.underlined, Ansi.cyanBright)
return doc + Ansi.annotate(partDoc, annotation)
}
return doc + partDoc
}
)
}
const renderDateOutput = (
leadingSymbol: string,
trailingSymbol: string,
parts: string,
options: DateOptionsReq,
renderOptions?: RenderOptions | undefined
) => renderPrompt(parts, options.message, leadingSymbol, trailingSymbol, renderOptions)
const renderDateNextFrame = Effect.fnUntraced(function*(state: DateState, options: DateOptionsReq) {
const figures = yield* platformFigures
const leadingSymbol = Ansi.annotate("?", Ansi.cyanBright)
const trailingSymbol = Ansi.annotate(figures.pointerSmall, Ansi.blackBright)
const parts = renderParts(state)
const promptMsg = renderDateOutput(leadingSymbol, trailingSymbol, parts, options)
const errorMsg = renderDateError(state, figures.pointerSmall)
return Ansi.cursorHide + promptMsg + errorMsg
})
const renderDateSubmission = Effect.fnUntraced(function*(state: DateState, options: DateOptionsReq) {
const figures = yield* platformFigures
const leadingSymbol = Ansi.annotate(figures.tick, Ansi.green)
const trailingSymbol = Ansi.annotate(figures.ellipsis, Ansi.blackBright)
const parts = renderParts(state, true)
const promptMsg = renderDateOutput(leadingSymbol, trailingSymbol, parts, options)
return promptMsg + "\n"
})
const processUp = (state: DateState) => {
state.dateParts[state.cursor].increment()
return Action.NextFrame({
state: { ...state, typed: "" }
})
}
const processDown = (state: DateState) => {
state.dateParts[state.cursor].decrement()
return Action.NextFrame({
state: { ...state, typed: "" }
})
}
const processDateCursorLeft = (state: DateState) => {
const previous = state.dateParts[state.cursor].previousPart()
if (Option.isSome(previous)) {
return Action.NextFrame({
state: {
...state,
typed: "",
cursor: state.dateParts.indexOf(previous.value)
}
})
}
return Action.Beep()
}
const processDateCursorRight = (state: DateState) => {
const next = state.dateParts[state.cursor].nextPart()
if (Option.isSome(next)) {
return Action.NextFrame({
state: {
...state,
typed: "",
cursor: state.dateParts.indexOf(next.value)
}
})
}
return Action.Beep()
}
const processDateNext = (state: DateState) => {
const next = state.dateParts[state.cursor].nextPart()
const cursor = Option.match(next, {
onNone: () => state.dateParts.findIndex((part) => !part.isToken()),
onSome: (next) => state.dateParts.indexOf(next)
})
return Action.NextFrame({
state: { ...state, cursor }
})
}
const defaultDateProcessor = (value: string, state: DateState) => {
if (/\d/.test(value)) {
const typed = state.typed + value
state.dateParts[state.cursor].setValue(typed)
return Action.NextFrame({
state: { ...state, typed }
})
}
return Action.Beep()
}
const defaultLocales: DateOptionsReq["locales"] = {
months: [
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December"
],
monthsShort: ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"],
weekdays: ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"],
weekdaysShort: ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]
}
const handleDateRender = (options: DateOptionsReq) => {
return (state: DateState, action: Action<DateState, globalThis.Date>) => {
return Action.$match(action, {
Beep: () => Effect.succeed(renderBeep),
NextFrame: ({ state }) => renderDateNextFrame(state, options),
Submit: () => renderDateSubmission(state, options)
})
}
}
const handleDateProcess = (options: DateOptionsReq) => {
return (input: Terminal.UserInput, state: DateState) => {
switch (input.key.name) {
case "left": {
return Effect.succeed(processDateCursorLeft(state))
}
case "right": {
return Effect.succeed(processDateCursorRight(state))
}
case "k":
case "up": {
return Effect.succeed(processUp(state))
}
case "j":
case "down": {
return Effect.succeed(processDown(state))
}
case "tab": {
return Effect.succeed(processDateNext(state))
}
case "enter":
case "return": {
return Effect.match(options.validate(state.value), {
onFailure: (error) =>
Action.NextFrame({
state: {
...state,
error: Option.some(error)
}
}),
onSuccess: (value) => Action.Submit({ value })
})
}
default: {
return Effect.succeed(defaultDateProcessor(Option.getOrElse(input.input, () => ""), state))
}
}
}
}
const DATE_PART_REGEXP =
/\\(.)|"((?:\\["\\]|[^"])+)"|(D[Do]?|d{3,4}|d)|(M{1,4})|(YY(?:YY)?)|([aA])|([Hh]{1,2})|(m{1,2})|(s{1,2})|(S{1,4})|./g
const regExpGroups: Record<number, (params: DatePartParams) => DatePart> = {
1: ({ token, ...opts }) => new Token({ token: token.replace(/\\(.)/g, "$1"), ...opts }),
2: (opts) => new Day(opts),
3: (opts) => new Month(opts),
4: (opts) => new Year(opts),
5: (opts) => new Meridiem(opts),
6: (opts) => new Hours(opts),
7: (opts) => new Minutes(opts),
8: (opts) => new Seconds(opts),
9: (opts) => new Milliseconds(opts)
}
const makeDateParts = (
dateMask: string,
date: globalThis.Date,
locales: DateOptions["locales"]
) => {
const parts: Array<DatePart> = []
let result: RegExpExecArray | null = null
// oxlint-disable-next-line no-cond-assign
while (result = DATE_PART_REGEXP.exec(dateMask)) {
const match = result.shift()
const index = result.findIndex((group) => group !== undefined)
if (index in regExpGroups) {
const token = (result[index] || match)!
parts.push(regExpGroups[index]({ token, date, parts, locales }))
} else {
parts.push(new Token({ token: (result[index] || match)!, date, parts, locales }))
}
}
const orderedParts = parts.reduce((array, element) => {
const lastElement = array[array.length - 1]
if (element.isToken() && lastElement !== undefined && lastElement.isToken()) {
lastElement.setValue(element.token)
} else {
array.push(element)
}
return array
}, Arr.empty<DatePart>())
parts.splice(0, parts.length, ...orderedParts)
return parts
}
interface DatePartParams {
readonly token: string
readonly locales: DateOptions["locales"]
readonly date?: globalThis.Date
readonly parts?: ReadonlyArray<DatePart>
}
abstract class DatePart {
token: string
readonly date: globalThis.Date
readonly parts: ReadonlyArray<DatePart>
readonly locales: DateOptions["locales"]
constructor(params: DatePartParams) {
this.token = params.token
this.locales = params.locales
this.date = params.date || new Date()
this.parts = params.parts || [this]
}
/**
* Increments this date part.
*/
abstract increment(): void
/**
* Decrements this date part.
*/
abstract decrement(): void
/**
* Sets the current value of this date part to the provided value.
*/
abstract setValue(value: string): void
/**
* Returns `true` if this `DatePart` is a `Token`, `false` otherwise.
*/
isToken(): this is Token {
return false
}
/**
* Retrieves the next date part in the list of parts.
*/
nextPart(): Option.Option<DatePart> {
const currentPartIndex = Option.getOrElse(Arr.findFirstIndex(this.parts, (part) => part === this), () => 0)
return Arr.findFirst(this.parts.slice(currentPartIndex + 1), (part) => !part.isToken())
}
/**
* Retrieves the previous date part in the list of parts.
*/
previousPart(): Option.Option<DatePart> {
const currentPartIndex = Arr.findFirstIndex(this.parts, (part) => part === this)
if (Option.isSome(currentPartIndex)) {
return Arr.findLast(this.parts.slice(0, currentPartIndex.value), (part) => !part.isToken())
}
return Option.none()
}
toString() {
return String(this.date)
}
}
class Token extends DatePart {
increment(): void {}
decrement(): void {}
setValue(value: string): void {
this.token = this.token + value
}
override isToken(): this is Token {
return true
}
override toString() {
return this.token
}
}
class Milliseconds extends DatePart {
increment(): void {
this.date.setMilliseconds(this.date.getMilliseconds() + 1)
}
decrement(): void {
this.date.setMilliseconds(this.date.getMilliseconds() - 1)
}
setValue(value: string): void {
this.date.setMilliseconds(Number.parseInt(value.slice(-this.token.length)))
}
override toString() {
const millis = `${this.date.getMilliseconds()}`
return millis.padStart(4, "0").substring(0, this.token.length)
}
}
class Seconds extends DatePart {
increment(): void {
this.date.setSeconds(this.date.getSeconds() + 1)
}
decrement(): void {
this.date.setSeconds(this.date.getSeconds() - 1)
}
setValue(value: string): void {
this.date.setSeconds(Number.parseInt(value.slice(-2)))
}
override toString() {
const seconds = `${this.date.getSeconds()}`
return this.token.length > 1
? seconds.padStart(2, "0")
: seconds
}
}
class Minutes extends DatePart {
increment(): void {
this.date.setMinutes(this.date.getMinutes() + 1)
}
decrement(): void {
this.date.setMinutes(this.date.getMinutes() - 1)
}
setValue(value: string): void {
this.date.setMinutes(Number.parseInt(value.slice(-2)))
}
override toString() {
const minutes = `${this.date.getMinutes()}`
return this.token.length > 1
? minutes.padStart(2, "0") :
minutes
}
}
class Hours extends DatePart {
increment(): void {
this.date.setHours(this.date.getHours() + 1)
}
decrement(): void {
this.date.setHours(this.date.getHours() - 1)
}
setValue(value: string): void {
this.date.setHours(Number.parseInt(value.slice(-2)))
}
override toString() {
const hours = /h/.test(this.token)
? this.date.getHours() % 12 || 12
: this.date.getHours()
return this.token.length > 1
? `${hours}`.padStart(2, "0")
: `${hours}`
}
}
class Day extends DatePart {
increment(): void {
this.date.setDate(this.date.getDate() + 1)
}
decrement(): void {
this.date.setDate(this.date.getDate() - 1)
}
setValue(value: string): void {
this.date.setDate(Number.parseInt(value.slice(-2)))
}
override toString() {
const date = this.date.getDate()
const day = this.date.getDay()
switch (this.token) {
case "DD":
return `${date}`.padStart(2, "0")
case "Do":
return `${date}${this.ordinalIndicator(date)}`
case "d":
return `${day + 1}`
case "ddd":
return this.locales!.weekdaysShort[day]!
case "dddd":
return this.locales!.weekdays[day]!
default:
return `${date}`
}
}
private ordinalIndicator(day: number): string {
switch (day % 10) {
case 1:
return "st"
case 2:
return "nd"
case 3:
return "rd"
default:
return "th"
}
}
}
class Month extends DatePart {
increment(): void {
this.date.setMonth(this.date.getMonth() + 1)
}
decrement(): void {
this.date.setMonth(this.date.getMonth() - 1)
}
setValue(value: string): void {
const month = Number.parseInt(value.slice(-2)) - 1
this.date.setMonth(month < 0 ? 0 : month)
}
override toString() {
const month = this.date.getMonth()
switch (this.token.length) {
case 2:
return `${month + 1}`.padStart(2, "0")
case 3:
return this.locales!.monthsShort[month]!
case 4:
return this.locales!.months[month]!
default:
return `${month + 1}`
}
}
}
class Year extends DatePart {
increment(): void {
this.date.setFullYear(this.date.getFullYear() + 1)
}
decrement(): void {
this.date.setFullYear(this.date.getFullYear() - 1)
}
setValue(value: string): void {
this.date.setFullYear(Number.parseInt(value.slice(-4)))
}
override toString() {
const year = `${this.date.getFullYear()}`.padStart(4, "0")
return this.token.length === 2
? year.substring(-2)
: year
}
}
class Meridiem extends DatePart {
increment(): void {
this.date.setHours((this.date.getHours() + 12) % 24)
}
decrement(): void {
this.increment()
}
setValue(_value: string): void {}
override toString() {
const meridiem = this.date.getHours() > 12 ? "pm" : "am"
return /A/.test(this.token)
? meridiem.toUpperCase()
: meridiem;
}
}
interface FileOptionsReq extends Required<Omit<FileOptions, "startingPath" | "default">> {
readonly startingPath: Option.Option<string>
readonly default: Option.Option<string>
}
interface FileState {
readonly cursor: number
readonly files: ReadonlyArray<string>
readonly allFiles: ReadonlyArray<string>
readonly query: string
readonly path: Option.Option<string>
readonly confirm: Confirm
}
const CONFIRM_MESSAGE = "The selected directory contains files. Would you like to traverse the selected directory?"
const FILE_FILTER_LABEL = "filter"
const FILE_FILTER_PLACEHOLDER = "type to filter"
const FILE_EMPTY_MESSAGE = "No matches"
type Confirm = Data.TaggedEnum<{
readonly Show: {}
readonly Hide: {}
}>
const Confirm = Data.taggedEnum<Confirm>()
const showConfirmation = Confirm.$is("Show")
const resolveCurrentPath = (
path: Option.Option<string>,
options: FileOptionsReq
): Effect.Effect<string, never, FileSystem.FileSystem> => {
if (Option.isSome(path)) {
return Effect.succeed(path.value)
}
if (Option.isSome(options.startingPath)) {
const startingPath = options.startingPath.value
return Effect.flatMap(FileSystem.FileSystem, (fs) =>
// Ensure the user provided starting path exists
Effect.orDie(fs.exists(startingPath)).pipe(
Effect.flatMap((exists) =>
exists ? Effect.void : Effect.die(
`The provided starting path '${startingPath}' does not exist`
)
),
Effect.as(startingPath)
))
}
return Effect.sync(() => process.cwd())
}
const getFileList = Effect.fnUntraced(function*(directory: string, options: FileOptionsReq) {
const fs = yield* FileSystem.FileSystem
const path = yield* Path.Path
const files = yield* Effect.orDie(fs.readDirectory(directory)).pipe(
// Always prepend the `".."` option to the file list but allow it
// to be filtered out if the user so desires
Effect.map((files) => ["..", ...files])
)
return yield* Effect.filter(files, (file) => {
const result = options.filter(file)
const userDefinedFilter = Effect.isEffect(result)
? result
: Effect.succeed(result)
const directoryFilter = options.type === "directory"
? Effect.map(
Effect.orDie(fs.stat(path.join(directory, file))),
(info) => info.type === "Directory"
)
: Effect.succeed(true)
return Effect.zipWith(userDefinedFilter, directoryFilter, (a, b) => a && b)
}, { concurrency: files.length })
})
const filterFiles = (files: ReadonlyArray<string>, query: string) => {
if (query.length === 0) {
return files
}
const normalizedQuery = query.toLowerCase()
const filtered: Array<string> = []
for (let index = 0; index < files.length; index++) {
if (files[index].toLowerCase().includes(normalizedQuery)) {
filtered.push(files[index])
}
}
return filtered
}
const updateFileState = (
state: FileState,
query: string,
allFiles: ReadonlyArray<string> = state.allFiles
): FileState => {
const files = filterFiles(allFiles, query)
if (files.length === 0) {
return { ...state, query, allFiles, files, cursor: 0 }
}
const selected = state.files[state.cursor]
const cursor = selected === undefined ? 0 : files.indexOf(selected)
return {
...state,
query,
allFiles,
files,
cursor: cursor === -1 ? 0 : cursor
}
}
const handleFileClear = (options: FileOptionsReq) => {
return Effect.fnUntraced(function*(state: FileState, _: Action<FileState, string>) {
const terminal = yield* Terminal.Terminal
const columns = yield* terminal.columns
const path = yield* Path.Path
const figures = yield* platformFigures
const currentPath = yield* resolveCurrentPath(state.path, options)
const selectedPath = state.files[state.cursor]
const resolvedPath = selectedPath === undefined ? currentPath : path.resolve(currentPath, selectedPath)
const resolvedPathText = `${figures.pointerSmall} ${resolvedPath}`
const isConfirming = showConfirmation(state.confirm)
const promptText = isConfirming
? renderPrompt("(Y/n)", CONFIRM_MESSAGE, "?", figures.pointerSmall, { plain: true })
: renderPrompt(renderFileFilter(state, { plain: true }), options.message, figures.tick, figures.ellipsis, {
plain: true
})
const filesText = isConfirming
? ""
: renderFiles(state, state.files, figures, options, { plain: true })
const outputText = isConfirming
? `${promptText}\n${resolvedPathText}`
: `${promptText}\n${resolvedPathText}\n${filesText}`
const clearOutput = eraseText(outputText, columns)
const resetCurrentLine = Ansi.eraseLine + Ansi.cursorLeft
return clearOutput + resetCurrentLine
})
}
type RenderOptions = {
readonly plain?: boolean
}
const renderPrompt = (
confirm: string,
message: string,
leadingSymbol: string,
trailingSymbol: string,
options?: RenderOptions | undefined
) => {
const prefix = leadingSymbol + " "
const annotate = options?.plain === true
? (line: string) => line
: annotateLine
return Arr.match(message.split(NEWLINE_REGEXP), {
onEmpty: () => prefix + " " + trailingSymbol + " " + confirm,
onNonEmpty: (promptLines) => {
const lines = Arr.map(promptLines, (line) => annotate(line))
return prefix + lines.join("\n") + " " + trailingSymbol + " " + confirm
}
})
}
const renderPrefix = (
state: FileState,
toDisplay: { readonly startIndex: number; readonly endIndex: number },
currentIndex: number,
length: number,
figures: Effect.Success<typeof platformFigures>,
renderOptions?: RenderOptions | undefined
) => {
let prefix = " "
if (currentIndex === toDisplay.startIndex && toDisplay.startIndex > 0) {
prefix = figures.arrowUp
} else if (currentIndex === toDisplay.endIndex - 1 && toDisplay.endIndex < length) {
prefix = figures.arrowDown
}
if (state.cursor === currentIndex) {
return renderOptions?.plain === true
? figures.pointer + prefix
: Ansi.annotate(figures.pointer, Ansi.cyanBright) + prefix
}
return prefix + " "
}
const renderFileName = (file: string, isSelected: boolean, renderOptions?: RenderOptions | undefined) => {
if (renderOptions?.plain === true) {
return file
}
return isSelected
? Ansi.annotate(file, Ansi.combine(Ansi.underlined, Ansi.cyanBright))
: file
}
const renderFileFilter = (state: FileState, renderOptions?: RenderOptions | undefined) => {
const filterValue = state.query.length === 0
? renderOptions?.plain === true
? FILE_FILTER_PLACEHOLDER
: Ansi.annotate(FILE_FILTER_PLACEHOLDER, Ansi.blackBright)
: renderOptions?.plain === true
? state.query
: Ansi.annotate(state.query, Ansi.combine(Ansi.underlined, Ansi.cyanBright))
return `[${FILE_FILTER_LABEL}: ${filterValue}]`
}
const renderFiles = (
state: FileState,
files: ReadonlyArray<string>,
figures: Effect.Success<typeof platformFigures>,
options: FileOptionsReq,
renderOptions?: RenderOptions | undefined
) => {
const length = files.length
if (length === 0) {
return renderOptions?.plain === true
? FILE_EMPTY_MESSAGE
: Ansi.annotate(FILE_EMPTY_MESSAGE, Ansi.blackBright)
}
const toDisplay = entriesToDisplay(state.cursor, length, options.maxPerPage)
const documents: Array<string> = []
for (let index = toDisplay.startIndex; index < toDisplay.endIndex; index++) {
const isSelected = state.cursor === index
const prefix = renderPrefix(state, toDisplay, index, length, figures, renderOptions)
const fileName = renderFileName(files[index], isSelected, renderOptions)
documents.push(prefix + fileName)
}
return documents.join("\n")
}
const renderFileNextFrame = Effect.fnUntraced(function*(state: FileState, options: FileOptionsReq) {
const path = yield* Path.Path
const figures = yield* platformFigures
const currentPath = yield* resolveCurrentPath(state.path, options)
const selectedPath = state.files[state.cursor]
const resolvedPath = selectedPath === undefined ? currentPath : path.resolve(currentPath, selectedPath)
const resolvedPathMsg = Ansi.annotate(figures.pointerSmall + " " + resolvedPath, Ansi.blackBright)
if (showConfirmation(state.confirm)) {
const leadingSymbol = Ansi.annotate("?", Ansi.cyanBright)
const trailingSymbol = Ansi.annotate(figures.pointerSmall, Ansi.blackBright)
const confirm = Ansi.annotate("(Y/n)", Ansi.blackBright)
const promptMsg = renderPrompt(confirm, CONFIRM_MESSAGE, leadingSymbol, trailingSymbol)
return Ansi.cursorHide + promptMsg + "\n" + resolvedPathMsg
}
const leadingSymbol = Ansi.annotate(figures.tick, Ansi.green)
const trailingSymbol = Ansi.annotate(figures.ellipsis, Ansi.blackBright)
const promptMsg = renderPrompt(renderFileFilter(state), options.message, leadingSymbol, trailingSymbol)
const files = renderFiles(state, state.files, figures, options)
return Ansi.cursorHide + promptMsg + "\n" + resolvedPathMsg + "\n" + files
})
const renderFileSubmission = Effect.fnUntraced(function*(state: FileState, value: string, options: FileOptionsReq) {
const figures = yield* platformFigures
const leadingSymbol = Ansi.annotate(figures.tick, Ansi.green)
const trailingSymbol = Ansi.annotate(figures.ellipsis, Ansi.blackBright)
const promptMsg = renderPrompt(renderFileFilter(state), options.message, leadingSymbol, trailingSymbol)
return promptMsg + " " + Ansi.annotate(value, Ansi.white) + "\n"
})
const handleFileRender = (options: FileOptionsReq) => {
return (
state: FileState,
action: Action<FileState, string>
): Effect.Effect<string, never, Path.Path | FileSystem.FileSystem> => {
return Action.$match(action, {
Beep: () => Effect.succeed(renderBeep),
NextFrame: ({ state }) => renderFileNextFrame(state, options),
Submit: ({ value }) => renderFileSubmission(state, value, options)
})
}
}
const processFileCursorUp = (state: FileState) => {
if (state.files.length === 0) {
return Effect.succeed(Action.Beep())
}
const cursor = state.cursor - 1
return Effect.succeed(Action.NextFrame({
state: { ...state, cursor: cursor < 0 ? state.files.length - 1 : cursor }
}))
}
const processFileCursorDown = (state: FileState) => {
if (state.files.length === 0) {
return Effect.succeed(Action.Beep())
}
return Effect.succeed(Action.NextFrame({
state: { ...state, cursor: (state.cursor + 1) % state.files.length }
}))
}
const processFileBackspace = (state: FileState) => {
if (state.query.length === 0) {
return Effect.succeed(Action.Beep())
}
const query = state.query.slice(0, state.query.length - 1)
return Effect.succeed(Action.NextFrame({ state: updateFileState(state, query) }))
}
const processFileClear = (state: FileState) => Effect.succeed(Action.NextFrame({ state: updateFileState(state, "") }))
const processFileInput = (input: string, state: FileState) => {
if (input.length === 0) {
return Effect.succeed(Action.Beep())
}
const query = state.query + input
return Effect.succeed(Action.NextFrame({ state: updateFileState(state, query) }))
}
const processSelection = Effect.fnUntraced(function*(state: FileState, options: FileOptionsReq) {
if (state.files.length === 0) {
return Action.Beep()
}
const fs = yield* FileSystem.FileSystem
const path = yield* Path.Path
const currentPath = yield* resolveCurrentPath(state.path, options)
const selectedPath = state.files[state.cursor]
const resolvedPath = path.resolve(currentPath, selectedPath)
const info = yield* Effect.orDie(fs.stat(resolvedPath))
if (info.type === "Directory") {
const files = yield* getFileList(resolvedPath, options)
const filesWithoutParent = files.filter((file) => file !== "..")
// If the user selected a directory AND the prompt type can result with
// a directory, we must confirm:
// - If the selected directory has any files
// - Confirm whether or not the user wants to traverse those files
if (options.type === "directory" || options.type === "either") {
return filesWithoutParent.length === 0
// Directory is empty so it's safe to select it
? Action.Submit({ value: resolvedPath })
// Directory has contents - show confirmation to user
: Action.NextFrame({
state: { ...state, confirm: Confirm.Show() }
})
}
return Action.NextFrame({
state: {
cursor: 0,
files,
allFiles: files,
query: "",
path: Option.some(resolvedPath),
confirm: Confirm.Hide()
}
})
}
return Action.Submit({ value: resolvedPath })
})
const handleFileProcess = (options: FileOptionsReq) => {
return Effect.fnUntraced(function*(input: Terminal.UserInput, state: FileState) {
if (input.key.ctrl) {
if (input.key.name === "u") {
if (showConfirmation(state.confirm)) {
return Action.Beep()
}
return yield* processFileClear(state)
}
return Action.Beep()
}
switch (input.key.name) {
case "k":
case "up": {
return yield* processFileCursorUp(state)
}
case "j":
case "down":
case "tab": {
return yield* processFileCursorDown(state)
}
case "backspace": {
if (showConfirmation(state.confirm)) {
return Action.Beep()
}
return yield* processFileBackspace(state)
}
case "enter":
case "return": {
return yield* processSelection(state, options)
}
case "y":
case "t": {
if (showConfirmation(state.confirm)) {
const path = yield* Path.Path
const currentPath = yield* resolveCurrentPath(state.path, options)
const selectedPath = state.files[state.cursor]
const resolvedPath = path.resolve(currentPath, selectedPath)
const files = yield* getFileList(resolvedPath, options)
return Action.NextFrame({
state: {
cursor: 0,
files,
allFiles: files,
query: "",
path: Option.some(resolvedPath),
confirm: Confirm.Hide()
}
})
}
return yield* processFileInput(Option.getOrElse(input.input, () => ""), state)
}
case "n":
case "f": {
if (showConfirmation(state.confirm)) {
const path = yield* Path.Path
const currentPath = yield* resolveCurrentPath(state.path, options)
const selectedPath = state.files[state.cursor]
const resolvedPath = path.resolve(currentPath, selectedPath)
return Action.Submit({ value: resolvedPath })
}
return yield* processFileInput(Option.getOrElse(input.input, () => ""), state)
}
default: {
if (showConfirmation(state.confirm)) {
return Action.Beep()
}
return yield* processFileInput(Option.getOrElse(input.input, () => ""), state)
}
}
})
}
interface SelectOptionsReq<A> extends Required<SelectOptions<A>> {}
interface MultiSelectOptionsReq extends MultiSelectOptions {}
type MultiSelectState = {
index: number
selectedIndices: Set<number>
error: Option.Option<string>
}
const renderMultiSelectError = (
state: MultiSelectState,
pointer: string,
renderOptions?: RenderOptions | undefined
): string => {
if (Option.isSome(state.error)) {
return Arr.match(state.error.value.split(NEWLINE_REGEXP), {
onEmpty: () => "",
onNonEmpty: (errorLines) => {
if (renderOptions?.plain === true) {
return `${pointer} ${errorLines.join("\n")}`
}
const prefix = Ansi.annotate(pointer, Ansi.red) + " "
const lines = Arr.map(errorLines, (str) => annotateErrorLine(str))
return Ansi.cursorSavePosition + "\n" + prefix + lines.join("\n") + Ansi.cursorRestorePosition
}
})
}
return ""
}
const renderChoiceDescription = <A>(
choice: SelectChoice<A>,
isActive: boolean,
renderOptions?: RenderOptions | undefined
) => {
if (!choice.disabled && choice.description && isActive) {
return renderOptions?.plain === true
? "- " + choice.description
: Ansi.annotate("- " + choice.description, Ansi.blackBright)
}
return ""
}
const metaOptionsCount = 2
const renderMultiSelectTitle = (
title: string,
isHighlighted: boolean,
renderOptions?: RenderOptions | undefined
) => {
if (renderOptions?.plain === true || !isHighlighted) {
return title
}
return Ansi.annotate(title, Ansi.combine(Ansi.underlined, Ansi.cyanBright))
}
const renderMultiSelectChoices = <A>(
state: MultiSelectState,
options: SelectOptionsReq<A> & MultiSelectOptionsReq,
figures: Effect.Success<typeof platformFigures>,
renderOptions?: RenderOptions | undefined
) => {
const choices = options.choices
const totalChoices = choices.length
const selectedCount = state.selectedIndices.size
const allSelected = selectedCount === totalChoices
const selectAllText = allSelected
? options?.selectNone ?? "Select None"
: options?.selectAll ?? "Select All"
const inverseSelectionText = options?.inverseSelection ?? "Inverse Selection"
const metaOptions = [
{ title: selectAllText },
{ title: inverseSelectionText }
]
const allChoices = [...metaOptions, ...choices]
const toDisplay = entriesToDisplay(state.index, allChoices.length, options.maxPerPage)
const documents: Array<string> = []
for (let index = toDisplay.startIndex; index < toDisplay.endIndex; index++) {
const choice = allChoices[index]
const isHighlighted = state.index === index
let prefix = " "
if (index === toDisplay.startIndex && toDisplay.startIndex > 0) {
prefix = figures.arrowUp
} else if (index === toDisplay.endIndex - 1 && toDisplay.endIndex < allChoices.length) {
prefix = figures.arrowDown
}
if (index < metaOptions.length) {
// Meta options
const title = renderMultiSelectTitle(choice.title, isHighlighted, renderOptions)
documents.push(prefix + " " + title)
} else {
// Regular choices
const choiceIndex = index - metaOptions.length
const isSelected = state.selectedIndices.has(choiceIndex)
const checkbox = isSelected ? figures.checkboxOn : figures.checkboxOff
const annotatedCheckbox = isHighlighted && renderOptions?.plain !== true
? Ansi.annotate(checkbox, Ansi.cyanBright)
: checkbox
const title = renderMultiSelectTitle(choice.title, isHighlighted, renderOptions)
const description = renderChoiceDescription(choice as SelectChoice<A>, isHighlighted, renderOptions)
documents.push(prefix + " " + annotatedCheckbox + " " + title + " " + description)
}
}
return documents.join("\n")
}
const renderMultiSelectNextFrame = Effect.fnUntraced(
function*<A>(state: MultiSelectState, options: SelectOptionsReq<A>) {
const figures = yield* platformFigures
const choices = renderMultiSelectChoices(state, options, figures)
const leadingSymbol = Ansi.annotate("?", Ansi.cyanBright)
const trailingSymbol = Ansi.annotate(figures.pointerSmall, Ansi.blackBright)
const promptMsg = renderSelectOutput(leadingSymbol, trailingSymbol, options)
const error = renderMultiSelectError(state, figures.pointer)
return Ansi.cursorHide + promptMsg + "\n" + choices + error
}
)
const renderMultiSelectSubmission = Effect.fnUntraced(
function*<A>(state: MultiSelectState, options: SelectOptionsReq<A>) {
const figures = yield* platformFigures
const selectedChoices = Array.from(state.selectedIndices).sort(EffectNumber.Order).map((index) =>
options.choices[index].title
)
const selectedText = selectedChoices.join(", ")
const leadingSymbol = Ansi.annotate(figures.tick, Ansi.green)
const trailingSymbol = Ansi.annotate(figures.ellipsis, Ansi.blackBright)
const promptMsg = renderSelectOutput(leadingSymbol, trailingSymbol, options)
return promptMsg + " " + Ansi.annotate(selectedText, Ansi.white) + "\n"
}
)
const processMultiSelectCursorUp = (state: MultiSelectState, totalChoices: number) => {
const newIndex = state.index === 0 ? totalChoices - 1 : state.index - 1
return Effect.succeed(Action.NextFrame({ state: { ...state, index: newIndex } }))
}
const processMultiSelectCursorDown = (state: MultiSelectState, totalChoices: number) => {
const newIndex = (state.index + 1) % totalChoices
return Effect.succeed(Action.NextFrame({ state: { ...state, index: newIndex } }))
}
const processSpace = <A>(
state: MultiSelectState,
options: SelectOptionsReq<A>
) => {
const selectedIndices = new Set(state.selectedIndices)
if (state.index === 0) {
if (state.selectedIndices.size === options.choices.length) {
selectedIndices.clear()
} else {
for (let i = 0; i < options.choices.length; i++) {
selectedIndices.add(i)
}
}
} else if (state.index === 1) {
for (let i = 0; i < options.choices.length; i++) {
if (state.selectedIndices.has(i)) {
selectedIndices.delete(i)
} else {
selectedIndices.add(i)
}
}
} else {
const choiceIndex = state.index - metaOptionsCount
if (selectedIndices.has(choiceIndex)) {
selectedIndices.delete(choiceIndex)
} else {
selectedIndices.add(choiceIndex)
}
}
return Effect.succeed(Action.NextFrame({ state: { ...state, selectedIndices } }))
}
const handleMultiSelectClear = <A>(options: SelectOptionsReq<A>) =>
Effect.fnUntraced(function*(state: MultiSelectState, _: Action<MultiSelectState, Array<A>>) {
const terminal = yield* Terminal.Terminal
const columns = yield* terminal.columns
const figures = yield* platformFigures
const clearPrompt = Ansi.eraseLine + Ansi.cursorLeft
const promptText = renderSelectOutput("?", figures.pointerSmall, options, { plain: true })
const choicesText = renderMultiSelectChoices(state, options, figures, { plain: true })
const errorText = renderMultiSelectError(state, figures.pointer, { plain: true })
const clearOutput = clearOutputWithError(`${promptText}\n${choicesText}`, columns, errorText)
return clearOutput + clearPrompt
})
const handleMultiSelectProcess = <A>(options: SelectOptionsReq<A> & MultiSelectOptionsReq) => {
return (input: Terminal.UserInput, state: MultiSelectState) => {
const totalChoices = options.choices.length + metaOptionsCount
switch (input.key.name) {
case "k":
case "up": {
return processMultiSelectCursorUp({ ...state, error: Option.none() }, totalChoices)
}
case "j":
case "down":
case "tab": {
return processMultiSelectCursorDown({ ...state, error: Option.none() }, totalChoices)
}
case "space": {
return processSpace(state, options)
}
case "enter":
case "return": {
const selectedCount = state.selectedIndices.size
if (options.min !== undefined && selectedCount < options.min) {
return Effect.succeed(
Action.NextFrame({ state: { ...state, error: Option.some(`At least ${options.min} are required`) } })
)
}
if (options.max !== undefined && selectedCount > options.max) {
return Effect.succeed(
Action.NextFrame({ state: { ...state, error: Option.some(`At most ${options.max} choices are allowed`) } })
)
}
const selectedValues = Array.from(state.selectedIndices).sort(EffectNumber.Order).map((index) =>
options.choices[index].value
)
return Effect.succeed(Action.Submit({ value: selectedValues }))
}
default: {
return Effect.succeed(Action.Beep())
}
}
}
}
const handleMultiSelectRender = <A>(options: SelectOptionsReq<A>) => {
return (state: MultiSelectState, action: Action<MultiSelectState, Array<A>>) => {
return Action.$match(action, {
Beep: () => Effect.succeed(renderBeep),
NextFrame: ({ state }) => renderMultiSelectNextFrame(state, options),
Submit: () => renderMultiSelectSubmission(state, options)
})
}
}
interface IntegerOptionsReq extends Required<IntegerOptions> {}
interface FloatOptionsReq extends Required<FloatOptions> {}
interface NumberState {
readonly cursor: number
readonly value: string
readonly error: Option.Option<string>
}
const handleNumberClear = (options: IntegerOptionsReq) => {
return Effect.fnUntraced(function*(state: NumberState, _: Action<NumberState, number>) {
const terminal = yield* Terminal.Terminal
const columns = yield* terminal.columns
const figures = yield* platformFigures
const resetCurrentLine = Ansi.eraseLine + Ansi.cursorLeft
const errorText = renderNumberError(state, figures.pointerSmall, { plain: true })
const promptText = renderNumberOutput(state, "?", figures.pointerSmall, options, { plain: true })
const clearOutput = clearOutputWithError(promptText, columns, errorText)
return clearOutput + resetCurrentLine
})
}
const renderNumberInput = (
state: NumberState,
submitted: boolean,
renderOptions?: RenderOptions | undefined
): string => {
const value = state.value === "" ? "" : `${state.value}`
if (submitted || renderOptions?.plain === true) {
return value
}
const annotation = Option.isSome(state.error) ?
Ansi.red :
Ansi.combine(Ansi.underlined, Ansi.cyanBright)
return Ansi.annotate(value, annotation)
}
const renderNumberError = (
state: NumberState,
pointer: string,
renderOptions?: RenderOptions | undefined
) => {
if (Option.isSome(state.error)) {
return Arr.match(state.error.value.split(NEWLINE_REGEXP), {
onEmpty: () => "",
onNonEmpty: (errorLines) => {
if (renderOptions?.plain === true) {
return `${pointer} ${errorLines.join("\n")}`
}
const prefix = Ansi.annotate(pointer, Ansi.red) + " "
const lines = Arr.map(errorLines, (str) => annotateErrorLine(str))
return Ansi.cursorSavePosition + "\n" + prefix + lines.join("\n") + Ansi.cursorRestorePosition
}
})
}
return ""
}
const renderNumberOutput = (
state: NumberState,
leadingSymbol: string,
trailingSymbol: string,
options: IntegerOptionsReq,
renderOptions?: RenderOptions | undefined,
submitted: boolean = false
) => {
const value = renderNumberInput(state, submitted, renderOptions)
return renderPrompt(value, options.message, leadingSymbol, trailingSymbol, renderOptions)
}
const renderNumberNextFrame = Effect.fnUntraced(function*(state: NumberState, options: IntegerOptionsReq) {
const figures = yield* platformFigures
const leadingSymbol = Ansi.annotate("?", Ansi.cyanBright)
const trailingSymbol = Ansi.annotate(figures.pointerSmall, Ansi.blackBright)
const errorMsg = renderNumberError(state, figures.pointerSmall)
const promptMsg = renderNumberOutput(state, leadingSymbol, trailingSymbol, options)
return promptMsg + errorMsg
})
const renderNumberSubmission = Effect.fnUntraced(function*(nextState: NumberState, options: IntegerOptionsReq) {
const figures = yield* platformFigures
const leadingSymbol = Ansi.annotate(figures.tick, Ansi.green)
const trailingSymbol = Ansi.annotate(figures.ellipsis, Ansi.blackBright)
const promptMsg = renderNumberOutput(nextState, leadingSymbol, trailingSymbol, options, undefined, true)
return promptMsg + "\n"
})
const processNumberBackspace = (state: NumberState) => {
if (state.value.length <= 0) {
return Effect.succeed(Action.Beep())
}
const value = state.value.slice(0, state.value.length - 1)
return Effect.succeed(Action.NextFrame({
state: { ...state, value, error: Option.none() }
}))
}
const processNumberClear = (state: NumberState) =>
Effect.succeed(Action.NextFrame({
state: { ...state, cursor: 0, value: "", error: Option.none() }
}))
const defaultIntProcessor = (input: string, state: NumberState) => {
if (state.value.length === 0 && input === "-") {
return Effect.succeed(Action.NextFrame({
state: { ...state, value: "-", error: Option.none() }
}))
}
const parsed = Number.parseInt(state.value + input)
if (Number.isNaN(parsed)) {
return Effect.succeed(Action.Beep())
} else {
return Effect.succeed(Action.NextFrame({
state: { ...state, value: `${parsed}`, error: Option.none() }
}))
}
}
const defaultFloatProcessor = (input: string, state: NumberState) => {
if (input === "." && state.value.includes(".")) {
return Effect.succeed(Action.Beep())
}
if (state.value.length === 0 && input === "-") {
return Effect.succeed(Action.NextFrame({
state: { ...state, value: "-", error: Option.none() }
}))
}
const parsed = Number.parseFloat(state.value + input)
if (Number.isNaN(parsed)) {
return Effect.succeed(Action.Beep())
} else {
return Effect.succeed(Action.NextFrame({
state: {
...state,
value: input === "." ? `${parsed}.` : `${parsed}`,
error: Option.none()
}
}))
}
}
const handleRenderInteger = (options: IntegerOptionsReq) => {
return (state: NumberState, action: Action<NumberState, number>) => {
return Action.$match(action, {
Beep: () => Effect.succeed(renderBeep),
NextFrame: ({ state }) => renderNumberNextFrame(state, options),
Submit: () => renderNumberSubmission(state, options)
})
}
}
const handleProcessInteger = (options: IntegerOptionsReq) => {
return (input: Terminal.UserInput, state: NumberState) => {
if (input.key.ctrl && input.key.name === "u") {
return processNumberClear(state)
}
switch (input.key.name) {
case "backspace": {
return processNumberBackspace(state)
}
case "k":
case "up": {
return Effect.succeed(Action.NextFrame({
state: {
...state,
value: state.value === "" || state.value === "-"
? `${options.incrementBy}`
: `${Number.parseInt(state.value) + options.incrementBy}`,
error: Option.none()
}
}))
}
case "j":
case "down": {
return Effect.succeed(Action.NextFrame({
state: {
...state,
value: state.value === "" || state.value === "-"
? `-${options.decrementBy}`
: `${Number.parseInt(state.value) - options.decrementBy}`,
error: Option.none()
}
}))
}
case "enter":
case "return": {
const parsed = Number.parseInt(state.value)
if (Number.isNaN(parsed)) {
return Effect.succeed(Action.NextFrame({
state: {
...state,
error: Option.some("Must provide an integer value")
}
}))
} else {
return Effect.match(options.validate(parsed), {
onFailure: (error) =>
Action.NextFrame({
state: {
...state,
error: Option.some(error)
}
}),
onSuccess: (value) => Action.Submit({ value })
})
}
}
default: {
return defaultIntProcessor(Option.getOrElse(input.input, () => ""), state)
}
}
}
}
const handleRenderFloat = (options: FloatOptionsReq) => {
return (state: NumberState, action: Action<NumberState, number>) => {
return Action.$match(action, {
Beep: () => Effect.succeed(renderBeep),
NextFrame: ({ state }) => renderNumberNextFrame(state, options),
Submit: () => renderNumberSubmission(state, options)
})
}
}
const handleProcessFloat = (options: FloatOptionsReq) => {
return (input: Terminal.UserInput, state: NumberState) => {
if (input.key.ctrl && input.key.name === "u") {
return processNumberClear(state)
}
switch (input.key.name) {
case "backspace": {
return processNumberBackspace(state)
}
case "k":
case "up": {
return Effect.succeed(Action.NextFrame({
state: {
...state,
value: state.value === "" || state.value === "-"
? `${options.incrementBy}`
: `${Number.parseFloat(state.value) + options.incrementBy}`,
error: Option.none()
}
}))
}
case "j":
case "down": {
return Effect.succeed(Action.NextFrame({
state: {
...state,
value: state.value === "" || state.value === "-"
? `-${options.decrementBy}`
: `${Number.parseFloat(state.value) - options.decrementBy}`,
error: Option.none()
}
}))
}
case "enter":
case "return": {
const parsed = Number.parseFloat(state.value)
if (Number.isNaN(parsed)) {
return Effect.succeed(Action.NextFrame({
state: {
...state,
error: Option.some("Must provide a floating point value")
}
}))
} else {
return Effect.flatMap(
Effect.sync(() => EffectNumber.round(parsed, options.precision)),
(rounded) =>
Effect.match(options.validate(rounded), {
onFailure: (error) =>
Action.NextFrame({
state: {
...state,
error: Option.some(error)
}
}),
onSuccess: (value) => Action.Submit({ value })
})
)
}
}
default: {
return defaultFloatProcessor(Option.getOrElse(input.input, () => ""), state)
}
}
}
}
type SelectState = number
type AutoCompleteState = {
readonly query: string
readonly index: number
readonly filtered: ReadonlyArray<number>
}
interface SelectOptionsReq<A> extends Required<SelectOptions<A>> {}
interface AutoCompleteOptionsReq<A> extends Required<AutoCompleteOptions<A>> {}
const filterAutoCompleteChoices = <A>(choices: ReadonlyArray<SelectChoice<A>>, query: string) => {
const normalizedQuery = query.toLowerCase()
const indices: Array<number> = []
for (let i = 0; i < choices.length; i++) {
if (choices[i].title.toLowerCase().includes(normalizedQuery)) {
indices.push(i)
}
}
return indices
}
const updateAutoCompleteState = <A>(
state: AutoCompleteState,
options: AutoCompleteOptionsReq<A>,
query: string
): AutoCompleteState => {
const filtered = filterAutoCompleteChoices(options.choices, query)
if (filtered.length === 0) {
return { ...state, query, filtered, index: 0 }
}
if (filtered.includes(state.index)) {
return { ...state, query, filtered }
}
return { ...state, query, filtered, index: filtered[0] }
}
const autoCompleteCursor = (state: AutoCompleteState) =>
Option.getOrElse(Arr.findFirstIndex(state.filtered, (index) => index === state.index), () => 0)
const renderSelectOutput = <A>(
leadingSymbol: string,
trailingSymbol: string,
options: SelectOptionsReq<A>,
renderOptions?: RenderOptions | undefined
) => renderPrompt("", options.message, leadingSymbol, trailingSymbol, renderOptions)
const renderAutoCompleteFilter = <A>(
state: AutoCompleteState,
options: AutoCompleteOptionsReq<A>,
renderOptions?: RenderOptions | undefined
) => {
const filterValue = state.query.length === 0
? renderOptions?.plain === true
? options.filterPlaceholder
: Ansi.annotate(options.filterPlaceholder, Ansi.blackBright)
: renderOptions?.plain === true
? state.query
: Ansi.annotate(state.query, Ansi.combine(Ansi.underlined, Ansi.cyanBright))
return `[${options.filterLabel}: ${filterValue}]`
}
const renderAutoCompleteOutput = <A>(
state: AutoCompleteState,
leadingSymbol: string,
trailingSymbol: string,
options: AutoCompleteOptionsReq<A>,
renderOptions?: RenderOptions | undefined
) => {
const filter = renderAutoCompleteFilter(state, options, renderOptions)
return renderPrompt(filter, options.message, leadingSymbol, trailingSymbol, renderOptions)
}
const renderChoicePrefix = <A>(
state: SelectState,
choices: SelectOptionsReq<A>["choices"],
toDisplay: { readonly startIndex: number; readonly endIndex: number },
currentIndex: number,
figures: Effect.Success<typeof platformFigures>,
renderOptions?: RenderOptions | undefined
) => {
let prefix = " "
if (currentIndex === toDisplay.startIndex && toDisplay.startIndex > 0) {
prefix = figures.arrowUp
} else if (currentIndex === toDisplay.endIndex - 1 && toDisplay.endIndex < choices.length) {
prefix = figures.arrowDown
}
if (renderOptions?.plain === true) {
return state === currentIndex
? figures.pointer + prefix
: prefix + " "
}
if (choices[currentIndex].disabled) {
const annotation = Ansi.combine(Ansi.bold, Ansi.blackBright)
return state === currentIndex
? Ansi.annotate(figures.pointer, annotation) + prefix
: prefix + " "
}
return state === currentIndex
? Ansi.annotate(figures.pointer, Ansi.cyanBright) + prefix
: prefix + " "
}
const renderAutoCompleteChoicePrefix = <A>(
state: AutoCompleteState,
options: AutoCompleteOptionsReq<A>,
toDisplay: { readonly startIndex: number; readonly endIndex: number },
currentIndex: number,
figures: Effect.Success<typeof platformFigures>,
renderOptions?: RenderOptions | undefined
) => {
let prefix = " "
if (currentIndex === toDisplay.startIndex && toDisplay.startIndex > 0) {
prefix = figures.arrowUp
} else if (currentIndex === toDisplay.endIndex - 1 && toDisplay.endIndex < state.filtered.length) {
prefix = figures.arrowDown
}
const choiceIndex = state.filtered[currentIndex]
if (renderOptions?.plain === true) {
return state.index === choiceIndex
? figures.pointer + prefix
: prefix + " "
}
const choice = options.choices[choiceIndex]
if (choice.disabled) {
const annotation = Ansi.combine(Ansi.bold, Ansi.blackBright)
return state.index === choiceIndex
? Ansi.annotate(figures.pointer, annotation) + prefix
: prefix + " "
}
return state.index === choiceIndex
? Ansi.annotate(figures.pointer, Ansi.cyanBright) + prefix
: prefix + " "
}
const renderChoiceTitle = <A>(
choice: SelectChoice<A>,
isSelected: boolean,
renderOptions?: RenderOptions | undefined
) => {
if (renderOptions?.plain === true) {
return choice.title
}
const title = choice.title
if (isSelected) {
return choice.disabled
? Ansi.annotate(title, Ansi.combine(Ansi.underlined, Ansi.blackBright))
: Ansi.annotate(title, Ansi.combine(Ansi.underlined, Ansi.cyanBright))
}
return choice.disabled
? Ansi.annotate(title, Ansi.combine(Ansi.strikethrough, Ansi.blackBright))
: title
}
const renderSelectChoices = <A>(
state: SelectState,
options: SelectOptionsReq<A>,
figures: Effect.Success<typeof platformFigures>,
renderOptions?: RenderOptions | undefined
) => {
const choices = options.choices
const toDisplay = entriesToDisplay(state, choices.length, options.maxPerPage)
const documents: Array<string> = []
for (let index = toDisplay.startIndex; index < toDisplay.endIndex; index++) {
const choice = choices[index]
const isSelected = state === index
const prefix = renderChoicePrefix(state, choices, toDisplay, index, figures, renderOptions)
const title = renderChoiceTitle(choice, isSelected, renderOptions)
const description = renderChoiceDescription(choice, isSelected, renderOptions)
documents.push(prefix + title + " " + description)
}
return documents.join("\n")
}
const renderAutoCompleteChoices = <A>(
state: AutoCompleteState,
options: AutoCompleteOptionsReq<A>,
figures: Effect.Success<typeof platformFigures>,
renderOptions?: RenderOptions | undefined
) => {
if (state.filtered.length === 0) {
return renderOptions?.plain === true
? options.emptyMessage
: Ansi.annotate(options.emptyMessage, Ansi.blackBright)
}
const cursor = autoCompleteCursor(state)
const toDisplay = entriesToDisplay(cursor, state.filtered.length, options.maxPerPage)
const documents: Array<string> = []
for (let index = toDisplay.startIndex; index < toDisplay.endIndex; index++) {
const choiceIndex = state.filtered[index]
const choice = options.choices[choiceIndex]
const isSelected = state.index === choiceIndex
const prefix = renderAutoCompleteChoicePrefix(state, options, toDisplay, index, figures, renderOptions)
const title = renderChoiceTitle(choice, isSelected, renderOptions)
const description = renderChoiceDescription(choice, isSelected, renderOptions)
documents.push(prefix + title + " " + description)
}
return documents.join("\n")
}
const renderSelectNextFrame = Effect.fnUntraced(function*<A>(state: SelectState, options: SelectOptionsReq<A>) {
const figures = yield* platformFigures
const choices = renderSelectChoices(state, options, figures)
const leadingSymbol = Ansi.annotate("?", Ansi.cyanBright)
const trailingSymbol = Ansi.annotate(figures.pointerSmall, Ansi.blackBright)
const promptMsg = renderSelectOutput(leadingSymbol, trailingSymbol, options)
return Ansi.cursorHide + promptMsg + "\n" + choices
})
const renderAutoCompleteNextFrame = Effect.fnUntraced(function*<A>(
state: AutoCompleteState,
options: AutoCompleteOptionsReq<A>
) {
const figures = yield* platformFigures
const choices = renderAutoCompleteChoices(state, options, figures)
const leadingSymbol = Ansi.annotate("?", Ansi.cyanBright)
const trailingSymbol = Ansi.annotate(figures.pointerSmall, Ansi.blackBright)
const promptMsg = renderAutoCompleteOutput(state, leadingSymbol, trailingSymbol, options)
return Ansi.cursorHide + promptMsg + "\n" + choices
})
const renderSelectSubmission = Effect.fnUntraced(function*<A>(state: SelectState, options: SelectOptionsReq<A>) {
const figures = yield* platformFigures
const selected = options.choices[state].title
const leadingSymbol = Ansi.annotate(figures.tick, Ansi.green)
const trailingSymbol = Ansi.annotate(figures.ellipsis, Ansi.blackBright)
const promptMsg = renderSelectOutput(leadingSymbol, trailingSymbol, options)
return promptMsg + " " + Ansi.annotate(selected, Ansi.white) + "\n"
})
const renderAutoCompleteSubmission = Effect.fnUntraced(function*<A>(
state: AutoCompleteState,
options: AutoCompleteOptionsReq<A>
) {
const figures = yield* platformFigures
const selected = options.choices[state.index].title
const leadingSymbol = Ansi.annotate(figures.tick, Ansi.green)
const trailingSymbol = Ansi.annotate(figures.ellipsis, Ansi.blackBright)
const promptMsg = renderAutoCompleteOutput(state, leadingSymbol, trailingSymbol, options)
return promptMsg + " " + Ansi.annotate(selected, Ansi.white) + "\n"
})
const processSelectCursorUp = <A>(state: SelectState, choices: SelectOptionsReq<A>["choices"]) => {
if (state === 0) {
return Effect.succeed(Action.NextFrame({ state: choices.length - 1 }))
}
return Effect.succeed(Action.NextFrame({ state: state - 1 }))
}
const processSelectCursorDown = <A>(state: SelectState, choices: SelectOptionsReq<A>["choices"]) => {
if (state === choices.length - 1) {
return Effect.succeed(Action.NextFrame({ state: 0 }))
}
return Effect.succeed(Action.NextFrame({ state: state + 1 }))
}
const processSelectNext = <A>(state: SelectState, choices: SelectOptionsReq<A>["choices"]) => {
return Effect.succeed(Action.NextFrame({ state: (state + 1) % choices.length }))
}
const processAutoCompleteCursorUp = (state: AutoCompleteState) => {
if (state.filtered.length === 0) {
return Effect.succeed(Action.Beep())
}
const cursor = autoCompleteCursor(state)
const nextCursor = cursor === 0 ? state.filtered.length - 1 : cursor - 1
return Effect.succeed(Action.NextFrame({ state: { ...state, index: state.filtered[nextCursor] } }))
}
const processAutoCompleteCursorDown = (state: AutoCompleteState) => {
if (state.filtered.length === 0) {
return Effect.succeed(Action.Beep())
}
const cursor = autoCompleteCursor(state)
const nextCursor = (cursor + 1) % state.filtered.length
return Effect.succeed(Action.NextFrame({ state: { ...state, index: state.filtered[nextCursor] } }))
}
const processAutoCompleteNext = (state: AutoCompleteState) => processAutoCompleteCursorDown(state)
const processAutoCompleteBackspace = <A>(state: AutoCompleteState, options: AutoCompleteOptionsReq<A>) => {
if (state.query.length === 0) {
return Effect.succeed(Action.Beep())
}
const query = state.query.slice(0, state.query.length - 1)
return Effect.succeed(Action.NextFrame({ state: updateAutoCompleteState(state, options, query) }))
}
const processAutoCompleteClear = <A>(state: AutoCompleteState, options: AutoCompleteOptionsReq<A>) =>
Effect.succeed(Action.NextFrame({ state: updateAutoCompleteState(state, options, "") }))
const processAutoCompleteInput = <A>(input: string, state: AutoCompleteState, options: AutoCompleteOptionsReq<A>) => {
if (input.length === 0) {
return Effect.succeed(Action.Beep())
}
const query = state.query + input
return Effect.succeed(Action.NextFrame({ state: updateAutoCompleteState(state, options, query) }))
}
const handleSelectRender = <A>(options: SelectOptionsReq<A>) => {
return (state: SelectState, action: Action<SelectState, A>) => {
return Action.$match(action, {
Beep: () => Effect.succeed(renderBeep),
NextFrame: ({ state }) => renderSelectNextFrame(state, options),
Submit: () => renderSelectSubmission(state, options)
})
}
}
const handleAutoCompleteRender = <A>(options: AutoCompleteOptionsReq<A>) => {
return (state: AutoCompleteState, action: Action<AutoCompleteState, A>) => {
return Action.$match(action, {
Beep: () => Effect.succeed(renderBeep),
NextFrame: ({ state }) => renderAutoCompleteNextFrame(state, options),
Submit: () => renderAutoCompleteSubmission(state, options)
})
}
}
const handleSelectClear = <A>(options: SelectOptionsReq<A>) =>
Effect.fnUntraced(function*(state: SelectState, _: Action<SelectState, A>) {
const terminal = yield* Terminal.Terminal
const columns = yield* terminal.columns
const figures = yield* platformFigures
const clearPrompt = Ansi.eraseLine + Ansi.cursorLeft
const promptText = renderSelectOutput("?", figures.pointerSmall, options, { plain: true })
const choicesText = renderSelectChoices(state, options, figures, { plain: true })
const clearOutput = eraseText(`${promptText}\n${choicesText}`, columns)
return clearOutput + clearPrompt
})
const handleAutoCompleteClear = <A>(options: AutoCompleteOptionsReq<A>) =>
Effect.fnUntraced(function*(state: AutoCompleteState, _: Action<AutoCompleteState, A>) {
const terminal = yield* Terminal.Terminal
const columns = yield* terminal.columns
const figures = yield* platformFigures
const clearPrompt = Ansi.eraseLine + Ansi.cursorLeft
const promptText = renderAutoCompleteOutput(state, "?", figures.pointerSmall, options, { plain: true })
const choicesText = renderAutoCompleteChoices(state, options, figures, { plain: true })
const clearOutput = eraseText(`${promptText}\n${choicesText}`, columns)
return clearOutput + clearPrompt
})
const handleSelectProcess = <A>(options: SelectOptionsReq<A>) => {
return (input: Terminal.UserInput, state: SelectState) => {
switch (input.key.name) {
case "k":
case "up": {
return processSelectCursorUp(state, options.choices)
}
case "j":
case "down": {
return processSelectCursorDown(state, options.choices)
}
case "tab": {
return processSelectNext(state, options.choices)
}
case "enter":
case "return": {
const selected = options.choices[state]
if (selected.disabled) {
return Effect.succeed(Action.Beep())
}
return Effect.succeed(Action.Submit({ value: selected.value }))
}
default: {
return Effect.succeed(Action.Beep())
}
}
}
}
const handleAutoCompleteProcess = <A>(options: AutoCompleteOptionsReq<A>) => {
return (input: Terminal.UserInput, state: AutoCompleteState) => {
if (input.key.ctrl) {
if (input.key.name === "u") {
return processAutoCompleteClear(state, options)
}
return Effect.succeed(Action.Beep())
}
switch (input.key.name) {
case "k":
case "up": {
return processAutoCompleteCursorUp(state)
}
case "j":
case "down": {
return processAutoCompleteCursorDown(state)
}
case "tab": {
return processAutoCompleteNext(state)
}
case "backspace": {
return processAutoCompleteBackspace(state, options)
}
case "enter":
case "return": {
if (state.filtered.length === 0) {
return Effect.succeed(Action.Beep())
}
const selected = options.choices[state.index]
if (selected.disabled) {
return Effect.succeed(Action.Beep())
}
return Effect.succeed(Action.Submit({ value: selected.value }))
}
default: {
return processAutoCompleteInput(Option.getOrElse(input.input, () => ""), state, options)
}
}
}
}
interface TextOptionsReq extends Required<TextOptions> {
/**
* The type of the text option.
*/
readonly type: "hidden" | "password" | "text"
}
interface TextState {
readonly cursor: number
readonly value: string
readonly error: Option.Option<string>
}
const renderClearScreen = Effect.fnUntraced(function*(state: TextState, options: TextOptionsReq) {
const terminal = yield* Terminal.Terminal
const columns = yield* terminal.columns
const figures = yield* platformFigures
const resetCurrentLine = Ansi.eraseLine + Ansi.cursorLeft
const errorText = renderTextError(state, figures.pointerSmall, { plain: true })
const clearOutput = clearOutputWithError(
renderTextOutput(state, "?", figures.pointerSmall, options, { plain: true }),
columns,
errorText
)
return clearOutput + resetCurrentLine
})
const renderTextInput = (
nextState: TextState,
options: TextOptionsReq,
submitted: boolean,
renderOptions?: RenderOptions | undefined
) => {
const text = nextState.value
if (renderOptions?.plain === true) {
switch (options.type) {
case "hidden": {
return ""
}
case "password": {
return "*".repeat(text.length)
}
case "text": {
return text
}
}
}
const annotation = Option.isSome(nextState.error) ?
Ansi.red
: submitted ?
Ansi.white
: nextState.value.length === 0 ?
Ansi.blackBright
: Ansi.combine(Ansi.underlined, Ansi.cyanBright)
switch (options.type) {
case "hidden": {
return ""
}
case "password": {
return Ansi.annotate("*".repeat(text.length), annotation)
}
case "text": {
return Ansi.annotate(text, annotation)
}
}
}
const renderTextError = (
nextState: TextState,
pointer: string,
renderOptions?: RenderOptions | undefined
): string => {
if (Option.isSome(nextState.error)) {
return Arr.match(nextState.error.value.split(NEWLINE_REGEXP), {
onEmpty: () => "",
onNonEmpty: (errorLines) => {
if (renderOptions?.plain === true) {
return `${pointer} ${errorLines.join("\n")}`
}
const prefix = Ansi.annotate(pointer, Ansi.red) + " "
const lines = Arr.map(errorLines, (str) => annotateErrorLine(str))
return Ansi.cursorSavePosition + "\n" + prefix + lines.join("\n") + Ansi.cursorRestorePosition
}
})
}
return ""
}
const renderTextOutput = (
nextState: TextState,
leadingSymbol: string,
trailingSymbol: string,
options: TextOptionsReq,
renderOptions?: RenderOptions | undefined,
submitted: boolean = false
) => {
const value = renderTextInput(nextState, options, submitted, renderOptions)
return renderPrompt(value, options.message, leadingSymbol, trailingSymbol, renderOptions)
}
const renderTextNextFrame = Effect.fnUntraced(function*(state: TextState, options: TextOptionsReq) {
const figures = yield* platformFigures
const leadingSymbol = Ansi.annotate("?", Ansi.cyanBright)
const trailingSymbol = Ansi.annotate(figures.pointerSmall, Ansi.blackBright)
const promptMsg = renderTextOutput(state, leadingSymbol, trailingSymbol, options)
const errorMsg = renderTextError(state, figures.pointerSmall)
const offset = state.cursor - state.value.length
return promptMsg + errorMsg + Ansi.cursorMove(offset)
})
const renderTextSubmission = Effect.fnUntraced(function*(state: TextState, options: TextOptionsReq) {
const figures = yield* platformFigures
const leadingSymbol = Ansi.annotate(figures.tick, Ansi.green)
const trailingSymbol = Ansi.annotate(figures.ellipsis, Ansi.blackBright)
const promptMsg = renderTextOutput(state, leadingSymbol, trailingSymbol, options, undefined, true)
return promptMsg + "\n"
})
const processTextBackspace = (state: TextState) => {
if (state.cursor <= 0) {
return Effect.succeed(Action.Beep())
}
const beforeCursor = state.value.slice(0, state.cursor - 1)
const afterCursor = state.value.slice(state.cursor)
const cursor = state.cursor - 1
const value = `${beforeCursor}${afterCursor}`
return Effect.succeed(
Action.NextFrame({
state: { ...state, cursor, value, error: Option.none() }
})
)
}
const processTextClear = (state: TextState) =>
Effect.succeed(
Action.NextFrame({
state: { ...state, cursor: 0, value: "", error: Option.none() }
})
)
const processTextCursorLeft = (state: TextState) => {
if (state.cursor <= 0) {
return Effect.succeed(Action.Beep())
}
const cursor = state.cursor - 1
return Effect.succeed(
Action.NextFrame({
state: { ...state, cursor, error: Option.none() }
})
)
}
const processTextCursorRight = (state: TextState) => {
if (state.cursor >= state.value.length) {
return Effect.succeed(Action.Beep())
}
const cursor = Math.min(state.cursor + 1, state.value.length)
return Effect.succeed(
Action.NextFrame({
state: { ...state, cursor, error: Option.none() }
})
)
}
const processTextCursorStart = (state: TextState) =>
Effect.succeed(
Action.NextFrame({
state: { ...state, cursor: 0, error: Option.none() }
})
)
const processTextCursorEnd = (state: TextState) =>
Effect.succeed(
Action.NextFrame({
state: { ...state, cursor: state.value.length, error: Option.none() }
})
)
const processTab = (state: TextState, options: TextOptionsReq) => {
if (state.value === options.default) {
return Effect.succeed(Action.Beep())
}
const value = state.value.length === 0 ? options.default : state.value
return Effect.succeed(
Action.NextFrame({
state: { ...state, value, cursor: value.length, error: Option.none() }
})
)
}
const defaultTextProcessor = (input: string, state: TextState) => {
const beforeCursor = state.value.slice(0, state.cursor)
const afterCursor = state.value.slice(state.cursor)
const value = `${beforeCursor}${input}${afterCursor}`
const cursor = state.cursor + input.length
return Effect.succeed(
Action.NextFrame({
state: { ...state, cursor, value, error: Option.none() }
})
)
}
const handleTextRender = (options: TextOptionsReq) => {
return (state: TextState, action: Action<TextState, string>) => {
return Action.$match(action, {
Beep: () => Effect.succeed(renderBeep),
NextFrame: ({ state }) => renderTextNextFrame(state, options),
Submit: () => renderTextSubmission(state, options)
})
}
}
const handleTextProcess = (options: TextOptionsReq) => {
return (input: Terminal.UserInput, state: TextState) => {
if (input.key.ctrl) {
switch (input.key.name) {
case "u": {
return processTextClear(state)
}
case "a": {
return processTextCursorStart(state)
}
case "e": {
return processTextCursorEnd(state)
}
default: {
return Effect.succeed(Action.Beep())
}
}
}
switch (input.key.name) {
case "backspace": {
return processTextBackspace(state)
}
case "left": {
return processTextCursorLeft(state)
}
case "right": {
return processTextCursorRight(state)
}
case "home": {
return processTextCursorStart(state)
}
case "end": {
return processTextCursorEnd(state)
}
case "enter":
case "return": {
const value = state.value
return Effect.match(options.validate(value), {
onFailure: (error) =>
Action.NextFrame({
state: { ...state, value, error: Option.some(error) }
}),
onSuccess: (value) => Action.Submit({ value })
})
}
case "tab": {
return processTab(state, options)
}
default: {
return defaultTextProcessor(Option.getOrElse(input.input, () => ""), state)
}
}
}
}
const handleTextClear = (options: TextOptionsReq) => {
return (state: TextState, _: Action<TextState, string>) => {
return renderClearScreen(state, options)
}
}
const basePrompt = (
options: TextOptions,
type: TextOptionsReq["type"]
): Prompt<string> => {
const opts: TextOptionsReq = {
default: "",
type,
validate: Effect.succeed,
...options
}
const initialState: TextState = {
cursor: opts.default.length,
value: opts.default,
error: Option.none()
}
return custom(initialState, {
render: handleTextRender(opts),
process: handleTextProcess(opts),
clear: handleTextClear(opts)
})
}
interface ToggleOptionsReq extends Required<ToggleOptions> {}
type ToggleState = boolean
const handleToggleClear = Effect.fnUntraced(function*(options: ToggleOptionsReq) {
const terminal = yield* Terminal.Terminal
const columns = yield* terminal.columns
const figures = yield* platformFigures
const clearPrompt = Ansi.eraseLine + Ansi.cursorLeft
const toggleText = `${options.active} / ${options.inactive}`
const promptText = renderPrompt(toggleText, options.message, "?", figures.pointerSmall, { plain: true })
const clearOutput = eraseText(promptText, columns)
return clearOutput + clearPrompt
})
const renderToggle = (
value: boolean,
options: ToggleOptionsReq,
submitted: boolean = false
) => {
const separator = Ansi.annotate("/", Ansi.blackBright)
const selectedAnnotation = Ansi.combine(Ansi.underlined, submitted ? Ansi.white : Ansi.cyanBright)
const inactive = value
? options.inactive
: Ansi.annotate(options.inactive, selectedAnnotation)
const active = value
? Ansi.annotate(options.active, selectedAnnotation)
: options.active
return active + " " + separator + " " + inactive
}
const renderToggleOutput = (
toggle: string,
leadingSymbol: string,
trailingSymbol: string,
options: ToggleOptionsReq
) => {
const promptLines = options.message.split(NEWLINE_REGEXP)
const prefix = leadingSymbol + " "
if (Arr.isReadonlyArrayNonEmpty(promptLines)) {
const lines = Arr.map(promptLines, (line) => annotateLine(line))
return prefix + lines.join("\n") + " " + trailingSymbol + " " + toggle
}
return prefix + " " + trailingSymbol + " " + toggle
}
const renderToggleNextFrame = Effect.fnUntraced(function*(state: ToggleState, options: ToggleOptionsReq) {
const figures = yield* platformFigures
const leadingSymbol = Ansi.annotate("?", Ansi.cyanBright)
const trailingSymbol = Ansi.annotate(figures.pointerSmall, Ansi.blackBright)
const toggle = renderToggle(state, options)
const promptMsg = renderToggleOutput(toggle, leadingSymbol, trailingSymbol, options)
return Ansi.cursorHide + promptMsg
})
const renderToggleSubmission = Effect.fnUntraced(function*(value: boolean, options: ToggleOptionsReq) {
const figures = yield* platformFigures
const leadingSymbol = Ansi.annotate(figures.tick, Ansi.green)
const trailingSymbol = Ansi.annotate(figures.ellipsis, Ansi.blackBright)
const toggle = renderToggle(value, options, true)
const promptMsg = renderToggleOutput(toggle, leadingSymbol, trailingSymbol, options)
return promptMsg + "\n"
})
const activate = Effect.succeed(Action.NextFrame({ state: true }))
const deactivate = Effect.succeed(Action.NextFrame({ state: false }))
const handleToggleRender = (options: ToggleOptionsReq) => {
return (state: ToggleState, action: Action<ToggleState, boolean>) => {
switch (action._tag) {
case "Beep": {
return Effect.succeed(renderBeep)
}
case "NextFrame": {
return renderToggleNextFrame(state, options)
}
case "Submit": {
return renderToggleSubmission(state, options)
}
}
}
}
const handleToggleProcess = (input: Terminal.UserInput, state: ToggleState) => {
switch (input.key.name) {
case "0":
case "j":
case "delete":
case "right":
case "down": {
return deactivate
}
case "1":
case "k":
case "left":
case "up": {
return activate
}
case " ":
case "tab": {
return state ? deactivate : activate
}
case "enter":
case "return": {
return Effect.succeed(Action.Submit({ value: state }))
}
default: {
return Effect.succeed(Action.Beep())
}
}
}
const entriesToDisplay = (cursor: number, total: number, maxVisible?: number) => {
const max = maxVisible === undefined ? total : maxVisible
let startIndex = Math.min(total - max, cursor - Math.floor(max / 2))
if (startIndex < 0) {
startIndex = 0
}
const endIndex = Math.min(startIndex + max, total)
return { startIndex, endIndex }
}

Xet Storage Details

Size:
122 kB
·
Xet hash:
67b49e460b40b18903f20d0acbb784f30231f3dc2b41716c9057b5b47911cab8

Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.