@pokit/terminal 0.2.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/LICENSE +21 -0
- package/dist/index.d.ts +35 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +32 -0
- package/dist/prompter/autocomplete-prompt.d.ts +31 -0
- package/dist/prompter/autocomplete-prompt.d.ts.map +1 -0
- package/dist/prompter/autocomplete-prompt.js +266 -0
- package/dist/prompter/prompter.d.ts +16 -0
- package/dist/prompter/prompter.d.ts.map +1 -0
- package/dist/prompter/prompter.js +206 -0
- package/dist/reporter/adapter.d.ts +45 -0
- package/dist/reporter/adapter.d.ts.map +1 -0
- package/dist/reporter/adapter.js +599 -0
- package/dist/reporter/symbols.d.ts +50 -0
- package/dist/reporter/symbols.d.ts.map +1 -0
- package/dist/reporter/symbols.js +47 -0
- package/dist/screen.d.ts +22 -0
- package/dist/screen.d.ts.map +1 -0
- package/dist/screen.js +39 -0
- package/package.json +63 -0
- package/src/index.ts +63 -0
- package/src/prompter/autocomplete-prompt.ts +352 -0
- package/src/prompter/prompter.ts +267 -0
- package/src/reporter/adapter.ts +748 -0
- package/src/reporter/symbols.ts +78 -0
- package/src/screen.ts +53 -0
|
@@ -0,0 +1,748 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Clack Reporter Adapter
|
|
3
|
+
*
|
|
4
|
+
* Implements the ReporterAdapter interface using @clack/prompts.
|
|
5
|
+
* Consumes CLI events from the EventBus and renders them to the terminal.
|
|
6
|
+
*
|
|
7
|
+
* Design principles:
|
|
8
|
+
* - Groups are visual containers with a bold title (intro) and completion indicator (outro)
|
|
9
|
+
* - Activities within sequential groups show as spinner items that complete with checkmarks
|
|
10
|
+
* - Parallel groups use a single spinner that tracks progress, showing completions as they finish
|
|
11
|
+
* - Logs during an active spinner will pause the spinner, show the log, then resume
|
|
12
|
+
* - Process output is never interleaved with spinners
|
|
13
|
+
*
|
|
14
|
+
* Rendering strategy:
|
|
15
|
+
* - group:start -> p.intro() with bold label (or line-based output)
|
|
16
|
+
* - group:end -> p.outro() with success indicator
|
|
17
|
+
* - activity:start (sequential) -> spinner.start() (or line-based output)
|
|
18
|
+
* - activity:start (parallel) -> track activity, update combined spinner
|
|
19
|
+
* - activity:success -> spinner.stop() with checkmark (code 0) or update combined spinner
|
|
20
|
+
* - activity:failure -> spinner.stop() with X (code 1)
|
|
21
|
+
* - activity:update -> spinner.message()
|
|
22
|
+
* - log -> pause spinner if active, p.log.*, resume spinner
|
|
23
|
+
*
|
|
24
|
+
* Non-interactive output (--no-tty/NO_TTY/CI):
|
|
25
|
+
* - Uses line-based output without spinners or clack decorative UI
|
|
26
|
+
* - Unicode symbols are controlled separately with --no-unicode/NO_UNICODE
|
|
27
|
+
* - Color is controlled separately with --no-color/NO_COLOR
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import * as p from '@clack/prompts';
|
|
31
|
+
import pc from 'picocolors';
|
|
32
|
+
import type {
|
|
33
|
+
ReporterAdapter,
|
|
34
|
+
ReporterAdapterController,
|
|
35
|
+
EventBus,
|
|
36
|
+
CLIEvent,
|
|
37
|
+
ActivityId,
|
|
38
|
+
GroupId,
|
|
39
|
+
GroupLayout,
|
|
40
|
+
LogLevel,
|
|
41
|
+
OutputConfig,
|
|
42
|
+
} from '@pokit/core';
|
|
43
|
+
import { detectOutputConfig, CommandError } from '@pokit/core';
|
|
44
|
+
import { getSymbols, type SymbolSet } from './symbols';
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Extract error message and optional output from an error.
|
|
48
|
+
* If the error is a CommandError with output, includes that in the message.
|
|
49
|
+
*/
|
|
50
|
+
function formatErrorMessage(error: Error | string): string {
|
|
51
|
+
if (typeof error === 'string') {
|
|
52
|
+
return error;
|
|
53
|
+
}
|
|
54
|
+
if (error instanceof CommandError && error.output) {
|
|
55
|
+
return `${error.message}\n\n${error.output}`;
|
|
56
|
+
}
|
|
57
|
+
return error.message;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Helper to conditionally apply color.
|
|
62
|
+
* In no-color mode, returns text unchanged.
|
|
63
|
+
*/
|
|
64
|
+
function colorize(text: string, colorFn: (s: string) => string, useColor: boolean): string {
|
|
65
|
+
return useColor ? colorFn(text) : text;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Write a line to stdout.
|
|
70
|
+
* Uses process.stdout.write to ensure proper capture in all environments.
|
|
71
|
+
* (Bun's console.log may not be captured by stdout interception)
|
|
72
|
+
*/
|
|
73
|
+
function writeLine(line: string): void {
|
|
74
|
+
process.stdout.write(line + '\n');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function isPlainOutput(outputConfig: OutputConfig): boolean {
|
|
78
|
+
return !outputConfig.unicode || !outputConfig.interactive;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function canUseInteractiveUI(outputConfig: OutputConfig): boolean {
|
|
82
|
+
return outputConfig.unicode && outputConfig.interactive;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
type SpinnerInstance = ReturnType<typeof p.spinner>;
|
|
86
|
+
|
|
87
|
+
type SpinnerEntry = {
|
|
88
|
+
spinner: SpinnerInstance;
|
|
89
|
+
label: string;
|
|
90
|
+
/** Current message being displayed (for restore after log) */
|
|
91
|
+
currentMessage: string;
|
|
92
|
+
/** Parent group ID for tracking failures */
|
|
93
|
+
parentGroupId?: GroupId;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
type GroupEntry = {
|
|
97
|
+
label: string;
|
|
98
|
+
layout: GroupLayout;
|
|
99
|
+
/** Track if any activity in this group has failed */
|
|
100
|
+
hasFailure: boolean;
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
type ParallelActivity = {
|
|
104
|
+
label: string;
|
|
105
|
+
groupId: GroupId;
|
|
106
|
+
status: 'pending' | 'success' | 'failure';
|
|
107
|
+
error?: string;
|
|
108
|
+
/** Remediation steps for failed checks */
|
|
109
|
+
remediation?: string[];
|
|
110
|
+
/** Documentation URL for failed checks */
|
|
111
|
+
documentationUrl?: string;
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* A log message that was buffered during an active spinner
|
|
116
|
+
*/
|
|
117
|
+
type BufferedLog = {
|
|
118
|
+
activityId: ActivityId;
|
|
119
|
+
level: LogLevel;
|
|
120
|
+
message: string;
|
|
121
|
+
timestamp: number;
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
/** Maximum number of logs to buffer per activity to prevent memory issues */
|
|
125
|
+
const MAX_BUFFERED_LOGS_PER_ACTIVITY = 100;
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* State for tracking active spinners and groups
|
|
129
|
+
*/
|
|
130
|
+
type AdapterState = {
|
|
131
|
+
/** Active spinners for sequential activities */
|
|
132
|
+
spinners: Map<ActivityId, SpinnerEntry>;
|
|
133
|
+
/** Active groups with their layout info */
|
|
134
|
+
groups: Map<GroupId, GroupEntry>;
|
|
135
|
+
/** For parallel groups: track activities and use a single spinner */
|
|
136
|
+
parallelActivities: Map<ActivityId, ParallelActivity>;
|
|
137
|
+
/** The single spinner used for parallel group progress */
|
|
138
|
+
parallelSpinner: SpinnerEntry | null;
|
|
139
|
+
/** The group ID that owns the parallel spinner */
|
|
140
|
+
parallelSpinnerGroupId: GroupId | null;
|
|
141
|
+
/** Logs buffered during active spinners, to be flushed on activity completion */
|
|
142
|
+
bufferedLogs: BufferedLog[];
|
|
143
|
+
/** Verbose mode - when true, all logs are displayed immediately (no buffering) */
|
|
144
|
+
verbose: boolean;
|
|
145
|
+
/** Output configuration for color/unicode support */
|
|
146
|
+
outputConfig: OutputConfig;
|
|
147
|
+
/** Symbol set based on output config */
|
|
148
|
+
symbols: SymbolSet;
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Update the parallel spinner message based on current activity states
|
|
153
|
+
*/
|
|
154
|
+
function updateParallelSpinnerMessage(state: AdapterState): void {
|
|
155
|
+
if (!state.parallelSpinner) return;
|
|
156
|
+
|
|
157
|
+
const activities = Array.from(state.parallelActivities.values()).filter(
|
|
158
|
+
(a) => a.groupId === state.parallelSpinnerGroupId
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
const pending = activities.filter((a) => a.status === 'pending');
|
|
162
|
+
const completed = activities.filter((a) => a.status !== 'pending');
|
|
163
|
+
|
|
164
|
+
if (pending.length === 0) {
|
|
165
|
+
// All done - this will be cleaned up by group:end
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const message =
|
|
170
|
+
pending.length === 1
|
|
171
|
+
? pending[0]!.label
|
|
172
|
+
: `Running ${pending.length} tasks (${completed.length}/${activities.length} done)`;
|
|
173
|
+
|
|
174
|
+
state.parallelSpinner.currentMessage = message;
|
|
175
|
+
state.parallelSpinner.spinner.message(message);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Display a log message.
|
|
180
|
+
* Uses clack's log functions in interactive unicode mode, line output otherwise.
|
|
181
|
+
*
|
|
182
|
+
* @param level - The log level
|
|
183
|
+
* @param message - The message to display
|
|
184
|
+
* @param state - The adapter state (for output config)
|
|
185
|
+
* @param indented - Whether to indent the log (for buffered logs inside activity context)
|
|
186
|
+
*/
|
|
187
|
+
function displayLog(
|
|
188
|
+
level: LogLevel,
|
|
189
|
+
message: string,
|
|
190
|
+
state: AdapterState,
|
|
191
|
+
indented: boolean = false
|
|
192
|
+
): void {
|
|
193
|
+
const { outputConfig, symbols } = state;
|
|
194
|
+
|
|
195
|
+
// In plain or non-interactive mode, use simple console output
|
|
196
|
+
if (isPlainOutput(outputConfig)) {
|
|
197
|
+
const prefix = indented ? `${symbols.groupLine} ` : '';
|
|
198
|
+
const levelPrefix = {
|
|
199
|
+
info: symbols.info,
|
|
200
|
+
warn: symbols.warning,
|
|
201
|
+
error: symbols.error,
|
|
202
|
+
success: symbols.success,
|
|
203
|
+
step: symbols.step,
|
|
204
|
+
}[level];
|
|
205
|
+
writeLine(`${prefix}${levelPrefix} ${message}`);
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Unicode + interactive mode - use clack's decorative output
|
|
210
|
+
const prefix = indented ? '\u2502 ' : ''; // │ for indented logs
|
|
211
|
+
const formattedMessage = prefix + message;
|
|
212
|
+
|
|
213
|
+
switch (level) {
|
|
214
|
+
case 'info':
|
|
215
|
+
p.log.info(formattedMessage);
|
|
216
|
+
break;
|
|
217
|
+
case 'warn':
|
|
218
|
+
p.log.warn(formattedMessage);
|
|
219
|
+
break;
|
|
220
|
+
case 'error':
|
|
221
|
+
p.log.error(formattedMessage);
|
|
222
|
+
break;
|
|
223
|
+
case 'success':
|
|
224
|
+
p.log.success(formattedMessage);
|
|
225
|
+
break;
|
|
226
|
+
case 'step':
|
|
227
|
+
p.log.step(formattedMessage);
|
|
228
|
+
break;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Flush buffered logs for a specific activity.
|
|
234
|
+
*
|
|
235
|
+
* @param state - The adapter state
|
|
236
|
+
* @param activityId - The activity ID whose logs should be flushed
|
|
237
|
+
*/
|
|
238
|
+
function flushLogsForActivity(state: AdapterState, activityId: ActivityId): void {
|
|
239
|
+
// Filter logs for this activity, sorted by timestamp
|
|
240
|
+
const activityLogs = state.bufferedLogs
|
|
241
|
+
.filter((log) => log.activityId === activityId)
|
|
242
|
+
.sort((a, b) => a.timestamp - b.timestamp);
|
|
243
|
+
|
|
244
|
+
// Display each buffered log with indentation
|
|
245
|
+
for (const log of activityLogs) {
|
|
246
|
+
displayLog(log.level, log.message, state, true);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Remove flushed logs from buffer
|
|
250
|
+
state.bufferedLogs = state.bufferedLogs.filter((log) => log.activityId !== activityId);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Options for the reporter adapter
|
|
255
|
+
*/
|
|
256
|
+
export type ReporterAdapterOptions = {
|
|
257
|
+
/** When true, logs are displayed immediately instead of being buffered during spinners */
|
|
258
|
+
verbose?: boolean;
|
|
259
|
+
/** Output configuration (color, unicode, verbose settings) */
|
|
260
|
+
output?: OutputConfig;
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Create a Clack-based ReporterAdapter
|
|
265
|
+
*
|
|
266
|
+
* @param options - Optional configuration for the adapter
|
|
267
|
+
*/
|
|
268
|
+
export function createReporterAdapter(options?: ReporterAdapterOptions): ReporterAdapter {
|
|
269
|
+
// Get output config from options, or detect from args/env
|
|
270
|
+
const outputConfig: OutputConfig = options?.output ?? detectOutputConfig(process.argv.slice(2));
|
|
271
|
+
// Backwards compatibility: default interactive when missing
|
|
272
|
+
if (outputConfig.interactive === undefined) {
|
|
273
|
+
outputConfig.interactive = true;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Verbose can be set via options.verbose for backwards compatibility
|
|
277
|
+
if (options?.verbose !== undefined) {
|
|
278
|
+
outputConfig.verbose = options.verbose;
|
|
279
|
+
}
|
|
280
|
+
const symbols = getSymbols(outputConfig);
|
|
281
|
+
|
|
282
|
+
return {
|
|
283
|
+
start(bus: EventBus): ReporterAdapterController {
|
|
284
|
+
const state: AdapterState = {
|
|
285
|
+
spinners: new Map(),
|
|
286
|
+
groups: new Map(),
|
|
287
|
+
parallelActivities: new Map(),
|
|
288
|
+
parallelSpinner: null,
|
|
289
|
+
parallelSpinnerGroupId: null,
|
|
290
|
+
bufferedLogs: [],
|
|
291
|
+
verbose: outputConfig.verbose,
|
|
292
|
+
outputConfig,
|
|
293
|
+
symbols,
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
const handleEvent = (event: CLIEvent): void => {
|
|
297
|
+
switch (event.type) {
|
|
298
|
+
// Root lifecycle (app-level intro/outro)
|
|
299
|
+
case 'root:start':
|
|
300
|
+
case 'root:end':
|
|
301
|
+
// Handled by the router
|
|
302
|
+
break;
|
|
303
|
+
|
|
304
|
+
// Group lifecycle (command-level intro/outro)
|
|
305
|
+
case 'group:start': {
|
|
306
|
+
state.groups.set(event.id, {
|
|
307
|
+
label: event.label,
|
|
308
|
+
layout: event.layout,
|
|
309
|
+
hasFailure: false,
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
// In plain or non-interactive mode, use simple bracket notation
|
|
313
|
+
if (isPlainOutput(state.outputConfig)) {
|
|
314
|
+
const label = colorize(event.label, pc.bold, state.outputConfig.color);
|
|
315
|
+
writeLine(`${state.symbols.groupStart}${label}${state.symbols.groupEnd}`);
|
|
316
|
+
} else {
|
|
317
|
+
p.intro(colorize(event.label, pc.bold, state.outputConfig.color));
|
|
318
|
+
}
|
|
319
|
+
break;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
case 'group:end': {
|
|
323
|
+
const group = state.groups.get(event.id);
|
|
324
|
+
state.groups.delete(event.id);
|
|
325
|
+
|
|
326
|
+
let hasFailures = group?.hasFailure ?? false;
|
|
327
|
+
// Collect errors with remediation info to print after the group closes
|
|
328
|
+
type DeferredError = {
|
|
329
|
+
error: string;
|
|
330
|
+
remediation?: string[];
|
|
331
|
+
documentationUrl?: string;
|
|
332
|
+
};
|
|
333
|
+
const deferredErrors: DeferredError[] = [];
|
|
334
|
+
|
|
335
|
+
// If this was a parallel group, show completion results and clean up
|
|
336
|
+
if (group?.layout === 'parallel') {
|
|
337
|
+
// Collect activities for this group
|
|
338
|
+
const activities = Array.from(state.parallelActivities.entries()).filter(
|
|
339
|
+
([, a]) => a.groupId === event.id
|
|
340
|
+
);
|
|
341
|
+
|
|
342
|
+
// Sort: successes first, then failures (so failures are more visible at end)
|
|
343
|
+
const sorted = activities.sort(([, a], [, b]) => {
|
|
344
|
+
if (a.status === 'success' && b.status !== 'success') return -1;
|
|
345
|
+
if (a.status !== 'success' && b.status === 'success') return 1;
|
|
346
|
+
return 0;
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
// Stop the parallel spinner - use first success as the message
|
|
350
|
+
if (state.parallelSpinner && state.parallelSpinnerGroupId === event.id) {
|
|
351
|
+
const firstSuccess = sorted.find(([, a]) => a.status === 'success');
|
|
352
|
+
if (firstSuccess) {
|
|
353
|
+
// Show first success via spinner stop
|
|
354
|
+
state.parallelSpinner.spinner.stop(firstSuccess[1].label);
|
|
355
|
+
// Flush buffered logs for this activity
|
|
356
|
+
flushLogsForActivity(state, firstSuccess[0]);
|
|
357
|
+
state.parallelActivities.delete(firstSuccess[0]);
|
|
358
|
+
} else {
|
|
359
|
+
// All failed - show first failure label (not error) via spinner stop
|
|
360
|
+
const firstFailure = sorted[0];
|
|
361
|
+
if (firstFailure) {
|
|
362
|
+
state.parallelSpinner.spinner.error(firstFailure[1].label);
|
|
363
|
+
hasFailures = true;
|
|
364
|
+
// Defer the error message with remediation to print after outro
|
|
365
|
+
if (firstFailure[1].error) {
|
|
366
|
+
deferredErrors.push({
|
|
367
|
+
error: firstFailure[1].error,
|
|
368
|
+
remediation: firstFailure[1].remediation,
|
|
369
|
+
documentationUrl: firstFailure[1].documentationUrl,
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
// Flush buffered logs for this activity
|
|
373
|
+
flushLogsForActivity(state, firstFailure[0]);
|
|
374
|
+
state.parallelActivities.delete(firstFailure[0]);
|
|
375
|
+
} else {
|
|
376
|
+
state.parallelSpinner.spinner.stop('Complete');
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
state.parallelSpinner = null;
|
|
380
|
+
state.parallelSpinnerGroupId = null;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// Show remaining results (only labels inside group, defer errors)
|
|
384
|
+
for (const [activityId, activity] of state.parallelActivities) {
|
|
385
|
+
if (activity.groupId !== event.id) continue;
|
|
386
|
+
if (activity.status === 'success') {
|
|
387
|
+
if (isPlainOutput(state.outputConfig)) {
|
|
388
|
+
const prefix = colorize(
|
|
389
|
+
state.symbols.success,
|
|
390
|
+
pc.green,
|
|
391
|
+
state.outputConfig.color
|
|
392
|
+
);
|
|
393
|
+
writeLine(` ${prefix} ${activity.label}`);
|
|
394
|
+
} else {
|
|
395
|
+
p.log.success(activity.label);
|
|
396
|
+
}
|
|
397
|
+
} else if (activity.status === 'failure') {
|
|
398
|
+
hasFailures = true;
|
|
399
|
+
// Show label inside group, defer error message with remediation
|
|
400
|
+
if (isPlainOutput(state.outputConfig)) {
|
|
401
|
+
const prefix = colorize(state.symbols.error, pc.red, state.outputConfig.color);
|
|
402
|
+
writeLine(` ${prefix} ${activity.label}`);
|
|
403
|
+
} else {
|
|
404
|
+
p.log.error(activity.label);
|
|
405
|
+
}
|
|
406
|
+
if (activity.error) {
|
|
407
|
+
deferredErrors.push({
|
|
408
|
+
error: activity.error,
|
|
409
|
+
remediation: activity.remediation,
|
|
410
|
+
documentationUrl: activity.documentationUrl,
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
// Flush any buffered logs for this parallel activity
|
|
415
|
+
flushLogsForActivity(state, activityId);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// Clean up all activities for this group
|
|
419
|
+
for (const [id, activity] of [...state.parallelActivities]) {
|
|
420
|
+
if (activity.groupId === event.id) {
|
|
421
|
+
state.parallelActivities.delete(id);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// Show appropriate outro based on whether there were failures
|
|
427
|
+
if (isPlainOutput(state.outputConfig)) {
|
|
428
|
+
// Plain output - simple bracket notation
|
|
429
|
+
if (hasFailures) {
|
|
430
|
+
const failedText = colorize(state.symbols.failed, pc.red, state.outputConfig.color);
|
|
431
|
+
writeLine(`${state.symbols.groupStart}${failedText}${state.symbols.groupEnd}`);
|
|
432
|
+
} else {
|
|
433
|
+
const doneText = colorize(state.symbols.done, pc.green, state.outputConfig.color);
|
|
434
|
+
writeLine(`${state.symbols.groupStart}${doneText}${state.symbols.groupEnd}`);
|
|
435
|
+
}
|
|
436
|
+
} else {
|
|
437
|
+
// Unicode + interactive mode - use clack's outro
|
|
438
|
+
if (hasFailures) {
|
|
439
|
+
p.outro(
|
|
440
|
+
colorize(`${state.symbols.failed} Failed`, pc.red, state.outputConfig.color)
|
|
441
|
+
);
|
|
442
|
+
} else {
|
|
443
|
+
p.outro(colorize(`${state.symbols.done} Done`, pc.green, state.outputConfig.color));
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// Print deferred error messages with remediation after the group closes
|
|
448
|
+
for (const deferred of deferredErrors) {
|
|
449
|
+
if (isPlainOutput(state.outputConfig)) {
|
|
450
|
+
writeLine(`${state.symbols.error} ${deferred.error}`);
|
|
451
|
+
} else {
|
|
452
|
+
p.log.error(deferred.error);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// Display remediation steps if available
|
|
456
|
+
if (deferred.remediation && deferred.remediation.length > 0) {
|
|
457
|
+
writeLine('');
|
|
458
|
+
writeLine(' To fix:');
|
|
459
|
+
for (const step of deferred.remediation) {
|
|
460
|
+
writeLine(` - ${step}`);
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// Display documentation link if available
|
|
465
|
+
if (deferred.documentationUrl) {
|
|
466
|
+
writeLine('');
|
|
467
|
+
writeLine(` More info: ${deferred.documentationUrl}`);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
break;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// Activity lifecycle (task-level spinner)
|
|
474
|
+
case 'activity:start': {
|
|
475
|
+
// Find the parent group to determine layout
|
|
476
|
+
const parentGroup = event.parentId ? state.groups.get(event.parentId as GroupId) : null;
|
|
477
|
+
|
|
478
|
+
if (parentGroup?.layout === 'parallel') {
|
|
479
|
+
// Track this activity for the parallel group
|
|
480
|
+
state.parallelActivities.set(event.id, {
|
|
481
|
+
label: event.label,
|
|
482
|
+
groupId: event.parentId as GroupId,
|
|
483
|
+
status: 'pending',
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
// In plain or non-interactive mode, track activities - results shown at group:end
|
|
487
|
+
if (isPlainOutput(state.outputConfig)) {
|
|
488
|
+
if (!state.parallelSpinnerGroupId) {
|
|
489
|
+
state.parallelSpinnerGroupId = event.parentId as GroupId;
|
|
490
|
+
const activities = state.parallelActivities.size;
|
|
491
|
+
writeLine(` Running ${activities} task${activities > 1 ? 's' : ''}...`);
|
|
492
|
+
}
|
|
493
|
+
} else {
|
|
494
|
+
// Unicode + interactive mode: create or update the parallel spinner
|
|
495
|
+
if (!state.parallelSpinner) {
|
|
496
|
+
const spinner = p.spinner();
|
|
497
|
+
state.parallelSpinner = {
|
|
498
|
+
spinner,
|
|
499
|
+
label: event.label,
|
|
500
|
+
currentMessage: event.label,
|
|
501
|
+
};
|
|
502
|
+
state.parallelSpinnerGroupId = event.parentId as GroupId;
|
|
503
|
+
spinner.start(event.label);
|
|
504
|
+
}
|
|
505
|
+
updateParallelSpinnerMessage(state);
|
|
506
|
+
}
|
|
507
|
+
} else {
|
|
508
|
+
// Sequential activity
|
|
509
|
+
if (isPlainOutput(state.outputConfig)) {
|
|
510
|
+
// Plain output: track activity without spinner - result shown on completion
|
|
511
|
+
state.spinners.set(event.id, {
|
|
512
|
+
spinner: null as unknown as SpinnerInstance, // Not used in non-interactive output
|
|
513
|
+
label: event.label,
|
|
514
|
+
currentMessage: event.label,
|
|
515
|
+
parentGroupId: event.parentId as GroupId | undefined,
|
|
516
|
+
});
|
|
517
|
+
} else {
|
|
518
|
+
// Unicode + interactive mode: create individual spinner
|
|
519
|
+
const spinner = p.spinner();
|
|
520
|
+
state.spinners.set(event.id, {
|
|
521
|
+
spinner,
|
|
522
|
+
label: event.label,
|
|
523
|
+
currentMessage: event.label,
|
|
524
|
+
parentGroupId: event.parentId as GroupId | undefined,
|
|
525
|
+
});
|
|
526
|
+
spinner.start(event.label);
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
break;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
case 'activity:update': {
|
|
533
|
+
// Check if this is a parallel activity
|
|
534
|
+
const parallelActivity = state.parallelActivities.get(event.id);
|
|
535
|
+
if (parallelActivity) {
|
|
536
|
+
// Update the activity label if message provided
|
|
537
|
+
if (event.payload.message) {
|
|
538
|
+
parallelActivity.label = event.payload.message;
|
|
539
|
+
if (canUseInteractiveUI(state.outputConfig)) {
|
|
540
|
+
updateParallelSpinnerMessage(state);
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
break;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// Sequential activity
|
|
547
|
+
const entry = state.spinners.get(event.id);
|
|
548
|
+
if (entry) {
|
|
549
|
+
const text =
|
|
550
|
+
event.payload.message ||
|
|
551
|
+
(event.payload.progress !== undefined ? `${event.payload.progress}%` : null);
|
|
552
|
+
if (text) {
|
|
553
|
+
entry.currentMessage = text;
|
|
554
|
+
// Only update spinner in unicode mode
|
|
555
|
+
if (canUseInteractiveUI(state.outputConfig) && entry.spinner) {
|
|
556
|
+
entry.spinner.message(text);
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
break;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
case 'activity:success': {
|
|
564
|
+
// Check if this is a parallel activity
|
|
565
|
+
const parallelActivity = state.parallelActivities.get(event.id);
|
|
566
|
+
if (parallelActivity) {
|
|
567
|
+
parallelActivity.status = 'success';
|
|
568
|
+
if (canUseInteractiveUI(state.outputConfig)) {
|
|
569
|
+
updateParallelSpinnerMessage(state);
|
|
570
|
+
}
|
|
571
|
+
// Note: For parallel activities, logs will be flushed at group:end
|
|
572
|
+
break;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
// Sequential activity
|
|
576
|
+
const entry = state.spinners.get(event.id);
|
|
577
|
+
if (entry) {
|
|
578
|
+
if (isPlainOutput(state.outputConfig)) {
|
|
579
|
+
// Plain output - print success line
|
|
580
|
+
const prefix = colorize(state.symbols.success, pc.green, state.outputConfig.color);
|
|
581
|
+
writeLine(` ${prefix} ${entry.label}`);
|
|
582
|
+
} else if (entry.spinner) {
|
|
583
|
+
// Unicode + interactive mode - stop spinner
|
|
584
|
+
entry.spinner.stop(entry.label);
|
|
585
|
+
}
|
|
586
|
+
state.spinners.delete(event.id);
|
|
587
|
+
|
|
588
|
+
// Flush any buffered logs for this activity
|
|
589
|
+
flushLogsForActivity(state, event.id);
|
|
590
|
+
}
|
|
591
|
+
break;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
case 'activity:failure': {
|
|
595
|
+
const errorMessage = formatErrorMessage(event.error);
|
|
596
|
+
|
|
597
|
+
// Check if this is a parallel activity
|
|
598
|
+
const parallelActivity = state.parallelActivities.get(event.id);
|
|
599
|
+
if (parallelActivity) {
|
|
600
|
+
parallelActivity.status = 'failure';
|
|
601
|
+
parallelActivity.error = errorMessage;
|
|
602
|
+
parallelActivity.remediation = event.remediation;
|
|
603
|
+
parallelActivity.documentationUrl = event.documentationUrl;
|
|
604
|
+
// Mark the parent group as having failures
|
|
605
|
+
const parentGroup = state.groups.get(parallelActivity.groupId);
|
|
606
|
+
if (parentGroup) {
|
|
607
|
+
parentGroup.hasFailure = true;
|
|
608
|
+
}
|
|
609
|
+
if (canUseInteractiveUI(state.outputConfig)) {
|
|
610
|
+
updateParallelSpinnerMessage(state);
|
|
611
|
+
}
|
|
612
|
+
break;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
// Sequential activity
|
|
616
|
+
const entry = state.spinners.get(event.id);
|
|
617
|
+
if (entry) {
|
|
618
|
+
// Mark the parent group as having failures
|
|
619
|
+
if (entry.parentGroupId) {
|
|
620
|
+
const parentGroup = state.groups.get(entry.parentGroupId);
|
|
621
|
+
if (parentGroup) {
|
|
622
|
+
parentGroup.hasFailure = true;
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
if (isPlainOutput(state.outputConfig)) {
|
|
627
|
+
// Plain output - print error line
|
|
628
|
+
const prefix = colorize(state.symbols.error, pc.red, state.outputConfig.color);
|
|
629
|
+
writeLine(` ${prefix} ${errorMessage}`);
|
|
630
|
+
} else if (entry.spinner) {
|
|
631
|
+
// Unicode + interactive mode - stop spinner with error
|
|
632
|
+
entry.spinner.error(errorMessage);
|
|
633
|
+
}
|
|
634
|
+
state.spinners.delete(event.id);
|
|
635
|
+
|
|
636
|
+
// Display remediation steps if available
|
|
637
|
+
const linePrefix = state.outputConfig.unicode ? '\u2502' : state.symbols.groupLine;
|
|
638
|
+
if (event.remediation && event.remediation.length > 0) {
|
|
639
|
+
writeLine(linePrefix);
|
|
640
|
+
writeLine(`${linePrefix} To fix:`);
|
|
641
|
+
for (const step of event.remediation) {
|
|
642
|
+
writeLine(`${linePrefix} - ${step}`);
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
// Display documentation link if available
|
|
647
|
+
if (event.documentationUrl) {
|
|
648
|
+
writeLine(linePrefix);
|
|
649
|
+
writeLine(`${linePrefix} More info: ${event.documentationUrl}`);
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
// Flush any buffered logs for this activity
|
|
653
|
+
flushLogsForActivity(state, event.id);
|
|
654
|
+
}
|
|
655
|
+
break;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
// Log events
|
|
659
|
+
case 'log': {
|
|
660
|
+
// Verbose mode: always display logs immediately
|
|
661
|
+
if (state.verbose) {
|
|
662
|
+
displayLog(event.level, event.message, state, false);
|
|
663
|
+
break;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
const hasActiveSpinners = state.spinners.size > 0 || state.parallelSpinner !== null;
|
|
667
|
+
|
|
668
|
+
// Error logs interrupt spinners immediately (only in unicode mode)
|
|
669
|
+
if (
|
|
670
|
+
event.level === 'error' &&
|
|
671
|
+
hasActiveSpinners &&
|
|
672
|
+
event.activityId &&
|
|
673
|
+
canUseInteractiveUI(state.outputConfig)
|
|
674
|
+
) {
|
|
675
|
+
const spinner = state.spinners.get(event.activityId);
|
|
676
|
+
if (spinner && spinner.spinner) {
|
|
677
|
+
// Temporarily stop spinner, show error, resume
|
|
678
|
+
const currentMessage = spinner.currentMessage;
|
|
679
|
+
spinner.spinner.stop(currentMessage);
|
|
680
|
+
displayLog(event.level, event.message, state, false);
|
|
681
|
+
spinner.spinner.start(currentMessage);
|
|
682
|
+
} else {
|
|
683
|
+
// No spinner for this activity, just display
|
|
684
|
+
displayLog(event.level, event.message, state, false);
|
|
685
|
+
}
|
|
686
|
+
break;
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
// Buffer logs during active spinners
|
|
690
|
+
if (hasActiveSpinners && event.activityId) {
|
|
691
|
+
// Check if we've hit the buffer limit for this activity
|
|
692
|
+
const activityLogCount = state.bufferedLogs.filter(
|
|
693
|
+
(log) => log.activityId === event.activityId
|
|
694
|
+
).length;
|
|
695
|
+
|
|
696
|
+
if (activityLogCount < MAX_BUFFERED_LOGS_PER_ACTIVITY) {
|
|
697
|
+
state.bufferedLogs.push({
|
|
698
|
+
activityId: event.activityId,
|
|
699
|
+
level: event.level,
|
|
700
|
+
message: event.message,
|
|
701
|
+
timestamp: Date.now(),
|
|
702
|
+
});
|
|
703
|
+
}
|
|
704
|
+
// If over limit, silently drop (prevent memory issues)
|
|
705
|
+
break;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
// No active spinners - display immediately
|
|
709
|
+
displayLog(event.level, event.message, state, false);
|
|
710
|
+
break;
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
};
|
|
714
|
+
|
|
715
|
+
const unsubscribe = bus.on(handleEvent);
|
|
716
|
+
|
|
717
|
+
return {
|
|
718
|
+
stop(): void {
|
|
719
|
+
// Stop all active spinners (only in interactive mode where spinners exist)
|
|
720
|
+
if (canUseInteractiveUI(state.outputConfig)) {
|
|
721
|
+
for (const entry of state.spinners.values()) {
|
|
722
|
+
try {
|
|
723
|
+
if (entry.spinner) {
|
|
724
|
+
entry.spinner.error('Stopped');
|
|
725
|
+
}
|
|
726
|
+
} catch {
|
|
727
|
+
// Spinner may already be stopped
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
if (state.parallelSpinner) {
|
|
731
|
+
try {
|
|
732
|
+
state.parallelSpinner.spinner.error('Stopped');
|
|
733
|
+
} catch {
|
|
734
|
+
// Spinner may already be stopped
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
state.spinners.clear();
|
|
739
|
+
state.groups.clear();
|
|
740
|
+
state.parallelActivities.clear();
|
|
741
|
+
state.parallelSpinner = null;
|
|
742
|
+
state.parallelSpinnerGroupId = null;
|
|
743
|
+
unsubscribe();
|
|
744
|
+
},
|
|
745
|
+
};
|
|
746
|
+
},
|
|
747
|
+
};
|
|
748
|
+
}
|