1import * as Context from "effect/Context";
2import * as Effect from "effect/Effect";
3import type * as Scope from "effect/Scope";
4import type { TerminalProgressState } from "@alchemy.run/sigil/ansi";
5import type { NonInteractiveTerminal } from "../../Interaction.ts";
6import type {
7 ConfirmOptions,
8 CycleSelectOptions,
9 AwaitExternalOptions,
10 MessageOptions,
11 InteractionError,
12 LiveViewHandle,
13 LiveViewOptions,
14 MenuOptions,
15 MultiSelectOptions,
16 PasswordInputOptions,
17 ProgressHandle,
18 ProgressOptions,
19 RenderOptions,
20 Screen,
21 SelectOptions,
22 CliKitCapabilities,
23 TextInputOptions,
24 View,
25} from "../components/types.ts";
26
27/** The sole injected owner of terminal rendering and input for a CLI process. */
28export class CliKit extends Context.Service<
29 CliKit,
30 {
31 readonly terminal: CliKitCapabilities;
32
33 /** Terminal-emulator progress displayed outside the rendered TUI. */
34 readonly nativeProgress: {
35 readonly set: (
36 state: TerminalProgressState,
37 value?: number,
38 ) => Effect.Effect<void>;
39 };
40
41 readonly output: {
42 /** Append a completed layout to terminal scrollback/output. */
43 readonly print: (
44 view: View,
45 options?: RenderOptions,
46 ) => Effect.Effect<void>;
47
48 /** Render a layout without writing it. Useful for help, logs and snapshots. */
49 readonly format: (view: View, options?: RenderOptions) => string;
50
51 /** Effect form of `format`, useful when composing CLI programs. */
52 readonly render: (
53 view: View,
54 options?: RenderOptions,
55 ) => Effect.Effect<string>;
56
57 /** Append an arbitrary visual layout. Prefer the semantic methods for logs. */
58 readonly info: (message: string | MessageOptions) => Effect.Effect<void>;
59 readonly success: (
60 message: string | MessageOptions,
61 ) => Effect.Effect<void>;
62 readonly warning: (
63 message: string | MessageOptions,
64 ) => Effect.Effect<void>;
65 readonly error: (message: string | MessageOptions) => Effect.Effect<void>;
66 };
67
68 readonly prompt: {
69 readonly text: (
70 options: TextInputOptions,
71 ) => Effect.Effect<string, InteractionError>;
72 readonly password: (
73 options: PasswordInputOptions,
74 ) => Effect.Effect<string, InteractionError>;
75 readonly confirm: (
76 options: ConfirmOptions,
77 ) => Effect.Effect<boolean, InteractionError>;
78 readonly select: <Value>(
79 options: SelectOptions<Value>,
80 ) => Effect.Effect<Value, InteractionError>;
81 readonly multiSelect: <Value>(
82 options: MultiSelectOptions<Value>,
83 ) => Effect.Effect<ReadonlyArray<Value>, InteractionError>;
84 readonly cycle: <State>(
85 options: CycleSelectOptions<State>,
86 ) => Effect.Effect<ReadonlyArray<State>, InteractionError>;
87 readonly awaitExternal: (
88 options: AwaitExternalOptions,
89 ) => Effect.Effect<string, InteractionError>;
90
91 /**
92 * Display an application menu. Each invocation replaces the current app
93 * flow, so looping back to a menu clears any prompts shown since the last
94 * selection.
95 */
96 readonly menu: <Value>(
97 options: MenuOptions<Value>,
98 ) => Effect.Effect<Value, InteractionError>;
99
100 /** Run an arbitrary interactive screen in the service's single live region. */
101 readonly custom: <Value>(
102 screen: Screen<Value>,
103 ) => Effect.Effect<Value, InteractionError>;
104 };
105
106 /** Run a sequence of prompts as one owned interaction. */
107 readonly wizard: <A, E, R>(
108 effect: Effect.Effect<A, E, R>,
109 ) => Effect.Effect<A, E | NonInteractiveTerminal, R>;
110
111 /**
112 * Keep one renderer alive while an Effect drives menus, screens and prompt
113 * flows. The application is cleared and the renderer exits when it settles.
114 */
115 readonly application: <A, E, R>(
116 effect: Effect.Effect<A, E, R>,
117 ) => Effect.Effect<A, E | NonInteractiveTerminal, R>;
118
119 readonly live: {
120 /**
121 * Add a mutable row to the live region. The handle is idempotent, and
122 * the enclosing Scope closes it as a backstop so interruption can never
123 * leave an orphaned row keeping the renderer mounted.
124 */
125 readonly progress: (
126 options: ProgressOptions,
127 ) => Effect.Effect<ProgressHandle, never, Scope.Scope>;
128 /**
129 * Mount a live layout while the scope is open. The view is immutable —
130 * dynamic content flows through a caller-owned store (`LiveStore` +
131 * `useLiveStore`) that the view's component subscribes to.
132 */
133 readonly open: (
134 view: View,
135 options?: LiveViewOptions,
136 ) => Effect.Effect<LiveViewHandle, never, Scope.Scope>;
137 };
138
139 /** Run work behind a progress row and collapse it to a final status line. */
140 readonly task: <A, E, R>(
141 options: ProgressOptions,
142 effect: Effect.Effect<A, E, R>,
143 ) => Effect.Effect<A, E, R>;
144 }
145>()("Alchemy::CliKit") {}
146
147/** Effectful service accessors for code that must defer acquisition to use time. */
148export const accessors = {
149 output: {
150 info: (message: string | MessageOptions) =>
151 Effect.flatMap(CliKit, (service) => service.output.info(message)),
152 success: (message: string | MessageOptions) =>
153 Effect.flatMap(CliKit, (service) => service.output.success(message)),
154 warning: (message: string | MessageOptions) =>
155 Effect.flatMap(CliKit, (service) => service.output.warning(message)),
156 error: (message: string | MessageOptions) =>
157 Effect.flatMap(CliKit, (service) => service.output.error(message)),
158 },
159 prompt: {
160 text: (options: TextInputOptions) =>
161 Effect.flatMap(CliKit, (service) => service.prompt.text(options)),
162 password: (options: PasswordInputOptions) =>
163 Effect.flatMap(CliKit, (service) => service.prompt.password(options)),
164 confirm: (options: ConfirmOptions) =>
165 Effect.flatMap(CliKit, (service) => service.prompt.confirm(options)),
166 select: <Value>(options: SelectOptions<Value>) =>
167 Effect.flatMap(CliKit, (service) => service.prompt.select(options)),
168 multiSelect: <Value>(options: MultiSelectOptions<Value>) =>
169 Effect.flatMap(CliKit, (service) => service.prompt.multiSelect(options)),
170 },
171};
172
173export const ApplicationPresentation = Context.Reference<
174 "inline" | "alternate"
175>("Alchemy::CliKit/ApplicationPresentation", { defaultValue: () => "inline" });
176
177/** Pipeable presentation modifiers for {@link CliKit.application}. */
178export const Application = {
179 alternate: <A, E, R>(effect: Effect.Effect<A, E, R>) =>
180 effect.pipe(
181 Effect.provideService(ApplicationPresentation, "alternate" as const),
182 ),
183};