lsp tool
This commit is contained in:
@@ -0,0 +1 @@
|
|||||||
|
node_modules/
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,394 @@
|
|||||||
|
/**
|
||||||
|
* LSP Tool Extension
|
||||||
|
*
|
||||||
|
* Provides Language Server Protocol tool for:
|
||||||
|
* - definitions, references, hover, signature help
|
||||||
|
* - document symbols, diagnostics, workspace diagnostics
|
||||||
|
* - rename, code actions
|
||||||
|
*
|
||||||
|
* Supported languages:
|
||||||
|
* - Dart/Flutter (dart language-server)
|
||||||
|
* - TypeScript/JavaScript (tsgo --lsp or typescript-language-server)
|
||||||
|
* - Vue (vue-language-server)
|
||||||
|
* - Svelte (svelteserver)
|
||||||
|
* - Python (pyright-langserver)
|
||||||
|
* - Go (gopls)
|
||||||
|
* - Rust (rust-analyzer)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as path from "node:path";
|
||||||
|
import { Type, type Static } from "@sinclair/typebox";
|
||||||
|
import { StringEnum } from "@mariozechner/pi-ai";
|
||||||
|
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
||||||
|
import { Text } from "@mariozechner/pi-tui";
|
||||||
|
import type { SignatureHelp, WorkspaceEdit, CodeAction, Command } from "vscode-languageserver-protocol";
|
||||||
|
import { getOrCreateManager, shutdownManager, formatDiagnostic, filterDiagnosticsBySeverity, uriToPath, resolvePosition, type SeverityFilter } from "./lsp-core.js";
|
||||||
|
|
||||||
|
const PREVIEW_LINES = 10;
|
||||||
|
const DIAGNOSTICS_WAIT_MS_DEFAULT = 3000;
|
||||||
|
|
||||||
|
function diagnosticsWaitMsForFile(filePath: string): number {
|
||||||
|
const ext = path.extname(filePath).toLowerCase();
|
||||||
|
if (ext === ".rs") return 20000;
|
||||||
|
return DIAGNOSTICS_WAIT_MS_DEFAULT;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ACTIONS = ["definition", "references", "hover", "symbols", "diagnostics", "workspace-diagnostics", "signature", "rename", "codeAction", "restart"] as const;
|
||||||
|
const SEVERITY_FILTERS = ["all", "error", "warning", "info", "hint"] as const;
|
||||||
|
|
||||||
|
const LspParams = Type.Object({
|
||||||
|
action: StringEnum(ACTIONS),
|
||||||
|
file: Type.Optional(Type.String({ description: "File path (required for most actions)" })),
|
||||||
|
files: Type.Optional(Type.Array(Type.String(), { description: "File paths for workspace-diagnostics" })),
|
||||||
|
line: Type.Optional(Type.Number({ description: "Line (1-indexed). Required for position-based actions unless query provided." })),
|
||||||
|
column: Type.Optional(Type.Number({ description: "Column (1-indexed). Required for position-based actions unless query provided." })),
|
||||||
|
endLine: Type.Optional(Type.Number({ description: "End line for range-based actions (codeAction)" })),
|
||||||
|
endColumn: Type.Optional(Type.Number({ description: "End column for range-based actions (codeAction)" })),
|
||||||
|
query: Type.Optional(Type.String({ description: "Symbol name filter (for symbols) or to resolve position (for definition/references/hover/signature)" })),
|
||||||
|
newName: Type.Optional(Type.String({ description: "New name for rename action" })),
|
||||||
|
severity: Type.Optional(StringEnum(SEVERITY_FILTERS, { description: 'Filter diagnostics: "all"|"error"|"warning"|"info"|"hint"' })),
|
||||||
|
});
|
||||||
|
|
||||||
|
type LspParamsType = Static<typeof LspParams>;
|
||||||
|
|
||||||
|
function abortable<T>(promise: Promise<T>, signal?: AbortSignal): Promise<T> {
|
||||||
|
if (!signal) return promise;
|
||||||
|
if (signal.aborted) return Promise.reject(new Error("aborted"));
|
||||||
|
|
||||||
|
return new Promise<T>((resolve, reject) => {
|
||||||
|
const onAbort = () => {
|
||||||
|
cleanup();
|
||||||
|
reject(new Error("aborted"));
|
||||||
|
};
|
||||||
|
|
||||||
|
const cleanup = () => {
|
||||||
|
signal.removeEventListener("abort", onAbort);
|
||||||
|
};
|
||||||
|
|
||||||
|
signal.addEventListener("abort", onAbort, { once: true });
|
||||||
|
|
||||||
|
promise.then(
|
||||||
|
(value) => {
|
||||||
|
cleanup();
|
||||||
|
resolve(value);
|
||||||
|
},
|
||||||
|
(err) => {
|
||||||
|
cleanup();
|
||||||
|
reject(err);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAbortedError(e: unknown): boolean {
|
||||||
|
return e instanceof Error && e.message === "aborted";
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelledToolResult() {
|
||||||
|
return {
|
||||||
|
content: [{ type: "text" as const, text: "Cancelled" }],
|
||||||
|
details: { cancelled: true },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExecuteArgs = {
|
||||||
|
signal: AbortSignal | undefined;
|
||||||
|
onUpdate: ((update: { content: Array<{ type: "text"; text: string }>; details?: Record<string, unknown> }) => void) | undefined;
|
||||||
|
ctx: { cwd: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
function isAbortSignalLike(value: unknown): value is AbortSignal {
|
||||||
|
return !!value && typeof value === "object" && "aborted" in value && typeof (value as Record<string, unknown>).aborted === "boolean" && typeof (value as Record<string, unknown>).addEventListener === "function";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isContextLike(value: unknown): value is { cwd: string } {
|
||||||
|
return !!value && typeof value === "object" && typeof (value as { cwd: unknown }).cwd === "string";
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeExecuteArgs(onUpdateArg: unknown, ctxArg: unknown, signalArg: unknown): ExecuteArgs {
|
||||||
|
// Runtime >= 0.51: (signal, onUpdate, ctx)
|
||||||
|
if (isContextLike(signalArg)) {
|
||||||
|
return {
|
||||||
|
signal: isAbortSignalLike(onUpdateArg) ? onUpdateArg : undefined,
|
||||||
|
onUpdate: typeof ctxArg === "function" ? (ctxArg as ExecuteArgs["onUpdate"]) : undefined,
|
||||||
|
ctx: signalArg,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Runtime <= 0.50: (onUpdate, ctx, signal)
|
||||||
|
if (isContextLike(ctxArg)) {
|
||||||
|
return {
|
||||||
|
signal: isAbortSignalLike(signalArg) ? signalArg : undefined,
|
||||||
|
onUpdate: typeof onUpdateArg === "function" ? (onUpdateArg as ExecuteArgs["onUpdate"]) : undefined,
|
||||||
|
ctx: ctxArg,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error("Invalid tool execution context");
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatLocation(loc: { uri: string; range?: { start?: { line: number; character: number } } }, cwd?: string): string {
|
||||||
|
const abs = uriToPath(loc.uri);
|
||||||
|
const display = cwd && path.isAbsolute(abs) ? path.relative(cwd, abs) : abs;
|
||||||
|
const { line, character: col } = loc.range?.start ?? {};
|
||||||
|
return typeof line === "number" && typeof col === "number" ? `${display}:${line + 1}:${col + 1}` : display;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatHover(contents: unknown): string {
|
||||||
|
if (typeof contents === "string") return contents;
|
||||||
|
if (Array.isArray(contents))
|
||||||
|
return contents
|
||||||
|
.map((c) => (typeof c === "string" ? c : (c as { value?: string })?.value ?? ""))
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("\n\n");
|
||||||
|
if (contents && typeof contents === "object" && "value" in contents) return String((contents as { value: unknown }).value);
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSignature(help: SignatureHelp | null): string {
|
||||||
|
if (!help?.signatures?.length) return "No signature help available.";
|
||||||
|
const sig = help.signatures[help.activeSignature ?? 0] ?? help.signatures[0];
|
||||||
|
let text = sig.label ?? "Signature";
|
||||||
|
if (sig.documentation) text += `\n${typeof sig.documentation === "string" ? sig.documentation : sig.documentation?.value ?? ""}`;
|
||||||
|
if (sig.parameters?.length) {
|
||||||
|
const params = sig.parameters
|
||||||
|
.map((p) => (typeof p.label === "string" ? p.label : Array.isArray(p.label) ? String(p.label[0]) + "-" + String(p.label[1]) : ""))
|
||||||
|
.filter(Boolean);
|
||||||
|
if (params.length) text += `\nParameters: ${params.join(", ")}`;
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectSymbols(symbols: Array<{ name?: string; range?: { start?: { line: number; character: number } }; children?: unknown[] }>, depth = 0, lines: string[] = [], query?: string): string[] {
|
||||||
|
for (const sym of symbols) {
|
||||||
|
const name = sym?.name ?? "<unknown>";
|
||||||
|
if (query && !name.toLowerCase().includes(query.toLowerCase())) {
|
||||||
|
if (sym.children?.length) collectSymbols(sym.children as typeof symbols, depth + 1, lines, query);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const loc = sym?.range?.start ? `${sym.range.start.line + 1}:${sym.range.start.character + 1}` : "";
|
||||||
|
lines.push(`${" ".repeat(depth)}${name}${loc ? ` (${loc})` : ""}`);
|
||||||
|
if (sym.children?.length) collectSymbols(sym.children as typeof symbols, depth + 1, lines, query);
|
||||||
|
}
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatWorkspaceEdit(edit: WorkspaceEdit, cwd?: string): string {
|
||||||
|
const lines: string[] = [];
|
||||||
|
|
||||||
|
if (edit.documentChanges?.length) {
|
||||||
|
for (const change of edit.documentChanges) {
|
||||||
|
// TextDocumentEdit has textDocument, CreateFile/RenameFile/DeleteFile don't
|
||||||
|
if ("textDocument" in change && change.textDocument?.uri) {
|
||||||
|
const fp = uriToPath(change.textDocument.uri);
|
||||||
|
const display = cwd && path.isAbsolute(fp) ? path.relative(cwd, fp) : fp;
|
||||||
|
lines.push(`${display}:`);
|
||||||
|
for (const e of change.edits || []) {
|
||||||
|
if ("range" in e) {
|
||||||
|
const loc = `${e.range.start.line + 1}:${e.range.start.character + 1}`;
|
||||||
|
lines.push(` [${loc}] → "${e.newText}"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (edit.changes) {
|
||||||
|
for (const [uri, edits] of Object.entries(edit.changes)) {
|
||||||
|
const fp = uriToPath(uri);
|
||||||
|
const display = cwd && path.isAbsolute(fp) ? path.relative(cwd, fp) : fp;
|
||||||
|
lines.push(`${display}:`);
|
||||||
|
for (const e of edits) {
|
||||||
|
const loc = `${e.range.start.line + 1}:${e.range.start.character + 1}`;
|
||||||
|
lines.push(` [${loc}] → "${e.newText}"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.length ? lines.join("\n") : "No edits.";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCodeActions(actions: (CodeAction | Command)[]): string[] {
|
||||||
|
return actions.map((a, i) => {
|
||||||
|
// CodeAction has title directly; Command also has title
|
||||||
|
const title = a.title || "Untitled action";
|
||||||
|
// Only CodeAction has kind and isPreferred
|
||||||
|
const kind = "kind" in a && a.kind ? ` (${a.kind})` : "";
|
||||||
|
const isPreferred = "isPreferred" in a && a.isPreferred ? " ★" : "";
|
||||||
|
return `${i + 1}. ${title}${kind}${isPreferred}`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function (pi: ExtensionAPI) {
|
||||||
|
pi.registerTool({
|
||||||
|
name: "lsp",
|
||||||
|
label: "LSP",
|
||||||
|
description: `Query language server for definitions, references, types, symbols, diagnostics, rename, and code actions.
|
||||||
|
|
||||||
|
Actions: definition, references, hover, signature, rename (require file + line/column or query), symbols (file, optional query), diagnostics (file), workspace-diagnostics (files array), codeAction (file + position), restart (no args - restarts all LSP servers).
|
||||||
|
Use bash to find files: find src -name "*.ts" -type f`,
|
||||||
|
parameters: LspParams,
|
||||||
|
|
||||||
|
async execute(_toolCallId, params, onUpdateArg, ctxArg, signalArg) {
|
||||||
|
const { signal, ctx } = normalizeExecuteArgs(onUpdateArg, ctxArg, signalArg);
|
||||||
|
if (signal?.aborted) return cancelledToolResult();
|
||||||
|
const manager = getOrCreateManager(ctx.cwd);
|
||||||
|
const { action, file, files, line, column, endLine, endColumn, query, newName, severity } = params as LspParamsType;
|
||||||
|
const sevFilter: SeverityFilter = severity || "all";
|
||||||
|
const needsFile = action !== "workspace-diagnostics" && action !== "restart";
|
||||||
|
const needsPos = ["definition", "references", "hover", "signature", "rename", "codeAction"].includes(action);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (needsFile && !file) throw new Error(`Action "${action}" requires a file path.`);
|
||||||
|
|
||||||
|
let rLine = line,
|
||||||
|
rCol = column,
|
||||||
|
fromQuery = false;
|
||||||
|
if (needsPos && (rLine === undefined || rCol === undefined) && query && file) {
|
||||||
|
const resolved = await abortable(resolvePosition(manager, file, query), signal);
|
||||||
|
if (resolved) {
|
||||||
|
rLine = resolved.line;
|
||||||
|
rCol = resolved.column;
|
||||||
|
fromQuery = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (needsPos && (rLine === undefined || rCol === undefined)) {
|
||||||
|
throw new Error(`Action "${action}" requires line/column or a query matching a symbol.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const qLine = query ? `query: ${query}\n` : "";
|
||||||
|
const sevLine = sevFilter !== "all" ? `severity: ${sevFilter}\n` : "";
|
||||||
|
const posLine = fromQuery && rLine && rCol ? `resolvedPosition: ${rLine}:${rCol}\n` : "";
|
||||||
|
|
||||||
|
switch (action) {
|
||||||
|
case "definition": {
|
||||||
|
const results = await abortable(manager.getDefinition(file!, rLine!, rCol!), signal);
|
||||||
|
const locs = results.map((l) => formatLocation(l, ctx?.cwd));
|
||||||
|
const payload = locs.length ? locs.join("\n") : fromQuery ? `${file}:${rLine}:${rCol}` : "No definitions found.";
|
||||||
|
return { content: [{ type: "text", text: `action: definition\n${qLine}${posLine}${payload}` }], details: results };
|
||||||
|
}
|
||||||
|
case "references": {
|
||||||
|
const results = await abortable(manager.getReferences(file!, rLine!, rCol!), signal);
|
||||||
|
const locs = results.map((l) => formatLocation(l, ctx?.cwd));
|
||||||
|
return { content: [{ type: "text", text: `action: references\n${qLine}${posLine}${locs.length ? locs.join("\n") : "No references found."}` }], details: results };
|
||||||
|
}
|
||||||
|
case "hover": {
|
||||||
|
const result = await abortable(manager.getHover(file!, rLine!, rCol!), signal);
|
||||||
|
const payload = result ? formatHover(result.contents) || "No hover information." : "No hover information.";
|
||||||
|
return { content: [{ type: "text", text: `action: hover\n${qLine}${posLine}${payload}` }], details: result ?? null };
|
||||||
|
}
|
||||||
|
case "symbols": {
|
||||||
|
const symbols = await abortable(manager.getDocumentSymbols(file!), signal);
|
||||||
|
const lines = collectSymbols(symbols, 0, [], query);
|
||||||
|
const payload = lines.length ? lines.join("\n") : query ? `No symbols matching "${query}".` : "No symbols found.";
|
||||||
|
return { content: [{ type: "text", text: `action: symbols\n${qLine}${payload}` }], details: symbols };
|
||||||
|
}
|
||||||
|
case "diagnostics": {
|
||||||
|
const result = await abortable(manager.touchFileAndWait(file!, diagnosticsWaitMsForFile(file!)), signal);
|
||||||
|
const filtered = filterDiagnosticsBySeverity(result.diagnostics, sevFilter);
|
||||||
|
const payload = result.unsupported
|
||||||
|
? `Unsupported: ${result.error || "No LSP for this file."}`
|
||||||
|
: !result.receivedResponse
|
||||||
|
? "Timeout: LSP server did not respond. Try again."
|
||||||
|
: filtered.length
|
||||||
|
? filtered.map(formatDiagnostic).join("\n")
|
||||||
|
: "No diagnostics.";
|
||||||
|
return { content: [{ type: "text", text: `action: diagnostics\n${sevLine}${payload}` }], details: { ...result, diagnostics: filtered } };
|
||||||
|
}
|
||||||
|
case "workspace-diagnostics": {
|
||||||
|
if (!files?.length) throw new Error('Action "workspace-diagnostics" requires a "files" array.');
|
||||||
|
const waitMs = Math.max(...files.map(diagnosticsWaitMsForFile));
|
||||||
|
const result = await abortable(manager.getDiagnosticsForFiles(files, waitMs), signal);
|
||||||
|
const out: string[] = [];
|
||||||
|
let errors = 0,
|
||||||
|
warnings = 0,
|
||||||
|
filesWithIssues = 0;
|
||||||
|
|
||||||
|
for (const item of result.items) {
|
||||||
|
const display = ctx?.cwd && path.isAbsolute(item.file) ? path.relative(ctx.cwd, item.file) : item.file;
|
||||||
|
if (item.status !== "ok") {
|
||||||
|
out.push(`${display}: ${item.error || item.status}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const filtered = filterDiagnosticsBySeverity(item.diagnostics, sevFilter);
|
||||||
|
if (filtered.length) {
|
||||||
|
filesWithIssues++;
|
||||||
|
out.push(`${display}:`);
|
||||||
|
for (const d of filtered) {
|
||||||
|
if (d.severity === 1) errors++;
|
||||||
|
else if (d.severity === 2) warnings++;
|
||||||
|
out.push(` ${formatDiagnostic(d)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const summary = `Analyzed ${result.items.length} file(s): ${errors} error(s), ${warnings} warning(s) in ${filesWithIssues} file(s)`;
|
||||||
|
return { content: [{ type: "text", text: `action: workspace-diagnostics\n${sevLine}${summary}\n\n${out.length ? out.join("\n") : "No diagnostics."}` }], details: result };
|
||||||
|
}
|
||||||
|
case "signature": {
|
||||||
|
const result = await abortable(manager.getSignatureHelp(file!, rLine!, rCol!), signal);
|
||||||
|
return { content: [{ type: "text", text: `action: signature\n${qLine}${posLine}${formatSignature(result)}` }], details: result ?? null };
|
||||||
|
}
|
||||||
|
case "rename": {
|
||||||
|
if (!newName) throw new Error('Action "rename" requires a "newName" parameter.');
|
||||||
|
const result = await abortable(manager.rename(file!, rLine!, rCol!, newName), signal);
|
||||||
|
if (!result) return { content: [{ type: "text", text: `action: rename\n${qLine}${posLine}No rename available at this position.` }], details: null };
|
||||||
|
const edits = formatWorkspaceEdit(result, ctx?.cwd);
|
||||||
|
return { content: [{ type: "text", text: `action: rename\n${qLine}${posLine}newName: ${newName}\n\n${edits}` }], details: result };
|
||||||
|
}
|
||||||
|
case "codeAction": {
|
||||||
|
const result = await abortable(manager.getCodeActions(file!, rLine!, rCol!, endLine, endColumn), signal);
|
||||||
|
const actions = formatCodeActions(result);
|
||||||
|
return { content: [{ type: "text", text: `action: codeAction\n${qLine}${posLine}${actions.length ? actions.join("\n") : "No code actions available."}` }], details: result };
|
||||||
|
}
|
||||||
|
case "restart": {
|
||||||
|
await shutdownManager();
|
||||||
|
return { content: [{ type: "text", text: "action: restart\nLSP servers restarted. Next query will start fresh servers." }], details: { restarted: true } };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (signal?.aborted || isAbortedError(e)) return cancelledToolResult();
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
renderCall(args, theme) {
|
||||||
|
const params = args as LspParamsType;
|
||||||
|
let text = theme.fg("toolTitle", theme.bold("lsp ")) + theme.fg("accent", params.action || "...");
|
||||||
|
if (params.file) text += " " + theme.fg("muted", params.file);
|
||||||
|
else if (params.files?.length) text += " " + theme.fg("muted", `${params.files.length} file(s)`);
|
||||||
|
if (params.query) text += " " + theme.fg("dim", `query="${params.query}"`);
|
||||||
|
else if (params.line !== undefined && params.column !== undefined) text += theme.fg("warning", `:${params.line}:${params.column}`);
|
||||||
|
if (params.severity && params.severity !== "all") text += " " + theme.fg("dim", `[${params.severity}]`);
|
||||||
|
return new Text(text, 0, 0);
|
||||||
|
},
|
||||||
|
|
||||||
|
renderResult(result, options, theme) {
|
||||||
|
if (options.isPartial) return new Text("", 0, 0);
|
||||||
|
|
||||||
|
const textContent = (result.content?.find((c: { type: string }) => c.type === "text") as { text?: string })?.text || "";
|
||||||
|
const lines = textContent.split("\n");
|
||||||
|
|
||||||
|
let headerEnd = 0;
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
if (/^(action|query|severity|resolvedPosition):/.test(lines[i])) headerEnd = i + 1;
|
||||||
|
else break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const header = lines.slice(0, headerEnd);
|
||||||
|
const content = lines.slice(headerEnd);
|
||||||
|
const maxLines = options.expanded ? content.length : PREVIEW_LINES;
|
||||||
|
const display = content.slice(0, maxLines);
|
||||||
|
const remaining = content.length - maxLines;
|
||||||
|
|
||||||
|
let out = header.map((l: string) => theme.fg("muted", l)).join("\n");
|
||||||
|
if (display.length) {
|
||||||
|
if (out) out += "\n";
|
||||||
|
out += display.map((l: string) => theme.fg("toolOutput", l)).join("\n");
|
||||||
|
}
|
||||||
|
if (remaining > 0) out += theme.fg("dim", `\n... (${remaining} more lines)`);
|
||||||
|
|
||||||
|
return new Text(out, 0, 0);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,586 @@
|
|||||||
|
/**
|
||||||
|
* LSP Hook Extension
|
||||||
|
*
|
||||||
|
* Provides automatic diagnostics feedback (default: agent end).
|
||||||
|
* Can run after each write/edit or once per agent response.
|
||||||
|
*
|
||||||
|
* Usage: /lsp to configure hook mode
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as path from "node:path";
|
||||||
|
import * as fs from "node:fs";
|
||||||
|
import * as os from "node:os";
|
||||||
|
import { type ExtensionAPI, type ExtensionContext } from "@mariozechner/pi-coding-agent";
|
||||||
|
import { Text } from "@mariozechner/pi-tui";
|
||||||
|
import { type Diagnostic } from "vscode-languageserver-protocol";
|
||||||
|
import { LSP_SERVERS, formatDiagnostic, getOrCreateManager, shutdownManager } from "./lsp-core.js";
|
||||||
|
|
||||||
|
type HookScope = "session" | "global";
|
||||||
|
type HookMode = "edit_write" | "agent_end" | "disabled";
|
||||||
|
|
||||||
|
const DIAGNOSTICS_WAIT_MS_DEFAULT = 3000;
|
||||||
|
|
||||||
|
function diagnosticsWaitMsForFile(filePath: string): number {
|
||||||
|
const ext = path.extname(filePath).toLowerCase();
|
||||||
|
if (ext === ".rs") return 20000;
|
||||||
|
return DIAGNOSTICS_WAIT_MS_DEFAULT;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DIAGNOSTICS_PREVIEW_LINES = 10;
|
||||||
|
const LSP_IDLE_SHUTDOWN_MS = 2 * 60 * 1000;
|
||||||
|
const DIM = "\x1b[2m",
|
||||||
|
GREEN = "\x1b[32m",
|
||||||
|
YELLOW = "\x1b[33m",
|
||||||
|
RESET = "\x1b[0m";
|
||||||
|
const DEFAULT_HOOK_MODE: HookMode = "agent_end";
|
||||||
|
const SETTINGS_NAMESPACE = "lsp";
|
||||||
|
const LSP_CONFIG_ENTRY = "lsp-hook-config";
|
||||||
|
|
||||||
|
const WARMUP_MAP: Record<string, string> = {
|
||||||
|
"pubspec.yaml": ".dart",
|
||||||
|
"package.json": ".ts",
|
||||||
|
"pyproject.toml": ".py",
|
||||||
|
"go.mod": ".go",
|
||||||
|
"Cargo.toml": ".rs",
|
||||||
|
};
|
||||||
|
|
||||||
|
const MODE_LABELS: Record<HookMode, string> = {
|
||||||
|
edit_write: "After each edit/write",
|
||||||
|
agent_end: "At agent end",
|
||||||
|
disabled: "Disabled",
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeHookMode(value: unknown): HookMode | undefined {
|
||||||
|
if (value === "edit_write" || value === "agent_end" || value === "disabled") return value;
|
||||||
|
if (value === "turn_end") return "agent_end";
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HookConfigEntry {
|
||||||
|
scope: HookScope;
|
||||||
|
hookMode?: HookMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function (pi: ExtensionAPI) {
|
||||||
|
type LspActivity = "idle" | "loading" | "working";
|
||||||
|
|
||||||
|
let activeClients: Set<string> = new Set();
|
||||||
|
let statusUpdateFn: ((key: string, text: string | undefined) => void) | null = null;
|
||||||
|
let hookMode: HookMode = DEFAULT_HOOK_MODE;
|
||||||
|
let hookScope: HookScope = "global";
|
||||||
|
let activity: LspActivity = "idle";
|
||||||
|
let diagnosticsAbort: AbortController | null = null;
|
||||||
|
let shuttingDown = false;
|
||||||
|
let idleShutdownTimer: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
|
const touchedFiles: Map<string, boolean> = new Map();
|
||||||
|
const globalSettingsPath = path.join(os.homedir(), ".pi", "agent", "settings.json");
|
||||||
|
|
||||||
|
function readSettingsFile(filePath: string): Record<string, unknown> {
|
||||||
|
try {
|
||||||
|
if (!fs.existsSync(filePath)) return {};
|
||||||
|
const raw = fs.readFileSync(filePath, "utf-8");
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
return parsed && typeof parsed === "object" ? (parsed as Record<string, unknown>) : {};
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getGlobalHookMode(): HookMode | undefined {
|
||||||
|
const settings = readSettingsFile(globalSettingsPath);
|
||||||
|
const lspSettings = settings[SETTINGS_NAMESPACE];
|
||||||
|
const hookValue = (lspSettings as { hookMode?: unknown; hookEnabled?: unknown } | undefined)?.hookMode;
|
||||||
|
const normalized = normalizeHookMode(hookValue);
|
||||||
|
if (normalized) return normalized;
|
||||||
|
|
||||||
|
const legacyEnabled = (lspSettings as { hookEnabled?: unknown } | undefined)?.hookEnabled;
|
||||||
|
if (typeof legacyEnabled === "boolean") return legacyEnabled ? "edit_write" : "disabled";
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setGlobalHookMode(mode: HookMode): boolean {
|
||||||
|
try {
|
||||||
|
const settings = readSettingsFile(globalSettingsPath);
|
||||||
|
const existing = settings[SETTINGS_NAMESPACE];
|
||||||
|
const nextNamespace =
|
||||||
|
existing && typeof existing === "object" ? { ...(existing as Record<string, unknown>), hookMode: mode } : { hookMode: mode };
|
||||||
|
|
||||||
|
settings[SETTINGS_NAMESPACE] = nextNamespace;
|
||||||
|
fs.mkdirSync(path.dirname(globalSettingsPath), { recursive: true });
|
||||||
|
fs.writeFileSync(globalSettingsPath, JSON.stringify(settings, null, 2), "utf-8");
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLastHookEntry(ctx: ExtensionContext): HookConfigEntry | undefined {
|
||||||
|
const branchEntries = ctx.sessionManager.getBranch();
|
||||||
|
let latest: HookConfigEntry | undefined;
|
||||||
|
|
||||||
|
for (const entry of branchEntries) {
|
||||||
|
if (entry.type === "custom" && entry.customType === LSP_CONFIG_ENTRY) {
|
||||||
|
latest = entry.data as HookConfigEntry | undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return latest;
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreHookState(ctx: ExtensionContext): void {
|
||||||
|
const entry = getLastHookEntry(ctx);
|
||||||
|
if (entry?.scope === "session") {
|
||||||
|
const normalized = normalizeHookMode(entry.hookMode);
|
||||||
|
if (normalized) {
|
||||||
|
hookMode = normalized;
|
||||||
|
hookScope = "session";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const legacyEnabled = (entry as { hookEnabled?: unknown }).hookEnabled;
|
||||||
|
if (typeof legacyEnabled === "boolean") {
|
||||||
|
hookMode = legacyEnabled ? "edit_write" : "disabled";
|
||||||
|
hookScope = "session";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const globalSetting = getGlobalHookMode();
|
||||||
|
hookMode = globalSetting ?? DEFAULT_HOOK_MODE;
|
||||||
|
hookScope = "global";
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistHookEntry(entry: HookConfigEntry): void {
|
||||||
|
pi.appendEntry<HookConfigEntry>(LSP_CONFIG_ENTRY, entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
function labelForMode(mode: HookMode): string {
|
||||||
|
return MODE_LABELS[mode];
|
||||||
|
}
|
||||||
|
|
||||||
|
function messageContentToText(content: unknown): string {
|
||||||
|
if (typeof content === "string") return content;
|
||||||
|
if (Array.isArray(content)) {
|
||||||
|
return content
|
||||||
|
.map((item) =>
|
||||||
|
item && typeof item === "object" && "type" in item && (item as { type: string }).type === "text"
|
||||||
|
? String((item as { text?: string }).text ?? "")
|
||||||
|
: ""
|
||||||
|
)
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("\n");
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDiagnosticsForDisplay(text: string): string {
|
||||||
|
return text
|
||||||
|
.replace(/\n?This file has errors, please fix\n/gi, "\n")
|
||||||
|
.replace(/<\/?file_diagnostics>\n?/gi, "")
|
||||||
|
.replace(/\n{3,}/g, "\n\n")
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setActivity(next: LspActivity): void {
|
||||||
|
activity = next;
|
||||||
|
updateLspStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearIdleShutdownTimer(): void {
|
||||||
|
if (!idleShutdownTimer) return;
|
||||||
|
clearTimeout(idleShutdownTimer);
|
||||||
|
idleShutdownTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function shutdownLspServersForIdle(): Promise<void> {
|
||||||
|
diagnosticsAbort?.abort();
|
||||||
|
diagnosticsAbort = null;
|
||||||
|
setActivity("idle");
|
||||||
|
|
||||||
|
await shutdownManager();
|
||||||
|
activeClients.clear();
|
||||||
|
updateLspStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleIdleShutdown(): void {
|
||||||
|
clearIdleShutdownTimer();
|
||||||
|
|
||||||
|
idleShutdownTimer = setTimeout(() => {
|
||||||
|
idleShutdownTimer = null;
|
||||||
|
if (shuttingDown) return;
|
||||||
|
void shutdownLspServersForIdle();
|
||||||
|
}, LSP_IDLE_SHUTDOWN_MS);
|
||||||
|
|
||||||
|
(idleShutdownTimer as NodeJS.Timeout & { unref?: () => void }).unref?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateLspStatus(): void {
|
||||||
|
if (!statusUpdateFn) return;
|
||||||
|
|
||||||
|
const clients = activeClients.size > 0 ? [...activeClients].join(", ") : "";
|
||||||
|
const clientsText = clients ? `${DIM}${clients}${RESET}` : "";
|
||||||
|
const activityHint = activity === "idle" ? "" : `${DIM}•${RESET}`;
|
||||||
|
|
||||||
|
if (hookMode === "disabled") {
|
||||||
|
const text = clientsText ? `${YELLOW}LSP${RESET} ${DIM}(tool)${RESET}: ${clientsText}` : `${YELLOW}LSP${RESET} ${DIM}(tool)${RESET}`;
|
||||||
|
statusUpdateFn("lsp", text);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let text = `${GREEN}LSP${RESET}`;
|
||||||
|
if (activityHint) text += ` ${activityHint}`;
|
||||||
|
if (clientsText) text += ` ${clientsText}`;
|
||||||
|
statusUpdateFn("lsp", text);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeFilePath(filePath: string, cwd: string): string {
|
||||||
|
return path.isAbsolute(filePath) ? filePath : path.resolve(cwd, filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
pi.registerMessageRenderer("lsp-diagnostics", (message, options, theme) => {
|
||||||
|
const content = formatDiagnosticsForDisplay(messageContentToText(message.content));
|
||||||
|
if (!content) return new Text("", 0, 0);
|
||||||
|
|
||||||
|
const expanded = options.expanded === true;
|
||||||
|
const lines = content.split("\n");
|
||||||
|
const maxLines = expanded ? lines.length : DIAGNOSTICS_PREVIEW_LINES;
|
||||||
|
const display = lines.slice(0, maxLines);
|
||||||
|
const remaining = lines.length - display.length;
|
||||||
|
|
||||||
|
const styledLines = display.map((line) => {
|
||||||
|
if (line.startsWith("File: ")) return theme.fg("muted", line);
|
||||||
|
return theme.fg("toolOutput", line);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!expanded && remaining > 0) {
|
||||||
|
styledLines.push(theme.fg("dim", `... (${remaining} more lines)`));
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Text(styledLines.join("\n"), 0, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
function getServerConfig(filePath: string) {
|
||||||
|
const ext = path.extname(filePath);
|
||||||
|
return LSP_SERVERS.find((s) => s.extensions.includes(ext));
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureActiveClientForFile(filePath: string, cwd: string): string | undefined {
|
||||||
|
const absPath = normalizeFilePath(filePath, cwd);
|
||||||
|
const cfg = getServerConfig(absPath);
|
||||||
|
if (!cfg) return undefined;
|
||||||
|
|
||||||
|
if (!activeClients.has(cfg.id)) {
|
||||||
|
activeClients.add(cfg.id);
|
||||||
|
updateLspStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
return absPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractLspFiles(input: Record<string, unknown>): string[] {
|
||||||
|
const files: string[] = [];
|
||||||
|
|
||||||
|
if (typeof input.file === "string") files.push(input.file);
|
||||||
|
if (Array.isArray(input.files)) {
|
||||||
|
for (const item of input.files) {
|
||||||
|
if (typeof item === "string") files.push(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDiagnosticsOutput(
|
||||||
|
filePath: string,
|
||||||
|
diagnostics: Diagnostic[],
|
||||||
|
cwd: string,
|
||||||
|
includeFileHeader: boolean
|
||||||
|
): { notification: string; errorCount: number; output: string } {
|
||||||
|
const absPath = path.isAbsolute(filePath) ? filePath : path.resolve(cwd, filePath);
|
||||||
|
const relativePath = path.relative(cwd, absPath);
|
||||||
|
const errorCount = diagnostics.filter((e) => e.severity === 1).length;
|
||||||
|
|
||||||
|
const MAX = 5;
|
||||||
|
const lines = diagnostics.slice(0, MAX).map((e) => {
|
||||||
|
const sev = e.severity === 1 ? "ERROR" : "WARN";
|
||||||
|
return `${sev}[${e.range.start.line + 1}] ${e.message.split("\n")[0]}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
let notification = `📋 ${relativePath}\n${lines.join("\n")}`;
|
||||||
|
if (diagnostics.length > MAX) notification += `\n... +${diagnostics.length - MAX} more`;
|
||||||
|
|
||||||
|
const header = includeFileHeader ? `File: ${relativePath}\n` : "";
|
||||||
|
const output = `\n${header}This file has errors, please fix\n<file_diagnostics>\n${diagnostics.map(formatDiagnostic).join("\n")}\n</file_diagnostics>\n`;
|
||||||
|
|
||||||
|
return { notification, errorCount, output };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function collectDiagnostics(
|
||||||
|
filePath: string,
|
||||||
|
ctx: ExtensionContext,
|
||||||
|
includeWarnings: boolean,
|
||||||
|
includeFileHeader: boolean,
|
||||||
|
notify = true
|
||||||
|
): Promise<string | undefined> {
|
||||||
|
const manager = getOrCreateManager(ctx.cwd);
|
||||||
|
const absPath = ensureActiveClientForFile(filePath, ctx.cwd);
|
||||||
|
if (!absPath) return undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await manager.touchFileAndWait(absPath, diagnosticsWaitMsForFile(absPath));
|
||||||
|
if (!result.receivedResponse) return undefined;
|
||||||
|
|
||||||
|
const diagnostics = includeWarnings ? result.diagnostics : result.diagnostics.filter((d) => d.severity === 1);
|
||||||
|
if (!diagnostics.length) return undefined;
|
||||||
|
|
||||||
|
const report = buildDiagnosticsOutput(filePath, diagnostics, ctx.cwd, includeFileHeader);
|
||||||
|
|
||||||
|
if (notify) {
|
||||||
|
if (ctx.hasUI) ctx.ui.notify(report.notification, report.errorCount > 0 ? "error" : "warning");
|
||||||
|
else console.error(report.notification);
|
||||||
|
}
|
||||||
|
|
||||||
|
return report.output;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pi.registerCommand("lsp-restart", {
|
||||||
|
description: "Restart all LSP servers",
|
||||||
|
handler: async (_args, ctx) => {
|
||||||
|
await shutdownManager();
|
||||||
|
activeClients.clear();
|
||||||
|
touchedFiles.clear();
|
||||||
|
updateLspStatus();
|
||||||
|
ctx.ui.notify("LSP servers restarted", "info");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.registerCommand("lsp", {
|
||||||
|
description: "LSP settings (auto diagnostics hook)",
|
||||||
|
handler: async (_args, ctx) => {
|
||||||
|
if (!ctx.hasUI) {
|
||||||
|
ctx.ui.notify("LSP settings require UI", "warning");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentMark = " ✓";
|
||||||
|
const modeOptions = (["edit_write", "agent_end", "disabled"] as HookMode[]).map((mode) => ({
|
||||||
|
mode,
|
||||||
|
label: mode === hookMode ? `${labelForMode(mode)}${currentMark}` : labelForMode(mode),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const modeChoice = await ctx.ui.select(
|
||||||
|
"LSP auto diagnostics hook mode:",
|
||||||
|
modeOptions.map((option) => option.label)
|
||||||
|
);
|
||||||
|
if (!modeChoice) return;
|
||||||
|
|
||||||
|
const nextMode = modeOptions.find((option) => option.label === modeChoice)?.mode;
|
||||||
|
if (!nextMode) return;
|
||||||
|
|
||||||
|
const scopeOptions = [
|
||||||
|
{ scope: "session" as HookScope, label: "Session only" },
|
||||||
|
{ scope: "global" as HookScope, label: "Global (all sessions)" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const scopeChoice = await ctx.ui.select(
|
||||||
|
"Apply LSP auto diagnostics hook setting to:",
|
||||||
|
scopeOptions.map((option) => option.label)
|
||||||
|
);
|
||||||
|
if (!scopeChoice) return;
|
||||||
|
|
||||||
|
const scope = scopeOptions.find((option) => option.label === scopeChoice)?.scope;
|
||||||
|
if (!scope) return;
|
||||||
|
if (scope === "global") {
|
||||||
|
const ok = setGlobalHookMode(nextMode);
|
||||||
|
if (!ok) {
|
||||||
|
ctx.ui.notify("Failed to update global settings", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
hookMode = nextMode;
|
||||||
|
hookScope = scope;
|
||||||
|
touchedFiles.clear();
|
||||||
|
persistHookEntry({ scope, hookMode: nextMode });
|
||||||
|
updateLspStatus();
|
||||||
|
ctx.ui.notify(`LSP hook: ${labelForMode(hookMode)} (${hookScope})`, "info");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.on("session_start", async (_event, ctx) => {
|
||||||
|
restoreHookState(ctx);
|
||||||
|
statusUpdateFn = ctx.hasUI && ctx.ui.setStatus ? ctx.ui.setStatus.bind(ctx.ui) : null;
|
||||||
|
updateLspStatus();
|
||||||
|
|
||||||
|
if (hookMode === "disabled") return;
|
||||||
|
|
||||||
|
const manager = getOrCreateManager(ctx.cwd);
|
||||||
|
|
||||||
|
for (const [marker, ext] of Object.entries(WARMUP_MAP)) {
|
||||||
|
if (fs.existsSync(path.join(ctx.cwd, marker))) {
|
||||||
|
setActivity("loading");
|
||||||
|
manager
|
||||||
|
.getClientsForFile(path.join(ctx.cwd, `dummy${ext}`))
|
||||||
|
.then((clients) => {
|
||||||
|
if (clients.length > 0) {
|
||||||
|
const cfg = LSP_SERVERS.find((s) => s.extensions.includes(ext));
|
||||||
|
if (cfg) activeClients.add(cfg.id);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => setActivity("idle"));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.on("session_switch", async (_event, ctx) => {
|
||||||
|
restoreHookState(ctx);
|
||||||
|
updateLspStatus();
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.on("session_tree", async (_event, ctx) => {
|
||||||
|
restoreHookState(ctx);
|
||||||
|
updateLspStatus();
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.on("session_fork", async (_event, ctx) => {
|
||||||
|
restoreHookState(ctx);
|
||||||
|
updateLspStatus();
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.on("session_shutdown", async () => {
|
||||||
|
shuttingDown = true;
|
||||||
|
clearIdleShutdownTimer();
|
||||||
|
diagnosticsAbort?.abort();
|
||||||
|
diagnosticsAbort = null;
|
||||||
|
setActivity("idle");
|
||||||
|
|
||||||
|
await shutdownManager();
|
||||||
|
activeClients.clear();
|
||||||
|
statusUpdateFn?.("lsp", undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.on("tool_call", async (event, ctx) => {
|
||||||
|
const input = event.input && typeof event.input === "object" ? (event.input as Record<string, unknown>) : {};
|
||||||
|
|
||||||
|
if (event.toolName === "lsp") {
|
||||||
|
clearIdleShutdownTimer();
|
||||||
|
const files = extractLspFiles(input);
|
||||||
|
for (const file of files) {
|
||||||
|
ensureActiveClientForFile(file, ctx.cwd);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.toolName !== "read" && event.toolName !== "write" && event.toolName !== "edit") return;
|
||||||
|
|
||||||
|
clearIdleShutdownTimer();
|
||||||
|
const filePath = typeof input.path === "string" ? input.path : undefined;
|
||||||
|
if (!filePath) return;
|
||||||
|
|
||||||
|
const absPath = ensureActiveClientForFile(filePath, ctx.cwd);
|
||||||
|
if (!absPath) return;
|
||||||
|
|
||||||
|
void getOrCreateManager(ctx.cwd).getClientsForFile(absPath).catch(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.on("agent_start", async () => {
|
||||||
|
clearIdleShutdownTimer();
|
||||||
|
diagnosticsAbort?.abort();
|
||||||
|
diagnosticsAbort = null;
|
||||||
|
setActivity("idle");
|
||||||
|
touchedFiles.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
function agentWasAborted(event: { messages?: Array<{ role?: string; stopReason?: string }> }): boolean {
|
||||||
|
const messages = Array.isArray(event?.messages) ? event.messages : [];
|
||||||
|
return messages.some((m) => m && typeof m === "object" && m.role === "assistant" && (m.stopReason === "aborted" || m.stopReason === "error"));
|
||||||
|
}
|
||||||
|
|
||||||
|
pi.on("agent_end", async (event, ctx) => {
|
||||||
|
try {
|
||||||
|
if (hookMode !== "agent_end") return;
|
||||||
|
|
||||||
|
if (agentWasAborted(event)) {
|
||||||
|
touchedFiles.clear();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (touchedFiles.size === 0) return;
|
||||||
|
if (!ctx.isIdle() || ctx.hasPendingMessages()) return;
|
||||||
|
|
||||||
|
const abort = new AbortController();
|
||||||
|
diagnosticsAbort?.abort();
|
||||||
|
diagnosticsAbort = abort;
|
||||||
|
|
||||||
|
const files = Array.from(touchedFiles.entries());
|
||||||
|
touchedFiles.clear();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const outputs: string[] = [];
|
||||||
|
for (const [filePath, includeWarnings] of files) {
|
||||||
|
if (shuttingDown || abort.signal.aborted) return;
|
||||||
|
if (!ctx.isIdle() || ctx.hasPendingMessages()) {
|
||||||
|
abort.abort();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const output = await collectDiagnostics(filePath, ctx, includeWarnings, true, false);
|
||||||
|
if (abort.signal.aborted) return;
|
||||||
|
if (output) outputs.push(output);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shuttingDown || abort.signal.aborted) return;
|
||||||
|
|
||||||
|
if (outputs.length) {
|
||||||
|
pi.sendMessage(
|
||||||
|
{
|
||||||
|
customType: "lsp-diagnostics",
|
||||||
|
content: outputs.join("\n"),
|
||||||
|
display: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
triggerTurn: true,
|
||||||
|
deliverAs: "followUp",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (diagnosticsAbort === abort) diagnosticsAbort = null;
|
||||||
|
if (!shuttingDown) setActivity("idle");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!shuttingDown) scheduleIdleShutdown();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.on("tool_result", async (event, ctx) => {
|
||||||
|
if (event.toolName !== "write" && event.toolName !== "edit") return;
|
||||||
|
|
||||||
|
const filePath = event.input.path as string;
|
||||||
|
if (!filePath) return;
|
||||||
|
|
||||||
|
const absPath = ensureActiveClientForFile(filePath, ctx.cwd);
|
||||||
|
if (!absPath) return;
|
||||||
|
|
||||||
|
if (hookMode === "disabled") return;
|
||||||
|
|
||||||
|
if (hookMode === "agent_end") {
|
||||||
|
const includeWarnings = event.toolName === "write";
|
||||||
|
const existing = touchedFiles.get(absPath) ?? false;
|
||||||
|
touchedFiles.set(absPath, existing || includeWarnings);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const includeWarnings = event.toolName === "write";
|
||||||
|
const output = await collectDiagnostics(absPath, ctx, includeWarnings, false);
|
||||||
|
if (!output) return;
|
||||||
|
|
||||||
|
return { content: [...event.content, { type: "text" as const, text: output }] as Array<{ type: "text"; text: string }> };
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"name": "lsp",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"pi": {
|
||||||
|
"extensions": [
|
||||||
|
"./lsp.ts",
|
||||||
|
"./lsp-tool.ts"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@sinclair/typebox": "^0.34.33",
|
||||||
|
"vscode-languageserver-protocol": "^3.17.5"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@mariozechner/pi-ai": "^0.50.0",
|
||||||
|
"@mariozechner/pi-coding-agent": "^0.50.0",
|
||||||
|
"@mariozechner/pi-tui": "^0.50.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^25.3.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
+2499
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ESNext",
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"declaration": true,
|
||||||
|
"types": ["node"]
|
||||||
|
},
|
||||||
|
"include": ["*.ts"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user