effective-progress 0.4.1 → 0.4.2
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 +89 -66
- package/package.json +1 -1
- package/src/api.ts +57 -21
- package/src/index.ts +0 -1
- package/src/renderer/ansi.ts +138 -0
- package/src/renderer/tree.ts +68 -0
- package/src/renderer/types.ts +67 -0
- package/src/renderer.ts +747 -719
- package/src/runtime.ts +23 -42
- package/src/types.ts +27 -7
- package/src/theme.ts +0 -87
package/src/renderer.ts
CHANGED
|
@@ -1,239 +1,514 @@
|
|
|
1
|
-
import
|
|
2
|
-
import {
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
import { Clock, Context, Effect, Layer, Ref } from "effect";
|
|
3
|
+
import { fitRenderedText, visibleWidth } from "./renderer/ansi";
|
|
4
|
+
import { computeTreeInfo, renderTreePrefix } from "./renderer/tree";
|
|
5
|
+
import { Track } from "./renderer/types";
|
|
6
|
+
import type {
|
|
7
|
+
CellWrapMode,
|
|
8
|
+
ColumnTrack,
|
|
9
|
+
ProgressColumn,
|
|
10
|
+
ProgressColumnContext,
|
|
11
|
+
ProgressColumnVariant,
|
|
12
|
+
} from "./renderer/types";
|
|
3
13
|
import type { ProgressTerminalService } from "./terminal";
|
|
4
|
-
import type { RendererConfigShape, TaskSnapshot, TaskStore } from "./types";
|
|
14
|
+
import type { ProgressBarConfigShape, RendererConfigShape, TaskSnapshot, TaskStore } from "./types";
|
|
15
|
+
export { Track };
|
|
16
|
+
export type {
|
|
17
|
+
CellWrapMode,
|
|
18
|
+
ColumnTrack,
|
|
19
|
+
ProgressColumn,
|
|
20
|
+
ProgressColumnContext,
|
|
21
|
+
ProgressColumnVariant,
|
|
22
|
+
TaskTreeInfo,
|
|
23
|
+
} from "./renderer/types";
|
|
5
24
|
|
|
6
25
|
const HIDE_CURSOR = "\x1b[?25l";
|
|
7
26
|
const SHOW_CURSOR = "\x1b[?25h";
|
|
8
27
|
const CLEAR_LINE = "\x1b[2K";
|
|
9
28
|
const MOVE_UP_ONE = "\x1b[1A";
|
|
29
|
+
const RESERVED_SECONDS_WIDTH = 3; // width of "59s"
|
|
30
|
+
const PREVIEW_RENDER_WIDTH = 10_000;
|
|
31
|
+
const TREE_PREFIX_COLLAPSE_BAR_WIDTH = 10;
|
|
10
32
|
|
|
11
33
|
const clamp = (value: number, minimum: number, maximum: number): number =>
|
|
12
34
|
Math.min(Math.max(value, minimum), maximum);
|
|
13
35
|
|
|
14
|
-
// Use code-point length instead of UTF-16 code-unit length so surrogate pairs
|
|
15
|
-
// (for example emoji) count as a single visible character.
|
|
16
36
|
const textWidth = (text: string): number => Array.from(text).length;
|
|
17
37
|
|
|
18
|
-
const
|
|
19
|
-
|
|
38
|
+
const formatDurationSeconds = (seconds: number): string => {
|
|
39
|
+
const value = Math.max(0, Math.floor(seconds));
|
|
40
|
+
if (value < 60) {
|
|
41
|
+
return `${value}s`;
|
|
42
|
+
}
|
|
43
|
+
if (value < 3600) {
|
|
44
|
+
const mins = Math.floor(value / 60);
|
|
45
|
+
const secs = value % 60;
|
|
46
|
+
return secs > 0 ? `${mins}m ${secs}s` : `${mins}m`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const hours = Math.floor(value / 3600);
|
|
50
|
+
const mins = Math.floor((value % 3600) / 60);
|
|
51
|
+
return mins > 0 ? `${hours}h ${mins}m` : `${hours}h`;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const formatDeterminateUnits = (completed: number, total: number): string => {
|
|
55
|
+
const totalText = `${total}`;
|
|
56
|
+
const completedText = `${completed}`.padStart(totalText.length, " ");
|
|
57
|
+
return `${completedText}/${totalText}`;
|
|
58
|
+
};
|
|
20
59
|
|
|
21
60
|
const formatElapsed = (snapshot: TaskSnapshot, now: number): string => {
|
|
22
61
|
const elapsedMillis = Math.max(0, (snapshot.completedAt ?? now) - snapshot.startedAt);
|
|
23
|
-
|
|
24
|
-
snapshot.status === "running"
|
|
25
|
-
? Duration.seconds(Math.floor(elapsedMillis / 1000))
|
|
26
|
-
: Duration.millis(elapsedMillis);
|
|
27
|
-
return `${Duration.format(duration)}`;
|
|
62
|
+
return formatDurationSeconds(elapsedMillis / 1000);
|
|
28
63
|
};
|
|
29
64
|
|
|
30
65
|
const formatEta = (snapshot: TaskSnapshot, now: number): string => {
|
|
31
|
-
if (snapshot.status !== "running") {
|
|
32
|
-
return "
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
if (snapshot.units._tag !== "DeterminateTaskUnits") {
|
|
36
|
-
return "ETA: --";
|
|
66
|
+
if (snapshot.status !== "running" || snapshot.units._tag !== "DeterminateTaskUnits") {
|
|
67
|
+
return "";
|
|
37
68
|
}
|
|
38
69
|
|
|
39
70
|
const { completed, total } = snapshot.units;
|
|
40
71
|
const remaining = total - completed;
|
|
41
72
|
if (completed <= 0 || remaining <= 0) {
|
|
42
|
-
return "
|
|
73
|
+
return "";
|
|
43
74
|
}
|
|
44
75
|
|
|
45
76
|
const elapsedMillis = Math.max(1, now - snapshot.startedAt);
|
|
46
77
|
const etaMillis = Math.max(0, Math.floor((elapsedMillis / completed) * remaining));
|
|
47
|
-
|
|
48
|
-
return `ETA: ${Duration.format(duration)}`;
|
|
78
|
+
return `ETA: ${formatDurationSeconds(etaMillis / 1000)}`;
|
|
49
79
|
};
|
|
50
80
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
| {
|
|
54
|
-
readonly _tag: "Auto";
|
|
55
|
-
}
|
|
56
|
-
| {
|
|
57
|
-
readonly _tag: "Fixed";
|
|
58
|
-
readonly width: number;
|
|
59
|
-
}
|
|
60
|
-
| {
|
|
61
|
-
readonly _tag: "Fraction";
|
|
62
|
-
readonly weight: number;
|
|
63
|
-
};
|
|
81
|
+
const reserveTimeWidth = (formattedDuration: string): string =>
|
|
82
|
+
formattedDuration.padStart(RESERVED_SECONDS_WIDTH, " ");
|
|
64
83
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
fixed: (width: number): ColumnTrack => ({
|
|
68
|
-
_tag: "Fixed",
|
|
69
|
-
width: Math.max(0, Math.floor(width)),
|
|
70
|
-
}),
|
|
71
|
-
fr: (weight = 1): ColumnTrack => ({
|
|
72
|
-
_tag: "Fraction",
|
|
73
|
-
weight: Math.max(0.001, weight),
|
|
74
|
-
}),
|
|
75
|
-
} as const;
|
|
84
|
+
const styleIfTTY = (isTTY: boolean, style: (text: string) => string, text: string): string =>
|
|
85
|
+
isTTY ? style(text) : text;
|
|
76
86
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
readonly
|
|
80
|
-
readonly
|
|
81
|
-
readonly
|
|
82
|
-
readonly
|
|
87
|
+
interface ColumnBaseOptions {
|
|
88
|
+
readonly id?: string;
|
|
89
|
+
readonly track?: ColumnTrack;
|
|
90
|
+
readonly minWidth?: number;
|
|
91
|
+
readonly maxWidth?: number;
|
|
92
|
+
readonly collapsePriority?: number;
|
|
93
|
+
readonly wrapMode?: CellWrapMode;
|
|
83
94
|
}
|
|
84
95
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
96
|
+
const normalizeNumber = (value: number | undefined, fallback: number): number =>
|
|
97
|
+
value === undefined ? fallback : Math.max(0, Math.floor(value));
|
|
98
|
+
|
|
99
|
+
export interface DescriptionColumnOptions extends ColumnBaseOptions {}
|
|
100
|
+
|
|
101
|
+
export class DescriptionColumn implements ProgressColumn {
|
|
102
|
+
readonly id: string;
|
|
103
|
+
readonly track: ColumnTrack;
|
|
104
|
+
readonly minWidth: number;
|
|
105
|
+
readonly maxWidth?: number;
|
|
106
|
+
readonly collapsePriority: number;
|
|
107
|
+
readonly wrapMode: CellWrapMode;
|
|
108
|
+
|
|
109
|
+
constructor(options: DescriptionColumnOptions = {}) {
|
|
110
|
+
this.id = options.id ?? "description";
|
|
111
|
+
this.track = options.track ?? Track.fr(1);
|
|
112
|
+
this.minWidth = normalizeNumber(options.minWidth, 8);
|
|
113
|
+
this.maxWidth = options.maxWidth;
|
|
114
|
+
this.collapsePriority = normalizeNumber(options.collapsePriority, 100);
|
|
115
|
+
this.wrapMode = options.wrapMode ?? "ellipsis";
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
static Default(): DescriptionColumn {
|
|
119
|
+
return new DescriptionColumn();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
static make(options: DescriptionColumnOptions = {}): DescriptionColumn {
|
|
123
|
+
return new DescriptionColumn(options);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
render(context: ProgressColumnContext): string {
|
|
127
|
+
const prefix = renderTreePrefix(context.tree);
|
|
128
|
+
return `${prefix}${context.task.description}`;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
variants(context: ProgressColumnContext): ReadonlyArray<ProgressColumnVariant> {
|
|
132
|
+
const full: ProgressColumnVariant = {
|
|
133
|
+
render: () => `${renderTreePrefix(context.tree)}${context.task.description}`,
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
if (context.tree.depth <= 0) {
|
|
137
|
+
return [full];
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return [
|
|
141
|
+
full,
|
|
142
|
+
{
|
|
143
|
+
render: () => context.task.description,
|
|
144
|
+
},
|
|
145
|
+
];
|
|
146
|
+
}
|
|
90
147
|
}
|
|
91
148
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
readonly
|
|
95
|
-
readonly
|
|
149
|
+
export interface BarColumnOptions extends ColumnBaseOptions {
|
|
150
|
+
readonly barWidth?: number;
|
|
151
|
+
readonly fillChar?: string;
|
|
152
|
+
readonly emptyChar?: string;
|
|
153
|
+
readonly leftBracket?: string;
|
|
154
|
+
readonly rightBracket?: string;
|
|
96
155
|
}
|
|
97
156
|
|
|
98
|
-
|
|
99
|
-
|
|
157
|
+
const resolveBarConfig = (
|
|
158
|
+
snapshot: TaskSnapshot,
|
|
159
|
+
options: BarColumnOptions,
|
|
160
|
+
): ProgressBarConfigShape => ({
|
|
161
|
+
spinnerFrames: snapshot.config.spinnerFrames,
|
|
162
|
+
barWidth: options.barWidth ?? snapshot.config.barWidth,
|
|
163
|
+
fillChar: options.fillChar ?? snapshot.config.fillChar,
|
|
164
|
+
emptyChar: options.emptyChar ?? snapshot.config.emptyChar,
|
|
165
|
+
leftBracket: options.leftBracket ?? snapshot.config.leftBracket,
|
|
166
|
+
rightBracket: options.rightBracket ?? snapshot.config.rightBracket,
|
|
167
|
+
});
|
|
100
168
|
|
|
101
|
-
|
|
102
|
-
export interface CellModel {
|
|
169
|
+
export class BarColumn implements ProgressColumn {
|
|
103
170
|
readonly id: string;
|
|
104
|
-
readonly track
|
|
105
|
-
readonly minWidth
|
|
171
|
+
readonly track: ColumnTrack;
|
|
172
|
+
readonly minWidth: number;
|
|
106
173
|
readonly maxWidth?: number;
|
|
107
|
-
readonly
|
|
108
|
-
readonly
|
|
109
|
-
readonly
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
174
|
+
readonly collapsePriority: number;
|
|
175
|
+
readonly wrapMode: CellWrapMode;
|
|
176
|
+
readonly options: BarColumnOptions;
|
|
177
|
+
|
|
178
|
+
constructor(options: BarColumnOptions = {}) {
|
|
179
|
+
this.id = options.id ?? "bar";
|
|
180
|
+
this.track = options.track ?? Track.auto();
|
|
181
|
+
this.minWidth = normalizeNumber(options.minWidth, 1);
|
|
182
|
+
this.maxWidth = options.maxWidth;
|
|
183
|
+
this.collapsePriority = normalizeNumber(options.collapsePriority, 10);
|
|
184
|
+
this.wrapMode = options.wrapMode ?? "truncate";
|
|
185
|
+
this.options = options;
|
|
186
|
+
}
|
|
113
187
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
readonly gap?: number;
|
|
118
|
-
readonly lineVariant?: "lead" | "continuation";
|
|
119
|
-
}
|
|
188
|
+
static Default(): BarColumn {
|
|
189
|
+
return new BarColumn();
|
|
190
|
+
}
|
|
120
191
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
192
|
+
static make(options: BarColumnOptions = {}): BarColumn {
|
|
193
|
+
return new BarColumn(options);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
measure(context: ProgressColumnContext): number {
|
|
197
|
+
if (context.task.units._tag !== "DeterminateTaskUnits") {
|
|
198
|
+
return 0;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const config = resolveBarConfig(context.task, this.options);
|
|
202
|
+
const bracketWidth = textWidth(config.leftBracket) + textWidth(config.rightBracket);
|
|
203
|
+
return Math.max(this.minWidth, bracketWidth + Math.max(1, config.barWidth));
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
render(context: ProgressColumnContext, width: number): string {
|
|
207
|
+
if (context.task.units._tag !== "DeterminateTaskUnits") {
|
|
208
|
+
return "";
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const config = resolveBarConfig(context.task, this.options);
|
|
212
|
+
const bracketWidth = textWidth(config.leftBracket) + textWidth(config.rightBracket);
|
|
213
|
+
const totalWidth = Math.max(1, Math.floor(width));
|
|
214
|
+
const innerWidth = Math.max(1, totalWidth - bracketWidth);
|
|
215
|
+
const safeTotal = Math.max(1, context.task.units.total);
|
|
216
|
+
const ratio =
|
|
217
|
+
context.task.status === "done" ? 1 : clamp(context.task.units.completed / safeTotal, 0, 1);
|
|
218
|
+
const filled = Math.round(ratio * innerWidth);
|
|
219
|
+
|
|
220
|
+
const fill = config.fillChar.repeat(filled);
|
|
221
|
+
const empty = config.emptyChar.repeat(Math.max(0, innerWidth - filled));
|
|
222
|
+
|
|
223
|
+
const fillStyle =
|
|
224
|
+
context.task.status === "failed"
|
|
225
|
+
? chalk.red
|
|
226
|
+
: context.task.status === "done"
|
|
227
|
+
? chalk.green
|
|
228
|
+
: chalk.blue;
|
|
229
|
+
const emptyStyle = context.task.status === "failed" ? chalk.red : chalk.white.dim;
|
|
230
|
+
|
|
231
|
+
return [
|
|
232
|
+
styleIfTTY(context.isTTY, chalk.white.dim, config.leftBracket),
|
|
233
|
+
styleIfTTY(context.isTTY, fillStyle, fill),
|
|
234
|
+
styleIfTTY(context.isTTY, emptyStyle, empty),
|
|
235
|
+
styleIfTTY(context.isTTY, chalk.white.dim, config.rightBracket),
|
|
236
|
+
].join("");
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
variants(context: ProgressColumnContext): ReadonlyArray<ProgressColumnVariant> {
|
|
240
|
+
if (context.task.units._tag !== "DeterminateTaskUnits") {
|
|
241
|
+
return [];
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const config = resolveBarConfig(context.task, this.options);
|
|
245
|
+
const bracketWidth = textWidth(config.leftBracket) + textWidth(config.rightBracket);
|
|
246
|
+
const fullWidth = Math.max(this.minWidth, bracketWidth + Math.max(1, config.barWidth));
|
|
247
|
+
const compactInnerWidths = [20, TREE_PREFIX_COLLAPSE_BAR_WIDTH]
|
|
248
|
+
.map((innerWidth) => Math.max(1, Math.floor(innerWidth)))
|
|
249
|
+
.filter((innerWidth) => innerWidth < Math.max(1, config.barWidth));
|
|
250
|
+
const compactWidths = compactInnerWidths.map((innerWidth) =>
|
|
251
|
+
Math.max(this.minWidth, bracketWidth + innerWidth),
|
|
252
|
+
);
|
|
253
|
+
const uniqueWidths = [...new Set([fullWidth, ...compactWidths])];
|
|
254
|
+
|
|
255
|
+
return uniqueWidths.map((variantWidth) => ({
|
|
256
|
+
measure: () => variantWidth,
|
|
257
|
+
render: (variantContext, width) => this.render(variantContext, width),
|
|
258
|
+
}));
|
|
259
|
+
}
|
|
127
260
|
}
|
|
128
261
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
readonly
|
|
262
|
+
export interface AmountColumnOptions extends ColumnBaseOptions {
|
|
263
|
+
readonly doneSymbol?: string;
|
|
264
|
+
readonly failedSymbol?: string;
|
|
132
265
|
}
|
|
133
266
|
|
|
134
|
-
|
|
135
|
-
export interface FittedCell {
|
|
267
|
+
export class AmountColumn implements ProgressColumn {
|
|
136
268
|
readonly id: string;
|
|
137
|
-
readonly
|
|
138
|
-
readonly
|
|
139
|
-
|
|
269
|
+
readonly track: ColumnTrack;
|
|
270
|
+
readonly minWidth: number;
|
|
271
|
+
readonly maxWidth?: number;
|
|
272
|
+
readonly collapsePriority: number;
|
|
273
|
+
readonly wrapMode: CellWrapMode;
|
|
274
|
+
readonly doneSymbol: string;
|
|
275
|
+
readonly failedSymbol: string;
|
|
276
|
+
|
|
277
|
+
constructor(options: AmountColumnOptions = {}) {
|
|
278
|
+
this.id = options.id ?? "amount";
|
|
279
|
+
this.track = options.track ?? Track.auto();
|
|
280
|
+
this.minWidth = normalizeNumber(options.minWidth, 1);
|
|
281
|
+
this.maxWidth = options.maxWidth;
|
|
282
|
+
this.collapsePriority = normalizeNumber(options.collapsePriority, 30);
|
|
283
|
+
this.wrapMode = options.wrapMode ?? "truncate";
|
|
284
|
+
this.doneSymbol = options.doneSymbol ?? "✓";
|
|
285
|
+
this.failedSymbol = options.failedSymbol ?? "✗";
|
|
286
|
+
}
|
|
140
287
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
readonly theme: ThemeService;
|
|
145
|
-
readonly gap: number;
|
|
146
|
-
readonly cells: ReadonlyArray<FittedCell>;
|
|
147
|
-
}
|
|
288
|
+
static Default(): AmountColumn {
|
|
289
|
+
return new AmountColumn();
|
|
290
|
+
}
|
|
148
291
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
readonly rows: ReadonlyArray<FittedRow>;
|
|
153
|
-
}
|
|
292
|
+
static make(options: AmountColumnOptions = {}): AmountColumn {
|
|
293
|
+
return new AmountColumn(options);
|
|
294
|
+
}
|
|
154
295
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
readonly taskBlocks: ReadonlyArray<FittedTaskBlock>;
|
|
158
|
-
}
|
|
296
|
+
render(context: ProgressColumnContext): string {
|
|
297
|
+
const { task } = context;
|
|
159
298
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
}
|
|
299
|
+
if (task.units._tag === "DeterminateTaskUnits") {
|
|
300
|
+
return styleIfTTY(
|
|
301
|
+
context.isTTY,
|
|
302
|
+
task.status === "failed" ? chalk.red : chalk.whiteBright,
|
|
303
|
+
formatDeterminateUnits(task.units.completed, task.units.total),
|
|
304
|
+
);
|
|
305
|
+
}
|
|
167
306
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
307
|
+
if (task.status === "running") {
|
|
308
|
+
const frames = task.config.spinnerFrames;
|
|
309
|
+
const frameIndex = (task.units.spinnerFrame + context.tick) % frames.length;
|
|
310
|
+
const frame = frames[frameIndex] ?? frames[0] ?? "";
|
|
311
|
+
return styleIfTTY(context.isTTY, chalk.yellow, frame);
|
|
312
|
+
}
|
|
172
313
|
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
readonly maxTaskWidth: number | undefined;
|
|
177
|
-
}
|
|
314
|
+
if (task.status === "done") {
|
|
315
|
+
return styleIfTTY(context.isTTY, chalk.green, this.doneSymbol);
|
|
316
|
+
}
|
|
178
317
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
readonly frame: FrameModel;
|
|
182
|
-
readonly width: ShrinkWidthConstraints;
|
|
318
|
+
return styleIfTTY(context.isTTY, chalk.red, this.failedSymbol);
|
|
319
|
+
}
|
|
183
320
|
}
|
|
184
321
|
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
readonly fitFrame: (options: ShrinkStageFitOptions) => FittedFrameModel;
|
|
322
|
+
export interface ElapsedColumnOptions extends ColumnBaseOptions {
|
|
323
|
+
readonly padSeconds?: boolean;
|
|
188
324
|
}
|
|
189
325
|
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
readonly
|
|
326
|
+
export class ElapsedColumn implements ProgressColumn {
|
|
327
|
+
readonly id: string;
|
|
328
|
+
readonly track: ColumnTrack;
|
|
329
|
+
readonly minWidth: number;
|
|
330
|
+
readonly maxWidth?: number;
|
|
331
|
+
readonly collapsePriority: number;
|
|
332
|
+
readonly wrapMode: CellWrapMode;
|
|
333
|
+
readonly padSeconds: boolean;
|
|
334
|
+
|
|
335
|
+
constructor(options: ElapsedColumnOptions = {}) {
|
|
336
|
+
this.id = options.id ?? "elapsed";
|
|
337
|
+
this.track = options.track ?? Track.auto();
|
|
338
|
+
this.minWidth = normalizeNumber(options.minWidth, 1);
|
|
339
|
+
this.maxWidth = options.maxWidth;
|
|
340
|
+
this.collapsePriority = normalizeNumber(options.collapsePriority, 40);
|
|
341
|
+
this.wrapMode = options.wrapMode ?? "truncate";
|
|
342
|
+
this.padSeconds = options.padSeconds ?? true;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
static Default(): ElapsedColumn {
|
|
346
|
+
return new ElapsedColumn();
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
static make(options: ElapsedColumnOptions = {}): ElapsedColumn {
|
|
350
|
+
return new ElapsedColumn(options);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
render(context: ProgressColumnContext): string {
|
|
354
|
+
const raw = formatElapsed(context.task, context.now);
|
|
355
|
+
const elapsed = this.padSeconds ? reserveTimeWidth(raw) : raw;
|
|
356
|
+
return styleIfTTY(context.isTTY, chalk.gray, elapsed);
|
|
357
|
+
}
|
|
193
358
|
}
|
|
194
359
|
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
readonly
|
|
360
|
+
export interface EtaColumnOptions extends ColumnBaseOptions {
|
|
361
|
+
readonly label?: string;
|
|
362
|
+
readonly pendingText?: string;
|
|
198
363
|
}
|
|
199
364
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
readonly
|
|
203
|
-
readonly
|
|
204
|
-
readonly
|
|
205
|
-
readonly
|
|
206
|
-
readonly
|
|
207
|
-
readonly
|
|
208
|
-
readonly
|
|
209
|
-
|
|
365
|
+
export class EtaColumn implements ProgressColumn {
|
|
366
|
+
readonly id: string;
|
|
367
|
+
readonly track: ColumnTrack;
|
|
368
|
+
readonly minWidth: number;
|
|
369
|
+
readonly maxWidth?: number;
|
|
370
|
+
readonly collapsePriority: number;
|
|
371
|
+
readonly wrapMode: CellWrapMode;
|
|
372
|
+
readonly label: string;
|
|
373
|
+
readonly pendingText: string;
|
|
374
|
+
|
|
375
|
+
constructor(options: EtaColumnOptions = {}) {
|
|
376
|
+
this.id = options.id ?? "eta";
|
|
377
|
+
this.track = options.track ?? Track.auto();
|
|
378
|
+
this.minWidth = normalizeNumber(options.minWidth, 0);
|
|
379
|
+
this.maxWidth = options.maxWidth;
|
|
380
|
+
this.collapsePriority = normalizeNumber(options.collapsePriority, 20);
|
|
381
|
+
this.wrapMode = options.wrapMode ?? "truncate";
|
|
382
|
+
this.label = options.label ?? "ETA";
|
|
383
|
+
this.pendingText = options.pendingText ?? `${this.label}: `;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
static Default(): EtaColumn {
|
|
387
|
+
return new EtaColumn();
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
static make(options: EtaColumnOptions = {}): EtaColumn {
|
|
391
|
+
return new EtaColumn(options);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
private resolveEtaText(context: ProgressColumnContext, includeLabel: boolean): string {
|
|
395
|
+
const value = formatEta(context.task, context.now);
|
|
396
|
+
if (value.length === 0) {
|
|
397
|
+
if (context.task.status === "running" && context.task.units._tag === "DeterminateTaskUnits") {
|
|
398
|
+
if (includeLabel) {
|
|
399
|
+
return this.pendingText;
|
|
400
|
+
}
|
|
401
|
+
const pendingPrefix = `${this.label}: `;
|
|
402
|
+
return this.pendingText.startsWith(pendingPrefix)
|
|
403
|
+
? this.pendingText.slice(pendingPrefix.length)
|
|
404
|
+
: this.pendingText.replace(/^ETA:\s*/, "");
|
|
405
|
+
}
|
|
406
|
+
return "";
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
if (includeLabel) {
|
|
410
|
+
if (this.label === "ETA") {
|
|
411
|
+
return value;
|
|
412
|
+
}
|
|
413
|
+
return value.replace(/^ETA/, this.label);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
const labeled = this.label === "ETA" ? value : value.replace(/^ETA/, this.label);
|
|
417
|
+
const prefix = `${this.label}: `;
|
|
418
|
+
return labeled.startsWith(prefix)
|
|
419
|
+
? labeled.slice(prefix.length)
|
|
420
|
+
: labeled.replace(/^ETA:\s*/, "");
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
render(context: ProgressColumnContext): string {
|
|
424
|
+
return styleIfTTY(context.isTTY, chalk.gray, this.resolveEtaText(context, true));
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
variants(context: ProgressColumnContext): ReadonlyArray<ProgressColumnVariant> {
|
|
428
|
+
const full = this.resolveEtaText(context, true);
|
|
429
|
+
if (full.length === 0) {
|
|
430
|
+
return [
|
|
431
|
+
{
|
|
432
|
+
render: () => "",
|
|
433
|
+
},
|
|
434
|
+
];
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
const compact = this.resolveEtaText(context, false);
|
|
438
|
+
if (compact === full) {
|
|
439
|
+
return [
|
|
440
|
+
{
|
|
441
|
+
render: () => styleIfTTY(context.isTTY, chalk.gray, full),
|
|
442
|
+
},
|
|
443
|
+
];
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
return [
|
|
447
|
+
{
|
|
448
|
+
render: () => styleIfTTY(context.isTTY, chalk.gray, full),
|
|
449
|
+
},
|
|
450
|
+
{
|
|
451
|
+
render: () => styleIfTTY(context.isTTY, chalk.gray, compact),
|
|
452
|
+
},
|
|
453
|
+
];
|
|
454
|
+
}
|
|
210
455
|
}
|
|
211
456
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
readonly run: (options: FrameRendererRunOptions) => Effect.Effect<void>;
|
|
457
|
+
export interface LiteralColumnOptions extends ColumnBaseOptions {
|
|
458
|
+
readonly text: string;
|
|
215
459
|
}
|
|
216
460
|
|
|
217
|
-
|
|
461
|
+
export class LiteralColumn implements ProgressColumn {
|
|
462
|
+
readonly id: string;
|
|
463
|
+
readonly track: ColumnTrack;
|
|
464
|
+
readonly minWidth: number;
|
|
465
|
+
readonly maxWidth?: number;
|
|
466
|
+
readonly collapsePriority: number;
|
|
467
|
+
readonly wrapMode: CellWrapMode;
|
|
468
|
+
readonly text: string;
|
|
218
469
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
470
|
+
constructor(options: LiteralColumnOptions) {
|
|
471
|
+
this.id = options.id ?? `literal:${options.text}`;
|
|
472
|
+
this.track = options.track ?? Track.auto();
|
|
473
|
+
this.minWidth = normalizeNumber(options.minWidth, 0);
|
|
474
|
+
this.maxWidth = options.maxWidth;
|
|
475
|
+
this.collapsePriority = normalizeNumber(options.collapsePriority, 0);
|
|
476
|
+
this.wrapMode = options.wrapMode ?? "truncate";
|
|
477
|
+
this.text = options.text;
|
|
222
478
|
}
|
|
223
479
|
|
|
224
|
-
|
|
225
|
-
};
|
|
480
|
+
static Default(text = "•"): LiteralColumn {
|
|
481
|
+
return new LiteralColumn({ text });
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
static make(text: string, options: Omit<LiteralColumnOptions, "text"> = {}): LiteralColumn {
|
|
485
|
+
return new LiteralColumn({ ...options, text });
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
render(): string {
|
|
489
|
+
return this.text;
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
export const Columns = {
|
|
494
|
+
defaults: (): ReadonlyArray<ProgressColumn> => [
|
|
495
|
+
DescriptionColumn.Default(),
|
|
496
|
+
BarColumn.Default(),
|
|
497
|
+
AmountColumn.Default(),
|
|
498
|
+
ElapsedColumn.Default(),
|
|
499
|
+
EtaColumn.Default(),
|
|
500
|
+
],
|
|
501
|
+
} as const;
|
|
226
502
|
|
|
227
|
-
const
|
|
228
|
-
|
|
229
|
-
|
|
503
|
+
const resolveTrack = (column: ProgressColumn): ColumnTrack => column.track ?? Track.auto();
|
|
504
|
+
|
|
505
|
+
const resolveColumnBounds = (column: ProgressColumn): { min: number; max?: number } => {
|
|
506
|
+
const min = Math.max(0, Math.floor(column.minWidth ?? 0));
|
|
507
|
+
const max =
|
|
508
|
+
column.maxWidth === undefined ? undefined : Math.max(min, Math.floor(column.maxWidth));
|
|
230
509
|
return { min, max };
|
|
231
510
|
};
|
|
232
511
|
|
|
233
|
-
const resolveTrack = (cell: CellModel): ColumnTrack => cell.track ?? Track.auto();
|
|
234
|
-
|
|
235
|
-
// Deterministic integer ratio distribution. We ceil each step and subtract
|
|
236
|
-
// from the remaining pool so the final sum always matches the target exactly.
|
|
237
512
|
const ratioDistribute = (
|
|
238
513
|
total: number,
|
|
239
514
|
ratios: ReadonlyArray<number>,
|
|
@@ -261,35 +536,37 @@ const ratioDistribute = (
|
|
|
261
536
|
return amounts;
|
|
262
537
|
};
|
|
263
538
|
|
|
264
|
-
const resolveTotalWidth = (
|
|
265
|
-
|
|
266
|
-
|
|
539
|
+
const resolveTotalWidth = (
|
|
540
|
+
terminalColumns: number | undefined,
|
|
541
|
+
width: number | "fullwidth",
|
|
542
|
+
): number | undefined => {
|
|
543
|
+
if (width === "fullwidth") {
|
|
544
|
+
if (terminalColumns === undefined) {
|
|
545
|
+
return undefined;
|
|
546
|
+
}
|
|
267
547
|
|
|
268
|
-
|
|
269
|
-
return maxTaskWidth;
|
|
548
|
+
return Math.max(1, Math.floor(terminalColumns));
|
|
270
549
|
}
|
|
271
550
|
|
|
272
|
-
const
|
|
273
|
-
|
|
274
|
-
|
|
551
|
+
const resolvedConfiguredWidth = Math.max(1, Math.floor(width));
|
|
552
|
+
|
|
553
|
+
if (terminalColumns === undefined) {
|
|
554
|
+
return resolvedConfiguredWidth;
|
|
275
555
|
}
|
|
276
556
|
|
|
277
|
-
return Math.min(terminalColumns,
|
|
557
|
+
return Math.min(Math.max(1, Math.floor(terminalColumns)), resolvedConfiguredWidth);
|
|
278
558
|
};
|
|
279
559
|
|
|
280
560
|
const shrinkByPriority = (
|
|
281
561
|
widths: Array<number>,
|
|
282
562
|
minWidths: ReadonlyArray<number>,
|
|
283
|
-
|
|
563
|
+
columns: ReadonlyArray<ProgressColumn>,
|
|
284
564
|
overflow: number,
|
|
285
565
|
): number => {
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
// decides whether we hard-truncate or append an ellipsis when fitted.
|
|
289
|
-
const shrinkable = cells
|
|
290
|
-
.map((cell, index) => ({
|
|
566
|
+
const shrinkable = columns
|
|
567
|
+
.map((column, index) => ({
|
|
291
568
|
index,
|
|
292
|
-
priority:
|
|
569
|
+
priority: column.collapsePriority ?? Number.MAX_SAFE_INTEGER,
|
|
293
570
|
}))
|
|
294
571
|
.sort((a, b) => a.priority - b.priority);
|
|
295
572
|
|
|
@@ -320,8 +597,6 @@ const shrinkProportionally = (
|
|
|
320
597
|
minWidths: ReadonlyArray<number>,
|
|
321
598
|
overflow: number,
|
|
322
599
|
): number => {
|
|
323
|
-
// Last-resort collapse pass: if priority-based shrinking is not enough,
|
|
324
|
-
// reduce all remaining shrinkable columns proportionally.
|
|
325
600
|
let remainingOverflow = overflow;
|
|
326
601
|
|
|
327
602
|
while (remainingOverflow > 0) {
|
|
@@ -371,37 +646,39 @@ const shrinkProportionally = (
|
|
|
371
646
|
return remainingOverflow;
|
|
372
647
|
};
|
|
373
648
|
|
|
374
|
-
const
|
|
375
|
-
|
|
649
|
+
const resolveColumnWidths = (
|
|
650
|
+
columns: ReadonlyArray<ProgressColumn>,
|
|
651
|
+
intrinsicWidths: ReadonlyArray<number>,
|
|
376
652
|
totalWidth: number | undefined,
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
653
|
+
gap: number,
|
|
654
|
+
): {
|
|
655
|
+
readonly widths: ReadonlyArray<number>;
|
|
656
|
+
readonly overflowBeforeShrink: number;
|
|
657
|
+
} => {
|
|
658
|
+
if (columns.length === 0) {
|
|
659
|
+
return {
|
|
660
|
+
widths: [],
|
|
661
|
+
overflowBeforeShrink: 0,
|
|
662
|
+
};
|
|
386
663
|
}
|
|
387
664
|
|
|
388
|
-
const minWidths =
|
|
389
|
-
const maxWidths =
|
|
390
|
-
const widths: Array<number> = Array.from({ length:
|
|
665
|
+
const minWidths = columns.map((column) => resolveColumnBounds(column).min);
|
|
666
|
+
const maxWidths = columns.map((column) => resolveColumnBounds(column).max);
|
|
667
|
+
const widths: Array<number> = Array.from({ length: columns.length }, () => 0);
|
|
391
668
|
|
|
392
|
-
for (let i = 0; i <
|
|
393
|
-
const
|
|
394
|
-
const track = resolveTrack(
|
|
669
|
+
for (let i = 0; i < columns.length; i++) {
|
|
670
|
+
const column = columns[i]!;
|
|
671
|
+
const track = resolveTrack(column);
|
|
395
672
|
const minWidth = minWidths[i] ?? 0;
|
|
396
673
|
const maxWidth = maxWidths[i];
|
|
397
|
-
const intrinsic =
|
|
674
|
+
const intrinsic = Math.max(0, Math.floor(intrinsicWidths[i] ?? 0));
|
|
398
675
|
|
|
399
676
|
const baseWidth = (() => {
|
|
400
677
|
switch (track._tag) {
|
|
401
678
|
case "Fixed":
|
|
402
679
|
return Math.max(minWidth, track.width);
|
|
403
680
|
case "Fraction":
|
|
404
|
-
return minWidth;
|
|
681
|
+
return totalWidth === undefined ? Math.max(minWidth, intrinsic) : minWidth;
|
|
405
682
|
case "Auto":
|
|
406
683
|
return Math.max(minWidth, intrinsic);
|
|
407
684
|
}
|
|
@@ -412,20 +689,24 @@ const resolveRowWidths = (
|
|
|
412
689
|
}
|
|
413
690
|
|
|
414
691
|
if (totalWidth === undefined) {
|
|
415
|
-
return
|
|
692
|
+
return {
|
|
693
|
+
widths,
|
|
694
|
+
overflowBeforeShrink: 0,
|
|
695
|
+
};
|
|
416
696
|
}
|
|
417
697
|
|
|
418
|
-
const usableWidth = Math.max(1, totalWidth - gap * Math.max(0,
|
|
698
|
+
const usableWidth = Math.max(1, totalWidth - gap * Math.max(0, columns.length - 1));
|
|
419
699
|
|
|
420
700
|
const fractionColumns: Array<{ index: number; weight: number }> = [];
|
|
421
|
-
for (let index = 0; index <
|
|
422
|
-
const track = resolveTrack(
|
|
701
|
+
for (let index = 0; index < columns.length; index++) {
|
|
702
|
+
const track = resolveTrack(columns[index]!);
|
|
423
703
|
if (track._tag === "Fraction") {
|
|
424
704
|
fractionColumns.push({ index, weight: track.weight });
|
|
425
705
|
}
|
|
426
706
|
}
|
|
427
707
|
|
|
428
708
|
let remaining = usableWidth - widths.reduce((sum, width) => sum + width, 0);
|
|
709
|
+
const overflowBeforeShrink = Math.max(0, -remaining);
|
|
429
710
|
|
|
430
711
|
if (remaining > 0 && fractionColumns.length > 0) {
|
|
431
712
|
const distributed = ratioDistribute(
|
|
@@ -444,505 +725,297 @@ const resolveRowWidths = (
|
|
|
444
725
|
|
|
445
726
|
if (remaining < 0) {
|
|
446
727
|
let overflow = -remaining;
|
|
447
|
-
overflow = shrinkByPriority(widths, minWidths,
|
|
728
|
+
overflow = shrinkByPriority(widths, minWidths, columns, overflow);
|
|
448
729
|
if (overflow > 0) {
|
|
449
730
|
overflow = shrinkProportionally(widths, minWidths, overflow);
|
|
450
731
|
}
|
|
451
732
|
}
|
|
452
733
|
|
|
453
|
-
return
|
|
454
|
-
|
|
455
|
-
|
|
734
|
+
return {
|
|
735
|
+
widths: widths.map((width, index) => {
|
|
736
|
+
const min = minWidths[index] ?? 0;
|
|
737
|
+
const max = maxWidths[index];
|
|
456
738
|
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
739
|
+
if (max === undefined) {
|
|
740
|
+
return Math.max(min, width);
|
|
741
|
+
}
|
|
460
742
|
|
|
461
|
-
|
|
462
|
-
|
|
743
|
+
return clamp(Math.max(min, width), min, max);
|
|
744
|
+
}),
|
|
745
|
+
overflowBeforeShrink,
|
|
746
|
+
};
|
|
463
747
|
};
|
|
464
748
|
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
749
|
+
const normalizeColumns = (
|
|
750
|
+
columns: ReadonlyArray<ProgressColumn | string>,
|
|
751
|
+
): ReadonlyArray<ProgressColumn> => {
|
|
752
|
+
const resolved = columns.length > 0 ? columns : Columns.defaults();
|
|
469
753
|
|
|
470
|
-
|
|
471
|
-
|
|
754
|
+
return resolved.map((entry, index) => {
|
|
755
|
+
const normalized = typeof entry === "string" ? LiteralColumn.make(entry) : entry;
|
|
472
756
|
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
757
|
+
if (
|
|
758
|
+
typeof normalized !== "object" ||
|
|
759
|
+
normalized === null ||
|
|
760
|
+
typeof normalized.render !== "function"
|
|
761
|
+
) {
|
|
762
|
+
throw new Error(`Invalid progress column at index ${index}`);
|
|
476
763
|
}
|
|
477
|
-
}
|
|
478
764
|
|
|
479
|
-
|
|
480
|
-
};
|
|
481
|
-
|
|
482
|
-
const fromCharacterTokens = (tokens: ReadonlyArray<CharacterToken>): ReadonlyArray<Segment> => {
|
|
483
|
-
if (tokens.length === 0) {
|
|
484
|
-
return [];
|
|
485
|
-
}
|
|
486
|
-
|
|
487
|
-
const segments: Array<Segment> = [];
|
|
488
|
-
let currentRole = tokens[0]!.role;
|
|
489
|
-
let buffer = "";
|
|
490
|
-
|
|
491
|
-
for (const token of tokens) {
|
|
492
|
-
if (token.role !== currentRole) {
|
|
493
|
-
segments.push(createSegment(buffer, currentRole));
|
|
494
|
-
buffer = token.char;
|
|
495
|
-
currentRole = token.role;
|
|
496
|
-
continue;
|
|
765
|
+
if (typeof normalized.id !== "string" || normalized.id.length === 0) {
|
|
766
|
+
throw new Error(`Progress column at index ${index} is missing a valid id`);
|
|
497
767
|
}
|
|
498
768
|
|
|
499
|
-
|
|
500
|
-
}
|
|
501
|
-
|
|
502
|
-
if (buffer.length > 0) {
|
|
503
|
-
segments.push(createSegment(buffer, currentRole));
|
|
504
|
-
}
|
|
505
|
-
|
|
506
|
-
return segments;
|
|
769
|
+
return normalized;
|
|
770
|
+
});
|
|
507
771
|
};
|
|
508
772
|
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
): ReadonlyArray<Segment> => {
|
|
514
|
-
// Segment fitting is role-preserving: truncate by character tokens,
|
|
515
|
-
// optionally append ellipsis, then pad with plain-space tokens.
|
|
516
|
-
const target = Math.max(0, Math.floor(width));
|
|
517
|
-
if (target <= 0) {
|
|
518
|
-
return [];
|
|
519
|
-
}
|
|
520
|
-
|
|
521
|
-
const chars = toCharacterTokens(segments);
|
|
522
|
-
|
|
523
|
-
const truncatedChars = (() => {
|
|
524
|
-
if (chars.length <= target) {
|
|
525
|
-
return chars;
|
|
526
|
-
}
|
|
527
|
-
|
|
528
|
-
if (wrapMode === "no-wrap-ellipsis") {
|
|
529
|
-
if (target === 1) {
|
|
530
|
-
return [{ char: "…", role: chars[0]?.role ?? "plain" }];
|
|
531
|
-
}
|
|
532
|
-
|
|
533
|
-
const keep = chars.slice(0, Math.max(0, target - 1));
|
|
534
|
-
const ellipsisRole = keep[keep.length - 1]?.role ?? chars[0]?.role ?? "plain";
|
|
535
|
-
return [...keep, { char: "…", role: ellipsisRole }];
|
|
536
|
-
}
|
|
773
|
+
interface OrderedTaskModel {
|
|
774
|
+
readonly snapshot: TaskSnapshot;
|
|
775
|
+
readonly depth: number;
|
|
776
|
+
}
|
|
537
777
|
|
|
538
|
-
|
|
539
|
-
|
|
778
|
+
interface RenderedFrame {
|
|
779
|
+
readonly lines: ReadonlyArray<string>;
|
|
780
|
+
readonly lineByTaskId: ReadonlyMap<number, string>;
|
|
781
|
+
}
|
|
540
782
|
|
|
541
|
-
|
|
542
|
-
|
|
783
|
+
const fallbackVariantForColumn = (column: ProgressColumn): ProgressColumnVariant => ({
|
|
784
|
+
measure: column.measure === undefined ? undefined : (context) => column.measure?.(context) ?? 0,
|
|
785
|
+
render: (context, width) => column.render(context, width),
|
|
786
|
+
});
|
|
543
787
|
|
|
544
|
-
|
|
545
|
-
|
|
788
|
+
const resolveVariantForLevel = (
|
|
789
|
+
column: ProgressColumn,
|
|
790
|
+
context: ProgressColumnContext,
|
|
791
|
+
level: number,
|
|
792
|
+
): ProgressColumnVariant => {
|
|
793
|
+
const variants = column.variants?.(context);
|
|
794
|
+
if (variants === undefined || variants.length === 0) {
|
|
795
|
+
return fallbackVariantForColumn(column);
|
|
546
796
|
}
|
|
547
797
|
|
|
548
|
-
return [
|
|
798
|
+
return variants[Math.min(level, variants.length - 1)]!;
|
|
549
799
|
};
|
|
550
800
|
|
|
551
|
-
const
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
const
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
801
|
+
const isDescriptionColumn = (column: ProgressColumn): boolean =>
|
|
802
|
+
column instanceof DescriptionColumn || column.id === "description";
|
|
803
|
+
|
|
804
|
+
const isEtaColumn = (column: ProgressColumn): boolean =>
|
|
805
|
+
column instanceof EtaColumn || column.id === "eta";
|
|
806
|
+
|
|
807
|
+
const isBarColumn = (column: ProgressColumn): boolean =>
|
|
808
|
+
column instanceof BarColumn || column.id === "bar";
|
|
809
|
+
|
|
810
|
+
const renderTaskFrame = (
|
|
811
|
+
orderedTasks: ReadonlyArray<OrderedTaskModel>,
|
|
812
|
+
columns: ReadonlyArray<ProgressColumn>,
|
|
813
|
+
rendererConfig: RendererConfigShape,
|
|
814
|
+
now: number,
|
|
815
|
+
tick: number,
|
|
816
|
+
terminalColumns: number | undefined,
|
|
817
|
+
isTTY: boolean,
|
|
818
|
+
): RenderedFrame => {
|
|
819
|
+
if (orderedTasks.length === 0) {
|
|
820
|
+
return {
|
|
821
|
+
lines: [],
|
|
822
|
+
lineByTaskId: new Map(),
|
|
823
|
+
};
|
|
568
824
|
}
|
|
569
825
|
|
|
570
|
-
|
|
571
|
-
}
|
|
572
|
-
|
|
573
|
-
const computeTreeInfo = (ordered: ReadonlyArray<{ snapshot: TaskSnapshot; depth: number }>) => {
|
|
574
|
-
// Precompute sibling/ancestor relationships so connector rendering is
|
|
575
|
-
// deterministic and independent from column layout decisions.
|
|
576
|
-
const hasNextSiblingByIndex: Array<boolean> = Array.from({ length: ordered.length }, () => false);
|
|
826
|
+
const orderedWithTree = computeTreeInfo(
|
|
827
|
+
orderedTasks.map((entry) => ({ snapshot: entry.snapshot, depth: entry.depth })),
|
|
828
|
+
);
|
|
577
829
|
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
830
|
+
const contexts = orderedWithTree.map<ProgressColumnContext>((entry, index) => ({
|
|
831
|
+
task: orderedTasks[index]!.snapshot,
|
|
832
|
+
depth: orderedTasks[index]!.depth,
|
|
833
|
+
tree: entry.tree,
|
|
834
|
+
now,
|
|
835
|
+
tick,
|
|
836
|
+
isTTY,
|
|
837
|
+
}));
|
|
838
|
+
|
|
839
|
+
const gap = Math.max(0, Math.floor(rendererConfig.columnGap ?? 1));
|
|
840
|
+
const totalWidth = resolveTotalWidth(terminalColumns, rendererConfig.width);
|
|
841
|
+
const maxVariantLevelByColumn = columns.map((column) => {
|
|
842
|
+
let maxLevel = 0;
|
|
843
|
+
for (const context of contexts) {
|
|
844
|
+
const variants = column.variants?.(context);
|
|
845
|
+
if (variants !== undefined && variants.length > 0) {
|
|
846
|
+
maxLevel = Math.max(maxLevel, variants.length - 1);
|
|
588
847
|
}
|
|
589
848
|
}
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
const ancestorStateByDepth: Array<boolean> = [];
|
|
593
|
-
|
|
594
|
-
return ordered.map((entry, index) => {
|
|
595
|
-
const depth = entry.depth;
|
|
596
|
-
ancestorStateByDepth.length = depth;
|
|
597
|
-
|
|
598
|
-
const hasChildren =
|
|
599
|
-
index + 1 < ordered.length &&
|
|
600
|
-
ordered[index + 1] !== undefined &&
|
|
601
|
-
ordered[index + 1]!.depth > depth;
|
|
602
|
-
|
|
603
|
-
const tree: TaskTreeInfo = {
|
|
604
|
-
depth,
|
|
605
|
-
hasNextSibling: hasNextSiblingByIndex[index] ?? false,
|
|
606
|
-
hasChildren,
|
|
607
|
-
ancestorHasNextSibling: [...ancestorStateByDepth],
|
|
608
|
-
};
|
|
609
|
-
|
|
610
|
-
ancestorStateByDepth[depth] = hasNextSiblingByIndex[index] ?? false;
|
|
611
|
-
|
|
612
|
-
return {
|
|
613
|
-
...entry,
|
|
614
|
-
tree,
|
|
615
|
-
};
|
|
849
|
+
return maxLevel;
|
|
616
850
|
});
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
const
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
const
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
segments: [createSegment(text, "text")],
|
|
635
|
-
});
|
|
636
|
-
|
|
637
|
-
const spinnerCell = (snapshot: TaskSnapshot, tick: number): CellModel => {
|
|
638
|
-
const frames = snapshot.config.spinnerFrames;
|
|
639
|
-
const frameIndex =
|
|
640
|
-
snapshot.units._tag === "IndeterminateTaskUnits"
|
|
641
|
-
? (snapshot.units.spinnerFrame + tick) % frames.length
|
|
642
|
-
: 0;
|
|
643
|
-
const frame = frames[frameIndex] ?? frames[0] ?? "";
|
|
644
|
-
|
|
645
|
-
return {
|
|
646
|
-
id: "spinner",
|
|
647
|
-
track: Track.auto(),
|
|
648
|
-
wrapMode: "truncate",
|
|
649
|
-
collapsePriority: 70,
|
|
650
|
-
minWidth: 1,
|
|
651
|
-
segments: [createSegment(frame, "spinner")],
|
|
652
|
-
};
|
|
653
|
-
};
|
|
654
|
-
|
|
655
|
-
const barCell = (snapshot: TaskSnapshot): CellModel => {
|
|
656
|
-
if (snapshot.units._tag !== "DeterminateTaskUnits") {
|
|
657
|
-
return {
|
|
658
|
-
id: "bar",
|
|
659
|
-
segments: [],
|
|
660
|
-
};
|
|
661
|
-
}
|
|
662
|
-
|
|
663
|
-
const { config } = snapshot;
|
|
664
|
-
const units = snapshot.units;
|
|
665
|
-
const bracketWidth = textWidth(config.leftBracket) + textWidth(config.rightBracket);
|
|
666
|
-
const preferredInnerWidth = Math.max(1, config.barWidth);
|
|
667
|
-
const intrinsicWidth = bracketWidth + preferredInnerWidth;
|
|
668
|
-
|
|
669
|
-
return {
|
|
670
|
-
id: "bar",
|
|
671
|
-
track: Track.auto(),
|
|
672
|
-
minWidth: Math.max(1, bracketWidth + 1),
|
|
673
|
-
wrapMode: "truncate",
|
|
674
|
-
collapsePriority: 10,
|
|
675
|
-
intrinsicWidth,
|
|
676
|
-
segments: [],
|
|
677
|
-
renderAtWidth: (width) => {
|
|
678
|
-
const targetWidth = Math.max(1, Math.floor(width));
|
|
679
|
-
const safeTotal = units.total <= 0 ? 1 : units.total;
|
|
680
|
-
const ratio = Math.min(1, Math.max(0, units.completed / safeTotal));
|
|
681
|
-
|
|
682
|
-
const resolvedInnerWidth = Math.max(1, targetWidth - bracketWidth);
|
|
683
|
-
const clampedRatio = snapshot.status === "done" ? 1 : ratio;
|
|
684
|
-
const filled = Math.round(clampedRatio * resolvedInnerWidth);
|
|
685
|
-
|
|
686
|
-
const fillRole: ThemeRole =
|
|
687
|
-
snapshot.status === "failed"
|
|
688
|
-
? "statusFailed"
|
|
689
|
-
: snapshot.status === "done"
|
|
690
|
-
? "statusDone"
|
|
691
|
-
: "barFill";
|
|
692
|
-
|
|
693
|
-
const emptyRole: ThemeRole = snapshot.status === "failed" ? "statusFailed" : "barEmpty";
|
|
694
|
-
|
|
695
|
-
return [
|
|
696
|
-
createSegment(config.leftBracket, "barBracket"),
|
|
697
|
-
createSegment(config.fillChar.repeat(filled), fillRole),
|
|
698
|
-
createSegment(config.emptyChar.repeat(Math.max(0, resolvedInnerWidth - filled)), emptyRole),
|
|
699
|
-
createSegment(config.rightBracket, "barBracket"),
|
|
700
|
-
];
|
|
701
|
-
},
|
|
702
|
-
};
|
|
703
|
-
};
|
|
704
|
-
|
|
705
|
-
const unitsCell = (snapshot: TaskSnapshot): CellModel => ({
|
|
706
|
-
id: "units",
|
|
707
|
-
track: Track.auto(),
|
|
708
|
-
minWidth: 3,
|
|
709
|
-
wrapMode: "truncate",
|
|
710
|
-
collapsePriority: 40,
|
|
711
|
-
segments:
|
|
712
|
-
snapshot.units._tag === "DeterminateTaskUnits"
|
|
713
|
-
? [createSegment(`${snapshot.units.completed}/${snapshot.units.total}`, "units")]
|
|
714
|
-
: [],
|
|
715
|
-
});
|
|
716
|
-
|
|
717
|
-
const etaCell = (snapshot: TaskSnapshot, now: number): CellModel => ({
|
|
718
|
-
id: "eta",
|
|
719
|
-
track: Track.auto(),
|
|
720
|
-
minWidth: 4,
|
|
721
|
-
wrapMode: "truncate",
|
|
722
|
-
collapsePriority: 20,
|
|
723
|
-
segments: [createSegment(formatEta(snapshot, now), "eta")],
|
|
724
|
-
});
|
|
851
|
+
const variantLevelByColumn = columns.map(() => 0);
|
|
852
|
+
|
|
853
|
+
const resolveLayoutForVariants = (levels: ReadonlyArray<number>) => {
|
|
854
|
+
const intrinsicByColumn: Array<number> = Array.from({ length: columns.length }, () => 0);
|
|
855
|
+
const hasContentByColumn: Array<boolean> = Array.from({ length: columns.length }, () => false);
|
|
856
|
+
|
|
857
|
+
for (let col = 0; col < columns.length; col++) {
|
|
858
|
+
const column = columns[col]!;
|
|
859
|
+
const level = levels[col] ?? 0;
|
|
860
|
+
|
|
861
|
+
for (const context of contexts) {
|
|
862
|
+
const variant = resolveVariantForLevel(column, context, level);
|
|
863
|
+
const preview = `${variant.render(context, PREVIEW_RENDER_WIDTH)}`;
|
|
864
|
+
const previewWidth = visibleWidth(preview);
|
|
865
|
+
if (previewWidth > 0) {
|
|
866
|
+
hasContentByColumn[col] = true;
|
|
867
|
+
}
|
|
725
868
|
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
}
|
|
869
|
+
if (typeof variant.measure === "function") {
|
|
870
|
+
const measured = Number(variant.measure(context));
|
|
871
|
+
if (Number.isFinite(measured)) {
|
|
872
|
+
intrinsicByColumn[col] = Math.max(
|
|
873
|
+
intrinsicByColumn[col] ?? 0,
|
|
874
|
+
Math.max(0, Math.floor(measured)),
|
|
875
|
+
);
|
|
876
|
+
}
|
|
877
|
+
} else {
|
|
878
|
+
intrinsicByColumn[col] = Math.max(intrinsicByColumn[col] ?? 0, previewWidth);
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
}
|
|
734
882
|
|
|
735
|
-
const
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
wrapMode: "truncate",
|
|
742
|
-
collapsePriority: 60,
|
|
743
|
-
segments: [createSegment("done", "statusDone")],
|
|
744
|
-
};
|
|
745
|
-
}
|
|
883
|
+
const activeColumnIndexes = columns
|
|
884
|
+
.map((_column, index) => index)
|
|
885
|
+
.filter((index) => hasContentByColumn[index] ?? false);
|
|
886
|
+
const activeColumns = activeColumnIndexes.map((index) => columns[index]!);
|
|
887
|
+
const intrinsicWidths = activeColumnIndexes.map((index) => intrinsicByColumn[index] ?? 0);
|
|
888
|
+
const widthResolution = resolveColumnWidths(activeColumns, intrinsicWidths, totalWidth, gap);
|
|
746
889
|
|
|
747
|
-
if (snapshot.status === "failed") {
|
|
748
890
|
return {
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
segments: [createSegment("[failed]", "statusFailed")],
|
|
891
|
+
intrinsicByColumn,
|
|
892
|
+
activeColumnIndexes,
|
|
893
|
+
activeColumns,
|
|
894
|
+
widths: widthResolution.widths,
|
|
895
|
+
overflowBeforeShrink: widthResolution.overflowBeforeShrink,
|
|
755
896
|
};
|
|
756
|
-
}
|
|
757
|
-
|
|
758
|
-
return {
|
|
759
|
-
id: "status",
|
|
760
|
-
segments: [],
|
|
761
897
|
};
|
|
762
|
-
};
|
|
763
898
|
|
|
764
|
-
|
|
765
|
-
buildFrame: ({ orderedTasks, rendererConfig, now, tick }) => {
|
|
766
|
-
// Build stage emits semantic rows only. No ANSI and no width trimming here.
|
|
767
|
-
const orderedWithTree = computeTreeInfo(
|
|
768
|
-
orderedTasks.map((entry) => ({ snapshot: entry.snapshot, depth: entry.depth })),
|
|
769
|
-
);
|
|
899
|
+
let layout = resolveLayoutForVariants(variantLevelByColumn);
|
|
770
900
|
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
const
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
const runningOrDoneDeterminate =
|
|
780
|
-
isDeterminate && (snapshot.status === "running" || snapshot.status === "done");
|
|
901
|
+
if (totalWidth !== undefined) {
|
|
902
|
+
while (true) {
|
|
903
|
+
const hasCompressedColumns = layout.activeColumnIndexes.some((columnIndex, activeIndex) => {
|
|
904
|
+
const assignedWidth = layout.widths[activeIndex] ?? 0;
|
|
905
|
+
const intrinsicWidth = layout.intrinsicByColumn[columnIndex] ?? 0;
|
|
906
|
+
return assignedWidth < intrinsicWidth;
|
|
907
|
+
});
|
|
781
908
|
|
|
782
|
-
|
|
909
|
+
if (layout.overflowBeforeShrink <= 0 && !hasCompressedColumns) {
|
|
910
|
+
break;
|
|
911
|
+
}
|
|
783
912
|
|
|
784
|
-
const
|
|
913
|
+
const hasPendingEtaCompaction = columns.some(
|
|
914
|
+
(column, index) =>
|
|
915
|
+
isEtaColumn(column) &&
|
|
916
|
+
(variantLevelByColumn[index] ?? 0) < (maxVariantLevelByColumn[index] ?? 0),
|
|
917
|
+
);
|
|
918
|
+
const activeBarWidths = layout.activeColumnIndexes.flatMap((columnIndex, activeIndex) =>
|
|
919
|
+
isBarColumn(columns[columnIndex]!) ? [layout.widths[activeIndex] ?? 0] : [],
|
|
920
|
+
);
|
|
921
|
+
const barsAreTightEnoughForTreeCollapse = activeBarWidths.every(
|
|
922
|
+
(width) => width <= TREE_PREFIX_COLLAPSE_BAR_WIDTH,
|
|
923
|
+
);
|
|
785
924
|
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
925
|
+
const candidates = columns
|
|
926
|
+
.map((_column, index) => index)
|
|
927
|
+
.filter(
|
|
928
|
+
(index) => (variantLevelByColumn[index] ?? 0) < (maxVariantLevelByColumn[index] ?? 0),
|
|
929
|
+
)
|
|
930
|
+
.map((index) => {
|
|
931
|
+
const column = columns[index]!;
|
|
932
|
+
if (
|
|
933
|
+
isDescriptionColumn(column) &&
|
|
934
|
+
(hasPendingEtaCompaction || !barsAreTightEnoughForTreeCollapse)
|
|
935
|
+
) {
|
|
936
|
+
return undefined;
|
|
937
|
+
}
|
|
792
938
|
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
}
|
|
814
|
-
} else if (determinateFailure) {
|
|
815
|
-
if (showTwoLineDeterminate) {
|
|
816
|
-
rows.push({
|
|
817
|
-
lineVariant: "lead",
|
|
818
|
-
cells: [treeCell(tree, "lead"), textCell(snapshot.description)],
|
|
819
|
-
});
|
|
820
|
-
rows.push({
|
|
821
|
-
lineVariant: "continuation",
|
|
822
|
-
cells: [
|
|
823
|
-
treeCell(tree, "continuation"),
|
|
824
|
-
barCell(snapshot),
|
|
825
|
-
unitsCell(snapshot),
|
|
826
|
-
statusCell(snapshot),
|
|
827
|
-
elapsedCell(snapshot, now),
|
|
828
|
-
],
|
|
829
|
-
});
|
|
830
|
-
} else {
|
|
831
|
-
rows.push({
|
|
832
|
-
lineVariant: "lead",
|
|
833
|
-
cells: [
|
|
834
|
-
treeCell(tree, "lead"),
|
|
835
|
-
textCell(snapshot.description),
|
|
836
|
-
barCell(snapshot),
|
|
837
|
-
unitsCell(snapshot),
|
|
838
|
-
statusCell(snapshot),
|
|
839
|
-
elapsedCell(snapshot, now),
|
|
840
|
-
],
|
|
841
|
-
});
|
|
842
|
-
}
|
|
843
|
-
} else if (snapshot.status === "running") {
|
|
844
|
-
rows.push({
|
|
845
|
-
lineVariant: "lead",
|
|
846
|
-
cells: [
|
|
847
|
-
treeCell(tree, "lead"),
|
|
848
|
-
spinnerCell(snapshot, tick),
|
|
849
|
-
textCell(snapshot.description),
|
|
850
|
-
elapsedCell(snapshot, now),
|
|
851
|
-
],
|
|
852
|
-
});
|
|
853
|
-
} else {
|
|
854
|
-
rows.push({
|
|
855
|
-
lineVariant: "lead",
|
|
856
|
-
cells: [
|
|
857
|
-
treeCell(tree, "lead"),
|
|
858
|
-
textCell(snapshot.description),
|
|
859
|
-
statusCell(snapshot),
|
|
860
|
-
elapsedCell(snapshot, now),
|
|
861
|
-
],
|
|
862
|
-
});
|
|
939
|
+
const currentIntrinsic = layout.intrinsicByColumn[index] ?? 0;
|
|
940
|
+
const trialLevels = [...variantLevelByColumn];
|
|
941
|
+
trialLevels[index] = (trialLevels[index] ?? 0) + 1;
|
|
942
|
+
const nextLayout = resolveLayoutForVariants(trialLevels);
|
|
943
|
+
const nextIntrinsic = nextLayout.intrinsicByColumn[index] ?? 0;
|
|
944
|
+
return {
|
|
945
|
+
index,
|
|
946
|
+
reduction: Math.max(0, currentIntrinsic - nextIntrinsic),
|
|
947
|
+
priority: column.collapsePriority ?? Number.MAX_SAFE_INTEGER,
|
|
948
|
+
};
|
|
949
|
+
})
|
|
950
|
+
.filter(
|
|
951
|
+
(candidate): candidate is { index: number; reduction: number; priority: number } =>
|
|
952
|
+
candidate !== undefined,
|
|
953
|
+
)
|
|
954
|
+
.sort((a, b) => b.reduction - a.reduction || a.priority - b.priority || a.index - b.index);
|
|
955
|
+
|
|
956
|
+
const best = candidates[0];
|
|
957
|
+
if (best === undefined) {
|
|
958
|
+
break;
|
|
863
959
|
}
|
|
864
960
|
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
rows,
|
|
870
|
-
};
|
|
871
|
-
});
|
|
961
|
+
variantLevelByColumn[best.index] = (variantLevelByColumn[best.index] ?? 0) + 1;
|
|
962
|
+
layout = resolveLayoutForVariants(variantLevelByColumn);
|
|
963
|
+
}
|
|
964
|
+
}
|
|
872
965
|
|
|
966
|
+
if (layout.activeColumnIndexes.length === 0) {
|
|
967
|
+
const emptyLines = orderedTasks.map(() => "");
|
|
873
968
|
return {
|
|
874
|
-
|
|
969
|
+
lines: emptyLines,
|
|
970
|
+
lineByTaskId: new Map(orderedTasks.map((entry) => [entry.snapshot.id as number, ""])),
|
|
875
971
|
};
|
|
876
|
-
}
|
|
877
|
-
};
|
|
878
|
-
|
|
879
|
-
const defaultShrinkStageService: ShrinkStageService = {
|
|
880
|
-
fitFrame: ({ frame, width }) => {
|
|
881
|
-
// Convert logical rows into concrete widths and fitted segments.
|
|
882
|
-
const totalWidth = resolveTotalWidth(width);
|
|
883
|
-
|
|
884
|
-
return {
|
|
885
|
-
taskBlocks: frame.taskBlocks.map((block) => ({
|
|
886
|
-
taskId: block.taskId,
|
|
887
|
-
rows: block.rows.map((row) => {
|
|
888
|
-
const gap = Math.max(0, Math.floor(row.gap ?? 1));
|
|
889
|
-
const widths = resolveRowWidths(row, totalWidth);
|
|
972
|
+
}
|
|
890
973
|
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
})),
|
|
910
|
-
};
|
|
911
|
-
},
|
|
912
|
-
};
|
|
974
|
+
const gapText = " ".repeat(gap);
|
|
975
|
+
const lines = contexts.map((context) =>
|
|
976
|
+
layout.activeColumns
|
|
977
|
+
.map((column, index) => {
|
|
978
|
+
const originalColumnIndex = layout.activeColumnIndexes[index]!;
|
|
979
|
+
const width = layout.widths[index] ?? 0;
|
|
980
|
+
const variant = resolveVariantForLevel(
|
|
981
|
+
column,
|
|
982
|
+
context,
|
|
983
|
+
variantLevelByColumn[originalColumnIndex] ?? 0,
|
|
984
|
+
);
|
|
985
|
+
const raw = `${variant.render(context, width)}`;
|
|
986
|
+
const wrapMode = columns[originalColumnIndex]!.wrapMode ?? "truncate";
|
|
987
|
+
return fitRenderedText(raw, width, wrapMode, isTTY);
|
|
988
|
+
})
|
|
989
|
+
.join(gapText)
|
|
990
|
+
.trimEnd(),
|
|
991
|
+
);
|
|
913
992
|
|
|
914
|
-
const
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
return byDepth(segment.text);
|
|
993
|
+
const lineByTaskId = new Map<number, string>();
|
|
994
|
+
for (let i = 0; i < orderedTasks.length; i++) {
|
|
995
|
+
lineByTaskId.set(orderedTasks[i]!.snapshot.id as number, lines[i] ?? "");
|
|
918
996
|
}
|
|
919
997
|
|
|
920
|
-
|
|
921
|
-
|
|
998
|
+
return {
|
|
999
|
+
lines,
|
|
1000
|
+
lineByTaskId,
|
|
1001
|
+
};
|
|
922
1002
|
};
|
|
923
1003
|
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
),
|
|
937
|
-
};
|
|
1004
|
+
export interface FrameRendererService {
|
|
1005
|
+
readonly run: (
|
|
1006
|
+
storeRef: Ref.Ref<TaskStore>,
|
|
1007
|
+
logsRef: Ref.Ref<ReadonlyArray<string>>,
|
|
1008
|
+
pendingLogsRef: Ref.Ref<ReadonlyArray<string>>,
|
|
1009
|
+
dirtyRef: Ref.Ref<boolean>,
|
|
1010
|
+
terminal: ProgressTerminalService,
|
|
1011
|
+
isTTY: boolean,
|
|
1012
|
+
rendererConfig: RendererConfigShape,
|
|
1013
|
+
maxRetainedLogLines: number,
|
|
1014
|
+
) => Effect.Effect<void>;
|
|
1015
|
+
}
|
|
938
1016
|
|
|
939
|
-
const makeDefaultFrameRenderer = (
|
|
940
|
-
|
|
941
|
-
shrinkStage: ShrinkStageService,
|
|
942
|
-
colorStage: ColorStageService,
|
|
943
|
-
fallbackTheme: ThemeService,
|
|
944
|
-
): FrameRendererService => ({
|
|
945
|
-
run: ({
|
|
1017
|
+
const makeDefaultFrameRenderer = (): FrameRendererService => ({
|
|
1018
|
+
run: (
|
|
946
1019
|
storeRef,
|
|
947
1020
|
logsRef,
|
|
948
1021
|
pendingLogsRef,
|
|
@@ -951,11 +1024,10 @@ const makeDefaultFrameRenderer = (
|
|
|
951
1024
|
isTTY,
|
|
952
1025
|
rendererConfig,
|
|
953
1026
|
maxRetainedLogLines,
|
|
954
|
-
|
|
1027
|
+
) =>
|
|
955
1028
|
Effect.gen(function* () {
|
|
956
|
-
// The frame renderer owns terminal state (cursor/session), drives tick
|
|
957
|
-
// updates, and coordinates build -> shrink -> color for each frame.
|
|
958
1029
|
const retainLogHistory = maxRetainedLogLines > 0;
|
|
1030
|
+
const compiledColumns = normalizeColumns(rendererConfig.columns);
|
|
959
1031
|
let previousLineCount = 0;
|
|
960
1032
|
let nonTTYTaskSignatureById = new Map<number, string>();
|
|
961
1033
|
let tick = 0;
|
|
@@ -999,7 +1071,7 @@ const makeDefaultFrameRenderer = (
|
|
|
999
1071
|
return;
|
|
1000
1072
|
}
|
|
1001
1073
|
|
|
1002
|
-
yield* terminal.writeStderr(
|
|
1074
|
+
yield* terminal.writeStderr(`\n${SHOW_CURSOR}`);
|
|
1003
1075
|
previousLineCount = 0;
|
|
1004
1076
|
sessionActive = false;
|
|
1005
1077
|
});
|
|
@@ -1007,7 +1079,7 @@ const makeDefaultFrameRenderer = (
|
|
|
1007
1079
|
const renderNonTTYTaskUpdates = (
|
|
1008
1080
|
ordered: ReadonlyArray<{
|
|
1009
1081
|
snapshot: TaskSnapshot;
|
|
1010
|
-
|
|
1082
|
+
line: string;
|
|
1011
1083
|
}>,
|
|
1012
1084
|
) => {
|
|
1013
1085
|
const nextTaskSignatureById = new Map<number, string>();
|
|
@@ -1024,13 +1096,16 @@ const makeDefaultFrameRenderer = (
|
|
|
1024
1096
|
|
|
1025
1097
|
nextTaskSignatureById.set(taskId, signature);
|
|
1026
1098
|
if (nonTTYTaskSignatureById.get(taskId) !== signature) {
|
|
1027
|
-
|
|
1099
|
+
const line = ordered[i]!.line;
|
|
1100
|
+
if (line.length > 0) {
|
|
1101
|
+
changedTaskLines.push(line);
|
|
1102
|
+
}
|
|
1028
1103
|
}
|
|
1029
1104
|
}
|
|
1030
1105
|
|
|
1031
1106
|
return Effect.gen(function* () {
|
|
1032
1107
|
if (changedTaskLines.length > 0) {
|
|
1033
|
-
yield* terminal.writeStderr(changedTaskLines.join("\n")
|
|
1108
|
+
yield* terminal.writeStderr(`${changedTaskLines.join("\n")}\n`);
|
|
1034
1109
|
}
|
|
1035
1110
|
|
|
1036
1111
|
nonTTYTaskSignatureById = nextTaskSignatureById;
|
|
@@ -1039,7 +1114,6 @@ const makeDefaultFrameRenderer = (
|
|
|
1039
1114
|
|
|
1040
1115
|
const renderFrame = (mode: "tick" | "final") =>
|
|
1041
1116
|
Effect.gen(function* () {
|
|
1042
|
-
// Materialize one frame snapshot from current state.
|
|
1043
1117
|
const drainedLogs = yield* Ref.getAndSet(pendingLogsRef, []);
|
|
1044
1118
|
const store = yield* Ref.get(storeRef);
|
|
1045
1119
|
const orderedTasks = store.renderOrder.flatMap((row) => {
|
|
@@ -1052,7 +1126,6 @@ const makeDefaultFrameRenderer = (
|
|
|
1052
1126
|
{
|
|
1053
1127
|
snapshot,
|
|
1054
1128
|
depth: row.depth,
|
|
1055
|
-
theme: store.themes.get(snapshot.id) ?? fallbackTheme,
|
|
1056
1129
|
},
|
|
1057
1130
|
];
|
|
1058
1131
|
});
|
|
@@ -1060,38 +1133,22 @@ const makeDefaultFrameRenderer = (
|
|
|
1060
1133
|
const now = yield* Clock.currentTimeMillis;
|
|
1061
1134
|
const frameTick = mode === "final" ? tick + 1 : tick;
|
|
1062
1135
|
const terminalColumns = isTTY ? yield* terminal.stderrColumns : undefined;
|
|
1063
|
-
const maxTaskWidth = rendererConfig.maxTaskWidth;
|
|
1064
1136
|
|
|
1065
|
-
const
|
|
1137
|
+
const renderedFrame = renderTaskFrame(
|
|
1066
1138
|
orderedTasks,
|
|
1139
|
+
compiledColumns,
|
|
1067
1140
|
rendererConfig,
|
|
1068
1141
|
now,
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
frame: frameModel,
|
|
1074
|
-
width: {
|
|
1075
|
-
terminalColumns,
|
|
1076
|
-
maxTaskWidth,
|
|
1077
|
-
},
|
|
1078
|
-
});
|
|
1079
|
-
|
|
1080
|
-
const taskLines = colorStage.colorFrame({ frame: fittedFrame });
|
|
1081
|
-
|
|
1082
|
-
const taskLineMap = new Map<number, ReadonlyArray<string>>();
|
|
1083
|
-
let lineCursor = 0;
|
|
1084
|
-
for (const block of fittedFrame.taskBlocks) {
|
|
1085
|
-
const lineCount = block.rows.length;
|
|
1086
|
-
taskLineMap.set(block.taskId, taskLines.slice(lineCursor, lineCursor + lineCount));
|
|
1087
|
-
lineCursor += lineCount;
|
|
1088
|
-
}
|
|
1142
|
+
isTTY ? frameTick : 0,
|
|
1143
|
+
terminalColumns,
|
|
1144
|
+
isTTY,
|
|
1145
|
+
);
|
|
1089
1146
|
|
|
1090
1147
|
if (isTTY) {
|
|
1091
1148
|
let frame = "";
|
|
1092
1149
|
|
|
1093
1150
|
if (previousLineCount > 0) {
|
|
1094
|
-
frame +=
|
|
1151
|
+
frame += `\r${CLEAR_LINE}`;
|
|
1095
1152
|
for (let i = 1; i < previousLineCount; i++) {
|
|
1096
1153
|
frame += MOVE_UP_ONE + CLEAR_LINE;
|
|
1097
1154
|
}
|
|
@@ -1099,19 +1156,19 @@ const makeDefaultFrameRenderer = (
|
|
|
1099
1156
|
|
|
1100
1157
|
if (retainLogHistory) {
|
|
1101
1158
|
const historyLogs = yield* Ref.get(logsRef);
|
|
1102
|
-
const lines = yield* clipTTYFrameLines([...historyLogs, ...
|
|
1159
|
+
const lines = yield* clipTTYFrameLines([...historyLogs, ...renderedFrame.lines]);
|
|
1103
1160
|
if (lines.length > 0) {
|
|
1104
1161
|
frame += lines.join("\n");
|
|
1105
1162
|
}
|
|
1106
1163
|
previousLineCount = lines.length;
|
|
1107
1164
|
} else {
|
|
1108
1165
|
if (drainedLogs.length > 0) {
|
|
1109
|
-
frame += drainedLogs.join("\n")
|
|
1166
|
+
frame += `${drainedLogs.join("\n")}\n`;
|
|
1110
1167
|
}
|
|
1111
|
-
if (
|
|
1112
|
-
frame +=
|
|
1168
|
+
if (renderedFrame.lines.length > 0) {
|
|
1169
|
+
frame += renderedFrame.lines.join("\n");
|
|
1113
1170
|
}
|
|
1114
|
-
previousLineCount =
|
|
1171
|
+
previousLineCount = renderedFrame.lines.length;
|
|
1115
1172
|
}
|
|
1116
1173
|
|
|
1117
1174
|
if (frame) {
|
|
@@ -1121,19 +1178,17 @@ const makeDefaultFrameRenderer = (
|
|
|
1121
1178
|
}
|
|
1122
1179
|
|
|
1123
1180
|
if (drainedLogs.length > 0) {
|
|
1124
|
-
yield* terminal.writeStderr(drainedLogs.join("\n")
|
|
1181
|
+
yield* terminal.writeStderr(`${drainedLogs.join("\n")}\n`);
|
|
1125
1182
|
}
|
|
1126
1183
|
|
|
1127
1184
|
const orderedForNonTTY = orderedTasks.map((task) => ({
|
|
1128
1185
|
snapshot: task.snapshot,
|
|
1129
|
-
|
|
1186
|
+
line: renderedFrame.lineByTaskId.get(task.snapshot.id as number) ?? "",
|
|
1130
1187
|
}));
|
|
1131
1188
|
yield* renderNonTTYTaskUpdates(orderedForNonTTY);
|
|
1132
1189
|
});
|
|
1133
1190
|
|
|
1134
1191
|
const renderLoop = Effect.gen(function* () {
|
|
1135
|
-
// Tick loop: render eagerly in TTY mode for spinner animation, and
|
|
1136
|
-
// render opportunistically in non-TTY mode based on signatures/dirty bit.
|
|
1137
1192
|
rendererActive = true;
|
|
1138
1193
|
if (isTTY) {
|
|
1139
1194
|
yield* startTTYSession;
|
|
@@ -1188,39 +1243,12 @@ const makeDefaultFrameRenderer = (
|
|
|
1188
1243
|
}),
|
|
1189
1244
|
});
|
|
1190
1245
|
|
|
1191
|
-
export class BuildStage extends Context.Tag("stromseng.dev/effective-progress/BuildStage")<
|
|
1192
|
-
BuildStage,
|
|
1193
|
-
BuildStageService
|
|
1194
|
-
>() {
|
|
1195
|
-
static readonly Default = Layer.succeed(BuildStage, BuildStage.of(defaultBuildStageService));
|
|
1196
|
-
}
|
|
1197
|
-
|
|
1198
|
-
export class ShrinkStage extends Context.Tag("stromseng.dev/effective-progress/ShrinkStage")<
|
|
1199
|
-
ShrinkStage,
|
|
1200
|
-
ShrinkStageService
|
|
1201
|
-
>() {
|
|
1202
|
-
static readonly Default = Layer.succeed(ShrinkStage, ShrinkStage.of(defaultShrinkStageService));
|
|
1203
|
-
}
|
|
1204
|
-
|
|
1205
|
-
export class ColorStage extends Context.Tag("stromseng.dev/effective-progress/ColorStage")<
|
|
1206
|
-
ColorStage,
|
|
1207
|
-
ColorStageService
|
|
1208
|
-
>() {
|
|
1209
|
-
static readonly Default = Layer.succeed(ColorStage, ColorStage.of(defaultColorStageService));
|
|
1210
|
-
}
|
|
1211
|
-
|
|
1212
1246
|
export class FrameRenderer extends Context.Tag("stromseng.dev/effective-progress/FrameRenderer")<
|
|
1213
1247
|
FrameRenderer,
|
|
1214
1248
|
FrameRendererService
|
|
1215
1249
|
>() {
|
|
1216
|
-
static readonly Default = Layer.
|
|
1250
|
+
static readonly Default = Layer.succeed(
|
|
1217
1251
|
FrameRenderer,
|
|
1218
|
-
|
|
1219
|
-
const buildStage = yield* BuildStage;
|
|
1220
|
-
const shrinkStage = yield* ShrinkStage;
|
|
1221
|
-
const colorStage = yield* ColorStage;
|
|
1222
|
-
const theme = yield* Theme;
|
|
1223
|
-
return FrameRenderer.of(makeDefaultFrameRenderer(buildStage, shrinkStage, colorStage, theme));
|
|
1224
|
-
}),
|
|
1252
|
+
FrameRenderer.of(makeDefaultFrameRenderer()),
|
|
1225
1253
|
);
|
|
1226
1254
|
}
|