@trim21/personal-pi-extensions 0.1.487 → 0.1.489
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/aft/tools.ts +39 -9
- package/src/claude-code/files.ts +12 -193
- package/src/{claude-code → lib/lsp}/lsp-rename.md +0 -2
- package/src/lib/lsp/rename-tool.ts +237 -0
- package/src/opencode/files.ts +7 -3
- package/src/spawn-agent.ts +2 -1
package/package.json
CHANGED
package/src/aft/tools.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import { readFileSync } from "node:fs";
|
|
9
9
|
import { stat } from "node:fs/promises";
|
|
10
|
+
import { setTimeout as sleep } from "node:timers/promises";
|
|
10
11
|
import { fileURLToPath } from "node:url";
|
|
11
12
|
|
|
12
13
|
import {
|
|
@@ -18,7 +19,7 @@ import {
|
|
|
18
19
|
PLAIN_CALLGRAPH_THEME,
|
|
19
20
|
type StatusSnapshot,
|
|
20
21
|
} from "@cortexkit/aft-bridge";
|
|
21
|
-
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
22
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
22
23
|
import { type Static, Type } from "typebox";
|
|
23
24
|
import { Value } from "typebox/value";
|
|
24
25
|
|
|
@@ -290,6 +291,42 @@ const CallgraphParams = Type.Object(
|
|
|
290
291
|
/** 只读导航的合法"否定答案":符号未定义或索引仍在构建——返回文本而非报错。 */
|
|
291
292
|
const CALLGRAPH_SOFT_CODES = new Set(["symbol_not_found", "callgraph_building"]);
|
|
292
293
|
|
|
294
|
+
/**
|
|
295
|
+
* callgraph 查询的 building 重试总预算与间隔。Rust 侧内联等待窗口
|
|
296
|
+
* (AFT_CALLGRAPH_BUILD_WAIT_MS)覆盖不到的场景会秒回 callgraph_building:
|
|
297
|
+
* 语义索引冷种子 gate 激活期间 callgraph 冷构建被 defer(大仓库可持续数分钟),
|
|
298
|
+
* 以及冷构建本身超过内联窗口。这里在扩展侧重试,预算耗尽才把 building
|
|
299
|
+
* 文本交给模型。
|
|
300
|
+
*/
|
|
301
|
+
export const CALLGRAPH_BUILD_RETRY_BUDGET_MS = 90_000;
|
|
302
|
+
const CALLGRAPH_BUILD_RETRY_INTERVAL_MS = 3_000;
|
|
303
|
+
|
|
304
|
+
export async function callCallgraphWithBuildRetry(
|
|
305
|
+
bridge: AftProjectTransport,
|
|
306
|
+
rawArgs: Record<string, unknown>,
|
|
307
|
+
extCtx: ExtensionContext,
|
|
308
|
+
timing?: { budgetMs: number; intervalMs: number },
|
|
309
|
+
): Promise<{ text: string; response: Record<string, unknown> }> {
|
|
310
|
+
const budgetMs = timing?.budgetMs ?? CALLGRAPH_BUILD_RETRY_BUDGET_MS;
|
|
311
|
+
const intervalMs = timing?.intervalMs ?? CALLGRAPH_BUILD_RETRY_INTERVAL_MS;
|
|
312
|
+
const deadline = Date.now() + budgetMs;
|
|
313
|
+
for (;;) {
|
|
314
|
+
const { text, response } = await callAftTool(
|
|
315
|
+
bridge,
|
|
316
|
+
"callgraph",
|
|
317
|
+
rawArgs,
|
|
318
|
+
extCtx,
|
|
319
|
+
undefined,
|
|
320
|
+
CALLGRAPH_SOFT_CODES,
|
|
321
|
+
);
|
|
322
|
+
const code = typeof response.code === "string" ? response.code : "";
|
|
323
|
+
if (code !== "callgraph_building" || Date.now() >= deadline) {
|
|
324
|
+
return { text, response };
|
|
325
|
+
}
|
|
326
|
+
await sleep(intervalMs);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
293
330
|
export function registerCallgraphTool(pi: ExtensionAPI, ctx: AftToolContext): void {
|
|
294
331
|
pi.registerTool({
|
|
295
332
|
name: "aft_callgraph",
|
|
@@ -317,14 +354,7 @@ export function registerCallgraphTool(pi: ExtensionAPI, ctx: AftToolContext): vo
|
|
|
317
354
|
includeUnresolved: params.includeUnresolved,
|
|
318
355
|
});
|
|
319
356
|
|
|
320
|
-
const { text, response } = await
|
|
321
|
-
bridgeFor(ctx),
|
|
322
|
-
"callgraph",
|
|
323
|
-
rawArgs,
|
|
324
|
-
extCtx,
|
|
325
|
-
undefined,
|
|
326
|
-
CALLGRAPH_SOFT_CODES,
|
|
327
|
-
);
|
|
357
|
+
const { text, response } = await callCallgraphWithBuildRetry(bridgeFor(ctx), rawArgs, extCtx);
|
|
328
358
|
const out =
|
|
329
359
|
text ||
|
|
330
360
|
formatCallgraphSections(params.op, response, PLAIN_CALLGRAPH_THEME, {
|
package/src/claude-code/files.ts
CHANGED
|
@@ -14,11 +14,10 @@ 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";
|
|
19
18
|
import { type LspService, registerLsp } from "../lib/lsp/lsp.js";
|
|
20
|
-
import {
|
|
21
|
-
import { formatSubtitlePath
|
|
19
|
+
import { registerLspRenameTool } from "../lib/lsp/rename-tool.js";
|
|
20
|
+
import { formatSubtitlePath } from "../lib/path.js";
|
|
22
21
|
import type { ToolPendant } from "../lib/pendant.ts";
|
|
23
22
|
import { guardWriteAccess } from "../lib/write-guard.js";
|
|
24
23
|
import {
|
|
@@ -60,10 +59,6 @@ const WRITE_PROMPT = readFileSync(
|
|
|
60
59
|
fileURLToPath(new URL("write.md", import.meta.url)),
|
|
61
60
|
"utf8",
|
|
62
61
|
).trim();
|
|
63
|
-
const LSP_RENAME_PROMPT = readFileSync(
|
|
64
|
-
fileURLToPath(new URL("lsp-rename.md", import.meta.url)),
|
|
65
|
-
"utf8",
|
|
66
|
-
).trim();
|
|
67
62
|
const IMAGE_MIMES = new Map<string, string>([
|
|
68
63
|
[".gif", "image/gif"],
|
|
69
64
|
[".jpeg", "image/jpeg"],
|
|
@@ -679,195 +674,19 @@ export function registerFileTools(
|
|
|
679
674
|
},
|
|
680
675
|
});
|
|
681
676
|
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
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
|
-
|
|
677
|
+
// lsp-rename 工具壳与 opencode 共享(lib/lsp/rename-tool.ts);本工具集
|
|
678
|
+
// 跟踪 read-before-write 状态,rename 落盘的文件要标记为已读并随 details
|
|
679
|
+
// 持久化(FILE_TOOL_NAMES 的 restoreFileReads 依赖 details.reads)。
|
|
680
|
+
registerLspRenameTool(pi, service, {
|
|
681
|
+
recordReads: async (applied) => {
|
|
844
682
|
const reads: Record<string, FileSnapshot> = {};
|
|
845
|
-
for (const
|
|
846
|
-
|
|
683
|
+
for (const fileEdit of applied) {
|
|
684
|
+
const key = await readStateKey(fileEdit.path);
|
|
685
|
+
const snapshot = snapshotOf(fileEdit.newText);
|
|
847
686
|
state.reads.set(key, snapshot);
|
|
687
|
+
reads[key] = snapshot;
|
|
848
688
|
}
|
|
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
|
-
};
|
|
689
|
+
return reads;
|
|
871
690
|
},
|
|
872
691
|
});
|
|
873
692
|
}
|
|
@@ -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.
|
|
@@ -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
|
+
}
|
package/src/opencode/files.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Opencode File Tools —— read / edit / write 统一构建点。
|
|
3
3
|
*
|
|
4
|
-
* 三个工具在同一个 registerFileTools(pi, service)
|
|
5
|
-
* service 实例(registerLsp
|
|
6
|
-
*
|
|
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
|
|
@@ -31,6 +31,7 @@ import { Type } from "typebox";
|
|
|
31
31
|
|
|
32
32
|
import { appendLspDiagnosticText } from "../lib/lsp/diagnostic.js";
|
|
33
33
|
import { type LspService, registerLsp } from "../lib/lsp/lsp.js";
|
|
34
|
+
import { registerLspRenameTool } from "../lib/lsp/rename-tool.js";
|
|
34
35
|
import { formatSubtitlePath } from "../lib/path.js";
|
|
35
36
|
import { guardWriteAccess } from "../lib/write-guard.js";
|
|
36
37
|
import { applyEdit, normalizeToLF, stripBom } from "./edit-engine.js";
|
|
@@ -696,6 +697,9 @@ export function registerFileTools(pi: ExtensionAPI, service: LspService): void {
|
|
|
696
697
|
registerReadTool(pi, service);
|
|
697
698
|
registerEditTool(pi, service);
|
|
698
699
|
registerWriteTool(pi, service);
|
|
700
|
+
// 工具壳与 claude-code 共享(lib/lsp/rename-tool.ts);opencode 不跟踪
|
|
701
|
+
// read-before-write 状态,不传 recordReads hook。
|
|
702
|
+
registerLspRenameTool(pi, service);
|
|
699
703
|
}
|
|
700
704
|
|
|
701
705
|
/** 独立入口:创建 LSP service(闭包共享给三个工具)并注册。 */
|
package/src/spawn-agent.ts
CHANGED
|
@@ -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` tool,
|
|
93
|
+
* which stays hidden unless a subagent declares it.
|
|
93
94
|
*/
|
|
94
95
|
const TOOL_EXTENSION_OVERRIDES: Record<string, string> = {
|
|
95
96
|
read: "opencode/files.ts",
|