@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.
- package/dist/schemas/index.d.ts +1 -1
- package/dist/schemas/index.d.ts.map +1 -1
- package/dist/schemas/index.js +1 -1
- package/dist/schemas/index.js.map +1 -1
- package/dist/utils/index.d.ts +1 -0
- package/dist/utils/index.d.ts.map +1 -1
- package/dist/utils/index.js +18 -0
- package/dist/utils/index.js.map +1 -1
- package/dist/utils/terminal-ui.d.ts +203 -0
- package/dist/utils/terminal-ui.d.ts.map +1 -0
- package/dist/utils/terminal-ui.js +534 -0
- package/dist/utils/terminal-ui.js.map +1 -0
- package/package.json +1 -1
- package/src/utils/index.ts +44 -0
- package/src/utils/terminal-ui.ts +706 -0
|
@@ -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
|
+
}
|