From fd29c02c81ddaefe2df6b5200e67d8da5c14bbc8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?T=C3=82CHES?= Date: Mon, 16 Mar 2026 09:22:52 -0600 Subject: [PATCH] feat(lsp): activate LSP by default, add call hierarchy/format/signature, sync edits (#639) LSP was never activated in interactive sessions because the default active tools list hardcoded only read/bash/edit/write. This adds lsp to that list and ships four new capabilities alongside edit sync and stronger prompt guidance. - Add "lsp" to default active tools in agent-session.ts - New actions: incoming_calls, outgoing_calls, format, signature - Wire edit/write tools to notify LSP clients on file changes - Strengthen system prompt and GSD prompt with full LSP operation catalog Co-authored-by: Claude Opus 4.6 (1M context) --- .../pi-coding-agent/src/core/agent-session.ts | 2 +- .../pi-coding-agent/src/core/lsp/client.ts | 26 +++ .../pi-coding-agent/src/core/lsp/index.ts | 159 +++++++++++++++++- packages/pi-coding-agent/src/core/lsp/lsp.md | 6 + .../pi-coding-agent/src/core/lsp/types.ts | 53 ++++++ .../pi-coding-agent/src/core/lsp/utils.ts | 56 ++++++ .../pi-coding-agent/src/core/system-prompt.ts | 8 +- .../pi-coding-agent/src/core/tools/edit.ts | 3 + .../pi-coding-agent/src/core/tools/write.ts | 3 + .../extensions/gsd/prompts/system.md | 2 +- 10 files changed, 313 insertions(+), 5 deletions(-) diff --git a/packages/pi-coding-agent/src/core/agent-session.ts b/packages/pi-coding-agent/src/core/agent-session.ts index 2e8fac03a..3d1351ddf 100644 --- a/packages/pi-coding-agent/src/core/agent-session.ts +++ b/packages/pi-coding-agent/src/core/agent-session.ts @@ -2331,7 +2331,7 @@ export class AgentSession { const defaultActiveToolNames = this._baseToolsOverride ? Object.keys(this._baseToolsOverride) - : ["read", "bash", "edit", "write"]; + : ["read", "bash", "edit", "write", "lsp"]; const baseActiveToolNames = options.activeToolNames ?? defaultActiveToolNames; this._refreshToolRegistry({ activeToolNames: baseActiveToolNames, diff --git a/packages/pi-coding-agent/src/core/lsp/client.ts b/packages/pi-coding-agent/src/core/lsp/client.ts index 6f04593d5..7431a2014 100644 --- a/packages/pi-coding-agent/src/core/lsp/client.ts +++ b/packages/pi-coding-agent/src/core/lsp/client.ts @@ -124,6 +124,18 @@ const CLIENT_CAPABILITIES = { properties: ["edit"], }, }, + callHierarchy: { + dynamicRegistration: false, + }, + signatureHelp: { + dynamicRegistration: false, + signatureInformation: { + documentationFormat: ["markdown", "plaintext"], + parameterInformation: { + labelOffsetSupport: true, + }, + }, + }, formatting: { dynamicRegistration: false, }, @@ -701,6 +713,20 @@ export async function refreshFile(client: LspClient, filePath: string, signal?: } } +/** + * Notify all LSP clients that have the file open that it changed on disk. + * Synchronous entry point — async refresh runs in background. + * Swallows errors so editing never fails because of LSP. + */ +export function notifyFileChanged(filePath: string): void { + const uri = fileToUri(filePath); + for (const client of clients.values()) { + if (client.openFiles.has(uri)) { + refreshFile(client, filePath).catch(() => {}); + } + } +} + /** * Shutdown a specific client by key. */ diff --git a/packages/pi-coding-agent/src/core/lsp/index.ts b/packages/pi-coding-agent/src/core/lsp/index.ts index 06c6c785a..05f6f6934 100644 --- a/packages/pi-coding-agent/src/core/lsp/index.ts +++ b/packages/pi-coding-agent/src/core/lsp/index.ts @@ -15,10 +15,13 @@ import { WARMUP_TIMEOUT_MS, } from "./client.js"; import { getServersForFile, type LspConfig, loadConfig } from "./config.js"; -import { applyWorkspaceEdit } from "./edits.js"; +import { applyTextEdits, applyWorkspaceEdit } from "./edits.js"; import { ToolAbortError, clampTimeout, throwIfAborted } from "./helpers.js"; import { detectLspmux } from "./lspmux.js"; import { + type CallHierarchyIncomingCall, + type CallHierarchyItem, + type CallHierarchyOutgoingCall, type CodeAction, type CodeActionContext, type Command, @@ -32,7 +35,9 @@ import { type LspToolDetails, lspSchema, type ServerConfig, + type SignatureHelp, type SymbolInformation, + type TextEdit, type WorkspaceEdit, } from "./types.js"; import { @@ -42,12 +47,14 @@ import { extractHoverText, fileToUri, filterWorkspaceSymbols, + formatCallHierarchyItem, formatCodeAction, formatDiagnostic, formatDiagnosticsSummary, formatDocumentSymbol, formatGroupedDiagnosticMessages, formatLocation, + formatSignatureHelp, formatSymbolInformation, formatWorkspaceEdit, hasGlobPattern, @@ -338,7 +345,7 @@ export function createLspTool(cwd: string): AgentTool, ): Promise> { - const { action, file, line, symbol, occurrence, query, new_name, apply, timeout } = params; + const { action, file, line, symbol, occurrence, query, new_name, apply, tab_size, insert_spaces, timeout } = params; const timeoutSec = clampTimeout(timeout); const timeoutSignal = AbortSignal.timeout(timeoutSec * 1000); signal = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal; @@ -876,6 +883,154 @@ export function createLspTool(cwd: string): AgentTool 0) { + incomingLines.push(` ${header}\n${context.map(l => ` ${l}`).join("\n")}`); + } else { + incomingLines.push(` ${header}`); + } + } + + const truncation = incomingResult.length > REFERENCE_CONTEXT_LIMIT + ? `\n ... ${incomingResult.length - REFERENCE_CONTEXT_LIMIT} additional caller(s) omitted` + : ""; + output = `${incomingResult.length} caller(s) of ${prepareResult[0].name}:\n${incomingLines.join("\n")}${truncation}`; + break; + } + + case "outgoing_calls": { + const prepareResult = (await sendRequest( + client, + "textDocument/prepareCallHierarchy", + { + textDocument: { uri }, + position, + }, + signal, + )) as CallHierarchyItem[] | null; + + if (!prepareResult || prepareResult.length === 0) { + output = "No call hierarchy item found at this position"; + break; + } + + const outgoingResult = (await sendRequest( + client, + "callHierarchy/outgoingCalls", + { item: prepareResult[0] }, + signal, + )) as CallHierarchyOutgoingCall[] | null; + + if (!outgoingResult || outgoingResult.length === 0) { + output = `No outgoing calls found from ${prepareResult[0].name}`; + break; + } + + const outgoingLines: string[] = []; + const limitedOutgoing = outgoingResult.slice(0, REFERENCE_CONTEXT_LIMIT); + for (const call of limitedOutgoing) { + const header = formatCallHierarchyItem(call.to, cwd); + const filePath = uriToFile(call.to.uri); + const callLine = call.to.selectionRange.start.line; + const context = await readLocationContext(filePath, callLine + 1, LOCATION_CONTEXT_LINES); + if (context.length > 0) { + outgoingLines.push(` ${header}\n${context.map(l => ` ${l}`).join("\n")}`); + } else { + outgoingLines.push(` ${header}`); + } + } + + const outTruncation = outgoingResult.length > REFERENCE_CONTEXT_LIMIT + ? `\n ... ${outgoingResult.length - REFERENCE_CONTEXT_LIMIT} additional callee(s) omitted` + : ""; + output = `${outgoingResult.length} callee(s) from ${prepareResult[0].name}:\n${outgoingLines.join("\n")}${outTruncation}`; + break; + } + + case "format": { + if (!targetFile) { + output = "Error: file parameter required for format"; + break; + } + + const formatResult = (await sendRequest( + client, + "textDocument/formatting", + { + textDocument: { uri }, + options: { + tabSize: tab_size ?? 4, + insertSpaces: insert_spaces ?? true, + }, + }, + signal, + )) as TextEdit[] | null; + + if (!formatResult || formatResult.length === 0) { + const relPath = path.relative(cwd, targetFile); + output = `${relPath}: already formatted (no changes)`; + break; + } + + await applyTextEdits(targetFile, formatResult); + const relPath = path.relative(cwd, targetFile); + output = `Formatted ${relPath}: ${formatResult.length} edit(s) applied`; + break; + } + + case "signature": { + const sigResult = (await sendRequest( + client, + "textDocument/signatureHelp", + { + textDocument: { uri }, + position, + }, + signal, + )) as SignatureHelp | null; + + if (!sigResult || !sigResult.signatures || sigResult.signatures.length === 0) { + output = "No signature information at this position"; + } else { + output = formatSignatureHelp(sigResult); + } + break; + } + case "rename": { if (!new_name) { return { diff --git a/packages/pi-coding-agent/src/core/lsp/lsp.md b/packages/pi-coding-agent/src/core/lsp/lsp.md index a978ee0e7..9a5123e8f 100644 --- a/packages/pi-coding-agent/src/core/lsp/lsp.md +++ b/packages/pi-coding-agent/src/core/lsp/lsp.md @@ -8,8 +8,12 @@ Interacts with Language Server Protocol servers for code intelligence. - `references`: Find references → locations with 3-line source context (first 50), remaining location-only - `hover`: Get type info and documentation → type signature + docs - `symbols`: List symbols in file, or search workspace (with query, no file) +- `incoming_calls`: Find all callers of a function → call sites with context +- `outgoing_calls`: Find all functions called by a function → callees with context - `rename`: Rename symbol across codebase → preview or apply edits - `code_actions`: List available quick-fixes/refactors/import actions; apply one when `apply: true` and `query` matches title or index +- `format`: Format file using language server formatter → applies edits in-place +- `signature`: Get function signature and parameter info at cursor position - `status`: Show active language servers - `reload`: Restart the language server @@ -22,6 +26,8 @@ Interacts with Language Server Protocol servers for code intelligence. - `query`: Symbol search query, code-action kind filter (list mode), or code-action selector (apply mode) - `new_name`: Required for rename - `apply`: Apply edits for rename/code_actions (default true for rename, list mode for code_actions unless explicitly true) +- `tab_size`: Tab size for formatting (default: 4) +- `insert_spaces`: Use spaces for formatting (default: true) - `timeout`: Request timeout in seconds (clamped to 5-60, default 20) diff --git a/packages/pi-coding-agent/src/core/lsp/types.ts b/packages/pi-coding-agent/src/core/lsp/types.ts index b4bdd0d03..2187edb49 100644 --- a/packages/pi-coding-agent/src/core/lsp/types.ts +++ b/packages/pi-coding-agent/src/core/lsp/types.ts @@ -29,6 +29,10 @@ export const lspSchema = Type.Object({ "code_actions", "type_definition", "implementation", + "incoming_calls", + "outgoing_calls", + "format", + "signature", "status", "reload", ], @@ -43,6 +47,8 @@ export const lspSchema = Type.Object({ query: Type.Optional(Type.String({ description: "Search query or SSR pattern" })), new_name: Type.Optional(Type.String({ description: "New name for rename" })), apply: Type.Optional(Type.Boolean({ description: "Apply edits (default: true)" })), + tab_size: Type.Optional(Type.Number({ description: "Tab size for formatting (default: 4)" })), + insert_spaces: Type.Optional(Type.Boolean({ description: "Use spaces for formatting (default: true)" })), timeout: Type.Optional(Type.Number({ description: "Request timeout in seconds" })), }); @@ -419,3 +425,50 @@ export interface LspJsonRpcNotification { method: string; params?: unknown; } + +// ============================================================================= +// Call Hierarchy +// ============================================================================= + +export interface CallHierarchyItem { + name: string; + kind: SymbolKind; + tags?: number[]; + detail?: string; + uri: string; + range: Range; + selectionRange: Range; + data?: unknown; +} + +export interface CallHierarchyIncomingCall { + from: CallHierarchyItem; + fromRanges: Range[]; +} + +export interface CallHierarchyOutgoingCall { + to: CallHierarchyItem; + fromRanges: Range[]; +} + +// ============================================================================= +// Signature Help +// ============================================================================= + +export interface ParameterInformation { + label: string | [number, number]; + documentation?: string | MarkupContent; +} + +export interface SignatureInformation { + label: string; + documentation?: string | MarkupContent; + parameters?: ParameterInformation[]; + activeParameter?: number; +} + +export interface SignatureHelp { + signatures: SignatureInformation[]; + activeSignature?: number; + activeParameter?: number; +} diff --git a/packages/pi-coding-agent/src/core/lsp/utils.ts b/packages/pi-coding-agent/src/core/lsp/utils.ts index f40e618ba..8047789fa 100644 --- a/packages/pi-coding-agent/src/core/lsp/utils.ts +++ b/packages/pi-coding-agent/src/core/lsp/utils.ts @@ -3,12 +3,15 @@ import path from "node:path"; import { glob } from "glob"; import { isEnoent } from "./helpers.js"; import type { + CallHierarchyItem, CodeAction, Command, Diagnostic, DiagnosticSeverity, DocumentSymbol, Location, + MarkupContent, + SignatureHelp, SymbolInformation, SymbolKind, TextEdit, @@ -680,3 +683,56 @@ export async function readLocationContext(filePath: string, line: number, contex throw error; } } + +// ============================================================================= +// Call Hierarchy Formatting +// ============================================================================= + +export function formatCallHierarchyItem(item: CallHierarchyItem, cwd: string): string { + const icon = symbolKindToIcon(item.kind); + const detail = item.detail ? ` ${item.detail}` : ""; + const relPath = path.relative(cwd, uriToFile(item.uri)); + const line = item.selectionRange.start.line + 1; + return `${icon} ${item.name}${detail} @ ${relPath}:${line}`; +} + +// ============================================================================= +// Signature Help Formatting +// ============================================================================= + +function extractDocText(doc: string | MarkupContent | undefined): string { + if (!doc) return ""; + if (typeof doc === "string") return doc; + return doc.value; +} + +export function formatSignatureHelp(result: SignatureHelp): string { + if (!result.signatures || result.signatures.length === 0) { + return "No signature information"; + } + + const activeIdx = result.activeSignature ?? 0; + const sig = result.signatures[activeIdx] ?? result.signatures[0]; + const activeParam = result.activeParameter ?? sig.activeParameter; + + const lines: string[] = [sig.label]; + + const sigDoc = extractDocText(sig.documentation); + if (sigDoc) { + lines.push("", sigDoc); + } + + if (sig.parameters && sig.parameters.length > 0) { + lines.push("", "Parameters:"); + for (let i = 0; i < sig.parameters.length; i++) { + const p = sig.parameters[i]; + const label = typeof p.label === "string" ? p.label : sig.label.slice(p.label[0], p.label[1]); + const active = i === activeParam ? " <-- active" : ""; + const doc = extractDocText(p.documentation); + const docSuffix = doc ? ` — ${doc}` : ""; + lines.push(` ${label}${docSuffix}${active}`); + } + } + + return lines.join("\n"); +} diff --git a/packages/pi-coding-agent/src/core/system-prompt.ts b/packages/pi-coding-agent/src/core/system-prompt.ts index 1b57d13fe..a7cb75768 100644 --- a/packages/pi-coding-agent/src/core/system-prompt.ts +++ b/packages/pi-coding-agent/src/core/system-prompt.ts @@ -159,7 +159,13 @@ export function buildSystemPrompt(options: BuildSystemPromptOptions = {}): strin // LSP guideline if (hasLsp) { addGuideline( - "Use lsp for go-to-definition, find-references, hover, rename, and diagnostics when working in typed codebases. Prefer lsp over grep for semantic navigation (finding call sites, implementations, type info). Falls back gracefully if no language server is available for the file type.", + `Use lsp as the primary tool for code navigation in typed codebases: +- Navigation: definition, type_definition, implementation, references, incoming_calls, outgoing_calls +- Understanding: hover (types + docs), signature (parameter info), symbols (file/workspace search) +- Refactoring: rename (project-wide), code_actions (quick-fixes, imports, refactors), format (formatter) +- Verification: diagnostics after edits to catch type errors immediately +- Never grep for a symbol definition when lsp can resolve it semantically +- Never shell out to a formatter when lsp format is available`, ); } diff --git a/packages/pi-coding-agent/src/core/tools/edit.ts b/packages/pi-coding-agent/src/core/tools/edit.ts index 600f94bd0..ff8b36f21 100644 --- a/packages/pi-coding-agent/src/core/tools/edit.ts +++ b/packages/pi-coding-agent/src/core/tools/edit.ts @@ -11,6 +11,7 @@ import { restoreLineEndings, stripBom, } from "./edit-diff.js"; +import { notifyFileChanged } from "../lsp/client.js"; import { resolveToCwd } from "./path-utils.js"; const editSchema = Type.Object({ @@ -187,6 +188,8 @@ export function createEditTool(cwd: string, options?: EditToolOptions): AgentToo const finalContent = bom + restoreLineEndings(newContent, originalEnding); await ops.writeFile(absolutePath, finalContent); + try { notifyFileChanged(absolutePath); } catch { /* best-effort */ } + // Check if aborted after writing if (aborted) { return; diff --git a/packages/pi-coding-agent/src/core/tools/write.ts b/packages/pi-coding-agent/src/core/tools/write.ts index 09e0f650c..24c7be022 100644 --- a/packages/pi-coding-agent/src/core/tools/write.ts +++ b/packages/pi-coding-agent/src/core/tools/write.ts @@ -2,6 +2,7 @@ import type { AgentTool } from "@gsd/pi-agent-core"; import { type Static, Type } from "@sinclair/typebox"; import { mkdir as fsMkdir, writeFile as fsWriteFile } from "fs/promises"; import { dirname } from "path"; +import { notifyFileChanged } from "../lsp/client.js"; import { resolveToCwd } from "./path-utils.js"; const writeSchema = Type.Object({ @@ -83,6 +84,8 @@ export function createWriteTool(cwd: string, options?: WriteToolOptions): AgentT // Write the file await ops.writeFile(absolutePath, content); + try { notifyFileChanged(absolutePath); } catch { /* best-effort */ } + // Check if aborted after writing if (aborted) { return; diff --git a/src/resources/extensions/gsd/prompts/system.md b/src/resources/extensions/gsd/prompts/system.md index 29a640d05..a82b8a28e 100644 --- a/src/resources/extensions/gsd/prompts/system.md +++ b/src/resources/extensions/gsd/prompts/system.md @@ -139,7 +139,7 @@ Templates showing the expected format for each artifact type are in: **File editing:** Always `read` a file before using `edit`. The `edit` tool requires exact text match — you need the real content, not a guess. Use `write` only for new files or complete rewrites. -**Code navigation:** Use `lsp` for go-to-definition, find-references, and type info. Falls back gracefully if no server is available. Never `grep` for a symbol definition when `lsp` can resolve it semantically. +**Code navigation:** Use `lsp` for definition, type_definition, implementation, references, incoming_calls, outgoing_calls, hover, signature, symbols, rename, code_actions, format, and diagnostics. Falls back gracefully if no server is available. Never `grep` for a symbol definition when `lsp` can resolve it semantically. Never shell out to prettier/rustfmt/gofmt when `lsp format` is available. After editing code, use `lsp diagnostics` to verify no type errors were introduced. **Codebase exploration:** Use `subagent` with `scout` for broad unfamiliar subsystem mapping. Use `rg` for text search across files. Use `lsp` for structural navigation. Never read files one-by-one to "explore" — search first, then read what's relevant.