@mrclrchtr/supi-lsp 0.1.0 → 1.1.2
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/README.md +112 -0
- package/node_modules/@mrclrchtr/supi-core/README.md +90 -0
- package/node_modules/@mrclrchtr/supi-core/package.json +26 -0
- package/node_modules/@mrclrchtr/supi-core/src/config-settings.ts +76 -0
- package/node_modules/@mrclrchtr/supi-core/src/config.ts +186 -0
- package/node_modules/@mrclrchtr/supi-core/src/context-messages.ts +119 -0
- package/node_modules/@mrclrchtr/supi-core/src/context-provider-registry.ts +36 -0
- package/node_modules/@mrclrchtr/supi-core/src/context-tag.ts +31 -0
- package/node_modules/@mrclrchtr/supi-core/src/debug-registry.ts +255 -0
- package/node_modules/@mrclrchtr/supi-core/src/index.ts +83 -0
- package/node_modules/@mrclrchtr/supi-core/src/project-roots.ts +170 -0
- package/node_modules/@mrclrchtr/supi-core/src/registry-utils.ts +54 -0
- package/node_modules/@mrclrchtr/supi-core/src/session-utils.ts +29 -0
- package/node_modules/@mrclrchtr/supi-core/src/settings-command.ts +15 -0
- package/node_modules/@mrclrchtr/supi-core/src/settings-registry.ts +41 -0
- package/node_modules/@mrclrchtr/supi-core/src/settings-ui.ts +226 -0
- package/node_modules/@mrclrchtr/supi-core/src/terminal.ts +60 -0
- package/package.json +16 -11
- package/{capabilities.ts → src/capabilities.ts} +8 -0
- package/src/client/client-refresh.ts +229 -0
- package/{client.ts → src/client/client.ts} +178 -30
- package/{transport.ts → src/client/transport.ts} +10 -6
- package/src/config.ts +143 -0
- package/src/defaults.json +82 -0
- package/src/diagnostics/diagnostic-augmentation.ts +82 -0
- package/src/diagnostics/diagnostic-display.ts +68 -0
- package/{diagnostic-summary.ts → src/diagnostics/diagnostic-summary.ts} +11 -7
- package/{diagnostics.ts → src/diagnostics/diagnostics.ts} +9 -4
- package/src/diagnostics/stale-diagnostics.ts +47 -0
- package/src/diagnostics/suppression-diagnostics.ts +58 -0
- package/src/format.ts +359 -0
- package/src/guidance.ts +163 -0
- package/src/index.ts +17 -0
- package/src/lsp-state.ts +82 -0
- package/src/lsp.ts +481 -0
- package/src/manager/manager-client-state.ts +34 -0
- package/src/manager/manager-diagnostics.ts +139 -0
- package/src/manager/manager-helpers.ts +39 -0
- package/src/manager/manager-project-info.ts +46 -0
- package/src/manager/manager-stale-resync.ts +47 -0
- package/src/manager/manager-types.ts +39 -0
- package/src/manager/manager-workspace-recovery.ts +83 -0
- package/src/manager/manager-workspace-symbol.ts +18 -0
- package/src/manager/manager.ts +550 -0
- package/src/overrides.ts +173 -0
- package/src/pattern-matcher.ts +197 -0
- package/src/renderer.ts +120 -0
- package/src/scanner.ts +153 -0
- package/src/search-fallback.ts +98 -0
- package/src/service-registry.ts +153 -0
- package/src/settings-registration.ts +292 -0
- package/{summary.ts → src/summary.ts} +44 -9
- package/src/tool-actions.ts +430 -0
- package/src/tree-persist.ts +48 -0
- package/src/tsconfig-scope.ts +156 -0
- package/{types.ts → src/types.ts} +123 -0
- package/src/ui.ts +358 -0
- package/{utils.ts → src/utils.ts} +8 -25
- package/src/workspace-sentinels.ts +114 -0
- package/bash-guard.ts +0 -58
- package/config.ts +0 -99
- package/defaults.json +0 -40
- package/format.ts +0 -190
- package/guidance.ts +0 -140
- package/lsp.ts +0 -375
- package/manager.ts +0 -396
- package/overrides.ts +0 -95
- package/recent-paths.ts +0 -126
- package/runtime-state.ts +0 -113
- package/tool-actions.ts +0 -211
- package/tsconfig.json +0 -5
- package/ui.ts +0 -303
package/src/lsp.ts
ADDED
|
@@ -0,0 +1,481 @@
|
|
|
1
|
+
// LSP Extension for pi — provides hover, definition, diagnostics, symbols, rename, code-actions
|
|
2
|
+
// via a registered `lsp` tool. Keeps language servers warm, surfaces inline diagnostics,
|
|
3
|
+
// and injects diagnostic context only when outstanding issues exist.
|
|
4
|
+
// biome-ignore-all lint/nursery/noExcessiveLinesPerFile: lsp.ts stays cohesive wiring; recovery and sentinel helpers live in focused modules.
|
|
5
|
+
|
|
6
|
+
import * as path from "node:path";
|
|
7
|
+
import { StringEnum } from "@earendil-works/pi-ai";
|
|
8
|
+
import type { BeforeAgentStartEventResult, ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
9
|
+
import { pruneAndReorderContextMessages, restorePromptContent } from "@mrclrchtr/supi-core";
|
|
10
|
+
import { Type } from "typebox";
|
|
11
|
+
import { loadConfig, resolveLanguageAlias } from "./config.ts";
|
|
12
|
+
import { formatDiagnosticsDisplayContent } from "./diagnostics/diagnostic-display.ts";
|
|
13
|
+
import { assessStaleDiagnostics } from "./diagnostics/stale-diagnostics.ts";
|
|
14
|
+
import {
|
|
15
|
+
buildProjectGuidelines,
|
|
16
|
+
diagnosticsContextFingerprint,
|
|
17
|
+
formatDiagnosticsContext,
|
|
18
|
+
lspPromptGuidelines,
|
|
19
|
+
lspPromptSnippet,
|
|
20
|
+
MAX_DETAILED_DIAGNOSTICS,
|
|
21
|
+
} from "./guidance.ts";
|
|
22
|
+
import {
|
|
23
|
+
createRuntimeState,
|
|
24
|
+
disableLspState,
|
|
25
|
+
ensureLspToolActive,
|
|
26
|
+
isLspAwareTool,
|
|
27
|
+
type LspRuntimeState,
|
|
28
|
+
refreshProjectServers,
|
|
29
|
+
removeLspTool,
|
|
30
|
+
} from "./lsp-state.ts";
|
|
31
|
+
import { LspManager } from "./manager/manager.ts";
|
|
32
|
+
import { forceResyncStaleModuleFiles } from "./manager/manager-stale-resync.ts";
|
|
33
|
+
import { registerLspAwareToolOverrides } from "./overrides.ts";
|
|
34
|
+
import { registerLspMessageRenderer } from "./renderer.ts";
|
|
35
|
+
import { scanMissingServers, scanProjectCapabilities, startDetectedServers } from "./scanner.ts";
|
|
36
|
+
import {
|
|
37
|
+
clearSessionLspService,
|
|
38
|
+
SessionLspService,
|
|
39
|
+
setSessionLspServiceState,
|
|
40
|
+
} from "./service-registry.ts";
|
|
41
|
+
import {
|
|
42
|
+
getLspDisabledMessage,
|
|
43
|
+
loadLspSettings,
|
|
44
|
+
registerLspSettings,
|
|
45
|
+
} from "./settings-registration.ts";
|
|
46
|
+
import { type LspAction, lspToolDescription, safeExecuteAction } from "./tool-actions.ts";
|
|
47
|
+
import {
|
|
48
|
+
persistLspActiveState,
|
|
49
|
+
persistLspInactiveState,
|
|
50
|
+
registerTreePersistHandlers,
|
|
51
|
+
} from "./tree-persist.ts";
|
|
52
|
+
import { FileChangeType } from "./types.ts";
|
|
53
|
+
import { toggleLspStatusOverlay, updateLspUi } from "./ui.ts";
|
|
54
|
+
import { fileToUri } from "./utils.ts";
|
|
55
|
+
import {
|
|
56
|
+
isWorkspaceRecoveryTrigger,
|
|
57
|
+
scanWorkspaceSentinels,
|
|
58
|
+
syncWorkspaceSentinelSnapshot,
|
|
59
|
+
} from "./workspace-sentinels.ts";
|
|
60
|
+
|
|
61
|
+
const LspActionEnum = StringEnum([
|
|
62
|
+
"hover",
|
|
63
|
+
"definition",
|
|
64
|
+
"references",
|
|
65
|
+
"diagnostics",
|
|
66
|
+
"symbols",
|
|
67
|
+
"rename",
|
|
68
|
+
"code_actions",
|
|
69
|
+
"workspace_symbol",
|
|
70
|
+
"search",
|
|
71
|
+
"symbol_hover",
|
|
72
|
+
"recover",
|
|
73
|
+
] as const);
|
|
74
|
+
|
|
75
|
+
export default function lspExtension(pi: ExtensionAPI) {
|
|
76
|
+
registerLspSettings();
|
|
77
|
+
const state = createRuntimeState();
|
|
78
|
+
|
|
79
|
+
registerLspAwareToolOverrides(pi, {
|
|
80
|
+
getInlineSeverity: () => state.inlineSeverity,
|
|
81
|
+
getManager: () => state.manager,
|
|
82
|
+
getCwd: () => state.manager?.getCwd() ?? process.cwd(),
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
registerLspTool(pi, state, lspPromptGuidelines);
|
|
86
|
+
registerSessionLifecycleHandlers(pi, state);
|
|
87
|
+
registerBehaviorHandlers(pi, state);
|
|
88
|
+
registerTreePersistHandlers(pi, state);
|
|
89
|
+
registerLspStatusCommand(pi, state);
|
|
90
|
+
registerLspMessageRenderer(pi);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function registerSessionLifecycleHandlers(pi: ExtensionAPI, state: LspRuntimeState): void {
|
|
94
|
+
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: session_start orchestrates setup, server detection, settings, and persistence.
|
|
95
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
96
|
+
if (state.manager) {
|
|
97
|
+
clearSessionLspService(state.manager.getCwd());
|
|
98
|
+
await state.manager.shutdownAll();
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const cwd = ctx.cwd;
|
|
102
|
+
const lspSettings = loadLspSettings(cwd);
|
|
103
|
+
|
|
104
|
+
if (!lspSettings.enabled) {
|
|
105
|
+
clearSessionLspService(cwd);
|
|
106
|
+
disableLspState(pi, state);
|
|
107
|
+
persistLspInactiveState(pi, state);
|
|
108
|
+
setSessionLspServiceState(cwd, { kind: "disabled" });
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
state.inlineSeverity = lspSettings.severity;
|
|
113
|
+
|
|
114
|
+
const config = loadConfig(cwd);
|
|
115
|
+
|
|
116
|
+
// Apply server allowlist filter from supi shared config
|
|
117
|
+
if (lspSettings.active.length > 0) {
|
|
118
|
+
const allowList = new Set(lspSettings.active.map(resolveLanguageAlias));
|
|
119
|
+
for (const name of Object.keys(config.servers)) {
|
|
120
|
+
if (!allowList.has(name)) {
|
|
121
|
+
delete config.servers[name];
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
clearSessionLspService(cwd);
|
|
127
|
+
state.manager = new LspManager(config, cwd);
|
|
128
|
+
state.manager.setExcludePatterns(lspSettings.exclude ?? []);
|
|
129
|
+
setSessionLspServiceState(cwd, { kind: "pending" });
|
|
130
|
+
state.detectedServers = scanProjectCapabilities(config, cwd);
|
|
131
|
+
state.manager.registerDetectedServers(state.detectedServers);
|
|
132
|
+
await startDetectedServers(state.manager, state.detectedServers);
|
|
133
|
+
|
|
134
|
+
const missing = scanMissingServers(config, cwd);
|
|
135
|
+
if (missing.length > 0) {
|
|
136
|
+
const parts = missing.map((m) => `${m.name} (${m.command})`);
|
|
137
|
+
ctx.ui.notify(
|
|
138
|
+
`LSP server not found for: ${parts.join(", ")}. Install the server to enable language intelligence.`,
|
|
139
|
+
"warning",
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
state.sentinelSnapshot = scanWorkspaceSentinels(cwd);
|
|
144
|
+
state.lastWorkspaceChangeAt = 0;
|
|
145
|
+
state.staleSuspected = false;
|
|
146
|
+
refreshProjectServers(state);
|
|
147
|
+
state.lastDiagnosticsFingerprint = null;
|
|
148
|
+
state.currentContextToken = null;
|
|
149
|
+
state.lspActive = true;
|
|
150
|
+
setSessionLspServiceState(cwd, {
|
|
151
|
+
kind: "ready",
|
|
152
|
+
service: new SessionLspService(state.manager),
|
|
153
|
+
});
|
|
154
|
+
registerLspTool(pi, state, buildProjectGuidelines(state.projectServers, cwd));
|
|
155
|
+
ensureLspToolActive(pi);
|
|
156
|
+
persistLspActiveState(pi, state);
|
|
157
|
+
updateLspUi(ctx, state.manager, state.inlineSeverity, state.projectServers);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
pi.on("session_shutdown", async () => {
|
|
161
|
+
if (state.manager) {
|
|
162
|
+
clearSessionLspService(state.manager.getCwd());
|
|
163
|
+
await state.manager.shutdownAll();
|
|
164
|
+
state.manager = null;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
state.inspector.close?.();
|
|
168
|
+
state.detectedServers = [];
|
|
169
|
+
state.projectServers = [];
|
|
170
|
+
state.lastDiagnosticsFingerprint = null;
|
|
171
|
+
state.currentContextToken = null;
|
|
172
|
+
state.staleSuspected = false;
|
|
173
|
+
state.lastWorkspaceChangeAt = 0;
|
|
174
|
+
state.sentinelSnapshot = new Map();
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
pi.on("agent_end", async (_event, ctx) => {
|
|
178
|
+
state.currentContextToken = null;
|
|
179
|
+
refreshProjectServers(state);
|
|
180
|
+
|
|
181
|
+
if (state.manager) {
|
|
182
|
+
updateLspUi(ctx, state.manager, state.inlineSeverity, state.projectServers);
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function markWorkspaceChange(state: LspRuntimeState): void {
|
|
188
|
+
state.lastWorkspaceChangeAt = Date.now();
|
|
189
|
+
state.staleSuspected = true;
|
|
190
|
+
state.lastDiagnosticsFingerprint = null;
|
|
191
|
+
state.currentContextToken = null;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function softRecoverWorkspaceChanges(
|
|
195
|
+
state: LspRuntimeState,
|
|
196
|
+
changes: import("./types.ts").FileEvent[],
|
|
197
|
+
): boolean {
|
|
198
|
+
if (!state.manager || changes.length === 0) return false;
|
|
199
|
+
|
|
200
|
+
state.manager.clearAllPullResultIds();
|
|
201
|
+
state.manager.notifyWorkspaceFileChanges(changes);
|
|
202
|
+
markWorkspaceChange(state);
|
|
203
|
+
return true;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function refreshWorkspaceSentinels(state: LspRuntimeState, cwd: string): boolean {
|
|
207
|
+
const { snapshot, changes } = syncWorkspaceSentinelSnapshot(cwd, state.sentinelSnapshot);
|
|
208
|
+
state.sentinelSnapshot = snapshot;
|
|
209
|
+
return softRecoverWorkspaceChanges(state, changes);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function recoverWorkspaceChangesFromToolResult(
|
|
213
|
+
state: LspRuntimeState,
|
|
214
|
+
cwd: string,
|
|
215
|
+
event: { toolName: string; isError: boolean; input?: unknown },
|
|
216
|
+
): boolean {
|
|
217
|
+
if (!state.manager || event.isError) return false;
|
|
218
|
+
if (event.toolName !== "write" && event.toolName !== "edit") return false;
|
|
219
|
+
if (!event.input || typeof event.input !== "object") return false;
|
|
220
|
+
|
|
221
|
+
const pathValue = (event.input as { path?: unknown }).path;
|
|
222
|
+
if (typeof pathValue !== "string") return false;
|
|
223
|
+
|
|
224
|
+
const resolvedPath = path.resolve(cwd, pathValue);
|
|
225
|
+
const fileEvent = { uri: fileToUri(resolvedPath), type: FileChangeType.Changed };
|
|
226
|
+
|
|
227
|
+
// Sentinel files (package.json, tsconfig.json, lockfiles, .d.ts)
|
|
228
|
+
if (isWorkspaceRecoveryTrigger(pathValue, cwd)) {
|
|
229
|
+
if (resolvedPath.endsWith(".d.ts")) {
|
|
230
|
+
return softRecoverWorkspaceChanges(state, [fileEvent]);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const { snapshot, changes } = syncWorkspaceSentinelSnapshot(cwd, state.sentinelSnapshot);
|
|
234
|
+
state.sentinelSnapshot = snapshot;
|
|
235
|
+
return softRecoverWorkspaceChanges(state, changes.length > 0 ? changes : [fileEvent]);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Source files matching an active language server's file types
|
|
239
|
+
if (state.manager.hasServerForExtension(pathValue)) {
|
|
240
|
+
return softRecoverWorkspaceChanges(state, [fileEvent]);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
return false;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** Build the `lsp-context` custom message used to surface outstanding diagnostics. */
|
|
247
|
+
// biome-ignore lint/complexity/useMaxParams: wrapper groups the prompt payload fields in one place.
|
|
248
|
+
function buildDiagnosticResult(
|
|
249
|
+
diagnostics: import("./manager/manager-types.ts").OutstandingDiagnosticSummaryEntry[],
|
|
250
|
+
detailed: { file: string; diagnostics: import("./types.ts").Diagnostic[] }[] | undefined,
|
|
251
|
+
severity: number,
|
|
252
|
+
token: string,
|
|
253
|
+
staleWarning?: string | null,
|
|
254
|
+
): BeforeAgentStartEventResult {
|
|
255
|
+
return {
|
|
256
|
+
message: {
|
|
257
|
+
customType: "lsp-context",
|
|
258
|
+
content: formatDiagnosticsDisplayContent(diagnostics, detailed),
|
|
259
|
+
display: true,
|
|
260
|
+
details: {
|
|
261
|
+
contextToken: token,
|
|
262
|
+
promptContent: formatDiagnosticsContext(diagnostics, 3, detailed, staleWarning),
|
|
263
|
+
inlineSeverity: severity,
|
|
264
|
+
...(staleWarning ? { staleWarning } : {}),
|
|
265
|
+
diagnostics: diagnostics.map((d) => ({
|
|
266
|
+
file: d.file,
|
|
267
|
+
errors: d.errors,
|
|
268
|
+
warnings: d.warnings,
|
|
269
|
+
information: d.information,
|
|
270
|
+
hints: d.hints,
|
|
271
|
+
})),
|
|
272
|
+
},
|
|
273
|
+
},
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function registerBehaviorHandlers(pi: ExtensionAPI, state: LspRuntimeState): void {
|
|
278
|
+
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: before_agent_start coordinates sentinel recovery, pruning, refresh, and diagnostic injection.
|
|
279
|
+
pi.on("before_agent_start", async (_event, ctx) => {
|
|
280
|
+
if (!state.manager || !state.lspActive) {
|
|
281
|
+
removeLspTool(pi);
|
|
282
|
+
if (!state.manager && state.lspActive) {
|
|
283
|
+
persistLspInactiveState(pi, state);
|
|
284
|
+
}
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
ensureLspToolActive(pi);
|
|
289
|
+
|
|
290
|
+
refreshWorkspaceSentinels(state, ctx.cwd);
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Two-pass prune/refresh pattern:
|
|
294
|
+
*
|
|
295
|
+
* 1. Prune files deleted since the last turn and send didClose.
|
|
296
|
+
* 2. Re-sync remaining open docs and wait for diagnostics to settle.
|
|
297
|
+
* 3. Prune *again* — late publishDiagnostics notifications (already
|
|
298
|
+
* in-flight when step 1 ran) may have re-created stale entries for
|
|
299
|
+
* files that no longer exist. `getAllDiagnostics()` also filters
|
|
300
|
+
* by existence, so this second pass is belt-and-suspenders.
|
|
301
|
+
*/
|
|
302
|
+
state.manager.pruneMissingFiles();
|
|
303
|
+
try {
|
|
304
|
+
await state.manager.refreshOpenDiagnostics();
|
|
305
|
+
} catch {
|
|
306
|
+
// Refresh failures must not prevent agent startup
|
|
307
|
+
}
|
|
308
|
+
state.manager.pruneMissingFiles();
|
|
309
|
+
|
|
310
|
+
// Force re-open files with module-resolution errors to clear stale
|
|
311
|
+
// diagnostics that persist when the TS server caches by content hash.
|
|
312
|
+
// Must run before the diagnostic summary so fresh results are captured.
|
|
313
|
+
try {
|
|
314
|
+
await forceResyncStaleModuleFiles(state.manager, ctx.cwd);
|
|
315
|
+
} catch {
|
|
316
|
+
// Best-effort: don't fail the agent turn
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
refreshProjectServers(state);
|
|
320
|
+
updateLspUi(ctx, state.manager, state.inlineSeverity, state.projectServers);
|
|
321
|
+
|
|
322
|
+
const diagnostics = state.manager.getOutstandingDiagnosticSummary(state.inlineSeverity);
|
|
323
|
+
const totalDiags = diagnostics.reduce((sum, d) => sum + d.total, 0);
|
|
324
|
+
const detailed =
|
|
325
|
+
totalDiags <= MAX_DETAILED_DIAGNOSTICS
|
|
326
|
+
? state.manager.getOutstandingDiagnostics(state.inlineSeverity)
|
|
327
|
+
: undefined;
|
|
328
|
+
const staleAssessment = state.staleSuspected
|
|
329
|
+
? assessStaleDiagnostics(state.manager.getOutstandingDiagnostics(4))
|
|
330
|
+
: { suspected: false, matchedFiles: [], warning: null };
|
|
331
|
+
state.staleSuspected = staleAssessment.suspected;
|
|
332
|
+
|
|
333
|
+
const staleWarning = staleAssessment.suspected ? staleAssessment.warning : null;
|
|
334
|
+
const content = formatDiagnosticsContext(diagnostics, 3, detailed, staleWarning);
|
|
335
|
+
const fingerprint = diagnosticsContextFingerprint(content);
|
|
336
|
+
|
|
337
|
+
if (!content) {
|
|
338
|
+
state.lastDiagnosticsFingerprint = null;
|
|
339
|
+
state.currentContextToken = null;
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
if (fingerprint === state.lastDiagnosticsFingerprint) {
|
|
344
|
+
state.currentContextToken = null;
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
state.lastDiagnosticsFingerprint = fingerprint;
|
|
349
|
+
state.currentContextToken = `lsp-context-${++state.contextCounter}`;
|
|
350
|
+
|
|
351
|
+
const result = buildDiagnosticResult(
|
|
352
|
+
diagnostics,
|
|
353
|
+
detailed,
|
|
354
|
+
state.inlineSeverity,
|
|
355
|
+
state.currentContextToken,
|
|
356
|
+
staleWarning,
|
|
357
|
+
);
|
|
358
|
+
|
|
359
|
+
return result;
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
pi.on("context", (event) => {
|
|
363
|
+
const messages = pruneAndReorderContextMessages(
|
|
364
|
+
event.messages as Array<{
|
|
365
|
+
role?: string;
|
|
366
|
+
customType?: string;
|
|
367
|
+
content?: unknown;
|
|
368
|
+
details?: unknown;
|
|
369
|
+
}>,
|
|
370
|
+
"lsp-context",
|
|
371
|
+
state.currentContextToken,
|
|
372
|
+
);
|
|
373
|
+
const contextMessages = restorePromptContent(
|
|
374
|
+
messages,
|
|
375
|
+
"lsp-context",
|
|
376
|
+
state.currentContextToken,
|
|
377
|
+
) as typeof event.messages;
|
|
378
|
+
|
|
379
|
+
if (
|
|
380
|
+
contextMessages.length === event.messages.length &&
|
|
381
|
+
contextMessages.every((m, i) => m === event.messages[i])
|
|
382
|
+
) {
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
return { messages: contextMessages };
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
pi.on("tool_result", async (event, ctx) => {
|
|
389
|
+
if (!state.manager) return;
|
|
390
|
+
|
|
391
|
+
const recoveryTriggered = recoverWorkspaceChangesFromToolResult(state, ctx.cwd, {
|
|
392
|
+
toolName: event.toolName,
|
|
393
|
+
isError: event.isError,
|
|
394
|
+
input: (event as { input?: unknown }).input,
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
if (recoveryTriggered || isLspAwareTool(event.toolName)) {
|
|
398
|
+
refreshProjectServers(state);
|
|
399
|
+
updateLspUi(ctx, state.manager, state.inlineSeverity, state.projectServers);
|
|
400
|
+
}
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function registerLspTool(
|
|
405
|
+
pi: ExtensionAPI,
|
|
406
|
+
state: LspRuntimeState,
|
|
407
|
+
promptGuidelines: string[],
|
|
408
|
+
): void {
|
|
409
|
+
pi.registerTool({
|
|
410
|
+
name: "lsp",
|
|
411
|
+
label: "LSP",
|
|
412
|
+
description: lspToolDescription,
|
|
413
|
+
promptSnippet: lspPromptSnippet,
|
|
414
|
+
promptGuidelines,
|
|
415
|
+
parameters: Type.Object({
|
|
416
|
+
action: LspActionEnum,
|
|
417
|
+
file: Type.Optional(Type.String({ description: "File path (relative or absolute)" })),
|
|
418
|
+
line: Type.Optional(Type.Number({ description: "1-based line number" })),
|
|
419
|
+
character: Type.Optional(Type.Number({ description: "1-based column number" })),
|
|
420
|
+
newName: Type.Optional(Type.String({ description: "New name (for rename action)" })),
|
|
421
|
+
query: Type.Optional(
|
|
422
|
+
Type.String({ description: "Search query (for workspace_symbol and search actions)" }),
|
|
423
|
+
),
|
|
424
|
+
symbol: Type.Optional(Type.String({ description: "Symbol name (for symbol_hover action)" })),
|
|
425
|
+
}),
|
|
426
|
+
// biome-ignore lint/complexity/useMaxParams: pi ToolDefinition.execute signature
|
|
427
|
+
async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
|
|
428
|
+
if (!state.manager) {
|
|
429
|
+
return {
|
|
430
|
+
content: [{ type: "text", text: "LSP not initialized. Start a new session first." }],
|
|
431
|
+
details: {},
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
const text = await safeExecuteAction(
|
|
436
|
+
state.manager,
|
|
437
|
+
params as {
|
|
438
|
+
action: LspAction;
|
|
439
|
+
file?: string;
|
|
440
|
+
line?: number;
|
|
441
|
+
character?: number;
|
|
442
|
+
newName?: string;
|
|
443
|
+
query?: string;
|
|
444
|
+
symbol?: string;
|
|
445
|
+
},
|
|
446
|
+
);
|
|
447
|
+
|
|
448
|
+
return {
|
|
449
|
+
content: [{ type: "text", text }],
|
|
450
|
+
details: {},
|
|
451
|
+
};
|
|
452
|
+
},
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function registerLspStatusCommand(pi: ExtensionAPI, state: LspRuntimeState): void {
|
|
457
|
+
pi.registerCommand("lsp-status", {
|
|
458
|
+
description: "Show detected LSP servers, roots, open files, and diagnostics",
|
|
459
|
+
handler: async (_args, ctx) => {
|
|
460
|
+
const lspSettings = loadLspSettings(ctx.cwd);
|
|
461
|
+
if (!lspSettings.enabled) {
|
|
462
|
+
ctx.ui.notify(getLspDisabledMessage(ctx.cwd), "warning");
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
if (!state.manager) {
|
|
467
|
+
ctx.ui.notify("LSP not initialized", "warning");
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
refreshProjectServers(state);
|
|
472
|
+
toggleLspStatusOverlay(
|
|
473
|
+
ctx,
|
|
474
|
+
state.manager,
|
|
475
|
+
state.inlineSeverity,
|
|
476
|
+
state.inspector,
|
|
477
|
+
state.projectServers,
|
|
478
|
+
);
|
|
479
|
+
},
|
|
480
|
+
});
|
|
481
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import * as path from "node:path";
|
|
2
|
+
import type { LspClient } from "../client/client.ts";
|
|
3
|
+
|
|
4
|
+
export function closeFileAcrossClients(clients: Iterable<LspClient>, filePath: string): void {
|
|
5
|
+
const resolvedPath = path.resolve(filePath);
|
|
6
|
+
for (const client of clients) {
|
|
7
|
+
client.didClose(resolvedPath);
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function pruneMissingFilesFromClients(clients: Iterable<LspClient>): string[] {
|
|
12
|
+
const removed: string[] = [];
|
|
13
|
+
for (const client of clients) {
|
|
14
|
+
const prune = (client as unknown as { pruneMissingFiles?: () => string[] }).pruneMissingFiles;
|
|
15
|
+
if (typeof prune === "function") {
|
|
16
|
+
removed.push(...prune.call(client));
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
return removed;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function refreshOpenDiagnosticsForClients(
|
|
23
|
+
clients: Iterable<LspClient>,
|
|
24
|
+
options?: { maxWaitMs?: number; quietMs?: number },
|
|
25
|
+
): Promise<void> {
|
|
26
|
+
const refreshes = Array.from(clients)
|
|
27
|
+
.filter((client) => client.status === "running")
|
|
28
|
+
.map((client) =>
|
|
29
|
+
client.refreshOpenDiagnostics(options).catch(() => {
|
|
30
|
+
// Soft-fail: LSP errors must not prevent agent startup
|
|
31
|
+
}),
|
|
32
|
+
);
|
|
33
|
+
await Promise.all(refreshes);
|
|
34
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import type { LspClient } from "../client/client.ts";
|
|
3
|
+
import { relativeFilePathFromUri } from "../diagnostics/diagnostic-summary.ts";
|
|
4
|
+
import { shouldIgnoreLspPath } from "../summary.ts";
|
|
5
|
+
import type { Diagnostic } from "../types.ts";
|
|
6
|
+
import { fileToUri, uriToFile } from "../utils.ts";
|
|
7
|
+
import { isExcludedByPattern } from "./manager-helpers.ts";
|
|
8
|
+
|
|
9
|
+
export interface DiagnosticSnapshotEntry {
|
|
10
|
+
receivedAt: number;
|
|
11
|
+
diagnostics: Diagnostic[];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface CascadeDiagnosticEntry {
|
|
15
|
+
uri: string;
|
|
16
|
+
diagnostics: Diagnostic[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface CascadingDiagnosticsResult {
|
|
20
|
+
primary: Diagnostic[];
|
|
21
|
+
cascade: CascadeDiagnosticEntry[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Find diagnostics for open files updated during a sync of another file.
|
|
26
|
+
*
|
|
27
|
+
* Files are considered cascade-affected when their diagnostic cache entry is
|
|
28
|
+
* new or has a newer `receivedAt` timestamp than the pre-sync snapshot.
|
|
29
|
+
*/
|
|
30
|
+
export function findCascadeDiagnosticEntries(
|
|
31
|
+
preSnapshot: Map<string, DiagnosticSnapshotEntry>,
|
|
32
|
+
postEntries: Map<string, DiagnosticSnapshotEntry>,
|
|
33
|
+
editedUri: string,
|
|
34
|
+
maxSeverity: number,
|
|
35
|
+
): CascadeDiagnosticEntry[] {
|
|
36
|
+
const result: CascadeDiagnosticEntry[] = [];
|
|
37
|
+
|
|
38
|
+
for (const [uri, entry] of postEntries) {
|
|
39
|
+
if (uri === editedUri) continue;
|
|
40
|
+
|
|
41
|
+
const previous = preSnapshot.get(uri);
|
|
42
|
+
if (previous && entry.receivedAt <= previous.receivedAt) continue;
|
|
43
|
+
|
|
44
|
+
const diagnostics = filterDiagnosticsBySeverity(entry.diagnostics, maxSeverity);
|
|
45
|
+
if (diagnostics.length === 0) continue;
|
|
46
|
+
|
|
47
|
+
result.push({ uri, diagnostics });
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return result.sort((a, b) => a.uri.localeCompare(b.uri));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Snapshot current diagnostic cache entries for the client's open URIs. */
|
|
54
|
+
export function snapshotOpenClientDiagnostics(
|
|
55
|
+
client: Pick<LspClient, "openUris" | "getDiagnosticCacheEntry">,
|
|
56
|
+
): Map<string, DiagnosticSnapshotEntry> {
|
|
57
|
+
const snapshot = new Map<string, DiagnosticSnapshotEntry>();
|
|
58
|
+
|
|
59
|
+
for (const uri of client.openUris) {
|
|
60
|
+
const entry = client.getDiagnosticCacheEntry(uri);
|
|
61
|
+
if (!entry) continue;
|
|
62
|
+
snapshot.set(uri, {
|
|
63
|
+
receivedAt: entry.receivedAt,
|
|
64
|
+
diagnostics: entry.diagnostics,
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return snapshot;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Build post-sync entries map for the client's open URIs. */
|
|
72
|
+
export function collectOpenClientDiagnosticEntries(
|
|
73
|
+
client: Pick<LspClient, "openUris" | "getDiagnosticCacheEntry">,
|
|
74
|
+
): Map<string, DiagnosticSnapshotEntry> {
|
|
75
|
+
return snapshotOpenClientDiagnostics(client);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Sync a file and return its diagnostics plus cascade-affected open files. */
|
|
79
|
+
export async function syncClientFileAndGetCascadingDiagnostics(
|
|
80
|
+
client: Pick<
|
|
81
|
+
LspClient,
|
|
82
|
+
"openUris" | "getDiagnosticCacheEntry" | "syncAndWaitForDiagnostics" | "clearPullResultIds"
|
|
83
|
+
>,
|
|
84
|
+
filePath: string,
|
|
85
|
+
maxSeverity: number,
|
|
86
|
+
): Promise<CascadingDiagnosticsResult> {
|
|
87
|
+
const preSnapshot = snapshotOpenClientDiagnostics(client);
|
|
88
|
+
const primary = filterDiagnosticsBySeverity(
|
|
89
|
+
await client.syncAndWaitForDiagnostics(filePath, readFileSync(filePath, "utf-8")),
|
|
90
|
+
maxSeverity,
|
|
91
|
+
);
|
|
92
|
+
client.clearPullResultIds();
|
|
93
|
+
|
|
94
|
+
const cascade = findCascadeDiagnosticEntries(
|
|
95
|
+
preSnapshot,
|
|
96
|
+
collectOpenClientDiagnosticEntries(client),
|
|
97
|
+
fileToUri(filePath),
|
|
98
|
+
maxSeverity,
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
return { primary, cascade };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function mapCascadeDiagnosticsToFiles(
|
|
105
|
+
entries: CascadeDiagnosticEntry[],
|
|
106
|
+
): Array<{ file: string; diagnostics: Diagnostic[] }> {
|
|
107
|
+
return entries.map((entry) => ({ file: uriToFile(entry.uri), diagnostics: entry.diagnostics }));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function collectOutstandingDiagnosticsDetailed(
|
|
111
|
+
clients: Iterable<Pick<LspClient, "getAllDiagnostics">>,
|
|
112
|
+
cwd: string,
|
|
113
|
+
excludePatterns: string[],
|
|
114
|
+
maxSeverity: number,
|
|
115
|
+
): Array<{ file: string; diagnostics: Diagnostic[] }> {
|
|
116
|
+
const fileDiags = new Map<string, Diagnostic[]>();
|
|
117
|
+
|
|
118
|
+
for (const client of clients) {
|
|
119
|
+
for (const entry of client.getAllDiagnostics()) {
|
|
120
|
+
const file = relativeFilePathFromUri(entry.uri, cwd);
|
|
121
|
+
if (shouldIgnoreLspPath(file, cwd)) continue;
|
|
122
|
+
if (isExcludedByPattern(file, excludePatterns)) continue;
|
|
123
|
+
const filtered = filterDiagnosticsBySeverity(entry.diagnostics, maxSeverity);
|
|
124
|
+
if (filtered.length === 0) continue;
|
|
125
|
+
const existing = fileDiags.get(file) ?? [];
|
|
126
|
+
fileDiags.set(file, [...existing, ...filtered]);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return Array.from(fileDiags.entries())
|
|
131
|
+
.map(([file, diagnostics]) => ({ file, diagnostics }))
|
|
132
|
+
.sort((a, b) => a.file.localeCompare(b.file));
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function filterDiagnosticsBySeverity(diagnostics: Diagnostic[], maxSeverity: number): Diagnostic[] {
|
|
136
|
+
return diagnostics.filter(
|
|
137
|
+
(diagnostic) => diagnostic.severity !== undefined && diagnostic.severity <= maxSeverity,
|
|
138
|
+
);
|
|
139
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import * as path from "node:path";
|
|
2
|
+
import * as projectRoots from "@mrclrchtr/supi-core";
|
|
3
|
+
import { isGlobMatch } from "../pattern-matcher.ts";
|
|
4
|
+
|
|
5
|
+
/** Unique key for a client identified by server name and root. */
|
|
6
|
+
export function clientKey(serverName: string, root: string): string {
|
|
7
|
+
return `${serverName}:${root}`;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Resolve the project root for a file given its server's root markers. */
|
|
11
|
+
export function resolveRootForFile(
|
|
12
|
+
filePath: string,
|
|
13
|
+
serverName: string,
|
|
14
|
+
rootMarkers: string[],
|
|
15
|
+
opts: { knownRoots: Map<string, string[]>; cwd: string },
|
|
16
|
+
): string {
|
|
17
|
+
const preferredRoots = opts.knownRoots.get(serverName) ?? [];
|
|
18
|
+
const knownRoot = projectRoots.resolveKnownRoot(filePath, preferredRoots);
|
|
19
|
+
if (knownRoot) return knownRoot;
|
|
20
|
+
const fileDir = path.dirname(path.resolve(filePath));
|
|
21
|
+
return projectRoots.findProjectRoot(fileDir, rootMarkers, opts.cwd);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Add a root to the set of known roots for a server, deduplicating. */
|
|
25
|
+
export function rememberKnownRoot(
|
|
26
|
+
knownRoots: Map<string, string[]>,
|
|
27
|
+
serverName: string,
|
|
28
|
+
root: string,
|
|
29
|
+
): void {
|
|
30
|
+
const roots = knownRoots.get(serverName) ?? [];
|
|
31
|
+
knownRoots.set(serverName, projectRoots.mergeKnownRoots(roots, root));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Check if a file path matches any user-configured exclude pattern. */
|
|
35
|
+
export function isExcludedByPattern(file: string, excludePatterns: string[]): boolean {
|
|
36
|
+
return (
|
|
37
|
+
excludePatterns.length > 0 && excludePatterns.some((pattern) => isGlobMatch(file, pattern))
|
|
38
|
+
);
|
|
39
|
+
}
|