@trim21/personal-pi-extensions 0.0.407 → 0.0.408
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/aft/tools.ts +1 -1
- package/src/claude-code/files.ts +212 -206
- package/src/lib/lsp/diagnostic.ts +24 -8
- package/src/lib/lsp/lsp.ts +8 -7
- package/src/lib/path.ts +28 -8
- package/src/opencode/files.ts +39 -34
package/package.json
CHANGED
package/src/aft/tools.ts
CHANGED
|
@@ -157,7 +157,7 @@ export function registerOutlineTool(pi: ExtensionAPI, ctx: AftToolContext): void
|
|
|
157
157
|
});
|
|
158
158
|
}
|
|
159
159
|
|
|
160
|
-
/** 构建 aft_outline pendant 的 subtitle:`target="…"
|
|
160
|
+
/** 构建 aft_outline pendant 的 subtitle:`target="…"`(路径过长时显示上一级目录加文件名)。 */
|
|
161
161
|
export function buildOutlineSubtitle(cwd: string, target: string): string {
|
|
162
162
|
return `target="${formatSubtitlePath(cwd, resolvePathArg(cwd, target))}"`;
|
|
163
163
|
}
|
package/src/claude-code/files.ts
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
} from "@earendil-works/pi-coding-agent";
|
|
15
15
|
import { Type } from "typebox";
|
|
16
16
|
|
|
17
|
+
import { appendLspDiagnosticText } from "../lib/lsp/diagnostic.js";
|
|
17
18
|
import { type LspService, registerLsp } from "../lib/lsp/lsp.js";
|
|
18
19
|
import { formatSubtitlePath } from "../lib/path.js";
|
|
19
20
|
import { guardWriteAccess } from "../lib/write-guard.js";
|
|
@@ -394,173 +395,177 @@ export function registerFileTools(
|
|
|
394
395
|
replaceAll: params.replace_all,
|
|
395
396
|
},
|
|
396
397
|
});
|
|
397
|
-
const [message, details, diagnosticText, errorCount] =
|
|
398
|
-
[string, FileToolDetails, string, number]
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
398
|
+
const [message, details, diagnosticText, errorCount, warningCount] =
|
|
399
|
+
await withFileMutationQueue<[string, FileToolDetails, string, number, number]>(
|
|
400
|
+
filePath,
|
|
401
|
+
async () => {
|
|
402
|
+
const oldString = params.old_string;
|
|
403
|
+
const newString = params.new_string;
|
|
404
|
+
if (oldString === newString) {
|
|
405
|
+
throw new Error(
|
|
406
|
+
"No changes to make: old_string and new_string are exactly the same.",
|
|
407
|
+
);
|
|
408
|
+
}
|
|
409
|
+
// 空 old_string:创建新文件或填充空文件(不需要先 Read,对齐 Claude Code)
|
|
410
|
+
if (oldString === "") {
|
|
411
|
+
let exists = true;
|
|
412
|
+
try {
|
|
413
|
+
const value = await stat(filePath);
|
|
414
|
+
if (value.isFile()) {
|
|
415
|
+
const content = await readFile(filePath, "utf8");
|
|
416
|
+
if (content.trim() !== "") {
|
|
417
|
+
throw new Error("Cannot create new file - file already exists.");
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
} catch (error) {
|
|
421
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT") {
|
|
422
|
+
exists = false;
|
|
423
|
+
} else {
|
|
424
|
+
throw error;
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
if (!exists) await mkdir(dirname(filePath), { recursive: true });
|
|
428
|
+
await writeFile(filePath, newString, "utf8");
|
|
429
|
+
const snapshot = snapshotOf(newString);
|
|
430
|
+
const key = await readStateKey(filePath);
|
|
431
|
+
state.reads.set(key, snapshot);
|
|
432
|
+
const diff = generateDiffString("", convertLeadingTabsToSpaces(newString));
|
|
433
|
+
throwIfAborted(signal);
|
|
434
|
+
const {
|
|
435
|
+
text: diagnosticText,
|
|
436
|
+
errorCount,
|
|
437
|
+
warningCount,
|
|
438
|
+
} = await service.lspDiagnosticsForFile(filePath, ctx.cwd, {
|
|
439
|
+
notify: (message, level) => ctx.ui.notify(message, level),
|
|
440
|
+
signal,
|
|
441
|
+
});
|
|
442
|
+
return [
|
|
443
|
+
`The file ${filePath} has been updated successfully.`,
|
|
444
|
+
{
|
|
445
|
+
diff: diff.diff,
|
|
446
|
+
patch: generateUnifiedPatch(filePath, "", convertLeadingTabsToSpaces(newString)),
|
|
447
|
+
firstChangedLine: diff.firstChangedLine,
|
|
448
|
+
reads: { [key]: snapshot },
|
|
449
|
+
},
|
|
450
|
+
diagnosticText,
|
|
451
|
+
errorCount,
|
|
452
|
+
warningCount,
|
|
453
|
+
];
|
|
454
|
+
}
|
|
455
|
+
const replaceAll = params.replace_all ?? false;
|
|
456
|
+
// 防止 OOM 的大文件检查(对齐 Claude Code)
|
|
457
|
+
try {
|
|
458
|
+
const { size } = await stat(filePath);
|
|
459
|
+
if (size > MAX_EDIT_FILE_SIZE) {
|
|
460
|
+
throw new Error(
|
|
461
|
+
`File is too large to edit (${formatFileSize(size)}). Maximum editable file size is ${formatFileSize(MAX_EDIT_FILE_SIZE)}.`,
|
|
462
|
+
);
|
|
463
|
+
}
|
|
464
|
+
} catch (error) {
|
|
465
|
+
if (!(error instanceof Error) || !("code" in error) || error.code !== "ENOENT") {
|
|
466
|
+
throw error;
|
|
414
467
|
}
|
|
415
468
|
}
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
}
|
|
469
|
+
let content: Buffer;
|
|
470
|
+
try {
|
|
471
|
+
content = await readFile(filePath);
|
|
472
|
+
} catch (error) {
|
|
473
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT") {
|
|
474
|
+
const suggestion = await didYouMean(filePath, ctx.cwd);
|
|
475
|
+
throw new Error(
|
|
476
|
+
`File does not exist. Note: your current working directory is ${ctx.cwd}.${suggestion ? ` Did you mean ${suggestion}?` : ""}`,
|
|
477
|
+
{ cause: error },
|
|
478
|
+
);
|
|
479
|
+
}
|
|
420
480
|
throw error;
|
|
421
481
|
}
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
{
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
)
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
const
|
|
470
|
-
throw new Error(
|
|
471
|
-
`File does not exist. Note: your current working directory is ${ctx.cwd}.${suggestion ? ` Did you mean ${suggestion}?` : ""}`,
|
|
472
|
-
{ cause: error },
|
|
473
|
-
);
|
|
474
|
-
}
|
|
475
|
-
throw error;
|
|
476
|
-
}
|
|
477
|
-
if (extname(filePath).toLowerCase() === ".ipynb") {
|
|
478
|
-
throw new Error(
|
|
479
|
-
"File is a Jupyter Notebook. Use the NotebookEditTool to edit this file.",
|
|
480
|
-
);
|
|
481
|
-
}
|
|
482
|
-
const key = await readStateKey(filePath);
|
|
483
|
-
requireCurrentRead(state, key, filePath, content);
|
|
484
|
-
await access(filePath, constants.R_OK | constants.W_OK);
|
|
485
|
-
throwIfAborted(signal);
|
|
486
|
-
const original = content.toString("utf8");
|
|
487
|
-
|
|
488
|
-
// CRLF 规范化后匹配(old_string 不需要带 \r),写回时恢复原行尾
|
|
489
|
-
const crlfCount = (original.match(/\r\n/g) ?? []).length;
|
|
490
|
-
const lfCount = (original.match(/(?<!\r)\n/g) ?? []).length;
|
|
491
|
-
const lineEnding = crlfCount > lfCount ? "\r\n" : "\n";
|
|
492
|
-
const normalized = original.replaceAll("\r\n", "\n");
|
|
493
|
-
const actualOldString = findActualString(normalized, oldString) ?? oldString;
|
|
494
|
-
const matches = normalized.split(actualOldString).length - 1;
|
|
495
|
-
if (matches === 0) {
|
|
496
|
-
throw new Error(`String to replace not found in file.\nString: ${oldString}`);
|
|
497
|
-
}
|
|
498
|
-
if (!replaceAll && matches > 1) {
|
|
499
|
-
throw new Error(
|
|
500
|
-
`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}`,
|
|
501
|
-
);
|
|
502
|
-
}
|
|
503
|
-
const actualNewString = preserveQuoteStyle(oldString, actualOldString, newString);
|
|
504
|
-
// 删除场景(new_string 为空):old_string 不以换行结尾且文件里是
|
|
505
|
-
// "old_string\n" 时连换行一起删,避免留下空行(对齐 Claude Code
|
|
506
|
-
// applyEditToFile 的 stripTrailingNewline 语义)
|
|
507
|
-
let searchString = actualOldString;
|
|
508
|
-
if (
|
|
509
|
-
actualNewString === "" &&
|
|
510
|
-
!actualOldString.endsWith("\n") &&
|
|
511
|
-
normalized.includes(actualOldString + "\n")
|
|
512
|
-
) {
|
|
513
|
-
searchString = actualOldString + "\n";
|
|
514
|
-
}
|
|
515
|
-
// split/join 与函数替换:replacement 含 $ 时不会触发 $& 等特殊语义
|
|
516
|
-
const updated = replaceAll
|
|
517
|
-
? normalized.split(searchString).join(actualNewString)
|
|
518
|
-
: normalized.replace(searchString, () => 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
|
-
state.reads.set(key, snapshot);
|
|
523
|
-
// patch/diff 仅供显示:前导 tab 转空格,避免 UI 渲染错位(对齐 Claude Code)
|
|
524
|
-
const diff = generateDiffString(
|
|
525
|
-
convertLeadingTabsToSpaces(original),
|
|
526
|
-
convertLeadingTabsToSpaces(restored),
|
|
527
|
-
);
|
|
528
|
-
const text = replaceAll
|
|
529
|
-
? `The file ${filePath} has been updated. All occurrences were successfully replaced.`
|
|
530
|
-
: `The file ${filePath} has been updated successfully.`;
|
|
531
|
-
throwIfAborted(signal);
|
|
532
|
-
const { text: diagnosticText, errorCount } = await service.lspDiagnosticsForFile(
|
|
533
|
-
filePath,
|
|
534
|
-
ctx.cwd,
|
|
535
|
-
{
|
|
536
|
-
notify: (message, level) => ctx.ui.notify(message, level),
|
|
537
|
-
},
|
|
538
|
-
);
|
|
539
|
-
return [
|
|
540
|
-
text,
|
|
541
|
-
{
|
|
542
|
-
diff: diff.diff,
|
|
543
|
-
patch: generateUnifiedPatch(
|
|
544
|
-
filePath,
|
|
482
|
+
if (extname(filePath).toLowerCase() === ".ipynb") {
|
|
483
|
+
throw new Error(
|
|
484
|
+
"File is a Jupyter Notebook. Use the NotebookEditTool to edit this file.",
|
|
485
|
+
);
|
|
486
|
+
}
|
|
487
|
+
const key = await readStateKey(filePath);
|
|
488
|
+
requireCurrentRead(state, key, filePath, content);
|
|
489
|
+
await access(filePath, constants.R_OK | constants.W_OK);
|
|
490
|
+
throwIfAborted(signal);
|
|
491
|
+
const original = content.toString("utf8");
|
|
492
|
+
|
|
493
|
+
// CRLF 规范化后匹配(old_string 不需要带 \r),写回时恢复原行尾
|
|
494
|
+
const crlfCount = (original.match(/\r\n/g) ?? []).length;
|
|
495
|
+
const lfCount = (original.match(/(?<!\r)\n/g) ?? []).length;
|
|
496
|
+
const lineEnding = crlfCount > lfCount ? "\r\n" : "\n";
|
|
497
|
+
const normalized = original.replaceAll("\r\n", "\n");
|
|
498
|
+
const actualOldString = findActualString(normalized, oldString) ?? oldString;
|
|
499
|
+
const matches = normalized.split(actualOldString).length - 1;
|
|
500
|
+
if (matches === 0) {
|
|
501
|
+
throw new Error(`String to replace not found in file.\nString: ${oldString}`);
|
|
502
|
+
}
|
|
503
|
+
if (!replaceAll && matches > 1) {
|
|
504
|
+
throw new Error(
|
|
505
|
+
`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}`,
|
|
506
|
+
);
|
|
507
|
+
}
|
|
508
|
+
const actualNewString = preserveQuoteStyle(oldString, actualOldString, newString);
|
|
509
|
+
// 删除场景(new_string 为空):old_string 不以换行结尾且文件里是
|
|
510
|
+
// "old_string\n" 时连换行一起删,避免留下空行(对齐 Claude Code
|
|
511
|
+
// applyEditToFile 的 stripTrailingNewline 语义)
|
|
512
|
+
let searchString = actualOldString;
|
|
513
|
+
if (
|
|
514
|
+
actualNewString === "" &&
|
|
515
|
+
!actualOldString.endsWith("\n") &&
|
|
516
|
+
normalized.includes(actualOldString + "\n")
|
|
517
|
+
) {
|
|
518
|
+
searchString = actualOldString + "\n";
|
|
519
|
+
}
|
|
520
|
+
// split/join 与函数替换:replacement 含 $ 时不会触发 $& 等特殊语义
|
|
521
|
+
const updated = replaceAll
|
|
522
|
+
? normalized.split(searchString).join(actualNewString)
|
|
523
|
+
: normalized.replace(searchString, () => actualNewString);
|
|
524
|
+
const restored = lineEnding === "\r\n" ? updated.replaceAll("\n", "\r\n") : updated;
|
|
525
|
+
await writeFile(filePath, restored, "utf8");
|
|
526
|
+
const snapshot = snapshotOf(restored);
|
|
527
|
+
state.reads.set(key, snapshot);
|
|
528
|
+
// patch/diff 仅供显示:前导 tab 转空格,避免 UI 渲染错位(对齐 Claude Code)
|
|
529
|
+
const diff = generateDiffString(
|
|
545
530
|
convertLeadingTabsToSpaces(original),
|
|
546
531
|
convertLeadingTabsToSpaces(restored),
|
|
547
|
-
)
|
|
548
|
-
|
|
549
|
-
|
|
532
|
+
);
|
|
533
|
+
const text = replaceAll
|
|
534
|
+
? `The file ${filePath} has been updated. All occurrences were successfully replaced.`
|
|
535
|
+
: `The file ${filePath} has been updated successfully.`;
|
|
536
|
+
throwIfAborted(signal);
|
|
537
|
+
const {
|
|
538
|
+
text: diagnosticText,
|
|
539
|
+
errorCount,
|
|
540
|
+
warningCount,
|
|
541
|
+
} = await service.lspDiagnosticsForFile(filePath, ctx.cwd, {
|
|
542
|
+
notify: (message, level) => ctx.ui.notify(message, level),
|
|
543
|
+
});
|
|
544
|
+
return [
|
|
545
|
+
text,
|
|
546
|
+
{
|
|
547
|
+
diff: diff.diff,
|
|
548
|
+
patch: generateUnifiedPatch(
|
|
549
|
+
filePath,
|
|
550
|
+
convertLeadingTabsToSpaces(original),
|
|
551
|
+
convertLeadingTabsToSpaces(restored),
|
|
552
|
+
),
|
|
553
|
+
firstChangedLine: diff.firstChangedLine,
|
|
554
|
+
reads: { [key]: snapshot },
|
|
555
|
+
},
|
|
556
|
+
diagnosticText,
|
|
557
|
+
errorCount,
|
|
558
|
+
warningCount,
|
|
559
|
+
];
|
|
550
560
|
},
|
|
551
|
-
|
|
552
|
-
errorCount,
|
|
553
|
-
];
|
|
554
|
-
});
|
|
561
|
+
);
|
|
555
562
|
|
|
556
|
-
const text = diagnosticText
|
|
557
|
-
? `${message}\n\nLSP errors detected in this file, please fix:\n${diagnosticText}`
|
|
558
|
-
: message;
|
|
563
|
+
const text = appendLspDiagnosticText(message, diagnosticText, errorCount);
|
|
559
564
|
return {
|
|
560
565
|
content: [{ type: "text" as const, text }],
|
|
561
566
|
details: {
|
|
562
567
|
...details,
|
|
563
|
-
pendant: { subtitle: formatSubtitlePath(ctx.cwd, filePath, errorCount) },
|
|
568
|
+
pendant: { subtitle: formatSubtitlePath(ctx.cwd, filePath, errorCount, warningCount) },
|
|
564
569
|
},
|
|
565
570
|
};
|
|
566
571
|
},
|
|
@@ -593,65 +598,66 @@ export function registerFileTools(
|
|
|
593
598
|
absolutePath: filePath,
|
|
594
599
|
change: { oldText: "", newText: params.content },
|
|
595
600
|
});
|
|
596
|
-
const [message, details, diagnosticText, errorCount] =
|
|
597
|
-
[string, FileToolDetails, string, number]
|
|
598
|
-
>(filePath, async () => {
|
|
599
|
-
let original: string | undefined;
|
|
600
|
-
let key: string | undefined;
|
|
601
|
-
try {
|
|
602
|
-
const value = await stat(filePath);
|
|
603
|
-
if (value.isFile()) {
|
|
604
|
-
const content = await readFile(filePath);
|
|
605
|
-
key = await readStateKey(filePath);
|
|
606
|
-
requireCurrentRead(state, key, filePath, content);
|
|
607
|
-
original = content.toString("utf8");
|
|
608
|
-
}
|
|
609
|
-
} catch (error) {
|
|
610
|
-
if (!(error instanceof Error) || !("code" in error) || error.code !== "ENOENT") {
|
|
611
|
-
throw error;
|
|
612
|
-
}
|
|
613
|
-
}
|
|
614
|
-
throwIfAborted(signal);
|
|
615
|
-
await mkdir(dirname(filePath), { recursive: true });
|
|
616
|
-
await writeFile(filePath, params.content, "utf8");
|
|
617
|
-
const snapshot = snapshotOf(params.content);
|
|
618
|
-
// 新建文件:writeFile 之后 realpath 才能解析;覆盖写则复用上面的 key
|
|
619
|
-
const resolvedKey = key ?? (await readStateKey(filePath));
|
|
620
|
-
state.reads.set(resolvedKey, snapshot);
|
|
621
|
-
const diff = generateDiffString(original ?? "", params.content);
|
|
622
|
-
const text =
|
|
623
|
-
original === undefined
|
|
624
|
-
? `File created successfully at: ${filePath}`
|
|
625
|
-
: `The file ${filePath} has been updated successfully.`;
|
|
626
|
-
throwIfAborted(signal);
|
|
627
|
-
const { text: diagnosticText, errorCount } = await service.lspDiagnosticsForFile(
|
|
601
|
+
const [message, details, diagnosticText, errorCount, warningCount] =
|
|
602
|
+
await withFileMutationQueue<[string, FileToolDetails, string, number, number]>(
|
|
628
603
|
filePath,
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
604
|
+
async () => {
|
|
605
|
+
let original: string | undefined;
|
|
606
|
+
let key: string | undefined;
|
|
607
|
+
try {
|
|
608
|
+
const value = await stat(filePath);
|
|
609
|
+
if (value.isFile()) {
|
|
610
|
+
const content = await readFile(filePath);
|
|
611
|
+
key = await readStateKey(filePath);
|
|
612
|
+
requireCurrentRead(state, key, filePath, content);
|
|
613
|
+
original = content.toString("utf8");
|
|
614
|
+
}
|
|
615
|
+
} catch (error) {
|
|
616
|
+
if (!(error instanceof Error) || !("code" in error) || error.code !== "ENOENT") {
|
|
617
|
+
throw error;
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
throwIfAborted(signal);
|
|
621
|
+
await mkdir(dirname(filePath), { recursive: true });
|
|
622
|
+
await writeFile(filePath, params.content, "utf8");
|
|
623
|
+
const snapshot = snapshotOf(params.content);
|
|
624
|
+
// 新建文件:writeFile 之后 realpath 才能解析;覆盖写则复用上面的 key
|
|
625
|
+
const resolvedKey = key ?? (await readStateKey(filePath));
|
|
626
|
+
state.reads.set(resolvedKey, snapshot);
|
|
627
|
+
const diff = generateDiffString(original ?? "", params.content);
|
|
628
|
+
const text =
|
|
629
|
+
original === undefined
|
|
630
|
+
? `File created successfully at: ${filePath}`
|
|
631
|
+
: `The file ${filePath} has been updated successfully.`;
|
|
632
|
+
throwIfAborted(signal);
|
|
633
|
+
const {
|
|
634
|
+
text: diagnosticText,
|
|
635
|
+
errorCount,
|
|
636
|
+
warningCount,
|
|
637
|
+
} = await service.lspDiagnosticsForFile(filePath, ctx.cwd, {
|
|
638
|
+
notify: (message, level) => ctx.ui.notify(message, level),
|
|
639
|
+
});
|
|
640
|
+
return [
|
|
641
|
+
text,
|
|
642
|
+
{
|
|
643
|
+
diff: diff.diff,
|
|
644
|
+
patch: generateUnifiedPatch(filePath, original ?? "", params.content),
|
|
645
|
+
firstChangedLine: diff.firstChangedLine,
|
|
646
|
+
reads: { [resolvedKey]: snapshot },
|
|
647
|
+
},
|
|
648
|
+
diagnosticText,
|
|
649
|
+
errorCount,
|
|
650
|
+
warningCount,
|
|
651
|
+
];
|
|
632
652
|
},
|
|
633
653
|
);
|
|
634
|
-
return [
|
|
635
|
-
text,
|
|
636
|
-
{
|
|
637
|
-
diff: diff.diff,
|
|
638
|
-
patch: generateUnifiedPatch(filePath, original ?? "", params.content),
|
|
639
|
-
firstChangedLine: diff.firstChangedLine,
|
|
640
|
-
reads: { [resolvedKey]: snapshot },
|
|
641
|
-
},
|
|
642
|
-
diagnosticText,
|
|
643
|
-
errorCount,
|
|
644
|
-
];
|
|
645
|
-
});
|
|
646
654
|
|
|
647
|
-
const text = diagnosticText
|
|
648
|
-
? `${message}\n\nLSP errors detected in this file, please fix:\n${diagnosticText}`
|
|
649
|
-
: message;
|
|
655
|
+
const text = appendLspDiagnosticText(message, diagnosticText, errorCount);
|
|
650
656
|
return {
|
|
651
657
|
content: [{ type: "text" as const, text }],
|
|
652
658
|
details: {
|
|
653
659
|
...details,
|
|
654
|
-
pendant: { subtitle: formatSubtitlePath(ctx.cwd, filePath, errorCount) },
|
|
660
|
+
pendant: { subtitle: formatSubtitlePath(ctx.cwd, filePath, errorCount, warningCount) },
|
|
655
661
|
},
|
|
656
662
|
};
|
|
657
663
|
},
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
* 级诊断,每个文件最多 20 条。
|
|
2
|
+
* 诊断报告格式化:报告 ERROR 与 WARN,每个文件最多 20 条。
|
|
4
3
|
*/
|
|
5
4
|
|
|
6
5
|
import type { Diagnostic } from "./client.js";
|
|
@@ -14,8 +13,12 @@ const SEVERITY_LABELS: Record<number, string> = {
|
|
|
14
13
|
4: "HINT",
|
|
15
14
|
};
|
|
16
15
|
|
|
16
|
+
function severityOf(diagnostic: Diagnostic): number {
|
|
17
|
+
return diagnostic.severity ?? 1;
|
|
18
|
+
}
|
|
19
|
+
|
|
17
20
|
export function prettyDiagnostic(diagnostic: Diagnostic): string {
|
|
18
|
-
const severity = SEVERITY_LABELS[diagnostic
|
|
21
|
+
const severity = SEVERITY_LABELS[severityOf(diagnostic)] ?? "ERROR";
|
|
19
22
|
const line = diagnostic.range.start.line + 1;
|
|
20
23
|
const col = diagnostic.range.start.character + 1;
|
|
21
24
|
// 3.18 起 message 可能是 MarkupContent(客户端未声明 markupMessageSupport 时不会出现)
|
|
@@ -24,12 +27,25 @@ export function prettyDiagnostic(diagnostic: Diagnostic): string {
|
|
|
24
27
|
return `${severity} [${line}:${col}] ${message}`;
|
|
25
28
|
}
|
|
26
29
|
|
|
27
|
-
/** 返回空字符串表示没有 ERROR
|
|
30
|
+
/** 返回空字符串表示没有 ERROR / WARN。ERROR 在前,WARN 在后。 */
|
|
28
31
|
export function report(file: string, issues: Diagnostic[]): string {
|
|
29
|
-
const errors = issues.filter((item) => item
|
|
30
|
-
|
|
31
|
-
const
|
|
32
|
-
|
|
32
|
+
const errors = issues.filter((item) => severityOf(item) === 1);
|
|
33
|
+
const warnings = issues.filter((item) => item.severity === 2);
|
|
34
|
+
const relevant = [...errors, ...warnings];
|
|
35
|
+
if (relevant.length === 0) return "";
|
|
36
|
+
const limited = relevant.slice(0, MAX_PER_FILE);
|
|
37
|
+
const more = relevant.length - MAX_PER_FILE;
|
|
33
38
|
const suffix = more > 0 ? `\n... and ${more} more` : "";
|
|
34
39
|
return `<diagnostics file="${file}">\n${limited.map((d) => prettyDiagnostic(d)).join("\n")}${suffix}\n</diagnostics>`;
|
|
35
40
|
}
|
|
41
|
+
|
|
42
|
+
/** 把 LSP 诊断块接到工具成功文案后面;无诊断时原样返回。 */
|
|
43
|
+
export function appendLspDiagnosticText(
|
|
44
|
+
message: string,
|
|
45
|
+
diagnosticText: string,
|
|
46
|
+
errorCount: number,
|
|
47
|
+
): string {
|
|
48
|
+
if (!diagnosticText) return message;
|
|
49
|
+
const kind = errorCount > 0 ? "errors" : "warnings";
|
|
50
|
+
return `${message}\n\nLSP ${kind} detected in this file:\n${diagnosticText}`;
|
|
51
|
+
}
|
package/src/lib/lsp/lsp.ts
CHANGED
|
@@ -212,7 +212,7 @@ export interface LspService {
|
|
|
212
212
|
file: string,
|
|
213
213
|
cwd: string,
|
|
214
214
|
options?: LspRequestOptions,
|
|
215
|
-
): Promise<{ text: string; errorCount: number }>;
|
|
215
|
+
): Promise<{ text: string; errorCount: number; warningCount: number }>;
|
|
216
216
|
shutdownAll(): Promise<void>;
|
|
217
217
|
/** 注入 status 渲染回调;传入 undefined 表示不再渲染。 */
|
|
218
218
|
attachStatus(render: StatusRenderer | undefined): void;
|
|
@@ -408,21 +408,22 @@ export function createLspService(
|
|
|
408
408
|
}
|
|
409
409
|
|
|
410
410
|
/**
|
|
411
|
-
* edit/write 用:等待文档诊断并返回该文件的 ERROR 报告(text
|
|
412
|
-
*
|
|
411
|
+
* edit/write 用:等待文档诊断并返回该文件的 ERROR / WARN 报告(text 空串表示无此类诊断)
|
|
412
|
+
* 与数量。内部所有 LSP 失败都会被吞掉,不干扰写操作本身。
|
|
413
413
|
*/
|
|
414
414
|
async function lspDiagnosticsForFile(
|
|
415
415
|
file: string,
|
|
416
416
|
cwd: string,
|
|
417
417
|
options?: LspRequestOptions,
|
|
418
|
-
): Promise<{ text: string; errorCount: number }> {
|
|
419
|
-
if (options?.signal?.aborted) return { text: "", errorCount: 0 };
|
|
418
|
+
): Promise<{ text: string; errorCount: number; warningCount: number }> {
|
|
419
|
+
if (options?.signal?.aborted) return { text: "", errorCount: 0, warningCount: 0 };
|
|
420
420
|
await touchFile(file, cwd, "document", options);
|
|
421
421
|
const all = await diagnostics();
|
|
422
422
|
const normalized = normalize(file);
|
|
423
423
|
const issues = all[normalized] ?? [];
|
|
424
|
-
const errorCount = issues.filter((item) => item.severity === 1).length;
|
|
425
|
-
|
|
424
|
+
const errorCount = issues.filter((item) => (item.severity ?? 1) === 1).length;
|
|
425
|
+
const warningCount = issues.filter((item) => item.severity === 2).length;
|
|
426
|
+
return { text: report(normalized, issues), errorCount, warningCount };
|
|
426
427
|
}
|
|
427
428
|
|
|
428
429
|
/** 终止全部服务器进程(session_shutdown 时调用)。 */
|
package/src/lib/path.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
import { stat } from "node:fs/promises";
|
|
6
6
|
import { homedir } from "node:os";
|
|
7
|
-
import { basename, isAbsolute, join, relative, resolve } from "node:path";
|
|
7
|
+
import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
10
|
* Expand a leading `~` to the user's home directory.
|
|
@@ -60,13 +60,33 @@ export function formatDisplayPath(cwd: string, filePath: string): string {
|
|
|
60
60
|
return filePath;
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
-
/** subtitle
|
|
63
|
+
/** subtitle 中路径的最大显示长度,超过时退化为 `parent/basename`。 */
|
|
64
64
|
export const MAX_SUBTITLE_PATH_LENGTH = 20;
|
|
65
65
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
const
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
|
|
66
|
+
function shortenSubtitlePath(filePath: string, display: string): string {
|
|
67
|
+
if (display.length <= MAX_SUBTITLE_PATH_LENGTH) return display;
|
|
68
|
+
const name = basename(filePath);
|
|
69
|
+
const parentDir = dirname(filePath);
|
|
70
|
+
const parentName = basename(parentDir);
|
|
71
|
+
if (parentName === "" || parentName === "." || parentDir === parentName) return name;
|
|
72
|
+
return join(parentName, name);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function formatSubtitleCounts(errorCount?: number, warningCount?: number): string {
|
|
76
|
+
const parts: string[] = [];
|
|
77
|
+
if (errorCount) parts.push(`ⓧ ${errorCount}`);
|
|
78
|
+
if (warningCount) parts.push(`⚠ ${warningCount}`);
|
|
79
|
+
return parts.join(" ");
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** subtitle 用的显示路径:优先 `./…` / `~/…` / 绝对路径,过长时显示上一级目录加文件名。 */
|
|
83
|
+
export function formatSubtitlePath(
|
|
84
|
+
cwd: string,
|
|
85
|
+
filePath: string,
|
|
86
|
+
errorCount?: number,
|
|
87
|
+
warningCount?: number,
|
|
88
|
+
): string {
|
|
89
|
+
const pathText = shortenSubtitlePath(filePath, formatDisplayPath(cwd, filePath));
|
|
90
|
+
const counts = formatSubtitleCounts(errorCount, warningCount);
|
|
91
|
+
return counts ? `${pathText} (${counts})` : pathText;
|
|
72
92
|
}
|
package/src/opencode/files.ts
CHANGED
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
} from "@earendil-works/pi-coding-agent";
|
|
30
30
|
import { Type } from "typebox";
|
|
31
31
|
|
|
32
|
+
import { appendLspDiagnosticText } from "../lib/lsp/diagnostic.js";
|
|
32
33
|
import { type LspService, registerLsp } from "../lib/lsp/lsp.js";
|
|
33
34
|
import { formatSubtitlePath } from "../lib/path.js";
|
|
34
35
|
import { guardWriteAccess } from "../lib/write-guard.js";
|
|
@@ -492,9 +493,8 @@ function registerEditTool(pi: ExtensionAPI, service: LspService): void {
|
|
|
492
493
|
change: { oldText: oldString, newText: newString, replaceAll },
|
|
493
494
|
});
|
|
494
495
|
|
|
495
|
-
const [message, details, diagnosticText, errorCount] =
|
|
496
|
-
absolutePath,
|
|
497
|
-
async () => {
|
|
496
|
+
const [message, details, diagnosticText, errorCount, warningCount] =
|
|
497
|
+
await withFileMutationQueue(absolutePath, async () => {
|
|
498
498
|
signal?.throwIfAborted();
|
|
499
499
|
|
|
500
500
|
// opencode: 前置校验,先于空 oldString 分支
|
|
@@ -519,16 +519,17 @@ function registerEditTool(pi: ExtensionAPI, service: LspService): void {
|
|
|
519
519
|
await mkdir(dirname(absolutePath), { recursive: true });
|
|
520
520
|
signal?.throwIfAborted();
|
|
521
521
|
await writeFile(absolutePath, newString, "utf8");
|
|
522
|
-
const {
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
);
|
|
522
|
+
const {
|
|
523
|
+
text: diagnosticText,
|
|
524
|
+
errorCount,
|
|
525
|
+
warningCount,
|
|
526
|
+
} = await service.lspDiagnosticsForFile(absolutePath, ctx.cwd, { signal });
|
|
527
527
|
return [
|
|
528
528
|
"Edit applied successfully.",
|
|
529
529
|
{ diff: "", patch: "", firstChangedLine: 0 },
|
|
530
530
|
diagnosticText,
|
|
531
531
|
errorCount,
|
|
532
|
+
warningCount,
|
|
532
533
|
] as const;
|
|
533
534
|
}
|
|
534
535
|
|
|
@@ -560,28 +561,28 @@ function registerEditTool(pi: ExtensionAPI, service: LspService): void {
|
|
|
560
561
|
const diffNew = normalizeToLF(applied.contentNew);
|
|
561
562
|
const diffResult = generateDiffString(diffOld, diffNew);
|
|
562
563
|
const patch = generateUnifiedPatch(filePath, diffOld, diffNew);
|
|
563
|
-
const {
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
);
|
|
564
|
+
const {
|
|
565
|
+
text: diagnosticText,
|
|
566
|
+
errorCount,
|
|
567
|
+
warningCount,
|
|
568
|
+
} = await service.lspDiagnosticsForFile(absolutePath, ctx.cwd, { signal });
|
|
568
569
|
return [
|
|
569
570
|
"Edit applied successfully.",
|
|
570
571
|
{ diff: diffResult.diff, patch, firstChangedLine: diffResult.firstChangedLine },
|
|
571
572
|
diagnosticText,
|
|
572
573
|
errorCount,
|
|
574
|
+
warningCount,
|
|
573
575
|
] as const;
|
|
574
|
-
}
|
|
575
|
-
);
|
|
576
|
+
});
|
|
576
577
|
|
|
577
|
-
const text = diagnosticText
|
|
578
|
-
? `${message}\n\nLSP errors detected in this file, please fix:\n${diagnosticText}`
|
|
579
|
-
: message;
|
|
578
|
+
const text = appendLspDiagnosticText(message, diagnosticText, errorCount);
|
|
580
579
|
return {
|
|
581
580
|
content: [{ type: "text" as const, text }],
|
|
582
581
|
details: {
|
|
583
582
|
...details,
|
|
584
|
-
pendant: {
|
|
583
|
+
pendant: {
|
|
584
|
+
subtitle: formatSubtitlePath(ctx.cwd, absolutePath, errorCount, warningCount),
|
|
585
|
+
},
|
|
585
586
|
},
|
|
586
587
|
};
|
|
587
588
|
},
|
|
@@ -636,9 +637,8 @@ function registerWriteTool(pi: ExtensionAPI, service: LspService): void {
|
|
|
636
637
|
});
|
|
637
638
|
const dir = dirname(absolutePath);
|
|
638
639
|
|
|
639
|
-
const [message, details, diagnosticText, errorCount] =
|
|
640
|
-
absolutePath,
|
|
641
|
-
async () => {
|
|
640
|
+
const [message, details, diagnosticText, errorCount, warningCount] =
|
|
641
|
+
await withFileMutationQueue(absolutePath, async () => {
|
|
642
642
|
signal?.throwIfAborted();
|
|
643
643
|
|
|
644
644
|
// opencode: desiredBom = source.bom || next.bom —— 保留原文件 BOM,
|
|
@@ -661,24 +661,29 @@ function registerWriteTool(pi: ExtensionAPI, service: LspService): void {
|
|
|
661
661
|
await mkdir(dir, { recursive: true });
|
|
662
662
|
signal?.throwIfAborted();
|
|
663
663
|
await writeFile(absolutePath, desiredBom + nextText, "utf8");
|
|
664
|
-
const {
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
);
|
|
664
|
+
const {
|
|
665
|
+
text: diagnosticText,
|
|
666
|
+
errorCount,
|
|
667
|
+
warningCount,
|
|
668
|
+
} = await service.lspDiagnosticsForFile(absolutePath, ctx.cwd, { signal });
|
|
669
669
|
|
|
670
|
-
return [
|
|
671
|
-
|
|
672
|
-
|
|
670
|
+
return [
|
|
671
|
+
"Wrote file successfully.",
|
|
672
|
+
{},
|
|
673
|
+
diagnosticText,
|
|
674
|
+
errorCount,
|
|
675
|
+
warningCount,
|
|
676
|
+
] as const;
|
|
677
|
+
});
|
|
673
678
|
|
|
674
|
-
const text = diagnosticText
|
|
675
|
-
? `${message}\n\nLSP errors detected in this file, please fix:\n${diagnosticText}`
|
|
676
|
-
: message;
|
|
679
|
+
const text = appendLspDiagnosticText(message, diagnosticText, errorCount);
|
|
677
680
|
return {
|
|
678
681
|
content: [{ type: "text" as const, text }],
|
|
679
682
|
details: {
|
|
680
683
|
...details,
|
|
681
|
-
pendant: {
|
|
684
|
+
pendant: {
|
|
685
|
+
subtitle: formatSubtitlePath(ctx.cwd, absolutePath, errorCount, warningCount),
|
|
686
|
+
},
|
|
682
687
|
},
|
|
683
688
|
};
|
|
684
689
|
},
|