@pokit/reporter-clack 0.0.1 → 0.0.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pokit/reporter-clack",
3
- "version": "0.0.1",
3
+ "version": "0.0.2",
4
4
  "description": "Clack-based event reporter for pok CLI applications",
5
5
  "keywords": [
6
6
  "cli",
@@ -26,9 +26,8 @@
26
26
  "bugs": {
27
27
  "url": "https://github.com/notation-dev/openpok/issues"
28
28
  },
29
- "main": "./src/index.ts",
30
- "module": "./src/index.ts",
31
- "types": "./src/index.ts",
29
+ "main": "./dist/index.js",
30
+ "types": "./dist/index.d.ts",
32
31
  "exports": {
33
32
  ".": {
34
33
  "bun": "./src/index.ts",
@@ -39,7 +38,8 @@
39
38
  "files": [
40
39
  "dist",
41
40
  "README.md",
42
- "LICENSE"
41
+ "LICENSE",
42
+ "src"
43
43
  ],
44
44
  "publishConfig": {
45
45
  "access": "public"
@@ -50,10 +50,10 @@
50
50
  },
51
51
  "devDependencies": {
52
52
  "@types/bun": "latest",
53
- "@pokit/core": "0.0.1"
53
+ "@pokit/core": "0.0.2"
54
54
  },
55
55
  "peerDependencies": {
56
- "@pokit/core": "0.0.1"
56
+ "@pokit/core": "0.0.2"
57
57
  },
58
58
  "engines": {
59
59
  "bun": ">=1.0.0"
package/src/adapter.ts ADDED
@@ -0,0 +1,817 @@
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 plain text in plain mode)
16
+ * - group:end -> p.outro() with success indicator
17
+ * - activity:start (sequential) -> spinner.start() (or plain text indicator)
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
+ * Plain mode (--plain or CI environment):
25
+ * - When unicode is disabled, uses ASCII symbols and bypasses clack's decorative output
26
+ * - When color is disabled (--no-color or NO_COLOR env), strips ANSI color codes
27
+ */
28
+
29
+ import * as p from '@clack/prompts';
30
+ import pc from 'picocolors';
31
+ import type {
32
+ ReporterAdapter,
33
+ ReporterAdapterController,
34
+ EventBus,
35
+ CLIEvent,
36
+ ActivityId,
37
+ GroupId,
38
+ GroupLayout,
39
+ LogLevel,
40
+ OutputConfig,
41
+ } from '@pokit/core';
42
+ import { detectOutputConfig, CommandError } from '@pokit/core';
43
+
44
+ /**
45
+ * Extract error message and optional output from an error.
46
+ * If the error is a CommandError with output, includes that in the message.
47
+ */
48
+ function formatErrorMessage(error: Error | string): string {
49
+ if (typeof error === 'string') {
50
+ return error;
51
+ }
52
+ if (error instanceof CommandError && error.output) {
53
+ return `${error.message}\n\n${error.output}`;
54
+ }
55
+ return error.message;
56
+ }
57
+ import { getSymbols, type SymbolSet } from './symbols';
58
+
59
+ /**
60
+ * Helper to conditionally apply color.
61
+ * In no-color mode, returns text unchanged.
62
+ */
63
+ function colorize(text: string, colorFn: (s: string) => string, useColor: boolean): string {
64
+ return useColor ? colorFn(text) : text;
65
+ }
66
+
67
+ /**
68
+ * Write a line to stdout.
69
+ * Uses process.stdout.write to ensure proper capture in all environments.
70
+ * (Bun's console.log may not be captured by stdout interception)
71
+ */
72
+ function writeLine(line: string): void {
73
+ process.stdout.write(line + '\n');
74
+ }
75
+
76
+ type SpinnerInstance = ReturnType<typeof p.spinner>;
77
+
78
+ type SpinnerEntry = {
79
+ spinner: SpinnerInstance;
80
+ label: string;
81
+ /** Current message being displayed (for restore after log) */
82
+ currentMessage: string;
83
+ /** Parent group ID for tracking failures */
84
+ parentGroupId?: GroupId;
85
+ };
86
+
87
+ type GroupEntry = {
88
+ label: string;
89
+ layout: GroupLayout;
90
+ /** Track if any activity in this group has failed */
91
+ hasFailure: boolean;
92
+ };
93
+
94
+ type ParallelActivity = {
95
+ label: string;
96
+ groupId: GroupId;
97
+ status: 'pending' | 'success' | 'failure';
98
+ error?: string;
99
+ /** Remediation steps for failed checks */
100
+ remediation?: string[];
101
+ /** Documentation URL for failed checks */
102
+ documentationUrl?: string;
103
+ };
104
+
105
+ /**
106
+ * A log message that was buffered during an active spinner
107
+ */
108
+ type BufferedLog = {
109
+ activityId: ActivityId;
110
+ level: LogLevel;
111
+ message: string;
112
+ timestamp: number;
113
+ };
114
+
115
+ /** Maximum number of logs to buffer per activity to prevent memory issues */
116
+ const MAX_BUFFERED_LOGS_PER_ACTIVITY = 100;
117
+
118
+ /**
119
+ * State for tracking active spinners and groups
120
+ */
121
+ type AdapterState = {
122
+ /** Active spinners for sequential activities */
123
+ spinners: Map<ActivityId, SpinnerEntry>;
124
+ /** Active groups with their layout info */
125
+ groups: Map<GroupId, GroupEntry>;
126
+ /** For parallel groups: track activities and use a single spinner */
127
+ parallelActivities: Map<ActivityId, ParallelActivity>;
128
+ /** The single spinner used for parallel group progress */
129
+ parallelSpinner: SpinnerEntry | null;
130
+ /** The group ID that owns the parallel spinner */
131
+ parallelSpinnerGroupId: GroupId | null;
132
+ /** When true, ignore all events (for fullscreen TUI takeover) */
133
+ suspended: boolean;
134
+ /** Activities that were suspended - we'll show completion for these on resume */
135
+ suspendedActivities: Map<ActivityId, { label: string }>;
136
+ /** Logs buffered during active spinners, to be flushed on activity completion */
137
+ bufferedLogs: BufferedLog[];
138
+ /** Verbose mode - when true, all logs are displayed immediately (no buffering) */
139
+ verbose: boolean;
140
+ /** Output configuration for color/unicode support */
141
+ outputConfig: OutputConfig;
142
+ /** Symbol set based on output config */
143
+ symbols: SymbolSet;
144
+ };
145
+
146
+ /**
147
+ * Update the parallel spinner message based on current activity states
148
+ */
149
+ function updateParallelSpinnerMessage(state: AdapterState): void {
150
+ if (!state.parallelSpinner) return;
151
+
152
+ const activities = Array.from(state.parallelActivities.values()).filter(
153
+ (a) => a.groupId === state.parallelSpinnerGroupId
154
+ );
155
+
156
+ const pending = activities.filter((a) => a.status === 'pending');
157
+ const completed = activities.filter((a) => a.status !== 'pending');
158
+
159
+ if (pending.length === 0) {
160
+ // All done - this will be cleaned up by group:end
161
+ return;
162
+ }
163
+
164
+ const message =
165
+ pending.length === 1
166
+ ? pending[0]!.label
167
+ : `Running ${pending.length} tasks (${completed.length}/${activities.length} done)`;
168
+
169
+ state.parallelSpinner.currentMessage = message;
170
+ state.parallelSpinner.spinner.message(message);
171
+ }
172
+
173
+ /**
174
+ * Display a log message.
175
+ * Uses clack's log functions in unicode mode, plain console.log in plain mode.
176
+ *
177
+ * @param level - The log level
178
+ * @param message - The message to display
179
+ * @param state - The adapter state (for output config)
180
+ * @param indented - Whether to indent the log (for buffered logs inside activity context)
181
+ */
182
+ function displayLog(
183
+ level: LogLevel,
184
+ message: string,
185
+ state: AdapterState,
186
+ indented: boolean = false
187
+ ): void {
188
+ const { outputConfig, symbols } = state;
189
+
190
+ // In plain mode (no unicode), use simple console output
191
+ if (!outputConfig.unicode) {
192
+ const prefix = indented ? `${symbols.groupLine} ` : '';
193
+ const levelPrefix = {
194
+ info: symbols.info,
195
+ warn: symbols.warning,
196
+ error: symbols.error,
197
+ success: symbols.success,
198
+ step: symbols.step,
199
+ }[level];
200
+ writeLine(`${prefix}${levelPrefix} ${message}`);
201
+ return;
202
+ }
203
+
204
+ // Unicode mode - use clack's decorative output
205
+ const prefix = indented ? '\u2502 ' : ''; // │ for indented logs
206
+ const formattedMessage = prefix + message;
207
+
208
+ switch (level) {
209
+ case 'info':
210
+ p.log.info(formattedMessage);
211
+ break;
212
+ case 'warn':
213
+ p.log.warn(formattedMessage);
214
+ break;
215
+ case 'error':
216
+ p.log.error(formattedMessage);
217
+ break;
218
+ case 'success':
219
+ p.log.success(formattedMessage);
220
+ break;
221
+ case 'step':
222
+ p.log.step(formattedMessage);
223
+ break;
224
+ }
225
+ }
226
+
227
+ /**
228
+ * Flush buffered logs for a specific activity.
229
+ *
230
+ * @param state - The adapter state
231
+ * @param activityId - The activity ID whose logs should be flushed
232
+ */
233
+ function flushLogsForActivity(state: AdapterState, activityId: ActivityId): void {
234
+ // Filter logs for this activity, sorted by timestamp
235
+ const activityLogs = state.bufferedLogs
236
+ .filter((log) => log.activityId === activityId)
237
+ .sort((a, b) => a.timestamp - b.timestamp);
238
+
239
+ // Display each buffered log with indentation
240
+ for (const log of activityLogs) {
241
+ displayLog(log.level, log.message, state, true);
242
+ }
243
+
244
+ // Remove flushed logs from buffer
245
+ state.bufferedLogs = state.bufferedLogs.filter((log) => log.activityId !== activityId);
246
+ }
247
+
248
+ /**
249
+ * Options for the reporter adapter
250
+ */
251
+ export type ReporterAdapterOptions = {
252
+ /** When true, logs are displayed immediately instead of being buffered during spinners */
253
+ verbose?: boolean;
254
+ /** Output configuration (color, unicode, verbose settings) */
255
+ output?: OutputConfig;
256
+ };
257
+
258
+ /**
259
+ * Create a Clack-based ReporterAdapter
260
+ *
261
+ * @param options - Optional configuration for the adapter
262
+ */
263
+ export function createReporterAdapter(options?: ReporterAdapterOptions): ReporterAdapter {
264
+ // Get output config from options, or use defaults
265
+ const outputConfig: OutputConfig = options?.output ?? {
266
+ color: true,
267
+ unicode: true,
268
+ verbose: options?.verbose ?? false,
269
+ };
270
+ // Verbose can be set via options.verbose for backwards compatibility
271
+ if (options?.verbose !== undefined) {
272
+ outputConfig.verbose = options.verbose;
273
+ }
274
+ const symbols = getSymbols(outputConfig);
275
+
276
+ return {
277
+ start(bus: EventBus): ReporterAdapterController {
278
+ const state: AdapterState = {
279
+ spinners: new Map(),
280
+ groups: new Map(),
281
+ parallelActivities: new Map(),
282
+ parallelSpinner: null,
283
+ parallelSpinnerGroupId: null,
284
+ suspended: false,
285
+ suspendedActivities: new Map(),
286
+ bufferedLogs: [],
287
+ verbose: outputConfig.verbose,
288
+ outputConfig,
289
+ symbols,
290
+ };
291
+
292
+ const handleEvent = (event: CLIEvent): void => {
293
+ switch (event.type) {
294
+ // Root lifecycle (app-level intro/outro)
295
+ case 'root:start':
296
+ case 'root:end':
297
+ // Handled by the router
298
+ break;
299
+
300
+ // Group lifecycle (command-level intro/outro)
301
+ case 'group:start': {
302
+ if (state.suspended) break;
303
+
304
+ state.groups.set(event.id, {
305
+ label: event.label,
306
+ layout: event.layout,
307
+ hasFailure: false,
308
+ });
309
+
310
+ // In plain mode, use simple bracket notation
311
+ if (!state.outputConfig.unicode) {
312
+ const label = colorize(event.label, pc.bold, state.outputConfig.color);
313
+ writeLine(`${state.symbols.groupStart}${label}${state.symbols.groupEnd}`);
314
+ } else {
315
+ p.intro(colorize(event.label, pc.bold, state.outputConfig.color));
316
+ }
317
+ break;
318
+ }
319
+
320
+ case 'group:end': {
321
+ if (state.suspended) break;
322
+
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, 0);
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.stop(firstFailure[1].label, 1);
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', 0);
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 (!state.outputConfig.unicode) {
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 (!state.outputConfig.unicode) {
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 (!state.outputConfig.unicode) {
428
+ // Plain mode - 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 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 (!state.outputConfig.unicode) {
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
+ if (state.suspended) break;
476
+
477
+ // Find the parent group to determine layout
478
+ const parentGroup = event.parentId ? state.groups.get(event.parentId as GroupId) : null;
479
+
480
+ if (parentGroup?.layout === 'parallel') {
481
+ // Track this activity for the parallel group
482
+ state.parallelActivities.set(event.id, {
483
+ label: event.label,
484
+ groupId: event.parentId as GroupId,
485
+ status: 'pending',
486
+ });
487
+
488
+ // In plain mode, just track activities - results shown at group:end
489
+ if (!state.outputConfig.unicode) {
490
+ if (!state.parallelSpinnerGroupId) {
491
+ state.parallelSpinnerGroupId = event.parentId as GroupId;
492
+ const activities = state.parallelActivities.size;
493
+ writeLine(` Running ${activities} task${activities > 1 ? 's' : ''}...`);
494
+ }
495
+ } else {
496
+ // Unicode mode: create or update the parallel spinner
497
+ if (!state.parallelSpinner) {
498
+ const spinner = p.spinner();
499
+ state.parallelSpinner = {
500
+ spinner,
501
+ label: event.label,
502
+ currentMessage: event.label,
503
+ };
504
+ state.parallelSpinnerGroupId = event.parentId as GroupId;
505
+ spinner.start(event.label);
506
+ }
507
+ updateParallelSpinnerMessage(state);
508
+ }
509
+ } else {
510
+ // Sequential activity
511
+ if (!state.outputConfig.unicode) {
512
+ // Plain mode: track activity without spinner - result shown on completion
513
+ state.spinners.set(event.id, {
514
+ spinner: null as unknown as SpinnerInstance, // Not used in plain mode
515
+ label: event.label,
516
+ currentMessage: event.label,
517
+ parentGroupId: event.parentId as GroupId | undefined,
518
+ });
519
+ } else {
520
+ // Unicode mode: create individual spinner
521
+ const spinner = p.spinner();
522
+ state.spinners.set(event.id, {
523
+ spinner,
524
+ label: event.label,
525
+ currentMessage: event.label,
526
+ parentGroupId: event.parentId as GroupId | undefined,
527
+ });
528
+ spinner.start(event.label);
529
+ }
530
+ }
531
+ break;
532
+ }
533
+
534
+ case 'activity:update': {
535
+ if (state.suspended) break;
536
+
537
+ // Check if this is a parallel activity
538
+ const parallelActivity = state.parallelActivities.get(event.id);
539
+ if (parallelActivity) {
540
+ // Update the activity label if message provided
541
+ if (event.payload.message) {
542
+ parallelActivity.label = event.payload.message;
543
+ if (state.outputConfig.unicode) {
544
+ updateParallelSpinnerMessage(state);
545
+ }
546
+ }
547
+ break;
548
+ }
549
+
550
+ // Sequential activity
551
+ const entry = state.spinners.get(event.id);
552
+ if (entry) {
553
+ const text =
554
+ event.payload.message ||
555
+ (event.payload.progress !== undefined ? `${event.payload.progress}%` : null);
556
+ if (text) {
557
+ entry.currentMessage = text;
558
+ // Only update spinner in unicode mode
559
+ if (state.outputConfig.unicode && entry.spinner) {
560
+ entry.spinner.message(text);
561
+ }
562
+ }
563
+ }
564
+ break;
565
+ }
566
+
567
+ case 'activity:success': {
568
+ // Check if this is a parallel activity
569
+ const parallelActivity = state.parallelActivities.get(event.id);
570
+ if (parallelActivity) {
571
+ parallelActivity.status = 'success';
572
+ if (state.outputConfig.unicode) {
573
+ updateParallelSpinnerMessage(state);
574
+ }
575
+ // Note: For parallel activities, logs will be flushed at group:end
576
+ break;
577
+ }
578
+
579
+ // Sequential activity
580
+ const entry = state.spinners.get(event.id);
581
+ if (entry) {
582
+ if (!state.outputConfig.unicode) {
583
+ // Plain mode - print success line
584
+ const prefix = colorize(state.symbols.success, pc.green, state.outputConfig.color);
585
+ writeLine(` ${prefix} ${entry.label}`);
586
+ } else if (entry.spinner) {
587
+ // Unicode mode - stop spinner
588
+ entry.spinner.stop(entry.label, 0);
589
+ }
590
+ state.spinners.delete(event.id);
591
+
592
+ // Flush any buffered logs for this activity
593
+ flushLogsForActivity(state, event.id);
594
+ } else {
595
+ // Check if this was a suspended activity
596
+ const suspended = state.suspendedActivities.get(event.id);
597
+ if (suspended && !state.suspended) {
598
+ if (!state.outputConfig.unicode) {
599
+ const prefix = colorize(
600
+ state.symbols.success,
601
+ pc.green,
602
+ state.outputConfig.color
603
+ );
604
+ writeLine(` ${prefix} ${suspended.label}`);
605
+ } else {
606
+ p.log.success(suspended.label);
607
+ }
608
+ state.suspendedActivities.delete(event.id);
609
+ }
610
+ }
611
+ break;
612
+ }
613
+
614
+ case 'activity:failure': {
615
+ const errorMessage = formatErrorMessage(event.error);
616
+
617
+ // Check if this is a parallel activity
618
+ const parallelActivity = state.parallelActivities.get(event.id);
619
+ if (parallelActivity) {
620
+ parallelActivity.status = 'failure';
621
+ parallelActivity.error = errorMessage;
622
+ parallelActivity.remediation = event.remediation;
623
+ parallelActivity.documentationUrl = event.documentationUrl;
624
+ // Mark the parent group as having failures
625
+ const parentGroup = state.groups.get(parallelActivity.groupId);
626
+ if (parentGroup) {
627
+ parentGroup.hasFailure = true;
628
+ }
629
+ if (state.outputConfig.unicode) {
630
+ updateParallelSpinnerMessage(state);
631
+ }
632
+ break;
633
+ }
634
+
635
+ // Sequential activity
636
+ const entry = state.spinners.get(event.id);
637
+ if (entry) {
638
+ // Mark the parent group as having failures
639
+ if (entry.parentGroupId) {
640
+ const parentGroup = state.groups.get(entry.parentGroupId);
641
+ if (parentGroup) {
642
+ parentGroup.hasFailure = true;
643
+ }
644
+ }
645
+
646
+ if (!state.outputConfig.unicode) {
647
+ // Plain mode - print error line
648
+ const prefix = colorize(state.symbols.error, pc.red, state.outputConfig.color);
649
+ writeLine(` ${prefix} ${errorMessage}`);
650
+ } else if (entry.spinner) {
651
+ // Unicode mode - stop spinner with error
652
+ entry.spinner.stop(errorMessage, 1);
653
+ }
654
+ state.spinners.delete(event.id);
655
+
656
+ // Display remediation steps if available
657
+ const linePrefix = state.outputConfig.unicode ? '\u2502' : state.symbols.groupLine;
658
+ if (event.remediation && event.remediation.length > 0) {
659
+ writeLine(linePrefix);
660
+ writeLine(`${linePrefix} To fix:`);
661
+ for (const step of event.remediation) {
662
+ writeLine(`${linePrefix} - ${step}`);
663
+ }
664
+ }
665
+
666
+ // Display documentation link if available
667
+ if (event.documentationUrl) {
668
+ writeLine(linePrefix);
669
+ writeLine(`${linePrefix} More info: ${event.documentationUrl}`);
670
+ }
671
+
672
+ // Flush any buffered logs for this activity
673
+ flushLogsForActivity(state, event.id);
674
+ } else {
675
+ // Check if this was a suspended activity
676
+ const suspended = state.suspendedActivities.get(event.id);
677
+ if (suspended && !state.suspended) {
678
+ if (!state.outputConfig.unicode) {
679
+ const prefix = colorize(state.symbols.error, pc.red, state.outputConfig.color);
680
+ writeLine(` ${prefix} ${suspended.label}: ${errorMessage}`);
681
+ } else {
682
+ p.log.error(`${suspended.label}: ${errorMessage}`);
683
+ }
684
+ state.suspendedActivities.delete(event.id);
685
+ }
686
+ }
687
+ break;
688
+ }
689
+
690
+ // Log events
691
+ case 'log': {
692
+ if (state.suspended) break;
693
+
694
+ // Verbose mode: always display logs immediately
695
+ if (state.verbose) {
696
+ displayLog(event.level, event.message, state, false);
697
+ break;
698
+ }
699
+
700
+ const hasActiveSpinners = state.spinners.size > 0 || state.parallelSpinner !== null;
701
+
702
+ // Error logs interrupt spinners immediately (only in unicode mode)
703
+ if (
704
+ event.level === 'error' &&
705
+ hasActiveSpinners &&
706
+ event.activityId &&
707
+ state.outputConfig.unicode
708
+ ) {
709
+ const spinner = state.spinners.get(event.activityId);
710
+ if (spinner && spinner.spinner) {
711
+ // Temporarily stop spinner, show error, resume
712
+ const currentMessage = spinner.currentMessage;
713
+ spinner.spinner.stop(currentMessage, 0);
714
+ displayLog(event.level, event.message, state, false);
715
+ spinner.spinner.start(currentMessage);
716
+ } else {
717
+ // No spinner for this activity, just display
718
+ displayLog(event.level, event.message, state, false);
719
+ }
720
+ break;
721
+ }
722
+
723
+ // Buffer logs during active spinners
724
+ if (hasActiveSpinners && event.activityId) {
725
+ // Check if we've hit the buffer limit for this activity
726
+ const activityLogCount = state.bufferedLogs.filter(
727
+ (log) => log.activityId === event.activityId
728
+ ).length;
729
+
730
+ if (activityLogCount < MAX_BUFFERED_LOGS_PER_ACTIVITY) {
731
+ state.bufferedLogs.push({
732
+ activityId: event.activityId,
733
+ level: event.level,
734
+ message: event.message,
735
+ timestamp: Date.now(),
736
+ });
737
+ }
738
+ // If over limit, silently drop (prevent memory issues)
739
+ break;
740
+ }
741
+
742
+ // No active spinners - display immediately
743
+ displayLog(event.level, event.message, state, false);
744
+ break;
745
+ }
746
+
747
+ // Reporter control events
748
+ case 'reporter:suspend': {
749
+ state.suspended = true;
750
+ // Stop all active spinners and track them for completion messages (only in unicode mode)
751
+ if (state.outputConfig.unicode) {
752
+ for (const [id, entry] of state.spinners) {
753
+ try {
754
+ if (entry.spinner) {
755
+ entry.spinner.stop(entry.label + '...', 0);
756
+ }
757
+ state.suspendedActivities.set(id, { label: entry.label });
758
+ } catch {
759
+ // Spinner may already be stopped
760
+ }
761
+ }
762
+ state.spinners.clear();
763
+
764
+ // Also stop parallel spinner
765
+ if (state.parallelSpinner) {
766
+ try {
767
+ state.parallelSpinner.spinner.stop('Paused...', 0);
768
+ } catch {
769
+ // Spinner may already be stopped
770
+ }
771
+ state.parallelSpinner = null;
772
+ }
773
+ }
774
+ break;
775
+ }
776
+
777
+ case 'reporter:resume': {
778
+ state.suspended = false;
779
+ break;
780
+ }
781
+ }
782
+ };
783
+
784
+ const unsubscribe = bus.on(handleEvent);
785
+
786
+ return {
787
+ stop(): void {
788
+ // Stop all active spinners (only in unicode mode where spinners exist)
789
+ if (state.outputConfig.unicode) {
790
+ for (const entry of state.spinners.values()) {
791
+ try {
792
+ if (entry.spinner) {
793
+ entry.spinner.stop('Stopped', 1);
794
+ }
795
+ } catch {
796
+ // Spinner may already be stopped
797
+ }
798
+ }
799
+ if (state.parallelSpinner) {
800
+ try {
801
+ state.parallelSpinner.spinner.stop('Stopped', 1);
802
+ } catch {
803
+ // Spinner may already be stopped
804
+ }
805
+ }
806
+ }
807
+ state.spinners.clear();
808
+ state.groups.clear();
809
+ state.parallelActivities.clear();
810
+ state.parallelSpinner = null;
811
+ state.parallelSpinnerGroupId = null;
812
+ unsubscribe();
813
+ },
814
+ };
815
+ },
816
+ };
817
+ }
package/src/symbols.ts ADDED
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Symbol Sets for CLI Output
3
+ *
4
+ * Provides Unicode and ASCII symbol sets for terminal output.
5
+ * The appropriate set is selected based on OutputConfig.
6
+ */
7
+
8
+ import type { OutputConfig } from '@pokit/core';
9
+
10
+ /**
11
+ * Complete set of symbols used in CLI output
12
+ */
13
+ export type SymbolSet = {
14
+ /** Success indicator (e.g., checkmark) */
15
+ success: string;
16
+ /** Error indicator (e.g., X mark) */
17
+ error: string;
18
+ /** Warning indicator */
19
+ warning: string;
20
+ /** Info indicator */
21
+ info: string;
22
+ /** Step/progress indicator */
23
+ step: string;
24
+ /** Group start (e.g., top-left corner) */
25
+ groupStart: string;
26
+ /** Group end (e.g., bottom-left corner) */
27
+ groupEnd: string;
28
+ /** Group line (e.g., vertical bar) */
29
+ groupLine: string;
30
+ /** Done message */
31
+ done: string;
32
+ /** Failed message */
33
+ failed: string;
34
+ };
35
+
36
+ /**
37
+ * Unicode symbols for rich terminal output
38
+ * Used when unicode support is detected
39
+ */
40
+ export const UNICODE_SYMBOLS: SymbolSet = {
41
+ success: '\u25C7', // ◇
42
+ error: '\u25A0', // ■
43
+ warning: '\u25B2', // ▲
44
+ info: '\u25CF', // ●
45
+ step: '\u25C7', // ◇
46
+ groupStart: '\u250C', // ┌
47
+ groupEnd: '\u2514', // └
48
+ groupLine: '\u2502', // │
49
+ done: '\u2714', // ✔
50
+ failed: '\u2718', // ✘
51
+ };
52
+
53
+ /**
54
+ * ASCII symbols for plain text output
55
+ * Used in CI environments or when unicode is not supported
56
+ */
57
+ export const ASCII_SYMBOLS: SymbolSet = {
58
+ success: '[OK]',
59
+ error: '[ERR]',
60
+ warning: '[WARN]',
61
+ info: '[INFO]',
62
+ step: '-',
63
+ groupStart: '[',
64
+ groupEnd: ']',
65
+ groupLine: '|',
66
+ done: 'Done',
67
+ failed: 'Failed',
68
+ };
69
+
70
+ /**
71
+ * Get the appropriate symbol set based on output configuration
72
+ *
73
+ * @param config - Output configuration
74
+ * @returns Symbol set to use
75
+ */
76
+ export function getSymbols(config: OutputConfig): SymbolSet {
77
+ return config.unicode ? UNICODE_SYMBOLS : ASCII_SYMBOLS;
78
+ }