ralphctl 0.1.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.
Files changed (118) hide show
  1. package/CHANGELOG.md +94 -0
  2. package/LICENSE +21 -0
  3. package/README.md +189 -0
  4. package/bin/ralphctl +13 -0
  5. package/package.json +92 -0
  6. package/schemas/config.schema.json +20 -0
  7. package/schemas/ideate-output.schema.json +22 -0
  8. package/schemas/projects.schema.json +53 -0
  9. package/schemas/requirements-output.schema.json +24 -0
  10. package/schemas/sprint.schema.json +109 -0
  11. package/schemas/task-import.schema.json +49 -0
  12. package/schemas/tasks.schema.json +72 -0
  13. package/src/ai/executor.ts +973 -0
  14. package/src/ai/lifecycle.ts +45 -0
  15. package/src/ai/parser.ts +40 -0
  16. package/src/ai/permissions.ts +207 -0
  17. package/src/ai/process-manager.ts +248 -0
  18. package/src/ai/prompts/ideate-auto.md +144 -0
  19. package/src/ai/prompts/ideate.md +165 -0
  20. package/src/ai/prompts/index.ts +89 -0
  21. package/src/ai/prompts/plan-auto.md +131 -0
  22. package/src/ai/prompts/plan-common.md +157 -0
  23. package/src/ai/prompts/plan-interactive.md +190 -0
  24. package/src/ai/prompts/task-execution.md +159 -0
  25. package/src/ai/prompts/ticket-refine.md +230 -0
  26. package/src/ai/rate-limiter.ts +89 -0
  27. package/src/ai/runner.ts +478 -0
  28. package/src/ai/session.ts +319 -0
  29. package/src/ai/task-context.ts +270 -0
  30. package/src/cli-metadata.ts +7 -0
  31. package/src/cli.ts +65 -0
  32. package/src/commands/completion/index.ts +33 -0
  33. package/src/commands/config/config.ts +58 -0
  34. package/src/commands/config/index.ts +33 -0
  35. package/src/commands/dashboard/dashboard.ts +5 -0
  36. package/src/commands/dashboard/index.ts +6 -0
  37. package/src/commands/doctor/doctor.ts +271 -0
  38. package/src/commands/doctor/index.ts +25 -0
  39. package/src/commands/progress/index.ts +25 -0
  40. package/src/commands/progress/log.ts +64 -0
  41. package/src/commands/progress/show.ts +14 -0
  42. package/src/commands/project/add.ts +336 -0
  43. package/src/commands/project/index.ts +104 -0
  44. package/src/commands/project/list.ts +31 -0
  45. package/src/commands/project/remove.ts +43 -0
  46. package/src/commands/project/repo.ts +118 -0
  47. package/src/commands/project/show.ts +49 -0
  48. package/src/commands/sprint/close.ts +180 -0
  49. package/src/commands/sprint/context.ts +109 -0
  50. package/src/commands/sprint/create.ts +60 -0
  51. package/src/commands/sprint/current.ts +75 -0
  52. package/src/commands/sprint/delete.ts +72 -0
  53. package/src/commands/sprint/health.ts +229 -0
  54. package/src/commands/sprint/ideate.ts +496 -0
  55. package/src/commands/sprint/index.ts +226 -0
  56. package/src/commands/sprint/list.ts +86 -0
  57. package/src/commands/sprint/plan-utils.ts +207 -0
  58. package/src/commands/sprint/plan.ts +549 -0
  59. package/src/commands/sprint/refine.ts +359 -0
  60. package/src/commands/sprint/requirements.ts +58 -0
  61. package/src/commands/sprint/show.ts +140 -0
  62. package/src/commands/sprint/start.ts +119 -0
  63. package/src/commands/sprint/switch.ts +20 -0
  64. package/src/commands/task/add.ts +316 -0
  65. package/src/commands/task/import.ts +150 -0
  66. package/src/commands/task/index.ts +123 -0
  67. package/src/commands/task/list.ts +145 -0
  68. package/src/commands/task/next.ts +45 -0
  69. package/src/commands/task/remove.ts +47 -0
  70. package/src/commands/task/reorder.ts +45 -0
  71. package/src/commands/task/show.ts +111 -0
  72. package/src/commands/task/status.ts +99 -0
  73. package/src/commands/ticket/add.ts +265 -0
  74. package/src/commands/ticket/edit.ts +166 -0
  75. package/src/commands/ticket/index.ts +114 -0
  76. package/src/commands/ticket/list.ts +128 -0
  77. package/src/commands/ticket/refine-utils.ts +89 -0
  78. package/src/commands/ticket/refine.ts +268 -0
  79. package/src/commands/ticket/remove.ts +48 -0
  80. package/src/commands/ticket/show.ts +74 -0
  81. package/src/completion/handle.ts +30 -0
  82. package/src/completion/resolver.ts +241 -0
  83. package/src/interactive/dashboard.ts +268 -0
  84. package/src/interactive/escapable.ts +81 -0
  85. package/src/interactive/file-browser.ts +153 -0
  86. package/src/interactive/index.ts +429 -0
  87. package/src/interactive/menu.ts +403 -0
  88. package/src/interactive/selectors.ts +273 -0
  89. package/src/interactive/wizard.ts +221 -0
  90. package/src/providers/claude.ts +53 -0
  91. package/src/providers/copilot.ts +86 -0
  92. package/src/providers/index.ts +43 -0
  93. package/src/providers/types.ts +85 -0
  94. package/src/schemas/index.ts +130 -0
  95. package/src/store/config.ts +74 -0
  96. package/src/store/progress.ts +230 -0
  97. package/src/store/project.ts +276 -0
  98. package/src/store/sprint.ts +229 -0
  99. package/src/store/task.ts +443 -0
  100. package/src/store/ticket.ts +178 -0
  101. package/src/theme/index.ts +215 -0
  102. package/src/theme/ui.ts +872 -0
  103. package/src/utils/detect-scripts.ts +247 -0
  104. package/src/utils/editor-input.ts +41 -0
  105. package/src/utils/editor.ts +37 -0
  106. package/src/utils/exit-codes.ts +27 -0
  107. package/src/utils/file-lock.ts +135 -0
  108. package/src/utils/git.ts +185 -0
  109. package/src/utils/ids.ts +37 -0
  110. package/src/utils/issue-fetch.ts +244 -0
  111. package/src/utils/json-extract.ts +62 -0
  112. package/src/utils/multiline.ts +61 -0
  113. package/src/utils/path-selector.ts +236 -0
  114. package/src/utils/paths.ts +108 -0
  115. package/src/utils/provider.ts +34 -0
  116. package/src/utils/requirements-export.ts +63 -0
  117. package/src/utils/storage.ts +107 -0
  118. package/tsconfig.json +25 -0
@@ -0,0 +1,872 @@
1
+ import ora, { type Ora } from 'ora';
2
+ import { banner, type ColorFn, colors, getRandomQuote, getStatusEmoji, gradients, isColorSupported } from './index.ts';
3
+
4
+ // ============================================================================
5
+ // ICONS
6
+ // ============================================================================
7
+
8
+ /** Emoji for interactive prompts (distinct from ASCII icons) */
9
+ export const emoji = {
10
+ donut: '🍩',
11
+ } as const;
12
+
13
+ /** Icons for visual hierarchy (ASCII only for professional look) */
14
+ export const icons = {
15
+ // Entities
16
+ sprint: '>',
17
+ ticket: '#',
18
+ task: '*',
19
+ project: '@',
20
+
21
+ // Actions
22
+ edit: '>',
23
+
24
+ // Status indicators
25
+ success: '+',
26
+ error: 'x',
27
+ warning: '!',
28
+ info: 'i',
29
+ tip: '?',
30
+ active: '*',
31
+ inactive: 'o',
32
+ bullet: '-',
33
+ } as const;
34
+
35
+ // ============================================================================
36
+ // LOGGING UTILITIES (consistent formatting with 2-space indent)
37
+ // ============================================================================
38
+
39
+ const INDENT = ' ';
40
+
41
+ /**
42
+ * Structured logging utilities for consistent output
43
+ */
44
+ export const log = {
45
+ /** Info message with icon */
46
+ info(message: string): void {
47
+ console.log(`${INDENT}${colors.info(icons.info)} ${message}`);
48
+ },
49
+
50
+ /** Success message with icon */
51
+ success(message: string): void {
52
+ console.log(`${INDENT}${colors.success(icons.success)} ${message}`);
53
+ },
54
+
55
+ /** Warning message with icon */
56
+ warn(message: string): void {
57
+ console.log(`${INDENT}${colors.warning(icons.warning)} ${message}`);
58
+ },
59
+
60
+ /** Error message with icon */
61
+ error(message: string): void {
62
+ console.log(`${INDENT}${colors.error(icons.error)} ${message}`);
63
+ },
64
+
65
+ /** Dimmed/muted message */
66
+ dim(message: string): void {
67
+ console.log(`${INDENT}${colors.muted(message)}`);
68
+ },
69
+
70
+ /** List item with bullet */
71
+ item(message: string): void {
72
+ console.log(`${INDENT}${INDENT}${colors.muted(icons.bullet)} ${message}`);
73
+ },
74
+
75
+ /** Success list item */
76
+ itemSuccess(message: string): void {
77
+ console.log(`${INDENT}${INDENT}${colors.success(icons.success)} ${message}`);
78
+ },
79
+
80
+ /** Error list item */
81
+ itemError(message: string, detail?: string): void {
82
+ console.log(`${INDENT}${INDENT}${colors.error(icons.error)} ${message}`);
83
+ if (detail) {
84
+ console.log(`${INDENT}${INDENT} ${colors.muted(detail)}`);
85
+ }
86
+ },
87
+
88
+ /** Raw text with indent */
89
+ raw(message: string, indentLevel = 1): void {
90
+ const prefix = INDENT.repeat(indentLevel);
91
+ console.log(`${prefix}${message}`);
92
+ },
93
+
94
+ /** Newline for spacing */
95
+ newline(): void {
96
+ console.log('');
97
+ },
98
+ };
99
+
100
+ // ============================================================================
101
+ // BOX & HEADER DRAWING
102
+ // ============================================================================
103
+
104
+ /**
105
+ * Print a simple header with icon and title
106
+ */
107
+ export function printHeader(title: string, icon?: string): void {
108
+ const displayIcon = icon ?? emoji.donut;
109
+ console.log('');
110
+ console.log(` ${displayIcon} ${colors.highlight(title)}`);
111
+ console.log(colors.muted(` ${'─'.repeat(40)}`));
112
+ console.log('');
113
+ }
114
+
115
+ /**
116
+ * Print a separator line
117
+ */
118
+ export function printSeparator(width = 40): void {
119
+ console.log(`${INDENT}${colors.muted('─'.repeat(width))}`);
120
+ }
121
+
122
+ // ============================================================================
123
+ // BOX-DRAWING CHARACTERS & UTILITIES
124
+ // ============================================================================
125
+
126
+ /** Box-drawing character sets */
127
+ export const boxChars = {
128
+ /** Light box-drawing (default) */
129
+ light: {
130
+ topLeft: '┌',
131
+ topRight: '┐',
132
+ bottomLeft: '└',
133
+ bottomRight: '┘',
134
+ horizontal: '─',
135
+ vertical: '│',
136
+ teeRight: '├',
137
+ teeLeft: '┤',
138
+ teeDown: '┬',
139
+ teeUp: '┴',
140
+ cross: '┼',
141
+ },
142
+ /** Rounded corners */
143
+ rounded: {
144
+ topLeft: '╭',
145
+ topRight: '╮',
146
+ bottomLeft: '╰',
147
+ bottomRight: '╯',
148
+ horizontal: '─',
149
+ vertical: '│',
150
+ teeRight: '├',
151
+ teeLeft: '┤',
152
+ teeDown: '┬',
153
+ teeUp: '┴',
154
+ cross: '┼',
155
+ },
156
+ /** Heavy box-drawing */
157
+ heavy: {
158
+ topLeft: '┏',
159
+ topRight: '┓',
160
+ bottomLeft: '┗',
161
+ bottomRight: '┛',
162
+ horizontal: '━',
163
+ vertical: '┃',
164
+ teeRight: '┣',
165
+ teeLeft: '┫',
166
+ teeDown: '┳',
167
+ teeUp: '┻',
168
+ cross: '╋',
169
+ },
170
+ } as const;
171
+
172
+ export type BoxStyle = keyof typeof boxChars;
173
+
174
+ // Comprehensive ANSI escape sequence regex (CSI, OSC, and character set sequences)
175
+ // eslint-disable-next-line no-control-regex
176
+ const ANSI_REGEX = /\x1B(?:\[[0-9;]*[A-Za-z]|\][^\x07]*\x07|\([A-Z])/g;
177
+
178
+ /** Strip ANSI escape codes from a string for width calculation */
179
+ function stripAnsi(s: string): string {
180
+ return s.replace(ANSI_REGEX, '');
181
+ }
182
+
183
+ /**
184
+ * Sanitize a user-controlled string for safe terminal display.
185
+ * Strips all ANSI escape sequences that could manipulate the terminal.
186
+ */
187
+ export function sanitizeForDisplay(s: string): string {
188
+ return s.replace(ANSI_REGEX, '');
189
+ }
190
+
191
+ /** Minimum inner width for rendered boxes and cards */
192
+ export const MIN_BOX_WIDTH = 20;
193
+
194
+ /** Default terminal width fallback when not a TTY */
195
+ const DEFAULT_TERMINAL_WIDTH = 80;
196
+
197
+ /** Get the current terminal width, clamped to a reasonable minimum */
198
+ function getTerminalWidth(): number {
199
+ return process.stdout.columns || DEFAULT_TERMINAL_WIDTH;
200
+ }
201
+
202
+ /**
203
+ * Word-wrap a plain-text line to fit within maxWidth visible characters.
204
+ * Splits on word boundaries where possible, hard-breaks long words.
205
+ * Preserves leading whitespace on the first line; continuation lines get the same indent.
206
+ */
207
+ function wrapLine(line: string, maxWidth: number): string[] {
208
+ const visible = stripAnsi(line);
209
+ if (visible.length <= maxWidth) return [line];
210
+
211
+ // Detect leading indent
212
+ const indentMatch = /^(\s*)/.exec(visible);
213
+ const indent = indentMatch?.[1] ?? '';
214
+ const indentLen = indent.length;
215
+ const wrapWidth = maxWidth - indentLen;
216
+
217
+ if (wrapWidth <= 0) return [line]; // Can't wrap if indent eats everything
218
+
219
+ const words = visible.trimStart().split(/(\s+)/);
220
+ const wrapped: string[] = [];
221
+ let current = '';
222
+
223
+ for (const word of words) {
224
+ if (current.length + word.length <= wrapWidth) {
225
+ current += word;
226
+ } else if (current.length === 0) {
227
+ // Single word longer than wrapWidth — hard break
228
+ for (let i = 0; i < word.length; i += wrapWidth) {
229
+ wrapped.push(indent + word.slice(i, i + wrapWidth));
230
+ }
231
+ } else {
232
+ wrapped.push(indent + current.trimEnd());
233
+ current = word.trimStart();
234
+ }
235
+ }
236
+ if (current.trimEnd().length > 0) {
237
+ wrapped.push(indent + current.trimEnd());
238
+ }
239
+
240
+ return wrapped.length > 0 ? wrapped : [line];
241
+ }
242
+
243
+ /** Standard label width for detail views (accommodates labels like "External ID:") */
244
+ export const DETAIL_LABEL_WIDTH = 14;
245
+
246
+ /** Draw a horizontal line with optional label */
247
+ export function horizontalLine(width: number, style: BoxStyle = 'light'): string {
248
+ return boxChars[style].horizontal.repeat(width);
249
+ }
250
+
251
+ /** Draw a vertical line character */
252
+ export function verticalLine(style: BoxStyle = 'light'): string {
253
+ return boxChars[style].vertical;
254
+ }
255
+
256
+ /**
257
+ * Render a box with border around content lines.
258
+ * Automatically wraps content to fit within the terminal width.
259
+ */
260
+ export function renderBox(
261
+ lines: string[],
262
+ options: { style?: BoxStyle; padding?: number; colorFn?: ColorFn } = {}
263
+ ): string {
264
+ const { style = 'rounded', padding = 1, colorFn = colors.muted } = options;
265
+ const chars = boxChars[style];
266
+ const pad = ' '.repeat(padding);
267
+
268
+ // Clamp to terminal width (border chars = 2, plus padding on each side)
269
+ const termWidth = getTerminalWidth();
270
+ const maxInnerWidth = Math.max(MIN_BOX_WIDTH, termWidth - 2);
271
+
272
+ // Wrap lines that exceed available content width
273
+ const maxContentWidth = maxInnerWidth - padding * 2;
274
+ const wrappedLines = lines.flatMap((l) => wrapLine(l, maxContentWidth));
275
+
276
+ const contentWidths = wrappedLines.map((l) => stripAnsi(l).length);
277
+ const innerWidth = Math.min(Math.max(...contentWidths, MIN_BOX_WIDTH) + padding * 2, maxInnerWidth);
278
+
279
+ const result: string[] = [];
280
+ result.push(colorFn(chars.topLeft + chars.horizontal.repeat(innerWidth) + chars.topRight));
281
+
282
+ for (const line of wrappedLines) {
283
+ const visibleLen = stripAnsi(line).length;
284
+ const rightPad = ' '.repeat(Math.max(0, innerWidth - padding * 2 - visibleLen));
285
+ result.push(colorFn(chars.vertical) + pad + line + rightPad + pad + colorFn(chars.vertical));
286
+ }
287
+
288
+ result.push(colorFn(chars.bottomLeft + chars.horizontal.repeat(innerWidth) + chars.bottomRight));
289
+ return result.join('\n');
290
+ }
291
+
292
+ /**
293
+ * Render a card with title bar and content body.
294
+ * Automatically wraps content lines to fit within the terminal width.
295
+ */
296
+ export function renderCard(
297
+ title: string,
298
+ lines: string[],
299
+ options: { style?: BoxStyle; colorFn?: ColorFn } = {}
300
+ ): string {
301
+ const { style = 'rounded', colorFn = colors.muted } = options;
302
+ const chars = boxChars[style];
303
+
304
+ // Clamp to terminal width (border chars + 1 padding on each side = 4 total)
305
+ const termWidth = getTerminalWidth();
306
+ const maxInnerWidth = Math.max(MIN_BOX_WIDTH, termWidth - 4);
307
+
308
+ const safeTitle = sanitizeForDisplay(title);
309
+ const titleWidth = Math.min(safeTitle.length, maxInnerWidth - 2);
310
+
311
+ // Wrap lines that exceed available content width (innerWidth minus 2 for padding)
312
+ const wrappedLines = lines.flatMap((l) => wrapLine(l, maxInnerWidth - 2));
313
+
314
+ const contentWidths = wrappedLines.map((l) => stripAnsi(l).length);
315
+ const innerWidth = Math.min(Math.max(...contentWidths, titleWidth, MIN_BOX_WIDTH) + 2, maxInnerWidth);
316
+
317
+ const result: string[] = [];
318
+ // Top border
319
+ result.push(colorFn(chars.topLeft + chars.horizontal.repeat(innerWidth) + chars.topRight));
320
+ // Title line
321
+ const titlePad = ' '.repeat(Math.max(0, innerWidth - titleWidth - 2));
322
+ result.push(colorFn(chars.vertical) + ' ' + colors.highlight(safeTitle) + titlePad + ' ' + colorFn(chars.vertical));
323
+ // Separator
324
+ result.push(colorFn(chars.teeRight + chars.horizontal.repeat(innerWidth) + chars.teeLeft));
325
+ // Content lines
326
+ for (const line of wrappedLines) {
327
+ const visibleLen = stripAnsi(line).length;
328
+ const rightPad = ' '.repeat(Math.max(0, innerWidth - visibleLen - 2));
329
+ result.push(colorFn(chars.vertical) + ' ' + line + rightPad + ' ' + colorFn(chars.vertical));
330
+ }
331
+ // Bottom border
332
+ result.push(colorFn(chars.bottomLeft + chars.horizontal.repeat(innerWidth) + chars.bottomRight));
333
+ return result.join('\n');
334
+ }
335
+
336
+ // ============================================================================
337
+ // BANNER & WELCOME
338
+ // ============================================================================
339
+
340
+ // Re-export getRandomQuote for external use
341
+ export { getRandomQuote } from './index.ts';
342
+
343
+ /**
344
+ * Show the themed banner with gradient styling.
345
+ * Falls back to flat color when colors are not supported.
346
+ */
347
+ export function showBanner(): void {
348
+ if (isColorSupported) {
349
+ console.log(gradients.donut.multiline(banner.art));
350
+ } else {
351
+ console.log(banner.art);
352
+ }
353
+ const quote = getRandomQuote();
354
+ console.log(colors.muted(` "${quote}"\n`));
355
+ }
356
+
357
+ // ============================================================================
358
+ // SECTION HEADERS (simpler style)
359
+ // ============================================================================
360
+
361
+ /**
362
+ * Format a section header with icon
363
+ */
364
+ export function section(title: string, icon?: string): string {
365
+ const prefix = icon ? `${icon} ` : '';
366
+ return '\n' + colors.info(prefix + title) + '\n';
367
+ }
368
+
369
+ /**
370
+ * Format a subsection header
371
+ */
372
+ export function subsection(title: string): string {
373
+ return colors.muted(` ${title}`);
374
+ }
375
+
376
+ // ============================================================================
377
+ // FIELD FORMATTING (consistent alignment)
378
+ // ============================================================================
379
+
380
+ /**
381
+ * Format a labeled field with consistent padding
382
+ * @param label - Field label (e.g., "ID", "Name")
383
+ * @param value - Field value
384
+ * @param labelWidth - Width for label column (default 12)
385
+ */
386
+ export function field(label: string, value: string, labelWidth = 12): string {
387
+ const paddedLabel = (label + ':').padEnd(labelWidth);
388
+ return `${INDENT}${colors.muted(paddedLabel)} ${value}`;
389
+ }
390
+
391
+ /**
392
+ * Format a detail field for card content (trimmed for consistent alignment).
393
+ * Used in show commands to build card content lines.
394
+ */
395
+ export function labelValue(label: string, value: string, labelWidth = DETAIL_LABEL_WIDTH): string {
396
+ return field(label, value, labelWidth).trimStart();
397
+ }
398
+
399
+ /**
400
+ * Format a multiline field with proper indentation
401
+ * First line shows label, subsequent lines are indented to align
402
+ */
403
+ export function fieldMultiline(label: string, value: string, labelWidth = 12): string {
404
+ const lines = value.split('\n');
405
+ const paddedLabel = (label + ':').padEnd(labelWidth);
406
+ const indent = INDENT + ' '.repeat(labelWidth + 1);
407
+
408
+ if (lines.length === 1) {
409
+ return `${INDENT}${colors.muted(paddedLabel)} ${value}`;
410
+ }
411
+
412
+ const firstLine = lines[0] ?? '';
413
+ const result: string[] = [];
414
+ result.push(`${INDENT}${colors.muted(paddedLabel)} ${firstLine}`);
415
+ for (let i = 1; i < lines.length; i++) {
416
+ const line = lines[i] ?? '';
417
+ result.push(`${indent}${line}`);
418
+ }
419
+ return result.join('\n');
420
+ }
421
+
422
+ // ============================================================================
423
+ // STATUS FORMATTING
424
+ // ============================================================================
425
+
426
+ /**
427
+ * Format task status for display
428
+ */
429
+ export function formatTaskStatus(status: 'todo' | 'in_progress' | 'done'): string {
430
+ const emoji = getStatusEmoji(status);
431
+ const labels: Record<string, string> = {
432
+ todo: 'To Do',
433
+ in_progress: 'In Progress',
434
+ done: 'Done',
435
+ };
436
+ const statusColors: Record<string, ColorFn> = {
437
+ todo: colors.muted,
438
+ in_progress: colors.warning,
439
+ done: colors.success,
440
+ };
441
+ const colorFn = statusColors[status] ?? colors.muted;
442
+ return colorFn(`${emoji} ${labels[status] ?? status}`);
443
+ }
444
+
445
+ /**
446
+ * Format sprint status for display
447
+ */
448
+ export function formatSprintStatus(status: 'draft' | 'active' | 'closed'): string {
449
+ const emoji = getStatusEmoji(status);
450
+ const labels: Record<string, string> = {
451
+ draft: 'Draft',
452
+ active: 'Active',
453
+ closed: 'Closed',
454
+ };
455
+ const statusColors: Record<string, ColorFn> = {
456
+ draft: colors.warning,
457
+ active: colors.success,
458
+ closed: colors.muted,
459
+ };
460
+ const colorFn = statusColors[status] ?? colors.muted;
461
+ return colorFn(`${emoji} ${labels[status] ?? status}`);
462
+ }
463
+
464
+ /**
465
+ * Format a badge (inline status indicator)
466
+ */
467
+ export function badge(text: string, type: 'success' | 'warning' | 'error' | 'muted' = 'muted'): string {
468
+ const colorFn = colors[type];
469
+ return colorFn(`[${text}]`);
470
+ }
471
+
472
+ // ============================================================================
473
+ // SUMMARY & STATS
474
+ // ============================================================================
475
+
476
+ /**
477
+ * Print a summary line with label and value
478
+ */
479
+ export function printSummary(items: [string, string | number][]): void {
480
+ printSeparator();
481
+ for (const [label, value] of items) {
482
+ console.log(`${INDENT}${colors.muted(label)} ${colors.highlight(String(value))}`);
483
+ }
484
+ }
485
+
486
+ /**
487
+ * Print a count summary (e.g., "5/10 tasks done (50%)")
488
+ */
489
+ export function printCountSummary(label: string, done: number, total: number): void {
490
+ const percent = total > 0 ? Math.round((done / total) * 100) : 0;
491
+ const color = percent === 100 ? colors.success : percent > 50 ? colors.warning : colors.muted;
492
+ printSeparator();
493
+ console.log(`${INDENT}${label} ${color(`${String(done)}/${String(total)} (${String(percent)}%)`)}`);
494
+ }
495
+
496
+ // ============================================================================
497
+ // MESSAGES
498
+ // ============================================================================
499
+
500
+ /**
501
+ * Show a success message with optional details
502
+ */
503
+ export function showSuccess(message: string, details?: [string, string][]): void {
504
+ console.log('\n' + `${INDENT}${colors.success(icons.success)} ${colors.success(message)}`);
505
+ if (details) {
506
+ console.log(details.map(([label, value]) => field(label, value)).join('\n'));
507
+ }
508
+ }
509
+
510
+ /**
511
+ * Show an error message
512
+ */
513
+ export function showError(message: string): void {
514
+ console.log('\n' + `${INDENT}${colors.error(icons.error)} ${colors.error(message)}`);
515
+ }
516
+
517
+ /**
518
+ * Show an info message
519
+ */
520
+ export function showInfo(message: string): void {
521
+ console.log(`${INDENT}${colors.info(icons.info)} ${colors.info(message)}`);
522
+ }
523
+
524
+ /**
525
+ * Show a warning message
526
+ */
527
+ export function showWarning(message: string): void {
528
+ console.log(`${INDENT}${colors.warning(icons.warning)} ${colors.warning(message)}`);
529
+ }
530
+
531
+ /**
532
+ * Show a tip/hint
533
+ */
534
+ export function showTip(message: string): void {
535
+ console.log(`${INDENT}${colors.muted(icons.tip + ' ' + message)}`);
536
+ }
537
+
538
+ /**
539
+ * Show an empty state with helpful next action
540
+ */
541
+ export function showEmpty(what: string, hint?: string): void {
542
+ console.log('\n' + `${INDENT}${colors.muted(icons.inactive)} ${colors.muted(`No ${what} yet.`)}`);
543
+ if (hint) {
544
+ console.log(`${INDENT} ${colors.muted(icons.tip + ' ' + hint)}\n`);
545
+ }
546
+ }
547
+
548
+ // ============================================================================
549
+ // NEXT STEPS / HINTS
550
+ // ============================================================================
551
+
552
+ /**
553
+ * Show next step suggestion (single)
554
+ */
555
+ export function showNextStep(command: string, description?: string): void {
556
+ const desc = description ? ` ${colors.muted('- ' + description)}` : '';
557
+ console.log(`${INDENT}${colors.muted('→')} ${colors.highlight(command)}${desc}`);
558
+ }
559
+
560
+ /**
561
+ * Show multiple next step suggestions compactly
562
+ */
563
+ export function showNextSteps(steps: [command: string, description?: string][]): void {
564
+ for (const [command, description] of steps) {
565
+ showNextStep(command, description);
566
+ }
567
+ }
568
+
569
+ // ============================================================================
570
+ // FORMATTING HELPERS
571
+ // ============================================================================
572
+
573
+ /**
574
+ * Format muted/secondary text
575
+ */
576
+ export function formatMuted(text: string): string {
577
+ return colors.muted(text);
578
+ }
579
+
580
+ /**
581
+ * Show a random Ralph quote
582
+ */
583
+ export function showRandomQuote(): void {
584
+ const quote = getRandomQuote();
585
+ console.log(colors.muted(` "${quote}"`));
586
+ }
587
+
588
+ // ============================================================================
589
+ // SPINNER
590
+ // ============================================================================
591
+
592
+ /**
593
+ * Create a spinner for async operations
594
+ */
595
+ export function createSpinner(text: string): Ora {
596
+ return ora({
597
+ text,
598
+ color: 'yellow',
599
+ prefixText: INDENT,
600
+ // Disable stdin-discarder: it puts stdin in raw mode, which swallows
601
+ // Ctrl+C (byte 0x03) instead of letting the OS deliver a real SIGINT.
602
+ discardStdin: false,
603
+ spinner: {
604
+ interval: 80,
605
+ frames: Array(8)
606
+ .fill(emoji.donut)
607
+ .map((d: string, i) => (i % 2 === 0 ? colors.highlight(d) : colors.muted(d))),
608
+ },
609
+ });
610
+ }
611
+
612
+ // ============================================================================
613
+ // ANIMATION HELPERS
614
+ // ============================================================================
615
+
616
+ /**
617
+ * Check if the current output supports interactive features (TTY).
618
+ * Returns false for piped output, CI environments, or when NO_COLOR is set.
619
+ */
620
+ export function isTTY(): boolean {
621
+ if (!process.stdout.isTTY || process.env['NO_COLOR']) return false;
622
+ return true;
623
+ }
624
+
625
+ /**
626
+ * Typewriter effect: prints text one character at a time.
627
+ * Falls back to instant print when not a TTY.
628
+ * @param text - Text to display
629
+ * @param delayMs - Delay between characters (default 30ms)
630
+ */
631
+ export async function typewriter(text: string, delayMs = 30): Promise<void> {
632
+ if (!isTTY()) {
633
+ console.log(text);
634
+ return;
635
+ }
636
+ for (const char of text) {
637
+ process.stdout.write(char);
638
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
639
+ }
640
+ process.stdout.write('\n');
641
+ }
642
+
643
+ /**
644
+ * Progressive reveal: prints lines one at a time with a delay.
645
+ * Falls back to printing all lines at once when not a TTY.
646
+ * @param lines - Lines to reveal progressively
647
+ * @param delayMs - Delay between lines (default 50ms)
648
+ */
649
+ export async function progressiveReveal(lines: string[], delayMs = 50): Promise<void> {
650
+ if (!isTTY()) {
651
+ for (const line of lines) {
652
+ console.log(line);
653
+ }
654
+ return;
655
+ }
656
+ for (const line of lines) {
657
+ console.log(line);
658
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
659
+ }
660
+ }
661
+
662
+ // ============================================================================
663
+ // TERMINAL BELL
664
+ // ============================================================================
665
+
666
+ /**
667
+ * Ring the terminal bell to notify the user.
668
+ * No-op when not a TTY (piped output, CI, etc.).
669
+ */
670
+ export function terminalBell(): void {
671
+ if (isTTY()) {
672
+ process.stdout.write('\x07');
673
+ }
674
+ }
675
+
676
+ // ============================================================================
677
+ // ENHANCED SPINNERS
678
+ // ============================================================================
679
+
680
+ /** Spinner variant presets */
681
+ export type SpinnerVariant = 'donut' | 'sprinkle' | 'minimal';
682
+
683
+ /**
684
+ * Create a themed spinner with a variant style.
685
+ * @param text - Spinner message
686
+ * @param variant - Visual style: 'donut' (default), 'sprinkle', 'minimal'
687
+ */
688
+ export function createThemedSpinner(text: string, variant: SpinnerVariant = 'donut'): Ora {
689
+ const spinnerConfig: Record<SpinnerVariant, { interval: number; frames: string[] }> = {
690
+ donut: {
691
+ interval: 80,
692
+ frames: Array(8)
693
+ .fill(emoji.donut)
694
+ .map((d: string, i: number) => (i % 2 === 0 ? colors.highlight(d) : colors.muted(d))),
695
+ },
696
+ sprinkle: {
697
+ interval: 120,
698
+ frames: ['🍩', '🍪', '🧁', '🍰', '🎂', '🍰', '🧁', '🍪'],
699
+ },
700
+ minimal: {
701
+ interval: 100,
702
+ frames: ['·', '•', '●', '•'],
703
+ },
704
+ };
705
+
706
+ return ora({
707
+ text,
708
+ color: 'yellow',
709
+ prefixText: INDENT,
710
+ spinner: spinnerConfig[variant],
711
+ });
712
+ }
713
+
714
+ // ============================================================================
715
+ // CLEAR SCREEN
716
+ // ============================================================================
717
+
718
+ /**
719
+ * Clear the terminal screen.
720
+ * No-op when not a TTY (piped output, CI, etc.).
721
+ */
722
+ export function clearScreen(): void {
723
+ if (isTTY()) {
724
+ process.stdout.write('\x1B[2J\x1B[0f');
725
+ }
726
+ }
727
+
728
+ // ============================================================================
729
+ // PROGRESS BAR
730
+ // ============================================================================
731
+
732
+ export interface ProgressBarOptions {
733
+ width?: number;
734
+ filled?: string;
735
+ empty?: string;
736
+ showPercent?: boolean;
737
+ }
738
+
739
+ export function progressBar(done: number, total: number, options: ProgressBarOptions = {}): string {
740
+ const { width = 20, filled = '\u2588', empty = '\u2591', showPercent = true } = options;
741
+ if (total === 0 || width <= 0) return colors.muted('\u2500'.repeat(Math.max(0, width)));
742
+ const filledCount = Math.round((done / total) * width);
743
+ const emptyCount = width - filledCount;
744
+ const percent = Math.round((done / total) * 100);
745
+ const bar = colors.success(filled.repeat(filledCount)) + colors.muted(empty.repeat(emptyCount));
746
+ if (!showPercent) return bar;
747
+ const label = percent === 100 ? colors.success(`${String(percent)}%`) : colors.muted(`${String(percent)}%`);
748
+ return `${bar} ${label}`;
749
+ }
750
+
751
+ // ============================================================================
752
+ // TABLE RENDERER
753
+ // ============================================================================
754
+
755
+ export interface TableColumn {
756
+ header: string;
757
+ align?: 'left' | 'right';
758
+ color?: ColorFn;
759
+ minWidth?: number;
760
+ }
761
+
762
+ export interface TableOptions {
763
+ style?: BoxStyle;
764
+ indent?: number;
765
+ colorFn?: ColorFn;
766
+ }
767
+
768
+ export function renderTable(columns: TableColumn[], rows: string[][], options: TableOptions = {}): string {
769
+ const { style = 'rounded', indent = 2, colorFn = colors.muted } = options;
770
+ const chars = boxChars[style];
771
+ const pad = ' '.repeat(indent);
772
+
773
+ // Calculate column widths (ANSI-safe)
774
+ const colWidths = columns.map((col, i) => {
775
+ const headerWidth = col.header.length;
776
+ const dataWidth = Math.max(0, ...rows.map((row) => stripAnsi(row[i] ?? '').length));
777
+ return Math.max(headerWidth, dataWidth, col.minWidth ?? 0);
778
+ });
779
+
780
+ const result: string[] = [];
781
+
782
+ // Top border
783
+ const topLine = colWidths.map((w) => chars.horizontal.repeat(w + 2)).join(chars.teeDown);
784
+ result.push(pad + colorFn(chars.topLeft + topLine + chars.topRight));
785
+
786
+ // Header row
787
+ const headerCells = columns.map((col, i) => {
788
+ const w = colWidths[i] ?? 0;
789
+ return ' ' + colors.highlight(col.header.padEnd(w)) + ' ';
790
+ });
791
+ result.push(pad + colorFn(chars.vertical) + headerCells.join(colorFn(chars.vertical)) + colorFn(chars.vertical));
792
+
793
+ // Header separator
794
+ const sepLine = colWidths.map((w) => chars.horizontal.repeat(w + 2)).join(chars.cross);
795
+ result.push(pad + colorFn(chars.teeRight + sepLine + chars.teeLeft));
796
+
797
+ // Data rows
798
+ for (const row of rows) {
799
+ const cells = columns.map((col, i) => {
800
+ const w = colWidths[i] ?? 0;
801
+ const cell = row[i] ?? '';
802
+ const visibleLen = stripAnsi(cell).length;
803
+ const padding = Math.max(0, w - visibleLen);
804
+ const coloredCell = col.color ? col.color(cell) : cell;
805
+ if (col.align === 'right') {
806
+ return ' ' + ' '.repeat(padding) + coloredCell + ' ';
807
+ }
808
+ return ' ' + coloredCell + ' '.repeat(padding) + ' ';
809
+ });
810
+ result.push(pad + colorFn(chars.vertical) + cells.join(colorFn(chars.vertical)) + colorFn(chars.vertical));
811
+ }
812
+
813
+ // Bottom border
814
+ const bottomLine = colWidths.map((w) => chars.horizontal.repeat(w + 2)).join(chars.teeUp);
815
+ result.push(pad + colorFn(chars.bottomLeft + bottomLine + chars.bottomRight));
816
+
817
+ return result.join('\n');
818
+ }
819
+
820
+ // ============================================================================
821
+ // COLUMN LAYOUT
822
+ // ============================================================================
823
+
824
+ export interface ColumnOptions {
825
+ gap?: number;
826
+ minWidth?: number;
827
+ }
828
+
829
+ export function renderColumns(blocks: string[][], options: ColumnOptions = {}): string {
830
+ const { gap = 4, minWidth = 20 } = options;
831
+ const colCount = blocks.length;
832
+ if (colCount === 0) return '';
833
+ if (colCount === 1) return (blocks[0] ?? []).join('\n');
834
+
835
+ // Calculate width of each block
836
+ const widths = blocks.map((lines) => Math.max(minWidth, ...lines.map((l) => stripAnsi(l).length)));
837
+
838
+ // Find max line count
839
+ const maxLines = Math.max(...blocks.map((b) => b.length));
840
+ const gapStr = ' '.repeat(gap);
841
+
842
+ const result: string[] = [];
843
+ for (let i = 0; i < maxLines; i++) {
844
+ const parts = blocks.map((block, colIdx) => {
845
+ const line = block[i] ?? '';
846
+ const w = widths[colIdx] ?? minWidth;
847
+ const visibleLen = stripAnsi(line).length;
848
+ return line + ' '.repeat(Math.max(0, w - visibleLen));
849
+ });
850
+ result.push(parts.join(gapStr));
851
+ }
852
+ return result.join('\n');
853
+ }
854
+
855
+ // ============================================================================
856
+ // PROGRESS SUMMARY
857
+ // ============================================================================
858
+
859
+ export interface ProgressSummaryLabels {
860
+ done?: string;
861
+ remaining?: string;
862
+ title?: string;
863
+ }
864
+
865
+ export function renderProgressSummary(done: number, total: number, labels: ProgressSummaryLabels = {}): string {
866
+ const { done: doneLabel = 'done', remaining: remainingLabel = 'remaining', title } = labels;
867
+ const remaining = total - done;
868
+ const bar = progressBar(done, total);
869
+ const summary = `${colors.success(String(done))} ${colors.muted(doneLabel)}, ${colors.muted(String(remaining))} ${colors.muted(remainingLabel)}`;
870
+ const prefix = title ? `${colors.highlight(title)} ` : '';
871
+ return `${prefix}${bar} ${summary}`;
872
+ }