effective-progress 0.4.3 → 0.5.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 +13 -106
- package/package.json +4 -1
- package/src/api.ts +11 -49
- package/src/index.ts +0 -1
- package/src/ink-renderer/app.tsx +31 -0
- package/src/ink-renderer/columns/amount-column.tsx +17 -0
- package/src/ink-renderer/columns/bar-column.tsx +31 -0
- package/src/ink-renderer/columns/description-column.tsx +7 -0
- package/src/ink-renderer/columns/elapsed-column.tsx +7 -0
- package/src/ink-renderer/columns/eta-column.tsx +18 -0
- package/src/ink-renderer/columns/index.ts +6 -0
- package/src/ink-renderer/columns/types.ts +11 -0
- package/src/ink-renderer/format.ts +55 -0
- package/src/ink-renderer/index.ts +1 -0
- package/src/ink-renderer/layout.ts +145 -0
- package/src/ink-renderer/model.ts +24 -0
- package/src/ink-renderer/service.tsx +121 -0
- package/src/ink-renderer/task-row.tsx +53 -0
- package/src/{renderer → ink-renderer}/tree.ts +3 -9
- package/src/ink-renderer/types.ts +18 -0
- package/src/runtime.ts +9 -83
- package/src/types.ts +0 -70
- package/src/console.ts +0 -177
- package/src/renderer/ansi.ts +0 -138
- package/src/renderer/types.ts +0 -67
- package/src/renderer.ts +0 -1246
package/README.md
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
|
|
16
16
|
- multiple nested tree-like progress bars
|
|
17
17
|
- spinner support for “we have no idea how long this takes” work
|
|
18
|
-
- keep using `Console.log` / `Effect.logInfo` while
|
|
18
|
+
- keep using `Console.log` / `Effect.logInfo` while progress rendering is active
|
|
19
19
|
- familiar `.all` and `.forEach` APIs — swap `Effect` for `Progress`, get progress bars basically for free
|
|
20
20
|
- flicker-free rendering (in theory) by drawing everything in a single terminal frame
|
|
21
21
|
|
|
@@ -82,100 +82,26 @@ Effect.runPromise(program);
|
|
|
82
82
|
### Other examples
|
|
83
83
|
|
|
84
84
|
- `examples/simpleExample.ts` - low-boilerplate real-world flow
|
|
85
|
-
- `examples/advancedExample.ts` - full API usage
|
|
85
|
+
- `examples/advancedExample.ts` - full API usage and manual task control
|
|
86
86
|
- `examples/showcase.ts` - nested concurrent tasks, spinner workloads, and mixed Effect/Console logging
|
|
87
87
|
- `examples/performance.ts` - stress-style run with high log volume and deeply nested progress updates
|
|
88
88
|
|
|
89
89
|
## Configuration
|
|
90
90
|
|
|
91
|
-
### Console
|
|
91
|
+
### Console behavior
|
|
92
92
|
|
|
93
|
-
- `
|
|
94
|
-
-
|
|
93
|
+
- The Ink renderer runs with `patchConsole: true`, so console output is patched by Ink while the app is mounted.
|
|
94
|
+
- `Progress.task`, `Progress.all`, and `Progress.forEach` write through the currently provided Effect `Console` implementation.
|
|
95
95
|
- Formatting is controlled by the API consumer's logger/console implementation.
|
|
96
96
|
|
|
97
|
-
###
|
|
97
|
+
### Ink renderer behavior
|
|
98
98
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
-
|
|
104
|
-
-
|
|
105
|
-
- bar width: `40`
|
|
106
|
-
|
|
107
|
-
```ts
|
|
108
|
-
import { Effect } from "effect";
|
|
109
|
-
import * as Progress from "effective-progress";
|
|
110
|
-
|
|
111
|
-
const configured = program.pipe(
|
|
112
|
-
Effect.provideService(Progress.RendererConfig, {
|
|
113
|
-
width: 80,
|
|
114
|
-
nonTtyUpdateStep: 2,
|
|
115
|
-
}),
|
|
116
|
-
Effect.provideService(Progress.ProgressBarConfig, {
|
|
117
|
-
barWidth: 36,
|
|
118
|
-
}),
|
|
119
|
-
);
|
|
120
|
-
|
|
121
|
-
Effect.runPromise(configured);
|
|
122
|
-
```
|
|
123
|
-
|
|
124
|
-
You can customize column order/content Rich-style by providing a `columns` array:
|
|
125
|
-
|
|
126
|
-
```ts
|
|
127
|
-
const configured = program.pipe(
|
|
128
|
-
Effect.provideService(Progress.RendererConfig, {
|
|
129
|
-
columns: [
|
|
130
|
-
Progress.DescriptionColumn.Default(),
|
|
131
|
-
Progress.BarColumn.make({ track: Progress.Track.fr(1) }),
|
|
132
|
-
Progress.AmountColumn.Default(),
|
|
133
|
-
"•",
|
|
134
|
-
Progress.ElapsedColumn.Default(),
|
|
135
|
-
"•",
|
|
136
|
-
Progress.EtaColumn.Default(),
|
|
137
|
-
],
|
|
138
|
-
}),
|
|
139
|
-
);
|
|
140
|
-
```
|
|
141
|
-
|
|
142
|
-
For full terminal width rendering, set:
|
|
143
|
-
|
|
144
|
-
```ts
|
|
145
|
-
Effect.provideService(Progress.RendererConfig, {
|
|
146
|
-
width: "fullwidth",
|
|
147
|
-
});
|
|
148
|
-
```
|
|
149
|
-
|
|
150
|
-
Description-specific caps should be configured on `DescriptionColumn` (for example `DescriptionColumn.make({ maxWidth: 40 })`) rather than globally.
|
|
151
|
-
|
|
152
|
-
Per top-level call, you can override render config via helper APIs:
|
|
153
|
-
|
|
154
|
-
```ts
|
|
155
|
-
const run = Progress.task(effect, {
|
|
156
|
-
description: "work",
|
|
157
|
-
render: {
|
|
158
|
-
columns: [Progress.DescriptionColumn.Default(), "|", Progress.AmountColumn.Default()],
|
|
159
|
-
},
|
|
160
|
-
});
|
|
161
|
-
|
|
162
|
-
const wrapped = Progress.withRenderConfig(run, {
|
|
163
|
-
columns: [Progress.DescriptionColumn.Default()],
|
|
164
|
-
});
|
|
165
|
-
```
|
|
166
|
-
|
|
167
|
-
Task-level `progressbar` config is optional and inherits from its parent task (or from global `ProgressBarConfig` for root tasks):
|
|
168
|
-
|
|
169
|
-
```ts
|
|
170
|
-
yield *
|
|
171
|
-
progress.withTask(Effect.sleep("1 second"), {
|
|
172
|
-
description: "Worker pipeline",
|
|
173
|
-
progressbar: {
|
|
174
|
-
barWidth: 20,
|
|
175
|
-
spinnerFrames: [".", "o", "O", "0"],
|
|
176
|
-
},
|
|
177
|
-
});
|
|
178
|
-
```
|
|
99
|
+
- Rendering is powered by [Ink](https://github.com/vadimdemedes/ink).
|
|
100
|
+
- Built-in columns are: description, bar, amount/spinner, elapsed, and ETA.
|
|
101
|
+
- Column widths are shared per frame (widest visible cell wins), so rows stay aligned.
|
|
102
|
+
- Elapsed and ETA reserve stable widths to reduce jitter while tasks transition states.
|
|
103
|
+
- Layout uses a 100-column baseline and grows when content requires more space.
|
|
104
|
+
- On narrow terminals, layout compacts to fit available width and tree prefixes are suppressed when description space is too tight.
|
|
179
105
|
|
|
180
106
|
## Manual task control
|
|
181
107
|
|
|
@@ -194,22 +120,7 @@ const program = Progress.task(
|
|
|
194
120
|
|
|
195
121
|
## Column customization
|
|
196
122
|
|
|
197
|
-
|
|
198
|
-
You can also pass your own objects/classes implementing `ProgressColumn`:
|
|
199
|
-
|
|
200
|
-
```ts
|
|
201
|
-
const CustomColumn: Progress.ProgressColumn = {
|
|
202
|
-
id: "custom",
|
|
203
|
-
render: () => "extra",
|
|
204
|
-
};
|
|
205
|
-
|
|
206
|
-
const program = Progress.task(myEffect, {
|
|
207
|
-
description: "Work",
|
|
208
|
-
render: {
|
|
209
|
-
columns: [Progress.DescriptionColumn.Default(), Progress.BarColumn.Default(), CustomColumn],
|
|
210
|
-
},
|
|
211
|
-
});
|
|
212
|
-
```
|
|
123
|
+
Custom column APIs are not part of the first Ink release. The renderer ships with built-in columns only, and old renderer config APIs (`RendererConfig`, `ProgressBarConfig`, custom column definitions) are intentionally removed in this iteration.
|
|
213
124
|
|
|
214
125
|
## Terminal service and mocking
|
|
215
126
|
|
|
@@ -240,10 +151,6 @@ const program = Progress.task(Effect.sleep("100 millis"), { description: "work"
|
|
|
240
151
|
);
|
|
241
152
|
```
|
|
242
153
|
|
|
243
|
-
## Dependencies & package size
|
|
244
|
-
|
|
245
|
-
This library is designed for CLI workflows, where package size is typically a lower-priority concern. Alongside `effect` though, I will strive to only rely on other high quality packages.
|
|
246
|
-
|
|
247
154
|
## Notes
|
|
248
155
|
|
|
249
156
|
- 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.5.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": {
|
|
@@ -33,7 +33,10 @@
|
|
|
33
33
|
"format:check": "oxfmt --check ."
|
|
34
34
|
},
|
|
35
35
|
"dependencies": {
|
|
36
|
+
"@types/react": "^19.2.14",
|
|
36
37
|
"es-toolkit": "^1.44.0",
|
|
38
|
+
"ink": "^6.8.0",
|
|
39
|
+
"react": "^19.2.4",
|
|
37
40
|
"type-fest": "^5.4.4"
|
|
38
41
|
},
|
|
39
42
|
"devDependencies": {
|
package/src/api.ts
CHANGED
|
@@ -1,49 +1,20 @@
|
|
|
1
1
|
import { Effect, Exit, Option } from "effect";
|
|
2
2
|
import { dual } from "effect/Function";
|
|
3
3
|
import type { Concurrency } from "effect/Types";
|
|
4
|
-
import type { PartialDeep } from "type-fest";
|
|
5
4
|
import { Progress } from "./runtime";
|
|
6
|
-
import {
|
|
7
|
-
import type { AddTaskOptions,
|
|
5
|
+
import { Task } from "./types";
|
|
6
|
+
import type { AddTaskOptions, TrackOptions } from "./types";
|
|
8
7
|
import { inferTotal } from "./utils";
|
|
9
8
|
|
|
10
|
-
|
|
11
|
-
readonly render?: PartialDeep<RendererConfigShape>;
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
const provideProgress = <A, E, R>(
|
|
15
|
-
effect: Effect.Effect<A, E, R>,
|
|
16
|
-
renderConfig: PartialDeep<RendererConfigShape> | undefined,
|
|
17
|
-
) =>
|
|
9
|
+
const provideProgress = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
|
|
18
10
|
Effect.gen(function* () {
|
|
19
11
|
const existing = yield* Effect.serviceOption(Progress);
|
|
20
12
|
if (Option.isSome(existing)) {
|
|
21
13
|
return yield* Effect.provideService(effect, Progress, existing.value);
|
|
22
14
|
}
|
|
23
|
-
|
|
24
|
-
return yield* Effect.scoped(effect.pipe(Effect.provide(Progress.Default)));
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
return yield* Effect.scoped(
|
|
28
|
-
effect.pipe(
|
|
29
|
-
Effect.provide(Progress.Default),
|
|
30
|
-
Effect.provideService(RendererConfig, renderConfig),
|
|
31
|
-
),
|
|
32
|
-
);
|
|
15
|
+
return yield* Effect.scoped(effect.pipe(Effect.provide(Progress.Default)));
|
|
33
16
|
});
|
|
34
17
|
|
|
35
|
-
export const withRenderConfig: {
|
|
36
|
-
<A, E, R>(
|
|
37
|
-
effect: Effect.Effect<A, E, R>,
|
|
38
|
-
config: PartialDeep<RendererConfigShape>,
|
|
39
|
-
): Effect.Effect<A, E, Exclude<R, RendererConfig>>;
|
|
40
|
-
(
|
|
41
|
-
config: PartialDeep<RendererConfigShape>,
|
|
42
|
-
): <A, E, R>(effect: Effect.Effect<A, E, R>) => Effect.Effect<A, E, Exclude<R, RendererConfig>>;
|
|
43
|
-
} = dual(2, <A, E, R>(effect: Effect.Effect<A, E, R>, config: PartialDeep<RendererConfigShape>) =>
|
|
44
|
-
Effect.provideService(effect, RendererConfig, config),
|
|
45
|
-
);
|
|
46
|
-
|
|
47
18
|
export interface EffectExecutionOptions {
|
|
48
19
|
readonly concurrency?: Concurrency;
|
|
49
20
|
readonly batching?: boolean | "inherit";
|
|
@@ -55,9 +26,7 @@ export interface EffectAllExecutionOptions extends EffectExecutionOptions {
|
|
|
55
26
|
readonly mode?: "default" | "validate" | "either";
|
|
56
27
|
}
|
|
57
28
|
|
|
58
|
-
export type AllOptions = Omit<TrackOptions, "total"> &
|
|
59
|
-
EffectAllExecutionOptions &
|
|
60
|
-
RenderOverrideOptions;
|
|
29
|
+
export type AllOptions = Omit<TrackOptions, "total"> & EffectAllExecutionOptions;
|
|
61
30
|
export type AllReturn<
|
|
62
31
|
Arg extends
|
|
63
32
|
| ReadonlyArray<Effect.Effect<any, any, any>>
|
|
@@ -77,9 +46,9 @@ export interface ForEachExecutionOptions extends EffectExecutionOptions {
|
|
|
77
46
|
readonly discard?: false | undefined;
|
|
78
47
|
}
|
|
79
48
|
|
|
80
|
-
export type ForEachOptions = TrackOptions & ForEachExecutionOptions
|
|
49
|
+
export type ForEachOptions = TrackOptions & ForEachExecutionOptions;
|
|
81
50
|
|
|
82
|
-
export type TaskOptions = AddTaskOptions
|
|
51
|
+
export type TaskOptions = AddTaskOptions;
|
|
83
52
|
|
|
84
53
|
export const task: {
|
|
85
54
|
<A, E, R>(
|
|
@@ -90,14 +59,11 @@ export const task: {
|
|
|
90
59
|
options: TaskOptions,
|
|
91
60
|
): (effect: Effect.Effect<A, E, R>) => Effect.Effect<A, E, Exclude<R, Progress | Task>>;
|
|
92
61
|
} = dual(2, <A, E, R>(effect: Effect.Effect<A, E, R>, options: TaskOptions) => {
|
|
93
|
-
const { render, ...taskOptions } = options;
|
|
94
|
-
|
|
95
62
|
return provideProgress(
|
|
96
63
|
Effect.gen(function* () {
|
|
97
64
|
const progress = yield* Progress;
|
|
98
|
-
return yield* progress.withTask(effect,
|
|
65
|
+
return yield* progress.withTask(effect, options);
|
|
99
66
|
}),
|
|
100
|
-
render,
|
|
101
67
|
) as Effect.Effect<A, E, Exclude<R, Progress | Task>>;
|
|
102
68
|
});
|
|
103
69
|
|
|
@@ -119,14 +85,14 @@ const countEffects = (effects: AllArg): number =>
|
|
|
119
85
|
export const all: {
|
|
120
86
|
<const Arg extends AllArg, O extends EffectAllExecutionOptions>(
|
|
121
87
|
effects: Arg,
|
|
122
|
-
options: Omit<TrackOptions, "total"> & O
|
|
88
|
+
options: Omit<TrackOptions, "total"> & O,
|
|
123
89
|
): AllReturn<Arg, O>;
|
|
124
|
-
<O extends EffectAllExecutionOptions
|
|
90
|
+
<O extends EffectAllExecutionOptions>(
|
|
125
91
|
options: Omit<TrackOptions, "total"> & O,
|
|
126
92
|
): <const Arg extends AllArg>(effects: Arg) => AllReturn<Arg, O>;
|
|
127
93
|
} = dual(
|
|
128
94
|
2,
|
|
129
|
-
<const Arg extends AllArg, O extends EffectAllExecutionOptions
|
|
95
|
+
<const Arg extends AllArg, O extends EffectAllExecutionOptions>(
|
|
130
96
|
effects: Arg,
|
|
131
97
|
options: Omit<TrackOptions, "total"> & O,
|
|
132
98
|
) =>
|
|
@@ -166,11 +132,9 @@ export const all: {
|
|
|
166
132
|
description: options.description,
|
|
167
133
|
total: countEffects(effects),
|
|
168
134
|
transient: options.transient,
|
|
169
|
-
progressbar: options.progressbar,
|
|
170
135
|
},
|
|
171
136
|
);
|
|
172
137
|
}),
|
|
173
|
-
options.render,
|
|
174
138
|
) as AllReturn<Arg, O>,
|
|
175
139
|
);
|
|
176
140
|
|
|
@@ -226,10 +190,8 @@ export const forEach: {
|
|
|
226
190
|
description: options.description,
|
|
227
191
|
total: options.total ?? inferTotal(iterable),
|
|
228
192
|
transient: options.transient,
|
|
229
|
-
progressbar: options.progressbar,
|
|
230
193
|
},
|
|
231
194
|
);
|
|
232
195
|
}),
|
|
233
|
-
options.render,
|
|
234
196
|
) as Effect.Effect<ReadonlyArray<B>, E, Exclude<R, Progress | Task>>,
|
|
235
197
|
);
|
package/src/index.ts
CHANGED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { Box } from "ink";
|
|
2
|
+
import { computeSharedColumnWidths } from "./layout";
|
|
3
|
+
import { TaskRow } from "./task-row";
|
|
4
|
+
import type { TaskRowModel } from "./types";
|
|
5
|
+
|
|
6
|
+
export interface ProgressAppProps {
|
|
7
|
+
readonly rows: ReadonlyArray<TaskRowModel>;
|
|
8
|
+
readonly now: number;
|
|
9
|
+
readonly tick: number;
|
|
10
|
+
readonly isTTY: boolean;
|
|
11
|
+
readonly terminalColumns?: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const ProgressApp = ({ rows, now, tick, isTTY, terminalColumns }: ProgressAppProps) => {
|
|
15
|
+
const widths = computeSharedColumnWidths(rows, now, tick, terminalColumns);
|
|
16
|
+
|
|
17
|
+
return (
|
|
18
|
+
<Box flexDirection="column">
|
|
19
|
+
{rows.map((row) => (
|
|
20
|
+
<TaskRow
|
|
21
|
+
key={row.task.id as number}
|
|
22
|
+
row={row}
|
|
23
|
+
now={now}
|
|
24
|
+
tick={tick}
|
|
25
|
+
isTTY={isTTY}
|
|
26
|
+
widths={widths}
|
|
27
|
+
/>
|
|
28
|
+
))}
|
|
29
|
+
</Box>
|
|
30
|
+
);
|
|
31
|
+
};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { Text } from "ink";
|
|
2
|
+
import { formatAmount } from "../format";
|
|
3
|
+
import type { ColumnProps } from "./types";
|
|
4
|
+
|
|
5
|
+
export const AmountColumn = ({ task, tick }: ColumnProps) => {
|
|
6
|
+
const text = formatAmount(task, tick);
|
|
7
|
+
const color =
|
|
8
|
+
task.status === "failed"
|
|
9
|
+
? "red"
|
|
10
|
+
: task.status === "done"
|
|
11
|
+
? "green"
|
|
12
|
+
: task.units._tag === "DeterminateTaskUnits"
|
|
13
|
+
? "whiteBright"
|
|
14
|
+
: "yellow";
|
|
15
|
+
|
|
16
|
+
return <Text color={color}>{text}</Text>;
|
|
17
|
+
};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { Text } from "ink";
|
|
2
|
+
import type { ColumnProps } from "./types";
|
|
3
|
+
|
|
4
|
+
const clamp = (value: number, minimum: number, maximum: number): number =>
|
|
5
|
+
Math.min(Math.max(value, minimum), maximum);
|
|
6
|
+
|
|
7
|
+
export interface BarColumnProps extends ColumnProps {
|
|
8
|
+
readonly width: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export const BarColumn = ({ task, width }: BarColumnProps) => {
|
|
12
|
+
if (task.units._tag !== "DeterminateTaskUnits") {
|
|
13
|
+
return <Text />;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const barWidth = Math.max(1, Math.floor(width));
|
|
17
|
+
const safeTotal = Math.max(1, task.units.total);
|
|
18
|
+
const ratio = task.status === "done" ? 1 : clamp(task.units.completed / safeTotal, 0, 1);
|
|
19
|
+
const filled = Math.round(barWidth * ratio);
|
|
20
|
+
const empty = Math.max(0, barWidth - filled);
|
|
21
|
+
const bar = `${"━".repeat(filled)}${"─".repeat(empty)}`;
|
|
22
|
+
|
|
23
|
+
const color =
|
|
24
|
+
task.status === "failed" ? "red" : task.status === "done" ? "green" : "blue";
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
<Text wrap="truncate-end" color={color}>
|
|
28
|
+
{bar}
|
|
29
|
+
</Text>
|
|
30
|
+
);
|
|
31
|
+
};
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { Text } from "ink";
|
|
2
|
+
import { renderTreePrefix } from "../tree";
|
|
3
|
+
import type { ColumnProps } from "./types";
|
|
4
|
+
|
|
5
|
+
export const DescriptionColumn = ({ task, tree, showTree }: ColumnProps) => (
|
|
6
|
+
<Text wrap="truncate-end">{`${showTree ? renderTreePrefix(tree) : ""}${task.description}`}</Text>
|
|
7
|
+
);
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { Text } from "ink";
|
|
2
|
+
import { formatEta } from "../format";
|
|
3
|
+
import type { ColumnProps } from "./types";
|
|
4
|
+
|
|
5
|
+
export const EtaColumn = ({ task, now }: ColumnProps) => {
|
|
6
|
+
if (task.status !== "running" || task.units._tag !== "DeterminateTaskUnits") {
|
|
7
|
+
return <Text />;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const eta = formatEta(task, now);
|
|
11
|
+
const text = eta.length > 0 ? `ETA: ${eta}` : "ETA: --";
|
|
12
|
+
|
|
13
|
+
return (
|
|
14
|
+
<Text wrap="truncate-end" color="gray">
|
|
15
|
+
{text}
|
|
16
|
+
</Text>
|
|
17
|
+
);
|
|
18
|
+
};
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { AmountColumn } from "./amount-column";
|
|
2
|
+
export { BarColumn } from "./bar-column";
|
|
3
|
+
export { DescriptionColumn } from "./description-column";
|
|
4
|
+
export { ElapsedColumn } from "./elapsed-column";
|
|
5
|
+
export { EtaColumn } from "./eta-column";
|
|
6
|
+
export type { ColumnProps } from "./types";
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { TaskSnapshot } from "../../types";
|
|
2
|
+
import type { TaskTreeInfo } from "../types";
|
|
3
|
+
|
|
4
|
+
export interface ColumnProps {
|
|
5
|
+
readonly task: TaskSnapshot;
|
|
6
|
+
readonly tree: TaskTreeInfo;
|
|
7
|
+
readonly now: number;
|
|
8
|
+
readonly tick: number;
|
|
9
|
+
readonly isTTY: boolean;
|
|
10
|
+
readonly showTree: boolean;
|
|
11
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { TaskSnapshot } from "../types";
|
|
2
|
+
|
|
3
|
+
export const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] as const;
|
|
4
|
+
|
|
5
|
+
export const formatDurationSeconds = (seconds: number): string => {
|
|
6
|
+
const value = Math.max(0, Math.floor(seconds));
|
|
7
|
+
if (value < 60) {
|
|
8
|
+
return `${value}s`;
|
|
9
|
+
}
|
|
10
|
+
if (value < 3600) {
|
|
11
|
+
const mins = Math.floor(value / 60);
|
|
12
|
+
const secs = value % 60;
|
|
13
|
+
return secs > 0 ? `${mins}m ${secs}s` : `${mins}m`;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const hours = Math.floor(value / 3600);
|
|
17
|
+
const mins = Math.floor((value % 3600) / 60);
|
|
18
|
+
return mins > 0 ? `${hours}h ${mins}m` : `${hours}h`;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export const formatElapsed = (task: TaskSnapshot, now: number): string => {
|
|
22
|
+
const elapsedMillis = Math.max(0, (task.completedAt ?? now) - task.startedAt);
|
|
23
|
+
return formatDurationSeconds(elapsedMillis / 1000);
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export const formatEta = (task: TaskSnapshot, now: number): string => {
|
|
27
|
+
if (task.status !== "running" || task.units._tag !== "DeterminateTaskUnits") {
|
|
28
|
+
return "";
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const { completed, total } = task.units;
|
|
32
|
+
const remaining = total - completed;
|
|
33
|
+
if (completed <= 0 || remaining <= 0) {
|
|
34
|
+
return "";
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const elapsedMillis = Math.max(1, now - task.startedAt);
|
|
38
|
+
const etaMillis = Math.max(0, Math.floor((elapsedMillis / completed) * remaining));
|
|
39
|
+
return formatDurationSeconds(etaMillis / 1000);
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export const formatAmount = (task: TaskSnapshot, tick: number): string => {
|
|
43
|
+
if (task.units._tag === "DeterminateTaskUnits") {
|
|
44
|
+
const totalText = `${task.units.total}`;
|
|
45
|
+
const completedText = `${task.units.completed}`.padStart(totalText.length, " ");
|
|
46
|
+
return `${completedText}/${totalText}`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (task.status === "running") {
|
|
50
|
+
const frameIndex = (task.units.spinnerFrame + tick) % SPINNER_FRAMES.length;
|
|
51
|
+
return SPINNER_FRAMES[frameIndex] ?? SPINNER_FRAMES[0]!;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return task.status === "done" ? "✓" : "✗";
|
|
55
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { InkRenderer, type InkRendererService } from "./service";
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { formatAmount, formatElapsed, formatEta } from "./format";
|
|
2
|
+
import { renderTreePrefix } from "./tree";
|
|
3
|
+
import type { TaskRowModel } from "./types";
|
|
4
|
+
|
|
5
|
+
export const DEFAULT_BAR_WIDTH = 20;
|
|
6
|
+
const MIN_DESCRIPTION_WIDTH = 8;
|
|
7
|
+
const MIN_BAR_WIDTH = 8;
|
|
8
|
+
const MIN_ELAPSED_WIDTH = 3;
|
|
9
|
+
const MIN_AMOUNT_WIDTH = 1;
|
|
10
|
+
const BASELINE_ROW_WIDTH = 100;
|
|
11
|
+
export const MIN_DESCRIPTION_COLUMNS_FOR_TREE = 24;
|
|
12
|
+
const RESERVED_ELAPSED_WIDTH_UP_TO_ONE_HOUR = Array.from("59m 59s").length;
|
|
13
|
+
const RESERVED_ETA_WIDTH_UP_TO_ONE_HOUR = Array.from("ETA: 59m 59s").length;
|
|
14
|
+
|
|
15
|
+
const textWidth = (text: string): number => Array.from(text).length;
|
|
16
|
+
|
|
17
|
+
export interface SharedColumnWidths {
|
|
18
|
+
readonly row: number;
|
|
19
|
+
readonly description: number;
|
|
20
|
+
readonly bar: number;
|
|
21
|
+
readonly amount: number;
|
|
22
|
+
readonly elapsed: number;
|
|
23
|
+
readonly eta: number;
|
|
24
|
+
readonly showTree: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const computeWidths = (
|
|
28
|
+
rows: ReadonlyArray<TaskRowModel>,
|
|
29
|
+
now: number,
|
|
30
|
+
tick: number,
|
|
31
|
+
terminalColumns?: number,
|
|
32
|
+
includeTree = true,
|
|
33
|
+
): Omit<SharedColumnWidths, "showTree"> => {
|
|
34
|
+
let hasDeterminate = false;
|
|
35
|
+
let description = MIN_DESCRIPTION_WIDTH;
|
|
36
|
+
let amount = 1;
|
|
37
|
+
let elapsed = RESERVED_ELAPSED_WIDTH_UP_TO_ONE_HOUR;
|
|
38
|
+
let eta = RESERVED_ETA_WIDTH_UP_TO_ONE_HOUR;
|
|
39
|
+
|
|
40
|
+
for (const row of rows) {
|
|
41
|
+
const { task, tree } = row;
|
|
42
|
+
const treePrefix = includeTree ? renderTreePrefix(tree) : "";
|
|
43
|
+
description = Math.max(description, textWidth(`${treePrefix}${task.description}`));
|
|
44
|
+
|
|
45
|
+
if (task.units._tag === "DeterminateTaskUnits") {
|
|
46
|
+
hasDeterminate = true;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
amount = Math.max(amount, textWidth(formatAmount(task, tick)));
|
|
50
|
+
elapsed = Math.max(elapsed, textWidth(formatElapsed(task, now)));
|
|
51
|
+
|
|
52
|
+
if (task.status === "running" && task.units._tag === "DeterminateTaskUnits") {
|
|
53
|
+
const etaValue = formatEta(task, now);
|
|
54
|
+
const etaText = `ETA: ${etaValue.length > 0 ? etaValue : "--"}`;
|
|
55
|
+
eta = Math.max(eta, textWidth(etaText));
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const bar = hasDeterminate ? DEFAULT_BAR_WIDTH : 0;
|
|
60
|
+
let widths = {
|
|
61
|
+
description,
|
|
62
|
+
bar,
|
|
63
|
+
amount,
|
|
64
|
+
elapsed,
|
|
65
|
+
eta,
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const visible = (w: typeof widths): Array<number> =>
|
|
69
|
+
[w.description, w.bar, w.amount, w.elapsed, w.eta].filter((width) => width > 0);
|
|
70
|
+
const total = (w: typeof widths): number => {
|
|
71
|
+
const cols = visible(w);
|
|
72
|
+
return cols.reduce((sum, width) => sum + width, 0) + Math.max(0, cols.length - 1);
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const baselineTarget = Math.max(BASELINE_ROW_WIDTH, total(widths));
|
|
76
|
+
const target =
|
|
77
|
+
terminalColumns === undefined
|
|
78
|
+
? baselineTarget
|
|
79
|
+
: Math.max(1, Math.min(Math.max(1, Math.floor(terminalColumns)), baselineTarget));
|
|
80
|
+
|
|
81
|
+
if (total(widths) < target) {
|
|
82
|
+
widths.description += target - total(widths);
|
|
83
|
+
} else if (total(widths) > target) {
|
|
84
|
+
let overflow = total(widths) - target;
|
|
85
|
+
|
|
86
|
+
const reduceBy = (key: keyof typeof widths, min: number) => {
|
|
87
|
+
if (overflow <= 0) {
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
const current = widths[key];
|
|
91
|
+
if (current <= min) {
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
const reducible = current - min;
|
|
95
|
+
const delta = Math.min(reducible, overflow);
|
|
96
|
+
widths = { ...widths, [key]: current - delta };
|
|
97
|
+
overflow -= delta;
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
// Compress the description first, then optional columns.
|
|
101
|
+
reduceBy("description", MIN_DESCRIPTION_WIDTH);
|
|
102
|
+
reduceBy("eta", 0);
|
|
103
|
+
reduceBy("bar", MIN_BAR_WIDTH);
|
|
104
|
+
reduceBy("bar", 0);
|
|
105
|
+
reduceBy("elapsed", MIN_ELAPSED_WIDTH);
|
|
106
|
+
reduceBy("amount", MIN_AMOUNT_WIDTH);
|
|
107
|
+
reduceBy("description", 0);
|
|
108
|
+
|
|
109
|
+
if (total(widths) < target) {
|
|
110
|
+
widths.description += target - total(widths);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const rowWidth = total(widths);
|
|
115
|
+
|
|
116
|
+
return {
|
|
117
|
+
row: rowWidth,
|
|
118
|
+
description: widths.description,
|
|
119
|
+
bar: widths.bar,
|
|
120
|
+
amount: widths.amount,
|
|
121
|
+
elapsed: widths.elapsed,
|
|
122
|
+
eta: widths.eta,
|
|
123
|
+
};
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
export const computeSharedColumnWidths = (
|
|
127
|
+
rows: ReadonlyArray<TaskRowModel>,
|
|
128
|
+
now: number,
|
|
129
|
+
tick: number,
|
|
130
|
+
terminalColumns?: number,
|
|
131
|
+
): SharedColumnWidths => {
|
|
132
|
+
const withTree = computeWidths(rows, now, tick, terminalColumns, true);
|
|
133
|
+
if (withTree.description >= MIN_DESCRIPTION_COLUMNS_FOR_TREE) {
|
|
134
|
+
return {
|
|
135
|
+
...withTree,
|
|
136
|
+
showTree: true,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const withoutTree = computeWidths(rows, now, tick, terminalColumns, false);
|
|
141
|
+
return {
|
|
142
|
+
...withoutTree,
|
|
143
|
+
showTree: false,
|
|
144
|
+
};
|
|
145
|
+
};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { TaskStore } from "../types";
|
|
2
|
+
import type { OrderedTask, TaskRowModel } from "./types";
|
|
3
|
+
import { computeTreeInfo } from "./tree";
|
|
4
|
+
|
|
5
|
+
const orderedVisibleTasks = (store: TaskStore): ReadonlyArray<OrderedTask> =>
|
|
6
|
+
store.renderOrder.flatMap((row) => {
|
|
7
|
+
const snapshot = store.tasks.get(row.id);
|
|
8
|
+
if (!snapshot || (snapshot.transient && snapshot.status !== "running")) {
|
|
9
|
+
return [];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
return [
|
|
13
|
+
{
|
|
14
|
+
snapshot,
|
|
15
|
+
depth: row.depth,
|
|
16
|
+
},
|
|
17
|
+
];
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
export const toTaskRows = (store: TaskStore): ReadonlyArray<TaskRowModel> =>
|
|
21
|
+
computeTreeInfo(orderedVisibleTasks(store)).map((entry) => ({
|
|
22
|
+
task: entry.snapshot,
|
|
23
|
+
tree: entry.tree,
|
|
24
|
+
}));
|