effective-progress 0.4.3 → 0.5.1

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
@@ -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 raw console calls are buffered and replayed through your existing `Console` / logger between frame renders
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 with custom config and manual task control
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 replay
91
+ ### Console behavior
92
92
 
93
- - `Progress.task`, `Progress.all`, and `Progress.forEach` buffer `Console.log`/`Console.dir` calls and replay them through the outer `Console`.
94
- - Calls are replayed between progress frame renders to avoid tearing the TTY frame.
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
- ### Configuring renderer and progress bars
97
+ ### Ink renderer behavior
98
98
 
99
- Configure global renderer behavior once, and a global base progress bar style:
100
-
101
- Defaults:
102
-
103
- - columns: `DescriptionColumn`, `BarColumn`, `AmountColumn`, `ElapsedColumn`, `EtaColumn`
104
- - total progress width: `80`
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
- Built-in columns are exported as classes with `Default()` and `make()` helpers.
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.4.3",
3
+ "version": "0.5.1",
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,8 +33,9 @@
33
33
  "format:check": "oxfmt --check ."
34
34
  },
35
35
  "dependencies": {
36
- "es-toolkit": "^1.44.0",
37
- "type-fest": "^5.4.4"
36
+ "@types/react": "^19.2.14",
37
+ "ink": "^6.8.0",
38
+ "react": "^19.2.4"
38
39
  },
39
40
  "devDependencies": {
40
41
  "@effect/language-service": "^0.73.1",
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 { RendererConfig, Task } from "./types";
7
- import type { AddTaskOptions, RendererConfigShape, TrackOptions } from "./types";
5
+ import { Task } from "./types";
6
+ import type { AddTaskOptions, TrackOptions } from "./types";
8
7
  import { inferTotal } from "./utils";
9
8
 
10
- interface RenderOverrideOptions {
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
- if (renderConfig === undefined) {
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 & RenderOverrideOptions;
49
+ export type ForEachOptions = TrackOptions & ForEachExecutionOptions;
81
50
 
82
- export type TaskOptions = AddTaskOptions & RenderOverrideOptions;
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, taskOptions);
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 & RenderOverrideOptions,
88
+ options: Omit<TrackOptions, "total"> & O,
123
89
  ): AllReturn<Arg, O>;
124
- <O extends EffectAllExecutionOptions & RenderOverrideOptions>(
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 & RenderOverrideOptions>(
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
@@ -1,5 +1,4 @@
1
1
  export * from "./api";
2
- export * from "./renderer";
3
2
  export { Progress } from "./runtime";
4
3
  export * from "./terminal";
5
4
  export * from "./types";
@@ -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,7 @@
1
+ import { Text } from "ink";
2
+ import { formatElapsed } from "../format";
3
+ import type { ColumnProps } from "./types";
4
+
5
+ export const ElapsedColumn = ({ task, now }: ColumnProps) => (
6
+ <Text color="gray">{formatElapsed(task, now)}</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
+ };