@trim21/personal-pi-extensions 0.0.406 → 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.406",
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
@@ -8,7 +8,7 @@
8
8
  import { readFileSync } from "node:fs";
9
9
  import { stat } from "node:fs/promises";
10
10
  import { homedir } from "node:os";
11
- import { basename, isAbsolute, join, resolve } from "node:path";
11
+ import { isAbsolute, join, resolve } from "node:path";
12
12
  import { fileURLToPath } from "node:url";
13
13
 
14
14
  import {
@@ -23,7 +23,7 @@ import {
23
23
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
24
24
  import { Type } from "typebox";
25
25
 
26
- import { formatDisplayPath } from "../lib/path.js";
26
+ import { formatDisplayPath, formatSubtitlePath } from "../lib/path.js";
27
27
  import { type ToolPendant } from "../lib/pendant.js";
28
28
  import { callAftTool, SEMANTIC_INDEX_WAIT_TIMEOUT_MS } from "./bridge.js";
29
29
 
@@ -51,16 +51,6 @@ export function resolvePathArg(cwd: string, input: string): string {
51
51
  return isAbsolute(input) ? input : resolve(cwd, input);
52
52
  }
53
53
 
54
- /** subtitle 中路径的最大显示长度,超过时退化为仅文件名。 */
55
- export const MAX_SUBTITLE_PATH_LENGTH = 20;
56
-
57
- /** subtitle 用的显示路径:优先 `./…` / `~/…` / 绝对路径,结果过长时只显示文件名。 */
58
- export function formatSubtitlePath(cwd: string, filePath: string): string {
59
- const display = formatDisplayPath(cwd, filePath);
60
- if (display.length <= MAX_SUBTITLE_PATH_LENGTH) return display;
61
- return basename(filePath);
62
- }
63
-
64
54
  export interface AftToolContext {
65
55
  cwd: string;
66
56
  pool: AftTransportPool;
@@ -167,7 +157,7 @@ export function registerOutlineTool(pi: ExtensionAPI, ctx: AftToolContext): void
167
157
  });
168
158
  }
169
159
 
170
- /** 构建 aft_outline pendant 的 subtitle:`target="…"`(路径过长时只显示文件名)。 */
160
+ /** 构建 aft_outline pendant 的 subtitle:`target="…"`(路径过长时显示上一级目录加文件名)。 */
171
161
  export function buildOutlineSubtitle(cwd: string, target: string): string {
172
162
  return `target="${formatSubtitlePath(cwd, resolvePathArg(cwd, target))}"`;
173
163
  }
@@ -14,7 +14,9 @@ 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";
19
+ import { formatSubtitlePath } from "../lib/path.js";
18
20
  import { guardWriteAccess } from "../lib/write-guard.js";
19
21
  import {
20
22
  type ClaudeCodeState,
@@ -295,7 +297,13 @@ export function registerFileTools(
295
297
  const snapshot = snapshotOf(image, false);
296
298
  const key = await readStateKey(filePath);
297
299
  state.reads.set(key, snapshot);
298
- return { content, details: { reads: { [key]: snapshot } } };
300
+ return {
301
+ content,
302
+ details: {
303
+ reads: { [key]: snapshot },
304
+ pendant: { subtitle: formatSubtitlePath(ctx.cwd, filePath) },
305
+ },
306
+ };
299
307
  }
300
308
 
301
309
  const offset = params.offset ?? 1;
@@ -313,7 +321,10 @@ export function registerFileTools(
313
321
  previous.limit === limit &&
314
322
  snapshotsEqual(previous, snapshotOf(buffer))
315
323
  ) {
316
- return { content: [{ type: "text", text: FILE_UNCHANGED_STUB }], details: {} };
324
+ return {
325
+ content: [{ type: "text", text: FILE_UNCHANGED_STUB }],
326
+ details: { pendant: { subtitle: formatSubtitlePath(ctx.cwd, filePath) } },
327
+ };
317
328
  }
318
329
  if (isBinary(buffer.subarray(0, SAMPLE_BYTES)))
319
330
  throw new Error(`Cannot read binary file: ${filePath}`);
@@ -340,7 +351,10 @@ export function registerFileTools(
340
351
  });
341
352
  return {
342
353
  content: [{ type: "text", text: formatted.text }],
343
- details: { reads: { [key]: snapshot } },
354
+ details: {
355
+ reads: { [key]: snapshot },
356
+ pendant: { subtitle: formatSubtitlePath(ctx.cwd, filePath) },
357
+ },
344
358
  };
345
359
  },
346
360
  });
@@ -381,159 +395,179 @@ export function registerFileTools(
381
395
  replaceAll: params.replace_all,
382
396
  },
383
397
  });
384
- const [message, details, diagnosticText] = await withFileMutationQueue<
385
- [string, FileToolDetails, string]
386
- >(filePath, async () => {
387
- const oldString = params.old_string;
388
- const newString = params.new_string;
389
- if (oldString === newString) {
390
- throw new Error("No changes to make: old_string and new_string are exactly the same.");
391
- }
392
- // 空 old_string:创建新文件或填充空文件(不需要先 Read,对齐 Claude Code)
393
- if (oldString === "") {
394
- let exists = true;
395
- try {
396
- const value = await stat(filePath);
397
- if (value.isFile()) {
398
- const content = await readFile(filePath, "utf8");
399
- if (content.trim() !== "") {
400
- 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
+ }
401
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
+ ];
402
454
  }
403
- } catch (error) {
404
- if (error instanceof Error && "code" in error && error.code === "ENOENT") {
405
- exists = false;
406
- } else {
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;
467
+ }
468
+ }
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
+ }
407
480
  throw error;
408
481
  }
409
- }
410
- if (!exists) await mkdir(dirname(filePath), { recursive: true });
411
- await writeFile(filePath, newString, "utf8");
412
- const snapshot = snapshotOf(newString);
413
- const key = await readStateKey(filePath);
414
- state.reads.set(key, snapshot);
415
- const diff = generateDiffString("", convertLeadingTabsToSpaces(newString));
416
- throwIfAborted(signal);
417
- const diagnosticText = await service.lspDiagnosticsForFile(filePath, ctx.cwd, {
418
- notify: (message, level) => ctx.ui.notify(message, level),
419
- signal,
420
- });
421
- return [
422
- `The file ${filePath} has been updated successfully.`,
423
- {
424
- diff: diff.diff,
425
- patch: generateUnifiedPatch(filePath, "", convertLeadingTabsToSpaces(newString)),
426
- firstChangedLine: diff.firstChangedLine,
427
- reads: { [key]: snapshot },
428
- },
429
- diagnosticText,
430
- ];
431
- }
432
- const replaceAll = params.replace_all ?? false;
433
- // 防止 OOM 的大文件检查(对齐 Claude Code)
434
- try {
435
- const { size } = await stat(filePath);
436
- if (size > MAX_EDIT_FILE_SIZE) {
437
- throw new Error(
438
- `File is too large to edit (${formatFileSize(size)}). Maximum editable file size is ${formatFileSize(MAX_EDIT_FILE_SIZE)}.`,
439
- );
440
- }
441
- } catch (error) {
442
- if (!(error instanceof Error) || !("code" in error) || error.code !== "ENOENT") {
443
- throw error;
444
- }
445
- }
446
- let content: Buffer;
447
- try {
448
- content = await readFile(filePath);
449
- } catch (error) {
450
- if (error instanceof Error && "code" in error && error.code === "ENOENT") {
451
- const suggestion = await didYouMean(filePath, ctx.cwd);
452
- throw new Error(
453
- `File does not exist. Note: your current working directory is ${ctx.cwd}.${suggestion ? ` Did you mean ${suggestion}?` : ""}`,
454
- { cause: error },
455
- );
456
- }
457
- throw error;
458
- }
459
- if (extname(filePath).toLowerCase() === ".ipynb") {
460
- throw new Error(
461
- "File is a Jupyter Notebook. Use the NotebookEditTool to edit this file.",
462
- );
463
- }
464
- const key = await readStateKey(filePath);
465
- requireCurrentRead(state, key, filePath, content);
466
- await access(filePath, constants.R_OK | constants.W_OK);
467
- throwIfAborted(signal);
468
- const original = content.toString("utf8");
469
-
470
- // CRLF 规范化后匹配(old_string 不需要带 \r),写回时恢复原行尾
471
- const crlfCount = (original.match(/\r\n/g) ?? []).length;
472
- const lfCount = (original.match(/(?<!\r)\n/g) ?? []).length;
473
- const lineEnding = crlfCount > lfCount ? "\r\n" : "\n";
474
- const normalized = original.replaceAll("\r\n", "\n");
475
- const actualOldString = findActualString(normalized, oldString) ?? oldString;
476
- const matches = normalized.split(actualOldString).length - 1;
477
- if (matches === 0) {
478
- throw new Error(`String to replace not found in file.\nString: ${oldString}`);
479
- }
480
- if (!replaceAll && matches > 1) {
481
- throw new Error(
482
- `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}`,
483
- );
484
- }
485
- const actualNewString = preserveQuoteStyle(oldString, actualOldString, newString);
486
- // 删除场景(new_string 为空):old_string 不以换行结尾且文件里是
487
- // "old_string\n" 时连换行一起删,避免留下空行(对齐 Claude Code
488
- // applyEditToFile 的 stripTrailingNewline 语义)
489
- let searchString = actualOldString;
490
- if (
491
- actualNewString === "" &&
492
- !actualOldString.endsWith("\n") &&
493
- normalized.includes(actualOldString + "\n")
494
- ) {
495
- searchString = actualOldString + "\n";
496
- }
497
- // split/join 与函数替换:replacement 含 $ 时不会触发 $& 等特殊语义
498
- const updated = replaceAll
499
- ? normalized.split(searchString).join(actualNewString)
500
- : normalized.replace(searchString, () => actualNewString);
501
- const restored = lineEnding === "\r\n" ? updated.replaceAll("\n", "\r\n") : updated;
502
- await writeFile(filePath, restored, "utf8");
503
- const snapshot = snapshotOf(restored);
504
- state.reads.set(key, snapshot);
505
- // patch/diff 仅供显示:前导 tab 转空格,避免 UI 渲染错位(对齐 Claude Code)
506
- const diff = generateDiffString(
507
- convertLeadingTabsToSpaces(original),
508
- convertLeadingTabsToSpaces(restored),
509
- );
510
- const text = replaceAll
511
- ? `The file ${filePath} has been updated. All occurrences were successfully replaced.`
512
- : `The file ${filePath} has been updated successfully.`;
513
- throwIfAborted(signal);
514
- const diagnosticText = await service.lspDiagnosticsForFile(filePath, ctx.cwd, {
515
- notify: (message, level) => ctx.ui.notify(message, level),
516
- });
517
- return [
518
- text,
519
- {
520
- diff: diff.diff,
521
- patch: generateUnifiedPatch(
522
- 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(
523
530
  convertLeadingTabsToSpaces(original),
524
531
  convertLeadingTabsToSpaces(restored),
525
- ),
526
- firstChangedLine: diff.firstChangedLine,
527
- 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
+ ];
528
560
  },
529
- diagnosticText,
530
- ];
531
- });
561
+ );
532
562
 
533
- const text = diagnosticText
534
- ? `${message}\n\nLSP errors detected in this file, please fix:\n${diagnosticText}`
535
- : message;
536
- return { content: [{ type: "text" as const, text }], details };
563
+ const text = appendLspDiagnosticText(message, diagnosticText, errorCount);
564
+ return {
565
+ content: [{ type: "text" as const, text }],
566
+ details: {
567
+ ...details,
568
+ pendant: { subtitle: formatSubtitlePath(ctx.cwd, filePath, errorCount, warningCount) },
569
+ },
570
+ };
537
571
  },
538
572
  });
539
573
 
@@ -564,56 +598,68 @@ export function registerFileTools(
564
598
  absolutePath: filePath,
565
599
  change: { oldText: "", newText: params.content },
566
600
  });
567
- const [message, details, diagnosticText] = await withFileMutationQueue<
568
- [string, FileToolDetails, string]
569
- >(filePath, async () => {
570
- let original: string | undefined;
571
- let key: string | undefined;
572
- try {
573
- const value = await stat(filePath);
574
- if (value.isFile()) {
575
- const content = await readFile(filePath);
576
- key = await readStateKey(filePath);
577
- requireCurrentRead(state, key, filePath, content);
578
- original = content.toString("utf8");
579
- }
580
- } catch (error) {
581
- if (!(error instanceof Error) || !("code" in error) || error.code !== "ENOENT") {
582
- throw error;
583
- }
584
- }
585
- throwIfAborted(signal);
586
- await mkdir(dirname(filePath), { recursive: true });
587
- await writeFile(filePath, params.content, "utf8");
588
- const snapshot = snapshotOf(params.content);
589
- // 新建文件:writeFile 之后 realpath 才能解析;覆盖写则复用上面的 key
590
- const resolvedKey = key ?? (await readStateKey(filePath));
591
- state.reads.set(resolvedKey, snapshot);
592
- const diff = generateDiffString(original ?? "", params.content);
593
- const text =
594
- original === undefined
595
- ? `File created successfully at: ${filePath}`
596
- : `The file ${filePath} has been updated successfully.`;
597
- throwIfAborted(signal);
598
- const diagnosticText = await service.lspDiagnosticsForFile(filePath, ctx.cwd, {
599
- notify: (message, level) => ctx.ui.notify(message, level),
600
- });
601
- return [
602
- text,
603
- {
604
- diff: diff.diff,
605
- patch: generateUnifiedPatch(filePath, original ?? "", params.content),
606
- firstChangedLine: diff.firstChangedLine,
607
- reads: { [resolvedKey]: snapshot },
601
+ const [message, details, diagnosticText, errorCount, warningCount] =
602
+ await withFileMutationQueue<[string, FileToolDetails, string, number, number]>(
603
+ filePath,
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
+ ];
608
652
  },
609
- diagnosticText,
610
- ];
611
- });
653
+ );
612
654
 
613
- const text = diagnosticText
614
- ? `${message}\n\nLSP errors detected in this file, please fix:\n${diagnosticText}`
615
- : message;
616
- return { content: [{ type: "text" as const, text }], details };
655
+ const text = appendLspDiagnosticText(message, diagnosticText, errorCount);
656
+ return {
657
+ content: [{ type: "text" as const, text }],
658
+ details: {
659
+ ...details,
660
+ pendant: { subtitle: formatSubtitlePath(ctx.cwd, filePath, errorCount, warningCount) },
661
+ },
662
+ };
617
663
  },
618
664
  });
619
665
  }
@@ -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
+ }
@@ -208,7 +208,11 @@ export interface LspService {
208
208
  options?: LspRequestOptions,
209
209
  ): Promise<void>;
210
210
  diagnostics(): Promise<Record<string, Diagnostic[]>>;
211
- lspDiagnosticsForFile(file: string, cwd: string, options?: LspRequestOptions): Promise<string>;
211
+ lspDiagnosticsForFile(
212
+ file: string,
213
+ cwd: string,
214
+ options?: LspRequestOptions,
215
+ ): Promise<{ text: string; errorCount: number; warningCount: number }>;
212
216
  shutdownAll(): Promise<void>;
213
217
  /** 注入 status 渲染回调;传入 undefined 表示不再渲染。 */
214
218
  attachStatus(render: StatusRenderer | undefined): void;
@@ -404,19 +408,22 @@ export function createLspService(
404
408
  }
405
409
 
406
410
  /**
407
- * edit/write 用:等待文档诊断并返回该文件的 ERROR 报告(空串表示无错误)。
408
- * 内部所有 LSP 失败都会被吞掉,不干扰写操作本身。
411
+ * edit/write 用:等待文档诊断并返回该文件的 ERROR / WARN 报告(text 空串表示无此类诊断)
412
+ * 与数量。内部所有 LSP 失败都会被吞掉,不干扰写操作本身。
409
413
  */
410
414
  async function lspDiagnosticsForFile(
411
415
  file: string,
412
416
  cwd: string,
413
417
  options?: LspRequestOptions,
414
- ): Promise<string> {
415
- if (options?.signal?.aborted) return "";
418
+ ): Promise<{ text: string; errorCount: number; warningCount: number }> {
419
+ if (options?.signal?.aborted) return { text: "", errorCount: 0, warningCount: 0 };
416
420
  await touchFile(file, cwd, "document", options);
417
421
  const all = await diagnostics();
418
422
  const normalized = normalize(file);
419
- return report(normalized, all[normalized] ?? []);
423
+ const issues = all[normalized] ?? [];
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 };
420
427
  }
421
428
 
422
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 { 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.
@@ -59,3 +59,34 @@ export function formatDisplayPath(cwd: string, filePath: string): string {
59
59
  }
60
60
  return filePath;
61
61
  }
62
+
63
+ /** subtitle 中路径的最大显示长度,超过时退化为 `parent/basename`。 */
64
+ export const MAX_SUBTITLE_PATH_LENGTH = 20;
65
+
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;
92
+ }
@@ -29,7 +29,9 @@ 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";
34
+ import { formatSubtitlePath } from "../lib/path.js";
33
35
  import { guardWriteAccess } from "../lib/write-guard.js";
34
36
  import { applyEdit, normalizeToLF, stripBom } from "./edit-engine.js";
35
37
 
@@ -356,7 +358,7 @@ function registerReadTool(pi: ExtensionAPI, service: LspService): void {
356
358
 
357
359
  return {
358
360
  content: [{ type: "text", text: output }],
359
- details: undefined,
361
+ details: { pendant: { subtitle: formatSubtitlePath(ctx.cwd, absolutePath) } },
360
362
  };
361
363
  }
362
364
 
@@ -384,7 +386,10 @@ function registerReadTool(pi: ExtensionAPI, service: LspService): void {
384
386
  { type: "text", text: "Image read successfully" },
385
387
  { type: "image", data: base64, mimeType },
386
388
  ];
387
- return { content, details: undefined };
389
+ return {
390
+ content,
391
+ details: { pendant: { subtitle: formatSubtitlePath(ctx.cwd, absolutePath) } },
392
+ };
388
393
  }
389
394
 
390
395
  if (isBinaryExtension(absolutePath) || isBinaryFileBySample(await readSample(absolutePath))) {
@@ -431,7 +436,13 @@ function registerReadTool(pi: ExtensionAPI, service: LspService): void {
431
436
  // 后台 warm-up 失败不影响读取
432
437
  });
433
438
 
434
- return { content, details };
439
+ return {
440
+ content,
441
+ details: {
442
+ ...details,
443
+ pendant: { subtitle: formatSubtitlePath(ctx.cwd, absolutePath) },
444
+ },
445
+ };
435
446
  },
436
447
  });
437
448
  }
@@ -482,9 +493,8 @@ function registerEditTool(pi: ExtensionAPI, service: LspService): void {
482
493
  change: { oldText: oldString, newText: newString, replaceAll },
483
494
  });
484
495
 
485
- const [message, details, diagnosticText] = await withFileMutationQueue(
486
- absolutePath,
487
- async () => {
496
+ const [message, details, diagnosticText, errorCount, warningCount] =
497
+ await withFileMutationQueue(absolutePath, async () => {
488
498
  signal?.throwIfAborted();
489
499
 
490
500
  // opencode: 前置校验,先于空 oldString 分支
@@ -509,13 +519,17 @@ function registerEditTool(pi: ExtensionAPI, service: LspService): void {
509
519
  await mkdir(dirname(absolutePath), { recursive: true });
510
520
  signal?.throwIfAborted();
511
521
  await writeFile(absolutePath, newString, "utf8");
512
- const diagnosticText = await service.lspDiagnosticsForFile(absolutePath, ctx.cwd, {
513
- signal,
514
- });
522
+ const {
523
+ text: diagnosticText,
524
+ errorCount,
525
+ warningCount,
526
+ } = await service.lspDiagnosticsForFile(absolutePath, ctx.cwd, { signal });
515
527
  return [
516
528
  "Edit applied successfully.",
517
529
  { diff: "", patch: "", firstChangedLine: 0 },
518
530
  diagnosticText,
531
+ errorCount,
532
+ warningCount,
519
533
  ] as const;
520
534
  }
521
535
 
@@ -547,21 +561,30 @@ function registerEditTool(pi: ExtensionAPI, service: LspService): void {
547
561
  const diffNew = normalizeToLF(applied.contentNew);
548
562
  const diffResult = generateDiffString(diffOld, diffNew);
549
563
  const patch = generateUnifiedPatch(filePath, diffOld, diffNew);
550
- const diagnosticText = await service.lspDiagnosticsForFile(absolutePath, ctx.cwd, {
551
- signal,
552
- });
564
+ const {
565
+ text: diagnosticText,
566
+ errorCount,
567
+ warningCount,
568
+ } = await service.lspDiagnosticsForFile(absolutePath, ctx.cwd, { signal });
553
569
  return [
554
570
  "Edit applied successfully.",
555
571
  { diff: diffResult.diff, patch, firstChangedLine: diffResult.firstChangedLine },
556
572
  diagnosticText,
573
+ errorCount,
574
+ warningCount,
557
575
  ] as const;
576
+ });
577
+
578
+ const text = appendLspDiagnosticText(message, diagnosticText, errorCount);
579
+ return {
580
+ content: [{ type: "text" as const, text }],
581
+ details: {
582
+ ...details,
583
+ pendant: {
584
+ subtitle: formatSubtitlePath(ctx.cwd, absolutePath, errorCount, warningCount),
585
+ },
558
586
  },
559
- );
560
-
561
- const text = diagnosticText
562
- ? `${message}\n\nLSP errors detected in this file, please fix:\n${diagnosticText}`
563
- : message;
564
- return { content: [{ type: "text" as const, text }], details };
587
+ };
565
588
  },
566
589
  });
567
590
  }
@@ -614,9 +637,8 @@ function registerWriteTool(pi: ExtensionAPI, service: LspService): void {
614
637
  });
615
638
  const dir = dirname(absolutePath);
616
639
 
617
- const [message, details, diagnosticText] = await withFileMutationQueue(
618
- absolutePath,
619
- async () => {
640
+ const [message, details, diagnosticText, errorCount, warningCount] =
641
+ await withFileMutationQueue(absolutePath, async () => {
620
642
  signal?.throwIfAborted();
621
643
 
622
644
  // opencode: desiredBom = source.bom || next.bom —— 保留原文件 BOM,
@@ -639,18 +661,31 @@ function registerWriteTool(pi: ExtensionAPI, service: LspService): void {
639
661
  await mkdir(dir, { recursive: true });
640
662
  signal?.throwIfAborted();
641
663
  await writeFile(absolutePath, desiredBom + nextText, "utf8");
642
- const diagnosticText = await service.lspDiagnosticsForFile(absolutePath, ctx.cwd, {
643
- signal,
644
- });
664
+ const {
665
+ text: diagnosticText,
666
+ errorCount,
667
+ warningCount,
668
+ } = await service.lspDiagnosticsForFile(absolutePath, ctx.cwd, { signal });
645
669
 
646
- return ["Wrote file successfully.", undefined, diagnosticText] as const;
670
+ return [
671
+ "Wrote file successfully.",
672
+ {},
673
+ diagnosticText,
674
+ errorCount,
675
+ warningCount,
676
+ ] as const;
677
+ });
678
+
679
+ const text = appendLspDiagnosticText(message, diagnosticText, errorCount);
680
+ return {
681
+ content: [{ type: "text" as const, text }],
682
+ details: {
683
+ ...details,
684
+ pendant: {
685
+ subtitle: formatSubtitlePath(ctx.cwd, absolutePath, errorCount, warningCount),
686
+ },
647
687
  },
648
- );
649
-
650
- const text = diagnosticText
651
- ? `${message}\n\nLSP errors detected in this file, please fix:\n${diagnosticText}`
652
- : message;
653
- return { content: [{ type: "text" as const, text }], details };
688
+ };
654
689
  },
655
690
  });
656
691
  }