effective-progress 0.1.2 → 0.2.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/README.md +148 -15
- package/package.json +4 -2
- package/src/api.ts +33 -37
- package/src/colors.ts +156 -0
- package/src/console.ts +3 -4
- package/src/index.ts +3 -1
- package/src/renderer.ts +147 -83
- package/src/runtime.ts +107 -36
- package/src/terminal.ts +61 -0
- package/src/types.ts +34 -18
package/README.md
CHANGED
|
@@ -1,11 +1,23 @@
|
|
|
1
1
|
# effective-progress
|
|
2
2
|
|
|
3
|
+
[](https://www.npmjs.com/package/effective-progress)
|
|
4
|
+
|
|
5
|
+
> [!WARNING]
|
|
6
|
+
> Pre-`1.0.0`, breaking changes may happen in any release. SemVer guarantees will begin at `1.0.0`.
|
|
7
|
+
> I recommend using only the `Progress.all` and `Progress.forEach` APIs for now, as they will likely change the least. The lower-level APIs for manual progress bar control are more likely to see breaking changes as I iterate on the design.
|
|
8
|
+
>
|
|
9
|
+
> Please open an issue or reach out if you have any questions or want to contribute!
|
|
10
|
+
> Feedback and contributions are very welcome!
|
|
11
|
+
|
|
12
|
+
<img alt="Showcase output" src="docs/images/showcase.gif" width="600" />
|
|
13
|
+
|
|
3
14
|
`effective-progress` is an [Effect](https://effect.website/)-first terminal progress library with:
|
|
4
15
|
|
|
5
16
|
- multiple progress bars
|
|
6
17
|
- nested child progress bars
|
|
7
18
|
- spinner support for indeterminate work
|
|
8
19
|
- clean log rendering alongside progress output, allowing you to simply use Effects `Console.log` or `Effect.logInfo`.
|
|
20
|
+
- simple to use `.all` and `.forEach` APIs similar to the ones you already know and love from `effect`. Just swap `Effect` for `Progress` and get progress bars for free!
|
|
9
21
|
|
|
10
22
|
## Install
|
|
11
23
|
|
|
@@ -32,12 +44,7 @@ const program = Progress.all(
|
|
|
32
44
|
Effect.runPromise(program);
|
|
33
45
|
```
|
|
34
46
|
|
|
35
|
-
|
|
36
|
-
bun run examples/basic.ts
|
|
37
|
-
Completed task 1
|
|
38
|
-
Completed task 2
|
|
39
|
-
- Running tasks in parallel: ━━━━━━━━━━━━────────────────── 2/5 40%
|
|
40
|
-
```
|
|
47
|
+
<img alt="Basic example output" src="docs/images/basic.gif" width="600" />
|
|
41
48
|
|
|
42
49
|
## Nested example
|
|
43
50
|
|
|
@@ -68,25 +75,151 @@ const program = Progress.all(
|
|
|
68
75
|
Effect.runPromise(program);
|
|
69
76
|
```
|
|
70
77
|
|
|
71
|
-
|
|
72
|
-
❯ bun run examples/nesting.ts
|
|
73
|
-
- Running tasks in parallel: ━━━━━━━━━━━━────────────────── 2/5 40%
|
|
74
|
-
- Running subtasks for task 3: ━━━━━━━━────────────────────── 4/15 27%
|
|
75
|
-
- Running subtasks for task 4: ━━━━━━━━────────────────────── 4/15 27%
|
|
76
|
-
```
|
|
78
|
+
<img alt="Nested example output" src="docs/images/nesting.gif" width="600" />
|
|
77
79
|
|
|
78
80
|
## Other examples
|
|
79
81
|
|
|
80
82
|
- `examples/simpleExample.ts` - low-boilerplate real-world flow
|
|
81
83
|
- `examples/advancedExample.ts` - full API usage with custom config and manual task control
|
|
84
|
+
- `examples/showcase.ts` - nested concurrent tasks, spinner workloads, and mixed Effect/Console logging
|
|
85
|
+
- `examples/performance.ts` - stress-style run with high log volume and deeply nested progress updates
|
|
82
86
|
|
|
83
87
|
## Log retention
|
|
84
88
|
|
|
85
|
-
- `maxLogLines` controls in-memory log retention.
|
|
86
|
-
-
|
|
89
|
+
- `maxLogLines` on `RendererConfig` controls in-memory log retention.
|
|
90
|
+
- Omitted or set to `0` means no log history is kept in memory.
|
|
87
91
|
- `maxLogLines > 0` keeps only the latest `N` log lines in memory.
|
|
88
92
|
|
|
93
|
+
## Configuring renderer and progress bars
|
|
94
|
+
|
|
95
|
+
Configure global renderer behavior once, and a global base progress bar style:
|
|
96
|
+
|
|
97
|
+
```ts
|
|
98
|
+
import { Effect } from "effect";
|
|
99
|
+
import * as Progress from "effective-progress";
|
|
100
|
+
|
|
101
|
+
const configured = program.pipe(
|
|
102
|
+
Effect.provideService(Progress.RendererConfig, {
|
|
103
|
+
maxLogLines: 12,
|
|
104
|
+
nonTtyUpdateStep: 2,
|
|
105
|
+
}),
|
|
106
|
+
Effect.provideService(Progress.ProgressBarConfig, {
|
|
107
|
+
barWidth: 36,
|
|
108
|
+
colors: {
|
|
109
|
+
fill: { kind: "hex", value: "#00b894" },
|
|
110
|
+
spinner: { kind: "ansi256", value: 214 },
|
|
111
|
+
},
|
|
112
|
+
}),
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
Effect.runPromise(configured);
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
## Terminal service and mocking
|
|
119
|
+
|
|
120
|
+
`effective-progress` now exposes a `ProgressTerminal` service that controls terminal detection and I/O:
|
|
121
|
+
|
|
122
|
+
- `isTTY`
|
|
123
|
+
- `stderrRows`
|
|
124
|
+
- `stderrColumns`
|
|
125
|
+
- `writeStderr(text)`
|
|
126
|
+
- `withRawInputCapture(effect)`
|
|
127
|
+
|
|
128
|
+
You can provide a mock in tests instead of monkeypatching global process streams:
|
|
129
|
+
|
|
130
|
+
```ts
|
|
131
|
+
import { Effect } from "effect";
|
|
132
|
+
import * as Progress from "effective-progress";
|
|
133
|
+
|
|
134
|
+
const mockTerminal: Progress.ProgressTerminalService = {
|
|
135
|
+
isTTY: Effect.succeed(true),
|
|
136
|
+
stderrRows: Effect.succeed(40),
|
|
137
|
+
stderrColumns: Effect.succeed(120),
|
|
138
|
+
writeStderr: (_text) => Effect.void,
|
|
139
|
+
withRawInputCapture: (effect) => effect,
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
const program = Progress.withTask({ description: "work" }, Effect.sleep("100 millis")).pipe(
|
|
143
|
+
Effect.provideService(Progress.ProgressTerminal, mockTerminal),
|
|
144
|
+
);
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
## Migration note
|
|
148
|
+
|
|
149
|
+
`RendererConfig.isTTY` has been removed.
|
|
150
|
+
TTY mode is now sourced from `ProgressTerminal.isTTY`.
|
|
151
|
+
|
|
152
|
+
Task-level `progressbar` config is optional and inherits from its parent task (or from global `ProgressBarConfig` for root tasks):
|
|
153
|
+
|
|
154
|
+
```ts
|
|
155
|
+
yield *
|
|
156
|
+
progress.withTask(
|
|
157
|
+
{
|
|
158
|
+
description: "Worker pipeline",
|
|
159
|
+
progressbar: {
|
|
160
|
+
barWidth: 20,
|
|
161
|
+
spinnerFrames: [".", "o", "O", "0"],
|
|
162
|
+
colors: {
|
|
163
|
+
spinner: { kind: "named", value: "magentaBright" },
|
|
164
|
+
},
|
|
165
|
+
},
|
|
166
|
+
},
|
|
167
|
+
Effect.sleep("1 second"),
|
|
168
|
+
);
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
For manual usage, `withTask` captures logs implicitly and provides the current `Task` context:
|
|
172
|
+
|
|
173
|
+
```ts
|
|
174
|
+
const program = Progress.withTask(
|
|
175
|
+
{ description: "Manual task" },
|
|
176
|
+
Effect.gen(function* () {
|
|
177
|
+
const currentTask = yield* Progress.Task;
|
|
178
|
+
yield* Console.log("This log is rendered through progress output", { taskId: currentTask });
|
|
179
|
+
yield* Effect.sleep("1 second");
|
|
180
|
+
}),
|
|
181
|
+
);
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
## Progress bar colors
|
|
185
|
+
|
|
186
|
+
`progressbar.colors` is configured with typed color tokens that are validated by Effect Schema.
|
|
187
|
+
|
|
188
|
+
```ts
|
|
189
|
+
progressbar: {
|
|
190
|
+
spinnerFrames: ["-", "\\", "|", "/"],
|
|
191
|
+
barWidth: 30,
|
|
192
|
+
fillChar: "━",
|
|
193
|
+
emptyChar: "─",
|
|
194
|
+
leftBracket: "",
|
|
195
|
+
rightBracket: "",
|
|
196
|
+
colors: {
|
|
197
|
+
fill: { kind: "named", value: "cyan" },
|
|
198
|
+
empty: { kind: "hex", value: "#9ca3af", modifiers: ["dim"] },
|
|
199
|
+
brackets: { kind: "rgb", value: { r: 156, g: 163, b: 175 } },
|
|
200
|
+
percent: { kind: "named", value: "whiteBright", modifiers: ["bold"] },
|
|
201
|
+
spinner: { kind: "ansi256", value: 214 },
|
|
202
|
+
done: { kind: "named", value: "greenBright" },
|
|
203
|
+
failed: { kind: "named", value: "redBright", modifiers: ["bold"] },
|
|
204
|
+
},
|
|
205
|
+
}
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
Supported color styles:
|
|
209
|
+
|
|
210
|
+
- `named` (for example `cyan`, `greenBright`)
|
|
211
|
+
- `hex` (for example `#00b894`)
|
|
212
|
+
- `rgb` (for example `{ r: 0, g: 184, b: 148 }`)
|
|
213
|
+
- `ansi256` (for example `214`)
|
|
214
|
+
|
|
215
|
+
Supported modifiers:
|
|
216
|
+
|
|
217
|
+
- `bold`, `dim`, `italic`, `underline`, `inverse`, `hidden`, `strikethrough`
|
|
218
|
+
|
|
219
|
+
## Dependencies & package size
|
|
220
|
+
|
|
221
|
+
This library is designed for CLI workflows, where package size is typically a lower-priority concern. Alongside `effect`, it relies on two mature runtime dependencies: `chalk` and `es-toolkit`.
|
|
222
|
+
|
|
89
223
|
## Notes
|
|
90
224
|
|
|
91
|
-
- This is a WIP library, so expect breaking changes. Feedback and contributions are very welcome!
|
|
92
225
|
- As Effect 4.0 is around the corner with some changes to logging, there may be some adjustments needed to align with the new Effect APIs.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "effective-progress",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Effect-first terminal progress bars with nested multibar support",
|
|
5
5
|
"homepage": "https://github.com/stromseng/effective-progress#readme",
|
|
6
6
|
"bugs": {
|
|
@@ -31,7 +31,9 @@
|
|
|
31
31
|
},
|
|
32
32
|
"dependencies": {
|
|
33
33
|
"chalk": "^5.6.2",
|
|
34
|
-
"effect": "^3.19.
|
|
34
|
+
"effect": "^3.19.17",
|
|
35
|
+
"es-toolkit": "^1.44.0",
|
|
36
|
+
"type-fest": "^5.4.4"
|
|
35
37
|
},
|
|
36
38
|
"devDependencies": {
|
|
37
39
|
"@effect/language-service": "^0.73.1",
|
package/src/api.ts
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { Effect } from "effect";
|
|
2
2
|
import type { Concurrency } from "effect/Types";
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import type { TrackOptions } from "./types";
|
|
3
|
+
import { Progress, provideProgressService } from "./runtime";
|
|
4
|
+
import { Task } from "./types";
|
|
5
|
+
import type { AddTaskOptions, TrackOptions } from "./types";
|
|
7
6
|
import { inferTotal } from "./utils";
|
|
8
7
|
|
|
9
8
|
export interface EffectExecutionOptions {
|
|
@@ -23,7 +22,7 @@ export type AllReturn<
|
|
|
23
22
|
O extends EffectAllExecutionOptions,
|
|
24
23
|
> =
|
|
25
24
|
[Effect.All.ReturnTuple<Arg, Effect.All.IsDiscard<O>, Effect.All.ExtractMode<O>>] extends
|
|
26
|
-
[Effect.Effect<infer A, infer E, infer R>] ? Effect.Effect<A, E, Exclude<R, Progress>>
|
|
25
|
+
[Effect.Effect<infer A, infer E, infer R>] ? Effect.Effect<A, E, Exclude<R, Progress | Task>>
|
|
27
26
|
: never;
|
|
28
27
|
|
|
29
28
|
export interface ForEachExecutionOptions extends EffectExecutionOptions {
|
|
@@ -32,26 +31,16 @@ export interface ForEachExecutionOptions extends EffectExecutionOptions {
|
|
|
32
31
|
|
|
33
32
|
export type ForEachOptions = TrackOptions & ForEachExecutionOptions;
|
|
34
33
|
|
|
35
|
-
export const
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
return yield* Effect.scoped(
|
|
48
|
-
Effect.gen(function* () {
|
|
49
|
-
const service = yield* makeProgressService;
|
|
50
|
-
const console = makeProgressConsole(service, outerConsole);
|
|
51
|
-
return yield* Effect.withConsole(Effect.provideService(effect, Progress, service), console);
|
|
52
|
-
}),
|
|
53
|
-
);
|
|
54
|
-
});
|
|
34
|
+
export const withTask = <A, E, R>(
|
|
35
|
+
options: AddTaskOptions,
|
|
36
|
+
effect: Effect.Effect<A, E, R>,
|
|
37
|
+
): Effect.Effect<A, E, Exclude<R, Progress | Task>> =>
|
|
38
|
+
provideProgressService(
|
|
39
|
+
Effect.gen(function* () {
|
|
40
|
+
const progress = yield* Progress;
|
|
41
|
+
return yield* progress.withTask(options, effect);
|
|
42
|
+
}),
|
|
43
|
+
) as Effect.Effect<A, E, Exclude<R, Progress | Task>>;
|
|
55
44
|
|
|
56
45
|
export const all = <
|
|
57
46
|
const Arg extends ReadonlyArray<Effect.Effect<any, any, any>>,
|
|
@@ -60,7 +49,7 @@ export const all = <
|
|
|
60
49
|
effects: Arg,
|
|
61
50
|
options: Omit<TrackOptions, "total"> & O,
|
|
62
51
|
): AllReturn<Arg, O> =>
|
|
63
|
-
|
|
52
|
+
provideProgressService(
|
|
64
53
|
Effect.gen(function* () {
|
|
65
54
|
const progress = yield* Progress;
|
|
66
55
|
return yield* progress.withTask(
|
|
@@ -68,9 +57,11 @@ export const all = <
|
|
|
68
57
|
description: options.description,
|
|
69
58
|
total: effects.length,
|
|
70
59
|
transient: options.transient,
|
|
60
|
+
progressbar: options.progressbar,
|
|
71
61
|
},
|
|
72
|
-
(
|
|
73
|
-
|
|
62
|
+
Effect.gen(function* () {
|
|
63
|
+
const taskId = yield* Task;
|
|
64
|
+
return yield* Effect.all(
|
|
74
65
|
effects.map((effect) => Effect.tap(effect, () => progress.advanceTask(taskId, 1))),
|
|
75
66
|
{
|
|
76
67
|
concurrency: options.concurrency,
|
|
@@ -79,7 +70,8 @@ export const all = <
|
|
|
79
70
|
mode: options.mode,
|
|
80
71
|
concurrentFinalizers: options.concurrentFinalizers,
|
|
81
72
|
},
|
|
82
|
-
)
|
|
73
|
+
);
|
|
74
|
+
}),
|
|
83
75
|
);
|
|
84
76
|
}),
|
|
85
77
|
) as AllReturn<Arg, O>;
|
|
@@ -88,8 +80,8 @@ export const forEach = <A, B, E, R>(
|
|
|
88
80
|
iterable: Iterable<A>,
|
|
89
81
|
f: (item: A, index: number) => Effect.Effect<B, E, R>,
|
|
90
82
|
options: ForEachOptions,
|
|
91
|
-
): Effect.Effect<ReadonlyArray<B>, E, Exclude<R, Progress>> =>
|
|
92
|
-
|
|
83
|
+
): Effect.Effect<ReadonlyArray<B>, E, Exclude<R, Progress | Task>> =>
|
|
84
|
+
provideProgressService(
|
|
93
85
|
Effect.gen(function* () {
|
|
94
86
|
const progress = yield* Progress;
|
|
95
87
|
|
|
@@ -98,18 +90,22 @@ export const forEach = <A, B, E, R>(
|
|
|
98
90
|
description: options.description,
|
|
99
91
|
total: options.total ?? inferTotal(iterable),
|
|
100
92
|
transient: options.transient,
|
|
93
|
+
progressbar: options.progressbar,
|
|
101
94
|
},
|
|
102
|
-
(
|
|
103
|
-
|
|
95
|
+
Effect.gen(function* () {
|
|
96
|
+
const taskId = yield* Task;
|
|
97
|
+
return yield* Effect.forEach(
|
|
104
98
|
iterable,
|
|
105
|
-
(item, index) =>
|
|
99
|
+
(item, index) =>
|
|
100
|
+
Effect.tap(f(item, index), () => progress.advanceTask(taskId, 1)),
|
|
106
101
|
{
|
|
107
102
|
concurrency: options.concurrency,
|
|
108
103
|
batching: options.batching,
|
|
109
104
|
discard: options.discard,
|
|
110
105
|
concurrentFinalizers: options.concurrentFinalizers,
|
|
111
106
|
},
|
|
112
|
-
)
|
|
107
|
+
);
|
|
108
|
+
}),
|
|
113
109
|
);
|
|
114
110
|
}),
|
|
115
|
-
)
|
|
111
|
+
) as Effect.Effect<ReadonlyArray<B>, E, Exclude<R, Progress | Task>>;
|
package/src/colors.ts
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
import type { ChalkInstance } from "chalk";
|
|
3
|
+
import { Schema } from "effect";
|
|
4
|
+
|
|
5
|
+
export const StyleModifierSchema = Schema.Literal(
|
|
6
|
+
"bold",
|
|
7
|
+
"dim",
|
|
8
|
+
"italic",
|
|
9
|
+
"underline",
|
|
10
|
+
"inverse",
|
|
11
|
+
"hidden",
|
|
12
|
+
"strikethrough",
|
|
13
|
+
);
|
|
14
|
+
export type StyleModifier = typeof StyleModifierSchema.Type;
|
|
15
|
+
|
|
16
|
+
export const NamedColorSchema = Schema.Literal(
|
|
17
|
+
"black",
|
|
18
|
+
"red",
|
|
19
|
+
"green",
|
|
20
|
+
"yellow",
|
|
21
|
+
"blue",
|
|
22
|
+
"magenta",
|
|
23
|
+
"cyan",
|
|
24
|
+
"white",
|
|
25
|
+
"blackBright",
|
|
26
|
+
"redBright",
|
|
27
|
+
"greenBright",
|
|
28
|
+
"yellowBright",
|
|
29
|
+
"blueBright",
|
|
30
|
+
"magentaBright",
|
|
31
|
+
"cyanBright",
|
|
32
|
+
"whiteBright",
|
|
33
|
+
);
|
|
34
|
+
export type NamedColor = typeof NamedColorSchema.Type;
|
|
35
|
+
|
|
36
|
+
const HexColorSchema = Schema.String.pipe(Schema.pattern(/^#(?:[A-Fa-f0-9]{3}|[A-Fa-f0-9]{6})$/));
|
|
37
|
+
const ColorChannelSchema = Schema.Number.pipe(
|
|
38
|
+
Schema.int(),
|
|
39
|
+
Schema.greaterThanOrEqualTo(0),
|
|
40
|
+
Schema.lessThanOrEqualTo(255),
|
|
41
|
+
);
|
|
42
|
+
const Ansi256Schema = Schema.Number.pipe(
|
|
43
|
+
Schema.int(),
|
|
44
|
+
Schema.greaterThanOrEqualTo(0),
|
|
45
|
+
Schema.lessThanOrEqualTo(255),
|
|
46
|
+
);
|
|
47
|
+
const ModifiersSchema = Schema.optional(Schema.Array(StyleModifierSchema));
|
|
48
|
+
|
|
49
|
+
export const NamedColorStyleSchema = Schema.Struct({
|
|
50
|
+
kind: Schema.Literal("named"),
|
|
51
|
+
value: NamedColorSchema,
|
|
52
|
+
modifiers: ModifiersSchema,
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
export const HexColorStyleSchema = Schema.Struct({
|
|
56
|
+
kind: Schema.Literal("hex"),
|
|
57
|
+
value: HexColorSchema,
|
|
58
|
+
modifiers: ModifiersSchema,
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
export const RgbColorStyleSchema = Schema.Struct({
|
|
62
|
+
kind: Schema.Literal("rgb"),
|
|
63
|
+
value: Schema.Struct({
|
|
64
|
+
r: ColorChannelSchema,
|
|
65
|
+
g: ColorChannelSchema,
|
|
66
|
+
b: ColorChannelSchema,
|
|
67
|
+
}),
|
|
68
|
+
modifiers: ModifiersSchema,
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
export const Ansi256ColorStyleSchema = Schema.Struct({
|
|
72
|
+
kind: Schema.Literal("ansi256"),
|
|
73
|
+
value: Ansi256Schema,
|
|
74
|
+
modifiers: ModifiersSchema,
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
export const ColorStyleSchema = Schema.Union(
|
|
78
|
+
NamedColorStyleSchema,
|
|
79
|
+
HexColorStyleSchema,
|
|
80
|
+
RgbColorStyleSchema,
|
|
81
|
+
Ansi256ColorStyleSchema,
|
|
82
|
+
);
|
|
83
|
+
export type ColorStyle = typeof ColorStyleSchema.Type;
|
|
84
|
+
|
|
85
|
+
export const ProgressBarColorsSchema = Schema.Struct({
|
|
86
|
+
fill: ColorStyleSchema,
|
|
87
|
+
empty: ColorStyleSchema,
|
|
88
|
+
brackets: ColorStyleSchema,
|
|
89
|
+
percent: ColorStyleSchema,
|
|
90
|
+
spinner: ColorStyleSchema,
|
|
91
|
+
done: ColorStyleSchema,
|
|
92
|
+
failed: ColorStyleSchema,
|
|
93
|
+
});
|
|
94
|
+
export type ProgressBarColors = typeof ProgressBarColorsSchema.Type;
|
|
95
|
+
|
|
96
|
+
export const defaultProgressBarColors: ProgressBarColors = {
|
|
97
|
+
fill: { kind: "named", value: "cyan" },
|
|
98
|
+
empty: { kind: "named", value: "white", modifiers: ["dim"] },
|
|
99
|
+
brackets: { kind: "named", value: "white", modifiers: ["dim"] },
|
|
100
|
+
percent: { kind: "named", value: "white", modifiers: ["bold"] },
|
|
101
|
+
spinner: { kind: "named", value: "yellow" },
|
|
102
|
+
done: { kind: "named", value: "green" },
|
|
103
|
+
failed: { kind: "named", value: "red" },
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const applyModifier = (instance: ChalkInstance, modifier: StyleModifier): ChalkInstance =>
|
|
107
|
+
instance[modifier] as ChalkInstance;
|
|
108
|
+
|
|
109
|
+
const applyNamedColor = (instance: ChalkInstance, color: NamedColor): ChalkInstance =>
|
|
110
|
+
instance[color] as ChalkInstance;
|
|
111
|
+
|
|
112
|
+
const resolveBaseStyle = (style: ColorStyle): ChalkInstance => {
|
|
113
|
+
switch (style.kind) {
|
|
114
|
+
case "named":
|
|
115
|
+
return applyNamedColor(chalk, style.value);
|
|
116
|
+
case "hex":
|
|
117
|
+
return chalk.hex(style.value);
|
|
118
|
+
case "rgb":
|
|
119
|
+
return chalk.rgb(style.value.r, style.value.g, style.value.b);
|
|
120
|
+
case "ansi256":
|
|
121
|
+
return chalk.ansi256(style.value);
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
const applyModifiers = (instance: ChalkInstance, modifiers: ReadonlyArray<StyleModifier>) => {
|
|
126
|
+
let styled = instance;
|
|
127
|
+
for (const modifier of modifiers) {
|
|
128
|
+
styled = applyModifier(styled, modifier);
|
|
129
|
+
}
|
|
130
|
+
return styled;
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
export const applyColorStyle = (style: ColorStyle, text: string): string => {
|
|
134
|
+
const styled = applyModifiers(resolveBaseStyle(style), style.modifiers ?? []);
|
|
135
|
+
return styled(text);
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
export interface CompiledProgressBarColors {
|
|
139
|
+
readonly fill: (text: string) => string;
|
|
140
|
+
readonly empty: (text: string) => string;
|
|
141
|
+
readonly brackets: (text: string) => string;
|
|
142
|
+
readonly percent: (text: string) => string;
|
|
143
|
+
readonly spinner: (text: string) => string;
|
|
144
|
+
readonly done: (text: string) => string;
|
|
145
|
+
readonly failed: (text: string) => string;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export const compileProgressBarColors = (colors: ProgressBarColors): CompiledProgressBarColors => ({
|
|
149
|
+
fill: (text) => applyColorStyle(colors.fill, text),
|
|
150
|
+
empty: (text) => applyColorStyle(colors.empty, text),
|
|
151
|
+
brackets: (text) => applyColorStyle(colors.brackets, text),
|
|
152
|
+
percent: (text) => applyColorStyle(colors.percent, text),
|
|
153
|
+
spinner: (text) => applyColorStyle(colors.spinner, text),
|
|
154
|
+
done: (text) => applyColorStyle(colors.done, text),
|
|
155
|
+
failed: (text) => applyColorStyle(colors.failed, text),
|
|
156
|
+
});
|
package/src/console.ts
CHANGED
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
import { Console, Effect } from "effect";
|
|
2
2
|
import { formatWithOptions } from "node:util";
|
|
3
|
-
import type { ProgressService } from "./types";
|
|
4
3
|
|
|
5
4
|
export const makeProgressConsole = (
|
|
6
|
-
|
|
5
|
+
progressLog: (...args: ReadonlyArray<unknown>) => Effect.Effect<void, never, never>,
|
|
7
6
|
outerConsole: Console.Console,
|
|
8
7
|
): Console.Console => {
|
|
9
|
-
const log = (...args: ReadonlyArray<unknown>) =>
|
|
8
|
+
const log = (...args: ReadonlyArray<unknown>) => progressLog(...args);
|
|
10
9
|
const unsafeLog = (...args: ReadonlyArray<unknown>) => {
|
|
11
|
-
Effect.runFork(
|
|
10
|
+
Effect.runFork(progressLog(...args));
|
|
12
11
|
};
|
|
13
12
|
|
|
14
13
|
const delegate = (effect: Effect.Effect<void, never, never>) => effect;
|
package/src/index.ts
CHANGED
package/src/renderer.ts
CHANGED
|
@@ -1,49 +1,60 @@
|
|
|
1
|
-
import { Effect, Ref } from "effect";
|
|
2
|
-
import
|
|
3
|
-
|
|
1
|
+
import { Effect, Ref, Schema } from "effect";
|
|
2
|
+
import {
|
|
3
|
+
type CompiledProgressBarColors,
|
|
4
|
+
compileProgressBarColors,
|
|
5
|
+
ProgressBarColorsSchema,
|
|
6
|
+
} from "./colors";
|
|
7
|
+
import type { ProgressTerminalService } from "./terminal";
|
|
8
|
+
import type { ProgressBarConfigShape, RendererConfigShape } from "./types";
|
|
4
9
|
import { DeterminateTaskUnits, TaskId, TaskSnapshot } from "./types";
|
|
5
10
|
|
|
6
11
|
const HIDE_CURSOR = "\x1b[?25l";
|
|
7
12
|
const SHOW_CURSOR = "\x1b[?25h";
|
|
8
13
|
const CLEAR_LINE = "\x1b[2K";
|
|
9
14
|
const MOVE_UP_ONE = "\x1b[1A";
|
|
10
|
-
const
|
|
15
|
+
const encodeProgressBarColorsKey = Schema.encodeSync(Schema.parseJson(ProgressBarColorsSchema));
|
|
11
16
|
|
|
12
|
-
const renderDeterminate = (
|
|
17
|
+
const renderDeterminate = (
|
|
18
|
+
units: DeterminateTaskUnits,
|
|
19
|
+
progressbar: ProgressBarConfigShape,
|
|
20
|
+
colors: CompiledProgressBarColors,
|
|
21
|
+
): string => {
|
|
13
22
|
const safeTotal = units.total <= 0 ? 1 : units.total;
|
|
14
23
|
const ratio = Math.min(1, Math.max(0, units.completed / safeTotal));
|
|
15
|
-
const filled = Math.round(ratio *
|
|
16
|
-
const bar = `${
|
|
24
|
+
const filled = Math.round(ratio * progressbar.barWidth);
|
|
25
|
+
const bar = `${colors.fill(progressbar.fillChar.repeat(filled))}${colors.empty(progressbar.emptyChar.repeat(progressbar.barWidth - filled))}`;
|
|
17
26
|
const percent = String(Math.round(ratio * 100)).padStart(3, " ");
|
|
18
|
-
return `${
|
|
27
|
+
return `${colors.brackets(progressbar.leftBracket)}${bar}${colors.brackets(progressbar.rightBracket)} ${units.completed}/${units.total} ${colors.percent(percent + "%")}`;
|
|
19
28
|
};
|
|
20
29
|
|
|
21
30
|
const buildTaskLine = (
|
|
22
31
|
snapshot: TaskSnapshot,
|
|
23
32
|
depth: number,
|
|
24
33
|
tick: number,
|
|
25
|
-
|
|
34
|
+
colors: CompiledProgressBarColors,
|
|
26
35
|
): string => {
|
|
36
|
+
const progressbar = snapshot.progressbar;
|
|
27
37
|
const prefix = `${" ".repeat(depth)}- ${snapshot.description}: `;
|
|
28
38
|
|
|
29
39
|
if (snapshot.status === "failed") {
|
|
30
|
-
return `${prefix}${
|
|
40
|
+
return `${prefix}${colors.failed("[failed]")}`;
|
|
31
41
|
}
|
|
32
42
|
|
|
33
43
|
if (snapshot.status === "done") {
|
|
34
44
|
if (snapshot.units._tag === "DeterminateTaskUnits") {
|
|
35
|
-
return `${prefix}${
|
|
45
|
+
return `${prefix}${colors.done("[done]")} ${snapshot.units.completed}/${snapshot.units.total}`;
|
|
36
46
|
}
|
|
37
|
-
return `${prefix}${
|
|
47
|
+
return `${prefix}${colors.done("[done]")}`;
|
|
38
48
|
}
|
|
39
49
|
|
|
40
50
|
if (snapshot.units._tag === "DeterminateTaskUnits") {
|
|
41
|
-
return prefix + renderDeterminate(snapshot.units,
|
|
51
|
+
return prefix + renderDeterminate(snapshot.units, progressbar, colors);
|
|
42
52
|
}
|
|
43
53
|
|
|
44
|
-
const frames =
|
|
54
|
+
const frames = progressbar.spinnerFrames;
|
|
45
55
|
const frameIndex = (snapshot.units.spinnerFrame + tick) % frames.length;
|
|
46
|
-
|
|
56
|
+
const frame = frames[frameIndex] ?? frames[0]!;
|
|
57
|
+
return `${prefix}${colors.spinner(frame)}`;
|
|
47
58
|
};
|
|
48
59
|
|
|
49
60
|
const orderTasksForRender = (
|
|
@@ -74,28 +85,72 @@ export const runProgressServiceRenderer = (
|
|
|
74
85
|
logsRef: Ref.Ref<ReadonlyArray<string>>,
|
|
75
86
|
pendingLogsRef: Ref.Ref<ReadonlyArray<string>>,
|
|
76
87
|
dirtyRef: Ref.Ref<boolean>,
|
|
77
|
-
|
|
88
|
+
terminal: ProgressTerminalService,
|
|
89
|
+
isTTY: boolean,
|
|
90
|
+
rendererConfig: RendererConfigShape,
|
|
78
91
|
maxRetainedLogLines: number,
|
|
79
92
|
) => {
|
|
80
|
-
const isTTY = config.isTTY;
|
|
81
93
|
const retainLogHistory = maxRetainedLogLines > 0;
|
|
94
|
+
const colorCache = new Map<string, CompiledProgressBarColors>();
|
|
82
95
|
let previousLineCount = 0;
|
|
83
|
-
let previousTaskLineCount = 0;
|
|
84
96
|
let nonTTYTaskSignatureById = new Map<number, string>();
|
|
85
97
|
let tick = 0;
|
|
86
|
-
let
|
|
98
|
+
let rendererActive = false;
|
|
99
|
+
let sessionActive = false;
|
|
100
|
+
|
|
101
|
+
const getCompiledColors = (progressbar: ProgressBarConfigShape): CompiledProgressBarColors => {
|
|
102
|
+
const key = encodeProgressBarColorsKey(progressbar.colors);
|
|
103
|
+
const cached = colorCache.get(key);
|
|
104
|
+
if (cached) {
|
|
105
|
+
return cached;
|
|
106
|
+
}
|
|
87
107
|
|
|
88
|
-
|
|
89
|
-
|
|
108
|
+
const compiled = compileProgressBarColors(progressbar.colors);
|
|
109
|
+
colorCache.set(key, compiled);
|
|
110
|
+
return compiled;
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const clipTTYFrameLines = (lines: ReadonlyArray<string>) =>
|
|
114
|
+
Effect.gen(function* () {
|
|
115
|
+
const terminalRows = yield* terminal.stderrRows;
|
|
116
|
+
if (terminalRows === undefined) {
|
|
117
|
+
return lines;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const visibleLineLimit = Math.max(1, terminalRows - 1);
|
|
121
|
+
if (lines.length <= visibleLineLimit) {
|
|
122
|
+
return lines;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (visibleLineLimit === 1) {
|
|
126
|
+
return [`... ${lines.length} lines hidden`];
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const hiddenLineCount = lines.length - visibleLineLimit + 1;
|
|
130
|
+
return [
|
|
131
|
+
`... ${hiddenLineCount} lines hidden (showing latest lines)`,
|
|
132
|
+
...lines.slice(lines.length - (visibleLineLimit - 1)),
|
|
133
|
+
];
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
const startTTYSession = Effect.gen(function* () {
|
|
137
|
+
if (!isTTY || sessionActive) {
|
|
90
138
|
return;
|
|
91
139
|
}
|
|
92
140
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
141
|
+
yield* terminal.writeStderr(HIDE_CURSOR);
|
|
142
|
+
sessionActive = true;
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
const stopTTYSession = Effect.gen(function* () {
|
|
146
|
+
if (!isTTY || !sessionActive) {
|
|
147
|
+
return;
|
|
96
148
|
}
|
|
97
|
-
|
|
98
|
-
|
|
149
|
+
|
|
150
|
+
yield* terminal.writeStderr("\n" + SHOW_CURSOR);
|
|
151
|
+
previousLineCount = 0;
|
|
152
|
+
sessionActive = false;
|
|
153
|
+
});
|
|
99
154
|
|
|
100
155
|
const renderNonTTYTaskUpdates = (
|
|
101
156
|
ordered: ReadonlyArray<{ snapshot: TaskSnapshot; depth: number }>,
|
|
@@ -103,7 +158,7 @@ export const runProgressServiceRenderer = (
|
|
|
103
158
|
) => {
|
|
104
159
|
const nextTaskSignatureById = new Map<number, string>();
|
|
105
160
|
const changedTaskLines: Array<string> = [];
|
|
106
|
-
const nonTtyUpdateStep = Math.max(1, Math.floor(
|
|
161
|
+
const nonTtyUpdateStep = Math.max(1, Math.floor(rendererConfig.nonTtyUpdateStep));
|
|
107
162
|
|
|
108
163
|
for (let i = 0; i < ordered.length; i++) {
|
|
109
164
|
const taskId = ordered[i]!.snapshot.id as number;
|
|
@@ -120,11 +175,13 @@ export const runProgressServiceRenderer = (
|
|
|
120
175
|
}
|
|
121
176
|
}
|
|
122
177
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
178
|
+
return Effect.gen(function* () {
|
|
179
|
+
if (changedTaskLines.length > 0) {
|
|
180
|
+
yield* terminal.writeStderr(changedTaskLines.join("\n") + "\n");
|
|
181
|
+
}
|
|
126
182
|
|
|
127
|
-
|
|
183
|
+
nonTTYTaskSignatureById = nextTaskSignatureById;
|
|
184
|
+
});
|
|
128
185
|
};
|
|
129
186
|
|
|
130
187
|
const renderFrame = (mode: "tick" | "final") =>
|
|
@@ -137,95 +194,102 @@ export const runProgressServiceRenderer = (
|
|
|
137
194
|
const frameTick = mode === "final" ? tick + 1 : tick;
|
|
138
195
|
const taskLines = ordered.map(({ snapshot, depth }) => {
|
|
139
196
|
const lineTick = isTTY ? frameTick : 0;
|
|
140
|
-
return buildTaskLine(snapshot, depth, lineTick,
|
|
197
|
+
return buildTaskLine(snapshot, depth, lineTick, getCompiledColors(snapshot.progressbar));
|
|
141
198
|
});
|
|
142
199
|
|
|
143
200
|
if (isTTY) {
|
|
201
|
+
let frame = "";
|
|
202
|
+
|
|
203
|
+
// 1. Cursor reset — move up and clear previous frame lines
|
|
204
|
+
if (previousLineCount > 0) {
|
|
205
|
+
frame += "\r" + CLEAR_LINE;
|
|
206
|
+
for (let i = 1; i < previousLineCount; i++) {
|
|
207
|
+
frame += MOVE_UP_ONE + CLEAR_LINE;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
144
211
|
if (retainLogHistory) {
|
|
145
212
|
const historyLogs = yield* Ref.get(logsRef);
|
|
146
|
-
const lines = [...historyLogs, ...taskLines];
|
|
147
|
-
clearTTYLines(previousLineCount);
|
|
213
|
+
const lines = yield* clipTTYFrameLines([...historyLogs, ...taskLines]);
|
|
148
214
|
if (lines.length > 0) {
|
|
149
|
-
|
|
215
|
+
frame += lines.join("\n");
|
|
150
216
|
}
|
|
151
217
|
previousLineCount = lines.length;
|
|
152
|
-
|
|
218
|
+
} else {
|
|
219
|
+
// 2. Logs (scroll above the task block)
|
|
220
|
+
if (drainedLogs.length > 0) {
|
|
221
|
+
frame += drainedLogs.join("\n") + "\n";
|
|
222
|
+
}
|
|
223
|
+
// 3. Task lines
|
|
224
|
+
if (taskLines.length > 0) {
|
|
225
|
+
frame += taskLines.join("\n");
|
|
226
|
+
}
|
|
227
|
+
previousLineCount = taskLines.length;
|
|
153
228
|
}
|
|
154
229
|
|
|
155
|
-
|
|
156
|
-
if (
|
|
157
|
-
|
|
158
|
-
}
|
|
159
|
-
if (taskLines.length > 0) {
|
|
160
|
-
process.stderr.write(taskLines.join("\n"));
|
|
230
|
+
// 4. Single atomic write
|
|
231
|
+
if (frame) {
|
|
232
|
+
yield* terminal.writeStderr(frame);
|
|
161
233
|
}
|
|
162
|
-
previousTaskLineCount = taskLines.length;
|
|
163
234
|
return;
|
|
164
235
|
}
|
|
165
236
|
|
|
166
237
|
if (drainedLogs.length > 0) {
|
|
167
|
-
|
|
238
|
+
yield* terminal.writeStderr(drainedLogs.join("\n") + "\n");
|
|
168
239
|
}
|
|
169
|
-
renderNonTTYTaskUpdates(ordered, taskLines);
|
|
240
|
+
yield* renderNonTTYTaskUpdates(ordered, taskLines);
|
|
170
241
|
});
|
|
171
242
|
|
|
172
|
-
|
|
243
|
+
const renderLoop = Effect.gen(function* () {
|
|
244
|
+
rendererActive = true;
|
|
173
245
|
if (isTTY) {
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
if (config.disableUserInput && process.stdin.isTTY) {
|
|
177
|
-
const stdin = process.stdin;
|
|
178
|
-
const wasRaw = Boolean(stdin.isRaw);
|
|
179
|
-
stdin.resume();
|
|
180
|
-
stdin.setRawMode?.(true);
|
|
181
|
-
|
|
182
|
-
const onData = (chunk: Buffer) => {
|
|
183
|
-
if (chunk.length === 1 && chunk[0] === 3) {
|
|
184
|
-
process.kill(process.pid, "SIGINT");
|
|
185
|
-
}
|
|
186
|
-
};
|
|
187
|
-
|
|
188
|
-
stdin.on("data", onData);
|
|
189
|
-
|
|
190
|
-
teardownInput = () => {
|
|
191
|
-
try {
|
|
192
|
-
stdin.off("data", onData);
|
|
193
|
-
stdin.setRawMode?.(wasRaw);
|
|
194
|
-
stdin.pause();
|
|
195
|
-
} catch {
|
|
196
|
-
// Best effort terminal restoration.
|
|
197
|
-
}
|
|
198
|
-
};
|
|
199
|
-
}
|
|
246
|
+
yield* startTTYSession;
|
|
200
247
|
}
|
|
201
248
|
|
|
202
249
|
while (true) {
|
|
203
250
|
const dirty = yield* Ref.getAndSet(dirtyRef, false);
|
|
204
|
-
const
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
),
|
|
251
|
+
const tasks = Array.from((yield* Ref.get(tasksRef)).values()).filter(
|
|
252
|
+
(task) => !(task.transient && task.status !== "running"),
|
|
253
|
+
);
|
|
254
|
+
const hasActiveSpinners = tasks.some(
|
|
255
|
+
(task) => task.status === "running" && task.units._tag === "IndeterminateTaskUnits",
|
|
210
256
|
);
|
|
257
|
+
const hasPendingLogs = (yield* Ref.get(pendingLogsRef)).length > 0;
|
|
211
258
|
|
|
212
|
-
if (
|
|
259
|
+
if (isTTY) {
|
|
260
|
+
if (dirty || hasActiveSpinners || hasPendingLogs) {
|
|
261
|
+
yield* renderFrame("tick");
|
|
262
|
+
}
|
|
263
|
+
} else if (dirty || hasActiveSpinners) {
|
|
213
264
|
yield* renderFrame("tick");
|
|
214
265
|
}
|
|
215
266
|
|
|
216
267
|
tick += 1;
|
|
217
|
-
yield* Effect.sleep(
|
|
268
|
+
yield* Effect.sleep(Math.max(1, Math.floor(rendererConfig.renderIntervalMillis)));
|
|
218
269
|
}
|
|
219
270
|
}).pipe(
|
|
220
271
|
Effect.ensuring(
|
|
221
272
|
Effect.gen(function* () {
|
|
222
|
-
|
|
273
|
+
if (!rendererActive) {
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
223
276
|
|
|
224
277
|
if (isTTY) {
|
|
225
|
-
|
|
226
|
-
|
|
278
|
+
if (sessionActive) {
|
|
279
|
+
yield* renderFrame("final");
|
|
280
|
+
yield* stopTTYSession;
|
|
281
|
+
}
|
|
282
|
+
return;
|
|
227
283
|
}
|
|
284
|
+
|
|
285
|
+
yield* renderFrame("final");
|
|
228
286
|
}),
|
|
229
287
|
),
|
|
230
288
|
);
|
|
289
|
+
|
|
290
|
+
if (isTTY && rendererConfig.disableUserInput) {
|
|
291
|
+
return terminal.withRawInputCapture(renderLoop);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
return renderLoop;
|
|
231
295
|
};
|
package/src/runtime.ts
CHANGED
|
@@ -1,19 +1,40 @@
|
|
|
1
|
-
import { Effect, Exit, FiberRef, Option, Ref } from "effect";
|
|
1
|
+
import { Context, Effect, Exit, FiberRef, Layer, Option, Ref } from "effect";
|
|
2
|
+
import { mergeWith } from "es-toolkit/object";
|
|
2
3
|
import { formatWithOptions } from "node:util";
|
|
4
|
+
import type { PartialDeep } from "type-fest";
|
|
5
|
+
import { makeProgressConsole } from "./console";
|
|
3
6
|
import { runProgressServiceRenderer } from "./renderer";
|
|
7
|
+
import { ProgressTerminal } from "./terminal";
|
|
4
8
|
import type { AddTaskOptions, ProgressService, UpdateTaskOptions } from "./types";
|
|
5
9
|
import {
|
|
10
|
+
decodeProgressBarConfigSync,
|
|
11
|
+
decodeRendererConfigSync,
|
|
6
12
|
defaultProgressBarConfig,
|
|
13
|
+
defaultRendererConfig,
|
|
7
14
|
DeterminateTaskUnits,
|
|
8
15
|
IndeterminateTaskUnits,
|
|
9
|
-
Progress,
|
|
10
16
|
ProgressBarConfig,
|
|
17
|
+
RendererConfig,
|
|
18
|
+
Task,
|
|
11
19
|
TaskId,
|
|
12
20
|
TaskSnapshot,
|
|
13
21
|
} from "./types";
|
|
14
22
|
import { inferTotal } from "./utils";
|
|
15
23
|
|
|
16
|
-
const
|
|
24
|
+
const mergeConfig = <T extends Record<PropertyKey, any>>(
|
|
25
|
+
base: T,
|
|
26
|
+
override: PartialDeep<T> | undefined,
|
|
27
|
+
): T =>
|
|
28
|
+
mergeWith(
|
|
29
|
+
structuredClone(base),
|
|
30
|
+
(override ?? {}) as Record<PropertyKey, any>,
|
|
31
|
+
(_targetValue, sourceValue) => {
|
|
32
|
+
if (Array.isArray(sourceValue)) {
|
|
33
|
+
return sourceValue;
|
|
34
|
+
}
|
|
35
|
+
return undefined;
|
|
36
|
+
},
|
|
37
|
+
) as T;
|
|
17
38
|
|
|
18
39
|
const updatedSnapshot = (snapshot: TaskSnapshot, options: UpdateTaskOptions): TaskSnapshot => {
|
|
19
40
|
const currentUnits = snapshot.units;
|
|
@@ -54,49 +75,53 @@ const updatedSnapshot = (snapshot: TaskSnapshot, options: UpdateTaskOptions): Ta
|
|
|
54
75
|
status: snapshot.status,
|
|
55
76
|
transient: options.transient ?? snapshot.transient,
|
|
56
77
|
units,
|
|
78
|
+
progressbar: snapshot.progressbar,
|
|
57
79
|
});
|
|
58
80
|
};
|
|
59
81
|
|
|
60
|
-
|
|
61
|
-
const
|
|
62
|
-
const
|
|
63
|
-
|
|
82
|
+
const makeProgressService = Effect.gen(function* () {
|
|
83
|
+
const rendererConfigOption = yield* Effect.serviceOption(RendererConfig);
|
|
84
|
+
const progressBarConfigOption = yield* Effect.serviceOption(ProgressBarConfig);
|
|
85
|
+
|
|
86
|
+
const rendererConfig = decodeRendererConfigSync(
|
|
87
|
+
mergeConfig(
|
|
88
|
+
defaultRendererConfig,
|
|
89
|
+
Option.isSome(rendererConfigOption) ? rendererConfigOption.value : undefined,
|
|
90
|
+
),
|
|
91
|
+
);
|
|
92
|
+
const progressBarConfig = decodeProgressBarConfigSync(
|
|
93
|
+
mergeConfig(
|
|
94
|
+
defaultProgressBarConfig,
|
|
95
|
+
Option.isSome(progressBarConfigOption) ? progressBarConfigOption.value : undefined,
|
|
96
|
+
),
|
|
97
|
+
);
|
|
98
|
+
const terminal = yield* ProgressTerminal;
|
|
99
|
+
const isTTY = yield* terminal.isTTY;
|
|
100
|
+
const maxRetainedLogLines = Math.max(0, Math.floor(rendererConfig.maxLogLines ?? 0));
|
|
64
101
|
|
|
65
102
|
const nextTaskIdRef = yield* Ref.make(0);
|
|
66
103
|
const tasksRef = yield* Ref.make(new Map<TaskId, TaskSnapshot>());
|
|
67
104
|
const logsRef = yield* Ref.make<ReadonlyArray<string>>([]);
|
|
68
105
|
const pendingLogsRef = yield* Ref.make<ReadonlyArray<string>>([]);
|
|
69
106
|
const dirtyRef = yield* Ref.make(true);
|
|
70
|
-
const dirtyScheduledRef = yield* Ref.make(false);
|
|
71
107
|
const currentParentRef = yield* FiberRef.make(Option.none<TaskId>());
|
|
108
|
+
const scope = yield* Effect.scope;
|
|
72
109
|
|
|
73
|
-
yield* Effect.
|
|
110
|
+
yield* Effect.forkIn(
|
|
74
111
|
runProgressServiceRenderer(
|
|
75
112
|
tasksRef,
|
|
76
113
|
logsRef,
|
|
77
114
|
pendingLogsRef,
|
|
78
115
|
dirtyRef,
|
|
79
|
-
|
|
116
|
+
terminal,
|
|
117
|
+
isTTY,
|
|
118
|
+
rendererConfig,
|
|
80
119
|
maxRetainedLogLines,
|
|
81
120
|
),
|
|
121
|
+
scope,
|
|
82
122
|
);
|
|
83
123
|
|
|
84
|
-
const markDirty =
|
|
85
|
-
const shouldSchedule = yield* Ref.modify(dirtyScheduledRef, (scheduled) =>
|
|
86
|
-
scheduled ? [false, true] : [true, true],
|
|
87
|
-
);
|
|
88
|
-
|
|
89
|
-
if (!shouldSchedule) {
|
|
90
|
-
return;
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
yield* Effect.forkDaemon(
|
|
94
|
-
Effect.sleep(DIRTY_DEBOUNCE_INTERVAL).pipe(
|
|
95
|
-
Effect.zipRight(Ref.set(dirtyRef, true)),
|
|
96
|
-
Effect.ensuring(Ref.set(dirtyScheduledRef, false)),
|
|
97
|
-
),
|
|
98
|
-
);
|
|
99
|
-
});
|
|
124
|
+
const markDirty = Ref.set(dirtyRef, true);
|
|
100
125
|
|
|
101
126
|
const addTask = (options: AddTaskOptions) =>
|
|
102
127
|
Effect.gen(function* () {
|
|
@@ -109,6 +134,12 @@ export const makeProgressService = Effect.gen(function* () {
|
|
|
109
134
|
options.total === undefined || options.total <= 0
|
|
110
135
|
? new IndeterminateTaskUnits({ spinnerFrame: 0 })
|
|
111
136
|
: new DeterminateTaskUnits({ completed: 0, total: Math.max(0, options.total) });
|
|
137
|
+
const tasks = yield* Ref.get(tasksRef);
|
|
138
|
+
const parentSnapshot = Option.isSome(parentId) ? tasks.get(parentId.value) : undefined;
|
|
139
|
+
const inheritedProgressBarConfig = parentSnapshot?.progressbar ?? progressBarConfig;
|
|
140
|
+
const resolvedProgressBarConfig = decodeProgressBarConfigSync(
|
|
141
|
+
mergeConfig(inheritedProgressBarConfig, options.progressbar),
|
|
142
|
+
);
|
|
112
143
|
|
|
113
144
|
const snapshot = new TaskSnapshot({
|
|
114
145
|
id: taskId,
|
|
@@ -117,6 +148,7 @@ export const makeProgressService = Effect.gen(function* () {
|
|
|
117
148
|
status: "running",
|
|
118
149
|
transient: options.transient ?? false,
|
|
119
150
|
units,
|
|
151
|
+
progressbar: resolvedProgressBarConfig,
|
|
120
152
|
});
|
|
121
153
|
|
|
122
154
|
yield* Ref.update(tasksRef, (tasks) => {
|
|
@@ -156,8 +188,7 @@ export const makeProgressService = Effect.gen(function* () {
|
|
|
156
188
|
total: snapshot.units.total,
|
|
157
189
|
})
|
|
158
190
|
: new IndeterminateTaskUnits({
|
|
159
|
-
spinnerFrame:
|
|
160
|
-
(snapshot.units.spinnerFrame + amount) % Math.max(1, config.spinnerFrames.length),
|
|
191
|
+
spinnerFrame: Math.max(0, snapshot.units.spinnerFrame + amount),
|
|
161
192
|
});
|
|
162
193
|
|
|
163
194
|
next.set(
|
|
@@ -169,6 +200,7 @@ export const makeProgressService = Effect.gen(function* () {
|
|
|
169
200
|
status: snapshot.status,
|
|
170
201
|
transient: snapshot.transient,
|
|
171
202
|
units,
|
|
203
|
+
progressbar: snapshot.progressbar,
|
|
172
204
|
}),
|
|
173
205
|
);
|
|
174
206
|
|
|
@@ -203,6 +235,7 @@ export const makeProgressService = Effect.gen(function* () {
|
|
|
203
235
|
total: snapshot.units.total,
|
|
204
236
|
})
|
|
205
237
|
: snapshot.units,
|
|
238
|
+
progressbar: snapshot.progressbar,
|
|
206
239
|
}),
|
|
207
240
|
);
|
|
208
241
|
return next;
|
|
@@ -230,12 +263,13 @@ export const makeProgressService = Effect.gen(function* () {
|
|
|
230
263
|
status: "failed",
|
|
231
264
|
transient: snapshot.transient,
|
|
232
265
|
units: snapshot.units,
|
|
266
|
+
progressbar: snapshot.progressbar,
|
|
233
267
|
}),
|
|
234
268
|
);
|
|
235
269
|
return next;
|
|
236
270
|
}).pipe(Effect.zipRight(markDirty));
|
|
237
271
|
|
|
238
|
-
const
|
|
272
|
+
const appendLog = (args: ReadonlyArray<unknown>) =>
|
|
239
273
|
Effect.gen(function* () {
|
|
240
274
|
if (args.length === 0) {
|
|
241
275
|
return;
|
|
@@ -243,7 +277,7 @@ export const makeProgressService = Effect.gen(function* () {
|
|
|
243
277
|
|
|
244
278
|
const message = formatWithOptions(
|
|
245
279
|
{
|
|
246
|
-
colors:
|
|
280
|
+
colors: isTTY,
|
|
247
281
|
depth: 6,
|
|
248
282
|
},
|
|
249
283
|
...args,
|
|
@@ -263,6 +297,11 @@ export const makeProgressService = Effect.gen(function* () {
|
|
|
263
297
|
yield* markDirty;
|
|
264
298
|
});
|
|
265
299
|
|
|
300
|
+
const log = (...args: ReadonlyArray<unknown>) =>
|
|
301
|
+
Effect.gen(function* () {
|
|
302
|
+
yield* appendLog(args);
|
|
303
|
+
});
|
|
304
|
+
|
|
266
305
|
const getTask = (taskId: TaskId) =>
|
|
267
306
|
Ref.get(tasksRef).pipe(Effect.map((tasks) => Option.fromNullable(tasks.get(taskId))));
|
|
268
307
|
|
|
@@ -270,6 +309,7 @@ export const makeProgressService = Effect.gen(function* () {
|
|
|
270
309
|
|
|
271
310
|
const withTask: ProgressService["withTask"] = (options, effect) =>
|
|
272
311
|
Effect.gen(function* () {
|
|
312
|
+
const outerConsole = yield* Effect.console;
|
|
273
313
|
const inheritedParentId = yield* FiberRef.get(currentParentRef);
|
|
274
314
|
const resolvedParentId =
|
|
275
315
|
options.parentId === undefined ? inheritedParentId : Option.some(options.parentId);
|
|
@@ -281,7 +321,14 @@ export const makeProgressService = Effect.gen(function* () {
|
|
|
281
321
|
});
|
|
282
322
|
|
|
283
323
|
const exit = yield* Effect.exit(
|
|
284
|
-
Effect.locally(
|
|
324
|
+
Effect.locally(
|
|
325
|
+
Effect.withConsole(
|
|
326
|
+
Effect.provideService(effect, Task, taskId),
|
|
327
|
+
makeProgressConsole(log, outerConsole),
|
|
328
|
+
),
|
|
329
|
+
currentParentRef,
|
|
330
|
+
Option.some(taskId),
|
|
331
|
+
),
|
|
285
332
|
);
|
|
286
333
|
|
|
287
334
|
if (Exit.isSuccess(exit)) {
|
|
@@ -302,15 +349,17 @@ export const makeProgressService = Effect.gen(function* () {
|
|
|
302
349
|
description: options.description,
|
|
303
350
|
total: options.total ?? inferTotal(iterable),
|
|
304
351
|
transient: options.transient,
|
|
352
|
+
progressbar: options.progressbar,
|
|
305
353
|
},
|
|
306
|
-
(
|
|
307
|
-
|
|
354
|
+
Effect.gen(function* () {
|
|
355
|
+
const taskId = yield* Task;
|
|
356
|
+
return yield* Effect.forEach(iterable, (item, index) =>
|
|
308
357
|
Effect.tap(f(item, index), () => advanceTask(taskId, 1)),
|
|
309
358
|
);
|
|
310
|
-
},
|
|
359
|
+
}),
|
|
311
360
|
);
|
|
312
361
|
|
|
313
|
-
|
|
362
|
+
const service: ProgressService = {
|
|
314
363
|
addTask,
|
|
315
364
|
updateTask,
|
|
316
365
|
advanceTask,
|
|
@@ -321,5 +370,27 @@ export const makeProgressService = Effect.gen(function* () {
|
|
|
321
370
|
listTasks,
|
|
322
371
|
withTask,
|
|
323
372
|
trackIterable,
|
|
324
|
-
}
|
|
373
|
+
};
|
|
374
|
+
|
|
375
|
+
return Progress.of(service);
|
|
325
376
|
});
|
|
377
|
+
|
|
378
|
+
export class Progress extends Context.Tag("stromseng.dev/Progress")<Progress, ProgressService>() {
|
|
379
|
+
static readonly Default = Layer.scoped(Progress, makeProgressService);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
export const provideProgressService = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
|
|
383
|
+
Effect.gen(function* () {
|
|
384
|
+
const existing = yield* Effect.serviceOption(Progress);
|
|
385
|
+
if (Option.isSome(existing)) {
|
|
386
|
+
return yield* Effect.provideService(effect, Progress, existing.value);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
const existingTerminal = yield* Effect.serviceOption(ProgressTerminal);
|
|
390
|
+
if (Option.isSome(existingTerminal)) {
|
|
391
|
+
return yield* Effect.scoped(effect.pipe(Effect.provide(Progress.Default)));
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
const defaultLayers = Layer.provide(Progress.Default, ProgressTerminal.Default);
|
|
395
|
+
return yield* Effect.scoped(effect.pipe(Effect.provide(defaultLayers)));
|
|
396
|
+
});
|
package/src/terminal.ts
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { Context, Effect, Layer } from "effect";
|
|
2
|
+
|
|
3
|
+
export interface ProgressTerminalService {
|
|
4
|
+
readonly isTTY: Effect.Effect<boolean>;
|
|
5
|
+
readonly stderrRows: Effect.Effect<number | undefined>;
|
|
6
|
+
readonly stderrColumns: Effect.Effect<number | undefined>;
|
|
7
|
+
readonly writeStderr: (text: string) => Effect.Effect<void>;
|
|
8
|
+
readonly withRawInputCapture: <A, E, R>(
|
|
9
|
+
effect: Effect.Effect<A, E, R>,
|
|
10
|
+
) => Effect.Effect<A, E, R>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const withRawInputCapture: ProgressTerminalService["withRawInputCapture"] = (effect) =>
|
|
14
|
+
Effect.suspend(() => {
|
|
15
|
+
if (!process.stdin.isTTY) {
|
|
16
|
+
return effect;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const stdin = process.stdin;
|
|
20
|
+
const wasRaw = Boolean(stdin.isRaw);
|
|
21
|
+
const onData = (chunk: Buffer) => {
|
|
22
|
+
if (chunk.length === 1 && chunk[0] === 3) {
|
|
23
|
+
process.kill(process.pid, "SIGINT");
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
return Effect.acquireUseRelease(
|
|
28
|
+
Effect.sync(() => {
|
|
29
|
+
stdin.resume();
|
|
30
|
+
stdin.setRawMode?.(true);
|
|
31
|
+
stdin.on("data", onData);
|
|
32
|
+
}),
|
|
33
|
+
() => effect,
|
|
34
|
+
() =>
|
|
35
|
+
Effect.sync(() => {
|
|
36
|
+
try {
|
|
37
|
+
stdin.off("data", onData);
|
|
38
|
+
stdin.setRawMode?.(wasRaw);
|
|
39
|
+
stdin.pause();
|
|
40
|
+
} catch {
|
|
41
|
+
// Best effort terminal restoration.
|
|
42
|
+
}
|
|
43
|
+
}),
|
|
44
|
+
);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
export class ProgressTerminal extends Context.Tag("stromseng.dev/ProgressTerminal")<
|
|
48
|
+
ProgressTerminal,
|
|
49
|
+
ProgressTerminalService
|
|
50
|
+
>() {
|
|
51
|
+
static readonly Default = Layer.succeed(ProgressTerminal, {
|
|
52
|
+
isTTY: Effect.sync(() => Boolean(process.stderr.isTTY)),
|
|
53
|
+
stderrRows: Effect.sync(() => process.stderr.rows),
|
|
54
|
+
stderrColumns: Effect.sync(() => process.stderr.columns),
|
|
55
|
+
writeStderr: (text) =>
|
|
56
|
+
Effect.sync(() => {
|
|
57
|
+
process.stderr.write(text);
|
|
58
|
+
}),
|
|
59
|
+
withRawInputCapture,
|
|
60
|
+
} satisfies ProgressTerminalService);
|
|
61
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -1,39 +1,53 @@
|
|
|
1
1
|
import { Brand, Context, Effect, Option, Schema } from "effect";
|
|
2
|
+
import type { PartialDeep } from "type-fest";
|
|
3
|
+
import { defaultProgressBarColors, ProgressBarColorsSchema } from "./colors";
|
|
2
4
|
|
|
3
|
-
export const
|
|
4
|
-
|
|
5
|
+
export const RendererConfigSchema = Schema.Struct({
|
|
6
|
+
disableUserInput: Schema.Boolean,
|
|
7
|
+
renderIntervalMillis: Schema.Number,
|
|
8
|
+
maxLogLines: Schema.optional(Schema.Number),
|
|
9
|
+
nonTtyUpdateStep: Schema.Number,
|
|
10
|
+
});
|
|
11
|
+
export type RendererConfigShape = typeof RendererConfigSchema.Type;
|
|
12
|
+
export const decodeRendererConfigSync = Schema.decodeUnknownSync(RendererConfigSchema);
|
|
5
13
|
|
|
6
14
|
export const ProgressBarConfigSchema = Schema.Struct({
|
|
7
|
-
isTTY: Schema.Boolean,
|
|
8
|
-
disableUserInput: Schema.Boolean,
|
|
9
15
|
spinnerFrames: Schema.NonEmptyArray(Schema.String),
|
|
10
16
|
barWidth: Schema.Number,
|
|
11
17
|
fillChar: Schema.String,
|
|
12
18
|
emptyChar: Schema.String,
|
|
13
19
|
leftBracket: Schema.String,
|
|
14
20
|
rightBracket: Schema.String,
|
|
15
|
-
|
|
16
|
-
nonTtyUpdateStep: Schema.Number,
|
|
21
|
+
colors: ProgressBarColorsSchema,
|
|
17
22
|
});
|
|
18
|
-
|
|
19
23
|
export type ProgressBarConfigShape = typeof ProgressBarConfigSchema.Type;
|
|
24
|
+
export const decodeProgressBarConfigSync = Schema.decodeUnknownSync(ProgressBarConfigSchema);
|
|
20
25
|
|
|
21
|
-
export const
|
|
22
|
-
isTTY: Boolean(process.stderr.isTTY),
|
|
26
|
+
export const defaultRendererConfig: RendererConfigShape = {
|
|
23
27
|
disableUserInput: true,
|
|
24
|
-
|
|
25
|
-
|
|
28
|
+
renderIntervalMillis: 50, // 20 FPS
|
|
29
|
+
maxLogLines: 0,
|
|
30
|
+
nonTtyUpdateStep: 5,
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export const defaultProgressBarConfig: ProgressBarConfigShape = {
|
|
34
|
+
spinnerFrames: ["-", "\\", "|", "/"],
|
|
35
|
+
barWidth: 30,
|
|
26
36
|
fillChar: "━",
|
|
27
37
|
emptyChar: "─",
|
|
28
38
|
leftBracket: "",
|
|
29
39
|
rightBracket: "",
|
|
30
|
-
|
|
31
|
-
nonTtyUpdateStep: 5,
|
|
40
|
+
colors: defaultProgressBarColors,
|
|
32
41
|
};
|
|
33
42
|
|
|
43
|
+
export class RendererConfig extends Context.Tag("stromseng.dev/RendererConfig")<
|
|
44
|
+
RendererConfig,
|
|
45
|
+
PartialDeep<RendererConfigShape>
|
|
46
|
+
>() {}
|
|
47
|
+
|
|
34
48
|
export class ProgressBarConfig extends Context.Tag("stromseng.dev/ProgressBarConfig")<
|
|
35
49
|
ProgressBarConfig,
|
|
36
|
-
ProgressBarConfigShape
|
|
50
|
+
PartialDeep<ProgressBarConfigShape>
|
|
37
51
|
>() {}
|
|
38
52
|
|
|
39
53
|
const TaskIdSchema = Schema.Number.pipe(Schema.brand("TaskId"));
|
|
@@ -50,6 +64,7 @@ export interface AddTaskOptions {
|
|
|
50
64
|
readonly total?: number;
|
|
51
65
|
readonly transient?: boolean;
|
|
52
66
|
readonly parentId?: TaskId;
|
|
67
|
+
readonly progressbar?: PartialDeep<ProgressBarConfigShape>;
|
|
53
68
|
}
|
|
54
69
|
|
|
55
70
|
export interface UpdateTaskOptions {
|
|
@@ -87,6 +102,7 @@ export class TaskSnapshot extends Schema.TaggedClass<TaskSnapshot>()("TaskSnapsh
|
|
|
87
102
|
status: TaskStatusSchema,
|
|
88
103
|
transient: Schema.Boolean,
|
|
89
104
|
units: TaskUnitsSchema,
|
|
105
|
+
progressbar: ProgressBarConfigSchema,
|
|
90
106
|
}) {}
|
|
91
107
|
|
|
92
108
|
export interface ProgressService {
|
|
@@ -100,16 +116,16 @@ export interface ProgressService {
|
|
|
100
116
|
readonly listTasks: Effect.Effect<ReadonlyArray<TaskSnapshot>>;
|
|
101
117
|
readonly withTask: <A, E, R>(
|
|
102
118
|
options: AddTaskOptions,
|
|
103
|
-
effect:
|
|
104
|
-
) => Effect.Effect<A, E, R
|
|
119
|
+
effect: Effect.Effect<A, E, R>,
|
|
120
|
+
) => Effect.Effect<A, E, Exclude<R, Task>>;
|
|
105
121
|
readonly trackIterable: <A, B, E, R>(
|
|
106
122
|
iterable: Iterable<A>,
|
|
107
123
|
options: TrackOptions,
|
|
108
124
|
f: (item: A, index: number) => Effect.Effect<B, E, R>,
|
|
109
|
-
) => Effect.Effect<ReadonlyArray<B>, E, R
|
|
125
|
+
) => Effect.Effect<ReadonlyArray<B>, E, Exclude<R, Task>>;
|
|
110
126
|
}
|
|
111
127
|
|
|
112
|
-
export class
|
|
128
|
+
export class Task extends Context.Tag("stromseng.dev/Task")<Task, TaskId>() {}
|
|
113
129
|
|
|
114
130
|
export class TaskAddedEvent extends Schema.TaggedClass<TaskAddedEvent>()("TaskAdded", {
|
|
115
131
|
taskId: TaskIdSchema,
|