effective-progress 0.1.3 → 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 +42 -13
- package/package.json +2 -2
- package/src/api.ts +31 -35
- package/src/colors.ts +4 -54
- package/src/index.ts +2 -1
- package/src/renderer.ts +100 -68
- package/src/runtime.ts +47 -50
- package/src/terminal.ts +61 -0
- package/src/types.ts +4 -7
package/README.md
CHANGED
|
@@ -82,6 +82,7 @@ Effect.runPromise(program);
|
|
|
82
82
|
- `examples/simpleExample.ts` - low-boilerplate real-world flow
|
|
83
83
|
- `examples/advancedExample.ts` - full API usage with custom config and manual task control
|
|
84
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
|
|
85
86
|
|
|
86
87
|
## Log retention
|
|
87
88
|
|
|
@@ -114,6 +115,40 @@ const configured = program.pipe(
|
|
|
114
115
|
Effect.runPromise(configured);
|
|
115
116
|
```
|
|
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
|
+
|
|
117
152
|
Task-level `progressbar` config is optional and inherits from its parent task (or from global `ProgressBarConfig` for root tasks):
|
|
118
153
|
|
|
119
154
|
```ts
|
|
@@ -129,25 +164,19 @@ yield *
|
|
|
129
164
|
},
|
|
130
165
|
},
|
|
131
166
|
},
|
|
132
|
-
|
|
167
|
+
Effect.sleep("1 second"),
|
|
133
168
|
);
|
|
134
169
|
```
|
|
135
170
|
|
|
136
|
-
For manual
|
|
171
|
+
For manual usage, `withTask` captures logs implicitly and provides the current `Task` context:
|
|
137
172
|
|
|
138
173
|
```ts
|
|
139
|
-
const program = Progress.
|
|
174
|
+
const program = Progress.withTask(
|
|
175
|
+
{ description: "Manual task" },
|
|
140
176
|
Effect.gen(function* () {
|
|
141
|
-
const
|
|
142
|
-
|
|
143
|
-
yield*
|
|
144
|
-
progress.withCapturedLogs(
|
|
145
|
-
Effect.gen(function* () {
|
|
146
|
-
yield* Console.log("This log is rendered through progress output");
|
|
147
|
-
yield* Effect.sleep("1 second");
|
|
148
|
-
}),
|
|
149
|
-
),
|
|
150
|
-
);
|
|
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");
|
|
151
180
|
}),
|
|
152
181
|
);
|
|
153
182
|
```
|
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,7 @@
|
|
|
31
31
|
},
|
|
32
32
|
"dependencies": {
|
|
33
33
|
"chalk": "^5.6.2",
|
|
34
|
-
"effect": "^3.19.
|
|
34
|
+
"effect": "^3.19.17",
|
|
35
35
|
"es-toolkit": "^1.44.0",
|
|
36
36
|
"type-fest": "^5.4.4"
|
|
37
37
|
},
|
package/src/api.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { Effect
|
|
1
|
+
import { Effect } from "effect";
|
|
2
2
|
import type { Concurrency } from "effect/Types";
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import type { TrackOptions } from "./types";
|
|
3
|
+
import { Progress, provideProgressService } from "./runtime";
|
|
4
|
+
import { Task } from "./types";
|
|
5
|
+
import type { AddTaskOptions, TrackOptions } from "./types";
|
|
6
6
|
import { inferTotal } from "./utils";
|
|
7
7
|
|
|
8
8
|
export interface EffectExecutionOptions {
|
|
@@ -22,7 +22,7 @@ export type AllReturn<
|
|
|
22
22
|
O extends EffectAllExecutionOptions,
|
|
23
23
|
> =
|
|
24
24
|
[Effect.All.ReturnTuple<Arg, Effect.All.IsDiscard<O>, Effect.All.ExtractMode<O>>] extends
|
|
25
|
-
[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>>
|
|
26
26
|
: never;
|
|
27
27
|
|
|
28
28
|
export interface ForEachExecutionOptions extends EffectExecutionOptions {
|
|
@@ -31,20 +31,16 @@ export interface ForEachExecutionOptions extends EffectExecutionOptions {
|
|
|
31
31
|
|
|
32
32
|
export type ForEachOptions = TrackOptions & ForEachExecutionOptions;
|
|
33
33
|
|
|
34
|
-
export const
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
return yield* Effect.provideService(effect, Progress, service);
|
|
45
|
-
}),
|
|
46
|
-
);
|
|
47
|
-
});
|
|
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>>;
|
|
48
44
|
|
|
49
45
|
export const all = <
|
|
50
46
|
const Arg extends ReadonlyArray<Effect.Effect<any, any, any>>,
|
|
@@ -53,7 +49,7 @@ export const all = <
|
|
|
53
49
|
effects: Arg,
|
|
54
50
|
options: Omit<TrackOptions, "total"> & O,
|
|
55
51
|
): AllReturn<Arg, O> =>
|
|
56
|
-
|
|
52
|
+
provideProgressService(
|
|
57
53
|
Effect.gen(function* () {
|
|
58
54
|
const progress = yield* Progress;
|
|
59
55
|
return yield* progress.withTask(
|
|
@@ -63,11 +59,10 @@ export const all = <
|
|
|
63
59
|
transient: options.transient,
|
|
64
60
|
progressbar: options.progressbar,
|
|
65
61
|
},
|
|
66
|
-
(
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
),
|
|
62
|
+
Effect.gen(function* () {
|
|
63
|
+
const taskId = yield* Task;
|
|
64
|
+
return yield* Effect.all(
|
|
65
|
+
effects.map((effect) => Effect.tap(effect, () => progress.advanceTask(taskId, 1))),
|
|
71
66
|
{
|
|
72
67
|
concurrency: options.concurrency,
|
|
73
68
|
batching: options.batching,
|
|
@@ -75,7 +70,8 @@ export const all = <
|
|
|
75
70
|
mode: options.mode,
|
|
76
71
|
concurrentFinalizers: options.concurrentFinalizers,
|
|
77
72
|
},
|
|
78
|
-
)
|
|
73
|
+
);
|
|
74
|
+
}),
|
|
79
75
|
);
|
|
80
76
|
}),
|
|
81
77
|
) as AllReturn<Arg, O>;
|
|
@@ -84,8 +80,8 @@ export const forEach = <A, B, E, R>(
|
|
|
84
80
|
iterable: Iterable<A>,
|
|
85
81
|
f: (item: A, index: number) => Effect.Effect<B, E, R>,
|
|
86
82
|
options: ForEachOptions,
|
|
87
|
-
): Effect.Effect<ReadonlyArray<B>, E, Exclude<R, Progress>> =>
|
|
88
|
-
|
|
83
|
+
): Effect.Effect<ReadonlyArray<B>, E, Exclude<R, Progress | Task>> =>
|
|
84
|
+
provideProgressService(
|
|
89
85
|
Effect.gen(function* () {
|
|
90
86
|
const progress = yield* Progress;
|
|
91
87
|
|
|
@@ -96,20 +92,20 @@ export const forEach = <A, B, E, R>(
|
|
|
96
92
|
transient: options.transient,
|
|
97
93
|
progressbar: options.progressbar,
|
|
98
94
|
},
|
|
99
|
-
(
|
|
100
|
-
|
|
95
|
+
Effect.gen(function* () {
|
|
96
|
+
const taskId = yield* Task;
|
|
97
|
+
return yield* Effect.forEach(
|
|
101
98
|
iterable,
|
|
102
99
|
(item, index) =>
|
|
103
|
-
Effect.tap(
|
|
104
|
-
progress.advanceTask(taskId, 1),
|
|
105
|
-
),
|
|
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
CHANGED
|
@@ -103,61 +103,11 @@ export const defaultProgressBarColors: ProgressBarColors = {
|
|
|
103
103
|
failed: { kind: "named", value: "red" },
|
|
104
104
|
};
|
|
105
105
|
|
|
106
|
-
const applyModifier = (instance: ChalkInstance, modifier: StyleModifier): ChalkInstance =>
|
|
107
|
-
|
|
108
|
-
case "bold":
|
|
109
|
-
return instance.bold;
|
|
110
|
-
case "dim":
|
|
111
|
-
return instance.dim;
|
|
112
|
-
case "italic":
|
|
113
|
-
return instance.italic;
|
|
114
|
-
case "underline":
|
|
115
|
-
return instance.underline;
|
|
116
|
-
case "inverse":
|
|
117
|
-
return instance.inverse;
|
|
118
|
-
case "hidden":
|
|
119
|
-
return instance.hidden;
|
|
120
|
-
case "strikethrough":
|
|
121
|
-
return instance.strikethrough;
|
|
122
|
-
}
|
|
123
|
-
};
|
|
106
|
+
const applyModifier = (instance: ChalkInstance, modifier: StyleModifier): ChalkInstance =>
|
|
107
|
+
instance[modifier] as ChalkInstance;
|
|
124
108
|
|
|
125
|
-
const applyNamedColor = (instance: ChalkInstance, color: NamedColor): ChalkInstance =>
|
|
126
|
-
|
|
127
|
-
case "black":
|
|
128
|
-
return instance.black;
|
|
129
|
-
case "red":
|
|
130
|
-
return instance.red;
|
|
131
|
-
case "green":
|
|
132
|
-
return instance.green;
|
|
133
|
-
case "yellow":
|
|
134
|
-
return instance.yellow;
|
|
135
|
-
case "blue":
|
|
136
|
-
return instance.blue;
|
|
137
|
-
case "magenta":
|
|
138
|
-
return instance.magenta;
|
|
139
|
-
case "cyan":
|
|
140
|
-
return instance.cyan;
|
|
141
|
-
case "white":
|
|
142
|
-
return instance.white;
|
|
143
|
-
case "blackBright":
|
|
144
|
-
return instance.blackBright;
|
|
145
|
-
case "redBright":
|
|
146
|
-
return instance.redBright;
|
|
147
|
-
case "greenBright":
|
|
148
|
-
return instance.greenBright;
|
|
149
|
-
case "yellowBright":
|
|
150
|
-
return instance.yellowBright;
|
|
151
|
-
case "blueBright":
|
|
152
|
-
return instance.blueBright;
|
|
153
|
-
case "magentaBright":
|
|
154
|
-
return instance.magentaBright;
|
|
155
|
-
case "cyanBright":
|
|
156
|
-
return instance.cyanBright;
|
|
157
|
-
case "whiteBright":
|
|
158
|
-
return instance.whiteBright;
|
|
159
|
-
}
|
|
160
|
-
};
|
|
109
|
+
const applyNamedColor = (instance: ChalkInstance, color: NamedColor): ChalkInstance =>
|
|
110
|
+
instance[color] as ChalkInstance;
|
|
161
111
|
|
|
162
112
|
const resolveBaseStyle = (style: ColorStyle): ChalkInstance => {
|
|
163
113
|
switch (style.kind) {
|
package/src/index.ts
CHANGED
package/src/renderer.ts
CHANGED
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
compileProgressBarColors,
|
|
5
5
|
ProgressBarColorsSchema,
|
|
6
6
|
} from "./colors";
|
|
7
|
+
import type { ProgressTerminalService } from "./terminal";
|
|
7
8
|
import type { ProgressBarConfigShape, RendererConfigShape } from "./types";
|
|
8
9
|
import { DeterminateTaskUnits, TaskId, TaskSnapshot } from "./types";
|
|
9
10
|
|
|
@@ -84,19 +85,18 @@ export const runProgressServiceRenderer = (
|
|
|
84
85
|
logsRef: Ref.Ref<ReadonlyArray<string>>,
|
|
85
86
|
pendingLogsRef: Ref.Ref<ReadonlyArray<string>>,
|
|
86
87
|
dirtyRef: Ref.Ref<boolean>,
|
|
88
|
+
terminal: ProgressTerminalService,
|
|
89
|
+
isTTY: boolean,
|
|
87
90
|
rendererConfig: RendererConfigShape,
|
|
88
91
|
maxRetainedLogLines: number,
|
|
89
|
-
rendererLatch: Effect.Latch,
|
|
90
92
|
) => {
|
|
91
|
-
const isTTY = rendererConfig.isTTY;
|
|
92
93
|
const retainLogHistory = maxRetainedLogLines > 0;
|
|
93
94
|
const colorCache = new Map<string, CompiledProgressBarColors>();
|
|
94
95
|
let previousLineCount = 0;
|
|
95
|
-
let previousTaskLineCount = 0;
|
|
96
96
|
let nonTTYTaskSignatureById = new Map<number, string>();
|
|
97
97
|
let tick = 0;
|
|
98
98
|
let rendererActive = false;
|
|
99
|
-
let
|
|
99
|
+
let sessionActive = false;
|
|
100
100
|
|
|
101
101
|
const getCompiledColors = (progressbar: ProgressBarConfigShape): CompiledProgressBarColors => {
|
|
102
102
|
const key = encodeProgressBarColorsKey(progressbar.colors);
|
|
@@ -110,17 +110,47 @@ export const runProgressServiceRenderer = (
|
|
|
110
110
|
return compiled;
|
|
111
111
|
};
|
|
112
112
|
|
|
113
|
-
const
|
|
114
|
-
|
|
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) {
|
|
115
138
|
return;
|
|
116
139
|
}
|
|
117
140
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
141
|
+
yield* terminal.writeStderr(HIDE_CURSOR);
|
|
142
|
+
sessionActive = true;
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
const stopTTYSession = Effect.gen(function* () {
|
|
146
|
+
if (!isTTY || !sessionActive) {
|
|
147
|
+
return;
|
|
121
148
|
}
|
|
122
|
-
|
|
123
|
-
|
|
149
|
+
|
|
150
|
+
yield* terminal.writeStderr("\n" + SHOW_CURSOR);
|
|
151
|
+
previousLineCount = 0;
|
|
152
|
+
sessionActive = false;
|
|
153
|
+
});
|
|
124
154
|
|
|
125
155
|
const renderNonTTYTaskUpdates = (
|
|
126
156
|
ordered: ReadonlyArray<{ snapshot: TaskSnapshot; depth: number }>,
|
|
@@ -145,11 +175,13 @@ export const runProgressServiceRenderer = (
|
|
|
145
175
|
}
|
|
146
176
|
}
|
|
147
177
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
178
|
+
return Effect.gen(function* () {
|
|
179
|
+
if (changedTaskLines.length > 0) {
|
|
180
|
+
yield* terminal.writeStderr(changedTaskLines.join("\n") + "\n");
|
|
181
|
+
}
|
|
151
182
|
|
|
152
|
-
|
|
183
|
+
nonTTYTaskSignatureById = nextTaskSignatureById;
|
|
184
|
+
});
|
|
153
185
|
};
|
|
154
186
|
|
|
155
187
|
const renderFrame = (mode: "tick" | "final") =>
|
|
@@ -166,78 +198,69 @@ export const runProgressServiceRenderer = (
|
|
|
166
198
|
});
|
|
167
199
|
|
|
168
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
|
+
|
|
169
211
|
if (retainLogHistory) {
|
|
170
212
|
const historyLogs = yield* Ref.get(logsRef);
|
|
171
|
-
const lines = [...historyLogs, ...taskLines];
|
|
172
|
-
clearTTYLines(previousLineCount);
|
|
213
|
+
const lines = yield* clipTTYFrameLines([...historyLogs, ...taskLines]);
|
|
173
214
|
if (lines.length > 0) {
|
|
174
|
-
|
|
215
|
+
frame += lines.join("\n");
|
|
175
216
|
}
|
|
176
217
|
previousLineCount = lines.length;
|
|
177
|
-
|
|
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;
|
|
178
228
|
}
|
|
179
229
|
|
|
180
|
-
|
|
181
|
-
if (
|
|
182
|
-
|
|
230
|
+
// 4. Single atomic write
|
|
231
|
+
if (frame) {
|
|
232
|
+
yield* terminal.writeStderr(frame);
|
|
183
233
|
}
|
|
184
|
-
if (taskLines.length > 0) {
|
|
185
|
-
process.stderr.write(taskLines.join("\n"));
|
|
186
|
-
}
|
|
187
|
-
previousTaskLineCount = taskLines.length;
|
|
188
234
|
return;
|
|
189
235
|
}
|
|
190
236
|
|
|
191
237
|
if (drainedLogs.length > 0) {
|
|
192
|
-
|
|
238
|
+
yield* terminal.writeStderr(drainedLogs.join("\n") + "\n");
|
|
193
239
|
}
|
|
194
|
-
renderNonTTYTaskUpdates(ordered, taskLines);
|
|
240
|
+
yield* renderNonTTYTaskUpdates(ordered, taskLines);
|
|
195
241
|
});
|
|
196
242
|
|
|
197
|
-
|
|
198
|
-
yield* rendererLatch.await;
|
|
243
|
+
const renderLoop = Effect.gen(function* () {
|
|
199
244
|
rendererActive = true;
|
|
200
|
-
|
|
201
245
|
if (isTTY) {
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
if (rendererConfig.disableUserInput && process.stdin.isTTY) {
|
|
205
|
-
const stdin = process.stdin;
|
|
206
|
-
const wasRaw = Boolean(stdin.isRaw);
|
|
207
|
-
stdin.resume();
|
|
208
|
-
stdin.setRawMode?.(true);
|
|
209
|
-
|
|
210
|
-
const onData = (chunk: Buffer) => {
|
|
211
|
-
if (chunk.length === 1 && chunk[0] === 3) {
|
|
212
|
-
process.kill(process.pid, "SIGINT");
|
|
213
|
-
}
|
|
214
|
-
};
|
|
215
|
-
|
|
216
|
-
stdin.on("data", onData);
|
|
217
|
-
|
|
218
|
-
teardownInput = () => {
|
|
219
|
-
try {
|
|
220
|
-
stdin.off("data", onData);
|
|
221
|
-
stdin.setRawMode?.(wasRaw);
|
|
222
|
-
stdin.pause();
|
|
223
|
-
} catch {
|
|
224
|
-
// Best effort terminal restoration.
|
|
225
|
-
}
|
|
226
|
-
};
|
|
227
|
-
}
|
|
246
|
+
yield* startTTYSession;
|
|
228
247
|
}
|
|
229
248
|
|
|
230
249
|
while (true) {
|
|
231
250
|
const dirty = yield* Ref.getAndSet(dirtyRef, false);
|
|
232
|
-
const
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
),
|
|
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",
|
|
238
256
|
);
|
|
257
|
+
const hasPendingLogs = (yield* Ref.get(pendingLogsRef)).length > 0;
|
|
239
258
|
|
|
240
|
-
if (
|
|
259
|
+
if (isTTY) {
|
|
260
|
+
if (dirty || hasActiveSpinners || hasPendingLogs) {
|
|
261
|
+
yield* renderFrame("tick");
|
|
262
|
+
}
|
|
263
|
+
} else if (dirty || hasActiveSpinners) {
|
|
241
264
|
yield* renderFrame("tick");
|
|
242
265
|
}
|
|
243
266
|
|
|
@@ -251,13 +274,22 @@ export const runProgressServiceRenderer = (
|
|
|
251
274
|
return;
|
|
252
275
|
}
|
|
253
276
|
|
|
254
|
-
yield* renderFrame("final");
|
|
255
|
-
|
|
256
277
|
if (isTTY) {
|
|
257
|
-
|
|
258
|
-
|
|
278
|
+
if (sessionActive) {
|
|
279
|
+
yield* renderFrame("final");
|
|
280
|
+
yield* stopTTYSession;
|
|
281
|
+
}
|
|
282
|
+
return;
|
|
259
283
|
}
|
|
284
|
+
|
|
285
|
+
yield* renderFrame("final");
|
|
260
286
|
}),
|
|
261
287
|
),
|
|
262
288
|
);
|
|
289
|
+
|
|
290
|
+
if (isTTY && rendererConfig.disableUserInput) {
|
|
291
|
+
return terminal.withRawInputCapture(renderLoop);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
return renderLoop;
|
|
263
295
|
};
|
package/src/runtime.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { Context, Effect, Exit, FiberRef, Layer, Option, Ref } from "effect";
|
|
2
2
|
import { mergeWith } from "es-toolkit/object";
|
|
3
3
|
import { formatWithOptions } from "node:util";
|
|
4
4
|
import type { PartialDeep } from "type-fest";
|
|
5
5
|
import { makeProgressConsole } from "./console";
|
|
6
6
|
import { runProgressServiceRenderer } from "./renderer";
|
|
7
|
+
import { ProgressTerminal } from "./terminal";
|
|
7
8
|
import type { AddTaskOptions, ProgressService, UpdateTaskOptions } from "./types";
|
|
8
9
|
import {
|
|
9
10
|
decodeProgressBarConfigSync,
|
|
@@ -12,16 +13,14 @@ import {
|
|
|
12
13
|
defaultRendererConfig,
|
|
13
14
|
DeterminateTaskUnits,
|
|
14
15
|
IndeterminateTaskUnits,
|
|
15
|
-
Progress,
|
|
16
16
|
ProgressBarConfig,
|
|
17
17
|
RendererConfig,
|
|
18
|
+
Task,
|
|
18
19
|
TaskId,
|
|
19
20
|
TaskSnapshot,
|
|
20
21
|
} from "./types";
|
|
21
22
|
import { inferTotal } from "./utils";
|
|
22
23
|
|
|
23
|
-
const DIRTY_DEBOUNCE_INTERVAL = "10 millis";
|
|
24
|
-
|
|
25
24
|
const mergeConfig = <T extends Record<PropertyKey, any>>(
|
|
26
25
|
base: T,
|
|
27
26
|
override: PartialDeep<T> | undefined,
|
|
@@ -80,7 +79,7 @@ const updatedSnapshot = (snapshot: TaskSnapshot, options: UpdateTaskOptions): Ta
|
|
|
80
79
|
});
|
|
81
80
|
};
|
|
82
81
|
|
|
83
|
-
|
|
82
|
+
const makeProgressService = Effect.gen(function* () {
|
|
84
83
|
const rendererConfigOption = yield* Effect.serviceOption(RendererConfig);
|
|
85
84
|
const progressBarConfigOption = yield* Effect.serviceOption(ProgressBarConfig);
|
|
86
85
|
|
|
@@ -96,6 +95,8 @@ export const makeProgressService = Effect.gen(function* () {
|
|
|
96
95
|
Option.isSome(progressBarConfigOption) ? progressBarConfigOption.value : undefined,
|
|
97
96
|
),
|
|
98
97
|
);
|
|
98
|
+
const terminal = yield* ProgressTerminal;
|
|
99
|
+
const isTTY = yield* terminal.isTTY;
|
|
99
100
|
const maxRetainedLogLines = Math.max(0, Math.floor(rendererConfig.maxLogLines ?? 0));
|
|
100
101
|
|
|
101
102
|
const nextTaskIdRef = yield* Ref.make(0);
|
|
@@ -103,9 +104,6 @@ export const makeProgressService = Effect.gen(function* () {
|
|
|
103
104
|
const logsRef = yield* Ref.make<ReadonlyArray<string>>([]);
|
|
104
105
|
const pendingLogsRef = yield* Ref.make<ReadonlyArray<string>>([]);
|
|
105
106
|
const dirtyRef = yield* Ref.make(true);
|
|
106
|
-
const dirtyScheduledRef = yield* Ref.make(false);
|
|
107
|
-
const rendererStartedRef = yield* Ref.make(false);
|
|
108
|
-
const rendererLatch = yield* Effect.makeLatch(false);
|
|
109
107
|
const currentParentRef = yield* FiberRef.make(Option.none<TaskId>());
|
|
110
108
|
const scope = yield* Effect.scope;
|
|
111
109
|
|
|
@@ -115,29 +113,15 @@ export const makeProgressService = Effect.gen(function* () {
|
|
|
115
113
|
logsRef,
|
|
116
114
|
pendingLogsRef,
|
|
117
115
|
dirtyRef,
|
|
116
|
+
terminal,
|
|
117
|
+
isTTY,
|
|
118
118
|
rendererConfig,
|
|
119
119
|
maxRetainedLogLines,
|
|
120
|
-
rendererLatch,
|
|
121
120
|
),
|
|
122
121
|
scope,
|
|
123
122
|
);
|
|
124
123
|
|
|
125
|
-
const markDirty =
|
|
126
|
-
const shouldSchedule = yield* Ref.modify(dirtyScheduledRef, (scheduled) =>
|
|
127
|
-
scheduled ? [false, true] : [true, true],
|
|
128
|
-
);
|
|
129
|
-
|
|
130
|
-
if (!shouldSchedule) {
|
|
131
|
-
return;
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
yield* Effect.forkDaemon(
|
|
135
|
-
Effect.sleep(DIRTY_DEBOUNCE_INTERVAL).pipe(
|
|
136
|
-
Effect.zipRight(Ref.set(dirtyRef, true)),
|
|
137
|
-
Effect.ensuring(Ref.set(dirtyScheduledRef, false)),
|
|
138
|
-
),
|
|
139
|
-
);
|
|
140
|
-
});
|
|
124
|
+
const markDirty = Ref.set(dirtyRef, true);
|
|
141
125
|
|
|
142
126
|
const addTask = (options: AddTaskOptions) =>
|
|
143
127
|
Effect.gen(function* () {
|
|
@@ -151,18 +135,11 @@ export const makeProgressService = Effect.gen(function* () {
|
|
|
151
135
|
? new IndeterminateTaskUnits({ spinnerFrame: 0 })
|
|
152
136
|
: new DeterminateTaskUnits({ completed: 0, total: Math.max(0, options.total) });
|
|
153
137
|
const tasks = yield* Ref.get(tasksRef);
|
|
154
|
-
const parentSnapshot =
|
|
155
|
-
Option.isSome(parentId) ? tasks.get(parentId.value) : undefined;
|
|
138
|
+
const parentSnapshot = Option.isSome(parentId) ? tasks.get(parentId.value) : undefined;
|
|
156
139
|
const inheritedProgressBarConfig = parentSnapshot?.progressbar ?? progressBarConfig;
|
|
157
140
|
const resolvedProgressBarConfig = decodeProgressBarConfigSync(
|
|
158
141
|
mergeConfig(inheritedProgressBarConfig, options.progressbar),
|
|
159
142
|
);
|
|
160
|
-
const shouldOpenRenderer = yield* Ref.modify(rendererStartedRef, (started) =>
|
|
161
|
-
started ? [false, true] : [true, true],
|
|
162
|
-
);
|
|
163
|
-
if (shouldOpenRenderer) {
|
|
164
|
-
yield* rendererLatch.open;
|
|
165
|
-
}
|
|
166
143
|
|
|
167
144
|
const snapshot = new TaskSnapshot({
|
|
168
145
|
id: taskId,
|
|
@@ -292,22 +269,15 @@ export const makeProgressService = Effect.gen(function* () {
|
|
|
292
269
|
return next;
|
|
293
270
|
}).pipe(Effect.zipRight(markDirty));
|
|
294
271
|
|
|
295
|
-
const
|
|
272
|
+
const appendLog = (args: ReadonlyArray<unknown>) =>
|
|
296
273
|
Effect.gen(function* () {
|
|
297
274
|
if (args.length === 0) {
|
|
298
275
|
return;
|
|
299
276
|
}
|
|
300
277
|
|
|
301
|
-
const shouldOpenRenderer = yield* Ref.modify(rendererStartedRef, (started) =>
|
|
302
|
-
started ? [false, true] : [true, true],
|
|
303
|
-
);
|
|
304
|
-
if (shouldOpenRenderer) {
|
|
305
|
-
yield* rendererLatch.open;
|
|
306
|
-
}
|
|
307
|
-
|
|
308
278
|
const message = formatWithOptions(
|
|
309
279
|
{
|
|
310
|
-
colors:
|
|
280
|
+
colors: isTTY,
|
|
311
281
|
depth: 6,
|
|
312
282
|
},
|
|
313
283
|
...args,
|
|
@@ -327,10 +297,9 @@ export const makeProgressService = Effect.gen(function* () {
|
|
|
327
297
|
yield* markDirty;
|
|
328
298
|
});
|
|
329
299
|
|
|
330
|
-
const
|
|
300
|
+
const log = (...args: ReadonlyArray<unknown>) =>
|
|
331
301
|
Effect.gen(function* () {
|
|
332
|
-
|
|
333
|
-
return yield* Effect.withConsole(effect, makeProgressConsole(log, outerConsole));
|
|
302
|
+
yield* appendLog(args);
|
|
334
303
|
});
|
|
335
304
|
|
|
336
305
|
const getTask = (taskId: TaskId) =>
|
|
@@ -340,6 +309,7 @@ export const makeProgressService = Effect.gen(function* () {
|
|
|
340
309
|
|
|
341
310
|
const withTask: ProgressService["withTask"] = (options, effect) =>
|
|
342
311
|
Effect.gen(function* () {
|
|
312
|
+
const outerConsole = yield* Effect.console;
|
|
343
313
|
const inheritedParentId = yield* FiberRef.get(currentParentRef);
|
|
344
314
|
const resolvedParentId =
|
|
345
315
|
options.parentId === undefined ? inheritedParentId : Option.some(options.parentId);
|
|
@@ -351,7 +321,14 @@ export const makeProgressService = Effect.gen(function* () {
|
|
|
351
321
|
});
|
|
352
322
|
|
|
353
323
|
const exit = yield* Effect.exit(
|
|
354
|
-
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
|
+
),
|
|
355
332
|
);
|
|
356
333
|
|
|
357
334
|
if (Exit.isSuccess(exit)) {
|
|
@@ -374,11 +351,12 @@ export const makeProgressService = Effect.gen(function* () {
|
|
|
374
351
|
transient: options.transient,
|
|
375
352
|
progressbar: options.progressbar,
|
|
376
353
|
},
|
|
377
|
-
(
|
|
378
|
-
|
|
354
|
+
Effect.gen(function* () {
|
|
355
|
+
const taskId = yield* Task;
|
|
356
|
+
return yield* Effect.forEach(iterable, (item, index) =>
|
|
379
357
|
Effect.tap(f(item, index), () => advanceTask(taskId, 1)),
|
|
380
358
|
);
|
|
381
|
-
},
|
|
359
|
+
}),
|
|
382
360
|
);
|
|
383
361
|
|
|
384
362
|
const service: ProgressService = {
|
|
@@ -388,7 +366,6 @@ export const makeProgressService = Effect.gen(function* () {
|
|
|
388
366
|
completeTask,
|
|
389
367
|
failTask,
|
|
390
368
|
log,
|
|
391
|
-
withCapturedLogs,
|
|
392
369
|
getTask,
|
|
393
370
|
listTasks,
|
|
394
371
|
withTask,
|
|
@@ -397,3 +374,23 @@ export const makeProgressService = Effect.gen(function* () {
|
|
|
397
374
|
|
|
398
375
|
return Progress.of(service);
|
|
399
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
|
@@ -3,7 +3,6 @@ import type { PartialDeep } from "type-fest";
|
|
|
3
3
|
import { defaultProgressBarColors, ProgressBarColorsSchema } from "./colors";
|
|
4
4
|
|
|
5
5
|
export const RendererConfigSchema = Schema.Struct({
|
|
6
|
-
isTTY: Schema.Boolean,
|
|
7
6
|
disableUserInput: Schema.Boolean,
|
|
8
7
|
renderIntervalMillis: Schema.Number,
|
|
9
8
|
maxLogLines: Schema.optional(Schema.Number),
|
|
@@ -25,7 +24,6 @@ export type ProgressBarConfigShape = typeof ProgressBarConfigSchema.Type;
|
|
|
25
24
|
export const decodeProgressBarConfigSync = Schema.decodeUnknownSync(ProgressBarConfigSchema);
|
|
26
25
|
|
|
27
26
|
export const defaultRendererConfig: RendererConfigShape = {
|
|
28
|
-
isTTY: Boolean(process.stderr.isTTY),
|
|
29
27
|
disableUserInput: true,
|
|
30
28
|
renderIntervalMillis: 50, // 20 FPS
|
|
31
29
|
maxLogLines: 0,
|
|
@@ -114,21 +112,20 @@ export interface ProgressService {
|
|
|
114
112
|
readonly completeTask: (taskId: TaskId) => Effect.Effect<void>;
|
|
115
113
|
readonly failTask: (taskId: TaskId) => Effect.Effect<void>;
|
|
116
114
|
readonly log: (...args: ReadonlyArray<unknown>) => Effect.Effect<void>;
|
|
117
|
-
readonly withCapturedLogs: <A, E, R>(effect: Effect.Effect<A, E, R>) => Effect.Effect<A, E, R>;
|
|
118
115
|
readonly getTask: (taskId: TaskId) => Effect.Effect<Option.Option<TaskSnapshot>>;
|
|
119
116
|
readonly listTasks: Effect.Effect<ReadonlyArray<TaskSnapshot>>;
|
|
120
117
|
readonly withTask: <A, E, R>(
|
|
121
118
|
options: AddTaskOptions,
|
|
122
|
-
effect:
|
|
123
|
-
) => Effect.Effect<A, E, R
|
|
119
|
+
effect: Effect.Effect<A, E, R>,
|
|
120
|
+
) => Effect.Effect<A, E, Exclude<R, Task>>;
|
|
124
121
|
readonly trackIterable: <A, B, E, R>(
|
|
125
122
|
iterable: Iterable<A>,
|
|
126
123
|
options: TrackOptions,
|
|
127
124
|
f: (item: A, index: number) => Effect.Effect<B, E, R>,
|
|
128
|
-
) => Effect.Effect<ReadonlyArray<B>, E, R
|
|
125
|
+
) => Effect.Effect<ReadonlyArray<B>, E, Exclude<R, Task>>;
|
|
129
126
|
}
|
|
130
127
|
|
|
131
|
-
export class
|
|
128
|
+
export class Task extends Context.Tag("stromseng.dev/Task")<Task, TaskId>() {}
|
|
132
129
|
|
|
133
130
|
export class TaskAddedEvent extends Schema.TaggedClass<TaskAddedEvent>()("TaskAdded", {
|
|
134
131
|
taskId: TaskIdSchema,
|