Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions packages/cli/examples/spinner.ts
Original file line number Diff line number Diff line change
@@ -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*() {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like there are a bunch of compilation errors in the example here.

// 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)`,

Check failure on line 33 in packages/cli/examples/spinner.ts

View workflow job for this annotation

GitHub Actions / Lint

Require line break(s)
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)
33 changes: 33 additions & 0 deletions packages/cli/src/Prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -595,6 +596,38 @@ export const date: (options: Prompt.DateOptions) => Prompt<Date> = InternalDateP
*/
export const file: (options?: Prompt.FileOptions) => Prompt<string> = 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: {
<A, E, R>(
options: InternalSpinner.SpinnerOptions<A, E>
): (effect: Effect<A, E, R>) => Effect<A, E, R | Terminal>
<A, E, R>(
effect: Effect<A, E, R>,
options: InternalSpinner.SpinnerOptions<A, E>
): Effect<A, E, R | Terminal>
} = InternalSpinner.spinner

/**
* @since 1.0.0
* @category combinators
Expand Down
182 changes: 182 additions & 0 deletions packages/cli/src/internal/prompt/spinner.ts
Original file line number Diff line number Diff line change
@@ -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<A, E> {
readonly message: string
readonly frames?: ReadonlyArray<string>
readonly interval?: Duration.DurationInput
readonly onSuccess?: (value: A) => string
readonly onFailure?: (error: E) => string
}

// Full classic dots spinner sequence
const DEFAULT_FRAMES: ReadonlyArray<string> = [
"⠋",
"⠙",
"⠹",
"⠸",
"⠼",
"⠴",
"⠦",
"⠧",
"⠇",
"⠏"
]

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<any>, 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: {
<A, E, R>(
options: SpinnerOptions<A, E>
): (effect: Effect.Effect<A, E, R>) => Effect.Effect<A, E, R | Terminal.Terminal>
<A, E, R>(
effect: Effect.Effect<A, E, R>,
options: SpinnerOptions<A, E>
): Effect.Effect<A, E, R | Terminal.Terminal>
} = dual(
2,
<A, E, R>(
effect: Effect.Effect<A, E, R>,
options: SpinnerOptions<A, E>
): Effect.Effect<A, E, R | Terminal.Terminal> =>
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<A, E> | 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<any>, 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<A, E>): 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<A, E>) =>
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<A, E>) => {
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)))
)
)
)
Loading