cowork-cli 2.2.2 → 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.2.2",
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
@@ -19,7 +19,8 @@ function clientLoader() {
19
19
  return new OpenAI({
20
20
  apiKey: config.model_api_key,
21
21
  baseURL: baseURL,
22
- timeout: 60000 // 60 seconds timeout
22
+ timeout: 60000, // 60 seconds timeout
23
+ maxRetries: 0, // Disable SDK's built-in retries to prevent overlapping attempts with BaseModel's retry loop.
23
24
  });
24
25
  }
25
26
 
@@ -1,8 +1,22 @@
1
1
  import { toolDefinitions, dispatchTool } from '../tools/index.js';
2
- import { logger } from '../../utils/logger.js';
2
+ import { logger, formatMain, formatDim } from '../../utils/logger.js';
3
3
  import { ui } from '../../utils/ui.js';
4
4
  import { outputFormatted } from '../../utils/outputFormatter.js';
5
5
 
6
+ // Defined at module scope: avoid re-allocating on every caught error.
7
+ // Transient Node.js-level network error codes that warrant an automatic retry.
8
+ const TRANSIENT_NET_CODES = new Set([
9
+ 'ECONNRESET', // Connection forcibly closed by the remote side
10
+ 'ETIMEDOUT', // Connection or operation timed out
11
+ 'ECONNREFUSED', // Remote host actively refused the connection
12
+ 'EAI_AGAIN', // Temporary DNS resolution failure
13
+ 'ENETUNREACH', // Network is unreachable
14
+ 'EHOSTUNREACH', // Host is unreachable
15
+ ]);
16
+
17
+ // Maximum delay (ms) the backoff is allowed to reach, regardless of retry count.
18
+ const MAX_BACKOFF_MS = 30000;
19
+
6
20
  /**
7
21
  * Base class for AI model interaction handlers.
8
22
  * Encapsulates message history, API calling with retries, and robust tool execution.
@@ -18,6 +32,8 @@ export default class BaseModel {
18
32
  this.messages = [];
19
33
  this.maxTurns = 15; // Safeguard against infinite tool-calling loops
20
34
  this.lastRequestTime = 0; // For proactive throttling
35
+ this._runStartTime = 0;
36
+ this._runUsage = { prompt: 0, completion: 0, total: 0 };
21
37
  }
22
38
 
23
39
  /**
@@ -36,6 +52,10 @@ export default class BaseModel {
36
52
  * @param {string|null} systemPrompt Optional system-level instructions.
37
53
  */
38
54
  async run(query, systemPrompt = null) {
55
+ // Reset per-run tracking state
56
+ this._runStartTime = performance.now();
57
+ this._runUsage = { prompt: 0, completion: 0, total: 0 };
58
+
39
59
  if (systemPrompt) {
40
60
  this.addMessage('system', systemPrompt);
41
61
  }
@@ -50,7 +70,23 @@ export default class BaseModel {
50
70
  const response = await this._getCompletion();
51
71
  ui.stop();
52
72
 
53
- const message = response.choices[0].message;
73
+ // Guard against empty/null choices (content filter, provider quirks).
74
+ const choice = response.choices?.[0];
75
+ if (!choice?.message) {
76
+ logger.error("[API Error] Received empty or malformed response (no choices).");
77
+ return;
78
+ }
79
+ const message = choice.message;
80
+ const finishReason = choice.finish_reason;
81
+
82
+ // Surface meaningful finish reasons to the user instead of silent behaviour.
83
+ if (finishReason === 'content_filter') {
84
+ logger.secondary("[System]: Response was blocked by the provider's content filter.");
85
+ return;
86
+ }
87
+ if (finishReason === 'length') {
88
+ logger.secondary("[System]: Response was truncated due to token limits.");
89
+ }
54
90
 
55
91
  // Let subclasses handle/format the response (e.g. Gemini thought signatures)
56
92
  await this.handleResponse(message);
@@ -64,6 +100,7 @@ export default class BaseModel {
64
100
  process.stdout.write("\n");
65
101
  }
66
102
  }
103
+ this._printStats();
67
104
  return;
68
105
  }
69
106
 
@@ -71,7 +108,9 @@ export default class BaseModel {
71
108
  await this._processToolCalls(message.tool_calls);
72
109
 
73
110
  } catch (err) {
74
- ui.stop();
111
+ // Use ui.fail() (red dot) instead of ui.stop() (green dot) so the
112
+ // terminal reflects that the turn ended in error. fail() is a no-op when IDLE.
113
+ ui.fail();
75
114
  // Deep error logging for API failures
76
115
  if (err.status) {
77
116
  logger.error(`[API Error] Status: ${err.status}`);
@@ -87,6 +126,7 @@ export default class BaseModel {
87
126
  }
88
127
  }
89
128
 
129
+ this._printStats();
90
130
  logger.secondary("[System]: Reached maximum conversation turns. Ending session.");
91
131
  }
92
132
 
@@ -118,39 +158,44 @@ export default class BaseModel {
118
158
 
119
159
  // Update last request time on successful response
120
160
  this.lastRequestTime = Date.now();
161
+
162
+ // Accumulate token usage across all turns (usage may be absent on some providers)
163
+ const u = response.usage;
164
+ if (u) {
165
+ this._runUsage.prompt += u.prompt_tokens ?? 0;
166
+ this._runUsage.completion += u.completion_tokens ?? 0;
167
+ this._runUsage.total += u.total_tokens ?? 0;
168
+ }
169
+
121
170
  return response;
122
171
 
123
172
  } catch (err) {
124
173
  // Transient HTTP status codes (rate-limit, server errors)
125
174
  const isHttpTransient = [429, 500, 502, 503, 504].includes(err.status);
126
- // Transient Node.js-level network errors (flaky connections, DNS hiccups)
127
- const TRANSIENT_NET_CODES = new Set([
128
- 'ECONNRESET', // Connection forcibly closed by the remote side
129
- 'ETIMEDOUT', // Connection or operation timed out
130
- 'ECONNREFUSED', // Remote host actively refused the connection
131
- 'EAI_AGAIN', // Temporary DNS resolution failure
132
- 'ENETUNREACH', // Network is unreachable
133
- 'EHOSTUNREACH', // Host is unreachable
134
- ]);
135
175
  const isNetTransient = TRANSIENT_NET_CODES.has(err.code);
136
176
  const isTransient = isHttpTransient || isNetTransient;
137
177
 
138
178
  if (isTransient && retries < maxRetries) {
139
179
  retries++;
140
180
 
141
- let delay = Math.pow(2, retries) * 1000;
181
+ // Cap exponential delay at MAX_BACKOFF_MS to prevent unbounded wait times.
182
+ let delay = Math.min(Math.pow(2, retries) * 1000, MAX_BACKOFF_MS);
142
183
 
143
184
  // 2. Adhere to Retry-After header if present (HTTP errors only)
144
185
  const retryAfter = err.headers?.['retry-after'];
145
186
  if (retryAfter) {
146
187
  const seconds = parseInt(retryAfter);
147
188
  if (!isNaN(seconds)) {
148
- delay = seconds * 1000;
189
+ // Clamp to [1s, MAX_BACKOFF_MS] so a zero/negative header cannot bypass throttling
190
+ // and an absurd value cannot hang the process.
191
+ delay = Math.min(Math.max(seconds * 1000, 1000), MAX_BACKOFF_MS);
149
192
  } else {
150
193
  // Handle Date string format
151
194
  const retryDate = new Date(retryAfter);
152
195
  if (!isNaN(retryDate.getTime())) {
153
- delay = Math.max(0, retryDate.getTime() - Date.now());
196
+ // Clamp — past dates become 1s, far-future dates are capped at MAX_BACKOFF_MS
197
+ // so the process can never hang indefinitely.
198
+ delay = Math.min(Math.max(retryDate.getTime() - Date.now(), 1000), MAX_BACKOFF_MS);
154
199
  }
155
200
  }
156
201
  }
@@ -169,6 +214,11 @@ export default class BaseModel {
169
214
  throw err;
170
215
  }
171
216
  }
217
+
218
+ // Exhaustion guard — the while loop should always return or throw before
219
+ // reaching here. If it doesn't (e.g. a future refactor breaks the invariant),
220
+ // surface an explicit error rather than returning undefined to the caller.
221
+ throw new Error('_getCompletion: retry loop exhausted without returning a response.');
172
222
  }
173
223
 
174
224
  /**
@@ -253,4 +303,24 @@ export default class BaseModel {
253
303
  async handleResponse(message) {
254
304
  this.messages.push(message);
255
305
  }
306
+
307
+ /**
308
+ * Prints a compact stats line: elapsed time and cumulative token usage.
309
+ * Only printed on clean exits (final answer or max-turns). Skipped on errors.
310
+ * Token counts are omitted silently if the provider did not return usage data.
311
+ * @private
312
+ */
313
+ _printStats() {
314
+ const elapsed = ((performance.now() - this._runStartTime) / 1000).toFixed(2);
315
+ const { prompt, completion, total } = this._runUsage;
316
+
317
+ const timeStr = `${formatDim('time')} ${formatMain(elapsed + 's')}`;
318
+
319
+ // Only append token info when the provider actually returned usage data
320
+ const tokenStr = total > 0
321
+ ? ` ${formatDim('·')} ${formatDim('tokens')} ${formatMain(String(total))} ${formatDim(`(${prompt}p/${completion}c)`)}`
322
+ : '';
323
+
324
+ ui.log(timeStr + tokenStr);
325
+ }
256
326
  }
@@ -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
  }