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 +2 -1
- package/package.json +1 -1
- package/src/configs/sys.txt +8 -2
- package/src/engine/client.js +2 -1
- package/src/engine/models/BaseModel.js +85 -15
- package/src/utils/logger.js +1 -1
- package/src/utils/outputFormatter.js +286 -26
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,
|
|
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
package/src/configs/sys.txt
CHANGED
|
@@ -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
|
-
-
|
|
12
|
-
|
|
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
|
package/src/engine/client.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/utils/logger.js
CHANGED
|
@@ -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 (
|
|
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
|
-
|
|
12
|
-
|
|
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
|
-
|
|
15
|
-
const wrappedLines = lines.map(line => {
|
|
16
|
-
if (line.length <= width) return line;
|
|
117
|
+
let inCodeBlock = false;
|
|
17
118
|
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
|
40
|
-
|
|
41
|
-
|
|
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
|
|
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
|
-
|
|
52
|
-
|
|
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.
|
|
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
|
}
|