cowork-cli 2.3.0 → 2.4.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, and subtle horizontal rules, 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.4.0",
4
4
  "description": "work with cowork",
5
5
  "bin": {
6
6
  "cwk": "bin/cli.js"
@@ -8,8 +8,14 @@ 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: ---
13
19
  - Cite code locations using: file_path:line_number.
14
20
 
15
21
  # EXECUTION & STYLE
@@ -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,82 @@
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, ' ');
102
+
103
+ let rawWidth = overrideWidth || process.stdout.columns || 80;
104
+ if (typeof rawWidth !== 'number' || isNaN(rawWidth) || rawWidth < 10) {
105
+ rawWidth = 80;
106
+ }
10
107
 
11
- // Dynamically determine width (fallback to 80 if not detectable)
12
- const width = overrideWidth || process.stdout.columns || 80;
108
+ let width = rawWidth;
109
+ if (width >= 60) {
110
+ width = Math.floor(width * 0.80);
111
+ } else {
112
+ width = Math.max(10, Math.floor(width * 0.95));
113
+ }
114
+ const lines = strText.split('\n');
115
+ const wrappedLines = [];
13
116
 
14
- const lines = text.split('\n');
15
- const wrappedLines = lines.map(line => {
16
- if (line.length <= width) return line;
117
+ let inCodeBlock = false;
17
118
 
18
- // Split by whitespace but keep the whitespace as tokens
19
- const tokens = line.split(/(\s+)/);
119
+ // Helper function to wrap a string to a specific width (ANSI and wide-char aware)
120
+ function wrapString(content, wrapWidth) {
121
+ const safeWidth = Math.max(5, wrapWidth);
122
+ if (visualLength(content) <= safeWidth) return [content];
123
+
124
+ const tokens = content.split(/(\s+)/);
20
125
  const result = [];
21
126
  let currentLine = '';
22
127
 
23
128
  for (const token of tokens) {
24
129
  if (!token) continue;
25
130
 
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
131
+ const tokenLen = visualLength(token);
132
+ const currentLineLen = visualLength(currentLine);
133
+
134
+ if (currentLineLen + tokenLen > safeWidth) {
135
+ // If it's a long word that exceeds the entire width
136
+ if (tokenLen > safeWidth && !/^\s+$/.test(token)) {
32
137
  if (currentLine) {
33
138
  result.push(currentLine.trimEnd());
34
139
  currentLine = '';
35
140
  }
36
141
 
37
- // Force-break the long word
38
142
  let remainingWord = token;
39
- while (remainingWord.length > width) {
40
- result.push(remainingWord.substring(0, width));
41
- remainingWord = remainingWord.substring(width);
143
+ while (visualLength(remainingWord) > safeWidth) {
144
+ let visibleChars = 0;
145
+ let splitIdx = 0;
146
+ while (visibleChars < safeWidth && splitIdx < remainingWord.length) {
147
+ if (remainingWord.startsWith('\x1b[', splitIdx)) {
148
+ const endEsc = remainingWord.indexOf('m', splitIdx);
149
+ if (endEsc !== -1) {
150
+ splitIdx = endEsc + 1;
151
+ continue;
152
+ }
153
+ }
154
+ const code = remainingWord.charCodeAt(splitIdx);
155
+ const isWide = (code >= 0x3000 && code <= 0x9FFF) || (code >= 0xFF00 && code <= 0xFFEF);
156
+ if (visibleChars + (isWide ? 2 : 1) > safeWidth) {
157
+ break;
158
+ }
159
+ visibleChars += isWide ? 2 : 1;
160
+ splitIdx++;
161
+ }
162
+ result.push(remainingWord.substring(0, splitIdx));
163
+ remainingWord = remainingWord.substring(splitIdx);
42
164
  }
43
165
  currentLine = remainingWord;
44
166
  }
45
- // If it's whitespace that causes overflow, just wrap to next line
167
+ // If it's whitespace that causes overflow
46
168
  else if (/^\s+$/.test(token)) {
47
169
  if (currentLine) {
48
170
  result.push(currentLine.trimEnd());
49
171
  currentLine = '';
50
172
  }
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
173
+ }
174
+ // Normal word that causes overflow
55
175
  else {
56
176
  if (currentLine) {
57
177
  result.push(currentLine.trimEnd());
@@ -59,14 +179,154 @@ export function outputFormatted(text, overrideWidth) {
59
179
  currentLine = token;
60
180
  }
61
181
  } else {
62
- // Just append the token (word or whitespace)
63
182
  currentLine += token;
64
183
  }
65
184
  }
66
185
 
67
186
  if (currentLine) result.push(currentLine.trimEnd());
68
- return result.join('\n');
69
- });
187
+ return result.length > 0 ? result : [''];
188
+ }
189
+
190
+ for (const line of lines) {
191
+ // Check if the line already contains ANSI escape formatting (e.g., pre-colored command outputs)
192
+ const hasAnsi = /\x1b\[[0-9;]*[a-zA-Z]/.test(line);
193
+ if (hasAnsi) {
194
+ const wrappedSegments = wrapString(line, width);
195
+ const balancedSegments = balanceAnsiStyles(wrappedSegments);
196
+ for (const segment of balancedSegments) {
197
+ wrappedLines.push(segment);
198
+ }
199
+ continue;
200
+ }
201
+
202
+ // 1. Detect Horizontal Rules (e.g., ---, ***, ___ )
203
+ if (line.match(/^(\s*)([-*_])\2\2+(\s*)$/)) {
204
+ const dividerWidth = Math.min(40, width);
205
+ const padding = ' '.repeat(Math.max(0, Math.floor((width - dividerWidth) / 2)));
206
+ wrappedLines.push(`${padding}${colors.dim}${'· '.repeat(dividerWidth / 2)}${reset}`);
207
+ continue;
208
+ }
209
+
210
+ // 2. Detect code block boundaries
211
+ if (line.trim().startsWith('```')) {
212
+ if (!inCodeBlock) {
213
+ inCodeBlock = true;
214
+ const lang = line.trim().substring(3) || 'code';
215
+ const borderLength = Math.max(5, Math.min(30, width - lang.length - 7));
216
+ wrappedLines.push(`${colors.dim}┌── [${colors.tool}${lang}${colors.dim}] ${'─'.repeat(borderLength)}${reset}`);
217
+ } else {
218
+ inCodeBlock = false;
219
+ const borderLength = Math.max(5, Math.min(30, width - 4));
220
+ wrappedLines.push(`${colors.dim}└───${'─'.repeat(borderLength)}${reset}`);
221
+ }
222
+ continue;
223
+ }
224
+
225
+ // 3. If in code block, preserve line with vertical boundary
226
+ if (inCodeBlock) {
227
+ wrappedLines.push(`${colors.dim}│ ${reset}${line}`);
228
+ continue;
229
+ }
230
+
231
+ // 4. Check for Markdown Headers: # Header
232
+ const headerMatch = line.match(/^(#+)\s*(.*)/);
233
+ if (headerMatch) {
234
+ const level = headerMatch[1].length;
235
+ const title = headerMatch[2];
236
+ const symbol = level === 1 ? '◆' : level === 2 ? '◇' : '◈';
237
+ const wrapWidth = Math.max(10, width - 2); // 2 is symbol + space
238
+ const wrappedTitle = wrapString(title, wrapWidth);
239
+ const baseColor = colors.header;
240
+
241
+ if (wrappedTitle.length > 0) {
242
+ const formattedFirst = applyInlineStyles(wrappedTitle[0]).replace(/\x1b\[39m/g, `\x1b[39m${baseColor}`);
243
+ wrappedLines.push(`${colors.header}${symbol} ${baseColor}${formattedFirst}${reset}`);
244
+ for (let i = 1; i < wrappedTitle.length; i++) {
245
+ const formattedSub = applyInlineStyles(wrappedTitle[i]).replace(/\x1b\[39m/g, `\x1b[39m${baseColor}`);
246
+ wrappedLines.push(` ${baseColor}${formattedSub}${reset}`);
247
+ }
248
+ }
249
+ continue;
250
+ }
251
+
252
+
253
+
254
+ // 5. Check for List Item structure: bullets (- * +) or numbering (e.g., 1. or 1.1.)
255
+ const listMatch = line.match(/^(\s*)([-*+]\s|\d+(\.\d+)*\.\s)/);
256
+ if (listMatch) {
257
+ const prefix = listMatch[0];
258
+ const content = line.substring(prefix.length);
259
+ const wrapWidth = Math.max(10, width - prefix.length);
260
+ const formattedContent = applyInlineStyles(content);
261
+ const wrappedSegments = wrapString(formattedContent, wrapWidth);
262
+
263
+ let formattedPrefix = prefix;
264
+ const bulletMatch = prefix.match(/^(\s*)([-*+])(\s)/);
265
+ if (bulletMatch) {
266
+ const bulletSymbol = bulletMatch[2] === '-' ? '•' : bulletMatch[2] === '*' ? '◦' : '▪';
267
+ formattedPrefix = `${bulletMatch[1]}${colors.main}${bulletSymbol}${reset}${bulletMatch[3]}`;
268
+ } else {
269
+ const numberMatch = prefix.match(/^(\s*)(\d+(\.\d+)*\.)(\s)/);
270
+ if (numberMatch) {
271
+ formattedPrefix = `${numberMatch[1]}${colors.main}${numberMatch[2]}${reset}${numberMatch[4]}`;
272
+ }
273
+ }
274
+
275
+ const balancedSegments = balanceAnsiStyles(wrappedSegments);
276
+ wrappedLines.push(formattedPrefix + balancedSegments[0]);
277
+ const hangingIndent = ' '.repeat(prefix.length);
278
+ for (let i = 1; i < balancedSegments.length; i++) {
279
+ wrappedLines.push(hangingIndent + balancedSegments[i]);
280
+ }
281
+ continue;
282
+ }
283
+
284
+ // 6. Check for Blockquote structure
285
+ const quoteMatch = line.match(/^(\s*)(>\s?)/);
286
+ if (quoteMatch) {
287
+ const prefix = quoteMatch[0];
288
+ const content = line.substring(prefix.length);
289
+ const wrapWidth = Math.max(10, width - prefix.length);
290
+ const baseColor = colors.dim;
291
+
292
+ // Prefix quote content with baseColor and restore it after any inline color resets
293
+ const formattedContent = `${baseColor}${applyInlineStyles(content).replace(/\x1b\[39m/g, `\x1b[39m${baseColor}`)}${reset}`;
294
+ const wrappedSegments = wrapString(formattedContent, wrapWidth);
295
+
296
+ // Use dim color for the vertical bar to make blockquotes feel integrated and soft
297
+ const formattedPrefix = prefix.replace(/>\s?/, `${colors.dim}│ ${reset}`);
298
+ const balancedSegments = balanceAnsiStyles(wrappedSegments);
299
+
300
+ for (const segment of balancedSegments) {
301
+ wrappedLines.push(formattedPrefix + segment);
302
+ }
303
+ continue;
304
+ }
305
+
306
+ // 7. Check for Indented paragraph structure
307
+ const indentMatch = line.match(/^(\s+)/);
308
+ if (indentMatch) {
309
+ const prefix = indentMatch[0];
310
+ const content = line.substring(prefix.length);
311
+ const wrapWidth = Math.max(10, width - prefix.length);
312
+ const formattedContent = applyInlineStyles(content);
313
+ const wrappedSegments = wrapString(formattedContent, wrapWidth);
314
+ const balancedSegments = balanceAnsiStyles(wrappedSegments);
315
+
316
+ for (const segment of balancedSegments) {
317
+ wrappedLines.push(prefix + segment);
318
+ }
319
+ continue;
320
+ }
321
+
322
+ // 8. Fallback: plain text paragraph
323
+ const formatted = applyInlineStyles(line);
324
+ const wrappedSegments = wrapString(formatted, width);
325
+ const balancedSegments = balanceAnsiStyles(wrappedSegments);
326
+ for (const segment of balancedSegments) {
327
+ wrappedLines.push(segment);
328
+ }
329
+ }
70
330
 
71
331
  return wrappedLines.join('\n');
72
332
  }