@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trim21/personal-pi-extensions",
3
- "version": "0.0.407",
3
+ "version": "0.0.408",
4
4
  "type": "module",
5
5
  "description": "Custom pi coding-agent extensions: bwrap sandbox, workspace guard, opencode edit, and more",
6
6
  "keywords": [
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
  }
@@ -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] = await withFileMutationQueue<
398
- [string, FileToolDetails, string, number]
399
- >(filePath, async () => {
400
- const oldString = params.old_string;
401
- const newString = params.new_string;
402
- if (oldString === newString) {
403
- throw new Error("No changes to make: old_string and new_string are exactly the same.");
404
- }
405
- // 空 old_string:创建新文件或填充空文件(不需要先 Read,对齐 Claude Code)
406
- if (oldString === "") {
407
- let exists = true;
408
- try {
409
- const value = await stat(filePath);
410
- if (value.isFile()) {
411
- const content = await readFile(filePath, "utf8");
412
- if (content.trim() !== "") {
413
- throw new Error("Cannot create new file - file already exists.");
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
- } catch (error) {
417
- if (error instanceof Error && "code" in error && error.code === "ENOENT") {
418
- exists = false;
419
- } else {
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
- if (!exists) await mkdir(dirname(filePath), { recursive: true });
424
- await writeFile(filePath, newString, "utf8");
425
- const snapshot = snapshotOf(newString);
426
- const key = await readStateKey(filePath);
427
- state.reads.set(key, snapshot);
428
- const diff = generateDiffString("", convertLeadingTabsToSpaces(newString));
429
- throwIfAborted(signal);
430
- const { text: diagnosticText, errorCount } = await service.lspDiagnosticsForFile(
431
- filePath,
432
- ctx.cwd,
433
- {
434
- notify: (message, level) => ctx.ui.notify(message, level),
435
- signal,
436
- },
437
- );
438
- return [
439
- `The file ${filePath} has been updated successfully.`,
440
- {
441
- diff: diff.diff,
442
- patch: generateUnifiedPatch(filePath, "", convertLeadingTabsToSpaces(newString)),
443
- firstChangedLine: diff.firstChangedLine,
444
- reads: { [key]: snapshot },
445
- },
446
- diagnosticText,
447
- errorCount,
448
- ];
449
- }
450
- const replaceAll = params.replace_all ?? false;
451
- // 防止 OOM 的大文件检查(对齐 Claude Code)
452
- try {
453
- const { size } = await stat(filePath);
454
- if (size > MAX_EDIT_FILE_SIZE) {
455
- throw new Error(
456
- `File is too large to edit (${formatFileSize(size)}). Maximum editable file size is ${formatFileSize(MAX_EDIT_FILE_SIZE)}.`,
457
- );
458
- }
459
- } catch (error) {
460
- if (!(error instanceof Error) || !("code" in error) || error.code !== "ENOENT") {
461
- throw error;
462
- }
463
- }
464
- let content: Buffer;
465
- try {
466
- content = await readFile(filePath);
467
- } catch (error) {
468
- if (error instanceof Error && "code" in error && error.code === "ENOENT") {
469
- const suggestion = await didYouMean(filePath, ctx.cwd);
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
- firstChangedLine: diff.firstChangedLine,
549
- reads: { [key]: snapshot },
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
- diagnosticText,
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] = await withFileMutationQueue<
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
- ctx.cwd,
630
- {
631
- notify: (message, level) => ctx.ui.notify(message, level),
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
- * 诊断报告格式化(移植自 opencode lsp/diagnostic.ts):只报告 ERROR
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.severity || 1] ?? "ERROR";
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.severity === 1);
30
- if (errors.length === 0) return "";
31
- const limited = errors.slice(0, MAX_PER_FILE);
32
- const more = errors.length - MAX_PER_FILE;
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
+ }
@@ -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
- * 与 ERROR 数量。内部所有 LSP 失败都会被吞掉,不干扰写操作本身。
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
- return { text: report(normalized, issues), errorCount };
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
- /** subtitle 用的显示路径:优先 `./…` / `~/…` / 绝对路径,结果过长时只显示文件名。 */
67
- export function formatSubtitlePath(cwd: string, filePath: string, errorCount?: number): string {
68
- const display = formatDisplayPath(cwd, filePath);
69
- const base = display.length <= MAX_SUBTITLE_PATH_LENGTH ? display : basename(filePath);
70
- if (errorCount === undefined || errorCount === 0) return base;
71
- return `${base} (ⓧ ${errorCount})`;
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
  }
@@ -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] = await withFileMutationQueue(
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 { text: diagnosticText, errorCount } = await service.lspDiagnosticsForFile(
523
- absolutePath,
524
- ctx.cwd,
525
- { signal },
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 { text: diagnosticText, errorCount } = await service.lspDiagnosticsForFile(
564
- absolutePath,
565
- ctx.cwd,
566
- { signal },
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: { subtitle: formatSubtitlePath(ctx.cwd, absolutePath, errorCount) },
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] = await withFileMutationQueue(
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 { text: diagnosticText, errorCount } = await service.lspDiagnosticsForFile(
665
- absolutePath,
666
- ctx.cwd,
667
- { signal },
668
- );
664
+ const {
665
+ text: diagnosticText,
666
+ errorCount,
667
+ warningCount,
668
+ } = await service.lspDiagnosticsForFile(absolutePath, ctx.cwd, { signal });
669
669
 
670
- return ["Wrote file successfully.", {}, diagnosticText, errorCount] as const;
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: { subtitle: formatSubtitlePath(ctx.cwd, absolutePath, errorCount) },
684
+ pendant: {
685
+ subtitle: formatSubtitlePath(ctx.cwd, absolutePath, errorCount, warningCount),
686
+ },
682
687
  },
683
688
  };
684
689
  },