@trim21/personal-pi-extensions 0.0.407 → 0.0.409
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 +1 -1
- 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 +19 -18
- package/src/lib/path.ts +28 -8
- package/src/opencode/files.ts +39 -34
package/README.md
CHANGED
|
@@ -386,7 +386,7 @@ read/edit/write 工具内置 LSP 诊断(写文件后等待并报告 ERROR 级
|
|
|
386
386
|
|
|
387
387
|
内置默认服务器(typescript / pyright / ruff / clangd)始终存在;`servers` 以 key 为服务器 id 与默认合并——同 key 整体覆盖(整个配置替换默认)、新 key 新增、`"enabled": false` 移除(如 `"clangd": { "enabled": false }`)。executable 的发现逻辑(如 tsserver 路径、venv 里的 python)不内置,需要时用 `bin` / `args` / `settings` 自行表达。
|
|
388
388
|
|
|
389
|
-
旧的 `enabled`(白名单)/ `disabled`(排除)与全局超时字段(`initializeTimeoutMs`
|
|
389
|
+
旧的 `enabled`(白名单)/ `disabled`(排除)与全局超时字段(`initializeTimeoutMs` 等)继续可用。`enabled` 里的 id 必须是已注册服务器,否则视为配置错误;`disabled` 里未注册的 id 直接忽略。
|
|
390
390
|
|
|
391
391
|
---
|
|
392
392
|
|
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
|
@@ -7,9 +7,10 @@
|
|
|
7
7
|
* (本地覆盖全局):`servers` 按 id 合并(同名 id 整体覆盖、新增 id,全局
|
|
8
8
|
* 其余服务器保留),`enabled`/`disabled` 白名单与各超时参数继续生效;
|
|
9
9
|
* 没有内置默认服务器,所有服务器均须在配置里定义;
|
|
10
|
-
* 配置在每个工具的调用 cwd 下惰性读取;enabled
|
|
11
|
-
*
|
|
12
|
-
*
|
|
10
|
+
* 配置在每个工具的调用 cwd 下惰性读取;enabled 引用不存在的服务器 id
|
|
11
|
+
* 是配置错误:全局配置在扩展加载(createLspService)时抛错,本地配置在
|
|
12
|
+
* session 开始预加载时通知,工具调用时校验抛错兜底。disabled 中未注册
|
|
13
|
+
* 的 id 直接忽略;
|
|
13
14
|
* - client 按 (root, serverID) 缓存,并发 spawn 去重,启动失败记入 broken
|
|
14
15
|
* 集合(服务实例生命周期内不再重试);
|
|
15
16
|
* - 工具只与 touchFile / diagnostics / lspDiagnosticsForFile 三个方法打交道;通知回调按请求传入。
|
|
@@ -114,20 +115,19 @@ function readConfigFileSync(filePath: string): LspConfig {
|
|
|
114
115
|
}
|
|
115
116
|
|
|
116
117
|
/**
|
|
117
|
-
* enabled
|
|
118
|
-
*
|
|
118
|
+
* enabled 引用的 id 必须存在于实际生效的服务器集合(注入的 adapters 或配置
|
|
119
|
+
* servers),否则抛配置错误(避免白名单静默失效)。disabled 中未注册的 id
|
|
120
|
+
* 直接忽略。
|
|
119
121
|
*/
|
|
120
122
|
function validateConfig(config: LspConfig, adapters?: LspServerAdapter[]): void {
|
|
121
123
|
const available = new Set(
|
|
122
124
|
adapters ? adapters.map((adapter) => adapter.id) : Object.keys(config.servers ?? {}),
|
|
123
125
|
);
|
|
124
|
-
const unknown =
|
|
125
|
-
(id) => !available.has(id),
|
|
126
|
-
);
|
|
126
|
+
const unknown = (config.enabled ?? []).filter((id) => !available.has(id));
|
|
127
127
|
if (unknown.length > 0) {
|
|
128
128
|
const list = [...available].toSorted().join(", ") || "none";
|
|
129
129
|
throw new Error(
|
|
130
|
-
`lsp.json: unknown server id in enabled
|
|
130
|
+
`lsp.json: unknown server id in enabled: ${unknown.join(", ")} (available: ${list})`,
|
|
131
131
|
);
|
|
132
132
|
}
|
|
133
133
|
}
|
|
@@ -155,8 +155,8 @@ export async function loadLspConfig(
|
|
|
155
155
|
}
|
|
156
156
|
|
|
157
157
|
/**
|
|
158
|
-
* 按配置过滤 adapter 列表。enabled
|
|
159
|
-
*
|
|
158
|
+
* 按配置过滤 adapter 列表。enabled 引用了实际生效集合中不存在的服务器 id
|
|
159
|
+
* 时抛错(配置错误,避免白名单静默失效);disabled 中未注册的 id 忽略。
|
|
160
160
|
*/
|
|
161
161
|
export function filterAdapters(
|
|
162
162
|
adapters: LspServerAdapter[],
|
|
@@ -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
|
},
|