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 CHANGED
@@ -1,11 +1,23 @@
1
1
  # effective-progress
2
2
 
3
+ [![npm version](https://img.shields.io/npm/v/effective-progress)](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
- ```bash
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
- ```bash
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
- - `maxLogLines` omitted or set to `0` means no log history is kept in memory.
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.1.2",
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.16"
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 { Console, Effect, Option } from "effect";
1
+ import { Effect } from "effect";
2
2
  import type { Concurrency } from "effect/Types";
3
- import { makeProgressConsole } from "./console";
4
- import { makeProgressService } from "./runtime";
5
- import { Progress } from "./types";
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 withProgressService = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
36
- Effect.gen(function* () {
37
- const outerConsole = yield* Console.consoleWith((console) => Effect.succeed(console));
38
- const existing = yield* Effect.serviceOption(Progress);
39
- if (Option.isSome(existing)) {
40
- const console = makeProgressConsole(existing.value, outerConsole);
41
- return yield* Effect.withConsole(
42
- Effect.provideService(effect, Progress, existing.value),
43
- console,
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
- withProgressService(
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
- (taskId) =>
73
- Effect.all(
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
- withProgressService(
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
- (taskId) =>
103
- Effect.forEach(
95
+ Effect.gen(function* () {
96
+ const taskId = yield* Task;
97
+ return yield* Effect.forEach(
104
98
  iterable,
105
- (item, index) => Effect.tap(f(item, index), () => progress.advanceTask(taskId, 1)),
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
- progress: ProgressService,
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>) => progress.log(...args);
8
+ const log = (...args: ReadonlyArray<unknown>) => progressLog(...args);
10
9
  const unsafeLog = (...args: ReadonlyArray<unknown>) => {
11
- Effect.runFork(progress.log(...args));
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
@@ -1,3 +1,5 @@
1
1
  export * from "./api";
2
- export * from "./runtime";
2
+ export * from "./colors";
3
+ export { Progress } from "./runtime";
4
+ export * from "./terminal";
3
5
  export * from "./types";
package/src/renderer.ts CHANGED
@@ -1,49 +1,60 @@
1
- import { Effect, Ref } from "effect";
2
- import chalk from "chalk";
3
- import type { ProgressBarConfigShape } from "./types";
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 RENDER_INTERVAL = "80 millis";
15
+ const encodeProgressBarColorsKey = Schema.encodeSync(Schema.parseJson(ProgressBarColorsSchema));
11
16
 
12
- const renderDeterminate = (units: DeterminateTaskUnits, config: ProgressBarConfigShape): string => {
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 * config.barWidth);
16
- const bar = `${chalk.cyan(config.fillChar.repeat(filled))}${chalk.dim(config.emptyChar.repeat(config.barWidth - filled))}`;
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 `${chalk.dim(config.leftBracket)}${bar}${chalk.dim(config.rightBracket)} ${units.completed}/${units.total} ${chalk.bold(percent + "%")}`;
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
- config: ProgressBarConfigShape,
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}${chalk.red("[failed]")}`;
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}${chalk.green("[done]")} ${snapshot.units.completed}/${snapshot.units.total}`;
45
+ return `${prefix}${colors.done("[done]")} ${snapshot.units.completed}/${snapshot.units.total}`;
36
46
  }
37
- return `${prefix}${chalk.green("[done]")}`;
47
+ return `${prefix}${colors.done("[done]")}`;
38
48
  }
39
49
 
40
50
  if (snapshot.units._tag === "DeterminateTaskUnits") {
41
- return prefix + renderDeterminate(snapshot.units, config);
51
+ return prefix + renderDeterminate(snapshot.units, progressbar, colors);
42
52
  }
43
53
 
44
- const frames = config.spinnerFrames;
54
+ const frames = progressbar.spinnerFrames;
45
55
  const frameIndex = (snapshot.units.spinnerFrame + tick) % frames.length;
46
- return `${prefix}${chalk.yellow(frames[frameIndex])}`;
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
- config: ProgressBarConfigShape,
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 teardownInput: (() => void) | undefined;
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
- const clearTTYLines = (lineCount: number) => {
89
- if (lineCount <= 0) {
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
- let output = "\r" + CLEAR_LINE;
94
- for (let i = 1; i < lineCount; i++) {
95
- output += MOVE_UP_ONE + CLEAR_LINE;
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
- process.stderr.write(output + "\r");
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(config.nonTtyUpdateStep));
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
- if (changedTaskLines.length > 0) {
124
- process.stderr.write(changedTaskLines.join("\n") + "\n");
125
- }
178
+ return Effect.gen(function* () {
179
+ if (changedTaskLines.length > 0) {
180
+ yield* terminal.writeStderr(changedTaskLines.join("\n") + "\n");
181
+ }
126
182
 
127
- nonTTYTaskSignatureById = nextTaskSignatureById;
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, config);
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
- process.stderr.write(lines.join("\n"));
215
+ frame += lines.join("\n");
150
216
  }
151
217
  previousLineCount = lines.length;
152
- return;
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
- clearTTYLines(previousTaskLineCount);
156
- if (drainedLogs.length > 0) {
157
- process.stderr.write(drainedLogs.join("\n") + "\n");
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
- process.stderr.write(drainedLogs.join("\n") + "\n");
238
+ yield* terminal.writeStderr(drainedLogs.join("\n") + "\n");
168
239
  }
169
- renderNonTTYTaskUpdates(ordered, taskLines);
240
+ yield* renderNonTTYTaskUpdates(ordered, taskLines);
170
241
  });
171
242
 
172
- return Effect.gen(function* () {
243
+ const renderLoop = Effect.gen(function* () {
244
+ rendererActive = true;
173
245
  if (isTTY) {
174
- process.stderr.write(HIDE_CURSOR);
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 hasActiveSpinners = yield* Ref.get(tasksRef).pipe(
205
- Effect.map((tasks) =>
206
- Array.from(tasks.values()).some(
207
- (task) => task.status === "running" && task.units._tag === "IndeterminateTaskUnits",
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 (dirty || hasActiveSpinners) {
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(RENDER_INTERVAL);
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
- yield* renderFrame("final");
273
+ if (!rendererActive) {
274
+ return;
275
+ }
223
276
 
224
277
  if (isTTY) {
225
- teardownInput?.();
226
- process.stderr.write("\n" + SHOW_CURSOR);
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 DIRTY_DEBOUNCE_INTERVAL = "10 millis";
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
- export const makeProgressService = Effect.gen(function* () {
61
- const configOption = yield* Effect.serviceOption(ProgressBarConfig);
62
- const config = Option.getOrElse(configOption, () => defaultProgressBarConfig);
63
- const maxRetainedLogLines = Math.max(0, Math.floor(config.maxLogLines ?? 0));
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.forkScoped(
110
+ yield* Effect.forkIn(
74
111
  runProgressServiceRenderer(
75
112
  tasksRef,
76
113
  logsRef,
77
114
  pendingLogsRef,
78
115
  dirtyRef,
79
- config,
116
+ terminal,
117
+ isTTY,
118
+ rendererConfig,
80
119
  maxRetainedLogLines,
81
120
  ),
121
+ scope,
82
122
  );
83
123
 
84
- const markDirty = Effect.gen(function* () {
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 log = (...args: ReadonlyArray<unknown>) =>
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: config.isTTY,
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(effect(taskId), currentParentRef, Option.some(taskId)),
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
- (taskId) => {
307
- return Effect.forEach(iterable, (item, index) =>
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
- return Progress.of({
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
+ });
@@ -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 SPINNER_FRAMES = ["-", "\\", "|", "/"] as const;
4
- export const DEFAULT_PROGRESS_BAR_WIDTH = 30;
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
- maxLogLines: Schema.optional(Schema.Number),
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 defaultProgressBarConfig: ProgressBarConfigShape = {
22
- isTTY: Boolean(process.stderr.isTTY),
26
+ export const defaultRendererConfig: RendererConfigShape = {
23
27
  disableUserInput: true,
24
- spinnerFrames: SPINNER_FRAMES,
25
- barWidth: DEFAULT_PROGRESS_BAR_WIDTH,
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
- maxLogLines: 0,
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: (taskId: TaskId) => Effect.Effect<A, E, R>,
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 Progress extends Context.Tag("stromseng.dev/Progress")<Progress, ProgressService>() {}
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,