effective-progress 0.2.4 → 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 +45 -40
- package/package.json +1 -1
- package/src/api.ts +20 -7
- package/src/index.ts +2 -1
- package/src/renderer.ts +1155 -214
- package/src/runtime.ts +106 -32
- package/src/terminal.ts +12 -10
- package/src/theme.ts +87 -0
- package/src/types.ts +12 -11
- package/src/colors.ts +0 -156
package/src/renderer.ts
CHANGED
|
@@ -1,286 +1,1227 @@
|
|
|
1
|
-
import { Clock, Duration, Effect,
|
|
2
|
-
import {
|
|
3
|
-
type CompiledProgressBarColors,
|
|
4
|
-
compileProgressBarColors,
|
|
5
|
-
ProgressBarColorsSchema,
|
|
6
|
-
} from "./colors";
|
|
1
|
+
import { Clock, Context, Duration, Effect, Layer, Ref } from "effect";
|
|
2
|
+
import { Theme, type ThemeRole, type ThemeService } from "./theme";
|
|
7
3
|
import type { ProgressTerminalService } from "./terminal";
|
|
8
|
-
import type {
|
|
9
|
-
import { DeterminateTaskUnits, TaskSnapshot } from "./types";
|
|
4
|
+
import type { RendererConfigShape, TaskSnapshot, TaskStore } from "./types";
|
|
10
5
|
|
|
11
6
|
const HIDE_CURSOR = "\x1b[?25l";
|
|
12
7
|
const SHOW_CURSOR = "\x1b[?25h";
|
|
13
8
|
const CLEAR_LINE = "\x1b[2K";
|
|
14
9
|
const MOVE_UP_ONE = "\x1b[1A";
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
const bar = `${colors.fill(progressbar.fillChar.repeat(filled))}${colors.empty(progressbar.emptyChar.repeat(progressbar.barWidth - filled))}`;
|
|
26
|
-
const percent = String(Math.round(ratio * 100)).padStart(3, " ");
|
|
27
|
-
return `${colors.brackets(progressbar.leftBracket)}${bar}${colors.brackets(progressbar.rightBracket)} ${units.completed}/${units.total} ${colors.percent(percent + "%")}`;
|
|
28
|
-
};
|
|
10
|
+
|
|
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);
|
|
29
20
|
|
|
30
21
|
const formatElapsed = (snapshot: TaskSnapshot, now: number): string => {
|
|
31
|
-
const elapsedMillis = (snapshot.completedAt ?? now) - snapshot.startedAt;
|
|
22
|
+
const elapsedMillis = Math.max(0, (snapshot.completedAt ?? now) - snapshot.startedAt);
|
|
32
23
|
const duration =
|
|
33
24
|
snapshot.status === "running"
|
|
34
25
|
? Duration.seconds(Math.floor(elapsedMillis / 1000))
|
|
35
26
|
: Duration.millis(elapsedMillis);
|
|
36
|
-
return
|
|
27
|
+
return `${Duration.format(duration)}`;
|
|
37
28
|
};
|
|
38
29
|
|
|
39
|
-
const
|
|
40
|
-
snapshot
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
colors: CompiledProgressBarColors,
|
|
44
|
-
now: number,
|
|
45
|
-
): string => {
|
|
46
|
-
const progressbar = snapshot.config;
|
|
47
|
-
const prefix = `${" ".repeat(depth)}- ${snapshot.description}: `;
|
|
48
|
-
const elapsed = formatElapsed(snapshot, now);
|
|
30
|
+
const formatEta = (snapshot: TaskSnapshot, now: number): string => {
|
|
31
|
+
if (snapshot.status !== "running") {
|
|
32
|
+
return "ETA: --";
|
|
33
|
+
}
|
|
49
34
|
|
|
50
|
-
if (snapshot.
|
|
51
|
-
return
|
|
35
|
+
if (snapshot.units._tag !== "DeterminateTaskUnits") {
|
|
36
|
+
return "ETA: --";
|
|
52
37
|
}
|
|
53
38
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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;
|
|
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;
|
|
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));
|
|
222
|
+
}
|
|
223
|
+
|
|
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;
|
|
57
252
|
}
|
|
58
|
-
|
|
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;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
return amounts;
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
const resolveTotalWidth = (width: ShrinkWidthConstraints): number | undefined => {
|
|
265
|
+
const maxTaskWidth =
|
|
266
|
+
width.maxTaskWidth === undefined ? undefined : Math.max(1, Math.floor(width.maxTaskWidth));
|
|
267
|
+
|
|
268
|
+
if (width.terminalColumns === undefined) {
|
|
269
|
+
return maxTaskWidth;
|
|
59
270
|
}
|
|
60
271
|
|
|
61
|
-
|
|
62
|
-
|
|
272
|
+
const terminalColumns = Math.max(1, Math.floor(width.terminalColumns));
|
|
273
|
+
if (maxTaskWidth === undefined) {
|
|
274
|
+
return terminalColumns;
|
|
63
275
|
}
|
|
64
276
|
|
|
65
|
-
|
|
66
|
-
const frameIndex = (snapshot.units.spinnerFrame + tick) % frames.length;
|
|
67
|
-
const frame = frames[frameIndex] ?? frames[0]!;
|
|
68
|
-
return `${prefix}${colors.spinner(frame)}${elapsed}`;
|
|
277
|
+
return Math.min(terminalColumns, maxTaskWidth);
|
|
69
278
|
};
|
|
70
279
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
) => {
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
return cached;
|
|
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;
|
|
298
|
+
|
|
299
|
+
for (const entry of shrinkable) {
|
|
300
|
+
if (remainingOverflow <= 0) {
|
|
301
|
+
break;
|
|
94
302
|
}
|
|
95
303
|
|
|
96
|
-
const
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
+
}
|
|
100
310
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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;
|
|
106
355
|
}
|
|
107
356
|
|
|
108
|
-
const
|
|
109
|
-
if (
|
|
110
|
-
|
|
357
|
+
const reduceBy = Math.min(entry.available, target);
|
|
358
|
+
if (reduceBy <= 0) {
|
|
359
|
+
continue;
|
|
111
360
|
}
|
|
112
361
|
|
|
113
|
-
|
|
114
|
-
|
|
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);
|
|
115
408
|
}
|
|
409
|
+
})();
|
|
116
410
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
411
|
+
widths[i] =
|
|
412
|
+
maxWidth === undefined ? baseWidth : clamp(baseWidth, minWidth, Math.max(minWidth, maxWidth));
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
if (totalWidth === undefined) {
|
|
416
|
+
return widths;
|
|
417
|
+
}
|
|
123
418
|
|
|
124
|
-
const
|
|
125
|
-
|
|
126
|
-
|
|
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 });
|
|
127
426
|
}
|
|
427
|
+
}
|
|
128
428
|
|
|
129
|
-
|
|
130
|
-
sessionActive = true;
|
|
131
|
-
});
|
|
429
|
+
let remaining = usableWidth - widths.reduce((sum, width) => sum + width, 0);
|
|
132
430
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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);
|
|
136
441
|
}
|
|
137
442
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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);
|
|
141
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" }];
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
const keep = chars.slice(0, Math.max(0, target - 1));
|
|
535
|
+
const ellipsisRole = keep[keep.length - 1]?.role ?? chars[0]?.role ?? "plain";
|
|
536
|
+
return [...keep, { char: "…", role: ellipsisRole }];
|
|
537
|
+
}
|
|
538
|
+
|
|
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);
|
|
142
561
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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;
|
|
163
589
|
}
|
|
164
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")],
|
|
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
|
+
);
|
|
771
|
+
|
|
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";
|
|
165
784
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
+
],
|
|
853
|
+
});
|
|
854
|
+
} else {
|
|
855
|
+
rows.push({
|
|
856
|
+
lineVariant: "lead",
|
|
857
|
+
cells: [
|
|
858
|
+
treeCell(tree, "lead"),
|
|
859
|
+
textCell(snapshot.description),
|
|
860
|
+
statusCell(snapshot),
|
|
861
|
+
elapsedCell(snapshot, now),
|
|
862
|
+
],
|
|
863
|
+
});
|
|
169
864
|
}
|
|
170
865
|
|
|
171
|
-
|
|
866
|
+
return {
|
|
867
|
+
taskId: snapshot.id as number,
|
|
868
|
+
depth: orderedEntry.depth,
|
|
869
|
+
theme: orderedEntry.theme,
|
|
870
|
+
rows,
|
|
871
|
+
};
|
|
172
872
|
});
|
|
173
|
-
};
|
|
174
873
|
|
|
175
|
-
|
|
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
|
+
}) =>
|
|
176
956
|
Effect.gen(function* () {
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
const
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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;
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
const visibleLineLimit = Math.max(1, terminalRows - 1);
|
|
974
|
+
if (lines.length <= visibleLineLimit) {
|
|
975
|
+
return lines;
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
if (visibleLineLimit === 1) {
|
|
979
|
+
return [`... ${lines.length} lines hidden`];
|
|
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) {
|
|
991
|
+
return;
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
yield* terminal.writeStderr(HIDE_CURSOR);
|
|
995
|
+
sessionActive = true;
|
|
183
996
|
});
|
|
184
|
-
|
|
185
|
-
const
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
997
|
+
|
|
998
|
+
const stopTTYSession = Effect.gen(function* () {
|
|
999
|
+
if (!isTTY || !sessionActive) {
|
|
1000
|
+
return;
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
yield* terminal.writeStderr("\n" + SHOW_CURSOR);
|
|
1004
|
+
previousLineCount = 0;
|
|
1005
|
+
sessionActive = false;
|
|
189
1006
|
});
|
|
190
1007
|
|
|
191
|
-
|
|
192
|
-
|
|
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));
|
|
193
1017
|
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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}`;
|
|
1025
|
+
|
|
1026
|
+
nextTaskSignatureById.set(taskId, signature);
|
|
1027
|
+
if (nonTTYTaskSignatureById.get(taskId) !== signature) {
|
|
1028
|
+
changedTaskLines.push(...ordered[i]!.lines);
|
|
199
1029
|
}
|
|
200
1030
|
}
|
|
201
1031
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
if (lines.length > 0) {
|
|
206
|
-
frame += lines.join("\n");
|
|
1032
|
+
return Effect.gen(function* () {
|
|
1033
|
+
if (changedTaskLines.length > 0) {
|
|
1034
|
+
yield* terminal.writeStderr(changedTaskLines.join("\n") + "\n");
|
|
207
1035
|
}
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
1036
|
+
|
|
1037
|
+
nonTTYTaskSignatureById = nextTaskSignatureById;
|
|
1038
|
+
});
|
|
1039
|
+
};
|
|
1040
|
+
|
|
1041
|
+
const renderFrame = (mode: "tick" | "final") =>
|
|
1042
|
+
Effect.gen(function* () {
|
|
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;
|
|
213
1089
|
}
|
|
214
|
-
|
|
215
|
-
if (
|
|
216
|
-
frame
|
|
1090
|
+
|
|
1091
|
+
if (isTTY) {
|
|
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);
|
|
1120
|
+
}
|
|
1121
|
+
return;
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
if (drainedLogs.length > 0) {
|
|
1125
|
+
yield* terminal.writeStderr(drainedLogs.join("\n") + "\n");
|
|
217
1126
|
}
|
|
218
|
-
previousLineCount = taskLines.length;
|
|
219
|
-
}
|
|
220
1127
|
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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
|
+
});
|
|
1134
|
+
|
|
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;
|
|
224
1141
|
}
|
|
225
|
-
return;
|
|
226
|
-
}
|
|
227
1142
|
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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;
|
|
233
1152
|
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
1153
|
+
if (isTTY) {
|
|
1154
|
+
if (dirty || hasActiveSpinners || hasPendingLogs) {
|
|
1155
|
+
yield* renderFrame("tick");
|
|
1156
|
+
}
|
|
1157
|
+
} else if (dirty || hasActiveSpinners) {
|
|
1158
|
+
yield* renderFrame("tick");
|
|
1159
|
+
}
|
|
239
1160
|
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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
|
+
),
|
|
247
1182
|
);
|
|
248
|
-
const hasPendingLogs = (yield* Ref.get(pendingLogsRef)).length > 0;
|
|
249
1183
|
|
|
250
|
-
if (isTTY) {
|
|
251
|
-
|
|
252
|
-
yield* renderFrame("tick");
|
|
253
|
-
}
|
|
254
|
-
} else if (dirty || hasActiveSpinners) {
|
|
255
|
-
yield* renderFrame("tick");
|
|
1184
|
+
if (isTTY && rendererConfig.disableUserInput) {
|
|
1185
|
+
return yield* terminal.withRawInputCapture(renderLoop);
|
|
256
1186
|
}
|
|
257
1187
|
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
}).pipe(
|
|
262
|
-
Effect.ensuring(
|
|
263
|
-
Effect.gen(function* () {
|
|
264
|
-
if (!rendererActive) {
|
|
265
|
-
return;
|
|
266
|
-
}
|
|
1188
|
+
return yield* renderLoop;
|
|
1189
|
+
}),
|
|
1190
|
+
});
|
|
267
1191
|
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
}
|
|
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
|
+
}
|
|
275
1198
|
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
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
|
+
}
|
|
280
1205
|
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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
|
+
}
|
|
284
1212
|
|
|
285
|
-
|
|
286
|
-
|
|
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
|
+
}
|