Skip to content

Commit 7d63555

Browse files
committed
cli: add Prompt.spinner
1 parent 1e09570 commit 7d63555

File tree

3 files changed

+267
-0
lines changed

3 files changed

+267
-0
lines changed

packages/cli/examples/spinner.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import * as Prompt from "@effect/cli/Prompt"
2+
import { NodeRuntime, NodeTerminal } from "@effect/platform-node"
3+
import { Console, Effect } from "effect"
4+
5+
// Demonstration of success, failure, and custom final messages
6+
const program = Effect.gen(function*() {
7+
// Success case with custom success message
8+
const user = yield* Prompt.spinner(
9+
"Fetching user…",
10+
Effect.sleep("1200 millis").pipe(Effect.as({ id: 42, name: "Ada" })),
11+
{
12+
onSuccess: (user) => `Loaded ${user.name} (ID: ${user.id})`
13+
}
14+
)
15+
yield* Console.log(`User: ${JSON.stringify(user)}`)
16+
17+
// Failure case with custom error message and proper error handling
18+
yield* Prompt.spinner(
19+
"Processing data…",
20+
Effect.sleep("800 millis").pipe(Effect.zipRight(Effect.fail(new Error("Network timeout")))),
21+
{
22+
onFailure: (error) => `Processing failed: ${error.message}`
23+
}
24+
).pipe(
25+
Effect.catchAll((error) => Console.log(`Caught error: ${error.message}`))
26+
)
27+
28+
// Success case with both success and error mappers
29+
yield* Prompt.spinner(
30+
"Uploading files…",
31+
Effect.sleep("600 millis").pipe(Effect.as({ uploaded: 5, skipped: 2 })),
32+
{
33+
onSuccess: (result) => `Uploaded ${result.uploaded} files (${result.skipped} skipped)`,
34+
onFailure: (error) => `Upload failed: ${error}`
35+
}
36+
)
37+
38+
// Simple case without custom messages (uses original message)
39+
yield* Prompt.spinner(
40+
"Cleaning up…",
41+
Effect.sleep("300 millis").pipe(Effect.as("done"))
42+
)
43+
44+
// Timeout case - demonstrates spinner handles timeout/interruption gracefully
45+
yield* Prompt.spinner(
46+
"Long running task…",
47+
Effect.sleep("2 seconds").pipe(Effect.as("completed")),
48+
{
49+
onSuccess: () => "Task completed successfully",
50+
onFailure: () => "Task timed out"
51+
}
52+
).pipe(
53+
Effect.timeout("800 millis"),
54+
Effect.catchAll((error) => Console.log(`Caught timeout: ${error._tag}`))
55+
)
56+
57+
// Die case - demonstrates spinner handles defects gracefully
58+
yield* Prompt.spinner(
59+
"Risky operation…",
60+
Effect.sleep("400 millis").pipe(Effect.zipRight(Effect.die("Unexpected system error"))),
61+
{
62+
onFailure: (error) => `Operation failed: ${error}`
63+
}
64+
).pipe(
65+
Effect.catchAllCause((cause) => Console.log(`Caught defect: ${cause}`))
66+
)
67+
68+
yield* Console.log("All done!")
69+
})
70+
71+
const MainLive = NodeTerminal.layer
72+
73+
program.pipe(Effect.provide(MainLive), NodeRuntime.runMain)

packages/cli/src/Prompt.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import * as InternalListPrompt from "./internal/prompt/list.js"
1616
import * as InternalMultiSelectPrompt from "./internal/prompt/multi-select.js"
1717
import * as InternalNumberPrompt from "./internal/prompt/number.js"
1818
import * as InternalSelectPrompt from "./internal/prompt/select.js"
19+
import * as InternalSpinner from "./internal/prompt/spinner.js"
1920
import * as InternalTextPrompt from "./internal/prompt/text.js"
2021
import * as InternalTogglePrompt from "./internal/prompt/toggle.js"
2122
import type { Primitive } from "./Primitive.js"
@@ -595,6 +596,35 @@ export const date: (options: Prompt.DateOptions) => Prompt<Date> = InternalDateP
595596
*/
596597
export const file: (options?: Prompt.FileOptions) => Prompt<string> = InternalFilePrompt.file
597598

599+
/**
600+
* Displays a spinner while the provided `effect` runs and then renders a
601+
* check mark on success or a cross on failure. The error from `effect` is
602+
* rethrown unchanged.
603+
*
604+
* **Example**
605+
*
606+
* ```ts
607+
* import * as Prompt from "@effect/cli/Prompt"
608+
* import * as Effect from "effect/Effect"
609+
*
610+
* const fetchUser = Effect.sleep("500 millis").pipe(Effect.as({ id: 1, name: "Ada" }))
611+
*
612+
* const program = Prompt.spinner(
613+
* "Fetching user…",
614+
* fetchUser,
615+
* { onSuccess: (user) => `Loaded ${user.name}` }
616+
* )
617+
* ```
618+
*
619+
* @since 1.0.0
620+
* @category constructors
621+
*/
622+
export const spinner: <A, E, R>(
623+
message: string,
624+
effect: Effect<A, E, R>,
625+
options?: InternalSpinner.SpinnerOptions<E, A>
626+
) => Effect<A, E, R | Terminal> = InternalSpinner.spinner
627+
598628
/**
599629
* @since 1.0.0
600630
* @category combinators
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
import * as Terminal from "@effect/platform/Terminal"
2+
import * as Ansi from "@effect/printer-ansi/Ansi"
3+
import * as Doc from "@effect/printer-ansi/AnsiDoc"
4+
import * as Optimize from "@effect/printer/Optimize"
5+
import * as Cause from "effect/Cause"
6+
import type * as Duration from "effect/Duration"
7+
import * as Effect from "effect/Effect"
8+
import * as Exit from "effect/Exit"
9+
import * as Fiber from "effect/Fiber"
10+
import * as Option from "effect/Option"
11+
import * as Ref from "effect/Ref"
12+
import * as InternalAnsiUtils from "./ansi-utils.js"
13+
14+
/**
15+
* @internal
16+
*/
17+
export interface SpinnerOptions<E = never, A = never> {
18+
readonly frames?: ReadonlyArray<string>
19+
readonly interval?: Duration.DurationInput
20+
readonly onSuccess?: (value: A) => string
21+
readonly onFailure?: (error: E) => string
22+
}
23+
24+
// Full classic dots spinner sequence
25+
const DEFAULT_FRAMES: ReadonlyArray<string> = [
26+
"⠋",
27+
"⠙",
28+
"⠹",
29+
"⠸",
30+
"⠼",
31+
"⠴",
32+
"⠦",
33+
"⠧",
34+
"⠇",
35+
"⠏"
36+
]
37+
38+
const DEFAULT_INTERVAL: Duration.DurationInput = "80 millis" as Duration.DurationInput
39+
40+
// Small render helpers to reduce per-frame work.
41+
const CLEAR_LINE = Doc.cat(Doc.eraseLine, Doc.cursorLeft)
42+
const CURSOR_HIDE = Doc.render(Doc.cursorHide, { style: "pretty" })
43+
const CURSOR_SHOW = Doc.render(Doc.cursorShow, { style: "pretty" })
44+
const renderWithWidth = (columns: number) => Doc.render({ style: "pretty", options: { lineWidth: columns } })
45+
46+
const optimizeAndRender = (columns: number, doc: Doc.Doc<any>, addNewline = false) => {
47+
const prepared = addNewline ? Doc.cat(doc, Doc.hardLine) : doc
48+
return prepared.pipe(Optimize.optimize(Optimize.Deep), renderWithWidth(columns))
49+
}
50+
51+
/**
52+
* A spinner that renders while `effect` runs and prints ✔/✖ on completion.
53+
*
54+
* @internal
55+
*/
56+
export const spinner = <A, E, R>(
57+
message: string,
58+
effect: Effect.Effect<A, E, R>,
59+
options?: SpinnerOptions<E, A>
60+
): Effect.Effect<A, E, R | Terminal.Terminal> =>
61+
Effect.acquireUseRelease(
62+
// acquire
63+
Effect.gen(function*() {
64+
const terminal = yield* Terminal.Terminal
65+
66+
// Hide cursor while active
67+
yield* Effect.orDie(terminal.display(CURSOR_HIDE))
68+
69+
const indexRef = yield* Ref.make(0)
70+
const exitRef = yield* Ref.make<Option.Option<Exit.Exit<A, E>>>(Option.none())
71+
72+
const frames = options?.frames ?? DEFAULT_FRAMES
73+
const frameCount = frames.length
74+
const interval = options?.interval ?? DEFAULT_INTERVAL
75+
76+
const messageDoc = Doc.annotate(Doc.text(message), Ansi.bold)
77+
78+
const displayDoc = (doc: Doc.Doc<any>, addNewline = false) =>
79+
Effect.gen(function*() {
80+
const columns = yield* terminal.columns
81+
const out = optimizeAndRender(columns, doc, addNewline)
82+
yield* Effect.orDie(terminal.display(out))
83+
})
84+
85+
const renderFrame = Effect.gen(function*() {
86+
const i = yield* Ref.modify(indexRef, (n) => [n, n + 1] as const)
87+
const spinnerDoc = Doc.annotate(Doc.text(frames[i % frameCount]!), Ansi.blue)
88+
89+
const line = Doc.hsep([spinnerDoc, messageDoc])
90+
yield* displayDoc(Doc.cat(CLEAR_LINE, line))
91+
})
92+
93+
const computeFinalMessage = (exit: Exit.Exit<A, E>): string =>
94+
Exit.match(exit, {
95+
onFailure: (cause) => {
96+
let baseMessage = message
97+
if (options?.onFailure) {
98+
const failureOption = Cause.failureOption(cause)
99+
if (Option.isSome(failureOption)) {
100+
baseMessage = options.onFailure(failureOption.value)
101+
}
102+
}
103+
if (Cause.isInterrupted(cause)) {
104+
return `${baseMessage} (interrupted)`
105+
} else if (Cause.isDie(cause)) {
106+
return `${baseMessage} (died)`
107+
} else {
108+
return baseMessage
109+
}
110+
},
111+
onSuccess: (value) => options?.onSuccess ? options.onSuccess(value) : message
112+
})
113+
114+
const renderFinal = (exit: Exit.Exit<A, E>) =>
115+
Effect.gen(function*() {
116+
const figures = yield* InternalAnsiUtils.figures
117+
const icon = Exit.isSuccess(exit)
118+
? Doc.annotate(figures.tick, Ansi.green)
119+
: Doc.annotate(figures.cross, Ansi.red)
120+
121+
const finalMessage = computeFinalMessage(exit)
122+
123+
const msgDoc = Doc.annotate(Doc.text(finalMessage), Ansi.bold)
124+
const line = Doc.hsep([icon, msgDoc])
125+
126+
yield* displayDoc(Doc.cat(CLEAR_LINE, line), true)
127+
})
128+
129+
// Spinner fiber: loop until we see an Exit in exitRef, then render final line and stop.
130+
const loop = Effect.gen(function*() {
131+
while (true) {
132+
const maybeExit = yield* Ref.get(exitRef)
133+
if (Option.isSome(maybeExit)) {
134+
yield* renderFinal(maybeExit.value)
135+
break
136+
}
137+
yield* renderFrame
138+
yield* Effect.sleep(interval)
139+
}
140+
}).pipe(
141+
// Always restore cursor from inside the spinner fiber too
142+
Effect.ensuring(Effect.orDie(terminal.display(CURSOR_SHOW)))
143+
)
144+
145+
const fiber = yield* Effect.fork(loop)
146+
return { fiber, exitRef, terminal }
147+
}),
148+
// use
149+
(_) => effect,
150+
// release
151+
({ exitRef, fiber, terminal }, exit) =>
152+
Effect.gen(function*() {
153+
// Signal the spinner fiber to finish by setting the exitRef.
154+
// (No external interrupt of the spinner fiber.)
155+
yield* Ref.set(exitRef, Option.some(exit))
156+
157+
// Wait a short, bounded time for the spinner to flush final output.
158+
// If this ever times out in a pathological TTY, we fail-safe and continue.
159+
yield* Fiber.await(fiber).pipe(Effect.timeout("2 seconds"), Effect.ignore)
160+
}).pipe(
161+
// Ensure cursor is shown even if something above failed.
162+
Effect.ensuring(Effect.orDie(terminal.display(CURSOR_SHOW)))
163+
)
164+
)

0 commit comments

Comments
 (0)