feat(lsp): start LSPs on demand, improve auto-start (#2103)

Carlos Alexandro Becker and Andrey Nering created

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

Change summary

go.mod                               |   2 
internal/agent/agentic_fetch_tool.go |   2 
internal/agent/common_test.go        |   8 
internal/agent/coordinator.go        |  20 +-
internal/agent/tools/diagnostics.go  |  32 ++
internal/agent/tools/edit.go         |   7 
internal/agent/tools/lsp_restart.go  |   9 
internal/agent/tools/multiedit.go    |   7 
internal/agent/tools/references.go   |  13 
internal/agent/tools/view.go         |   7 
internal/agent/tools/write.go        |   7 
internal/app/app.go                  |  30 +-
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               |  77 --------
internal/lsp/filtermatching_test.go  | 111 ------------
internal/lsp/manager.go              | 271 ++++++++++++++++++++++++++++++
internal/ui/model/header.go          |   8 
internal/ui/model/lsp.go             |   7 
internal/ui/model/session.go         |  54 +++++
internal/ui/model/ui.go              |  19 +
25 files changed, 500 insertions(+), 426 deletions(-)

Detailed changes

go.mod 🔗

@@ -55,6 +55,7 @@ require (
 	github.com/rivo/uniseg v0.4.7
 	github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06
 	github.com/sahilm/fuzzy v0.1.1
+	github.com/sourcegraph/jsonrpc2 v0.2.1
 	github.com/spf13/cobra v1.10.2
 	github.com/stretchr/testify v1.11.1
 	github.com/tidwall/gjson v1.18.0
@@ -150,7 +151,6 @@ require (
 	github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
 	github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
 	github.com/sethvargo/go-retry v0.3.0 // indirect
-	github.com/sourcegraph/jsonrpc2 v0.2.1 // indirect
 	github.com/spf13/pflag v1.0.9 // indirect
 	github.com/tetratelabs/wazero v1.11.0 // indirect
 	github.com/tidwall/match v1.1.1 // indirect

internal/agent/agentic_fetch_tool.go 🔗

@@ -169,7 +169,7 @@ func (c *coordinator) agenticFetchTool(_ context.Context, client *http.Client) (
 				tools.NewGlobTool(tmpDir),
 				tools.NewGrepTool(tmpDir),
 				tools.NewSourcegraphTool(client),
-				tools.NewViewTool(c.lspClients, c.permissions, c.filetracker, tmpDir),
+				tools.NewViewTool(c.lspManager, c.permissions, c.filetracker, tmpDir),
 			}
 
 			agent := NewSessionAgent(SessionAgentOptions{

internal/agent/common_test.go 🔗

@@ -204,15 +204,15 @@ func coderAgent(r *vcr.Recorder, env fakeEnv, large, small fantasy.LanguageModel
 	allTools := []fantasy.AgentTool{
 		tools.NewBashTool(env.permissions, env.workingDir, cfg.Options.Attribution, modelName),
 		tools.NewDownloadTool(env.permissions, env.workingDir, r.GetDefaultClient()),
-		tools.NewEditTool(env.lspClients, env.permissions, env.history, *env.filetracker, env.workingDir),
-		tools.NewMultiEditTool(env.lspClients, env.permissions, env.history, *env.filetracker, env.workingDir),
+		tools.NewEditTool(nil, env.permissions, env.history, *env.filetracker, env.workingDir),
+		tools.NewMultiEditTool(nil, env.permissions, env.history, *env.filetracker, env.workingDir),
 		tools.NewFetchTool(env.permissions, env.workingDir, r.GetDefaultClient()),
 		tools.NewGlobTool(env.workingDir),
 		tools.NewGrepTool(env.workingDir),
 		tools.NewLsTool(env.permissions, env.workingDir, cfg.Tools.Ls),
 		tools.NewSourcegraphTool(r.GetDefaultClient()),
-		tools.NewViewTool(env.lspClients, env.permissions, *env.filetracker, env.workingDir),
-		tools.NewWriteTool(env.lspClients, env.permissions, env.history, *env.filetracker, env.workingDir),
+		tools.NewViewTool(nil, env.permissions, *env.filetracker, env.workingDir),
+		tools.NewWriteTool(nil, env.permissions, env.history, *env.filetracker, env.workingDir),
 	}
 
 	return testSessionAgent(env, large, small, systemPrompt, allTools...), nil

internal/agent/coordinator.go 🔗

@@ -21,7 +21,6 @@ import (
 	"github.com/charmbracelet/crush/internal/agent/prompt"
 	"github.com/charmbracelet/crush/internal/agent/tools"
 	"github.com/charmbracelet/crush/internal/config"
-	"github.com/charmbracelet/crush/internal/csync"
 	"github.com/charmbracelet/crush/internal/filetracker"
 	"github.com/charmbracelet/crush/internal/history"
 	"github.com/charmbracelet/crush/internal/log"
@@ -67,7 +66,7 @@ type coordinator struct {
 	permissions permission.Service
 	history     history.Service
 	filetracker filetracker.Service
-	lspClients  *csync.Map[string, *lsp.Client]
+	lspManager  *lsp.Manager
 
 	currentAgent SessionAgent
 	agents       map[string]SessionAgent
@@ -83,7 +82,7 @@ func NewCoordinator(
 	permissions permission.Service,
 	history history.Service,
 	filetracker filetracker.Service,
-	lspClients *csync.Map[string, *lsp.Client],
+	lspManager *lsp.Manager,
 ) (Coordinator, error) {
 	c := &coordinator{
 		cfg:         cfg,
@@ -92,7 +91,7 @@ func NewCoordinator(
 		permissions: permissions,
 		history:     history,
 		filetracker: filetracker,
-		lspClients:  lspClients,
+		lspManager:  lspManager,
 		agents:      make(map[string]SessionAgent),
 	}
 
@@ -423,20 +422,21 @@ func (c *coordinator) buildTools(ctx context.Context, agent config.Agent) ([]fan
 		tools.NewJobOutputTool(),
 		tools.NewJobKillTool(),
 		tools.NewDownloadTool(c.permissions, c.cfg.WorkingDir(), nil),
-		tools.NewEditTool(c.lspClients, c.permissions, c.history, c.filetracker, c.cfg.WorkingDir()),
-		tools.NewMultiEditTool(c.lspClients, c.permissions, c.history, c.filetracker, c.cfg.WorkingDir()),
+		tools.NewEditTool(c.lspManager, c.permissions, c.history, c.filetracker, c.cfg.WorkingDir()),
+		tools.NewMultiEditTool(c.lspManager, c.permissions, c.history, c.filetracker, c.cfg.WorkingDir()),
 		tools.NewFetchTool(c.permissions, c.cfg.WorkingDir(), nil),
 		tools.NewGlobTool(c.cfg.WorkingDir()),
 		tools.NewGrepTool(c.cfg.WorkingDir()),
 		tools.NewLsTool(c.permissions, c.cfg.WorkingDir(), c.cfg.Tools.Ls),
 		tools.NewSourcegraphTool(nil),
 		tools.NewTodosTool(c.sessions),
-		tools.NewViewTool(c.lspClients, c.permissions, c.filetracker, c.cfg.WorkingDir(), c.cfg.Options.SkillsPaths...),
-		tools.NewWriteTool(c.lspClients, c.permissions, c.history, c.filetracker, c.cfg.WorkingDir()),
+		tools.NewViewTool(c.lspManager, c.permissions, c.filetracker, c.cfg.WorkingDir(), c.cfg.Options.SkillsPaths...),
+		tools.NewWriteTool(c.lspManager, c.permissions, c.history, c.filetracker, c.cfg.WorkingDir()),
 	)
 
-	if c.lspClients.Len() > 0 {
-		allTools = append(allTools, tools.NewDiagnosticsTool(c.lspClients), tools.NewReferencesTool(c.lspClients), tools.NewLSPRestartTool(c.lspClients))
+	// Add LSP tools if user has configured LSPs or auto_lsp is enabled (nil or true).
+	if len(c.cfg.LSP) > 0 || c.cfg.Options.AutoLSP == nil || *c.cfg.Options.AutoLSP {
+		allTools = append(allTools, tools.NewDiagnosticsTool(c.lspManager), tools.NewReferencesTool(c.lspManager), tools.NewLSPRestartTool(c.lspManager))
 	}
 
 	if len(c.cfg.MCP) > 0 {

internal/agent/tools/diagnostics.go 🔗

@@ -10,7 +10,6 @@ import (
 	"time"
 
 	"charm.land/fantasy"
-	"github.com/charmbracelet/crush/internal/csync"
 	"github.com/charmbracelet/crush/internal/lsp"
 	"github.com/charmbracelet/x/powernap/pkg/lsp/protocol"
 )
@@ -24,25 +23,36 @@ const DiagnosticsToolName = "lsp_diagnostics"
 //go:embed diagnostics.md
 var diagnosticsDescription []byte
 
-func NewDiagnosticsTool(lspClients *csync.Map[string, *lsp.Client]) fantasy.AgentTool {
+func NewDiagnosticsTool(lspManager *lsp.Manager) fantasy.AgentTool {
 	return fantasy.NewAgentTool(
 		DiagnosticsToolName,
 		string(diagnosticsDescription),
 		func(ctx context.Context, params DiagnosticsParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
-			if lspClients.Len() == 0 {
+			if lspManager.Clients().Len() == 0 {
 				return fantasy.NewTextErrorResponse("no LSP clients available"), nil
 			}
-			notifyLSPs(ctx, lspClients, params.FilePath)
-			output := getDiagnostics(params.FilePath, lspClients)
+			notifyLSPs(ctx, lspManager, params.FilePath)
+			output := getDiagnostics(params.FilePath, lspManager)
 			return fantasy.NewTextResponse(output), nil
 		})
 }
 
-func notifyLSPs(ctx context.Context, lsps *csync.Map[string, *lsp.Client], filepath string) {
+func notifyLSPs(
+	ctx context.Context,
+	manager *lsp.Manager,
+	filepath string,
+) {
 	if filepath == "" {
 		return
 	}
-	for client := range lsps.Seq() {
+
+	if manager == nil {
+		return
+	}
+
+	manager.Start(ctx, filepath)
+
+	for client := range manager.Clients().Seq() {
 		if !client.HandlesFile(filepath) {
 			continue
 		}
@@ -52,11 +62,15 @@ func notifyLSPs(ctx context.Context, lsps *csync.Map[string, *lsp.Client], filep
 	}
 }
 
-func getDiagnostics(filePath string, lsps *csync.Map[string, *lsp.Client]) string {
+func getDiagnostics(filePath string, manager *lsp.Manager) string {
+	if manager == nil {
+		return ""
+	}
+
 	fileDiagnostics := []string{}
 	projectDiagnostics := []string{}
 
-	for lspName, client := range lsps.Seq2() {
+	for lspName, client := range manager.Clients().Seq2() {
 		for location, diags := range client.GetDiagnostics() {
 			path, err := location.Path()
 			if err != nil {

internal/agent/tools/edit.go 🔗

@@ -11,7 +11,6 @@ import (
 	"time"
 
 	"charm.land/fantasy"
-	"github.com/charmbracelet/crush/internal/csync"
 	"github.com/charmbracelet/crush/internal/diff"
 	"github.com/charmbracelet/crush/internal/filepathext"
 	"github.com/charmbracelet/crush/internal/filetracker"
@@ -61,7 +60,7 @@ type editContext struct {
 }
 
 func NewEditTool(
-	lspClients *csync.Map[string, *lsp.Client],
+	lspManager *lsp.Manager,
 	permissions permission.Service,
 	files history.Service,
 	filetracker filetracker.Service,
@@ -99,10 +98,10 @@ func NewEditTool(
 				return response, nil
 			}
 
-			notifyLSPs(ctx, lspClients, params.FilePath)
+			notifyLSPs(ctx, lspManager, params.FilePath)
 
 			text := fmt.Sprintf("<result>\n%s\n</result>\n", response.Content)
-			text += getDiagnostics(params.FilePath, lspClients)
+			text += getDiagnostics(params.FilePath, lspManager)
 			response.Content = text
 			return response, nil
 		})

internal/agent/tools/lsp_restart.go 🔗

@@ -10,7 +10,6 @@ import (
 	"sync"
 
 	"charm.land/fantasy"
-	"github.com/charmbracelet/crush/internal/csync"
 	"github.com/charmbracelet/crush/internal/lsp"
 )
 
@@ -25,20 +24,20 @@ type LSPRestartParams struct {
 	Name string `json:"name,omitempty"`
 }
 
-func NewLSPRestartTool(lspClients *csync.Map[string, *lsp.Client]) fantasy.AgentTool {
+func NewLSPRestartTool(lspManager *lsp.Manager) fantasy.AgentTool {
 	return fantasy.NewAgentTool(
 		LSPRestartToolName,
 		string(lspRestartDescription),
 		func(ctx context.Context, params LSPRestartParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
-			if lspClients.Len() == 0 {
+			if lspManager.Clients().Len() == 0 {
 				return fantasy.NewTextErrorResponse("no LSP clients available to restart"), nil
 			}
 
 			clientsToRestart := make(map[string]*lsp.Client)
 			if params.Name == "" {
-				maps.Insert(clientsToRestart, lspClients.Seq2())
+				maps.Insert(clientsToRestart, lspManager.Clients().Seq2())
 			} else {
-				client, exists := lspClients.Get(params.Name)
+				client, exists := lspManager.Clients().Get(params.Name)
 				if !exists {
 					return fantasy.NewTextErrorResponse(fmt.Sprintf("LSP client '%s' not found", params.Name)), nil
 				}

internal/agent/tools/multiedit.go 🔗

@@ -11,7 +11,6 @@ import (
 	"time"
 
 	"charm.land/fantasy"
-	"github.com/charmbracelet/crush/internal/csync"
 	"github.com/charmbracelet/crush/internal/diff"
 	"github.com/charmbracelet/crush/internal/filepathext"
 	"github.com/charmbracelet/crush/internal/filetracker"
@@ -59,7 +58,7 @@ const MultiEditToolName = "multiedit"
 var multieditDescription []byte
 
 func NewMultiEditTool(
-	lspClients *csync.Map[string, *lsp.Client],
+	lspManager *lsp.Manager,
 	permissions permission.Service,
 	files history.Service,
 	filetracker filetracker.Service,
@@ -104,11 +103,11 @@ func NewMultiEditTool(
 			}
 
 			// Notify LSP clients about the change
-			notifyLSPs(ctx, lspClients, params.FilePath)
+			notifyLSPs(ctx, lspManager, params.FilePath)
 
 			// Wait for LSP diagnostics and add them to the response
 			text := fmt.Sprintf("<result>\n%s\n</result>\n", response.Content)
-			text += getDiagnostics(params.FilePath, lspClients)
+			text += getDiagnostics(params.FilePath, lspManager)
 			response.Content = text
 			return response, nil
 		})

internal/agent/tools/references.go 🔗

@@ -15,7 +15,6 @@ import (
 	"strings"
 
 	"charm.land/fantasy"
-	"github.com/charmbracelet/crush/internal/csync"
 	"github.com/charmbracelet/crush/internal/lsp"
 	"github.com/charmbracelet/x/powernap/pkg/lsp/protocol"
 )
@@ -26,7 +25,7 @@ type ReferencesParams struct {
 }
 
 type referencesTool struct {
-	lspClients *csync.Map[string, *lsp.Client]
+	lspManager *lsp.Manager
 }
 
 const ReferencesToolName = "lsp_references"
@@ -34,7 +33,7 @@ const ReferencesToolName = "lsp_references"
 //go:embed references.md
 var referencesDescription []byte
 
-func NewReferencesTool(lspClients *csync.Map[string, *lsp.Client]) fantasy.AgentTool {
+func NewReferencesTool(lspManager *lsp.Manager) fantasy.AgentTool {
 	return fantasy.NewAgentTool(
 		ReferencesToolName,
 		string(referencesDescription),
@@ -43,7 +42,7 @@ func NewReferencesTool(lspClients *csync.Map[string, *lsp.Client]) fantasy.Agent
 				return fantasy.NewTextErrorResponse("symbol is required"), nil
 			}
 
-			if lspClients.Len() == 0 {
+			if lspManager.Clients().Len() == 0 {
 				return fantasy.NewTextErrorResponse("no LSP clients available"), nil
 			}
 
@@ -61,7 +60,7 @@ func NewReferencesTool(lspClients *csync.Map[string, *lsp.Client]) fantasy.Agent
 			var allLocations []protocol.Location
 			var allErrs error
 			for _, match := range matches {
-				locations, err := find(ctx, lspClients, params.Symbol, match)
+				locations, err := find(ctx, lspManager, params.Symbol, match)
 				if err != nil {
 					if strings.Contains(err.Error(), "no identifier found") {
 						// grep probably matched a comment, string value, or something else that's irrelevant
@@ -91,14 +90,14 @@ func (r *referencesTool) Name() string {
 	return ReferencesToolName
 }
 
-func find(ctx context.Context, lspClients *csync.Map[string, *lsp.Client], symbol string, match grepMatch) ([]protocol.Location, error) {
+func find(ctx context.Context, lspManager *lsp.Manager, symbol string, match grepMatch) ([]protocol.Location, error) {
 	absPath, err := filepath.Abs(match.path)
 	if err != nil {
 		return nil, fmt.Errorf("failed to get absolute path: %s", err)
 	}
 
 	var client *lsp.Client
-	for c := range lspClients.Seq() {
+	for c := range lspManager.Clients().Seq() {
 		if c.HandlesFile(absPath) {
 			client = c
 			break

internal/agent/tools/view.go 🔗

@@ -13,7 +13,6 @@ import (
 	"unicode/utf8"
 
 	"charm.land/fantasy"
-	"github.com/charmbracelet/crush/internal/csync"
 	"github.com/charmbracelet/crush/internal/filepathext"
 	"github.com/charmbracelet/crush/internal/filetracker"
 	"github.com/charmbracelet/crush/internal/lsp"
@@ -48,7 +47,7 @@ const (
 )
 
 func NewViewTool(
-	lspClients *csync.Map[string, *lsp.Client],
+	lspManager *lsp.Manager,
 	permissions permission.Service,
 	filetracker filetracker.Service,
 	workingDir string,
@@ -184,7 +183,7 @@ func NewViewTool(
 				return fantasy.ToolResponse{}, fmt.Errorf("error reading file: %w", err)
 			}
 
-			notifyLSPs(ctx, lspClients, filePath)
+			notifyLSPs(ctx, lspManager, filePath)
 			output := "<file>\n"
 			// Format the output with line numbers
 			output += addLineNumbers(content, params.Offset+1)
@@ -195,7 +194,7 @@ func NewViewTool(
 					params.Offset+len(strings.Split(content, "\n")))
 			}
 			output += "\n</file>\n"
-			output += getDiagnostics(filePath, lspClients)
+			output += getDiagnostics(filePath, lspManager)
 			filetracker.RecordRead(ctx, sessionID, filePath)
 			return fantasy.WithResponseMetadata(
 				fantasy.NewTextResponse(output),

internal/agent/tools/write.go 🔗

@@ -11,7 +11,6 @@ import (
 	"time"
 
 	"charm.land/fantasy"
-	"github.com/charmbracelet/crush/internal/csync"
 	"github.com/charmbracelet/crush/internal/diff"
 	"github.com/charmbracelet/crush/internal/filepathext"
 	"github.com/charmbracelet/crush/internal/filetracker"
@@ -45,7 +44,7 @@ type WriteResponseMetadata struct {
 const WriteToolName = "write"
 
 func NewWriteTool(
-	lspClients *csync.Map[string, *lsp.Client],
+	lspManager *lsp.Manager,
 	permissions permission.Service,
 	files history.Service,
 	filetracker filetracker.Service,
@@ -161,11 +160,11 @@ func NewWriteTool(
 
 			filetracker.RecordRead(ctx, sessionID, filePath)
 
-			notifyLSPs(ctx, lspClients, params.FilePath)
+			notifyLSPs(ctx, lspManager, params.FilePath)
 
 			result := fmt.Sprintf("File successfully written: %s", filePath)
 			result = fmt.Sprintf("<result>\n%s\n</result>", result)
-			result += getDiagnostics(filePath, lspClients)
+			result += getDiagnostics(filePath, lspManager)
 			return fantasy.WithResponseMetadata(fantasy.NewTextResponse(result),
 				WriteResponseMetadata{
 					Diff:      diff,

internal/app/app.go 🔗

@@ -21,7 +21,6 @@ import (
 	"github.com/charmbracelet/crush/internal/agent"
 	"github.com/charmbracelet/crush/internal/agent/tools/mcp"
 	"github.com/charmbracelet/crush/internal/config"
-	"github.com/charmbracelet/crush/internal/csync"
 	"github.com/charmbracelet/crush/internal/db"
 	"github.com/charmbracelet/crush/internal/filetracker"
 	"github.com/charmbracelet/crush/internal/format"
@@ -58,7 +57,7 @@ type App struct {
 
 	AgentCoordinator agent.Coordinator
 
-	LSPClients *csync.Map[string, *lsp.Client]
+	LSPManager *lsp.Manager
 
 	config *config.Config
 
@@ -90,7 +89,7 @@ func New(ctx context.Context, conn *sql.DB, cfg *config.Config) (*App, error) {
 		History:     files,
 		Permissions: permission.NewPermissionService(cfg.WorkingDir(), skipPermissionsRequests, allowedTools),
 		FileTracker: filetracker.NewService(q),
-		LSPClients:  csync.NewMap[string, *lsp.Client](),
+		LSPManager:  lsp.NewManager(cfg),
 
 		globalCtx: ctx,
 
@@ -103,9 +102,6 @@ func New(ctx context.Context, conn *sql.DB, cfg *config.Config) (*App, error) {
 
 	app.setupEvents()
 
-	// Initialize LSP clients in the background.
-	go app.initLSPClients(ctx)
-
 	// Check for updates in the background.
 	go app.checkForUpdates(ctx)
 
@@ -122,6 +118,13 @@ func New(ctx context.Context, conn *sql.DB, cfg *config.Config) (*App, error) {
 	if err := app.InitCoderAgent(ctx); err != nil {
 		return nil, fmt.Errorf("failed to initialize coder agent: %w", err)
 	}
+
+	// Set up callback for LSP state updates.
+	app.LSPManager.SetCallback(func(name string, client *lsp.Client) {
+		client.SetDiagnosticsCallback(updateLSPDiagnostics)
+		updateLSPState(name, client.GetServerState(), nil, client, 0)
+	})
+
 	return app, nil
 }
 
@@ -482,7 +485,7 @@ func (app *App) InitCoderAgent(ctx context.Context) error {
 		app.Permissions,
 		app.History,
 		app.FileTracker,
-		app.LSPClients,
+		app.LSPManager,
 	)
 	if err != nil {
 		slog.Error("Failed to create coder agent", "err", err)
@@ -545,16 +548,9 @@ func (app *App) Shutdown() {
 	// Shutdown all LSP clients.
 	shutdownCtx, cancel := context.WithTimeout(app.globalCtx, 5*time.Second)
 	defer cancel()
-	for name, client := range app.LSPClients.Seq2() {
-		wg.Go(func() {
-			if err := client.Close(shutdownCtx); err != nil &&
-				!errors.Is(err, io.EOF) &&
-				!errors.Is(err, context.Canceled) &&
-				err.Error() != "signal: killed" {
-				slog.Warn("Failed to shutdown LSP client", "name", name, "error", err)
-			}
-		})
-	}
+	wg.Go(func() {
+		app.LSPManager.StopAll(shutdownCtx)
+	})
 
 	// Call all cleanup functions.
 	for _, cleanup := range app.cleanupFuncs {

internal/app/lsp.go 🔗

@@ -1,163 +0,0 @@
-package app
-
-import (
-	"cmp"
-	"context"
-	"log/slog"
-	"os/exec"
-	"slices"
-	"sync"
-	"time"
-
-	"github.com/charmbracelet/crush/internal/config"
-	"github.com/charmbracelet/crush/internal/lsp"
-	powernapconfig "github.com/charmbracelet/x/powernap/pkg/config"
-)
-
-// initLSPClients initializes LSP clients.
-func (app *App) initLSPClients(ctx context.Context) {
-	slog.Info("LSP clients initialization started")
-
-	manager := powernapconfig.NewManager()
-	manager.LoadDefaults()
-
-	var userConfiguredLSPs []string
-	for name, clientConfig := range app.config.LSP {
-		if clientConfig.Disabled {
-			slog.Info("Skipping disabled LSP client", "name", name)
-			manager.RemoveServer(name)
-			continue
-		}
-
-		// HACK: the user might have the command name in their config, instead
-		// of the actual name. This finds out these cases, and adjusts the name
-		// accordingly.
-		if _, ok := manager.GetServer(name); !ok {
-			for sname, server := range manager.GetServers() {
-				if server.Command == name {
-					name = sname
-					break
-				}
-			}
-		}
-		userConfiguredLSPs = append(userConfiguredLSPs, name)
-		manager.AddServer(name, &powernapconfig.ServerConfig{
-			Command:     clientConfig.Command,
-			Args:        clientConfig.Args,
-			Environment: clientConfig.Env,
-			FileTypes:   clientConfig.FileTypes,
-			RootMarkers: clientConfig.RootMarkers,
-			InitOptions: clientConfig.InitOptions,
-			Settings:    clientConfig.Options,
-		})
-	}
-
-	servers := manager.GetServers()
-	filtered := lsp.FilterMatching(app.config.WorkingDir(), servers)
-
-	for _, name := range userConfiguredLSPs {
-		if _, ok := filtered[name]; !ok {
-			updateLSPState(name, lsp.StateDisabled, nil, nil, 0)
-		}
-	}
-
-	var wg sync.WaitGroup
-	for name, server := range filtered {
-		if app.config.Options.AutoLSP != nil && !*app.config.Options.AutoLSP && !slices.Contains(userConfiguredLSPs, name) {
-			slog.Debug("Ignoring non user-define LSP client due to AutoLSP being disabled", "name", name)
-			continue
-		}
-		wg.Go(func() {
-			app.createAndStartLSPClient(
-				ctx, name,
-				toOurConfig(server, app.config.LSP[name]),
-				slices.Contains(userConfiguredLSPs, name),
-			)
-		})
-	}
-	wg.Wait()
-
-	if app.AgentCoordinator != nil {
-		if err := app.AgentCoordinator.UpdateModels(ctx); err != nil {
-			slog.Error("Failed to refresh tools after LSP startup", "error", err)
-		}
-	}
-}
-
-// toOurConfig merges powernap default config with user config.
-// If user config is zero value, it means no user override exists.
-func toOurConfig(in *powernapconfig.ServerConfig, user config.LSPConfig) config.LSPConfig {
-	return config.LSPConfig{
-		Command:     in.Command,
-		Args:        in.Args,
-		Env:         in.Environment,
-		FileTypes:   in.FileTypes,
-		RootMarkers: in.RootMarkers,
-		InitOptions: in.InitOptions,
-		Options:     in.Settings,
-		Timeout:     user.Timeout,
-	}
-}
-
-// createAndStartLSPClient creates a new LSP client, initializes it, and starts its workspace watcher.
-func (app *App) createAndStartLSPClient(ctx context.Context, name string, config config.LSPConfig, userConfigured bool) {
-	if !userConfigured {
-		if _, err := exec.LookPath(config.Command); err != nil {
-			slog.Warn("Default LSP config skipped: server not installed", "name", name, "error", err)
-			return
-		}
-	}
-
-	slog.Debug("Creating LSP client", "name", name, "command", config.Command, "fileTypes", config.FileTypes, "args", config.Args)
-
-	// Update state to starting.
-	updateLSPState(name, lsp.StateStarting, nil, nil, 0)
-
-	// Create LSP client.
-	lspClient, err := lsp.New(ctx, name, config, app.config.Resolver(), app.config.Options.DebugLSP)
-	if err != nil {
-		if !userConfigured {
-			slog.Warn("Default LSP config skipped due to error", "name", name, "error", err)
-			updateLSPState(name, lsp.StateDisabled, nil, nil, 0)
-			return
-		}
-		slog.Error("Failed to create LSP client for", "name", name, "error", err)
-		updateLSPState(name, lsp.StateError, err, nil, 0)
-		return
-	}
-
-	// Set diagnostics callback
-	lspClient.SetDiagnosticsCallback(updateLSPDiagnostics)
-
-	// Increase initialization timeout as some servers take more time to start.
-	initCtx, cancel := context.WithTimeout(ctx, time.Duration(cmp.Or(config.Timeout, 30))*time.Second)
-	defer cancel()
-
-	// Initialize LSP client.
-	_, err = lspClient.Initialize(initCtx, app.config.WorkingDir())
-	if err != nil {
-		slog.Error("LSP client initialization failed", "name", name, "error", err)
-		updateLSPState(name, lsp.StateError, err, lspClient, 0)
-		lspClient.Close(ctx)
-		return
-	}
-
-	// Wait for the server to be ready.
-	if err := lspClient.WaitForServerReady(initCtx); err != nil {
-		slog.Error("Server failed to become ready", "name", name, "error", err)
-		// Server never reached a ready state, but let's continue anyway, as
-		// some functionality might still work.
-		lspClient.SetServerState(lsp.StateError)
-		updateLSPState(name, lsp.StateError, err, lspClient, 0)
-	} else {
-		// Server reached a ready state successfully.
-		slog.Debug("LSP server is ready", "name", name)
-		lspClient.SetServerState(lsp.StateReady)
-		updateLSPState(name, lsp.StateReady, nil, lspClient, 0)
-	}
-
-	slog.Debug("LSP client initialized", "name", name)
-
-	// Add to map with mutex protection before starting goroutine
-	app.LSPClients.Set(name, lspClient)
-}

internal/db/db.go 🔗

@@ -108,6 +108,9 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) {
 	if q.listNewFilesStmt, err = db.PrepareContext(ctx, listNewFiles); err != nil {
 		return nil, fmt.Errorf("error preparing query ListNewFiles: %w", err)
 	}
+	if q.listSessionReadFilesStmt, err = db.PrepareContext(ctx, listSessionReadFiles); err != nil {
+		return nil, fmt.Errorf("error preparing query ListSessionReadFiles: %w", err)
+	}
 	if q.listSessionsStmt, err = db.PrepareContext(ctx, listSessions); err != nil {
 		return nil, fmt.Errorf("error preparing query ListSessions: %w", err)
 	}
@@ -271,6 +274,11 @@ func (q *Queries) Close() error {
 			err = fmt.Errorf("error closing listNewFilesStmt: %w", cerr)
 		}
 	}
+	if q.listSessionReadFilesStmt != nil {
+		if cerr := q.listSessionReadFilesStmt.Close(); cerr != nil {
+			err = fmt.Errorf("error closing listSessionReadFilesStmt: %w", cerr)
+		}
+	}
 	if q.listSessionsStmt != nil {
 		if cerr := q.listSessionsStmt.Close(); cerr != nil {
 			err = fmt.Errorf("error closing listSessionsStmt: %w", cerr)
@@ -368,6 +376,7 @@ type Queries struct {
 	listLatestSessionFilesStmt     *sql.Stmt
 	listMessagesBySessionStmt      *sql.Stmt
 	listNewFilesStmt               *sql.Stmt
+	listSessionReadFilesStmt       *sql.Stmt
 	listSessionsStmt               *sql.Stmt
 	listUserMessagesBySessionStmt  *sql.Stmt
 	recordFileReadStmt             *sql.Stmt
@@ -408,6 +417,7 @@ func (q *Queries) WithTx(tx *sql.Tx) *Queries {
 		listLatestSessionFilesStmt:     q.listLatestSessionFilesStmt,
 		listMessagesBySessionStmt:      q.listMessagesBySessionStmt,
 		listNewFilesStmt:               q.listNewFilesStmt,
+		listSessionReadFilesStmt:       q.listSessionReadFilesStmt,
 		listSessionsStmt:               q.listSessionsStmt,
 		listUserMessagesBySessionStmt:  q.listUserMessagesBySessionStmt,
 		recordFileReadStmt:             q.recordFileReadStmt,

internal/db/querier.go 🔗

@@ -37,6 +37,7 @@ type Querier interface {
 	ListLatestSessionFiles(ctx context.Context, sessionID string) ([]File, error)
 	ListMessagesBySession(ctx context.Context, sessionID string) ([]Message, error)
 	ListNewFiles(ctx context.Context) ([]File, error)
+	ListSessionReadFiles(ctx context.Context, sessionID string) ([]ReadFile, error)
 	ListSessions(ctx context.Context) ([]Session, error)
 	ListUserMessagesBySession(ctx context.Context, sessionID string) ([]Message, error)
 	RecordFileRead(ctx context.Context, arg RecordFileReadParams) error

internal/db/read_files.sql.go 🔗

@@ -48,6 +48,39 @@ type RecordFileReadParams struct {
 	Path      string `json:"path"`
 }
 
+const listSessionReadFiles = `-- name: ListSessionReadFiles :many
+SELECT session_id, path, read_at FROM read_files
+WHERE session_id = ?
+ORDER BY read_at DESC
+`
+
+func (q *Queries) ListSessionReadFiles(ctx context.Context, sessionID string) ([]ReadFile, error) {
+	rows, err := q.query(ctx, q.listSessionReadFilesStmt, listSessionReadFiles, sessionID)
+	if err != nil {
+		return nil, err
+	}
+	defer rows.Close()
+	items := []ReadFile{}
+	for rows.Next() {
+		var i ReadFile
+		if err := rows.Scan(
+			&i.SessionID,
+			&i.Path,
+			&i.ReadAt,
+		); err != nil {
+			return nil, err
+		}
+		items = append(items, i)
+	}
+	if err := rows.Close(); err != nil {
+		return nil, err
+	}
+	if err := rows.Err(); err != nil {
+		return nil, err
+	}
+	return items, nil
+}
+
 func (q *Queries) RecordFileRead(ctx context.Context, arg RecordFileReadParams) error {
 	_, err := q.exec(ctx, q.recordFileReadStmt, recordFileRead,
 		arg.SessionID,

internal/db/sql/read_files.sql 🔗

@@ -13,3 +13,8 @@ INSERT INTO read_files (
 -- name: GetFileRead :one
 SELECT * FROM read_files
 WHERE session_id = ? AND path = ? LIMIT 1;
+
+-- name: ListSessionReadFiles :many
+SELECT * FROM read_files
+WHERE session_id = ?
+ORDER BY read_at DESC;

internal/filetracker/service.go 🔗

@@ -3,6 +3,7 @@ package filetracker
 
 import (
 	"context"
+	"fmt"
 	"log/slog"
 	"os"
 	"path/filepath"
@@ -19,6 +20,9 @@ type Service interface {
 	// LastReadTime returns when a file was last read.
 	// Returns zero time if never read.
 	LastReadTime(ctx context.Context, sessionID, path string) time.Time
+
+	// ListReadFiles returns the paths of all files read in a session.
+	ListReadFiles(ctx context.Context, sessionID string) ([]string, error)
 }
 
 type service struct {
@@ -68,3 +72,22 @@ func relpath(path string) string {
 	}
 	return relpath
 }
+
+// ListReadFiles returns the paths of all files read in a session.
+func (s *service) ListReadFiles(ctx context.Context, sessionID string) ([]string, error) {
+	readFiles, err := s.q.ListSessionReadFiles(ctx, sessionID)
+	if err != nil {
+		return nil, fmt.Errorf("listing read files: %w", err)
+	}
+
+	basepath, err := os.Getwd()
+	if err != nil {
+		return nil, fmt.Errorf("getting working directory: %w", err)
+	}
+
+	paths := make([]string, 0, len(readFiles))
+	for _, rf := range readFiles {
+		paths = append(paths, filepath.Join(basepath, rf.Path))
+	}
+	return paths, nil
+}

internal/lsp/client.go 🔗

@@ -13,12 +13,9 @@ import (
 	"sync/atomic"
 	"time"
 
-	"github.com/bmatcuk/doublestar/v4"
 	"github.com/charmbracelet/crush/internal/config"
 	"github.com/charmbracelet/crush/internal/csync"
-	"github.com/charmbracelet/crush/internal/fsext"
 	"github.com/charmbracelet/crush/internal/home"
-	powernapconfig "github.com/charmbracelet/x/powernap/pkg/config"
 	powernap "github.com/charmbracelet/x/powernap/pkg/lsp"
 	"github.com/charmbracelet/x/powernap/pkg/lsp/protocol"
 	"github.com/charmbracelet/x/powernap/pkg/transport"
@@ -200,6 +197,8 @@ func (c *Client) Restart() error {
 		slog.Warn("Error closing client during restart", "name", c.name, "error", err)
 	}
 
+	c.SetServerState(StateStopped)
+
 	c.diagCountsCache = DiagnosticCounts{}
 	c.diagCountsVersion = 0
 
@@ -237,7 +236,8 @@ func (c *Client) Restart() error {
 type ServerState int
 
 const (
-	StateStarting ServerState = iota
+	StateStopped ServerState = iota
+	StateStarting
 	StateReady
 	StateError
 	StateDisabled
@@ -578,72 +578,3 @@ func (c *Client) FindReferences(ctx context.Context, filepath string, line, char
 	// See: https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#position
 	return c.client.FindReferences(ctx, filepath, line-1, character-1, includeDeclaration)
 }
-
-// FilterMatching gets a list of configs and only returns the ones with
-// matching root markers.
-func FilterMatching(dir string, servers map[string]*powernapconfig.ServerConfig) map[string]*powernapconfig.ServerConfig {
-	result := map[string]*powernapconfig.ServerConfig{}
-	if len(servers) == 0 {
-		return result
-	}
-
-	type serverPatterns struct {
-		server   *powernapconfig.ServerConfig
-		patterns []string
-	}
-	normalized := make(map[string]serverPatterns, len(servers))
-	for name, server := range servers {
-		var patterns []string
-		for _, p := range server.RootMarkers {
-			if p == ".git" {
-				// ignore .git for discovery
-				continue
-			}
-			patterns = append(patterns, filepath.ToSlash(p))
-		}
-		if len(patterns) == 0 {
-			slog.Debug("ignoring lsp with no root markers", "name", name)
-			continue
-		}
-		normalized[name] = serverPatterns{server: server, patterns: patterns}
-	}
-
-	walker := fsext.NewFastGlobWalker(dir)
-	_ = filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error {
-		if err != nil {
-			return nil
-		}
-
-		if walker.ShouldSkip(path) {
-			if d.IsDir() {
-				return filepath.SkipDir
-			}
-			return nil
-		}
-
-		relPath, err := filepath.Rel(dir, path)
-		if err != nil {
-			return nil
-		}
-		relPath = filepath.ToSlash(relPath)
-
-		for name, sp := range normalized {
-			for _, pattern := range sp.patterns {
-				matched, err := doublestar.Match(pattern, relPath)
-				if err != nil || !matched {
-					continue
-				}
-				result[name] = sp.server
-				delete(normalized, name)
-				break
-			}
-		}
-
-		if len(normalized) == 0 {
-			return filepath.SkipAll
-		}
-		return nil
-	})
-
-	return result
-}

internal/lsp/filtermatching_test.go 🔗

@@ -1,111 +0,0 @@
-package lsp
-
-import (
-	"os"
-	"path/filepath"
-	"testing"
-
-	powernapconfig "github.com/charmbracelet/x/powernap/pkg/config"
-	"github.com/stretchr/testify/require"
-)
-
-func TestFilterMatching(t *testing.T) {
-	t.Parallel()
-
-	t.Run("matches servers with existing root markers", func(t *testing.T) {
-		t.Parallel()
-		tmpDir := t.TempDir()
-
-		require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "go.mod"), []byte("module test"), 0o644))
-		require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "Cargo.toml"), []byte("[package]"), 0o644))
-
-		servers := map[string]*powernapconfig.ServerConfig{
-			"gopls":          {RootMarkers: []string{"go.mod", "go.work"}},
-			"rust-analyzer":  {RootMarkers: []string{"Cargo.toml"}},
-			"typescript-lsp": {RootMarkers: []string{"package.json", "tsconfig.json"}},
-		}
-
-		result := FilterMatching(tmpDir, servers)
-
-		require.Contains(t, result, "gopls")
-		require.Contains(t, result, "rust-analyzer")
-		require.NotContains(t, result, "typescript-lsp")
-	})
-
-	t.Run("returns empty for empty servers", func(t *testing.T) {
-		t.Parallel()
-		tmpDir := t.TempDir()
-
-		result := FilterMatching(tmpDir, map[string]*powernapconfig.ServerConfig{})
-
-		require.Empty(t, result)
-	})
-
-	t.Run("returns empty when no markers match", func(t *testing.T) {
-		t.Parallel()
-		tmpDir := t.TempDir()
-
-		servers := map[string]*powernapconfig.ServerConfig{
-			"gopls":  {RootMarkers: []string{"go.mod"}},
-			"python": {RootMarkers: []string{"pyproject.toml"}},
-		}
-
-		result := FilterMatching(tmpDir, servers)
-
-		require.Empty(t, result)
-	})
-
-	t.Run("glob patterns work", func(t *testing.T) {
-		t.Parallel()
-		tmpDir := t.TempDir()
-
-		require.NoError(t, os.MkdirAll(filepath.Join(tmpDir, "src"), 0o755))
-		require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "src", "main.go"), []byte("package main"), 0o644))
-
-		servers := map[string]*powernapconfig.ServerConfig{
-			"gopls":  {RootMarkers: []string{"**/*.go"}},
-			"python": {RootMarkers: []string{"**/*.py"}},
-		}
-
-		result := FilterMatching(tmpDir, servers)
-
-		require.Contains(t, result, "gopls")
-		require.NotContains(t, result, "python")
-	})
-
-	t.Run("servers with empty root markers are not included", func(t *testing.T) {
-		t.Parallel()
-		tmpDir := t.TempDir()
-
-		require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "go.mod"), []byte("module test"), 0o644))
-
-		servers := map[string]*powernapconfig.ServerConfig{
-			"gopls":   {RootMarkers: []string{"go.mod"}},
-			"generic": {RootMarkers: []string{}},
-		}
-
-		result := FilterMatching(tmpDir, servers)
-
-		require.Contains(t, result, "gopls")
-		require.NotContains(t, result, "generic")
-	})
-
-	t.Run("stops early when all servers match", func(t *testing.T) {
-		t.Parallel()
-		tmpDir := t.TempDir()
-
-		require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "go.mod"), []byte("module test"), 0o644))
-		require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "Cargo.toml"), []byte("[package]"), 0o644))
-
-		servers := map[string]*powernapconfig.ServerConfig{
-			"gopls":         {RootMarkers: []string{"go.mod"}},
-			"rust-analyzer": {RootMarkers: []string{"Cargo.toml"}},
-		}
-
-		result := FilterMatching(tmpDir, servers)
-
-		require.Len(t, result, 2)
-		require.Contains(t, result, "gopls")
-		require.Contains(t, result, "rust-analyzer")
-	})
-}

internal/lsp/manager.go 🔗

@@ -0,0 +1,271 @@
+// Package lsp provides a manager for Language Server Protocol (LSP) clients.
+package lsp
+
+import (
+	"cmp"
+	"context"
+	"errors"
+	"io"
+	"log/slog"
+	"os/exec"
+	"path/filepath"
+	"strings"
+	"sync"
+	"time"
+
+	"github.com/charmbracelet/crush/internal/config"
+	"github.com/charmbracelet/crush/internal/csync"
+	"github.com/charmbracelet/crush/internal/fsext"
+	powernapconfig "github.com/charmbracelet/x/powernap/pkg/config"
+	"github.com/charmbracelet/x/powernap/pkg/lsp"
+	"github.com/charmbracelet/x/powernap/pkg/lsp/protocol"
+	"github.com/sourcegraph/jsonrpc2"
+)
+
+// Manager handles lazy initialization of LSP clients based on file types.
+type Manager struct {
+	clients  *csync.Map[string, *Client]
+	cfg      *config.Config
+	manager  *powernapconfig.Manager
+	callback func(name string, client *Client)
+	mu       sync.Mutex
+}
+
+// NewManager creates a new LSP manager service.
+func NewManager(cfg *config.Config) *Manager {
+	manager := powernapconfig.NewManager()
+	manager.LoadDefaults()
+
+	// Merge user-configured LSPs into the manager.
+	for name, clientConfig := range cfg.LSP {
+		if clientConfig.Disabled {
+			slog.Debug("LSP disabled by user config", "name", name)
+			manager.RemoveServer(name)
+			continue
+		}
+
+		// HACK: the user might have the command name in their config instead
+		// of the actual name. Find and use the correct name.
+		actualName := resolveServerName(manager, name)
+		manager.AddServer(actualName, &powernapconfig.ServerConfig{
+			Command:     clientConfig.Command,
+			Args:        clientConfig.Args,
+			Environment: clientConfig.Env,
+			FileTypes:   clientConfig.FileTypes,
+			RootMarkers: clientConfig.RootMarkers,
+			InitOptions: clientConfig.InitOptions,
+			Settings:    clientConfig.Options,
+		})
+	}
+
+	return &Manager{
+		clients: csync.NewMap[string, *Client](),
+		cfg:     cfg,
+		manager: manager,
+	}
+}
+
+// Clients returns the map of LSP clients.
+func (m *Manager) Clients() *csync.Map[string, *Client] {
+	return m.clients
+}
+
+// SetCallback sets a callback that is invoked when a new LSP
+// client is successfully started. This allows the coordinator to add LSP tools.
+func (s *Manager) SetCallback(cb func(name string, client *Client)) {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	s.callback = cb
+}
+
+// Start starts an LSP server that can handle the given file path.
+// If an appropriate LSP is already running, this is a no-op.
+func (s *Manager) Start(ctx context.Context, filePath string) {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+
+	var wg sync.WaitGroup
+	for name, server := range s.manager.GetServers() {
+		if !handles(server, filePath, s.cfg.WorkingDir()) {
+			continue
+		}
+		wg.Go(func() {
+			s.startServer(ctx, name, server)
+		})
+	}
+	wg.Wait()
+}
+
+// skipAutoStartCommands contains commands that are too generic or ambiguous to
+// auto-start without explicit user configuration.
+var skipAutoStartCommands = map[string]bool{
+	"buck2":   true,
+	"buf":     true,
+	"cue":     true,
+	"dart":    true,
+	"deno":    true,
+	"dotnet":  true,
+	"dprint":  true,
+	"gleam":   true,
+	"java":    true,
+	"julia":   true,
+	"koka":    true,
+	"node":    true,
+	"npx":     true,
+	"perl":    true,
+	"plz":     true,
+	"python":  true,
+	"python3": true,
+	"R":       true,
+	"racket":  true,
+	"rome":    true,
+	"rubocop": true,
+	"ruff":    true,
+	"scarb":   true,
+	"solc":    true,
+	"stylua":  true,
+	"swipl":   true,
+	"tflint":  true,
+}
+
+func (s *Manager) startServer(ctx context.Context, name string, server *powernapconfig.ServerConfig) {
+	userConfigured := s.isUserConfigured(name)
+
+	if !userConfigured {
+		if _, err := exec.LookPath(server.Command); err != nil {
+			slog.Debug("LSP server not installed, skipping", "name", name, "command", server.Command)
+			return
+		}
+		if skipAutoStartCommands[server.Command] {
+			slog.Debug("LSP command too generic for auto-start, skipping", "name", name, "command", server.Command)
+			return
+		}
+	}
+
+	cfg := s.buildConfig(name, server)
+	if client, ok := s.clients.Get(name); ok {
+		switch client.GetServerState() {
+		case StateReady, StateStarting:
+			s.callback(name, client)
+			// already done, return
+			return
+		}
+	}
+	client, err := New(ctx, name, cfg, s.cfg.Resolver(), s.cfg.Options.DebugLSP)
+	if err != nil {
+		slog.Error("Failed to create LSP client", "name", name, "error", err)
+		return
+	}
+	s.callback(name, client)
+
+	defer func() {
+		s.clients.Set(name, client)
+		s.callback(name, client)
+	}()
+
+	initCtx, cancel := context.WithTimeout(ctx, time.Duration(cmp.Or(cfg.Timeout, 30))*time.Second)
+	defer cancel()
+
+	if _, err := client.Initialize(initCtx, s.cfg.WorkingDir()); err != nil {
+		slog.Error("LSP client initialization failed", "name", name, "error", err)
+		client.Close(ctx)
+		return
+	}
+
+	if err := client.WaitForServerReady(initCtx); err != nil {
+		slog.Warn("LSP server not fully ready, continuing anyway", "name", name, "error", err)
+		client.SetServerState(StateError)
+	} else {
+		client.SetServerState(StateReady)
+	}
+
+	slog.Debug("LSP client started", "name", name)
+}
+
+func (s *Manager) isUserConfigured(name string) bool {
+	cfg, ok := s.cfg.LSP[name]
+	return ok && !cfg.Disabled
+}
+
+func (s *Manager) buildConfig(name string, server *powernapconfig.ServerConfig) config.LSPConfig {
+	cfg := config.LSPConfig{
+		Command:     server.Command,
+		Args:        server.Args,
+		Env:         server.Environment,
+		FileTypes:   server.FileTypes,
+		RootMarkers: server.RootMarkers,
+		InitOptions: server.InitOptions,
+		Options:     server.Settings,
+	}
+	if userCfg, ok := s.cfg.LSP[name]; ok {
+		cfg.Timeout = userCfg.Timeout
+	}
+	return cfg
+}
+
+func resolveServerName(manager *powernapconfig.Manager, name string) string {
+	if _, ok := manager.GetServer(name); ok {
+		return name
+	}
+	for sname, server := range manager.GetServers() {
+		if server.Command == name {
+			return sname
+		}
+	}
+	return name
+}
+
+func handlesFiletype(server *powernapconfig.ServerConfig, ext string, language protocol.LanguageKind) bool {
+	for _, ft := range server.FileTypes {
+		if protocol.LanguageKind(ft) == language ||
+			ft == strings.TrimPrefix(ext, ".") ||
+			"."+ft == ext {
+			return true
+		}
+	}
+	return false
+}
+
+func hasRootMarkers(dir string, markers []string) bool {
+	if len(markers) == 0 {
+		return true
+	}
+	for _, pattern := range markers {
+		// Use fsext.GlobWithDoubleStar to find matches
+		matches, _, err := fsext.GlobWithDoubleStar(pattern, dir, 1)
+		if err == nil && len(matches) > 0 {
+			return true
+		}
+	}
+	return false
+}
+
+func handles(server *powernapconfig.ServerConfig, filePath, workDir string) bool {
+	language := lsp.DetectLanguage(filePath)
+	ext := filepath.Ext(filePath)
+	return handlesFiletype(server, ext, language) &&
+		hasRootMarkers(workDir, server.RootMarkers)
+}
+
+// StopAll stops all running LSP clients and clears the client map.
+func (s *Manager) StopAll(ctx context.Context) {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+
+	var wg sync.WaitGroup
+	for name, client := range s.clients.Seq2() {
+		wg.Go(func() {
+			defer func() { s.callback(name, client) }()
+			if err := client.Close(ctx); err != nil &&
+				!errors.Is(err, io.EOF) &&
+				!errors.Is(err, context.Canceled) &&
+				!errors.Is(err, jsonrpc2.ErrClosed) &&
+				err.Error() != "signal: killed" {
+				slog.Warn("Failed to stop LSP client", "name", name, "error", err)
+			}
+			client.SetServerState(StateStopped)
+			slog.Debug("Stopped LSP client", "name", name)
+		})
+	}
+	wg.Wait()
+}

internal/ui/model/header.go 🔗

@@ -74,7 +74,13 @@ func (h *header) drawHeader(
 	b.WriteString(h.compactLogo)
 
 	availDetailWidth := width - leftPadding - rightPadding - lipgloss.Width(b.String()) - minHeaderDiags
-	details := renderHeaderDetails(h.com, session, h.com.App.LSPClients, detailsOpen, availDetailWidth)
+	details := renderHeaderDetails(
+		h.com,
+		session,
+		h.com.App.LSPManager.Clients(),
+		detailsOpen,
+		availDetailWidth,
+	)
 
 	remainingWidth := width -
 		lipgloss.Width(b.String()) -

internal/ui/model/lsp.go 🔗

@@ -31,7 +31,7 @@ func (m *UI) lspInfo(width, maxItems int, isSection bool) string {
 
 	var lsps []LSPInfo
 	for _, state := range states {
-		client, ok := m.com.App.LSPClients.Get(state.Name)
+		client, ok := m.com.App.LSPManager.Clients().Get(state.Name)
 		if !ok {
 			continue
 		}
@@ -89,6 +89,9 @@ func lspList(t *styles.Styles, lsps []LSPInfo, width, maxItems int) string {
 		var description string
 		var diagnostics string
 		switch l.State {
+		case lsp.StateStopped:
+			icon = t.ItemOfflineIcon.Foreground(t.Muted.GetBackground()).String()
+			description = t.Subtle.Render("stopped")
 		case lsp.StateStarting:
 			icon = t.ItemBusyIcon.String()
 			description = t.Subtle.Render("starting...")
@@ -103,7 +106,7 @@ func lspList(t *styles.Styles, lsps []LSPInfo, width, maxItems int) string {
 			}
 		case lsp.StateDisabled:
 			icon = t.ItemOfflineIcon.Foreground(t.Muted.GetBackground()).String()
-			description = t.Subtle.Render("inactive")
+			description = t.Subtle.Render("disabled")
 		default:
 			icon = t.ItemOfflineIcon.String()
 		}

internal/ui/model/session.go 🔗

@@ -3,6 +3,7 @@ package model
 import (
 	"context"
 	"fmt"
+	"log/slog"
 	"path/filepath"
 	"slices"
 	"strings"
@@ -22,8 +23,32 @@ import (
 // loadSessionMsg is a message indicating that a session and its files have
 // been loaded.
 type loadSessionMsg struct {
-	session *session.Session
-	files   []SessionFile
+	session   *session.Session
+	files     []SessionFile
+	readFiles []string
+}
+
+// lspFilePaths returns deduplicated file paths from both modified and read
+// files for starting LSP servers.
+func (msg loadSessionMsg) lspFilePaths() []string {
+	seen := make(map[string]struct{}, len(msg.files)+len(msg.readFiles))
+	paths := make([]string, 0, len(msg.files)+len(msg.readFiles))
+	for _, f := range msg.files {
+		p := f.LatestVersion.Path
+		if _, ok := seen[p]; ok {
+			continue
+		}
+		seen[p] = struct{}{}
+		paths = append(paths, p)
+	}
+	for _, p := range msg.readFiles {
+		if _, ok := seen[p]; ok {
+			continue
+		}
+		seen[p] = struct{}{}
+		paths = append(paths, p)
+	}
+	return paths
 }
 
 // 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 {
 			return util.ReportError(err)
 		}
 
+		readFiles, err := m.com.App.FileTracker.ListReadFiles(context.Background(), sessionID)
+		if err != nil {
+			slog.Error("Failed to load read files for session", "error", err)
+		}
+
 		return loadSessionMsg{
-			session: &session,
-			files:   sessionFiles,
+			session:   &session,
+			files:     sessionFiles,
+			readFiles: readFiles,
 		}
 	}
 }
@@ -200,3 +231,18 @@ func fileList(t *styles.Styles, cwd string, filesWithChanges []SessionFile, widt
 
 	return lipgloss.JoinVertical(lipgloss.Left, renderedFiles...)
 }
+
+// startLSPs starts LSP servers for the given file paths.
+func (m *UI) startLSPs(paths []string) tea.Cmd {
+	if len(paths) == 0 {
+		return nil
+	}
+
+	return func() tea.Msg {
+		ctx := context.Background()
+		for _, path := range paths {
+			m.com.App.LSPManager.Start(ctx, path)
+		}
+		return nil
+	}
+}

internal/ui/model/ui.go 🔗

@@ -397,6 +397,7 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		m.setState(uiChat, m.focus)
 		m.session = msg.session
 		m.sessionFiles = msg.files
+		cmds = append(cmds, m.startLSPs(msg.lspFilePaths()))
 		msgs, err := m.com.App.Messages.List(context.Background(), m.session.ID)
 		if err != nil {
 			cmds = append(cmds, util.ReportError(err))
@@ -417,8 +418,14 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		m.historyReset()
 		cmds = append(cmds, m.loadPromptHistory())
 		m.updateLayoutAndSize()
+
 	case sessionFilesUpdatesMsg:
 		m.sessionFiles = msg.sessionFiles
+		var paths []string
+		for _, f := range msg.sessionFiles {
+			paths = append(paths, f.LatestVersion.Path)
+		}
+		cmds = append(cmds, m.startLSPs(paths))
 
 	case sendMessageMsg:
 		cmds = append(cmds, m.sendMessage(msg.Content, msg.Attachments...))
@@ -2706,8 +2713,10 @@ func (m *UI) sendMessage(content string, attachments ...message.Attachment) tea.
 		m.setState(uiChat, m.focus)
 	}
 
+	ctx := context.Background()
 	for _, path := range m.sessionFileReads {
-		m.com.App.FileTracker.RecordRead(context.Background(), m.session.ID, path)
+		m.com.App.FileTracker.RecordRead(ctx, m.session.ID, path)
+		m.com.App.LSPManager.Start(ctx, path)
 	}
 
 	// Capture session ID to avoid race with main goroutine updating m.session.
@@ -2965,7 +2974,13 @@ func (m *UI) newSession() tea.Cmd {
 	m.promptQueue = 0
 	m.pillsView = ""
 	m.historyReset()
-	return m.loadPromptHistory()
+	return tea.Batch(
+		func() tea.Msg {
+			m.com.App.LSPManager.StopAll(context.Background())
+			return nil
+		},
+		m.loadPromptHistory(),
+	)
 }
 
 // handlePasteMsg handles a paste message.