@trim21/personal-pi-extensions 0.1.488 → 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
CHANGED
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",
|