on main help sign in
feat(lsp): start LSPs on demand, improve auto-start (#2103)
02f66a2Carlos Alexandro Becker
go.mod+1−1
@@ -55,6 +55,7 @@ require (
55 github.com/rivo/uniseg v0.4.7
55 github.com/rivo/uniseg v0.4.7
56 github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06
56 github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06
57 github.com/sahilm/fuzzy v0.1.1
57 github.com/sahilm/fuzzy v0.1.1
58+ github.com/sourcegraph/jsonrpc2 v0.2.1
58 github.com/spf13/cobra v1.10.2
59 github.com/spf13/cobra v1.10.2
59 github.com/stretchr/testify v1.11.1
60 github.com/stretchr/testify v1.11.1
60 github.com/tidwall/gjson v1.18.0
61 github.com/tidwall/gjson v1.18.0
@@ -150,7 +151,6 @@ require (
150 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
151 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
151 github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
152 github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
152 github.com/sethvargo/go-retry v0.3.0 // indirect
153 github.com/sethvargo/go-retry v0.3.0 // indirect
153− github.com/sourcegraph/jsonrpc2 v0.2.1 // indirect
154 github.com/spf13/pflag v1.0.9 // indirect
154 github.com/spf13/pflag v1.0.9 // indirect
155 github.com/tetratelabs/wazero v1.11.0 // indirect
155 github.com/tetratelabs/wazero v1.11.0 // indirect
156 github.com/tidwall/match v1.1.1 // indirect
156 github.com/tidwall/match v1.1.1 // indirect
internal/agent/agentic_fetch_tool.go+1−1
@@ -169,7 +169,7 @@ func (c *coordinator) agenticFetchTool(_ context.Context, client *http.Client) (
169 tools.NewGlobTool(tmpDir),
169 tools.NewGlobTool(tmpDir),
170 tools.NewGrepTool(tmpDir),
170 tools.NewGrepTool(tmpDir),
171 tools.NewSourcegraphTool(client),
171 tools.NewSourcegraphTool(client),
172− tools.NewViewTool(c.lspClients, c.permissions, c.filetracker, tmpDir),
172+ tools.NewViewTool(c.lspManager, c.permissions, c.filetracker, tmpDir),
173 }
173 }
174
174
175 agent := NewSessionAgent(SessionAgentOptions{
175 agent := NewSessionAgent(SessionAgentOptions{
internal/agent/common_test.go+4−4
@@ -204,15 +204,15 @@ func coderAgent(r *vcr.Recorder, env fakeEnv, large, small fantasy.LanguageModel
204 allTools := []fantasy.AgentTool{
204 allTools := []fantasy.AgentTool{
205 tools.NewBashTool(env.permissions, env.workingDir, cfg.Options.Attribution, modelName),
205 tools.NewBashTool(env.permissions, env.workingDir, cfg.Options.Attribution, modelName),
206 tools.NewDownloadTool(env.permissions, env.workingDir, r.GetDefaultClient()),
206 tools.NewDownloadTool(env.permissions, env.workingDir, r.GetDefaultClient()),
207− tools.NewEditTool(env.lspClients, env.permissions, env.history, *env.filetracker, env.workingDir),
207+ tools.NewEditTool(nil, env.permissions, env.history, *env.filetracker, env.workingDir),
208− tools.NewMultiEditTool(env.lspClients, env.permissions, env.history, *env.filetracker, env.workingDir),
208+ tools.NewMultiEditTool(nil, env.permissions, env.history, *env.filetracker, env.workingDir),
209 tools.NewFetchTool(env.permissions, env.workingDir, r.GetDefaultClient()),
209 tools.NewFetchTool(env.permissions, env.workingDir, r.GetDefaultClient()),
210 tools.NewGlobTool(env.workingDir),
210 tools.NewGlobTool(env.workingDir),
211 tools.NewGrepTool(env.workingDir),
211 tools.NewGrepTool(env.workingDir),
212 tools.NewLsTool(env.permissions, env.workingDir, cfg.Tools.Ls),
212 tools.NewLsTool(env.permissions, env.workingDir, cfg.Tools.Ls),
213 tools.NewSourcegraphTool(r.GetDefaultClient()),
213 tools.NewSourcegraphTool(r.GetDefaultClient()),
214− tools.NewViewTool(env.lspClients, env.permissions, *env.filetracker, env.workingDir),
214+ tools.NewViewTool(nil, env.permissions, *env.filetracker, env.workingDir),
215− tools.NewWriteTool(env.lspClients, env.permissions, env.history, *env.filetracker, env.workingDir),
215+ tools.NewWriteTool(nil, env.permissions, env.history, *env.filetracker, env.workingDir),
216 }
216 }
217
217
218 return testSessionAgent(env, large, small, systemPrompt, allTools...), nil
218 return testSessionAgent(env, large, small, systemPrompt, allTools...), nil
internal/agent/coordinator.go+10−10
@@ -21,7 +21,6 @@ import (
21 "github.com/charmbracelet/crush/internal/agent/prompt"
21 "github.com/charmbracelet/crush/internal/agent/prompt"
22 "github.com/charmbracelet/crush/internal/agent/tools"
22 "github.com/charmbracelet/crush/internal/agent/tools"
23 "github.com/charmbracelet/crush/internal/config"
23 "github.com/charmbracelet/crush/internal/config"
24− "github.com/charmbracelet/crush/internal/csync"
25 "github.com/charmbracelet/crush/internal/filetracker"
24 "github.com/charmbracelet/crush/internal/filetracker"
26 "github.com/charmbracelet/crush/internal/history"
25 "github.com/charmbracelet/crush/internal/history"
27 "github.com/charmbracelet/crush/internal/log"
26 "github.com/charmbracelet/crush/internal/log"
@@ -67,7 +66,7 @@ type coordinator struct {
67 permissions permission.Service
66 permissions permission.Service
68 history history.Service
67 history history.Service
69 filetracker filetracker.Service
68 filetracker filetracker.Service
70− lspClients *csync.Map[string, *lsp.Client]
69+ lspManager *lsp.Manager
71
70
72 currentAgent SessionAgent
71 currentAgent SessionAgent
73 agents map[string]SessionAgent
72 agents map[string]SessionAgent
@@ -83,7 +82,7 @@ func NewCoordinator(
83 permissions permission.Service,
82 permissions permission.Service,
84 history history.Service,
83 history history.Service,
85 filetracker filetracker.Service,
84 filetracker filetracker.Service,
86− lspClients *csync.Map[string, *lsp.Client],
85+ lspManager *lsp.Manager,
87) (Coordinator, error) {
86) (Coordinator, error) {
88 c := &coordinator{
87 c := &coordinator{
89 cfg: cfg,
88 cfg: cfg,
@@ -92,7 +91,7 @@ func NewCoordinator(
92 permissions: permissions,
91 permissions: permissions,
93 history: history,
92 history: history,
94 filetracker: filetracker,
93 filetracker: filetracker,
95− lspClients: lspClients,
94+ lspManager: lspManager,
96 agents: make(map[string]SessionAgent),
95 agents: make(map[string]SessionAgent),
97 }
96 }
98
97
@@ -423,20 +422,21 @@ func (c *coordinator) buildTools(ctx context.Context, agent config.Agent) ([]fan
423 tools.NewJobOutputTool(),
422 tools.NewJobOutputTool(),
424 tools.NewJobKillTool(),
423 tools.NewJobKillTool(),
425 tools.NewDownloadTool(c.permissions, c.cfg.WorkingDir(), nil),
424 tools.NewDownloadTool(c.permissions, c.cfg.WorkingDir(), nil),
426− tools.NewEditTool(c.lspClients, c.permissions, c.history, c.filetracker, c.cfg.WorkingDir()),
425+ tools.NewEditTool(c.lspManager, c.permissions, c.history, c.filetracker, c.cfg.WorkingDir()),
427− tools.NewMultiEditTool(c.lspClients, c.permissions, c.history, c.filetracker, c.cfg.WorkingDir()),
426+ tools.NewMultiEditTool(c.lspManager, c.permissions, c.history, c.filetracker, c.cfg.WorkingDir()),
428 tools.NewFetchTool(c.permissions, c.cfg.WorkingDir(), nil),
427 tools.NewFetchTool(c.permissions, c.cfg.WorkingDir(), nil),
429 tools.NewGlobTool(c.cfg.WorkingDir()),
428 tools.NewGlobTool(c.cfg.WorkingDir()),
430 tools.NewGrepTool(c.cfg.WorkingDir()),
429 tools.NewGrepTool(c.cfg.WorkingDir()),
431 tools.NewLsTool(c.permissions, c.cfg.WorkingDir(), c.cfg.Tools.Ls),
430 tools.NewLsTool(c.permissions, c.cfg.WorkingDir(), c.cfg.Tools.Ls),
432 tools.NewSourcegraphTool(nil),
431 tools.NewSourcegraphTool(nil),
433 tools.NewTodosTool(c.sessions),
432 tools.NewTodosTool(c.sessions),
434− tools.NewViewTool(c.lspClients, c.permissions, c.filetracker, c.cfg.WorkingDir(), c.cfg.Options.SkillsPaths...),
433+ tools.NewViewTool(c.lspManager, c.permissions, c.filetracker, c.cfg.WorkingDir(), c.cfg.Options.SkillsPaths...),
435− tools.NewWriteTool(c.lspClients, c.permissions, c.history, c.filetracker, c.cfg.WorkingDir()),
434+ tools.NewWriteTool(c.lspManager, c.permissions, c.history, c.filetracker, c.cfg.WorkingDir()),
436 )
435 )
437
436
438− if c.lspClients.Len() > 0 {
437+ // Add LSP tools if user has configured LSPs or auto_lsp is enabled (nil or true).
438+ if len(c.cfg.LSP) > 0 || c.cfg.Options.AutoLSP == nil || *c.cfg.Options.AutoLSP {
439− allTools = append(allTools, tools.NewDiagnosticsTool(c.lspClients), tools.NewReferencesTool(c.lspClients), tools.NewLSPRestartTool(c.lspClients))
439+ allTools = append(allTools, tools.NewDiagnosticsTool(c.lspManager), tools.NewReferencesTool(c.lspManager), tools.NewLSPRestartTool(c.lspManager))
440 }
440 }
441
441
442 if len(c.cfg.MCP) > 0 {
442 if len(c.cfg.MCP) > 0 {
internal/agent/tools/diagnostics.go+23−9
@@ -10,7 +10,6 @@ import (
10 "time"
10 "time"
11
11
12 "charm.land/fantasy"
12 "charm.land/fantasy"
13− "github.com/charmbracelet/crush/internal/csync"
14 "github.com/charmbracelet/crush/internal/lsp"
13 "github.com/charmbracelet/crush/internal/lsp"
15 "github.com/charmbracelet/x/powernap/pkg/lsp/protocol"
14 "github.com/charmbracelet/x/powernap/pkg/lsp/protocol"
16)
15)
@@ -24,25 +23,36 @@ const DiagnosticsToolName = "lsp_diagnostics"
24//go:embed diagnostics.md
23//go:embed diagnostics.md
25var diagnosticsDescription []byte
24var diagnosticsDescription []byte
26
25
27−func NewDiagnosticsTool(lspClients *csync.Map[string, *lsp.Client]) fantasy.AgentTool {
26+func NewDiagnosticsTool(lspManager *lsp.Manager) fantasy.AgentTool {
28 return fantasy.NewAgentTool(
27 return fantasy.NewAgentTool(
29 DiagnosticsToolName,
28 DiagnosticsToolName,
30 string(diagnosticsDescription),
29 string(diagnosticsDescription),
31 func(ctx context.Context, params DiagnosticsParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
30 func(ctx context.Context, params DiagnosticsParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
32− if lspClients.Len() == 0 {
31+ if lspManager.Clients().Len() == 0 {
33 return fantasy.NewTextErrorResponse("no LSP clients available"), nil
32 return fantasy.NewTextErrorResponse("no LSP clients available"), nil
34 }
33 }
35− notifyLSPs(ctx, lspClients, params.FilePath)
34+ notifyLSPs(ctx, lspManager, params.FilePath)
36− output := getDiagnostics(params.FilePath, lspClients)
35+ output := getDiagnostics(params.FilePath, lspManager)
37 return fantasy.NewTextResponse(output), nil
36 return fantasy.NewTextResponse(output), nil
38 })
37 })
39}
38}
40
39
41−func notifyLSPs(ctx context.Context, lsps *csync.Map[string, *lsp.Client], filepath string) {
40+func notifyLSPs(
41+ ctx context.Context,
42+ manager *lsp.Manager,
43+ filepath string,
44+) {
42 if filepath == "" {
45 if filepath == "" {
43 return
46 return
44 }
47 }
48+
49+ if manager == nil {
50+ return
51+ }
52+
53+ manager.Start(ctx, filepath)
54+
45− for client := range lsps.Seq() {
55+ for client := range manager.Clients().Seq() {
46 if !client.HandlesFile(filepath) {
56 if !client.HandlesFile(filepath) {
47 continue
57 continue
48 }
58 }
@@ -52,11 +62,15 @@ func notifyLSPs(ctx context.Context, lsps *csync.Map[string, *lsp.Client], filep
52 }
62 }
53}
63}
54
64
55−func getDiagnostics(filePath string, lsps *csync.Map[string, *lsp.Client]) string {
65+func getDiagnostics(filePath string, manager *lsp.Manager) string {
66+ if manager == nil {
67+ return ""
68+ }
69+
56 fileDiagnostics := []string{}
70 fileDiagnostics := []string{}
57 projectDiagnostics := []string{}
71 projectDiagnostics := []string{}
58
72
59− for lspName, client := range lsps.Seq2() {
73+ for lspName, client := range manager.Clients().Seq2() {
60 for location, diags := range client.GetDiagnostics() {
74 for location, diags := range client.GetDiagnostics() {
61 path, err := location.Path()
75 path, err := location.Path()
62 if err != nil {
76 if err != nil {
internal/agent/tools/edit.go+3−4
@@ -11,7 +11,6 @@ import (
11 "time"
11 "time"
12
12
13 "charm.land/fantasy"
13 "charm.land/fantasy"
14− "github.com/charmbracelet/crush/internal/csync"
15 "github.com/charmbracelet/crush/internal/diff"
14 "github.com/charmbracelet/crush/internal/diff"
16 "github.com/charmbracelet/crush/internal/filepathext"
15 "github.com/charmbracelet/crush/internal/filepathext"
17 "github.com/charmbracelet/crush/internal/filetracker"
16 "github.com/charmbracelet/crush/internal/filetracker"
@@ -61,7 +60,7 @@ type editContext struct {
61}
60}
62
61
63func NewEditTool(
62func NewEditTool(
64− lspClients *csync.Map[string, *lsp.Client],
63+ lspManager *lsp.Manager,
65 permissions permission.Service,
64 permissions permission.Service,
66 files history.Service,
65 files history.Service,
67 filetracker filetracker.Service,
66 filetracker filetracker.Service,
@@ -99,10 +98,10 @@ func NewEditTool(
99 return response, nil
98 return response, nil
100 }
99 }
101
100
102− notifyLSPs(ctx, lspClients, params.FilePath)
101+ notifyLSPs(ctx, lspManager, params.FilePath)
103
102
104 text := fmt.Sprintf("<result>\n%s\n</result>\n", response.Content)
103 text := fmt.Sprintf("<result>\n%s\n</result>\n", response.Content)
105− text += getDiagnostics(params.FilePath, lspClients)
104+ text += getDiagnostics(params.FilePath, lspManager)
106 response.Content = text
105 response.Content = text
107 return response, nil
106 return response, nil
108 })
107 })
internal/agent/tools/lsp_restart.go+4−5
@@ -10,7 +10,6 @@ import (
10 "sync"
10 "sync"
11
11
12 "charm.land/fantasy"
12 "charm.land/fantasy"
13− "github.com/charmbracelet/crush/internal/csync"
14 "github.com/charmbracelet/crush/internal/lsp"
13 "github.com/charmbracelet/crush/internal/lsp"
15)
14)
16
15
@@ -25,20 +24,20 @@ type LSPRestartParams struct {
25 Name string `json:"name,omitempty"`
24 Name string `json:"name,omitempty"`
26}
25}
27
26
28−func NewLSPRestartTool(lspClients *csync.Map[string, *lsp.Client]) fantasy.AgentTool {
27+func NewLSPRestartTool(lspManager *lsp.Manager) fantasy.AgentTool {
29 return fantasy.NewAgentTool(
28 return fantasy.NewAgentTool(
30 LSPRestartToolName,
29 LSPRestartToolName,
31 string(lspRestartDescription),
30 string(lspRestartDescription),
32 func(ctx context.Context, params LSPRestartParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
31 func(ctx context.Context, params LSPRestartParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
33− if lspClients.Len() == 0 {
32+ if lspManager.Clients().Len() == 0 {
34 return fantasy.NewTextErrorResponse("no LSP clients available to restart"), nil
33 return fantasy.NewTextErrorResponse("no LSP clients available to restart"), nil
35 }
34 }
36
35
37 clientsToRestart := make(map[string]*lsp.Client)
36 clientsToRestart := make(map[string]*lsp.Client)
38 if params.Name == "" {
37 if params.Name == "" {
39− maps.Insert(clientsToRestart, lspClients.Seq2())
38+ maps.Insert(clientsToRestart, lspManager.Clients().Seq2())
40 } else {
39 } else {
41− client, exists := lspClients.Get(params.Name)
40+ client, exists := lspManager.Clients().Get(params.Name)
42 if !exists {
41 if !exists {
43 return fantasy.NewTextErrorResponse(fmt.Sprintf("LSP client '%s' not found", params.Name)), nil
42 return fantasy.NewTextErrorResponse(fmt.Sprintf("LSP client '%s' not found", params.Name)), nil
44 }
43 }
internal/agent/tools/multiedit.go+3−4
@@ -11,7 +11,6 @@ import (
11 "time"
11 "time"
12
12
13 "charm.land/fantasy"
13 "charm.land/fantasy"
14− "github.com/charmbracelet/crush/internal/csync"
15 "github.com/charmbracelet/crush/internal/diff"
14 "github.com/charmbracelet/crush/internal/diff"
16 "github.com/charmbracelet/crush/internal/filepathext"
15 "github.com/charmbracelet/crush/internal/filepathext"
17 "github.com/charmbracelet/crush/internal/filetracker"
16 "github.com/charmbracelet/crush/internal/filetracker"
@@ -59,7 +58,7 @@ const MultiEditToolName = "multiedit"
59var multieditDescription []byte
58var multieditDescription []byte
60
59
61func NewMultiEditTool(
60func NewMultiEditTool(
62− lspClients *csync.Map[string, *lsp.Client],
61+ lspManager *lsp.Manager,
63 permissions permission.Service,
62 permissions permission.Service,
64 files history.Service,
63 files history.Service,
65 filetracker filetracker.Service,
64 filetracker filetracker.Service,
@@ -104,11 +103,11 @@ func NewMultiEditTool(
104 }
103 }
105
104
106 // Notify LSP clients about the change
105 // Notify LSP clients about the change
107− notifyLSPs(ctx, lspClients, params.FilePath)
106+ notifyLSPs(ctx, lspManager, params.FilePath)
108
107
109 // Wait for LSP diagnostics and add them to the response
108 // Wait for LSP diagnostics and add them to the response
110 text := fmt.Sprintf("<result>\n%s\n</result>\n", response.Content)
109 text := fmt.Sprintf("<result>\n%s\n</result>\n", response.Content)
111− text += getDiagnostics(params.FilePath, lspClients)
110+ text += getDiagnostics(params.FilePath, lspManager)
112 response.Content = text
111 response.Content = text
113 return response, nil
112 return response, nil
114 })
113 })
internal/agent/tools/references.go+6−7
@@ -15,7 +15,6 @@ import (
15 "strings"
15 "strings"
16
16
17 "charm.land/fantasy"
17 "charm.land/fantasy"
18− "github.com/charmbracelet/crush/internal/csync"
19 "github.com/charmbracelet/crush/internal/lsp"
18 "github.com/charmbracelet/crush/internal/lsp"
20 "github.com/charmbracelet/x/powernap/pkg/lsp/protocol"
19 "github.com/charmbracelet/x/powernap/pkg/lsp/protocol"
21)
20)
@@ -26,7 +25,7 @@ type ReferencesParams struct {
26}
25}
27
26
28type referencesTool struct {
27type referencesTool struct {
29− lspClients *csync.Map[string, *lsp.Client]
28+ lspManager *lsp.Manager
30}
29}
31
30
32const ReferencesToolName = "lsp_references"
31const ReferencesToolName = "lsp_references"
@@ -34,7 +33,7 @@ const ReferencesToolName = "lsp_references"
34//go:embed references.md
33//go:embed references.md
35var referencesDescription []byte
34var referencesDescription []byte
36
35
37−func NewReferencesTool(lspClients *csync.Map[string, *lsp.Client]) fantasy.AgentTool {
36+func NewReferencesTool(lspManager *lsp.Manager) fantasy.AgentTool {
38 return fantasy.NewAgentTool(
37 return fantasy.NewAgentTool(
39 ReferencesToolName,
38 ReferencesToolName,
40 string(referencesDescription),
39 string(referencesDescription),
@@ -43,7 +42,7 @@ func NewReferencesTool(lspClients *csync.Map[string, *lsp.Client]) fantasy.Agent
43 return fantasy.NewTextErrorResponse("symbol is required"), nil
42 return fantasy.NewTextErrorResponse("symbol is required"), nil
44 }
43 }
45
44
46− if lspClients.Len() == 0 {
45+ if lspManager.Clients().Len() == 0 {
47 return fantasy.NewTextErrorResponse("no LSP clients available"), nil
46 return fantasy.NewTextErrorResponse("no LSP clients available"), nil
48 }
47 }
49
48
@@ -61,7 +60,7 @@ func NewReferencesTool(lspClients *csync.Map[string, *lsp.Client]) fantasy.Agent
61 var allLocations []protocol.Location
60 var allLocations []protocol.Location
62 var allErrs error
61 var allErrs error
63 for _, match := range matches {
62 for _, match := range matches {
64− locations, err := find(ctx, lspClients, params.Symbol, match)
63+ locations, err := find(ctx, lspManager, params.Symbol, match)
65 if err != nil {
64 if err != nil {
66 if strings.Contains(err.Error(), "no identifier found") {
65 if strings.Contains(err.Error(), "no identifier found") {
67 // grep probably matched a comment, string value, or something else that's irrelevant
66 // grep probably matched a comment, string value, or something else that's irrelevant
@@ -91,14 +90,14 @@ func (r *referencesTool) Name() string {
91 return ReferencesToolName
90 return ReferencesToolName
92}
91}
93
92
94−func find(ctx context.Context, lspClients *csync.Map[string, *lsp.Client], symbol string, match grepMatch) ([]protocol.Location, error) {
93+func find(ctx context.Context, lspManager *lsp.Manager, symbol string, match grepMatch) ([]protocol.Location, error) {
95 absPath, err := filepath.Abs(match.path)
94 absPath, err := filepath.Abs(match.path)
96 if err != nil {
95 if err != nil {
97 return nil, fmt.Errorf("failed to get absolute path: %s", err)
96 return nil, fmt.Errorf("failed to get absolute path: %s", err)
98 }
97 }
99
98
100 var client *lsp.Client
99 var client *lsp.Client
101− for c := range lspClients.Seq() {
100+ for c := range lspManager.Clients().Seq() {
102 if c.HandlesFile(absPath) {
101 if c.HandlesFile(absPath) {
103 client = c
102 client = c
104 break
103 break
internal/agent/tools/view.go+3−4
@@ -13,7 +13,6 @@ import (
13 "unicode/utf8"
13 "unicode/utf8"
14
14
15 "charm.land/fantasy"
15 "charm.land/fantasy"
16− "github.com/charmbracelet/crush/internal/csync"
17 "github.com/charmbracelet/crush/internal/filepathext"
16 "github.com/charmbracelet/crush/internal/filepathext"
18 "github.com/charmbracelet/crush/internal/filetracker"
17 "github.com/charmbracelet/crush/internal/filetracker"
19 "github.com/charmbracelet/crush/internal/lsp"
18 "github.com/charmbracelet/crush/internal/lsp"
@@ -48,7 +47,7 @@ const (
48)
47)
49
48
50func NewViewTool(
49func NewViewTool(
51− lspClients *csync.Map[string, *lsp.Client],
50+ lspManager *lsp.Manager,
52 permissions permission.Service,
51 permissions permission.Service,
53 filetracker filetracker.Service,
52 filetracker filetracker.Service,
54 workingDir string,
53 workingDir string,
@@ -184,7 +183,7 @@ func NewViewTool(
184 return fantasy.ToolResponse{}, fmt.Errorf("error reading file: %w", err)
183 return fantasy.ToolResponse{}, fmt.Errorf("error reading file: %w", err)
185 }
184 }
186
185
187− notifyLSPs(ctx, lspClients, filePath)
186+ notifyLSPs(ctx, lspManager, filePath)
188 output := "<file>\n"
187 output := "<file>\n"
189 // Format the output with line numbers
188 // Format the output with line numbers
190 output += addLineNumbers(content, params.Offset+1)
189 output += addLineNumbers(content, params.Offset+1)
@@ -195,7 +194,7 @@ func NewViewTool(
195 params.Offset+len(strings.Split(content, "\n")))
194 params.Offset+len(strings.Split(content, "\n")))
196 }
195 }
197 output += "\n</file>\n"
196 output += "\n</file>\n"
198− output += getDiagnostics(filePath, lspClients)
197+ output += getDiagnostics(filePath, lspManager)
199 filetracker.RecordRead(ctx, sessionID, filePath)
198 filetracker.RecordRead(ctx, sessionID, filePath)
200 return fantasy.WithResponseMetadata(
199 return fantasy.WithResponseMetadata(
201 fantasy.NewTextResponse(output),
200 fantasy.NewTextResponse(output),
internal/agent/tools/write.go+3−4
@@ -11,7 +11,6 @@ import (
11 "time"
11 "time"
12
12
13 "charm.land/fantasy"
13 "charm.land/fantasy"
14− "github.com/charmbracelet/crush/internal/csync"
15 "github.com/charmbracelet/crush/internal/diff"
14 "github.com/charmbracelet/crush/internal/diff"
16 "github.com/charmbracelet/crush/internal/filepathext"
15 "github.com/charmbracelet/crush/internal/filepathext"
17 "github.com/charmbracelet/crush/internal/filetracker"
16 "github.com/charmbracelet/crush/internal/filetracker"
@@ -45,7 +44,7 @@ type WriteResponseMetadata struct {
45const WriteToolName = "write"
44const WriteToolName = "write"
46
45
47func NewWriteTool(
46func NewWriteTool(
48− lspClients *csync.Map[string, *lsp.Client],
47+ lspManager *lsp.Manager,
49 permissions permission.Service,
48 permissions permission.Service,
50 files history.Service,
49 files history.Service,
51 filetracker filetracker.Service,
50 filetracker filetracker.Service,
@@ -161,11 +160,11 @@ func NewWriteTool(
161
160
162 filetracker.RecordRead(ctx, sessionID, filePath)
161 filetracker.RecordRead(ctx, sessionID, filePath)
163
162
164− notifyLSPs(ctx, lspClients, params.FilePath)
163+ notifyLSPs(ctx, lspManager, params.FilePath)
165
164
166 result := fmt.Sprintf("File successfully written: %s", filePath)
165 result := fmt.Sprintf("File successfully written: %s", filePath)
167 result = fmt.Sprintf("<result>\n%s\n</result>", result)
166 result = fmt.Sprintf("<result>\n%s\n</result>", result)
168− result += getDiagnostics(filePath, lspClients)
167+ result += getDiagnostics(filePath, lspManager)
169 return fantasy.WithResponseMetadata(fantasy.NewTextResponse(result),
168 return fantasy.WithResponseMetadata(fantasy.NewTextResponse(result),
170 WriteResponseMetadata{
169 WriteResponseMetadata{
171 Diff: diff,
170 Diff: diff,
internal/app/app.go+13−17
@@ -21,7 +21,6 @@ import (
21 "github.com/charmbracelet/crush/internal/agent"
21 "github.com/charmbracelet/crush/internal/agent"
22 "github.com/charmbracelet/crush/internal/agent/tools/mcp"
22 "github.com/charmbracelet/crush/internal/agent/tools/mcp"
23 "github.com/charmbracelet/crush/internal/config"
23 "github.com/charmbracelet/crush/internal/config"
24− "github.com/charmbracelet/crush/internal/csync"
25 "github.com/charmbracelet/crush/internal/db"
24 "github.com/charmbracelet/crush/internal/db"
26 "github.com/charmbracelet/crush/internal/filetracker"
25 "github.com/charmbracelet/crush/internal/filetracker"
27 "github.com/charmbracelet/crush/internal/format"
26 "github.com/charmbracelet/crush/internal/format"
@@ -58,7 +57,7 @@ type App struct {
58
57
59 AgentCoordinator agent.Coordinator
58 AgentCoordinator agent.Coordinator
60
59
61− LSPClients *csync.Map[string, *lsp.Client]
60+ LSPManager *lsp.Manager
62
61
63 config *config.Config
62 config *config.Config
64
63
@@ -90,7 +89,7 @@ func New(ctx context.Context, conn *sql.DB, cfg *config.Config) (*App, error) {
90 History: files,
89 History: files,
91 Permissions: permission.NewPermissionService(cfg.WorkingDir(), skipPermissionsRequests, allowedTools),
90 Permissions: permission.NewPermissionService(cfg.WorkingDir(), skipPermissionsRequests, allowedTools),
92 FileTracker: filetracker.NewService(q),
91 FileTracker: filetracker.NewService(q),
93− LSPClients: csync.NewMap[string, *lsp.Client](),
92+ LSPManager: lsp.NewManager(cfg),
94
93
95 globalCtx: ctx,
94 globalCtx: ctx,
96
95
@@ -103,9 +102,6 @@ func New(ctx context.Context, conn *sql.DB, cfg *config.Config) (*App, error) {
103
102
104 app.setupEvents()
103 app.setupEvents()
105
104
106− // Initialize LSP clients in the background.
107− go app.initLSPClients(ctx)
108−
109 // Check for updates in the background.
105 // Check for updates in the background.
110 go app.checkForUpdates(ctx)
106 go app.checkForUpdates(ctx)
111
107
@@ -122,6 +118,13 @@ func New(ctx context.Context, conn *sql.DB, cfg *config.Config) (*App, error) {
122 if err := app.InitCoderAgent(ctx); err != nil {
118 if err := app.InitCoderAgent(ctx); err != nil {
123 return nil, fmt.Errorf("failed to initialize coder agent: %w", err)
119 return nil, fmt.Errorf("failed to initialize coder agent: %w", err)
124 }
120 }
121+
122+ // Set up callback for LSP state updates.
123+ app.LSPManager.SetCallback(func(name string, client *lsp.Client) {
124+ client.SetDiagnosticsCallback(updateLSPDiagnostics)
125+ updateLSPState(name, client.GetServerState(), nil, client, 0)
126+ })
127+
125 return app, nil
128 return app, nil
126}
129}
127
130
@@ -482,7 +485,7 @@ func (app *App) InitCoderAgent(ctx context.Context) error {
482 app.Permissions,
485 app.Permissions,
483 app.History,
486 app.History,
484 app.FileTracker,
487 app.FileTracker,
485− app.LSPClients,
488+ app.LSPManager,
486 )
489 )
487 if err != nil {
490 if err != nil {
488 slog.Error("Failed to create coder agent", "err", err)
491 slog.Error("Failed to create coder agent", "err", err)
@@ -545,16 +548,9 @@ func (app *App) Shutdown() {
545 // Shutdown all LSP clients.
548 // Shutdown all LSP clients.
546 shutdownCtx, cancel := context.WithTimeout(app.globalCtx, 5*time.Second)
549 shutdownCtx, cancel := context.WithTimeout(app.globalCtx, 5*time.Second)
547 defer cancel()
550 defer cancel()
548− for name, client := range app.LSPClients.Seq2() {
549− wg.Go(func() {
551+ wg.Go(func() {
550− if err := client.Close(shutdownCtx); err != nil &&
552+ app.LSPManager.StopAll(shutdownCtx)
551− !errors.Is(err, io.EOF) &&
552− !errors.Is(err, context.Canceled) &&
553− err.Error() != "signal: killed" {
554− slog.Warn("Failed to shutdown LSP client", "name", name, "error", err)
555− }
556− })
553+ })
557− }
558
554
559 // Call all cleanup functions.
555 // Call all cleanup functions.
560 for _, cleanup := range app.cleanupFuncs {
556 for _, cleanup := range app.cleanupFuncs {
internal/app/lsp.godeleted−163
@@ -1,163 +0,0 @@
1−package app
2−
3−import (
4− "cmp"
5− "context"
6− "log/slog"
7− "os/exec"
8− "slices"
9− "sync"
10− "time"
11−
12− "github.com/charmbracelet/crush/internal/config"
13− "github.com/charmbracelet/crush/internal/lsp"
14− powernapconfig "github.com/charmbracelet/x/powernap/pkg/config"
15−)
16−
17−// initLSPClients initializes LSP clients.
18−func (app *App) initLSPClients(ctx context.Context) {
19− slog.Info("LSP clients initialization started")
20−
21− manager := powernapconfig.NewManager()
22− manager.LoadDefaults()
23−
24− var userConfiguredLSPs []string
25− for name, clientConfig := range app.config.LSP {
26− if clientConfig.Disabled {
27− slog.Info("Skipping disabled LSP client", "name", name)
28− manager.RemoveServer(name)
29− continue
30− }
31−
32− // HACK: the user might have the command name in their config, instead
33− // of the actual name. This finds out these cases, and adjusts the name
34− // accordingly.
35− if _, ok := manager.GetServer(name); !ok {
36− for sname, server := range manager.GetServers() {
37− if server.Command == name {
38− name = sname
39− break
40− }
41− }
42− }
43− userConfiguredLSPs = append(userConfiguredLSPs, name)
44− manager.AddServer(name, &powernapconfig.ServerConfig{
45− Command: clientConfig.Command,
46− Args: clientConfig.Args,
47− Environment: clientConfig.Env,
48− FileTypes: clientConfig.FileTypes,
49− RootMarkers: clientConfig.RootMarkers,
50− InitOptions: clientConfig.InitOptions,
51− Settings: clientConfig.Options,
52− })
53− }
54−
55− servers := manager.GetServers()
56− filtered := lsp.FilterMatching(app.config.WorkingDir(), servers)
57−
58− for _, name := range userConfiguredLSPs {
59− if _, ok := filtered[name]; !ok {
60− updateLSPState(name, lsp.StateDisabled, nil, nil, 0)
61− }
62− }
63−
64− var wg sync.WaitGroup
65− for name, server := range filtered {
66− if app.config.Options.AutoLSP != nil && !*app.config.Options.AutoLSP && !slices.Contains(userConfiguredLSPs, name) {
67− slog.Debug("Ignoring non user-define LSP client due to AutoLSP being disabled", "name", name)
68− continue
69− }
70− wg.Go(func() {
71− app.createAndStartLSPClient(
72− ctx, name,
73− toOurConfig(server, app.config.LSP[name]),
74− slices.Contains(userConfiguredLSPs, name),
75− )
76− })
77− }
78− wg.Wait()
79−
80− if app.AgentCoordinator != nil {
81− if err := app.AgentCoordinator.UpdateModels(ctx); err != nil {
82− slog.Error("Failed to refresh tools after LSP startup", "error", err)
83− }
84− }
85−}
86−
87−// toOurConfig merges powernap default config with user config.
88−// If user config is zero value, it means no user override exists.
89−func toOurConfig(in *powernapconfig.ServerConfig, user config.LSPConfig) config.LSPConfig {
90− return config.LSPConfig{
91− Command: in.Command,
92− Args: in.Args,
93− Env: in.Environment,
94− FileTypes: in.FileTypes,
95− RootMarkers: in.RootMarkers,
96− InitOptions: in.InitOptions,
97− Options: in.Settings,
98− Timeout: user.Timeout,
99− }
100−}
101−
102−// createAndStartLSPClient creates a new LSP client, initializes it, and starts its workspace watcher.
103−func (app *App) createAndStartLSPClient(ctx context.Context, name string, config config.LSPConfig, userConfigured bool) {
104− if !userConfigured {
105− if _, err := exec.LookPath(config.Command); err != nil {
106− slog.Warn("Default LSP config skipped: server not installed", "name", name, "error", err)
107− return
108− }
109− }
110−
111− slog.Debug("Creating LSP client", "name", name, "command", config.Command, "fileTypes", config.FileTypes, "args", config.Args)
112−
113− // Update state to starting.
114− updateLSPState(name, lsp.StateStarting, nil, nil, 0)
115−
116− // Create LSP client.
117− lspClient, err := lsp.New(ctx, name, config, app.config.Resolver(), app.config.Options.DebugLSP)
118− if err != nil {
119− if !userConfigured {
120− slog.Warn("Default LSP config skipped due to error", "name", name, "error", err)
121− updateLSPState(name, lsp.StateDisabled, nil, nil, 0)
122− return
123− }
124− slog.Error("Failed to create LSP client for", "name", name, "error", err)
125− updateLSPState(name, lsp.StateError, err, nil, 0)
126− return
127− }
128−
129− // Set diagnostics callback
130− lspClient.SetDiagnosticsCallback(updateLSPDiagnostics)
131−
132− // Increase initialization timeout as some servers take more time to start.
133− initCtx, cancel := context.WithTimeout(ctx, time.Duration(cmp.Or(config.Timeout, 30))*time.Second)
134− defer cancel()
135−
136− // Initialize LSP client.
137− _, err = lspClient.Initialize(initCtx, app.config.WorkingDir())
138− if err != nil {
139− slog.Error("LSP client initialization failed", "name", name, "error", err)
140− updateLSPState(name, lsp.StateError, err, lspClient, 0)
141− lspClient.Close(ctx)
142− return
143− }
144−
145− // Wait for the server to be ready.
146− if err := lspClient.WaitForServerReady(initCtx); err != nil {
147− slog.Error("Server failed to become ready", "name", name, "error", err)
148− // Server never reached a ready state, but let's continue anyway, as
149− // some functionality might still work.
150− lspClient.SetServerState(lsp.StateError)
151− updateLSPState(name, lsp.StateError, err, lspClient, 0)
152− } else {
153− // Server reached a ready state successfully.
154− slog.Debug("LSP server is ready", "name", name)
155− lspClient.SetServerState(lsp.StateReady)
156− updateLSPState(name, lsp.StateReady, nil, lspClient, 0)
157− }
158−
159− slog.Debug("LSP client initialized", "name", name)
160−
161− // Add to map with mutex protection before starting goroutine
162− app.LSPClients.Set(name, lspClient)
163−}
internal/db/db.go+10
@@ -108,6 +108,9 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) {
108 if q.listNewFilesStmt, err = db.PrepareContext(ctx, listNewFiles); err != nil {
108 if q.listNewFilesStmt, err = db.PrepareContext(ctx, listNewFiles); err != nil {
109 return nil, fmt.Errorf("error preparing query ListNewFiles: %w", err)
109 return nil, fmt.Errorf("error preparing query ListNewFiles: %w", err)
110 }
110 }
111+ if q.listSessionReadFilesStmt, err = db.PrepareContext(ctx, listSessionReadFiles); err != nil {
112+ return nil, fmt.Errorf("error preparing query ListSessionReadFiles: %w", err)
113+ }
111 if q.listSessionsStmt, err = db.PrepareContext(ctx, listSessions); err != nil {
114 if q.listSessionsStmt, err = db.PrepareContext(ctx, listSessions); err != nil {
112 return nil, fmt.Errorf("error preparing query ListSessions: %w", err)
115 return nil, fmt.Errorf("error preparing query ListSessions: %w", err)
113 }
116 }
@@ -271,6 +274,11 @@ func (q *Queries) Close() error {
271 err = fmt.Errorf("error closing listNewFilesStmt: %w", cerr)
274 err = fmt.Errorf("error closing listNewFilesStmt: %w", cerr)
272 }
275 }
273 }
276 }
277+ if q.listSessionReadFilesStmt != nil {
278+ if cerr := q.listSessionReadFilesStmt.Close(); cerr != nil {
279+ err = fmt.Errorf("error closing listSessionReadFilesStmt: %w", cerr)
280+ }
281+ }
274 if q.listSessionsStmt != nil {
282 if q.listSessionsStmt != nil {
275 if cerr := q.listSessionsStmt.Close(); cerr != nil {
283 if cerr := q.listSessionsStmt.Close(); cerr != nil {
276 err = fmt.Errorf("error closing listSessionsStmt: %w", cerr)
284 err = fmt.Errorf("error closing listSessionsStmt: %w", cerr)
@@ -368,6 +376,7 @@ type Queries struct {
368 listLatestSessionFilesStmt *sql.Stmt
376 listLatestSessionFilesStmt *sql.Stmt
369 listMessagesBySessionStmt *sql.Stmt
377 listMessagesBySessionStmt *sql.Stmt
370 listNewFilesStmt *sql.Stmt
378 listNewFilesStmt *sql.Stmt
379+ listSessionReadFilesStmt *sql.Stmt
371 listSessionsStmt *sql.Stmt
380 listSessionsStmt *sql.Stmt
372 listUserMessagesBySessionStmt *sql.Stmt
381 listUserMessagesBySessionStmt *sql.Stmt
373 recordFileReadStmt *sql.Stmt
382 recordFileReadStmt *sql.Stmt
@@ -408,6 +417,7 @@ func (q *Queries) WithTx(tx *sql.Tx) *Queries {
408 listLatestSessionFilesStmt: q.listLatestSessionFilesStmt,
417 listLatestSessionFilesStmt: q.listLatestSessionFilesStmt,
409 listMessagesBySessionStmt: q.listMessagesBySessionStmt,
418 listMessagesBySessionStmt: q.listMessagesBySessionStmt,
410 listNewFilesStmt: q.listNewFilesStmt,
419 listNewFilesStmt: q.listNewFilesStmt,
420+ listSessionReadFilesStmt: q.listSessionReadFilesStmt,
411 listSessionsStmt: q.listSessionsStmt,
421 listSessionsStmt: q.listSessionsStmt,
412 listUserMessagesBySessionStmt: q.listUserMessagesBySessionStmt,
422 listUserMessagesBySessionStmt: q.listUserMessagesBySessionStmt,
413 recordFileReadStmt: q.recordFileReadStmt,
423 recordFileReadStmt: q.recordFileReadStmt,
internal/db/querier.go+1
@@ -37,6 +37,7 @@ type Querier interface {
37 ListLatestSessionFiles(ctx context.Context, sessionID string) ([]File, error)
37 ListLatestSessionFiles(ctx context.Context, sessionID string) ([]File, error)
38 ListMessagesBySession(ctx context.Context, sessionID string) ([]Message, error)
38 ListMessagesBySession(ctx context.Context, sessionID string) ([]Message, error)
39 ListNewFiles(ctx context.Context) ([]File, error)
39 ListNewFiles(ctx context.Context) ([]File, error)
40+ ListSessionReadFiles(ctx context.Context, sessionID string) ([]ReadFile, error)
40 ListSessions(ctx context.Context) ([]Session, error)
41 ListSessions(ctx context.Context) ([]Session, error)
41 ListUserMessagesBySession(ctx context.Context, sessionID string) ([]Message, error)
42 ListUserMessagesBySession(ctx context.Context, sessionID string) ([]Message, error)
42 RecordFileRead(ctx context.Context, arg RecordFileReadParams) error
43 RecordFileRead(ctx context.Context, arg RecordFileReadParams) error
internal/db/read_files.sql.go+33
@@ -48,6 +48,39 @@ type RecordFileReadParams struct {
48 Path string `json:"path"`
48 Path string `json:"path"`
49}
49}
50
50
51+const listSessionReadFiles = `-- name: ListSessionReadFiles :many
52+SELECT session_id, path, read_at FROM read_files
53+WHERE session_id = ?
54+ORDER BY read_at DESC
55+`
56+
57+func (q *Queries) ListSessionReadFiles(ctx context.Context, sessionID string) ([]ReadFile, error) {
58+ rows, err := q.query(ctx, q.listSessionReadFilesStmt, listSessionReadFiles, sessionID)
59+ if err != nil {
60+ return nil, err
61+ }
62+ defer rows.Close()
63+ items := []ReadFile{}
64+ for rows.Next() {
65+ var i ReadFile
66+ if err := rows.Scan(
67+ &i.SessionID,
68+ &i.Path,
69+ &i.ReadAt,
70+ ); err != nil {
71+ return nil, err
72+ }
73+ items = append(items, i)
74+ }
75+ if err := rows.Close(); err != nil {
76+ return nil, err
77+ }
78+ if err := rows.Err(); err != nil {
79+ return nil, err
80+ }
81+ return items, nil
82+}
83+
51func (q *Queries) RecordFileRead(ctx context.Context, arg RecordFileReadParams) error {
84func (q *Queries) RecordFileRead(ctx context.Context, arg RecordFileReadParams) error {
52 _, err := q.exec(ctx, q.recordFileReadStmt, recordFileRead,
85 _, err := q.exec(ctx, q.recordFileReadStmt, recordFileRead,
53 arg.SessionID,
86 arg.SessionID,
internal/db/sql/read_files.sql+5
@@ -13,3 +13,8 @@ INSERT INTO read_files (
13-- name: GetFileRead :one
13-- name: GetFileRead :one
14SELECT * FROM read_files
14SELECT * FROM read_files
15WHERE session_id = ? AND path = ? LIMIT 1;
15WHERE session_id = ? AND path = ? LIMIT 1;
16+
17+-- name: ListSessionReadFiles :many
18+SELECT * FROM read_files
19+WHERE session_id = ?
20+ORDER BY read_at DESC;
internal/filetracker/service.go+23
@@ -3,6 +3,7 @@ package filetracker
3
3
4import (
4import (
5 "context"
5 "context"
6+ "fmt"
6 "log/slog"
7 "log/slog"
7 "os"
8 "os"
8 "path/filepath"
9 "path/filepath"
@@ -19,6 +20,9 @@ type Service interface {
19 // LastReadTime returns when a file was last read.
20 // LastReadTime returns when a file was last read.
20 // Returns zero time if never read.
21 // Returns zero time if never read.
21 LastReadTime(ctx context.Context, sessionID, path string) time.Time
22 LastReadTime(ctx context.Context, sessionID, path string) time.Time
23+
24+ // ListReadFiles returns the paths of all files read in a session.
25+ ListReadFiles(ctx context.Context, sessionID string) ([]string, error)
22}
26}
23
27
24type service struct {
28type service struct {
@@ -68,3 +72,22 @@ func relpath(path string) string {
68 }
72 }
69 return relpath
73 return relpath
70}
74}
75+
76+// ListReadFiles returns the paths of all files read in a session.
77+func (s *service) ListReadFiles(ctx context.Context, sessionID string) ([]string, error) {
78+ readFiles, err := s.q.ListSessionReadFiles(ctx, sessionID)
79+ if err != nil {
80+ return nil, fmt.Errorf("listing read files: %w", err)
81+ }
82+
83+ basepath, err := os.Getwd()
84+ if err != nil {
85+ return nil, fmt.Errorf("getting working directory: %w", err)
86+ }
87+
88+ paths := make([]string, 0, len(readFiles))
89+ for _, rf := range readFiles {
90+ paths = append(paths, filepath.Join(basepath, rf.Path))
91+ }
92+ return paths, nil
93+}
internal/lsp/client.go+4−73
@@ -13,12 +13,9 @@ import (
13 "sync/atomic"
13 "sync/atomic"
14 "time"
14 "time"
15
15
16− "github.com/bmatcuk/doublestar/v4"
17 "github.com/charmbracelet/crush/internal/config"
16 "github.com/charmbracelet/crush/internal/config"
18 "github.com/charmbracelet/crush/internal/csync"
17 "github.com/charmbracelet/crush/internal/csync"
19− "github.com/charmbracelet/crush/internal/fsext"
20 "github.com/charmbracelet/crush/internal/home"
18 "github.com/charmbracelet/crush/internal/home"
21− powernapconfig "github.com/charmbracelet/x/powernap/pkg/config"
22 powernap "github.com/charmbracelet/x/powernap/pkg/lsp"
19 powernap "github.com/charmbracelet/x/powernap/pkg/lsp"
23 "github.com/charmbracelet/x/powernap/pkg/lsp/protocol"
20 "github.com/charmbracelet/x/powernap/pkg/lsp/protocol"
24 "github.com/charmbracelet/x/powernap/pkg/transport"
21 "github.com/charmbracelet/x/powernap/pkg/transport"
@@ -200,6 +197,8 @@ func (c *Client) Restart() error {
200 slog.Warn("Error closing client during restart", "name", c.name, "error", err)
197 slog.Warn("Error closing client during restart", "name", c.name, "error", err)
201 }
198 }
202
199
200+ c.SetServerState(StateStopped)
201+
203 c.diagCountsCache = DiagnosticCounts{}
202 c.diagCountsCache = DiagnosticCounts{}
204 c.diagCountsVersion = 0
203 c.diagCountsVersion = 0
205
204
@@ -237,7 +236,8 @@ func (c *Client) Restart() error {
237type ServerState int
236type ServerState int
238
237
239const (
238const (
240− StateStarting ServerState = iota
239+ StateStopped ServerState = iota
240+ StateStarting
241 StateReady
241 StateReady
242 StateError
242 StateError
243 StateDisabled
243 StateDisabled
@@ -578,72 +578,3 @@ func (c *Client) FindReferences(ctx context.Context, filepath string, line, char
578 // See: https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#position
578 // See: https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#position
579 return c.client.FindReferences(ctx, filepath, line-1, character-1, includeDeclaration)
579 return c.client.FindReferences(ctx, filepath, line-1, character-1, includeDeclaration)
580}
580}
581−
582−// FilterMatching gets a list of configs and only returns the ones with
583−// matching root markers.
584−func FilterMatching(dir string, servers map[string]*powernapconfig.ServerConfig) map[string]*powernapconfig.ServerConfig {
585− result := map[string]*powernapconfig.ServerConfig{}
586− if len(servers) == 0 {
587− return result
588− }
589−
590− type serverPatterns struct {
591− server *powernapconfig.ServerConfig
592− patterns []string
593− }
594− normalized := make(map[string]serverPatterns, len(servers))
595− for name, server := range servers {
596− var patterns []string
597− for _, p := range server.RootMarkers {
598− if p == ".git" {
599− // ignore .git for discovery
600− continue
601− }
602− patterns = append(patterns, filepath.ToSlash(p))
603− }
604− if len(patterns) == 0 {
605− slog.Debug("ignoring lsp with no root markers", "name", name)
606− continue
607− }
608− normalized[name] = serverPatterns{server: server, patterns: patterns}
609− }
610−
611− walker := fsext.NewFastGlobWalker(dir)
612− _ = filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error {
613− if err != nil {
614− return nil
615− }
616−
617− if walker.ShouldSkip(path) {
618− if d.IsDir() {
619− return filepath.SkipDir
620− }
621− return nil
622− }
623−
624− relPath, err := filepath.Rel(dir, path)
625− if err != nil {
626− return nil
627− }
628− relPath = filepath.ToSlash(relPath)
629−
630− for name, sp := range normalized {
631− for _, pattern := range sp.patterns {
632− matched, err := doublestar.Match(pattern, relPath)
633− if err != nil || !matched {
634− continue
635− }
636− result[name] = sp.server
637− delete(normalized, name)
638− break
639− }
640− }
641−
642− if len(normalized) == 0 {
643− return filepath.SkipAll
644− }
645− return nil
646− })
647−
648− return result
649−}
internal/lsp/filtermatching_test.godeleted−111
@@ -1,111 +0,0 @@
1−package lsp
2−
3−import (
4− "os"
5− "path/filepath"
6− "testing"
7−
8− powernapconfig "github.com/charmbracelet/x/powernap/pkg/config"
9− "github.com/stretchr/testify/require"
10−)
11−
12−func TestFilterMatching(t *testing.T) {
13− t.Parallel()
14−
15− t.Run("matches servers with existing root markers", func(t *testing.T) {
16− t.Parallel()
17− tmpDir := t.TempDir()
18−
19− require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "go.mod"), []byte("module test"), 0o644))
20− require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "Cargo.toml"), []byte("[package]"), 0o644))
21−
22− servers := map[string]*powernapconfig.ServerConfig{
23− "gopls": {RootMarkers: []string{"go.mod", "go.work"}},
24− "rust-analyzer": {RootMarkers: []string{"Cargo.toml"}},
25− "typescript-lsp": {RootMarkers: []string{"package.json", "tsconfig.json"}},
26− }
27−
28− result := FilterMatching(tmpDir, servers)
29−
30− require.Contains(t, result, "gopls")
31− require.Contains(t, result, "rust-analyzer")
32− require.NotContains(t, result, "typescript-lsp")
33− })
34−
35− t.Run("returns empty for empty servers", func(t *testing.T) {
36− t.Parallel()
37− tmpDir := t.TempDir()
38−
39− result := FilterMatching(tmpDir, map[string]*powernapconfig.ServerConfig{})
40−
41− require.Empty(t, result)
42− })
43−
44− t.Run("returns empty when no markers match", func(t *testing.T) {
45− t.Parallel()
46− tmpDir := t.TempDir()
47−
48− servers := map[string]*powernapconfig.ServerConfig{
49− "gopls": {RootMarkers: []string{"go.mod"}},
50− "python": {RootMarkers: []string{"pyproject.toml"}},
51− }
52−
53− result := FilterMatching(tmpDir, servers)
54−
55− require.Empty(t, result)
56− })
57−
58− t.Run("glob patterns work", func(t *testing.T) {
59− t.Parallel()
60− tmpDir := t.TempDir()
61−
62− require.NoError(t, os.MkdirAll(filepath.Join(tmpDir, "src"), 0o755))
63− require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "src", "main.go"), []byte("package main"), 0o644))
64−
65− servers := map[string]*powernapconfig.ServerConfig{
66− "gopls": {RootMarkers: []string{"**/*.go"}},
67− "python": {RootMarkers: []string{"**/*.py"}},
68− }
69−
70− result := FilterMatching(tmpDir, servers)
71−
72− require.Contains(t, result, "gopls")
73− require.NotContains(t, result, "python")
74− })
75−
76− t.Run("servers with empty root markers are not included", func(t *testing.T) {
77− t.Parallel()
78− tmpDir := t.TempDir()
79−
80− require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "go.mod"), []byte("module test"), 0o644))
81−
82− servers := map[string]*powernapconfig.ServerConfig{
83− "gopls": {RootMarkers: []string{"go.mod"}},
84− "generic": {RootMarkers: []string{}},
85− }
86−
87− result := FilterMatching(tmpDir, servers)
88−
89− require.Contains(t, result, "gopls")
90− require.NotContains(t, result, "generic")
91− })
92−
93− t.Run("stops early when all servers match", func(t *testing.T) {
94− t.Parallel()
95− tmpDir := t.TempDir()
96−
97− require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "go.mod"), []byte("module test"), 0o644))
98− require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "Cargo.toml"), []byte("[package]"), 0o644))
99−
100− servers := map[string]*powernapconfig.ServerConfig{
101− "gopls": {RootMarkers: []string{"go.mod"}},
102− "rust-analyzer": {RootMarkers: []string{"Cargo.toml"}},
103− }
104−
105− result := FilterMatching(tmpDir, servers)
106−
107− require.Len(t, result, 2)
108− require.Contains(t, result, "gopls")
109− require.Contains(t, result, "rust-analyzer")
110− })
111−}
internal/lsp/manager.gonew file+271
@@ -0,0 +1,271 @@
1+// Package lsp provides a manager for Language Server Protocol (LSP) clients.
2+package lsp
3+
4+import (
5+ "cmp"
6+ "context"
7+ "errors"
8+ "io"
9+ "log/slog"
10+ "os/exec"
11+ "path/filepath"
12+ "strings"
13+ "sync"
14+ "time"
15+
16+ "github.com/charmbracelet/crush/internal/config"
17+ "github.com/charmbracelet/crush/internal/csync"
18+ "github.com/charmbracelet/crush/internal/fsext"
19+ powernapconfig "github.com/charmbracelet/x/powernap/pkg/config"
20+ "github.com/charmbracelet/x/powernap/pkg/lsp"
21+ "github.com/charmbracelet/x/powernap/pkg/lsp/protocol"
22+ "github.com/sourcegraph/jsonrpc2"
23+)
24+
25+// Manager handles lazy initialization of LSP clients based on file types.
26+type Manager struct {
27+ clients *csync.Map[string, *Client]
28+ cfg *config.Config
29+ manager *powernapconfig.Manager
30+ callback func(name string, client *Client)
31+ mu sync.Mutex
32+}
33+
34+// NewManager creates a new LSP manager service.
35+func NewManager(cfg *config.Config) *Manager {
36+ manager := powernapconfig.NewManager()
37+ manager.LoadDefaults()
38+
39+ // Merge user-configured LSPs into the manager.
40+ for name, clientConfig := range cfg.LSP {
41+ if clientConfig.Disabled {
42+ slog.Debug("LSP disabled by user config", "name", name)
43+ manager.RemoveServer(name)
44+ continue
45+ }
46+
47+ // HACK: the user might have the command name in their config instead
48+ // of the actual name. Find and use the correct name.
49+ actualName := resolveServerName(manager, name)
50+ manager.AddServer(actualName, &powernapconfig.ServerConfig{
51+ Command: clientConfig.Command,
52+ Args: clientConfig.Args,
53+ Environment: clientConfig.Env,
54+ FileTypes: clientConfig.FileTypes,
55+ RootMarkers: clientConfig.RootMarkers,
56+ InitOptions: clientConfig.InitOptions,
57+ Settings: clientConfig.Options,
58+ })
59+ }
60+
61+ return &Manager{
62+ clients: csync.NewMap[string, *Client](),
63+ cfg: cfg,
64+ manager: manager,
65+ }
66+}
67+
68+// Clients returns the map of LSP clients.
69+func (m *Manager) Clients() *csync.Map[string, *Client] {
70+ return m.clients
71+}
72+
73+// SetCallback sets a callback that is invoked when a new LSP
74+// client is successfully started. This allows the coordinator to add LSP tools.
75+func (s *Manager) SetCallback(cb func(name string, client *Client)) {
76+ s.mu.Lock()
77+ defer s.mu.Unlock()
78+ s.callback = cb
79+}
80+
81+// Start starts an LSP server that can handle the given file path.
82+// If an appropriate LSP is already running, this is a no-op.
83+func (s *Manager) Start(ctx context.Context, filePath string) {
84+ s.mu.Lock()
85+ defer s.mu.Unlock()
86+
87+ var wg sync.WaitGroup
88+ for name, server := range s.manager.GetServers() {
89+ if !handles(server, filePath, s.cfg.WorkingDir()) {
90+ continue
91+ }
92+ wg.Go(func() {
93+ s.startServer(ctx, name, server)
94+ })
95+ }
96+ wg.Wait()
97+}
98+
99+// skipAutoStartCommands contains commands that are too generic or ambiguous to
100+// auto-start without explicit user configuration.
101+var skipAutoStartCommands = map[string]bool{
102+ "buck2": true,
103+ "buf": true,
104+ "cue": true,
105+ "dart": true,
106+ "deno": true,
107+ "dotnet": true,
108+ "dprint": true,
109+ "gleam": true,
110+ "java": true,
111+ "julia": true,
112+ "koka": true,
113+ "node": true,
114+ "npx": true,
115+ "perl": true,
116+ "plz": true,
117+ "python": true,
118+ "python3": true,
119+ "R": true,
120+ "racket": true,
121+ "rome": true,
122+ "rubocop": true,
123+ "ruff": true,
124+ "scarb": true,
125+ "solc": true,
126+ "stylua": true,
127+ "swipl": true,
128+ "tflint": true,
129+}
130+
131+func (s *Manager) startServer(ctx context.Context, name string, server *powernapconfig.ServerConfig) {
132+ userConfigured := s.isUserConfigured(name)
133+
134+ if !userConfigured {
135+ if _, err := exec.LookPath(server.Command); err != nil {
136+ slog.Debug("LSP server not installed, skipping", "name", name, "command", server.Command)
137+ return
138+ }
139+ if skipAutoStartCommands[server.Command] {
140+ slog.Debug("LSP command too generic for auto-start, skipping", "name", name, "command", server.Command)
141+ return
142+ }
143+ }
144+
145+ cfg := s.buildConfig(name, server)
146+ if client, ok := s.clients.Get(name); ok {
147+ switch client.GetServerState() {
148+ case StateReady, StateStarting:
149+ s.callback(name, client)
150+ // already done, return
151+ return
152+ }
153+ }
154+ client, err := New(ctx, name, cfg, s.cfg.Resolver(), s.cfg.Options.DebugLSP)
155+ if err != nil {
156+ slog.Error("Failed to create LSP client", "name", name, "error", err)
157+ return
158+ }
159+ s.callback(name, client)
160+
161+ defer func() {
162+ s.clients.Set(name, client)
163+ s.callback(name, client)
164+ }()
165+
166+ initCtx, cancel := context.WithTimeout(ctx, time.Duration(cmp.Or(cfg.Timeout, 30))*time.Second)
167+ defer cancel()
168+
169+ if _, err := client.Initialize(initCtx, s.cfg.WorkingDir()); err != nil {
170+ slog.Error("LSP client initialization failed", "name", name, "error", err)
171+ client.Close(ctx)
172+ return
173+ }
174+
175+ if err := client.WaitForServerReady(initCtx); err != nil {
176+ slog.Warn("LSP server not fully ready, continuing anyway", "name", name, "error", err)
177+ client.SetServerState(StateError)
178+ } else {
179+ client.SetServerState(StateReady)
180+ }
181+
182+ slog.Debug("LSP client started", "name", name)
183+}
184+
185+func (s *Manager) isUserConfigured(name string) bool {
186+ cfg, ok := s.cfg.LSP[name]
187+ return ok && !cfg.Disabled
188+}
189+
190+func (s *Manager) buildConfig(name string, server *powernapconfig.ServerConfig) config.LSPConfig {
191+ cfg := config.LSPConfig{
192+ Command: server.Command,
193+ Args: server.Args,
194+ Env: server.Environment,
195+ FileTypes: server.FileTypes,
196+ RootMarkers: server.RootMarkers,
197+ InitOptions: server.InitOptions,
198+ Options: server.Settings,
199+ }
200+ if userCfg, ok := s.cfg.LSP[name]; ok {
201+ cfg.Timeout = userCfg.Timeout
202+ }
203+ return cfg
204+}
205+
206+func resolveServerName(manager *powernapconfig.Manager, name string) string {
207+ if _, ok := manager.GetServer(name); ok {
208+ return name
209+ }
210+ for sname, server := range manager.GetServers() {
211+ if server.Command == name {
212+ return sname
213+ }
214+ }
215+ return name
216+}
217+
218+func handlesFiletype(server *powernapconfig.ServerConfig, ext string, language protocol.LanguageKind) bool {
219+ for _, ft := range server.FileTypes {
220+ if protocol.LanguageKind(ft) == language ||
221+ ft == strings.TrimPrefix(ext, ".") ||
222+ "."+ft == ext {
223+ return true
224+ }
225+ }
226+ return false
227+}
228+
229+func hasRootMarkers(dir string, markers []string) bool {
230+ if len(markers) == 0 {
231+ return true
232+ }
233+ for _, pattern := range markers {
234+ // Use fsext.GlobWithDoubleStar to find matches
235+ matches, _, err := fsext.GlobWithDoubleStar(pattern, dir, 1)
236+ if err == nil && len(matches) > 0 {
237+ return true
238+ }
239+ }
240+ return false
241+}
242+
243+func handles(server *powernapconfig.ServerConfig, filePath, workDir string) bool {
244+ language := lsp.DetectLanguage(filePath)
245+ ext := filepath.Ext(filePath)
246+ return handlesFiletype(server, ext, language) &&
247+ hasRootMarkers(workDir, server.RootMarkers)
248+}
249+
250+// StopAll stops all running LSP clients and clears the client map.
251+func (s *Manager) StopAll(ctx context.Context) {
252+ s.mu.Lock()
253+ defer s.mu.Unlock()
254+
255+ var wg sync.WaitGroup
256+ for name, client := range s.clients.Seq2() {
257+ wg.Go(func() {
258+ defer func() { s.callback(name, client) }()
259+ if err := client.Close(ctx); err != nil &&
260+ !errors.Is(err, io.EOF) &&
261+ !errors.Is(err, context.Canceled) &&
262+ !errors.Is(err, jsonrpc2.ErrClosed) &&
263+ err.Error() != "signal: killed" {
264+ slog.Warn("Failed to stop LSP client", "name", name, "error", err)
265+ }
266+ client.SetServerState(StateStopped)
267+ slog.Debug("Stopped LSP client", "name", name)
268+ })
269+ }
270+ wg.Wait()
271+}
internal/ui/model/header.go+7−1
@@ -74,7 +74,13 @@ func (h *header) drawHeader(
74 b.WriteString(h.compactLogo)
74 b.WriteString(h.compactLogo)
75
75
76 availDetailWidth := width - leftPadding - rightPadding - lipgloss.Width(b.String()) - minHeaderDiags
76 availDetailWidth := width - leftPadding - rightPadding - lipgloss.Width(b.String()) - minHeaderDiags
77− details := renderHeaderDetails(h.com, session, h.com.App.LSPClients, detailsOpen, availDetailWidth)
77+ details := renderHeaderDetails(
78+ h.com,
79+ session,
80+ h.com.App.LSPManager.Clients(),
81+ detailsOpen,
82+ availDetailWidth,
83+ )
78
84
79 remainingWidth := width -
85 remainingWidth := width -
80 lipgloss.Width(b.String()) -
86 lipgloss.Width(b.String()) -
internal/ui/model/lsp.go+5−2
@@ -31,7 +31,7 @@ func (m *UI) lspInfo(width, maxItems int, isSection bool) string {
31
31
32 var lsps []LSPInfo
32 var lsps []LSPInfo
33 for _, state := range states {
33 for _, state := range states {
34− client, ok := m.com.App.LSPClients.Get(state.Name)
34+ client, ok := m.com.App.LSPManager.Clients().Get(state.Name)
35 if !ok {
35 if !ok {
36 continue
36 continue
37 }
37 }
@@ -89,6 +89,9 @@ func lspList(t *styles.Styles, lsps []LSPInfo, width, maxItems int) string {
89 var description string
89 var description string
90 var diagnostics string
90 var diagnostics string
91 switch l.State {
91 switch l.State {
92+ case lsp.StateStopped:
93+ icon = t.ItemOfflineIcon.Foreground(t.Muted.GetBackground()).String()
94+ description = t.Subtle.Render("stopped")
92 case lsp.StateStarting:
95 case lsp.StateStarting:
93 icon = t.ItemBusyIcon.String()
96 icon = t.ItemBusyIcon.String()
94 description = t.Subtle.Render("starting...")
97 description = t.Subtle.Render("starting...")
@@ -103,7 +106,7 @@ func lspList(t *styles.Styles, lsps []LSPInfo, width, maxItems int) string {
103 }
106 }
104 case lsp.StateDisabled:
107 case lsp.StateDisabled:
105 icon = t.ItemOfflineIcon.Foreground(t.Muted.GetBackground()).String()
108 icon = t.ItemOfflineIcon.Foreground(t.Muted.GetBackground()).String()
106− description = t.Subtle.Render("inactive")
109+ description = t.Subtle.Render("disabled")
107 default:
110 default:
108 icon = t.ItemOfflineIcon.String()
111 icon = t.ItemOfflineIcon.String()
109 }
112 }
internal/ui/model/session.go+50−4
@@ -3,6 +3,7 @@ package model
3import (
3import (
4 "context"
4 "context"
5 "fmt"
5 "fmt"
6+ "log/slog"
6 "path/filepath"
7 "path/filepath"
7 "slices"
8 "slices"
8 "strings"
9 "strings"
@@ -22,8 +23,32 @@ import (
22// loadSessionMsg is a message indicating that a session and its files have
23// loadSessionMsg is a message indicating that a session and its files have
23// been loaded.
24// been loaded.
24type loadSessionMsg struct {
25type loadSessionMsg struct {
25− session *session.Session
26+ session *session.Session
26− files []SessionFile
27+ files []SessionFile
28+ readFiles []string
29+}
30+
31+// lspFilePaths returns deduplicated file paths from both modified and read
32+// files for starting LSP servers.
33+func (msg loadSessionMsg) lspFilePaths() []string {
34+ seen := make(map[string]struct{}, len(msg.files)+len(msg.readFiles))
35+ paths := make([]string, 0, len(msg.files)+len(msg.readFiles))
36+ for _, f := range msg.files {
37+ p := f.LatestVersion.Path
38+ if _, ok := seen[p]; ok {
39+ continue
40+ }
41+ seen[p] = struct{}{}
42+ paths = append(paths, p)
43+ }
44+ for _, p := range msg.readFiles {
45+ if _, ok := seen[p]; ok {
46+ continue
47+ }
48+ seen[p] = struct{}{}
49+ paths = append(paths, p)
50+ }
51+ return paths
27}
52}
28
53
29// SessionFile tracks the first and latest versions of a file in a session,
54// SessionFile tracks the first and latest versions of a file in a session,
@@ -51,9 +76,15 @@ func (m *UI) loadSession(sessionID string) tea.Cmd {
51 return util.ReportError(err)
76 return util.ReportError(err)
52 }
77 }
53
78
79+ readFiles, err := m.com.App.FileTracker.ListReadFiles(context.Background(), sessionID)
80+ if err != nil {
81+ slog.Error("Failed to load read files for session", "error", err)
82+ }
83+
54 return loadSessionMsg{
84 return loadSessionMsg{
55− session: &session,
85+ session: &session,
56− files: sessionFiles,
86+ files: sessionFiles,
87+ readFiles: readFiles,
57 }
88 }
58 }
89 }
59}
90}
@@ -200,3 +231,18 @@ func fileList(t *styles.Styles, cwd string, filesWithChanges []SessionFile, widt
200
231
201 return lipgloss.JoinVertical(lipgloss.Left, renderedFiles...)
232 return lipgloss.JoinVertical(lipgloss.Left, renderedFiles...)
202}
233}
234+
235+// startLSPs starts LSP servers for the given file paths.
236+func (m *UI) startLSPs(paths []string) tea.Cmd {
237+ if len(paths) == 0 {
238+ return nil
239+ }
240+
241+ return func() tea.Msg {
242+ ctx := context.Background()
243+ for _, path := range paths {
244+ m.com.App.LSPManager.Start(ctx, path)
245+ }
246+ return nil
247+ }
248+}
internal/ui/model/ui.go+17−2
@@ -397,6 +397,7 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
397 m.setState(uiChat, m.focus)
397 m.setState(uiChat, m.focus)
398 m.session = msg.session
398 m.session = msg.session
399 m.sessionFiles = msg.files
399 m.sessionFiles = msg.files
400+ cmds = append(cmds, m.startLSPs(msg.lspFilePaths()))
400 msgs, err := m.com.App.Messages.List(context.Background(), m.session.ID)
401 msgs, err := m.com.App.Messages.List(context.Background(), m.session.ID)
401 if err != nil {
402 if err != nil {
402 cmds = append(cmds, util.ReportError(err))
403 cmds = append(cmds, util.ReportError(err))
@@ -417,8 +418,14 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
417 m.historyReset()
418 m.historyReset()
418 cmds = append(cmds, m.loadPromptHistory())
419 cmds = append(cmds, m.loadPromptHistory())
419 m.updateLayoutAndSize()
420 m.updateLayoutAndSize()
421+
420 case sessionFilesUpdatesMsg:
422 case sessionFilesUpdatesMsg:
421 m.sessionFiles = msg.sessionFiles
423 m.sessionFiles = msg.sessionFiles
424+ var paths []string
425+ for _, f := range msg.sessionFiles {
426+ paths = append(paths, f.LatestVersion.Path)
427+ }
428+ cmds = append(cmds, m.startLSPs(paths))
422
429
423 case sendMessageMsg:
430 case sendMessageMsg:
424 cmds = append(cmds, m.sendMessage(msg.Content, msg.Attachments...))
431 cmds = append(cmds, m.sendMessage(msg.Content, msg.Attachments...))
@@ -2706,8 +2713,10 @@ func (m *UI) sendMessage(content string, attachments ...message.Attachment) tea.
2706 m.setState(uiChat, m.focus)
2713 m.setState(uiChat, m.focus)
2707 }
2714 }
2708
2715
2716+ ctx := context.Background()
2709 for _, path := range m.sessionFileReads {
2717 for _, path := range m.sessionFileReads {
2710− m.com.App.FileTracker.RecordRead(context.Background(), m.session.ID, path)
2718+ m.com.App.FileTracker.RecordRead(ctx, m.session.ID, path)
2719+ m.com.App.LSPManager.Start(ctx, path)
2711 }
2720 }
2712
2721
2713 // Capture session ID to avoid race with main goroutine updating m.session.
2722 // Capture session ID to avoid race with main goroutine updating m.session.
@@ -2965,7 +2974,13 @@ func (m *UI) newSession() tea.Cmd {
2965 m.promptQueue = 0
2974 m.promptQueue = 0
2966 m.pillsView = ""
2975 m.pillsView = ""
2967 m.historyReset()
2976 m.historyReset()
2977+ return tea.Batch(
2978+ func() tea.Msg {
2979+ m.com.App.LSPManager.StopAll(context.Background())
2980+ return nil
2981+ },
2968− return m.loadPromptHistory()
2982+ m.loadPromptHistory(),
2983+ )
2969}
2984}
2970
2985
2971// handlePasteMsg handles a paste message.
2986// handlePasteMsg handles a paste message.