pi-ui-extend 0.1.5 → 0.1.8

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.
@@ -5,7 +5,6 @@ export const DEFAULT_PI_TOOLS_SUITE_CONFIG_JSONC = `{
5
5
  "disabledModules": [
6
6
  // "ast-grep",
7
7
  // "async-subagents",
8
- // "terminal-bell",
9
8
  // "lsp",
10
9
  // "repo-discovery",
11
10
  // "antigravity-auth",
@@ -16,6 +15,14 @@ export const DEFAULT_PI_TOOLS_SUITE_CONFIG_JSONC = `{
16
15
  // "dcp"
17
16
  ],
18
17
 
18
+ // Renderer bundled terminal-bell notification settings.
19
+ "terminalBell": {
20
+ // Toggle terminal-bell attention signals across supported OSes:
21
+ // terminal BEL, macOS notification sound, and system notifications
22
+ // (macOS terminal-notifier/osascript or Linux notify-send when available).
23
+ "sound": true
24
+ },
25
+
19
26
  // Dynamic Context Pruning (DCP) module config.
20
27
  // The DCP module owns the compress tool and /dcp commands.
21
28
  "dcp": {
@@ -12,7 +12,6 @@ type ExtensionModule = {
12
12
  const MODULES: Array<{ name: string; load: () => Promise<ExtensionModule> }> = [
13
13
  { name: "ast-grep", load: () => import("./ast-grep/index") },
14
14
  { name: "async-subagents", load: () => import("./async-subagents/index") },
15
- { name: "terminal-bell", load: () => import("./terminal-bell/index") },
16
15
  { name: "lsp", load: () => import("./lsp/index") },
17
16
  { name: "repo-discovery", load: () => import("./repo-discovery/index") },
18
17
  { name: "antigravity-auth", load: () => import("./antigravity-auth/index") },
@@ -1,12 +1,19 @@
1
1
  import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
4
+ import { parse as parseJsonc } from "jsonc-parser";
5
+ import { getPiToolsSuiteUserConfigPath } from "../../config";
4
6
  import { findUp } from "./paths";
5
7
  import { askProjectConfigTrust, sha256 } from "./trust";
6
8
  import type { ConfigLayer, LoadedConfig, LspConfigFile, LspServerConfig, MatchableConfig } from "./types";
7
9
 
8
- function defaultAgentDir(): string {
9
- return process.env.PI_AGENT_DIR ?? path.join(process.env.HOME ?? "", ".pi", "agent");
10
+ function getPiConfigDir(): string | undefined {
11
+ const configured = process.env.PI_CONFIG_DIR;
12
+ return configured && configured.trim() !== "" ? configured : undefined;
13
+ }
14
+
15
+ function findProjectSuiteConfig(startDir: string): string | undefined {
16
+ return findUp(startDir, path.join(".pi", "pi-tools-suite.jsonc"));
10
17
  }
11
18
 
12
19
  function asObject(value: unknown): Record<string, unknown> | undefined {
@@ -78,9 +85,16 @@ function parseLspItems(parsed: unknown): LspServerConfig[] {
78
85
  return out;
79
86
  }
80
87
 
81
- async function readJsonLayer<TItem extends MatchableConfig>(options: {
88
+ function extractLspConfig(parsed: unknown): unknown {
89
+ const root = asObject(parsed);
90
+ if (!root) return undefined;
91
+ return root.lsp;
92
+ }
93
+
94
+ async function readJsoncLayer<TItem extends MatchableConfig>(options: {
82
95
  scope: "global" | "project";
83
96
  filePath: string;
97
+ selectConfig?: (parsed: unknown) => unknown;
84
98
  parseItems: (parsed: unknown) => TItem[];
85
99
  }): Promise<ConfigLayer<TItem> | undefined> {
86
100
  let raw: string;
@@ -91,14 +105,19 @@ async function readJsonLayer<TItem extends MatchableConfig>(options: {
91
105
  throw error;
92
106
  }
93
107
 
94
- const parsed = JSON.parse(raw) as unknown;
108
+ const parsed = parseJsonc(raw) as unknown;
109
+ const selected = options.selectConfig ? options.selectConfig(parsed) : parsed;
110
+ if (selected === undefined) return undefined;
111
+ const items = options.parseItems(selected);
112
+ if (items.length === 0) return undefined;
113
+
95
114
  return {
96
115
  scope: options.scope,
97
116
  path: options.filePath,
98
117
  dir: path.dirname(options.filePath),
99
118
  raw,
100
119
  hash: sha256(raw),
101
- items: options.parseItems(parsed),
120
+ items,
102
121
  };
103
122
  }
104
123
 
@@ -123,19 +142,25 @@ function binariesForLsp(items: LspServerConfig[]): string[] {
123
142
  export async function loadLspConfig(ctx: ExtensionContext): Promise<LoadedConfig<LspServerConfig>> {
124
143
  const warnings: string[] = [];
125
144
  const layers: ConfigLayer<LspServerConfig>[] = [];
126
- const globalPath = path.join(defaultAgentDir(), "lsp.json");
145
+ const piConfigDir = getPiConfigDir();
146
+ const globalPaths = [
147
+ getPiToolsSuiteUserConfigPath(process.env.HOME),
148
+ piConfigDir ? path.join(piConfigDir, "pi-tools-suite.jsonc") : undefined,
149
+ ].filter((item): item is string => typeof item === "string");
127
150
 
128
- try {
129
- const globalLayer = await readJsonLayer({ scope: "global", filePath: globalPath, parseItems: parseLspItems });
130
- if (globalLayer) layers.push(globalLayer);
131
- } catch (error) {
132
- warnings.push(`Failed to load global lsp config: ${(error as Error).message}`);
151
+ for (const globalPath of globalPaths) {
152
+ try {
153
+ const globalLayer = await readJsoncLayer({ scope: "global", filePath: globalPath, selectConfig: extractLspConfig, parseItems: parseLspItems });
154
+ if (globalLayer) layers.push(globalLayer);
155
+ } catch (error) {
156
+ warnings.push(`Failed to load global lsp config ${globalPath}: ${(error as Error).message}`);
157
+ }
133
158
  }
134
159
 
135
- const projectPath = findUp(ctx.cwd, path.join(".pi", "lsp.json"));
160
+ const projectPath = findProjectSuiteConfig(ctx.cwd);
136
161
  if (projectPath) {
137
162
  try {
138
- const projectLayer = await readJsonLayer({ scope: "project", filePath: projectPath, parseItems: parseLspItems });
163
+ const projectLayer = await readJsoncLayer({ scope: "project", filePath: projectPath, selectConfig: extractLspConfig, parseItems: parseLspItems });
139
164
  if (projectLayer) {
140
165
  const decision = await askProjectConfigTrust({
141
166
  ctx,
@@ -3,6 +3,7 @@ import path from "node:path";
3
3
  import { fileURLToPath, pathToFileURL } from "node:url";
4
4
  import type { CommandConfig, PathPlaceholders, ResolvedCommand } from "./types";
5
5
  import { applyTemplate, applyTemplateArray, applyTemplateRecord } from "./template";
6
+ import { matchesGlob } from "./glob";
6
7
 
7
8
  export function expandHome(input: string): string {
8
9
  if (input === "~") return process.env.HOME ?? input;
@@ -26,7 +27,16 @@ export function isSubPathOrSame(parent: string, child: string): boolean {
26
27
  }
27
28
 
28
29
  export function markerExists(candidateDir: string, marker: string): boolean {
29
- return fs.existsSync(path.resolve(candidateDir, marker));
30
+ if (!/[?*[]/.test(marker)) return fs.existsSync(path.resolve(candidateDir, marker));
31
+
32
+ const markerDir = path.dirname(marker);
33
+ const markerPattern = path.basename(marker);
34
+ const absoluteMarkerDir = path.resolve(candidateDir, markerDir === "." ? "" : markerDir);
35
+ try {
36
+ return fs.readdirSync(absoluteMarkerDir).some((entry) => matchesGlob(markerPattern, entry));
37
+ } catch {
38
+ return false;
39
+ }
30
40
  }
31
41
 
32
42
  export function findProjectRoot(filePath: string, rootMarkers: string[] | undefined, fallbackRoot: string): string | undefined {
@@ -18,12 +18,17 @@ export function delay(ms: number, signal?: AbortSignal): Promise<void> {
18
18
  });
19
19
  }
20
20
 
21
- export async function withTimeout<T>(promise: Promise<T>, timeoutMs: number, label: string): Promise<T> {
21
+ export async function withTimeout<T>(promise: Promise<T>, timeoutMs: number, label: string, signal?: AbortSignal): Promise<T> {
22
+ if (signal?.aborted) throw new Error("aborted");
22
23
  let timeout: NodeJS.Timeout | undefined;
24
+ let abort: (() => void) | undefined;
23
25
  const timeoutPromise = new Promise<never>((_resolve, reject) => {
24
26
  timeout = setTimeout(() => reject(new Error(`${label} timed out after ${timeoutMs}ms`)), timeoutMs);
27
+ abort = () => reject(new Error("aborted"));
28
+ signal?.addEventListener("abort", abort, { once: true });
25
29
  });
26
30
  return Promise.race([promise, timeoutPromise]).finally(() => {
27
31
  if (timeout) clearTimeout(timeout);
32
+ if (abort) signal?.removeEventListener("abort", abort);
28
33
  });
29
34
  }
@@ -10,6 +10,7 @@ function canWriteToChild(child: ChildProcessWithoutNullStreams): boolean {
10
10
  }
11
11
 
12
12
  export function killChild(child: ChildProcessWithoutNullStreams, signal: NodeJS.Signals): boolean {
13
+ let signaled = false;
13
14
  try {
14
15
  if (process.platform === "win32" && child.pid) {
15
16
  const result = spawnSync("taskkill", ["/pid", String(child.pid), "/T", "/F"], {
@@ -19,9 +20,22 @@ export function killChild(child: ChildProcessWithoutNullStreams, signal: NodeJS.
19
20
  });
20
21
  if (!result.error && result.status === 0) return true;
21
22
  }
22
- return child.kill(signal);
23
+
24
+ if (child.pid) {
25
+ try {
26
+ // LSP clients are spawned in their own process group on POSIX. Kill the
27
+ // whole group so wrapper scripts also lose children such as Godot and nc.
28
+ process.kill(-child.pid, signal);
29
+ signaled = true;
30
+ } catch {
31
+ // Fall back to the direct child below. The process may not be detached
32
+ // or may have already exited.
33
+ }
34
+ }
35
+
36
+ return child.kill(signal) || signaled;
23
37
  } catch {
24
- return false;
38
+ return signaled;
25
39
  }
26
40
  }
27
41
 
@@ -1,13 +1,16 @@
1
1
  import path from "node:path";
2
+ import fs from "node:fs/promises";
2
3
  import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
3
4
  import type { MessageConnection } from "vscode-jsonrpc";
4
5
  import { createMessageConnection, StreamMessageReader, StreamMessageWriter } from "vscode-jsonrpc/node";
5
6
  import {
6
7
  DefinitionRequest,
8
+ DiagnosticRefreshRequest,
7
9
  DidChangeConfigurationNotification,
8
10
  DidChangeTextDocumentNotification,
9
11
  DidOpenTextDocumentNotification,
10
12
  DidSaveTextDocumentNotification,
13
+ DocumentDiagnosticRequest,
11
14
  DocumentSymbolRequest,
12
15
  ExecuteCommandRequest,
13
16
  HoverRequest,
@@ -16,6 +19,7 @@ import {
16
19
  PublishDiagnosticsNotification,
17
20
  ReferencesRequest,
18
21
  type Diagnostic,
22
+ type DocumentDiagnosticReport,
19
23
  type InitializeResult,
20
24
  type ServerCapabilities,
21
25
  } from "vscode-languageserver-protocol";
@@ -24,11 +28,38 @@ import { bestEffortWriteJsonRpc, isChildRunning, killChild, terminateChild } fro
24
28
  import { DEFAULT_STARTUP_TIMEOUT_MS, REQUEST_TIMEOUT_MS } from "./constants";
25
29
  import { DocumentStore } from "./documents";
26
30
  import type { DiagnosticsStore } from "./diagnostics-store";
27
- import { filePathToUri } from "./_shared/paths";
31
+ import { filePathToUri, uriToFilePath } from "./_shared/paths";
28
32
  import { isExecutableAvailable } from "./_shared/runner";
29
33
  import type { LspServerConfig, ResolvedCommand } from "./_shared/types";
30
34
  import { supportsSave } from "./lsp-utils";
31
35
  import type { OpenDocument } from "./types";
36
+
37
+ interface MarkdownToken {
38
+ type: string;
39
+ markup: string;
40
+ content: string;
41
+ map: number[] | null;
42
+ children: MarkdownToken[] | null;
43
+ }
44
+
45
+ function markdownTextToken(content: string): MarkdownToken {
46
+ return { type: "text", markup: "", content, map: null, children: null };
47
+ }
48
+
49
+ function parseMarkdownTokens(text: string): MarkdownToken[] {
50
+ const tokens: MarkdownToken[] = [];
51
+ const lines = text.split(/\r?\n/);
52
+ for (let lineNumber = 0; lineNumber < lines.length; lineNumber += 1) {
53
+ const line = lines[lineNumber];
54
+ const heading = /^(#{1,6})\s+(.+?)\s*#*\s*$/.exec(line);
55
+ if (!heading) continue;
56
+ const [, markup, content] = heading;
57
+ tokens.push({ type: "heading_open", markup, content: "", map: [lineNumber, lineNumber + 1], children: null });
58
+ tokens.push({ type: "inline", markup: "", content, map: [lineNumber, lineNumber + 1], children: [markdownTextToken(content.trim())] });
59
+ tokens.push({ type: "heading_close", markup, content: "", map: null, children: null });
60
+ }
61
+ return tokens;
62
+ }
32
63
  import { tsserverDiagnosticToLsp, tsserverDiagnosticsFromResponse } from "./tsserver";
33
64
 
34
65
  export class LspClient {
@@ -40,6 +71,8 @@ export class LspClient {
40
71
  private initialized = false;
41
72
  private unavailableReason: string | undefined;
42
73
  private stderrTail = "";
74
+ private readonly dynamicDiagnosticProviders = new Map<string, string | undefined>();
75
+ private diagnosticProviderWaiters: Array<() => void> = [];
43
76
 
44
77
  constructor(
45
78
  private readonly server: LspServerConfig,
@@ -76,6 +109,7 @@ export class LspClient {
76
109
  const child = spawn(this.command.bin, this.command.args, {
77
110
  cwd: this.command.cwd,
78
111
  env: this.command.env ? { ...process.env, ...this.command.env } : process.env,
112
+ detached: process.platform !== "win32",
79
113
  shell: false,
80
114
  stdio: ["pipe", "pipe", "pipe"],
81
115
  });
@@ -113,6 +147,7 @@ export class LspClient {
113
147
  Promise.race([connection.sendRequest(InitializeRequest.method, this.initializeParams()), startupFailure]),
114
148
  this.server.startupTimeoutMs ?? DEFAULT_STARTUP_TIMEOUT_MS,
115
149
  `${this.server.id} initialize`,
150
+ signal,
116
151
  )) as InitializeResult;
117
152
  this.capabilities = initializeResult.capabilities;
118
153
  failStartup = undefined;
@@ -136,10 +171,12 @@ export class LspClient {
136
171
  window: { workDoneProgress: true },
137
172
  workspace: {
138
173
  configuration: true,
174
+ diagnostics: { refreshSupport: true },
139
175
  workspaceFolders: true,
140
176
  didChangeWatchedFiles: { dynamicRegistration: true },
141
177
  },
142
178
  textDocument: {
179
+ diagnostic: { dynamicRegistration: true, relatedDocumentSupport: true },
143
180
  synchronization: {
144
181
  didOpen: true,
145
182
  didChange: true,
@@ -178,8 +215,67 @@ export class LspClient {
178
215
  return items.map(() => this.server.settings ?? {});
179
216
  });
180
217
  anyConnection.onRequest("workspace/workspaceFolders", () => [{ name: path.basename(this.root), uri: filePathToUri(this.root) }]);
181
- anyConnection.onRequest("client/registerCapability", () => null);
182
- anyConnection.onRequest("client/unregisterCapability", () => null);
218
+ anyConnection.onRequest("markdown/parse", async (params: unknown) => {
219
+ const request = params as { uri?: unknown; text?: unknown } | undefined;
220
+ const text = typeof request?.text === "string"
221
+ ? request.text
222
+ : typeof request?.uri === "string"
223
+ ? this.documents.get(uriToFilePath(request.uri))?.text ?? await fs.readFile(uriToFilePath(request.uri), "utf8")
224
+ : "";
225
+ return parseMarkdownTokens(text);
226
+ });
227
+ anyConnection.onRequest("markdown/fs/readFile", async (params: unknown) => {
228
+ const uri = (params as { uri?: unknown } | undefined)?.uri;
229
+ if (typeof uri !== "string") return [];
230
+ return [...await fs.readFile(uriToFilePath(uri))];
231
+ });
232
+ anyConnection.onRequest("markdown/fs/stat", async (params: unknown) => {
233
+ const uri = (params as { uri?: unknown } | undefined)?.uri;
234
+ if (typeof uri !== "string") return undefined;
235
+ try {
236
+ const stat = await fs.stat(uriToFilePath(uri));
237
+ return { isDirectory: stat.isDirectory() };
238
+ } catch {
239
+ return undefined;
240
+ }
241
+ });
242
+ anyConnection.onRequest("markdown/fs/readDirectory", async (params: unknown) => {
243
+ const uri = (params as { uri?: unknown } | undefined)?.uri;
244
+ if (typeof uri !== "string") return [];
245
+ try {
246
+ const entries = await fs.readdir(uriToFilePath(uri), { withFileTypes: true });
247
+ return entries.map((entry) => [entry.name, { isDirectory: entry.isDirectory() }]);
248
+ } catch {
249
+ return [];
250
+ }
251
+ });
252
+ anyConnection.onRequest("markdown/fs/watcher/create", () => null);
253
+ anyConnection.onRequest("markdown/fs/watcher/delete", () => null);
254
+ anyConnection.onRequest("markdown/findMarkdownFilesInWorkspace", () => []);
255
+ anyConnection.onRequest("client/registerCapability", (params: unknown) => {
256
+ const registrations = (params as { registrations?: unknown[] } | undefined)?.registrations;
257
+ if (!Array.isArray(registrations)) return null;
258
+
259
+ for (const registration of registrations) {
260
+ const item = registration as { id?: unknown; method?: unknown; registerOptions?: unknown };
261
+ if (typeof item.id !== "string" || item.method !== DocumentDiagnosticRequest.method) continue;
262
+ const options = item.registerOptions as { identifier?: unknown } | undefined;
263
+ this.dynamicDiagnosticProviders.set(item.id, typeof options?.identifier === "string" ? options.identifier : undefined);
264
+ }
265
+ this.resolveDiagnosticProviderWaiters();
266
+ return null;
267
+ });
268
+ anyConnection.onRequest("client/unregisterCapability", (params: unknown) => {
269
+ const unregisterations = (params as { unregisterations?: unknown[] } | undefined)?.unregisterations;
270
+ if (!Array.isArray(unregisterations)) return null;
271
+
272
+ for (const registration of unregisterations) {
273
+ const item = registration as { id?: unknown; method?: unknown };
274
+ if (typeof item.id === "string" && item.method === DocumentDiagnosticRequest.method) this.dynamicDiagnosticProviders.delete(item.id);
275
+ }
276
+ return null;
277
+ });
278
+ anyConnection.onRequest(DiagnosticRefreshRequest.method, () => null);
183
279
  anyConnection.onRequest("window/workDoneProgress/create", () => null);
184
280
  anyConnection.onNotification("window/logMessage", () => undefined);
185
281
  anyConnection.onNotification("telemetry/event", () => undefined);
@@ -226,7 +322,50 @@ export class LspClient {
226
322
  return Array.isArray(commands) && commands.includes("typescript.tsserverRequest");
227
323
  }
228
324
 
229
- async tsserverDiagnostics(file: string, text: string, timeoutMs: number): Promise<Diagnostic[] | undefined> {
325
+ private supportsPullDiagnostics(): boolean {
326
+ return !!this.capabilities?.diagnosticProvider || this.dynamicDiagnosticProviders.size > 0;
327
+ }
328
+
329
+ private resolveDiagnosticProviderWaiters(): void {
330
+ const waiters = this.diagnosticProviderWaiters;
331
+ this.diagnosticProviderWaiters = [];
332
+ for (const waiter of waiters) waiter();
333
+ }
334
+
335
+ private async waitForPullDiagnosticsSupport(timeoutMs: number, signal?: AbortSignal): Promise<void> {
336
+ if (this.supportsPullDiagnostics() || timeoutMs <= 0) return;
337
+ await withTimeout(new Promise<void>((resolve) => {
338
+ this.diagnosticProviderWaiters.push(resolve);
339
+ }), timeoutMs, `${this.server.id} diagnostic registration`, signal).catch(() => undefined);
340
+ }
341
+
342
+ private diagnosticProviderIdentifiers(): Array<string | undefined> {
343
+ const identifiers: Array<string | undefined> = [];
344
+ const provider = this.capabilities?.diagnosticProvider;
345
+ if (provider) {
346
+ identifiers.push(
347
+ typeof provider === "object" && "identifier" in provider && typeof provider.identifier === "string"
348
+ ? provider.identifier
349
+ : undefined,
350
+ );
351
+ }
352
+ for (const identifier of this.dynamicDiagnosticProviders.values()) identifiers.push(identifier);
353
+
354
+ const seen = new Set<string>();
355
+ return identifiers.filter((identifier) => {
356
+ const key = identifier ?? "";
357
+ if (seen.has(key)) return false;
358
+ seen.add(key);
359
+ return true;
360
+ });
361
+ }
362
+
363
+ private diagnosticsFromReport(report: DocumentDiagnosticReport | null | undefined): Diagnostic[] | undefined {
364
+ if (!report || report.kind !== "full") return undefined;
365
+ return report.items;
366
+ }
367
+
368
+ async tsserverDiagnostics(file: string, text: string, timeoutMs: number, signal?: AbortSignal): Promise<Diagnostic[] | undefined> {
230
369
  const connection = this.connection;
231
370
  if (!connection || !this.supportsTsserverDiagnostics()) return undefined;
232
371
 
@@ -252,11 +391,51 @@ export class LspClient {
252
391
  }),
253
392
  timeoutMs,
254
393
  `${this.server.id} ${request.command}`,
394
+ signal,
255
395
  )));
256
396
 
257
397
  return responses.flatMap((response) => tsserverDiagnosticsFromResponse(response).map((diagnostic) => tsserverDiagnosticToLsp(diagnostic, text)));
258
398
  }
259
399
 
400
+ async pullDiagnostics(file: string, timeoutMs: number, signal?: AbortSignal): Promise<Diagnostic[] | undefined> {
401
+ const connection = this.connection;
402
+ if (!connection) return undefined;
403
+ if (!this.supportsPullDiagnostics()) {
404
+ await this.waitForPullDiagnosticsSupport(this.server.id === "csharp" ? Math.min(timeoutMs, 5_000) : 250, signal);
405
+ }
406
+ if (!this.supportsPullDiagnostics()) return undefined;
407
+ const identifiers = this.diagnosticProviderIdentifiers();
408
+ if (identifiers.length === 0) return undefined;
409
+
410
+ const uri = filePathToUri(file);
411
+ const settled = await Promise.allSettled(identifiers.map(async (identifier) => {
412
+ const report = (await withTimeout(
413
+ connection.sendRequest(DocumentDiagnosticRequest.method, {
414
+ textDocument: { uri },
415
+ identifier,
416
+ }),
417
+ timeoutMs,
418
+ `${this.server.id} textDocument/diagnostic${identifier ? ` (${identifier})` : ""}`,
419
+ signal,
420
+ )) as DocumentDiagnosticReport | null;
421
+ return this.diagnosticsFromReport(report) ?? [];
422
+ }));
423
+
424
+ const fulfilled = settled.filter((result): result is PromiseFulfilledResult<Diagnostic[]> => result.status === "fulfilled");
425
+ if (fulfilled.length === 0) {
426
+ const firstError = settled.find((result): result is PromiseRejectedResult => result.status === "rejected")?.reason;
427
+ throw firstError instanceof Error ? firstError : new Error(String(firstError ?? "pull diagnostics failed"));
428
+ }
429
+
430
+ const seen = new Set<string>();
431
+ return fulfilled.flatMap((result) => result.value).filter((diagnostic) => {
432
+ const key = JSON.stringify([diagnostic.range, diagnostic.severity, diagnostic.source, diagnostic.code, diagnostic.message]);
433
+ if (seen.has(key)) return false;
434
+ seen.add(key);
435
+ return true;
436
+ });
437
+ }
438
+
260
439
  async hover(file: string, line: number, character: number): Promise<unknown> {
261
440
  if (!this.connection) throw new Error(`${this.server.id}: LSP connection unavailable`);
262
441
  return withTimeout(
@@ -9,6 +9,7 @@ import { filePathToUri, findProjectRoot, normalizeRelativePath, resolveCommand,
9
9
  import { formatLspDiagnostics, formatWarnings, joinSections } from "./_shared/output";
10
10
  import type { LspServerConfig, StoredDiagnostics } from "./_shared/types";
11
11
  import { clientKey, couldMatchBeforeRoot, fileSizeAllowed, languageIdForFile, readTextFile } from "./lsp-utils";
12
+ import { localMarkdownDiagnostics } from "./markdown-diagnostics";
12
13
  import type { MatchedServer } from "./types";
13
14
 
14
15
  function isFreshDiagnosticsEntry(entry: StoredDiagnostics | undefined, since: number, version: number | undefined): entry is StoredDiagnostics {
@@ -17,6 +18,27 @@ function isFreshDiagnosticsEntry(entry: StoredDiagnostics | undefined, since: nu
17
18
  && (entry.version === undefined || version === undefined || entry.version >= version);
18
19
  }
19
20
 
21
+ function diagnosticsWithLocalFallback(serverId: string, file: string, text: string, diagnostics: StoredDiagnostics["diagnostics"]): StoredDiagnostics["diagnostics"] {
22
+ if (serverId !== "markdown") return diagnostics;
23
+ const hasLanguageServerLinkDiagnostics = diagnostics.some((diagnostic) => typeof diagnostic.code === "string" && diagnostic.code.startsWith("link."));
24
+ const localDiagnostics = localMarkdownDiagnostics(file, text).filter((diagnostic) => {
25
+ if (!hasLanguageServerLinkDiagnostics) return true;
26
+ return !(typeof diagnostic.code === "string" && diagnostic.code.startsWith("link."));
27
+ });
28
+ if (localDiagnostics.length === 0) return diagnostics;
29
+
30
+ const seen = new Set(diagnostics.map((diagnostic) => JSON.stringify([diagnostic.range, diagnostic.severity, diagnostic.source, diagnostic.code, diagnostic.message])));
31
+ return [
32
+ ...diagnostics,
33
+ ...localDiagnostics.filter((diagnostic) => {
34
+ const key = JSON.stringify([diagnostic.range, diagnostic.severity, diagnostic.source, diagnostic.code, diagnostic.message]);
35
+ if (seen.has(key)) return false;
36
+ seen.add(key);
37
+ return true;
38
+ }),
39
+ ];
40
+ }
41
+
20
42
  export class LspManager {
21
43
  private readonly diagnostics = new DiagnosticsStore();
22
44
  private readonly clients = new Map<string, LspClient>();
@@ -115,16 +137,30 @@ export class LspManager {
115
137
  // diagnostics don't degrade into a misleading publishDiagnostics timeout.
116
138
  let tsserverFallbackError: string | undefined;
117
139
  try {
118
- const tsserverDiagnostics = await client.tsserverDiagnostics(file, text, diagnosticsWaitMs);
140
+ const tsserverDiagnostics = await client.tsserverDiagnostics(file, text, diagnosticsWaitMs, ctx.signal);
119
141
  if (tsserverDiagnostics !== undefined) {
120
- this.diagnostics.set(match.server.id, match.root, filePathToUri(file), tsserverDiagnostics, doc.version);
121
- lines.push(formatLspDiagnostics(match.server.id, file, tsserverDiagnostics, match.root));
142
+ const diagnostics = diagnosticsWithLocalFallback(match.server.id, file, text, tsserverDiagnostics);
143
+ this.diagnostics.set(match.server.id, match.root, filePathToUri(file), diagnostics, doc.version);
144
+ lines.push(formatLspDiagnostics(match.server.id, file, diagnostics, match.root));
122
145
  continue;
123
146
  }
124
147
  } catch (error) {
125
148
  tsserverFallbackError = (error as Error).message;
126
149
  }
127
150
 
151
+ let pullDiagnosticsError: string | undefined;
152
+ try {
153
+ const pulledDiagnostics = await client.pullDiagnostics(file, diagnosticsWaitMs, ctx.signal);
154
+ if (pulledDiagnostics !== undefined) {
155
+ const diagnostics = diagnosticsWithLocalFallback(match.server.id, file, text, pulledDiagnostics);
156
+ this.diagnostics.set(match.server.id, match.root, filePathToUri(file), diagnostics, doc.version);
157
+ lines.push(formatLspDiagnostics(match.server.id, file, diagnostics, match.root));
158
+ continue;
159
+ }
160
+ } catch (error) {
161
+ pullDiagnosticsError = (error as Error).message;
162
+ }
163
+
128
164
  const entry = await this.diagnostics.waitForFile(
129
165
  match.server.id,
130
166
  match.root,
@@ -136,10 +172,13 @@ export class LspManager {
136
172
  );
137
173
  if (!isFreshDiagnosticsEntry(entry, startedAt, doc.version)) {
138
174
  const fallbackSuffix = tsserverFallbackError ? `; tsserver fallback failed: ${tsserverFallbackError}` : "";
139
- lines.push(`⚠️ ${match.server.id}: timed out after ${diagnosticsWaitMs}ms waiting for fresh diagnostics for ${match.relFile}${fallbackSuffix}`);
175
+ const pullSuffix = pullDiagnosticsError ? `; pull diagnostics failed: ${pullDiagnosticsError}` : "";
176
+ lines.push(`⚠️ ${match.server.id}: timed out after ${diagnosticsWaitMs}ms waiting for fresh diagnostics for ${match.relFile}${fallbackSuffix}${pullSuffix}`);
140
177
  continue;
141
178
  }
142
- lines.push(formatLspDiagnostics(match.server.id, file, entry.diagnostics, match.root));
179
+ const diagnostics = diagnosticsWithLocalFallback(match.server.id, file, text, entry.diagnostics);
180
+ if (diagnostics !== entry.diagnostics) this.diagnostics.set(match.server.id, match.root, filePathToUri(file), diagnostics, doc.version);
181
+ lines.push(formatLspDiagnostics(match.server.id, file, diagnostics, match.root));
143
182
  } catch (error) {
144
183
  lines.push(`⚠️ ${match.server.id}: ${(error as Error).message}`);
145
184
  }