From 0d4cbf82ab6e3b93696e01d075357c2c3331ddfa Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Fri, 23 Jan 2026 13:55:07 -0300 Subject: [PATCH] feat: lsp_restart (#1930) * feat: lsp_restart Signed-off-by: Carlos Alexandro Becker * wip Signed-off-by: Carlos Alexandro Becker * fix: typo Signed-off-by: Carlos Alexandro Becker * fix: actually restart Signed-off-by: Carlos Alexandro Becker * fix: simplify Signed-off-by: Carlos Alexandro Becker * fix: render lsp restart Signed-off-by: Carlos Alexandro Becker * fix: add lsp name to diag Signed-off-by: Carlos Alexandro Becker --------- Signed-off-by: Carlos Alexandro Becker --- internal/agent/coordinator.go | 2 +- internal/agent/tools/diagnostics.go | 6 +- internal/agent/tools/lsp_restart.go | 80 ++++++++++++++ internal/agent/tools/lsp_restart.md | 25 +++++ internal/config/config.go | 1 + internal/config/load_test.go | 4 +- internal/lsp/client.go | 162 +++++++++++++++++++--------- internal/ui/chat/lsp_restart.go | 62 +++++++++++ internal/ui/chat/tools.go | 2 + 9 files changed, 285 insertions(+), 59 deletions(-) create mode 100644 internal/agent/tools/lsp_restart.go create mode 100644 internal/agent/tools/lsp_restart.md create mode 100644 internal/ui/chat/lsp_restart.go diff --git a/internal/agent/coordinator.go b/internal/agent/coordinator.go index fd6662c1961c7f5f8aa6289b38e89b0aa9dc521a..8c2a785b2f8ffeb77bbf52bb9653e8a98369303b 100644 --- a/internal/agent/coordinator.go +++ b/internal/agent/coordinator.go @@ -406,7 +406,7 @@ func (c *coordinator) buildTools(ctx context.Context, agent config.Agent) ([]fan ) if len(c.cfg.LSP) > 0 { - allTools = append(allTools, tools.NewDiagnosticsTool(c.lspClients), tools.NewReferencesTool(c.lspClients)) + allTools = append(allTools, tools.NewDiagnosticsTool(c.lspClients), tools.NewReferencesTool(c.lspClients), tools.NewLSPRestartTool(c.lspClients)) } var filteredTools []fantasy.AgentTool diff --git a/internal/agent/tools/diagnostics.go b/internal/agent/tools/diagnostics.go index 85e8b8d0f7d997f8db83f0d6176ce30c644b86f0..9af0da43c396d9fa8aa9776f4f7fb177af6b5806 100644 --- a/internal/agent/tools/diagnostics.go +++ b/internal/agent/tools/diagnostics.go @@ -137,11 +137,9 @@ func formatDiagnostic(pth string, diagnostic protocol.Diagnostic, source string) location := fmt.Sprintf("%s:%d:%d", pth, diagnostic.Range.Start.Line+1, diagnostic.Range.Start.Character+1) - sourceInfo := "" + sourceInfo := source if diagnostic.Source != "" { - sourceInfo = diagnostic.Source - } else if source != "" { - sourceInfo = source + sourceInfo += " " + diagnostic.Source } codeInfo := "" diff --git a/internal/agent/tools/lsp_restart.go b/internal/agent/tools/lsp_restart.go new file mode 100644 index 0000000000000000000000000000000000000000..5e5a8a90a11927079086fe407384f32ceecf10c5 --- /dev/null +++ b/internal/agent/tools/lsp_restart.go @@ -0,0 +1,80 @@ +package tools + +import ( + "context" + _ "embed" + "fmt" + "log/slog" + "maps" + "strings" + "sync" + + "charm.land/fantasy" + "github.com/charmbracelet/crush/internal/csync" + "github.com/charmbracelet/crush/internal/lsp" +) + +const LSPRestartToolName = "lsp_restart" + +//go:embed lsp_restart.md +var lspRestartDescription []byte + +type LSPRestartParams struct { + // Name is the optional name of a specific LSP client to restart. + // If empty, all LSP clients will be restarted. + Name string `json:"name,omitempty"` +} + +func NewLSPRestartTool(lspClients *csync.Map[string, *lsp.Client]) fantasy.AgentTool { + return fantasy.NewAgentTool( + LSPRestartToolName, + string(lspRestartDescription), + func(ctx context.Context, params LSPRestartParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) { + if lspClients.Len() == 0 { + return fantasy.NewTextErrorResponse("no LSP clients available to restart"), nil + } + + clientsToRestart := make(map[string]*lsp.Client) + if params.Name == "" { + maps.Insert(clientsToRestart, lspClients.Seq2()) + } else { + client, exists := lspClients.Get(params.Name) + if !exists { + return fantasy.NewTextErrorResponse(fmt.Sprintf("LSP client '%s' not found", params.Name)), nil + } + clientsToRestart[params.Name] = client + } + + var restarted []string + var failed []string + var mu sync.Mutex + var wg sync.WaitGroup + for name, client := range clientsToRestart { + wg.Go(func() { + if err := client.Restart(); err != nil { + slog.Error("Failed to restart LSP client", "name", name, "error", err) + mu.Lock() + failed = append(failed, name) + mu.Unlock() + return + } + mu.Lock() + restarted = append(restarted, name) + mu.Unlock() + }) + } + + wg.Wait() + + var output string + if len(restarted) > 0 { + output = fmt.Sprintf("Successfully restarted %d LSP client(s): %s\n", len(restarted), strings.Join(restarted, ", ")) + } + if len(failed) > 0 { + output += fmt.Sprintf("Failed to restart %d LSP client(s): %s\n", len(failed), strings.Join(failed, ", ")) + return fantasy.NewTextErrorResponse(output), nil + } + + return fantasy.NewTextResponse(output), nil + }) +} diff --git a/internal/agent/tools/lsp_restart.md b/internal/agent/tools/lsp_restart.md new file mode 100644 index 0000000000000000000000000000000000000000..118ebd645391d9c73b01ff35a8a73094b6f766a3 --- /dev/null +++ b/internal/agent/tools/lsp_restart.md @@ -0,0 +1,25 @@ +Restart LSP (Language Server Protocol) clients. + + +- Restart all running LSP clients or a specific LSP client by name +- Useful when LSP servers become unresponsive or need to be reloaded +- Parameters: + - name (optional): Specific LSP client name to restart. If not provided, all clients will be restarted. + + + +- Gracefully shuts down all LSP clients +- Restarts them with their original configuration +- Reports success/failure for each client + + + +- Only restarts clients that were successfully started +- Does not modify LSP configurations +- Requires LSP clients to be already running + + + +- Use when LSP diagnostics are stale or unresponsive +- Call this tool if you notice LSP features not working properly + diff --git a/internal/config/config.go b/internal/config/config.go index f1dd94655a76bded8f5e9071b543fb09f8600e02..eb8394e11972de4c91017a4b92e59ccee804ef0c 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -696,6 +696,7 @@ func allToolNames() []string { "multiedit", "lsp_diagnostics", "lsp_references", + "lsp_restart", "fetch", "agentic_fetch", "glob", diff --git a/internal/config/load_test.go b/internal/config/load_test.go index 8924475ef9c652ea1962e4f032a0e62e560bce7a..08c888318724104935b9e92403f09f54f8ae20a4 100644 --- a/internal/config/load_test.go +++ b/internal/config/load_test.go @@ -486,7 +486,7 @@ func TestConfig_setupAgentsWithDisabledTools(t *testing.T) { coderAgent, ok := cfg.Agents[AgentCoder] require.True(t, ok) - assert.Equal(t, []string{"agent", "bash", "job_output", "job_kill", "multiedit", "lsp_diagnostics", "lsp_references", "fetch", "agentic_fetch", "glob", "ls", "sourcegraph", "todos", "view", "write"}, coderAgent.AllowedTools) + assert.Equal(t, []string{"agent", "bash", "job_output", "job_kill", "multiedit", "lsp_diagnostics", "lsp_references", "lsp_restart", "fetch", "agentic_fetch", "glob", "ls", "sourcegraph", "todos", "view", "write"}, coderAgent.AllowedTools) taskAgent, ok := cfg.Agents[AgentTask] require.True(t, ok) @@ -509,7 +509,7 @@ func TestConfig_setupAgentsWithEveryReadOnlyToolDisabled(t *testing.T) { cfg.SetupAgents() coderAgent, ok := cfg.Agents[AgentCoder] require.True(t, ok) - assert.Equal(t, []string{"agent", "bash", "job_output", "job_kill", "download", "edit", "multiedit", "lsp_diagnostics", "lsp_references", "fetch", "agentic_fetch", "todos", "write"}, coderAgent.AllowedTools) + assert.Equal(t, []string{"agent", "bash", "job_output", "job_kill", "download", "edit", "multiedit", "lsp_diagnostics", "lsp_references", "lsp_restart", "fetch", "agentic_fetch", "todos", "write"}, coderAgent.AllowedTools) taskAgent, ok := cfg.Agents[AgentTask] require.True(t, ok) diff --git a/internal/lsp/client.go b/internal/lsp/client.go index df28a30bbcce9504fb8a4a0eaba98a820028e705..d2f4ab8c6f1f495ec836198d86621a9df279457b 100644 --- a/internal/lsp/client.go +++ b/internal/lsp/client.go @@ -40,6 +40,10 @@ type Client struct { // Configuration for this LSP client config config.LSPConfig + // Original context and resolver for recreating the client + ctx context.Context + resolver config.VariableResolver + // Diagnostic change callback onDiagnosticsChanged func(name string, count int) @@ -59,58 +63,22 @@ type Client struct { } // New creates a new LSP client using the powernap implementation. -func New(ctx context.Context, name string, config config.LSPConfig, resolver config.VariableResolver) (*Client, error) { - // Convert working directory to file URI - workDir, err := os.Getwd() - if err != nil { - return nil, fmt.Errorf("failed to get working directory: %w", err) - } - - rootURI := string(protocol.URIFromPath(workDir)) - - command, err := resolver.ResolveValue(config.Command) - if err != nil { - return nil, fmt.Errorf("invalid lsp command: %w", err) - } - - // Create powernap client config - clientConfig := powernap.ClientConfig{ - Command: home.Long(command), - Args: config.Args, - RootURI: rootURI, - Environment: func() map[string]string { - env := make(map[string]string) - maps.Copy(env, config.Env) - return env - }(), - Settings: config.Options, - InitOptions: config.InitOptions, - WorkspaceFolders: []protocol.WorkspaceFolder{ - { - URI: rootURI, - Name: filepath.Base(workDir), - }, - }, - } - - // Create the powernap client - powernapClient, err := powernap.NewClient(clientConfig) - if err != nil { - return nil, fmt.Errorf("failed to create lsp client: %w", err) - } - +func New(ctx context.Context, name string, cfg config.LSPConfig, resolver config.VariableResolver) (*Client, error) { client := &Client{ - client: powernapClient, name: name, - fileTypes: config.FileTypes, + fileTypes: cfg.FileTypes, diagnostics: csync.NewVersionedMap[protocol.DocumentURI, []protocol.Diagnostic](), openFiles: csync.NewMap[string, *OpenFileInfo](), - config: config, + config: cfg, + ctx: ctx, + resolver: resolver, } - - // Initialize server state client.serverState.Store(StateStarting) + if err := client.createPowernapClient(); err != nil { + return nil, err + } + return client, nil } @@ -140,13 +108,7 @@ func (c *Client) Initialize(ctx context.Context, workspaceDir string) (*protocol Capabilities: protocolCaps, } - c.RegisterServerRequestHandler("workspace/applyEdit", HandleApplyEdit) - c.RegisterServerRequestHandler("workspace/configuration", HandleWorkspaceConfiguration) - c.RegisterServerRequestHandler("client/registerCapability", HandleRegisterCapability) - c.RegisterNotificationHandler("window/showMessage", HandleServerMessage) - c.RegisterNotificationHandler("textDocument/publishDiagnostics", func(_ context.Context, _ string, params json.RawMessage) { - HandleDiagnostics(c, params) - }) + c.registerHandlers() return result, nil } @@ -163,6 +125,102 @@ func (c *Client) Close(ctx context.Context) error { return c.client.Exit() } +// createPowernapClient creates a new powernap client with the current configuration. +func (c *Client) createPowernapClient() error { + workDir, err := os.Getwd() + if err != nil { + return fmt.Errorf("failed to get working directory: %w", err) + } + + rootURI := string(protocol.URIFromPath(workDir)) + + command, err := c.resolver.ResolveValue(c.config.Command) + if err != nil { + return fmt.Errorf("invalid lsp command: %w", err) + } + + clientConfig := powernap.ClientConfig{ + Command: home.Long(command), + Args: c.config.Args, + RootURI: rootURI, + Environment: maps.Clone(c.config.Env), + Settings: c.config.Options, + InitOptions: c.config.InitOptions, + WorkspaceFolders: []protocol.WorkspaceFolder{ + { + URI: rootURI, + Name: filepath.Base(workDir), + }, + }, + } + + powernapClient, err := powernap.NewClient(clientConfig) + if err != nil { + return fmt.Errorf("failed to create lsp client: %w", err) + } + + c.client = powernapClient + return nil +} + +// registerHandlers registers the standard LSP notification and request handlers. +func (c *Client) registerHandlers() { + c.RegisterServerRequestHandler("workspace/applyEdit", HandleApplyEdit) + c.RegisterServerRequestHandler("workspace/configuration", HandleWorkspaceConfiguration) + c.RegisterServerRequestHandler("client/registerCapability", HandleRegisterCapability) + c.RegisterNotificationHandler("window/showMessage", HandleServerMessage) + c.RegisterNotificationHandler("textDocument/publishDiagnostics", func(_ context.Context, _ string, params json.RawMessage) { + HandleDiagnostics(c, params) + }) +} + +// Restart closes the current LSP client and creates a new one with the same configuration. +func (c *Client) Restart() error { + var openFiles []string + for uri := range c.openFiles.Seq2() { + openFiles = append(openFiles, string(uri)) + } + + closeCtx, cancel := context.WithTimeout(c.ctx, 10*time.Second) + defer cancel() + + if err := c.Close(closeCtx); err != nil { + slog.Warn("Error closing client during restart", "name", c.name, "error", err) + } + + c.diagCountsCache = DiagnosticCounts{} + c.diagCountsVersion = 0 + + if err := c.createPowernapClient(); err != nil { + return err + } + + initCtx, cancel := context.WithTimeout(c.ctx, 30*time.Second) + defer cancel() + + c.SetServerState(StateStarting) + + if err := c.client.Initialize(initCtx, false); err != nil { + c.SetServerState(StateError) + return fmt.Errorf("failed to initialize lsp client: %w", err) + } + + c.registerHandlers() + + if err := c.WaitForServerReady(initCtx); err != nil { + slog.Error("Server failed to become ready after restart", "name", c.name, "error", err) + c.SetServerState(StateError) + return err + } + + for _, uri := range openFiles { + if err := c.OpenFile(initCtx, uri); err != nil { + slog.Warn("Failed to reopen file after restart", "file", uri, "error", err) + } + } + return nil +} + // ServerState represents the state of an LSP server type ServerState int diff --git a/internal/ui/chat/lsp_restart.go b/internal/ui/chat/lsp_restart.go new file mode 100644 index 0000000000000000000000000000000000000000..66c316fcaf7c949711babeb9ebe864e558ae5bc0 --- /dev/null +++ b/internal/ui/chat/lsp_restart.go @@ -0,0 +1,62 @@ +package chat + +import ( + "encoding/json" + + "github.com/charmbracelet/crush/internal/agent/tools" + "github.com/charmbracelet/crush/internal/message" + "github.com/charmbracelet/crush/internal/ui/styles" +) + +// LSPRestartToolMessageItem is a message item that represents a lsprestart tool call. +type LSPRestartToolMessageItem struct { + *baseToolMessageItem +} + +var _ ToolMessageItem = (*LSPRestartToolMessageItem)(nil) + +// NewLSPRestartToolMessageItem creates a new [LSPRestartToolMessageItem]. +func NewLSPRestartToolMessageItem( + sty *styles.Styles, + toolCall message.ToolCall, + result *message.ToolResult, + canceled bool, +) ToolMessageItem { + return newBaseToolMessageItem(sty, toolCall, result, &LSPRestartToolRenderContext{}, canceled) +} + +// LSPRestartToolRenderContext renders lsprestart tool messages. +type LSPRestartToolRenderContext struct{} + +// RenderTool implements the [ToolRenderer] interface. +func (r *LSPRestartToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string { + cappedWidth := cappedMessageWidth(width) + if opts.IsPending() { + return pendingTool(sty, "Restart LSP", opts.Anim) + } + + var params tools.LSPRestartParams + _ = json.Unmarshal([]byte(opts.ToolCall.Input), ¶ms) + + toolParams := []string{} + if params.Name != "" { + toolParams = append(toolParams, params.Name) + } + + header := toolHeader(sty, opts.Status, "Restart LSP", cappedWidth, opts.Compact, toolParams...) + if opts.Compact { + return header + } + + if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok { + return joinToolParts(header, earlyState) + } + + if opts.HasEmptyResult() { + return header + } + + bodyWidth := cappedWidth - toolBodyLeftPaddingTotal + body := sty.Tool.Body.Render(toolOutputPlainContent(sty, opts.Result.Content, bodyWidth, opts.ExpandedContent)) + return joinToolParts(header, body) +} diff --git a/internal/ui/chat/tools.go b/internal/ui/chat/tools.go index 1c9f71eb41168d913f4193af896ecb1b389136e7..ffe8e680159dc7c5a5ee177e06f7aa678a945641 100644 --- a/internal/ui/chat/tools.go +++ b/internal/ui/chat/tools.go @@ -244,6 +244,8 @@ func NewToolMessageItem( item = NewTodosToolMessageItem(sty, toolCall, result, canceled) case tools.ReferencesToolName: item = NewReferencesToolMessageItem(sty, toolCall, result, canceled) + case tools.LSPRestartToolName: + item = NewLSPRestartToolMessageItem(sty, toolCall, result, canceled) default: if strings.HasPrefix(toolCall.Name, "mcp_") { item = NewMCPToolMessageItem(sty, toolCall, result, canceled)