diff --git a/packages/cli/examples/spinner.ts b/packages/cli/examples/spinner.ts new file mode 100644 index 00000000000..2153c7f2385 --- /dev/null +++ b/packages/cli/examples/spinner.ts @@ -0,0 +1,75 @@ +import * as Prompt from "@effect/cli/Prompt" +import { NodeRuntime, NodeTerminal } from "@effect/platform-node" +import { Console, Effect } from "effect" + +// Demonstration of success, failure, and custom final messages +const program = Effect.gen(function*() { + // Success case with custom success message + const user = yield* Prompt.spinner( + Effect.sleep("1200 millis").pipe(Effect.as({ id: 42, name: "Ada" })), + { + message: "Fetching user…", + onSuccess: (user: { id: number; name: string }) => `Loaded ${user.name} (ID: ${user.id})` + } + ) + yield* Console.log(`User: ${JSON.stringify(user)}`) + + // Failure case with custom error message and proper error handling + yield* Prompt.spinner( + Effect.sleep("800 millis").pipe(Effect.zipRight(Effect.fail(new Error("Network timeout")))), + { + message: "Processing data…", + onFailure: (error: Error) => `Processing failed: ${error.message}` + } + ).pipe( + Effect.catchAll((error) => Console.log(`Caught error: ${error.message}`)) + ) + + // Success case with both success and error mappers + yield* Prompt.spinner( + Effect.sleep("600 millis").pipe(Effect.as({ uploaded: 5, skipped: 2 })), + { + message: "Uploading files…", + onSuccess: (result: { uploaded: number; skipped: number }) => `Uploaded ${result.uploaded} files (${result.skipped} skipped)`, + onFailure: (error: unknown) => `Upload failed: ${error}` + } + ) + + // Simple case without custom messages (uses original message) + yield* Prompt.spinner( + Effect.sleep("300 millis").pipe(Effect.as("done")), + { + message: "Cleaning up…" + } + ) + + // Timeout case - demonstrates spinner handles timeout/interruption gracefully + yield* Prompt.spinner( + Effect.sleep("2 seconds").pipe(Effect.as("completed")), + { + message: "Long running task…", + onSuccess: () => "Task completed successfully", + onFailure: () => "Task timed out" + } + ).pipe( + Effect.timeout("800 millis"), + Effect.catchAll((error) => Console.log(`Caught timeout: ${error._tag}`)) + ) + + // Die case - demonstrates spinner handles defects gracefully + yield* Prompt.spinner( + Effect.sleep("400 millis").pipe(Effect.zipRight(Effect.die("Unexpected system error"))), + { + message: "Risky operation…", + onFailure: (error: unknown) => `Operation failed: ${error}` + } + ).pipe( + Effect.catchAllCause((cause) => Console.log(`Caught defect: ${cause}`)) + ) + + yield* Console.log("All done!") +}) + +const MainLive = NodeTerminal.layer + +program.pipe(Effect.provide(MainLive), NodeRuntime.runMain) diff --git a/packages/cli/src/Prompt.ts b/packages/cli/src/Prompt.ts index 6b128790e11..50ce5699c65 100644 --- a/packages/cli/src/Prompt.ts +++ b/packages/cli/src/Prompt.ts @@ -16,6 +16,7 @@ import * as InternalListPrompt from "./internal/prompt/list.js" import * as InternalMultiSelectPrompt from "./internal/prompt/multi-select.js" import * as InternalNumberPrompt from "./internal/prompt/number.js" import * as InternalSelectPrompt from "./internal/prompt/select.js" +import * as InternalSpinner from "./internal/prompt/spinner.js" import * as InternalTextPrompt from "./internal/prompt/text.js" import * as InternalTogglePrompt from "./internal/prompt/toggle.js" import type { Primitive } from "./Primitive.js" @@ -595,6 +596,38 @@ export const date: (options: Prompt.DateOptions) => Prompt = InternalDateP */ export const file: (options?: Prompt.FileOptions) => Prompt = InternalFilePrompt.file +/** + * Displays a spinner while the provided `effect` runs and then renders a + * check mark on success or a cross on failure. The error from `effect` is + * rethrown unchanged. + * + * **Example** + * + * ```ts + * import * as Prompt from "@effect/cli/Prompt" + * import * as Effect from "effect/Effect" + * + * const fetchUser = Effect.sleep("500 millis").pipe(Effect.as({ id: 1, name: "Ada" })) + * + * const program = Prompt.spinner(fetchUser, { + * message: "Fetching user…", + * onSuccess: (user) => `Loaded ${user.name}` + * }) + * ``` + * + * @since 1.0.0 + * @category constructors + */ +export const spinner: { + ( + options: InternalSpinner.SpinnerOptions + ): (effect: Effect) => Effect + ( + effect: Effect, + options: InternalSpinner.SpinnerOptions + ): Effect +} = InternalSpinner.spinner + /** * @since 1.0.0 * @category combinators diff --git a/packages/cli/src/internal/prompt/spinner.ts b/packages/cli/src/internal/prompt/spinner.ts new file mode 100644 index 00000000000..78e349f0506 --- /dev/null +++ b/packages/cli/src/internal/prompt/spinner.ts @@ -0,0 +1,182 @@ +import * as Terminal from "@effect/platform/Terminal" +import * as Ansi from "@effect/printer-ansi/Ansi" +import * as Doc from "@effect/printer-ansi/AnsiDoc" +import * as Optimize from "@effect/printer/Optimize" +import * as Cause from "effect/Cause" +import type * as Duration from "effect/Duration" +import * as Effect from "effect/Effect" +import * as Exit from "effect/Exit" +import * as Fiber from "effect/Fiber" +import { dual } from "effect/Function" +import * as Option from "effect/Option" +import * as InternalAnsiUtils from "./ansi-utils.js" + +/** + * @internal + */ +export interface SpinnerOptions { + readonly message: string + readonly frames?: ReadonlyArray + readonly interval?: Duration.DurationInput + readonly onSuccess?: (value: A) => string + readonly onFailure?: (error: E) => string +} + +// Full classic dots spinner sequence +const DEFAULT_FRAMES: ReadonlyArray = [ + "⠋", + "⠙", + "⠹", + "⠸", + "⠼", + "⠴", + "⠦", + "⠧", + "⠇", + "⠏" +] + +const DEFAULT_INTERVAL: Duration.DurationInput = "80 millis" as Duration.DurationInput + +// Small render helpers to reduce per-frame work. +const CLEAR_LINE = Doc.cat(Doc.eraseLine, Doc.cursorLeft) +const CURSOR_HIDE = Doc.render(Doc.cursorHide, { style: "pretty" }) +const CURSOR_SHOW = Doc.render(Doc.cursorShow, { style: "pretty" }) +const renderWithWidth = (columns: number) => Doc.render({ style: "pretty", options: { lineWidth: columns } }) + +const optimizeAndRender = (columns: number, doc: Doc.Doc, addNewline = false) => { + const prepared = addNewline ? Doc.cat(doc, Doc.hardLine) : doc + return prepared.pipe(Optimize.optimize(Optimize.Deep), renderWithWidth(columns)) +} + +/** + * A spinner that renders while `effect` runs and prints ✔/✖ on completion. + * + * @internal + */ +export const spinner: { + ( + options: SpinnerOptions + ): (effect: Effect.Effect) => Effect.Effect + ( + effect: Effect.Effect, + options: SpinnerOptions + ): Effect.Effect +} = dual( + 2, + ( + effect: Effect.Effect, + options: SpinnerOptions + ): Effect.Effect => + Effect.acquireUseRelease( + // acquire + Effect.gen(function*() { + const terminal = yield* Terminal.Terminal + + // Hide cursor while active + yield* Effect.orDie(terminal.display(CURSOR_HIDE)) + + let index = 0 + let exit: Exit.Exit | undefined = undefined + + const message = options.message + const frames = options.frames ?? DEFAULT_FRAMES + const frameCount = frames.length + const interval = options.interval ?? DEFAULT_INTERVAL + + const messageDoc = Doc.annotate(Doc.text(message), Ansi.bold) + + const displayDoc = (doc: Doc.Doc, addNewline = false) => + Effect.gen(function*() { + const columns = yield* terminal.columns + const out = optimizeAndRender(columns, doc, addNewline) + yield* Effect.orDie(terminal.display(out)) + }) + + const renderFrame = Effect.gen(function*() { + const i = index + index = index + 1 + const spinnerDoc = Doc.annotate(Doc.text(frames[i % frameCount]!), Ansi.blue) + + const line = Doc.hsep([spinnerDoc, messageDoc]) + yield* displayDoc(Doc.cat(CLEAR_LINE, line)) + }) + + const computeFinalMessage = (exit: Exit.Exit): string => + Exit.match(exit, { + onFailure: (cause) => { + let baseMessage = message + if (options.onFailure) { + const failureOption = Cause.failureOption(cause) + if (Option.isSome(failureOption)) { + baseMessage = options.onFailure(failureOption.value) + } + } + if (Cause.isInterrupted(cause)) { + return `${baseMessage} (interrupted)` + } else if (Cause.isDie(cause)) { + return `${baseMessage} (died)` + } else { + return baseMessage + } + }, + onSuccess: (value) => options.onSuccess ? options.onSuccess(value) : message + }) + + const renderFinal = (exit: Exit.Exit) => + Effect.gen(function*() { + const figures = yield* InternalAnsiUtils.figures + const icon = Exit.isSuccess(exit) + ? Doc.annotate(figures.tick, Ansi.green) + : Doc.annotate(figures.cross, Ansi.red) + + const finalMessage = computeFinalMessage(exit) + + const msgDoc = Doc.annotate(Doc.text(finalMessage), Ansi.bold) + const line = Doc.hsep([icon, msgDoc]) + + yield* displayDoc(Doc.cat(CLEAR_LINE, line), true) + }) + + // Spinner fiber: loop until we see an Exit in exit, then render final line and stop. + const loop = Effect.gen(function*() { + while (true) { + if (exit !== undefined) { + yield* renderFinal(exit) + break + } + yield* renderFrame + yield* Effect.sleep(interval) + } + }).pipe( + // Always restore cursor from inside the spinner fiber too + Effect.ensuring(Effect.orDie(terminal.display(CURSOR_SHOW))) + ) + + const fiber = yield* Effect.fork(loop) + return { + fiber, + terminal, + setExit: (e: Exit.Exit) => { + exit = e + } + } + }), + // use + (_) => effect, + // release + ({ fiber, setExit, terminal }, exitValue) => + Effect.gen(function*() { + // Signal the spinner fiber to finish by setting the exit. + // (No external interrupt of the spinner fiber.) + setExit(exitValue) + + // Wait a short, bounded time for the spinner to flush final output. + // If this ever times out in a pathological TTY, we fail-safe and continue. + yield* Fiber.await(fiber).pipe(Effect.timeout("2 seconds"), Effect.ignore) + }).pipe( + // Ensure cursor is shown even if something above failed. + Effect.ensuring(Effect.orDie(terminal.display(CURSOR_SHOW))) + ) + ) +)