effective-progress 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +36 -50
- package/package.json +1 -1
- package/src/index.ts +2 -1
- package/src/renderer.ts +1157 -201
- package/src/runtime.ts +88 -29
- package/src/theme.ts +87 -0
- package/src/types.ts +7 -4
- package/src/colors.ts +0 -30
package/src/renderer.ts
CHANGED
|
@@ -1,271 +1,1227 @@
|
|
|
1
|
-
import { Clock, Duration, Effect, Ref } from "effect";
|
|
2
|
-
import {
|
|
1
|
+
import { Clock, Context, Duration, Effect, Layer, Ref } from "effect";
|
|
2
|
+
import { Theme, type ThemeRole, type ThemeService } from "./theme";
|
|
3
3
|
import type { ProgressTerminalService } from "./terminal";
|
|
4
|
-
import type {
|
|
5
|
-
import { DeterminateTaskUnits, TaskSnapshot } from "./types";
|
|
4
|
+
import type { RendererConfigShape, TaskSnapshot, TaskStore } from "./types";
|
|
6
5
|
|
|
7
6
|
const HIDE_CURSOR = "\x1b[?25l";
|
|
8
7
|
const SHOW_CURSOR = "\x1b[?25h";
|
|
9
8
|
const CLEAR_LINE = "\x1b[2K";
|
|
10
9
|
const MOVE_UP_ONE = "\x1b[1A";
|
|
11
10
|
|
|
12
|
-
const
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
)
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
const percent = String(Math.round(ratio * 100)).padStart(3, " ");
|
|
22
|
-
return `${colors.brackets(progressbar.leftBracket)}${bar}${colors.brackets(progressbar.rightBracket)} ${units.completed}/${units.total} ${colors.percent(percent + "%")}`;
|
|
23
|
-
};
|
|
11
|
+
const clamp = (value: number, minimum: number, maximum: number): number =>
|
|
12
|
+
Math.min(Math.max(value, minimum), maximum);
|
|
13
|
+
|
|
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
|
+
const textWidth = (text: string): number => Array.from(text).length;
|
|
17
|
+
|
|
18
|
+
const segmentWidth = (segments: ReadonlyArray<Segment>): number =>
|
|
19
|
+
segments.reduce((width, segment) => width + textWidth(segment.text), 0);
|
|
24
20
|
|
|
25
21
|
const formatElapsed = (snapshot: TaskSnapshot, now: number): string => {
|
|
26
|
-
const elapsedMillis = (snapshot.completedAt ?? now) - snapshot.startedAt;
|
|
22
|
+
const elapsedMillis = Math.max(0, (snapshot.completedAt ?? now) - snapshot.startedAt);
|
|
27
23
|
const duration =
|
|
28
24
|
snapshot.status === "running"
|
|
29
25
|
? Duration.seconds(Math.floor(elapsedMillis / 1000))
|
|
30
26
|
: Duration.millis(elapsedMillis);
|
|
31
|
-
return
|
|
27
|
+
return `${Duration.format(duration)}`;
|
|
32
28
|
};
|
|
33
29
|
|
|
34
|
-
const
|
|
35
|
-
snapshot
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
colors: ColorizerService,
|
|
39
|
-
now: number,
|
|
40
|
-
): string => {
|
|
41
|
-
const progressbar = snapshot.config;
|
|
42
|
-
const prefix = `${" ".repeat(depth)}- ${snapshot.description}: `;
|
|
43
|
-
const elapsed = formatElapsed(snapshot, now);
|
|
30
|
+
const formatEta = (snapshot: TaskSnapshot, now: number): string => {
|
|
31
|
+
if (snapshot.status !== "running") {
|
|
32
|
+
return "ETA: --";
|
|
33
|
+
}
|
|
44
34
|
|
|
45
|
-
if (snapshot.
|
|
46
|
-
return
|
|
35
|
+
if (snapshot.units._tag !== "DeterminateTaskUnits") {
|
|
36
|
+
return "ETA: --";
|
|
47
37
|
}
|
|
48
38
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
39
|
+
const { completed, total } = snapshot.units;
|
|
40
|
+
const remaining = total - completed;
|
|
41
|
+
if (completed <= 0 || remaining <= 0) {
|
|
42
|
+
return "ETA: --";
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const elapsedMillis = Math.max(1, now - snapshot.startedAt);
|
|
46
|
+
const etaMillis = Math.max(0, Math.floor((elapsedMillis / completed) * remaining));
|
|
47
|
+
const duration = Duration.seconds(Math.floor(etaMillis / 1000));
|
|
48
|
+
return `ETA: ${Duration.format(duration)}`;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/** Defines how a cell claims width during the shrink/fit stage. */
|
|
52
|
+
export type ColumnTrack =
|
|
53
|
+
| {
|
|
54
|
+
readonly _tag: "Auto";
|
|
55
|
+
}
|
|
56
|
+
| {
|
|
57
|
+
readonly _tag: "Fixed";
|
|
58
|
+
readonly width: number;
|
|
52
59
|
}
|
|
53
|
-
|
|
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;
|
|
76
|
+
|
|
77
|
+
/** Tree relationship metadata for rendering connectors on each row. */
|
|
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
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Unstyled text token emitted by build and consumed by color stage. */
|
|
93
|
+
export interface Segment {
|
|
94
|
+
readonly text: string;
|
|
95
|
+
readonly role: ThemeRole;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** How a cell behaves when width is smaller than its intrinsic content. */
|
|
99
|
+
export type CellWrapMode = "truncate" | "no-wrap-ellipsis";
|
|
100
|
+
|
|
101
|
+
/** Logical, uncolored cell description produced by the build stage. */
|
|
102
|
+
export interface CellModel {
|
|
103
|
+
readonly id: string;
|
|
104
|
+
readonly track?: ColumnTrack;
|
|
105
|
+
readonly minWidth?: number;
|
|
106
|
+
readonly maxWidth?: number;
|
|
107
|
+
readonly wrapMode?: CellWrapMode;
|
|
108
|
+
readonly collapsePriority?: number;
|
|
109
|
+
readonly intrinsicWidth?: number;
|
|
110
|
+
readonly segments: ReadonlyArray<Segment>;
|
|
111
|
+
readonly renderAtWidth?: (width: number) => ReadonlyArray<Segment>;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** A logical line composed of cells prior to width fitting and coloring. */
|
|
115
|
+
export interface LogicalRow {
|
|
116
|
+
readonly cells: ReadonlyArray<CellModel>;
|
|
117
|
+
readonly gap?: number;
|
|
118
|
+
readonly lineVariant?: "lead" | "continuation";
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** All rows produced for one task entry (single-line or multi-line). */
|
|
122
|
+
export interface TaskBlockModel {
|
|
123
|
+
readonly taskId: number;
|
|
124
|
+
readonly depth: number;
|
|
125
|
+
readonly theme: ThemeService;
|
|
126
|
+
readonly rows: ReadonlyArray<LogicalRow>;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Full uncolored frame model for all currently rendered tasks. */
|
|
130
|
+
export interface FrameModel {
|
|
131
|
+
readonly taskBlocks: ReadonlyArray<TaskBlockModel>;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Cell after shrink/fit with concrete width and fitted segments. */
|
|
135
|
+
export interface FittedCell {
|
|
136
|
+
readonly id: string;
|
|
137
|
+
readonly width: number;
|
|
138
|
+
readonly segments: ReadonlyArray<Segment>;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Row after shrink/fit with concrete per-cell widths. */
|
|
142
|
+
export interface FittedRow {
|
|
143
|
+
readonly depth: number;
|
|
144
|
+
readonly theme: ThemeService;
|
|
145
|
+
readonly gap: number;
|
|
146
|
+
readonly cells: ReadonlyArray<FittedCell>;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Fitted rows for a single task block. */
|
|
150
|
+
export interface FittedTaskBlock {
|
|
151
|
+
readonly taskId: number;
|
|
152
|
+
readonly rows: ReadonlyArray<FittedRow>;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Full frame after fit but before ANSI styling. */
|
|
156
|
+
export interface FittedFrameModel {
|
|
157
|
+
readonly taskBlocks: ReadonlyArray<FittedTaskBlock>;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Inputs required by the default build stage implementation. */
|
|
161
|
+
export interface BuildStageBuildOptions {
|
|
162
|
+
readonly orderedTasks: ReadonlyArray<OrderedTaskModel>;
|
|
163
|
+
readonly rendererConfig: RendererConfigShape;
|
|
164
|
+
readonly now: number;
|
|
165
|
+
readonly tick: number;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Public extension point: convert task snapshots to a logical frame model. */
|
|
169
|
+
export interface BuildStageService {
|
|
170
|
+
readonly buildFrame: (options: BuildStageBuildOptions) => FrameModel;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Width constraints applied by the shrink stage. */
|
|
174
|
+
export interface ShrinkWidthConstraints {
|
|
175
|
+
readonly terminalColumns: number | undefined;
|
|
176
|
+
readonly maxTaskWidth: number | undefined;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Inputs for shrink/fit stage execution. */
|
|
180
|
+
export interface ShrinkStageFitOptions {
|
|
181
|
+
readonly frame: FrameModel;
|
|
182
|
+
readonly width: ShrinkWidthConstraints;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Public extension point: resolve concrete widths and truncate content. */
|
|
186
|
+
export interface ShrinkStageService {
|
|
187
|
+
readonly fitFrame: (options: ShrinkStageFitOptions) => FittedFrameModel;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Inputs for the color/materialization stage. */
|
|
191
|
+
export interface ColorStageColorOptions {
|
|
192
|
+
readonly frame: FittedFrameModel;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Public extension point: style a fitted frame into terminal lines. */
|
|
196
|
+
export interface ColorStageService {
|
|
197
|
+
readonly colorFrame: (options: ColorStageColorOptions) => ReadonlyArray<string>;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Inputs for the top-level frame renderer loop. */
|
|
201
|
+
export interface FrameRendererRunOptions {
|
|
202
|
+
readonly storeRef: Ref.Ref<TaskStore>;
|
|
203
|
+
readonly logsRef: Ref.Ref<ReadonlyArray<string>>;
|
|
204
|
+
readonly pendingLogsRef: Ref.Ref<ReadonlyArray<string>>;
|
|
205
|
+
readonly dirtyRef: Ref.Ref<boolean>;
|
|
206
|
+
readonly terminal: ProgressTerminalService;
|
|
207
|
+
readonly isTTY: boolean;
|
|
208
|
+
readonly rendererConfig: RendererConfigShape;
|
|
209
|
+
readonly maxRetainedLogLines: number;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Service that owns the render loop and writes frames to the terminal. */
|
|
213
|
+
export interface FrameRendererService {
|
|
214
|
+
readonly run: (options: FrameRendererRunOptions) => Effect.Effect<void>;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const createSegment = (text: string, role: ThemeRole): Segment => ({ text, role });
|
|
218
|
+
|
|
219
|
+
const getCellIntrinsicWidth = (cell: CellModel): number => {
|
|
220
|
+
if (cell.intrinsicWidth !== undefined) {
|
|
221
|
+
return Math.max(0, Math.floor(cell.intrinsicWidth));
|
|
54
222
|
}
|
|
55
223
|
|
|
56
|
-
|
|
57
|
-
|
|
224
|
+
return segmentWidth(cell.segments);
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
const getCellBounds = (cell: CellModel): { min: number; max?: number } => {
|
|
228
|
+
const min = Math.max(0, Math.floor(cell.minWidth ?? 0));
|
|
229
|
+
const max = cell.maxWidth === undefined ? undefined : Math.max(min, Math.floor(cell.maxWidth));
|
|
230
|
+
return { min, max };
|
|
231
|
+
};
|
|
232
|
+
|
|
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
|
+
const ratioDistribute = (
|
|
238
|
+
total: number,
|
|
239
|
+
ratios: ReadonlyArray<number>,
|
|
240
|
+
minimums: ReadonlyArray<number>,
|
|
241
|
+
): ReadonlyArray<number> => {
|
|
242
|
+
const amounts = [...minimums];
|
|
243
|
+
const totalMinimum = amounts.reduce((sum, value) => sum + value, 0);
|
|
244
|
+
const distributable = Math.max(0, total - totalMinimum);
|
|
245
|
+
|
|
246
|
+
let remaining = distributable;
|
|
247
|
+
let totalRatio = ratios.reduce((sum, ratio) => sum + ratio, 0);
|
|
248
|
+
|
|
249
|
+
for (let i = 0; i < ratios.length; i++) {
|
|
250
|
+
if (remaining <= 0) {
|
|
251
|
+
break;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const ratio = ratios[i] ?? 0;
|
|
255
|
+
const share = totalRatio > 0 ? Math.ceil((ratio * remaining) / totalRatio) : 0;
|
|
256
|
+
amounts[i] = (amounts[i] ?? 0) + share;
|
|
257
|
+
remaining -= share;
|
|
258
|
+
totalRatio -= ratio;
|
|
58
259
|
}
|
|
59
260
|
|
|
60
|
-
|
|
61
|
-
const frameIndex = (snapshot.units.spinnerFrame + tick) % frames.length;
|
|
62
|
-
const frame = frames[frameIndex] ?? frames[0]!;
|
|
63
|
-
return `${prefix}${colors.spinner(frame)}${elapsed}`;
|
|
261
|
+
return amounts;
|
|
64
262
|
};
|
|
65
263
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
pendingLogsRef: Ref.Ref<ReadonlyArray<string>>,
|
|
70
|
-
dirtyRef: Ref.Ref<boolean>,
|
|
71
|
-
terminal: ProgressTerminalService,
|
|
72
|
-
isTTY: boolean,
|
|
73
|
-
rendererConfig: RendererConfigShape,
|
|
74
|
-
maxRetainedLogLines: number,
|
|
75
|
-
) =>
|
|
76
|
-
Effect.gen(function* () {
|
|
77
|
-
const colorizer = yield* Colorizer;
|
|
78
|
-
const retainLogHistory = maxRetainedLogLines > 0;
|
|
79
|
-
let previousLineCount = 0;
|
|
80
|
-
let nonTTYTaskSignatureById = new Map<number, string>();
|
|
81
|
-
let tick = 0;
|
|
82
|
-
let rendererActive = false;
|
|
83
|
-
let sessionActive = false;
|
|
84
|
-
|
|
85
|
-
const clipTTYFrameLines = (lines: ReadonlyArray<string>) =>
|
|
86
|
-
Effect.gen(function* () {
|
|
87
|
-
const terminalRows = yield* terminal.stderrRows;
|
|
88
|
-
if (terminalRows === undefined) {
|
|
89
|
-
return lines;
|
|
90
|
-
}
|
|
264
|
+
const resolveTotalWidth = (width: ShrinkWidthConstraints): number | undefined => {
|
|
265
|
+
const maxTaskWidth =
|
|
266
|
+
width.maxTaskWidth === undefined ? undefined : Math.max(1, Math.floor(width.maxTaskWidth));
|
|
91
267
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
}
|
|
268
|
+
if (width.terminalColumns === undefined) {
|
|
269
|
+
return maxTaskWidth;
|
|
270
|
+
}
|
|
96
271
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
272
|
+
const terminalColumns = Math.max(1, Math.floor(width.terminalColumns));
|
|
273
|
+
if (maxTaskWidth === undefined) {
|
|
274
|
+
return terminalColumns;
|
|
275
|
+
}
|
|
100
276
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
277
|
+
return Math.min(terminalColumns, maxTaskWidth);
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
const shrinkByPriority = (
|
|
281
|
+
widths: Array<number>,
|
|
282
|
+
minWidths: ReadonlyArray<number>,
|
|
283
|
+
cells: ReadonlyArray<CellModel>,
|
|
284
|
+
overflow: number,
|
|
285
|
+
): number => {
|
|
286
|
+
// First collapse pass: only columns marked as wrapable/truncatable,
|
|
287
|
+
// processed by explicit collapse priority.
|
|
288
|
+
const shrinkable = cells
|
|
289
|
+
.map((cell, index) => ({
|
|
290
|
+
index,
|
|
291
|
+
priority: cell.collapsePriority ?? Number.MAX_SAFE_INTEGER,
|
|
292
|
+
wrapMode: cell.wrapMode ?? "truncate",
|
|
293
|
+
}))
|
|
294
|
+
.filter((entry) => entry.wrapMode === "truncate")
|
|
295
|
+
.sort((a, b) => a.priority - b.priority);
|
|
296
|
+
|
|
297
|
+
let remainingOverflow = overflow;
|
|
107
298
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
299
|
+
for (const entry of shrinkable) {
|
|
300
|
+
if (remainingOverflow <= 0) {
|
|
301
|
+
break;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const current = widths[entry.index] ?? 0;
|
|
305
|
+
const min = minWidths[entry.index] ?? 0;
|
|
306
|
+
const available = Math.max(0, current - min);
|
|
307
|
+
if (available <= 0) {
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const reduceBy = Math.min(available, remainingOverflow);
|
|
312
|
+
widths[entry.index] = current - reduceBy;
|
|
313
|
+
remainingOverflow -= reduceBy;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
return remainingOverflow;
|
|
317
|
+
};
|
|
318
|
+
|
|
319
|
+
const shrinkProportionally = (
|
|
320
|
+
widths: Array<number>,
|
|
321
|
+
minWidths: ReadonlyArray<number>,
|
|
322
|
+
overflow: number,
|
|
323
|
+
): number => {
|
|
324
|
+
// Last-resort collapse pass: if priority-based shrinking is not enough,
|
|
325
|
+
// reduce all remaining shrinkable columns proportionally.
|
|
326
|
+
let remainingOverflow = overflow;
|
|
327
|
+
|
|
328
|
+
while (remainingOverflow > 0) {
|
|
329
|
+
const entries = widths
|
|
330
|
+
.map((width, index) => ({
|
|
331
|
+
index,
|
|
332
|
+
width,
|
|
333
|
+
min: minWidths[index] ?? 0,
|
|
334
|
+
available: Math.max(0, width - (minWidths[index] ?? 0)),
|
|
335
|
+
}))
|
|
336
|
+
.filter((entry) => entry.available > 0);
|
|
337
|
+
|
|
338
|
+
if (entries.length === 0) {
|
|
339
|
+
break;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const distribution = ratioDistribute(
|
|
343
|
+
remainingOverflow,
|
|
344
|
+
entries.map((entry) => Math.max(1, entry.width)),
|
|
345
|
+
entries.map(() => 0),
|
|
346
|
+
);
|
|
347
|
+
|
|
348
|
+
let reduced = 0;
|
|
349
|
+
|
|
350
|
+
for (let i = 0; i < entries.length; i++) {
|
|
351
|
+
const entry = entries[i]!;
|
|
352
|
+
const target = distribution[i] ?? 0;
|
|
353
|
+
if (target <= 0) {
|
|
354
|
+
continue;
|
|
111
355
|
}
|
|
112
356
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
357
|
+
const reduceBy = Math.min(entry.available, target);
|
|
358
|
+
if (reduceBy <= 0) {
|
|
359
|
+
continue;
|
|
360
|
+
}
|
|
116
361
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
362
|
+
widths[entry.index] = (widths[entry.index] ?? 0) - reduceBy;
|
|
363
|
+
remainingOverflow -= reduceBy;
|
|
364
|
+
reduced += reduceBy;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
if (reduced <= 0) {
|
|
368
|
+
break;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
return remainingOverflow;
|
|
373
|
+
};
|
|
374
|
+
|
|
375
|
+
const resolveRowWidths = (
|
|
376
|
+
row: LogicalRow,
|
|
377
|
+
totalWidth: number | undefined,
|
|
378
|
+
): ReadonlyArray<number> => {
|
|
379
|
+
// 1) Resolve base widths from tracks + intrinsic size.
|
|
380
|
+
// 2) Distribute extra room to fraction columns.
|
|
381
|
+
// 3) If overflowing: priority-based collapse, then proportional fallback.
|
|
382
|
+
const gap = Math.max(0, Math.floor(row.gap ?? 1));
|
|
383
|
+
const cells = row.cells;
|
|
384
|
+
|
|
385
|
+
if (cells.length === 0) {
|
|
386
|
+
return [];
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
const minWidths = cells.map((cell) => getCellBounds(cell).min);
|
|
390
|
+
const maxWidths = cells.map((cell) => getCellBounds(cell).max);
|
|
391
|
+
const widths: Array<number> = Array.from({ length: cells.length }, () => 0);
|
|
392
|
+
|
|
393
|
+
for (let i = 0; i < cells.length; i++) {
|
|
394
|
+
const cell = cells[i]!;
|
|
395
|
+
const track = resolveTrack(cell);
|
|
396
|
+
const minWidth = minWidths[i] ?? 0;
|
|
397
|
+
const maxWidth = maxWidths[i];
|
|
398
|
+
const intrinsic = getCellIntrinsicWidth(cell);
|
|
399
|
+
|
|
400
|
+
const baseWidth = (() => {
|
|
401
|
+
switch (track._tag) {
|
|
402
|
+
case "Fixed":
|
|
403
|
+
return Math.max(minWidth, track.width);
|
|
404
|
+
case "Fraction":
|
|
405
|
+
return minWidth;
|
|
406
|
+
case "Auto":
|
|
407
|
+
return Math.max(minWidth, intrinsic);
|
|
120
408
|
}
|
|
409
|
+
})();
|
|
121
410
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
});
|
|
411
|
+
widths[i] =
|
|
412
|
+
maxWidth === undefined ? baseWidth : clamp(baseWidth, minWidth, Math.max(minWidth, maxWidth));
|
|
413
|
+
}
|
|
126
414
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
415
|
+
if (totalWidth === undefined) {
|
|
416
|
+
return widths;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
const usableWidth = Math.max(1, totalWidth - gap * Math.max(0, cells.length - 1));
|
|
420
|
+
|
|
421
|
+
const fractionColumns: Array<{ index: number; weight: number }> = [];
|
|
422
|
+
for (let index = 0; index < cells.length; index++) {
|
|
423
|
+
const track = resolveTrack(cells[index]!);
|
|
424
|
+
if (track._tag === "Fraction") {
|
|
425
|
+
fractionColumns.push({ index, weight: track.weight });
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
let remaining = usableWidth - widths.reduce((sum, width) => sum + width, 0);
|
|
430
|
+
|
|
431
|
+
if (remaining > 0 && fractionColumns.length > 0) {
|
|
432
|
+
const distributed = ratioDistribute(
|
|
433
|
+
remaining,
|
|
434
|
+
fractionColumns.map((entry) => entry.weight),
|
|
435
|
+
fractionColumns.map(() => 0),
|
|
436
|
+
);
|
|
437
|
+
|
|
438
|
+
for (let i = 0; i < fractionColumns.length; i++) {
|
|
439
|
+
const entry = fractionColumns[i]!;
|
|
440
|
+
widths[entry.index] = (widths[entry.index] ?? 0) + (distributed[i] ?? 0);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
remaining = usableWidth - widths.reduce((sum, width) => sum + width, 0);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
if (remaining < 0) {
|
|
447
|
+
let overflow = -remaining;
|
|
448
|
+
overflow = shrinkByPriority(widths, minWidths, cells, overflow);
|
|
449
|
+
if (overflow > 0) {
|
|
450
|
+
overflow = shrinkProportionally(widths, minWidths, overflow);
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
return widths.map((width, index) => {
|
|
455
|
+
const min = minWidths[index] ?? 0;
|
|
456
|
+
const max = maxWidths[index];
|
|
457
|
+
|
|
458
|
+
if (max === undefined) {
|
|
459
|
+
return Math.max(min, width);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
return clamp(Math.max(min, width), min, max);
|
|
463
|
+
});
|
|
464
|
+
};
|
|
465
|
+
|
|
466
|
+
interface CharacterToken {
|
|
467
|
+
readonly char: string;
|
|
468
|
+
readonly role: ThemeRole;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
const toCharacterTokens = (segments: ReadonlyArray<Segment>): Array<CharacterToken> => {
|
|
472
|
+
const tokens: Array<CharacterToken> = [];
|
|
473
|
+
|
|
474
|
+
for (const segment of segments) {
|
|
475
|
+
for (const char of Array.from(segment.text)) {
|
|
476
|
+
tokens.push({ char, role: segment.role });
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
return tokens;
|
|
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;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
buffer += token.char;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
if (buffer.length > 0) {
|
|
504
|
+
segments.push(createSegment(buffer, currentRole));
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
return segments;
|
|
508
|
+
};
|
|
509
|
+
|
|
510
|
+
const fitSegments = (
|
|
511
|
+
segments: ReadonlyArray<Segment>,
|
|
512
|
+
width: number,
|
|
513
|
+
wrapMode: CellWrapMode,
|
|
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" }];
|
|
148
532
|
}
|
|
149
533
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
+
}
|
|
154
538
|
|
|
155
|
-
|
|
156
|
-
|
|
539
|
+
return chars.slice(0, target);
|
|
540
|
+
})();
|
|
541
|
+
|
|
542
|
+
const truncatedSegments = fromCharacterTokens(truncatedChars);
|
|
543
|
+
const visibleWidth = segmentWidth(truncatedSegments);
|
|
544
|
+
|
|
545
|
+
if (visibleWidth >= target) {
|
|
546
|
+
return truncatedSegments;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
return [...truncatedSegments, createSegment(" ".repeat(target - visibleWidth), "plain")];
|
|
550
|
+
};
|
|
551
|
+
|
|
552
|
+
const treeAncestorPrefix = (tree: TaskTreeInfo): string =>
|
|
553
|
+
tree.ancestorHasNextSibling.map((hasNext) => (hasNext ? "│ " : " ")).join("");
|
|
554
|
+
|
|
555
|
+
const renderTreePrefix = (tree: TaskTreeInfo, variant: "lead" | "continuation"): string => {
|
|
556
|
+
if (tree.depth <= 0) {
|
|
557
|
+
return "";
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
const ancestor = treeAncestorPrefix(tree);
|
|
561
|
+
|
|
562
|
+
if (variant === "lead") {
|
|
563
|
+
return `${ancestor}${tree.hasNextSibling ? "├─ " : "└─ "}`;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
const trunk = `${ancestor}${tree.hasNextSibling ? "│ " : " "}`;
|
|
567
|
+
if (tree.hasChildren) {
|
|
568
|
+
return `${trunk}│ `;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
return trunk;
|
|
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);
|
|
578
|
+
|
|
579
|
+
for (let i = 0; i < ordered.length; i++) {
|
|
580
|
+
const depth = ordered[i]!.depth;
|
|
581
|
+
for (let j = i + 1; j < ordered.length; j++) {
|
|
582
|
+
const candidateDepth = ordered[j]!.depth;
|
|
583
|
+
if (candidateDepth < depth) {
|
|
584
|
+
break;
|
|
585
|
+
}
|
|
586
|
+
if (candidateDepth === depth) {
|
|
587
|
+
hasNextSiblingByIndex[i] = true;
|
|
588
|
+
break;
|
|
589
|
+
}
|
|
590
|
+
}
|
|
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
|
+
};
|
|
617
|
+
});
|
|
618
|
+
};
|
|
619
|
+
|
|
620
|
+
const treeCell = (tree: TaskTreeInfo, variant: "lead" | "continuation"): CellModel => ({
|
|
621
|
+
id: "tree",
|
|
622
|
+
track: Track.auto(),
|
|
623
|
+
wrapMode: "truncate",
|
|
624
|
+
collapsePriority: 100,
|
|
625
|
+
minWidth: 0,
|
|
626
|
+
segments: [createSegment(renderTreePrefix(tree, variant), "treeConnector")],
|
|
627
|
+
});
|
|
628
|
+
|
|
629
|
+
const textCell = (text: string): CellModel => ({
|
|
630
|
+
id: "text",
|
|
631
|
+
track: Track.auto(),
|
|
632
|
+
wrapMode: "no-wrap-ellipsis",
|
|
633
|
+
collapsePriority: 50,
|
|
634
|
+
minWidth: 4,
|
|
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
|
+
});
|
|
726
|
+
|
|
727
|
+
const elapsedCell = (snapshot: TaskSnapshot, now: number): CellModel => ({
|
|
728
|
+
id: "elapsed",
|
|
729
|
+
track: Track.auto(),
|
|
730
|
+
minWidth: 4,
|
|
731
|
+
wrapMode: "truncate",
|
|
732
|
+
collapsePriority: 30,
|
|
733
|
+
segments: [createSegment(formatElapsed(snapshot, now), "elapsed")],
|
|
734
|
+
});
|
|
735
|
+
|
|
736
|
+
const statusCell = (snapshot: TaskSnapshot): CellModel => {
|
|
737
|
+
if (snapshot.status === "done") {
|
|
738
|
+
return {
|
|
739
|
+
id: "status",
|
|
740
|
+
track: Track.auto(),
|
|
741
|
+
minWidth: 6,
|
|
742
|
+
wrapMode: "truncate",
|
|
743
|
+
collapsePriority: 60,
|
|
744
|
+
segments: [createSegment("done", "statusDone")],
|
|
157
745
|
};
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
if (snapshot.status === "failed") {
|
|
749
|
+
return {
|
|
750
|
+
id: "status",
|
|
751
|
+
track: Track.auto(),
|
|
752
|
+
minWidth: 8,
|
|
753
|
+
wrapMode: "truncate",
|
|
754
|
+
collapsePriority: 60,
|
|
755
|
+
segments: [createSegment("[failed]", "statusFailed")],
|
|
756
|
+
};
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
return {
|
|
760
|
+
id: "status",
|
|
761
|
+
segments: [],
|
|
762
|
+
};
|
|
763
|
+
};
|
|
764
|
+
|
|
765
|
+
const defaultBuildStageService: BuildStageService = {
|
|
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
|
+
);
|
|
158
771
|
|
|
159
|
-
const
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
772
|
+
const taskBlocks: Array<TaskBlockModel> = orderedWithTree.map((entry, index) => {
|
|
773
|
+
const orderedEntry = orderedTasks[index]!;
|
|
774
|
+
const snapshot = orderedEntry.snapshot;
|
|
775
|
+
const tree = entry.tree;
|
|
776
|
+
const isDeterminate = snapshot.units._tag === "DeterminateTaskUnits";
|
|
777
|
+
const showTwoLineDeterminate =
|
|
778
|
+
isDeterminate && rendererConfig.determinateTaskLayout === "two-lines";
|
|
779
|
+
|
|
780
|
+
const runningOrDoneDeterminate =
|
|
781
|
+
isDeterminate && (snapshot.status === "running" || snapshot.status === "done");
|
|
782
|
+
|
|
783
|
+
const determinateFailure = isDeterminate && snapshot.status === "failed";
|
|
784
|
+
|
|
785
|
+
const rows: Array<LogicalRow> = [];
|
|
786
|
+
|
|
787
|
+
if (runningOrDoneDeterminate) {
|
|
788
|
+
if (showTwoLineDeterminate) {
|
|
789
|
+
rows.push({
|
|
790
|
+
lineVariant: "lead",
|
|
791
|
+
cells: [treeCell(tree, "lead"), textCell(snapshot.description)],
|
|
792
|
+
});
|
|
793
|
+
|
|
794
|
+
rows.push({
|
|
795
|
+
lineVariant: "continuation",
|
|
796
|
+
cells: [
|
|
797
|
+
treeCell(tree, "continuation"),
|
|
798
|
+
barCell(snapshot),
|
|
799
|
+
unitsCell(snapshot),
|
|
800
|
+
snapshot.status === "running" ? etaCell(snapshot, now) : elapsedCell(snapshot, now),
|
|
801
|
+
],
|
|
802
|
+
});
|
|
803
|
+
} else {
|
|
804
|
+
rows.push({
|
|
805
|
+
lineVariant: "lead",
|
|
806
|
+
cells: [
|
|
807
|
+
treeCell(tree, "lead"),
|
|
808
|
+
textCell(snapshot.description),
|
|
809
|
+
barCell(snapshot),
|
|
810
|
+
unitsCell(snapshot),
|
|
811
|
+
snapshot.status === "running" ? etaCell(snapshot, now) : elapsedCell(snapshot, now),
|
|
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
|
+
],
|
|
167
853
|
});
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
+
],
|
|
174
863
|
});
|
|
864
|
+
}
|
|
175
865
|
|
|
176
|
-
|
|
177
|
-
|
|
866
|
+
return {
|
|
867
|
+
taskId: snapshot.id as number,
|
|
868
|
+
depth: orderedEntry.depth,
|
|
869
|
+
theme: orderedEntry.theme,
|
|
870
|
+
rows,
|
|
871
|
+
};
|
|
872
|
+
});
|
|
178
873
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
874
|
+
return {
|
|
875
|
+
taskBlocks,
|
|
876
|
+
};
|
|
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);
|
|
891
|
+
|
|
892
|
+
return {
|
|
893
|
+
depth: block.depth,
|
|
894
|
+
theme: block.theme,
|
|
895
|
+
gap,
|
|
896
|
+
cells: row.cells.map((cell, index) => {
|
|
897
|
+
const widthForCell = widths[index] ?? 0;
|
|
898
|
+
const rendered = cell.renderAtWidth?.(widthForCell) ?? cell.segments;
|
|
899
|
+
const wrapMode = cell.wrapMode ?? "truncate";
|
|
900
|
+
const fittedSegments = fitSegments(rendered, widthForCell, wrapMode);
|
|
901
|
+
|
|
902
|
+
return {
|
|
903
|
+
id: cell.id,
|
|
904
|
+
width: widthForCell,
|
|
905
|
+
segments: fittedSegments,
|
|
906
|
+
};
|
|
907
|
+
}),
|
|
908
|
+
};
|
|
909
|
+
}),
|
|
910
|
+
})),
|
|
911
|
+
};
|
|
912
|
+
},
|
|
913
|
+
};
|
|
914
|
+
|
|
915
|
+
const styleSegment = (segment: Segment, depth: number, theme: ThemeService): string => {
|
|
916
|
+
const byDepth = theme.depthPalette?.(depth, segment.role);
|
|
917
|
+
if (byDepth !== undefined) {
|
|
918
|
+
return byDepth(segment.text);
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
const style = theme.styles[segment.role] ?? theme.styles.plain;
|
|
922
|
+
return style(segment.text);
|
|
923
|
+
};
|
|
924
|
+
|
|
925
|
+
const defaultColorStageService: ColorStageService = {
|
|
926
|
+
colorFrame: ({ frame }) =>
|
|
927
|
+
frame.taskBlocks.flatMap((block) =>
|
|
928
|
+
block.rows.map((row) => {
|
|
929
|
+
const gap = " ".repeat(row.gap);
|
|
930
|
+
return row.cells
|
|
931
|
+
.map((cell) =>
|
|
932
|
+
cell.segments.map((segment) => styleSegment(segment, row.depth, row.theme)).join(""),
|
|
933
|
+
)
|
|
934
|
+
.join(gap)
|
|
935
|
+
.trimEnd();
|
|
936
|
+
}),
|
|
937
|
+
),
|
|
938
|
+
};
|
|
939
|
+
|
|
940
|
+
const makeDefaultFrameRenderer = (
|
|
941
|
+
buildStage: BuildStageService,
|
|
942
|
+
shrinkStage: ShrinkStageService,
|
|
943
|
+
colorStage: ColorStageService,
|
|
944
|
+
fallbackTheme: ThemeService,
|
|
945
|
+
): FrameRendererService => ({
|
|
946
|
+
run: ({
|
|
947
|
+
storeRef,
|
|
948
|
+
logsRef,
|
|
949
|
+
pendingLogsRef,
|
|
950
|
+
dirtyRef,
|
|
951
|
+
terminal,
|
|
952
|
+
isTTY,
|
|
953
|
+
rendererConfig,
|
|
954
|
+
maxRetainedLogLines,
|
|
955
|
+
}) =>
|
|
956
|
+
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
|
+
const retainLogHistory = maxRetainedLogLines > 0;
|
|
960
|
+
let previousLineCount = 0;
|
|
961
|
+
let nonTTYTaskSignatureById = new Map<number, string>();
|
|
962
|
+
let tick = 0;
|
|
963
|
+
let rendererActive = false;
|
|
964
|
+
let sessionActive = false;
|
|
965
|
+
|
|
966
|
+
const clipTTYFrameLines = (lines: ReadonlyArray<string>) =>
|
|
967
|
+
Effect.gen(function* () {
|
|
968
|
+
const terminalRows = yield* terminal.stderrRows;
|
|
969
|
+
if (terminalRows === undefined) {
|
|
970
|
+
return lines;
|
|
185
971
|
}
|
|
186
972
|
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
if (lines.length > 0) {
|
|
191
|
-
frame += lines.join("\n");
|
|
192
|
-
}
|
|
193
|
-
previousLineCount = lines.length;
|
|
194
|
-
} else {
|
|
195
|
-
// 2. Logs (scroll above the task block)
|
|
196
|
-
if (drainedLogs.length > 0) {
|
|
197
|
-
frame += drainedLogs.join("\n") + "\n";
|
|
198
|
-
}
|
|
199
|
-
// 3. Task lines
|
|
200
|
-
if (taskLines.length > 0) {
|
|
201
|
-
frame += taskLines.join("\n");
|
|
202
|
-
}
|
|
203
|
-
previousLineCount = taskLines.length;
|
|
973
|
+
const visibleLineLimit = Math.max(1, terminalRows - 1);
|
|
974
|
+
if (lines.length <= visibleLineLimit) {
|
|
975
|
+
return lines;
|
|
204
976
|
}
|
|
205
977
|
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
yield* terminal.writeStderr(frame);
|
|
978
|
+
if (visibleLineLimit === 1) {
|
|
979
|
+
return [`... ${lines.length} lines hidden`];
|
|
209
980
|
}
|
|
981
|
+
|
|
982
|
+
const hiddenLineCount = lines.length - visibleLineLimit + 1;
|
|
983
|
+
return [
|
|
984
|
+
`... ${hiddenLineCount} lines hidden (showing latest lines)`,
|
|
985
|
+
...lines.slice(lines.length - (visibleLineLimit - 1)),
|
|
986
|
+
];
|
|
987
|
+
});
|
|
988
|
+
|
|
989
|
+
const startTTYSession = Effect.gen(function* () {
|
|
990
|
+
if (!isTTY || sessionActive) {
|
|
210
991
|
return;
|
|
211
992
|
}
|
|
212
993
|
|
|
213
|
-
|
|
214
|
-
|
|
994
|
+
yield* terminal.writeStderr(HIDE_CURSOR);
|
|
995
|
+
sessionActive = true;
|
|
996
|
+
});
|
|
997
|
+
|
|
998
|
+
const stopTTYSession = Effect.gen(function* () {
|
|
999
|
+
if (!isTTY || !sessionActive) {
|
|
1000
|
+
return;
|
|
215
1001
|
}
|
|
216
|
-
|
|
1002
|
+
|
|
1003
|
+
yield* terminal.writeStderr("\n" + SHOW_CURSOR);
|
|
1004
|
+
previousLineCount = 0;
|
|
1005
|
+
sessionActive = false;
|
|
217
1006
|
});
|
|
218
1007
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
1008
|
+
const renderNonTTYTaskUpdates = (
|
|
1009
|
+
ordered: ReadonlyArray<{
|
|
1010
|
+
snapshot: TaskSnapshot;
|
|
1011
|
+
lines: ReadonlyArray<string>;
|
|
1012
|
+
}>,
|
|
1013
|
+
) => {
|
|
1014
|
+
const nextTaskSignatureById = new Map<number, string>();
|
|
1015
|
+
const changedTaskLines: Array<string> = [];
|
|
1016
|
+
const nonTtyUpdateStep = Math.max(1, Math.floor(rendererConfig.nonTtyUpdateStep));
|
|
224
1017
|
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
);
|
|
233
|
-
const hasPendingLogs = (yield* Ref.get(pendingLogsRef)).length > 0;
|
|
1018
|
+
for (let i = 0; i < ordered.length; i++) {
|
|
1019
|
+
const taskId = ordered[i]!.snapshot.id as number;
|
|
1020
|
+
const snapshot = ordered[i]!.snapshot;
|
|
1021
|
+
const signature =
|
|
1022
|
+
snapshot.units._tag === "DeterminateTaskUnits"
|
|
1023
|
+
? `${snapshot.status}:${snapshot.description}:${Math.floor(snapshot.units.completed / nonTtyUpdateStep)}:${snapshot.units.total}`
|
|
1024
|
+
: `${snapshot.status}:${snapshot.description}`;
|
|
234
1025
|
|
|
235
|
-
|
|
236
|
-
if (
|
|
237
|
-
|
|
1026
|
+
nextTaskSignatureById.set(taskId, signature);
|
|
1027
|
+
if (nonTTYTaskSignatureById.get(taskId) !== signature) {
|
|
1028
|
+
changedTaskLines.push(...ordered[i]!.lines);
|
|
238
1029
|
}
|
|
239
|
-
} else if (dirty || hasActiveSpinners) {
|
|
240
|
-
yield* renderFrame("tick");
|
|
241
1030
|
}
|
|
242
1031
|
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
1032
|
+
return Effect.gen(function* () {
|
|
1033
|
+
if (changedTaskLines.length > 0) {
|
|
1034
|
+
yield* terminal.writeStderr(changedTaskLines.join("\n") + "\n");
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
nonTTYTaskSignatureById = nextTaskSignatureById;
|
|
1038
|
+
});
|
|
1039
|
+
};
|
|
1040
|
+
|
|
1041
|
+
const renderFrame = (mode: "tick" | "final") =>
|
|
248
1042
|
Effect.gen(function* () {
|
|
249
|
-
|
|
250
|
-
|
|
1043
|
+
// Materialize one frame snapshot from current state.
|
|
1044
|
+
const drainedLogs = yield* Ref.getAndSet(pendingLogsRef, []);
|
|
1045
|
+
const store = yield* Ref.get(storeRef);
|
|
1046
|
+
const orderedTasks = store.renderOrder.flatMap((row) => {
|
|
1047
|
+
const snapshot = store.tasks.get(row.id);
|
|
1048
|
+
if (!snapshot || (snapshot.transient && snapshot.status !== "running")) {
|
|
1049
|
+
return [];
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
return [
|
|
1053
|
+
{
|
|
1054
|
+
snapshot,
|
|
1055
|
+
depth: row.depth,
|
|
1056
|
+
theme: store.themes.get(snapshot.id) ?? fallbackTheme,
|
|
1057
|
+
},
|
|
1058
|
+
];
|
|
1059
|
+
});
|
|
1060
|
+
|
|
1061
|
+
const now = yield* Clock.currentTimeMillis;
|
|
1062
|
+
const frameTick = mode === "final" ? tick + 1 : tick;
|
|
1063
|
+
const terminalColumns = isTTY ? yield* terminal.stderrColumns : undefined;
|
|
1064
|
+
const maxTaskWidth = rendererConfig.maxTaskWidth;
|
|
1065
|
+
|
|
1066
|
+
const frameModel = buildStage.buildFrame({
|
|
1067
|
+
orderedTasks,
|
|
1068
|
+
rendererConfig,
|
|
1069
|
+
now,
|
|
1070
|
+
tick: isTTY ? frameTick : 0,
|
|
1071
|
+
});
|
|
1072
|
+
|
|
1073
|
+
const fittedFrame = shrinkStage.fitFrame({
|
|
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;
|
|
251
1089
|
}
|
|
252
1090
|
|
|
253
1091
|
if (isTTY) {
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
1092
|
+
let frame = "";
|
|
1093
|
+
|
|
1094
|
+
if (previousLineCount > 0) {
|
|
1095
|
+
frame += "\r" + CLEAR_LINE;
|
|
1096
|
+
for (let i = 1; i < previousLineCount; i++) {
|
|
1097
|
+
frame += MOVE_UP_ONE + CLEAR_LINE;
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
if (retainLogHistory) {
|
|
1102
|
+
const historyLogs = yield* Ref.get(logsRef);
|
|
1103
|
+
const lines = yield* clipTTYFrameLines([...historyLogs, ...taskLines]);
|
|
1104
|
+
if (lines.length > 0) {
|
|
1105
|
+
frame += lines.join("\n");
|
|
1106
|
+
}
|
|
1107
|
+
previousLineCount = lines.length;
|
|
1108
|
+
} else {
|
|
1109
|
+
if (drainedLogs.length > 0) {
|
|
1110
|
+
frame += drainedLogs.join("\n") + "\n";
|
|
1111
|
+
}
|
|
1112
|
+
if (taskLines.length > 0) {
|
|
1113
|
+
frame += taskLines.join("\n");
|
|
1114
|
+
}
|
|
1115
|
+
previousLineCount = taskLines.length;
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
if (frame) {
|
|
1119
|
+
yield* terminal.writeStderr(frame);
|
|
257
1120
|
}
|
|
258
1121
|
return;
|
|
259
1122
|
}
|
|
260
1123
|
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
);
|
|
1124
|
+
if (drainedLogs.length > 0) {
|
|
1125
|
+
yield* terminal.writeStderr(drainedLogs.join("\n") + "\n");
|
|
1126
|
+
}
|
|
265
1127
|
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
1128
|
+
const orderedForNonTTY = orderedTasks.map((task) => ({
|
|
1129
|
+
snapshot: task.snapshot,
|
|
1130
|
+
lines: taskLineMap.get(task.snapshot.id as number) ?? [],
|
|
1131
|
+
}));
|
|
1132
|
+
yield* renderNonTTYTaskUpdates(orderedForNonTTY);
|
|
1133
|
+
});
|
|
269
1134
|
|
|
270
|
-
|
|
271
|
-
|
|
1135
|
+
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
|
+
rendererActive = true;
|
|
1139
|
+
if (isTTY) {
|
|
1140
|
+
yield* startTTYSession;
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
while (true) {
|
|
1144
|
+
const dirty = yield* Ref.getAndSet(dirtyRef, false);
|
|
1145
|
+
const tasks = Array.from((yield* Ref.get(storeRef)).tasks.values()).filter(
|
|
1146
|
+
(task) => !(task.transient && task.status !== "running"),
|
|
1147
|
+
);
|
|
1148
|
+
const hasActiveSpinners = tasks.some(
|
|
1149
|
+
(task) => task.status === "running" && task.units._tag === "IndeterminateTaskUnits",
|
|
1150
|
+
);
|
|
1151
|
+
const hasPendingLogs = (yield* Ref.get(pendingLogsRef)).length > 0;
|
|
1152
|
+
|
|
1153
|
+
if (isTTY) {
|
|
1154
|
+
if (dirty || hasActiveSpinners || hasPendingLogs) {
|
|
1155
|
+
yield* renderFrame("tick");
|
|
1156
|
+
}
|
|
1157
|
+
} else if (dirty || hasActiveSpinners) {
|
|
1158
|
+
yield* renderFrame("tick");
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
tick += 1;
|
|
1162
|
+
yield* Effect.sleep(Math.max(1, Math.floor(rendererConfig.renderIntervalMillis)));
|
|
1163
|
+
}
|
|
1164
|
+
}).pipe(
|
|
1165
|
+
Effect.ensuring(
|
|
1166
|
+
Effect.gen(function* () {
|
|
1167
|
+
if (!rendererActive) {
|
|
1168
|
+
return;
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
if (isTTY) {
|
|
1172
|
+
if (sessionActive) {
|
|
1173
|
+
yield* renderFrame("final");
|
|
1174
|
+
yield* stopTTYSession;
|
|
1175
|
+
}
|
|
1176
|
+
return;
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
yield* renderFrame("final");
|
|
1180
|
+
}),
|
|
1181
|
+
),
|
|
1182
|
+
);
|
|
1183
|
+
|
|
1184
|
+
if (isTTY && rendererConfig.disableUserInput) {
|
|
1185
|
+
return yield* terminal.withRawInputCapture(renderLoop);
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
return yield* renderLoop;
|
|
1189
|
+
}),
|
|
1190
|
+
});
|
|
1191
|
+
|
|
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
|
+
export class FrameRenderer extends Context.Tag("stromseng.dev/effective-progress/FrameRenderer")<
|
|
1214
|
+
FrameRenderer,
|
|
1215
|
+
FrameRendererService
|
|
1216
|
+
>() {
|
|
1217
|
+
static readonly Default = Layer.effect(
|
|
1218
|
+
FrameRenderer,
|
|
1219
|
+
Effect.gen(function* () {
|
|
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
|
+
}),
|
|
1226
|
+
);
|
|
1227
|
+
}
|