on main help sign in
feat: lsp_restart (#1930)

* 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>

commit0d4cbf82ab6e3b93696e01d075357c2c3331ddfa authorCarlos Alexandro Becker <caarlos0@users.noreply.github.com> dateFri 23 Jan 2026, 13:55 (9 months ago) parentc682e1c
9 files changed+285−59
internal/agent/coordinator.go+1−1 internal/agent/tools/diagnostics.go+2−4 internal/agent/tools/lsp_restart.go+80 internal/agent/tools/lsp_restart.md+25 internal/config/config.go+1 internal/config/load_test.go+2−2 internal/lsp/client.go+110−52 internal/ui/chat/lsp_restart.go+62 internal/ui/chat/tools.go+2
internal/agent/coordinator.go+1−1
@@ -406,7 +406,7 @@ func (c *coordinator) buildTools(ctx context.Context, agent config.Agent) ([]fan
406406 )
407407
408408 if len(c.cfg.LSP) > 0 {
409− allTools = append(allTools, tools.NewDiagnosticsTool(c.lspClients), tools.NewReferencesTool(c.lspClients))
409+ allTools = append(allTools, tools.NewDiagnosticsTool(c.lspClients), tools.NewReferencesTool(c.lspClients), tools.NewLSPRestartTool(c.lspClients))
410410 }
411411
412412 var filteredTools []fantasy.AgentTool
internal/agent/tools/diagnostics.go+2−4
@@ -137,11 +137,9 @@ func formatDiagnostic(pth string, diagnostic protocol.Diagnostic, source string)
137137
138138 location := fmt.Sprintf("%s:%d:%d", pth, diagnostic.Range.Start.Line+1, diagnostic.Range.Start.Character+1)
139139
140− sourceInfo := ""
140+ sourceInfo := source
141141 if diagnostic.Source != "" {
142− sourceInfo = diagnostic.Source
143− } else if source != "" {
144− sourceInfo = source
142+ sourceInfo += " " + diagnostic.Source
145143 }
146144
147145 codeInfo := ""
internal/agent/tools/lsp_restart.gonew file+80
@@ -0,0 +1,80 @@
1+package tools
2+
3+import (
4+ "context"
5+ _ "embed"
6+ "fmt"
7+ "log/slog"
8+ "maps"
9+ "strings"
10+ "sync"
11+
12+ "charm.land/fantasy"
13+ "github.com/charmbracelet/crush/internal/csync"
14+ "github.com/charmbracelet/crush/internal/lsp"
15+)
16+
17+const LSPRestartToolName = "lsp_restart"
18+
19+//go:embed lsp_restart.md
20+var lspRestartDescription []byte
21+
22+type LSPRestartParams struct {
23+ // Name is the optional name of a specific LSP client to restart.
24+ // If empty, all LSP clients will be restarted.
25+ Name string `json:"name,omitempty"`
26+}
27+
28+func NewLSPRestartTool(lspClients *csync.Map[string, *lsp.Client]) fantasy.AgentTool {
29+ return fantasy.NewAgentTool(
30+ LSPRestartToolName,
31+ string(lspRestartDescription),
32+ func(ctx context.Context, params LSPRestartParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
33+ if lspClients.Len() == 0 {
34+ return fantasy.NewTextErrorResponse("no LSP clients available to restart"), nil
35+ }
36+
37+ clientsToRestart := make(map[string]*lsp.Client)
38+ if params.Name == "" {
39+ maps.Insert(clientsToRestart, lspClients.Seq2())
40+ } else {
41+ client, exists := lspClients.Get(params.Name)
42+ if !exists {
43+ return fantasy.NewTextErrorResponse(fmt.Sprintf("LSP client '%s' not found", params.Name)), nil
44+ }
45+ clientsToRestart[params.Name] = client
46+ }
47+
48+ var restarted []string
49+ var failed []string
50+ var mu sync.Mutex
51+ var wg sync.WaitGroup
52+ for name, client := range clientsToRestart {
53+ wg.Go(func() {
54+ if err := client.Restart(); err != nil {
55+ slog.Error("Failed to restart LSP client", "name", name, "error", err)
56+ mu.Lock()
57+ failed = append(failed, name)
58+ mu.Unlock()
59+ return
60+ }
61+ mu.Lock()
62+ restarted = append(restarted, name)
63+ mu.Unlock()
64+ })
65+ }
66+
67+ wg.Wait()
68+
69+ var output string
70+ if len(restarted) > 0 {
71+ output = fmt.Sprintf("Successfully restarted %d LSP client(s): %s\n", len(restarted), strings.Join(restarted, ", "))
72+ }
73+ if len(failed) > 0 {
74+ output += fmt.Sprintf("Failed to restart %d LSP client(s): %s\n", len(failed), strings.Join(failed, ", "))
75+ return fantasy.NewTextErrorResponse(output), nil
76+ }
77+
78+ return fantasy.NewTextResponse(output), nil
79+ })
80+}
internal/agent/tools/lsp_restart.mdnew file+25
@@ -0,0 +1,25 @@
1+Restart LSP (Language Server Protocol) clients.
2+
3+<usage>
4+- Restart all running LSP clients or a specific LSP client by name
5+- Useful when LSP servers become unresponsive or need to be reloaded
6+- Parameters:
7+ - name (optional): Specific LSP client name to restart. If not provided, all clients will be restarted.
8+</usage>
9+
10+<features>
11+- Gracefully shuts down all LSP clients
12+- Restarts them with their original configuration
13+- Reports success/failure for each client
14+</features>
15+
16+<limitations>
17+- Only restarts clients that were successfully started
18+- Does not modify LSP configurations
19+- Requires LSP clients to be already running
20+</limitations>
21+
22+<tips>
23+- Use when LSP diagnostics are stale or unresponsive
24+- Call this tool if you notice LSP features not working properly
25+</tips>
internal/config/config.go+1
@@ -696,6 +696,7 @@ func allToolNames() []string {
696696 "multiedit",
697697 "lsp_diagnostics",
698698 "lsp_references",
699+ "lsp_restart",
699700 "fetch",
700701 "agentic_fetch",
701702 "glob",
internal/config/load_test.go+2−2
@@ -486,7 +486,7 @@ func TestConfig_setupAgentsWithDisabledTools(t *testing.T) {
486486 coderAgent, ok := cfg.Agents[AgentCoder]
487487 require.True(t, ok)
488488
489− 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)
489+ 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)
490490
491491 taskAgent, ok := cfg.Agents[AgentTask]
492492 require.True(t, ok)
@@ -509,7 +509,7 @@ func TestConfig_setupAgentsWithEveryReadOnlyToolDisabled(t *testing.T) {
509509 cfg.SetupAgents()
510510 coderAgent, ok := cfg.Agents[AgentCoder]
511511 require.True(t, ok)
512− assert.Equal(t, []string{"agent", "bash", "job_output", "job_kill", "download", "edit", "multiedit", "lsp_diagnostics", "lsp_references", "fetch", "agentic_fetch", "todos", "write"}, coderAgent.AllowedTools)
512+ 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)
513513
514514 taskAgent, ok := cfg.Agents[AgentTask]
515515 require.True(t, ok)
internal/lsp/client.go+110−52
@@ -40,6 +40,10 @@ type Client struct {
4040 // Configuration for this LSP client
4141 config config.LSPConfig
4242
43+ // Original context and resolver for recreating the client
44+ ctx context.Context
45+ resolver config.VariableResolver
46+
4347 // Diagnostic change callback
4448 onDiagnosticsChanged func(name string, count int)
4549
@@ -59,58 +63,22 @@ type Client struct {
5963}
6064
6165// New creates a new LSP client using the powernap implementation.
62−func New(ctx context.Context, name string, config config.LSPConfig, resolver config.VariableResolver) (*Client, error) {
63− // Convert working directory to file URI
64− workDir, err := os.Getwd()
65− if err != nil {
66− return nil, fmt.Errorf("failed to get working directory: %w", err)
67− }
68−
69− rootURI := string(protocol.URIFromPath(workDir))
70−
71− command, err := resolver.ResolveValue(config.Command)
72− if err != nil {
73− return nil, fmt.Errorf("invalid lsp command: %w", err)
74− }
75−
76− // Create powernap client config
77− clientConfig := powernap.ClientConfig{
78− Command: home.Long(command),
79− Args: config.Args,
80− RootURI: rootURI,
81− Environment: func() map[string]string {
82− env := make(map[string]string)
83− maps.Copy(env, config.Env)
84− return env
85− }(),
86− Settings: config.Options,
87− InitOptions: config.InitOptions,
88− WorkspaceFolders: []protocol.WorkspaceFolder{
89− {
90− URI: rootURI,
91− Name: filepath.Base(workDir),
92− },
93− },
94− }
95−
96− // Create the powernap client
97− powernapClient, err := powernap.NewClient(clientConfig)
98− if err != nil {
99− return nil, fmt.Errorf("failed to create lsp client: %w", err)
100− }
101−
66+func New(ctx context.Context, name string, cfg config.LSPConfig, resolver config.VariableResolver) (*Client, error) {
10267 client := &Client{
103− client: powernapClient,
10468 name: name,
105− fileTypes: config.FileTypes,
69+ fileTypes: cfg.FileTypes,
10670 diagnostics: csync.NewVersionedMap[protocol.DocumentURI, []protocol.Diagnostic](),
10771 openFiles: csync.NewMap[string, *OpenFileInfo](),
108− config: config,
72+ config: cfg,
73+ ctx: ctx,
74+ resolver: resolver,
10975 }
110−
111− // Initialize server state
11276 client.serverState.Store(StateStarting)
11377
78+ if err := client.createPowernapClient(); err != nil {
79+ return nil, err
80+ }
81+
11482 return client, nil
11583}
11684
@@ -140,13 +108,7 @@ func (c *Client) Initialize(ctx context.Context, workspaceDir string) (*protocol
140108 Capabilities: protocolCaps,
141109 }
142110
143− c.RegisterServerRequestHandler("workspace/applyEdit", HandleApplyEdit)
144− c.RegisterServerRequestHandler("workspace/configuration", HandleWorkspaceConfiguration)
145− c.RegisterServerRequestHandler("client/registerCapability", HandleRegisterCapability)
146− c.RegisterNotificationHandler("window/showMessage", HandleServerMessage)
147− c.RegisterNotificationHandler("textDocument/publishDiagnostics", func(_ context.Context, _ string, params json.RawMessage) {
148− HandleDiagnostics(c, params)
149− })
111+ c.registerHandlers()
150112
151113 return result, nil
152114}
@@ -163,6 +125,102 @@ func (c *Client) Close(ctx context.Context) error {
163125 return c.client.Exit()
164126}
165127
128+// createPowernapClient creates a new powernap client with the current configuration.
129+func (c *Client) createPowernapClient() error {
130+ workDir, err := os.Getwd()
131+ if err != nil {
132+ return fmt.Errorf("failed to get working directory: %w", err)
133+ }
134+
135+ rootURI := string(protocol.URIFromPath(workDir))
136+
137+ command, err := c.resolver.ResolveValue(c.config.Command)
138+ if err != nil {
139+ return fmt.Errorf("invalid lsp command: %w", err)
140+ }
141+
142+ clientConfig := powernap.ClientConfig{
143+ Command: home.Long(command),
144+ Args: c.config.Args,
145+ RootURI: rootURI,
146+ Environment: maps.Clone(c.config.Env),
147+ Settings: c.config.Options,
148+ InitOptions: c.config.InitOptions,
149+ WorkspaceFolders: []protocol.WorkspaceFolder{
150+ {
151+ URI: rootURI,
152+ Name: filepath.Base(workDir),
153+ },
154+ },
155+ }
156+
157+ powernapClient, err := powernap.NewClient(clientConfig)
158+ if err != nil {
159+ return fmt.Errorf("failed to create lsp client: %w", err)
160+ }
161+
162+ c.client = powernapClient
163+ return nil
164+}
165+
166+// registerHandlers registers the standard LSP notification and request handlers.
167+func (c *Client) registerHandlers() {
168+ c.RegisterServerRequestHandler("workspace/applyEdit", HandleApplyEdit)
169+ c.RegisterServerRequestHandler("workspace/configuration", HandleWorkspaceConfiguration)
170+ c.RegisterServerRequestHandler("client/registerCapability", HandleRegisterCapability)
171+ c.RegisterNotificationHandler("window/showMessage", HandleServerMessage)
172+ c.RegisterNotificationHandler("textDocument/publishDiagnostics", func(_ context.Context, _ string, params json.RawMessage) {
173+ HandleDiagnostics(c, params)
174+ })
175+}
176+
177+// Restart closes the current LSP client and creates a new one with the same configuration.
178+func (c *Client) Restart() error {
179+ var openFiles []string
180+ for uri := range c.openFiles.Seq2() {
181+ openFiles = append(openFiles, string(uri))
182+ }
183+
184+ closeCtx, cancel := context.WithTimeout(c.ctx, 10*time.Second)
185+ defer cancel()
186+
187+ if err := c.Close(closeCtx); err != nil {
188+ slog.Warn("Error closing client during restart", "name", c.name, "error", err)
189+ }
190+
191+ c.diagCountsCache = DiagnosticCounts{}
192+ c.diagCountsVersion = 0
193+
194+ if err := c.createPowernapClient(); err != nil {
195+ return err
196+ }
197+
198+ initCtx, cancel := context.WithTimeout(c.ctx, 30*time.Second)
199+ defer cancel()
200+
201+ c.SetServerState(StateStarting)
202+
203+ if err := c.client.Initialize(initCtx, false); err != nil {
204+ c.SetServerState(StateError)
205+ return fmt.Errorf("failed to initialize lsp client: %w", err)
206+ }
207+
208+ c.registerHandlers()
209+
210+ if err := c.WaitForServerReady(initCtx); err != nil {
211+ slog.Error("Server failed to become ready after restart", "name", c.name, "error", err)
212+ c.SetServerState(StateError)
213+ return err
214+ }
215+
216+ for _, uri := range openFiles {
217+ if err := c.OpenFile(initCtx, uri); err != nil {
218+ slog.Warn("Failed to reopen file after restart", "file", uri, "error", err)
219+ }
220+ }
221+ return nil
222+}
223+
166224// ServerState represents the state of an LSP server
167225type ServerState int
168226
internal/ui/chat/lsp_restart.gonew file+62
@@ -0,0 +1,62 @@
1+package chat
2+
3+import (
4+ "encoding/json"
5+
6+ "github.com/charmbracelet/crush/internal/agent/tools"
7+ "github.com/charmbracelet/crush/internal/message"
8+ "github.com/charmbracelet/crush/internal/ui/styles"
9+)
10+
11+// LSPRestartToolMessageItem is a message item that represents a lsprestart tool call.
12+type LSPRestartToolMessageItem struct {
13+ *baseToolMessageItem
14+}
15+
16+var _ ToolMessageItem = (*LSPRestartToolMessageItem)(nil)
17+
18+// NewLSPRestartToolMessageItem creates a new [LSPRestartToolMessageItem].
19+func NewLSPRestartToolMessageItem(
20+ sty *styles.Styles,
21+ toolCall message.ToolCall,
22+ result *message.ToolResult,
23+ canceled bool,
24+) ToolMessageItem {
25+ return newBaseToolMessageItem(sty, toolCall, result, &LSPRestartToolRenderContext{}, canceled)
26+}
27+
28+// LSPRestartToolRenderContext renders lsprestart tool messages.
29+type LSPRestartToolRenderContext struct{}
30+
31+// RenderTool implements the [ToolRenderer] interface.
32+func (r *LSPRestartToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
33+ cappedWidth := cappedMessageWidth(width)
34+ if opts.IsPending() {
35+ return pendingTool(sty, "Restart LSP", opts.Anim)
36+ }
37+
38+ var params tools.LSPRestartParams
39+ _ = json.Unmarshal([]byte(opts.ToolCall.Input), &params)
40+
41+ toolParams := []string{}
42+ if params.Name != "" {
43+ toolParams = append(toolParams, params.Name)
44+ }
45+
46+ header := toolHeader(sty, opts.Status, "Restart LSP", cappedWidth, opts.Compact, toolParams...)
47+ if opts.Compact {
48+ return header
49+ }
50+
51+ if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok {
52+ return joinToolParts(header, earlyState)
53+ }
54+
55+ if opts.HasEmptyResult() {
56+ return header
57+ }
58+
59+ bodyWidth := cappedWidth - toolBodyLeftPaddingTotal
60+ body := sty.Tool.Body.Render(toolOutputPlainContent(sty, opts.Result.Content, bodyWidth, opts.ExpandedContent))
61+ return joinToolParts(header, body)
62+}
internal/ui/chat/tools.go+2
@@ -244,6 +244,8 @@ func NewToolMessageItem(
244244 item = NewTodosToolMessageItem(sty, toolCall, result, canceled)
245245 case tools.ReferencesToolName:
246246 item = NewReferencesToolMessageItem(sty, toolCall, result, canceled)
247+ case tools.LSPRestartToolName:
248+ item = NewLSPRestartToolMessageItem(sty, toolCall, result, canceled)
247249 default:
248250 if strings.HasPrefix(toolCall.Name, "mcp_") {
249251 item = NewMCPToolMessageItem(sty, toolCall, result, canceled)