1/**
2 * Builds interactive terminal prompts for CLI applications.
3 *
4 * A `Prompt<A>` describes a small terminal UI that renders frames, reads
5 * keyboard input, validates responses, and eventually produces an `A`. Prompts
6 * can ask for simple values, selections, lists, files, hidden text, or custom
7 * interactions. This module includes prompt constructors, tools for combining
8 * and transforming prompt output, and support for running prompts through the
9 * `Terminal` service.
10 *
11 * @stability unstable
12 * @since 4.0.0
13 */
14import * as Arr from "../Array.ts"
15import type { NoSuchElementError } from "../Cause.ts"
16import type * as Cause from "../Cause.ts"
17import * as Context from "../Context.ts"
18import * as Data from "../Data.ts"
19import * as Effect from "../Effect.ts"
20import * as Effectable from "../Effectable.ts"
21import * as FileSystem from "../FileSystem.ts"
22import { dual, pipe } from "../Function.ts"
23import * as EffectNumber from "../Number.ts"
24import * as Option from "../Option.ts"
25import * as Path from "../Path.ts"
26import * as Predicate from "../Predicate.ts"
27import * as Queue from "../Queue.ts"
28import * as Redacted from "../Redacted.ts"
29import * as Terminal from "../Terminal.ts"
30import type { Covariant } from "../Types.ts"
31import * as Ansi from "./internal/ansi.ts"
32import type * as Primitive from "./Primitive.ts"
33
34declare const process: {
35 readonly platform: string
36 readonly cwd: () => string
37}
38
39const TypeId = "~effect/cli/Prompt"
40
41/**
42 * Represents an interactive terminal prompt that produces an `Output` value.
43 *
44 * **Details**
45 *
46 * A `Prompt` is an `Effect` that may fail with `Terminal.QuitError` and
47 * requires the prompt environment needed to render frames, read input, and
48 * access files or paths when a prompt uses them.
49 *
50 * @stability unstable
51 * @category models
52 * @since 4.0.0
53 */
54export interface Prompt<Output> extends Effect.Effect<Output, Terminal.QuitError, Environment> {
55 readonly [TypeId]: {
56 readonly _Output: Covariant<Output>
57 }
58}
59
60/**
61 * Returns `true` if the provided value is a `Prompt`.
62 *
63 * @stability unstable
64 * @category guards
65 * @since 4.0.0
66 */
67export const isPrompt = (u: unknown): u is Prompt<unknown> => Predicate.hasProperty(u, TypeId)
68
69/**
70 * Represents the services available to a custom `Prompt`.
71 *
72 * @stability unstable
73 * @category models
74 * @since 4.0.0
75 */
76export type Environment = FileSystem.FileSystem | Path.Path | Terminal.Terminal
77
78/**
79 * Represents the action that should be taken by a `Prompt` based upon user
80 * input or an external event received during the current frame.
81 *
82 * @stability unstable
83 * @category models
84 * @since 4.0.0
85 */
86export type Action<State, Output> = Data.TaggedEnum<{
87 readonly Beep: {}
88 readonly NextFrame: { readonly state: State }
89 readonly Submit: { readonly value: Output }
90}>
91
92/**
93 * Type-level definition for the tagged `Prompt.Action` variants.
94 *
95 * **Details**
96 *
97 * It connects the action state and output type parameters to the `Beep`,
98 * `NextFrame`, and `Submit` action cases.
99 *
100 * @stability unstable
101 * @category models
102 * @since 4.0.0
103 */
104export interface ActionDefinition extends Data.TaggedEnum.WithGenerics<2> {
105 readonly taggedEnum: Action<this["A"], this["B"]>
106}
107
108/**
109 * Represents the input that should be processed by a `Prompt` based upon user
110 * input or an external event received during the current frame.
111 *
112 * @stability unstable
113 * @category models
114 * @since 4.0.0
115 */
116export type ProcessInput<A> = Data.TaggedEnum<{
117 readonly Input: { readonly input: Terminal.UserInput }
118 readonly Event: { readonly value: A }
119}>
120
121/**
122 * Represents the set of handlers used by a `Prompt`.
123 *
124 * **Details**
125 *
126 * The handlers render the current frame, process user input into the next
127 * `Prompt.Action`, and clear the terminal screen before the next frame.
128 *
129 * @stability unstable
130 * @category models
131 * @since 4.0.0
132 */
133export interface Handlers<State, Output, Input = Terminal.UserInput> {
134 /**
135 * A function that is called to render the current frame of the `Prompt`.
136 */
137 readonly render: (
138 state: State,
139 action: Action<State, Output>
140 ) => Effect.Effect<string, never, Environment>
141 /**
142 * A function that is called to process user input and determine the next
143 * `Prompt.Action` that should be taken.
144 */
145 readonly process: (
146 input: Input,
147 state: State
148 ) => Effect.Effect<Action<State, Output>, never, Environment>
149 /**
150 * A function that is called to clear the terminal screen before rendering
151 * the next frame of the `Prompt`.
152 */
153 readonly clear: (
154 state: State,
155 action: Action<State, Output>
156 ) => Effect.Effect<string, never, Environment>
157}
158
159/**
160 * Defines the symbols used to render built-in prompts.
161 *
162 * **Details**
163 *
164 * Set a symbol to an empty string to omit both the symbol and its adjacent
165 * spacing.
166 *
167 * @stability unstable
168 * @category models
169 * @since 4.0.0
170 */
171export interface Theme {
172 /** The leading symbol on an active prompt line. */
173 readonly prefix: string
174 /** The marker for the highlighted choice. */
175 readonly pointer: string
176 /** The trailing prompt and validation marker. */
177 readonly pointerSmall: string
178 /** The marker shown when a prompt has been submitted. */
179 readonly ellipsis: string
180 /** The leading symbol on a completed prompt line. */
181 readonly tick: string
182 /** The marker indicating choices above the current page. */
183 readonly arrowUp: string
184 /** The marker indicating choices below the current page. */
185 readonly arrowDown: string
186 /** The marker for a selected multi-select choice. */
187 readonly checkboxOn: string
188 /** The marker for an unselected multi-select choice. */
189 readonly checkboxOff: string
190 /** The separator between a choice and its description. */
191 readonly descriptionSeparator: string
192 /** The character used to mask password input. */
193 readonly passwordMask: string
194 /** The separator between toggle labels. */
195 readonly toggleSeparator: string
196 /** The color used for active prompt elements. */
197 readonly primaryColor: string
198 /** The color used for secondary prompt elements. */
199 readonly mutedColor: string
200 /** The color used for completed prompt markers. */
201 readonly successColor: string
202 /** The color used for validation errors. */
203 readonly errorColor: string
204 /** The color used for submitted values. */
205 readonly submittedColor: string
206}
207
208/**
209 * Options shared by built-in prompts that support theme overrides.
210 *
211 * @stability unstable
212 * @category options
213 * @since 4.0.0
214 */
215export interface ThemeOptions {
216 /** Overrides the context theme for this prompt. */
217 readonly theme?: Partial<Theme>
218}
219
220type OptionsReq<A> = Required<Omit<A, "theme">> & ThemeOptions
221
222/**
223 * Options for a confirmation prompt that asks the user to choose a boolean
224 * yes/no value.
225 *
226 * @stability unstable
227 * @category options
228 * @since 4.0.0
229 */
230export interface ConfirmOptions extends ThemeOptions {
231 /**
232 * The message to display in the prompt.
233 */
234 readonly message: string
235 /**
236 * The initial value of the confirm prompt (defaults to `false`).
237 */
238 readonly initial?: boolean
239 /**
240 * The label to display after a user has responded to the prompt.
241 */
242 readonly label?: {
243 /**
244 * The label used if the prompt is confirmed (defaults to `"yes"`).
245 */
246 readonly confirm: string
247 /**
248 * The label used if the prompt is not confirmed (defaults to `"no"`).
249 */
250 readonly deny: string
251 }
252 /**
253 * The placeholder to display when a user is responding to the prompt.
254 */
255 readonly placeholder?: {
256 /**
257 * The placeholder to use if the `initial` value of the prompt is `true`
258 * (defaults to `"(Y/n)"`).
259 */
260 readonly defaultConfirm?: string
261 /**
262 * The placeholder to use if the `initial` value of the prompt is `false`
263 * (defaults to `"(y/N)"`).
264 */
265 readonly defaultDeny?: string
266 }
267}
268
269/**
270 * Options for a date prompt, including the displayed message, initial value,
271 * format mask, validation, and locale labels.
272 *
273 * @stability unstable
274 * @category options
275 * @since 4.0.0
276 */
277export interface DateOptions extends ThemeOptions {
278 /**
279 * The message to display in the prompt.
280 */
281 readonly message: string
282 /**
283 * The initial date value to display in the prompt (defaults to the current
284 * date).
285 */
286 readonly initial?: globalThis.Date
287 /**
288 * The format mask of the date (defaults to `YYYY-MM-DD HH:mm:ss`).
289 */
290 readonly dateMask?: string
291 /**
292 * An effectful function that can be used to validate the value entered into
293 * the prompt before final submission.
294 */
295 readonly validate?: (value: globalThis.Date) => Effect.Effect<globalThis.Date, string>
296 /**
297 * Custom locales that can be used in place of the defaults.
298 */
299 readonly locales?: {
300 /**
301 * The full names of each month of the year.
302 */
303 readonly months: [
304 string,
305 string,
306 string,
307 string,
308 string,
309 string,
310 string,
311 string,
312 string,
313 string,
314 string,
315 string
316 ]
317 /**
318 * The short names of each month of the year.
319 */
320 readonly monthsShort: [
321 string,
322 string,
323 string,
324 string,
325 string,
326 string,
327 string,
328 string,
329 string,
330 string,
331 string,
332 string
333 ]
334 /**
335 * The full names of each day of the week.
336 */
337 readonly weekdays: [string, string, string, string, string, string, string]
338 /**
339 * The short names of each day of the week.
340 */
341 readonly weekdaysShort: [string, string, string, string, string, string, string]
342 }
343}
344
345/**
346 * Options for an integer prompt, including bounds, keyboard step sizes, and
347 * additional validation.
348 *
349 * @stability unstable
350 * @category options
351 * @since 4.0.0
352 */
353export interface IntOptions extends ThemeOptions {
354 /**
355 * The message to display in the prompt.
356 */
357 readonly message: string
358 /**
359 * The default value of the integer prompt.
360 */
361 readonly default?: number
362 /**
363 * The minimum value that can be entered by the user (defaults to `-Infinity`).
364 */
365 readonly min?: number
366 /**
367 * The maximum value that can be entered by the user (defaults to `Infinity`).
368 */
369 readonly max?: number
370 /**
371 * The value that will be used to increment the prompt value when using the
372 * up arrow key (defaults to `1`).
373 */
374 readonly incrementBy?: number
375 /**
376 * The value that will be used to decrement the prompt value when using the
377 * down arrow key (defaults to `1`).
378 */
379 readonly decrementBy?: number
380 /**
381 * An effectful function that can be used to validate the value entered into
382 * the prompt before final submission.
383 */
384 readonly validate?: (value: number) => Effect.Effect<number, string>
385}
386
387/**
388 * Options for `Number`, extending `IntOptions` with display precision.
389 *
390 * @stability unstable
391 * @category options
392 * @since 4.0.0
393 */
394export interface NumberOptions extends IntOptions {
395 /**
396 * The precision to use for the floating point value (defaults to `2`).
397 */
398 readonly precision?: number
399}
400
401/**
402 * Options for a text prompt that returns a list of strings by splitting the
403 * input on a delimiter.
404 *
405 * @stability unstable
406 * @category options
407 * @since 4.0.0
408 */
409export interface ListOptions extends TextOptions {
410 /**
411 * The delimiter that separates list entries.
412 */
413 readonly delimiter?: string
414}
415
416/**
417 * Options for a file-system selection prompt.
418 *
419 * **Details**
420 *
421 * They control which path type can be selected, the starting directory, paging,
422 * and filtering of displayed entries.
423 *
424 * @stability unstable
425 * @category options
426 * @since 4.0.0
427 */
428export interface FileOptions extends ThemeOptions {
429 /**
430 * The path type that will be selected, defaulting to `"file"`.
431 */
432 readonly type?: Primitive.PathType
433 /**
434 * The message to display in the prompt, defaulting to `"Choose a file"`.
435 */
436 readonly message?: string
437 /**
438 * Where the user will initially be prompted to select files from, defaulting
439 * to the current working directory.
440 */
441 readonly startingPath?: string
442 /**
443 * The default path to select when the prompt is first displayed.
444 */
445 readonly default?: string
446 /**
447 * The number of choices to display at one time, defaulting to `10`.
448 */
449 readonly maxPerPage?: number
450 /**
451 * A predicate or effect that keeps a file in the prompt when it returns
452 * `true`, defaulting to returning all files.
453 */
454 readonly filter?: (file: string) => boolean | Effect.Effect<boolean, never, Environment>
455}
456
457/**
458 * Options for a prompt that asks the user to select one value from a list of
459 * choices.
460 *
461 * @stability unstable
462 * @category options
463 * @since 4.0.0
464 */
465export interface SelectOptions<A> extends ThemeOptions {
466 /**
467 * The prompt message. Omit to display only the choices.
468 */
469 readonly message?: string | undefined
470 /**
471 * The choices to display to the user.
472 */
473 readonly choices: ReadonlyArray<SelectChoice<A>>
474 /**
475 * The number of choices to display at one time (defaults to `10`).
476 */
477 readonly maxPerPage?: number
478}
479
480/**
481 * Options for an autocomplete prompt that lets the user filter selectable
482 * choices by typing.
483 *
484 * @stability unstable
485 * @category options
486 * @since 4.0.0
487 */
488export interface AutoCompleteOptions<A> extends SelectOptions<A> {
489 /**
490 * The required prompt message.
491 */
492 readonly message: string
493 /**
494 * The label used for the filter display (defaults to "filter").
495 */
496 readonly filterLabel?: string
497 /**
498 * The placeholder shown when the filter is empty (defaults to "type to filter").
499 */
500 readonly filterPlaceholder?: string
501 /**
502 * The message displayed when no choices match (defaults to "No matches").
503 */
504 readonly emptyMessage?: string
505}
506
507/**
508 * Options for a multi-select prompt, including bulk-selection labels and
509 * minimum or maximum selection counts.
510 *
511 * @stability unstable
512 * @category options
513 * @since 4.0.0
514 */
515export interface MultiSelectOptions {
516 /**
517 * Text for the "Select All" option (defaults to "Select All").
518 */
519 readonly selectAll?: string
520 /**
521 * Text for the "Select None" option (defaults to "Select None").
522 */
523 readonly selectNone?: string
524 /**
525 * Text for the "Inverse Selection" option (defaults to "Inverse Selection").
526 */
527 readonly inverseSelection?: string
528 /**
529 * The minimum number of choices that must be selected.
530 */
531 readonly min?: number
532 /**
533 * The maximum number of choices that can be selected.
534 */
535 readonly max?: number
536}
537
538/**
539 * Represents one choice displayed by select, autocomplete, and multi-select
540 * prompts.
541 *
542 * @stability unstable
543 * @category models
544 * @since 4.0.0
545 */
546export interface SelectChoice<A> {
547 /**
548 * The name of the select option that is displayed to the user.
549 */
550 readonly title: string
551 /**
552 * The underlying value of the select option.
553 */
554 readonly value: A
555 /**
556 * An optional description for the select option which will be displayed
557 * to the user.
558 */
559 readonly description?: string
560 /**
561 * Whether or not this select option is disabled.
562 */
563 readonly disabled?: boolean
564 /**
565 * Whether this option should be selected by default (only used by MultiSelect).
566 */
567 readonly selected?: boolean
568}
569
570/**
571 * Options for text-entry prompts, including the displayed message, default
572 * text, and effectful validation before submission.
573 *
574 * @stability unstable
575 * @category options
576 * @since 4.0.0
577 */
578export interface TextOptions extends ThemeOptions {
579 /**
580 * The message to display in the prompt.
581 */
582 readonly message: string
583 /**
584 * The default value of the text option.
585 */
586 readonly default?: string
587 /**
588 * An effectful function that can be used to validate the value entered into
589 * the prompt before final submission.
590 */
591 readonly validate?: (value: string) => Effect.Effect<string, string>
592}
593
594/**
595 * Options for a toggle prompt that lets the user switch between active and
596 * inactive boolean states.
597 *
598 * @stability unstable
599 * @category options
600 * @since 4.0.0
601 */
602export interface ToggleOptions extends ThemeOptions {
603 /**
604 * The message to display in the prompt.
605 */
606 readonly message: string
607 /**
608 * The intitial value of the toggle prompt (defaults to `false`).
609 */
610 readonly initial?: boolean
611 /**
612 * The text to display when the toggle is in the active state (defaults to
613 * `on`).
614 */
615 readonly active?: string
616 /**
617 * The text to display when the toggle is in the inactive state (defaults to
618 * `off`).
619 */
620 readonly inactive?: string
621}
622
623const defaultTheme: Theme = {
624 prefix: "?",
625 arrowUp: "↑",
626 arrowDown: "↓",
627 checkboxOn: "☒",
628 checkboxOff: "☐",
629 tick: "✔",
630 ellipsis: "…",
631 pointerSmall: "›",
632 pointer: "❯",
633 descriptionSeparator: "- ",
634 passwordMask: "*",
635 toggleSeparator: "/",
636 primaryColor: Ansi.cyanBright,
637 mutedColor: Ansi.blackBright,
638 successColor: Ansi.green,
639 errorColor: Ansi.red,
640 submittedColor: Ansi.white
641}
642
643const windowsTheme: Theme = {
644 ...defaultTheme,
645 checkboxOn: "[*]",
646 checkboxOff: "[ ]",
647 tick: "√",
648 ellipsis: "...",
649 pointerSmall: "»",
650 pointer: ">"
651}
652
653/**
654 * Creates a prompt theme using the current platform defaults.
655 *
656 * @stability unstable
657 * @category constructors
658 * @since 4.0.0
659 */
660export const makeTheme = (options?: Partial<Theme>): Theme => ({
661 ...(process.platform === "win32" ? windowsTheme : defaultTheme),
662 ...options
663})
664
665/**
666 * Context reference for the theme used by built-in prompts.
667 *
668 * **Details**
669 *
670 * Provide this reference once to theme every prompt in an application. A
671 * prompt's `theme` option takes precedence over the context value.
672 *
673 * @stability unstable
674 * @category services
675 * @since 4.0.0
676 */
677export const Theme: Context.Reference<Theme> = Context.Reference("effect/cli/Prompt/Theme", {
678 defaultValue: makeTheme
679})
680
681const getTheme = (options: ThemeOptions): Effect.Effect<Theme> =>
682 Effect.map(Theme, (theme) => ({ ...theme, ...options.theme }))
683
684/**
685 * Type alias for any `Prompt`, regardless of its output type.
686 *
687 * @stability unstable
688 * @category utility types
689 * @since 4.0.0
690 */
691export type Any = Prompt<unknown>
692
693/**
694 * Namespace containing return-type helpers for `Prompt.all`.
695 *
696 * @stability unstable
697 * @since 4.0.0
698 */
699export declare namespace All {
700 /**
701 * Computes the prompt returned by `Prompt.all` for an iterable of prompts.
702 *
703 * **Details**
704 *
705 * The resulting prompt produces an array of each prompt's output value.
706 *
707 * @stability unstable
708 * @category utility types
709 * @since 4.0.0
710 */
711 export type ReturnIterable<T extends Iterable<Any>> = [T] extends [Iterable<Prompt<infer A>>] ? Prompt<Array<A>>
712 : never
713
714 /**
715 * Computes the prompt returned by `Prompt.all` for a readonly tuple or array
716 * of prompts, preserving tuple positions in the output type.
717 *
718 * @stability unstable
719 * @category utility types
720 * @since 4.0.0
721 */
722 export type ReturnTuple<T extends ReadonlyArray<unknown>> = Prompt<
723 T[number] extends never ? []
724 : { -readonly [K in keyof T]: [T[K]] extends [Prompt<infer _A>] ? _A : never }
725 > extends infer X ? X : never
726
727 /**
728 * Computes the prompt returned by `Prompt.all` for a record of prompts,
729 * preserving the record keys and replacing each prompt with its output type.
730 *
731 * @stability unstable
732 * @category utility types
733 * @since 4.0.0
734 */
735 export type ReturnObject<T> = [T] extends [{ [K: string]: Any }] ? Prompt<
736 {
737 -readonly [K in keyof T]: [T[K]] extends [Prompt<infer _A>] ? _A : never
738 }
739 >
740 : never
741
742 /**
743 * Computes the return prompt type for `Prompt.all` based on the input
744 * structure.
745 *
746 * @stability unstable
747 * @category constructors
748 * @since 4.0.0
749 */
750 export type Return<
751 Arg extends Iterable<Any> | Record<string, Any>
752 > = [Arg] extends [ReadonlyArray<Any>] ? ReturnTuple<Arg>
753 : [Arg] extends [Iterable<Any>] ? ReturnIterable<Arg>
754 : [Arg] extends [Record<string, Any>] ? ReturnObject<Arg>
755 : never
756}
757
758/**
759 * Runs all the provided prompts in sequence respecting the structure provided
760 * in input.
761 *
762 * **Details**
763 *
764 * Supports either a tuple / iterable of prompts or a record / struct of prompts
765 * as an argument.
766 *
767 * **Example** (Collecting prompt results)
768 *
769 * ```ts import.meta.vitest
770 * import { Effect, FileSystem, Layer, Path, Terminal } from "effect"
771 * import { Prompt } from "effect/cli"
772 *
773 * const terminal = Terminal.make({
774 * columns: Effect.succeed(80),
775 * rows: Effect.succeed(24),
776 * readInput: Effect.succeed({} as never),
777 * readLine: Effect.die("unused"),
778 * display: () => Effect.void
779 * })
780 * const services = Layer.mergeAll(
781 * FileSystem.layerNoop({}),
782 * Path.layer,
783 * Layer.succeed(Terminal.Terminal, terminal)
784 * )
785 *
786 * const username = Prompt.succeed("alice")
787 * const password = Prompt.succeed("secret")
788 *
789 * const allWithTuple = Prompt.all([username, password])
790 *
791 * const allWithRecord = Prompt.all({ username, password })
792 *
793 * await Effect.runPromise(Effect.provide(allWithTuple, services)) // => ["alice", "secret"]
794 * await Effect.runPromise(Effect.provide(allWithRecord, services)) // => { username: "alice", password: "secret" }
795 * ```
796 *
797 * @stability unstable
798 * @category combining
799 * @since 4.0.0
800 */
801export const all: <
802 const Arg extends Iterable<Prompt<any>> | Record<string, Prompt<any>>
803>(arg: Arg) => All.Return<Arg> = function() {
804 if (arguments.length === 1) {
805 if (isPrompt(arguments[0])) {
806 return map(arguments[0], (x) => [x]) as any
807 } else if (Predicate.isIterable(arguments[0])) {
808 return allTupled(Arr.fromIterable(arguments[0] as Iterable<Prompt<any>>)) as any
809 } else {
810 const entries = Object.entries(arguments[0] as Readonly<{ [K: string]: Prompt<any> }>)
811 if (entries.length === 0) {
812 return succeed({}) as any
813 }
814 let result = map(entries[0][1], (value) => ({ [entries[0][0]]: value }))
815 if (entries.length === 1) {
816 return result as any
817 }
818 const rest = entries.slice(1)
819 for (const [key, prompt] of rest) {
820 result = result.pipe(
821 flatMap((record) =>
822 prompt.pipe(map((value) => ({
823 ...record,
824 [key]: value
825 })))
826 )
827 )
828 }
829 return result as any
830 }
831 }
832 return allTupled(arguments[0]) as any
833}
834
835const annotateLine = (line: string): string => Ansi.annotate(line, Ansi.bold)
836const annotateErrorLine = (line: string, color: string): string =>
837 Ansi.annotate(line, Ansi.combine(Ansi.italicized, color))
838const annotateSymbol = (symbol: string, ...styles: Array<string | Array<string>>): string =>
839 symbol.length === 0 ? "" : Ansi.annotate(symbol, ...styles)
840const separateSymbol = (symbol: string, text: string): string => symbol.length === 0 ? text : symbol + " " + text
841const renderPagingPrefix = (theme: Theme, showArrowUp: boolean, showArrowDown: boolean): string => {
842 const width = Math.max(theme.arrowUp.length, theme.arrowDown.length)
843 if (showArrowUp) {
844 return theme.arrowUp.padEnd(width)
845 }
846 if (showArrowDown) {
847 return theme.arrowDown.padEnd(width)
848 }
849 return " ".repeat(width)
850}
851
852/**
853 * Creates a confirmation prompt that asks the user to choose a boolean yes/no
854 * value.
855 *
856 * **When to use**
857 *
858 * Use to ask for a yes/no answer that can be submitted directly.
859 *
860 * **Details**
861 *
862 * `initial` defaults to `false`. Enter submits the current default, yes-style
863 * input submits `true`, no-style input submits `false`, and other input beeps.
864 *
865 * @see {@link Toggle} for an interactive switch-before-submit boolean prompt
866 *
867 * @stability unstable
868 * @category constructors
869 * @since 4.0.0
870 */
871export const Confirm = (options: ConfirmOptions): Prompt<boolean> => {
872 const opts: ConfirmOptionsReq = {
873 initial: false,
874 ...options,
875 label: {
876 confirm: "yes",
877 deny: "no",
878 ...options.label
879 },
880 placeholder: {
881 defaultConfirm: "(Y/n)",
882 defaultDeny: "(y/N)",
883 ...options.placeholder
884 }
885 }
886 const initialState: ConfirmState = { value: opts.initial }
887 return Custom(initialState, {
888 render: handleConfirmRender(opts),
889 process: (input) => handleConfirmProcess(input, opts.initial),
890 clear: handleConfirmClear(opts)
891 })
892}
893
894/**
895 * Creates a custom `Prompt` from the specified initial state and handlers.
896 *
897 * **Details**
898 *
899 * The initial state can either be a pure value or an `Effect`. This is
900 * particularly useful when the initial state of the `Prompt` must be computed
901 * by performing an effectful computation, such as reading data from the file
902 * system. A `Prompt` runs as a render loop: `render` returns ANSI output for
903 * the current frame, the `Terminal` obtains user input, `process` returns the
904 * next prompt action, and `clear` returns ANSI output used to clear the previous
905 * frame.
906 *
907 * Optionally, an external `events` dequeue can be provided as the third
908 * argument. When present, the render loop will race user input against events
909 * from the dequeue, allowing background events to trigger re-renders without
910 * waiting for a keypress. When an event is received from the dequeue, the
911 * `receive` handler is called instead of `process`.
912 *
913 * @stability unstable
914 * @category constructors
915 * @since 4.0.0
916 */
917export const Custom: {
918 <State, Output>(
919 initialState: State | Effect.Effect<State, never, Environment>,
920 handlers: Handlers<State, Output>
921 ): Prompt<Output>
922 <State, Output, A>(
923 initialState: State | Effect.Effect<State, never, Environment>,
924 events: Queue.Dequeue<A, never>,
925 handlers: Handlers<State, Output, ProcessInput<A>>
926 ): Prompt<Output>
927} = <State, Output, A>(
928 initialState: State | Effect.Effect<State, never, Environment>,
929 ...args:
930 | [handlers: Handlers<State, Output, Terminal.UserInput>]
931 | [events: Queue.Dequeue<A, never>, handlers: Handlers<State, Output, ProcessInput<A>>]
932): Prompt<Output> => {
933 const [events, handlers] = args.length === 1
934 ? [undefined, args[0]] as const
935 : [args[0], args[1]] as const
936 const op = Object.create(proto)
937 op._tag = "Loop"
938 op.initialState = initialState
939 op.render = handlers.render
940 op.process = handlers.process
941 op.clear = handlers.clear
942 op.events = events
943 return op
944}
945
946/**
947 * Creates a date prompt that lets the user edit a formatted date value and
948 * validates the final `Date` before submission.
949 *
950 * **Details**
951 *
952 * `initial` defaults to the current `Date`, `dateMask` defaults to
953 * `YYYY-MM-DD HH:mm:ss`, mask parsing creates editable date parts plus literal
954 * tokens, `locales` customizes month and weekday labels, and `validate` runs on
955 * submission.
956 *
957 * **Gotchas**
958 *
959 * A supplied `initial` `Date` is edited in place during prompt interaction.
960 * Date edits use JavaScript `Date` setters, so out-of-range typed values can
961 * normalize before validation. If the prompt is meant to be editable,
962 * `dateMask` should contain at least one editable date token.
963 *
964 * @stability unstable
965 * @category constructors
966 * @since 4.0.0
967 */
968export const Date = (options: DateOptions): Prompt<globalThis.Date> => {
969 const opts: DateOptionsReq = {
970 initial: new globalThis.Date(),
971 dateMask: "YYYY-MM-DD HH:mm:ss",
972 validate: Effect.succeed,
973 ...options,
974 locales: {
975 ...defaultLocales,
976 ...options.locales
977 }
978 }
979 const dateParts = makeDateParts(opts.dateMask, opts.initial, opts.locales)
980 const initialCursorPosition = dateParts.findIndex((part) => !part.isToken())
981 const initialState: DateState = {
982 dateParts,
983 typed: "",
984 cursor: initialCursorPosition,
985 value: opts.initial,
986 error: Option.none()
987 }
988 return Custom(initialState, {
989 render: handleDateRender(opts),
990 process: handleDateProcess(opts),
991 clear: handleDateClear(opts)
992 })
993}
994
995/**
996 * Creates a file-system selection prompt and returns the selected path.
997 *
998 * **Details**
999 *
1000 * The prompt can be configured to select files, directories, or either path
1001 * type.
1002 *
1003 * You can also type to filter the listed entries. Every printable character is
1004 * appended to the filter query, so navigation is bound to the arrow keys,
1005 * `tab`, and the `Ctrl+P` / `Ctrl+N` chords used by `readline` and `fzf`
1006 * (`Ctrl+K` also moves up). `Ctrl+U` clears the query.
1007 *
1008 * @stability unstable
1009 * @category constructors
1010 * @since 4.0.0
1011 */
1012export const File = (options: FileOptions = {}): Prompt<string> => {
1013 const opts: FileOptionsReq = {
1014 ...options,
1015 type: options.type ?? "file",
1016 message: options.message ?? `Choose a file`,
1017 startingPath: Option.fromUndefinedOr(options.startingPath),
1018 default: Option.fromUndefinedOr(options.default),
1019 maxPerPage: options.maxPerPage ?? 10,
1020 filter: options.filter ?? (() => Effect.succeed(true))
1021 }
1022 const initialState: Effect.Effect<
1023 FileState,
1024 never,
1025 Environment
1026 > = Effect.gen(function*() {
1027 const currentPath = yield* resolveCurrentPath(Option.none(), opts)
1028 const path = yield* Path.Path
1029 const defaultPath = Option.map(opts.default, (defaultValue) => path.resolve(currentPath, defaultValue))
1030 const initialPath = Option.match(defaultPath, {
1031 onNone: () => currentPath,
1032 onSome: (defaultPath) => path.dirname(defaultPath)
1033 })
1034 const files = yield* getFileList(initialPath, opts)
1035 const cursor = Option.match(defaultPath, {
1036 onNone: () => 0,
1037 onSome: (defaultPath) => {
1038 const index = files.indexOf(path.basename(defaultPath))
1039 return index === -1 ? 0 : index
1040 }
1041 })
1042 const confirm = FileConfirm.Hide()
1043 return { cursor, files, allFiles: files, query: "", path: Option.map(defaultPath, path.dirname), confirm }
1044 })
1045 return Custom(initialState, {
1046 render: handleFileRender(opts),
1047 process: handleFileProcess(opts),
1048 clear: handleFileClear(opts)
1049 })
1050}
1051
1052/**
1053 * Composes prompts by using the output of this prompt to create the next prompt.
1054 *
1055 * @stability unstable
1056 * @category combinators
1057 * @since 4.0.0
1058 */
1059export const flatMap: {
1060 <Output, Output2>(
1061 f: (output: Output) => Prompt<Output2>
1062 ): (self: Prompt<Output>) => Prompt<Output2>
1063 <Output, Output2>(
1064 self: Prompt<Output>,
1065 f: (output: Output) => Prompt<Output2>
1066 ): Prompt<Output2>
1067} = dual(2, <Output, Output2>(
1068 self: Prompt<Output>,
1069 f: (output: Output) => Prompt<Output2>
1070) => {
1071 const op = Object.create(proto)
1072 op._tag = "OnSuccess"
1073 op.prompt = self
1074 op.onSuccess = f
1075 return op
1076})
1077
1078/**
1079 * Creates a floating-point number prompt.
1080 *
1081 * **Details**
1082 *
1083 * The prompt supports minimum and maximum bounds, keyboard step sizes, display
1084 * precision, and additional validation before submission.
1085 *
1086 * @stability unstable
1087 * @category constructors
1088 * @since 4.0.0
1089 */
1090export const Number = (options: NumberOptions): Prompt<number> => {
1091 const opts: NumberOptionsReq = {
1092 default: 0,
1093 min: globalThis.Number.NEGATIVE_INFINITY,
1094 max: globalThis.Number.POSITIVE_INFINITY,
1095 incrementBy: 1,
1096 decrementBy: 1,
1097 precision: 2,
1098 validate: (n) => {
1099 if (n < opts.min) {
1100 return Effect.fail(`${n} must be greater than or equal to ${opts.min}`)
1101 }
1102 if (n > opts.max) {
1103 return Effect.fail(`${n} must be less than or equal to ${opts.max}`)
1104 }
1105 return Effect.succeed(n)
1106 },
1107 ...options
1108 }
1109 const initialValue = options.default === undefined ? "" : `${opts.default}`
1110 const initialState: NumberState = {
1111 cursor: initialValue.length,
1112 value: initialValue,
1113 error: Option.none()
1114 }
1115 return Custom(initialState, {
1116 render: handleRenderFloat(opts),
1117 process: handleProcessFloat(opts),
1118 clear: handleNumberClear(opts)
1119 })
1120}
1121/**
1122 * Creates a text prompt that does not echo typed input and returns the
1123 * submitted value wrapped in `Redacted`.
1124 *
1125 * @stability unstable
1126 * @category constructors
1127 * @since 4.0.0
1128 */
1129export const Hidden = (
1130 options: TextOptions
1131): Prompt<Redacted.Redacted> => basePrompt(options, "hidden").pipe(map(Redacted.make))
1132
1133/**
1134 * Creates an integer prompt.
1135 *
1136 * **Details**
1137 *
1138 * The prompt supports minimum and maximum bounds, keyboard step sizes, and
1139 * additional validation before submission.
1140 *
1141 * @stability unstable
1142 * @category constructors
1143 * @since 4.0.0
1144 */
1145export const Int = (options: IntOptions): Prompt<number> => {
1146 const opts: IntOptionsReq = {
1147 default: 0,
1148 min: globalThis.Number.NEGATIVE_INFINITY,
1149 max: globalThis.Number.POSITIVE_INFINITY,
1150 incrementBy: 1,
1151 decrementBy: 1,
1152 validate: (n) => {
1153 if (n < opts.min) {
1154 return Effect.fail(`${n} must be greater than or equal to ${opts.min}`)
1155 }
1156 if (n > opts.max) {
1157 return Effect.fail(`${n} must be less than or equal to ${opts.max}`)
1158 }
1159 return Effect.succeed(n)
1160 },
1161 ...options
1162 }
1163 const initialValue = options.default === undefined ? "" : `${opts.default}`
1164 const initialState: NumberState = {
1165 cursor: initialValue.length,
1166 value: initialValue,
1167 error: Option.none()
1168 }
1169 return Custom(initialState, {
1170 render: handleRenderInteger(opts),
1171 process: handleProcessInteger(opts),
1172 clear: handleNumberClear(opts)
1173 })
1174}
1175
1176/**
1177 * Creates a text prompt that returns an array of strings by splitting the
1178 * submitted input on the configured delimiter.
1179 *
1180 * @stability unstable
1181 * @category constructors
1182 * @since 4.0.0
1183 */
1184export const List = (options: ListOptions): Prompt<Array<string>> =>
1185 String(options).pipe(
1186 map((output) => output.split(options.delimiter || ","))
1187 )
1188
1189/**
1190 * Transforms the output value produced by a prompt.
1191 *
1192 * @stability unstable
1193 * @category combinators
1194 * @since 4.0.0
1195 */
1196export const map: {
1197 <Output, Output2>(
1198 f: (output: Output) => Output2
1199 ): (self: Prompt<Output>) => Prompt<Output2>
1200 <Output, Output2>(
1201 self: Prompt<Output>,
1202 f: (output: Output) => Output2
1203 ): Prompt<Output2>
1204} = dual(2, <Output, Output2>(
1205 self: Prompt<Output>,
1206 f: (output: Output) => Output2
1207) => flatMap(self, (a) => succeed(f(a))))
1208
1209/**
1210 * Creates a password prompt that masks typed input and returns the submitted
1211 * value wrapped in `Redacted`.
1212 *
1213 * @stability unstable
1214 * @category constructors
1215 * @since 4.0.0
1216 */
1217export const Password = (
1218 options: TextOptions
1219): Prompt<Redacted.Redacted> => basePrompt(options, "password").pipe(map(Redacted.make))
1220
1221/**
1222 * Runs a prompt by reading terminal input and rendering prompt frames until the
1223 * prompt submits a value.
1224 *
1225 * **Gotchas**
1226 *
1227 * The returned effect may fail with `Terminal.QuitError` if terminal input ends
1228 * or the prompt is quit.
1229 *
1230 * @stability unstable
1231 * @category running
1232 * @since 4.0.0
1233 */
1234export const run: <Output>(
1235 self: Prompt<Output>
1236) => Effect.Effect<
1237 Output,
1238 Terminal.QuitError,
1239 Environment
1240> = Effect.fnUntraced(
1241 function*<Output>(self: Prompt<Output>) {
1242 const terminal = yield* Terminal.Terminal
1243 const input = yield* terminal.readInput
1244 return yield* runWithInput(self, terminal, input)
1245 },
1246 Effect.mapError(() => new Terminal.QuitError({})),
1247 Effect.scoped
1248)
1249
1250const getSelectInitialIndex = <A>(choices: ReadonlyArray<SelectChoice<A>>): number => {
1251 let initialIndex = 0
1252 let seenSelected = -1
1253 for (let i = 0; i < choices.length; i++) {
1254 const choice = choices[i] as SelectChoice<A>
1255 if (choice.selected === true) {
1256 if (seenSelected !== -1) {
1257 throw new Error("InvalidArgumentException: only a single choice can be selected by default for Prompt.Select")
1258 }
1259 seenSelected = i
1260 }
1261 }
1262 if (seenSelected !== -1) {
1263 initialIndex = seenSelected
1264 }
1265 return initialIndex
1266}
1267
1268/**
1269 * Creates a prompt that lets the user select a single value from a list of
1270 * choices.
1271 *
1272 * **Gotchas**
1273 *
1274 * At most one choice may be marked as selected by default.
1275 *
1276 * @stability unstable
1277 * @category constructors
1278 * @since 4.0.0
1279 */
1280export const Select = <const A>(options: SelectOptions<A>): Prompt<A> => {
1281 const opts: SelectOptionsReq<A> = {
1282 maxPerPage: 10,
1283 ...options
1284 }
1285 const initialIndex = getSelectInitialIndex(opts.choices)
1286 return Custom(initialIndex, {
1287 render: handleSelectRender(opts),
1288 process: handleSelectProcess(opts),
1289 clear: handleSelectClear(opts)
1290 })
1291}
1292
1293/**
1294 * Creates a prompt that lets users filter select choices by typing.
1295 *
1296 * **Details**
1297 *
1298 * Every printable character is appended to the filter query, so navigation is
1299 * bound to the arrow keys, `tab`, and the `Ctrl+P` / `Ctrl+N` chords used by
1300 * `readline` and `fzf` (`Ctrl+K` also moves up). `Ctrl+U` clears the query.
1301 *
1302 * **Example** (Filtering choices with autocomplete)
1303 *
1304 * ```ts import.meta.vitest
1305 * import { Prompt } from "effect/cli"
1306 *
1307 * const language = Prompt.AutoComplete({
1308 * message: "Choose a language",
1309 * choices: [
1310 * { title: "TypeScript", value: "ts" },
1311 * { title: "Rust", value: "rs" },
1312 * { title: "Kotlin", value: "kt" }
1313 * ]
1314 * })
1315 *
1316 * Prompt.isPrompt(language) // => true
1317 * ```
1318 *
1319 * @stability unstable
1320 * @category constructors
1321 * @since 4.0.0
1322 */
1323export const AutoComplete = <const A>(options: AutoCompleteOptions<A>): Prompt<A> => {
1324 const opts: AutoCompleteOptionsReq<A> = {
1325 maxPerPage: 10,
1326 filterLabel: "filter",
1327 filterPlaceholder: "type to filter",
1328 emptyMessage: "No matches",
1329 ...options
1330 }
1331 const initialIndex = getSelectInitialIndex(opts.choices)
1332 const filtered = filterAutoCompleteChoices(opts.choices, "")
1333 const index = filtered.length === 0
1334 ? 0
1335 : filtered.includes(initialIndex)
1336 ? initialIndex
1337 : filtered[0]
1338 const initialState: AutoCompleteState = {
1339 query: "",
1340 index,
1341 filtered
1342 }
1343 return Custom(initialState, {
1344 render: handleAutoCompleteRender(opts),
1345 process: handleAutoCompleteProcess(opts),
1346 clear: handleAutoCompleteClear(opts)
1347 })
1348}
1349
1350/**
1351 * Creates a prompt that lets the user select multiple choices and returns their
1352 * values as an array.
1353 *
1354 * **Details**
1355 *
1356 * The prompt supports default selected choices, bulk-selection commands, and
1357 * minimum or maximum selection counts.
1358 *
1359 * @stability unstable
1360 * @category constructors
1361 * @since 4.0.0
1362 */
1363export const MultiSelect = <const A>(
1364 options: SelectOptions<A> & MultiSelectOptions
1365): Prompt<Array<A>> => {
1366 const opts: SelectOptionsReq<A> & MultiSelectOptionsReq = {
1367 maxPerPage: 10,
1368 ...options
1369 }
1370 // Seed initial selection from choices marked as selected: true
1371 const initialSelected = new Set<number>()
1372 for (let i = 0; i < opts.choices.length; i++) {
1373 const choice = opts.choices[i] as SelectChoice<A>
1374 if (choice.selected === true && !choice.disabled) {
1375 initialSelected.add(i)
1376 }
1377 }
1378 const initialState: MultiSelectState = { index: 0, selectedIndices: initialSelected, error: Option.none() }
1379 return Custom(initialState, {
1380 render: handleMultiSelectRender(opts),
1381 process: handleMultiSelectProcess(opts),
1382 clear: handleMultiSelectClear(opts)
1383 })
1384}
1385
1386/**
1387 * Creates a `Prompt` which immediately succeeds with the specified value.
1388 *
1389 * **Details**
1390 *
1391 * This prompt does not attempt to obtain user input or render anything to the
1392 * screen.
1393 *
1394 * @stability unstable
1395 * @category constructors
1396 * @since 4.0.0
1397 */
1398export const succeed = <A>(value: A): Prompt<A> => {
1399 const op = Object.create(proto)
1400 op._tag = "Succeed"
1401 op.value = value
1402 return op
1403}
1404
1405/**
1406 * Creates a text-entry prompt that echoes input and returns the submitted
1407 * string after validation.
1408 *
1409 * @stability unstable
1410 * @category constructors
1411 * @since 4.0.0
1412 */
1413export const String = (
1414 options: TextOptions
1415): Prompt<string> => basePrompt(options, "text")
1416
1417/**
1418 * Creates a toggle prompt that lets the user switch between active and inactive
1419 * states and returns the selected boolean value.
1420 *
1421 * @stability unstable
1422 * @category constructors
1423 * @since 4.0.0
1424 */
1425export const Toggle = (options: ToggleOptions): Prompt<boolean> => {
1426 const opts: ToggleOptionsReq = {
1427 initial: false,
1428 active: "on",
1429 inactive: "off",
1430 ...options
1431 }
1432 return Custom(opts.initial, {
1433 render: handleToggleRender(opts),
1434 process: handleToggleProcess,
1435 clear: () => handleToggleClear(opts)
1436 })
1437}
1438
1439const proto = {
1440 ...Effectable.Prototype<Prompt<any>>({
1441 label: "Prompt",
1442 evaluate() {
1443 return run(this)
1444 }
1445 }),
1446 [TypeId]: {
1447 _Output: (_: never) => _
1448 }
1449}
1450
1451type Op<Tag extends string, Body = {}> = Prompt<never> & Body & {
1452 readonly _tag: Tag
1453}
1454
1455type PromptPrimitive = Loop | OnSuccess | Succeed
1456
1457interface Loop extends
1458 Op<"Loop", {
1459 readonly initialState: unknown | Effect.Effect<unknown, never, Environment>
1460 readonly render: Handlers<unknown, unknown>["render"]
1461 readonly process: (
1462 input: unknown,
1463 state: unknown
1464 ) => Effect.Effect<Action<unknown, unknown>, never, Environment>
1465 readonly clear: Handlers<unknown, unknown>["clear"]
1466 readonly events: Queue.Dequeue<unknown, never> | undefined
1467 }>
1468{}
1469
1470/**
1471 * @internal
1472 */
1473export interface OnSuccess extends
1474 Op<"OnSuccess", {
1475 readonly prompt: PromptPrimitive
1476 readonly onSuccess: (value: unknown) => Prompt<unknown>
1477 }>
1478{}
1479
1480interface Succeed extends
1481 Op<"Succeed", {
1482 readonly value: unknown
1483 }>
1484{}
1485
1486const allTupled = <const T extends ArrayLike<Prompt<any>>>(arg: T): Prompt<
1487 {
1488 [K in keyof T]: [T[K]] extends [Prompt<infer A>] ? A : never
1489 }
1490> => {
1491 if (arg.length === 0) {
1492 return succeed([]) as any
1493 }
1494 if (arg.length === 1) {
1495 return map(arg[0], (x) => [x]) as any
1496 }
1497 let result = map(arg[0], (x) => [x])
1498 for (let i = 1; i < arg.length; i++) {
1499 const curr = arg[i]
1500 result = flatMap(result, (tuple) => map(curr, (a) => [...tuple, a]))
1501 }
1502 return result as any
1503}
1504
1505const runWithInput = <Output>(
1506 prompt: Prompt<Output>,
1507 terminal: Terminal.Terminal,
1508 input: Queue.Dequeue<Terminal.UserInput, Cause.Done>
1509): Effect.Effect<Output, NoSuchElementError, Environment> =>
1510 Effect.suspend(() => {
1511 const op = prompt as PromptPrimitive
1512 switch (op._tag) {
1513 case "Loop": {
1514 return runLoop(op, terminal, input)
1515 }
1516 case "OnSuccess": {
1517 return Effect.flatMap(
1518 runWithInput(op.prompt, terminal, input),
1519 (a) => runWithInput(op.onSuccess(a), terminal, input)
1520 ) as any
1521 }
1522 case "Succeed": {
1523 return Effect.succeed(op.value)
1524 }
1525 }
1526 })
1527
1528const runLoop = Effect.fnUntraced(
1529 function*(
1530 loop: Loop,
1531 terminal: Terminal.Terminal,
1532 input: Queue.Dequeue<Terminal.UserInput, Cause.Done>
1533 ) {
1534 let state = Effect.isEffect(loop.initialState) ? yield* loop.initialState : loop.initialState
1535 let action: Action<unknown, unknown> = Action.NextFrame({ state })
1536 let clear = ""
1537 while (true) {
1538 const msg = yield* loop.render(state, action)
1539 yield* Effect.orDie(terminal.display(clear + msg))
1540 clear = ""
1541 if (loop.events) {
1542 const takeInput = Queue.take(input).pipe(
1543 Effect.map((input) => ({ _tag: "Input" as const, input }))
1544 )
1545 const result = yield* Effect.raceFirst(
1546 takeInput,
1547 Queue.take(loop.events).pipe(Effect.map((value) => ({ _tag: "Event" as const, value })))
1548 )
1549 action = yield* loop.process(result, state)
1550 } else {
1551 const result = yield* Queue.take(input)
1552 action = yield* loop.process(result, state)
1553 }
1554 switch (action._tag) {
1555 case "Beep":
1556 continue
1557 case "NextFrame": {
1558 clear = yield* loop.clear(state, action)
1559 state = action.state
1560 continue
1561 }
1562 case "Submit": {
1563 clear = yield* loop.clear(state, action)
1564 const msg = yield* loop.render(state, action)
1565 yield* Effect.orDie(terminal.display(clear + msg))
1566 return action.value
1567 }
1568 }
1569 }
1570 },
1571 (effect, _, terminal) => Effect.ensuring(effect, Effect.orDie(terminal.display(Ansi.cursorShow)))
1572)
1573
1574const Action = Data.taggedEnum<ActionDefinition>()
1575
1576/**
1577 * Clears all lines taken up by the specified `text`.
1578 */
1579const eraseText = (text: string, columns: number): string => {
1580 if (columns === 0) {
1581 return Ansi.eraseLine + Ansi.cursorTo(0)
1582 }
1583 let rows = 0
1584 const lines = text.split(NEWLINE_REGEXP)
1585 for (const line of lines) {
1586 rows += 1 + Math.floor(Math.max(line.length - 1, 0) / columns)
1587 }
1588 return Ansi.eraseLines(rows)
1589}
1590
1591const lines = (prompt: string, columns: number): number => {
1592 const lines = prompt.split(NEWLINE_REGEXP)
1593 return columns === 0
1594 ? lines.length
1595 : pipe(
1596 Arr.map(lines, (line) => Math.ceil(line.length / columns)),
1597 Arr.reduce(0, (left, right) => left + right)
1598 )
1599}
1600
1601const clearOutputWithError = (outputText: string, columns: number, errorText?: string): string => {
1602 if (errorText !== undefined && errorText.length > 0) {
1603 return Ansi.cursorDown(lines(errorText, columns))
1604 + eraseText(`\n${errorText}`, columns)
1605 + eraseText(outputText, columns)
1606 }
1607 return eraseText(outputText, columns)
1608}
1609
1610interface ConfirmOptionsReq extends OptionsReq<ConfirmOptions> {}
1611
1612interface ConfirmState {
1613 readonly value: boolean
1614}
1615
1616const renderBeep = Ansi.beep
1617
1618const NEWLINE_REGEXP = /\r?\n/
1619
1620const handleConfirmClear = (options: ConfirmOptionsReq) => {
1621 return Effect.fnUntraced(function*(state: ConfirmState, _: Action<ConfirmState, boolean>) {
1622 const terminal = yield* Terminal.Terminal
1623 const columns = yield* terminal.columns
1624 const figures = yield* getTheme(options)
1625 const confirmMessage = state.value
1626 ? options.placeholder.defaultConfirm!
1627 : options.placeholder.defaultDeny!
1628 const promptText = renderConfirmOutput(
1629 confirmMessage,
1630 figures.prefix,
1631 figures.pointerSmall,
1632 options,
1633 { plain: true }
1634 )
1635 const clearOutput = eraseText(promptText, columns)
1636 const resetCurrentLine = Ansi.eraseLine + Ansi.cursorLeft
1637 return clearOutput + resetCurrentLine
1638 })
1639}
1640
1641const renderConfirmOutput = (
1642 confirm: string,
1643 leadingSymbol: string,
1644 trailingSymbol: string,
1645 options: ConfirmOptionsReq,
1646 renderOptions?: RenderOptions | undefined
1647) => renderPrompt(confirm, options.message, leadingSymbol, trailingSymbol, renderOptions)
1648
1649const renderConfirmNextFrame = Effect.fnUntraced(function*(state: ConfirmState, options: ConfirmOptionsReq) {
1650 const figures = yield* getTheme(options)
1651 const leadingSymbol = annotateSymbol(figures.prefix, figures.primaryColor)
1652 const trailingSymbol = annotateSymbol(figures.pointerSmall, figures.mutedColor)
1653 // Marking these explicitly as present with `!` because they always will be
1654 // and there is really no value in adding a `DeepRequired` type helper just
1655 // for these internal cases
1656 const confirmMessage = state.value
1657 ? options.placeholder.defaultConfirm!
1658 : options.placeholder.defaultDeny!
1659 const confirm = Ansi.annotate(confirmMessage, figures.mutedColor)
1660 const promptMsg = renderConfirmOutput(confirm, leadingSymbol, trailingSymbol, options)
1661 return Ansi.cursorHide + promptMsg
1662})
1663
1664const renderConfirmSubmission = Effect.fnUntraced(function*(value: boolean, options: ConfirmOptionsReq) {
1665 const figures = yield* getTheme(options)
1666 const leadingSymbol = annotateSymbol(figures.tick, figures.successColor)
1667 const trailingSymbol = annotateSymbol(figures.ellipsis, figures.mutedColor)
1668 const confirmMessage = value ? options.label.confirm : options.label.deny
1669 const promptMsg = renderConfirmOutput(confirmMessage, leadingSymbol, trailingSymbol, options)
1670 return promptMsg + "\n"
1671})
1672
1673const handleConfirmRender = (options: ConfirmOptionsReq) => {
1674 return (_: ConfirmState, action: Action<ConfirmState, boolean>) => {
1675 return Action.$match(action, {
1676 Beep: () => Effect.succeed(renderBeep),
1677 NextFrame: ({ state }) => renderConfirmNextFrame(state, options),
1678 Submit: ({ value }) => renderConfirmSubmission(value, options)
1679 })
1680 }
1681}
1682
1683const TRUE_VALUE_REGEXP = /^y|t$/
1684const FALSE_VALUE_REGEXP = /^n|f$/
1685
1686const handleConfirmProcess = (input: Terminal.UserInput, defaultValue: boolean) => {
1687 const value = Option.getOrElse(input.input, () => "")
1688 if (input.key.name === "enter" || input.key.name === "return") {
1689 return Effect.succeed(Action.Submit({ value: defaultValue }))
1690 }
1691 if (TRUE_VALUE_REGEXP.test(value.toLowerCase())) {
1692 return Effect.succeed(Action.Submit({ value: true }))
1693 }
1694 if (FALSE_VALUE_REGEXP.test(value.toLowerCase())) {
1695 return Effect.succeed(Action.Submit({ value: false }))
1696 }
1697 return Effect.succeed(Action.Beep())
1698}
1699
1700interface DateOptionsReq extends OptionsReq<DateOptions> {}
1701
1702interface DateState {
1703 readonly typed: string
1704 readonly cursor: number
1705 readonly value: globalThis.Date
1706 readonly dateParts: ReadonlyArray<DatePart>
1707 readonly error: Option.Option<string>
1708}
1709
1710const handleDateClear = (options: DateOptionsReq) => {
1711 return Effect.fnUntraced(function*(state: DateState, _: Action<DateState, globalThis.Date>) {
1712 const terminal = yield* Terminal.Terminal
1713 const columns = yield* terminal.columns
1714 const figures = yield* getTheme(options)
1715 const resetCurrentLine = Ansi.eraseLine + Ansi.cursorLeft
1716 const parts = Arr.reduce(state.dateParts, "", (doc, part) => doc + part.toString())
1717 const promptText = renderDateOutput(figures.prefix, figures.pointerSmall, parts, options, { plain: true })
1718 const errorText = Option.isSome(state.error)
1719 ? Arr.match(state.error.value.split(NEWLINE_REGEXP), {
1720 onEmpty: () => "",
1721 onNonEmpty: (errorLines) => separateSymbol(figures.pointerSmall, errorLines.join("\n"))
1722 })
1723 : ""
1724 const clearOutput = clearOutputWithError(promptText, columns, errorText)
1725 return clearOutput + resetCurrentLine
1726 })
1727}
1728
1729const renderDateError = (state: DateState, pointer: string, theme: Theme): string => {
1730 if (Option.isSome(state.error)) {
1731 const errorLines = state.error.value.split(NEWLINE_REGEXP)
1732 if (Arr.isReadonlyArrayNonEmpty(errorLines)) {
1733 const prefix = annotateSymbol(pointer, theme.errorColor)
1734 const lines = Arr.map(errorLines, (str) => annotateErrorLine(str, theme.errorColor))
1735 return Ansi.cursorSavePosition + "\n" + separateSymbol(prefix, lines.join("\n")) + Ansi.cursorRestorePosition
1736 }
1737 }
1738 return ""
1739}
1740
1741const renderParts = (state: DateState, theme: Theme, submitted: boolean = false) => {
1742 return Arr.reduce(
1743 state.dateParts,
1744 "",
1745 (doc, part, currentIndex) => {
1746 const partDoc = part.toString()
1747 if (currentIndex === state.cursor && !submitted) {
1748 const annotation = Ansi.combine(Ansi.underlined, theme.primaryColor)
1749 return doc + Ansi.annotate(partDoc, annotation)
1750 }
1751 return doc + partDoc
1752 }
1753 )
1754}
1755
1756const renderDateOutput = (
1757 leadingSymbol: string,
1758 trailingSymbol: string,
1759 parts: string,
1760 options: DateOptionsReq,
1761 renderOptions?: RenderOptions | undefined
1762) => renderPrompt(parts, options.message, leadingSymbol, trailingSymbol, renderOptions)
1763
1764const renderDateNextFrame = Effect.fnUntraced(function*(state: DateState, options: DateOptionsReq) {
1765 const figures = yield* getTheme(options)
1766 const leadingSymbol = annotateSymbol(figures.prefix, figures.primaryColor)
1767 const trailingSymbol = annotateSymbol(figures.pointerSmall, figures.mutedColor)
1768 const parts = renderParts(state, figures)
1769 const promptMsg = renderDateOutput(leadingSymbol, trailingSymbol, parts, options)
1770 const errorMsg = renderDateError(state, figures.pointerSmall, figures)
1771 return Ansi.cursorHide + promptMsg + errorMsg
1772})
1773
1774const renderDateSubmission = Effect.fnUntraced(function*(state: DateState, options: DateOptionsReq) {
1775 const figures = yield* getTheme(options)
1776 const leadingSymbol = annotateSymbol(figures.tick, figures.successColor)
1777 const trailingSymbol = annotateSymbol(figures.ellipsis, figures.mutedColor)
1778 const parts = renderParts(state, figures, true)
1779 const promptMsg = renderDateOutput(leadingSymbol, trailingSymbol, parts, options)
1780 return promptMsg + "\n"
1781})
1782
1783const processUp = (state: DateState) => {
1784 state.dateParts[state.cursor].increment()
1785 return Action.NextFrame({
1786 state: { ...state, typed: "" }
1787 })
1788}
1789
1790const processDown = (state: DateState) => {
1791 state.dateParts[state.cursor].decrement()
1792 return Action.NextFrame({
1793 state: { ...state, typed: "" }
1794 })
1795}
1796
1797const processDateCursorLeft = (state: DateState) => {
1798 const previous = state.dateParts[state.cursor].previousPart()
1799 if (Option.isSome(previous)) {
1800 return Action.NextFrame({
1801 state: {
1802 ...state,
1803 typed: "",
1804 cursor: state.dateParts.indexOf(previous.value)
1805 }
1806 })
1807 }
1808 return Action.Beep()
1809}
1810
1811const processDateCursorRight = (state: DateState) => {
1812 const next = state.dateParts[state.cursor].nextPart()
1813 if (Option.isSome(next)) {
1814 return Action.NextFrame({
1815 state: {
1816 ...state,
1817 typed: "",
1818 cursor: state.dateParts.indexOf(next.value)
1819 }
1820 })
1821 }
1822 return Action.Beep()
1823}
1824
1825const processDateNext = (state: DateState) => {
1826 const next = state.dateParts[state.cursor].nextPart()
1827 const cursor = Option.match(next, {
1828 onNone: () => state.dateParts.findIndex((part) => !part.isToken()),
1829 onSome: (next) => state.dateParts.indexOf(next)
1830 })
1831 return Action.NextFrame({
1832 state: { ...state, typed: "", cursor }
1833 })
1834}
1835
1836const defaultDateProcessor = (value: string, state: DateState) => {
1837 if (/\d/.test(value)) {
1838 const typed = state.typed + value
1839 state.dateParts[state.cursor].setValue(typed)
1840 return Action.NextFrame({
1841 state: { ...state, typed }
1842 })
1843 }
1844 return Action.Beep()
1845}
1846
1847const defaultLocales: DateOptionsReq["locales"] = {
1848 months: [
1849 "January",
1850 "February",
1851 "March",
1852 "April",
1853 "May",
1854 "June",
1855 "July",
1856 "August",
1857 "September",
1858 "October",
1859 "November",
1860 "December"
1861 ],
1862 monthsShort: ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"],
1863 weekdays: ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"],
1864 weekdaysShort: ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]
1865}
1866
1867const handleDateRender = (options: DateOptionsReq) => {
1868 return (state: DateState, action: Action<DateState, globalThis.Date>) => {
1869 return Action.$match(action, {
1870 Beep: () => Effect.succeed(renderBeep),
1871 NextFrame: ({ state }) => renderDateNextFrame(state, options),
1872 Submit: () => renderDateSubmission(state, options)
1873 })
1874 }
1875}
1876
1877const handleDateProcess = (options: DateOptionsReq) => {
1878 return (input: Terminal.UserInput, state: DateState) => {
1879 switch (input.key.name) {
1880 case "left": {
1881 return Effect.succeed(processDateCursorLeft(state))
1882 }
1883 case "right": {
1884 return Effect.succeed(processDateCursorRight(state))
1885 }
1886 case "k":
1887 case "up": {
1888 return Effect.succeed(processUp(state))
1889 }
1890 case "j":
1891 case "down": {
1892 return Effect.succeed(processDown(state))
1893 }
1894 case "tab": {
1895 return Effect.succeed(processDateNext(state))
1896 }
1897 case "enter":
1898 case "return": {
1899 return Effect.match(options.validate(state.value), {
1900 onFailure: (error) =>
1901 Action.NextFrame({
1902 state: {
1903 ...state,
1904 error: Option.some(error)
1905 }
1906 }),
1907 onSuccess: (value) => Action.Submit({ value })
1908 })
1909 }
1910 default: {
1911 return Effect.succeed(defaultDateProcessor(Option.getOrElse(input.input, () => ""), state))
1912 }
1913 }
1914 }
1915}
1916
1917const DATE_PART_REGEXP =
1918 /\\(.)|"((?:\\["\\]|[^"])+)"|(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
1919
1920const regExpGroups: Record<number, (params: DatePartParams) => DatePart> = {
1921 1: ({ token, ...opts }) => new Token({ token: token.replace(/\\(.)/g, "$1"), ...opts }),
1922 2: (opts) => new Day(opts),
1923 3: (opts) => new Month(opts),
1924 4: (opts) => new Year(opts),
1925 5: (opts) => new Meridiem(opts),
1926 6: (opts) => new Hours(opts),
1927 7: (opts) => new Minutes(opts),
1928 8: (opts) => new Seconds(opts),
1929 9: (opts) => new Milliseconds(opts)
1930}
1931
1932const makeDateParts = (
1933 dateMask: string,
1934 date: globalThis.Date,
1935 locales: DateOptions["locales"]
1936) => {
1937 const parts: Array<DatePart> = []
1938 let result: RegExpExecArray | null = null
1939 // oxlint-disable-next-line no-cond-assign
1940 while (result = DATE_PART_REGEXP.exec(dateMask)) {
1941 const match = result.shift()
1942 const index = result.findIndex((group) => group !== undefined)
1943 if (index in regExpGroups) {
1944 const token = (result[index] || match)!
1945 parts.push(regExpGroups[index]({ token, date, parts, locales }))
1946 } else {
1947 parts.push(new Token({ token: (result[index] || match)!, date, parts, locales }))
1948 }
1949 }
1950 const orderedParts = parts.reduce((array, element) => {
1951 const lastElement = array[array.length - 1]
1952 if (element.isToken() && lastElement !== undefined && lastElement.isToken()) {
1953 lastElement.setValue(element.token)
1954 } else {
1955 array.push(element)
1956 }
1957 return array
1958 }, Arr.empty<DatePart>())
1959 parts.splice(0, parts.length, ...orderedParts)
1960 return parts
1961}
1962
1963interface DatePartParams {
1964 readonly token: string
1965 readonly locales: DateOptions["locales"]
1966 readonly date?: globalThis.Date
1967 readonly parts?: ReadonlyArray<DatePart>
1968}
1969
1970abstract class DatePart {
1971 token: string
1972 readonly date: globalThis.Date
1973 readonly parts: ReadonlyArray<DatePart>
1974 readonly locales: DateOptions["locales"]
1975
1976 constructor(params: DatePartParams) {
1977 this.token = params.token
1978 this.locales = params.locales
1979 this.date = params.date || new globalThis.Date()
1980 this.parts = params.parts || [this]
1981 }
1982
1983 /**
1984 * Increments this date part.
1985 */
1986 abstract increment(): void
1987
1988 /**
1989 * Decrements this date part.
1990 */
1991 abstract decrement(): void
1992
1993 /**
1994 * Sets the current value of this date part to the provided value.
1995 */
1996 abstract setValue(value: string): void
1997
1998 /**
1999 * Returns `true` if this `DatePart` is a `Token`, `false` otherwise.
2000 */
2001 isToken(): this is Token {
2002 return false
2003 }
2004
2005 /**
2006 * Retrieves the next date part in the list of parts.
2007 */
2008 nextPart(): Option.Option<DatePart> {
2009 const currentPartIndex = Option.getOrElse(Arr.findFirstIndex(this.parts, (part) => part === this), () => 0)
2010 return Arr.findFirst(this.parts.slice(currentPartIndex + 1), (part) => !part.isToken())
2011 }
2012
2013 /**
2014 * Retrieves the previous date part in the list of parts.
2015 */
2016 previousPart(): Option.Option<DatePart> {
2017 const currentPartIndex = Arr.findFirstIndex(this.parts, (part) => part === this)
2018 if (Option.isSome(currentPartIndex)) {
2019 return Arr.findLast(this.parts.slice(0, currentPartIndex.value), (part) => !part.isToken())
2020 }
2021 return Option.none()
2022 }
2023
2024 toString() {
2025 return globalThis.String(this.date)
2026 }
2027}
2028
2029class Token extends DatePart {
2030 increment(): void {}
2031
2032 decrement(): void {}
2033
2034 setValue(value: string): void {
2035 this.token = this.token + value
2036 }
2037
2038 override isToken(): this is Token {
2039 return true
2040 }
2041
2042 override toString() {
2043 return this.token
2044 }
2045}
2046
2047class Milliseconds extends DatePart {
2048 increment(): void {
2049 this.date.setMilliseconds(this.date.getMilliseconds() + 1)
2050 }
2051
2052 decrement(): void {
2053 this.date.setMilliseconds(this.date.getMilliseconds() - 1)
2054 }
2055
2056 setValue(value: string): void {
2057 this.date.setMilliseconds(globalThis.Number.parseInt(value.slice(-this.token.length)))
2058 }
2059
2060 override toString() {
2061 const millis = `${this.date.getMilliseconds()}`
2062 return millis.padStart(4, "0").substring(0, this.token.length)
2063 }
2064}
2065
2066class Seconds extends DatePart {
2067 increment(): void {
2068 this.date.setSeconds(this.date.getSeconds() + 1)
2069 }
2070
2071 decrement(): void {
2072 this.date.setSeconds(this.date.getSeconds() - 1)
2073 }
2074
2075 setValue(value: string): void {
2076 this.date.setSeconds(globalThis.Number.parseInt(value.slice(-2)))
2077 }
2078
2079 override toString() {
2080 const seconds = `${this.date.getSeconds()}`
2081 return this.token.length > 1
2082 ? seconds.padStart(2, "0")
2083 : seconds
2084 }
2085}
2086
2087class Minutes extends DatePart {
2088 increment(): void {
2089 this.date.setMinutes(this.date.getMinutes() + 1)
2090 }
2091
2092 decrement(): void {
2093 this.date.setMinutes(this.date.getMinutes() - 1)
2094 }
2095
2096 setValue(value: string): void {
2097 this.date.setMinutes(globalThis.Number.parseInt(value.slice(-2)))
2098 }
2099
2100 override toString() {
2101 const minutes = `${this.date.getMinutes()}`
2102 return this.token.length > 1
2103 ? minutes.padStart(2, "0") :
2104 minutes
2105 }
2106}
2107
2108class Hours extends DatePart {
2109 increment(): void {
2110 this.date.setHours(this.date.getHours() + 1)
2111 }
2112
2113 decrement(): void {
2114 this.date.setHours(this.date.getHours() - 1)
2115 }
2116
2117 setValue(value: string): void {
2118 this.date.setHours(globalThis.Number.parseInt(value.slice(-2)))
2119 }
2120
2121 override toString() {
2122 const hours = /h/.test(this.token)
2123 ? this.date.getHours() % 12 || 12
2124 : this.date.getHours()
2125 return this.token.length > 1
2126 ? `${hours}`.padStart(2, "0")
2127 : `${hours}`
2128 }
2129}
2130
2131class Day extends DatePart {
2132 increment(): void {
2133 this.date.setDate(this.date.getDate() + 1)
2134 }
2135
2136 decrement(): void {
2137 this.date.setDate(this.date.getDate() - 1)
2138 }
2139
2140 setValue(value: string): void {
2141 this.date.setDate(globalThis.Number.parseInt(value.slice(-2)))
2142 }
2143
2144 override toString() {
2145 const date = this.date.getDate()
2146 const day = this.date.getDay()
2147 switch (this.token) {
2148 case "DD":
2149 return `${date}`.padStart(2, "0")
2150 case "Do":
2151 return `${date}${this.ordinalIndicator(date)}`
2152 case "d":
2153 return `${day + 1}`
2154 case "ddd":
2155 return this.locales!.weekdaysShort[day]!
2156 case "dddd":
2157 return this.locales!.weekdays[day]!
2158 default:
2159 return `${date}`
2160 }
2161 }
2162
2163 private ordinalIndicator(day: number): string {
2164 if (day >= 11 && day <= 13) {
2165 return "th"
2166 }
2167 switch (day % 10) {
2168 case 1:
2169 return "st"
2170 case 2:
2171 return "nd"
2172 case 3:
2173 return "rd"
2174 default:
2175 return "th"
2176 }
2177 }
2178}
2179
2180class Month extends DatePart {
2181 increment(): void {
2182 this.date.setMonth(this.date.getMonth() + 1)
2183 }
2184
2185 decrement(): void {
2186 this.date.setMonth(this.date.getMonth() - 1)
2187 }
2188
2189 setValue(value: string): void {
2190 const month = globalThis.Number.parseInt(value.slice(-2)) - 1
2191 this.date.setMonth(month < 0 ? 0 : month)
2192 }
2193
2194 override toString() {
2195 const month = this.date.getMonth()
2196 switch (this.token.length) {
2197 case 2:
2198 return `${month + 1}`.padStart(2, "0")
2199 case 3:
2200 return this.locales!.monthsShort[month]!
2201 case 4:
2202 return this.locales!.months[month]!
2203 default:
2204 return `${month + 1}`
2205 }
2206 }
2207}
2208
2209class Year extends DatePart {
2210 increment(): void {
2211 this.date.setFullYear(this.date.getFullYear() + 1)
2212 }
2213
2214 decrement(): void {
2215 this.date.setFullYear(this.date.getFullYear() - 1)
2216 }
2217
2218 setValue(value: string): void {
2219 this.date.setFullYear(globalThis.Number.parseInt(value.slice(-4)))
2220 }
2221
2222 override toString() {
2223 const year = `${this.date.getFullYear()}`.padStart(4, "0")
2224 return this.token.length === 2
2225 ? year.slice(-2)
2226 : year
2227 }
2228}
2229
2230class Meridiem extends DatePart {
2231 increment(): void {
2232 this.date.setHours((this.date.getHours() + 12) % 24)
2233 }
2234
2235 decrement(): void {
2236 this.increment()
2237 }
2238
2239 setValue(_value: string): void {}
2240
2241 override toString() {
2242 const meridiem = this.date.getHours() >= 12 ? "pm" : "am"
2243 return /A/.test(this.token)
2244 ? meridiem.toUpperCase()
2245 : meridiem
2246 }
2247}
2248
2249interface FileOptionsReq extends OptionsReq<Omit<FileOptions, "startingPath" | "default">> {
2250 readonly startingPath: Option.Option<string>
2251 readonly default: Option.Option<string>
2252}
2253
2254interface FileState {
2255 readonly cursor: number
2256 readonly files: ReadonlyArray<string>
2257 readonly allFiles: ReadonlyArray<string>
2258 readonly query: string
2259 readonly path: Option.Option<string>
2260 readonly confirm: FileConfirm
2261}
2262
2263const CONFIRM_MESSAGE = "The selected directory contains files. Would you like to traverse the selected directory?"
2264const FILE_FILTER_LABEL = "filter"
2265const FILE_FILTER_PLACEHOLDER = "type to filter"
2266const FILE_EMPTY_MESSAGE = "No matches"
2267type FileConfirm = Data.TaggedEnum<{
2268 readonly Show: {}
2269 readonly Hide: {}
2270}>
2271const FileConfirm = Data.taggedEnum<FileConfirm>()
2272
2273const showConfirmation = FileConfirm.$is("Show")
2274
2275const resolveCurrentPath = (
2276 path: Option.Option<string>,
2277 options: FileOptionsReq
2278): Effect.Effect<string, never, FileSystem.FileSystem> => {
2279 if (Option.isSome(path)) {
2280 return Effect.succeed(path.value)
2281 }
2282 if (Option.isSome(options.startingPath)) {
2283 const startingPath = options.startingPath.value
2284 return Effect.flatMap(FileSystem.FileSystem, (fs) =>
2285 // Ensure the user provided starting path exists
2286 Effect.orDie(fs.exists(startingPath)).pipe(
2287 Effect.flatMap((exists) =>
2288 exists ? Effect.void : Effect.die(
2289 `The provided starting path '${startingPath}' does not exist`
2290 )
2291 ),
2292 Effect.as(startingPath)
2293 ))
2294 }
2295 return Effect.sync(() => process.cwd())
2296}
2297
2298const getFileList = Effect.fnUntraced(function*(directory: string, options: FileOptionsReq) {
2299 const fs = yield* FileSystem.FileSystem
2300 const path = yield* Path.Path
2301 const files = yield* Effect.orDie(fs.readDirectory(directory)).pipe(
2302 // Always prepend the `".."` option to the file list but allow it
2303 // to be filtered out if the user so desires
2304 Effect.map((files) => ["..", ...files])
2305 )
2306 return yield* Effect.filter(files, (file) => {
2307 const result = options.filter(file)
2308 const userDefinedFilter = Effect.isEffect(result)
2309 ? result
2310 : Effect.succeed(result)
2311 const directoryFilter = options.type === "directory"
2312 ? Effect.map(
2313 Effect.orDie(fs.stat(path.join(directory, file))),
2314 (info) => info.type === "Directory"
2315 )
2316 : Effect.succeed(true)
2317 return Effect.zipWith(userDefinedFilter, directoryFilter, (a, b) => a && b)
2318 }, { concurrency: files.length })
2319})
2320
2321const filterFiles = (files: ReadonlyArray<string>, query: string) => {
2322 if (query.length === 0) {
2323 return files
2324 }
2325 const normalizedQuery = query.toLowerCase()
2326 const filtered: Array<string> = []
2327 for (let index = 0; index < files.length; index++) {
2328 if (files[index].toLowerCase().includes(normalizedQuery)) {
2329 filtered.push(files[index])
2330 }
2331 }
2332 return filtered
2333}
2334
2335const updateFileState = (
2336 state: FileState,
2337 query: string,
2338 allFiles: ReadonlyArray<string> = state.allFiles
2339): FileState => {
2340 const files = filterFiles(allFiles, query)
2341 if (files.length === 0) {
2342 return { ...state, query, allFiles, files, cursor: 0 }
2343 }
2344 const selected = state.files[state.cursor]
2345 const cursor = selected === undefined ? 0 : files.indexOf(selected)
2346 return {
2347 ...state,
2348 query,
2349 allFiles,
2350 files,
2351 cursor: cursor === -1 ? 0 : cursor
2352 }
2353}
2354
2355const handleFileClear = (options: FileOptionsReq) => {
2356 return Effect.fnUntraced(function*(state: FileState, _: Action<FileState, string>) {
2357 const terminal = yield* Terminal.Terminal
2358 const columns = yield* terminal.columns
2359 const path = yield* Path.Path
2360 const figures = yield* getTheme(options)
2361 const currentPath = yield* resolveCurrentPath(state.path, options)
2362 const selectedPath = state.files[state.cursor]
2363 const resolvedPath = selectedPath === undefined ? currentPath : path.resolve(currentPath, selectedPath)
2364 const resolvedPathText = separateSymbol(figures.pointerSmall, resolvedPath)
2365 const isConfirming = showConfirmation(state.confirm)
2366 const promptText = isConfirming
2367 ? renderPrompt("(Y/n)", CONFIRM_MESSAGE, figures.prefix, figures.pointerSmall, { plain: true })
2368 : renderPrompt(
2369 renderFileFilter(state, figures, { plain: true }),
2370 options.message,
2371 figures.tick,
2372 figures.ellipsis,
2373 {
2374 plain: true
2375 }
2376 )
2377 const filesText = isConfirming
2378 ? ""
2379 : renderFiles(state, state.files, figures, options, { plain: true })
2380 const outputText = isConfirming
2381 ? `${promptText}\n${resolvedPathText}`
2382 : `${promptText}\n${resolvedPathText}\n${filesText}`
2383 const clearOutput = eraseText(outputText, columns)
2384 const resetCurrentLine = Ansi.eraseLine + Ansi.cursorLeft
2385 return clearOutput + resetCurrentLine
2386 })
2387}
2388
2389type RenderOptions = {
2390 readonly plain?: boolean
2391}
2392
2393const renderPrompt = (
2394 confirm: string,
2395 message: string,
2396 leadingSymbol: string,
2397 trailingSymbol: string,
2398 options?: RenderOptions | undefined
2399) => {
2400 const prefix = leadingSymbol.length === 0 ? "" : leadingSymbol + " "
2401 const renderLine = (line: string) => {
2402 let output = prefix + line
2403 if (trailingSymbol.length > 0) {
2404 output += " " + trailingSymbol
2405 }
2406 if (confirm.length > 0) {
2407 output += " " + confirm
2408 } else if (trailingSymbol.length > 0) {
2409 output += " "
2410 }
2411 return output
2412 }
2413 const annotate = options?.plain === true
2414 ? (line: string) => line
2415 : annotateLine
2416 return Arr.match(message.split(NEWLINE_REGEXP), {
2417 onEmpty: () => renderLine(""),
2418 onNonEmpty: (promptLines) => {
2419 const lines = Arr.map(promptLines, (line) => annotate(line))
2420 return renderLine(lines.join("\n"))
2421 }
2422 })
2423}
2424
2425const renderPrefix = (
2426 state: FileState,
2427 toDisplay: { readonly startIndex: number; readonly endIndex: number },
2428 currentIndex: number,
2429 length: number,
2430 figures: Theme,
2431 renderOptions?: RenderOptions | undefined
2432) => {
2433 const prefix = renderPagingPrefix(
2434 figures,
2435 currentIndex === toDisplay.startIndex && toDisplay.startIndex > 0,
2436 currentIndex === toDisplay.endIndex - 1 && toDisplay.endIndex < length
2437 )
2438 if (state.cursor === currentIndex) {
2439 return renderOptions?.plain === true
2440 ? figures.pointer + prefix
2441 : annotateSymbol(figures.pointer, figures.primaryColor) + prefix
2442 }
2443 return prefix + " ".repeat(figures.pointer.length)
2444}
2445
2446const renderFileName = (
2447 file: string,
2448 isSelected: boolean,
2449 theme: Theme,
2450 renderOptions?: RenderOptions | undefined
2451) => {
2452 if (renderOptions?.plain === true) {
2453 return file
2454 }
2455 return isSelected
2456 ? Ansi.annotate(file, Ansi.combine(Ansi.underlined, theme.primaryColor))
2457 : file
2458}
2459
2460const renderFileFilter = (state: FileState, theme: Theme, renderOptions?: RenderOptions | undefined) => {
2461 const filterValue = state.query.length === 0
2462 ? renderOptions?.plain === true
2463 ? FILE_FILTER_PLACEHOLDER
2464 : Ansi.annotate(FILE_FILTER_PLACEHOLDER, theme.mutedColor)
2465 : renderOptions?.plain === true
2466 ? state.query
2467 : Ansi.annotate(state.query, Ansi.combine(Ansi.underlined, theme.primaryColor))
2468 return `[${FILE_FILTER_LABEL}: ${filterValue}]`
2469}
2470
2471const renderFiles = (
2472 state: FileState,
2473 files: ReadonlyArray<string>,
2474 figures: Theme,
2475 options: FileOptionsReq,
2476 renderOptions?: RenderOptions | undefined
2477) => {
2478 const length = files.length
2479 if (length === 0) {
2480 return renderOptions?.plain === true
2481 ? FILE_EMPTY_MESSAGE
2482 : Ansi.annotate(FILE_EMPTY_MESSAGE, figures.mutedColor)
2483 }
2484 const toDisplay = entriesToDisplay(state.cursor, length, options.maxPerPage)
2485 const documents: Array<string> = []
2486 for (let index = toDisplay.startIndex; index < toDisplay.endIndex; index++) {
2487 const isSelected = state.cursor === index
2488 const prefix = renderPrefix(state, toDisplay, index, length, figures, renderOptions)
2489 const fileName = renderFileName(files[index], isSelected, figures, renderOptions)
2490 documents.push(prefix + fileName)
2491 }
2492 return documents.join("\n")
2493}
2494
2495const renderFileNextFrame = Effect.fnUntraced(function*(state: FileState, options: FileOptionsReq) {
2496 const path = yield* Path.Path
2497 const figures = yield* getTheme(options)
2498 const currentPath = yield* resolveCurrentPath(state.path, options)
2499 const selectedPath = state.files[state.cursor]
2500 const resolvedPath = selectedPath === undefined ? currentPath : path.resolve(currentPath, selectedPath)
2501 const resolvedPathMsg = Ansi.annotate(separateSymbol(figures.pointerSmall, resolvedPath), figures.mutedColor)
2502
2503 if (showConfirmation(state.confirm)) {
2504 const leadingSymbol = annotateSymbol(figures.prefix, figures.primaryColor)
2505 const trailingSymbol = annotateSymbol(figures.pointerSmall, figures.mutedColor)
2506 const confirm = Ansi.annotate("(Y/n)", figures.mutedColor)
2507 const promptMsg = renderPrompt(confirm, CONFIRM_MESSAGE, leadingSymbol, trailingSymbol)
2508 return Ansi.cursorHide + promptMsg + "\n" + resolvedPathMsg
2509 }
2510 const leadingSymbol = annotateSymbol(figures.tick, figures.successColor)
2511 const trailingSymbol = annotateSymbol(figures.ellipsis, figures.mutedColor)
2512 const promptMsg = renderPrompt(renderFileFilter(state, figures), options.message, leadingSymbol, trailingSymbol)
2513 const files = renderFiles(state, state.files, figures, options)
2514 return Ansi.cursorHide + promptMsg + "\n" + resolvedPathMsg + "\n" + files
2515})
2516
2517const renderFileSubmission = Effect.fnUntraced(function*(state: FileState, value: string, options: FileOptionsReq) {
2518 const figures = yield* getTheme(options)
2519 const leadingSymbol = annotateSymbol(figures.tick, figures.successColor)
2520 const trailingSymbol = annotateSymbol(figures.ellipsis, figures.mutedColor)
2521 const promptMsg = renderPrompt(renderFileFilter(state, figures), options.message, leadingSymbol, trailingSymbol)
2522 return promptMsg + " " + Ansi.annotate(value, figures.submittedColor) + "\n"
2523})
2524
2525const handleFileRender = (options: FileOptionsReq) => {
2526 return (
2527 state: FileState,
2528 action: Action<FileState, string>
2529 ): Effect.Effect<string, never, Path.Path | FileSystem.FileSystem> => {
2530 return Action.$match(action, {
2531 Beep: () => Effect.succeed(renderBeep),
2532 NextFrame: ({ state }) => renderFileNextFrame(state, options),
2533 Submit: ({ value }) => renderFileSubmission(state, value, options)
2534 })
2535 }
2536}
2537
2538const processFileCursorUp = (state: FileState) => {
2539 if (state.files.length === 0) {
2540 return Effect.succeed(Action.Beep())
2541 }
2542 const cursor = state.cursor - 1
2543 return Effect.succeed(Action.NextFrame({
2544 state: { ...state, cursor: cursor < 0 ? state.files.length - 1 : cursor }
2545 }))
2546}
2547
2548const processFileCursorDown = (state: FileState) => {
2549 if (state.files.length === 0) {
2550 return Effect.succeed(Action.Beep())
2551 }
2552 return Effect.succeed(Action.NextFrame({
2553 state: { ...state, cursor: (state.cursor + 1) % state.files.length }
2554 }))
2555}
2556
2557const processFileBackspace = (state: FileState) => {
2558 if (state.query.length === 0) {
2559 return Effect.succeed(Action.Beep())
2560 }
2561 const query = state.query.slice(0, state.query.length - 1)
2562 return Effect.succeed(Action.NextFrame({ state: updateFileState(state, query) }))
2563}
2564
2565const processFileClear = (state: FileState) => Effect.succeed(Action.NextFrame({ state: updateFileState(state, "") }))
2566
2567const processFileInput = (input: string, state: FileState) => {
2568 if (input.length === 0) {
2569 return Effect.succeed(Action.Beep())
2570 }
2571 const query = state.query + input
2572 return Effect.succeed(Action.NextFrame({ state: updateFileState(state, query) }))
2573}
2574
2575const processSelection = Effect.fnUntraced(function*(state: FileState, options: FileOptionsReq) {
2576 if (state.files.length === 0) {
2577 return Action.Beep()
2578 }
2579 const fs = yield* FileSystem.FileSystem
2580 const path = yield* Path.Path
2581 const currentPath = yield* resolveCurrentPath(state.path, options)
2582 const selectedPath = state.files[state.cursor]
2583 const resolvedPath = path.resolve(currentPath, selectedPath)
2584 const info = yield* Effect.orDie(fs.stat(resolvedPath))
2585 if (info.type === "Directory") {
2586 const files = yield* getFileList(resolvedPath, options)
2587 const filesWithoutParent = files.filter((file) => file !== "..")
2588 // If the user selected a directory AND the prompt type can result with
2589 // a directory, we must confirm:
2590 // - If the selected directory has any files
2591 // - Confirm whether or not the user wants to traverse those files
2592 if (options.type === "directory" || options.type === "either") {
2593 return filesWithoutParent.length === 0
2594 // Directory is empty so it's safe to select it
2595 ? Action.Submit({ value: resolvedPath })
2596 // Directory has contents - show confirmation to user
2597 : Action.NextFrame({
2598 state: { ...state, confirm: FileConfirm.Show() }
2599 })
2600 }
2601 return Action.NextFrame({
2602 state: {
2603 cursor: 0,
2604 files,
2605 allFiles: files,
2606 query: "",
2607 path: Option.some(resolvedPath),
2608 confirm: FileConfirm.Hide()
2609 }
2610 })
2611 }
2612 return Action.Submit({ value: resolvedPath })
2613})
2614
2615const handleFileProcess = (options: FileOptionsReq) => {
2616 return Effect.fnUntraced(function*(input: Terminal.UserInput, state: FileState) {
2617 // Navigation is bound to ctrl chords rather than bare `j`/`k`, so that every
2618 // printable character reaches the filter input.
2619 if (input.key.ctrl) {
2620 switch (input.key.name) {
2621 case "u": {
2622 if (showConfirmation(state.confirm)) {
2623 return Action.Beep()
2624 }
2625 return yield* processFileClear(state)
2626 }
2627 case "p":
2628 case "k": {
2629 return yield* processFileCursorUp(state)
2630 }
2631 case "n": {
2632 return yield* processFileCursorDown(state)
2633 }
2634 default: {
2635 return Action.Beep()
2636 }
2637 }
2638 }
2639 switch (input.key.name) {
2640 case "up": {
2641 return yield* processFileCursorUp(state)
2642 }
2643 case "down":
2644 case "tab": {
2645 return yield* processFileCursorDown(state)
2646 }
2647 case "backspace": {
2648 if (showConfirmation(state.confirm)) {
2649 return Action.Beep()
2650 }
2651 return yield* processFileBackspace(state)
2652 }
2653 case "enter":
2654 case "return": {
2655 return yield* processSelection(state, options)
2656 }
2657 case "y":
2658 case "t": {
2659 if (showConfirmation(state.confirm)) {
2660 const path = yield* Path.Path
2661 const currentPath = yield* resolveCurrentPath(state.path, options)
2662 const selectedPath = state.files[state.cursor]
2663 const resolvedPath = path.resolve(currentPath, selectedPath)
2664 const files = yield* getFileList(resolvedPath, options)
2665 return Action.NextFrame({
2666 state: {
2667 cursor: 0,
2668 files,
2669 allFiles: files,
2670 query: "",
2671 path: Option.some(resolvedPath),
2672 confirm: FileConfirm.Hide()
2673 }
2674 })
2675 }
2676 return yield* processFileInput(Option.getOrElse(input.input, () => ""), state)
2677 }
2678 case "n":
2679 case "f": {
2680 if (showConfirmation(state.confirm)) {
2681 const path = yield* Path.Path
2682 const currentPath = yield* resolveCurrentPath(state.path, options)
2683 const selectedPath = state.files[state.cursor]
2684 const resolvedPath = path.resolve(currentPath, selectedPath)
2685 return Action.Submit({ value: resolvedPath })
2686 }
2687 return yield* processFileInput(Option.getOrElse(input.input, () => ""), state)
2688 }
2689 default: {
2690 if (showConfirmation(state.confirm)) {
2691 return Action.Beep()
2692 }
2693 return yield* processFileInput(Option.getOrElse(input.input, () => ""), state)
2694 }
2695 }
2696 })
2697}
2698
2699interface SelectOptionsReq<A> extends SelectOptions<A> {
2700 readonly maxPerPage: number
2701}
2702interface MultiSelectOptionsReq extends MultiSelectOptions {}
2703
2704type MultiSelectState = {
2705 index: number
2706 selectedIndices: Set<number>
2707 error: Option.Option<string>
2708}
2709
2710const renderMultiSelectError = (
2711 state: MultiSelectState,
2712 pointer: string,
2713 theme: Theme,
2714 renderOptions?: RenderOptions | undefined
2715): string => {
2716 if (Option.isSome(state.error)) {
2717 return Arr.match(state.error.value.split(NEWLINE_REGEXP), {
2718 onEmpty: () => "",
2719 onNonEmpty: (errorLines) => {
2720 if (renderOptions?.plain === true) {
2721 return separateSymbol(pointer, errorLines.join("\n"))
2722 }
2723 const prefix = annotateSymbol(pointer, theme.errorColor)
2724 const lines = Arr.map(errorLines, (str) => annotateErrorLine(str, theme.errorColor))
2725 return Ansi.cursorSavePosition + "\n" + separateSymbol(prefix, lines.join("\n")) + Ansi.cursorRestorePosition
2726 }
2727 })
2728 }
2729 return ""
2730}
2731
2732const renderChoiceDescription = <A>(
2733 choice: SelectChoice<A>,
2734 isActive: boolean,
2735 theme: Theme,
2736 renderOptions?: RenderOptions | undefined
2737) => {
2738 if (!choice.disabled && choice.description && isActive) {
2739 const text = theme.descriptionSeparator + choice.description
2740 return renderOptions?.plain === true
2741 ? text
2742 : Ansi.annotate(text, theme.mutedColor)
2743 }
2744 return ""
2745}
2746
2747const metaOptionsCount = 2
2748
2749const renderMultiSelectTitle = (
2750 title: string,
2751 isHighlighted: boolean,
2752 theme: Theme,
2753 renderOptions?: RenderOptions | undefined
2754) => {
2755 if (renderOptions?.plain === true || !isHighlighted) {
2756 return title
2757 }
2758 return Ansi.annotate(title, Ansi.combine(Ansi.underlined, theme.primaryColor))
2759}
2760
2761const renderMultiSelectChoices = <A>(
2762 state: MultiSelectState,
2763 options: SelectOptionsReq<A> & MultiSelectOptionsReq,
2764 figures: Theme,
2765 renderOptions?: RenderOptions | undefined
2766) => {
2767 const choices = options.choices
2768 const checkboxWidth = Math.max(figures.checkboxOn.length, figures.checkboxOff.length)
2769 const selectableCount = choices.filter((choice) => !choice.disabled).length
2770 const selectedCount = Array.from(state.selectedIndices).filter((index) => !choices[index].disabled).length
2771 const allSelected = selectedCount === selectableCount
2772
2773 const selectAllText = allSelected
2774 ? options?.selectNone ?? "Select None"
2775 : options?.selectAll ?? "Select All"
2776
2777 const inverseSelectionText = options?.inverseSelection ?? "Inverse Selection"
2778
2779 const metaOptions = [
2780 { title: selectAllText },
2781 { title: inverseSelectionText }
2782 ]
2783 const allChoices = [...metaOptions, ...choices]
2784 const toDisplay = entriesToDisplay(state.index, allChoices.length, options.maxPerPage)
2785 const documents: Array<string> = []
2786 for (let index = toDisplay.startIndex; index < toDisplay.endIndex; index++) {
2787 const choice = allChoices[index]
2788 const isHighlighted = state.index === index
2789 const prefix = renderPagingPrefix(
2790 figures,
2791 index === toDisplay.startIndex && toDisplay.startIndex > 0,
2792 index === toDisplay.endIndex - 1 && toDisplay.endIndex < allChoices.length
2793 )
2794 if (index < metaOptions.length) {
2795 // Meta options
2796 const title = renderMultiSelectTitle(choice.title, isHighlighted, figures, renderOptions)
2797 documents.push(prefix + (prefix.length === 0 ? "" : " ") + title)
2798 } else {
2799 // Regular choices
2800 const choiceIndex = index - metaOptions.length
2801 const isSelected = state.selectedIndices.has(choiceIndex)
2802 const checkbox = isSelected ? figures.checkboxOn : figures.checkboxOff
2803 const annotatedCheckbox = isHighlighted && renderOptions?.plain !== true
2804 ? Ansi.annotate(checkbox, figures.primaryColor)
2805 : checkbox
2806 const selectChoice = choice as SelectChoice<A>
2807 const title = renderChoiceTitle(selectChoice, isHighlighted, figures, renderOptions)
2808 const description = renderChoiceDescription(selectChoice, isHighlighted, figures, renderOptions)
2809 const checkboxPrefix = checkboxWidth === 0
2810 ? ""
2811 : annotatedCheckbox + " ".repeat(checkboxWidth - checkbox.length + 1)
2812 documents.push(
2813 prefix + (prefix.length === 0 ? "" : " ") + checkboxPrefix + title + " " + description
2814 )
2815 }
2816 }
2817 return documents.join("\n")
2818}
2819
2820const renderMultiSelectNextFrame = Effect.fnUntraced(
2821 function*<A>(state: MultiSelectState, options: SelectOptionsReq<A>) {
2822 const figures = yield* getTheme(options)
2823 const choices = renderMultiSelectChoices(state, options, figures)
2824 const leadingSymbol = annotateSymbol(figures.prefix, figures.primaryColor)
2825 const trailingSymbol = annotateSymbol(figures.pointerSmall, figures.mutedColor)
2826 const promptMsg = renderSelectOutput(leadingSymbol, trailingSymbol, options)
2827 const error = renderMultiSelectError(state, figures.pointer, figures)
2828 return Ansi.cursorHide + withSelectHeader(promptMsg, choices) + error
2829 }
2830)
2831
2832const renderMultiSelectSubmission = Effect.fnUntraced(
2833 function*<A>(state: MultiSelectState, options: SelectOptionsReq<A>) {
2834 const figures = yield* getTheme(options)
2835 const selectedChoices = Array.from(state.selectedIndices).sort(EffectNumber.Order).map((index) =>
2836 options.choices[index].title
2837 )
2838 const selectedText = selectedChoices.join(", ")
2839 const leadingSymbol = annotateSymbol(figures.tick, figures.successColor)
2840 const trailingSymbol = annotateSymbol(figures.ellipsis, figures.mutedColor)
2841 const promptMsg = renderSelectOutput(leadingSymbol, trailingSymbol, options) ?? leadingSymbol
2842 return promptMsg + " " + Ansi.annotate(selectedText, figures.submittedColor) + "\n"
2843 }
2844)
2845
2846const processMultiSelectCursorUp = (state: MultiSelectState, totalChoices: number) => {
2847 const newIndex = state.index === 0 ? totalChoices - 1 : state.index - 1
2848 return Effect.succeed(Action.NextFrame({ state: { ...state, index: newIndex } }))
2849}
2850
2851const processMultiSelectCursorDown = (state: MultiSelectState, totalChoices: number) => {
2852 const newIndex = (state.index + 1) % totalChoices
2853 return Effect.succeed(Action.NextFrame({ state: { ...state, index: newIndex } }))
2854}
2855
2856const processSpace = <A>(
2857 state: MultiSelectState,
2858 options: SelectOptionsReq<A>
2859) => {
2860 const selectedIndices = new Set(state.selectedIndices)
2861 if (state.index === 0) {
2862 const selectableCount = options.choices.filter((choice) => !choice.disabled).length
2863 const selectedCount = Array.from(state.selectedIndices).filter((index) => !options.choices[index].disabled).length
2864 if (selectedCount === selectableCount) {
2865 selectedIndices.clear()
2866 } else {
2867 for (let i = 0; i < options.choices.length; i++) {
2868 if (!options.choices[i].disabled) {
2869 selectedIndices.add(i)
2870 }
2871 }
2872 }
2873 } else if (state.index === 1) {
2874 for (let i = 0; i < options.choices.length; i++) {
2875 if (options.choices[i].disabled || state.selectedIndices.has(i)) {
2876 selectedIndices.delete(i)
2877 } else {
2878 selectedIndices.add(i)
2879 }
2880 }
2881 } else {
2882 const choiceIndex = state.index - metaOptionsCount
2883 if (options.choices[choiceIndex].disabled) {
2884 return Effect.succeed(Action.Beep())
2885 } else if (selectedIndices.has(choiceIndex)) {
2886 selectedIndices.delete(choiceIndex)
2887 } else {
2888 selectedIndices.add(choiceIndex)
2889 }
2890 }
2891 return Effect.succeed(Action.NextFrame({ state: { ...state, selectedIndices } }))
2892}
2893
2894const handleMultiSelectClear = <A>(options: SelectOptionsReq<A>) =>
2895 Effect.fnUntraced(function*(state: MultiSelectState, _: Action<MultiSelectState, Array<A>>) {
2896 const terminal = yield* Terminal.Terminal
2897 const columns = yield* terminal.columns
2898 const figures = yield* getTheme(options)
2899 const clearPrompt = Ansi.eraseLine + Ansi.cursorLeft
2900 const promptText = renderSelectOutput(figures.prefix, figures.pointerSmall, options, { plain: true })
2901 const choicesText = renderMultiSelectChoices(state, options, figures, { plain: true })
2902 const errorText = renderMultiSelectError(state, figures.pointer, figures, { plain: true })
2903 const clearOutput = clearOutputWithError(withSelectHeader(promptText, choicesText), columns, errorText)
2904 return clearOutput + clearPrompt
2905 })
2906
2907const handleMultiSelectProcess = <A>(options: SelectOptionsReq<A> & MultiSelectOptionsReq) => {
2908 return (input: Terminal.UserInput, state: MultiSelectState) => {
2909 const totalChoices = options.choices.length + metaOptionsCount
2910 switch (input.key.name) {
2911 case "k":
2912 case "up": {
2913 return processMultiSelectCursorUp({ ...state, error: Option.none() }, totalChoices)
2914 }
2915 case "j":
2916 case "down":
2917 case "tab": {
2918 return processMultiSelectCursorDown({ ...state, error: Option.none() }, totalChoices)
2919 }
2920 case "space": {
2921 return processSpace(state, options)
2922 }
2923 case "enter":
2924 case "return": {
2925 const selectedIndices = Array.from(state.selectedIndices).filter((index) => !options.choices[index].disabled)
2926 const selectedCount = selectedIndices.length
2927 if (options.min !== undefined && selectedCount < options.min) {
2928 return Effect.succeed(
2929 Action.NextFrame({ state: { ...state, error: Option.some(`At least ${options.min} are required`) } })
2930 )
2931 }
2932 if (options.max !== undefined && selectedCount > options.max) {
2933 return Effect.succeed(
2934 Action.NextFrame({ state: { ...state, error: Option.some(`At most ${options.max} choices are allowed`) } })
2935 )
2936 }
2937 const selectedValues = selectedIndices.sort(EffectNumber.Order).map((index) => options.choices[index].value)
2938 return Effect.succeed(Action.Submit({ value: selectedValues }))
2939 }
2940 default: {
2941 return Effect.succeed(Action.Beep())
2942 }
2943 }
2944 }
2945}
2946
2947const handleMultiSelectRender = <A>(options: SelectOptionsReq<A>) => {
2948 return (state: MultiSelectState, action: Action<MultiSelectState, Array<A>>) => {
2949 return Action.$match(action, {
2950 Beep: () => Effect.succeed(renderBeep),
2951 NextFrame: ({ state }) => renderMultiSelectNextFrame(state, options),
2952 Submit: () => renderMultiSelectSubmission(state, options)
2953 })
2954 }
2955}
2956
2957interface IntOptionsReq extends OptionsReq<IntOptions> {}
2958interface NumberOptionsReq extends OptionsReq<NumberOptions> {}
2959
2960interface NumberState {
2961 readonly cursor: number
2962 readonly value: string
2963 readonly error: Option.Option<string>
2964}
2965
2966const handleNumberClear = (options: IntOptionsReq) => {
2967 return Effect.fnUntraced(function*(state: NumberState, _: Action<NumberState, number>) {
2968 const terminal = yield* Terminal.Terminal
2969 const columns = yield* terminal.columns
2970 const figures = yield* getTheme(options)
2971 const resetCurrentLine = Ansi.eraseLine + Ansi.cursorLeft
2972 const errorText = renderNumberError(state, figures.pointerSmall, figures, { plain: true })
2973 const promptText = renderNumberOutput(state, figures.prefix, figures.pointerSmall, options, figures, {
2974 plain: true
2975 })
2976 const clearOutput = clearOutputWithError(promptText, columns, errorText)
2977 return clearOutput + resetCurrentLine
2978 })
2979}
2980
2981const renderNumberInput = (
2982 state: NumberState,
2983 submitted: boolean,
2984 theme: Theme,
2985 renderOptions?: RenderOptions | undefined
2986): string => {
2987 const value = state.value === "" ? "" : `${state.value}`
2988 if (submitted || renderOptions?.plain === true) {
2989 return value
2990 }
2991 const annotation = Option.isSome(state.error) ?
2992 theme.errorColor :
2993 Ansi.combine(Ansi.underlined, theme.primaryColor)
2994 return Ansi.annotate(value, annotation)
2995}
2996
2997const renderNumberError = (
2998 state: NumberState,
2999 pointer: string,
3000 theme: Theme,
3001 renderOptions?: RenderOptions | undefined
3002) => {
3003 if (Option.isSome(state.error)) {
3004 return Arr.match(state.error.value.split(NEWLINE_REGEXP), {
3005 onEmpty: () => "",
3006 onNonEmpty: (errorLines) => {
3007 if (renderOptions?.plain === true) {
3008 return separateSymbol(pointer, errorLines.join("\n"))
3009 }
3010 const prefix = annotateSymbol(pointer, theme.errorColor)
3011 const lines = Arr.map(errorLines, (str) => annotateErrorLine(str, theme.errorColor))
3012 return Ansi.cursorSavePosition + "\n" + separateSymbol(prefix, lines.join("\n")) + Ansi.cursorRestorePosition
3013 }
3014 })
3015 }
3016 return ""
3017}
3018
3019const renderNumberOutput = (
3020 state: NumberState,
3021 leadingSymbol: string,
3022 trailingSymbol: string,
3023 options: IntOptionsReq,
3024 theme: Theme,
3025 renderOptions?: RenderOptions | undefined,
3026 submitted: boolean = false
3027) => {
3028 const value = renderNumberInput(state, submitted, theme, renderOptions)
3029 return renderPrompt(value, options.message, leadingSymbol, trailingSymbol, renderOptions)
3030}
3031
3032const renderNumberNextFrame = Effect.fnUntraced(function*(state: NumberState, options: IntOptionsReq) {
3033 const figures = yield* getTheme(options)
3034 const leadingSymbol = annotateSymbol(figures.prefix, figures.primaryColor)
3035 const trailingSymbol = annotateSymbol(figures.pointerSmall, figures.mutedColor)
3036 const errorMsg = renderNumberError(state, figures.pointerSmall, figures)
3037 const promptMsg = renderNumberOutput(state, leadingSymbol, trailingSymbol, options, figures)
3038 return promptMsg + errorMsg
3039})
3040
3041const renderNumberSubmission = Effect.fnUntraced(function*(nextState: NumberState, options: IntOptionsReq) {
3042 const figures = yield* getTheme(options)
3043 const leadingSymbol = annotateSymbol(figures.tick, figures.successColor)
3044 const trailingSymbol = annotateSymbol(figures.ellipsis, figures.mutedColor)
3045 const promptMsg = renderNumberOutput(nextState, leadingSymbol, trailingSymbol, options, figures, undefined, true)
3046 return promptMsg + "\n"
3047})
3048
3049const processNumberBackspace = (state: NumberState) => {
3050 if (state.value.length <= 0) {
3051 return Effect.succeed(Action.Beep())
3052 }
3053 const value = state.value.slice(0, state.value.length - 1)
3054 return Effect.succeed(Action.NextFrame({
3055 state: { ...state, value, error: Option.none() }
3056 }))
3057}
3058
3059const processNumberClear = (state: NumberState) =>
3060 Effect.succeed(Action.NextFrame({
3061 state: { ...state, cursor: 0, value: "", error: Option.none() }
3062 }))
3063
3064const defaultIntProcessor = (input: string, state: NumberState) => {
3065 if (state.value.length === 0 && input === "-") {
3066 return Effect.succeed(Action.NextFrame({
3067 state: { ...state, value: "-", error: Option.none() }
3068 }))
3069 }
3070
3071 const parsed = globalThis.Number.parseInt(state.value + input)
3072 if (globalThis.Number.isNaN(parsed)) {
3073 return Effect.succeed(Action.Beep())
3074 } else {
3075 return Effect.succeed(Action.NextFrame({
3076 state: { ...state, value: `${parsed}`, error: Option.none() }
3077 }))
3078 }
3079}
3080
3081const defaultFloatProcessor = (input: string, state: NumberState) => {
3082 if (input === "." && state.value.includes(".")) {
3083 return Effect.succeed(Action.Beep())
3084 }
3085 if (state.value.length === 0 && input === "-") {
3086 return Effect.succeed(Action.NextFrame({
3087 state: { ...state, value: "-", error: Option.none() }
3088 }))
3089 }
3090
3091 const parsed = globalThis.Number.parseFloat(state.value + input)
3092 if (globalThis.Number.isNaN(parsed)) {
3093 return Effect.succeed(Action.Beep())
3094 } else {
3095 return Effect.succeed(Action.NextFrame({
3096 state: {
3097 ...state,
3098 value: input === "."
3099 ? `${parsed}.`
3100 : state.value.includes(".") && /^\d$/.test(input)
3101 ? state.value + input
3102 : `${parsed}`,
3103 error: Option.none()
3104 }
3105 }))
3106 }
3107}
3108
3109const handleRenderInteger = (options: IntOptionsReq) => {
3110 return (state: NumberState, action: Action<NumberState, number>) => {
3111 return Action.$match(action, {
3112 Beep: () => Effect.succeed(renderBeep),
3113 NextFrame: ({ state }) => renderNumberNextFrame(state, options),
3114 Submit: () => renderNumberSubmission(state, options)
3115 })
3116 }
3117}
3118
3119const handleProcessInteger = (options: IntOptionsReq) => {
3120 return (input: Terminal.UserInput, state: NumberState) => {
3121 if (input.key.ctrl && input.key.name === "u") {
3122 return processNumberClear(state)
3123 }
3124 switch (input.key.name) {
3125 case "backspace": {
3126 return processNumberBackspace(state)
3127 }
3128 case "k":
3129 case "up": {
3130 return Effect.succeed(Action.NextFrame({
3131 state: {
3132 ...state,
3133 value: state.value === "" || state.value === "-"
3134 ? `${options.incrementBy}`
3135 : `${globalThis.Number.parseInt(state.value) + options.incrementBy}`,
3136 error: Option.none()
3137 }
3138 }))
3139 }
3140 case "j":
3141 case "down": {
3142 return Effect.succeed(Action.NextFrame({
3143 state: {
3144 ...state,
3145 value: state.value === "" || state.value === "-"
3146 ? `-${options.decrementBy}`
3147 : `${globalThis.Number.parseInt(state.value) - options.decrementBy}`,
3148 error: Option.none()
3149 }
3150 }))
3151 }
3152 case "enter":
3153 case "return": {
3154 const parsed = globalThis.Number.parseInt(state.value)
3155 if (globalThis.Number.isNaN(parsed)) {
3156 return Effect.succeed(Action.NextFrame({
3157 state: {
3158 ...state,
3159 error: Option.some("Must provide an integer value")
3160 }
3161 }))
3162 } else {
3163 return Effect.match(options.validate(parsed), {
3164 onFailure: (error) =>
3165 Action.NextFrame({
3166 state: {
3167 ...state,
3168 error: Option.some(error)
3169 }
3170 }),
3171 onSuccess: (value) => Action.Submit({ value })
3172 })
3173 }
3174 }
3175 default: {
3176 return defaultIntProcessor(Option.getOrElse(input.input, () => ""), state)
3177 }
3178 }
3179 }
3180}
3181
3182const handleRenderFloat = (options: NumberOptionsReq) => {
3183 return (state: NumberState, action: Action<NumberState, number>) => {
3184 return Action.$match(action, {
3185 Beep: () => Effect.succeed(renderBeep),
3186 NextFrame: ({ state }) => renderNumberNextFrame(state, options),
3187 Submit: () => renderNumberSubmission(state, options)
3188 })
3189 }
3190}
3191
3192const handleProcessFloat = (options: NumberOptionsReq) => {
3193 return (input: Terminal.UserInput, state: NumberState) => {
3194 if (input.key.ctrl && input.key.name === "u") {
3195 return processNumberClear(state)
3196 }
3197 switch (input.key.name) {
3198 case "backspace": {
3199 return processNumberBackspace(state)
3200 }
3201 case "k":
3202 case "up": {
3203 return Effect.succeed(Action.NextFrame({
3204 state: {
3205 ...state,
3206 value: state.value === "" || state.value === "-"
3207 ? `${options.incrementBy}`
3208 : `${globalThis.Number.parseFloat(state.value) + options.incrementBy}`,
3209 error: Option.none()
3210 }
3211 }))
3212 }
3213 case "j":
3214 case "down": {
3215 return Effect.succeed(Action.NextFrame({
3216 state: {
3217 ...state,
3218 value: state.value === "" || state.value === "-"
3219 ? `-${options.decrementBy}`
3220 : `${globalThis.Number.parseFloat(state.value) - options.decrementBy}`,
3221 error: Option.none()
3222 }
3223 }))
3224 }
3225 case "enter":
3226 case "return": {
3227 const parsed = globalThis.Number.parseFloat(state.value)
3228 if (globalThis.Number.isNaN(parsed)) {
3229 return Effect.succeed(Action.NextFrame({
3230 state: {
3231 ...state,
3232 error: Option.some("Must provide a floating point value")
3233 }
3234 }))
3235 } else {
3236 return Effect.flatMap(
3237 Effect.sync(() => EffectNumber.round(parsed, options.precision)),
3238 (rounded) =>
3239 Effect.match(options.validate(rounded), {
3240 onFailure: (error) =>
3241 Action.NextFrame({
3242 state: {
3243 ...state,
3244 error: Option.some(error)
3245 }
3246 }),
3247 onSuccess: (value) => Action.Submit({ value })
3248 })
3249 )
3250 }
3251 }
3252 default: {
3253 return defaultFloatProcessor(Option.getOrElse(input.input, () => ""), state)
3254 }
3255 }
3256 }
3257}
3258
3259type SelectState = number
3260
3261type AutoCompleteState = {
3262 readonly query: string
3263 readonly index: number
3264 readonly filtered: ReadonlyArray<number>
3265}
3266
3267interface AutoCompleteOptionsReq<A> extends OptionsReq<AutoCompleteOptions<A>> {}
3268
3269const filterAutoCompleteChoices = <A>(choices: ReadonlyArray<SelectChoice<A>>, query: string) => {
3270 const normalizedQuery = query.toLowerCase()
3271 const indices: Array<number> = []
3272 for (let i = 0; i < choices.length; i++) {
3273 if (choices[i].title.toLowerCase().includes(normalizedQuery)) {
3274 indices.push(i)
3275 }
3276 }
3277 return indices
3278}
3279
3280const updateAutoCompleteState = <A>(
3281 state: AutoCompleteState,
3282 options: AutoCompleteOptionsReq<A>,
3283 query: string
3284): AutoCompleteState => {
3285 const filtered = filterAutoCompleteChoices(options.choices, query)
3286 if (filtered.length === 0) {
3287 return { ...state, query, filtered, index: 0 }
3288 }
3289 if (filtered.includes(state.index)) {
3290 return { ...state, query, filtered }
3291 }
3292 return { ...state, query, filtered, index: filtered[0] }
3293}
3294
3295const autoCompleteCursor = (state: AutoCompleteState) =>
3296 Option.getOrElse(Arr.findFirstIndex(state.filtered, (index) => index === state.index), () => 0)
3297
3298const renderSelectOutput = <A>(
3299 leadingSymbol: string,
3300 trailingSymbol: string,
3301 options: SelectOptionsReq<A>,
3302 renderOptions?: RenderOptions | undefined
3303): string | undefined =>
3304 options.message === undefined
3305 ? undefined
3306 : renderPrompt("", options.message, leadingSymbol, trailingSymbol, renderOptions)
3307
3308const withSelectHeader = (header: string | undefined, body: string): string =>
3309 header === undefined ? body : header + "\n" + body
3310
3311const renderAutoCompleteFilter = <A>(
3312 state: AutoCompleteState,
3313 options: AutoCompleteOptionsReq<A>,
3314 theme: Theme,
3315 renderOptions?: RenderOptions | undefined
3316) => {
3317 const filterValue = state.query.length === 0
3318 ? renderOptions?.plain === true
3319 ? options.filterPlaceholder
3320 : Ansi.annotate(options.filterPlaceholder, theme.mutedColor)
3321 : renderOptions?.plain === true
3322 ? state.query
3323 : Ansi.annotate(state.query, Ansi.combine(Ansi.underlined, theme.primaryColor))
3324 return `[${options.filterLabel}: ${filterValue}]`
3325}
3326
3327const renderAutoCompleteOutput = <A>(
3328 state: AutoCompleteState,
3329 leadingSymbol: string,
3330 trailingSymbol: string,
3331 options: AutoCompleteOptionsReq<A>,
3332 theme: Theme,
3333 renderOptions?: RenderOptions | undefined
3334) => {
3335 const filter = renderAutoCompleteFilter(state, options, theme, renderOptions)
3336 return renderPrompt(filter, options.message, leadingSymbol, trailingSymbol, renderOptions)
3337}
3338
3339const renderChoicePrefix = <A>(
3340 state: SelectState,
3341 choices: SelectOptionsReq<A>["choices"],
3342 toDisplay: { readonly startIndex: number; readonly endIndex: number },
3343 currentIndex: number,
3344 figures: Theme,
3345 renderOptions?: RenderOptions | undefined
3346) => {
3347 const prefix = renderPagingPrefix(
3348 figures,
3349 currentIndex === toDisplay.startIndex && toDisplay.startIndex > 0,
3350 currentIndex === toDisplay.endIndex - 1 && toDisplay.endIndex < choices.length
3351 )
3352 if (renderOptions?.plain === true) {
3353 return state === currentIndex
3354 ? figures.pointer + prefix
3355 : prefix + " ".repeat(figures.pointer.length)
3356 }
3357 if (choices[currentIndex].disabled) {
3358 const annotation = Ansi.combine(Ansi.bold, figures.mutedColor)
3359 return state === currentIndex
3360 ? annotateSymbol(figures.pointer, annotation) + prefix
3361 : prefix + " ".repeat(figures.pointer.length)
3362 }
3363 return state === currentIndex
3364 ? annotateSymbol(figures.pointer, figures.primaryColor) + prefix
3365 : prefix + " ".repeat(figures.pointer.length)
3366}
3367
3368const renderAutoCompleteChoicePrefix = <A>(
3369 state: AutoCompleteState,
3370 options: AutoCompleteOptionsReq<A>,
3371 toDisplay: { readonly startIndex: number; readonly endIndex: number },
3372 currentIndex: number,
3373 figures: Theme,
3374 renderOptions?: RenderOptions | undefined
3375) => {
3376 const prefix = renderPagingPrefix(
3377 figures,
3378 currentIndex === toDisplay.startIndex && toDisplay.startIndex > 0,
3379 currentIndex === toDisplay.endIndex - 1 && toDisplay.endIndex < state.filtered.length
3380 )
3381 const choiceIndex = state.filtered[currentIndex]
3382 if (renderOptions?.plain === true) {
3383 return state.index === choiceIndex
3384 ? figures.pointer + prefix
3385 : prefix + " ".repeat(figures.pointer.length)
3386 }
3387 const choice = options.choices[choiceIndex]
3388 if (choice.disabled) {
3389 const annotation = Ansi.combine(Ansi.bold, figures.mutedColor)
3390 return state.index === choiceIndex
3391 ? annotateSymbol(figures.pointer, annotation) + prefix
3392 : prefix + " ".repeat(figures.pointer.length)
3393 }
3394 return state.index === choiceIndex
3395 ? annotateSymbol(figures.pointer, figures.primaryColor) + prefix
3396 : prefix + " ".repeat(figures.pointer.length)
3397}
3398
3399const renderChoiceTitle = <A>(
3400 choice: SelectChoice<A>,
3401 isSelected: boolean,
3402 theme: Theme,
3403 renderOptions?: RenderOptions | undefined
3404) => {
3405 if (renderOptions?.plain === true) {
3406 return choice.title
3407 }
3408 const title = choice.title
3409 if (isSelected) {
3410 return choice.disabled
3411 ? Ansi.annotate(title, Ansi.combine(Ansi.underlined, theme.mutedColor))
3412 : Ansi.annotate(title, Ansi.combine(Ansi.underlined, theme.primaryColor))
3413 }
3414 return choice.disabled
3415 ? Ansi.annotate(title, Ansi.combine(Ansi.strikethrough, theme.mutedColor))
3416 : title
3417}
3418
3419const renderSelectChoices = <A>(
3420 state: SelectState,
3421 options: SelectOptionsReq<A>,
3422 figures: Theme,
3423 renderOptions?: RenderOptions | undefined
3424) => {
3425 const choices = options.choices
3426 const toDisplay = entriesToDisplay(state, choices.length, options.maxPerPage)
3427 const documents: Array<string> = []
3428 for (let index = toDisplay.startIndex; index < toDisplay.endIndex; index++) {
3429 const choice = choices[index]
3430 const isSelected = state === index
3431 const prefix = renderChoicePrefix(state, choices, toDisplay, index, figures, renderOptions)
3432 const title = renderChoiceTitle(choice, isSelected, figures, renderOptions)
3433 const description = renderChoiceDescription(choice, isSelected, figures, renderOptions)
3434 documents.push(prefix + title + " " + description)
3435 }
3436 return documents.join("\n")
3437}
3438
3439const renderAutoCompleteChoices = <A>(
3440 state: AutoCompleteState,
3441 options: AutoCompleteOptionsReq<A>,
3442 figures: Theme,
3443 renderOptions?: RenderOptions | undefined
3444) => {
3445 if (state.filtered.length === 0) {
3446 return renderOptions?.plain === true
3447 ? options.emptyMessage
3448 : Ansi.annotate(options.emptyMessage, figures.mutedColor)
3449 }
3450 const cursor = autoCompleteCursor(state)
3451 const toDisplay = entriesToDisplay(cursor, state.filtered.length, options.maxPerPage)
3452 const documents: Array<string> = []
3453 for (let index = toDisplay.startIndex; index < toDisplay.endIndex; index++) {
3454 const choiceIndex = state.filtered[index]
3455 const choice = options.choices[choiceIndex]
3456 const isSelected = state.index === choiceIndex
3457 const prefix = renderAutoCompleteChoicePrefix(state, options, toDisplay, index, figures, renderOptions)
3458 const title = renderChoiceTitle(choice, isSelected, figures, renderOptions)
3459 const description = renderChoiceDescription(choice, isSelected, figures, renderOptions)
3460 documents.push(prefix + title + " " + description)
3461 }
3462 return documents.join("\n")
3463}
3464
3465const renderSelectNextFrame = Effect.fnUntraced(function*<A>(state: SelectState, options: SelectOptionsReq<A>) {
3466 const figures = yield* getTheme(options)
3467 const choices = renderSelectChoices(state, options, figures)
3468 const leadingSymbol = annotateSymbol(figures.prefix, figures.primaryColor)
3469 const trailingSymbol = annotateSymbol(figures.pointerSmall, figures.mutedColor)
3470 const promptMsg = renderSelectOutput(leadingSymbol, trailingSymbol, options)
3471 return Ansi.cursorHide + withSelectHeader(promptMsg, choices)
3472})
3473
3474const renderAutoCompleteNextFrame = Effect.fnUntraced(function*<A>(
3475 state: AutoCompleteState,
3476 options: AutoCompleteOptionsReq<A>
3477) {
3478 const figures = yield* getTheme(options)
3479 const choices = renderAutoCompleteChoices(state, options, figures)
3480 const leadingSymbol = annotateSymbol(figures.prefix, figures.primaryColor)
3481 const trailingSymbol = annotateSymbol(figures.pointerSmall, figures.mutedColor)
3482 const promptMsg = renderAutoCompleteOutput(state, leadingSymbol, trailingSymbol, options, figures)
3483 return Ansi.cursorHide + promptMsg + "\n" + choices
3484})
3485
3486const renderSelectSubmission = Effect.fnUntraced(function*<A>(state: SelectState, options: SelectOptionsReq<A>) {
3487 const figures = yield* getTheme(options)
3488 const selected = options.choices[state].title
3489 const leadingSymbol = annotateSymbol(figures.tick, figures.successColor)
3490 const trailingSymbol = annotateSymbol(figures.ellipsis, figures.mutedColor)
3491 const promptMsg = renderSelectOutput(leadingSymbol, trailingSymbol, options) ?? leadingSymbol
3492 return promptMsg + " " + Ansi.annotate(selected, figures.submittedColor) + "\n"
3493})
3494
3495const renderAutoCompleteSubmission = Effect.fnUntraced(function*<A>(
3496 state: AutoCompleteState,
3497 options: AutoCompleteOptionsReq<A>
3498) {
3499 const figures = yield* getTheme(options)
3500 const selected = options.choices[state.index].title
3501 const leadingSymbol = annotateSymbol(figures.tick, figures.successColor)
3502 const trailingSymbol = annotateSymbol(figures.ellipsis, figures.mutedColor)
3503 const promptMsg = renderAutoCompleteOutput(state, leadingSymbol, trailingSymbol, options, figures)
3504 return promptMsg + " " + Ansi.annotate(selected, figures.submittedColor) + "\n"
3505})
3506
3507const processSelectCursorUp = <A>(state: SelectState, choices: SelectOptionsReq<A>["choices"]) => {
3508 if (state === 0) {
3509 return Effect.succeed(Action.NextFrame({ state: choices.length - 1 }))
3510 }
3511 return Effect.succeed(Action.NextFrame({ state: state - 1 }))
3512}
3513
3514const processSelectCursorDown = <A>(state: SelectState, choices: SelectOptionsReq<A>["choices"]) => {
3515 if (state === choices.length - 1) {
3516 return Effect.succeed(Action.NextFrame({ state: 0 }))
3517 }
3518 return Effect.succeed(Action.NextFrame({ state: state + 1 }))
3519}
3520
3521const processSelectNext = <A>(state: SelectState, choices: SelectOptionsReq<A>["choices"]) => {
3522 return Effect.succeed(Action.NextFrame({ state: (state + 1) % choices.length }))
3523}
3524
3525const processAutoCompleteCursorUp = (state: AutoCompleteState) => {
3526 if (state.filtered.length === 0) {
3527 return Effect.succeed(Action.Beep())
3528 }
3529 const cursor = autoCompleteCursor(state)
3530 const nextCursor = cursor === 0 ? state.filtered.length - 1 : cursor - 1
3531 return Effect.succeed(Action.NextFrame({ state: { ...state, index: state.filtered[nextCursor] } }))
3532}
3533
3534const processAutoCompleteCursorDown = (state: AutoCompleteState) => {
3535 if (state.filtered.length === 0) {
3536 return Effect.succeed(Action.Beep())
3537 }
3538 const cursor = autoCompleteCursor(state)
3539 const nextCursor = (cursor + 1) % state.filtered.length
3540 return Effect.succeed(Action.NextFrame({ state: { ...state, index: state.filtered[nextCursor] } }))
3541}
3542
3543const processAutoCompleteNext = (state: AutoCompleteState) => processAutoCompleteCursorDown(state)
3544
3545const processAutoCompleteBackspace = <A>(state: AutoCompleteState, options: AutoCompleteOptionsReq<A>) => {
3546 if (state.query.length === 0) {
3547 return Effect.succeed(Action.Beep())
3548 }
3549 const query = state.query.slice(0, state.query.length - 1)
3550 return Effect.succeed(Action.NextFrame({ state: updateAutoCompleteState(state, options, query) }))
3551}
3552
3553const processAutoCompleteClear = <A>(state: AutoCompleteState, options: AutoCompleteOptionsReq<A>) =>
3554 Effect.succeed(Action.NextFrame({ state: updateAutoCompleteState(state, options, "") }))
3555
3556const processAutoCompleteInput = <A>(input: string, state: AutoCompleteState, options: AutoCompleteOptionsReq<A>) => {
3557 if (input.length === 0) {
3558 return Effect.succeed(Action.Beep())
3559 }
3560 const query = state.query + input
3561 return Effect.succeed(Action.NextFrame({ state: updateAutoCompleteState(state, options, query) }))
3562}
3563
3564const handleSelectRender = <A>(options: SelectOptionsReq<A>) => {
3565 return (state: SelectState, action: Action<SelectState, A>) => {
3566 return Action.$match(action, {
3567 Beep: () => Effect.succeed(renderBeep),
3568 NextFrame: ({ state }) => renderSelectNextFrame(state, options),
3569 Submit: () => renderSelectSubmission(state, options)
3570 })
3571 }
3572}
3573
3574const handleAutoCompleteRender = <A>(options: AutoCompleteOptionsReq<A>) => {
3575 return (state: AutoCompleteState, action: Action<AutoCompleteState, A>) => {
3576 return Action.$match(action, {
3577 Beep: () => Effect.succeed(renderBeep),
3578 NextFrame: ({ state }) => renderAutoCompleteNextFrame(state, options),
3579 Submit: () => renderAutoCompleteSubmission(state, options)
3580 })
3581 }
3582}
3583
3584const handleSelectClear = <A>(options: SelectOptionsReq<A>) =>
3585 Effect.fnUntraced(function*(state: SelectState, _: Action<SelectState, A>) {
3586 const terminal = yield* Terminal.Terminal
3587 const columns = yield* terminal.columns
3588 const figures = yield* getTheme(options)
3589 const clearPrompt = Ansi.eraseLine + Ansi.cursorLeft
3590 const promptText = renderSelectOutput(figures.prefix, figures.pointerSmall, options, { plain: true })
3591 const choicesText = renderSelectChoices(state, options, figures, { plain: true })
3592 const clearOutput = eraseText(withSelectHeader(promptText, choicesText), columns)
3593 return clearOutput + clearPrompt
3594 })
3595
3596const handleAutoCompleteClear = <A>(options: AutoCompleteOptionsReq<A>) =>
3597 Effect.fnUntraced(function*(state: AutoCompleteState, _: Action<AutoCompleteState, A>) {
3598 const terminal = yield* Terminal.Terminal
3599 const columns = yield* terminal.columns
3600 const figures = yield* getTheme(options)
3601 const clearPrompt = Ansi.eraseLine + Ansi.cursorLeft
3602 const promptText = renderAutoCompleteOutput(
3603 state,
3604 figures.prefix,
3605 figures.pointerSmall,
3606 options,
3607 figures,
3608 { plain: true }
3609 )
3610 const choicesText = renderAutoCompleteChoices(state, options, figures, { plain: true })
3611 const clearOutput = eraseText(`${promptText}\n${choicesText}`, columns)
3612 return clearOutput + clearPrompt
3613 })
3614
3615const handleSelectProcess = <A>(options: SelectOptionsReq<A>) => {
3616 return (input: Terminal.UserInput, state: SelectState) => {
3617 switch (input.key.name) {
3618 case "k":
3619 case "up": {
3620 return processSelectCursorUp(state, options.choices)
3621 }
3622 case "j":
3623 case "down": {
3624 return processSelectCursorDown(state, options.choices)
3625 }
3626 case "tab": {
3627 return processSelectNext(state, options.choices)
3628 }
3629 case "enter":
3630 case "return": {
3631 const selected = options.choices[state]
3632 if (selected.disabled) {
3633 return Effect.succeed(Action.Beep())
3634 }
3635 return Effect.succeed(Action.Submit({ value: selected.value }))
3636 }
3637 default: {
3638 return Effect.succeed(Action.Beep())
3639 }
3640 }
3641 }
3642}
3643
3644const handleAutoCompleteProcess = <A>(options: AutoCompleteOptionsReq<A>) => {
3645 return (input: Terminal.UserInput, state: AutoCompleteState) => {
3646 // Navigation is bound to ctrl chords rather than bare `j`/`k`, so that every
3647 // printable character reaches the filter input.
3648 if (input.key.ctrl) {
3649 switch (input.key.name) {
3650 case "u": {
3651 return processAutoCompleteClear(state, options)
3652 }
3653 case "p":
3654 case "k": {
3655 return processAutoCompleteCursorUp(state)
3656 }
3657 case "n": {
3658 return processAutoCompleteCursorDown(state)
3659 }
3660 default: {
3661 return Effect.succeed(Action.Beep())
3662 }
3663 }
3664 }
3665 switch (input.key.name) {
3666 case "up": {
3667 return processAutoCompleteCursorUp(state)
3668 }
3669 case "down": {
3670 return processAutoCompleteCursorDown(state)
3671 }
3672 case "tab": {
3673 return processAutoCompleteNext(state)
3674 }
3675 case "backspace": {
3676 return processAutoCompleteBackspace(state, options)
3677 }
3678 case "enter":
3679 case "return": {
3680 if (state.filtered.length === 0) {
3681 return Effect.succeed(Action.Beep())
3682 }
3683 const selected = options.choices[state.index]
3684 if (selected.disabled) {
3685 return Effect.succeed(Action.Beep())
3686 }
3687 return Effect.succeed(Action.Submit({ value: selected.value }))
3688 }
3689 default: {
3690 return processAutoCompleteInput(Option.getOrElse(input.input, () => ""), state, options)
3691 }
3692 }
3693 }
3694}
3695
3696interface TextOptionsReq extends OptionsReq<TextOptions> {
3697 /**
3698 * The type of the text option.
3699 */
3700 readonly type: "hidden" | "password" | "text"
3701}
3702
3703interface TextState {
3704 readonly cursor: number
3705 readonly value: string
3706 readonly error: Option.Option<string>
3707}
3708
3709const renderClearScreen = Effect.fnUntraced(function*(state: TextState, options: TextOptionsReq) {
3710 const terminal = yield* Terminal.Terminal
3711 const columns = yield* terminal.columns
3712 const figures = yield* getTheme(options)
3713 const resetCurrentLine = Ansi.eraseLine + Ansi.cursorLeft
3714 const errorText = renderTextError(state, figures.pointerSmall, figures, { plain: true })
3715 const clearOutput = clearOutputWithError(
3716 renderTextOutput(state, figures.prefix, figures.pointerSmall, options, figures, { plain: true }),
3717 columns,
3718 errorText
3719 )
3720 return clearOutput + resetCurrentLine
3721})
3722
3723const renderTextInput = (
3724 nextState: TextState,
3725 options: TextOptionsReq,
3726 theme: Theme,
3727 submitted: boolean,
3728 renderOptions?: RenderOptions | undefined
3729) => {
3730 const text = nextState.value
3731 if (renderOptions?.plain === true) {
3732 switch (options.type) {
3733 case "hidden": {
3734 return ""
3735 }
3736 case "password": {
3737 return theme.passwordMask.repeat(text.length)
3738 }
3739 case "text": {
3740 return text
3741 }
3742 }
3743 }
3744
3745 if (text.length === 0) {
3746 // Avoid wrapping an empty value in ANSI codes, which would make renderPrompt
3747 // add spacing for content that occupies no terminal columns.
3748 return ""
3749 }
3750
3751 const annotation = Option.isSome(nextState.error) ?
3752 theme.errorColor
3753 : submitted ?
3754 theme.submittedColor
3755 : Ansi.combine(Ansi.underlined, theme.primaryColor)
3756
3757 switch (options.type) {
3758 case "hidden": {
3759 return ""
3760 }
3761 case "password": {
3762 return annotateSymbol(theme.passwordMask.repeat(text.length), annotation)
3763 }
3764 case "text": {
3765 return Ansi.annotate(text, annotation)
3766 }
3767 }
3768}
3769
3770const renderTextError = (
3771 nextState: TextState,
3772 pointer: string,
3773 theme: Theme,
3774 renderOptions?: RenderOptions | undefined
3775): string => {
3776 if (Option.isSome(nextState.error)) {
3777 return Arr.match(nextState.error.value.split(NEWLINE_REGEXP), {
3778 onEmpty: () => "",
3779 onNonEmpty: (errorLines) => {
3780 if (renderOptions?.plain === true) {
3781 return separateSymbol(pointer, errorLines.join("\n"))
3782 }
3783 const prefix = annotateSymbol(pointer, theme.errorColor)
3784 const lines = Arr.map(errorLines, (str) => annotateErrorLine(str, theme.errorColor))
3785 return Ansi.cursorSavePosition + "\n" + separateSymbol(prefix, lines.join("\n")) + Ansi.cursorRestorePosition
3786 }
3787 })
3788 }
3789 return ""
3790}
3791
3792const renderTextOutput = (
3793 nextState: TextState,
3794 leadingSymbol: string,
3795 trailingSymbol: string,
3796 options: TextOptionsReq,
3797 theme: Theme,
3798 renderOptions?: RenderOptions | undefined,
3799 submitted: boolean = false
3800) => {
3801 const value = renderTextInput(nextState, options, theme, submitted, renderOptions)
3802 return renderPrompt(value, options.message, leadingSymbol, trailingSymbol, renderOptions)
3803}
3804
3805const renderTextNextFrame = Effect.fnUntraced(function*(state: TextState, options: TextOptionsReq) {
3806 const figures = yield* getTheme(options)
3807 const leadingSymbol = annotateSymbol(figures.prefix, figures.primaryColor)
3808 const trailingSymbol = annotateSymbol(figures.pointerSmall, figures.mutedColor)
3809 const promptMsg = renderTextOutput(state, leadingSymbol, trailingSymbol, options, figures)
3810 const errorMsg = renderTextError(state, figures.pointerSmall, figures)
3811 const cursorWidth = options.type === "password" ? figures.passwordMask.length : 1
3812 const offset = (state.cursor - state.value.length) * cursorWidth
3813 return promptMsg + errorMsg + Ansi.cursorMove(offset)
3814})
3815
3816const renderTextSubmission = Effect.fnUntraced(function*(state: TextState, options: TextOptionsReq) {
3817 const figures = yield* getTheme(options)
3818 const leadingSymbol = annotateSymbol(figures.tick, figures.successColor)
3819 const trailingSymbol = annotateSymbol(figures.ellipsis, figures.mutedColor)
3820 const promptMsg = renderTextOutput(state, leadingSymbol, trailingSymbol, options, figures, undefined, true)
3821 return promptMsg + "\n"
3822})
3823
3824const processTextBackspace = (state: TextState) => {
3825 if (state.cursor <= 0) {
3826 return Effect.succeed(Action.Beep())
3827 }
3828 const beforeCursor = state.value.slice(0, state.cursor - 1)
3829 const afterCursor = state.value.slice(state.cursor)
3830 const cursor = state.cursor - 1
3831 const value = `${beforeCursor}${afterCursor}`
3832 return Effect.succeed(
3833 Action.NextFrame({
3834 state: { ...state, cursor, value, error: Option.none() }
3835 })
3836 )
3837}
3838
3839const processTextClear = (state: TextState) =>
3840 Effect.succeed(
3841 Action.NextFrame({
3842 state: { ...state, cursor: 0, value: "", error: Option.none() }
3843 })
3844 )
3845
3846const processTextCursorLeft = (state: TextState) => {
3847 if (state.cursor <= 0) {
3848 return Effect.succeed(Action.Beep())
3849 }
3850 const cursor = state.cursor - 1
3851 return Effect.succeed(
3852 Action.NextFrame({
3853 state: { ...state, cursor, error: Option.none() }
3854 })
3855 )
3856}
3857
3858const processTextCursorRight = (state: TextState) => {
3859 if (state.cursor >= state.value.length) {
3860 return Effect.succeed(Action.Beep())
3861 }
3862 const cursor = Math.min(state.cursor + 1, state.value.length)
3863 return Effect.succeed(
3864 Action.NextFrame({
3865 state: { ...state, cursor, error: Option.none() }
3866 })
3867 )
3868}
3869
3870const processTextCursorStart = (state: TextState) =>
3871 Effect.succeed(
3872 Action.NextFrame({
3873 state: { ...state, cursor: 0, error: Option.none() }
3874 })
3875 )
3876
3877const processTextCursorEnd = (state: TextState) =>
3878 Effect.succeed(
3879 Action.NextFrame({
3880 state: { ...state, cursor: state.value.length, error: Option.none() }
3881 })
3882 )
3883
3884const processTab = (state: TextState, options: TextOptionsReq) => {
3885 if (state.value === options.default) {
3886 return Effect.succeed(Action.Beep())
3887 }
3888 const value = state.value.length === 0 ? options.default : state.value
3889 return Effect.succeed(
3890 Action.NextFrame({
3891 state: { ...state, value, cursor: value.length, error: Option.none() }
3892 })
3893 )
3894}
3895
3896const defaultTextProcessor = (input: string, state: TextState) => {
3897 const beforeCursor = state.value.slice(0, state.cursor)
3898 const afterCursor = state.value.slice(state.cursor)
3899 const value = `${beforeCursor}${input}${afterCursor}`
3900 const cursor = state.cursor + input.length
3901 return Effect.succeed(
3902 Action.NextFrame({
3903 state: { ...state, cursor, value, error: Option.none() }
3904 })
3905 )
3906}
3907
3908const handleTextRender = (options: TextOptionsReq) => {
3909 return (state: TextState, action: Action<TextState, string>) => {
3910 return Action.$match(action, {
3911 Beep: () => Effect.succeed(renderBeep),
3912 NextFrame: ({ state }) => renderTextNextFrame(state, options),
3913 Submit: () => renderTextSubmission(state, options)
3914 })
3915 }
3916}
3917
3918const handleTextProcess = (options: TextOptionsReq) => {
3919 return (input: Terminal.UserInput, state: TextState) => {
3920 if (input.key.ctrl) {
3921 switch (input.key.name) {
3922 case "u": {
3923 return processTextClear(state)
3924 }
3925 case "a": {
3926 return processTextCursorStart(state)
3927 }
3928 case "e": {
3929 return processTextCursorEnd(state)
3930 }
3931 default: {
3932 return Effect.succeed(Action.Beep())
3933 }
3934 }
3935 }
3936 switch (input.key.name) {
3937 case "backspace": {
3938 return processTextBackspace(state)
3939 }
3940 case "left": {
3941 return processTextCursorLeft(state)
3942 }
3943 case "right": {
3944 return processTextCursorRight(state)
3945 }
3946 case "home": {
3947 return processTextCursorStart(state)
3948 }
3949 case "end": {
3950 return processTextCursorEnd(state)
3951 }
3952 case "enter":
3953 case "return": {
3954 const value = state.value
3955 return Effect.match(options.validate(value), {
3956 onFailure: (error) =>
3957 Action.NextFrame({
3958 state: { ...state, value, error: Option.some(error) }
3959 }),
3960 onSuccess: (value) => Action.Submit({ value })
3961 })
3962 }
3963 case "tab": {
3964 return processTab(state, options)
3965 }
3966 default: {
3967 return defaultTextProcessor(Option.getOrElse(input.input, () => ""), state)
3968 }
3969 }
3970 }
3971}
3972
3973const handleTextClear = (options: TextOptionsReq) => {
3974 return (state: TextState, _: Action<TextState, string>) => {
3975 return renderClearScreen(state, options)
3976 }
3977}
3978
3979const basePrompt = (
3980 options: TextOptions,
3981 type: TextOptionsReq["type"]
3982): Prompt<string> => {
3983 const opts: TextOptionsReq = {
3984 default: "",
3985 type,
3986 validate: Effect.succeed,
3987 ...options
3988 }
3989
3990 const initialState: TextState = {
3991 cursor: opts.default.length,
3992 value: opts.default,
3993 error: Option.none()
3994 }
3995 return Custom(initialState, {
3996 render: handleTextRender(opts),
3997 process: handleTextProcess(opts),
3998 clear: handleTextClear(opts)
3999 })
4000}
4001
4002interface ToggleOptionsReq extends OptionsReq<ToggleOptions> {}
4003
4004type ToggleState = boolean
4005
4006const handleToggleClear = Effect.fnUntraced(function*(options: ToggleOptionsReq) {
4007 const terminal = yield* Terminal.Terminal
4008 const columns = yield* terminal.columns
4009 const figures = yield* getTheme(options)
4010 const clearPrompt = Ansi.eraseLine + Ansi.cursorLeft
4011 const toggleText = options.active + " " + separateSymbol(figures.toggleSeparator, options.inactive)
4012 const promptText = renderPrompt(toggleText, options.message, figures.prefix, figures.pointerSmall, { plain: true })
4013 const clearOutput = eraseText(promptText, columns)
4014 return clearOutput + clearPrompt
4015})
4016
4017const renderToggle = (
4018 value: boolean,
4019 options: ToggleOptionsReq,
4020 theme: Theme,
4021 submitted: boolean = false
4022) => {
4023 const separator = annotateSymbol(theme.toggleSeparator, theme.mutedColor)
4024 const selectedAnnotation = Ansi.combine(
4025 Ansi.underlined,
4026 submitted ? theme.submittedColor : theme.primaryColor
4027 )
4028 const inactive = value
4029 ? options.inactive
4030 : Ansi.annotate(options.inactive, selectedAnnotation)
4031 const active = value
4032 ? Ansi.annotate(options.active, selectedAnnotation)
4033 : options.active
4034 return active + " " + separateSymbol(separator, inactive)
4035}
4036
4037const renderToggleOutput = (
4038 toggle: string,
4039 leadingSymbol: string,
4040 trailingSymbol: string,
4041 options: ToggleOptionsReq
4042) => {
4043 return renderPrompt(toggle, options.message, leadingSymbol, trailingSymbol)
4044}
4045
4046const renderToggleNextFrame = Effect.fnUntraced(function*(state: ToggleState, options: ToggleOptionsReq) {
4047 const figures = yield* getTheme(options)
4048 const leadingSymbol = annotateSymbol(figures.prefix, figures.primaryColor)
4049 const trailingSymbol = annotateSymbol(figures.pointerSmall, figures.mutedColor)
4050 const toggle = renderToggle(state, options, figures)
4051 const promptMsg = renderToggleOutput(toggle, leadingSymbol, trailingSymbol, options)
4052 return Ansi.cursorHide + promptMsg
4053})
4054
4055const renderToggleSubmission = Effect.fnUntraced(function*(value: boolean, options: ToggleOptionsReq) {
4056 const figures = yield* getTheme(options)
4057 const leadingSymbol = annotateSymbol(figures.tick, figures.successColor)
4058 const trailingSymbol = annotateSymbol(figures.ellipsis, figures.mutedColor)
4059 const toggle = renderToggle(value, options, figures, true)
4060 const promptMsg = renderToggleOutput(toggle, leadingSymbol, trailingSymbol, options)
4061 return promptMsg + "\n"
4062})
4063
4064const activate = Effect.succeed(Action.NextFrame({ state: true }))
4065const deactivate = Effect.succeed(Action.NextFrame({ state: false }))
4066
4067const handleToggleRender = (options: ToggleOptionsReq) => {
4068 return (state: ToggleState, action: Action<ToggleState, boolean>) => {
4069 switch (action._tag) {
4070 case "Beep": {
4071 return Effect.succeed(renderBeep)
4072 }
4073 case "NextFrame": {
4074 return renderToggleNextFrame(state, options)
4075 }
4076 case "Submit": {
4077 return renderToggleSubmission(state, options)
4078 }
4079 }
4080 }
4081}
4082
4083const handleToggleProcess = (input: Terminal.UserInput, state: ToggleState) => {
4084 switch (input.key.name) {
4085 case "0":
4086 case "j":
4087 case "delete":
4088 case "right":
4089 case "down": {
4090 return deactivate
4091 }
4092 case "1":
4093 case "k":
4094 case "left":
4095 case "up": {
4096 return activate
4097 }
4098 case " ":
4099 case "tab": {
4100 return state ? deactivate : activate
4101 }
4102 case "enter":
4103 case "return": {
4104 return Effect.succeed(Action.Submit({ value: state }))
4105 }
4106 default: {
4107 return Effect.succeed(Action.Beep())
4108 }
4109 }
4110}
4111
4112const entriesToDisplay = (cursor: number, total: number, maxVisible?: number) => {
4113 const max = maxVisible === undefined ? total : maxVisible
4114 let startIndex = Math.min(total - max, cursor - Math.floor(max / 2))
4115 if (startIndex < 0) {
4116 startIndex = 0
4117 }
4118 const endIndex = Math.min(startIndex + max, total)
4119 return { startIndex, endIndex }
4120}