feat: lsp_restart (#1930)

Carlos Alexandro Becker created

* feat: lsp_restart

Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>

* wip

Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>

* fix: typo

Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>

* fix: actually restart

Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>

* fix: simplify

Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>

* fix: render lsp restart

Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>

* fix: add lsp name to diag

Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>

---------

Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>

Change summary

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(-)

Detailed changes

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

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 := ""

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
+		})
+}

internal/agent/tools/lsp_restart.md 🔗

@@ -0,0 +1,25 @@
+Restart LSP (Language Server Protocol) clients.
+
+<usage>
+- 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.
+</usage>
+
+<features>
+- Gracefully shuts down all LSP clients
+- Restarts them with their original configuration
+- Reports success/failure for each client
+</features>
+
+<limitations>
+- Only restarts clients that were successfully started
+- Does not modify LSP configurations
+- Requires LSP clients to be already running
+</limitations>
+
+<tips>
+- Use when LSP diagnostics are stale or unresponsive
+- Call this tool if you notice LSP features not working properly
+</tips>

internal/config/config.go 🔗

@@ -696,6 +696,7 @@ func allToolNames() []string {
 		"multiedit",
 		"lsp_diagnostics",
 		"lsp_references",
+		"lsp_restart",
 		"fetch",
 		"agentic_fetch",
 		"glob",

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)

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
 

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), &params)
+
+	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)
+}

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)