@task-mcp/shared 1.0.21 → 1.0.23

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.
@@ -30,6 +30,170 @@ function validateInputLength(input: string): void {
30
30
  }
31
31
  }
32
32
 
33
+ /**
34
+ * Extracted metadata from natural language input
35
+ */
36
+ export interface ExtractedMetadata {
37
+ /** Extracted priority (!high, !medium, !low, !critical) */
38
+ priority?: Priority;
39
+ /** Extracted due date (today, tomorrow, next week, etc.) */
40
+ dueDate?: string;
41
+ /** Extracted tags (#tag) */
42
+ tags?: string[];
43
+ /** Extracted estimated time in minutes (~2h, ~30m) */
44
+ estimateMinutes?: number;
45
+ /** Remaining text after metadata extraction */
46
+ remaining: string;
47
+ }
48
+
49
+ /**
50
+ * Options for metadata extraction
51
+ */
52
+ export interface ExtractMetadataOptions {
53
+ /** Extract priority markers (!high, !low, etc.) */
54
+ extractPriority?: boolean;
55
+ /** Extract date expressions (today, tomorrow, by Friday, etc.) */
56
+ extractDate?: boolean;
57
+ /** Extract hashtags (#tag) */
58
+ extractTags?: boolean;
59
+ /** Extract time estimates (~2h, ~30m) */
60
+ extractEstimate?: boolean;
61
+ }
62
+
63
+ const DEFAULT_EXTRACT_OPTIONS: ExtractMetadataOptions = {
64
+ extractPriority: true,
65
+ extractDate: true,
66
+ extractTags: true,
67
+ extractEstimate: true,
68
+ };
69
+
70
+ /**
71
+ * Extract metadata from natural language input
72
+ *
73
+ * Supports:
74
+ * - Priority: !high, !medium, !low, !critical, !높음, !보통, !낮음, !긴급
75
+ * - Date: today, tomorrow, next week, 내일, 오늘, by Friday, 금요일까지
76
+ * - Tags: #tag, #개발, #backend
77
+ * - Time estimate: ~2h, ~30m, ~1h30m (with ~ prefix)
78
+ *
79
+ * @param input - Raw natural language input
80
+ * @param options - Options to control which metadata types to extract
81
+ * @returns Extracted metadata and remaining text
82
+ *
83
+ * @example
84
+ * ```typescript
85
+ * const result = extractMetadata("Review PR tomorrow #dev !high ~2h");
86
+ * // {
87
+ * // priority: "high",
88
+ * // dueDate: "2025-01-01",
89
+ * // tags: ["dev"],
90
+ * // estimateMinutes: 120,
91
+ * // remaining: "Review PR"
92
+ * // }
93
+ * ```
94
+ */
95
+ export function extractMetadata(
96
+ input: string,
97
+ options: ExtractMetadataOptions = DEFAULT_EXTRACT_OPTIONS
98
+ ): ExtractedMetadata {
99
+ validateInputLength(input);
100
+ let remaining = input.trim();
101
+ const result: ExtractedMetadata = { remaining: "" };
102
+
103
+ // Extract priority (!high, !critical, !medium, !low, !높음, !보통, !낮음)
104
+ if (options.extractPriority !== false) {
105
+ const priorityMatch = remaining.match(/!([\p{L}\p{N}_]+)/gu);
106
+ if (priorityMatch) {
107
+ for (const match of priorityMatch) {
108
+ const priority = parsePriority(match.slice(1));
109
+ if (priority) {
110
+ result.priority = priority;
111
+ remaining = remaining.replace(match, "").trim();
112
+ break;
113
+ }
114
+ }
115
+ }
116
+ }
117
+
118
+ // Extract tags (#dev, #backend, #개발)
119
+ if (options.extractTags !== false) {
120
+ const tagMatches = remaining.match(/#([\p{L}\p{N}_]+)/gu);
121
+ if (tagMatches) {
122
+ result.tags = tagMatches.map((m) => m.slice(1));
123
+ for (const match of tagMatches) {
124
+ remaining = remaining.replace(match, "").trim();
125
+ }
126
+ }
127
+ }
128
+
129
+ // Extract due date patterns
130
+ if (options.extractDate !== false) {
131
+ // "by Friday", "by tomorrow", "until next week"
132
+ const byMatch = remaining.match(/\b(by|until|before)\s+(\w+(\s+\w+)?)/i);
133
+ if (byMatch) {
134
+ const dateStr = byMatch[2]!;
135
+ const date = parseRelativeDate(dateStr);
136
+ if (date) {
137
+ result.dueDate = formatDate(date);
138
+ remaining = remaining.replace(byMatch[0], "").trim();
139
+ }
140
+ }
141
+
142
+ // Korean date patterns: "내일까지", "금요일까지"
143
+ if (!result.dueDate) {
144
+ const koreanDueMatch = remaining.match(/(\S+)까지/);
145
+ if (koreanDueMatch) {
146
+ const dateStr = koreanDueMatch[1]!;
147
+ const date = parseRelativeDate(dateStr);
148
+ if (date) {
149
+ result.dueDate = formatDate(date);
150
+ remaining = remaining.replace(koreanDueMatch[0], "").trim();
151
+ }
152
+ }
153
+ }
154
+
155
+ // "tomorrow", "today" at the end
156
+ if (!result.dueDate) {
157
+ const dateWords = ["tomorrow", "today", "내일", "오늘", "모레"];
158
+ for (const word of dateWords) {
159
+ if (remaining.toLowerCase().endsWith(word)) {
160
+ const date = parseRelativeDate(word);
161
+ if (date) {
162
+ result.dueDate = formatDate(date);
163
+ remaining = remaining.slice(0, -word.length).trim();
164
+ break;
165
+ }
166
+ }
167
+ }
168
+ }
169
+ }
170
+
171
+ // Extract time estimate (~2h, ~30m, ~1h30m)
172
+ if (options.extractEstimate !== false) {
173
+ const timeMatch = remaining.match(/~(\d+h\d+m|\d+h|\d+m)\b/);
174
+ if (timeMatch) {
175
+ let minutes = 0;
176
+ const hoursMatch = timeMatch[1]!.match(/(\d+)h/);
177
+ const minsMatch = timeMatch[1]!.match(/(\d+)m/);
178
+ if (hoursMatch) {
179
+ minutes += parseInt(hoursMatch[1]!, 10) * 60;
180
+ }
181
+ if (minsMatch) {
182
+ minutes += parseInt(minsMatch[1]!, 10);
183
+ }
184
+ if (minutes > 0) {
185
+ result.estimateMinutes = minutes;
186
+ remaining = remaining.replace(timeMatch[0], "").trim();
187
+ }
188
+ }
189
+ }
190
+
191
+ // Clean up extra spaces
192
+ result.remaining = remaining.replace(/\s+/g, " ").trim();
193
+
194
+ return result;
195
+ }
196
+
33
197
  /**
34
198
  * Target type for parsed input
35
199
  */
@@ -94,21 +258,21 @@ export function parseInput(input: string): ParsedInput {
94
258
  * - "성능 개선 아이디어 #performance"
95
259
  */
96
260
  export function parseInboxInput(input: string): InboxCreateInput {
97
- validateInputLength(input);
98
- let remaining = input.trim();
261
+ // Inbox only extracts tags
262
+ const metadata = extractMetadata(input, {
263
+ extractPriority: false,
264
+ extractDate: false,
265
+ extractTags: true,
266
+ extractEstimate: false,
267
+ });
268
+
99
269
  const result: InboxCreateInput = { content: "" };
100
270
 
101
- // Extract tags (#dev, #backend, #개발)
102
- const tagMatches = remaining.match(/#([\p{L}\p{N}_]+)/gu);
103
- if (tagMatches) {
104
- result.tags = tagMatches.map((m) => m.slice(1));
105
- for (const match of tagMatches) {
106
- remaining = remaining.replace(match, "").trim();
107
- }
271
+ if (metadata.tags) {
272
+ result.tags = metadata.tags;
108
273
  }
109
274
 
110
- // Clean up and set content
111
- result.content = remaining.replace(/\s+/g, " ").trim();
275
+ result.content = metadata.remaining;
112
276
 
113
277
  if (!result.content) {
114
278
  throw new InputValidationError(
@@ -123,7 +287,7 @@ export function parseInboxInput(input: string): InboxCreateInput {
123
287
  * Parse natural language task input
124
288
  *
125
289
  * Examples:
126
- * - "Review PR tomorrow #dev !high"
290
+ * - "Review PR tomorrow #dev !high ~2h"
127
291
  * - "내일까지 보고서 작성 #업무 !높음 @집중"
128
292
  * - "Fix bug by Friday #backend !critical"
129
293
  * - "Write tests every Monday #testing"
@@ -133,20 +297,7 @@ export function parseTaskInput(input: string): TaskCreateInput {
133
297
  let remaining = input.trim();
134
298
  const result: TaskCreateInput = { title: "" };
135
299
 
136
- // Extract priority (!high, !critical, !medium, !low, !높음, !보통, !낮음)
137
- const priorityMatch = remaining.match(/!([\p{L}\p{N}_]+)/gu);
138
- if (priorityMatch) {
139
- for (const match of priorityMatch) {
140
- const priority = parsePriority(match.slice(1));
141
- if (priority) {
142
- result.priority = priority;
143
- remaining = remaining.replace(match, "").trim();
144
- break;
145
- }
146
- }
147
- }
148
-
149
- // Extract contexts (@focus, @review, @집중)
300
+ // Extract contexts first (@focus, @review, @집중) - task-specific
150
301
  const contextMatches = remaining.match(/@([\p{L}\p{N}_]+)/gu);
151
302
  if (contextMatches) {
152
303
  result.contexts = contextMatches.map((m) => m.slice(1));
@@ -155,57 +306,20 @@ export function parseTaskInput(input: string): TaskCreateInput {
155
306
  }
156
307
  }
157
308
 
158
- // Extract tags (#dev, #backend, #개발)
159
- const tagMatches = remaining.match(/#([\p{L}\p{N}_]+)/gu);
160
- if (tagMatches) {
161
- result.tags = tagMatches.map((m) => m.slice(1));
162
- for (const match of tagMatches) {
163
- remaining = remaining.replace(match, "").trim();
164
- }
165
- }
166
-
167
- // Extract due date patterns
168
- // "by Friday", "by tomorrow", "until next week"
169
- const byMatch = remaining.match(/\b(by|until|before)\s+(\w+(\s+\w+)?)/i);
170
- if (byMatch) {
171
- const dateStr = byMatch[2]!;
172
- const date = parseRelativeDate(dateStr);
173
- if (date) {
174
- result.dueDate = formatDate(date);
175
- remaining = remaining.replace(byMatch[0], "").trim();
176
- }
177
- }
178
-
179
- // Korean date patterns: "내일까지", "금요일까지"
180
- const koreanDueMatch = remaining.match(/(\S+)까지/);
181
- if (koreanDueMatch && !result.dueDate) {
182
- const dateStr = koreanDueMatch[1]!;
183
- const date = parseRelativeDate(dateStr);
184
- if (date) {
185
- result.dueDate = formatDate(date);
186
- remaining = remaining.replace(koreanDueMatch[0], "").trim();
187
- }
188
- }
189
-
190
- // "tomorrow", "today" at the end
191
- const dateWords = ["tomorrow", "today", "내일", "오늘", "모레"];
192
- for (const word of dateWords) {
193
- if (remaining.toLowerCase().endsWith(word) && !result.dueDate) {
194
- const date = parseRelativeDate(word);
195
- if (date) {
196
- result.dueDate = formatDate(date);
197
- remaining = remaining.slice(0, -word.length).trim();
198
- }
199
- }
309
+ // Extract sortOrder (^1, ^10) - task-specific
310
+ const sortMatch = remaining.match(/\^(\d+)/);
311
+ if (sortMatch) {
312
+ result.sortOrder = parseInt(sortMatch[1]!, 10);
313
+ remaining = remaining.replace(sortMatch[0], "").trim();
200
314
  }
201
315
 
202
- // Extract time estimate (30m, 2h, 1h30m)
203
- // Pattern matches: "2h30m", "2h", "30m" (but not empty string)
204
- const timeMatch = remaining.match(/\b(\d+h\d+m|\d+h|\d+m)\b/);
205
- if (timeMatch) {
316
+ // Extract time estimate without ~ prefix for backward compatibility (30m, 2h, 1h30m)
317
+ // This handles the legacy format before extractMetadata handles ~prefix format
318
+ const legacyTimeMatch = remaining.match(/\b(\d+h\d+m|\d+h|\d+m)\b/);
319
+ if (legacyTimeMatch && !remaining.includes("~" + legacyTimeMatch[1])) {
206
320
  let minutes = 0;
207
- const hoursMatch = timeMatch[0].match(/(\d+)h/);
208
- const minsMatch = timeMatch[0].match(/(\d+)m/);
321
+ const hoursMatch = legacyTimeMatch[0].match(/(\d+)h/);
322
+ const minsMatch = legacyTimeMatch[0].match(/(\d+)m/);
209
323
  if (hoursMatch) {
210
324
  minutes += parseInt(hoursMatch[1]!, 10) * 60;
211
325
  }
@@ -214,20 +328,33 @@ export function parseTaskInput(input: string): TaskCreateInput {
214
328
  }
215
329
  if (minutes > 0) {
216
330
  result.estimate = { expected: minutes, confidence: "medium" };
217
- remaining = remaining.replace(timeMatch[0], "").trim();
331
+ remaining = remaining.replace(legacyTimeMatch[0], "").trim();
218
332
  }
219
333
  }
220
334
 
221
- // Extract sortOrder (^1, ^10, ^순서1)
222
- // Used for manual ordering within task lists
223
- const sortMatch = remaining.match(/\^(\d+)/);
224
- if (sortMatch) {
225
- result.sortOrder = parseInt(sortMatch[1]!, 10);
226
- remaining = remaining.replace(sortMatch[0], "").trim();
335
+ // Use extractMetadata for common patterns
336
+ const metadata = extractMetadata(remaining, {
337
+ extractPriority: true,
338
+ extractDate: true,
339
+ extractTags: true,
340
+ extractEstimate: true,
341
+ });
342
+
343
+ // Apply extracted metadata
344
+ if (metadata.priority) {
345
+ result.priority = metadata.priority;
346
+ }
347
+ if (metadata.dueDate) {
348
+ result.dueDate = metadata.dueDate;
349
+ }
350
+ if (metadata.tags) {
351
+ result.tags = metadata.tags;
352
+ }
353
+ if (metadata.estimateMinutes && !result.estimate) {
354
+ result.estimate = { expected: metadata.estimateMinutes, confidence: "medium" };
227
355
  }
228
356
 
229
- // Clean up extra spaces
230
- result.title = remaining.replace(/\s+/g, " ").trim();
357
+ result.title = metadata.remaining;
231
358
 
232
359
  if (!result.title) {
233
360
  throw new InputValidationError(
@@ -27,7 +27,7 @@ function assertNever(value: never): never {
27
27
  /**
28
28
  * Project a single task to the specified format
29
29
  */
30
- export function projectTask(task: Task, format: ResponseFormat): TaskSummary | TaskPreview | Task {
30
+ export function formatTask(task: Task, format: ResponseFormat): TaskSummary | TaskPreview | Task {
31
31
  switch (format) {
32
32
  case "concise":
33
33
  return {
@@ -59,19 +59,19 @@ export function projectTask(task: Task, format: ResponseFormat): TaskSummary | T
59
59
  /**
60
60
  * Project multiple tasks with optional limit
61
61
  */
62
- export function projectTasks(
62
+ export function formatTasks(
63
63
  tasks: Task[],
64
64
  format: ResponseFormat,
65
65
  limit?: number
66
66
  ): (TaskSummary | TaskPreview | Task)[] {
67
67
  const sliced = limit ? tasks.slice(0, limit) : tasks;
68
- return sliced.map((task) => projectTask(task, format));
68
+ return sliced.map((task) => formatTask(task, format));
69
69
  }
70
70
 
71
71
  /**
72
72
  * Project tasks with pagination
73
73
  */
74
- export function projectTasksPaginated(
74
+ export function formatTasksPaginated(
75
75
  tasks: Task[],
76
76
  format: ResponseFormat,
77
77
  limit: number = 20,
@@ -79,7 +79,7 @@ export function projectTasksPaginated(
79
79
  ): PaginatedResponse<TaskSummary | TaskPreview | Task> {
80
80
  const effectiveLimit = Math.min(limit, 100);
81
81
  const sliced = tasks.slice(offset, offset + effectiveLimit);
82
- const projected = sliced.map((task) => projectTask(task, format));
82
+ const projected = sliced.map((task) => formatTask(task, format));
83
83
 
84
84
  return {
85
85
  items: projected,
@@ -93,7 +93,7 @@ export function projectTasksPaginated(
93
93
  /**
94
94
  * Project a single inbox item to the specified format
95
95
  */
96
- export function projectInboxItem(
96
+ export function formatInboxItem(
97
97
  item: InboxItem,
98
98
  format: ResponseFormat
99
99
  ): InboxSummary | InboxPreview | InboxItem {
@@ -124,13 +124,13 @@ export function projectInboxItem(
124
124
  /**
125
125
  * Project multiple inbox items with optional limit
126
126
  */
127
- export function projectInboxItems(
127
+ export function formatInboxItems(
128
128
  items: InboxItem[],
129
129
  format: ResponseFormat,
130
130
  limit?: number
131
131
  ): (InboxSummary | InboxPreview | InboxItem)[] {
132
132
  const sliced = limit ? items.slice(0, limit) : items;
133
- return sliced.map((item) => projectInboxItem(item, format));
133
+ return sliced.map((item) => formatInboxItem(item, format));
134
134
  }
135
135
 
136
136
  /**
@@ -185,11 +185,9 @@ export function isFullWidth(char: string): boolean {
185
185
  (code >= 0xffe0 && code <= 0xffe6) || // Fullwidth Symbols
186
186
  (code >= 0x20000 && code <= 0x2ffff) || // CJK Extension B, C, D, E, F
187
187
  (code >= 0x30000 && code <= 0x3ffff) || // CJK Extension G, H, I
188
-
189
188
  // Symbols (typically wide in terminals)
190
189
  (code >= 0x2600 && code <= 0x26ff) || // Misc Symbols (sun, moon, stars, etc.)
191
190
  (code >= 0x2700 && code <= 0x27bf) || // Dingbats
192
-
193
191
  // Emoji ranges (comprehensive coverage)
194
192
  (code >= 0x1f1e0 && code <= 0x1f1ff) || // Regional Indicator Symbols (flags)
195
193
  (code >= 0x1f300 && code <= 0x1f5ff) || // Misc Symbols & Pictographs
@@ -202,7 +200,6 @@ export function isFullWidth(char: string): boolean {
202
200
  (code >= 0x1fa00 && code <= 0x1fa6f) || // Chess Symbols
203
201
  (code >= 0x1fa70 && code <= 0x1faff) || // Symbols & Pictographs Extended-A
204
202
  (code >= 0x1fb00 && code <= 0x1fbff) || // Symbols for Legacy Computing
205
-
206
203
  // Additional emoji-related
207
204
  (code >= 0x231a && code <= 0x231b) || // Watch, Hourglass
208
205
  (code >= 0x23e9 && code <= 0x23f3) || // Media control symbols
@@ -278,7 +275,11 @@ export const visibleLength = displayWidth;
278
275
  /**
279
276
  * Pad string to width (accounting for display width)
280
277
  */
281
- export function pad(str: string, width: number, align: "left" | "right" | "center" = "left"): string {
278
+ export function pad(
279
+ str: string,
280
+ width: number,
281
+ align: "left" | "right" | "center" = "left"
282
+ ): string {
282
283
  const len = displayWidth(str);
283
284
  const diff = width - len;
284
285
  if (diff <= 0) return str;
@@ -359,7 +360,8 @@ export function progressBar(
359
360
  const filledCount = Math.round((percent / 100) * width);
360
361
  const emptyCount = width - filledCount;
361
362
 
362
- const bar = color(filled.repeat(filledCount), filledColor) + color(empty.repeat(emptyCount), emptyColor);
363
+ const bar =
364
+ color(filled.repeat(filledCount), filledColor) + color(empty.repeat(emptyCount), emptyColor);
363
365
 
364
366
  return showPercent ? `${bar} ${percent}%` : bar;
365
367
  }
@@ -383,7 +385,10 @@ export function box(content: string, options: BoxOptions = {}): string {
383
385
  const { padding = 1, borderColor = "cyan", title, rounded = true } = options;
384
386
 
385
387
  const lines = content.split("\n");
386
- const maxLen = Math.max(...lines.map(l => displayWidth(stripAnsi(l))), title ? title.length + 2 : 0);
388
+ const maxLen = Math.max(
389
+ ...lines.map((l) => displayWidth(stripAnsi(l))),
390
+ title ? title.length + 2 : 0
391
+ );
387
392
  const innerWidth = options.width ? options.width - 2 - padding * 2 : maxLen + padding * 2;
388
393
 
389
394
  const tl = rounded ? BOX.rTopLeft : BOX.topLeft;
@@ -402,7 +407,10 @@ export function box(content: string, options: BoxOptions = {}): string {
402
407
  const remaining = innerWidth - titlePart.length;
403
408
  const leftPad = Math.floor(remaining / 2);
404
409
  const rightPad = remaining - leftPad;
405
- top = applyBorder(tl + h.repeat(leftPad)) + c.bold(titlePart) + applyBorder(h.repeat(rightPad) + tr);
410
+ top =
411
+ applyBorder(tl + h.repeat(leftPad)) +
412
+ c.bold(titlePart) +
413
+ applyBorder(h.repeat(rightPad) + tr);
406
414
  } else {
407
415
  top = applyBorder(tl + h.repeat(innerWidth) + tr);
408
416
  }
@@ -412,10 +420,16 @@ export function box(content: string, options: BoxOptions = {}): string {
412
420
  const paddingLines = Array(padding).fill(padLine);
413
421
 
414
422
  // Content lines
415
- const contentLines = lines.map(line => {
423
+ const contentLines = lines.map((line) => {
416
424
  const lineWidth = displayWidth(stripAnsi(line));
417
425
  const padRight = innerWidth - lineWidth - padding;
418
- return applyBorder(v) + " ".repeat(padding) + line + " ".repeat(Math.max(0, padRight)) + applyBorder(v);
426
+ return (
427
+ applyBorder(v) +
428
+ " ".repeat(padding) +
429
+ line +
430
+ " ".repeat(Math.max(0, padRight)) +
431
+ applyBorder(v)
432
+ );
419
433
  });
420
434
 
421
435
  // Bottom border
@@ -452,16 +466,16 @@ export function drawBox(lines: string[], options: BoxOptions = {}): string[] {
452
466
  const rightBorder = remainingWidth - leftBorder;
453
467
  result.push(
454
468
  applyBorder(BOX.topLeft) +
455
- applyBorder(BOX.horizontal.repeat(leftBorder)) +
456
- c.label(titleStr) +
457
- applyBorder(BOX.horizontal.repeat(rightBorder)) +
458
- applyBorder(BOX.topRight)
469
+ applyBorder(BOX.horizontal.repeat(leftBorder)) +
470
+ c.label(titleStr) +
471
+ applyBorder(BOX.horizontal.repeat(rightBorder)) +
472
+ applyBorder(BOX.topRight)
459
473
  );
460
474
  } else {
461
475
  result.push(
462
476
  applyBorder(BOX.topLeft) +
463
- applyBorder(BOX.horizontal.repeat(boxWidth - 2)) +
464
- applyBorder(BOX.topRight)
477
+ applyBorder(BOX.horizontal.repeat(boxWidth - 2)) +
478
+ applyBorder(BOX.topRight)
465
479
  );
466
480
  }
467
481
 
@@ -469,18 +483,18 @@ export function drawBox(lines: string[], options: BoxOptions = {}): string[] {
469
483
  for (const line of lines) {
470
484
  result.push(
471
485
  applyBorder(BOX.vertical) +
472
- padStr +
473
- padEnd(line, innerWidth) +
474
- padStr +
475
- applyBorder(BOX.vertical)
486
+ padStr +
487
+ padEnd(line, innerWidth) +
488
+ padStr +
489
+ applyBorder(BOX.vertical)
476
490
  );
477
491
  }
478
492
 
479
493
  // Bottom border
480
494
  result.push(
481
495
  applyBorder(BOX.bottomLeft) +
482
- applyBorder(BOX.horizontal.repeat(boxWidth - 2)) +
483
- applyBorder(BOX.bottomRight)
496
+ applyBorder(BOX.horizontal.repeat(boxWidth - 2)) +
497
+ applyBorder(BOX.bottomRight)
484
498
  );
485
499
 
486
500
  return result;
@@ -494,9 +508,9 @@ export function drawBox(lines: string[], options: BoxOptions = {}): string[] {
494
508
  * Place multiple boxes side by side (string input, string output)
495
509
  */
496
510
  export function sideBySide(boxes: string[], gap: number = 2): string {
497
- const boxLines = boxes.map(b => b.split("\n"));
498
- const maxHeight = Math.max(...boxLines.map(lines => lines.length));
499
- const boxWidths = boxLines.map(lines => Math.max(...lines.map(l => displayWidth(l))));
511
+ const boxLines = boxes.map((b) => b.split("\n"));
512
+ const maxHeight = Math.max(...boxLines.map((lines) => lines.length));
513
+ const boxWidths = boxLines.map((lines) => Math.max(...lines.map((l) => displayWidth(l))));
500
514
 
501
515
  // Pad each box to max height
502
516
  const paddedBoxLines = boxLines.map((lines, i) => {
@@ -504,7 +518,7 @@ export function sideBySide(boxes: string[], gap: number = 2): string {
504
518
  while (lines.length < maxHeight) {
505
519
  lines.push(" ".repeat(width));
506
520
  }
507
- return lines.map(line => {
521
+ return lines.map((line) => {
508
522
  const lineWidth = displayWidth(line);
509
523
  if (lineWidth < width) {
510
524
  return line + " ".repeat(width - lineWidth);
@@ -518,7 +532,7 @@ export function sideBySide(boxes: string[], gap: number = 2): string {
518
532
  const gapStr = " ".repeat(gap);
519
533
 
520
534
  for (let i = 0; i < maxHeight; i++) {
521
- const lineParts = paddedBoxLines.map(lines => lines[i] ?? "");
535
+ const lineParts = paddedBoxLines.map((lines) => lines[i] ?? "");
522
536
  result.push(lineParts.join(gapStr));
523
537
  }
524
538
 
@@ -529,14 +543,8 @@ export function sideBySide(boxes: string[], gap: number = 2): string {
529
543
  * Merge two boxes side by side (array input, array output)
530
544
  * For MCP server compatibility
531
545
  */
532
- export function sideBySideArrays(
533
- leftLines: string[],
534
- rightLines: string[],
535
- gap = 2
536
- ): string[] {
537
- const leftWidth = leftLines.length > 0
538
- ? Math.max(...leftLines.map(displayWidth))
539
- : 0;
546
+ export function sideBySideArrays(leftLines: string[], rightLines: string[], gap = 2): string[] {
547
+ const leftWidth = leftLines.length > 0 ? Math.max(...leftLines.map(displayWidth)) : 0;
540
548
 
541
549
  const maxLines = Math.max(leftLines.length, rightLines.length);
542
550
  const result: string[] = [];
@@ -580,10 +588,10 @@ export function table<T extends Record<string, unknown>>(
580
588
  const { headerColor = "cyan", borderColor = "gray" } = options;
581
589
 
582
590
  // Calculate column widths
583
- const widths = columns.map(col => {
591
+ const widths = columns.map((col) => {
584
592
  const headerWidth = displayWidth(col.header);
585
593
  const maxDataWidth = Math.max(
586
- ...data.map(row => {
594
+ ...data.map((row) => {
587
595
  const val = col.format ? col.format(row[col.key], row) : String(row[col.key] ?? "");
588
596
  return displayWidth(val);
589
597
  }),
@@ -601,10 +609,10 @@ export function table<T extends Record<string, unknown>>(
601
609
  .join(` ${border} `);
602
610
 
603
611
  // Separator
604
- const separator = widths.map(w => hBorder.repeat(w)).join(color(`─${BOX.cross}─`, borderColor));
612
+ const separator = widths.map((w) => hBorder.repeat(w)).join(color(`─${BOX.cross}─`, borderColor));
605
613
 
606
614
  // Data rows
607
- const dataRows = data.map(row =>
615
+ const dataRows = data.map((row) =>
608
616
  columns
609
617
  .map((col, i) => {
610
618
  const w = widths[i] ?? 0;
@@ -621,33 +629,24 @@ export function table<T extends Record<string, unknown>>(
621
629
  * Render table with full borders (array output)
622
630
  * For MCP server compatibility
623
631
  */
624
- export function renderTable(
625
- columns: TableColumn[],
626
- rows: Record<string, unknown>[]
627
- ): string[] {
632
+ export function renderTable(columns: TableColumn[], rows: Record<string, unknown>[]): string[] {
628
633
  const colWidths: number[] = columns.map((col) => {
629
634
  const headerWidth = col.header.length;
630
- const maxValueWidth = Math.max(
631
- ...rows.map((row) => String(row[col.key] ?? "").length)
632
- );
635
+ const maxValueWidth = Math.max(...rows.map((row) => String(row[col.key] ?? "").length));
633
636
  return col.width ?? Math.max(headerWidth, maxValueWidth);
634
637
  });
635
638
 
636
639
  const result: string[] = [];
637
640
 
638
641
  // Header row
639
- const headerCells = columns.map((col, i) =>
640
- c.label(center(col.header, colWidths[i] ?? 0))
642
+ const headerCells = columns.map((col, i) => c.label(center(col.header, colWidths[i] ?? 0)));
643
+ result.push(
644
+ c.muted(BOX.vertical) + headerCells.join(c.muted(BOX.vertical)) + c.muted(BOX.vertical)
641
645
  );
642
- result.push(c.muted(BOX.vertical) + headerCells.join(c.muted(BOX.vertical)) + c.muted(BOX.vertical));
643
646
 
644
647
  // Separator
645
648
  const separator = columns.map((_, i) => BOX.horizontal.repeat(colWidths[i] ?? 0));
646
- result.push(
647
- c.muted(BOX.teeRight) +
648
- c.muted(separator.join(BOX.cross)) +
649
- c.muted(BOX.teeLeft)
650
- );
649
+ result.push(c.muted(BOX.teeRight) + c.muted(separator.join(BOX.cross)) + c.muted(BOX.teeLeft));
651
650
 
652
651
  // Data rows
653
652
  for (const row of rows) {
@@ -782,5 +781,5 @@ export function banner(text: string): string {
782
781
  }
783
782
  }
784
783
 
785
- return lines.map(l => c.cyan(l)).join("\n");
784
+ return lines.map((l) => c.cyan(l)).join("\n");
786
785
  }