on main help sign in
feat(lsp): start LSPs on demand, improve auto-start (#2103)

* feat(lsp): start LSPs on demand, improve auto-start Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com> * refactor: simplify Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com> * chore: fmt Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com> * feat: handle new session/load session Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com> * refactor: manager holds client list reference Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com> * fix: do io in cmd Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com> * fix: better manager Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com> * fix: nil Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com> * fix: err Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com> * fix: properly handle restart Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com> * fix: add stopped state Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com> * fix: root markers Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com> * fix: load for read files as well Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com> * chore(go.mod): move indirect dependency to the right block --------- Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com> Co-authored-by: Andrey Nering <andreynering@users.noreply.github.com>

commit02f66a2e8614a6c029260bea7d3ca1565786915a authorCarlos Alexandro Becker <caarlos0@users.noreply.github.com> dateFri 6 Feb 2026, 16:12 (8 months ago) parent0491b67
25 files changed+500−426
go.mod+1−1 internal/agent/agentic_fetch_tool.go+1−1 internal/agent/common_test.go+4−4 internal/agent/coordinator.go+10−10 internal/agent/tools/diagnostics.go+23−9 internal/agent/tools/edit.go+3−4 internal/agent/tools/lsp_restart.go+4−5 internal/agent/tools/multiedit.go+3−4 internal/agent/tools/references.go+6−7 internal/agent/tools/view.go+3−4 internal/agent/tools/write.go+3−4 internal/app/app.go+13−17 internal/app/lsp.go−163 internal/db/db.go+10 internal/db/querier.go+1 internal/db/read_files.sql.go+33 internal/db/sql/read_files.sql+5 internal/filetracker/service.go+23 internal/lsp/client.go+4−73 internal/lsp/filtermatching_test.go−111 internal/lsp/manager.go+271 internal/ui/model/header.go+7−1 internal/ui/model/lsp.go+5−2 internal/ui/model/session.go+50−4 internal/ui/model/ui.go+17−2
go.mod+1−1
@@ -55,6 +55,7 @@ require (
5555 github.com/rivo/uniseg v0.4.7
5656 github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06
5757 github.com/sahilm/fuzzy v0.1.1
58+ github.com/sourcegraph/jsonrpc2 v0.2.1
5859 github.com/spf13/cobra v1.10.2
5960 github.com/stretchr/testify v1.11.1
6061 github.com/tidwall/gjson v1.18.0
@@ -150,7 +151,6 @@ require (
150151 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
151152 github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
152153 github.com/sethvargo/go-retry v0.3.0 // indirect
153− github.com/sourcegraph/jsonrpc2 v0.2.1 // indirect
154154 github.com/spf13/pflag v1.0.9 // indirect
155155 github.com/tetratelabs/wazero v1.11.0 // indirect
156156 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) (
169169 tools.NewGlobTool(tmpDir),
170170 tools.NewGrepTool(tmpDir),
171171 tools.NewSourcegraphTool(client),
172− tools.NewViewTool(c.lspClients, c.permissions, c.filetracker, tmpDir),
172+ tools.NewViewTool(c.lspManager, c.permissions, c.filetracker, tmpDir),
173173 }
174174
175175 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
204204 allTools := []fantasy.AgentTool{
205205 tools.NewBashTool(env.permissions, env.workingDir, cfg.Options.Attribution, modelName),
206206 tools.NewDownloadTool(env.permissions, env.workingDir, r.GetDefaultClient()),
207− tools.NewEditTool(env.lspClients, env.permissions, env.history, *env.filetracker, env.workingDir),
208− tools.NewMultiEditTool(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(nil, env.permissions, env.history, *env.filetracker, env.workingDir),
209209 tools.NewFetchTool(env.permissions, env.workingDir, r.GetDefaultClient()),
210210 tools.NewGlobTool(env.workingDir),
211211 tools.NewGrepTool(env.workingDir),
212212 tools.NewLsTool(env.permissions, env.workingDir, cfg.Tools.Ls),
213213 tools.NewSourcegraphTool(r.GetDefaultClient()),
214− tools.NewViewTool(env.lspClients, env.permissions, *env.filetracker, env.workingDir),
215− tools.NewWriteTool(env.lspClients, env.permissions, env.history, *env.filetracker, env.workingDir),
214+ tools.NewViewTool(nil, env.permissions, *env.filetracker, env.workingDir),
215+ tools.NewWriteTool(nil, env.permissions, env.history, *env.filetracker, env.workingDir),
216216 }
217217
218218 return testSessionAgent(env, large, small, systemPrompt, allTools...), nil
internal/agent/coordinator.go+10−10
@@ -21,7 +21,6 @@ import (
2121 "github.com/charmbracelet/crush/internal/agent/prompt"
2222 "github.com/charmbracelet/crush/internal/agent/tools"
2323 "github.com/charmbracelet/crush/internal/config"
24− "github.com/charmbracelet/crush/internal/csync"
2524 "github.com/charmbracelet/crush/internal/filetracker"
2625 "github.com/charmbracelet/crush/internal/history"
2726 "github.com/charmbracelet/crush/internal/log"
@@ -67,7 +66,7 @@ type coordinator struct {
6766 permissions permission.Service
6867 history history.Service
6968 filetracker filetracker.Service
70− lspClients *csync.Map[string, *lsp.Client]
69+ lspManager *lsp.Manager
7170
7271 currentAgent SessionAgent
7372 agents map[string]SessionAgent
@@ -83,7 +82,7 @@ func NewCoordinator(
8382 permissions permission.Service,
8483 history history.Service,
8584 filetracker filetracker.Service,
86− lspClients *csync.Map[string, *lsp.Client],
85+ lspManager *lsp.Manager,
8786) (Coordinator, error) {
8887 c := &coordinator{
8988 cfg: cfg,
@@ -92,7 +91,7 @@ func NewCoordinator(
9291 permissions: permissions,
9392 history: history,
9493 filetracker: filetracker,
95− lspClients: lspClients,
94+ lspManager: lspManager,
9695 agents: make(map[string]SessionAgent),
9796 }
9897
@@ -423,20 +422,21 @@ func (c *coordinator) buildTools(ctx context.Context, agent config.Agent) ([]fan
423422 tools.NewJobOutputTool(),
424423 tools.NewJobKillTool(),
425424 tools.NewDownloadTool(c.permissions, c.cfg.WorkingDir(), nil),
426− tools.NewEditTool(c.lspClients, c.permissions, c.history, c.filetracker, c.cfg.WorkingDir()),
427− tools.NewMultiEditTool(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()),
426+ tools.NewMultiEditTool(c.lspManager, c.permissions, c.history, c.filetracker, c.cfg.WorkingDir()),
428427 tools.NewFetchTool(c.permissions, c.cfg.WorkingDir(), nil),
429428 tools.NewGlobTool(c.cfg.WorkingDir()),
430429 tools.NewGrepTool(c.cfg.WorkingDir()),
431430 tools.NewLsTool(c.permissions, c.cfg.WorkingDir(), c.cfg.Tools.Ls),
432431 tools.NewSourcegraphTool(nil),
433432 tools.NewTodosTool(c.sessions),
434− tools.NewViewTool(c.lspClients, 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()),
433+ tools.NewViewTool(c.lspManager, c.permissions, c.filetracker, c.cfg.WorkingDir(), c.cfg.Options.SkillsPaths...),
434+ tools.NewWriteTool(c.lspManager, c.permissions, c.history, c.filetracker, c.cfg.WorkingDir()),
436435 )
437436
438− if c.lspClients.Len() > 0 {
439− allTools = append(allTools, tools.NewDiagnosticsTool(c.lspClients), tools.NewReferencesTool(c.lspClients), tools.NewLSPRestartTool(c.lspClients))
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.lspManager), tools.NewReferencesTool(c.lspManager), tools.NewLSPRestartTool(c.lspManager))
440440 }
441441
442442 if len(c.cfg.MCP) > 0 {
internal/agent/tools/diagnostics.go+23−9
@@ -10,7 +10,6 @@ import (
1010 "time"
1111
1212 "charm.land/fantasy"
13− "github.com/charmbracelet/crush/internal/csync"
1413 "github.com/charmbracelet/crush/internal/lsp"
1514 "github.com/charmbracelet/x/powernap/pkg/lsp/protocol"
1615)
@@ -24,25 +23,36 @@ const DiagnosticsToolName = "lsp_diagnostics"
2423//go:embed diagnostics.md
2524var diagnosticsDescription []byte
2625
27−func NewDiagnosticsTool(lspClients *csync.Map[string, *lsp.Client]) fantasy.AgentTool {
26+func NewDiagnosticsTool(lspManager *lsp.Manager) fantasy.AgentTool {
2827 return fantasy.NewAgentTool(
2928 DiagnosticsToolName,
3029 string(diagnosticsDescription),
3130 func(ctx context.Context, params DiagnosticsParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
32− if lspClients.Len() == 0 {
31+ if lspManager.Clients().Len() == 0 {
3332 return fantasy.NewTextErrorResponse("no LSP clients available"), nil
3433 }
35− notifyLSPs(ctx, lspClients, params.FilePath)
36− output := getDiagnostics(params.FilePath, lspClients)
34+ notifyLSPs(ctx, lspManager, params.FilePath)
35+ output := getDiagnostics(params.FilePath, lspManager)
3736 return fantasy.NewTextResponse(output), nil
3837 })
3938}
4039
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+) {
4245 if filepath == "" {
4346 return
4447 }
45− for client := range lsps.Seq() {
48+
49+ if manager == nil {
50+ return
51+ }
52+
53+ manager.Start(ctx, filepath)
54+
55+ for client := range manager.Clients().Seq() {
4656 if !client.HandlesFile(filepath) {
4757 continue
4858 }
@@ -52,11 +62,15 @@ func notifyLSPs(ctx context.Context, lsps *csync.Map[string, *lsp.Client], filep
5262 }
5363}
5464
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+
5670 fileDiagnostics := []string{}
5771 projectDiagnostics := []string{}
5872
59− for lspName, client := range lsps.Seq2() {
73+ for lspName, client := range manager.Clients().Seq2() {
6074 for location, diags := range client.GetDiagnostics() {
6175 path, err := location.Path()
6276 if err != nil {
internal/agent/tools/edit.go+3−4
@@ -11,7 +11,6 @@ import (
1111 "time"
1212
1313 "charm.land/fantasy"
14− "github.com/charmbracelet/crush/internal/csync"
1514 "github.com/charmbracelet/crush/internal/diff"
1615 "github.com/charmbracelet/crush/internal/filepathext"
1716 "github.com/charmbracelet/crush/internal/filetracker"
@@ -61,7 +60,7 @@ type editContext struct {
6160}
6261
6362func NewEditTool(
64− lspClients *csync.Map[string, *lsp.Client],
63+ lspManager *lsp.Manager,
6564 permissions permission.Service,
6665 files history.Service,
6766 filetracker filetracker.Service,
@@ -99,10 +98,10 @@ func NewEditTool(
9998 return response, nil
10099 }
101100
102− notifyLSPs(ctx, lspClients, params.FilePath)
101+ notifyLSPs(ctx, lspManager, params.FilePath)
103102
104103 text := fmt.Sprintf("<result>\n%s\n</result>\n", response.Content)
105− text += getDiagnostics(params.FilePath, lspClients)
104+ text += getDiagnostics(params.FilePath, lspManager)
106105 response.Content = text
107106 return response, nil
108107 })
internal/agent/tools/lsp_restart.go+4−5
@@ -10,7 +10,6 @@ import (
1010 "sync"
1111
1212 "charm.land/fantasy"
13− "github.com/charmbracelet/crush/internal/csync"
1413 "github.com/charmbracelet/crush/internal/lsp"
1514)
1615
@@ -25,20 +24,20 @@ type LSPRestartParams struct {
2524 Name string `json:"name,omitempty"`
2625}
2726
28−func NewLSPRestartTool(lspClients *csync.Map[string, *lsp.Client]) fantasy.AgentTool {
27+func NewLSPRestartTool(lspManager *lsp.Manager) fantasy.AgentTool {
2928 return fantasy.NewAgentTool(
3029 LSPRestartToolName,
3130 string(lspRestartDescription),
3231 func(ctx context.Context, params LSPRestartParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
33− if lspClients.Len() == 0 {
32+ if lspManager.Clients().Len() == 0 {
3433 return fantasy.NewTextErrorResponse("no LSP clients available to restart"), nil
3534 }
3635
3736 clientsToRestart := make(map[string]*lsp.Client)
3837 if params.Name == "" {
39− maps.Insert(clientsToRestart, lspClients.Seq2())
38+ maps.Insert(clientsToRestart, lspManager.Clients().Seq2())
4039 } else {
41− client, exists := lspClients.Get(params.Name)
40+ client, exists := lspManager.Clients().Get(params.Name)
4241 if !exists {
4342 return fantasy.NewTextErrorResponse(fmt.Sprintf("LSP client '%s' not found", params.Name)), nil
4443 }
internal/agent/tools/multiedit.go+3−4
@@ -11,7 +11,6 @@ import (
1111 "time"
1212
1313 "charm.land/fantasy"
14− "github.com/charmbracelet/crush/internal/csync"
1514 "github.com/charmbracelet/crush/internal/diff"
1615 "github.com/charmbracelet/crush/internal/filepathext"
1716 "github.com/charmbracelet/crush/internal/filetracker"
@@ -59,7 +58,7 @@ const MultiEditToolName = "multiedit"
5958var multieditDescription []byte
6059
6160func NewMultiEditTool(
62− lspClients *csync.Map[string, *lsp.Client],
61+ lspManager *lsp.Manager,
6362 permissions permission.Service,
6463 files history.Service,
6564 filetracker filetracker.Service,
@@ -104,11 +103,11 @@ func NewMultiEditTool(
104103 }
105104
106105 // Notify LSP clients about the change
107− notifyLSPs(ctx, lspClients, params.FilePath)
106+ notifyLSPs(ctx, lspManager, params.FilePath)
108107
109108 // Wait for LSP diagnostics and add them to the response
110109 text := fmt.Sprintf("<result>\n%s\n</result>\n", response.Content)
111− text += getDiagnostics(params.FilePath, lspClients)
110+ text += getDiagnostics(params.FilePath, lspManager)
112111 response.Content = text
113112 return response, nil
114113 })
internal/agent/tools/references.go+6−7
@@ -15,7 +15,6 @@ import (
1515 "strings"
1616
1717 "charm.land/fantasy"
18− "github.com/charmbracelet/crush/internal/csync"
1918 "github.com/charmbracelet/crush/internal/lsp"
2019 "github.com/charmbracelet/x/powernap/pkg/lsp/protocol"
2120)
@@ -26,7 +25,7 @@ type ReferencesParams struct {
2625}
2726
2827type referencesTool struct {
29− lspClients *csync.Map[string, *lsp.Client]
28+ lspManager *lsp.Manager
3029}
3130
3231const ReferencesToolName = "lsp_references"
@@ -34,7 +33,7 @@ const ReferencesToolName = "lsp_references"
3433//go:embed references.md
3534var referencesDescription []byte
3635
37−func NewReferencesTool(lspClients *csync.Map[string, *lsp.Client]) fantasy.AgentTool {
36+func NewReferencesTool(lspManager *lsp.Manager) fantasy.AgentTool {
3837 return fantasy.NewAgentTool(
3938 ReferencesToolName,
4039 string(referencesDescription),
@@ -43,7 +42,7 @@ func NewReferencesTool(lspClients *csync.Map[string, *lsp.Client]) fantasy.Agent
4342 return fantasy.NewTextErrorResponse("symbol is required"), nil
4443 }
4544
46− if lspClients.Len() == 0 {
45+ if lspManager.Clients().Len() == 0 {
4746 return fantasy.NewTextErrorResponse("no LSP clients available"), nil
4847 }
4948
@@ -61,7 +60,7 @@ func NewReferencesTool(lspClients *csync.Map[string, *lsp.Client]) fantasy.Agent
6160 var allLocations []protocol.Location
6261 var allErrs error
6362 for _, match := range matches {
64− locations, err := find(ctx, lspClients, params.Symbol, match)
63+ locations, err := find(ctx, lspManager, params.Symbol, match)
6564 if err != nil {
6665 if strings.Contains(err.Error(), "no identifier found") {
6766 // grep probably matched a comment, string value, or something else that's irrelevant
@@ -91,14 +90,14 @@ func (r *referencesTool) Name() string {
9190 return ReferencesToolName
9291}
9392
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) {
9594 absPath, err := filepath.Abs(match.path)
9695 if err != nil {
9796 return nil, fmt.Errorf("failed to get absolute path: %s", err)
9897 }
9998
10099 var client *lsp.Client
101− for c := range lspClients.Seq() {
100+ for c := range lspManager.Clients().Seq() {
102101 if c.HandlesFile(absPath) {
103102 client = c
104103 break
internal/agent/tools/view.go+3−4
@@ -13,7 +13,6 @@ import (
1313 "unicode/utf8"
1414
1515 "charm.land/fantasy"
16− "github.com/charmbracelet/crush/internal/csync"
1716 "github.com/charmbracelet/crush/internal/filepathext"
1817 "github.com/charmbracelet/crush/internal/filetracker"
1918 "github.com/charmbracelet/crush/internal/lsp"
@@ -48,7 +47,7 @@ const (
4847)
4948
5049func NewViewTool(
51− lspClients *csync.Map[string, *lsp.Client],
50+ lspManager *lsp.Manager,
5251 permissions permission.Service,
5352 filetracker filetracker.Service,
5453 workingDir string,
@@ -184,7 +183,7 @@ func NewViewTool(
184183 return fantasy.ToolResponse{}, fmt.Errorf("error reading file: %w", err)
185184 }
186185
187− notifyLSPs(ctx, lspClients, filePath)
186+ notifyLSPs(ctx, lspManager, filePath)
188187 output := "<file>\n"
189188 // Format the output with line numbers
190189 output += addLineNumbers(content, params.Offset+1)
@@ -195,7 +194,7 @@ func NewViewTool(
195194 params.Offset+len(strings.Split(content, "\n")))
196195 }
197196 output += "\n</file>\n"
198− output += getDiagnostics(filePath, lspClients)
197+ output += getDiagnostics(filePath, lspManager)
199198 filetracker.RecordRead(ctx, sessionID, filePath)
200199 return fantasy.WithResponseMetadata(
201200 fantasy.NewTextResponse(output),
internal/agent/tools/write.go+3−4
@@ -11,7 +11,6 @@ import (
1111 "time"
1212
1313 "charm.land/fantasy"
14− "github.com/charmbracelet/crush/internal/csync"
1514 "github.com/charmbracelet/crush/internal/diff"
1615 "github.com/charmbracelet/crush/internal/filepathext"
1716 "github.com/charmbracelet/crush/internal/filetracker"
@@ -45,7 +44,7 @@ type WriteResponseMetadata struct {
4544const WriteToolName = "write"
4645
4746func NewWriteTool(
48− lspClients *csync.Map[string, *lsp.Client],
47+ lspManager *lsp.Manager,
4948 permissions permission.Service,
5049 files history.Service,
5150 filetracker filetracker.Service,
@@ -161,11 +160,11 @@ func NewWriteTool(
161160
162161 filetracker.RecordRead(ctx, sessionID, filePath)
163162
164− notifyLSPs(ctx, lspClients, params.FilePath)
163+ notifyLSPs(ctx, lspManager, params.FilePath)
165164
166165 result := fmt.Sprintf("File successfully written: %s", filePath)
167166 result = fmt.Sprintf("<result>\n%s\n</result>", result)
168− result += getDiagnostics(filePath, lspClients)
167+ result += getDiagnostics(filePath, lspManager)
169168 return fantasy.WithResponseMetadata(fantasy.NewTextResponse(result),
170169 WriteResponseMetadata{
171170 Diff: diff,
internal/app/app.go+13−17
@@ -21,7 +21,6 @@ import (
2121 "github.com/charmbracelet/crush/internal/agent"
2222 "github.com/charmbracelet/crush/internal/agent/tools/mcp"
2323 "github.com/charmbracelet/crush/internal/config"
24− "github.com/charmbracelet/crush/internal/csync"
2524 "github.com/charmbracelet/crush/internal/db"
2625 "github.com/charmbracelet/crush/internal/filetracker"
2726 "github.com/charmbracelet/crush/internal/format"
@@ -58,7 +57,7 @@ type App struct {
5857
5958 AgentCoordinator agent.Coordinator
6059
61− LSPClients *csync.Map[string, *lsp.Client]
60+ LSPManager *lsp.Manager
6261
6362 config *config.Config
6463
@@ -90,7 +89,7 @@ func New(ctx context.Context, conn *sql.DB, cfg *config.Config) (*App, error) {
9089 History: files,
9190 Permissions: permission.NewPermissionService(cfg.WorkingDir(), skipPermissionsRequests, allowedTools),
9291 FileTracker: filetracker.NewService(q),
93− LSPClients: csync.NewMap[string, *lsp.Client](),
92+ LSPManager: lsp.NewManager(cfg),
9493
9594 globalCtx: ctx,
9695
@@ -103,9 +102,6 @@ func New(ctx context.Context, conn *sql.DB, cfg *config.Config) (*App, error) {
103102
104103 app.setupEvents()
105104
106− // Initialize LSP clients in the background.
107− go app.initLSPClients(ctx)
108−
109105 // Check for updates in the background.
110106 go app.checkForUpdates(ctx)
111107
@@ -122,6 +118,13 @@ func New(ctx context.Context, conn *sql.DB, cfg *config.Config) (*App, error) {
122118 if err := app.InitCoderAgent(ctx); err != nil {
123119 return nil, fmt.Errorf("failed to initialize coder agent: %w", err)
124120 }
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+
125128 return app, nil
126129}
127130
@@ -482,7 +485,7 @@ func (app *App) InitCoderAgent(ctx context.Context) error {
482485 app.Permissions,
483486 app.History,
484487 app.FileTracker,
485− app.LSPClients,
488+ app.LSPManager,
486489 )
487490 if err != nil {
488491 slog.Error("Failed to create coder agent", "err", err)
@@ -545,16 +548,9 @@ func (app *App) Shutdown() {
545548 // Shutdown all LSP clients.
546549 shutdownCtx, cancel := context.WithTimeout(app.globalCtx, 5*time.Second)
547550 defer cancel()
548− for name, client := range app.LSPClients.Seq2() {
549− wg.Go(func() {
550− if err := client.Close(shutdownCtx); err != nil &&
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− })
557− }
551+ wg.Go(func() {
552+ app.LSPManager.StopAll(shutdownCtx)
553+ })
558554
559555 // Call all cleanup functions.
560556 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) {
108108 if q.listNewFilesStmt, err = db.PrepareContext(ctx, listNewFiles); err != nil {
109109 return nil, fmt.Errorf("error preparing query ListNewFiles: %w", err)
110110 }
111+ if q.listSessionReadFilesStmt, err = db.PrepareContext(ctx, listSessionReadFiles); err != nil {
112+ return nil, fmt.Errorf("error preparing query ListSessionReadFiles: %w", err)
113+ }
111114 if q.listSessionsStmt, err = db.PrepareContext(ctx, listSessions); err != nil {
112115 return nil, fmt.Errorf("error preparing query ListSessions: %w", err)
113116 }
@@ -271,6 +274,11 @@ func (q *Queries) Close() error {
271274 err = fmt.Errorf("error closing listNewFilesStmt: %w", cerr)
272275 }
273276 }
277+ if q.listSessionReadFilesStmt != nil {
278+ if cerr := q.listSessionReadFilesStmt.Close(); cerr != nil {
279+ err = fmt.Errorf("error closing listSessionReadFilesStmt: %w", cerr)
280+ }
281+ }
274282 if q.listSessionsStmt != nil {
275283 if cerr := q.listSessionsStmt.Close(); cerr != nil {
276284 err = fmt.Errorf("error closing listSessionsStmt: %w", cerr)
@@ -368,6 +376,7 @@ type Queries struct {
368376 listLatestSessionFilesStmt *sql.Stmt
369377 listMessagesBySessionStmt *sql.Stmt
370378 listNewFilesStmt *sql.Stmt
379+ listSessionReadFilesStmt *sql.Stmt
371380 listSessionsStmt *sql.Stmt
372381 listUserMessagesBySessionStmt *sql.Stmt
373382 recordFileReadStmt *sql.Stmt
@@ -408,6 +417,7 @@ func (q *Queries) WithTx(tx *sql.Tx) *Queries {
408417 listLatestSessionFilesStmt: q.listLatestSessionFilesStmt,
409418 listMessagesBySessionStmt: q.listMessagesBySessionStmt,
410419 listNewFilesStmt: q.listNewFilesStmt,
420+ listSessionReadFilesStmt: q.listSessionReadFilesStmt,
411421 listSessionsStmt: q.listSessionsStmt,
412422 listUserMessagesBySessionStmt: q.listUserMessagesBySessionStmt,
413423 recordFileReadStmt: q.recordFileReadStmt,
internal/db/querier.go+1
@@ -37,6 +37,7 @@ type Querier interface {
3737 ListLatestSessionFiles(ctx context.Context, sessionID string) ([]File, error)
3838 ListMessagesBySession(ctx context.Context, sessionID string) ([]Message, error)
3939 ListNewFiles(ctx context.Context) ([]File, error)
40+ ListSessionReadFiles(ctx context.Context, sessionID string) ([]ReadFile, error)
4041 ListSessions(ctx context.Context) ([]Session, error)
4142 ListUserMessagesBySession(ctx context.Context, sessionID string) ([]Message, error)
4243 RecordFileRead(ctx context.Context, arg RecordFileReadParams) error
internal/db/read_files.sql.go+33
@@ -48,6 +48,39 @@ type RecordFileReadParams struct {
4848 Path string `json:"path"`
4949}
5050
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+
5184func (q *Queries) RecordFileRead(ctx context.Context, arg RecordFileReadParams) error {
5285 _, err := q.exec(ctx, q.recordFileReadStmt, recordFileRead,
5386 arg.SessionID,
internal/db/sql/read_files.sql+5
@@ -13,3 +13,8 @@ INSERT INTO read_files (
1313-- name: GetFileRead :one
1414SELECT * FROM read_files
1515WHERE 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
33
44import (
55 "context"
6+ "fmt"
67 "log/slog"
78 "os"
89 "path/filepath"
@@ -19,6 +20,9 @@ type Service interface {
1920 // LastReadTime returns when a file was last read.
2021 // Returns zero time if never read.
2122 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)
2226}
2327
2428type service struct {
@@ -68,3 +72,22 @@ func relpath(path string) string {
6872 }
6973 return relpath
7074}
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 (
1313 "sync/atomic"
1414 "time"
1515
16− "github.com/bmatcuk/doublestar/v4"
1716 "github.com/charmbracelet/crush/internal/config"
1817 "github.com/charmbracelet/crush/internal/csync"
19− "github.com/charmbracelet/crush/internal/fsext"
2018 "github.com/charmbracelet/crush/internal/home"
21− powernapconfig "github.com/charmbracelet/x/powernap/pkg/config"
2219 powernap "github.com/charmbracelet/x/powernap/pkg/lsp"
2320 "github.com/charmbracelet/x/powernap/pkg/lsp/protocol"
2421 "github.com/charmbracelet/x/powernap/pkg/transport"
@@ -200,6 +197,8 @@ func (c *Client) Restart() error {
200197 slog.Warn("Error closing client during restart", "name", c.name, "error", err)
201198 }
202199
200+ c.SetServerState(StateStopped)
201+
203202 c.diagCountsCache = DiagnosticCounts{}
204203 c.diagCountsVersion = 0
205204
@@ -237,7 +236,8 @@ func (c *Client) Restart() error {
237236type ServerState int
238237
239238const (
240− StateStarting ServerState = iota
239+ StateStopped ServerState = iota
240+ StateStarting
241241 StateReady
242242 StateError
243243 StateDisabled
@@ -578,72 +578,3 @@ func (c *Client) FindReferences(ctx context.Context, filepath string, line, char
578578 // See: https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#position
579579 return c.client.FindReferences(ctx, filepath, line-1, character-1, includeDeclaration)
580580}
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(
7474 b.WriteString(h.compactLogo)
7575
7676 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+ )
7884
7985 remainingWidth := width -
8086 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 {
3131
3232 var lsps []LSPInfo
3333 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)
3535 if !ok {
3636 continue
3737 }
@@ -89,6 +89,9 @@ func lspList(t *styles.Styles, lsps []LSPInfo, width, maxItems int) string {
8989 var description string
9090 var diagnostics string
9191 switch l.State {
92+ case lsp.StateStopped:
93+ icon = t.ItemOfflineIcon.Foreground(t.Muted.GetBackground()).String()
94+ description = t.Subtle.Render("stopped")
9295 case lsp.StateStarting:
9396 icon = t.ItemBusyIcon.String()
9497 description = t.Subtle.Render("starting...")
@@ -103,7 +106,7 @@ func lspList(t *styles.Styles, lsps []LSPInfo, width, maxItems int) string {
103106 }
104107 case lsp.StateDisabled:
105108 icon = t.ItemOfflineIcon.Foreground(t.Muted.GetBackground()).String()
106− description = t.Subtle.Render("inactive")
109+ description = t.Subtle.Render("disabled")
107110 default:
108111 icon = t.ItemOfflineIcon.String()
109112 }
internal/ui/model/session.go+50−4
@@ -3,6 +3,7 @@ package model
33import (
44 "context"
55 "fmt"
6+ "log/slog"
67 "path/filepath"
78 "slices"
89 "strings"
@@ -22,8 +23,32 @@ import (
2223// loadSessionMsg is a message indicating that a session and its files have
2324// been loaded.
2425type loadSessionMsg struct {
25− session *session.Session
26− files []SessionFile
26+ session *session.Session
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
2752}
2853
2954// 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 {
5176 return util.ReportError(err)
5277 }
5378
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+
5484 return loadSessionMsg{
55− session: &session,
56− files: sessionFiles,
85+ session: &session,
86+ files: sessionFiles,
87+ readFiles: readFiles,
5788 }
5889 }
5990}
@@ -200,3 +231,18 @@ func fileList(t *styles.Styles, cwd string, filesWithChanges []SessionFile, widt
200231
201232 return lipgloss.JoinVertical(lipgloss.Left, renderedFiles...)
202233}
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) {
397397 m.setState(uiChat, m.focus)
398398 m.session = msg.session
399399 m.sessionFiles = msg.files
400+ cmds = append(cmds, m.startLSPs(msg.lspFilePaths()))
400401 msgs, err := m.com.App.Messages.List(context.Background(), m.session.ID)
401402 if err != nil {
402403 cmds = append(cmds, util.ReportError(err))
@@ -417,8 +418,14 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
417418 m.historyReset()
418419 cmds = append(cmds, m.loadPromptHistory())
419420 m.updateLayoutAndSize()
421+
420422 case sessionFilesUpdatesMsg:
421423 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))
422429
423430 case sendMessageMsg:
424431 cmds = append(cmds, m.sendMessage(msg.Content, msg.Attachments...))
@@ -2706,8 +2713,10 @@ func (m *UI) sendMessage(content string, attachments ...message.Attachment) tea.
27062713 m.setState(uiChat, m.focus)
27072714 }
27082715
2716+ ctx := context.Background()
27092717 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)
27112720 }
27122721
27132722 // Capture session ID to avoid race with main goroutine updating m.session.
@@ -2965,7 +2974,13 @@ func (m *UI) newSession() tea.Cmd {
29652974 m.promptQueue = 0
29662975 m.pillsView = ""
29672976 m.historyReset()
2968− return m.loadPromptHistory()
2977+ return tea.Batch(
2978+ func() tea.Msg {
2979+ m.com.App.LSPManager.StopAll(context.Background())
2980+ return nil
2981+ },
2982+ m.loadPromptHistory(),
2983+ )
29692984}
29702985
29712986// handlePasteMsg handles a paste message.