1/**
2 * The engine's capability contract for talking to a human.
3 *
4 * `Interaction` is the narrow, presentation-free slice of terminal
5 * interaction that non-CLI code (auth flows, the state store, engine
6 * surfaces) may use: plain-string messages, a handful of prompt shapes,
7 * and `task`. Nothing in its vocabulary knows a renderer exists — the CLI
8 * provides an implementation backed by its terminal UI kit (see
9 * `Cli/CliKit/interaction.ts`), while every other process gets
10 * {@link layerNonInteractive}: messages render as plain status lines and
11 * every prompt fails with the typed {@link NonInteractiveTerminal}.
12 *
13 * Child processes (the RPC sidecar, spawned dev children) deliberately do
14 * NOT provide this service at all — interaction capability is structural,
15 * not configured. Code that might need a human declares it; a process that
16 * cannot reach one simply doesn't have the service in its graph.
17 */
18import * as Cause from "effect/Cause";
19import * as Context from "effect/Context";
20import * as Data from "effect/Data";
21import * as Effect from "effect/Effect";
22import * as Exit from "effect/Exit";
23import * as Layer from "effect/Layer";
24import * as Option from "effect/Option";
25import { ChildProcess } from "effect/process";
26import { UserFacingError } from "./UserFacingError.ts";
27import {
28 ANSI_DIM,
29 ANSI_RESET,
30 ansiFg,
31 colorsEnabled,
32} from "./Util/Terminal.ts";
33import { glyphsFor, statusColor, type StatusVariant } from "./Util/Theme.ts";
34import { unicodeEnabled } from "./Util/Terminal.ts";
35
36/** The user dismissed the active terminal interaction. */
37export class TerminalCancelled extends Data.TaggedError("TerminalCancelled") {}
38
39/** An interactive operation was requested without an interactive terminal. */
40export class NonInteractiveTerminal extends Data.TaggedError(
41 "NonInteractiveTerminal",
42)<{
43 readonly operation: string;
44 readonly message: string;
45}> {
46 readonly [UserFacingError] = true;
47}
48
49/** The platform's browser launcher exited unsuccessfully. */
50export class BrowserOpenFailed extends Data.TaggedError("BrowserOpenFailed")<{
51 readonly command: string;
52 readonly exitCode: number;
53}> {}
54
55export type InteractionError = TerminalCancelled | NonInteractiveTerminal;
56
57export interface MessageOptions {
58 readonly message: string;
59 readonly detail?: string;
60}
61
62export interface TextInputOptions {
63 readonly message: string;
64 /** Secondary guidance rendered beneath the field in muted text. */
65 readonly description?: string;
66 /** Place the editable field beside the message or beneath it. @default "inline" */
67 readonly layout?: "inline" | "stacked";
68 readonly placeholder?: string;
69 readonly initialValue?: string;
70 readonly defaultValue?: string;
71 readonly validate?: (value: string) => string | Error | undefined;
72}
73
74export interface PasswordInputOptions extends Omit<
75 TextInputOptions,
76 "initialValue" | "defaultValue"
77> {}
78
79export interface ConfirmOptions {
80 readonly message: string;
81 readonly initialValue?: boolean;
82 readonly confirmLabel?: string;
83 readonly cancelLabel?: string;
84}
85
86export interface Choice<Value> {
87 readonly value: Value;
88 readonly label: string;
89 /** Optional section heading shared by adjacent choices. */
90 readonly group?: string;
91 /** Indent the entire rendered row, including its selection indicator. */
92 readonly indent?: number;
93 /** Keep this row visible as the heading for following rows while scrolling. */
94 readonly sticky?: boolean;
95 /** Visually distinguish structural rows from ordinary choices. */
96 readonly tone?: "info";
97 readonly description?: string;
98 readonly disabled?: boolean | string;
99}
100
101export interface SelectOptions<Value> {
102 readonly message: string;
103 readonly options: ReadonlyArray<Choice<Value>>;
104 readonly initialValue?: Value;
105 readonly visibleCount?: number;
106 /** Allow typing to filter choices by label and description. */
107 readonly searchable?: boolean;
108 /** Place descriptions beside labels instead of on a second line. */
109 readonly descriptionPlacement?: "below" | "inline";
110}
111
112export interface MultiSelectOptions<Value> extends Omit<
113 SelectOptions<Value>,
114 "initialValue"
115> {
116 readonly initialValues?: ReadonlyArray<Value>;
117 readonly required?: boolean;
118}
119
120export interface AwaitExternalOptions {
121 readonly message: string;
122 readonly waitingLabel: string;
123 readonly url?: string;
124 /** Short code the user must enter on the authorization page. */
125 readonly code?: string;
126 readonly openFailed?: boolean;
127 /** Open the authorization URL again from the waiting screen. */
128 readonly onOpen?: () => Promise<void>;
129 /** Allow Enter to switch to manual code entry. @default true */
130 readonly allowManualInput?: boolean;
131 readonly inputLabel?: string;
132 readonly placeholder?: string;
133 readonly validate?: (value: string) => string | Error | undefined;
134}
135
136export interface ProgressOptions {
137 readonly label: string;
138 readonly detail?: string;
139 /** Terminal window title while this progress view is active. */
140 readonly title?: string;
141 /** Animate the leading status glyph. @default true */
142 readonly spinning?: boolean;
143}
144
145/**
146 * The capability to ask or tell the human driving this process. Provided
147 * by the CLI (terminal-backed) or {@link layerNonInteractive} (plain
148 * output, typed prompt failures); never provided in child processes.
149 */
150export class Interaction extends Context.Service<
151 Interaction,
152 {
153 readonly output: {
154 readonly info: (message: string | MessageOptions) => Effect.Effect<void>;
155 readonly success: (
156 message: string | MessageOptions,
157 ) => Effect.Effect<void>;
158 readonly warning: (
159 message: string | MessageOptions,
160 ) => Effect.Effect<void>;
161 readonly error: (message: string | MessageOptions) => Effect.Effect<void>;
162 };
163
164 readonly prompt: {
165 readonly text: (
166 options: TextInputOptions,
167 ) => Effect.Effect<string, InteractionError>;
168 readonly password: (
169 options: PasswordInputOptions,
170 ) => Effect.Effect<string, InteractionError>;
171 readonly confirm: (
172 options: ConfirmOptions,
173 ) => Effect.Effect<boolean, InteractionError>;
174 readonly select: <Value>(
175 options: SelectOptions<Value>,
176 ) => Effect.Effect<Value, InteractionError>;
177 readonly multiSelect: <Value>(
178 options: MultiSelectOptions<Value>,
179 ) => Effect.Effect<ReadonlyArray<Value>, InteractionError>;
180 readonly awaitExternal: (
181 options: AwaitExternalOptions,
182 ) => Effect.Effect<string, InteractionError>;
183 };
184
185 /** Run work behind a progress row and collapse it to a final status line. */
186 readonly task: <A, E, R>(
187 options: ProgressOptions,
188 effect: Effect.Effect<A, E, R>,
189 ) => Effect.Effect<A, E, R>;
190 }
191>()("Alchemy::Interaction") {}
192
193/** Effectful service accessors for code that must defer acquisition to use time. */
194export const accessors = {
195 output: {
196 info: (message: string | MessageOptions) =>
197 Effect.flatMap(Interaction, (service) => service.output.info(message)),
198 success: (message: string | MessageOptions) =>
199 Effect.flatMap(Interaction, (service) => service.output.success(message)),
200 warning: (message: string | MessageOptions) =>
201 Effect.flatMap(Interaction, (service) => service.output.warning(message)),
202 error: (message: string | MessageOptions) =>
203 Effect.flatMap(Interaction, (service) => service.output.error(message)),
204 },
205 prompt: {
206 text: (options: TextInputOptions) =>
207 Effect.flatMap(Interaction, (service) => service.prompt.text(options)),
208 password: (options: PasswordInputOptions) =>
209 Effect.flatMap(Interaction, (service) =>
210 service.prompt.password(options),
211 ),
212 confirm: (options: ConfirmOptions) =>
213 Effect.flatMap(Interaction, (service) => service.prompt.confirm(options)),
214 select: <Value>(options: SelectOptions<Value>) =>
215 Effect.flatMap(Interaction, (service) => service.prompt.select(options)),
216 multiSelect: <Value>(options: MultiSelectOptions<Value>) =>
217 Effect.flatMap(Interaction, (service) =>
218 service.prompt.multiSelect(options),
219 ),
220 },
221};
222
223/**
224 * Open a URL in the platform's default browser without invoking a shell.
225 *
226 * Fails with {@link BrowserOpenFailed} when the launcher exits non-zero (e.g.
227 * `xdg-open` with no handler installed). Some `xdg-open` configurations block
228 * until the browser itself exits — a launcher still running after a short
229 * grace period is treated as a successful launch rather than awaited.
230 */
231export const openUrl = (url: string) =>
232 Effect.gen(function* () {
233 const [command, args] =
234 process.platform === "win32"
235 ? (["rundll32.exe", ["url.dll,FileProtocolHandler", url]] as const)
236 : process.platform === "darwin"
237 ? (["open", [url]] as const)
238 : (["xdg-open", [url]] as const);
239 const handle = yield* ChildProcess.make(command, [...args], {
240 shell: false,
241 });
242 const exitCode = yield* handle.exitCode.pipe(
243 Effect.timeoutOption("3 seconds"),
244 );
245 if (Option.isSome(exitCode) && exitCode.value !== 0) {
246 return yield* Effect.fail(
247 new BrowserOpenFailed({ command, exitCode: exitCode.value }),
248 );
249 }
250 }).pipe(Effect.scoped);
251
252export interface NonInteractiveOptions {
253 readonly stdout?: NodeJS.WriteStream;
254 readonly colors?: boolean;
255 readonly unicode?: boolean;
256}
257
258const makeNonInteractive = (
259 options: NonInteractiveOptions,
260): Interaction["Service"] => {
261 const stdout = options.stdout ?? process.stdout;
262 const colors = options.colors ?? colorsEnabled(stdout);
263 const glyphs = glyphsFor(options.unicode ?? unicodeEnabled());
264
265 const colorize = (hex: string, value: string) =>
266 colors ? `${ansiFg(hex)}${value}${ANSI_RESET}` : value;
267 const muted = (value: string) =>
268 colors ? `${ANSI_DIM}${value}${ANSI_RESET}` : value;
269
270 // Plain-string equivalent of the CLI's `Status` component: colored glyph,
271 // the message (painted for errors), and a muted `· detail` suffix.
272 const statusText = (
273 variant: StatusVariant,
274 message: string,
275 detail?: string,
276 ) => {
277 const glyph = colorize(statusColor(variant), glyphs[variant]);
278 const body =
279 variant === "error" ? colorize(statusColor(variant), message) : message;
280 return `${glyph} ${body}${detail === undefined ? "" : ` ${muted(`· ${detail}`)}`}`;
281 };
282
283 const log = (variant: StatusVariant) => (message: string | MessageOptions) =>
284 Effect.sync(() => {
285 const { message: text, detail } =
286 typeof message === "string" ? { message } : message;
287 stdout.write(`${statusText(variant, text, detail)}\n`);
288 });
289
290 const unavailable = (operation: string) =>
291 Effect.fail(
292 new NonInteractiveTerminal({
293 operation,
294 message: `Cannot run ${operation} without an interactive terminal. Provide the equivalent command flags instead.`,
295 }),
296 );
297
298 return {
299 output: {
300 info: log("info"),
301 success: log("success"),
302 warning: log("warning"),
303 error: log("error"),
304 },
305 prompt: {
306 text: () => unavailable("text input"),
307 password: () => unavailable("password input"),
308 confirm: () => unavailable("confirmation"),
309 select: () => unavailable("selection"),
310 multiSelect: () => unavailable("multiple selection"),
311 awaitExternal: () => unavailable("external authorization"),
312 },
313 task: (taskOptions, effect) =>
314 Effect.suspend(() => {
315 let settled = false;
316 const settle = (variant: "success" | "error", message?: string) =>
317 Effect.suspend(() => {
318 if (settled) return Effect.void;
319 settled = true;
320 return log(variant)(message ?? taskOptions.label);
321 });
322 return log("info")({
323 message: taskOptions.label,
324 detail: taskOptions.detail,
325 }).pipe(
326 Effect.andThen(
327 effect.pipe(
328 Effect.onExit((exit) =>
329 Exit.isSuccess(exit)
330 ? settle("success")
331 : // Interruption (Ctrl+C) is not a failure — leave the row
332 // unsettled instead of painting a red error status.
333 Cause.hasInterruptsOnly(exit.cause)
334 ? Effect.void
335 : settle("error"),
336 ),
337 ),
338 ),
339 );
340 }),
341 };
342};
343
344/**
345 * The default Interaction for processes without a terminal renderer: the
346 * programmatic engine API, tests, and any headless embedding. Messages
347 * print as plain status lines; every prompt fails immediately with the
348 * typed {@link NonInteractiveTerminal}.
349 */
350export const layerNonInteractive = (options: NonInteractiveOptions = {}) =>
351 Layer.effect(
352 Interaction,
353 Effect.sync(() => makeNonInteractive(options)),
354 );