lsp-pi 1.0.1
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 +178 -0
- package/lsp-core.ts +1125 -0
- package/lsp-tool.ts +339 -0
- package/lsp.ts +575 -0
- package/package.json +46 -0
- package/tests/index.test.ts +235 -0
- package/tests/lsp-integration.test.ts +602 -0
- package/tests/lsp.test.ts +898 -0
- package/tsconfig.json +13 -0
package/lsp.ts
ADDED
|
@@ -0,0 +1,575 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LSP Hook Extension for pi-coding-agent
|
|
3
|
+
*
|
|
4
|
+
* Provides automatic diagnostics feedback (default: agent end).
|
|
5
|
+
* Can run after each write/edit or once per agent response.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* pi --extension ./lsp.ts
|
|
9
|
+
*
|
|
10
|
+
* Or load the directory to get both hook and tool:
|
|
11
|
+
* pi --extension ./lsp/
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import * as path from "node:path";
|
|
15
|
+
import * as fs from "node:fs";
|
|
16
|
+
import * as os from "node:os";
|
|
17
|
+
import { type ExtensionAPI, type ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
18
|
+
import { Text } from "@mariozechner/pi-tui";
|
|
19
|
+
import { type Diagnostic } from "vscode-languageserver-protocol";
|
|
20
|
+
import { LSP_SERVERS, formatDiagnostic, getOrCreateManager, shutdownManager } from "./lsp-core.js";
|
|
21
|
+
|
|
22
|
+
type HookScope = "session" | "global";
|
|
23
|
+
type HookMode = "edit_write" | "agent_end" | "disabled";
|
|
24
|
+
|
|
25
|
+
const DIAGNOSTICS_WAIT_MS_DEFAULT = 3000;
|
|
26
|
+
|
|
27
|
+
function diagnosticsWaitMsForFile(filePath: string): number {
|
|
28
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
29
|
+
if (ext === ".kt" || ext === ".kts") return 30000;
|
|
30
|
+
if (ext === ".swift") return 20000;
|
|
31
|
+
if (ext === ".rs") return 20000;
|
|
32
|
+
return DIAGNOSTICS_WAIT_MS_DEFAULT;
|
|
33
|
+
}
|
|
34
|
+
const DIAGNOSTICS_PREVIEW_LINES = 10;
|
|
35
|
+
const LSP_WORKING_MESSAGE = "LSP: Working...";
|
|
36
|
+
const DIM = "\x1b[2m", GREEN = "\x1b[32m", YELLOW = "\x1b[33m", RESET = "\x1b[0m";
|
|
37
|
+
const DEFAULT_HOOK_MODE: HookMode = "agent_end";
|
|
38
|
+
const SETTINGS_NAMESPACE = "lsp";
|
|
39
|
+
const LSP_CONFIG_ENTRY = "lsp-hook-config";
|
|
40
|
+
|
|
41
|
+
const WARMUP_MAP: Record<string, string> = {
|
|
42
|
+
"pubspec.yaml": ".dart",
|
|
43
|
+
"package.json": ".ts",
|
|
44
|
+
"pyproject.toml": ".py",
|
|
45
|
+
"go.mod": ".go",
|
|
46
|
+
"Cargo.toml": ".rs",
|
|
47
|
+
"settings.gradle": ".kt",
|
|
48
|
+
"settings.gradle.kts": ".kt",
|
|
49
|
+
"build.gradle": ".kt",
|
|
50
|
+
"build.gradle.kts": ".kt",
|
|
51
|
+
"pom.xml": ".kt",
|
|
52
|
+
"gradlew": ".kt",
|
|
53
|
+
"gradle.properties": ".kt",
|
|
54
|
+
"Package.swift": ".swift",
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const MODE_LABELS: Record<HookMode, string> = {
|
|
58
|
+
edit_write: "After each edit/write",
|
|
59
|
+
agent_end: "At agent end",
|
|
60
|
+
disabled: "Disabled",
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
function normalizeHookMode(value: unknown): HookMode | undefined {
|
|
64
|
+
if (value === "edit_write" || value === "agent_end" || value === "disabled") return value;
|
|
65
|
+
if (value === "turn_end") return "agent_end";
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
interface HookConfigEntry {
|
|
70
|
+
scope: HookScope;
|
|
71
|
+
hookMode?: HookMode;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export default function (pi: ExtensionAPI) {
|
|
75
|
+
type LspActivity = "idle" | "loading" | "working";
|
|
76
|
+
|
|
77
|
+
let activeClients: Set<string> = new Set();
|
|
78
|
+
let statusUpdateFn: ((key: string, text: string | undefined) => void) | null = null;
|
|
79
|
+
let hookMode: HookMode = DEFAULT_HOOK_MODE;
|
|
80
|
+
let hookScope: HookScope = "global";
|
|
81
|
+
let activity: LspActivity = "idle";
|
|
82
|
+
let diagnosticsAbort: AbortController | null = null;
|
|
83
|
+
let shuttingDown = false;
|
|
84
|
+
let lspWorkingMessageActive = false;
|
|
85
|
+
|
|
86
|
+
const touchedFiles: Map<string, boolean> = new Map();
|
|
87
|
+
const globalSettingsPath = path.join(os.homedir(), ".pi", "agent", "settings.json");
|
|
88
|
+
|
|
89
|
+
function readSettingsFile(filePath: string): Record<string, unknown> {
|
|
90
|
+
try {
|
|
91
|
+
if (!fs.existsSync(filePath)) return {};
|
|
92
|
+
const raw = fs.readFileSync(filePath, "utf-8");
|
|
93
|
+
const parsed = JSON.parse(raw);
|
|
94
|
+
return parsed && typeof parsed === "object" ? parsed as Record<string, unknown> : {};
|
|
95
|
+
} catch {
|
|
96
|
+
return {};
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function getGlobalHookMode(): HookMode | undefined {
|
|
101
|
+
const settings = readSettingsFile(globalSettingsPath);
|
|
102
|
+
const lspSettings = settings[SETTINGS_NAMESPACE];
|
|
103
|
+
const hookValue = (lspSettings as { hookMode?: unknown; hookEnabled?: unknown } | undefined)?.hookMode;
|
|
104
|
+
const normalized = normalizeHookMode(hookValue);
|
|
105
|
+
if (normalized) return normalized;
|
|
106
|
+
|
|
107
|
+
const legacyEnabled = (lspSettings as { hookEnabled?: unknown } | undefined)?.hookEnabled;
|
|
108
|
+
if (typeof legacyEnabled === "boolean") return legacyEnabled ? "edit_write" : "disabled";
|
|
109
|
+
return undefined;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function setGlobalHookMode(mode: HookMode): boolean {
|
|
113
|
+
try {
|
|
114
|
+
const settings = readSettingsFile(globalSettingsPath);
|
|
115
|
+
const existing = settings[SETTINGS_NAMESPACE];
|
|
116
|
+
const nextNamespace = (existing && typeof existing === "object")
|
|
117
|
+
? { ...(existing as Record<string, unknown>), hookMode: mode }
|
|
118
|
+
: { hookMode: mode };
|
|
119
|
+
|
|
120
|
+
settings[SETTINGS_NAMESPACE] = nextNamespace;
|
|
121
|
+
fs.mkdirSync(path.dirname(globalSettingsPath), { recursive: true });
|
|
122
|
+
fs.writeFileSync(globalSettingsPath, JSON.stringify(settings, null, 2), "utf-8");
|
|
123
|
+
return true;
|
|
124
|
+
} catch {
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function getLastHookEntry(ctx: ExtensionContext): HookConfigEntry | undefined {
|
|
130
|
+
const branchEntries = ctx.sessionManager.getBranch();
|
|
131
|
+
let latest: HookConfigEntry | undefined;
|
|
132
|
+
|
|
133
|
+
for (const entry of branchEntries) {
|
|
134
|
+
if (entry.type === "custom" && entry.customType === LSP_CONFIG_ENTRY) {
|
|
135
|
+
latest = entry.data as HookConfigEntry | undefined;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return latest;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function restoreHookState(ctx: ExtensionContext): void {
|
|
143
|
+
const entry = getLastHookEntry(ctx);
|
|
144
|
+
if (entry?.scope === "session") {
|
|
145
|
+
const normalized = normalizeHookMode(entry.hookMode);
|
|
146
|
+
if (normalized) {
|
|
147
|
+
hookMode = normalized;
|
|
148
|
+
hookScope = "session";
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const legacyEnabled = (entry as { hookEnabled?: unknown }).hookEnabled;
|
|
153
|
+
if (typeof legacyEnabled === "boolean") {
|
|
154
|
+
hookMode = legacyEnabled ? "edit_write" : "disabled";
|
|
155
|
+
hookScope = "session";
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const globalSetting = getGlobalHookMode();
|
|
161
|
+
hookMode = globalSetting ?? DEFAULT_HOOK_MODE;
|
|
162
|
+
hookScope = "global";
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function persistHookEntry(entry: HookConfigEntry): void {
|
|
166
|
+
pi.appendEntry<HookConfigEntry>(LSP_CONFIG_ENTRY, entry);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function labelForMode(mode: HookMode): string {
|
|
170
|
+
return MODE_LABELS[mode];
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function messageContentToText(content: unknown): string {
|
|
174
|
+
if (typeof content === "string") return content;
|
|
175
|
+
if (Array.isArray(content)) {
|
|
176
|
+
return content
|
|
177
|
+
.map((item) => (item && typeof item === "object" && "type" in item && (item as any).type === "text")
|
|
178
|
+
? String((item as any).text ?? "")
|
|
179
|
+
: "")
|
|
180
|
+
.filter(Boolean)
|
|
181
|
+
.join("\n");
|
|
182
|
+
}
|
|
183
|
+
return "";
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function formatDiagnosticsForDisplay(text: string): string {
|
|
187
|
+
return text
|
|
188
|
+
.replace(/\n?This file has errors, please fix\n/gi, "\n")
|
|
189
|
+
.replace(/<\/?file_diagnostics>\n?/gi, "")
|
|
190
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
191
|
+
.trim();
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function setActivity(next: LspActivity): void {
|
|
195
|
+
activity = next;
|
|
196
|
+
updateLspStatus();
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function showLspWorkingMessage(ctx: ExtensionContext): void {
|
|
200
|
+
if (!ctx.hasUI) return;
|
|
201
|
+
const ui = ctx.ui as { setWorkingMessage?: (text?: string) => void };
|
|
202
|
+
if (!ui.setWorkingMessage) return;
|
|
203
|
+
ui.setWorkingMessage(LSP_WORKING_MESSAGE);
|
|
204
|
+
lspWorkingMessageActive = true;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function clearLspWorkingMessage(ctx: ExtensionContext): void {
|
|
208
|
+
if (!lspWorkingMessageActive) return;
|
|
209
|
+
lspWorkingMessageActive = false;
|
|
210
|
+
if (!ctx.hasUI) return;
|
|
211
|
+
const ui = ctx.ui as { setWorkingMessage?: (text?: string) => void };
|
|
212
|
+
ui.setWorkingMessage?.();
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function updateLspStatus(): void {
|
|
216
|
+
if (!statusUpdateFn) return;
|
|
217
|
+
|
|
218
|
+
const clients = activeClients.size > 0 ? [...activeClients].join(", ") : "";
|
|
219
|
+
const clientsText = clients ? `${DIM}${clients}${RESET}` : "";
|
|
220
|
+
const activityText = activity === "loading"
|
|
221
|
+
? `${DIM}Loading...${RESET}`
|
|
222
|
+
: activity === "working"
|
|
223
|
+
? `${DIM}Working...${RESET}`
|
|
224
|
+
: "";
|
|
225
|
+
|
|
226
|
+
if (hookMode === "disabled") {
|
|
227
|
+
const text = clientsText
|
|
228
|
+
? `${YELLOW}LSP${RESET} ${DIM}(tool)${RESET}: ${clientsText}`
|
|
229
|
+
: `${YELLOW}LSP${RESET} ${DIM}(tool)${RESET}`;
|
|
230
|
+
statusUpdateFn("lsp", text);
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
let text = `${GREEN}LSP${RESET}`;
|
|
235
|
+
if (activityText) text += ` ${activityText}`;
|
|
236
|
+
if (clientsText) text += ` ${clientsText}`;
|
|
237
|
+
statusUpdateFn("lsp", text);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function normalizeFilePath(filePath: string, cwd: string): string {
|
|
241
|
+
return path.isAbsolute(filePath) ? filePath : path.resolve(cwd, filePath);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
pi.registerMessageRenderer("lsp-diagnostics", (message, options, theme) => {
|
|
245
|
+
const content = formatDiagnosticsForDisplay(messageContentToText(message.content));
|
|
246
|
+
if (!content) return new Text("", 0, 0);
|
|
247
|
+
|
|
248
|
+
const expanded = options.expanded === true;
|
|
249
|
+
const lines = content.split("\n");
|
|
250
|
+
const maxLines = expanded ? lines.length : DIAGNOSTICS_PREVIEW_LINES;
|
|
251
|
+
const display = lines.slice(0, maxLines);
|
|
252
|
+
const remaining = lines.length - display.length;
|
|
253
|
+
|
|
254
|
+
const styledLines = display.map((line) => {
|
|
255
|
+
if (line.startsWith("File: ")) return theme.fg("muted", line);
|
|
256
|
+
return theme.fg("toolOutput", line);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
if (!expanded && remaining > 0) {
|
|
260
|
+
styledLines.push(theme.fg("dim", `... (${remaining} more lines)`));
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
return new Text(styledLines.join("\n"), 0, 0);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
function getServerConfig(filePath: string) {
|
|
267
|
+
const ext = path.extname(filePath);
|
|
268
|
+
return LSP_SERVERS.find((s) => s.extensions.includes(ext));
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function ensureActiveClientForFile(filePath: string, cwd: string): string | undefined {
|
|
272
|
+
const absPath = normalizeFilePath(filePath, cwd);
|
|
273
|
+
const cfg = getServerConfig(absPath);
|
|
274
|
+
if (!cfg) return undefined;
|
|
275
|
+
|
|
276
|
+
if (!activeClients.has(cfg.id)) {
|
|
277
|
+
activeClients.add(cfg.id);
|
|
278
|
+
updateLspStatus();
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
return absPath;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function extractLspFiles(input: Record<string, unknown>): string[] {
|
|
285
|
+
const files: string[] = [];
|
|
286
|
+
|
|
287
|
+
if (typeof input.file === "string") files.push(input.file);
|
|
288
|
+
if (Array.isArray(input.files)) {
|
|
289
|
+
for (const item of input.files) {
|
|
290
|
+
if (typeof item === "string") files.push(item);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
return files;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function buildDiagnosticsOutput(
|
|
298
|
+
filePath: string,
|
|
299
|
+
diagnostics: Diagnostic[],
|
|
300
|
+
cwd: string,
|
|
301
|
+
includeFileHeader: boolean,
|
|
302
|
+
): { notification: string; errorCount: number; output: string } {
|
|
303
|
+
const absPath = path.isAbsolute(filePath) ? filePath : path.resolve(cwd, filePath);
|
|
304
|
+
const relativePath = path.relative(cwd, absPath);
|
|
305
|
+
const errorCount = diagnostics.filter((e) => e.severity === 1).length;
|
|
306
|
+
|
|
307
|
+
const MAX = 5;
|
|
308
|
+
const lines = diagnostics.slice(0, MAX).map((e) => {
|
|
309
|
+
const sev = e.severity === 1 ? "ERROR" : "WARN";
|
|
310
|
+
return `${sev}[${e.range.start.line + 1}] ${e.message.split("\n")[0]}`;
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
let notification = `📋 ${relativePath}\n${lines.join("\n")}`;
|
|
314
|
+
if (diagnostics.length > MAX) notification += `\n... +${diagnostics.length - MAX} more`;
|
|
315
|
+
|
|
316
|
+
const header = includeFileHeader ? `File: ${relativePath}\n` : "";
|
|
317
|
+
const output = `\n${header}This file has errors, please fix\n<file_diagnostics>\n${diagnostics.map(formatDiagnostic).join("\n")}\n</file_diagnostics>\n`;
|
|
318
|
+
|
|
319
|
+
return { notification, errorCount, output };
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
async function collectDiagnostics(
|
|
323
|
+
filePath: string,
|
|
324
|
+
ctx: ExtensionContext,
|
|
325
|
+
includeWarnings: boolean,
|
|
326
|
+
includeFileHeader: boolean,
|
|
327
|
+
notify = true,
|
|
328
|
+
): Promise<string | undefined> {
|
|
329
|
+
const manager = getOrCreateManager(ctx.cwd);
|
|
330
|
+
const absPath = ensureActiveClientForFile(filePath, ctx.cwd);
|
|
331
|
+
if (!absPath) return undefined;
|
|
332
|
+
|
|
333
|
+
try {
|
|
334
|
+
const result = await manager.touchFileAndWait(absPath, diagnosticsWaitMsForFile(absPath));
|
|
335
|
+
if (!result.receivedResponse) return undefined;
|
|
336
|
+
|
|
337
|
+
const diagnostics = includeWarnings
|
|
338
|
+
? result.diagnostics
|
|
339
|
+
: result.diagnostics.filter((d) => d.severity === 1);
|
|
340
|
+
if (!diagnostics.length) return undefined;
|
|
341
|
+
|
|
342
|
+
const report = buildDiagnosticsOutput(filePath, diagnostics, ctx.cwd, includeFileHeader);
|
|
343
|
+
|
|
344
|
+
if (notify) {
|
|
345
|
+
if (ctx.hasUI) ctx.ui.notify(report.notification, report.errorCount > 0 ? "error" : "warning");
|
|
346
|
+
else console.error(report.notification);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
return report.output;
|
|
350
|
+
} catch {
|
|
351
|
+
return undefined;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
pi.registerCommand("lsp", {
|
|
356
|
+
description: "LSP settings (auto diagnostics hook)",
|
|
357
|
+
handler: async (_args, ctx) => {
|
|
358
|
+
if (!ctx.hasUI) {
|
|
359
|
+
ctx.ui.notify("LSP settings require UI", "warning");
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const currentMark = " ✓";
|
|
364
|
+
const modeOptions = ([
|
|
365
|
+
"edit_write",
|
|
366
|
+
"agent_end",
|
|
367
|
+
"disabled",
|
|
368
|
+
] as HookMode[]).map((mode) => ({
|
|
369
|
+
mode,
|
|
370
|
+
label: mode === hookMode ? `${labelForMode(mode)}${currentMark}` : labelForMode(mode),
|
|
371
|
+
}));
|
|
372
|
+
|
|
373
|
+
const modeChoice = await ctx.ui.select(
|
|
374
|
+
"LSP auto diagnostics hook mode:",
|
|
375
|
+
modeOptions.map((option) => option.label),
|
|
376
|
+
);
|
|
377
|
+
if (!modeChoice) return;
|
|
378
|
+
|
|
379
|
+
const nextMode = modeOptions.find((option) => option.label === modeChoice)?.mode;
|
|
380
|
+
if (!nextMode) return;
|
|
381
|
+
|
|
382
|
+
const scopeOptions = [
|
|
383
|
+
{
|
|
384
|
+
scope: "session" as HookScope,
|
|
385
|
+
label: "Session only",
|
|
386
|
+
},
|
|
387
|
+
{
|
|
388
|
+
scope: "global" as HookScope,
|
|
389
|
+
label: "Global (all sessions)",
|
|
390
|
+
},
|
|
391
|
+
];
|
|
392
|
+
|
|
393
|
+
const scopeChoice = await ctx.ui.select(
|
|
394
|
+
"Apply LSP auto diagnostics hook setting to:",
|
|
395
|
+
scopeOptions.map((option) => option.label),
|
|
396
|
+
);
|
|
397
|
+
if (!scopeChoice) return;
|
|
398
|
+
|
|
399
|
+
const scope = scopeOptions.find((option) => option.label === scopeChoice)?.scope;
|
|
400
|
+
if (!scope) return;
|
|
401
|
+
if (scope === "global") {
|
|
402
|
+
const ok = setGlobalHookMode(nextMode);
|
|
403
|
+
if (!ok) {
|
|
404
|
+
ctx.ui.notify("Failed to update global settings", "error");
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
hookMode = nextMode;
|
|
410
|
+
hookScope = scope;
|
|
411
|
+
touchedFiles.clear();
|
|
412
|
+
persistHookEntry({ scope, hookMode: nextMode });
|
|
413
|
+
updateLspStatus();
|
|
414
|
+
ctx.ui.notify(`LSP hook: ${labelForMode(hookMode)} (${hookScope})`, "info");
|
|
415
|
+
},
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
419
|
+
restoreHookState(ctx);
|
|
420
|
+
statusUpdateFn = ctx.hasUI && ctx.ui.setStatus ? ctx.ui.setStatus.bind(ctx.ui) : null;
|
|
421
|
+
updateLspStatus();
|
|
422
|
+
|
|
423
|
+
if (hookMode === "disabled") return;
|
|
424
|
+
|
|
425
|
+
const manager = getOrCreateManager(ctx.cwd);
|
|
426
|
+
|
|
427
|
+
for (const [marker, ext] of Object.entries(WARMUP_MAP)) {
|
|
428
|
+
if (fs.existsSync(path.join(ctx.cwd, marker))) {
|
|
429
|
+
setActivity("loading");
|
|
430
|
+
manager.getClientsForFile(path.join(ctx.cwd, `dummy${ext}`))
|
|
431
|
+
.then((clients) => {
|
|
432
|
+
if (clients.length > 0) {
|
|
433
|
+
const cfg = LSP_SERVERS.find((s) => s.extensions.includes(ext));
|
|
434
|
+
if (cfg) activeClients.add(cfg.id);
|
|
435
|
+
}
|
|
436
|
+
})
|
|
437
|
+
.catch(() => {})
|
|
438
|
+
.finally(() => setActivity("idle"));
|
|
439
|
+
break;
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
pi.on("session_switch", async (_event, ctx) => {
|
|
445
|
+
restoreHookState(ctx);
|
|
446
|
+
updateLspStatus();
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
pi.on("session_tree", async (_event, ctx) => {
|
|
450
|
+
restoreHookState(ctx);
|
|
451
|
+
updateLspStatus();
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
pi.on("session_fork", async (_event, ctx) => {
|
|
455
|
+
restoreHookState(ctx);
|
|
456
|
+
updateLspStatus();
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
pi.on("session_shutdown", async () => {
|
|
460
|
+
shuttingDown = true;
|
|
461
|
+
diagnosticsAbort?.abort();
|
|
462
|
+
diagnosticsAbort = null;
|
|
463
|
+
setActivity("idle");
|
|
464
|
+
|
|
465
|
+
await shutdownManager();
|
|
466
|
+
activeClients.clear();
|
|
467
|
+
statusUpdateFn?.("lsp", undefined);
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
pi.on("tool_call", async (event, ctx) => {
|
|
471
|
+
if (event.toolName !== "lsp") return;
|
|
472
|
+
const files = extractLspFiles(event.input);
|
|
473
|
+
for (const file of files) {
|
|
474
|
+
ensureActiveClientForFile(file, ctx.cwd);
|
|
475
|
+
}
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
pi.on("agent_start", async (_event, ctx) => {
|
|
479
|
+
diagnosticsAbort?.abort();
|
|
480
|
+
diagnosticsAbort = null;
|
|
481
|
+
clearLspWorkingMessage(ctx);
|
|
482
|
+
setActivity("idle");
|
|
483
|
+
touchedFiles.clear();
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
function agentWasAborted(event: any): boolean {
|
|
487
|
+
const messages = Array.isArray(event?.messages) ? event.messages : [];
|
|
488
|
+
return messages.some((m: any) =>
|
|
489
|
+
m &&
|
|
490
|
+
typeof m === "object" &&
|
|
491
|
+
(m as any).role === "assistant" &&
|
|
492
|
+
(((m as any).stopReason === "aborted") || ((m as any).stopReason === "error"))
|
|
493
|
+
);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
pi.on("agent_end", async (event, ctx) => {
|
|
497
|
+
if (hookMode !== "agent_end") return;
|
|
498
|
+
|
|
499
|
+
if (agentWasAborted(event)) {
|
|
500
|
+
// Don't run diagnostics on aborted/error runs.
|
|
501
|
+
touchedFiles.clear();
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
if (touchedFiles.size === 0) return;
|
|
506
|
+
if (!ctx.isIdle() || ctx.hasPendingMessages()) return;
|
|
507
|
+
|
|
508
|
+
const abort = new AbortController();
|
|
509
|
+
diagnosticsAbort?.abort();
|
|
510
|
+
diagnosticsAbort = abort;
|
|
511
|
+
|
|
512
|
+
setActivity("working");
|
|
513
|
+
showLspWorkingMessage(ctx);
|
|
514
|
+
|
|
515
|
+
const files = Array.from(touchedFiles.entries());
|
|
516
|
+
touchedFiles.clear();
|
|
517
|
+
|
|
518
|
+
try {
|
|
519
|
+
const outputs: string[] = [];
|
|
520
|
+
for (const [filePath, includeWarnings] of files) {
|
|
521
|
+
if (shuttingDown || abort.signal.aborted) return;
|
|
522
|
+
if (!ctx.isIdle() || ctx.hasPendingMessages()) {
|
|
523
|
+
abort.abort();
|
|
524
|
+
return;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
const output = await collectDiagnostics(filePath, ctx, includeWarnings, true, false);
|
|
528
|
+
if (abort.signal.aborted) return;
|
|
529
|
+
if (output) outputs.push(output);
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
if (shuttingDown || abort.signal.aborted) return;
|
|
533
|
+
|
|
534
|
+
if (outputs.length) {
|
|
535
|
+
pi.sendMessage({
|
|
536
|
+
customType: "lsp-diagnostics",
|
|
537
|
+
content: outputs.join("\n"),
|
|
538
|
+
display: true,
|
|
539
|
+
}, {
|
|
540
|
+
triggerTurn: true,
|
|
541
|
+
deliverAs: "followUp",
|
|
542
|
+
});
|
|
543
|
+
}
|
|
544
|
+
} finally {
|
|
545
|
+
if (diagnosticsAbort === abort) diagnosticsAbort = null;
|
|
546
|
+
if (!shuttingDown) setActivity("idle");
|
|
547
|
+
clearLspWorkingMessage(ctx);
|
|
548
|
+
}
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
pi.on("tool_result", async (event, ctx) => {
|
|
552
|
+
if (event.toolName !== "write" && event.toolName !== "edit") return;
|
|
553
|
+
|
|
554
|
+
const filePath = event.input.path as string;
|
|
555
|
+
if (!filePath) return;
|
|
556
|
+
|
|
557
|
+
const absPath = ensureActiveClientForFile(filePath, ctx.cwd);
|
|
558
|
+
if (!absPath) return;
|
|
559
|
+
|
|
560
|
+
if (hookMode === "disabled") return;
|
|
561
|
+
|
|
562
|
+
if (hookMode === "agent_end") {
|
|
563
|
+
const includeWarnings = event.toolName === "write";
|
|
564
|
+
const existing = touchedFiles.get(absPath) ?? false;
|
|
565
|
+
touchedFiles.set(absPath, existing || includeWarnings);
|
|
566
|
+
return;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
const includeWarnings = event.toolName === "write";
|
|
570
|
+
const output = await collectDiagnostics(absPath, ctx, includeWarnings, false);
|
|
571
|
+
if (!output) return;
|
|
572
|
+
|
|
573
|
+
return { content: [...event.content, { type: "text" as const, text: output }] as Array<{ type: "text"; text: string }> };
|
|
574
|
+
});
|
|
575
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "lsp-pi",
|
|
3
|
+
"version": "1.0.1",
|
|
4
|
+
"description": "LSP extension for pi-coding-agent - provides language server tool and diagnostics feedback for Dart/Flutter, TypeScript, Vue, Svelte, Python, Go, Kotlin, Swift, Rust",
|
|
5
|
+
"scripts": {
|
|
6
|
+
"test": "npx tsx tests/lsp.test.ts",
|
|
7
|
+
"test:tool": "npx tsx tests/index.test.ts",
|
|
8
|
+
"test:integration": "npx tsx tests/lsp-integration.test.ts",
|
|
9
|
+
"test:all": "npm test && npm run test:tool && npm run test:integration"
|
|
10
|
+
},
|
|
11
|
+
"keywords": [
|
|
12
|
+
"lsp",
|
|
13
|
+
"language-server",
|
|
14
|
+
"dart",
|
|
15
|
+
"flutter",
|
|
16
|
+
"typescript",
|
|
17
|
+
"vue",
|
|
18
|
+
"svelte",
|
|
19
|
+
"python",
|
|
20
|
+
"go",
|
|
21
|
+
"kotlin",
|
|
22
|
+
"swift",
|
|
23
|
+
"rust",
|
|
24
|
+
"pi-coding-agent",
|
|
25
|
+
"extension",
|
|
26
|
+
"pi-package"
|
|
27
|
+
],
|
|
28
|
+
"author": "",
|
|
29
|
+
"license": "MIT",
|
|
30
|
+
"type": "module",
|
|
31
|
+
"pi": {
|
|
32
|
+
"extensions": ["./lsp.ts", "./lsp-tool.ts"]
|
|
33
|
+
},
|
|
34
|
+
"dependencies": {
|
|
35
|
+
"@mariozechner/pi-ai": "^0.50.0",
|
|
36
|
+
"@mariozechner/pi-coding-agent": "^0.50.0",
|
|
37
|
+
"@mariozechner/pi-tui": "^0.50.0",
|
|
38
|
+
"@sinclair/typebox": "^0.34.33",
|
|
39
|
+
"vscode-languageserver-protocol": "^3.17.5"
|
|
40
|
+
},
|
|
41
|
+
"devDependencies": {
|
|
42
|
+
"@types/node": "^24.10.2",
|
|
43
|
+
"tsx": "^4.21.0",
|
|
44
|
+
"typescript": "^5.9.3"
|
|
45
|
+
}
|
|
46
|
+
}
|