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