@oh-my-pi/pi-coding-agent 17.2.11 → 17.2.12
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/CHANGELOG.md +13 -0
- package/dist/{CHANGELOG-d9xenpn9.md → CHANGELOG-k9ghy5sn.md} +13 -0
- package/dist/cli.js +10795 -10791
- package/dist/types/advisor/runtime.d.ts +1 -1
- package/dist/types/config/custom-models.d.ts +31 -0
- package/dist/types/config/model-config-values.d.ts +19 -0
- package/dist/types/config/model-patch.d.ts +93 -0
- package/dist/types/config/model-provider-discovery.d.ts +51 -0
- package/dist/types/config/model-registry.d.ts +5 -52
- package/dist/types/config/settings.d.ts +5 -3
- package/dist/types/debug/raw-sse-buffer.d.ts +9 -0
- package/dist/types/discovery/agent-plugin-format.d.ts +6 -13
- package/dist/types/discovery/contained-path.d.ts +2 -3
- package/dist/types/eval/executor-base.d.ts +8 -2
- package/dist/types/eval/kernel-session-registry.d.ts +60 -0
- package/dist/types/export/share.d.ts +1 -1
- package/dist/types/lsp/diagnostics.d.ts +96 -0
- package/dist/types/lsp/index.d.ts +7 -128
- package/dist/types/lsp/servers.d.ts +72 -0
- package/dist/types/lsp/tool.d.ts +42 -0
- package/dist/types/lsp/workspace-diagnostics.d.ts +12 -0
- package/dist/types/lsp/writethrough.d.ts +33 -0
- package/dist/types/mcp/transports/header-policy.d.ts +2 -1
- package/dist/types/modes/components/agent-hub-projection.d.ts +2 -7
- package/dist/types/modes/components/agent-hub-renderer.d.ts +0 -7
- package/dist/types/modes/theme/color.d.ts +19 -0
- package/dist/types/modes/theme/loader.d.ts +19 -0
- package/dist/types/modes/theme/schema.d.ts +175 -0
- package/dist/types/modes/theme/symbols.d.ts +28 -0
- package/dist/types/modes/theme/theme-class.d.ts +244 -0
- package/dist/types/modes/theme/theme.d.ts +9 -280
- package/dist/types/modes/theme/tui-adapters.d.ts +13 -0
- package/dist/types/registry/agent-registry.d.ts +1 -3
- package/dist/types/secrets/index.d.ts +3 -1
- package/dist/types/secrets/message-transform.d.ts +40 -0
- package/dist/types/secrets/obfuscator.d.ts +0 -108
- package/dist/types/secrets/placeholder-scan.d.ts +95 -0
- package/dist/types/secrets/placeholder.d.ts +94 -0
- package/dist/types/secrets/replacement.d.ts +82 -0
- package/dist/types/session/agent-session-types.d.ts +6 -0
- package/dist/types/session/agent-session.d.ts +1 -1
- package/dist/types/session/session-handoff.d.ts +3 -3
- package/dist/types/session/session-manager.d.ts +32 -0
- package/dist/types/session/session-provider-boundary.d.ts +1 -1
- package/dist/types/session/turn-recovery.d.ts +2 -2
- package/dist/types/slash-commands/builtin-collaboration.d.ts +2 -0
- package/dist/types/slash-commands/builtin-completions.d.ts +36 -0
- package/dist/types/slash-commands/builtin-control.d.ts +2 -0
- package/dist/types/slash-commands/builtin-lifecycle.d.ts +3 -0
- package/dist/types/slash-commands/builtin-marketplace.d.ts +11 -0
- package/dist/types/slash-commands/builtin-modes.d.ts +5 -0
- package/dist/types/slash-commands/builtin-registry.d.ts +1 -1
- package/dist/types/slash-commands/builtin-session.d.ts +2 -0
- package/dist/types/tools/gh-common.d.ts +69 -0
- package/dist/types/tools/gh-pr-checkout.d.ts +84 -0
- package/dist/types/tools/gh-pr-diff.d.ts +102 -0
- package/dist/types/tools/gh-run-watch.d.ts +78 -0
- package/dist/types/tools/gh-search.d.ts +61 -0
- package/dist/types/tools/gh-types.d.ts +340 -0
- package/dist/types/tools/gh-view.d.ts +88 -0
- package/dist/types/tools/gh.d.ts +4 -201
- package/dist/types/tools/read-archive.d.ts +16 -0
- package/dist/types/tools/read-format.d.ts +104 -0
- package/dist/types/tools/read-path-resolution.d.ts +19 -0
- package/dist/types/tools/read-pdf-images.d.ts +12 -0
- package/dist/types/tools/read-renderer.d.ts +24 -0
- package/dist/types/tools/read-selector.d.ts +27 -0
- package/dist/types/tools/read-sqlite.d.ts +16 -0
- package/dist/types/tools/read-summary.d.ts +19 -0
- package/dist/types/tools/read.d.ts +1 -23
- package/dist/types/tools/shell-tokenize.d.ts +2 -1
- package/dist/types/utils/changelog.d.ts +0 -5
- package/package.json +13 -13
- package/src/advisor/runtime.ts +12 -7
- package/src/config/custom-models.ts +188 -0
- package/src/config/model-config-values.ts +128 -0
- package/src/config/model-patch.ts +252 -0
- package/src/config/model-provider-discovery.ts +132 -0
- package/src/config/model-registry.ts +64 -704
- package/src/config/settings.ts +7 -3
- package/src/debug/raw-sse-buffer.ts +20 -0
- package/src/discovery/agent-plugin-format.ts +7 -7
- package/src/discovery/contained-path.ts +2 -5
- package/src/edit/hashline/diff.ts +5 -1
- package/src/eval/executor-base.ts +39 -6
- package/src/eval/jl/executor.ts +82 -371
- package/src/eval/kernel-session-registry.ts +398 -0
- package/src/eval/py/executor.ts +53 -261
- package/src/eval/rb/executor.ts +36 -279
- package/src/export/share.ts +2 -1
- package/src/lsp/diagnostics.ts +516 -0
- package/src/lsp/index.ts +20 -2819
- package/src/lsp/servers.ts +296 -0
- package/src/lsp/tool.ts +1352 -0
- package/src/lsp/workspace-diagnostics.ts +170 -0
- package/src/lsp/writethrough.ts +561 -0
- package/src/mcp/transports/header-policy.ts +1 -1
- package/src/modes/components/agent-hub-projection.ts +2 -2
- package/src/modes/components/agent-hub-renderer.ts +3 -7
- package/src/modes/controllers/command-controller.ts +4 -1
- package/src/modes/theme/color.ts +133 -0
- package/src/modes/theme/loader.ts +178 -0
- package/src/modes/theme/schema.ts +263 -0
- package/src/modes/theme/symbols.ts +1000 -0
- package/src/modes/theme/theme-class.ts +611 -0
- package/src/modes/theme/theme.ts +27 -2453
- package/src/modes/theme/tui-adapters.ts +279 -0
- package/src/registry/agent-registry.ts +2 -2
- package/src/secrets/index.ts +6 -12
- package/src/secrets/message-transform.ts +287 -0
- package/src/secrets/obfuscator.ts +39 -1303
- package/src/secrets/placeholder-scan.ts +506 -0
- package/src/secrets/placeholder.ts +309 -0
- package/src/secrets/replacement.ts +216 -0
- package/src/session/agent-session-types.ts +6 -0
- package/src/session/agent-session.ts +113 -7
- package/src/session/indexed-session-storage.ts +7 -2
- package/src/session/session-advisors.ts +32 -34
- package/src/session/session-handoff.ts +39 -20
- package/src/session/session-manager.ts +67 -3
- package/src/session/session-provider-boundary.ts +3 -6
- package/src/session/turn-recovery.ts +21 -12
- package/src/slash-commands/builtin-collaboration.ts +504 -0
- package/src/slash-commands/builtin-completions.ts +291 -0
- package/src/slash-commands/builtin-control.ts +78 -0
- package/src/slash-commands/builtin-lifecycle.ts +506 -0
- package/src/slash-commands/builtin-marketplace.ts +567 -0
- package/src/slash-commands/builtin-modes.ts +518 -0
- package/src/slash-commands/builtin-registry.ts +21 -3000
- package/src/slash-commands/builtin-session.ts +592 -0
- package/src/task/executor.ts +17 -14
- package/src/tools/gh-common.ts +288 -0
- package/src/tools/gh-pr-checkout.ts +679 -0
- package/src/tools/gh-pr-diff.ts +473 -0
- package/src/tools/gh-run-watch.ts +1015 -0
- package/src/tools/gh-search.ts +500 -0
- package/src/tools/gh-types.ts +379 -0
- package/src/tools/gh-view.ts +612 -0
- package/src/tools/gh.ts +109 -3821
- package/src/tools/read-archive.ts +209 -0
- package/src/tools/read-format.ts +593 -0
- package/src/tools/read-path-resolution.ts +36 -0
- package/src/tools/read-pdf-images.ts +250 -0
- package/src/tools/read-renderer.ts +289 -0
- package/src/tools/read-selector.ts +84 -0
- package/src/tools/read-sqlite.ts +215 -0
- package/src/tools/read-summary.ts +199 -0
- package/src/tools/read.ts +77 -1820
- package/src/tools/shell-tokenize.ts +1 -1
- package/src/utils/changelog.ts +1 -1
- package/src/utils/title-generator.ts +3 -1
- package/src/web/search/providers/zai.ts +12 -3
package/src/lsp/tool.ts
ADDED
|
@@ -0,0 +1,1352 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import type {
|
|
4
|
+
AgentTool,
|
|
5
|
+
AgentToolContext,
|
|
6
|
+
AgentToolResult,
|
|
7
|
+
AgentToolUpdateCallback,
|
|
8
|
+
ToolApprovalDecision,
|
|
9
|
+
} from "@oh-my-pi/pi-agent-core";
|
|
10
|
+
import { logger, prompt, untilAborted } from "@oh-my-pi/pi-utils";
|
|
11
|
+
import { type Theme, theme } from "../modes/theme/theme";
|
|
12
|
+
import lspDescription from "../prompts/tools/lsp.md" with { type: "text" };
|
|
13
|
+
import type { ToolSession } from "../tools";
|
|
14
|
+
import { truncateForPrompt } from "../tools/approval";
|
|
15
|
+
import { formatPathRelativeToCwd, resolveToCwd } from "../tools/path-utils";
|
|
16
|
+
import { ToolAbortError, ToolError, throwIfAborted } from "../tools/tool-errors";
|
|
17
|
+
import { clampTimeout } from "../tools/tool-timeouts";
|
|
18
|
+
import {
|
|
19
|
+
clearInitializationFailure,
|
|
20
|
+
ensureFileOpen,
|
|
21
|
+
getActiveClients,
|
|
22
|
+
getOrCreateClient,
|
|
23
|
+
type LspServerStatus,
|
|
24
|
+
refreshFile,
|
|
25
|
+
sendNotification,
|
|
26
|
+
sendRequest,
|
|
27
|
+
waitForProjectLoaded,
|
|
28
|
+
} from "./client";
|
|
29
|
+
import { getLinterClient } from "./clients";
|
|
30
|
+
import { getServersForFile } from "./config";
|
|
31
|
+
import {
|
|
32
|
+
BATCH_DIAGNOSTICS_WAIT_TIMEOUT_MS,
|
|
33
|
+
formatLocationWithContext,
|
|
34
|
+
hasRustWorkspaceAncestor,
|
|
35
|
+
isOnlyQueriedDeclaration,
|
|
36
|
+
MAX_GLOB_DIAGNOSTIC_TARGETS,
|
|
37
|
+
normalizeLocationResult,
|
|
38
|
+
PROJECT_INDEXED_ACTIONS,
|
|
39
|
+
REFERENCE_CONTEXT_LIMIT,
|
|
40
|
+
REFERENCES_RETRY_COUNT,
|
|
41
|
+
REFERENCES_RETRY_DELAY_MS,
|
|
42
|
+
SINGLE_DIAGNOSTICS_WAIT_TIMEOUT_MS,
|
|
43
|
+
WORKSPACE_SYMBOL_LIMIT,
|
|
44
|
+
waitForDiagnostics,
|
|
45
|
+
} from "./diagnostics";
|
|
46
|
+
import { applyTextEdits, applyWorkspaceEdit, flattenWorkspaceTextEdits, rangesOverlap } from "./edits";
|
|
47
|
+
import { detectLspmux } from "./lspmux";
|
|
48
|
+
import {
|
|
49
|
+
configCache,
|
|
50
|
+
getConfig,
|
|
51
|
+
getLspServerForFile,
|
|
52
|
+
getLspServers,
|
|
53
|
+
getLspServersForFile,
|
|
54
|
+
isMethodNotFoundError,
|
|
55
|
+
isProjectAwareLspServer,
|
|
56
|
+
LSP_READONLY_ACTIONS,
|
|
57
|
+
reloadServer,
|
|
58
|
+
} from "./servers";
|
|
59
|
+
import {
|
|
60
|
+
type CodeAction,
|
|
61
|
+
type CodeActionContext,
|
|
62
|
+
type Command,
|
|
63
|
+
type Diagnostic,
|
|
64
|
+
type DocumentSymbol,
|
|
65
|
+
type Hover,
|
|
66
|
+
type Location,
|
|
67
|
+
type LocationLink,
|
|
68
|
+
type LspParams,
|
|
69
|
+
type LspToolDetails,
|
|
70
|
+
lspSchema,
|
|
71
|
+
type ServerConfig,
|
|
72
|
+
type SymbolInformation,
|
|
73
|
+
type TextEdit,
|
|
74
|
+
type WorkspaceEdit,
|
|
75
|
+
} from "./types";
|
|
76
|
+
import {
|
|
77
|
+
applyCodeAction,
|
|
78
|
+
dedupeWorkspaceSymbols,
|
|
79
|
+
extractHoverText,
|
|
80
|
+
fileToUri,
|
|
81
|
+
filterWorkspaceSymbols,
|
|
82
|
+
formatCodeAction,
|
|
83
|
+
formatDiagnostic,
|
|
84
|
+
formatDiagnosticsSummary,
|
|
85
|
+
formatDocumentSymbol,
|
|
86
|
+
formatGroupedDiagnosticMessages,
|
|
87
|
+
formatLocation,
|
|
88
|
+
formatSymbolInformation,
|
|
89
|
+
formatWorkspaceEdit,
|
|
90
|
+
resolveDiagnosticTargets,
|
|
91
|
+
resolveSymbolColumn,
|
|
92
|
+
sortDiagnostics,
|
|
93
|
+
symbolKindToIcon,
|
|
94
|
+
uriToFile,
|
|
95
|
+
} from "./utils";
|
|
96
|
+
import { runWorkspaceDiagnostics } from "./workspace-diagnostics";
|
|
97
|
+
|
|
98
|
+
const MAX_RENAME_PAIRS = 1000;
|
|
99
|
+
|
|
100
|
+
interface FileRenamePair {
|
|
101
|
+
oldUri: string;
|
|
102
|
+
newUri: string;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Enumerate the {oldUri, newUri} pairs needed for an LSP willRenameFiles/didRenameFiles request.
|
|
107
|
+
* For files this is a single pair. For directories this walks every regular file underneath
|
|
108
|
+
* and produces a parallel pair anchored at the new directory root.
|
|
109
|
+
*/
|
|
110
|
+
async function enumerateRenamePairs(
|
|
111
|
+
source: string,
|
|
112
|
+
dest: string,
|
|
113
|
+
): Promise<{ pairs: FileRenamePair[]; directory: boolean; exceeded: boolean }> {
|
|
114
|
+
const stat = await fs.promises.stat(source);
|
|
115
|
+
if (!stat.isDirectory()) {
|
|
116
|
+
return {
|
|
117
|
+
pairs: [{ oldUri: fileToUri(source), newUri: fileToUri(dest) }],
|
|
118
|
+
directory: false,
|
|
119
|
+
exceeded: false,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
const entries = await fs.promises.readdir(source, { recursive: true, withFileTypes: true });
|
|
123
|
+
const pairs: FileRenamePair[] = [];
|
|
124
|
+
for (const entry of entries) {
|
|
125
|
+
if (!entry.isFile()) continue;
|
|
126
|
+
if (pairs.length >= MAX_RENAME_PAIRS) {
|
|
127
|
+
return { pairs, directory: true, exceeded: true };
|
|
128
|
+
}
|
|
129
|
+
const parent = entry.parentPath ?? source;
|
|
130
|
+
const absOld = path.join(parent, entry.name);
|
|
131
|
+
const rel = path.relative(source, absOld);
|
|
132
|
+
pairs.push({
|
|
133
|
+
oldUri: fileToUri(absOld),
|
|
134
|
+
newUri: fileToUri(path.join(dest, rel)),
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
return { pairs, directory: true, exceeded: false };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* LSP tool for language server protocol operations.
|
|
142
|
+
*/
|
|
143
|
+
export class LspTool implements AgentTool<typeof lspSchema, LspToolDetails, Theme> {
|
|
144
|
+
readonly name = "lsp";
|
|
145
|
+
readonly approval = (args: unknown): ToolApprovalDecision => {
|
|
146
|
+
const rawAction = (args as Partial<LspParams>).action;
|
|
147
|
+
const action = typeof rawAction === "string" ? rawAction.toLowerCase() : "";
|
|
148
|
+
return LSP_READONLY_ACTIONS.has(action) ? "read" : "write";
|
|
149
|
+
};
|
|
150
|
+
readonly formatApprovalDetails = (args: unknown): string[] => {
|
|
151
|
+
const params = args as Partial<LspParams>;
|
|
152
|
+
const lines = [`Action: ${typeof params.action === "string" ? params.action : "(missing)"}`];
|
|
153
|
+
if (typeof params.file === "string" && params.file.length > 0) {
|
|
154
|
+
lines.push(`File: ${truncateForPrompt(params.file)}`);
|
|
155
|
+
}
|
|
156
|
+
return lines;
|
|
157
|
+
};
|
|
158
|
+
readonly label = "LSP";
|
|
159
|
+
readonly loadMode = "discoverable";
|
|
160
|
+
readonly summary = "Query LSP (language server) for diagnostics, hover info, and references";
|
|
161
|
+
readonly description: string;
|
|
162
|
+
readonly parameters = lspSchema;
|
|
163
|
+
readonly strict = true;
|
|
164
|
+
|
|
165
|
+
constructor(private readonly session: ToolSession) {
|
|
166
|
+
this.description = prompt.render(lspDescription);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
static createIf(session: ToolSession): LspTool | null {
|
|
170
|
+
return session.enableLsp === false ? null : new LspTool(session);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async execute(
|
|
174
|
+
_toolCallId: string,
|
|
175
|
+
params: LspParams,
|
|
176
|
+
signal?: AbortSignal,
|
|
177
|
+
_onUpdate?: AgentToolUpdateCallback<LspToolDetails>,
|
|
178
|
+
_context?: AgentToolContext,
|
|
179
|
+
): Promise<AgentToolResult<LspToolDetails>> {
|
|
180
|
+
const { action, file, line, symbol, query, new_name, apply, timeout } = params;
|
|
181
|
+
if (this.session.lspReadOnly && !LSP_READONLY_ACTIONS.has(action)) {
|
|
182
|
+
throw new ToolError(`LSP action ${action} is disabled in this read-only session`);
|
|
183
|
+
}
|
|
184
|
+
const timeoutSec = clampTimeout("lsp", timeout, this.session.settings.get("tools.maxTimeout"));
|
|
185
|
+
const timeoutSignal = AbortSignal.timeout(timeoutSec * 1000);
|
|
186
|
+
const callerSignal = signal;
|
|
187
|
+
signal = callerSignal ? AbortSignal.any([callerSignal, timeoutSignal]) : timeoutSignal;
|
|
188
|
+
throwIfAborted(signal);
|
|
189
|
+
|
|
190
|
+
const config = getConfig(this.session.cwd);
|
|
191
|
+
|
|
192
|
+
// Status action doesn't need a file
|
|
193
|
+
if (action === "status") {
|
|
194
|
+
const configuredNames = Object.keys(config.servers);
|
|
195
|
+
const lspmuxState = await detectLspmux();
|
|
196
|
+
const lspmuxStatus = lspmuxState.available
|
|
197
|
+
? lspmuxState.running
|
|
198
|
+
? "lspmux: active (multiplexing enabled)"
|
|
199
|
+
: "lspmux: installed but server not running"
|
|
200
|
+
: "";
|
|
201
|
+
|
|
202
|
+
// `Object.keys(config.servers)` reflects what is *configured & resolvable
|
|
203
|
+
// on PATH* — it does NOT prove the server actually starts. A wrapper
|
|
204
|
+
// binary that exits immediately (e.g. rustup without the rust-analyzer
|
|
205
|
+
// component) still appears here. Distinguish "configured" from
|
|
206
|
+
// "started" (have a live in-process client) so callers cannot mistake
|
|
207
|
+
// presence-on-PATH for a working server.
|
|
208
|
+
const startedClients = getActiveClients();
|
|
209
|
+
const startedByConfigName = new Map<string, LspServerStatus>();
|
|
210
|
+
// getActiveClients() reports `name = client.config.command` (the
|
|
211
|
+
// unresolved binary name from defaults.json), so match against
|
|
212
|
+
// `serverConfig.command`, not the resolved path.
|
|
213
|
+
for (const [name, serverConfig] of Object.entries(config.servers)) {
|
|
214
|
+
const matched = startedClients.find(c => c.name === serverConfig.command);
|
|
215
|
+
if (matched) startedByConfigName.set(name, matched);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const lines: string[] = [];
|
|
219
|
+
if (configuredNames.length === 0) {
|
|
220
|
+
lines.push("No language servers configured for this project");
|
|
221
|
+
} else {
|
|
222
|
+
const labelled = configuredNames.map(name => {
|
|
223
|
+
const started = startedByConfigName.get(name);
|
|
224
|
+
if (!started) return `${name} (configured, not started)`;
|
|
225
|
+
return `${name} (${started.status})`;
|
|
226
|
+
});
|
|
227
|
+
lines.push(`Language servers: ${labelled.join(", ")}`);
|
|
228
|
+
lines.push(
|
|
229
|
+
" note: 'configured, not started' means the binary resolves on PATH but no request has spawned it yet; 'ready' means a client process is live for this cwd.",
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
if (lspmuxStatus) lines.push(lspmuxStatus);
|
|
233
|
+
|
|
234
|
+
return {
|
|
235
|
+
content: [{ type: "text", text: lines.join("\n") }],
|
|
236
|
+
details: { action, success: true, request: params },
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Diagnostics can be batch or single-file - queries all applicable servers
|
|
241
|
+
if (action === "diagnostics") {
|
|
242
|
+
if (file === "*") {
|
|
243
|
+
// `*` => run workspace diagnostics across all configured servers
|
|
244
|
+
const result = await runWorkspaceDiagnostics(this.session.cwd, signal);
|
|
245
|
+
return {
|
|
246
|
+
content: [
|
|
247
|
+
{
|
|
248
|
+
type: "text",
|
|
249
|
+
text: `Workspace diagnostics (${result.projectType.description}):\n${result.output}`,
|
|
250
|
+
},
|
|
251
|
+
],
|
|
252
|
+
details: { action, success: true, request: params },
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (!file) {
|
|
257
|
+
return {
|
|
258
|
+
content: [
|
|
259
|
+
{
|
|
260
|
+
type: "text",
|
|
261
|
+
text: "Error: file parameter required. Use `*` for workspace-wide diagnostics or a path/glob for specific files.",
|
|
262
|
+
},
|
|
263
|
+
],
|
|
264
|
+
details: { action, success: false, request: params },
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
let targets: string[];
|
|
269
|
+
let truncatedGlobTargets = false;
|
|
270
|
+
const resolvedTargets = await resolveDiagnosticTargets(file, this.session.cwd, MAX_GLOB_DIAGNOSTIC_TARGETS);
|
|
271
|
+
targets = resolvedTargets.matches;
|
|
272
|
+
truncatedGlobTargets = resolvedTargets.truncated;
|
|
273
|
+
|
|
274
|
+
if (targets.length === 0) {
|
|
275
|
+
return {
|
|
276
|
+
content: [{ type: "text", text: `No files matched pattern: ${file}` }],
|
|
277
|
+
details: { action, success: true, request: params },
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const detailed = targets.length > 1 || truncatedGlobTargets;
|
|
282
|
+
const diagnosticsWaitTimeoutMs = detailed
|
|
283
|
+
? Math.min(BATCH_DIAGNOSTICS_WAIT_TIMEOUT_MS, timeoutSec * 1000)
|
|
284
|
+
: Math.min(SINGLE_DIAGNOSTICS_WAIT_TIMEOUT_MS, timeoutSec * 1000);
|
|
285
|
+
const results: string[] = [];
|
|
286
|
+
const allServerNames = new Set<string>();
|
|
287
|
+
if (truncatedGlobTargets) {
|
|
288
|
+
results.push(
|
|
289
|
+
`${theme.status.warning} Pattern matched more than ${MAX_GLOB_DIAGNOSTIC_TARGETS} files; showing first ${MAX_GLOB_DIAGNOSTIC_TARGETS}. Narrow the glob or use workspace diagnostics.`,
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
for (const target of targets) {
|
|
294
|
+
throwIfAborted(signal);
|
|
295
|
+
const resolved = resolveToCwd(target, this.session.cwd);
|
|
296
|
+
const servers = getServersForFile(config, resolved);
|
|
297
|
+
if (servers.length === 0) {
|
|
298
|
+
results.push(`${theme.status.error} ${target}: No language server found`);
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const uri = fileToUri(resolved);
|
|
303
|
+
const relPath = formatPathRelativeToCwd(resolved, this.session.cwd);
|
|
304
|
+
const allDiagnostics: Diagnostic[] = [];
|
|
305
|
+
|
|
306
|
+
// Query all applicable servers for this file
|
|
307
|
+
for (const [serverName, serverConfig] of servers) {
|
|
308
|
+
allServerNames.add(serverName);
|
|
309
|
+
try {
|
|
310
|
+
throwIfAborted(signal);
|
|
311
|
+
if (serverConfig.createClient) {
|
|
312
|
+
const linterClient = getLinterClient(serverName, serverConfig, this.session.cwd);
|
|
313
|
+
const diagnostics = await linterClient.lint(resolved);
|
|
314
|
+
allDiagnostics.push(...diagnostics);
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
317
|
+
const client = await getOrCreateClient(serverConfig, this.session.cwd, undefined, signal);
|
|
318
|
+
if (isProjectAwareLspServer(serverConfig)) {
|
|
319
|
+
await waitForProjectLoaded(client, signal);
|
|
320
|
+
throwIfAborted(signal);
|
|
321
|
+
}
|
|
322
|
+
const minVersion = client.diagnosticsVersion;
|
|
323
|
+
await refreshFile(client, resolved, signal);
|
|
324
|
+
const expectedDocumentVersion = client.openFiles.get(uri)?.version;
|
|
325
|
+
const diagnostics = await waitForDiagnostics(client, uri, {
|
|
326
|
+
timeoutMs: diagnosticsWaitTimeoutMs,
|
|
327
|
+
signal,
|
|
328
|
+
minVersion,
|
|
329
|
+
expectedDocumentVersion,
|
|
330
|
+
});
|
|
331
|
+
allDiagnostics.push(...diagnostics);
|
|
332
|
+
} catch (err) {
|
|
333
|
+
if (err instanceof ToolAbortError || signal?.aborted) {
|
|
334
|
+
throw err;
|
|
335
|
+
}
|
|
336
|
+
// Server failed, continue with others
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// Deduplicate diagnostics
|
|
341
|
+
const seen = new Set<string>();
|
|
342
|
+
const uniqueDiagnostics: Diagnostic[] = [];
|
|
343
|
+
for (const d of allDiagnostics) {
|
|
344
|
+
const key = `${d.range.start.line}:${d.range.start.character}:${d.range.end.line}:${d.range.end.character}:${d.message}`;
|
|
345
|
+
if (!seen.has(key)) {
|
|
346
|
+
seen.add(key);
|
|
347
|
+
uniqueDiagnostics.push(d);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
sortDiagnostics(uniqueDiagnostics);
|
|
352
|
+
|
|
353
|
+
if (!detailed && targets.length === 1) {
|
|
354
|
+
if (uniqueDiagnostics.length === 0) {
|
|
355
|
+
return {
|
|
356
|
+
content: [{ type: "text", text: "OK" }],
|
|
357
|
+
details: { action, serverName: Array.from(allServerNames).join(", "), success: true },
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
const summary = formatDiagnosticsSummary(uniqueDiagnostics);
|
|
362
|
+
const formatted = uniqueDiagnostics.map(d => formatDiagnostic(d, relPath));
|
|
363
|
+
const output = `${summary}:\n${formatGroupedDiagnosticMessages(formatted)}`;
|
|
364
|
+
return {
|
|
365
|
+
content: [{ type: "text", text: output }],
|
|
366
|
+
details: { action, serverName: Array.from(allServerNames).join(", "), success: true },
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
if (uniqueDiagnostics.length === 0) {
|
|
371
|
+
results.push(`${theme.status.success} ${relPath}: no issues`);
|
|
372
|
+
} else {
|
|
373
|
+
const summary = formatDiagnosticsSummary(uniqueDiagnostics);
|
|
374
|
+
results.push(`${theme.status.error} ${relPath}: ${summary}`);
|
|
375
|
+
const formatted = uniqueDiagnostics.map(d => formatDiagnostic(d, relPath));
|
|
376
|
+
results.push(formatGroupedDiagnosticMessages(formatted));
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
return {
|
|
381
|
+
content: [{ type: "text", text: results.join("\n") }],
|
|
382
|
+
details: { action, serverName: Array.from(allServerNames).join(", "), success: true },
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
if (action === "rename_file") {
|
|
387
|
+
if (!file || !new_name) {
|
|
388
|
+
return {
|
|
389
|
+
content: [
|
|
390
|
+
{
|
|
391
|
+
type: "text",
|
|
392
|
+
text: "Error: rename_file requires both `file` (source path) and `new_name` (destination path)",
|
|
393
|
+
},
|
|
394
|
+
],
|
|
395
|
+
details: { action, success: false, request: params },
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
const source = resolveToCwd(file, this.session.cwd);
|
|
400
|
+
const dest = resolveToCwd(new_name, this.session.cwd);
|
|
401
|
+
|
|
402
|
+
if (source === dest) {
|
|
403
|
+
return {
|
|
404
|
+
content: [{ type: "text", text: "Error: source and destination paths are identical" }],
|
|
405
|
+
details: { action, success: false, request: params },
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
let sourceStat: fs.Stats;
|
|
410
|
+
try {
|
|
411
|
+
sourceStat = await fs.promises.stat(source);
|
|
412
|
+
} catch {
|
|
413
|
+
return {
|
|
414
|
+
content: [
|
|
415
|
+
{
|
|
416
|
+
type: "text",
|
|
417
|
+
text: `Error: source path does not exist: ${formatPathRelativeToCwd(source, this.session.cwd)}`,
|
|
418
|
+
},
|
|
419
|
+
],
|
|
420
|
+
details: { action, success: false, request: params },
|
|
421
|
+
};
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
let destExists = false;
|
|
425
|
+
try {
|
|
426
|
+
await fs.promises.stat(dest);
|
|
427
|
+
destExists = true;
|
|
428
|
+
} catch {
|
|
429
|
+
// expected: destination must not exist
|
|
430
|
+
}
|
|
431
|
+
if (destExists) {
|
|
432
|
+
return {
|
|
433
|
+
content: [
|
|
434
|
+
{
|
|
435
|
+
type: "text",
|
|
436
|
+
text: `Error: destination already exists: ${formatPathRelativeToCwd(dest, this.session.cwd)}`,
|
|
437
|
+
},
|
|
438
|
+
],
|
|
439
|
+
details: { action, success: false, request: params },
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
const enumerated = await enumerateRenamePairs(source, dest);
|
|
444
|
+
if (enumerated.exceeded) {
|
|
445
|
+
return {
|
|
446
|
+
content: [
|
|
447
|
+
{
|
|
448
|
+
type: "text",
|
|
449
|
+
text: `Error: directory contains more than ${MAX_RENAME_PAIRS} files; rename in smaller batches to keep LSP edits accurate`,
|
|
450
|
+
},
|
|
451
|
+
],
|
|
452
|
+
details: { action, success: false, request: params },
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
const { pairs } = enumerated;
|
|
456
|
+
if (pairs.length === 0) {
|
|
457
|
+
return {
|
|
458
|
+
content: [{ type: "text", text: "Error: no files to rename" }],
|
|
459
|
+
details: { action, success: false, request: params },
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
const lspParams = { files: pairs };
|
|
464
|
+
// Filter to servers whose fileTypes match either the source or any
|
|
465
|
+
// destination path. Asking every configured server about a .md/.sql/.txt
|
|
466
|
+
// rename used to stack up willRenameFiles requests against irrelevant
|
|
467
|
+
// language servers and hit the wall-clock timeout. A server only has
|
|
468
|
+
// something useful to say about a rename if it understands one of the
|
|
469
|
+
// affected file extensions.
|
|
470
|
+
const allLspServers = getLspServers(config);
|
|
471
|
+
const relevantNames = new Set<string>();
|
|
472
|
+
const collectRelevant = (filePath: string) => {
|
|
473
|
+
for (const [name] of getLspServersForFile(config, filePath)) {
|
|
474
|
+
relevantNames.add(name);
|
|
475
|
+
}
|
|
476
|
+
};
|
|
477
|
+
collectRelevant(source);
|
|
478
|
+
collectRelevant(dest);
|
|
479
|
+
for (const pair of pairs) {
|
|
480
|
+
collectRelevant(uriToFile(pair.oldUri));
|
|
481
|
+
collectRelevant(uriToFile(pair.newUri));
|
|
482
|
+
}
|
|
483
|
+
const servers = allLspServers.filter(([name]) => relevantNames.has(name));
|
|
484
|
+
const respondingServers = new Set<string>();
|
|
485
|
+
const perServerEdits: Array<{ serverName: string; edit: WorkspaceEdit }> = [];
|
|
486
|
+
const serverNotes: string[] = [];
|
|
487
|
+
|
|
488
|
+
for (const [serverName, serverConfig] of servers) {
|
|
489
|
+
throwIfAborted(signal);
|
|
490
|
+
try {
|
|
491
|
+
const client = await getOrCreateClient(serverConfig, this.session.cwd, undefined, signal);
|
|
492
|
+
if (isProjectAwareLspServer(serverConfig)) {
|
|
493
|
+
await waitForProjectLoaded(client, signal);
|
|
494
|
+
}
|
|
495
|
+
const result = (await sendRequest(
|
|
496
|
+
client,
|
|
497
|
+
"workspace/willRenameFiles",
|
|
498
|
+
lspParams,
|
|
499
|
+
signal,
|
|
500
|
+
)) as WorkspaceEdit | null;
|
|
501
|
+
respondingServers.add(serverName);
|
|
502
|
+
if (result && (result.changes || result.documentChanges)) {
|
|
503
|
+
perServerEdits.push({ serverName, edit: result });
|
|
504
|
+
}
|
|
505
|
+
} catch (err) {
|
|
506
|
+
if (err instanceof ToolAbortError || signal?.aborted) {
|
|
507
|
+
throw err;
|
|
508
|
+
}
|
|
509
|
+
if (!isMethodNotFoundError(err)) {
|
|
510
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
511
|
+
serverNotes.push(` ${serverName}: ${msg}`);
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
const sourceLabel = formatPathRelativeToCwd(source, this.session.cwd);
|
|
517
|
+
const destLabel = formatPathRelativeToCwd(dest, this.session.cwd);
|
|
518
|
+
const fileCountLabel = sourceStat.isDirectory()
|
|
519
|
+
? `${pairs.length} file${pairs.length !== 1 ? "s" : ""} under ${sourceLabel}`
|
|
520
|
+
: sourceLabel;
|
|
521
|
+
|
|
522
|
+
const shouldApply = apply !== false;
|
|
523
|
+
if (!shouldApply) {
|
|
524
|
+
const lines: string[] = [];
|
|
525
|
+
lines.push(`Rename preview: ${fileCountLabel} → ${destLabel}`);
|
|
526
|
+
if (perServerEdits.length === 0) {
|
|
527
|
+
lines.push(" No LSP edits would be applied");
|
|
528
|
+
} else {
|
|
529
|
+
for (const { serverName, edit } of perServerEdits) {
|
|
530
|
+
const edits = formatWorkspaceEdit(edit, this.session.cwd);
|
|
531
|
+
if (edits.length === 0) continue;
|
|
532
|
+
lines.push(` ${serverName}:`);
|
|
533
|
+
for (const e of edits) {
|
|
534
|
+
lines.push(` ${e}`);
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
if (serverNotes.length > 0) {
|
|
539
|
+
lines.push(" Server notes:");
|
|
540
|
+
lines.push(...serverNotes);
|
|
541
|
+
}
|
|
542
|
+
return {
|
|
543
|
+
content: [{ type: "text", text: lines.join("\n") }],
|
|
544
|
+
details: {
|
|
545
|
+
action,
|
|
546
|
+
serverName: Array.from(respondingServers).join(", "),
|
|
547
|
+
success: true,
|
|
548
|
+
request: params,
|
|
549
|
+
},
|
|
550
|
+
};
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
const summary: string[] = [];
|
|
554
|
+
|
|
555
|
+
// Coalesce per-URI edits across servers before applying. Each server
|
|
556
|
+
// computed positions against the pre-edit file content, so applying
|
|
557
|
+
// server A then re-reading for server B yields stale positions and
|
|
558
|
+
// produces malformed imports. Group all text edits by URI, prefer the
|
|
559
|
+
// project-primary (project-aware) server on overlap, and apply once
|
|
560
|
+
// per URI from a single snapshot.
|
|
561
|
+
const serverConfigByName = new Map(servers);
|
|
562
|
+
interface AcceptedBucket {
|
|
563
|
+
primaryServer: string;
|
|
564
|
+
edits: TextEdit[];
|
|
565
|
+
discarded: number;
|
|
566
|
+
conflictServers: Set<string>;
|
|
567
|
+
}
|
|
568
|
+
const acceptedByUri = new Map<string, AcceptedBucket>();
|
|
569
|
+
for (const { serverName, edit } of perServerEdits) {
|
|
570
|
+
const cfg = serverConfigByName.get(serverName);
|
|
571
|
+
const incomingPrimary = cfg ? isProjectAwareLspServer(cfg) : false;
|
|
572
|
+
const flat = flattenWorkspaceTextEdits(edit);
|
|
573
|
+
for (const [uri, edits] of flat) {
|
|
574
|
+
const existing = acceptedByUri.get(uri);
|
|
575
|
+
if (!existing) {
|
|
576
|
+
acceptedByUri.set(uri, {
|
|
577
|
+
primaryServer: serverName,
|
|
578
|
+
edits: [...edits],
|
|
579
|
+
discarded: 0,
|
|
580
|
+
conflictServers: new Set(),
|
|
581
|
+
});
|
|
582
|
+
continue;
|
|
583
|
+
}
|
|
584
|
+
const existingCfg = serverConfigByName.get(existing.primaryServer);
|
|
585
|
+
const existingIsPrimary = existingCfg ? isProjectAwareLspServer(existingCfg) : false;
|
|
586
|
+
if (incomingPrimary && !existingIsPrimary) {
|
|
587
|
+
// Promote incoming to primary; keep existing edits that don't overlap.
|
|
588
|
+
const keptOld: TextEdit[] = [];
|
|
589
|
+
let discardedOld = 0;
|
|
590
|
+
for (const oe of existing.edits) {
|
|
591
|
+
if (edits.some(ne => rangesOverlap(ne.range, oe.range))) discardedOld++;
|
|
592
|
+
else keptOld.push(oe);
|
|
593
|
+
}
|
|
594
|
+
if (discardedOld > 0) existing.conflictServers.add(existing.primaryServer);
|
|
595
|
+
existing.discarded += discardedOld;
|
|
596
|
+
existing.primaryServer = serverName;
|
|
597
|
+
existing.edits = [...edits, ...keptOld];
|
|
598
|
+
} else {
|
|
599
|
+
// Existing wins; discard incoming edits that overlap any accepted edit.
|
|
600
|
+
let discardedNew = 0;
|
|
601
|
+
for (const ne of edits) {
|
|
602
|
+
if (existing.edits.some(ae => rangesOverlap(ae.range, ne.range))) {
|
|
603
|
+
discardedNew++;
|
|
604
|
+
} else {
|
|
605
|
+
existing.edits.push(ne);
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
if (discardedNew > 0) {
|
|
609
|
+
existing.conflictServers.add(serverName);
|
|
610
|
+
existing.discarded += discardedNew;
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
for (const [uri, bucket] of acceptedByUri) {
|
|
617
|
+
const filePath = uriToFile(uri);
|
|
618
|
+
await applyTextEdits(filePath, bucket.edits);
|
|
619
|
+
const rel = formatPathRelativeToCwd(filePath, this.session.cwd);
|
|
620
|
+
summary.push(` ${bucket.primaryServer}: applied ${bucket.edits.length} edit(s) to ${rel}`);
|
|
621
|
+
if (bucket.discarded > 0) {
|
|
622
|
+
const others = Array.from(bucket.conflictServers).join(", ");
|
|
623
|
+
summary.push(
|
|
624
|
+
` note: discarded ${bucket.discarded} overlapping edit(s) from ${others} (kept ${bucket.primaryServer})`,
|
|
625
|
+
);
|
|
626
|
+
logger.warn(
|
|
627
|
+
`lsp rename_file: discarded ${bucket.discarded} overlapping edit(s) from ${others} on ${rel}; kept ${bucket.primaryServer}`,
|
|
628
|
+
);
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
await fs.promises.mkdir(path.dirname(dest), { recursive: true });
|
|
633
|
+
await fs.promises.rename(source, dest);
|
|
634
|
+
summary.push(` Renamed ${sourceLabel} → ${destLabel}`);
|
|
635
|
+
|
|
636
|
+
for (const [serverName, serverConfig] of servers) {
|
|
637
|
+
try {
|
|
638
|
+
const client = await getOrCreateClient(serverConfig, this.session.cwd, undefined, signal);
|
|
639
|
+
for (const { oldUri } of pairs) {
|
|
640
|
+
if (client.openFiles.has(oldUri)) {
|
|
641
|
+
await sendNotification(client, "textDocument/didClose", { textDocument: { uri: oldUri } }, signal);
|
|
642
|
+
client.openFiles.delete(oldUri);
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
await sendNotification(client, "workspace/didRenameFiles", lspParams, signal);
|
|
646
|
+
} catch (err) {
|
|
647
|
+
if (err instanceof ToolAbortError || signal?.aborted) {
|
|
648
|
+
throw err;
|
|
649
|
+
}
|
|
650
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
651
|
+
serverNotes.push(` ${serverName}: ${msg}`);
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
if (serverNotes.length > 0) {
|
|
656
|
+
summary.push(" Server notes:");
|
|
657
|
+
summary.push(...serverNotes);
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
const header = `Renamed ${fileCountLabel} → ${destLabel}`;
|
|
661
|
+
return {
|
|
662
|
+
content: [{ type: "text", text: `${header}\n${summary.join("\n")}` }],
|
|
663
|
+
details: {
|
|
664
|
+
action,
|
|
665
|
+
serverName: Array.from(respondingServers).join(", "),
|
|
666
|
+
success: true,
|
|
667
|
+
request: params,
|
|
668
|
+
},
|
|
669
|
+
};
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
if (action === "capabilities") {
|
|
673
|
+
let serverList: Array<[string, ServerConfig]>;
|
|
674
|
+
if (file && file !== "*") {
|
|
675
|
+
const resolved = resolveToCwd(file, this.session.cwd);
|
|
676
|
+
serverList = getLspServersForFile(config, resolved);
|
|
677
|
+
if (serverList.length === 0) {
|
|
678
|
+
return {
|
|
679
|
+
content: [{ type: "text", text: "No language server found for this file" }],
|
|
680
|
+
details: { action, success: false, request: params },
|
|
681
|
+
};
|
|
682
|
+
}
|
|
683
|
+
} else {
|
|
684
|
+
serverList = getLspServers(config);
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
if (serverList.length === 0) {
|
|
688
|
+
return {
|
|
689
|
+
content: [{ type: "text", text: "No language servers configured" }],
|
|
690
|
+
details: { action, success: false, request: params },
|
|
691
|
+
};
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
const sections: string[] = [];
|
|
695
|
+
const respondingServers = new Set<string>();
|
|
696
|
+
for (const [serverName, serverConfig] of serverList) {
|
|
697
|
+
throwIfAborted(signal);
|
|
698
|
+
try {
|
|
699
|
+
const client = await getOrCreateClient(serverConfig, this.session.cwd, undefined, signal);
|
|
700
|
+
respondingServers.add(serverName);
|
|
701
|
+
const caps = client.serverCapabilities ?? {};
|
|
702
|
+
sections.push(`${serverName}:`);
|
|
703
|
+
sections.push(` capabilities: ${JSON.stringify(caps, null, 2).split("\n").join("\n ")}`);
|
|
704
|
+
} catch (err) {
|
|
705
|
+
if (err instanceof ToolAbortError || signal?.aborted) {
|
|
706
|
+
throw err;
|
|
707
|
+
}
|
|
708
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
709
|
+
sections.push(`${serverName}: failed to start (${msg})`);
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
return {
|
|
714
|
+
content: [{ type: "text", text: sections.join("\n") }],
|
|
715
|
+
details: {
|
|
716
|
+
action,
|
|
717
|
+
serverName: Array.from(respondingServers).join(", "),
|
|
718
|
+
success: true,
|
|
719
|
+
request: params,
|
|
720
|
+
},
|
|
721
|
+
};
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
if (action === "request") {
|
|
725
|
+
const method = query?.trim();
|
|
726
|
+
if (!method) {
|
|
727
|
+
return {
|
|
728
|
+
content: [
|
|
729
|
+
{
|
|
730
|
+
type: "text",
|
|
731
|
+
text: "Error: action=request requires `query` to specify the LSP method name (e.g., 'rust-analyzer/expandMacro')",
|
|
732
|
+
},
|
|
733
|
+
],
|
|
734
|
+
details: { action, success: false, request: params },
|
|
735
|
+
};
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
let chosenServer: [string, ServerConfig] | null = null;
|
|
739
|
+
let resolvedTarget: string | null = null;
|
|
740
|
+
if (file && file !== "*") {
|
|
741
|
+
resolvedTarget = resolveToCwd(file, this.session.cwd);
|
|
742
|
+
chosenServer = getLspServerForFile(config, resolvedTarget);
|
|
743
|
+
if (!chosenServer) {
|
|
744
|
+
return {
|
|
745
|
+
content: [{ type: "text", text: "No language server found for this file" }],
|
|
746
|
+
details: { action, success: false, request: params },
|
|
747
|
+
};
|
|
748
|
+
}
|
|
749
|
+
} else {
|
|
750
|
+
const all = getLspServers(config);
|
|
751
|
+
if (all.length === 0) {
|
|
752
|
+
return {
|
|
753
|
+
content: [{ type: "text", text: "No language servers configured" }],
|
|
754
|
+
details: { action, success: false, request: params },
|
|
755
|
+
};
|
|
756
|
+
}
|
|
757
|
+
chosenServer = all[0];
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
const [chosenName, chosenConfig] = chosenServer;
|
|
761
|
+
let requestParams: unknown;
|
|
762
|
+
if (params.payload !== undefined) {
|
|
763
|
+
try {
|
|
764
|
+
requestParams = JSON.parse(params.payload);
|
|
765
|
+
} catch (err) {
|
|
766
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
767
|
+
return {
|
|
768
|
+
content: [{ type: "text", text: `Error: invalid JSON in payload: ${msg}` }],
|
|
769
|
+
details: { action, serverName: chosenName, success: false, request: params },
|
|
770
|
+
};
|
|
771
|
+
}
|
|
772
|
+
} else if (resolvedTarget) {
|
|
773
|
+
const uri = fileToUri(resolvedTarget);
|
|
774
|
+
if (line !== undefined) {
|
|
775
|
+
const character = await resolveSymbolColumn(resolvedTarget, line, symbol);
|
|
776
|
+
requestParams = { textDocument: { uri }, position: { line: line - 1, character } };
|
|
777
|
+
} else {
|
|
778
|
+
requestParams = { textDocument: { uri } };
|
|
779
|
+
}
|
|
780
|
+
} else {
|
|
781
|
+
requestParams = {};
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
try {
|
|
785
|
+
const client = await getOrCreateClient(chosenConfig, this.session.cwd, undefined, signal);
|
|
786
|
+
if (resolvedTarget) {
|
|
787
|
+
await ensureFileOpen(client, resolvedTarget, signal);
|
|
788
|
+
}
|
|
789
|
+
const result = await sendRequest(client, method, requestParams, signal);
|
|
790
|
+
const formatted =
|
|
791
|
+
result === null || result === undefined
|
|
792
|
+
? "null"
|
|
793
|
+
: typeof result === "string"
|
|
794
|
+
? result
|
|
795
|
+
: JSON.stringify(result, null, 2);
|
|
796
|
+
return {
|
|
797
|
+
content: [{ type: "text", text: `${chosenName} ← ${method}:\n${formatted}` }],
|
|
798
|
+
details: { action, serverName: chosenName, success: true, request: params },
|
|
799
|
+
};
|
|
800
|
+
} catch (err) {
|
|
801
|
+
if (err instanceof ToolAbortError || signal?.aborted) {
|
|
802
|
+
throw new ToolAbortError();
|
|
803
|
+
}
|
|
804
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
805
|
+
// Echo a (truncated) preview of the params we sent so the caller can
|
|
806
|
+
// tell parse / shape errors (e.g. nested args dropped, missing field)
|
|
807
|
+
// apart from genuine server errors without spinning up another debug call.
|
|
808
|
+
const previewRaw = JSON.stringify(requestParams ?? null);
|
|
809
|
+
const preview = previewRaw.length > 400 ? `${previewRaw.slice(0, 397)}...` : previewRaw;
|
|
810
|
+
return {
|
|
811
|
+
content: [
|
|
812
|
+
{ type: "text", text: `LSP error from ${chosenName} on ${method}: ${msg}\n params: ${preview}` },
|
|
813
|
+
],
|
|
814
|
+
details: { action, serverName: chosenName, success: false, request: params },
|
|
815
|
+
};
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
// `*` means workspace scope for symbols/reload; other actions need a concrete file.
|
|
820
|
+
const isWorkspace = file === "*";
|
|
821
|
+
const requiresFile = !file && action !== "reload";
|
|
822
|
+
|
|
823
|
+
if (requiresFile) {
|
|
824
|
+
return {
|
|
825
|
+
content: [
|
|
826
|
+
{
|
|
827
|
+
type: "text",
|
|
828
|
+
text: "Error: file parameter required. Use `*` for workspace scope where supported.",
|
|
829
|
+
},
|
|
830
|
+
],
|
|
831
|
+
details: { action, success: false },
|
|
832
|
+
};
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
const resolvedFile = file && !isWorkspace ? resolveToCwd(file, this.session.cwd) : null;
|
|
836
|
+
if (action === "symbols" && (isWorkspace || !resolvedFile)) {
|
|
837
|
+
const normalizedQuery = query?.trim();
|
|
838
|
+
if (!normalizedQuery) {
|
|
839
|
+
return {
|
|
840
|
+
content: [{ type: "text", text: "Error: query parameter required for workspace symbol search" }],
|
|
841
|
+
details: { action, success: false, request: params },
|
|
842
|
+
};
|
|
843
|
+
}
|
|
844
|
+
const servers = getLspServers(config);
|
|
845
|
+
if (servers.length === 0) {
|
|
846
|
+
return {
|
|
847
|
+
content: [{ type: "text", text: "No language server found for this action" }],
|
|
848
|
+
details: { action, success: false, request: params },
|
|
849
|
+
};
|
|
850
|
+
}
|
|
851
|
+
const aggregatedSymbols: SymbolInformation[] = [];
|
|
852
|
+
const respondingServers = new Set<string>();
|
|
853
|
+
for (const [workspaceServerName, workspaceServerConfig] of servers) {
|
|
854
|
+
throwIfAborted(signal);
|
|
855
|
+
try {
|
|
856
|
+
const workspaceClient = await getOrCreateClient(
|
|
857
|
+
workspaceServerConfig,
|
|
858
|
+
this.session.cwd,
|
|
859
|
+
undefined,
|
|
860
|
+
signal,
|
|
861
|
+
);
|
|
862
|
+
const workspaceResult = (await sendRequest(
|
|
863
|
+
workspaceClient,
|
|
864
|
+
"workspace/symbol",
|
|
865
|
+
{ query: normalizedQuery },
|
|
866
|
+
signal,
|
|
867
|
+
)) as SymbolInformation[] | null;
|
|
868
|
+
if (!workspaceResult || workspaceResult.length === 0) {
|
|
869
|
+
continue;
|
|
870
|
+
}
|
|
871
|
+
respondingServers.add(workspaceServerName);
|
|
872
|
+
aggregatedSymbols.push(...filterWorkspaceSymbols(workspaceResult, normalizedQuery));
|
|
873
|
+
} catch (err) {
|
|
874
|
+
if (err instanceof ToolAbortError || signal?.aborted) {
|
|
875
|
+
throw err;
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
const dedupedSymbols = dedupeWorkspaceSymbols(aggregatedSymbols);
|
|
880
|
+
if (dedupedSymbols.length === 0) {
|
|
881
|
+
return {
|
|
882
|
+
content: [{ type: "text", text: `No symbols matching "${normalizedQuery}"` }],
|
|
883
|
+
details: {
|
|
884
|
+
action,
|
|
885
|
+
serverName: Array.from(respondingServers).join(", "),
|
|
886
|
+
success: true,
|
|
887
|
+
request: params,
|
|
888
|
+
},
|
|
889
|
+
};
|
|
890
|
+
}
|
|
891
|
+
const limitedSymbols = dedupedSymbols.slice(0, WORKSPACE_SYMBOL_LIMIT);
|
|
892
|
+
const lines = limitedSymbols.map(s => formatSymbolInformation(s, this.session.cwd));
|
|
893
|
+
const truncationLine =
|
|
894
|
+
dedupedSymbols.length > WORKSPACE_SYMBOL_LIMIT
|
|
895
|
+
? `\n[…${dedupedSymbols.length - WORKSPACE_SYMBOL_LIMIT} symbols elided…]`
|
|
896
|
+
: "";
|
|
897
|
+
return {
|
|
898
|
+
content: [
|
|
899
|
+
{
|
|
900
|
+
type: "text",
|
|
901
|
+
text: `Found ${dedupedSymbols.length} symbol(s) matching "${normalizedQuery}":\n${lines.map(l => ` ${l}`).join("\n")}${truncationLine}`,
|
|
902
|
+
},
|
|
903
|
+
],
|
|
904
|
+
details: {
|
|
905
|
+
action,
|
|
906
|
+
serverName: Array.from(respondingServers).join(", "),
|
|
907
|
+
success: true,
|
|
908
|
+
request: params,
|
|
909
|
+
},
|
|
910
|
+
};
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
if (action === "reload" && (isWorkspace || !resolvedFile)) {
|
|
914
|
+
// `reload *` is the user's explicit request to re-read config from
|
|
915
|
+
// disk. Drop the per-cwd cache entry so `.omp/lsp.json`, root markers,
|
|
916
|
+
// and plugin configs added after the first LSP call become visible —
|
|
917
|
+
// otherwise `getConfig` returns the first observation for the rest of
|
|
918
|
+
// the process lifetime (#3546).
|
|
919
|
+
configCache.delete(this.session.cwd);
|
|
920
|
+
const refreshedConfig = getConfig(this.session.cwd);
|
|
921
|
+
const servers = getLspServers(refreshedConfig);
|
|
922
|
+
if (servers.length === 0) {
|
|
923
|
+
return {
|
|
924
|
+
content: [{ type: "text", text: "No language server found for this action" }],
|
|
925
|
+
details: { action, success: false, request: params },
|
|
926
|
+
};
|
|
927
|
+
}
|
|
928
|
+
const outputs: string[] = [];
|
|
929
|
+
for (const [workspaceServerName, workspaceServerConfig] of servers) {
|
|
930
|
+
throwIfAborted(signal);
|
|
931
|
+
clearInitializationFailure(workspaceServerConfig, this.session.cwd);
|
|
932
|
+
try {
|
|
933
|
+
const workspaceClient = await getOrCreateClient(
|
|
934
|
+
workspaceServerConfig,
|
|
935
|
+
this.session.cwd,
|
|
936
|
+
undefined,
|
|
937
|
+
signal,
|
|
938
|
+
);
|
|
939
|
+
outputs.push(await reloadServer(workspaceClient, workspaceServerName, signal));
|
|
940
|
+
} catch (err) {
|
|
941
|
+
if (err instanceof ToolAbortError || signal?.aborted) {
|
|
942
|
+
throw err;
|
|
943
|
+
}
|
|
944
|
+
const errorMessage = err instanceof Error ? err.message : String(err);
|
|
945
|
+
outputs.push(`Failed to reload ${workspaceServerName}: ${errorMessage}`);
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
return {
|
|
949
|
+
content: [{ type: "text", text: outputs.join("\n") }],
|
|
950
|
+
details: { action, serverName: servers.map(([name]) => name).join(", "), success: true, request: params },
|
|
951
|
+
};
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
const serverInfo = resolvedFile ? getLspServerForFile(config, resolvedFile) : null;
|
|
955
|
+
if (!serverInfo) {
|
|
956
|
+
return {
|
|
957
|
+
content: [{ type: "text", text: "No language server found for this action" }],
|
|
958
|
+
details: { action, success: false },
|
|
959
|
+
};
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
const [serverName, serverConfig] = serverInfo;
|
|
963
|
+
|
|
964
|
+
if (action === "reload") clearInitializationFailure(serverConfig, this.session.cwd);
|
|
965
|
+
|
|
966
|
+
try {
|
|
967
|
+
const client = await getOrCreateClient(serverConfig, this.session.cwd, undefined, signal);
|
|
968
|
+
const targetFile = resolvedFile;
|
|
969
|
+
const isRustAnalyzerServer =
|
|
970
|
+
serverName === "rust-analyzer" ||
|
|
971
|
+
path.basename(serverConfig.command) === "rust-analyzer" ||
|
|
972
|
+
(serverConfig.resolvedCommand ? path.basename(serverConfig.resolvedCommand) === "rust-analyzer" : false);
|
|
973
|
+
const needsProjectIndex =
|
|
974
|
+
targetFile !== null && PROJECT_INDEXED_ACTIONS.has(action) && isProjectAwareLspServer(serverConfig);
|
|
975
|
+
const rustWorkspaceWait =
|
|
976
|
+
needsProjectIndex && isRustAnalyzerServer && targetFile !== null && hasRustWorkspaceAncestor(targetFile);
|
|
977
|
+
|
|
978
|
+
if (targetFile) {
|
|
979
|
+
await ensureFileOpen(client, targetFile, signal);
|
|
980
|
+
}
|
|
981
|
+
if (rustWorkspaceWait) {
|
|
982
|
+
await waitForProjectLoaded(client, signal);
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
// For project-aware servers, references/rename/definition without a `symbol`
|
|
986
|
+
// silently falls back to the first non-whitespace column on the line, which
|
|
987
|
+
// frequently points at the wrong identifier (decorator, keyword, parameter)
|
|
988
|
+
// and the server returns plausible-looking but unrelated results. Require
|
|
989
|
+
// `symbol` explicitly so callers cannot accidentally trigger that fallback.
|
|
990
|
+
if (
|
|
991
|
+
targetFile &&
|
|
992
|
+
line !== undefined &&
|
|
993
|
+
!symbol &&
|
|
994
|
+
(action === "references" || action === "rename" || action === "definition") &&
|
|
995
|
+
isProjectAwareLspServer(serverConfig)
|
|
996
|
+
) {
|
|
997
|
+
throw new ToolError(
|
|
998
|
+
`symbol is required for project-aware ${action}; pass symbol=<name>, optionally symbol#N for repeated occurrences`,
|
|
999
|
+
);
|
|
1000
|
+
}
|
|
1001
|
+
const uri = targetFile ? fileToUri(targetFile) : "";
|
|
1002
|
+
const resolvedLine = line ?? 1;
|
|
1003
|
+
const resolvedCharacter = targetFile ? await resolveSymbolColumn(targetFile, resolvedLine, symbol) : 0;
|
|
1004
|
+
const position = { line: resolvedLine - 1, character: resolvedCharacter };
|
|
1005
|
+
|
|
1006
|
+
let output: string;
|
|
1007
|
+
// Set on bare empty-lookup outcomes (no definition/references/…): the
|
|
1008
|
+
// result carries no information once consumed, so compaction may elide
|
|
1009
|
+
// it. Clean diagnostics runs are NOT useless — they are verification
|
|
1010
|
+
// evidence.
|
|
1011
|
+
let useless = false;
|
|
1012
|
+
|
|
1013
|
+
if (needsProjectIndex && !isRustAnalyzerServer) {
|
|
1014
|
+
await waitForProjectLoaded(client, signal);
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
switch (action) {
|
|
1018
|
+
// =====================================================================
|
|
1019
|
+
// Standard LSP Operations
|
|
1020
|
+
// =====================================================================
|
|
1021
|
+
|
|
1022
|
+
case "definition": {
|
|
1023
|
+
const result = (await sendRequest(
|
|
1024
|
+
client,
|
|
1025
|
+
"textDocument/definition",
|
|
1026
|
+
{
|
|
1027
|
+
textDocument: { uri },
|
|
1028
|
+
position,
|
|
1029
|
+
},
|
|
1030
|
+
signal,
|
|
1031
|
+
)) as Location | Location[] | LocationLink | LocationLink[] | null;
|
|
1032
|
+
|
|
1033
|
+
const locations = normalizeLocationResult(result);
|
|
1034
|
+
|
|
1035
|
+
if (locations.length === 0) {
|
|
1036
|
+
output = "No definition found";
|
|
1037
|
+
useless = true;
|
|
1038
|
+
} else {
|
|
1039
|
+
const lines = await Promise.all(
|
|
1040
|
+
locations.map(location => formatLocationWithContext(location, this.session.cwd)),
|
|
1041
|
+
);
|
|
1042
|
+
output = `Found ${locations.length} definition(s):\n${lines.join("\n")}`;
|
|
1043
|
+
}
|
|
1044
|
+
break;
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
case "type_definition": {
|
|
1048
|
+
const result = (await sendRequest(
|
|
1049
|
+
client,
|
|
1050
|
+
"textDocument/typeDefinition",
|
|
1051
|
+
{
|
|
1052
|
+
textDocument: { uri },
|
|
1053
|
+
position,
|
|
1054
|
+
},
|
|
1055
|
+
signal,
|
|
1056
|
+
)) as Location | Location[] | LocationLink | LocationLink[] | null;
|
|
1057
|
+
|
|
1058
|
+
const locations = normalizeLocationResult(result);
|
|
1059
|
+
|
|
1060
|
+
if (locations.length === 0) {
|
|
1061
|
+
output = "No type definition found";
|
|
1062
|
+
useless = true;
|
|
1063
|
+
} else {
|
|
1064
|
+
const lines = await Promise.all(
|
|
1065
|
+
locations.map(location => formatLocationWithContext(location, this.session.cwd)),
|
|
1066
|
+
);
|
|
1067
|
+
output = `Found ${locations.length} type definition(s):\n${lines.join("\n")}`;
|
|
1068
|
+
}
|
|
1069
|
+
break;
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
case "implementation": {
|
|
1073
|
+
const result = (await sendRequest(
|
|
1074
|
+
client,
|
|
1075
|
+
"textDocument/implementation",
|
|
1076
|
+
{
|
|
1077
|
+
textDocument: { uri },
|
|
1078
|
+
position,
|
|
1079
|
+
},
|
|
1080
|
+
signal,
|
|
1081
|
+
)) as Location | Location[] | LocationLink | LocationLink[] | null;
|
|
1082
|
+
|
|
1083
|
+
const locations = normalizeLocationResult(result);
|
|
1084
|
+
|
|
1085
|
+
if (locations.length === 0) {
|
|
1086
|
+
output = "No implementation found";
|
|
1087
|
+
useless = true;
|
|
1088
|
+
} else {
|
|
1089
|
+
const lines = await Promise.all(
|
|
1090
|
+
locations.map(location => formatLocationWithContext(location, this.session.cwd)),
|
|
1091
|
+
);
|
|
1092
|
+
output = `Found ${locations.length} implementation(s):\n${lines.join("\n")}`;
|
|
1093
|
+
}
|
|
1094
|
+
break;
|
|
1095
|
+
}
|
|
1096
|
+
case "references": {
|
|
1097
|
+
let result: Location[] | null = null;
|
|
1098
|
+
for (let attempt = 0; attempt <= REFERENCES_RETRY_COUNT; attempt++) {
|
|
1099
|
+
result = (await sendRequest(
|
|
1100
|
+
client,
|
|
1101
|
+
"textDocument/references",
|
|
1102
|
+
{
|
|
1103
|
+
textDocument: { uri },
|
|
1104
|
+
position,
|
|
1105
|
+
context: { includeDeclaration: true },
|
|
1106
|
+
},
|
|
1107
|
+
signal,
|
|
1108
|
+
)) as Location[] | null;
|
|
1109
|
+
|
|
1110
|
+
const locations = result ?? [];
|
|
1111
|
+
if (!isProjectAwareLspServer(serverConfig) || attempt === REFERENCES_RETRY_COUNT) {
|
|
1112
|
+
break;
|
|
1113
|
+
}
|
|
1114
|
+
if (locations.length > 0 && !isOnlyQueriedDeclaration(locations, uri, position)) {
|
|
1115
|
+
break;
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
await waitForProjectLoaded(client, signal);
|
|
1119
|
+
throwIfAborted(signal);
|
|
1120
|
+
await untilAborted(signal, () => Bun.sleep(REFERENCES_RETRY_DELAY_MS));
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
if (!result || result.length === 0) {
|
|
1124
|
+
output = "No references found";
|
|
1125
|
+
useless = true;
|
|
1126
|
+
} else {
|
|
1127
|
+
const contextualReferences = result.slice(0, REFERENCE_CONTEXT_LIMIT);
|
|
1128
|
+
const plainReferences = result.slice(REFERENCE_CONTEXT_LIMIT);
|
|
1129
|
+
const contextualLines = await Promise.all(
|
|
1130
|
+
contextualReferences.map(location => formatLocationWithContext(location, this.session.cwd)),
|
|
1131
|
+
);
|
|
1132
|
+
const plainLines = plainReferences.map(location => ` ${formatLocation(location, this.session.cwd)}`);
|
|
1133
|
+
const lines = plainLines.length
|
|
1134
|
+
? [
|
|
1135
|
+
...contextualLines,
|
|
1136
|
+
` ... ${plainLines.length} additional reference(s) shown without context`,
|
|
1137
|
+
...plainLines,
|
|
1138
|
+
]
|
|
1139
|
+
: contextualLines;
|
|
1140
|
+
output = `Found ${result.length} reference(s):\n${lines.join("\n")}`;
|
|
1141
|
+
}
|
|
1142
|
+
break;
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
case "hover": {
|
|
1146
|
+
const result = (await sendRequest(
|
|
1147
|
+
client,
|
|
1148
|
+
"textDocument/hover",
|
|
1149
|
+
{
|
|
1150
|
+
textDocument: { uri },
|
|
1151
|
+
position,
|
|
1152
|
+
},
|
|
1153
|
+
signal,
|
|
1154
|
+
)) as Hover | null;
|
|
1155
|
+
|
|
1156
|
+
if (!result?.contents) {
|
|
1157
|
+
output = "No hover information";
|
|
1158
|
+
} else {
|
|
1159
|
+
output = extractHoverText(result.contents);
|
|
1160
|
+
}
|
|
1161
|
+
break;
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
case "code_actions": {
|
|
1165
|
+
const diagnostics = client.diagnostics.get(uri)?.diagnostics ?? [];
|
|
1166
|
+
const context: CodeActionContext = {
|
|
1167
|
+
diagnostics,
|
|
1168
|
+
only: !apply && query ? [query] : undefined,
|
|
1169
|
+
triggerKind: 1,
|
|
1170
|
+
};
|
|
1171
|
+
|
|
1172
|
+
const result = (await sendRequest(
|
|
1173
|
+
client,
|
|
1174
|
+
"textDocument/codeAction",
|
|
1175
|
+
{
|
|
1176
|
+
textDocument: { uri },
|
|
1177
|
+
range: { start: position, end: position },
|
|
1178
|
+
context,
|
|
1179
|
+
},
|
|
1180
|
+
signal,
|
|
1181
|
+
)) as (CodeAction | Command)[] | null;
|
|
1182
|
+
|
|
1183
|
+
if (!result || result.length === 0) {
|
|
1184
|
+
output = "No code actions available";
|
|
1185
|
+
break;
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
if (apply === true && query) {
|
|
1189
|
+
const normalizedQuery = query.trim();
|
|
1190
|
+
if (normalizedQuery.length === 0) {
|
|
1191
|
+
output = "Error: query parameter required when apply=true for code_actions";
|
|
1192
|
+
break;
|
|
1193
|
+
}
|
|
1194
|
+
const parsedIndex = /^\d+$/.test(normalizedQuery) ? Number.parseInt(normalizedQuery, 10) : null;
|
|
1195
|
+
const selectedAction =
|
|
1196
|
+
parsedIndex !== null
|
|
1197
|
+
? result[parsedIndex]
|
|
1198
|
+
: result.find(actionItem =>
|
|
1199
|
+
actionItem.title.toLowerCase().includes(normalizedQuery.toLowerCase()),
|
|
1200
|
+
);
|
|
1201
|
+
|
|
1202
|
+
if (!selectedAction) {
|
|
1203
|
+
const actionLines = result.map((actionItem, index) => ` ${formatCodeAction(actionItem, index)}`);
|
|
1204
|
+
output = `No code action matches "${normalizedQuery}". Available actions:\n${actionLines.join("\n")}`;
|
|
1205
|
+
break;
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
const appliedAction = await applyCodeAction(selectedAction, {
|
|
1209
|
+
resolveCodeAction: async actionItem =>
|
|
1210
|
+
(await sendRequest(client, "codeAction/resolve", actionItem, signal)) as CodeAction,
|
|
1211
|
+
applyWorkspaceEdit: async edit => applyWorkspaceEdit(edit, this.session.cwd),
|
|
1212
|
+
executeCommand: async commandItem => {
|
|
1213
|
+
await sendRequest(
|
|
1214
|
+
client,
|
|
1215
|
+
"workspace/executeCommand",
|
|
1216
|
+
{
|
|
1217
|
+
command: commandItem.command,
|
|
1218
|
+
arguments: commandItem.arguments ?? [],
|
|
1219
|
+
},
|
|
1220
|
+
signal,
|
|
1221
|
+
);
|
|
1222
|
+
},
|
|
1223
|
+
});
|
|
1224
|
+
|
|
1225
|
+
if (!appliedAction) {
|
|
1226
|
+
output = `Action "${selectedAction.title}" has no workspace edit or command to apply`;
|
|
1227
|
+
break;
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
const summaryLines: string[] = [];
|
|
1231
|
+
if (appliedAction.edits.length > 0) {
|
|
1232
|
+
summaryLines.push(" Workspace edit:");
|
|
1233
|
+
summaryLines.push(...appliedAction.edits.map(item => ` ${item}`));
|
|
1234
|
+
}
|
|
1235
|
+
if (appliedAction.executedCommands.length > 0) {
|
|
1236
|
+
summaryLines.push(" Executed command(s):");
|
|
1237
|
+
summaryLines.push(...appliedAction.executedCommands.map(commandName => ` ${commandName}`));
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
output = `Applied "${appliedAction.title}":\n${summaryLines.join("\n")}`;
|
|
1241
|
+
break;
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
const actionLines = result.map((actionItem, index) => ` ${formatCodeAction(actionItem, index)}`);
|
|
1245
|
+
output = `${result.length} code action(s):\n${actionLines.join("\n")}`;
|
|
1246
|
+
break;
|
|
1247
|
+
}
|
|
1248
|
+
case "symbols": {
|
|
1249
|
+
if (!targetFile) {
|
|
1250
|
+
output = "Error: file parameter required for document symbols";
|
|
1251
|
+
break;
|
|
1252
|
+
}
|
|
1253
|
+
// File-based document symbols
|
|
1254
|
+
const result = (await sendRequest(
|
|
1255
|
+
client,
|
|
1256
|
+
"textDocument/documentSymbol",
|
|
1257
|
+
{
|
|
1258
|
+
textDocument: { uri },
|
|
1259
|
+
},
|
|
1260
|
+
signal,
|
|
1261
|
+
)) as (DocumentSymbol | SymbolInformation)[] | null;
|
|
1262
|
+
|
|
1263
|
+
if (!result || result.length === 0) {
|
|
1264
|
+
output = "No symbols found";
|
|
1265
|
+
useless = true;
|
|
1266
|
+
} else {
|
|
1267
|
+
const relPath = formatPathRelativeToCwd(targetFile, this.session.cwd);
|
|
1268
|
+
if ("selectionRange" in result[0]) {
|
|
1269
|
+
const lines = (result as DocumentSymbol[]).flatMap(s => formatDocumentSymbol(s));
|
|
1270
|
+
output = `Symbols in ${relPath}:\n${lines.join("\n")}`;
|
|
1271
|
+
} else {
|
|
1272
|
+
const lines = (result as SymbolInformation[]).map(s => {
|
|
1273
|
+
const line = s.location.range.start.line + 1;
|
|
1274
|
+
const icon = symbolKindToIcon(s.kind);
|
|
1275
|
+
return `${icon} ${s.name} @ line ${line}`;
|
|
1276
|
+
});
|
|
1277
|
+
output = `Symbols in ${relPath}:\n${lines.join("\n")}`;
|
|
1278
|
+
}
|
|
1279
|
+
}
|
|
1280
|
+
break;
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
case "rename": {
|
|
1284
|
+
if (!new_name) {
|
|
1285
|
+
return {
|
|
1286
|
+
content: [{ type: "text", text: "Error: new_name parameter required for rename" }],
|
|
1287
|
+
details: { action, serverName, success: false },
|
|
1288
|
+
};
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
const result = (await sendRequest(
|
|
1292
|
+
client,
|
|
1293
|
+
"textDocument/rename",
|
|
1294
|
+
{
|
|
1295
|
+
textDocument: { uri },
|
|
1296
|
+
position,
|
|
1297
|
+
newName: new_name,
|
|
1298
|
+
},
|
|
1299
|
+
signal,
|
|
1300
|
+
)) as WorkspaceEdit | null;
|
|
1301
|
+
|
|
1302
|
+
if (!result) {
|
|
1303
|
+
output = "Rename returned no edits";
|
|
1304
|
+
} else {
|
|
1305
|
+
const shouldApply = apply !== false;
|
|
1306
|
+
if (shouldApply) {
|
|
1307
|
+
const applied = await applyWorkspaceEdit(result, this.session.cwd);
|
|
1308
|
+
output = `Applied rename:\n${applied.map(a => ` ${a}`).join("\n")}`;
|
|
1309
|
+
} else {
|
|
1310
|
+
const preview = formatWorkspaceEdit(result, this.session.cwd);
|
|
1311
|
+
output = `Rename preview:\n${preview.map(p => ` ${p}`).join("\n")}`;
|
|
1312
|
+
}
|
|
1313
|
+
}
|
|
1314
|
+
break;
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
case "reload": {
|
|
1318
|
+
output = await reloadServer(client, serverName, signal);
|
|
1319
|
+
break;
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
default:
|
|
1323
|
+
output = `Unknown action: ${action}`;
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1326
|
+
return {
|
|
1327
|
+
content: [{ type: "text", text: output }],
|
|
1328
|
+
details: { serverName, action, success: true, request: params },
|
|
1329
|
+
...(useless ? { useless: true } : {}),
|
|
1330
|
+
};
|
|
1331
|
+
} catch (err) {
|
|
1332
|
+
if (err instanceof ToolError) throw err;
|
|
1333
|
+
if (err instanceof ToolAbortError || signal?.aborted) {
|
|
1334
|
+
// Distinguish a wall-clock timeout from a caller cancel:
|
|
1335
|
+
// callerSignal aborting → real cancel (re-throw ToolAbortError);
|
|
1336
|
+
// timeoutSignal aborting without callerSignal → emit a ToolError naming the
|
|
1337
|
+
// elapsed budget and server, instead of opaque "Operation aborted".
|
|
1338
|
+
if (timeoutSignal.aborted && !callerSignal?.aborted) {
|
|
1339
|
+
throw new ToolError(
|
|
1340
|
+
`LSP ${action} timed out after ${timeoutSec}s on ${serverName}. The server may still be indexing; try again or pass timeout=<larger>.`,
|
|
1341
|
+
);
|
|
1342
|
+
}
|
|
1343
|
+
throw new ToolAbortError();
|
|
1344
|
+
}
|
|
1345
|
+
const errorMessage = err instanceof Error ? err.message : String(err);
|
|
1346
|
+
return {
|
|
1347
|
+
content: [{ type: "text", text: `LSP error: ${errorMessage}` }],
|
|
1348
|
+
details: { serverName, action, success: false, request: params },
|
|
1349
|
+
};
|
|
1350
|
+
}
|
|
1351
|
+
}
|
|
1352
|
+
}
|