cowork-cli 2.3.0 → 2.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -53,7 +53,8 @@ cwk "Explain the data flow in the engine/ models"
53
53
 
54
54
  ## ✨ Features that Matter
55
55
 
56
- - **Zero-Whitespace UI**: High-density terminal output designed for professionals. No fluff, no headers, just data.
56
+ - **Zero-Whitespace UI**: High-density terminal output designed for professionals. No fluff, just clean, ansi-balanced structured layouts.
57
+ - **Rich Terminal Markdown Rendering**: Custom lightweight layout engine supporting responsive headers, bullets, blockquotes, code blocks with clean borders, horizontal rules, and tables, with full CJK visual character column matching.
57
58
  - **Interactive Feedback**: The AI can request clarifications via the `askUser` tool or trigger an interactive `[ Yes ] No` toggle using `askConfirm`.
58
59
  - **Smart Discovery**: Built-in `searchText`, `findFile`, and `projectTree` tools that respect your `.gitignore`.
59
60
  - **Web Research**: Dynamically search the web (`webSearch`) and read documentation (`webFetch`) directly from the CLI.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cowork-cli",
3
- "version": "2.3.0",
3
+ "version": "2.5.0",
4
4
  "description": "work with cowork",
5
5
  "bin": {
6
6
  "cwk": "bin/cli.js"
@@ -5,7 +5,7 @@
5
5
  "data": "#C2C6C5",
6
6
  "success": "#7AC391",
7
7
  "error": "#E07070",
8
- "dim": "#606060",
8
+ "dim": "#8F9399",
9
9
  "header": "#A37ACC"
10
10
  }
11
11
  }
@@ -8,8 +8,15 @@ You are Cowork (cwk), an interactive CLI tool acting as a Full Engineering Read-
8
8
 
9
9
  # CONSTRAINTS (Terminal Environment)
10
10
  - NO conversational filler, pleasantries, preambles, or postambles.
11
- - NO Markdown (no *, `, #, or code blocks). NO XML/HTML tags.
12
- - Output ONLY structured, concise plain text with simple spacing/indentation.
11
+ - Use ONLY the following supported Markdown syntax elements:
12
+ * Headers: # (H1), ## (H2), ### (H3)
13
+ * Lists: starting with -, *, + (bullets) or 1. (numbers)
14
+ * Blockquotes: starting with >
15
+ * Fenced code blocks: using ```
16
+ * Inline styles: **bold**, *italic* (or _italic_), `inline code`
17
+ * Links: [label](url)
18
+ * Horizontal rules: ---
19
+ * Tables: standard markdown tables using | and -
13
20
  - Cite code locations using: file_path:line_number.
14
21
 
15
22
  # EXECUTION & STYLE
@@ -16,7 +16,7 @@ try {
16
16
  data: '#C2C6C5',
17
17
  success: '#7AC391',
18
18
  error: '#E07070',
19
- dim: '#606060',
19
+ dim: '#8F9399',
20
20
  header: '#A37ACC',
21
21
  }
22
22
  };
@@ -31,7 +31,7 @@ function hexToAnsi(hex) {
31
31
 
32
32
  const reset = '\x1b[0m';
33
33
 
34
- const colors = {
34
+ export const colors = {
35
35
  main: hexToAnsi(config.accents.main),
36
36
  tool: hexToAnsi(config.accents.tool),
37
37
  data: hexToAnsi(config.accents.data),
@@ -1,3 +1,93 @@
1
+ import { colors } from './logger.js';
2
+
3
+ const reset = '\x1b[0m';
4
+
5
+ function stripAnsi(str) {
6
+ return str.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '');
7
+ }
8
+
9
+ function visualLength(str) {
10
+ if (!str) return 0;
11
+ const clean = stripAnsi(str);
12
+ let len = 0;
13
+ for (let i = 0; i < clean.length; i++) {
14
+ const code = clean.charCodeAt(i);
15
+ if ((code >= 0x3000 && code <= 0x9FFF) || (code >= 0xFF00 && code <= 0xFFEF)) {
16
+ len += 2;
17
+ } else {
18
+ len += 1;
19
+ }
20
+ }
21
+ return len;
22
+ }
23
+
24
+ function applyInlineStyles(str) {
25
+ if (!str) return '';
26
+
27
+ // 1. Inline code: `code` -> tool color (amber) + code + reset foreground color
28
+ str = str.replace(/`([^`]+)`/g, (match, code) => {
29
+ return `${colors.tool}${code}\x1b[39m`;
30
+ });
31
+
32
+ // 2. Bold styling: **text** -> bold terminal style + main color (blue) + text + reset
33
+ str = str.replace(/\*\*([^*]+)\*\*/g, (match, boldText) => {
34
+ return `\x1b[1m${colors.main}${boldText}\x1b[22m\x1b[39m`;
35
+ });
36
+
37
+ // 3. Italic/dim styling: *text* or _text_ -> dim color (grey) + text + reset foreground color
38
+ str = str.replace(/\*([^*]+)\*/g, (match, italicText) => {
39
+ return `${colors.dim}${italicText}\x1b[39m`;
40
+ });
41
+ str = str.replace(/_([^_]+)_/g, (match, italicText) => {
42
+ return `${colors.dim}${italicText}\x1b[39m`;
43
+ });
44
+
45
+ // 4. Link styling: [label](url) -> label (main/blue) + dim url (grey)
46
+ str = str.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (match, label, url) => {
47
+ return `${colors.main}${label}\x1b[39m ${colors.dim}(${url})\x1b[39m`;
48
+ });
49
+
50
+ return str;
51
+ }
52
+
53
+ function balanceAnsiStyles(wrappedLines) {
54
+ let activeColor = null;
55
+
56
+ return wrappedLines.map(line => {
57
+ let prefix = '';
58
+ if (activeColor) prefix += activeColor;
59
+
60
+ const ansiRegex = /\x1b\[([0-9;]*)m/g;
61
+ let match;
62
+ while ((match = ansiRegex.exec(line)) !== null) {
63
+ const code = match[1];
64
+ const parts = code.split(';');
65
+
66
+ if (parts.includes('0') || parts.includes('39')) {
67
+ activeColor = null;
68
+ }
69
+
70
+ for (let i = 0; i < parts.length; i++) {
71
+ const part = parts[i];
72
+ if (part === '38') {
73
+ if (parts[i + 1] === '2' || parts[i + 1] === '5') {
74
+ activeColor = match[0];
75
+ break;
76
+ }
77
+ } else if (/^[39][0-9]$/.test(part) && part !== '39') {
78
+ activeColor = match[0];
79
+ break;
80
+ }
81
+ }
82
+ }
83
+
84
+ let suffix = '';
85
+ if (activeColor) suffix += '\x1b[39m';
86
+
87
+ return prefix + line + suffix;
88
+ });
89
+ }
90
+
1
91
  /**
2
92
  * Wraps text to the current terminal width without breaking words unless necessary.
3
93
  * Dynamically detects terminal width and preserves whitespace/indentation.
@@ -6,52 +96,205 @@
6
96
  * @returns {string} The formatted text.
7
97
  */
8
98
  export function outputFormatted(text, overrideWidth) {
9
- if (!text) return '';
99
+ if (text === null || text === undefined) return '';
100
+ // Normalize newlines, strip carriage returns, and expand tabs to preserve column alignment
101
+ const strText = String(text).replace(/\r/g, '').replace(/\t/g, ' ');
10
102
 
11
- // Dynamically determine width (fallback to 80 if not detectable)
12
- const width = overrideWidth || process.stdout.columns || 80;
103
+ let rawWidth = overrideWidth || process.stdout.columns || 80;
104
+ if (typeof rawWidth !== 'number' || isNaN(rawWidth) || rawWidth < 10) {
105
+ rawWidth = 80;
106
+ }
13
107
 
14
- const lines = text.split('\n');
15
- const wrappedLines = lines.map(line => {
16
- if (line.length <= width) return line;
108
+ // Use full terminal width with a small 2-char right margin to prevent
109
+ // edge-wrapping artifacts on terminals that wrap at the last column.
110
+ let width = Math.max(10, rawWidth - 2);
111
+ const lines = strText.split('\n');
112
+ const wrappedLines = [];
113
+
114
+ let inCodeBlock = false;
115
+ let tableBuffer = []; // accumulates raw pipe-delimited rows
116
+
117
+ // ---------------------------------------------------------------------------
118
+ // Table renderer — called when a table block is complete
119
+ // ---------------------------------------------------------------------------
120
+ function renderTable(rawRows) {
121
+ // Parse each row into trimmed cell strings
122
+ const parsed = rawRows.map(row => {
123
+ const cells = row.split('|');
124
+ // strip the leading/trailing empty strings caused by outer pipes
125
+ if (cells[0].trim() === '') cells.shift();
126
+ if (cells[cells.length - 1].trim() === '') cells.pop();
127
+ return cells.map(c => c.trim());
128
+ });
129
+
130
+ if (parsed.length === 0) return;
131
+
132
+ // Identify the separator row (only dashes/colons/pipes) and extract alignment
133
+ let sepIdx = parsed.findIndex(row =>
134
+ row.every(c => /^:?-+:?$/.test(c))
135
+ );
136
+ if (sepIdx === -1) sepIdx = 1; // assume row 1 is separator if not found
137
+
138
+ const alignRow = parsed[sepIdx] || [];
139
+ const aligns = alignRow.map(c => {
140
+ if (/^:-+:$/.test(c)) return 'center';
141
+ if (/^-+:$/.test(c)) return 'right';
142
+ return 'left';
143
+ });
144
+
145
+ const headerRows = parsed.slice(0, sepIdx);
146
+ const dataRows = parsed.slice(sepIdx + 1);
147
+ const allDataRows = [...headerRows, ...dataRows];
148
+
149
+ // Determine number of columns
150
+ const numCols = Math.max(...allDataRows.map(r => r.length), aligns.length);
151
+
152
+ // Compute natural column widths (visual length of cell content)
153
+ const colWidths = Array(numCols).fill(0);
154
+ for (const row of allDataRows) {
155
+ for (let i = 0; i < numCols; i++) {
156
+ const cell = row[i] || '';
157
+ const cellLen = visualLength(stripAnsi(applyInlineStyles(cell)));
158
+ if (cellLen > colWidths[i]) colWidths[i] = cellLen;
159
+ }
160
+ }
161
+
162
+ // Cap table to available width: shrink columns proportionally if needed
163
+ // Total width = borders(numCols+1) + padding(numCols*2) + sum(colWidths)
164
+ const borderOverhead = (numCols + 1) + (numCols * 2);
165
+ const totalNatural = colWidths.reduce((a, b) => a + b, 0) + borderOverhead;
166
+ if (totalNatural > width) {
167
+ const budget = width - borderOverhead;
168
+ const ratio = budget / colWidths.reduce((a, b) => a + b, 0);
169
+ for (let i = 0; i < numCols; i++) {
170
+ colWidths[i] = Math.max(1, Math.floor(colWidths[i] * ratio));
171
+ }
172
+ }
173
+
174
+ // Helper: pad/truncate a cell to the target visual width
175
+ function fitCell(rawText, colW, align) {
176
+ const styled = applyInlineStyles(rawText || '');
177
+ const visLen = visualLength(stripAnsi(styled));
178
+ const overflow = visLen - colW;
179
+ let content = styled;
180
+ if (overflow > 0) {
181
+ // Truncate the raw text first, then re-style
182
+ const plain = stripAnsi(styled);
183
+ const cut = plain.slice(0, plain.length - overflow - 1) + '…';
184
+ content = applyInlineStyles(rawText.slice(0, cut.length));
185
+ }
186
+ const padTotal = colW - Math.min(visLen, colW);
187
+ if (align === 'right') {
188
+ return ' '.repeat(padTotal) + content;
189
+ } else if (align === 'center') {
190
+ const left = Math.floor(padTotal / 2);
191
+ const right = padTotal - left;
192
+ return ' '.repeat(left) + content + ' '.repeat(right);
193
+ } else {
194
+ return content + ' '.repeat(padTotal);
195
+ }
196
+ }
197
+
198
+ const D = colors.dim;
199
+ const R = reset;
200
+
201
+ // Box-drawing helpers
202
+ const hbar = (w) => '─'.repeat(w + 2); // col width + 2 padding
203
+
204
+ // ┌─...─┬─...─┐
205
+ const topBorder = D + '┌' + colWidths.map(hbar).join('┬') + '┐' + R;
206
+ // ├─...─┼─...─┤ (after header)
207
+ const midBorder = D + '├' + colWidths.map(hbar).join('┼') + '┤' + R;
208
+ // └─...─┴─...─┘
209
+ const botBorder = D + '└' + colWidths.map(hbar).join('┴') + '┘' + R;
210
+
211
+ function buildRow(cells, isHeader) {
212
+ const parts = colWidths.map((w, i) => {
213
+ const raw = cells[i] || '';
214
+ const align = aligns[i] || 'left';
215
+ const fitted = fitCell(raw, w, align);
216
+ return isHeader
217
+ ? ` \x1b[1m${colors.main}${stripAnsi(fitted)}\x1b[22m${R} `
218
+ : ` ${fitted} `;
219
+ });
220
+ return D + '│' + R + parts.join(D + '│' + R) + D + '│' + R;
221
+ }
222
+
223
+ // Add blank line before table if previous line is not blank
224
+ if (wrappedLines.length > 0 && wrappedLines[wrappedLines.length - 1] !== '') {
225
+ wrappedLines.push('');
226
+ }
227
+
228
+ wrappedLines.push(topBorder);
229
+ for (const hrow of headerRows) {
230
+ wrappedLines.push(buildRow(hrow, true));
231
+ }
232
+ if (dataRows.length > 0) {
233
+ wrappedLines.push(midBorder);
234
+ for (const drow of dataRows) {
235
+ wrappedLines.push(buildRow(drow, false));
236
+ }
237
+ }
238
+ wrappedLines.push(botBorder);
239
+ wrappedLines.push('');
240
+ }
17
241
 
18
- // Split by whitespace but keep the whitespace as tokens
19
- const tokens = line.split(/(\s+)/);
242
+ // Helper function to wrap a string to a specific width (ANSI and wide-char aware)
243
+ function wrapString(content, wrapWidth) {
244
+ const safeWidth = Math.max(5, wrapWidth);
245
+ if (visualLength(content) <= safeWidth) return [content];
246
+
247
+ const tokens = content.split(/(\s+)/);
20
248
  const result = [];
21
249
  let currentLine = '';
22
250
 
23
251
  for (const token of tokens) {
24
252
  if (!token) continue;
25
253
 
26
- // If adding this token exceeds width
27
- if ((currentLine + token).length > width) {
28
-
29
- // If it's a long word (not whitespace) that exceeds the entire width
30
- if (token.length > width && !/^\s+$/.test(token)) {
31
- // If we have content in currentLine, push it first
254
+ const tokenLen = visualLength(token);
255
+ const currentLineLen = visualLength(currentLine);
256
+
257
+ if (currentLineLen + tokenLen > safeWidth) {
258
+ // If it's a long word that exceeds the entire width
259
+ if (tokenLen > safeWidth && !/^\s+$/.test(token)) {
32
260
  if (currentLine) {
33
261
  result.push(currentLine.trimEnd());
34
262
  currentLine = '';
35
263
  }
36
264
 
37
- // Force-break the long word
38
265
  let remainingWord = token;
39
- while (remainingWord.length > width) {
40
- result.push(remainingWord.substring(0, width));
41
- remainingWord = remainingWord.substring(width);
266
+ while (visualLength(remainingWord) > safeWidth) {
267
+ let visibleChars = 0;
268
+ let splitIdx = 0;
269
+ while (visibleChars < safeWidth && splitIdx < remainingWord.length) {
270
+ if (remainingWord.startsWith('\x1b[', splitIdx)) {
271
+ const endEsc = remainingWord.indexOf('m', splitIdx);
272
+ if (endEsc !== -1) {
273
+ splitIdx = endEsc + 1;
274
+ continue;
275
+ }
276
+ }
277
+ const code = remainingWord.charCodeAt(splitIdx);
278
+ const isWide = (code >= 0x3000 && code <= 0x9FFF) || (code >= 0xFF00 && code <= 0xFFEF);
279
+ if (visibleChars + (isWide ? 2 : 1) > safeWidth) {
280
+ break;
281
+ }
282
+ visibleChars += isWide ? 2 : 1;
283
+ splitIdx++;
284
+ }
285
+ result.push(remainingWord.substring(0, splitIdx));
286
+ remainingWord = remainingWord.substring(splitIdx);
42
287
  }
43
288
  currentLine = remainingWord;
44
289
  }
45
- // If it's whitespace that causes overflow, just wrap to next line
290
+ // If it's whitespace that causes overflow
46
291
  else if (/^\s+$/.test(token)) {
47
292
  if (currentLine) {
48
293
  result.push(currentLine.trimEnd());
49
294
  currentLine = '';
50
295
  }
51
- // Do not start a new line with just spaces if it was leading spaces for a wrapped line
52
- // (Unless they are the very first spaces on a line, which we handled)
53
- }
54
- // If it's a normal word that exceeds width, push currentLine and start new with this word
296
+ }
297
+ // Normal word that causes overflow
55
298
  else {
56
299
  if (currentLine) {
57
300
  result.push(currentLine.trimEnd());
@@ -59,14 +302,183 @@ export function outputFormatted(text, overrideWidth) {
59
302
  currentLine = token;
60
303
  }
61
304
  } else {
62
- // Just append the token (word or whitespace)
63
305
  currentLine += token;
64
306
  }
65
307
  }
66
308
 
67
309
  if (currentLine) result.push(currentLine.trimEnd());
68
- return result.join('\n');
69
- });
310
+ return result.length > 0 ? result : [''];
311
+ }
312
+
313
+ for (const line of lines) {
314
+ // --- Table row detection (pipe-delimited, outside code blocks) -----------
315
+ const isTableRow = !inCodeBlock && /^\s*\|/.test(line);
316
+ if (isTableRow) {
317
+ tableBuffer.push(line.trim());
318
+ continue;
319
+ }
320
+ // Flush buffered table when a non-table line is encountered
321
+ if (tableBuffer.length > 0) {
322
+ renderTable(tableBuffer);
323
+ tableBuffer = [];
324
+ }
325
+
326
+ // Check if the line already contains ANSI escape formatting (e.g., pre-colored command outputs)
327
+ const hasAnsi = /\x1b\[[0-9;]*[a-zA-Z]/.test(line);
328
+ if (hasAnsi) {
329
+ const wrappedSegments = wrapString(line, width);
330
+ const balancedSegments = balanceAnsiStyles(wrappedSegments);
331
+ for (const segment of balancedSegments) {
332
+ wrappedLines.push(segment);
333
+ }
334
+ continue;
335
+ }
336
+
337
+ // 1. Detect Horizontal Rules (e.g., ---, ***, ___ )
338
+ if (line.match(/^(\s*)([-*_])\2\2+(\s*)$/)) {
339
+ wrappedLines.push(`${colors.dim}${'─'.repeat(width)}${reset}`);
340
+ continue;
341
+ }
342
+
343
+ // 2. Detect code block boundaries
344
+ if (line.trim().startsWith('```')) {
345
+ if (!inCodeBlock) {
346
+ inCodeBlock = true;
347
+ const lang = line.trim().substring(3).trim();
348
+ if (lang) {
349
+ wrappedLines.push(`${colors.dim} ${lang}${reset}`);
350
+ }
351
+ } else {
352
+ inCodeBlock = false;
353
+ }
354
+ continue;
355
+ }
356
+
357
+ // 3. If in code block, preserve line with vertical boundary
358
+ if (inCodeBlock) {
359
+ wrappedLines.push(`${colors.dim}│ ${reset}${line}`);
360
+ continue;
361
+ }
362
+
363
+ // 4. Check for Markdown Headers: # Header
364
+ const headerMatch = line.match(/^(#+)\s*(.*)/);
365
+ if (headerMatch) {
366
+ const level = headerMatch[1].length;
367
+ const title = headerMatch[2];
368
+
369
+ // Color hierarchy: H1 = header (purple), H2 = main (blue), H3+ = dim
370
+ const levelColor = level === 1 ? colors.header
371
+ : level === 2 ? colors.main
372
+ : colors.dim;
373
+
374
+ // Bold for H1 and H2, normal for H3+
375
+ const boldStart = level <= 2 ? '\x1b[1m' : '';
376
+ const boldEnd = level <= 2 ? '\x1b[22m' : '';
377
+
378
+ // Add a blank line before headers for breathing room
379
+ if (wrappedLines.length > 0 && wrappedLines[wrappedLines.length - 1] !== '') {
380
+ wrappedLines.push('');
381
+ }
382
+
383
+ const wrapWidth = Math.max(10, width);
384
+ const wrappedTitle = wrapString(title, wrapWidth);
385
+
386
+ for (const segment of wrappedTitle) {
387
+ const formatted = applyInlineStyles(segment)
388
+ .replace(/\x1b\[39m/g, `\x1b[39m${levelColor}`);
389
+ wrappedLines.push(`${boldStart}${levelColor}${formatted}${boldEnd}${reset}`);
390
+ }
391
+ continue;
392
+ }
393
+
394
+
395
+
396
+ // 5. Check for List Item structure: bullets (- * +) or numbering (e.g., 1. or 1.1.)
397
+ const listMatch = line.match(/^(\s*)([-*+]\s|\d+(\.\d+)*\.\s)/);
398
+ if (listMatch) {
399
+ const prefix = listMatch[0];
400
+ const content = line.substring(prefix.length);
401
+ const wrapWidth = Math.max(10, width - prefix.length);
402
+ const formattedContent = applyInlineStyles(content);
403
+ const wrappedSegments = wrapString(formattedContent, wrapWidth);
404
+
405
+ let formattedPrefix = prefix;
406
+ const bulletMatch = prefix.match(/^(\s*)([-*+])(\s)/);
407
+ if (bulletMatch) {
408
+ formattedPrefix = `${bulletMatch[1]}${colors.main}•${reset}${bulletMatch[3]}`;
409
+ } else {
410
+ const numberMatch = prefix.match(/^(\s*)(\d+(\.\d+)*\.)(\s)/);
411
+ if (numberMatch) {
412
+ formattedPrefix = `${numberMatch[1]}${colors.main}${numberMatch[2]}${reset}${numberMatch[4]}`;
413
+ }
414
+ }
415
+
416
+ const balancedSegments = balanceAnsiStyles(wrappedSegments);
417
+ wrappedLines.push(formattedPrefix + balancedSegments[0]);
418
+ const hangingIndent = ' '.repeat(prefix.length);
419
+ for (let i = 1; i < balancedSegments.length; i++) {
420
+ wrappedLines.push(hangingIndent + balancedSegments[i]);
421
+ }
422
+ continue;
423
+ }
424
+
425
+ // 6. Check for Blockquote structure
426
+ const quoteMatch = line.match(/^(\s*)(>\s?)/);
427
+ if (quoteMatch) {
428
+ // Add spacing before blockquotes for breathing room
429
+ if (wrappedLines.length > 0 && wrappedLines[wrappedLines.length - 1] !== '') {
430
+ wrappedLines.push('');
431
+ }
432
+ const prefix = quoteMatch[0];
433
+ const content = line.substring(prefix.length);
434
+ const wrapWidth = Math.max(10, width - prefix.length);
435
+ const baseColor = colors.dim;
436
+
437
+ // Prefix quote content with baseColor and restore it after any inline color resets
438
+ const formattedContent = `${baseColor}${applyInlineStyles(content).replace(/\x1b\[39m/g, `\x1b[39m${baseColor}`)}${reset}`;
439
+ const wrappedSegments = wrapString(formattedContent, wrapWidth);
440
+
441
+ // Use dim color for the vertical bar to make blockquotes feel integrated and soft
442
+ const formattedPrefix = prefix.replace(/>\s?/, `${colors.dim}│ ${reset}`);
443
+ const balancedSegments = balanceAnsiStyles(wrappedSegments);
444
+
445
+ for (const segment of balancedSegments) {
446
+ wrappedLines.push(formattedPrefix + segment);
447
+ }
448
+ continue;
449
+ }
450
+
451
+ // 7. Check for Indented paragraph structure
452
+ const indentMatch = line.match(/^(\s+)/);
453
+ if (indentMatch) {
454
+ const prefix = indentMatch[0];
455
+ const content = line.substring(prefix.length);
456
+ const wrapWidth = Math.max(10, width - prefix.length);
457
+ const formattedContent = applyInlineStyles(content);
458
+ const wrappedSegments = wrapString(formattedContent, wrapWidth);
459
+ const balancedSegments = balanceAnsiStyles(wrappedSegments);
460
+
461
+ for (const segment of balancedSegments) {
462
+ wrappedLines.push(prefix + segment);
463
+ }
464
+ continue;
465
+ }
466
+
467
+ // 8. Fallback: plain text paragraph
468
+ const formatted = applyInlineStyles(line);
469
+ const wrappedSegments = wrapString(formatted, width);
470
+ const balancedSegments = balanceAnsiStyles(wrappedSegments);
471
+ for (const segment of balancedSegments) {
472
+ wrappedLines.push(segment);
473
+ }
474
+ }
475
+
476
+ // Flush any table that ends at end-of-input
477
+ if (tableBuffer.length > 0) {
478
+ renderTable(tableBuffer);
479
+ }
70
480
 
71
- return wrappedLines.join('\n');
481
+ return wrappedLines
482
+ .map(line => line.replace(/[\s\u200B]+(?=(?:\x1b\[[0-9;]*[a-zA-Z]|\s)*$)/g, ''))
483
+ .join('\n');
72
484
  }
package/src/utils/ui.js CHANGED
@@ -10,7 +10,7 @@ const COLORS = {
10
10
  data: [194, 198, 197], // #C2C6C5 – silver (args, data)
11
11
  success: [122, 195, 145], // #7AC391 – green (● on stop)
12
12
  error: [224, 112, 112], // #E07070 – red (● on fail)
13
- dim: [ 96, 96, 96], // #606060 – grey (dim annotations)
13
+ dim: [143, 147, 153], // #8F9399cool grey (dim annotations)
14
14
  header: [163, 122, 204], // #A37ACC – purple (● header dot)
15
15
  };
16
16