@mrclrchtr/supi-code-intelligence 1.3.1 → 1.4.0

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.
Files changed (103) hide show
  1. package/README.md +70 -32
  2. package/node_modules/@mrclrchtr/supi-core/README.md +52 -41
  3. package/node_modules/@mrclrchtr/supi-core/package.json +1 -1
  4. package/node_modules/@mrclrchtr/supi-core/src/api.ts +13 -13
  5. package/node_modules/@mrclrchtr/supi-core/src/{config-settings.ts → config/config-settings.ts} +2 -2
  6. package/node_modules/@mrclrchtr/{supi-lsp/node_modules/@mrclrchtr/supi-core/src → supi-core/src/context}/context-provider-registry.ts +1 -1
  7. package/node_modules/@mrclrchtr/supi-core/src/extension.ts +1 -1
  8. package/node_modules/@mrclrchtr/supi-core/src/index.ts +13 -13
  9. package/node_modules/@mrclrchtr/{supi-lsp/node_modules/@mrclrchtr/supi-core/src → supi-core/src/settings}/settings-registry.ts +1 -1
  10. package/node_modules/@mrclrchtr/supi-lsp/README.md +58 -39
  11. package/node_modules/@mrclrchtr/supi-lsp/node_modules/@mrclrchtr/supi-core/README.md +52 -41
  12. package/node_modules/@mrclrchtr/supi-lsp/node_modules/@mrclrchtr/supi-core/package.json +1 -1
  13. package/node_modules/@mrclrchtr/supi-lsp/node_modules/@mrclrchtr/supi-core/src/api.ts +13 -13
  14. package/node_modules/@mrclrchtr/supi-lsp/node_modules/@mrclrchtr/supi-core/src/{config-settings.ts → config/config-settings.ts} +2 -2
  15. package/node_modules/@mrclrchtr/{supi-core/src → supi-lsp/node_modules/@mrclrchtr/supi-core/src/context}/context-provider-registry.ts +1 -1
  16. package/node_modules/@mrclrchtr/supi-lsp/node_modules/@mrclrchtr/supi-core/src/extension.ts +1 -1
  17. package/node_modules/@mrclrchtr/supi-lsp/node_modules/@mrclrchtr/supi-core/src/index.ts +13 -13
  18. package/node_modules/@mrclrchtr/{supi-core/src → supi-lsp/node_modules/@mrclrchtr/supi-core/src/settings}/settings-registry.ts +1 -1
  19. package/node_modules/@mrclrchtr/supi-lsp/package.json +3 -2
  20. package/node_modules/@mrclrchtr/supi-lsp/src/api.ts +16 -3
  21. package/node_modules/@mrclrchtr/supi-lsp/src/client/client-refresh.ts +1 -1
  22. package/node_modules/@mrclrchtr/supi-lsp/src/client/client.ts +27 -3
  23. package/node_modules/@mrclrchtr/supi-lsp/src/client/transport.ts +61 -5
  24. package/node_modules/@mrclrchtr/supi-lsp/src/config/tsconfig-scope.ts +244 -0
  25. package/node_modules/@mrclrchtr/supi-lsp/src/{types.ts → config/types.ts} +4 -2
  26. package/node_modules/@mrclrchtr/supi-lsp/src/coordinates.ts +11 -0
  27. package/node_modules/@mrclrchtr/supi-lsp/src/diagnostics/diagnostic-augmentation.ts +5 -5
  28. package/node_modules/@mrclrchtr/supi-lsp/src/diagnostics/diagnostic-context.ts +115 -0
  29. package/node_modules/@mrclrchtr/supi-lsp/src/diagnostics/diagnostic-display.ts +1 -1
  30. package/node_modules/@mrclrchtr/supi-lsp/src/diagnostics/diagnostic-summary.ts +3 -2
  31. package/node_modules/@mrclrchtr/supi-lsp/src/diagnostics/diagnostics.ts +1 -1
  32. package/node_modules/@mrclrchtr/supi-lsp/src/diagnostics/stale-diagnostics.ts +1 -1
  33. package/node_modules/@mrclrchtr/supi-lsp/src/diagnostics/suppression-diagnostics.ts +1 -1
  34. package/node_modules/@mrclrchtr/supi-lsp/src/{workspace-sentinels.ts → diagnostics/workspace-sentinels.ts} +2 -2
  35. package/node_modules/@mrclrchtr/supi-lsp/src/format.ts +2 -23
  36. package/node_modules/@mrclrchtr/supi-lsp/src/index.ts +18 -5
  37. package/node_modules/@mrclrchtr/supi-lsp/src/lsp.ts +72 -120
  38. package/node_modules/@mrclrchtr/supi-lsp/src/manager/manager-diagnostics.ts +1 -1
  39. package/node_modules/@mrclrchtr/supi-lsp/src/manager/manager-helpers.ts +4 -2
  40. package/node_modules/@mrclrchtr/supi-lsp/src/manager/manager-project-info.ts +10 -7
  41. package/node_modules/@mrclrchtr/supi-lsp/src/manager/manager-workspace-recovery.ts +1 -1
  42. package/node_modules/@mrclrchtr/supi-lsp/src/manager/manager-workspace-symbol.ts +158 -6
  43. package/node_modules/@mrclrchtr/supi-lsp/src/manager/manager.ts +202 -43
  44. package/node_modules/@mrclrchtr/supi-lsp/src/{lsp-state.ts → session/lsp-state.ts} +22 -11
  45. package/node_modules/@mrclrchtr/supi-lsp/src/{scanner.ts → session/scanner.ts} +3 -3
  46. package/node_modules/@mrclrchtr/supi-lsp/src/{service-registry.ts → session/service-registry.ts} +104 -12
  47. package/node_modules/@mrclrchtr/supi-lsp/src/{settings-registration.ts → session/settings-registration.ts} +1 -1
  48. package/node_modules/@mrclrchtr/supi-lsp/src/session/tree-persist.ts +75 -0
  49. package/node_modules/@mrclrchtr/supi-lsp/src/summary.ts +1 -1
  50. package/node_modules/@mrclrchtr/supi-lsp/src/tool/guidance.ts +138 -0
  51. package/node_modules/@mrclrchtr/supi-lsp/src/tool/names.ts +19 -0
  52. package/node_modules/@mrclrchtr/supi-lsp/src/{overrides.ts → tool/overrides.ts} +55 -24
  53. package/node_modules/@mrclrchtr/supi-lsp/src/tool/register-tools.ts +224 -0
  54. package/node_modules/@mrclrchtr/supi-lsp/src/tool/service-actions.ts +258 -0
  55. package/node_modules/@mrclrchtr/supi-lsp/src/{ui.ts → ui/ui.ts} +4 -4
  56. package/node_modules/@mrclrchtr/supi-lsp/src/utils.ts +11 -0
  57. package/node_modules/@mrclrchtr/supi-tree-sitter/README.md +46 -39
  58. package/node_modules/@mrclrchtr/supi-tree-sitter/package.json +1 -1
  59. package/node_modules/@mrclrchtr/supi-tree-sitter/src/api.ts +1 -1
  60. package/node_modules/@mrclrchtr/supi-tree-sitter/src/index.ts +1 -1
  61. package/node_modules/@mrclrchtr/supi-tree-sitter/src/{runtime.ts → session/runtime.ts} +3 -3
  62. package/node_modules/@mrclrchtr/supi-tree-sitter/src/{session.ts → session/session.ts} +4 -4
  63. package/node_modules/@mrclrchtr/supi-tree-sitter/src/{callees.ts → tool/callees.ts} +3 -3
  64. package/node_modules/@mrclrchtr/supi-tree-sitter/src/{exports.ts → tool/exports.ts} +4 -4
  65. package/node_modules/@mrclrchtr/supi-tree-sitter/src/{formatting.ts → tool/formatting.ts} +1 -1
  66. package/node_modules/@mrclrchtr/supi-tree-sitter/src/tool/guidance.ts +22 -0
  67. package/node_modules/@mrclrchtr/supi-tree-sitter/src/{imports.ts → tool/imports.ts} +4 -4
  68. package/node_modules/@mrclrchtr/supi-tree-sitter/src/{node-at.ts → tool/node-at.ts} +3 -3
  69. package/node_modules/@mrclrchtr/supi-tree-sitter/src/{outline.ts → tool/outline.ts} +3 -3
  70. package/node_modules/@mrclrchtr/supi-tree-sitter/src/tree-sitter.ts +6 -29
  71. package/package.json +4 -4
  72. package/src/actions/affected-action.ts +4 -4
  73. package/src/actions/brief-action.ts +12 -13
  74. package/src/actions/callees-action.ts +14 -10
  75. package/src/actions/callers-action.ts +4 -4
  76. package/src/actions/implementations-action.ts +4 -4
  77. package/src/code-intelligence.ts +1 -1
  78. package/src/pattern-structured.ts +20 -22
  79. package/src/providers/semantic-provider.ts +34 -0
  80. package/src/providers/structural-provider.ts +14 -0
  81. package/src/target-resolution.ts +26 -35
  82. package/src/tool/guidance.ts +21 -0
  83. package/node_modules/@mrclrchtr/supi-lsp/src/guidance.ts +0 -163
  84. package/node_modules/@mrclrchtr/supi-lsp/src/search-fallback.ts +0 -98
  85. package/node_modules/@mrclrchtr/supi-lsp/src/tool-actions.ts +0 -430
  86. package/node_modules/@mrclrchtr/supi-lsp/src/tree-persist.ts +0 -48
  87. package/node_modules/@mrclrchtr/supi-lsp/src/tsconfig-scope.ts +0 -156
  88. package/src/guidance.ts +0 -42
  89. /package/node_modules/@mrclrchtr/supi-core/src/{config.ts → config/config.ts} +0 -0
  90. /package/node_modules/@mrclrchtr/supi-core/src/{context-messages.ts → context/context-messages.ts} +0 -0
  91. /package/node_modules/@mrclrchtr/supi-core/src/{context-tag.ts → context/context-tag.ts} +0 -0
  92. /package/node_modules/@mrclrchtr/supi-core/src/{settings-command.ts → settings/settings-command.ts} +0 -0
  93. /package/node_modules/@mrclrchtr/supi-core/src/{settings-ui.ts → settings/settings-ui.ts} +0 -0
  94. /package/node_modules/@mrclrchtr/supi-lsp/node_modules/@mrclrchtr/supi-core/src/{config.ts → config/config.ts} +0 -0
  95. /package/node_modules/@mrclrchtr/supi-lsp/node_modules/@mrclrchtr/supi-core/src/{context-messages.ts → context/context-messages.ts} +0 -0
  96. /package/node_modules/@mrclrchtr/supi-lsp/node_modules/@mrclrchtr/supi-core/src/{context-tag.ts → context/context-tag.ts} +0 -0
  97. /package/node_modules/@mrclrchtr/supi-lsp/node_modules/@mrclrchtr/supi-core/src/{settings-command.ts → settings/settings-command.ts} +0 -0
  98. /package/node_modules/@mrclrchtr/supi-lsp/node_modules/@mrclrchtr/supi-core/src/{settings-ui.ts → settings/settings-ui.ts} +0 -0
  99. /package/node_modules/@mrclrchtr/supi-lsp/src/{capabilities.ts → config/capabilities.ts} +0 -0
  100. /package/node_modules/@mrclrchtr/supi-lsp/src/{config.ts → config/config.ts} +0 -0
  101. /package/node_modules/@mrclrchtr/supi-lsp/src/{defaults.json → config/defaults.json} +0 -0
  102. /package/node_modules/@mrclrchtr/supi-lsp/src/{renderer.ts → ui/renderer.ts} +0 -0
  103. /package/node_modules/@mrclrchtr/supi-tree-sitter/src/{structure.ts → tool/structure.ts} +0 -0
@@ -4,7 +4,16 @@ import * as fs from "node:fs";
4
4
  import * as path from "node:path";
5
5
  import * as projectRoots from "@mrclrchtr/supi-core/api";
6
6
  import { LspClient } from "../client/client.ts";
7
- import { getServerForFile } from "../config.ts";
7
+ import { getServerForFile } from "../config/config.ts";
8
+ import type {
9
+ DetectedProjectServer,
10
+ Diagnostic,
11
+ FileEvent,
12
+ LspConfig,
13
+ ProjectServerInfo,
14
+ SymbolInformation,
15
+ WorkspaceSymbol,
16
+ } from "../config/types.ts";
8
17
  import {
9
18
  accumulateOutstandingDiagnostics,
10
19
  collectDiagnosticSummaryCounts,
@@ -19,14 +28,7 @@ import {
19
28
  normalizeRelevantPaths,
20
29
  shouldIgnoreLspPath,
21
30
  } from "../summary.ts";
22
- import type {
23
- DetectedProjectServer,
24
- Diagnostic,
25
- FileEvent,
26
- LspConfig,
27
- ProjectServerInfo,
28
- } from "../types.ts";
29
- import { commandExists } from "../utils.ts";
31
+ import { commandExists, resolveSessionPath } from "../utils.ts";
30
32
  import {
31
33
  closeFileAcrossClients,
32
34
  pruneMissingFilesFromClients,
@@ -53,12 +55,15 @@ import type {
53
55
  ServerStatus,
54
56
  } from "./manager-types.ts";
55
57
  import { recoverWorkspaceDiagnostics as recoverWorkspaceDiagnosticsImpl } from "./manager-workspace-recovery.ts";
58
+
59
+ type UnavailableReason = "missing-command" | "start-failed" | "runtime-error";
60
+
56
61
  // ── LspManager ────────────────────────────────────────────────────────
57
62
  export class LspManager {
58
63
  /** Active clients keyed by "serverName:root" */
59
64
  private clients = new Map<string, LspClient>();
60
- /** Servers we've already tried and failed to start */
61
- private unavailable = new Set<string>();
65
+ /** Per-root startup failures keyed by "serverName:root" */
66
+ private unavailable = new Map<string, UnavailableReason>();
62
67
  /** Memoized per-command availability of LSP server binaries on PATH */
63
68
  private commandAvailability = new Map<string, boolean>();
64
69
  /** Guards against concurrent client creation for the same server:root key */
@@ -67,6 +72,8 @@ export class LspManager {
67
72
  private knownRoots = new Map<string, string[]>();
68
73
  /** User-configured gitignore-style exclude patterns */
69
74
  private excludePatterns: string[] = [];
75
+ /** Project roots already warmed for workspace-symbol queries. */
76
+ private warmedWorkspaceSymbolProjects = new Set<string>();
70
77
  constructor(
71
78
  private readonly config: LspConfig,
72
79
  private readonly cwd: string,
@@ -80,32 +87,43 @@ export class LspManager {
80
87
 
81
88
  /** Check whether any configured language server handles the given file's extension. */
82
89
  hasServerForExtension(filePath: string): boolean {
83
- return getServerForFile(this.config, filePath) !== null;
90
+ return getServerForFile(this.config, resolveSessionPath(this.cwd, filePath)) !== null;
84
91
  }
85
92
  // ── Public API ────────────────────────────────────────────────────
86
93
  registerDetectedServers(detected: DetectedProjectServer[]): void {
87
94
  this.knownRoots = projectRoots.buildKnownRootsMap(detected);
88
95
  }
89
- /** Check whether a file path has an available LSP server. */
90
- isSupportedSourceFile(filePath: string): boolean {
91
- // Dependency directories are intentionally excluded from recent-path
92
- // tracking and diagnostic summaries (shouldIgnoreLspPath). Keep runtime
93
- // guidance activation consistent: reading or editing a file under
94
- // node_modules / .pnpm must not arm LSP guidance for dependency sources.
95
- if (shouldIgnoreLspPath(filePath, this.cwd)) return false;
96
- const match = getServerForFile(this.config, filePath);
96
+ /** Check whether a file path has an available LSP server for explicit semantic operations. */
97
+ canServeFile(filePath: string): boolean {
98
+ const resolvedPath = resolveSessionPath(this.cwd, filePath);
99
+ const match = getServerForFile(this.config, resolvedPath);
97
100
  if (!match) return false;
98
101
  const [serverName, serverConfig] = match;
99
102
  // Mirror getClientForFile's root resolution so the unavailable check stays
100
103
  // root-specific. A failed startup in one workspace must not suppress
101
104
  // activation for unrelated roots served by the same language server.
102
- const root = resolveRootForFile(filePath, serverName, serverConfig.rootMarkers, {
105
+ const root = resolveRootForFile(resolvedPath, serverName, serverConfig.rootMarkers, {
103
106
  knownRoots: this.knownRoots,
104
107
  cwd: this.cwd,
105
108
  });
106
- if (this.unavailable.has(`${serverName}:${root}`)) return false;
109
+ const key = clientKey(serverName, root);
110
+ if (this.getUnavailableReason(key, serverConfig.command)) return false;
107
111
  return this.isServerCommandAvailable(serverConfig.command);
108
112
  }
113
+
114
+ /**
115
+ * Check whether a file should participate in runtime guidance and diagnostics.
116
+ * This is stricter than {@link canServeFile} and intentionally filters dependency
117
+ * and tsconfig-excluded paths from UI/context behavior.
118
+ */
119
+ isSupportedSourceFile(filePath: string): boolean {
120
+ // Dependency directories are intentionally excluded from recent-path
121
+ // tracking and diagnostic summaries (shouldIgnoreLspPath). Keep runtime
122
+ // guidance activation consistent: reading or editing a file under
123
+ // node_modules / .pnpm must not arm LSP guidance for dependency sources.
124
+ if (shouldIgnoreLspPath(filePath, this.cwd)) return false;
125
+ return this.canServeFile(filePath);
126
+ }
109
127
  private isServerCommandAvailable(command: string): boolean {
110
128
  // Only memoize positive lookups. A negative result may become stale if the
111
129
  // user installs the binary mid-session (e.g. `mise install`), and
@@ -117,12 +135,25 @@ export class LspManager {
117
135
  if (available) this.commandAvailability.set(command, true);
118
136
  return available;
119
137
  }
138
+
139
+ private getUnavailableReason(key: string, command?: string): UnavailableReason | null {
140
+ const reason = this.unavailable.get(key);
141
+ if (!reason) return null;
142
+
143
+ if (reason === "missing-command" && command && this.isServerCommandAvailable(command)) {
144
+ this.unavailable.delete(key);
145
+ return null;
146
+ }
147
+
148
+ return reason;
149
+ }
120
150
  /** Get or create an LSP client for the given file. */
121
151
  async getClientForFile(filePath: string): Promise<LspClient | null> {
122
- const match = getServerForFile(this.config, filePath);
152
+ const resolvedPath = resolveSessionPath(this.cwd, filePath);
153
+ const match = getServerForFile(this.config, resolvedPath);
123
154
  if (!match) return null;
124
155
  const [serverName, serverConfig] = match;
125
- const root = resolveRootForFile(filePath, serverName, serverConfig.rootMarkers, {
156
+ const root = resolveRootForFile(resolvedPath, serverName, serverConfig.rootMarkers, {
126
157
  knownRoots: this.knownRoots,
127
158
  cwd: this.cwd,
128
159
  });
@@ -132,7 +163,7 @@ export class LspManager {
132
163
  const serverConfig = this.config.servers[serverName];
133
164
  if (!serverConfig) return null;
134
165
  const key = clientKey(serverName, root);
135
- if (this.unavailable.has(key)) return null;
166
+ if (this.getUnavailableReason(key, serverConfig.command)) return null;
136
167
 
137
168
  // Return existing client
138
169
  const existing = this.clients.get(key);
@@ -141,7 +172,8 @@ export class LspManager {
141
172
  // If existing client errored, remove it
142
173
  if (existing && existing.status === "error") {
143
174
  this.clients.delete(key);
144
- this.unavailable.add(key);
175
+ this.unavailable.set(key, "runtime-error");
176
+ this.clearWarmedWorkspaceSymbolProjects(existing.name, existing.root);
145
177
  return null;
146
178
  }
147
179
 
@@ -168,25 +200,27 @@ export class LspManager {
168
200
  */
169
201
  private async performStart(
170
202
  serverName: string,
171
- serverConfig: import("../types.ts").ServerConfig,
203
+ serverConfig: import("../config/types.ts").ServerConfig,
172
204
  root: string,
173
205
  key: string,
174
206
  ): Promise<LspClient | null> {
175
207
  // Validate command exists
176
208
  if (!commandExists(serverConfig.command)) {
177
- this.unavailable.add(key);
209
+ this.unavailable.set(key, "missing-command");
178
210
  return null;
179
211
  }
180
212
 
181
213
  // Spawn new client
182
214
  const client = new LspClient(serverName, serverConfig, root);
215
+ this.clearWarmedWorkspaceSymbolProjects(serverName, root);
183
216
  this.clients.set(key, client);
184
217
  rememberKnownRoot(this.knownRoots, serverName, root);
185
218
  try {
186
219
  await client.start();
220
+ this.unavailable.delete(key);
187
221
  return client;
188
222
  } catch {
189
- this.unavailable.add(key);
223
+ this.unavailable.set(key, "start-failed");
190
224
  this.clients.delete(key);
191
225
  return null;
192
226
  }
@@ -194,10 +228,11 @@ export class LspManager {
194
228
 
195
229
  /** Find an already-started client for a file without spawning a new server. */
196
230
  private getExistingClientForFile(filePath: string): LspClient | null {
197
- const match = getServerForFile(this.config, filePath);
231
+ const resolvedPath = resolveSessionPath(this.cwd, filePath);
232
+ const match = getServerForFile(this.config, resolvedPath);
198
233
  if (!match) return null;
199
234
  const [serverName, serverConfig] = match;
200
- const root = resolveRootForFile(filePath, serverName, serverConfig.rootMarkers, {
235
+ const root = resolveRootForFile(resolvedPath, serverName, serverConfig.rootMarkers, {
201
236
  knownRoots: this.knownRoots,
202
237
  cwd: this.cwd,
203
238
  });
@@ -210,7 +245,7 @@ export class LspManager {
210
245
  const seen = new Set<string>();
211
246
 
212
247
  for (const filePath of filePaths) {
213
- const resolvedPath = path.resolve(this.cwd, filePath);
248
+ const resolvedPath = resolveSessionPath(this.cwd, filePath);
214
249
  const client = this.getExistingClientForFile(resolvedPath);
215
250
  if (!client) continue;
216
251
 
@@ -240,6 +275,7 @@ export class LspManager {
240
275
 
241
276
  this.clients.delete(key);
242
277
  this.unavailable.delete(key);
278
+ this.clearWarmedWorkspaceSymbolProjects(client.name, client.root);
243
279
 
244
280
  const replacement = new LspClient(client.name, serverConfig, client.root);
245
281
  this.clients.set(key, replacement);
@@ -258,7 +294,7 @@ export class LspManager {
258
294
  return true;
259
295
  } catch {
260
296
  this.clients.delete(key);
261
- this.unavailable.add(key);
297
+ this.unavailable.set(key, "start-failed");
262
298
  return false;
263
299
  }
264
300
  }
@@ -271,7 +307,8 @@ export class LspManager {
271
307
  root,
272
308
  fileTypes,
273
309
  client: this.clients.get(key),
274
- unavailable: this.unavailable.has(key),
310
+ unavailableReason:
311
+ this.getUnavailableReason(key, this.config.servers[serverName]?.command) ?? undefined,
275
312
  },
276
313
  this.cwd,
277
314
  );
@@ -303,7 +340,7 @@ export class LspManager {
303
340
  filePath: string,
304
341
  maxSeverity: number = 1,
305
342
  ): Promise<Diagnostic[]> {
306
- const resolvedPath = path.resolve(filePath);
343
+ const resolvedPath = resolveSessionPath(this.cwd, filePath);
307
344
  return (
308
345
  (await this.syncFileAndGetCascadingDiagnostics(resolvedPath, maxSeverity)).find(
309
346
  (entry) => entry.file === resolvedPath,
@@ -314,9 +351,9 @@ export class LspManager {
314
351
  filePath: string,
315
352
  maxSeverity: number = 1,
316
353
  ): Promise<Array<{ file: string; diagnostics: Diagnostic[] }>> {
317
- const client = await this.getClientForFile(filePath);
354
+ const resolvedPath = resolveSessionPath(this.cwd, filePath);
355
+ const client = await this.getClientForFile(resolvedPath);
318
356
  if (!client) return [];
319
- const resolvedPath = path.resolve(filePath);
320
357
  try {
321
358
  const { primary, cascade } = await syncClientFileAndGetCascadingDiagnostics(
322
359
  client,
@@ -334,7 +371,7 @@ export class LspManager {
334
371
  }
335
372
  /** Close a file across any active LSP clients and clear its cached diagnostics. */
336
373
  closeFile(filePath: string): void {
337
- closeFileAcrossClients(this.clients.values(), filePath);
374
+ closeFileAcrossClients(this.clients.values(), resolveSessionPath(this.cwd, filePath));
338
375
  }
339
376
  /** Remove any missing files from open-document and diagnostic state. */
340
377
  pruneMissingFiles(): string[] {
@@ -384,6 +421,7 @@ export class LspManager {
384
421
  this.clients.clear();
385
422
  this.unavailable.clear();
386
423
  this.knownRoots.clear();
424
+ this.warmedWorkspaceSymbolProjects.clear();
387
425
  }
388
426
  /** Get status of all servers. */
389
427
  getStatus(): ManagerStatus {
@@ -529,15 +567,30 @@ export class LspManager {
529
567
  maxSeverity,
530
568
  );
531
569
  }
532
- async workspaceSymbol(query: string) {
533
- return (await import("./manager-workspace-symbol.ts")).managerWorkspaceSymbol(
534
- this.clients.values(),
570
+ async workspaceSymbol(query: string): Promise<(SymbolInformation | WorkspaceSymbol)[] | null> {
571
+ const helper = await import("./manager-workspace-symbol.ts");
572
+ const initial = await helper.collectWorkspaceSymbols(this.clients.values(), query);
573
+ if (!initial.hasSupport) return null;
574
+ if (initial.results.length > 0) return initial.results;
575
+
576
+ const warmed = await this.warmWorkspaceSymbolProjectsUntilResult(
577
+ helper.findWorkspaceSymbolWarmTargets,
578
+ helper.getWorkspaceSymbolWarmPosition,
579
+ helper.collectWorkspaceSymbols,
535
580
  query,
536
581
  );
582
+ if (warmed.results) return warmed.results;
583
+ if (!warmed.warmedAny) return initial.results;
584
+
585
+ return this.retryWorkspaceSymbolAfterWarmup(
586
+ helper.collectWorkspaceSymbols,
587
+ query,
588
+ initial.results,
589
+ );
537
590
  }
538
591
  async ensureFileOpen(filePath: string): Promise<LspClient | null> {
539
- const client = await this.getClientForFile(filePath);
540
- const resolvedPath = path.resolve(filePath);
592
+ const resolvedPath = resolveSessionPath(this.cwd, filePath);
593
+ const client = await this.getClientForFile(resolvedPath);
541
594
  if (!client) return null;
542
595
  try {
543
596
  client.didOpen(resolvedPath, fs.readFileSync(resolvedPath, "utf-8"));
@@ -547,4 +600,110 @@ export class LspManager {
547
600
  return null;
548
601
  }
549
602
  }
603
+
604
+ // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: warm-up coordinates client/project iteration, targeted semantic nudges, and early-return queries in one place.
605
+ private async warmWorkspaceSymbolProjectsUntilResult(
606
+ findWarmTargets: (
607
+ root: string,
608
+ rootMarkers: string[],
609
+ fileTypes: string[],
610
+ ) => Array<{ projectRoot: string; file: string }>,
611
+ getWarmPosition: (
612
+ symbols: Awaited<ReturnType<LspClient["documentSymbols"]>>,
613
+ ) => import("../config/types.ts").Position | null,
614
+ collect: (
615
+ clients: Iterable<LspClient>,
616
+ query: string,
617
+ ) => Promise<{ results: (SymbolInformation | WorkspaceSymbol)[]; hasSupport: boolean }>,
618
+ query: string,
619
+ ): Promise<{ warmedAny: boolean; results: (SymbolInformation | WorkspaceSymbol)[] | null }> {
620
+ let warmedAny = false;
621
+
622
+ for (const client of Array.from(this.clients.values())) {
623
+ if (client.status !== "running") continue;
624
+ if (!client.serverCapabilities?.workspaceSymbolProvider) continue;
625
+
626
+ const serverConfig = this.config.servers[client.name];
627
+ if (!serverConfig) continue;
628
+
629
+ const warmTargets = findWarmTargets(
630
+ client.root,
631
+ serverConfig.rootMarkers,
632
+ serverConfig.fileTypes,
633
+ ).slice(0, 24);
634
+
635
+ for (const target of warmTargets) {
636
+ const projectKey = this.workspaceSymbolProjectKey(client.name, target.projectRoot);
637
+ if (this.warmedWorkspaceSymbolProjects.has(projectKey)) continue;
638
+ if (this.hasOpenFileInProject(client, target.projectRoot)) {
639
+ this.warmedWorkspaceSymbolProjects.add(projectKey);
640
+ continue;
641
+ }
642
+
643
+ const openedClient = await this.ensureFileOpen(target.file);
644
+ if (!openedClient) continue;
645
+
646
+ this.warmedWorkspaceSymbolProjects.add(projectKey);
647
+ warmedAny = true;
648
+
649
+ try {
650
+ const symbols = await openedClient.documentSymbols(target.file);
651
+ const hoverPosition = getWarmPosition(symbols);
652
+ if (hoverPosition) {
653
+ await openedClient.hover(target.file, hoverPosition);
654
+ }
655
+ } catch {
656
+ // Best-effort warm-up only.
657
+ }
658
+
659
+ const collected = await collect(this.clients.values(), query);
660
+ if (collected.hasSupport && collected.results.length > 0) {
661
+ return { warmedAny, results: collected.results };
662
+ }
663
+ }
664
+ }
665
+
666
+ return { warmedAny, results: null };
667
+ }
668
+
669
+ private async retryWorkspaceSymbolAfterWarmup(
670
+ collect: (
671
+ clients: Iterable<LspClient>,
672
+ query: string,
673
+ ) => Promise<{ results: (SymbolInformation | WorkspaceSymbol)[]; hasSupport: boolean }>,
674
+ query: string,
675
+ fallbackResults: (SymbolInformation | WorkspaceSymbol)[],
676
+ ): Promise<(SymbolInformation | WorkspaceSymbol)[]> {
677
+ const attempts = 5;
678
+ const delayMs = 50;
679
+
680
+ for (let attempt = 0; attempt < attempts; attempt++) {
681
+ if (attempt > 0) {
682
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
683
+ }
684
+ const retried = await collect(this.clients.values(), query);
685
+ if (!retried.hasSupport || retried.results.length > 0) {
686
+ return retried.hasSupport ? retried.results : fallbackResults;
687
+ }
688
+ }
689
+
690
+ return fallbackResults;
691
+ }
692
+
693
+ private hasOpenFileInProject(client: LspClient, projectRoot: string): boolean {
694
+ return client.openFiles.some((openFile) => projectRoots.isWithinOrEqual(projectRoot, openFile));
695
+ }
696
+
697
+ private workspaceSymbolProjectKey(serverName: string, projectRoot: string): string {
698
+ return `${serverName}:${path.resolve(projectRoot)}`;
699
+ }
700
+
701
+ private clearWarmedWorkspaceSymbolProjects(serverName: string, root: string): void {
702
+ const prefix = `${serverName}:${path.resolve(root)}`;
703
+ for (const key of Array.from(this.warmedWorkspaceSymbolProjects)) {
704
+ if (key === prefix || key.startsWith(`${prefix}${path.sep}`)) {
705
+ this.warmedWorkspaceSymbolProjects.delete(key);
706
+ }
707
+ }
708
+ }
550
709
  }
@@ -2,11 +2,12 @@
2
2
  // Extracted from lsp.ts to keep file sizes within Biome limits.
3
3
 
4
4
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
5
- import type { LspManager } from "./manager/manager.ts";
5
+ import type { DetectedProjectServer, ProjectServerInfo } from "../config/types.ts";
6
+ import type { LspManager } from "../manager/manager.ts";
7
+ import { LSP_TOOL_NAMES } from "../tool/names.ts";
8
+ import type { LspInspectorState } from "../ui/ui.ts";
6
9
  import { introspectCapabilities } from "./scanner.ts";
7
10
  import { clearSessionLspService } from "./service-registry.ts";
8
- import type { DetectedProjectServer, ProjectServerInfo } from "./types.ts";
9
- import type { LspInspectorState } from "./ui.ts";
10
11
 
11
12
  export interface LspRuntimeState {
12
13
  manager: LspManager | null;
@@ -49,7 +50,12 @@ export function refreshProjectServers(state: LspRuntimeState): void {
49
50
  }
50
51
 
51
52
  export function isLspAwareTool(toolName: string): boolean {
52
- return toolName === "lsp" || toolName === "read" || toolName === "write" || toolName === "edit";
53
+ return (
54
+ LSP_TOOL_NAMES.includes(toolName as (typeof LSP_TOOL_NAMES)[number]) ||
55
+ toolName === "read" ||
56
+ toolName === "write" ||
57
+ toolName === "edit"
58
+ );
53
59
  }
54
60
 
55
61
  export function disableLspState(pi: ExtensionAPI, state: LspRuntimeState): void {
@@ -66,17 +72,22 @@ export function disableLspState(pi: ExtensionAPI, state: LspRuntimeState): void
66
72
  state.lastWorkspaceChangeAt = 0;
67
73
  state.sentinelSnapshot = new Map();
68
74
  state.lspActive = false;
69
- removeLspTool(pi);
75
+ removeLspTools(pi);
70
76
  }
71
77
 
72
- export function removeLspTool(pi: ExtensionAPI): void {
78
+ export function removeLspTools(pi: ExtensionAPI): void {
73
79
  const activeTools = pi.getActiveTools();
74
- if (activeTools.includes("lsp"))
75
- pi.setActiveTools(activeTools.filter((t: string) => t !== "lsp"));
80
+ const nextTools = activeTools.filter(
81
+ (toolName: string) => !LSP_TOOL_NAMES.includes(toolName as (typeof LSP_TOOL_NAMES)[number]),
82
+ );
83
+ if (nextTools.length !== activeTools.length) {
84
+ pi.setActiveTools(nextTools);
85
+ }
76
86
  }
77
87
 
78
- export function ensureLspToolActive(pi: ExtensionAPI): void {
88
+ export function ensureLspToolsActive(pi: ExtensionAPI): void {
79
89
  const activeTools = pi.getActiveTools();
80
- if (activeTools.includes("lsp")) return;
81
- pi.setActiveTools([...activeTools, "lsp"]);
90
+ const missing = LSP_TOOL_NAMES.filter((toolName) => !activeTools.includes(toolName));
91
+ if (missing.length === 0) return;
92
+ pi.setActiveTools([...activeTools, ...missing]);
82
93
  }
@@ -1,15 +1,15 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import { dedupeTopmostRoots, walkProject } from "@mrclrchtr/supi-core/api";
4
- import type { LspManager } from "./manager/manager.ts";
5
4
  import type {
6
5
  DetectedProjectServer,
7
6
  LspConfig,
8
7
  MissingServer,
9
8
  ProjectServerInfo,
10
9
  ServerConfig,
11
- } from "./types.ts";
12
- import { commandExists } from "./utils.ts";
10
+ } from "../config/types.ts";
11
+ import type { LspManager } from "../manager/manager.ts";
12
+ import { commandExists } from "../utils.ts";
13
13
 
14
14
  const DEFAULT_MAX_DEPTH = 3;
15
15
 
@@ -3,8 +3,8 @@
3
3
  // to reuse the active LSP runtime without starting duplicate servers.
4
4
 
5
5
  import * as path from "node:path";
6
- import type { LspManager } from "./manager/manager.ts";
7
6
  import type {
7
+ CodeAction,
8
8
  Diagnostic,
9
9
  DocumentSymbol,
10
10
  Hover,
@@ -13,11 +13,43 @@ import type {
13
13
  Position,
14
14
  ProjectServerInfo,
15
15
  SymbolInformation,
16
+ WorkspaceEdit,
16
17
  WorkspaceSymbol,
17
- } from "./types.ts";
18
+ } from "../config/types.ts";
19
+ import type { LspManager } from "../manager/manager.ts";
20
+ import { resolveSessionPath } from "../utils.ts";
21
+
22
+ /** Workspace diagnostic summary grouped by file. */
23
+ export interface WorkspaceDiagnosticSummaryEntry {
24
+ file: string;
25
+ errors: number;
26
+ warnings: number;
27
+ }
28
+
29
+ /** Outstanding diagnostics grouped by file, including info and hint counts. */
30
+ export interface OutstandingDiagnosticSummaryEntry {
31
+ file: string;
32
+ total: number;
33
+ errors: number;
34
+ warnings: number;
35
+ information: number;
36
+ hints: number;
37
+ }
38
+
39
+ /** Result from a workspace diagnostic recovery pass. */
40
+ export interface RecoverDiagnosticsResult {
41
+ refreshedClients: number;
42
+ restartedClients: number;
43
+ staleAssessment: {
44
+ suspected: boolean;
45
+ matchedFiles: Array<{ file: string; diagnostics: Diagnostic[] }>;
46
+ warning: string | null;
47
+ };
48
+ }
18
49
 
19
50
  export type SessionLspServiceState =
20
51
  | { kind: "ready"; service: SessionLspService }
52
+ | { kind: "inactive"; service: SessionLspService }
21
53
  | { kind: "pending" }
22
54
  | { kind: "disabled" }
23
55
  | { kind: "unavailable"; reason: string };
@@ -25,7 +57,9 @@ export type SessionLspServiceState =
25
57
  /**
26
58
  * Public wrapper around {@link LspManager} that exposes stable semantic operations.
27
59
  * File path inputs may be absolute or session-cwd-relative; a leading `@` is stripped
28
- * to match pi's built-in path-tool convention.
60
+ * to match pi's built-in path-tool convention. Position arguments use raw 0-based LSP
61
+ * coordinates; use `toLspPosition()` from `@mrclrchtr/supi-lsp/api` when starting from
62
+ * user-facing 1-based line and character values.
29
63
  */
30
64
  export class SessionLspService {
31
65
  constructor(private readonly manager: LspManager) {}
@@ -77,44 +111,86 @@ export class SessionLspService {
77
111
  return this.manager.workspaceSymbol(query);
78
112
  }
79
113
 
114
+ async rename(
115
+ filePath: string,
116
+ position: Position,
117
+ newName: string,
118
+ ): Promise<WorkspaceEdit | null> {
119
+ const resolvedPath = this.resolveFilePath(filePath);
120
+ const client = await this.manager.ensureFileOpen(resolvedPath);
121
+ if (!client) return null;
122
+ return client.rename(resolvedPath, position, newName);
123
+ }
124
+
125
+ async codeActions(filePath: string, position: Position): Promise<CodeAction[] | null> {
126
+ const resolvedPath = this.resolveFilePath(filePath);
127
+ const client = await this.manager.ensureFileOpen(resolvedPath);
128
+ if (!client) return null;
129
+
130
+ const range = { start: position, end: position };
131
+ const diagnostics = client
132
+ .getDiagnostics(resolvedPath)
133
+ .filter((diagnostic) => diagnostic.range.start.line <= position.line)
134
+ .filter((diagnostic) => diagnostic.range.end.line >= position.line);
135
+
136
+ return client.codeActions(resolvedPath, range, { diagnostics });
137
+ }
138
+
80
139
  // ── Project / runtime awareness ─────────────────────────────────────
81
140
 
82
141
  getProjectServers(): ProjectServerInfo[] {
83
142
  return this.manager.getKnownProjectServers([]);
84
143
  }
85
144
 
145
+ /** Check whether the file can be served semantically for explicit LSP operations. */
86
146
  isSupportedSourceFile(filePath: string): boolean {
87
- return this.manager.isSupportedSourceFile(this.resolveFilePath(filePath));
147
+ return this.manager.canServeFile(this.resolveFilePath(filePath));
88
148
  }
89
149
 
90
150
  // ── Diagnostics ─────────────────────────────────────────────────────
91
151
 
152
+ /** Sync a file through LSP and return diagnostics up to the supplied severity threshold. */
153
+ async fileDiagnostics(filePath: string, maxSeverity: number = 4): Promise<Diagnostic[] | null> {
154
+ const resolvedPath = this.resolveFilePath(filePath);
155
+ if (!this.manager.canServeFile(resolvedPath)) return null;
156
+ return this.manager.syncFileAndGetDiagnostics(resolvedPath, maxSeverity);
157
+ }
158
+
159
+ /** Get a lightweight workspace diagnostic summary for all tracked files. */
160
+ getWorkspaceDiagnosticSummary(): WorkspaceDiagnosticSummaryEntry[] {
161
+ return this.manager.getDiagnosticSummary();
162
+ }
163
+
164
+ /** Get outstanding diagnostics grouped by file at or above the supplied severity threshold. */
92
165
  getOutstandingDiagnostics(
93
166
  maxSeverity: number = 1,
94
167
  ): Array<{ file: string; diagnostics: Diagnostic[] }> {
95
168
  return this.manager.getOutstandingDiagnostics(maxSeverity);
96
169
  }
97
170
 
98
- getOutstandingDiagnosticSummary(
99
- maxSeverity: number = 1,
100
- ): import("./manager/manager-types.ts").OutstandingDiagnosticSummaryEntry[] {
171
+ /** Get outstanding diagnostic counts grouped by file. */
172
+ getOutstandingDiagnosticSummary(maxSeverity: number = 1): OutstandingDiagnosticSummaryEntry[] {
101
173
  return this.manager.getOutstandingDiagnosticSummary(maxSeverity);
102
174
  }
103
175
 
104
- /** Access the underlying manager for advanced use cases (discouraged). */
105
- getManager(): LspManager {
106
- return this.manager;
176
+ /** Trigger a workspace-wide diagnostics refresh and stale-state recovery pass. */
177
+ async recoverDiagnostics(options?: {
178
+ restartIfStillStale?: boolean;
179
+ maxWaitMs?: number;
180
+ quietMs?: number;
181
+ }): Promise<RecoverDiagnosticsResult> {
182
+ return this.manager.recoverWorkspaceDiagnostics(options);
107
183
  }
108
184
 
109
185
  private resolveFilePath(filePath: string): string {
110
- const normalizedPath = filePath.startsWith("@") ? filePath.slice(1) : filePath;
111
- return path.resolve(this.manager.getCwd(), normalizedPath);
186
+ return resolveSessionPath(this.manager.getCwd(), filePath);
112
187
  }
113
188
  }
114
189
 
115
190
  // ── Registry ──────────────────────────────────────────────────────────
116
191
 
117
192
  const REGISTRY_KEY = Symbol.for("@mrclrchtr/supi-lsp/session-registry");
193
+ const WAIT_INTERVAL_MS = 25;
118
194
 
119
195
  function getRegistry(): Map<string, SessionLspServiceState> {
120
196
  const globalScope = globalThis as typeof globalThis & Record<symbol, unknown>;
@@ -147,6 +223,22 @@ export function getSessionLspService(cwd: string): SessionLspServiceState {
147
223
  );
148
224
  }
149
225
 
226
+ /** Wait briefly for a pending session-scoped LSP service to become ready. */
227
+ export async function waitForSessionLspService(
228
+ cwd: string,
229
+ timeoutMs: number = 250,
230
+ ): Promise<SessionLspServiceState> {
231
+ const deadline = Date.now() + Math.max(0, timeoutMs);
232
+ let state = getSessionLspService(cwd);
233
+
234
+ while (state.kind === "pending" && Date.now() < deadline) {
235
+ await new Promise((resolve) => setTimeout(resolve, WAIT_INTERVAL_MS));
236
+ state = getSessionLspService(cwd);
237
+ }
238
+
239
+ return state;
240
+ }
241
+
150
242
  /** Remove the LSP service state for a session cwd. */
151
243
  export function clearSessionLspService(cwd: string): void {
152
244
  registry.delete(normalizeCwd(cwd));