@trim21/personal-pi-extensions 0.1.488 → 0.1.490

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.1.488",
3
+ "version": "0.1.490",
4
4
  "type": "module",
5
5
  "description": "Custom pi coding-agent extensions: bwrap sandbox, workspace guard, opencode edit, and more",
6
6
  "keywords": [
@@ -14,11 +14,11 @@ import {
14
14
  } from "@earendil-works/pi-coding-agent";
15
15
  import { Type } from "typebox";
16
16
 
17
- import { RenameNotPossibleError } from "../lib/lsp/client.js";
18
17
  import { appendLspDiagnosticText } from "../lib/lsp/diagnostic.js";
18
+ import { registerLspInspectTools } from "../lib/lsp/inspect-tool.js";
19
19
  import { type LspService, registerLsp } from "../lib/lsp/lsp.js";
20
- import { canonicalizeEdit, expandWorkspaceEdit, symbolCandidates } from "../lib/lsp/rename.js";
21
- import { formatSubtitlePath, resolvePathArg } from "../lib/path.js";
20
+ import { registerLspRenameTool } from "../lib/lsp/rename-tool.js";
21
+ import { formatSubtitlePath } from "../lib/path.js";
22
22
  import type { ToolPendant } from "../lib/pendant.ts";
23
23
  import { guardWriteAccess } from "../lib/write-guard.js";
24
24
  import {
@@ -60,10 +60,6 @@ const WRITE_PROMPT = readFileSync(
60
60
  fileURLToPath(new URL("write.md", import.meta.url)),
61
61
  "utf8",
62
62
  ).trim();
63
- const LSP_RENAME_PROMPT = readFileSync(
64
- fileURLToPath(new URL("lsp-rename.md", import.meta.url)),
65
- "utf8",
66
- ).trim();
67
63
  const IMAGE_MIMES = new Map<string, string>([
68
64
  [".gif", "image/gif"],
69
65
  [".jpeg", "image/jpeg"],
@@ -679,197 +675,23 @@ export function registerFileTools(
679
675
  },
680
676
  });
681
677
 
682
- pi.registerTool({
683
- name: "lsp-rename",
684
- label: "Lsp Rename",
685
- description: "Rename a code symbol and update all references across the workspace via LSP",
686
- promptSnippet: "Workspace-wide symbol rename via LSP",
687
- promptGuidelines: [LSP_RENAME_PROMPT],
688
- parameters: Type.Object(
689
- {
690
- file_path: Type.String({
691
- description:
692
- "A file containing the symbol (absolute path, or relative / ~/ path resolved against the session cwd)",
693
- }),
694
- line: Type.Integer({
695
- minimum: 1,
696
- description:
697
- "1-based line number where the symbol appears (any occurrence works, not only the definition)",
698
- }),
699
- symbol: Type.String({
700
- minLength: 1,
701
- description: "The symbol's name exactly as it appears on that line",
702
- }),
703
- new_name: Type.String({ minLength: 1, description: "The new name for the symbol" }),
704
- character: Type.Optional(
705
- Type.Integer({
706
- minimum: 1,
707
- description:
708
- "1-based character offset of the symbol on that line. Only needed when the tool reports an ambiguity error on this line",
709
- }),
710
- ),
711
- },
712
- { additionalProperties: false },
713
- ),
714
- async execute(_id, params, signal, _onUpdate, ctx) {
715
- signal?.throwIfAborted();
716
-
717
- const filePath = resolvePathArg(ctx.cwd, params.file_path);
718
- let content: string;
719
- try {
720
- await stat(filePath);
721
- content = await readFile(filePath, "utf8");
722
- } catch (error) {
723
- if (error instanceof Error && "code" in error && error.code === "ENOENT") {
724
- throw new Error(`File does not exist: ${filePath}`, { cause: error });
725
- }
726
- throw error;
727
- }
728
-
729
- const notify = (message: string, level: "info" | "warning" | "error") =>
730
- ctx.ui.notify(message, level);
731
- const options = { notify, signal };
732
-
733
- // ── 按 symbol 名枚举行内候选,逐候选探测与消歧 ────────────────────────
734
- const candidates = symbolCandidates(
735
- content,
736
- params.line - 1,
737
- params.symbol,
738
- params.character === undefined ? undefined : params.character - 1,
739
- );
740
- if (candidates.length === 0) {
741
- throw new Error(
742
- `Symbol "${params.symbol}" not found on line ${params.line} of ${filePath}. Read the file again and locate the symbol.`,
743
- );
744
- }
745
- const successes: { result: Awaited<ReturnType<typeof service.rename>> }[] = [];
746
- const notPossibleErrors: string[] = [];
747
- for (const candidate of candidates) {
748
- signal?.throwIfAborted();
749
- try {
750
- const result = await service.rename({
751
- file: filePath,
752
- cwd: ctx.cwd,
753
- line: candidate.line,
754
- character: candidate.character,
755
- newName: params.new_name,
756
- options,
757
- });
758
- successes.push({ result });
759
- } catch (error) {
760
- if (error instanceof RenameNotPossibleError) {
761
- notPossibleErrors.push(error.message);
762
- continue;
763
- }
764
- throw error;
765
- }
766
- }
767
- if (successes.length === 0) {
768
- throw new RenameNotPossibleError(
769
- notPossibleErrors.length > 0
770
- ? notPossibleErrors.join("; ")
771
- : `no renameable symbol "${params.symbol}" on line ${params.line} of ${filePath}`,
772
- );
773
- }
774
-
775
- // 同一符号的多次出现编辑集合一致;不一致即为不同符号 → 要求补 character
776
- const groups = new Map<
777
- string,
778
- { result: (typeof successes)[number]["result"]; candidates: typeof candidates }
779
- >();
780
- for (const [index, candidate] of candidates.entries()) {
781
- const entry = successes[index];
782
- if (!entry) continue;
783
- const key = canonicalizeEdit(entry.result.edit);
784
- const group = groups.get(key);
785
- if (group) group.candidates.push(candidate);
786
- else groups.set(key, { result: entry.result, candidates: [candidate] });
787
- }
788
- if (groups.size > 1) {
789
- const listing = [...groups.values()]
790
- .map((group) =>
791
- group.candidates
792
- .map(
793
- (candidate) =>
794
- `- line ${candidate.line + 1}, column ${candidate.character + 1} (rename target: ${group.result.placeholder ?? "unknown"})`,
795
- )
796
- .join("\n"),
797
- )
798
- .join("\n");
799
- throw new Error(
800
- `Ambiguous rename target on line ${params.line} of ${filePath}: several distinct symbols share the name "${params.symbol}". Retry with 'character' (1-based) to pick one:\n${listing}`,
801
- );
802
- }
803
- const firstGroup = [...groups.values()][0];
804
- if (!firstGroup) throw new Error("LSP rename returned no target");
805
- const { result } = firstGroup;
806
-
807
- // ── 内存展开 → 审批 → 写盘 → 诊断 ────────────────────────────────────
808
- signal?.throwIfAborted();
809
- const applied = await expandWorkspaceEdit(result.edit, (path) => readFile(path, "utf8"));
810
- if (applied.length === 0) {
811
- throw new Error("LSP rename returned no edits");
812
- }
813
- for (const fileEdit of applied) {
814
- await guardWriteAccess(ctx, {
815
- toolName: "lsp-rename",
816
- absolutePath: fileEdit.path,
817
- change: { oldText: fileEdit.oldText, newText: fileEdit.newText },
818
- });
819
- }
820
-
821
- const diffs: string[] = [];
822
- const pendingReads: { key: string; snapshot: FileSnapshot }[] = [];
823
- for (const fileEdit of applied) {
824
- await withFileMutationQueue(fileEdit.path, async () => {
825
- await writeFile(fileEdit.path, fileEdit.newText, "utf8");
826
- const key = await readStateKey(fileEdit.path);
827
- pendingReads.push({ key, snapshot: snapshotOf(fileEdit.newText) });
828
- });
829
- diffs.push(
830
- `### ${fileEdit.path} (${fileEdit.changeCount} edit(s))\n\n\`\`\`diff\n${generateDiffString(fileEdit.oldText, fileEdit.newText).diff}\n\`\`\``,
831
- );
832
- }
833
-
834
- let diagnosticText = "";
835
- for (const fileEdit of applied) {
836
- signal?.throwIfAborted();
837
- const diagnostics = await service.lspDiagnosticsForFile(fileEdit.path, ctx.cwd, {
838
- notify,
839
- signal,
840
- });
841
- if (diagnostics.text !== "") diagnosticText += `${diagnostics.text}\n`;
842
- }
843
-
678
+ // lsp-rename 工具壳与 opencode 共享(lib/lsp/rename-tool.ts);本工具集
679
+ // 跟踪 read-before-write 状态,rename 落盘的文件要标记为已读并随 details
680
+ // 持久化(FILE_TOOL_NAMES 的 restoreFileReads 依赖 details.reads)。
681
+ registerLspRenameTool(pi, service, {
682
+ recordReads: async (applied) => {
844
683
  const reads: Record<string, FileSnapshot> = {};
845
- for (const { key, snapshot } of pendingReads) {
846
- reads[key] = snapshot;
684
+ for (const fileEdit of applied) {
685
+ const key = await readStateKey(fileEdit.path);
686
+ const snapshot = snapshotOf(fileEdit.newText);
847
687
  state.reads.set(key, snapshot);
688
+ reads[key] = snapshot;
848
689
  }
849
-
850
- const renamed =
851
- result.placeholder !== undefined && result.placeholder !== params.new_name
852
- ? `'${result.placeholder}' -> '${params.new_name}'`
853
- : `to '${params.new_name}'`;
854
- const summary = `Renamed ${renamed} across ${applied.length} file(s).`;
855
- const text =
856
- diagnosticText === ""
857
- ? summary
858
- : `${summary}\n\nLSP diagnostics detected in renamed files:\n${diagnosticText.trimEnd()}`;
859
-
860
- return {
861
- content: [{ type: "text" as const, text }],
862
- details: {
863
- reads,
864
- pendant: {
865
- title: "lsp-rename",
866
- subtitle: `${renamed} · ${applied.length} file(s)`,
867
- markdown: diffs.join("\n\n"),
868
- } satisfies ToolPendant,
869
- },
870
- };
690
+ return reads;
871
691
  },
872
692
  });
693
+ // 只读符号查询工具(find-definition / find-reference / inspect)与 opencode 共享。
694
+ registerLspInspectTools(pi, service);
873
695
  }
874
696
 
875
697
  /** 会更新 reads state 并随 details 持久化快照的工具名。 */
@@ -20,7 +20,13 @@ import {
20
20
  StreamMessageReader,
21
21
  StreamMessageWriter,
22
22
  } from "vscode-jsonrpc/node";
23
- import type { Diagnostic as VSCodeDiagnostic, WorkspaceEdit } from "vscode-languageserver-types";
23
+ import type {
24
+ Diagnostic as VSCodeDiagnostic,
25
+ Hover,
26
+ Location as LspLocation,
27
+ LocationLink,
28
+ WorkspaceEdit,
29
+ } from "vscode-languageserver-types";
24
30
 
25
31
  import type { LspServerHandle } from "./adapter.js";
26
32
  import { LANGUAGE_EXTENSIONS } from "./language.js";
@@ -72,6 +78,20 @@ const LSP_CONTENT_MODIFIED = -32801;
72
78
  */
73
79
  export class RenameNotPossibleError extends Error {}
74
80
 
81
+ /**
82
+ * 服务器不支持某 LSP 方法(MethodNotFound)。与传输失败区分,供服务层跳过
83
+ * 该服务器尝试下一个,而不是把整个操作当作失败。
84
+ */
85
+ export class LspMethodNotSupportedError extends Error {
86
+ readonly serverID: string;
87
+ readonly method: string;
88
+ constructor(serverID: string, method: string) {
89
+ super(`LSP server "${serverID}" does not support ${method}`);
90
+ this.serverID = serverID;
91
+ this.method = method;
92
+ }
93
+ }
94
+
75
95
  /**
76
96
  * rename edit 未覆盖 references 看到的全部文件:服务器索引可能仍在后台加载。
77
97
  * 抛出时发生在写盘之前,整个 rename 无副作用,可稍后重试。
@@ -148,6 +168,48 @@ export interface RenameSymbolResult {
148
168
  placeholder?: string;
149
169
  }
150
170
 
171
+ /** definition / references / hover 请求的输入(line / character 为 0-based LSP position)。 */
172
+ export interface InspectPositionRequest {
173
+ path: string;
174
+ line: number;
175
+ character: number;
176
+ }
177
+
178
+ /** definition / references 归一化后的位置(0-based;1-based 格式化由工具层负责)。 */
179
+ export interface InspectLocation {
180
+ path: string;
181
+ line: number;
182
+ character: number;
183
+ }
184
+
185
+ type DefinitionResult = LspLocation | LspLocation[] | LocationLink | LocationLink[] | null;
186
+
187
+ /** Location / LocationLink → path + 0-based 坐标;非 file: URI 是服务器的意外行为,跳过。 */
188
+ function toInspectLocations(result: DefinitionResult): InspectLocation[] {
189
+ if (result === null) return [];
190
+ const items = Array.isArray(result) ? result : [result];
191
+ const locations: InspectLocation[] = [];
192
+ for (const item of items) {
193
+ if ("targetUri" in item) {
194
+ if (!item.targetUri.startsWith("file:")) continue;
195
+ const range = item.targetSelectionRange ?? item.targetRange;
196
+ locations.push({
197
+ path: normalize(fileURLToPath(item.targetUri)),
198
+ line: range.start.line,
199
+ character: range.start.character,
200
+ });
201
+ } else {
202
+ if (!item.uri.startsWith("file:")) continue;
203
+ locations.push({
204
+ path: normalize(fileURLToPath(item.uri)),
205
+ line: item.range.start.line,
206
+ character: item.range.start.character,
207
+ });
208
+ }
209
+ }
210
+ return locations;
211
+ }
212
+
151
213
  export class InitializeError extends Error {
152
214
  readonly serverID: string;
153
215
  constructor(serverID: string, cause: unknown) {
@@ -241,6 +303,21 @@ export interface LspClient {
241
303
  * 位置不在符号上 / 服务器不支持 rename 时抛 RenameNotPossibleError。
242
304
  */
243
305
  renameSymbol(request: RenameSymbolRequest): Promise<RenameSymbolResult>;
306
+ /**
307
+ * textDocument/definition:归一化后的定义位置列表(Location / LocationLink
308
+ * 统一转 path + 0-based 坐标;无结果返回空数组)。
309
+ */
310
+ definition(request: InspectPositionRequest): Promise<InspectLocation[]>;
311
+ /**
312
+ * textDocument/references:归一化后的引用位置列表(是否含声明处由服务器
313
+ * 按 includeDeclaration 决定,此处固定包含,对齐 rename 覆盖校验口径)。
314
+ */
315
+ references(request: InspectPositionRequest): Promise<InspectLocation[]>;
316
+ /**
317
+ * textDocument/hover:服务器返回的 contents 原样透传,不做内容归一化;
318
+ * 服务器无信息时返回 null(合法应答,非错误)。
319
+ */
320
+ hover(request: InspectPositionRequest): Promise<Hover | null>;
244
321
  shutdown(): Promise<void>;
245
322
  }
246
323
 
@@ -922,6 +999,31 @@ export async function create(input: CreateInput): Promise<LspClient> {
922
999
  return 0;
923
1000
  };
924
1001
 
1002
+ // ── 只读符号查询(definition / references / hover)──────────────────────────
1003
+
1004
+ /** 请求前先同步磁盘内容(didOpen/didChange),保证服务器基于最新文本应答。 */
1005
+ const preparePositionRequest = async (request: InspectPositionRequest) => {
1006
+ const resolvedPath = normalize(
1007
+ isAbsolute(request.path) ? request.path : resolve(input.directory, request.path),
1008
+ );
1009
+ await openDocument({ path: resolvedPath });
1010
+ return {
1011
+ uri: pathToFileURL(resolvedPath).href,
1012
+ position: { line: request.line, character: request.character },
1013
+ };
1014
+ };
1015
+
1016
+ const sendInspectRequest = async <T>(method: string, message: object): Promise<T> => {
1017
+ try {
1018
+ return await retryOnContentModified(() => connection.sendRequest<T>(method, message));
1019
+ } catch (error) {
1020
+ if (error instanceof ResponseError && error.code === LSP_METHOD_NOT_FOUND) {
1021
+ throw new LspMethodNotSupportedError(input.serverID, method);
1022
+ }
1023
+ throw error;
1024
+ }
1025
+ };
1026
+
925
1027
  return {
926
1028
  root: input.root,
927
1029
  get serverID() {
@@ -1101,6 +1203,31 @@ export async function create(input: CreateInput): Promise<LspClient> {
1101
1203
  current = toPaths(await referencesRequest());
1102
1204
  }
1103
1205
  },
1206
+ async definition(request: InspectPositionRequest): Promise<InspectLocation[]> {
1207
+ const { uri, position } = await preparePositionRequest(request);
1208
+ return toInspectLocations(
1209
+ await sendInspectRequest<DefinitionResult>("textDocument/definition", {
1210
+ textDocument: { uri },
1211
+ position,
1212
+ }),
1213
+ );
1214
+ },
1215
+ async references(request: InspectPositionRequest): Promise<InspectLocation[]> {
1216
+ const { uri, position } = await preparePositionRequest(request);
1217
+ const locations = await sendInspectRequest<LspLocation[] | null>("textDocument/references", {
1218
+ textDocument: { uri },
1219
+ position,
1220
+ context: { includeDeclaration: true },
1221
+ });
1222
+ return toInspectLocations(locations);
1223
+ },
1224
+ async hover(request: InspectPositionRequest): Promise<Hover | null> {
1225
+ const { uri, position } = await preparePositionRequest(request);
1226
+ return await sendInspectRequest<Hover | null>("textDocument/hover", {
1227
+ textDocument: { uri },
1228
+ position,
1229
+ });
1230
+ },
1104
1231
  get diagnostics() {
1105
1232
  const result = new Map<string, Diagnostic[]>();
1106
1233
  for (const key of new Set([...pushDiagnostics.keys(), ...pullDiagnostics.keys()])) {
@@ -0,0 +1,376 @@
1
+ /**
2
+ * 只读 LSP 符号查询工具,由 claude-code / opencode 两个工具集共享注册:
3
+ *
4
+ * - lsp-find-definition:textDocument/definition → 定义位置列表
5
+ * - lsp-find-reference:textDocument/references(含声明处)→ 按文件分组
6
+ * - lsp-inspect:textDocument/hover → hover 内容原样透传
7
+ *
8
+ * 三个工具共用 lsp-rename 的定位与消歧语义(file_path + line + symbol,
9
+ * character 消歧):按词边界枚举行内候选逐个探测,格式化输出一致即视为
10
+ * 同一符号的多次出现,不一致报歧义并列出候选列号。
11
+ */
12
+
13
+ import { readFileSync } from "node:fs";
14
+ import { readFile, stat } from "node:fs/promises";
15
+ import { fileURLToPath } from "node:url";
16
+
17
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
18
+ import { Type } from "typebox";
19
+ import type { Hover } from "vscode-languageserver-types";
20
+
21
+ import { resolvePathArg } from "../path.js";
22
+ import type { InspectLocation } from "./client.js";
23
+ import type { LspService } from "./lsp.js";
24
+ import { type LspPosition, symbolCandidates } from "./rename.js";
25
+
26
+ /** references 输出上限:每文件最多列出的行片段数。 */
27
+ const MAX_ENTRIES_PER_FILE = 10;
28
+ /** references 输出上限:最多列出片段的文件数,超出部分按计数汇总。 */
29
+ const MAX_FILES_LISTED = 30;
30
+ const MAX_SNIPPET_LENGTH = 200;
31
+
32
+ /** 一次探测的产出:text 是消歧分组键,subtitle 供 pendant 摘要。 */
33
+ interface InspectOutput {
34
+ text: string;
35
+ subtitle: string;
36
+ }
37
+
38
+ function loadPrompt(fileName: string): string {
39
+ return readFileSync(fileURLToPath(new URL(fileName, import.meta.url)), "utf8").trim();
40
+ }
41
+
42
+ const FIND_DEFINITION_PROMPT = loadPrompt("lsp-find-definition.md");
43
+ const FIND_REFERENCE_PROMPT = loadPrompt("lsp-find-reference.md");
44
+ const INSPECT_PROMPT = loadPrompt("lsp-inspect.md");
45
+
46
+ const POSITION_SCHEMA = Type.Object(
47
+ {
48
+ file_path: Type.String({
49
+ description:
50
+ "A file containing the symbol (absolute path, or relative / ~/ path resolved against the session cwd)",
51
+ }),
52
+ line: Type.Integer({
53
+ minimum: 1,
54
+ description:
55
+ "1-based line number where the symbol appears (any occurrence works, not only the definition)",
56
+ }),
57
+ symbol: Type.String({
58
+ minLength: 1,
59
+ description: "The symbol's name exactly as it appears on that line",
60
+ }),
61
+ character: Type.Optional(
62
+ Type.Integer({
63
+ minimum: 1,
64
+ description:
65
+ "1-based character offset of the symbol on that line. Only needed when the tool reports an ambiguity error on this line",
66
+ }),
67
+ ),
68
+ },
69
+ { additionalProperties: false },
70
+ );
71
+
72
+ async function readSymbolFile(
73
+ cwd: string,
74
+ filePathArg: string,
75
+ ): Promise<{ path: string; content: string }> {
76
+ const filePath = resolvePathArg(cwd, filePathArg);
77
+ try {
78
+ await stat(filePath);
79
+ const content = await readFile(filePath, "utf8");
80
+ return { path: filePath, content };
81
+ } catch (error) {
82
+ if (error instanceof Error && "code" in error && error.code === "ENOENT") {
83
+ throw new Error(`File does not exist: ${filePath}`, { cause: error });
84
+ }
85
+ throw error;
86
+ }
87
+ }
88
+
89
+ /** 目标行内容片段(跨文件缓存;读取失败静默跳过片段,坐标仍然输出)。 */
90
+ async function lineSnippet(
91
+ path: string,
92
+ line: number,
93
+ cache: Map<string, string[]>,
94
+ ): Promise<string> {
95
+ let lines = cache.get(path);
96
+ if (lines === undefined) {
97
+ try {
98
+ const content = await readFile(path, "utf8");
99
+ lines = content.split("\n");
100
+ } catch {
101
+ return "";
102
+ }
103
+ cache.set(path, lines);
104
+ }
105
+ const text = (lines[line] ?? "").replace(/\r$/, "");
106
+ return text.length > MAX_SNIPPET_LENGTH ? `${text.slice(0, MAX_SNIPPET_LENGTH)}…` : text;
107
+ }
108
+
109
+ function toCoordinates(location: InspectLocation): string {
110
+ return `${location.path}:${location.line + 1}:${location.character + 1}`;
111
+ }
112
+
113
+ async function formatDefinitionLocations(
114
+ locations: InspectLocation[],
115
+ cache: Map<string, string[]>,
116
+ ): Promise<InspectOutput> {
117
+ if (locations.length === 0) {
118
+ return { text: "No definition found for this symbol.", subtitle: "0 definition(s)" };
119
+ }
120
+ const lines = [`Found ${locations.length} definition(s):`];
121
+ for (const location of locations) {
122
+ const snippet = await lineSnippet(location.path, location.line, cache);
123
+ lines.push(`- ${toCoordinates(location)}${snippet === "" ? "" : `\n ${snippet}`}`);
124
+ }
125
+ return {
126
+ text: lines.join("\n"),
127
+ subtitle: `${locations.length} definition(s)`,
128
+ };
129
+ }
130
+
131
+ async function formatReferenceLocations(
132
+ locations: InspectLocation[],
133
+ cache: Map<string, string[]>,
134
+ ): Promise<InspectOutput> {
135
+ if (locations.length === 0) {
136
+ return { text: "No references found for this symbol.", subtitle: "0 reference(s)" };
137
+ }
138
+ const byPath = new Map<string, InspectLocation[]>();
139
+ for (const location of locations) {
140
+ const existing = byPath.get(location.path);
141
+ if (existing) existing.push(location);
142
+ else byPath.set(location.path, [location]);
143
+ }
144
+ const paths = [...byPath.keys()];
145
+ const sections = [`Found ${locations.length} reference(s) in ${paths.length} file(s):`];
146
+ for (const path of paths.slice(0, MAX_FILES_LISTED)) {
147
+ const entries = byPath.get(path);
148
+ if (!entries) continue;
149
+ const shown = entries.slice(0, MAX_ENTRIES_PER_FILE);
150
+ sections.push(`### ${path} (${entries.length})`);
151
+ for (const entry of shown) {
152
+ const snippet = await lineSnippet(path, entry.line, cache);
153
+ sections.push(
154
+ `- ${entry.line + 1}:${entry.character + 1}${snippet === "" ? "" : `: ${snippet}`}`,
155
+ );
156
+ }
157
+ if (entries.length > shown.length) {
158
+ sections.push(`(+${entries.length - shown.length} more in this file)`);
159
+ }
160
+ }
161
+ const omitted = paths.slice(MAX_FILES_LISTED);
162
+ if (omitted.length > 0) {
163
+ const omittedCount = omitted.reduce((sum, path) => sum + (byPath.get(path)?.length ?? 0), 0);
164
+ sections.push(`(+${omittedCount} reference(s) in ${omitted.length} more file(s) not shown)`);
165
+ }
166
+ return {
167
+ text: sections.join("\n"),
168
+ subtitle: `${locations.length} reference(s) in ${paths.length} file(s)`,
169
+ };
170
+ }
171
+
172
+ type MarkedString = string | { language: string; value: string };
173
+
174
+ function formatMarkedString(marked: MarkedString): string {
175
+ return typeof marked === "string" ? marked : `\`\`\`${marked.language}\n${marked.value}\n\`\`\``;
176
+ }
177
+
178
+ /** hover contents 原样透传:仅做结构格式化(MarkedString → code fence),不改写内容。 */
179
+ function formatHoverContents(contents: Hover["contents"]): string {
180
+ if (typeof contents === "string") return contents;
181
+ if (Array.isArray(contents)) {
182
+ return contents.map((marked) => formatMarkedString(marked)).join("\n\n");
183
+ }
184
+ if ("language" in contents) return formatMarkedString(contents);
185
+ return contents.value;
186
+ }
187
+
188
+ /**
189
+ * 行内同名候选逐个探测:输出一致即同一符号的多次出现;不一致报歧义并列出
190
+ * 各候选的 1-based 列号(与 lsp-rename 的消歧行为一致)。
191
+ */
192
+ async function probeSymbolCandidates(options: {
193
+ content: string;
194
+ filePath: string;
195
+ line: number;
196
+ symbol: string;
197
+ character?: number;
198
+ signal?: AbortSignal;
199
+ probe: (position: LspPosition) => Promise<InspectOutput>;
200
+ }): Promise<InspectOutput> {
201
+ const candidates = symbolCandidates(
202
+ options.content,
203
+ options.line - 1,
204
+ options.symbol,
205
+ options.character === undefined ? undefined : options.character - 1,
206
+ );
207
+ if (candidates.length === 0) {
208
+ throw new Error(
209
+ `Symbol "${options.symbol}" not found on line ${options.line} of ${options.filePath}. Read the file again and locate the symbol.`,
210
+ );
211
+ }
212
+ const outputs: InspectOutput[] = [];
213
+ for (const candidate of candidates) {
214
+ options.signal?.throwIfAborted();
215
+ outputs.push(await options.probe(candidate));
216
+ }
217
+ const groups = new Map<string, { output: InspectOutput; candidates: LspPosition[] }>();
218
+ for (const [index, candidate] of candidates.entries()) {
219
+ const output = outputs[index];
220
+ if (!output) continue;
221
+ const group = groups.get(output.text);
222
+ if (group) group.candidates.push(candidate);
223
+ else groups.set(output.text, { output, candidates: [candidate] });
224
+ }
225
+ if (groups.size > 1) {
226
+ const listing = [...groups.values()]
227
+ .map((group) =>
228
+ group.candidates
229
+ .map((candidate) => `- line ${candidate.line + 1}, column ${candidate.character + 1}`)
230
+ .join("\n"),
231
+ )
232
+ .join("\n");
233
+ throw new Error(
234
+ `Ambiguous symbol on line ${options.line} of ${options.filePath}: several distinct symbols share the name "${options.symbol}". Retry with 'character' (1-based) to pick one:\n${listing}`,
235
+ );
236
+ }
237
+ const first = [...groups.values()][0];
238
+ if (!first) throw new Error("LSP inspect returned no result");
239
+ return first.output;
240
+ }
241
+
242
+ export function registerLspInspectTools(pi: ExtensionAPI, service: LspService): void {
243
+ pi.registerTool({
244
+ name: "lsp-find-definition",
245
+ label: "Lsp Find Definition",
246
+ description:
247
+ "Find where a code symbol is defined via LSP. Returns 1-based path:line:col locations with source line snippets.",
248
+ promptSnippet: "Find symbol definitions via LSP",
249
+ promptGuidelines: [FIND_DEFINITION_PROMPT],
250
+ parameters: POSITION_SCHEMA,
251
+ async execute(_id, params, signal, _onUpdate, ctx) {
252
+ signal?.throwIfAborted();
253
+ const { path: filePath, content } = await readSymbolFile(ctx.cwd, params.file_path);
254
+ const cache = new Map<string, string[]>();
255
+ const output = await probeSymbolCandidates({
256
+ content,
257
+ filePath,
258
+ line: params.line,
259
+ symbol: params.symbol,
260
+ character: params.character,
261
+ signal,
262
+ probe: async (position) => {
263
+ const result = await service.inspect({
264
+ file: filePath,
265
+ cwd: ctx.cwd,
266
+ line: position.line,
267
+ character: position.character,
268
+ query: "definition",
269
+ options: { signal },
270
+ });
271
+ return formatDefinitionLocations(result.locations, cache);
272
+ },
273
+ });
274
+ return {
275
+ content: [{ type: "text" as const, text: output.text }],
276
+ details: {
277
+ pendant: {
278
+ title: "lsp-find-definition",
279
+ subtitle: `${params.symbol} · ${output.subtitle}`,
280
+ },
281
+ },
282
+ };
283
+ },
284
+ });
285
+
286
+ pi.registerTool({
287
+ name: "lsp-find-reference",
288
+ label: "Lsp Find Reference",
289
+ description:
290
+ "Find all references to a code symbol across the workspace via LSP (includes the declaration). Grouped by file with 1-based line:col and source snippets.",
291
+ promptSnippet: "Find symbol references via LSP",
292
+ promptGuidelines: [FIND_REFERENCE_PROMPT],
293
+ parameters: POSITION_SCHEMA,
294
+ async execute(_id, params, signal, _onUpdate, ctx) {
295
+ signal?.throwIfAborted();
296
+ const { path: filePath, content } = await readSymbolFile(ctx.cwd, params.file_path);
297
+ const cache = new Map<string, string[]>();
298
+ const output = await probeSymbolCandidates({
299
+ content,
300
+ filePath,
301
+ line: params.line,
302
+ symbol: params.symbol,
303
+ character: params.character,
304
+ signal,
305
+ probe: async (position) => {
306
+ const result = await service.inspect({
307
+ file: filePath,
308
+ cwd: ctx.cwd,
309
+ line: position.line,
310
+ character: position.character,
311
+ query: "references",
312
+ options: { signal },
313
+ });
314
+ return formatReferenceLocations(result.locations, cache);
315
+ },
316
+ });
317
+ return {
318
+ content: [{ type: "text" as const, text: output.text }],
319
+ details: {
320
+ pendant: {
321
+ title: "lsp-find-reference",
322
+ subtitle: `${params.symbol} · ${output.subtitle}`,
323
+ },
324
+ },
325
+ };
326
+ },
327
+ });
328
+
329
+ pi.registerTool({
330
+ name: "lsp-inspect",
331
+ label: "Lsp Inspect",
332
+ description:
333
+ "Get hover information (type signature, documentation) for a code symbol via LSP. Content is passed through from the language server.",
334
+ promptSnippet: "Get hover info for a symbol via LSP",
335
+ promptGuidelines: [INSPECT_PROMPT],
336
+ parameters: POSITION_SCHEMA,
337
+ async execute(_id, params, signal, _onUpdate, ctx) {
338
+ signal?.throwIfAborted();
339
+ const { path: filePath, content } = await readSymbolFile(ctx.cwd, params.file_path);
340
+ const output = await probeSymbolCandidates({
341
+ content,
342
+ filePath,
343
+ line: params.line,
344
+ symbol: params.symbol,
345
+ character: params.character,
346
+ signal,
347
+ probe: async (position) => {
348
+ const result = await service.inspect({
349
+ file: filePath,
350
+ cwd: ctx.cwd,
351
+ line: position.line,
352
+ character: position.character,
353
+ query: "hover",
354
+ options: { signal },
355
+ });
356
+ if (result.hover === null) {
357
+ return {
358
+ text: `No hover information for '${params.symbol}' at line ${params.line} of ${filePath}.`,
359
+ subtitle: "no hover info",
360
+ };
361
+ }
362
+ return {
363
+ text: formatHoverContents(result.hover.contents),
364
+ subtitle: "hover",
365
+ };
366
+ },
367
+ });
368
+ return {
369
+ content: [{ type: "text" as const, text: output.text }],
370
+ details: {
371
+ pendant: { title: "lsp-inspect", subtitle: `${params.symbol} · ${output.subtitle}` },
372
+ },
373
+ };
374
+ },
375
+ });
376
+ }
@@ -0,0 +1,8 @@
1
+ ## LSP Find Definition
2
+
3
+ Find where a code symbol is defined and update nothing — read-only lookup via LSP.
4
+
5
+ - Locate the symbol with `file_path` + `line` (1-based) + `symbol` (the symbol's name exactly as it appears on that line). Any occurrence works — it does not have to be the definition.
6
+ - The tool computes the column itself; do not pass `character` unless asked to disambiguate.
7
+ - When several distinct symbols share the same name on that line, the tool refuses to guess: it reports an ambiguity error listing the candidate columns. Re-run with `character` (1-based) to pick one.
8
+ - Returns every definition site as `path:line:col` (1-based) with a source line snippet — imports, re-exports and overloads are resolved by the language server, unlike text search.
@@ -0,0 +1,8 @@
1
+ ## LSP Find Reference
2
+
3
+ Find every reference to a code symbol across the workspace — read-only lookup via LSP.
4
+
5
+ - Locate the symbol with `file_path` + `line` (1-based) + `symbol` (the symbol's name exactly as it appears on that line). Any occurrence works — it does not have to be the definition.
6
+ - The tool computes the column itself; do not pass `character` unless asked to disambiguate.
7
+ - When several distinct symbols share the same name on that line, the tool refuses to guess: it reports an ambiguity error listing the candidate columns. Re-run with `character` (1-based) to pick one.
8
+ - Results are grouped by file with 1-based `line:col` and source snippets; the declaration site is included. Run it before `lsp-rename` to preview the blast radius.
@@ -0,0 +1,8 @@
1
+ ## LSP Inspect
2
+
3
+ Read a code symbol's hover information (type signature, documentation) without opening the file — read-only via LSP.
4
+
5
+ - Locate the symbol with `file_path` + `line` (1-based) + `symbol` (the symbol's name exactly as it appears on that line). Any occurrence works — it does not have to be the definition.
6
+ - The tool computes the column itself; do not pass `character` unless asked to disambiguate.
7
+ - When several distinct symbols share the same name on that line, the tool refuses to guess: it reports an ambiguity error listing the candidate columns. Re-run with `character` (1-based) to pick one.
8
+ - The hover content is passed through from the language server as-is.
@@ -5,5 +5,3 @@ Rename a code symbol (function, class, variable, ...) and update every reference
5
5
  - Locate the symbol with `file_path` + `line` (1-based) + `symbol` (the symbol's name exactly as it appears on that line). Any occurrence works — it does not have to be the definition.
6
6
  - The tool computes the column itself; do not pass `character` unless asked to disambiguate.
7
7
  - When several distinct symbols share the same name on that line, the tool refuses to guess: it reports an ambiguity error listing the candidate columns. Re-run with `character` (1-based) to pick one.
8
- - Requires an LSP server of kind `language` (per lsp.json) for the file; linter-only servers cannot rename.
9
- - After the rename, affected files are reported with their edit counts and LSP diagnostics. Renamed files are marked as read — no re-Read needed before further edits.
@@ -25,7 +25,7 @@ import type { ExtensionAPI, ExtensionUIContext } from "@earendil-works/pi-coding
25
25
  import { minimatch } from "minimatch";
26
26
  import { type Static, Type } from "typebox";
27
27
  import { Value } from "typebox/value";
28
- import type { WorkspaceEdit } from "vscode-languageserver-types";
28
+ import type { Hover, WorkspaceEdit } from "vscode-languageserver-types";
29
29
 
30
30
  import { type LspServerAdapter } from "./adapter.js";
31
31
  import {
@@ -33,6 +33,8 @@ import {
33
33
  type CreateInput,
34
34
  type Diagnostic,
35
35
  type Info as LspClient,
36
+ type InspectLocation,
37
+ LspMethodNotSupportedError,
36
38
  RenameNotPossibleError,
37
39
  WATCH_KIND_CHANGE,
38
40
  WATCH_KIND_CREATE,
@@ -242,6 +244,16 @@ interface LspState {
242
244
  /** 渲染 LSP status 文本的回调(传入 undefined 表示清除)。 */
243
245
  export type StatusRenderer = (text: string | undefined) => void;
244
246
 
247
+ export type LspInspectQuery = "definition" | "references" | "hover";
248
+
249
+ /**
250
+ * 返回类型与 query 泛型关联:query 为 "hover" 时返回 hover 内容,否则返回
251
+ * 位置列表。实现内部用 cast 建立关联(TS 无法验证分支与泛型的对应关系)。
252
+ */
253
+ export type LspInspectResult<Q extends LspInspectQuery = LspInspectQuery> = Q extends "hover"
254
+ ? { serverID: string; query: "hover"; hover: Hover | null }
255
+ : { serverID: string; query: "definition" | "references"; locations: InspectLocation[] };
256
+
245
257
  export interface LspRequestOptions {
246
258
  notify?: ExtensionUIContext["notify"];
247
259
  /** 中止时提前结束诊断等待(已中止时直接跳过诊断)。 */
@@ -288,6 +300,19 @@ export interface LspService {
288
300
  newName: string;
289
301
  options?: LspRequestOptions;
290
302
  }): Promise<{ serverID: string; edit: WorkspaceEdit; placeholder?: string }>;
303
+ /**
304
+ * 只读符号查询(definition / references / hover):只面向 kind 为 "language"
305
+ * 的服务器,按配置顺序取第一个成功结果;服务器不支持该方法(MethodNotFound)
306
+ * 时跳过并尝试下一个,全部不支持时抛聚合错误。line / character 为 0-based。
307
+ */
308
+ inspect<Q extends LspInspectQuery>(request: {
309
+ file: string;
310
+ cwd: string;
311
+ line: number;
312
+ character: number;
313
+ query: Q;
314
+ options?: LspRequestOptions;
315
+ }): Promise<LspInspectResult<Q>>;
291
316
  shutdownAll(): Promise<void>;
292
317
  /** 停止全部服务器并禁用 LSP:之后工具调用不再 spawn,直到 start/reload。 */
293
318
  stop(): Promise<void>;
@@ -690,6 +715,56 @@ export function createLspService(
690
715
  : new Error(`LSP rename failed on all servers — ${detail}`);
691
716
  }
692
717
 
718
+ /** 只读符号查询:与 rename 同款多服务器策略,但 MethodNotFound 是"跳过"而非失败。 */
719
+ async function inspect<Q extends LspInspectQuery>(request: {
720
+ file: string;
721
+ cwd: string;
722
+ line: number;
723
+ character: number;
724
+ query: Q;
725
+ options?: LspRequestOptions;
726
+ }): Promise<LspInspectResult<Q>> {
727
+ const clients = await getClients(
728
+ request.file,
729
+ request.cwd,
730
+ request.options?.notify,
731
+ (adapter) => adapter.kind !== "linter",
732
+ );
733
+ if (clients.length === 0) {
734
+ throw new Error(
735
+ `no LSP language server available for ${request.file} (check lsp.json servers and kind)`,
736
+ );
737
+ }
738
+ const failures: { serverID: string; error: unknown }[] = [];
739
+ for (const client of clients) {
740
+ const position = { path: request.file, line: request.line, character: request.character };
741
+ try {
742
+ if (request.query === "hover") {
743
+ const hover = await client.hover(position);
744
+ return { serverID: client.serverID, query: "hover", hover } as LspInspectResult<Q>;
745
+ }
746
+ const locations =
747
+ request.query === "definition"
748
+ ? await client.definition(position)
749
+ : await client.references(position);
750
+ return {
751
+ serverID: client.serverID,
752
+ query: request.query,
753
+ locations,
754
+ } as LspInspectResult<Q>;
755
+ } catch (error) {
756
+ failures.push({ serverID: client.serverID, error });
757
+ }
758
+ }
759
+ const allNotSupported = failures.every((f) => f.error instanceof LspMethodNotSupportedError);
760
+ const detail = failures
761
+ .map((f) => `${f.serverID}: ${f.error instanceof Error ? f.error.message : String(f.error)}`)
762
+ .join("; ");
763
+ throw allNotSupported
764
+ ? new LspMethodNotSupportedError("all configured servers", `textDocument/${request.query}`)
765
+ : new Error(`LSP inspect failed on all servers — ${detail}`);
766
+ }
767
+
693
768
  /** 关闭全部 client 并清空缓存;closing 置 true 让 in-flight spawn 自行退出。 */
694
769
  async function closeAll(): Promise<void> {
695
770
  state.closing = true;
@@ -752,6 +827,7 @@ export function createLspService(
752
827
  diagnostics,
753
828
  lspDiagnosticsForFile,
754
829
  rename,
830
+ inspect,
755
831
  shutdownAll,
756
832
  stop,
757
833
  start,
@@ -0,0 +1,237 @@
1
+ /**
2
+ * lsp-rename 工具壳,由 claude-code / opencode 两个工具集共享注册。
3
+ *
4
+ * rename 的纯逻辑(候选枚举、同名消歧、WorkspaceEdit 展开)在 ./rename.ts,
5
+ * 这里只负责工具注册与执行编排:按行内候选逐个探测 → canonicalizeEdit 分组
6
+ * 消歧 → expandWorkspaceEdit 内存展开 → 审批 → 写盘 → 诊断。
7
+ *
8
+ * 两个工具集行为一致,唯一差异是 reads 记账:跟踪 read-before-write 状态的
9
+ * 工具集通过 hooks.recordReads 把重命名文件标记为已读并随 details 持久化;
10
+ * 不跟踪该状态的工具集不传 hook。
11
+ */
12
+
13
+ import { readFileSync } from "node:fs";
14
+ import { readFile, stat, writeFile } from "node:fs/promises";
15
+ import { fileURLToPath } from "node:url";
16
+
17
+ import {
18
+ type ExtensionAPI,
19
+ generateDiffString,
20
+ withFileMutationQueue,
21
+ } from "@earendil-works/pi-coding-agent";
22
+ import { Type } from "typebox";
23
+
24
+ import { resolvePathArg } from "../path.js";
25
+ import type { ToolPendant } from "../pendant.ts";
26
+ import { guardWriteAccess } from "../write-guard.js";
27
+ import { RenameNotPossibleError } from "./client.js";
28
+ import type { LspService } from "./lsp.js";
29
+ import {
30
+ type AppliedFileEdit,
31
+ canonicalizeEdit,
32
+ expandWorkspaceEdit,
33
+ symbolCandidates,
34
+ } from "./rename.js";
35
+
36
+ export interface LspRenameHooks {
37
+ /**
38
+ * rename 落盘后对每个被修改文件做已读记账;返回的 map 随 details.reads
39
+ * 持久化,供 session 恢复时重放。不跟踪已读状态的工具集不提供该 hook。
40
+ */
41
+ recordReads?: (applied: readonly AppliedFileEdit[]) => Promise<Record<string, unknown>>;
42
+ }
43
+
44
+ const LSP_RENAME_PROMPT = readFileSync(
45
+ fileURLToPath(new URL("lsp-rename.md", import.meta.url)),
46
+ "utf8",
47
+ ).trim();
48
+
49
+ export function registerLspRenameTool(
50
+ pi: ExtensionAPI,
51
+ service: LspService,
52
+ hooks: LspRenameHooks = {},
53
+ ): void {
54
+ pi.registerTool({
55
+ name: "lsp-rename",
56
+ label: "Lsp Rename",
57
+ description: "Rename a code symbol and update all references across the workspace via LSP",
58
+ promptSnippet: "Workspace-wide symbol rename via LSP",
59
+ promptGuidelines: [LSP_RENAME_PROMPT],
60
+ parameters: Type.Object(
61
+ {
62
+ file_path: Type.String({
63
+ description:
64
+ "A file containing the symbol (absolute path, or relative / ~/ path resolved against the session cwd)",
65
+ }),
66
+ line: Type.Integer({
67
+ minimum: 1,
68
+ description:
69
+ "1-based line number where the symbol appears (any occurrence works, not only the definition)",
70
+ }),
71
+ symbol: Type.String({
72
+ minLength: 1,
73
+ description: "The symbol's name exactly as it appears on that line",
74
+ }),
75
+ new_name: Type.String({ minLength: 1, description: "The new name for the symbol" }),
76
+ character: Type.Optional(
77
+ Type.Integer({
78
+ minimum: 1,
79
+ description:
80
+ "1-based character offset of the symbol on that line. Only needed when the tool reports an ambiguity error on this line",
81
+ }),
82
+ ),
83
+ },
84
+ { additionalProperties: false },
85
+ ),
86
+ async execute(_id, params, signal, _onUpdate, ctx) {
87
+ signal?.throwIfAborted();
88
+
89
+ const filePath = resolvePathArg(ctx.cwd, params.file_path);
90
+ let content: string;
91
+ try {
92
+ await stat(filePath);
93
+ content = await readFile(filePath, "utf8");
94
+ } catch (error) {
95
+ if (error instanceof Error && "code" in error && error.code === "ENOENT") {
96
+ throw new Error(`File does not exist: ${filePath}`, { cause: error });
97
+ }
98
+ throw error;
99
+ }
100
+
101
+ const notify = (message: string, level: "info" | "warning" | "error") =>
102
+ ctx.ui.notify(message, level);
103
+ const options = { notify, signal };
104
+
105
+ // ── 按 symbol 名枚举行内候选,逐候选探测与消歧 ────────────────────────
106
+ const candidates = symbolCandidates(
107
+ content,
108
+ params.line - 1,
109
+ params.symbol,
110
+ params.character === undefined ? undefined : params.character - 1,
111
+ );
112
+ if (candidates.length === 0) {
113
+ throw new Error(
114
+ `Symbol "${params.symbol}" not found on line ${params.line} of ${filePath}. Read the file again and locate the symbol.`,
115
+ );
116
+ }
117
+ const successes: { result: Awaited<ReturnType<typeof service.rename>> }[] = [];
118
+ const notPossibleErrors: string[] = [];
119
+ for (const candidate of candidates) {
120
+ signal?.throwIfAborted();
121
+ try {
122
+ const result = await service.rename({
123
+ file: filePath,
124
+ cwd: ctx.cwd,
125
+ line: candidate.line,
126
+ character: candidate.character,
127
+ newName: params.new_name,
128
+ options,
129
+ });
130
+ successes.push({ result });
131
+ } catch (error) {
132
+ if (error instanceof RenameNotPossibleError) {
133
+ notPossibleErrors.push(error.message);
134
+ continue;
135
+ }
136
+ throw error;
137
+ }
138
+ }
139
+ if (successes.length === 0) {
140
+ throw new RenameNotPossibleError(
141
+ notPossibleErrors.length > 0
142
+ ? notPossibleErrors.join("; ")
143
+ : `no renameable symbol "${params.symbol}" on line ${params.line} of ${filePath}`,
144
+ );
145
+ }
146
+
147
+ // 同一符号的多次出现编辑集合一致;不一致即为不同符号 → 要求补 character
148
+ const groups = new Map<
149
+ string,
150
+ { result: (typeof successes)[number]["result"]; candidates: typeof candidates }
151
+ >();
152
+ for (const [index, candidate] of candidates.entries()) {
153
+ const entry = successes[index];
154
+ if (!entry) continue;
155
+ const key = canonicalizeEdit(entry.result.edit);
156
+ const group = groups.get(key);
157
+ if (group) group.candidates.push(candidate);
158
+ else groups.set(key, { result: entry.result, candidates: [candidate] });
159
+ }
160
+ if (groups.size > 1) {
161
+ const listing = [...groups.values()]
162
+ .map((group) =>
163
+ group.candidates
164
+ .map(
165
+ (candidate) =>
166
+ `- line ${candidate.line + 1}, column ${candidate.character + 1} (rename target: ${group.result.placeholder ?? "unknown"})`,
167
+ )
168
+ .join("\n"),
169
+ )
170
+ .join("\n");
171
+ throw new Error(
172
+ `Ambiguous rename target on line ${params.line} of ${filePath}: several distinct symbols share the name "${params.symbol}". Retry with 'character' (1-based) to pick one:\n${listing}`,
173
+ );
174
+ }
175
+ const firstGroup = [...groups.values()][0];
176
+ if (!firstGroup) throw new Error("LSP rename returned no target");
177
+ const { result } = firstGroup;
178
+
179
+ // ── 内存展开 → 审批 → 写盘 → 诊断 ────────────────────────────────────
180
+ signal?.throwIfAborted();
181
+ const applied = await expandWorkspaceEdit(result.edit, (path) => readFile(path, "utf8"));
182
+ if (applied.length === 0) {
183
+ throw new Error("LSP rename returned no edits");
184
+ }
185
+ for (const fileEdit of applied) {
186
+ await guardWriteAccess(ctx, {
187
+ toolName: "lsp-rename",
188
+ absolutePath: fileEdit.path,
189
+ change: { oldText: fileEdit.oldText, newText: fileEdit.newText },
190
+ });
191
+ }
192
+
193
+ const diffs: string[] = [];
194
+ for (const fileEdit of applied) {
195
+ await withFileMutationQueue(fileEdit.path, async () => {
196
+ await writeFile(fileEdit.path, fileEdit.newText, "utf8");
197
+ });
198
+ diffs.push(
199
+ `### ${fileEdit.path} (${fileEdit.changeCount} edit(s))\n\n\`\`\`diff\n${generateDiffString(fileEdit.oldText, fileEdit.newText).diff}\n\`\`\``,
200
+ );
201
+ }
202
+ const reads = hooks.recordReads === undefined ? undefined : await hooks.recordReads(applied);
203
+
204
+ let diagnosticText = "";
205
+ for (const fileEdit of applied) {
206
+ signal?.throwIfAborted();
207
+ const diagnostics = await service.lspDiagnosticsForFile(fileEdit.path, ctx.cwd, {
208
+ notify,
209
+ signal,
210
+ });
211
+ if (diagnostics.text !== "") diagnosticText += `${diagnostics.text}\n`;
212
+ }
213
+
214
+ const renamed =
215
+ result.placeholder !== undefined && result.placeholder !== params.new_name
216
+ ? `'${result.placeholder}' -> '${params.new_name}'`
217
+ : `to '${params.new_name}'`;
218
+ const summary = `Renamed ${renamed} across ${applied.length} file(s).`;
219
+ const text =
220
+ diagnosticText === ""
221
+ ? summary
222
+ : `${summary}\n\nLSP diagnostics detected in renamed files:\n${diagnosticText.trimEnd()}`;
223
+
224
+ return {
225
+ content: [{ type: "text" as const, text }],
226
+ details: {
227
+ ...(reads !== undefined && { reads }),
228
+ pendant: {
229
+ title: "lsp-rename",
230
+ subtitle: `${renamed} · ${applied.length} file(s)`,
231
+ markdown: diffs.join("\n\n"),
232
+ } satisfies ToolPendant,
233
+ },
234
+ };
235
+ },
236
+ });
237
+ }
@@ -1,9 +1,9 @@
1
1
  /**
2
2
  * Opencode File Tools —— read / edit / write 统一构建点。
3
3
  *
4
- * 三个工具在同一个 registerFileTools(pi, service) 里注册,共享同一个 LSP
5
- * service 实例(registerLsp 创建的闭包变量),与 claude-code/files.ts 共享
6
- * read-snapshot state 的方式一致;不再用模块级全局缓存。
4
+ * read / edit / write 三个工具在同一个 registerFileTools(pi, service) 里注册,
5
+ * 共享同一个 LSP service 实例(registerLsp 创建的闭包变量);lsp-rename
6
+ * 工具壳与 claude-code 共享,同样挂进 registerFileTools。
7
7
  *
8
8
  * 对齐官方 v1(packages/opencode/src/tool/{read,edit,write}.ts):
9
9
  * - read:流式分行(LF / CRLF / CR)、每行 `N: ` 行号前缀、单行 2000
@@ -30,7 +30,9 @@ import {
30
30
  import { Type } from "typebox";
31
31
 
32
32
  import { appendLspDiagnosticText } from "../lib/lsp/diagnostic.js";
33
+ import { registerLspInspectTools } from "../lib/lsp/inspect-tool.js";
33
34
  import { type LspService, registerLsp } from "../lib/lsp/lsp.js";
35
+ import { registerLspRenameTool } from "../lib/lsp/rename-tool.js";
34
36
  import { formatSubtitlePath } from "../lib/path.js";
35
37
  import { guardWriteAccess } from "../lib/write-guard.js";
36
38
  import { applyEdit, normalizeToLF, stripBom } from "./edit-engine.js";
@@ -696,6 +698,11 @@ export function registerFileTools(pi: ExtensionAPI, service: LspService): void {
696
698
  registerReadTool(pi, service);
697
699
  registerEditTool(pi, service);
698
700
  registerWriteTool(pi, service);
701
+ // 工具壳与 claude-code 共享(lib/lsp/rename-tool.ts);opencode 不跟踪
702
+ // read-before-write 状态,不传 recordReads hook。
703
+ registerLspRenameTool(pi, service);
704
+ // 只读符号查询工具(find-definition / find-reference / inspect)与 claude-code 共享。
705
+ registerLspInspectTools(pi, service);
699
706
  }
700
707
 
701
708
  /** 独立入口:创建 LSP service(闭包共享给三个工具)并注册。 */
@@ -89,7 +89,8 @@ const SETTINGS_PATH = join(getAgentDir(), "settings.json");
89
89
  * implementation file (they share a read-snapshot state); the `tools`
90
90
  * allowlist still exposes only the declared subset. The opencode file tools
91
91
  * (read/edit/write) likewise share opencode/files.ts (they share the LSP
92
- * service instance).
92
+ * service instance); that file also registers the shared `lsp-rename` and
93
+ * LSP inspect tools, which stay hidden unless a subagent declares them.
93
94
  */
94
95
  const TOOL_EXTENSION_OVERRIDES: Record<string, string> = {
95
96
  read: "opencode/files.ts",