feat(coding-agent): added LSP format-on-write with server warmup

- Added LSP format on write to automatically format code files using language server formatters after writing.
- Implemented LSP server warmup at startup to avoid cold-start delays when first writing files.
- Added LSP server status display in welcome banner to show which language servers are active and ready.
- Fixed recent sessions display in welcome banner which was never wired up.
- Improved grep tool description to document pagination options and clarify recursive search behavior.
This commit is contained in:
can1357
2026-01-03 02:58:30 +01:00
parent 9262bbe9eb
commit ccc0a8b6c8
14 changed files with 325 additions and 17 deletions
+11
View File
@@ -2,14 +2,25 @@
## [Unreleased]
### Changed
- Improved grep tool description to document pagination options (`headLimit`, `offset`) and clarify recursive search behavior
### Added
- LSP format on write: Write tool now automatically formats code files using LSP after writing. Uses the language server's built-in formatter (e.g., rustfmt for Rust, gofmt for Go). Controlled via `lsp.formatOnWrite` setting (enabled by default).
- LSP diagnostics on write: Write tool now returns LSP diagnostics (errors/warnings) after writing code files. This gives immediate feedback on syntax errors and type issues. Controlled via `lsp.diagnosticsOnWrite` setting (enabled by default).
- LSP server warmup at startup: LSP servers are now started at launch to avoid cold-start delays when first writing files.
- LSP server status in welcome banner: Shows which language servers are active and ready.
- Edit fuzzy match setting: Added `edit.fuzzyMatch` setting (enabled by default) to control whether the edit tool accepts high-confidence fuzzy matches for whitespace/indentation differences. Toggle via `/settings`.
- Multi-server LSP diagnostics: Diagnostics now query all applicable language servers for a file type. For TypeScript/JavaScript projects with Biome, this means both type errors (from tsserver) and lint errors (from Biome) are reported together.
- Comprehensive LSP server configurations for 40+ languages including Rust, Go, Python, Java, Kotlin, Scala, Haskell, OCaml, Elixir, Ruby, PHP, C#, Lua, Nix, and many more. Each server includes sensible defaults for args, settings, and init options.
- Extended LSP config file search paths: Now searches for `lsp.json`, `.lsp.json` in project root and `.pi/` subdirectory, plus user-level configs in `~/.pi/` and home directory.
### Fixed
- Recent sessions now show in welcome banner (was never wired up).
## [1.340.0] - 2026-01-03
### Changed
+1
View File
@@ -572,6 +572,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
const builtInTools =
options.tools ??
createCodingTools(cwd, options.hasUI ?? false, sessionContext, {
lspFormatOnWrite: settingsManager.getLspFormatOnWrite(),
lspDiagnosticsOnWrite: settingsManager.getLspDiagnosticsOnWrite(),
editFuzzyMatch: settingsManager.getEditFuzzyMatch(),
});
@@ -28,6 +28,7 @@ export interface SessionHeader {
type: "session";
version?: number; // v1 sessions don't have this
id: string;
title?: string; // Auto-generated title from first message
timestamp: string;
cwd: string;
parentSession?: string;
@@ -454,6 +455,66 @@ export function findMostRecentSession(sessionDir: string): string | null {
}
}
/** Recent session info for display */
export interface RecentSessionInfo {
name: string;
path: string;
timeAgo: string;
}
/** Format a time difference as a human-readable string */
function formatTimeAgo(date: Date): string {
const now = Date.now();
const diffMs = now - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 1) return "just now";
if (diffMins < 60) return `${diffMins}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
if (diffDays < 7) return `${diffDays}d ago`;
return date.toLocaleDateString();
}
/** Get recent sessions for display in welcome screen */
export function getRecentSessions(sessionDir: string, limit = 3): RecentSessionInfo[] {
try {
const files = readdirSync(sessionDir)
.filter((f) => f.endsWith(".jsonl"))
.map((f) => join(sessionDir, f))
.filter(isValidSessionFile)
.map((path) => {
const stat = statSync(path);
// Try to get session name from first line
let name = path.split("/").pop()?.replace(".jsonl", "") ?? "Unknown";
try {
const content = readFileSync(path, "utf-8");
const firstLine = content.split("\n")[0];
if (firstLine) {
const header = JSON.parse(firstLine) as SessionHeader;
if (header.type === "session" && header.id) {
name = header.id;
}
}
} catch {
// Use filename as fallback
}
return { path, name, mtime: stat.mtime };
})
.sort((a, b) => b.mtime.getTime() - a.mtime.getTime())
.slice(0, limit);
return files.map((f) => ({
name: f.name.length > 30 ? `${f.name.slice(0, 27)}...` : f.name,
path: f.path,
timeAgo: formatTimeAgo(f.mtime),
}));
} catch {
return [];
}
}
/**
* Manages conversation sessions as append-only trees stored in JSONL files.
*
@@ -52,6 +52,7 @@ export interface MCPSettings {
}
export interface LspSettings {
formatOnWrite?: boolean; // default: true (format files using LSP after write tool writes code files)
diagnosticsOnWrite?: boolean; // default: true (return LSP diagnostics after write tool writes code files)
}
@@ -485,6 +486,18 @@ export class SettingsManager {
this.save();
}
getLspFormatOnWrite(): boolean {
return this.settings.lsp?.formatOnWrite ?? true;
}
setLspFormatOnWrite(enabled: boolean): void {
if (!this.globalSettings.lsp) {
this.globalSettings.lsp = {};
}
this.globalSettings.lsp.formatOnWrite = enabled;
this.save();
}
getLspDiagnosticsOnWrite(): boolean {
return this.settings.lsp?.diagnosticsOnWrite ?? true;
}
+1 -2
View File
@@ -66,8 +66,7 @@ Usage notes:
- If the commands are independent and can run in parallel, make multiple bash tool calls in a single message. For example, if you need to run "git status" and "git diff", send a single message with two bash tool calls in parallel.
- If the commands depend on each other and must run sequentially, use a single bash call with '&&' to chain them together (e.g., \`git add . && git commit -m "message" && git push\`). For instance, if one operation must complete before another starts (like mkdir before cp, Write before Bash for git operations, or git add before git commit), run these operations sequentially instead.
- Use ';' only when you need to run commands sequentially but don't care if earlier commands fail
- DO NOT use newlines to separate commands (newlines are ok in quoted strings)
- Try to maintain your current working directory throughout the session by using absolute paths and avoiding usage of \`cd\`. You may use \`cd\` if the User explicitly requests it.`,
- DO NOT use newlines to separate commands (newlines are ok in quoted strings)`,
parameters: bashSchema,
execute: async (
_toolCallId: string,
+3 -2
View File
@@ -68,10 +68,11 @@ export function createGrepTool(cwd: string): AgentTool<typeof grepSchema> {
Usage:
- ALWAYS use grep for search tasks. NEVER invoke \`grep\` or \`rg\` as a bash command. The grep tool has been optimized for correct permissions and access.
- Searches recursively by default - no need for -r flag
- Supports full regex syntax (e.g., "log.*Error", "function\\s+\\w+")
- Filter files with glob parameter (e.g., "*.js", "**/*.tsx") or type parameter (e.g., "js", "py", "rust")
- Filter files with glob parameter (e.g., "*.ts", "**/*.spec.ts") or type parameter (e.g., "ts", "py", "rust") - equivalent to grep's --include
- Output modes: "content" shows matching lines, "files_with_matches" shows only file paths (default), "count" shows match counts
- Use task tool for open-ended searches requiring multiple rounds
- Pagination: Use headLimit to limit results (like \`| head -N\`), offset to skip first N results
- Pattern syntax: Uses ripgrep (not grep) - literal braces need escaping (use \`interface\\{\\}\` to find \`interface{}\` in Go code)
- Multiline matching: By default patterns match within single lines only. For cross-line patterns like \`struct \\{[\\s\\S]*?field\`, use \`multiline: true\``,
parameters: grepSchema,
+25 -4
View File
@@ -1,13 +1,25 @@
export { type AskToolDetails, askTool, createAskTool } from "./ask.js";
export { type BashToolDetails, bashTool, createBashTool } from "./bash.js";
export { createEditTool, editTool } from "./edit.js";
export { createEditTool, type EditToolOptions, editTool } from "./edit.js";
// Exa MCP tools (22 tools)
export { exaTools } from "./exa/index.js";
export type { ExaRenderDetails, ExaSearchResponse, ExaSearchResult } from "./exa/types.js";
export { createFindTool, type FindToolDetails, findTool } from "./find.js";
export { createGrepTool, type GrepToolDetails, grepTool } from "./grep.js";
export { createLsTool, type LsToolDetails, lsTool } from "./ls.js";
export { createLspTool, type LspToolDetails, lspTool } from "./lsp/index.js";
export {
createLspTool,
type FileDiagnosticsResult,
type FileFormatResult,
formatFile,
getDiagnosticsForFile,
getLspStatus,
type LspServerStatus,
type LspToolDetails,
type LspWarmupResult,
lspTool,
warmupLspServers,
} from "./lsp/index.js";
export { createNotebookTool, type NotebookToolDetails, notebookTool } from "./notebook.js";
export { createReadTool, type ReadToolDetails, readTool } from "./read.js";
export { BUNDLED_AGENTS, createTaskTool, taskTool } from "./task/index.js";
@@ -41,7 +53,7 @@ import { createEditTool, editTool } from "./edit.js";
import { createFindTool, findTool } from "./find.js";
import { createGrepTool, grepTool } from "./grep.js";
import { createLsTool, lsTool } from "./ls.js";
import { createLspTool, getDiagnosticsForFile, lspTool } from "./lsp/index.js";
import { createLspTool, formatFile, getDiagnosticsForFile, lspTool } from "./lsp/index.js";
import { createNotebookTool, notebookTool } from "./notebook.js";
import { createReadTool, readTool } from "./read.js";
import { createTaskTool, taskTool } from "./task/index.js";
@@ -61,6 +73,10 @@ export interface SessionContext {
export interface CodingToolsOptions {
/** Whether to fetch LSP diagnostics after write tool writes files (default: true) */
lspDiagnosticsOnWrite?: boolean;
/** Whether to format files using LSP after write tool writes (default: true) */
lspFormatOnWrite?: boolean;
/** Whether to accept high-confidence fuzzy matches in edit tool (default: true) */
editFuzzyMatch?: boolean;
}
// Factory function type
@@ -71,12 +87,17 @@ const toolDefs: Record<string, { tool: Tool; create: ToolFactory }> = {
ask: { tool: askTool, create: createAskTool },
read: { tool: readTool, create: createReadTool },
bash: { tool: bashTool, create: createBashTool },
edit: { tool: editTool, create: createEditTool },
edit: {
tool: editTool,
create: (cwd, _ctx, options) => createEditTool(cwd, { fuzzyMatch: options?.editFuzzyMatch ?? true }),
},
write: {
tool: writeTool,
create: (cwd, _ctx, options) => {
const enableFormat = options?.lspFormatOnWrite ?? true;
const enableDiagnostics = options?.lspDiagnosticsOnWrite ?? true;
return createWriteTool(cwd, {
formatOnWrite: enableFormat ? (absolutePath) => formatFile(absolutePath, cwd) : undefined,
getDiagnostics: enableDiagnostics ? (absolutePath) => getDiagnosticsForFile(absolutePath, cwd) : undefined,
});
},
@@ -98,6 +98,12 @@ const CLIENT_CAPABILITIES = {
properties: ["edit"],
},
},
formatting: {
dynamicRegistration: false,
},
rangeFormatting: {
dynamicRegistration: false,
},
publishDiagnostics: {
relatedInformation: true,
versionSupport: false,
@@ -587,6 +593,25 @@ export function shutdownAll(): void {
clients.clear();
}
/** Status of an LSP server */
export interface LspServerStatus {
name: string;
status: "connecting" | "ready" | "error";
fileTypes: string[];
error?: string;
}
/**
* Get status of all active LSP clients.
*/
export function getActiveClients(): LspServerStatus[] {
return Array.from(clients.values()).map((client) => ({
name: client.config.command,
status: "ready" as const,
fileTypes: client.config.fileTypes,
}));
}
// =============================================================================
// Process Cleanup
// =============================================================================
@@ -12,7 +12,7 @@ import {
sendRequest,
} from "./client.js";
import { getServerForFile, getServersForFile, hasCapability, type LspConfig, loadConfig } from "./config.js";
import { applyWorkspaceEdit } from "./edits.js";
import { applyTextEdits, applyWorkspaceEdit } from "./edits.js";
import { renderCall, renderResult } from "./render.js";
import * as rustAnalyzer from "./rust-analyzer.js";
import {
@@ -32,6 +32,7 @@ import {
lspSchema,
type ServerConfig,
type SymbolInformation,
type TextEdit,
type WorkspaceEdit,
} from "./types.js";
import {
@@ -318,6 +319,85 @@ export async function getDiagnosticsForFile(
};
}
/** Result from formatFile */
export interface FileFormatResult {
/** Whether an LSP server with formatting support was available */
available: boolean;
/** Name of the LSP server used (if available) */
serverName?: string;
/** Whether formatting was applied */
formatted: boolean;
/** Error message if formatting failed */
error?: string;
}
/** Default formatting options for LSP */
const DEFAULT_FORMAT_OPTIONS = {
tabSize: 3,
insertSpaces: true,
trimTrailingWhitespace: true,
insertFinalNewline: true,
trimFinalNewlines: true,
};
/**
* Format a file using LSP.
* Uses the first available server that supports formatting.
*
* @param absolutePath - Absolute path to the file
* @param cwd - Working directory for LSP config resolution
* @returns Format result indicating success/failure
*/
export async function formatFile(absolutePath: string, cwd: string): Promise<FileFormatResult> {
const config = getConfig(cwd);
const servers = getServersForFile(config, absolutePath);
if (servers.length === 0) {
return { available: false, formatted: false };
}
const uri = fileToUri(absolutePath);
// Try each server until one successfully formats
for (const [serverName, serverConfig] of servers) {
try {
const client = await getOrCreateClient(serverConfig, cwd);
// Check if server supports formatting
const caps = client.serverCapabilities;
if (!caps?.documentFormattingProvider) {
continue;
}
// Ensure file is open and synced
await ensureFileOpen(client, absolutePath);
await refreshFile(client, absolutePath);
// Request formatting
const edits = (await sendRequest(client, "textDocument/formatting", {
textDocument: { uri },
options: DEFAULT_FORMAT_OPTIONS,
})) as TextEdit[] | null;
if (!edits || edits.length === 0) {
// No changes needed - file already formatted
return { available: true, serverName, formatted: false };
}
// Apply the formatting edits
await applyTextEdits(absolutePath, edits);
// Notify LSP of the change so diagnostics update
await refreshFile(client, absolutePath);
return { available: true, serverName, formatted: true };
} catch {}
}
// No server could format
return { available: false, formatted: false };
}
export function createLspTool(cwd: string): AgentTool<typeof lspSchema, LspToolDetails, Theme> {
return {
name: "lsp",
+25 -2
View File
@@ -2,7 +2,7 @@ import type { AgentTool } from "@oh-my-pi/pi-agent-core";
import { Type } from "@sinclair/typebox";
import { mkdir, writeFile } from "fs/promises";
import { dirname } from "path";
import type { FileDiagnosticsResult } from "./lsp/index.js";
import type { FileDiagnosticsResult, FileFormatResult } from "./lsp/index.js";
import { resolveToCwd } from "./path-utils.js";
const writeSchema = Type.Object({
@@ -12,12 +12,18 @@ const writeSchema = Type.Object({
/** Options for creating the write tool */
export interface WriteToolOptions {
/** Callback to format file using LSP after writing */
formatOnWrite?: (absolutePath: string) => Promise<FileFormatResult>;
/** Callback to get LSP diagnostics after writing a file */
getDiagnostics?: (absolutePath: string) => Promise<FileDiagnosticsResult>;
}
/** Details returned by the write tool for TUI rendering */
export interface WriteToolDetails {
/** Whether the file was formatted */
wasFormatted: boolean;
/** Format result (if available) */
formatResult?: FileFormatResult;
/** Whether LSP diagnostics were retrieved */
hasDiagnostics: boolean;
/** Diagnostic result (if available) */
@@ -92,7 +98,17 @@ Usage:
signal.removeEventListener("abort", onAbort);
}
// Get LSP diagnostics if callback provided
// Format file if callback provided (before diagnostics)
let formatResult: FileFormatResult | undefined;
if (options.formatOnWrite) {
try {
formatResult = await options.formatOnWrite(absolutePath);
} catch {
// Ignore formatting errors - don't fail the write
}
}
// Get LSP diagnostics if callback provided (after formatting)
let diagnosticsResult: FileDiagnosticsResult | undefined;
if (options.getDiagnostics) {
try {
@@ -105,6 +121,11 @@ Usage:
// Build result text
let resultText = `Successfully wrote ${content.length} bytes to ${path}`;
// Note if file was formatted
if (formatResult?.formatted) {
resultText += ` (formatted by ${formatResult.serverName})`;
}
// Append diagnostics if available and there are issues
if (diagnosticsResult?.available && diagnosticsResult.diagnostics.length > 0) {
resultText += `\n\nLSP Diagnostics (${diagnosticsResult.summary}):\n`;
@@ -114,6 +135,8 @@ Usage:
resolve({
content: [{ type: "text", text: resultText }],
details: {
wasFormatted: formatResult?.formatted ?? false,
formatResult,
hasDiagnostics: diagnosticsResult?.available ?? false,
diagnostics: diagnosticsResult,
},
+12 -2
View File
@@ -62,11 +62,20 @@ async function runInteractiveMode(
initialMessages: string[],
customTools: LoadedCustomTool[],
setToolUIContext: (uiContext: HookUIContext, hasUI: boolean) => void,
lspServers: Array<{ name: string; status: "ready" | "error"; fileTypes: string[] }> | undefined,
initialMessage?: string,
initialImages?: ImageContent[],
fdPath: string | undefined = undefined,
): Promise<void> {
const mode = new InteractiveMode(session, version, changelogMarkdown, customTools, setToolUIContext, fdPath);
const mode = new InteractiveMode(
session,
version,
changelogMarkdown,
customTools,
setToolUIContext,
lspServers,
fdPath,
);
await mode.init();
@@ -393,7 +402,7 @@ export async function main(args: string[]) {
}
time("buildSessionOptions");
const { session, customToolsResult, modelFallbackMessage } = await createAgentSession(sessionOptions);
const { session, customToolsResult, modelFallbackMessage, lspServers } = await createAgentSession(sessionOptions);
time("createAgentSession");
if (!isInteractive && !session.model) {
@@ -449,6 +458,7 @@ export async function main(args: string[]) {
parsed.messages,
customToolsResult.tools,
customToolsResult.setUIContext,
lspServers,
initialMessage,
initialImages,
fdPath,
@@ -135,6 +135,15 @@ export const SETTINGS_DEFS: SettingDef[] = [
get: (sm) => sm.getMCPProjectConfigEnabled(),
set: (sm, v) => sm.setMCPProjectConfigEnabled(v),
},
{
id: "lspFormatOnWrite",
tab: "config",
type: "boolean",
label: "LSP format on write",
description: "Automatically format code files using LSP after writing",
get: (sm) => sm.getLspFormatOnWrite(),
set: (sm, v) => sm.setLspFormatOnWrite(v),
},
{
id: "lspDiagnosticsOnWrite",
tab: "config",
@@ -7,6 +7,12 @@ export interface RecentSession {
timeAgo: string;
}
export interface LspServerInfo {
name: string;
status: "ready" | "error" | "connecting";
fileTypes: string[];
}
/**
* Premium welcome screen with block-based Pi logo and two-column layout.
*/
@@ -15,12 +21,20 @@ export class WelcomeComponent implements Component {
private modelName: string;
private providerName: string;
private recentSessions: RecentSession[];
private lspServers: LspServerInfo[];
constructor(version: string, modelName: string, providerName: string, recentSessions: RecentSession[] = []) {
constructor(
version: string,
modelName: string,
providerName: string,
recentSessions: RecentSession[] = [],
lspServers: LspServerInfo[] = [],
) {
this.version = version;
this.modelName = modelName;
this.providerName = providerName;
this.recentSessions = recentSessions;
this.lspServers = lspServers;
}
invalidate(): void {}
@@ -34,6 +48,10 @@ export class WelcomeComponent implements Component {
this.recentSessions = sessions;
}
setLspServers(servers: LspServerInfo[]): void {
this.lspServers = servers;
}
render(termWidth: number): string[] {
// Box dimensions - responsive with min/max
const minWidth = 80;
@@ -76,13 +94,32 @@ export class WelcomeComponent implements Component {
}
}
// LSP servers content
const lspLines: string[] = [];
if (this.lspServers.length === 0) {
lspLines.push(` ${theme.fg("dim", "No LSP servers")}`);
} else {
for (const server of this.lspServers) {
const icon =
server.status === "ready"
? theme.fg("success", "●")
: server.status === "connecting"
? theme.fg("warning", "○")
: theme.fg("error", "●");
const exts = server.fileTypes.slice(0, 3).join(" ");
lspLines.push(` ${icon} ${theme.fg("muted", server.name)} ${theme.fg("dim", exts)}`);
}
}
// Right column
const rightLines = [
` ${theme.bold(theme.fg("accent", "Tips"))}`,
` ${theme.fg("dim", "?")}${theme.fg("muted", " for keyboard shortcuts")}`,
` ${theme.fg("dim", "/")}${theme.fg("muted", " for commands")}`,
` ${theme.fg("dim", "!")}${theme.fg("muted", " to run bash")}`,
` ${theme.fg("dim", "/status")}${theme.fg("muted", " for loaded extensions")}`,
separator,
` ${theme.bold(theme.fg("accent", "LSP Servers"))}`,
...lspLines,
separator,
` ${theme.bold(theme.fg("accent", "Recent sessions"))}`,
...sessionLines,
@@ -28,7 +28,7 @@ import type { AgentSession, AgentSessionEvent } from "../../core/agent-session.j
import type { CustomToolSessionEvent, LoadedCustomTool } from "../../core/custom-tools/index.js";
import type { HookUIContext } from "../../core/hooks/index.js";
import { createCompactionSummaryMessage } from "../../core/messages.js";
import { type SessionContext, SessionManager } from "../../core/session-manager.js";
import { getRecentSessions, type SessionContext, SessionManager } from "../../core/session-manager.js";
import { loadSkills } from "../../core/skills.js";
import { loadProjectContextFiles } from "../../core/system-prompt.js";
import type { TruncationResult } from "../../core/tools/truncate.js";
@@ -158,6 +158,9 @@ export class InteractiveMode {
changelogMarkdown: string | undefined = undefined,
customTools: LoadedCustomTool[] = [],
private setToolUIContext: (uiContext: HookUIContext, hasUI: boolean) => void = () => {},
private lspServers:
| Array<{ name: string; status: "ready" | "error"; fileTypes: string[] }>
| undefined = undefined,
fdPath: string | undefined = undefined,
) {
this.session = session;
@@ -225,8 +228,22 @@ export class InteractiveMode {
const modelName = this.session.model?.name ?? "Unknown";
const providerName = this.session.model?.provider ?? "Unknown";
// Get recent sessions
const recentSessions = getRecentSessions(this.sessionManager.getSessionDir()).map((s) => ({
name: s.name,
timeAgo: s.timeAgo,
}));
// Convert LSP servers to welcome format
const lspServerInfo =
this.lspServers?.map((s) => ({
name: s.name,
status: s.status as "ready" | "error" | "connecting",
fileTypes: s.fileTypes,
})) ?? [];
// Add welcome header
const welcome = new WelcomeComponent(this.version, modelName, providerName);
const welcome = new WelcomeComponent(this.version, modelName, providerName, recentSessions, lspServerInfo);
// Setup UI layout
this.ui.addChild(new Spacer(1));