Skip to content
Source
Sign inSign up
Open History to see the last commit

Interaction.ts

554 lines21.3 KB

1/**
2 * The capability to talk to the human driving this process.
3 *
4 * One contract, two renderers:
5 *
6 * - {@link layerSigil} — the interactive Sigil renderer: live rows,
7 * prompts as real screens, full-screen sessions.
8 * - {@link layerNonInteractive} — append-only plain output for CI, piped
9 * output, the Alchemist API and tests. Views print once, every prompt,
10 * screen and session fails with the typed {@link NonInteractiveTerminal}.
11 *
12 * {@link layer} picks between them from the terminal's capabilities, so
13 * callers never branch on the renderer themselves.
14 *
15 * Child processes (the RPC sidecar, spawned dev children) deliberately do
16 * NOT provide this service at all — interaction capability is structural,
17 * not configured. Code that might need a human declares it; a process that
18 * cannot reach one simply doesn't have the service in its graph.
19 */
20import * as Cause from "effect/Cause";
21import * as Context from "effect/Context";
22import * as Data from "effect/Data";
23import * as Effect from "effect/Effect";
24import * as Exit from "effect/Exit";
25import * as Layer from "effect/Layer";
26import * as Option from "effect/Option";
27import { ChildProcess } from "effect/process";
28import type { ChildProcessSpawner } from "effect/process/ChildProcessSpawner";
29import type * as Scope from "effect/Scope";
30import type { ReactNode } from "react";
31import { UserFacingError } from "./UserFacingError.ts";
32import { ANSI_DIM, ANSI_RESET, ansiFg, colorsEnabled, unicodeEnabled } from "./Util/Terminal.ts";
33import { glyphsFor, statusColor, type StatusVariant } from "./Util/Theme.ts";
34​
35/** The user dismissed the active terminal interaction. */
36export class TerminalCancelled extends Data.TaggedError("TerminalCancelled") {}
37​
38/** An interactive operation was requested without an interactive terminal. */
39export class NonInteractiveTerminal extends Data.TaggedError("NonInteractiveTerminal")<{
40 readonly operation: string;
41 readonly message: string;
42}> {
43 readonly [UserFacingError] = true;
45​
46/** The platform's browser launcher exited unsuccessfully. */
47export class BrowserOpenFailed extends Data.TaggedError("BrowserOpenFailed")<{
48 readonly command: string;
49 readonly exitCode: number;
50}> {}
51​
52export type InteractionError = TerminalCancelled | NonInteractiveTerminal;
53​
54export interface MessageOptions {
55 readonly message: string;
56 readonly detail?: string;
58​
59export interface TextInputOptions {
60 readonly message: string;
61 /** Secondary guidance rendered beneath the field in muted text. */
62 readonly description?: string;
63 /** Place the editable field beside the message or beneath it. @default "inline" */
64 readonly layout?: "inline" | "stacked";
65 readonly placeholder?: string;
66 readonly initialValue?: string;
67 readonly defaultValue?: string;
68 readonly validate?: (value: string) => string | Error | undefined;
70​
71export interface PasswordInputOptions extends Omit<
72 TextInputOptions,
73 "initialValue" | "defaultValue"
74> {}
75​
76export interface ConfirmOptions {
77 readonly message: string;
78 readonly initialValue?: boolean;
79 readonly confirmLabel?: string;
80 readonly cancelLabel?: string;
82​
83export interface Choice<Value> {
84 readonly value: Value;
85 readonly label: string;
86 /** Optional section heading shared by adjacent choices. */
87 readonly group?: string;
88 /** Indent the entire rendered row, including its selection indicator. */
89 readonly indent?: number;
90 /** Keep this row visible as the heading for following rows while scrolling. */
91 readonly sticky?: boolean;
92 /** Visually distinguish structural rows from ordinary choices. */
93 readonly tone?: "info";
94 readonly description?: string;
95 readonly disabled?: boolean | string;
97​
98export interface SelectOptions<Value> {
99 readonly message: string;
100 readonly options: ReadonlyArray<Choice<Value>>;
101 readonly initialValue?: Value;
102 readonly visibleCount?: number;
103 /** Allow typing to filter choices by label and description. */
104 readonly searchable?: boolean;
105 /** Place descriptions beside labels instead of on a second line. */
106 readonly descriptionPlacement?: "below" | "inline";
108​
109export interface MultiSelectOptions<Value> extends Omit<SelectOptions<Value>, "initialValue"> {
110 readonly initialValues?: ReadonlyArray<Value>;
111 readonly required?: boolean;
113​
114export interface BrowserLoginOptions<A, E, R, E2 = never, R2 = never> {
115 /** Display name, e.g. `"Railway"`; titles the login. */
116 readonly provider: string;
117 /** The page the user logs in on. Opened in their browser for them. */
118 readonly url: string;
119 /** Short code the user confirms on the login page (device flows). */
120 readonly code?: string;
121 /** @default "waiting for browser authorization (up to 5 minutes)…" */
122 readonly waitingLabel?: string;
123 /** Completes the login: a token poll, a CLI process, an OAuth callback. */
124 readonly waitFor: Effect.Effect<A, E, R>;
125 /** Let the user paste a code instead, exchanged for the same result. */
126 readonly manualCode?: {
127 readonly label?: string;
128 readonly placeholder?: string;
129 readonly validate?: (value: string) => string | Error | undefined;
130 readonly exchange: (code: string) => Effect.Effect<A, E2, R2>;
131 };
133​
134export interface ProgressOptions {
135 readonly label: string;
136 readonly detail?: string;
137 /** Terminal window title while this progress view is active. */
138 readonly title?: string;
139 /** Animate the leading status glyph. @default true */
140 readonly spinning?: boolean;
142​
143/** A composable layout. Views are inert; only a renderer draws them. */
144export type View = ReactNode;
145​
146/** What the terminal behind this process can do. */
147export interface TerminalCapabilities {
148 /** Whether this process has usable terminal input for prompts and sessions. */
149 readonly input: boolean;
150 readonly columns: number;
151 readonly rows: number;
152 readonly colors: boolean;
153 readonly unicode: boolean;
154 /** Whether full-screen sessions may use the terminal's alternate buffer. */
155 readonly alternateScreen: boolean;
157​
158export interface ScreenController<Value> {
159 readonly submit: (value: Value, summary?: View) => void;
160 readonly cancel: () => void;
162​
163/**
164 * An interactive scene. The scene owns its local component state while the
165 * renderer owns input streams, serialization, rendering and teardown. Every
166 * prompt is a screen; {@link Interaction} `prompt.screen` runs any other.
167 */
168export interface Screen<Value> {
169 readonly name: string;
170 readonly render: (controller: ScreenController<Value>) => View;
172​
173export const Screen = {
174 make: <Value>(
175 name: string,
176 render: (controller: ScreenController<Value>) => View,
177 ): Screen<Value> => ({ name, render }),
178};
179​
180/**
181 * Handle for a progress row. Acquired within a `Scope`: the enclosing
182 * scope's close is a release backstop, so an interrupted fiber can never leave
183 * an orphaned row pinning the renderer. Settling early via `succeed`/`fail`/
184 * `close` is the normal path; every settle operation is idempotent.
185 */
186export interface ProgressHandle {
187 readonly update: (options: ProgressOptions) => Effect.Effect<void>;
188 readonly succeed: (message?: string) => Effect.Effect<void>;
189 readonly fail: (message?: string) => Effect.Effect<void>;
190 readonly close: Effect.Effect<void>;
192​
193/**
194 * A mounted live view. The view itself is immutable — dynamic content flows
195 * through a caller-owned store that its component subscribes to — so the
196 * handle only controls the block's lifetime. Scope-bound the same way as
197 * {@link ProgressHandle}; `close` settles early and is idempotent.
198 */
199export interface LiveViewHandle {
200 readonly close: Effect.Effect<void>;
202​
203export interface LiveViewOptions {
204 /** Place a session shell/header before completed prompt output. */
205 readonly placement?: "beforeTranscript" | "afterTranscript";
206 /** Commit the final view to the static transcript when it closes. */
207 readonly persistOnClose?: boolean;
209​
210export interface SessionOptions {
211 /** Take over the terminal's alternate screen buffer when supported. */
212 readonly alternate?: boolean;
214​
215/**
216 * The capability to ask or tell the human driving this process. Provided
217 * by {@link layer}; never provided in child processes.
218 */
219export class Interaction extends Context.Service<
220 Interaction,
221 {
222 readonly terminal: TerminalCapabilities;
223​
224 readonly output: {
225 /** Append a completed layout to terminal scrollback/output. */
226 readonly print: (view: View) => Effect.Effect<void>;
227 readonly info: (message: string | MessageOptions) => Effect.Effect<void>;
228 readonly success: (message: string | MessageOptions) => Effect.Effect<void>;
229 readonly warning: (message: string | MessageOptions) => Effect.Effect<void>;
230 readonly error: (message: string | MessageOptions) => Effect.Effect<void>;
231 };
232​
233 readonly prompt: {
234 readonly text: (options: TextInputOptions) => Effect.Effect<string, InteractionError>;
235 readonly password: (options: PasswordInputOptions) => Effect.Effect<string, InteractionError>;
236 readonly confirm: (options: ConfirmOptions) => Effect.Effect<boolean, InteractionError>;
237 readonly select: <Value>(
238 options: SelectOptions<Value>,
239 ) => Effect.Effect<Value, InteractionError>;
240 readonly multiSelect: <Value>(
241 options: MultiSelectOptions<Value>,
242 ) => Effect.Effect<ReadonlyArray<Value>, InteractionError>;
243 /** Run an arbitrary interactive screen. */
244 readonly screen: <Value>(screen: Screen<Value>) => Effect.Effect<Value, InteractionError>;
245 };
246​
247 /**
248 * Log the user in through their browser: open `url`, show it (and the
249 * device code), and wait for `waitFor`. Fails with `TerminalCancelled`
250 * when the user dismisses the login.
251 */
252 readonly browserLogin: <A, E, R, E2 = never, R2 = never>(
253 options: BrowserLoginOptions<A, E, R, E2, R2>,
254 ) => Effect.Effect<A, E | E2 | InteractionError, R | R2 | ChildProcessSpawner>;
255​
256 /**
257 * Mount a live layout while the scope is open. Without an interactive
258 * renderer the view prints once.
259 */
260 readonly live: (
261 view: View,
262 options?: LiveViewOptions,
263 ) => Effect.Effect<LiveViewHandle, never, Scope.Scope>;
264​
265 /** Add a mutable progress row, settled through its handle. */
266 readonly progress: (
267 options: ProgressOptions,
268 ) => Effect.Effect<ProgressHandle, never, Scope.Scope>;
269​
270 /** Run work behind a progress row and collapse it to a final status line. */
271 readonly task: <A, E, R>(
272 options: ProgressOptions,
273 effect: Effect.Effect<A, E, R>,
274 ) => Effect.Effect<A, E, R>;
275​
276 /**
277 * Keep one renderer alive while an Effect drives screens and prompts. A
278 * nested session reuses the outer renderer and clears the prompts it
279 * showed when it settles.
280 */
281 readonly session: <A, E, R>(
282 effect: Effect.Effect<A, E, R>,
283 options?: SessionOptions,
284 ) => Effect.Effect<A, E | NonInteractiveTerminal, R>;
285 }
286>()("Alchemy::Interaction") {}
287​
288// Top-level forms of the service's messages and prompts. Each acquires
289// `Interaction` when it runs, not when it's built, so a provider can hold
290// one in a value without capturing whichever service was ambient then.
291​
292export const print = (view: View) => Effect.flatMap(Interaction, (i) => i.output.print(view));
293export const info = (message: string | MessageOptions) =>
294 Effect.flatMap(Interaction, (i) => i.output.info(message));
295export const success = (message: string | MessageOptions) =>
296 Effect.flatMap(Interaction, (i) => i.output.success(message));
297export const warning = (message: string | MessageOptions) =>
298 Effect.flatMap(Interaction, (i) => i.output.warning(message));
299export const error = (message: string | MessageOptions) =>
300 Effect.flatMap(Interaction, (i) => i.output.error(message));
301​
302export const text = (options: TextInputOptions) =>
303 Effect.flatMap(Interaction, (i) => i.prompt.text(options));
304export const password = (options: PasswordInputOptions) =>
305 Effect.flatMap(Interaction, (i) => i.prompt.password(options));
306export const confirm = (options: ConfirmOptions) =>
307 Effect.flatMap(Interaction, (i) => i.prompt.confirm(options));
308export const select = <Value>(options: SelectOptions<Value>) =>
309 Effect.flatMap(Interaction, (i) => i.prompt.select(options));
310export const multiSelect = <Value>(options: MultiSelectOptions<Value>) =>
311 Effect.flatMap(Interaction, (i) => i.prompt.multiSelect(options));
312export const browserLogin = <A, E, R, E2 = never, R2 = never>(
313 options: BrowserLoginOptions<A, E, R, E2, R2>,
314) => Effect.flatMap(Interaction, (i) => i.browserLogin(options));
315export const screen = <Value>(scene: Screen<Value>) =>
316 Effect.flatMap(Interaction, (i) => i.prompt.screen(scene));
317​
318/**
319 * `task` for any renderer: a scoped progress row settled by the effect's
320 * exit. Interruption (Ctrl+C) is not a failure — the row is dropped instead
321 * of painted as an error.
322 */
323export const taskFromProgress =
324 (progress: Interaction["Service"]["progress"]): Interaction["Service"]["task"] =>
325 (options, effect) =>
326 Effect.scoped(
327 Effect.gen(function* () {
328 const handle = yield* progress(options);
329 return yield* effect.pipe(
330 Effect.onExit((exit) =>
331 Exit.isSuccess(exit)
332 ? handle.succeed()
333 : Cause.hasInterruptsOnly(exit.cause)
334 ? handle.close
335 : handle.fail(),
336 ),
337 );
338 }),
339 );
340​
341/**
342 * Open a URL in the platform's default browser without invoking a shell.
343 *
344 * Fails with {@link BrowserOpenFailed} when the launcher exits non-zero (e.g.
345 * `xdg-open` with no handler installed). Some `xdg-open` configurations block
346 * until the browser itself exits — a launcher still running after a short
347 * grace period is treated as a successful launch rather than awaited.
348 */
349export const openUrl = (url: string) =>
350 Effect.gen(function* () {
351 const [command, args] =
352 process.platform === "win32"
353 ? (["rundll32.exe", ["url.dll,FileProtocolHandler", url]] as const)
354 : process.platform === "darwin"
355 ? (["open", [url]] as const)
356 : (["xdg-open", [url]] as const);
357 const handle = yield* ChildProcess.make(command, [...args], {
358 shell: false,
359 });
360 const exitCode = yield* handle.exitCode.pipe(Effect.timeoutOption("3 seconds"));
361 if (Option.isSome(exitCode) && exitCode.value !== 0) {
362 return yield* new BrowserOpenFailed({ command, exitCode: exitCode.value });
363 }
364 }).pipe(Effect.scoped);
365​
366// Bun can hand concurrent dynamic imports of one module a half-evaluated
367// namespace (a TDZ ReferenceError on its exports), so every lazy renderer
368// module is imported through one shared promise.
369const importOnce = <M>(load: () => Promise<M>) => {
370 let module: Promise<M> | undefined;
371 return () => (module ??= load());
372};
373const loadFormat = importOnce(() => import("./Cli/components/view/Format.tsx"));
374const loadSigilRuntime = importOnce(() => import("./Cli/components/view/Runtime.tsx"));
375const loadCapabilities = importOnce(() => import("./Cli/components/capabilities.ts"));
376​
377export interface TerminalOptions {
378 readonly stdin?: NodeJS.ReadStream;
379 readonly stdout?: NodeJS.WriteStream;
380 readonly stderr?: NodeJS.WriteStream;
381 /** Override automatic TTY input detection. Primarily useful for tests. */
382 readonly input?: boolean;
383 readonly colors?: boolean;
384 readonly unicode?: boolean;
385 /** Capture console output while the interactive renderer owns the tty. */
386 readonly captureConsole?: boolean;
388​
389export type NonInteractiveOptions = Pick<TerminalOptions, "stdout" | "colors" | "unicode">;
390​
391const nonInteractiveFailure = (operation: string) =>
392 new NonInteractiveTerminal({
393 operation,
394 message: `Cannot run ${operation} without an interactive terminal. Provide the equivalent command flags instead.`,
395 });
396​
397export const makeNonInteractive = (options: NonInteractiveOptions = {}): Interaction["Service"] => {
398 const stdout = options.stdout ?? process.stdout;
399 const colors = options.colors ?? colorsEnabled(stdout);
400 const unicode = options.unicode ?? unicodeEnabled();
401 const glyphs = glyphsFor(unicode);
402 const terminal: TerminalCapabilities = {
403 input: false,
404 columns: stdout.columns ?? 80,
405 rows: stdout.rows ?? 24,
406 colors,
407 unicode,
408 alternateScreen: false,
409 };
410​
411 const colorize = (hex: string, value: string) =>
412 colors ? `${ansiFg(hex)}${value}${ANSI_RESET}` : value;
413 const muted = (value: string) => (colors ? `${ANSI_DIM}${value}${ANSI_RESET}` : value);
414​
415 // Plain-string equivalent of the CLI's `Status` component: colored glyph,
416 // the message (painted for errors), and a muted `· detail` suffix.
417 const statusText = (variant: StatusVariant, message: string, detail?: string) => {
418 const glyph = colorize(statusColor(variant), glyphs[variant]);
419 const body = variant === "error" ? colorize(statusColor(variant), message) : message;
420 return `${glyph} ${body}${detail === undefined ? "" : ` ${muted(`· ${detail}`)}`}`;
421 };
422​
423 const log = (variant: StatusVariant) => (message: string | MessageOptions) =>
424 Effect.sync(() => {
425 const { message: text, detail } = typeof message === "string" ? { message } : message;
426 stdout.write(`${statusText(variant, text, detail)}\n`);
427 });
428​
429 // Views are React layouts: load the static formatter only when one is
430 // actually printed, so headless processes never pull in the renderer.
431 const print = (view: View) =>
432 Effect.promise(loadFormat).pipe(
433 Effect.map(({ formatView }) =>
434 formatView(view, { ...terminal, columns: stdout.columns ?? terminal.columns }),
435 ),
436 Effect.flatMap((text) =>
437 text === "" ? Effect.void : Effect.sync(() => void stdout.write(`${text}\n`)),
438 ),
439 );
440​
441 const unavailable = (operation: string) => Effect.fail(nonInteractiveFailure(operation));
442​
443 const progress: Interaction["Service"]["progress"] = (initial) =>
444 Effect.acquireRelease(
445 log("info")({ message: initial.label, detail: initial.detail }).pipe(
446 Effect.map((): ProgressHandle => {
447 let current = initial;
448 let settled = false;
449 const settle = (variant: "success" | "error", message?: string) =>
450 Effect.suspend(() => {
451 if (settled) return Effect.void;
452 settled = true;
453 return log(variant)(message ?? current.label);
454 });
455 return {
456 update: (next) =>
457 Effect.sync(() => {
458 if (!settled) current = next;
459 }),
460 succeed: (message) => settle("success", message),
461 fail: (message) => settle("error", message),
462 close: Effect.sync(() => {
463 settled = true;
464 }),
465 };
466 }),
467 ),
468 (handle) => handle.close,
469 );
470​
471 return {
472 terminal,
473 output: {
474 print,
475 info: log("info"),
476 success: log("success"),
477 warning: log("warning"),
478 error: log("error"),
479 },
480 prompt: {
481 text: () => unavailable("text input"),
482 password: () => unavailable("password input"),
483 confirm: () => unavailable("confirmation"),
484 select: () => unavailable("selection"),
485 multiSelect: () => unavailable("multiple selection"),
486 screen: (screen) => unavailable(screen.name),
487 },
488 // Without a screen there's nothing to paste a code into: print the URL
489 // and wait, so device and poll logins still work headless.
490 browserLogin: (login) =>
491 Effect.gen(function* () {
492 const openFailed = yield* Effect.isFailure(openUrl(login.url));
493 yield* log("info")({
494 message: `${login.provider} authorization: ${openFailed ? "open" : "opened"} ${login.url}`,
495 detail: login.code === undefined ? undefined : `code ${login.code}`,
496 });
497 return yield* login.waitFor;
498 }),
499 live: (view) => print(view).pipe(Effect.as({ close: Effect.void })),
500 progress,
501 task: taskFromProgress(progress),
502 session: () => unavailable("application"),
503 };
504};
505​
506/**
507 * The append-only renderer: CI, redirected output, the programmatic engine
508 * API, tests, and any headless embedding. Messages print as plain status
509 * lines; every prompt fails immediately with the typed
510 * {@link NonInteractiveTerminal}.
511 */
512export const layerNonInteractive = (options: NonInteractiveOptions = {}) =>
513 Layer.effect(
514 Interaction,
515 Effect.sync(() => makeNonInteractive(options)),
516 );
517​
518/**
519 * The interactive Sigil renderer, regardless of what the terminal reports.
520 * Prefer {@link layer}, which falls back to plain output without a TTY.
521 */
522export const layerSigil = (options: TerminalOptions = {}) =>
523 Layer.effect(
524 Interaction,
525 Effect.gen(function* () {
526 const { resolveCapabilities } = yield* Effect.promise(loadCapabilities);
527 const { makeRuntime } = yield* Effect.promise(loadSigilRuntime);
528 const { service } = yield* Effect.acquireRelease(
529 Effect.sync(() => makeRuntime(options, { ...resolveCapabilities(options), input: true })),
530 ({ dispose }) => Effect.promise(dispose),
531 );
532 return service;
533 }),
534 );
535​
536/**
537 * The renderer this process's terminal supports: the interactive one when
538 * stdin and stdout are a TTY and nothing opted out (`--no-input`, CI, coding
539 * agents), plain output otherwise.
540 */
541export const layer = (options: TerminalOptions = {}) =>
542 Layer.unwrap(
543 Effect.gen(function* () {
544 const { resolveCapabilities } = yield* Effect.promise(loadCapabilities);
545 const capabilities = resolveCapabilities(options);
546 return capabilities.input
547 ? layerSigil(options)
548 : layerNonInteractive({
549 stdout: options.stdout,
550 colors: capabilities.colors,
551 unicode: capabilities.unicode,
552 });
553 }),
554 );