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)