@task-mcp/shared 1.0.8 → 1.0.10

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,706 @@
1
+ /**
2
+ * Terminal UI utilities for task-mcp
3
+ * Zero-dependency ANSI colors, box drawing, and layout helpers
4
+ * Supports CJK (Korean, Chinese, Japanese) character width calculation
5
+ */
6
+
7
+ // =============================================================================
8
+ // ANSI Escape Codes
9
+ // =============================================================================
10
+
11
+ const ESC = "\x1b[";
12
+ const RESET = `${ESC}0m`;
13
+
14
+ // Foreground color codes
15
+ const FG = {
16
+ black: 30,
17
+ red: 31,
18
+ green: 32,
19
+ yellow: 33,
20
+ blue: 34,
21
+ magenta: 35,
22
+ cyan: 36,
23
+ white: 37,
24
+ gray: 90,
25
+ brightRed: 91,
26
+ brightGreen: 92,
27
+ brightYellow: 93,
28
+ brightBlue: 94,
29
+ brightMagenta: 95,
30
+ brightCyan: 96,
31
+ brightWhite: 97,
32
+ } as const;
33
+
34
+ // Style codes
35
+ const STYLE = {
36
+ bold: 1,
37
+ dim: 2,
38
+ italic: 3,
39
+ underline: 4,
40
+ inverse: 7,
41
+ strikethrough: 9,
42
+ } as const;
43
+
44
+ type ColorName = keyof typeof FG;
45
+ type StyleName = keyof typeof STYLE;
46
+
47
+ /**
48
+ * Apply color to text
49
+ */
50
+ export function color(text: string, colorName: ColorName): string {
51
+ return `${ESC}${FG[colorName]}m${text}${RESET}`;
52
+ }
53
+
54
+ /**
55
+ * Apply style to text
56
+ */
57
+ export function style(text: string, styleName: StyleName): string {
58
+ return `${ESC}${STYLE[styleName]}m${text}${RESET}`;
59
+ }
60
+
61
+ /**
62
+ * Combine color and style
63
+ */
64
+ export function styled(text: string, colorName: ColorName, styleName?: StyleName): string {
65
+ if (styleName) {
66
+ return `${ESC}${STYLE[styleName]};${FG[colorName]}m${text}${RESET}`;
67
+ }
68
+ return color(text, colorName);
69
+ }
70
+
71
+ // =============================================================================
72
+ // Color Helper Functions
73
+ // =============================================================================
74
+
75
+ export const c = {
76
+ // Basic styles
77
+ reset: (s: string) => `${s}${RESET}`,
78
+ bold: (s: string) => style(s, "bold"),
79
+ dim: (s: string) => style(s, "dim"),
80
+ italic: (s: string) => style(s, "italic"),
81
+ underline: (s: string) => style(s, "underline"),
82
+
83
+ // Colors
84
+ black: (s: string) => color(s, "black"),
85
+ red: (s: string) => color(s, "red"),
86
+ green: (s: string) => color(s, "green"),
87
+ yellow: (s: string) => color(s, "yellow"),
88
+ blue: (s: string) => color(s, "blue"),
89
+ magenta: (s: string) => color(s, "magenta"),
90
+ cyan: (s: string) => color(s, "cyan"),
91
+ white: (s: string) => color(s, "white"),
92
+ gray: (s: string) => color(s, "gray"),
93
+
94
+ // Bright colors
95
+ brightRed: (s: string) => color(s, "brightRed"),
96
+ brightGreen: (s: string) => color(s, "brightGreen"),
97
+ brightYellow: (s: string) => color(s, "brightYellow"),
98
+ brightBlue: (s: string) => color(s, "brightBlue"),
99
+ brightMagenta: (s: string) => color(s, "brightMagenta"),
100
+ brightCyan: (s: string) => color(s, "brightCyan"),
101
+ brightWhite: (s: string) => color(s, "brightWhite"),
102
+
103
+ // Semantic colors
104
+ success: (s: string) => color(s, "green"),
105
+ error: (s: string) => color(s, "red"),
106
+ warning: (s: string) => color(s, "yellow"),
107
+ info: (s: string) => color(s, "cyan"),
108
+ muted: (s: string) => color(s, "gray"),
109
+ highlight: (s: string) => color(s, "brightCyan"),
110
+ label: (s: string) => styled(s, "cyan", "bold"),
111
+ title: (s: string) => styled(s, "brightWhite", "bold"),
112
+ value: (s: string) => color(s, "white"),
113
+ };
114
+
115
+ // =============================================================================
116
+ // Box Drawing Characters
117
+ // =============================================================================
118
+
119
+ export const BOX = {
120
+ // Single line
121
+ topLeft: "┌",
122
+ topRight: "┐",
123
+ bottomLeft: "└",
124
+ bottomRight: "┘",
125
+ horizontal: "─",
126
+ vertical: "│",
127
+ teeRight: "├",
128
+ teeLeft: "┤",
129
+ teeDown: "┬",
130
+ teeUp: "┴",
131
+ cross: "┼",
132
+
133
+ // Double line
134
+ dblTopLeft: "╔",
135
+ dblTopRight: "╗",
136
+ dblBottomLeft: "╚",
137
+ dblBottomRight: "╝",
138
+ dblHorizontal: "═",
139
+ dblVertical: "║",
140
+
141
+ // Rounded corners
142
+ rTopLeft: "╭",
143
+ rTopRight: "╮",
144
+ rBottomLeft: "╰",
145
+ rBottomRight: "╯",
146
+ } as const;
147
+
148
+ // =============================================================================
149
+ // String Utilities
150
+ // =============================================================================
151
+
152
+ /**
153
+ * Strip all ANSI escape codes from string
154
+ */
155
+ export function stripAnsi(str: string): string {
156
+ return str.replace(/\x1b\[[0-9;]*m/g, "");
157
+ }
158
+
159
+ /**
160
+ * Check if a character is full-width (CJK, Korean, emoji, etc.)
161
+ * Full-width characters take 2 columns in terminal
162
+ */
163
+ function isFullWidth(char: string): boolean {
164
+ const code = char.codePointAt(0);
165
+ if (code === undefined) return false;
166
+
167
+ return (
168
+ (code >= 0x1100 && code <= 0x115f) || // Hangul Jamo
169
+ (code >= 0x2e80 && code <= 0x9fff) || // CJK
170
+ (code >= 0xac00 && code <= 0xd7af) || // Hangul Syllables
171
+ (code >= 0xf900 && code <= 0xfaff) || // CJK Compatibility
172
+ (code >= 0xfe10 && code <= 0xfe1f) || // Vertical forms
173
+ (code >= 0xfe30 && code <= 0xfe6f) || // CJK Compatibility Forms
174
+ (code >= 0xff00 && code <= 0xff60) || // Fullwidth Forms
175
+ (code >= 0xffe0 && code <= 0xffe6) || // Fullwidth symbols
176
+ (code >= 0x1f300 && code <= 0x1f9ff) || // Emoji
177
+ (code >= 0x2600 && code <= 0x26ff) || // Misc Symbols
178
+ (code >= 0x2700 && code <= 0x27bf) || // Dingbats
179
+ (code >= 0x20000 && code <= 0x2ffff) // CJK Extension B+
180
+ );
181
+ }
182
+
183
+ /**
184
+ * Get display width of string (excluding ANSI codes, CJK = 2 columns)
185
+ */
186
+ export function displayWidth(str: string): number {
187
+ const stripped = stripAnsi(str);
188
+ let width = 0;
189
+ for (const char of stripped) {
190
+ width += isFullWidth(char) ? 2 : 1;
191
+ }
192
+ return width;
193
+ }
194
+
195
+ // Alias for compatibility
196
+ export const visibleLength = displayWidth;
197
+
198
+ /**
199
+ * Pad string to width (accounting for display width)
200
+ */
201
+ export function pad(str: string, width: number, align: "left" | "right" | "center" = "left"): string {
202
+ const len = displayWidth(str);
203
+ const diff = width - len;
204
+ if (diff <= 0) return str;
205
+
206
+ switch (align) {
207
+ case "right":
208
+ return " ".repeat(diff) + str;
209
+ case "center": {
210
+ const left = Math.floor(diff / 2);
211
+ return " ".repeat(left) + str + " ".repeat(diff - left);
212
+ }
213
+ default:
214
+ return str + " ".repeat(diff);
215
+ }
216
+ }
217
+
218
+ // Aliases for compatibility
219
+ export const padEnd = (str: string, width: number) => pad(str, width, "left");
220
+ export const padStart = (str: string, width: number) => pad(str, width, "right");
221
+ export const center = (str: string, width: number) => pad(str, width, "center");
222
+
223
+ /**
224
+ * Truncate string to max display width
225
+ */
226
+ export function truncateStr(str: string, maxLen: number, suffix = "..."): string {
227
+ const stripped = stripAnsi(str);
228
+ if (displayWidth(stripped) <= maxLen) return str;
229
+
230
+ const suffixWidth = displayWidth(suffix);
231
+ let width = 0;
232
+ let result = "";
233
+
234
+ for (const char of stripped) {
235
+ const charWidth = isFullWidth(char) ? 2 : 1;
236
+ if (width + charWidth > maxLen - suffixWidth) break;
237
+ result += char;
238
+ width += charWidth;
239
+ }
240
+
241
+ return result + suffix;
242
+ }
243
+
244
+ /**
245
+ * Create a horizontal line
246
+ */
247
+ export function hline(width: number, char: string = BOX.horizontal): string {
248
+ return char.repeat(width);
249
+ }
250
+
251
+ // =============================================================================
252
+ // Progress Bar
253
+ // =============================================================================
254
+
255
+ export interface ProgressBarOptions {
256
+ width?: number;
257
+ filled?: string;
258
+ empty?: string;
259
+ showPercent?: boolean;
260
+ filledColor?: ColorName;
261
+ emptyColor?: ColorName;
262
+ }
263
+
264
+ export function progressBar(
265
+ current: number,
266
+ total: number,
267
+ options: ProgressBarOptions = {}
268
+ ): string {
269
+ const {
270
+ width = 20,
271
+ filled = "█",
272
+ empty = "░",
273
+ showPercent = true,
274
+ filledColor = "green",
275
+ emptyColor = "gray",
276
+ } = options;
277
+
278
+ const percent = total > 0 ? Math.round((current / total) * 100) : 0;
279
+ const filledCount = Math.round((percent / 100) * width);
280
+ const emptyCount = width - filledCount;
281
+
282
+ const bar = color(filled.repeat(filledCount), filledColor) + color(empty.repeat(emptyCount), emptyColor);
283
+
284
+ return showPercent ? `${bar} ${percent}%` : bar;
285
+ }
286
+
287
+ // =============================================================================
288
+ // Box Drawing
289
+ // =============================================================================
290
+
291
+ export interface BoxOptions {
292
+ title?: string;
293
+ width?: number;
294
+ padding?: number;
295
+ borderColor?: ColorName;
296
+ rounded?: boolean;
297
+ }
298
+
299
+ /**
300
+ * Create a box around content (string input, string output)
301
+ */
302
+ export function box(content: string, options: BoxOptions = {}): string {
303
+ const { padding = 1, borderColor = "cyan", title, rounded = true } = options;
304
+
305
+ const lines = content.split("\n");
306
+ const maxLen = Math.max(...lines.map(l => displayWidth(stripAnsi(l))), title ? title.length + 2 : 0);
307
+ const innerWidth = options.width ? options.width - 2 - padding * 2 : maxLen + padding * 2;
308
+
309
+ const tl = rounded ? BOX.rTopLeft : BOX.topLeft;
310
+ const tr = rounded ? BOX.rTopRight : BOX.topRight;
311
+ const bl = rounded ? BOX.rBottomLeft : BOX.bottomLeft;
312
+ const br = rounded ? BOX.rBottomRight : BOX.bottomRight;
313
+ const h = BOX.horizontal;
314
+ const v = BOX.vertical;
315
+
316
+ const applyBorder = (s: string) => color(s, borderColor);
317
+
318
+ // Top border with optional title
319
+ let top: string;
320
+ if (title) {
321
+ const titlePart = ` ${title} `;
322
+ const remaining = innerWidth - titlePart.length;
323
+ const leftPad = Math.floor(remaining / 2);
324
+ const rightPad = remaining - leftPad;
325
+ top = applyBorder(tl + h.repeat(leftPad)) + c.bold(titlePart) + applyBorder(h.repeat(rightPad) + tr);
326
+ } else {
327
+ top = applyBorder(tl + h.repeat(innerWidth) + tr);
328
+ }
329
+
330
+ // Padding lines
331
+ const padLine = applyBorder(v) + " ".repeat(innerWidth) + applyBorder(v);
332
+ const paddingLines = Array(padding).fill(padLine);
333
+
334
+ // Content lines
335
+ const contentLines = lines.map(line => {
336
+ const lineWidth = displayWidth(stripAnsi(line));
337
+ const padRight = innerWidth - lineWidth - padding;
338
+ return applyBorder(v) + " ".repeat(padding) + line + " ".repeat(Math.max(0, padRight)) + applyBorder(v);
339
+ });
340
+
341
+ // Bottom border
342
+ const bottom = applyBorder(bl + h.repeat(innerWidth) + br);
343
+
344
+ return [top, ...paddingLines, ...contentLines, ...paddingLines, bottom].join("\n");
345
+ }
346
+
347
+ /**
348
+ * Draw a box around content (array input, array output)
349
+ * For MCP server compatibility
350
+ */
351
+ export function drawBox(lines: string[], options: BoxOptions = {}): string[] {
352
+ const { title, padding = 1, borderColor = "gray" } = options;
353
+
354
+ let maxContentWidth = 0;
355
+ for (const line of lines) {
356
+ maxContentWidth = Math.max(maxContentWidth, displayWidth(line));
357
+ }
358
+
359
+ const innerWidth = options.width ? options.width - 2 - padding * 2 : maxContentWidth;
360
+ const boxWidth = innerWidth + 2 + padding * 2;
361
+
362
+ const result: string[] = [];
363
+ const padStr = " ".repeat(padding);
364
+
365
+ const applyBorder = (s: string) => color(s, borderColor);
366
+
367
+ // Top border with optional title
368
+ if (title) {
369
+ const titleStr = ` ${title} `;
370
+ const remainingWidth = boxWidth - 2 - displayWidth(titleStr);
371
+ const leftBorder = Math.floor(remainingWidth / 2);
372
+ const rightBorder = remainingWidth - leftBorder;
373
+ result.push(
374
+ applyBorder(BOX.topLeft) +
375
+ applyBorder(BOX.horizontal.repeat(leftBorder)) +
376
+ c.label(titleStr) +
377
+ applyBorder(BOX.horizontal.repeat(rightBorder)) +
378
+ applyBorder(BOX.topRight)
379
+ );
380
+ } else {
381
+ result.push(
382
+ applyBorder(BOX.topLeft) +
383
+ applyBorder(BOX.horizontal.repeat(boxWidth - 2)) +
384
+ applyBorder(BOX.topRight)
385
+ );
386
+ }
387
+
388
+ // Content lines
389
+ for (const line of lines) {
390
+ result.push(
391
+ applyBorder(BOX.vertical) +
392
+ padStr +
393
+ padEnd(line, innerWidth) +
394
+ padStr +
395
+ applyBorder(BOX.vertical)
396
+ );
397
+ }
398
+
399
+ // Bottom border
400
+ result.push(
401
+ applyBorder(BOX.bottomLeft) +
402
+ applyBorder(BOX.horizontal.repeat(boxWidth - 2)) +
403
+ applyBorder(BOX.bottomRight)
404
+ );
405
+
406
+ return result;
407
+ }
408
+
409
+ // =============================================================================
410
+ // Side-by-Side Layout
411
+ // =============================================================================
412
+
413
+ /**
414
+ * Place multiple boxes side by side (string input, string output)
415
+ */
416
+ export function sideBySide(boxes: string[], gap: number = 2): string {
417
+ const boxLines = boxes.map(b => b.split("\n"));
418
+ const maxHeight = Math.max(...boxLines.map(lines => lines.length));
419
+ const boxWidths = boxLines.map(lines => Math.max(...lines.map(l => displayWidth(l))));
420
+
421
+ // Pad each box to max height
422
+ const paddedBoxLines = boxLines.map((lines, i) => {
423
+ const width = boxWidths[i] ?? 0;
424
+ while (lines.length < maxHeight) {
425
+ lines.push(" ".repeat(width));
426
+ }
427
+ return lines.map(line => {
428
+ const lineWidth = displayWidth(line);
429
+ if (lineWidth < width) {
430
+ return line + " ".repeat(width - lineWidth);
431
+ }
432
+ return line;
433
+ });
434
+ });
435
+
436
+ // Combine lines horizontally
437
+ const result: string[] = [];
438
+ const gapStr = " ".repeat(gap);
439
+
440
+ for (let i = 0; i < maxHeight; i++) {
441
+ const lineParts = paddedBoxLines.map(lines => lines[i] ?? "");
442
+ result.push(lineParts.join(gapStr));
443
+ }
444
+
445
+ return result.join("\n");
446
+ }
447
+
448
+ /**
449
+ * Merge two boxes side by side (array input, array output)
450
+ * For MCP server compatibility
451
+ */
452
+ export function sideBySideArrays(
453
+ leftLines: string[],
454
+ rightLines: string[],
455
+ gap = 2
456
+ ): string[] {
457
+ const leftWidth = leftLines.length > 0
458
+ ? Math.max(...leftLines.map(displayWidth))
459
+ : 0;
460
+
461
+ const maxLines = Math.max(leftLines.length, rightLines.length);
462
+ const result: string[] = [];
463
+ const gapStr = " ".repeat(gap);
464
+ const emptyLeft = " ".repeat(leftWidth);
465
+
466
+ for (let i = 0; i < maxLines; i++) {
467
+ const left = leftLines[i];
468
+ const right = rightLines[i] ?? "";
469
+
470
+ if (left !== undefined) {
471
+ result.push(padEnd(left, leftWidth) + gapStr + right);
472
+ } else {
473
+ result.push(emptyLeft + gapStr + right);
474
+ }
475
+ }
476
+
477
+ return result;
478
+ }
479
+
480
+ // =============================================================================
481
+ // Table Rendering
482
+ // =============================================================================
483
+
484
+ export interface TableColumn {
485
+ key: string;
486
+ header: string;
487
+ width?: number;
488
+ align?: "left" | "center" | "right";
489
+ format?: (value: unknown, row?: Record<string, unknown>) => string;
490
+ }
491
+
492
+ export function table<T extends Record<string, unknown>>(
493
+ data: T[],
494
+ columns: TableColumn[],
495
+ options: {
496
+ headerColor?: ColorName;
497
+ borderColor?: ColorName;
498
+ } = {}
499
+ ): string {
500
+ const { headerColor = "cyan", borderColor = "gray" } = options;
501
+
502
+ // Calculate column widths
503
+ const widths = columns.map(col => {
504
+ const headerWidth = displayWidth(col.header);
505
+ const maxDataWidth = Math.max(
506
+ ...data.map(row => {
507
+ const val = col.format ? col.format(row[col.key], row) : String(row[col.key] ?? "");
508
+ return displayWidth(val);
509
+ }),
510
+ 0
511
+ );
512
+ return col.width ?? Math.max(headerWidth, maxDataWidth);
513
+ });
514
+
515
+ const border = color(BOX.vertical, borderColor);
516
+ const hBorder = color(BOX.horizontal, borderColor);
517
+
518
+ // Header row
519
+ const headerRow = columns
520
+ .map((col, i) => color(pad(col.header, widths[i] ?? 0, col.align), headerColor))
521
+ .join(` ${border} `);
522
+
523
+ // Separator
524
+ const separator = widths.map(w => hBorder.repeat(w)).join(color(`─${BOX.cross}─`, borderColor));
525
+
526
+ // Data rows
527
+ const dataRows = data.map(row =>
528
+ columns
529
+ .map((col, i) => {
530
+ const w = widths[i] ?? 0;
531
+ const val = col.format ? col.format(row[col.key], row) : String(row[col.key] ?? "");
532
+ return pad(truncateStr(val, w), w, col.align);
533
+ })
534
+ .join(` ${border} `)
535
+ );
536
+
537
+ return [headerRow, separator, ...dataRows].join("\n");
538
+ }
539
+
540
+ /**
541
+ * Render table with full borders (array output)
542
+ * For MCP server compatibility
543
+ */
544
+ export function renderTable(
545
+ columns: TableColumn[],
546
+ rows: Record<string, unknown>[]
547
+ ): string[] {
548
+ const colWidths: number[] = columns.map((col) => {
549
+ const headerWidth = col.header.length;
550
+ const maxValueWidth = Math.max(
551
+ ...rows.map((row) => String(row[col.key] ?? "").length)
552
+ );
553
+ return col.width ?? Math.max(headerWidth, maxValueWidth);
554
+ });
555
+
556
+ const result: string[] = [];
557
+
558
+ // Header row
559
+ const headerCells = columns.map((col, i) =>
560
+ c.label(center(col.header, colWidths[i] ?? 0))
561
+ );
562
+ result.push(c.muted(BOX.vertical) + headerCells.join(c.muted(BOX.vertical)) + c.muted(BOX.vertical));
563
+
564
+ // Separator
565
+ const separator = columns.map((_, i) => BOX.horizontal.repeat(colWidths[i] ?? 0));
566
+ result.push(
567
+ c.muted(BOX.teeRight) +
568
+ c.muted(separator.join(BOX.cross)) +
569
+ c.muted(BOX.teeLeft)
570
+ );
571
+
572
+ // Data rows
573
+ for (const row of rows) {
574
+ const cells = columns.map((col, i) => {
575
+ const w = colWidths[i] ?? 0;
576
+ const value = String(row[col.key] ?? "");
577
+ const colorFn = col.format;
578
+ const colored = colorFn ? colorFn(value, row) : value;
579
+
580
+ switch (col.align) {
581
+ case "center":
582
+ return center(String(colored), w);
583
+ case "right":
584
+ return padStart(String(colored), w);
585
+ default:
586
+ return padEnd(String(colored), w);
587
+ }
588
+ });
589
+ result.push(c.muted(BOX.vertical) + cells.join(c.muted(BOX.vertical)) + c.muted(BOX.vertical));
590
+ }
591
+
592
+ // Top border
593
+ const topBorder =
594
+ c.muted(BOX.topLeft) +
595
+ c.muted(colWidths.map((w) => BOX.horizontal.repeat(w)).join(BOX.teeDown)) +
596
+ c.muted(BOX.topRight);
597
+
598
+ // Bottom border
599
+ const bottomBorder =
600
+ c.muted(BOX.bottomLeft) +
601
+ c.muted(colWidths.map((w) => BOX.horizontal.repeat(w)).join(BOX.teeUp)) +
602
+ c.muted(BOX.bottomRight);
603
+
604
+ return [topBorder, ...result, bottomBorder];
605
+ }
606
+
607
+ // =============================================================================
608
+ // Status & Priority Formatters
609
+ // =============================================================================
610
+
611
+ export const statusColors: Record<string, (s: string) => string> = {
612
+ pending: c.yellow,
613
+ in_progress: c.cyan,
614
+ completed: c.green,
615
+ blocked: c.red,
616
+ deferred: c.muted,
617
+ cancelled: c.muted,
618
+ };
619
+
620
+ export const statusIcons: Record<string, string> = {
621
+ pending: "○",
622
+ in_progress: "◐",
623
+ completed: "●",
624
+ blocked: "⊘",
625
+ deferred: "◇",
626
+ cancelled: "✕",
627
+ };
628
+
629
+ export const icons = {
630
+ // Status
631
+ pending: c.yellow("○"),
632
+ in_progress: c.blue("◐"),
633
+ completed: c.green("✓"),
634
+ blocked: c.red("✗"),
635
+ cancelled: c.gray("⊘"),
636
+
637
+ // Priority
638
+ critical: c.red("!!!"),
639
+ high: c.yellow("!!"),
640
+ medium: c.blue("!"),
641
+ low: c.gray("·"),
642
+
643
+ // Misc
644
+ arrow: c.cyan("→"),
645
+ bullet: c.gray("•"),
646
+ check: c.green("✓"),
647
+ cross: c.red("✗"),
648
+ warning: c.yellow("⚠"),
649
+ info: c.cyan("ℹ"),
650
+ };
651
+
652
+ export function formatStatus(status: string): string {
653
+ const icon = statusIcons[status] ?? "?";
654
+ const colorFn = statusColors[status] ?? c.white;
655
+ return colorFn(`${icon} ${status}`);
656
+ }
657
+
658
+ export const priorityColors: Record<string, (s: string) => string> = {
659
+ critical: c.brightRed,
660
+ high: c.yellow,
661
+ medium: c.cyan,
662
+ low: c.green,
663
+ };
664
+
665
+ export function formatPriority(priority: string): string {
666
+ const colorFn = priorityColors[priority] ?? c.white;
667
+ return colorFn(priority);
668
+ }
669
+
670
+ export function formatDependencies(deps: string[]): string {
671
+ if (deps.length === 0) return c.muted("None");
672
+ return c.magenta(deps.join(", "));
673
+ }
674
+
675
+ // =============================================================================
676
+ // Banner
677
+ // =============================================================================
678
+
679
+ /**
680
+ * ASCII art text banner
681
+ */
682
+ export function banner(text: string): string {
683
+ const letters: Record<string, string[]> = {
684
+ T: ["████", " ██ ", " ██ ", " ██ ", " ██ "],
685
+ A: [" ██ ", "████", "██ █", "████", "██ █"],
686
+ S: ["████", "██ ", "████", " ██", "████"],
687
+ K: ["██ █", "███ ", "██ ", "███ ", "██ █"],
688
+ M: ["█ █", "██ ██", "█ █ █", "█ █", "█ █"],
689
+ C: ["████", "██ ", "██ ", "██ ", "████"],
690
+ P: ["████", "██ █", "████", "██ ", "██ "],
691
+ " ": [" ", " ", " ", " ", " "],
692
+ };
693
+
694
+ const chars = text.toUpperCase().split("");
695
+ const lines: string[] = ["", "", "", "", ""];
696
+
697
+ const defaultLetter = [" ", " ", " ", " ", " "];
698
+ for (const char of chars) {
699
+ const letterLines = letters[char] ?? defaultLetter;
700
+ for (let i = 0; i < 5; i++) {
701
+ lines[i] += (letterLines[i] ?? "") + " ";
702
+ }
703
+ }
704
+
705
+ return lines.map(l => c.cyan(l)).join("\n");
706
+ }