@trim21/personal-pi-extensions 0.0.314 → 0.0.316
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/package.json +1 -1
- package/src/claude-code/files.ts +157 -233
- package/src/lib/lsp/lsp.ts +24 -3
- package/src/opencode/files.ts +94 -88
package/package.json
CHANGED
package/src/claude-code/files.ts
CHANGED
|
@@ -15,7 +15,6 @@ import {
|
|
|
15
15
|
import { Type } from "typebox";
|
|
16
16
|
|
|
17
17
|
import { createLspService, initLsp, type LspService } from "../lib/lsp/lsp.js";
|
|
18
|
-
import { type ToolPendant } from "../lib/pendant.js";
|
|
19
18
|
import { guardWriteAccess } from "../lib/write-guard.js";
|
|
20
19
|
import {
|
|
21
20
|
type ClaudeCodeState,
|
|
@@ -46,43 +45,6 @@ function formatFileSize(bytes: number): string {
|
|
|
46
45
|
return `${(bytes / 1024 / 1024 / 1024).toFixed(1)} GB`;
|
|
47
46
|
}
|
|
48
47
|
|
|
49
|
-
/** Edit/Write 结果的可折叠面板:diff(```diff 代码块)+ LSP errors(有则显示)。 */
|
|
50
|
-
export function buildEditPendant(options: {
|
|
51
|
-
filePath: string;
|
|
52
|
-
diff: string;
|
|
53
|
-
diagnosticText: string;
|
|
54
|
-
}): ToolPendant {
|
|
55
|
-
const lines = [`## Diff — \`${options.filePath}\``, "", "```diff", options.diff, "```"];
|
|
56
|
-
if (options.diagnosticText !== "") {
|
|
57
|
-
lines.push("", "## LSP errors", "", "```text", options.diagnosticText, "```");
|
|
58
|
-
}
|
|
59
|
-
return { markdown: lines.join("\n"), expanded: true };
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
/** Read 结果的可折叠面板:路径、总行数、读取范围与文件大小。 */
|
|
63
|
-
export function buildReadPendant(options: {
|
|
64
|
-
filePath: string;
|
|
65
|
-
byteSize: number;
|
|
66
|
-
offset?: number;
|
|
67
|
-
limit?: number;
|
|
68
|
-
totalLines?: number;
|
|
69
|
-
type?: string;
|
|
70
|
-
}): ToolPendant {
|
|
71
|
-
const lines = ["## Read", "", `- **Path**: \`${options.filePath}\``];
|
|
72
|
-
if (options.type) lines.push(`- **Type**: ${options.type}`);
|
|
73
|
-
if (options.totalLines !== undefined) {
|
|
74
|
-
const start = options.offset ?? 1;
|
|
75
|
-
const end = Math.min(
|
|
76
|
-
options.limit === undefined ? options.totalLines : start + options.limit - 1,
|
|
77
|
-
options.totalLines,
|
|
78
|
-
);
|
|
79
|
-
lines.push(`- **Total lines**: ${options.totalLines}`);
|
|
80
|
-
if (start <= end) lines.push(`- **Range**: ${start}-${end}`);
|
|
81
|
-
}
|
|
82
|
-
lines.push(`- **Size**: ${formatFileSize(options.byteSize)}`);
|
|
83
|
-
return { markdown: lines.join("\n"), expanded: false };
|
|
84
|
-
}
|
|
85
|
-
|
|
86
48
|
/** Tool guidance, kept in markdown so it reads like documentation. */
|
|
87
49
|
const READ_PROMPT = readFileSync(fileURLToPath(new URL("read.md", import.meta.url)), "utf8").trim();
|
|
88
50
|
const EDIT_PROMPT = readFileSync(fileURLToPath(new URL("edit.md", import.meta.url)), "utf8").trim();
|
|
@@ -107,8 +69,6 @@ export interface FileToolDetails {
|
|
|
107
69
|
* session 文件,resume 后由 session_start 重建 reads state。
|
|
108
70
|
*/
|
|
109
71
|
reads?: Record<string, FileSnapshot>;
|
|
110
|
-
/** 可折叠的 diff / LSP 结果面板(pi TUI 渲染)。 */
|
|
111
|
-
pendant?: ToolPendant;
|
|
112
72
|
}
|
|
113
73
|
|
|
114
74
|
function snapshotOf(content: Uint8Array | string, textEditable = true): FileSnapshot {
|
|
@@ -326,17 +286,7 @@ export function registerFileTools(
|
|
|
326
286
|
const snapshot = snapshotOf(image, false);
|
|
327
287
|
const key = await readStateKey(filePath);
|
|
328
288
|
state.reads.set(key, snapshot);
|
|
329
|
-
return {
|
|
330
|
-
content,
|
|
331
|
-
details: {
|
|
332
|
-
reads: { [key]: snapshot },
|
|
333
|
-
pendant: buildReadPendant({
|
|
334
|
-
filePath,
|
|
335
|
-
byteSize: image.length,
|
|
336
|
-
type: imageMime,
|
|
337
|
-
}),
|
|
338
|
-
},
|
|
339
|
-
};
|
|
289
|
+
return { content, details: { reads: { [key]: snapshot } } };
|
|
340
290
|
}
|
|
341
291
|
|
|
342
292
|
const buffer = await readFile(filePath);
|
|
@@ -366,16 +316,7 @@ export function registerFileTools(
|
|
|
366
316
|
});
|
|
367
317
|
return {
|
|
368
318
|
content: [{ type: "text", text: formatted.text }],
|
|
369
|
-
details: {
|
|
370
|
-
reads: { [key]: snapshot },
|
|
371
|
-
pendant: buildReadPendant({
|
|
372
|
-
filePath,
|
|
373
|
-
byteSize: buffer.length,
|
|
374
|
-
offset: params.offset ?? 1,
|
|
375
|
-
limit: params.limit,
|
|
376
|
-
totalLines: formatted.totalLines,
|
|
377
|
-
}),
|
|
378
|
-
},
|
|
319
|
+
details: { reads: { [key]: snapshot } },
|
|
379
320
|
};
|
|
380
321
|
},
|
|
381
322
|
});
|
|
@@ -416,143 +357,135 @@ export function registerFileTools(
|
|
|
416
357
|
replaceAll: params.replace_all,
|
|
417
358
|
},
|
|
418
359
|
});
|
|
419
|
-
const [message, details] = await withFileMutationQueue<
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
try {
|
|
431
|
-
const value = await stat(filePath);
|
|
432
|
-
if (value.isFile()) {
|
|
433
|
-
const content = await readFile(filePath, "utf8");
|
|
434
|
-
if (content.trim() !== "") {
|
|
435
|
-
throw new Error("Cannot create new file - file already exists.");
|
|
436
|
-
}
|
|
437
|
-
}
|
|
438
|
-
} catch (error) {
|
|
439
|
-
if (error instanceof Error && "code" in error && error.code === "ENOENT") {
|
|
440
|
-
exists = false;
|
|
441
|
-
} else {
|
|
442
|
-
throw error;
|
|
443
|
-
}
|
|
444
|
-
}
|
|
445
|
-
if (!exists) await mkdir(dirname(filePath), { recursive: true });
|
|
446
|
-
await writeFile(filePath, newString, "utf8");
|
|
447
|
-
const snapshot = snapshotOf(newString);
|
|
448
|
-
const key = await readStateKey(filePath);
|
|
449
|
-
state.reads.set(key, snapshot);
|
|
450
|
-
const diff = generateDiffString("", newString);
|
|
451
|
-
return [
|
|
452
|
-
`The file ${filePath} has been updated successfully.`,
|
|
453
|
-
{
|
|
454
|
-
diff: diff.diff,
|
|
455
|
-
patch: generateUnifiedPatch(filePath, "", newString),
|
|
456
|
-
firstChangedLine: diff.firstChangedLine,
|
|
457
|
-
reads: { [key]: snapshot },
|
|
458
|
-
},
|
|
459
|
-
];
|
|
460
|
-
}
|
|
461
|
-
const replaceAll = params.replace_all ?? false;
|
|
462
|
-
// 防止 OOM 的大文件检查(对齐 Claude Code)
|
|
360
|
+
const [message, details, diagnosticText] = await withFileMutationQueue<
|
|
361
|
+
[string, FileToolDetails, string]
|
|
362
|
+
>(filePath, async () => {
|
|
363
|
+
const oldString = params.old_string;
|
|
364
|
+
const newString = params.new_string;
|
|
365
|
+
if (oldString === newString) {
|
|
366
|
+
throw new Error("No changes to make: old_string and new_string are exactly the same.");
|
|
367
|
+
}
|
|
368
|
+
// 空 old_string:创建新文件或填充空文件(不需要先 Read,对齐 Claude Code)
|
|
369
|
+
if (oldString === "") {
|
|
370
|
+
let exists = true;
|
|
463
371
|
try {
|
|
464
|
-
const
|
|
465
|
-
if (
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
} catch (error) {
|
|
471
|
-
if (!(error instanceof Error) || !("code" in error) || error.code !== "ENOENT") {
|
|
472
|
-
throw error;
|
|
372
|
+
const value = await stat(filePath);
|
|
373
|
+
if (value.isFile()) {
|
|
374
|
+
const content = await readFile(filePath, "utf8");
|
|
375
|
+
if (content.trim() !== "") {
|
|
376
|
+
throw new Error("Cannot create new file - file already exists.");
|
|
377
|
+
}
|
|
473
378
|
}
|
|
474
|
-
}
|
|
475
|
-
let content: Buffer;
|
|
476
|
-
try {
|
|
477
|
-
content = await readFile(filePath);
|
|
478
379
|
} catch (error) {
|
|
479
380
|
if (error instanceof Error && "code" in error && error.code === "ENOENT") {
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
{ cause: error },
|
|
484
|
-
);
|
|
381
|
+
exists = false;
|
|
382
|
+
} else {
|
|
383
|
+
throw error;
|
|
485
384
|
}
|
|
486
|
-
throw error;
|
|
487
|
-
}
|
|
488
|
-
if (extname(filePath).toLowerCase() === ".ipynb") {
|
|
489
|
-
throw new Error(
|
|
490
|
-
"File is a Jupyter Notebook. Use the NotebookEditTool to edit this file.",
|
|
491
|
-
);
|
|
492
385
|
}
|
|
386
|
+
if (!exists) await mkdir(dirname(filePath), { recursive: true });
|
|
387
|
+
await writeFile(filePath, newString, "utf8");
|
|
388
|
+
const snapshot = snapshotOf(newString);
|
|
493
389
|
const key = await readStateKey(filePath);
|
|
494
|
-
requireCurrentRead(state, key, filePath, content);
|
|
495
|
-
await access(filePath, constants.R_OK | constants.W_OK);
|
|
496
|
-
throwIfAborted(signal);
|
|
497
|
-
const original = content.toString("utf8");
|
|
498
|
-
|
|
499
|
-
// CRLF 规范化后匹配(old_string 不需要带 \r),写回时恢复原行尾
|
|
500
|
-
const crlfCount = (original.match(/\r\n/g) ?? []).length;
|
|
501
|
-
const lfCount = (original.match(/(?<!\r)\n/g) ?? []).length;
|
|
502
|
-
const lineEnding = crlfCount > lfCount ? "\r\n" : "\n";
|
|
503
|
-
const normalized = original.replaceAll("\r\n", "\n");
|
|
504
|
-
const actualOldString = findActualString(normalized, oldString) ?? oldString;
|
|
505
|
-
const matches = normalized.split(actualOldString).length - 1;
|
|
506
|
-
if (matches === 0) {
|
|
507
|
-
throw new Error(`String to replace not found in file.\nString: ${oldString}`);
|
|
508
|
-
}
|
|
509
|
-
if (!replaceAll && matches > 1) {
|
|
510
|
-
throw new Error(
|
|
511
|
-
`Found ${matches} matches of the string to replace, but replace_all is false. To replace all occurrences, set replace_all to true. To replace only one occurrence, please provide more context to uniquely identify the instance.\nString: ${oldString}`,
|
|
512
|
-
);
|
|
513
|
-
}
|
|
514
|
-
const actualNewString = preserveQuoteStyle(oldString, actualOldString, newString);
|
|
515
|
-
// split/join 与函数替换:replacement 含 $ 时不会触发 $& 等特殊语义
|
|
516
|
-
const updated = replaceAll
|
|
517
|
-
? normalized.split(actualOldString).join(actualNewString)
|
|
518
|
-
: normalized.replace(actualOldString, () => actualNewString);
|
|
519
|
-
const restored = lineEnding === "\r\n" ? updated.replaceAll("\n", "\r\n") : updated;
|
|
520
|
-
await writeFile(filePath, restored, "utf8");
|
|
521
|
-
const snapshot = snapshotOf(restored);
|
|
522
390
|
state.reads.set(key, snapshot);
|
|
523
|
-
const diff = generateDiffString(
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
: `The file ${filePath} has been updated successfully.`;
|
|
391
|
+
const diff = generateDiffString("", newString);
|
|
392
|
+
throwIfAborted(signal);
|
|
393
|
+
const diagnosticText = await service.lspDiagnosticsForFile(filePath, ctx.cwd);
|
|
527
394
|
return [
|
|
528
|
-
|
|
395
|
+
`The file ${filePath} has been updated successfully.`,
|
|
529
396
|
{
|
|
530
397
|
diff: diff.diff,
|
|
531
|
-
patch: generateUnifiedPatch(filePath,
|
|
398
|
+
patch: generateUnifiedPatch(filePath, "", newString),
|
|
532
399
|
firstChangedLine: diff.firstChangedLine,
|
|
533
400
|
reads: { [key]: snapshot },
|
|
534
401
|
},
|
|
402
|
+
diagnosticText,
|
|
535
403
|
];
|
|
536
|
-
}
|
|
537
|
-
|
|
404
|
+
}
|
|
405
|
+
const replaceAll = params.replace_all ?? false;
|
|
406
|
+
// 防止 OOM 的大文件检查(对齐 Claude Code)
|
|
407
|
+
try {
|
|
408
|
+
const { size } = await stat(filePath);
|
|
409
|
+
if (size > MAX_EDIT_FILE_SIZE) {
|
|
410
|
+
throw new Error(
|
|
411
|
+
`File is too large to edit (${formatFileSize(size)}). Maximum editable file size is ${formatFileSize(MAX_EDIT_FILE_SIZE)}.`,
|
|
412
|
+
);
|
|
413
|
+
}
|
|
414
|
+
} catch (error) {
|
|
415
|
+
if (!(error instanceof Error) || !("code" in error) || error.code !== "ENOENT") {
|
|
416
|
+
throw error;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
let content: Buffer;
|
|
420
|
+
try {
|
|
421
|
+
content = await readFile(filePath);
|
|
422
|
+
} catch (error) {
|
|
423
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT") {
|
|
424
|
+
const suggestion = await didYouMean(filePath, ctx.cwd);
|
|
425
|
+
throw new Error(
|
|
426
|
+
`File does not exist. Note: your current working directory is ${ctx.cwd}.${suggestion ? ` Did you mean ${suggestion}?` : ""}`,
|
|
427
|
+
{ cause: error },
|
|
428
|
+
);
|
|
429
|
+
}
|
|
430
|
+
throw error;
|
|
431
|
+
}
|
|
432
|
+
if (extname(filePath).toLowerCase() === ".ipynb") {
|
|
433
|
+
throw new Error(
|
|
434
|
+
"File is a Jupyter Notebook. Use the NotebookEditTool to edit this file.",
|
|
435
|
+
);
|
|
436
|
+
}
|
|
437
|
+
const key = await readStateKey(filePath);
|
|
438
|
+
requireCurrentRead(state, key, filePath, content);
|
|
439
|
+
await access(filePath, constants.R_OK | constants.W_OK);
|
|
440
|
+
throwIfAborted(signal);
|
|
441
|
+
const original = content.toString("utf8");
|
|
442
|
+
|
|
443
|
+
// CRLF 规范化后匹配(old_string 不需要带 \r),写回时恢复原行尾
|
|
444
|
+
const crlfCount = (original.match(/\r\n/g) ?? []).length;
|
|
445
|
+
const lfCount = (original.match(/(?<!\r)\n/g) ?? []).length;
|
|
446
|
+
const lineEnding = crlfCount > lfCount ? "\r\n" : "\n";
|
|
447
|
+
const normalized = original.replaceAll("\r\n", "\n");
|
|
448
|
+
const actualOldString = findActualString(normalized, oldString) ?? oldString;
|
|
449
|
+
const matches = normalized.split(actualOldString).length - 1;
|
|
450
|
+
if (matches === 0) {
|
|
451
|
+
throw new Error(`String to replace not found in file.\nString: ${oldString}`);
|
|
452
|
+
}
|
|
453
|
+
if (!replaceAll && matches > 1) {
|
|
454
|
+
throw new Error(
|
|
455
|
+
`Found ${matches} matches of the string to replace, but replace_all is false. To replace all occurrences, set replace_all to true. To replace only one occurrence, please provide more context to uniquely identify the instance.\nString: ${oldString}`,
|
|
456
|
+
);
|
|
457
|
+
}
|
|
458
|
+
const actualNewString = preserveQuoteStyle(oldString, actualOldString, newString);
|
|
459
|
+
// split/join 与函数替换:replacement 含 $ 时不会触发 $& 等特殊语义
|
|
460
|
+
const updated = replaceAll
|
|
461
|
+
? normalized.split(actualOldString).join(actualNewString)
|
|
462
|
+
: normalized.replace(actualOldString, () => actualNewString);
|
|
463
|
+
const restored = lineEnding === "\r\n" ? updated.replaceAll("\n", "\r\n") : updated;
|
|
464
|
+
await writeFile(filePath, restored, "utf8");
|
|
465
|
+
const snapshot = snapshotOf(restored);
|
|
466
|
+
state.reads.set(key, snapshot);
|
|
467
|
+
const diff = generateDiffString(original, restored);
|
|
468
|
+
const text = replaceAll
|
|
469
|
+
? `The file ${filePath} has been updated. All occurrences were successfully replaced.`
|
|
470
|
+
: `The file ${filePath} has been updated successfully.`;
|
|
471
|
+
throwIfAborted(signal);
|
|
472
|
+
const diagnosticText = await service.lspDiagnosticsForFile(filePath, ctx.cwd);
|
|
473
|
+
return [
|
|
474
|
+
text,
|
|
475
|
+
{
|
|
476
|
+
diff: diff.diff,
|
|
477
|
+
patch: generateUnifiedPatch(filePath, original, restored),
|
|
478
|
+
firstChangedLine: diff.firstChangedLine,
|
|
479
|
+
reads: { [key]: snapshot },
|
|
480
|
+
},
|
|
481
|
+
diagnosticText,
|
|
482
|
+
];
|
|
483
|
+
});
|
|
538
484
|
|
|
539
|
-
throwIfAborted(signal);
|
|
540
|
-
// 写后等待文档诊断,ERROR 级错误追加到输出让模型可见
|
|
541
|
-
const diagnosticText = await service.lspDiagnosticsForFile(filePath, ctx.cwd);
|
|
542
485
|
const text = diagnosticText
|
|
543
486
|
? `${message}\n\nLSP errors detected in this file, please fix:\n${diagnosticText}`
|
|
544
487
|
: message;
|
|
545
|
-
return {
|
|
546
|
-
content: [{ type: "text" as const, text }],
|
|
547
|
-
details: {
|
|
548
|
-
...details,
|
|
549
|
-
pendant: buildEditPendant({
|
|
550
|
-
filePath,
|
|
551
|
-
diff: details.diff ?? "",
|
|
552
|
-
diagnosticText,
|
|
553
|
-
}),
|
|
554
|
-
},
|
|
555
|
-
};
|
|
488
|
+
return { content: [{ type: "text" as const, text }], details };
|
|
556
489
|
},
|
|
557
490
|
});
|
|
558
491
|
|
|
@@ -583,65 +516,54 @@ export function registerFileTools(
|
|
|
583
516
|
absolutePath: filePath,
|
|
584
517
|
change: { oldText: "", newText: params.content },
|
|
585
518
|
});
|
|
586
|
-
const [message, details] = await withFileMutationQueue<
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
}
|
|
599
|
-
} catch (error) {
|
|
600
|
-
if (!(error instanceof Error) || !("code" in error) || error.code !== "ENOENT") {
|
|
601
|
-
throw error;
|
|
602
|
-
}
|
|
519
|
+
const [message, details, diagnosticText] = await withFileMutationQueue<
|
|
520
|
+
[string, FileToolDetails, string]
|
|
521
|
+
>(filePath, async () => {
|
|
522
|
+
let original: string | undefined;
|
|
523
|
+
let key: string | undefined;
|
|
524
|
+
try {
|
|
525
|
+
const value = await stat(filePath);
|
|
526
|
+
if (value.isFile()) {
|
|
527
|
+
const content = await readFile(filePath);
|
|
528
|
+
key = await readStateKey(filePath);
|
|
529
|
+
requireCurrentRead(state, key, filePath, content);
|
|
530
|
+
original = content.toString("utf8");
|
|
603
531
|
}
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
532
|
+
} catch (error) {
|
|
533
|
+
if (!(error instanceof Error) || !("code" in error) || error.code !== "ENOENT") {
|
|
534
|
+
throw error;
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
throwIfAborted(signal);
|
|
538
|
+
await mkdir(dirname(filePath), { recursive: true });
|
|
539
|
+
await writeFile(filePath, params.content, "utf8");
|
|
540
|
+
const snapshot = snapshotOf(params.content);
|
|
541
|
+
// 新建文件:writeFile 之后 realpath 才能解析;覆盖写则复用上面的 key
|
|
542
|
+
const resolvedKey = key ?? (await readStateKey(filePath));
|
|
543
|
+
state.reads.set(resolvedKey, snapshot);
|
|
544
|
+
const diff = generateDiffString(original ?? "", params.content);
|
|
545
|
+
const text =
|
|
546
|
+
original === undefined
|
|
547
|
+
? `File created successfully at: ${filePath}`
|
|
548
|
+
: `The file ${filePath} has been updated successfully.`;
|
|
549
|
+
throwIfAborted(signal);
|
|
550
|
+
const diagnosticText = await service.lspDiagnosticsForFile(filePath, ctx.cwd);
|
|
551
|
+
return [
|
|
552
|
+
text,
|
|
553
|
+
{
|
|
554
|
+
diff: diff.diff,
|
|
555
|
+
patch: generateUnifiedPatch(filePath, original ?? "", params.content),
|
|
556
|
+
firstChangedLine: diff.firstChangedLine,
|
|
557
|
+
reads: { [resolvedKey]: snapshot },
|
|
558
|
+
},
|
|
559
|
+
diagnosticText,
|
|
560
|
+
];
|
|
561
|
+
});
|
|
627
562
|
|
|
628
|
-
throwIfAborted(signal);
|
|
629
|
-
// 写后等待文档诊断,ERROR 级错误追加到输出让模型可见
|
|
630
|
-
const diagnosticText = await service.lspDiagnosticsForFile(filePath, ctx.cwd);
|
|
631
563
|
const text = diagnosticText
|
|
632
564
|
? `${message}\n\nLSP errors detected in this file, please fix:\n${diagnosticText}`
|
|
633
565
|
: message;
|
|
634
|
-
return {
|
|
635
|
-
content: [{ type: "text" as const, text }],
|
|
636
|
-
details: {
|
|
637
|
-
...details,
|
|
638
|
-
pendant: buildEditPendant({
|
|
639
|
-
filePath,
|
|
640
|
-
diff: details.diff ?? "",
|
|
641
|
-
diagnosticText,
|
|
642
|
-
}),
|
|
643
|
-
},
|
|
644
|
-
};
|
|
566
|
+
return { content: [{ type: "text" as const, text }], details };
|
|
645
567
|
},
|
|
646
568
|
});
|
|
647
569
|
}
|
|
@@ -685,6 +607,7 @@ export default function claudeCodeFileTools(pi: ExtensionAPI): void {
|
|
|
685
607
|
// 若文件在此期间被外部修改,Edit/Write 时的指纹对比仍会要求重新 Read,
|
|
686
608
|
// 防呆语义不因重建而弱化。
|
|
687
609
|
pi.on("session_start", (_event, ctx) => {
|
|
610
|
+
service.setUi(ctx.ui);
|
|
688
611
|
restoreFileReads(state, ctx.sessionManager);
|
|
689
612
|
});
|
|
690
613
|
|
|
@@ -692,6 +615,7 @@ export default function claudeCodeFileTools(pi: ExtensionAPI): void {
|
|
|
692
615
|
// session_start,扩展实例也不重建。这里同样重放当前分支,丢弃被抛弃分支
|
|
693
616
|
// 的记账,避免 state 与当前分支脱节。
|
|
694
617
|
pi.on("session_tree", (_event, ctx) => {
|
|
618
|
+
service.setUi(ctx.ui);
|
|
695
619
|
restoreFileReads(state, ctx.sessionManager);
|
|
696
620
|
});
|
|
697
621
|
|
package/src/lib/lsp/lsp.ts
CHANGED
|
@@ -16,7 +16,7 @@ import { readFile } from "node:fs/promises";
|
|
|
16
16
|
import { homedir } from "node:os";
|
|
17
17
|
import { extname, join, normalize, sep } from "node:path";
|
|
18
18
|
|
|
19
|
-
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
19
|
+
import type { ExtensionAPI, ExtensionUIContext } from "@earendil-works/pi-coding-agent";
|
|
20
20
|
import { type Static, Type } from "typebox";
|
|
21
21
|
import { Value } from "typebox/value";
|
|
22
22
|
|
|
@@ -137,6 +137,8 @@ export interface LspService {
|
|
|
137
137
|
diagnostics(): Promise<Record<string, Diagnostic[]>>;
|
|
138
138
|
lspDiagnosticsForFile(file: string, cwd: string): Promise<string>;
|
|
139
139
|
shutdownAll(): Promise<void>;
|
|
140
|
+
/** 绑定当前会话的 UI 上下文,LSP 服务器启动失败时用它发错误通知(传 undefined 解绑)。 */
|
|
141
|
+
setUi(ui?: Pick<ExtensionUIContext, "notify">): void;
|
|
140
142
|
}
|
|
141
143
|
|
|
142
144
|
/** 文件必须在工作目录内才启用 LSP(对齐 opencode 的 containsPath)。 */
|
|
@@ -161,6 +163,7 @@ export function createLspService(
|
|
|
161
163
|
broken: new Set(),
|
|
162
164
|
spawning: new Map(),
|
|
163
165
|
};
|
|
166
|
+
let ui: Pick<ExtensionUIContext, "notify"> | undefined;
|
|
164
167
|
|
|
165
168
|
async function getClients(file: string, cwd: string): Promise<LspClient[]> {
|
|
166
169
|
if (!containsPath(file, cwd)) return [];
|
|
@@ -195,6 +198,10 @@ export function createLspService(
|
|
|
195
198
|
const handle = await adapter.spawn(root, cwd);
|
|
196
199
|
if (!handle) {
|
|
197
200
|
state.broken.add(key);
|
|
201
|
+
ui?.notify(
|
|
202
|
+
`LSP server "${adapter.id}" is not available for ${root} (binary not found)`,
|
|
203
|
+
"error",
|
|
204
|
+
);
|
|
198
205
|
return;
|
|
199
206
|
}
|
|
200
207
|
const client = await create({
|
|
@@ -214,8 +221,14 @@ export function createLspService(
|
|
|
214
221
|
}
|
|
215
222
|
state.clients.push(client);
|
|
216
223
|
return client;
|
|
217
|
-
} catch {
|
|
224
|
+
} catch (error) {
|
|
218
225
|
state.broken.add(key);
|
|
226
|
+
ui?.notify(
|
|
227
|
+
`LSP server "${adapter.id}" failed to start for ${root}: ${
|
|
228
|
+
error instanceof Error ? error.message : String(error)
|
|
229
|
+
}`,
|
|
230
|
+
"error",
|
|
231
|
+
);
|
|
219
232
|
return;
|
|
220
233
|
}
|
|
221
234
|
})();
|
|
@@ -284,7 +297,15 @@ export function createLspService(
|
|
|
284
297
|
state.broken.clear();
|
|
285
298
|
}
|
|
286
299
|
|
|
287
|
-
return {
|
|
300
|
+
return {
|
|
301
|
+
touchFile,
|
|
302
|
+
diagnostics,
|
|
303
|
+
lspDiagnosticsForFile,
|
|
304
|
+
shutdownAll,
|
|
305
|
+
setUi(nextUi) {
|
|
306
|
+
ui = nextUi;
|
|
307
|
+
},
|
|
308
|
+
};
|
|
288
309
|
}
|
|
289
310
|
|
|
290
311
|
/** 注册进程级生命周期:session_shutdown 时清理全部服务器进程。 */
|
package/src/opencode/files.ts
CHANGED
|
@@ -506,79 +506,84 @@ function registerEditTool(pi: ExtensionAPI, service: LspService): void {
|
|
|
506
506
|
if (signal?.aborted) throw new Error("Operation aborted");
|
|
507
507
|
};
|
|
508
508
|
|
|
509
|
-
const [message, details] = await withFileMutationQueue(
|
|
510
|
-
|
|
509
|
+
const [message, details, diagnosticText] = await withFileMutationQueue(
|
|
510
|
+
absolutePath,
|
|
511
|
+
async () => {
|
|
512
|
+
throwIfAborted();
|
|
511
513
|
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
514
|
+
// opencode: 前置校验,先于空 oldString 分支
|
|
515
|
+
if (oldString === newString) {
|
|
516
|
+
throw new Error("No changes to apply: oldString and newString are identical.");
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// opencode: 空 oldString + 文件不存在 → 创建新文件;文件存在 → 报错
|
|
520
|
+
if (oldString === "") {
|
|
521
|
+
let exists = true;
|
|
522
|
+
try {
|
|
523
|
+
await access(absolutePath, constants.F_OK);
|
|
524
|
+
} catch {
|
|
525
|
+
exists = false;
|
|
526
|
+
}
|
|
527
|
+
throwIfAborted();
|
|
528
|
+
if (exists) {
|
|
529
|
+
throw new Error(
|
|
530
|
+
"oldString cannot be empty when editing an existing file. Provide the exact text to replace, or use write for an intentional full-file replacement.",
|
|
531
|
+
);
|
|
532
|
+
}
|
|
533
|
+
// opencode: writeWithDirs 自动创建父目录;newString 开头的 BOM 原样保留
|
|
534
|
+
await mkdir(dirname(absolutePath), { recursive: true });
|
|
535
|
+
throwIfAborted();
|
|
536
|
+
await writeFile(absolutePath, newString, "utf8");
|
|
537
|
+
throwIfAborted();
|
|
538
|
+
const diagnosticText = await service.lspDiagnosticsForFile(absolutePath, ctx.cwd);
|
|
539
|
+
return [
|
|
540
|
+
"Edit applied successfully.",
|
|
541
|
+
{ diff: "", patch: "", firstChangedLine: 0 },
|
|
542
|
+
diagnosticText,
|
|
543
|
+
] as const;
|
|
544
|
+
}
|
|
516
545
|
|
|
517
|
-
// opencode: 空 oldString + 文件不存在 → 创建新文件;文件存在 → 报错
|
|
518
|
-
if (oldString === "") {
|
|
519
|
-
let exists = true;
|
|
520
546
|
try {
|
|
521
|
-
await access(absolutePath, constants.
|
|
522
|
-
} catch {
|
|
523
|
-
|
|
547
|
+
await access(absolutePath, constants.R_OK | constants.W_OK);
|
|
548
|
+
} catch (error: unknown) {
|
|
549
|
+
throwIfAborted();
|
|
550
|
+
const msg =
|
|
551
|
+
error instanceof Error && "code" in error && typeof error.code === "string"
|
|
552
|
+
? `Error code: ${error.code}`
|
|
553
|
+
: String(error);
|
|
554
|
+
throw new Error(`Could not edit file: ${filePath}. ${msg}.`, { cause: error });
|
|
524
555
|
}
|
|
525
556
|
throwIfAborted();
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
);
|
|
530
|
-
}
|
|
531
|
-
// opencode: writeWithDirs 自动创建父目录;newString 开头的 BOM 原样保留
|
|
532
|
-
await mkdir(dirname(absolutePath), { recursive: true });
|
|
557
|
+
|
|
558
|
+
const buffer = await readFile(absolutePath);
|
|
559
|
+
const rawContent = buffer.toString("utf8");
|
|
533
560
|
throwIfAborted();
|
|
534
|
-
|
|
561
|
+
|
|
562
|
+
// Strip BOM then normalize line endings to LF.
|
|
563
|
+
// The opencode replacers split on \n and expect only LF.
|
|
564
|
+
const { bom, text: content } = stripBom(rawContent);
|
|
565
|
+
const originalEnding = detectLineEnding(content);
|
|
566
|
+
const normalizedContent = normalizeToLF(content);
|
|
567
|
+
|
|
568
|
+
const newContent = replace(normalizedContent, oldString, newString, replaceAll);
|
|
535
569
|
throwIfAborted();
|
|
536
|
-
return [
|
|
537
|
-
"Edit applied successfully.",
|
|
538
|
-
{ diff: "", patch: "", firstChangedLine: 0 },
|
|
539
|
-
] as const;
|
|
540
|
-
}
|
|
541
570
|
|
|
542
|
-
|
|
543
|
-
await
|
|
544
|
-
} catch (error: unknown) {
|
|
571
|
+
const finalContent = bom + restoreLineEndings(newContent, originalEnding);
|
|
572
|
+
await writeFile(absolutePath, finalContent, "utf8");
|
|
545
573
|
throwIfAborted();
|
|
546
|
-
const msg =
|
|
547
|
-
error instanceof Error && "code" in error && typeof error.code === "string"
|
|
548
|
-
? `Error code: ${error.code}`
|
|
549
|
-
: String(error);
|
|
550
|
-
throw new Error(`Could not edit file: ${filePath}. ${msg}.`, { cause: error });
|
|
551
|
-
}
|
|
552
|
-
throwIfAborted();
|
|
553
574
|
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
throwIfAborted();
|
|
566
|
-
|
|
567
|
-
const finalContent = bom + restoreLineEndings(newContent, originalEnding);
|
|
568
|
-
await writeFile(absolutePath, finalContent, "utf8");
|
|
569
|
-
throwIfAborted();
|
|
570
|
-
|
|
571
|
-
const diffResult = generateDiffString(normalizedContent, newContent);
|
|
572
|
-
const patch = generateUnifiedPatch(filePath, normalizedContent, newContent);
|
|
573
|
-
return [
|
|
574
|
-
"Edit applied successfully.",
|
|
575
|
-
{ diff: diffResult.diff, patch, firstChangedLine: diffResult.firstChangedLine },
|
|
576
|
-
] as const;
|
|
577
|
-
});
|
|
575
|
+
const diffResult = generateDiffString(normalizedContent, newContent);
|
|
576
|
+
const patch = generateUnifiedPatch(filePath, normalizedContent, newContent);
|
|
577
|
+
throwIfAborted();
|
|
578
|
+
const diagnosticText = await service.lspDiagnosticsForFile(absolutePath, ctx.cwd);
|
|
579
|
+
return [
|
|
580
|
+
"Edit applied successfully.",
|
|
581
|
+
{ diff: diffResult.diff, patch, firstChangedLine: diffResult.firstChangedLine },
|
|
582
|
+
diagnosticText,
|
|
583
|
+
] as const;
|
|
584
|
+
},
|
|
585
|
+
);
|
|
578
586
|
|
|
579
|
-
throwIfAborted();
|
|
580
|
-
// opencode: 写后等待文档诊断,ERROR 级错误追加到输出让模型可见
|
|
581
|
-
const diagnosticText = await service.lspDiagnosticsForFile(absolutePath, ctx.cwd);
|
|
582
587
|
const text = diagnosticText
|
|
583
588
|
? `${message}\n\nLSP errors detected in this file, please fix:\n${diagnosticText}`
|
|
584
589
|
: message;
|
|
@@ -639,38 +644,39 @@ function registerWriteTool(pi: ExtensionAPI, service: LspService): void {
|
|
|
639
644
|
if (signal?.aborted) throw new Error("Operation aborted");
|
|
640
645
|
};
|
|
641
646
|
|
|
642
|
-
const [message, details] = await withFileMutationQueue(
|
|
643
|
-
|
|
647
|
+
const [message, details, diagnosticText] = await withFileMutationQueue(
|
|
648
|
+
absolutePath,
|
|
649
|
+
async () => {
|
|
650
|
+
throwIfAborted();
|
|
644
651
|
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
try {
|
|
649
|
-
const fh = await open(absolutePath, "r");
|
|
652
|
+
// opencode: desiredBom = source.bom || next.bom —— 保留原文件 BOM,
|
|
653
|
+
// 否则用新内容自带的 BOM
|
|
654
|
+
let existing: Buffer | undefined;
|
|
650
655
|
try {
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
+
const fh = await open(absolutePath, "r");
|
|
657
|
+
try {
|
|
658
|
+
existing = Buffer.alloc(3);
|
|
659
|
+
const { bytesRead } = await fh.read(existing, 0, 3, 0);
|
|
660
|
+
if (bytesRead < 3) existing = undefined;
|
|
661
|
+
} finally {
|
|
662
|
+
await fh.close();
|
|
663
|
+
}
|
|
664
|
+
} catch {
|
|
665
|
+
// 文件不存在:无旧 BOM
|
|
656
666
|
}
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
}
|
|
660
|
-
throwIfAborted();
|
|
661
|
-
const { bom: desiredBom, text: nextText } = resolveBom(existing, content);
|
|
667
|
+
throwIfAborted();
|
|
668
|
+
const { bom: desiredBom, text: nextText } = resolveBom(existing, content);
|
|
662
669
|
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
670
|
+
await mkdir(dir, { recursive: true });
|
|
671
|
+
throwIfAborted();
|
|
672
|
+
await writeFile(absolutePath, desiredBom + nextText, "utf8");
|
|
673
|
+
throwIfAborted();
|
|
674
|
+
const diagnosticText = await service.lspDiagnosticsForFile(absolutePath, ctx.cwd);
|
|
667
675
|
|
|
668
|
-
|
|
669
|
-
|
|
676
|
+
return ["Wrote file successfully.", undefined, diagnosticText] as const;
|
|
677
|
+
},
|
|
678
|
+
);
|
|
670
679
|
|
|
671
|
-
throwIfAborted();
|
|
672
|
-
// opencode: 写后等待文档诊断,ERROR 级错误追加到输出让模型可见
|
|
673
|
-
const diagnosticText = await service.lspDiagnosticsForFile(absolutePath, ctx.cwd);
|
|
674
680
|
const text = diagnosticText
|
|
675
681
|
? `${message}\n\nLSP errors detected in this file, please fix:\n${diagnosticText}`
|
|
676
682
|
: message;
|