@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.
- package/README.md +122 -0
- package/package.json +1 -1
- package/src/algorithms/critical-path.ts +43 -38
- package/src/algorithms/dependency-integrity.ts +9 -15
- package/src/algorithms/tech-analysis.ts +27 -27
- package/src/algorithms/topological-sort.ts +71 -26
- package/src/schemas/index.ts +2 -13
- package/src/schemas/response-format.ts +6 -6
- package/src/schemas/response-schema.ts +25 -20
- package/src/schemas/task.ts +4 -22
- package/src/utils/dashboard-renderer.ts +27 -59
- package/src/utils/date.ts +2 -10
- package/src/utils/hierarchy.ts +4 -5
- package/src/utils/index.ts +12 -6
- package/src/utils/natural-language.ts +210 -83
- package/src/utils/projection.ts +8 -8
- package/src/utils/terminal-ui.ts +53 -54
- package/src/utils/workspace.ts +16 -4
|
@@ -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
|
-
|
|
98
|
-
|
|
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
|
-
|
|
102
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
159
|
-
const
|
|
160
|
-
if (
|
|
161
|
-
result.
|
|
162
|
-
|
|
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
|
-
//
|
|
204
|
-
const
|
|
205
|
-
if (
|
|
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 =
|
|
208
|
-
const minsMatch =
|
|
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(
|
|
331
|
+
remaining = remaining.replace(legacyTimeMatch[0], "").trim();
|
|
218
332
|
}
|
|
219
333
|
}
|
|
220
334
|
|
|
221
|
-
//
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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
|
-
|
|
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(
|
package/src/utils/projection.ts
CHANGED
|
@@ -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
|
|
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
|
|
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) =>
|
|
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
|
|
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) =>
|
|
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
|
|
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
|
|
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) =>
|
|
133
|
+
return sliced.map((item) => formatInboxItem(item, format));
|
|
134
134
|
}
|
|
135
135
|
|
|
136
136
|
/**
|
package/src/utils/terminal-ui.ts
CHANGED
|
@@ -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(
|
|
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 =
|
|
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(
|
|
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 =
|
|
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
|
|
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
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
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
|
-
|
|
464
|
-
|
|
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
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
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
|
-
|
|
483
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
}
|