effective-progress 0.4.0 → 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 -63
- 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 -720
- 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,37 +536,38 @@ 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
|
-
const shrinkable = cells
|
|
289
|
-
.map((cell, index) => ({
|
|
566
|
+
const shrinkable = columns
|
|
567
|
+
.map((column, index) => ({
|
|
290
568
|
index,
|
|
291
|
-
priority:
|
|
292
|
-
wrapMode: cell.wrapMode ?? "truncate",
|
|
569
|
+
priority: column.collapsePriority ?? Number.MAX_SAFE_INTEGER,
|
|
293
570
|
}))
|
|
294
|
-
.filter((entry) => entry.wrapMode === "truncate")
|
|
295
571
|
.sort((a, b) => a.priority - b.priority);
|
|
296
572
|
|
|
297
573
|
let remainingOverflow = overflow;
|
|
@@ -321,8 +597,6 @@ const shrinkProportionally = (
|
|
|
321
597
|
minWidths: ReadonlyArray<number>,
|
|
322
598
|
overflow: number,
|
|
323
599
|
): number => {
|
|
324
|
-
// Last-resort collapse pass: if priority-based shrinking is not enough,
|
|
325
|
-
// reduce all remaining shrinkable columns proportionally.
|
|
326
600
|
let remainingOverflow = overflow;
|
|
327
601
|
|
|
328
602
|
while (remainingOverflow > 0) {
|
|
@@ -372,37 +646,39 @@ const shrinkProportionally = (
|
|
|
372
646
|
return remainingOverflow;
|
|
373
647
|
};
|
|
374
648
|
|
|
375
|
-
const
|
|
376
|
-
|
|
649
|
+
const resolveColumnWidths = (
|
|
650
|
+
columns: ReadonlyArray<ProgressColumn>,
|
|
651
|
+
intrinsicWidths: ReadonlyArray<number>,
|
|
377
652
|
totalWidth: number | undefined,
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
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
|
+
};
|
|
387
663
|
}
|
|
388
664
|
|
|
389
|
-
const minWidths =
|
|
390
|
-
const maxWidths =
|
|
391
|
-
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);
|
|
392
668
|
|
|
393
|
-
for (let i = 0; i <
|
|
394
|
-
const
|
|
395
|
-
const track = resolveTrack(
|
|
669
|
+
for (let i = 0; i < columns.length; i++) {
|
|
670
|
+
const column = columns[i]!;
|
|
671
|
+
const track = resolveTrack(column);
|
|
396
672
|
const minWidth = minWidths[i] ?? 0;
|
|
397
673
|
const maxWidth = maxWidths[i];
|
|
398
|
-
const intrinsic =
|
|
674
|
+
const intrinsic = Math.max(0, Math.floor(intrinsicWidths[i] ?? 0));
|
|
399
675
|
|
|
400
676
|
const baseWidth = (() => {
|
|
401
677
|
switch (track._tag) {
|
|
402
678
|
case "Fixed":
|
|
403
679
|
return Math.max(minWidth, track.width);
|
|
404
680
|
case "Fraction":
|
|
405
|
-
return minWidth;
|
|
681
|
+
return totalWidth === undefined ? Math.max(minWidth, intrinsic) : minWidth;
|
|
406
682
|
case "Auto":
|
|
407
683
|
return Math.max(minWidth, intrinsic);
|
|
408
684
|
}
|
|
@@ -413,20 +689,24 @@ const resolveRowWidths = (
|
|
|
413
689
|
}
|
|
414
690
|
|
|
415
691
|
if (totalWidth === undefined) {
|
|
416
|
-
return
|
|
692
|
+
return {
|
|
693
|
+
widths,
|
|
694
|
+
overflowBeforeShrink: 0,
|
|
695
|
+
};
|
|
417
696
|
}
|
|
418
697
|
|
|
419
|
-
const usableWidth = Math.max(1, totalWidth - gap * Math.max(0,
|
|
698
|
+
const usableWidth = Math.max(1, totalWidth - gap * Math.max(0, columns.length - 1));
|
|
420
699
|
|
|
421
700
|
const fractionColumns: Array<{ index: number; weight: number }> = [];
|
|
422
|
-
for (let index = 0; index <
|
|
423
|
-
const track = resolveTrack(
|
|
701
|
+
for (let index = 0; index < columns.length; index++) {
|
|
702
|
+
const track = resolveTrack(columns[index]!);
|
|
424
703
|
if (track._tag === "Fraction") {
|
|
425
704
|
fractionColumns.push({ index, weight: track.weight });
|
|
426
705
|
}
|
|
427
706
|
}
|
|
428
707
|
|
|
429
708
|
let remaining = usableWidth - widths.reduce((sum, width) => sum + width, 0);
|
|
709
|
+
const overflowBeforeShrink = Math.max(0, -remaining);
|
|
430
710
|
|
|
431
711
|
if (remaining > 0 && fractionColumns.length > 0) {
|
|
432
712
|
const distributed = ratioDistribute(
|
|
@@ -445,505 +725,297 @@ const resolveRowWidths = (
|
|
|
445
725
|
|
|
446
726
|
if (remaining < 0) {
|
|
447
727
|
let overflow = -remaining;
|
|
448
|
-
overflow = shrinkByPriority(widths, minWidths,
|
|
728
|
+
overflow = shrinkByPriority(widths, minWidths, columns, overflow);
|
|
449
729
|
if (overflow > 0) {
|
|
450
730
|
overflow = shrinkProportionally(widths, minWidths, overflow);
|
|
451
731
|
}
|
|
452
732
|
}
|
|
453
733
|
|
|
454
|
-
return
|
|
455
|
-
|
|
456
|
-
|
|
734
|
+
return {
|
|
735
|
+
widths: widths.map((width, index) => {
|
|
736
|
+
const min = minWidths[index] ?? 0;
|
|
737
|
+
const max = maxWidths[index];
|
|
457
738
|
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
739
|
+
if (max === undefined) {
|
|
740
|
+
return Math.max(min, width);
|
|
741
|
+
}
|
|
461
742
|
|
|
462
|
-
|
|
463
|
-
|
|
743
|
+
return clamp(Math.max(min, width), min, max);
|
|
744
|
+
}),
|
|
745
|
+
overflowBeforeShrink,
|
|
746
|
+
};
|
|
464
747
|
};
|
|
465
748
|
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
749
|
+
const normalizeColumns = (
|
|
750
|
+
columns: ReadonlyArray<ProgressColumn | string>,
|
|
751
|
+
): ReadonlyArray<ProgressColumn> => {
|
|
752
|
+
const resolved = columns.length > 0 ? columns : Columns.defaults();
|
|
470
753
|
|
|
471
|
-
|
|
472
|
-
|
|
754
|
+
return resolved.map((entry, index) => {
|
|
755
|
+
const normalized = typeof entry === "string" ? LiteralColumn.make(entry) : entry;
|
|
473
756
|
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
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}`);
|
|
477
763
|
}
|
|
478
|
-
}
|
|
479
764
|
|
|
480
|
-
|
|
481
|
-
};
|
|
482
|
-
|
|
483
|
-
const fromCharacterTokens = (tokens: ReadonlyArray<CharacterToken>): ReadonlyArray<Segment> => {
|
|
484
|
-
if (tokens.length === 0) {
|
|
485
|
-
return [];
|
|
486
|
-
}
|
|
487
|
-
|
|
488
|
-
const segments: Array<Segment> = [];
|
|
489
|
-
let currentRole = tokens[0]!.role;
|
|
490
|
-
let buffer = "";
|
|
491
|
-
|
|
492
|
-
for (const token of tokens) {
|
|
493
|
-
if (token.role !== currentRole) {
|
|
494
|
-
segments.push(createSegment(buffer, currentRole));
|
|
495
|
-
buffer = token.char;
|
|
496
|
-
currentRole = token.role;
|
|
497
|
-
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`);
|
|
498
767
|
}
|
|
499
768
|
|
|
500
|
-
|
|
501
|
-
}
|
|
502
|
-
|
|
503
|
-
if (buffer.length > 0) {
|
|
504
|
-
segments.push(createSegment(buffer, currentRole));
|
|
505
|
-
}
|
|
506
|
-
|
|
507
|
-
return segments;
|
|
769
|
+
return normalized;
|
|
770
|
+
});
|
|
508
771
|
};
|
|
509
772
|
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
): ReadonlyArray<Segment> => {
|
|
515
|
-
// Segment fitting is role-preserving: truncate by character tokens,
|
|
516
|
-
// optionally append ellipsis, then pad with plain-space tokens.
|
|
517
|
-
const target = Math.max(0, Math.floor(width));
|
|
518
|
-
if (target <= 0) {
|
|
519
|
-
return [];
|
|
520
|
-
}
|
|
521
|
-
|
|
522
|
-
const chars = toCharacterTokens(segments);
|
|
523
|
-
|
|
524
|
-
const truncatedChars = (() => {
|
|
525
|
-
if (chars.length <= target) {
|
|
526
|
-
return chars;
|
|
527
|
-
}
|
|
528
|
-
|
|
529
|
-
if (wrapMode === "no-wrap-ellipsis") {
|
|
530
|
-
if (target === 1) {
|
|
531
|
-
return [{ char: "…", role: chars[0]?.role ?? "plain" }];
|
|
532
|
-
}
|
|
533
|
-
|
|
534
|
-
const keep = chars.slice(0, Math.max(0, target - 1));
|
|
535
|
-
const ellipsisRole = keep[keep.length - 1]?.role ?? chars[0]?.role ?? "plain";
|
|
536
|
-
return [...keep, { char: "…", role: ellipsisRole }];
|
|
537
|
-
}
|
|
773
|
+
interface OrderedTaskModel {
|
|
774
|
+
readonly snapshot: TaskSnapshot;
|
|
775
|
+
readonly depth: number;
|
|
776
|
+
}
|
|
538
777
|
|
|
539
|
-
|
|
540
|
-
|
|
778
|
+
interface RenderedFrame {
|
|
779
|
+
readonly lines: ReadonlyArray<string>;
|
|
780
|
+
readonly lineByTaskId: ReadonlyMap<number, string>;
|
|
781
|
+
}
|
|
541
782
|
|
|
542
|
-
|
|
543
|
-
|
|
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
|
+
});
|
|
544
787
|
|
|
545
|
-
|
|
546
|
-
|
|
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);
|
|
547
796
|
}
|
|
548
797
|
|
|
549
|
-
return [
|
|
798
|
+
return variants[Math.min(level, variants.length - 1)]!;
|
|
550
799
|
};
|
|
551
800
|
|
|
552
|
-
const
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
const
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
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
|
+
};
|
|
569
824
|
}
|
|
570
825
|
|
|
571
|
-
|
|
572
|
-
}
|
|
573
|
-
|
|
574
|
-
const computeTreeInfo = (ordered: ReadonlyArray<{ snapshot: TaskSnapshot; depth: number }>) => {
|
|
575
|
-
// Precompute sibling/ancestor relationships so connector rendering is
|
|
576
|
-
// deterministic and independent from column layout decisions.
|
|
577
|
-
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
|
+
);
|
|
578
829
|
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
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);
|
|
589
847
|
}
|
|
590
848
|
}
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
const ancestorStateByDepth: Array<boolean> = [];
|
|
594
|
-
|
|
595
|
-
return ordered.map((entry, index) => {
|
|
596
|
-
const depth = entry.depth;
|
|
597
|
-
ancestorStateByDepth.length = depth;
|
|
598
|
-
|
|
599
|
-
const hasChildren =
|
|
600
|
-
index + 1 < ordered.length &&
|
|
601
|
-
ordered[index + 1] !== undefined &&
|
|
602
|
-
ordered[index + 1]!.depth > depth;
|
|
603
|
-
|
|
604
|
-
const tree: TaskTreeInfo = {
|
|
605
|
-
depth,
|
|
606
|
-
hasNextSibling: hasNextSiblingByIndex[index] ?? false,
|
|
607
|
-
hasChildren,
|
|
608
|
-
ancestorHasNextSibling: [...ancestorStateByDepth],
|
|
609
|
-
};
|
|
610
|
-
|
|
611
|
-
ancestorStateByDepth[depth] = hasNextSiblingByIndex[index] ?? false;
|
|
612
|
-
|
|
613
|
-
return {
|
|
614
|
-
...entry,
|
|
615
|
-
tree,
|
|
616
|
-
};
|
|
849
|
+
return maxLevel;
|
|
617
850
|
});
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
const
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
const
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
segments: [createSegment(text, "text")],
|
|
636
|
-
});
|
|
637
|
-
|
|
638
|
-
const spinnerCell = (snapshot: TaskSnapshot, tick: number): CellModel => {
|
|
639
|
-
const frames = snapshot.config.spinnerFrames;
|
|
640
|
-
const frameIndex =
|
|
641
|
-
snapshot.units._tag === "IndeterminateTaskUnits"
|
|
642
|
-
? (snapshot.units.spinnerFrame + tick) % frames.length
|
|
643
|
-
: 0;
|
|
644
|
-
const frame = frames[frameIndex] ?? frames[0] ?? "";
|
|
645
|
-
|
|
646
|
-
return {
|
|
647
|
-
id: "spinner",
|
|
648
|
-
track: Track.auto(),
|
|
649
|
-
wrapMode: "truncate",
|
|
650
|
-
collapsePriority: 70,
|
|
651
|
-
minWidth: 1,
|
|
652
|
-
segments: [createSegment(frame, "spinner")],
|
|
653
|
-
};
|
|
654
|
-
};
|
|
655
|
-
|
|
656
|
-
const barCell = (snapshot: TaskSnapshot): CellModel => {
|
|
657
|
-
if (snapshot.units._tag !== "DeterminateTaskUnits") {
|
|
658
|
-
return {
|
|
659
|
-
id: "bar",
|
|
660
|
-
segments: [],
|
|
661
|
-
};
|
|
662
|
-
}
|
|
663
|
-
|
|
664
|
-
const { config } = snapshot;
|
|
665
|
-
const units = snapshot.units;
|
|
666
|
-
const bracketWidth = textWidth(config.leftBracket) + textWidth(config.rightBracket);
|
|
667
|
-
const preferredInnerWidth = Math.max(1, config.barWidth);
|
|
668
|
-
const intrinsicWidth = bracketWidth + preferredInnerWidth;
|
|
669
|
-
|
|
670
|
-
return {
|
|
671
|
-
id: "bar",
|
|
672
|
-
track: Track.auto(),
|
|
673
|
-
minWidth: Math.max(1, bracketWidth + 1),
|
|
674
|
-
wrapMode: "truncate",
|
|
675
|
-
collapsePriority: 10,
|
|
676
|
-
intrinsicWidth,
|
|
677
|
-
segments: [],
|
|
678
|
-
renderAtWidth: (width) => {
|
|
679
|
-
const targetWidth = Math.max(1, Math.floor(width));
|
|
680
|
-
const safeTotal = units.total <= 0 ? 1 : units.total;
|
|
681
|
-
const ratio = Math.min(1, Math.max(0, units.completed / safeTotal));
|
|
682
|
-
|
|
683
|
-
const resolvedInnerWidth = Math.max(1, targetWidth - bracketWidth);
|
|
684
|
-
const clampedRatio = snapshot.status === "done" ? 1 : ratio;
|
|
685
|
-
const filled = Math.round(clampedRatio * resolvedInnerWidth);
|
|
686
|
-
|
|
687
|
-
const fillRole: ThemeRole =
|
|
688
|
-
snapshot.status === "failed"
|
|
689
|
-
? "statusFailed"
|
|
690
|
-
: snapshot.status === "done"
|
|
691
|
-
? "statusDone"
|
|
692
|
-
: "barFill";
|
|
693
|
-
|
|
694
|
-
const emptyRole: ThemeRole = snapshot.status === "failed" ? "statusFailed" : "barEmpty";
|
|
695
|
-
|
|
696
|
-
return [
|
|
697
|
-
createSegment(config.leftBracket, "barBracket"),
|
|
698
|
-
createSegment(config.fillChar.repeat(filled), fillRole),
|
|
699
|
-
createSegment(config.emptyChar.repeat(Math.max(0, resolvedInnerWidth - filled)), emptyRole),
|
|
700
|
-
createSegment(config.rightBracket, "barBracket"),
|
|
701
|
-
];
|
|
702
|
-
},
|
|
703
|
-
};
|
|
704
|
-
};
|
|
705
|
-
|
|
706
|
-
const unitsCell = (snapshot: TaskSnapshot): CellModel => ({
|
|
707
|
-
id: "units",
|
|
708
|
-
track: Track.auto(),
|
|
709
|
-
minWidth: 3,
|
|
710
|
-
wrapMode: "truncate",
|
|
711
|
-
collapsePriority: 40,
|
|
712
|
-
segments:
|
|
713
|
-
snapshot.units._tag === "DeterminateTaskUnits"
|
|
714
|
-
? [createSegment(`${snapshot.units.completed}/${snapshot.units.total}`, "units")]
|
|
715
|
-
: [],
|
|
716
|
-
});
|
|
717
|
-
|
|
718
|
-
const etaCell = (snapshot: TaskSnapshot, now: number): CellModel => ({
|
|
719
|
-
id: "eta",
|
|
720
|
-
track: Track.auto(),
|
|
721
|
-
minWidth: 4,
|
|
722
|
-
wrapMode: "truncate",
|
|
723
|
-
collapsePriority: 20,
|
|
724
|
-
segments: [createSegment(formatEta(snapshot, now), "eta")],
|
|
725
|
-
});
|
|
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
|
+
}
|
|
726
868
|
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
}
|
|
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
|
+
}
|
|
735
882
|
|
|
736
|
-
const
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
wrapMode: "truncate",
|
|
743
|
-
collapsePriority: 60,
|
|
744
|
-
segments: [createSegment("done", "statusDone")],
|
|
745
|
-
};
|
|
746
|
-
}
|
|
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);
|
|
747
889
|
|
|
748
|
-
if (snapshot.status === "failed") {
|
|
749
890
|
return {
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
segments: [createSegment("[failed]", "statusFailed")],
|
|
891
|
+
intrinsicByColumn,
|
|
892
|
+
activeColumnIndexes,
|
|
893
|
+
activeColumns,
|
|
894
|
+
widths: widthResolution.widths,
|
|
895
|
+
overflowBeforeShrink: widthResolution.overflowBeforeShrink,
|
|
756
896
|
};
|
|
757
|
-
}
|
|
758
|
-
|
|
759
|
-
return {
|
|
760
|
-
id: "status",
|
|
761
|
-
segments: [],
|
|
762
897
|
};
|
|
763
|
-
};
|
|
764
898
|
|
|
765
|
-
|
|
766
|
-
buildFrame: ({ orderedTasks, rendererConfig, now, tick }) => {
|
|
767
|
-
// Build stage emits semantic rows only. No ANSI and no width trimming here.
|
|
768
|
-
const orderedWithTree = computeTreeInfo(
|
|
769
|
-
orderedTasks.map((entry) => ({ snapshot: entry.snapshot, depth: entry.depth })),
|
|
770
|
-
);
|
|
899
|
+
let layout = resolveLayoutForVariants(variantLevelByColumn);
|
|
771
900
|
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
const
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
const runningOrDoneDeterminate =
|
|
781
|
-
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
|
+
});
|
|
782
908
|
|
|
783
|
-
|
|
909
|
+
if (layout.overflowBeforeShrink <= 0 && !hasCompressedColumns) {
|
|
910
|
+
break;
|
|
911
|
+
}
|
|
784
912
|
|
|
785
|
-
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
|
+
);
|
|
786
924
|
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
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
|
+
}
|
|
793
938
|
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
}
|
|
815
|
-
} else if (determinateFailure) {
|
|
816
|
-
if (showTwoLineDeterminate) {
|
|
817
|
-
rows.push({
|
|
818
|
-
lineVariant: "lead",
|
|
819
|
-
cells: [treeCell(tree, "lead"), textCell(snapshot.description)],
|
|
820
|
-
});
|
|
821
|
-
rows.push({
|
|
822
|
-
lineVariant: "continuation",
|
|
823
|
-
cells: [
|
|
824
|
-
treeCell(tree, "continuation"),
|
|
825
|
-
barCell(snapshot),
|
|
826
|
-
unitsCell(snapshot),
|
|
827
|
-
statusCell(snapshot),
|
|
828
|
-
elapsedCell(snapshot, now),
|
|
829
|
-
],
|
|
830
|
-
});
|
|
831
|
-
} else {
|
|
832
|
-
rows.push({
|
|
833
|
-
lineVariant: "lead",
|
|
834
|
-
cells: [
|
|
835
|
-
treeCell(tree, "lead"),
|
|
836
|
-
textCell(snapshot.description),
|
|
837
|
-
barCell(snapshot),
|
|
838
|
-
unitsCell(snapshot),
|
|
839
|
-
statusCell(snapshot),
|
|
840
|
-
elapsedCell(snapshot, now),
|
|
841
|
-
],
|
|
842
|
-
});
|
|
843
|
-
}
|
|
844
|
-
} else if (snapshot.status === "running") {
|
|
845
|
-
rows.push({
|
|
846
|
-
lineVariant: "lead",
|
|
847
|
-
cells: [
|
|
848
|
-
treeCell(tree, "lead"),
|
|
849
|
-
spinnerCell(snapshot, tick),
|
|
850
|
-
textCell(snapshot.description),
|
|
851
|
-
elapsedCell(snapshot, now),
|
|
852
|
-
],
|
|
853
|
-
});
|
|
854
|
-
} else {
|
|
855
|
-
rows.push({
|
|
856
|
-
lineVariant: "lead",
|
|
857
|
-
cells: [
|
|
858
|
-
treeCell(tree, "lead"),
|
|
859
|
-
textCell(snapshot.description),
|
|
860
|
-
statusCell(snapshot),
|
|
861
|
-
elapsedCell(snapshot, now),
|
|
862
|
-
],
|
|
863
|
-
});
|
|
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;
|
|
864
959
|
}
|
|
865
960
|
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
rows,
|
|
871
|
-
};
|
|
872
|
-
});
|
|
961
|
+
variantLevelByColumn[best.index] = (variantLevelByColumn[best.index] ?? 0) + 1;
|
|
962
|
+
layout = resolveLayoutForVariants(variantLevelByColumn);
|
|
963
|
+
}
|
|
964
|
+
}
|
|
873
965
|
|
|
966
|
+
if (layout.activeColumnIndexes.length === 0) {
|
|
967
|
+
const emptyLines = orderedTasks.map(() => "");
|
|
874
968
|
return {
|
|
875
|
-
|
|
969
|
+
lines: emptyLines,
|
|
970
|
+
lineByTaskId: new Map(orderedTasks.map((entry) => [entry.snapshot.id as number, ""])),
|
|
876
971
|
};
|
|
877
|
-
}
|
|
878
|
-
};
|
|
879
|
-
|
|
880
|
-
const defaultShrinkStageService: ShrinkStageService = {
|
|
881
|
-
fitFrame: ({ frame, width }) => {
|
|
882
|
-
// Convert logical rows into concrete widths and fitted segments.
|
|
883
|
-
const totalWidth = resolveTotalWidth(width);
|
|
884
|
-
|
|
885
|
-
return {
|
|
886
|
-
taskBlocks: frame.taskBlocks.map((block) => ({
|
|
887
|
-
taskId: block.taskId,
|
|
888
|
-
rows: block.rows.map((row) => {
|
|
889
|
-
const gap = Math.max(0, Math.floor(row.gap ?? 1));
|
|
890
|
-
const widths = resolveRowWidths(row, totalWidth);
|
|
972
|
+
}
|
|
891
973
|
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
})),
|
|
911
|
-
};
|
|
912
|
-
},
|
|
913
|
-
};
|
|
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
|
+
);
|
|
914
992
|
|
|
915
|
-
const
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
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] ?? "");
|
|
919
996
|
}
|
|
920
997
|
|
|
921
|
-
|
|
922
|
-
|
|
998
|
+
return {
|
|
999
|
+
lines,
|
|
1000
|
+
lineByTaskId,
|
|
1001
|
+
};
|
|
923
1002
|
};
|
|
924
1003
|
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
),
|
|
938
|
-
};
|
|
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
|
+
}
|
|
939
1016
|
|
|
940
|
-
const makeDefaultFrameRenderer = (
|
|
941
|
-
|
|
942
|
-
shrinkStage: ShrinkStageService,
|
|
943
|
-
colorStage: ColorStageService,
|
|
944
|
-
fallbackTheme: ThemeService,
|
|
945
|
-
): FrameRendererService => ({
|
|
946
|
-
run: ({
|
|
1017
|
+
const makeDefaultFrameRenderer = (): FrameRendererService => ({
|
|
1018
|
+
run: (
|
|
947
1019
|
storeRef,
|
|
948
1020
|
logsRef,
|
|
949
1021
|
pendingLogsRef,
|
|
@@ -952,11 +1024,10 @@ const makeDefaultFrameRenderer = (
|
|
|
952
1024
|
isTTY,
|
|
953
1025
|
rendererConfig,
|
|
954
1026
|
maxRetainedLogLines,
|
|
955
|
-
|
|
1027
|
+
) =>
|
|
956
1028
|
Effect.gen(function* () {
|
|
957
|
-
// The frame renderer owns terminal state (cursor/session), drives tick
|
|
958
|
-
// updates, and coordinates build -> shrink -> color for each frame.
|
|
959
1029
|
const retainLogHistory = maxRetainedLogLines > 0;
|
|
1030
|
+
const compiledColumns = normalizeColumns(rendererConfig.columns);
|
|
960
1031
|
let previousLineCount = 0;
|
|
961
1032
|
let nonTTYTaskSignatureById = new Map<number, string>();
|
|
962
1033
|
let tick = 0;
|
|
@@ -1000,7 +1071,7 @@ const makeDefaultFrameRenderer = (
|
|
|
1000
1071
|
return;
|
|
1001
1072
|
}
|
|
1002
1073
|
|
|
1003
|
-
yield* terminal.writeStderr(
|
|
1074
|
+
yield* terminal.writeStderr(`\n${SHOW_CURSOR}`);
|
|
1004
1075
|
previousLineCount = 0;
|
|
1005
1076
|
sessionActive = false;
|
|
1006
1077
|
});
|
|
@@ -1008,7 +1079,7 @@ const makeDefaultFrameRenderer = (
|
|
|
1008
1079
|
const renderNonTTYTaskUpdates = (
|
|
1009
1080
|
ordered: ReadonlyArray<{
|
|
1010
1081
|
snapshot: TaskSnapshot;
|
|
1011
|
-
|
|
1082
|
+
line: string;
|
|
1012
1083
|
}>,
|
|
1013
1084
|
) => {
|
|
1014
1085
|
const nextTaskSignatureById = new Map<number, string>();
|
|
@@ -1025,13 +1096,16 @@ const makeDefaultFrameRenderer = (
|
|
|
1025
1096
|
|
|
1026
1097
|
nextTaskSignatureById.set(taskId, signature);
|
|
1027
1098
|
if (nonTTYTaskSignatureById.get(taskId) !== signature) {
|
|
1028
|
-
|
|
1099
|
+
const line = ordered[i]!.line;
|
|
1100
|
+
if (line.length > 0) {
|
|
1101
|
+
changedTaskLines.push(line);
|
|
1102
|
+
}
|
|
1029
1103
|
}
|
|
1030
1104
|
}
|
|
1031
1105
|
|
|
1032
1106
|
return Effect.gen(function* () {
|
|
1033
1107
|
if (changedTaskLines.length > 0) {
|
|
1034
|
-
yield* terminal.writeStderr(changedTaskLines.join("\n")
|
|
1108
|
+
yield* terminal.writeStderr(`${changedTaskLines.join("\n")}\n`);
|
|
1035
1109
|
}
|
|
1036
1110
|
|
|
1037
1111
|
nonTTYTaskSignatureById = nextTaskSignatureById;
|
|
@@ -1040,7 +1114,6 @@ const makeDefaultFrameRenderer = (
|
|
|
1040
1114
|
|
|
1041
1115
|
const renderFrame = (mode: "tick" | "final") =>
|
|
1042
1116
|
Effect.gen(function* () {
|
|
1043
|
-
// Materialize one frame snapshot from current state.
|
|
1044
1117
|
const drainedLogs = yield* Ref.getAndSet(pendingLogsRef, []);
|
|
1045
1118
|
const store = yield* Ref.get(storeRef);
|
|
1046
1119
|
const orderedTasks = store.renderOrder.flatMap((row) => {
|
|
@@ -1053,7 +1126,6 @@ const makeDefaultFrameRenderer = (
|
|
|
1053
1126
|
{
|
|
1054
1127
|
snapshot,
|
|
1055
1128
|
depth: row.depth,
|
|
1056
|
-
theme: store.themes.get(snapshot.id) ?? fallbackTheme,
|
|
1057
1129
|
},
|
|
1058
1130
|
];
|
|
1059
1131
|
});
|
|
@@ -1061,38 +1133,22 @@ const makeDefaultFrameRenderer = (
|
|
|
1061
1133
|
const now = yield* Clock.currentTimeMillis;
|
|
1062
1134
|
const frameTick = mode === "final" ? tick + 1 : tick;
|
|
1063
1135
|
const terminalColumns = isTTY ? yield* terminal.stderrColumns : undefined;
|
|
1064
|
-
const maxTaskWidth = rendererConfig.maxTaskWidth;
|
|
1065
1136
|
|
|
1066
|
-
const
|
|
1137
|
+
const renderedFrame = renderTaskFrame(
|
|
1067
1138
|
orderedTasks,
|
|
1139
|
+
compiledColumns,
|
|
1068
1140
|
rendererConfig,
|
|
1069
1141
|
now,
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
frame: frameModel,
|
|
1075
|
-
width: {
|
|
1076
|
-
terminalColumns,
|
|
1077
|
-
maxTaskWidth,
|
|
1078
|
-
},
|
|
1079
|
-
});
|
|
1080
|
-
|
|
1081
|
-
const taskLines = colorStage.colorFrame({ frame: fittedFrame });
|
|
1082
|
-
|
|
1083
|
-
const taskLineMap = new Map<number, ReadonlyArray<string>>();
|
|
1084
|
-
let lineCursor = 0;
|
|
1085
|
-
for (const block of fittedFrame.taskBlocks) {
|
|
1086
|
-
const lineCount = block.rows.length;
|
|
1087
|
-
taskLineMap.set(block.taskId, taskLines.slice(lineCursor, lineCursor + lineCount));
|
|
1088
|
-
lineCursor += lineCount;
|
|
1089
|
-
}
|
|
1142
|
+
isTTY ? frameTick : 0,
|
|
1143
|
+
terminalColumns,
|
|
1144
|
+
isTTY,
|
|
1145
|
+
);
|
|
1090
1146
|
|
|
1091
1147
|
if (isTTY) {
|
|
1092
1148
|
let frame = "";
|
|
1093
1149
|
|
|
1094
1150
|
if (previousLineCount > 0) {
|
|
1095
|
-
frame +=
|
|
1151
|
+
frame += `\r${CLEAR_LINE}`;
|
|
1096
1152
|
for (let i = 1; i < previousLineCount; i++) {
|
|
1097
1153
|
frame += MOVE_UP_ONE + CLEAR_LINE;
|
|
1098
1154
|
}
|
|
@@ -1100,19 +1156,19 @@ const makeDefaultFrameRenderer = (
|
|
|
1100
1156
|
|
|
1101
1157
|
if (retainLogHistory) {
|
|
1102
1158
|
const historyLogs = yield* Ref.get(logsRef);
|
|
1103
|
-
const lines = yield* clipTTYFrameLines([...historyLogs, ...
|
|
1159
|
+
const lines = yield* clipTTYFrameLines([...historyLogs, ...renderedFrame.lines]);
|
|
1104
1160
|
if (lines.length > 0) {
|
|
1105
1161
|
frame += lines.join("\n");
|
|
1106
1162
|
}
|
|
1107
1163
|
previousLineCount = lines.length;
|
|
1108
1164
|
} else {
|
|
1109
1165
|
if (drainedLogs.length > 0) {
|
|
1110
|
-
frame += drainedLogs.join("\n")
|
|
1166
|
+
frame += `${drainedLogs.join("\n")}\n`;
|
|
1111
1167
|
}
|
|
1112
|
-
if (
|
|
1113
|
-
frame +=
|
|
1168
|
+
if (renderedFrame.lines.length > 0) {
|
|
1169
|
+
frame += renderedFrame.lines.join("\n");
|
|
1114
1170
|
}
|
|
1115
|
-
previousLineCount =
|
|
1171
|
+
previousLineCount = renderedFrame.lines.length;
|
|
1116
1172
|
}
|
|
1117
1173
|
|
|
1118
1174
|
if (frame) {
|
|
@@ -1122,19 +1178,17 @@ const makeDefaultFrameRenderer = (
|
|
|
1122
1178
|
}
|
|
1123
1179
|
|
|
1124
1180
|
if (drainedLogs.length > 0) {
|
|
1125
|
-
yield* terminal.writeStderr(drainedLogs.join("\n")
|
|
1181
|
+
yield* terminal.writeStderr(`${drainedLogs.join("\n")}\n`);
|
|
1126
1182
|
}
|
|
1127
1183
|
|
|
1128
1184
|
const orderedForNonTTY = orderedTasks.map((task) => ({
|
|
1129
1185
|
snapshot: task.snapshot,
|
|
1130
|
-
|
|
1186
|
+
line: renderedFrame.lineByTaskId.get(task.snapshot.id as number) ?? "",
|
|
1131
1187
|
}));
|
|
1132
1188
|
yield* renderNonTTYTaskUpdates(orderedForNonTTY);
|
|
1133
1189
|
});
|
|
1134
1190
|
|
|
1135
1191
|
const renderLoop = Effect.gen(function* () {
|
|
1136
|
-
// Tick loop: render eagerly in TTY mode for spinner animation, and
|
|
1137
|
-
// render opportunistically in non-TTY mode based on signatures/dirty bit.
|
|
1138
1192
|
rendererActive = true;
|
|
1139
1193
|
if (isTTY) {
|
|
1140
1194
|
yield* startTTYSession;
|
|
@@ -1189,39 +1243,12 @@ const makeDefaultFrameRenderer = (
|
|
|
1189
1243
|
}),
|
|
1190
1244
|
});
|
|
1191
1245
|
|
|
1192
|
-
export class BuildStage extends Context.Tag("stromseng.dev/effective-progress/BuildStage")<
|
|
1193
|
-
BuildStage,
|
|
1194
|
-
BuildStageService
|
|
1195
|
-
>() {
|
|
1196
|
-
static readonly Default = Layer.succeed(BuildStage, BuildStage.of(defaultBuildStageService));
|
|
1197
|
-
}
|
|
1198
|
-
|
|
1199
|
-
export class ShrinkStage extends Context.Tag("stromseng.dev/effective-progress/ShrinkStage")<
|
|
1200
|
-
ShrinkStage,
|
|
1201
|
-
ShrinkStageService
|
|
1202
|
-
>() {
|
|
1203
|
-
static readonly Default = Layer.succeed(ShrinkStage, ShrinkStage.of(defaultShrinkStageService));
|
|
1204
|
-
}
|
|
1205
|
-
|
|
1206
|
-
export class ColorStage extends Context.Tag("stromseng.dev/effective-progress/ColorStage")<
|
|
1207
|
-
ColorStage,
|
|
1208
|
-
ColorStageService
|
|
1209
|
-
>() {
|
|
1210
|
-
static readonly Default = Layer.succeed(ColorStage, ColorStage.of(defaultColorStageService));
|
|
1211
|
-
}
|
|
1212
|
-
|
|
1213
1246
|
export class FrameRenderer extends Context.Tag("stromseng.dev/effective-progress/FrameRenderer")<
|
|
1214
1247
|
FrameRenderer,
|
|
1215
1248
|
FrameRendererService
|
|
1216
1249
|
>() {
|
|
1217
|
-
static readonly Default = Layer.
|
|
1250
|
+
static readonly Default = Layer.succeed(
|
|
1218
1251
|
FrameRenderer,
|
|
1219
|
-
|
|
1220
|
-
const buildStage = yield* BuildStage;
|
|
1221
|
-
const shrinkStage = yield* ShrinkStage;
|
|
1222
|
-
const colorStage = yield* ColorStage;
|
|
1223
|
-
const theme = yield* Theme;
|
|
1224
|
-
return FrameRenderer.of(makeDefaultFrameRenderer(buildStage, shrinkStage, colorStage, theme));
|
|
1225
|
-
}),
|
|
1252
|
+
FrameRenderer.of(makeDefaultFrameRenderer()),
|
|
1226
1253
|
);
|
|
1227
1254
|
}
|