@iinm/plain-agent 1.10.2 → 1.10.3
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 -2
- package/package.json +1 -1
- package/src/{cliBatch.mjs → cli/batch.mjs} +3 -3
- package/src/{cliCommands.mjs → cli/commands.mjs} +13 -11
- package/src/{cliCompleter.mjs → cli/completer.mjs} +4 -4
- package/src/{cliCost.mjs → cli/cost.mjs} +3 -3
- package/src/cli/formatter.mjs +997 -0
- package/src/{cliInteractive.mjs → cli/interactive.mjs} +48 -14
- package/src/cli/tableDetector.mjs +228 -0
- package/src/config.d.ts +1 -1
- package/src/main.mjs +5 -5
- package/src/{mcpIntegration.mjs → mcp/integration.mjs} +7 -7
- package/src/tools/patchFile.mjs +18 -12
- package/src/{voiceInputGemini.mjs → voice/gemini.mjs} +2 -5
- package/src/voice/input.mjs +29 -0
- package/src/{voiceInputOpenAI.mjs → voice/openai.mjs} +15 -17
- package/src/cliFormatter.mjs +0 -573
- package/src/voiceInput.mjs +0 -61
- /package/src/{cliArgs.mjs → cli/args.mjs} +0 -0
- /package/src/{cliInterruptTransform.mjs → cli/interruptTransform.mjs} +0 -0
- /package/src/{cliMuteTransform.mjs → cli/muteTransform.mjs} +0 -0
- /package/src/{cliPasteTransform.mjs → cli/pasteTransform.mjs} +0 -0
- /package/src/{mcpClient.mjs → mcp/client.mjs} +0 -0
- /package/src/{voiceInputSession.mjs → voice/session.mjs} +0 -0
- /package/src/{voiceToggleKey.mjs → voice/toggleKey.mjs} +0 -0
|
@@ -0,0 +1,997 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @import { Message, MessageContentToolUse, MessageContentToolResult, ProviderTokenUsage } from "../model"
|
|
3
|
+
* @import { CompactContextInput } from "../tools/compactContext"
|
|
4
|
+
* @import { ExecCommandInput } from "../tools/execCommand"
|
|
5
|
+
* @import { PatchBlock, PatchFileInput } from "../tools/patchFile"
|
|
6
|
+
* @import { ReadFileInput } from "../tools/readFile"
|
|
7
|
+
* @import { WriteFileInput } from "../tools/writeFile"
|
|
8
|
+
* @import { TmuxCommandInput } from "../tools/tmuxCommand"
|
|
9
|
+
* @import { SwitchToSubagentInput } from "../tools/switchToSubagent"
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import fs from "node:fs/promises";
|
|
13
|
+
import { styleText } from "node:util";
|
|
14
|
+
import { parseBlocks } from "../tools/patchFile.mjs";
|
|
15
|
+
import { diffLines } from "../utils/diffLines.mjs";
|
|
16
|
+
import { noThrow } from "../utils/noThrow.mjs";
|
|
17
|
+
|
|
18
|
+
/** Length above which a single-line arg forces block-form rendering. */
|
|
19
|
+
const ARG_BLOCK_LENGTH_THRESHOLD = 60;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Format an args array for display.
|
|
23
|
+
* Uses compact JSON for short single-line args; switches to a YAML-style
|
|
24
|
+
* block form when any arg contains newlines or exceeds
|
|
25
|
+
* {@link ARG_BLOCK_LENGTH_THRESHOLD} characters so that long scripts passed
|
|
26
|
+
* to `bash -c`, `python -c`, `node -e`, etc. stay readable.
|
|
27
|
+
* @param {unknown} args
|
|
28
|
+
* @returns {string}
|
|
29
|
+
*/
|
|
30
|
+
export function formatArgs(args) {
|
|
31
|
+
if (!Array.isArray(args) || args.length === 0) {
|
|
32
|
+
return `args: ${JSON.stringify(args ?? [])}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const needsBlock = args.some(
|
|
36
|
+
(a) =>
|
|
37
|
+
typeof a === "string" &&
|
|
38
|
+
(a.includes("\n") || a.length > ARG_BLOCK_LENGTH_THRESHOLD),
|
|
39
|
+
);
|
|
40
|
+
if (!needsBlock) {
|
|
41
|
+
return `args: ${JSON.stringify(args)}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const lines = ["args:"];
|
|
45
|
+
for (const arg of args) {
|
|
46
|
+
if (
|
|
47
|
+
typeof arg === "string" &&
|
|
48
|
+
(arg.includes("\n") || arg.length > ARG_BLOCK_LENGTH_THRESHOLD)
|
|
49
|
+
) {
|
|
50
|
+
lines.push(" - |");
|
|
51
|
+
for (const line of arg.split("\n")) {
|
|
52
|
+
lines.push(` ${line}`);
|
|
53
|
+
}
|
|
54
|
+
} else {
|
|
55
|
+
lines.push(` - ${JSON.stringify(arg)}`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return lines.join("\n");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Format tool use for display.
|
|
63
|
+
* @param {MessageContentToolUse} toolUse
|
|
64
|
+
* @returns {Promise<string>}
|
|
65
|
+
*/
|
|
66
|
+
export async function formatToolUse(toolUse) {
|
|
67
|
+
const { toolName, input } = toolUse;
|
|
68
|
+
|
|
69
|
+
if (toolName === "exec_command") {
|
|
70
|
+
/** @type {Partial<ExecCommandInput>} */
|
|
71
|
+
const execCommandInput = input;
|
|
72
|
+
return [
|
|
73
|
+
`tool: ${toolName}`,
|
|
74
|
+
`command: ${JSON.stringify(execCommandInput.command)}`,
|
|
75
|
+
formatArgs(execCommandInput.args),
|
|
76
|
+
].join("\n");
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (toolName === "write_file") {
|
|
80
|
+
/** @type {Partial<WriteFileInput>} */
|
|
81
|
+
const writeFileInput = input;
|
|
82
|
+
return [
|
|
83
|
+
`tool: ${toolName}`,
|
|
84
|
+
`filePath: ${writeFileInput.filePath}`,
|
|
85
|
+
`content:\n${writeFileInput.content}`,
|
|
86
|
+
].join("\n");
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (toolName === "patch_file") {
|
|
90
|
+
/** @type {Partial<PatchFileInput>} */
|
|
91
|
+
const patchFileInput = input;
|
|
92
|
+
const filePath = patchFileInput.filePath ?? "";
|
|
93
|
+
const patch = patchFileInput.patch || "";
|
|
94
|
+
const rendered = await renderPatch(filePath, patch);
|
|
95
|
+
return [
|
|
96
|
+
`tool: ${toolName}`,
|
|
97
|
+
`path: ${filePath}`,
|
|
98
|
+
`patch:\n${rendered}`,
|
|
99
|
+
].join("\n");
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (toolName === "read_file") {
|
|
103
|
+
/** @type {Partial<ReadFileInput>} */
|
|
104
|
+
const readFileInput = input;
|
|
105
|
+
/** @type {string[]} */
|
|
106
|
+
const lines = [`tool: ${toolName}`, `filePath: ${readFileInput.filePath}`];
|
|
107
|
+
if (readFileInput.offset !== undefined) {
|
|
108
|
+
lines.push(`offset: ${readFileInput.offset}`);
|
|
109
|
+
}
|
|
110
|
+
if (readFileInput.limit !== undefined) {
|
|
111
|
+
lines.push(`limit: ${readFileInput.limit}`);
|
|
112
|
+
}
|
|
113
|
+
return lines.join("\n");
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (toolName === "tmux_command") {
|
|
117
|
+
/** @type {Partial<TmuxCommandInput>} */
|
|
118
|
+
const tmuxCommandInput = input;
|
|
119
|
+
return [
|
|
120
|
+
`tool: ${toolName}`,
|
|
121
|
+
`command: ${tmuxCommandInput.command}`,
|
|
122
|
+
formatArgs(tmuxCommandInput.args),
|
|
123
|
+
].join("\n");
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (toolName === "switch_to_subagent") {
|
|
127
|
+
/** @type {Partial<SwitchToSubagentInput>} */
|
|
128
|
+
const switchToSubagentInput = input;
|
|
129
|
+
return [
|
|
130
|
+
`tool: ${toolName}`,
|
|
131
|
+
`name: ${switchToSubagentInput.name}`,
|
|
132
|
+
`goal: ${switchToSubagentInput.goal}`,
|
|
133
|
+
].join("\n");
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (toolName === "compact_context") {
|
|
137
|
+
/** @type {Partial<CompactContextInput>} */
|
|
138
|
+
const compactContextInput = input;
|
|
139
|
+
return [
|
|
140
|
+
`tool: ${toolName}`,
|
|
141
|
+
`memoryPath: ${compactContextInput.memoryPath}`,
|
|
142
|
+
`reason: ${compactContextInput.reason}`,
|
|
143
|
+
].join("\n");
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (toolName === "switch_to_main_agent") {
|
|
147
|
+
/** @type {Partial<import("../tools/switchToMainAgent").SwitchToMainAgentInput>} */
|
|
148
|
+
const switchToMainAgentInput = input;
|
|
149
|
+
return [
|
|
150
|
+
`tool: ${toolName}`,
|
|
151
|
+
`memoryPath: ${switchToMainAgentInput.memoryPath}`,
|
|
152
|
+
].join("\n");
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (toolName === "web_search") {
|
|
156
|
+
/** @type {Partial<import("../tools/webSearch.mjs").WebSearchInput>} */
|
|
157
|
+
const webSearchInput = input;
|
|
158
|
+
const searchesLine = webSearchInput.searches
|
|
159
|
+
? webSearchInput.searches.map((s) => s.keywords.join(" ")).join(" | ")
|
|
160
|
+
: "";
|
|
161
|
+
return [
|
|
162
|
+
`tool: ${toolName}`,
|
|
163
|
+
`searches: ${searchesLine}`,
|
|
164
|
+
`question: ${webSearchInput.question}`,
|
|
165
|
+
].join("\n");
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (toolName === "web_fetch") {
|
|
169
|
+
/** @type {Partial<import("../tools/webFetch.mjs").WebFetchInput>} */
|
|
170
|
+
const webFetchInput = input;
|
|
171
|
+
return [
|
|
172
|
+
`tool: ${toolName}`,
|
|
173
|
+
`url: ${webFetchInput.url}`,
|
|
174
|
+
`question: ${webFetchInput.question}`,
|
|
175
|
+
].join("\n");
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const { provider: _, ...filteredToolUse } = toolUse;
|
|
179
|
+
|
|
180
|
+
return JSON.stringify(filteredToolUse, null, 2);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Maximum length of output to display */
|
|
184
|
+
const MAX_DISPLAY_OUTPUT_LENGTH = 1024;
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Format tool result for display.
|
|
188
|
+
* @param {MessageContentToolResult} toolResult
|
|
189
|
+
* @returns {string}
|
|
190
|
+
*/
|
|
191
|
+
export function formatToolResult(toolResult) {
|
|
192
|
+
const { content, isError } = toolResult;
|
|
193
|
+
|
|
194
|
+
/** @type {string[]} */
|
|
195
|
+
const contentStringParts = [];
|
|
196
|
+
for (const part of content) {
|
|
197
|
+
switch (part.type) {
|
|
198
|
+
case "text":
|
|
199
|
+
contentStringParts.push(part.text);
|
|
200
|
+
break;
|
|
201
|
+
case "image":
|
|
202
|
+
contentStringParts.push(
|
|
203
|
+
`data:${part.mimeType};base64,${part.data.slice(0, 20)}...`,
|
|
204
|
+
);
|
|
205
|
+
break;
|
|
206
|
+
default:
|
|
207
|
+
console.log(`Unsupported content part: ${JSON.stringify(part)}`);
|
|
208
|
+
break;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const contentString = contentStringParts.join("\n\n");
|
|
213
|
+
|
|
214
|
+
if (isError) {
|
|
215
|
+
return styleText("red", contentString);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (toolResult.toolName === "exec_command") {
|
|
219
|
+
return contentString
|
|
220
|
+
.replace(/(^<stdout>|<\/stdout>$)/gm, styleText("blue", "$1"))
|
|
221
|
+
.replace(
|
|
222
|
+
/(<truncated_output.+?>|<\/truncated_output>)/g,
|
|
223
|
+
styleText("yellow", "$1"),
|
|
224
|
+
)
|
|
225
|
+
.replace(/(^<stderr>|<\/stderr>$)/gm, styleText("magenta", "$1"))
|
|
226
|
+
.replace(/(^<error>|<\/error>$)/gm, styleText("red", "$1"));
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (toolResult.toolName === "read_file") {
|
|
230
|
+
return contentString.replace(
|
|
231
|
+
/^(\s*\d+:[0-9a-f]{2}\|)/gm,
|
|
232
|
+
styleText("gray", "$1"),
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (toolResult.toolName === "tmux_command") {
|
|
237
|
+
return contentString
|
|
238
|
+
.replace(/(^<stdout>|<\/stdout>$)/gm, styleText("blue", "$1"))
|
|
239
|
+
.replace(/(^<stderr>|<\/stderr>$)/gm, styleText("magenta", "$1"))
|
|
240
|
+
.replace(/(^<error>|<\/error>$)/gm, styleText("red", "$1"))
|
|
241
|
+
.replace(/(^<tmux:.*?>|<\/tmux:.*?>$)/gm, styleText("green", "$1"));
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if (contentString.length > MAX_DISPLAY_OUTPUT_LENGTH) {
|
|
245
|
+
return [
|
|
246
|
+
contentString.slice(0, MAX_DISPLAY_OUTPUT_LENGTH),
|
|
247
|
+
styleText("yellow", "... (Output truncated for display)"),
|
|
248
|
+
"\n",
|
|
249
|
+
].join("");
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
return contentString;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Format provider token usage for display.
|
|
257
|
+
* @param {ProviderTokenUsage} usage
|
|
258
|
+
* @returns {string}
|
|
259
|
+
*/
|
|
260
|
+
export function formatProviderTokenUsage(usage) {
|
|
261
|
+
/** @type {string[]} */
|
|
262
|
+
const lines = [];
|
|
263
|
+
/** @type {string[]} */
|
|
264
|
+
const header = [];
|
|
265
|
+
for (const [key, value] of Object.entries(usage)) {
|
|
266
|
+
if (typeof value === "number") {
|
|
267
|
+
header.push(`${key}: ${value}`);
|
|
268
|
+
} else if (typeof value === "string") {
|
|
269
|
+
header.push(`${key}: ${value}`);
|
|
270
|
+
} else if (value) {
|
|
271
|
+
lines.push(
|
|
272
|
+
`(${key}) ${Object.entries(value)
|
|
273
|
+
.filter(
|
|
274
|
+
([k]) =>
|
|
275
|
+
![
|
|
276
|
+
// OpenAI
|
|
277
|
+
"audio_tokens",
|
|
278
|
+
"accepted_prediction_tokens",
|
|
279
|
+
"rejected_prediction_tokens",
|
|
280
|
+
].includes(k),
|
|
281
|
+
)
|
|
282
|
+
.map(([k, v]) => `${k}: ${JSON.stringify(v)}`)
|
|
283
|
+
.join(", ")}`,
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const outputLines = [`\n${header.join(", ")}`];
|
|
289
|
+
|
|
290
|
+
if (lines.length) {
|
|
291
|
+
outputLines.push(lines.join(" / "));
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
return styleText("gray", outputLines.join("\n"));
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Format cost summary for interactive display
|
|
299
|
+
* @param {import("../costTracker.mjs").CostSummary} summary
|
|
300
|
+
* @returns {string}
|
|
301
|
+
*/
|
|
302
|
+
export function formatCostSummary(summary) {
|
|
303
|
+
if (!summary || Object.keys(summary.breakdown).length === 0) {
|
|
304
|
+
return styleText("gray", "No token usage recorded yet.");
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const lines = [];
|
|
308
|
+
|
|
309
|
+
if (summary.totalCost !== undefined) {
|
|
310
|
+
lines.push(
|
|
311
|
+
styleText(
|
|
312
|
+
"bold",
|
|
313
|
+
`\nTotal: ${summary.totalCost.toFixed(4)} ${summary.currency}`,
|
|
314
|
+
),
|
|
315
|
+
);
|
|
316
|
+
} else {
|
|
317
|
+
lines.push(styleText("yellow", "Total: N/A (no cost configuration)"));
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
lines.push(styleText("bold", "\nTokens:"));
|
|
321
|
+
for (const [key, { tokens, cost }] of Object.entries(summary.breakdown)) {
|
|
322
|
+
const tokenStr = `${key}: ${tokens.toLocaleString()}`;
|
|
323
|
+
|
|
324
|
+
if (cost !== undefined) {
|
|
325
|
+
const costStr = `${cost.toFixed(4)} ${summary.currency}`;
|
|
326
|
+
lines.push(` ${tokenStr.padEnd(30)} ${styleText("cyan", costStr)}`);
|
|
327
|
+
} else {
|
|
328
|
+
lines.push(` ${tokenStr.padEnd(30)} ${styleText("gray", "N/A")}`);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
return lines.join("\n");
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Format cost for batch mode JSON output
|
|
337
|
+
* @param {import("../costTracker.mjs").CostSummary} summary
|
|
338
|
+
*/
|
|
339
|
+
export function formatCostForBatch(summary) {
|
|
340
|
+
if (!summary || Object.keys(summary.breakdown).length === 0) {
|
|
341
|
+
return undefined;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
return {
|
|
345
|
+
total: summary.totalCost,
|
|
346
|
+
currency: summary.currency,
|
|
347
|
+
unit: summary.unit,
|
|
348
|
+
breakdown: Object.fromEntries(
|
|
349
|
+
Object.entries(summary.breakdown).map(([key, { tokens, cost }]) => [
|
|
350
|
+
key,
|
|
351
|
+
{ tokens, cost },
|
|
352
|
+
]),
|
|
353
|
+
),
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Print a message to the console.
|
|
359
|
+
* @param {Message} message
|
|
360
|
+
* @returns {Promise<void>}
|
|
361
|
+
*/
|
|
362
|
+
export async function printMessage(message) {
|
|
363
|
+
switch (message.role) {
|
|
364
|
+
case "assistant": {
|
|
365
|
+
// console.log(styleText("bold", "\nAgent:"));
|
|
366
|
+
// Pre-format all tool_use parts in parallel to avoid sequential awaits
|
|
367
|
+
const toolUseParts = message.content.filter(
|
|
368
|
+
(part) => part.type === "tool_use",
|
|
369
|
+
);
|
|
370
|
+
const formattedToolUses = await Promise.all(
|
|
371
|
+
toolUseParts.map((part) => formatToolUse(part)),
|
|
372
|
+
);
|
|
373
|
+
let toolUseIndex = 0;
|
|
374
|
+
for (const part of message.content) {
|
|
375
|
+
switch (part.type) {
|
|
376
|
+
// Note: Streamで表示するためここでは表示しない
|
|
377
|
+
// case "thinking":
|
|
378
|
+
// console.log(
|
|
379
|
+
// [
|
|
380
|
+
// styleText("blue", "<thinking>"),
|
|
381
|
+
// part.thinking,
|
|
382
|
+
// styleText("blue", "</thinking>\n"),
|
|
383
|
+
// ].join("\n"),
|
|
384
|
+
// );
|
|
385
|
+
// break;
|
|
386
|
+
// case "text":
|
|
387
|
+
// console.log(part.text);
|
|
388
|
+
// break;
|
|
389
|
+
case "tool_use":
|
|
390
|
+
console.log(styleText("bold", "\nTool call:"));
|
|
391
|
+
console.log(formattedToolUses[toolUseIndex++]);
|
|
392
|
+
break;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
break;
|
|
396
|
+
}
|
|
397
|
+
case "user": {
|
|
398
|
+
for (const part of message.content) {
|
|
399
|
+
switch (part.type) {
|
|
400
|
+
case "tool_result": {
|
|
401
|
+
console.log(styleText("bold", "\nTool result:"));
|
|
402
|
+
console.log(formatToolResult(part));
|
|
403
|
+
break;
|
|
404
|
+
}
|
|
405
|
+
case "text": {
|
|
406
|
+
console.log(styleText("bold", "\nUser:"));
|
|
407
|
+
const highlighted = part.text.replace(
|
|
408
|
+
/^(<context.+?>|<\/context>)/gm,
|
|
409
|
+
styleText("green", "$1"),
|
|
410
|
+
);
|
|
411
|
+
console.log(highlighted);
|
|
412
|
+
break;
|
|
413
|
+
}
|
|
414
|
+
case "image": {
|
|
415
|
+
break;
|
|
416
|
+
}
|
|
417
|
+
default: {
|
|
418
|
+
console.log(styleText("bold", "\nUnknown Message Format:"));
|
|
419
|
+
console.log(JSON.stringify(part, null, 2));
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
break;
|
|
424
|
+
}
|
|
425
|
+
default: {
|
|
426
|
+
console.log(styleText("bold", "\nUnknown Message Format:"));
|
|
427
|
+
console.log(JSON.stringify(message, null, 2));
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
/**
|
|
432
|
+
* Format markdown table lines with aligned columns.
|
|
433
|
+
* Input lines may have leading/trailing pipes.
|
|
434
|
+
* Output always has leading and trailing pipes with padded cells.
|
|
435
|
+
* When the table would exceed `maxWidth`, long cells are wrapped onto
|
|
436
|
+
* additional visual lines so the table stays within the terminal width.
|
|
437
|
+
* @param {string[]} lines - Raw table lines (including alignment row)
|
|
438
|
+
* @param {number} [maxWidth=Infinity] - Maximum terminal display width
|
|
439
|
+
* @returns {string} - Formatted table string with aligned columns
|
|
440
|
+
*/
|
|
441
|
+
export function formatMarkdownTable(
|
|
442
|
+
lines,
|
|
443
|
+
maxWidth = Number.POSITIVE_INFINITY,
|
|
444
|
+
) {
|
|
445
|
+
if (lines.length === 0) return "";
|
|
446
|
+
|
|
447
|
+
const rows = lines.map(splitTableRow);
|
|
448
|
+
|
|
449
|
+
// Calculate max display width for each column (natural width)
|
|
450
|
+
const colCount = Math.max(...rows.map((r) => r.length));
|
|
451
|
+
/** @type {number[]} */
|
|
452
|
+
const naturalWidths = new Array(colCount).fill(0);
|
|
453
|
+
for (const row of rows) {
|
|
454
|
+
for (let i = 0; i < row.length; i++) {
|
|
455
|
+
const width = charDisplayWidth(row[i]);
|
|
456
|
+
if (width > naturalWidths[i]) {
|
|
457
|
+
naturalWidths[i] = width;
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// Determine column widths that fit within maxWidth
|
|
463
|
+
const colWidths = fitColumns(naturalWidths, colCount, maxWidth);
|
|
464
|
+
|
|
465
|
+
// Check if wrapping is needed (any column was shrunk)
|
|
466
|
+
const needsWrapping = colWidths.some((w, i) => w < naturalWidths[i]);
|
|
467
|
+
|
|
468
|
+
if (!needsWrapping) {
|
|
469
|
+
// Original path: no wrapping, just pad and join
|
|
470
|
+
return rows
|
|
471
|
+
.map((row) => {
|
|
472
|
+
const fullRow = row.concat(new Array(colCount - row.length).fill(""));
|
|
473
|
+
const padded = fullRow.map((cell, i) =>
|
|
474
|
+
padCell(cell, colWidths[i] ?? 0),
|
|
475
|
+
);
|
|
476
|
+
return `| ${padded.join(" | ")} |`;
|
|
477
|
+
})
|
|
478
|
+
.join("\n");
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// Wrapped path: wrap cells and render multi-line rows
|
|
482
|
+
const wrappedRows = rows.map((row) => {
|
|
483
|
+
const fullRow = row.concat(new Array(colCount - row.length).fill(""));
|
|
484
|
+
const isSeparator = isSeparatorRow(fullRow);
|
|
485
|
+
return fullRow.map((cell, i) => {
|
|
486
|
+
if (isSeparator) {
|
|
487
|
+
// Regenerate separator dashes to fit the column width (no wrapping)
|
|
488
|
+
return ["-".repeat(colWidths[i])];
|
|
489
|
+
}
|
|
490
|
+
return wrapCell(cell, colWidths[i]);
|
|
491
|
+
});
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
return wrappedRows
|
|
495
|
+
.map((wrappedCells) => renderWrappedRow(wrappedCells, colWidths))
|
|
496
|
+
.join("\n");
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* Check if a row is a markdown table separator row.
|
|
501
|
+
* A separator row contains only dashes, colons, and spaces
|
|
502
|
+
* (e.g., "------", ":----:", "-----:", ":-----").
|
|
503
|
+
* @param {string[]} cells
|
|
504
|
+
* @returns {boolean}
|
|
505
|
+
*/
|
|
506
|
+
function isSeparatorRow(cells) {
|
|
507
|
+
return cells.length > 0 && cells.every((cell) => /^[-: ]+$/.test(cell));
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* Determine column widths that fit within maxWidth.
|
|
512
|
+
* If the natural total width fits, returns natural widths unchanged.
|
|
513
|
+
* Otherwise, shrinks columns proportionally (minimum 3 chars each).
|
|
514
|
+
* Returns null in any entry if the table cannot fit at all (fallback signal).
|
|
515
|
+
* @param {number[]} naturalWidths - Natural (max content) width per column
|
|
516
|
+
* @param {number} colCount - Number of columns
|
|
517
|
+
* @param {number} maxWidth - Available terminal width
|
|
518
|
+
* @returns {number[]} - Target width per column
|
|
519
|
+
*/
|
|
520
|
+
function fitColumns(naturalWidths, colCount, maxWidth) {
|
|
521
|
+
const gutter = 4 + (colCount - 1) * 3; // "| " + " |" + inter-column " | "
|
|
522
|
+
const available = maxWidth - gutter;
|
|
523
|
+
|
|
524
|
+
// If natural widths fit, use them as-is
|
|
525
|
+
const totalNatural = naturalWidths.reduce((s, w) => s + w, 0);
|
|
526
|
+
if (totalNatural <= available || maxWidth === Number.POSITIVE_INFINITY) {
|
|
527
|
+
return naturalWidths;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
// Shrink: allocate minimum width first, then distribute remainder proportionally
|
|
531
|
+
const minWidth = 3;
|
|
532
|
+
const minTotal = minWidth * colCount;
|
|
533
|
+
|
|
534
|
+
if (minTotal > available) {
|
|
535
|
+
// Cannot fit even at minimum — return natural widths (will overflow)
|
|
536
|
+
return naturalWidths;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
const result = naturalWidths.map(() => minWidth);
|
|
540
|
+
const remaining = available - minTotal;
|
|
541
|
+
|
|
542
|
+
// Distribute remaining space proportionally to natural widths
|
|
543
|
+
const naturalTotalAboveMin = naturalWidths.reduce(
|
|
544
|
+
(s, w) => s + Math.max(0, w - minWidth),
|
|
545
|
+
0,
|
|
546
|
+
);
|
|
547
|
+
|
|
548
|
+
if (naturalTotalAboveMin > 0) {
|
|
549
|
+
for (let i = 0; i < colCount; i++) {
|
|
550
|
+
const aboveMin = Math.max(0, naturalWidths[i] - minWidth);
|
|
551
|
+
const share = Math.round((aboveMin / naturalTotalAboveMin) * remaining);
|
|
552
|
+
result[i] = minWidth + share;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
// Adjust for rounding: distribute leftover pixels to widest columns
|
|
556
|
+
const currentTotal = result.reduce((s, w) => s + w, 0);
|
|
557
|
+
let diff = available - currentTotal;
|
|
558
|
+
// Sort column indices by natural width descending for fair distribution
|
|
559
|
+
const sortedIndices = naturalWidths
|
|
560
|
+
.map((w, i) => /** @type {[number, number]} */ ([i, w]))
|
|
561
|
+
.sort((a, b) => b[1] - a[1])
|
|
562
|
+
.map(([i]) => i);
|
|
563
|
+
let idx = 0;
|
|
564
|
+
while (diff > 0) {
|
|
565
|
+
result[sortedIndices[idx % colCount]]++;
|
|
566
|
+
diff--;
|
|
567
|
+
idx++;
|
|
568
|
+
}
|
|
569
|
+
while (diff < 0) {
|
|
570
|
+
result[sortedIndices[idx % colCount]]--;
|
|
571
|
+
diff++;
|
|
572
|
+
idx++;
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
return result;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/**
|
|
580
|
+
* Wrap a cell's content to fit within the given display width.
|
|
581
|
+
* Respects ANSI escape codes (does not break them) and CJK wide characters.
|
|
582
|
+
* @param {string} text - Cell content (may contain ANSI codes)
|
|
583
|
+
* @param {number} width - Maximum display width per line
|
|
584
|
+
* @returns {string[]} - Array of visual lines for this cell
|
|
585
|
+
*/
|
|
586
|
+
function wrapCell(text, width) {
|
|
587
|
+
if (width <= 0) return [text];
|
|
588
|
+
const textWidth = charDisplayWidth(text);
|
|
589
|
+
if (textWidth <= width) return [text];
|
|
590
|
+
|
|
591
|
+
// Build segments: each segment is either an ANSI escape code or a visible character
|
|
592
|
+
/** @type {{ text: string, displayWidth: number }[]} */
|
|
593
|
+
const segments = [];
|
|
594
|
+
let i = 0;
|
|
595
|
+
while (i < text.length) {
|
|
596
|
+
// Check for ANSI escape sequence
|
|
597
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: ANSI escape code pattern
|
|
598
|
+
const ansiMatch = text.slice(i).match(/^\u001b\[[0-9;]*m/);
|
|
599
|
+
if (ansiMatch) {
|
|
600
|
+
segments.push({ text: ansiMatch[0], displayWidth: 0 });
|
|
601
|
+
i += ansiMatch[0].length;
|
|
602
|
+
} else {
|
|
603
|
+
const ch = text[i];
|
|
604
|
+
const code = /** @type {number} */ (ch.codePointAt(0));
|
|
605
|
+
const isWide = isWideChar(code);
|
|
606
|
+
segments.push({ text: ch, displayWidth: isWide ? 2 : 1 });
|
|
607
|
+
i++;
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// Group segments into lines
|
|
612
|
+
/** @type {string[]} */
|
|
613
|
+
const lines = [];
|
|
614
|
+
/** @type {string} */
|
|
615
|
+
let currentLine = "";
|
|
616
|
+
let currentWidth = 0;
|
|
617
|
+
|
|
618
|
+
for (const seg of segments) {
|
|
619
|
+
if (seg.displayWidth === 0) {
|
|
620
|
+
// ANSI code: attach to current line without increasing width
|
|
621
|
+
currentLine += seg.text;
|
|
622
|
+
continue;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
if (currentWidth + seg.displayWidth > width) {
|
|
626
|
+
// This character would overflow — start a new line
|
|
627
|
+
lines.push(currentLine);
|
|
628
|
+
currentLine = seg.text;
|
|
629
|
+
currentWidth = seg.displayWidth;
|
|
630
|
+
} else {
|
|
631
|
+
currentLine += seg.text;
|
|
632
|
+
currentWidth += seg.displayWidth;
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
if (currentLine.length > 0 || lines.length === 0) {
|
|
637
|
+
lines.push(currentLine);
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
return lines;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/**
|
|
644
|
+
* Check if a Unicode code point is a wide (double-width) character.
|
|
645
|
+
* Extracted from charDisplayWidth for reuse in wrapCell.
|
|
646
|
+
* @param {number} code
|
|
647
|
+
* @returns {boolean}
|
|
648
|
+
*/
|
|
649
|
+
function isWideChar(code) {
|
|
650
|
+
return (
|
|
651
|
+
(code >= 0x1100 && code <= 0x115f) ||
|
|
652
|
+
(code >= 0x2e80 && code <= 0xa4cf) ||
|
|
653
|
+
(code >= 0xac00 && code <= 0xd7a3) ||
|
|
654
|
+
(code >= 0xf900 && code <= 0xfaff) ||
|
|
655
|
+
(code >= 0xfe10 && code <= 0xfe19) ||
|
|
656
|
+
(code >= 0xfe30 && code <= 0xfe6f) ||
|
|
657
|
+
(code >= 0xff00 && code <= 0xff60) ||
|
|
658
|
+
(code >= 0xffe0 && code <= 0xffe6) ||
|
|
659
|
+
(code >= 0x2614 && code <= 0x2615) ||
|
|
660
|
+
(code >= 0x2630 && code <= 0x2637) ||
|
|
661
|
+
(code >= 0x2648 && code <= 0x2653) ||
|
|
662
|
+
code === 0x267f ||
|
|
663
|
+
(code >= 0x268a && code <= 0x268f) ||
|
|
664
|
+
code === 0x2693 ||
|
|
665
|
+
code === 0x26a1 ||
|
|
666
|
+
(code >= 0x26aa && code <= 0x26ab) ||
|
|
667
|
+
(code >= 0x26bd && code <= 0x26be) ||
|
|
668
|
+
(code >= 0x26c4 && code <= 0x26c5) ||
|
|
669
|
+
code === 0x26ce ||
|
|
670
|
+
code === 0x26d4 ||
|
|
671
|
+
code === 0x26ea ||
|
|
672
|
+
(code >= 0x26f2 && code <= 0x26f3) ||
|
|
673
|
+
code === 0x26f5 ||
|
|
674
|
+
code === 0x26fa ||
|
|
675
|
+
code === 0x26fd ||
|
|
676
|
+
code === 0x2705 ||
|
|
677
|
+
(code >= 0x270a && code <= 0x270b) ||
|
|
678
|
+
code === 0x2728 ||
|
|
679
|
+
code === 0x274c ||
|
|
680
|
+
code === 0x274e ||
|
|
681
|
+
(code >= 0x2753 && code <= 0x2755) ||
|
|
682
|
+
code === 0x2757 ||
|
|
683
|
+
(code >= 0x2795 && code <= 0x2797) ||
|
|
684
|
+
code === 0x27b0 ||
|
|
685
|
+
code === 0x27bf ||
|
|
686
|
+
(code >= 0x2b1b && code <= 0x2b1c) ||
|
|
687
|
+
code === 0x2b50 ||
|
|
688
|
+
code === 0x2b55 ||
|
|
689
|
+
(code >= 0x231a && code <= 0x231b) ||
|
|
690
|
+
code === 0x2329 ||
|
|
691
|
+
code === 0x232a ||
|
|
692
|
+
(code >= 0x23e9 && code <= 0x23ec) ||
|
|
693
|
+
code === 0x23f0 ||
|
|
694
|
+
code === 0x23f3 ||
|
|
695
|
+
code === 0x1f004 ||
|
|
696
|
+
code === 0x1f0cf ||
|
|
697
|
+
code === 0x1f18e ||
|
|
698
|
+
(code >= 0x1f191 && code <= 0x1f19a) ||
|
|
699
|
+
(code >= 0x1f200 && code <= 0x1f202) ||
|
|
700
|
+
(code >= 0x1f210 && code <= 0x1f23b) ||
|
|
701
|
+
(code >= 0x1f240 && code <= 0x1f248) ||
|
|
702
|
+
(code >= 0x1f250 && code <= 0x1f251) ||
|
|
703
|
+
(code >= 0x1f260 && code <= 0x1f265) ||
|
|
704
|
+
(code >= 0x1f300 && code <= 0x1f320) ||
|
|
705
|
+
(code >= 0x1f32d && code <= 0x1f335) ||
|
|
706
|
+
(code >= 0x1f337 && code <= 0x1f37c) ||
|
|
707
|
+
(code >= 0x1f37e && code <= 0x1f393) ||
|
|
708
|
+
(code >= 0x1f3a0 && code <= 0x1f3ca) ||
|
|
709
|
+
(code >= 0x1f3cf && code <= 0x1f3d3) ||
|
|
710
|
+
(code >= 0x1f3e0 && code <= 0x1f3f0) ||
|
|
711
|
+
code === 0x1f3f4 ||
|
|
712
|
+
(code >= 0x1f3f8 && code <= 0x1f3fa) ||
|
|
713
|
+
(code >= 0x1f3fb && code <= 0x1f3ff) ||
|
|
714
|
+
(code >= 0x1f400 && code <= 0x1f43e) ||
|
|
715
|
+
code === 0x1f440 ||
|
|
716
|
+
(code >= 0x1f442 && code <= 0x1f4fc) ||
|
|
717
|
+
(code >= 0x1f4ff && code <= 0x1f53d) ||
|
|
718
|
+
(code >= 0x1f54b && code <= 0x1f54e) ||
|
|
719
|
+
(code >= 0x1f550 && code <= 0x1f567) ||
|
|
720
|
+
code === 0x1f57a ||
|
|
721
|
+
(code >= 0x1f595 && code <= 0x1f596) ||
|
|
722
|
+
code === 0x1f5a4 ||
|
|
723
|
+
(code >= 0x1f5fb && code <= 0x1f5ff) ||
|
|
724
|
+
(code >= 0x1f600 && code <= 0x1f64f) ||
|
|
725
|
+
(code >= 0x1f680 && code <= 0x1f6c5) ||
|
|
726
|
+
code === 0x1f6cc ||
|
|
727
|
+
(code >= 0x1f6d0 && code <= 0x1f6d2) ||
|
|
728
|
+
(code >= 0x1f6d5 && code <= 0x1f6d8) ||
|
|
729
|
+
(code >= 0x1f6dc && code <= 0x1f6df) ||
|
|
730
|
+
(code >= 0x1f6eb && code <= 0x1f6ec) ||
|
|
731
|
+
(code >= 0x1f6f4 && code <= 0x1f6fc) ||
|
|
732
|
+
(code >= 0x1f7e0 && code <= 0x1f7eb) ||
|
|
733
|
+
code === 0x1f7f0 ||
|
|
734
|
+
(code >= 0x1f90c && code <= 0x1f93a) ||
|
|
735
|
+
(code >= 0x1f93c && code <= 0x1f945) ||
|
|
736
|
+
(code >= 0x1f947 && code <= 0x1f9ff) ||
|
|
737
|
+
(code >= 0x1fa70 && code <= 0x1fa7c) ||
|
|
738
|
+
(code >= 0x1fa80 && code <= 0x1fa8a) ||
|
|
739
|
+
(code >= 0x1fa8e && code <= 0x1fac6) ||
|
|
740
|
+
code === 0x1fac8 ||
|
|
741
|
+
(code >= 0x1facd && code <= 0x1fadc) ||
|
|
742
|
+
(code >= 0x1fadf && code <= 0x1faea) ||
|
|
743
|
+
(code >= 0x1faef && code <= 0x1faf8) ||
|
|
744
|
+
(code >= 0x20000 && code <= 0x2fffd) ||
|
|
745
|
+
(code >= 0x30000 && code <= 0x3fffd)
|
|
746
|
+
);
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
/**
|
|
750
|
+
* Render a wrapped row (where cells may span multiple visual lines).
|
|
751
|
+
* Each cell's visual lines are padded to the column width, and cells
|
|
752
|
+
* are aligned horizontally across visual lines.
|
|
753
|
+
* @param {string[][]} wrappedCells - Array of visual-line arrays per cell
|
|
754
|
+
* @param {number[]} colWidths - Target display width per column
|
|
755
|
+
* @returns {string} - Rendered row (may contain embedded newlines)
|
|
756
|
+
*/
|
|
757
|
+
function renderWrappedRow(wrappedCells, colWidths) {
|
|
758
|
+
const maxLines = Math.max(...wrappedCells.map((c) => c.length));
|
|
759
|
+
const visualLines = [];
|
|
760
|
+
for (let lineIdx = 0; lineIdx < maxLines; lineIdx++) {
|
|
761
|
+
const parts = wrappedCells.map((cell, colIdx) => {
|
|
762
|
+
const text = cell[lineIdx] ?? "";
|
|
763
|
+
return padCell(text, colWidths[colIdx]);
|
|
764
|
+
});
|
|
765
|
+
visualLines.push(`| ${parts.join(" | ")} |`);
|
|
766
|
+
}
|
|
767
|
+
return visualLines.join("\n");
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
/** @type {RegExp} - ANSI escape code pattern */
|
|
771
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: ANSI escape code pattern
|
|
772
|
+
const ANSI_RE = /\u001b\[[0-9;]*m/g;
|
|
773
|
+
|
|
774
|
+
/**
|
|
775
|
+
* Strip ANSI escape codes for display width calculation.
|
|
776
|
+
* @param {string} str
|
|
777
|
+
* @returns {string}
|
|
778
|
+
*/
|
|
779
|
+
function stripAnsiCodes(str) {
|
|
780
|
+
return str.replace(ANSI_RE, "");
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
/**
|
|
784
|
+
* Calculate the terminal display width of a string.
|
|
785
|
+
* CJK full-width characters and emoji count as 2 columns; ASCII as 1.
|
|
786
|
+
* ANSI escape codes are stripped before measurement.
|
|
787
|
+
* @param {string} str
|
|
788
|
+
* @returns {number}
|
|
789
|
+
*/
|
|
790
|
+
function charDisplayWidth(str) {
|
|
791
|
+
const plain = stripAnsiCodes(str);
|
|
792
|
+
let width = 0;
|
|
793
|
+
for (const ch of plain) {
|
|
794
|
+
const code = /** @type {number} */ (ch.codePointAt(0));
|
|
795
|
+
width += isWideChar(code) ? 2 : 1;
|
|
796
|
+
}
|
|
797
|
+
return width;
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
/**
|
|
801
|
+
* Split a markdown table row into cells.
|
|
802
|
+
* Removes leading/trailing pipes, splits by `|`.
|
|
803
|
+
* Respects escaped pipes (`\|`).
|
|
804
|
+
* @param {string} line
|
|
805
|
+
* @returns {string[]}
|
|
806
|
+
*/
|
|
807
|
+
function splitTableRow(line) {
|
|
808
|
+
const trimmed = line.trim();
|
|
809
|
+
// Remove leading and trailing pipes
|
|
810
|
+
let inner;
|
|
811
|
+
if (trimmed.startsWith("|") && trimmed.endsWith("|")) {
|
|
812
|
+
inner = trimmed.slice(1, -1);
|
|
813
|
+
} else if (trimmed.startsWith("|")) {
|
|
814
|
+
inner = trimmed.slice(1);
|
|
815
|
+
} else if (trimmed.endsWith("|")) {
|
|
816
|
+
inner = trimmed.slice(0, -1);
|
|
817
|
+
} else {
|
|
818
|
+
inner = trimmed;
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
// Split by pipe, respecting escaped pipes
|
|
822
|
+
/** @type {string[]} */
|
|
823
|
+
const cells = [];
|
|
824
|
+
let current = "";
|
|
825
|
+
for (let i = 0; i < inner.length; i++) {
|
|
826
|
+
if (inner[i] === "\\" && i + 1 < inner.length && inner[i + 1] === "|") {
|
|
827
|
+
current += "|";
|
|
828
|
+
i++;
|
|
829
|
+
} else if (inner[i] === "|") {
|
|
830
|
+
cells.push(current);
|
|
831
|
+
current = "";
|
|
832
|
+
} else {
|
|
833
|
+
current += inner[i];
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
cells.push(current);
|
|
837
|
+
return cells.map((c) => c.trim());
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
/**
|
|
841
|
+
* Pad a cell string with trailing spaces to the given display width.
|
|
842
|
+
* @param {string} cell - Original cell content (may contain ANSI codes)
|
|
843
|
+
* @param {number} targetWidth - Target display width
|
|
844
|
+
* @returns {string}
|
|
845
|
+
*/
|
|
846
|
+
function padCell(cell, targetWidth) {
|
|
847
|
+
const currentWidth = charDisplayWidth(cell);
|
|
848
|
+
if (currentWidth >= targetWidth) return cell;
|
|
849
|
+
return cell + " ".repeat(targetWidth - currentWidth);
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
/**
|
|
853
|
+
* Render a patch_file `patch` string for terminal display.
|
|
854
|
+
*
|
|
855
|
+
* Attempts to show a side-by-side diff (- removed, + added, unchanged)
|
|
856
|
+
* by parsing the patch and reading the target file. Falls back to plain
|
|
857
|
+
* syntax highlighting on any failure.
|
|
858
|
+
*
|
|
859
|
+
* @param {string} filePath
|
|
860
|
+
* @param {string} patch
|
|
861
|
+
* @returns {Promise<string>}
|
|
862
|
+
*/
|
|
863
|
+
async function renderPatch(filePath, patch) {
|
|
864
|
+
if (!patch) {
|
|
865
|
+
return "";
|
|
866
|
+
}
|
|
867
|
+
const fallback = highlightPatchPlain(patch);
|
|
868
|
+
|
|
869
|
+
const nonce = extractPatchNonce(patch);
|
|
870
|
+
if (!nonce) {
|
|
871
|
+
return fallback;
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
/** @type {PatchBlock[]} */
|
|
875
|
+
let blocks;
|
|
876
|
+
try {
|
|
877
|
+
blocks = parseBlocks(patch, nonce);
|
|
878
|
+
} catch (err) {
|
|
879
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
880
|
+
console.error(
|
|
881
|
+
styleText("yellow", `Warning: Patch parsing failed: ${message}`),
|
|
882
|
+
);
|
|
883
|
+
return fallback;
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
let originalLines = null;
|
|
887
|
+
if (filePath) {
|
|
888
|
+
const original = await noThrow(() => fs.readFile(filePath, "utf8"));
|
|
889
|
+
if (!(original instanceof Error)) {
|
|
890
|
+
originalLines = splitContentLines(original);
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
return blocks
|
|
895
|
+
.map((block) => renderPatchBlock(block, originalLines, nonce))
|
|
896
|
+
.join("\n\n");
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
/**
|
|
900
|
+
* @param {PatchBlock} block
|
|
901
|
+
* @param {string[] | null} originalLines
|
|
902
|
+
* @param {string} nonce
|
|
903
|
+
* @returns {string}
|
|
904
|
+
*/
|
|
905
|
+
function renderPatchBlock(block, originalLines, nonce) {
|
|
906
|
+
/** @type {string[]} */
|
|
907
|
+
const out = [];
|
|
908
|
+
if (block.op === "replace") {
|
|
909
|
+
out.push(
|
|
910
|
+
styleText(
|
|
911
|
+
"cyan",
|
|
912
|
+
`>>> ${nonce} ${block.start}:${block.startHash}-${block.end}:${block.endHash}`,
|
|
913
|
+
),
|
|
914
|
+
);
|
|
915
|
+
if (originalLines) {
|
|
916
|
+
const safeStart = Math.max(1, block.start);
|
|
917
|
+
const safeEnd = Math.min(originalLines.length, block.end);
|
|
918
|
+
const oldSlice = originalLines.slice(safeStart - 1, safeEnd);
|
|
919
|
+
// Use a real line diff so unchanged lines render as context
|
|
920
|
+
// (no color, " " prefix) instead of being shown as both "- " and
|
|
921
|
+
// "+ ".
|
|
922
|
+
for (const op of diffLines(oldSlice, block.body)) {
|
|
923
|
+
if (op.type === "-") {
|
|
924
|
+
out.push(styleText("red", `- ${op.line}`));
|
|
925
|
+
} else if (op.type === "+") {
|
|
926
|
+
out.push(styleText("green", `+ ${op.line}`));
|
|
927
|
+
} else {
|
|
928
|
+
out.push(` ${op.line}`);
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
} else {
|
|
932
|
+
// No file context available — fall back to listing the body as
|
|
933
|
+
// additions so the user can still see the new content.
|
|
934
|
+
for (const line of block.body) {
|
|
935
|
+
out.push(styleText("green", `+ ${line}`));
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
} else {
|
|
939
|
+
const afterSuffix = block.afterHash ? `:${block.afterHash}` : "";
|
|
940
|
+
out.push(styleText("cyan", `>>> ${nonce} ${block.after}${afterSuffix}+`));
|
|
941
|
+
for (const line of block.body) {
|
|
942
|
+
out.push(styleText("green", `+ ${line}`));
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
out.push(styleText("cyan", `<<< ${nonce}`));
|
|
946
|
+
return out.join("\n");
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
/**
|
|
950
|
+
* Verbatim highlighter used as fallback when block-aware rendering is not
|
|
951
|
+
* possible (parse error, missing nonce, etc.).
|
|
952
|
+
* @param {string} patch
|
|
953
|
+
* @returns {string}
|
|
954
|
+
*/
|
|
955
|
+
function highlightPatchPlain(patch) {
|
|
956
|
+
if (!patch) {
|
|
957
|
+
return "";
|
|
958
|
+
}
|
|
959
|
+
// Patch open/close markers look like ">>> <nonce> ..." or "<<< <nonce>".
|
|
960
|
+
const headerRegex = /^(>>>|<<<)\s+\S+(\s.*)?$/;
|
|
961
|
+
return patch
|
|
962
|
+
.split("\n")
|
|
963
|
+
.map((line) => {
|
|
964
|
+
if (headerRegex.test(line)) {
|
|
965
|
+
return styleText("cyan", line);
|
|
966
|
+
}
|
|
967
|
+
if (line === "") {
|
|
968
|
+
return line;
|
|
969
|
+
}
|
|
970
|
+
return styleText("green", line);
|
|
971
|
+
})
|
|
972
|
+
.join("\n");
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
/**
|
|
976
|
+
* Extract the nonce from the first open marker in a patch_file patch.
|
|
977
|
+
* @param {string} patch
|
|
978
|
+
* @returns {string | null}
|
|
979
|
+
*/
|
|
980
|
+
function extractPatchNonce(patch) {
|
|
981
|
+
const match = patch.match(/^>>>\s+(\S+)/m);
|
|
982
|
+
return match ? match[1] : null;
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
/**
|
|
986
|
+
* Split file content into lines, dropping the trailing empty element when
|
|
987
|
+
* the file ends with a newline (matches patch_file's own line indexing).
|
|
988
|
+
* @param {string} content
|
|
989
|
+
* @returns {string[]}
|
|
990
|
+
*/
|
|
991
|
+
function splitContentLines(content) {
|
|
992
|
+
const lines = content.split("\n");
|
|
993
|
+
if (lines.length > 0 && lines[lines.length - 1] === "") {
|
|
994
|
+
lines.pop();
|
|
995
|
+
}
|
|
996
|
+
return lines;
|
|
997
|
+
}
|