@trim21/personal-pi-extensions 0.1.489 → 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 +1 -1
- package/src/claude-code/files.ts +3 -0
- package/src/lib/lsp/client.ts +128 -1
- package/src/lib/lsp/inspect-tool.ts +376 -0
- package/src/lib/lsp/lsp-find-definition.md +8 -0
- package/src/lib/lsp/lsp-find-reference.md +8 -0
- package/src/lib/lsp/lsp-inspect.md +8 -0
- package/src/lib/lsp/lsp.ts +77 -1
- package/src/opencode/files.ts +3 -0
- package/src/spawn-agent.ts +2 -2
package/package.json
CHANGED
package/src/claude-code/files.ts
CHANGED
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
import { Type } from "typebox";
|
|
16
16
|
|
|
17
17
|
import { appendLspDiagnosticText } from "../lib/lsp/diagnostic.js";
|
|
18
|
+
import { registerLspInspectTools } from "../lib/lsp/inspect-tool.js";
|
|
18
19
|
import { type LspService, registerLsp } from "../lib/lsp/lsp.js";
|
|
19
20
|
import { registerLspRenameTool } from "../lib/lsp/rename-tool.js";
|
|
20
21
|
import { formatSubtitlePath } from "../lib/path.js";
|
|
@@ -689,6 +690,8 @@ export function registerFileTools(
|
|
|
689
690
|
return reads;
|
|
690
691
|
},
|
|
691
692
|
});
|
|
693
|
+
// 只读符号查询工具(find-definition / find-reference / inspect)与 opencode 共享。
|
|
694
|
+
registerLspInspectTools(pi, service);
|
|
692
695
|
}
|
|
693
696
|
|
|
694
697
|
/** 会更新 reads state 并随 details 持久化快照的工具名。 */
|
package/src/lib/lsp/client.ts
CHANGED
|
@@ -20,7 +20,13 @@ import {
|
|
|
20
20
|
StreamMessageReader,
|
|
21
21
|
StreamMessageWriter,
|
|
22
22
|
} from "vscode-jsonrpc/node";
|
|
23
|
-
import type {
|
|
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.
|
package/src/lib/lsp/lsp.ts
CHANGED
|
@@ -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,
|
package/src/opencode/files.ts
CHANGED
|
@@ -30,6 +30,7 @@ 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";
|
|
34
35
|
import { registerLspRenameTool } from "../lib/lsp/rename-tool.js";
|
|
35
36
|
import { formatSubtitlePath } from "../lib/path.js";
|
|
@@ -700,6 +701,8 @@ export function registerFileTools(pi: ExtensionAPI, service: LspService): void {
|
|
|
700
701
|
// 工具壳与 claude-code 共享(lib/lsp/rename-tool.ts);opencode 不跟踪
|
|
701
702
|
// read-before-write 状态,不传 recordReads hook。
|
|
702
703
|
registerLspRenameTool(pi, service);
|
|
704
|
+
// 只读符号查询工具(find-definition / find-reference / inspect)与 claude-code 共享。
|
|
705
|
+
registerLspInspectTools(pi, service);
|
|
703
706
|
}
|
|
704
707
|
|
|
705
708
|
/** 独立入口:创建 LSP service(闭包共享给三个工具)并注册。 */
|
package/src/spawn-agent.ts
CHANGED
|
@@ -89,8 +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); that file also registers the shared `lsp-rename`
|
|
93
|
-
* which
|
|
92
|
+
* service instance); that file also registers the shared `lsp-rename` and
|
|
93
|
+
* LSP inspect tools, which stay hidden unless a subagent declares them.
|
|
94
94
|
*/
|
|
95
95
|
const TOOL_EXTENSION_OVERRIDES: Record<string, string> = {
|
|
96
96
|
read: "opencode/files.ts",
|