@cvr/stacked 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/ui.ts ADDED
@@ -0,0 +1,173 @@
1
+ import { createInterface } from "node:readline";
2
+ import pc from "picocolors";
3
+ import { Effect, ServiceMap } from "effect";
4
+
5
+ // ============================================================================
6
+ // TTY & Color Detection
7
+ // ============================================================================
8
+
9
+ const stderrIsTTY = process.stderr.isTTY === true;
10
+ const stdoutIsTTY = process.stdout.isTTY === true;
11
+
12
+ // Lazy color instances — deferred so --no-color flag can set env before first use
13
+ let _stderrColors: ReturnType<typeof pc.createColors> | null = null;
14
+ let _stdoutColors: ReturnType<typeof pc.createColors> | null = null;
15
+
16
+ const isColorEnabled = (isTTY: boolean) => {
17
+ if (process.env["NO_COLOR"] !== undefined) return false;
18
+ if (process.env["FORCE_COLOR"] !== undefined) return true;
19
+ if (process.env["TERM"] === "dumb") return false;
20
+ return isTTY;
21
+ };
22
+
23
+ const getColors = () => {
24
+ if (_stderrColors !== null) return _stderrColors;
25
+ _stderrColors = isColorEnabled(stderrIsTTY) ? pc : pc.createColors(false);
26
+ return _stderrColors;
27
+ };
28
+
29
+ const getStdoutColors = () => {
30
+ if (_stdoutColors !== null) return _stdoutColors;
31
+ _stdoutColors = isColorEnabled(stdoutIsTTY) ? pc : pc.createColors(false);
32
+ return _stdoutColors;
33
+ };
34
+
35
+ // ============================================================================
36
+ // Output Config (verbose/quiet, set by global flags)
37
+ // ============================================================================
38
+
39
+ export interface OutputConfig {
40
+ readonly verbose: boolean;
41
+ readonly quiet: boolean;
42
+ readonly yes: boolean;
43
+ }
44
+
45
+ export const OutputConfig = ServiceMap.Reference("@cvr/stacked/OutputConfig", {
46
+ defaultValue: (): OutputConfig => ({ verbose: false, quiet: false, yes: false }),
47
+ });
48
+
49
+ // ============================================================================
50
+ // Interactive Prompts
51
+ // ============================================================================
52
+
53
+ const stdinIsTTY = process.stdin.isTTY === true;
54
+
55
+ export const confirm = Effect.fn("ui.confirm")(function* (message: string) {
56
+ const config = yield* OutputConfig;
57
+ if (config.yes || !stdinIsTTY) return true;
58
+
59
+ process.stderr.write(`${message} [y/N] `);
60
+ const answer = yield* Effect.tryPromise({
61
+ try: () => {
62
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
63
+ return new Promise<string>((resolve) => {
64
+ rl.question("", (ans) => {
65
+ rl.close();
66
+ resolve(ans);
67
+ });
68
+ });
69
+ },
70
+ catch: () => "n" as const,
71
+ });
72
+ return answer.trim().toLowerCase() === "y";
73
+ });
74
+
75
+ // ============================================================================
76
+ // Styled Output (all write to stderr)
77
+ // ============================================================================
78
+
79
+ const write = (msg: string) =>
80
+ Effect.sync(() => {
81
+ process.stderr.write(msg + "\n");
82
+ });
83
+
84
+ export const success = Effect.fn("ui.success")(function* (msg: string) {
85
+ const config = yield* OutputConfig;
86
+ if (config.quiet) return;
87
+ yield* write(getColors().green(`✓ ${msg}`));
88
+ });
89
+
90
+ export const warn = Effect.fn("ui.warn")(function* (msg: string) {
91
+ const config = yield* OutputConfig;
92
+ if (config.quiet) return;
93
+ yield* write(getColors().yellow(`⚠ ${msg}`));
94
+ });
95
+
96
+ export const info = Effect.fn("ui.info")(function* (msg: string) {
97
+ const config = yield* OutputConfig;
98
+ if (config.quiet) return;
99
+ yield* write(getColors().cyan(msg));
100
+ });
101
+
102
+ export const error = (msg: string) => write(getColors().red(msg));
103
+
104
+ export const verbose = Effect.fn("ui.verbose")(function* (msg: string) {
105
+ const config = yield* OutputConfig;
106
+ if (!config.verbose) return;
107
+ yield* write(getColors().dim(msg));
108
+ });
109
+
110
+ // ============================================================================
111
+ // Spinner
112
+ // ============================================================================
113
+
114
+ const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
115
+
116
+ export const withSpinner = <A, E, R>(
117
+ message: string,
118
+ effect: Effect.Effect<A, E, R>,
119
+ ): Effect.Effect<A, E, R> => {
120
+ if (!stderrIsTTY) {
121
+ return write(message).pipe(Effect.andThen(effect));
122
+ }
123
+
124
+ return Effect.gen(function* () {
125
+ const c = getColors();
126
+ let frame = 0;
127
+ const interval = setInterval(() => {
128
+ const spinner = SPINNER_FRAMES[frame % SPINNER_FRAMES.length];
129
+ process.stderr.write(`\r${c.cyan(spinner ?? "⠋")} ${message}`);
130
+ frame++;
131
+ }, 80);
132
+
133
+ const cleanup = (icon: string) =>
134
+ Effect.sync(() => {
135
+ clearInterval(interval);
136
+ process.stderr.write(`\r${icon} ${message}\n`);
137
+ });
138
+
139
+ const result = yield* effect.pipe(
140
+ Effect.tap(() => cleanup(c.green("✓"))),
141
+ Effect.tapError(() => cleanup(c.red("✗"))),
142
+ Effect.onInterrupt(() => cleanup(c.yellow("⚠"))),
143
+ );
144
+
145
+ return result;
146
+ });
147
+ };
148
+
149
+ // ============================================================================
150
+ // Color Helpers — stderr (for tree views, status badges, etc.)
151
+ // ============================================================================
152
+
153
+ export const dim = (s: string) => getColors().dim(s);
154
+ export const bold = (s: string) => getColors().bold(s);
155
+ export const green = (s: string) => getColors().green(s);
156
+ export const yellow = (s: string) => getColors().yellow(s);
157
+ export const cyan = (s: string) => getColors().cyan(s);
158
+ export const red = (s: string) => getColors().red(s);
159
+ export const magenta = (s: string) => getColors().magenta(s);
160
+
161
+ // ============================================================================
162
+ // Color Helpers — stdout (for Console.log output that may be piped)
163
+ // ============================================================================
164
+
165
+ export const stdout = {
166
+ dim: (s: string) => getStdoutColors().dim(s),
167
+ bold: (s: string) => getStdoutColors().bold(s),
168
+ green: (s: string) => getStdoutColors().green(s),
169
+ yellow: (s: string) => getStdoutColors().yellow(s),
170
+ cyan: (s: string) => getStdoutColors().cyan(s),
171
+ red: (s: string) => getStdoutColors().red(s),
172
+ magenta: (s: string) => getStdoutColors().magenta(s),
173
+ };