flake-update-20260505
1/**
2 * LSP Tool Extension for pi-coding-agent
3 *
4 * Provides Language Server Protocol tool for:
5 * - definitions, references, hover, signature help
6 * - document symbols, diagnostics, workspace diagnostics
7 * - rename, code actions
8 *
9 * Supported languages:
10 * - Dart/Flutter (dart language-server)
11 * - TypeScript/JavaScript (typescript-language-server)
12 * - Vue (vue-language-server)
13 * - Svelte (svelteserver)
14 * - Python (pyright-langserver)
15 * - Go (gopls)
16 * - Kotlin (kotlin-ls)
17 * - Swift (sourcekit-lsp)
18 * - Rust (rust-analyzer)
19 *
20 * Usage:
21 * pi --extension ./lsp-tool.ts
22 *
23 * Or use the combined lsp.ts extension for both hook and tool functionality.
24 */
25
26import * as path from "node:path";
27import { Type, type Static } from "@sinclair/typebox";
28import { StringEnum } from "@mariozechner/pi-ai";
29import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
30import { Text } from "@mariozechner/pi-tui";
31import { getOrCreateManager, formatDiagnostic, filterDiagnosticsBySeverity, uriToPath, resolvePosition, type SeverityFilter } from "./lsp-core.js";
32
33const PREVIEW_LINES = 10;
34
35const DIAGNOSTICS_WAIT_MS_DEFAULT = 3000;
36
37function diagnosticsWaitMsForFile(filePath: string): number {
38 const ext = path.extname(filePath).toLowerCase();
39 if (ext === ".kt" || ext === ".kts") return 30000;
40 if (ext === ".swift") return 20000;
41 if (ext === ".rs") return 20000;
42 return DIAGNOSTICS_WAIT_MS_DEFAULT;
43}
44
45const ACTIONS = ["definition", "references", "hover", "symbols", "diagnostics", "workspace-diagnostics", "signature", "rename", "codeAction"] as const;
46const SEVERITY_FILTERS = ["all", "error", "warning", "info", "hint"] as const;
47
48const LspParams = Type.Object({
49 action: StringEnum(ACTIONS),
50 file: Type.Optional(Type.String({ description: "File path (required for most actions)" })),
51 files: Type.Optional(Type.Array(Type.String(), { description: "File paths for workspace-diagnostics" })),
52 line: Type.Optional(Type.Number({ description: "Line (1-indexed). Required for position-based actions unless query provided." })),
53 column: Type.Optional(Type.Number({ description: "Column (1-indexed). Required for position-based actions unless query provided." })),
54 endLine: Type.Optional(Type.Number({ description: "End line for range-based actions (codeAction)" })),
55 endColumn: Type.Optional(Type.Number({ description: "End column for range-based actions (codeAction)" })),
56 query: Type.Optional(Type.String({ description: "Symbol name filter (for symbols) or to resolve position (for definition/references/hover/signature)" })),
57 newName: Type.Optional(Type.String({ description: "New name for rename action" })),
58 severity: Type.Optional(StringEnum(SEVERITY_FILTERS, { description: 'Filter diagnostics: "all"|"error"|"warning"|"info"|"hint"' })),
59});
60
61type LspParamsType = Static<typeof LspParams>;
62
63function abortable<T>(promise: Promise<T>, signal?: AbortSignal): Promise<T> {
64 if (!signal) return promise;
65 if (signal.aborted) return Promise.reject(new Error("aborted"));
66
67 return new Promise<T>((resolve, reject) => {
68 const onAbort = () => {
69 cleanup();
70 reject(new Error("aborted"));
71 };
72
73 const cleanup = () => {
74 signal.removeEventListener("abort", onAbort);
75 };
76
77 signal.addEventListener("abort", onAbort, { once: true });
78
79 promise.then(
80 (value) => {
81 cleanup();
82 resolve(value);
83 },
84 (err) => {
85 cleanup();
86 reject(err);
87 },
88 );
89 });
90}
91
92function isAbortedError(e: unknown): boolean {
93 return e instanceof Error && e.message === "aborted";
94}
95
96function cancelledToolResult() {
97 return {
98 content: [{ type: "text" as const, text: "Cancelled" }],
99 details: { cancelled: true },
100 };
101}
102
103type ExecuteArgs = {
104 signal: AbortSignal | undefined;
105 onUpdate: ((update: { content: Array<{ type: "text"; text: string }>; details?: Record<string, unknown> }) => void) | undefined;
106 ctx: { cwd: string };
107};
108
109function isAbortSignalLike(value: unknown): value is AbortSignal {
110 return !!value
111 && typeof value === "object"
112 && "aborted" in value
113 && typeof (value as any).aborted === "boolean"
114 && typeof (value as any).addEventListener === "function";
115}
116
117function isContextLike(value: unknown): value is { cwd: string } {
118 return !!value && typeof value === "object" && typeof (value as any).cwd === "string";
119}
120
121function normalizeExecuteArgs(onUpdateArg: unknown, ctxArg: unknown, signalArg: unknown): ExecuteArgs {
122 // Runtime >= 0.51: (signal, onUpdate, ctx)
123 if (isContextLike(signalArg)) {
124 return {
125 signal: isAbortSignalLike(onUpdateArg) ? onUpdateArg : undefined,
126 onUpdate: typeof ctxArg === "function" ? ctxArg as ExecuteArgs["onUpdate"] : undefined,
127 ctx: signalArg,
128 };
129 }
130
131 // Runtime <= 0.50: (onUpdate, ctx, signal)
132 if (isContextLike(ctxArg)) {
133 return {
134 signal: isAbortSignalLike(signalArg) ? signalArg : undefined,
135 onUpdate: typeof onUpdateArg === "function" ? onUpdateArg as ExecuteArgs["onUpdate"] : undefined,
136 ctx: ctxArg,
137 };
138 }
139
140 throw new Error("Invalid tool execution context");
141}
142
143function formatLocation(loc: { uri: string; range?: { start?: { line: number; character: number } } }, cwd?: string): string {
144 const abs = uriToPath(loc.uri);
145 const display = cwd && path.isAbsolute(abs) ? path.relative(cwd, abs) : abs;
146 const { line, character: col } = loc.range?.start ?? {};
147 return typeof line === "number" && typeof col === "number" ? `${display}:${line + 1}:${col + 1}` : display;
148}
149
150function formatHover(contents: unknown): string {
151 if (typeof contents === "string") return contents;
152 if (Array.isArray(contents)) return contents.map(c => typeof c === "string" ? c : (c as any)?.value ?? "").filter(Boolean).join("\n\n");
153 if (contents && typeof contents === "object" && "value" in contents) return String((contents as any).value);
154 return "";
155}
156
157function formatSignature(help: any): string {
158 if (!help?.signatures?.length) return "No signature help available.";
159 const sig = help.signatures[help.activeSignature ?? 0] ?? help.signatures[0];
160 let text = sig.label ?? "Signature";
161 if (sig.documentation) text += `\n${typeof sig.documentation === "string" ? sig.documentation : sig.documentation?.value ?? ""}`;
162 if (sig.parameters?.length) {
163 const params = sig.parameters.map((p: any) => typeof p.label === "string" ? p.label : Array.isArray(p.label) ? p.label.join("-") : "").filter(Boolean);
164 if (params.length) text += `\nParameters: ${params.join(", ")}`;
165 }
166 return text;
167}
168
169function collectSymbols(symbols: any[], depth = 0, lines: string[] = [], query?: string): string[] {
170 for (const sym of symbols) {
171 const name = sym?.name ?? "<unknown>";
172 if (query && !name.toLowerCase().includes(query.toLowerCase())) {
173 if (sym.children?.length) collectSymbols(sym.children, depth + 1, lines, query);
174 continue;
175 }
176 const loc = sym?.range?.start ? `${sym.range.start.line + 1}:${sym.range.start.character + 1}` : "";
177 lines.push(`${" ".repeat(depth)}${name}${loc ? ` (${loc})` : ""}`);
178 if (sym.children?.length) collectSymbols(sym.children, depth + 1, lines, query);
179 }
180 return lines;
181}
182
183function formatWorkspaceEdit(edit: any, cwd?: string): string {
184 const lines: string[] = [];
185
186 if (edit.documentChanges?.length) {
187 for (const change of edit.documentChanges) {
188 if (change.textDocument?.uri) {
189 const fp = uriToPath(change.textDocument.uri);
190 const display = cwd && path.isAbsolute(fp) ? path.relative(cwd, fp) : fp;
191 lines.push(`${display}:`);
192 for (const e of change.edits || []) {
193 const loc = `${e.range.start.line + 1}:${e.range.start.character + 1}`;
194 lines.push(` [${loc}] → "${e.newText}"`);
195 }
196 }
197 }
198 }
199
200 if (edit.changes) {
201 for (const [uri, edits] of Object.entries(edit.changes)) {
202 const fp = uriToPath(uri);
203 const display = cwd && path.isAbsolute(fp) ? path.relative(cwd, fp) : fp;
204 lines.push(`${display}:`);
205 for (const e of edits as any[]) {
206 const loc = `${e.range.start.line + 1}:${e.range.start.character + 1}`;
207 lines.push(` [${loc}] → "${e.newText}"`);
208 }
209 }
210 }
211
212 return lines.length ? lines.join("\n") : "No edits.";
213}
214
215function formatCodeActions(actions: any[]): string[] {
216 return actions.map((a, i) => {
217 const title = a.title || a.command?.title || "Untitled action";
218 const kind = a.kind ? ` (${a.kind})` : "";
219 const isPreferred = a.isPreferred ? " ★" : "";
220 return `${i + 1}. ${title}${kind}${isPreferred}`;
221 });
222}
223
224export default function (pi: ExtensionAPI) {
225 pi.registerTool({
226 name: "lsp",
227 label: "LSP",
228 promptGuidelines: [
229 "Use lsp for go-to-definition, find-references, hover, and rename — faster and more accurate than grep for supported languages",
230 "Use lsp diagnostics to check for errors before suggesting fixes",
231 ],
232 description: `Query language server for definitions, references, types, symbols, diagnostics, rename, and code actions.
233
234Actions: definition, references, hover, signature, rename (require file + line/column or query), symbols (file, optional query), diagnostics (file), workspace-diagnostics (files array), codeAction (file + position).
235Use bash to find files: find src -name "*.ts" -type f`,
236 parameters: LspParams,
237
238 async execute(_toolCallId, params, onUpdateArg, ctxArg, signalArg) {
239 const { signal, onUpdate, ctx } = normalizeExecuteArgs(onUpdateArg, ctxArg, signalArg);
240 if (signal?.aborted) return cancelledToolResult();
241 if (onUpdate) {
242 onUpdate({ content: [{ type: "text", text: "Working..." }], details: { status: "working" } });
243 }
244
245 const manager = getOrCreateManager(ctx.cwd);
246 const { action, file, files, line, column, endLine, endColumn, query, newName, severity } = params as LspParamsType;
247 const sevFilter: SeverityFilter = severity || "all";
248 const needsFile = action !== "workspace-diagnostics";
249 const needsPos = ["definition", "references", "hover", "signature", "rename", "codeAction"].includes(action);
250
251 try {
252 if (needsFile && !file) throw new Error(`Action "${action}" requires a file path.`);
253
254 let rLine = line, rCol = column, fromQuery = false;
255 if (needsPos && (rLine === undefined || rCol === undefined) && query && file) {
256 const resolved = await abortable(resolvePosition(manager, file, query), signal);
257 if (resolved) { rLine = resolved.line; rCol = resolved.column; fromQuery = true; }
258 }
259 if (needsPos && (rLine === undefined || rCol === undefined)) {
260 throw new Error(`Action "${action}" requires line/column or a query matching a symbol.`);
261 }
262
263 const qLine = query ? `query: ${query}\n` : "";
264 const sevLine = sevFilter !== "all" ? `severity: ${sevFilter}\n` : "";
265 const posLine = fromQuery && rLine && rCol ? `resolvedPosition: ${rLine}:${rCol}\n` : "";
266
267 switch (action) {
268 case "definition": {
269 const results = await abortable(manager.getDefinition(file!, rLine!, rCol!), signal);
270 const locs = results.map(l => formatLocation(l, ctx?.cwd));
271 const payload = locs.length ? locs.join("\n") : fromQuery ? `${file}:${rLine}:${rCol}` : "No definitions found.";
272 return { content: [{ type: "text", text: `action: definition\n${qLine}${posLine}${payload}` }], details: results };
273 }
274 case "references": {
275 const results = await abortable(manager.getReferences(file!, rLine!, rCol!), signal);
276 const locs = results.map(l => formatLocation(l, ctx?.cwd));
277 return { content: [{ type: "text", text: `action: references\n${qLine}${posLine}${locs.length ? locs.join("\n") : "No references found."}` }], details: results };
278 }
279 case "hover": {
280 const result = await abortable(manager.getHover(file!, rLine!, rCol!), signal);
281 const payload = result ? formatHover(result.contents) || "No hover information." : "No hover information.";
282 return { content: [{ type: "text", text: `action: hover\n${qLine}${posLine}${payload}` }], details: result ?? null };
283 }
284 case "symbols": {
285 const symbols = await abortable(manager.getDocumentSymbols(file!), signal);
286 const lines = collectSymbols(symbols, 0, [], query);
287 const payload = lines.length ? lines.join("\n") : query ? `No symbols matching "${query}".` : "No symbols found.";
288 return { content: [{ type: "text", text: `action: symbols\n${qLine}${payload}` }], details: symbols };
289 }
290 case "diagnostics": {
291 const result = await abortable(manager.touchFileAndWait(file!, diagnosticsWaitMsForFile(file!)), signal);
292 const filtered = filterDiagnosticsBySeverity(result.diagnostics, sevFilter);
293 const payload = (result as any).unsupported
294 ? `Unsupported: ${(result as any).error || "No LSP for this file."}`
295 : !result.receivedResponse
296 ? "Timeout: LSP server did not respond. Try again."
297 : filtered.length ? filtered.map(formatDiagnostic).join("\n") : "No diagnostics.";
298 return { content: [{ type: "text", text: `action: diagnostics\n${sevLine}${payload}` }], details: { ...result, diagnostics: filtered } };
299 }
300 case "workspace-diagnostics": {
301 if (!files?.length) throw new Error('Action "workspace-diagnostics" requires a "files" array.');
302 const waitMs = Math.max(...files.map(diagnosticsWaitMsForFile));
303 const result = await abortable(manager.getDiagnosticsForFiles(files, waitMs), signal);
304 const out: string[] = [];
305 let errors = 0, warnings = 0, filesWithIssues = 0;
306
307 for (const item of result.items) {
308 const display = ctx?.cwd && path.isAbsolute(item.file) ? path.relative(ctx.cwd, item.file) : item.file;
309 if (item.status !== 'ok') { out.push(`${display}: ${item.error || item.status}`); continue; }
310 const filtered = filterDiagnosticsBySeverity(item.diagnostics, sevFilter);
311 if (filtered.length) {
312 filesWithIssues++;
313 out.push(`${display}:`);
314 for (const d of filtered) {
315 if (d.severity === 1) errors++; else if (d.severity === 2) warnings++;
316 out.push(` ${formatDiagnostic(d)}`);
317 }
318 }
319 }
320
321 const summary = `Analyzed ${result.items.length} file(s): ${errors} error(s), ${warnings} warning(s) in ${filesWithIssues} file(s)`;
322 return { content: [{ type: "text", text: `action: workspace-diagnostics\n${sevLine}${summary}\n\n${out.length ? out.join("\n") : "No diagnostics."}` }], details: result };
323 }
324 case "signature": {
325 const result = await abortable(manager.getSignatureHelp(file!, rLine!, rCol!), signal);
326 return { content: [{ type: "text", text: `action: signature\n${qLine}${posLine}${formatSignature(result)}` }], details: result ?? null };
327 }
328 case "rename": {
329 if (!newName) throw new Error('Action "rename" requires a "newName" parameter.');
330 const result = await abortable(manager.rename(file!, rLine!, rCol!, newName), signal);
331 if (!result) return { content: [{ type: "text", text: `action: rename\n${qLine}${posLine}No rename available at this position.` }], details: null };
332 const edits = formatWorkspaceEdit(result, ctx?.cwd);
333 return { content: [{ type: "text", text: `action: rename\n${qLine}${posLine}newName: ${newName}\n\n${edits}` }], details: result };
334 }
335 case "codeAction": {
336 const result = await abortable(manager.getCodeActions(file!, rLine!, rCol!, endLine, endColumn), signal);
337 const actions = formatCodeActions(result);
338 return { content: [{ type: "text", text: `action: codeAction\n${qLine}${posLine}${actions.length ? actions.join("\n") : "No code actions available."}` }], details: result };
339 }
340 }
341 } catch (e) {
342 if (signal?.aborted || isAbortedError(e)) return cancelledToolResult();
343 throw e;
344 }
345 },
346
347 renderCall(args, theme) {
348 const params = args as LspParamsType;
349 let text = theme.fg("toolTitle", theme.bold("lsp ")) + theme.fg("accent", params.action || "...");
350 if (params.file) text += " " + theme.fg("muted", params.file);
351 else if (params.files?.length) text += " " + theme.fg("muted", `${params.files.length} file(s)`);
352 if (params.query) text += " " + theme.fg("dim", `query="${params.query}"`);
353 else if (params.line !== undefined && params.column !== undefined) text += theme.fg("warning", `:${params.line}:${params.column}`);
354 if (params.severity && params.severity !== "all") text += " " + theme.fg("dim", `[${params.severity}]`);
355 return new Text(text, 0, 0);
356 },
357
358 renderResult(result, options, theme) {
359 if (options.isPartial) return new Text(theme.fg("warning", "Working..."), 0, 0);
360
361 const textContent = (result.content?.find((c: any) => c.type === "text") as any)?.text || "";
362 const lines = textContent.split("\n");
363
364 let headerEnd = 0;
365 for (let i = 0; i < lines.length; i++) {
366 if (/^(action|query|severity|resolvedPosition):/.test(lines[i])) headerEnd = i + 1;
367 else break;
368 }
369
370 const header = lines.slice(0, headerEnd);
371 const content = lines.slice(headerEnd);
372 const maxLines = options.expanded ? content.length : PREVIEW_LINES;
373 const display = content.slice(0, maxLines);
374 const remaining = content.length - maxLines;
375
376 let out = header.map((l: string) => theme.fg("muted", l)).join("\n");
377 if (display.length) {
378 if (out) out += "\n";
379 out += display.map((l: string) => theme.fg("toolOutput", l)).join("\n");
380 }
381 if (remaining > 0) out += theme.fg("dim", `\n... (${remaining} more lines)`);
382
383 return new Text(out, 0, 0);
384 },
385 });
386}