Detailed changes
@@ -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
@@ -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{
@@ -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
@@ -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 {
@@ -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 {
@@ -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
})
@@ -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
}
@@ -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
})
@@ -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
@@ -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),
@@ -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,
@@ -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 {
@@ -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)
-}
@@ -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,
@@ -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
@@ -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,
@@ -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;
@@ -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
+}
@@ -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
-}
@@ -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")
- })
-}
@@ -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()
+}
@@ -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()) -
@@ -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()
}
@@ -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
+ }
+}
@@ -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.