@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.
@@ -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
+ }