feat(lsp): auto-discover LSPs (#1834)

Carlos Alexandro Becker created

* feat(lsp): auto-discover LSPs

- auto-discover LSPs defined in powernap
- faster startup by walking dir only once to check root markers from all
  LSPs
- errors on auto-found LSPs are ignored (e.g. it might match
  golangci-lint-server but if you don't have it installed it shouldn't
  show in the list)

Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>

* fix: sidebar improvement

Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>

* fix: if

Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>

* fix: startup, disabled

Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>

* fix: lint

Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>

* fix: remove unneeded func

Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>

* perf: skip empty

Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>

* fix: server names

Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>

* fix: do not show failing non configured lsps

Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>

* fix: allow to disable auto lsp

Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>

* chore: update powernap

Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>

---------

Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>

Change summary

go.mod                                          |   2 
go.sum                                          |   4 
internal/app/app.go                             |   2 
internal/app/lsp.go                             |  89 +++++++++++-
internal/config/config.go                       |   1 
internal/lsp/client.go                          |  79 +++++++++--
internal/lsp/filtermatching_test.go             | 111 +++++++++++++++
internal/lsp/language.go                        | 132 -------------------
internal/lsp/rootmarkers_test.go                |  37 -----
internal/tui/components/chat/sidebar/sidebar.go |   2 
internal/tui/components/lsp/lsp.go              |  31 ++--
11 files changed, 273 insertions(+), 217 deletions(-)

Detailed changes

go.mod 🔗

@@ -31,7 +31,7 @@ require (
 	github.com/charmbracelet/x/exp/ordered v0.1.0
 	github.com/charmbracelet/x/exp/slice v0.0.0-20251201173703-9f73bfd934ff
 	github.com/charmbracelet/x/exp/strings v0.1.0
-	github.com/charmbracelet/x/powernap v0.0.0-20260113142046-c1fa3de7983b
+	github.com/charmbracelet/x/powernap v0.0.0-20260127155452-b72a9a918687
 	github.com/charmbracelet/x/term v0.2.2
 	github.com/denisbrodbeck/machineid v1.0.1
 	github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec

go.sum 🔗

@@ -122,8 +122,8 @@ github.com/charmbracelet/x/exp/strings v0.1.0 h1:i69S2XI7uG1u4NLGeJPSYU++Nmjvpo9
 github.com/charmbracelet/x/exp/strings v0.1.0/go.mod h1:/ehtMPNh9K4odGFkqYJKpIYyePhdp1hLBRvyY4bWkH8=
 github.com/charmbracelet/x/json v0.2.0 h1:DqB+ZGx2h+Z+1s98HOuOyli+i97wsFQIxP2ZQANTPrQ=
 github.com/charmbracelet/x/json v0.2.0/go.mod h1:opFIflx2YgXgi49xVUu8gEQ21teFAxyMwvOiZhIvWNM=
-github.com/charmbracelet/x/powernap v0.0.0-20260113142046-c1fa3de7983b h1:5ye9hzBKH623bMVz5auIuY6K21loCdxpRmFle2O9R/8=
-github.com/charmbracelet/x/powernap v0.0.0-20260113142046-c1fa3de7983b/go.mod h1:cmdl5zlP5mR8TF2Y68UKc7hdGUDiSJ2+4hk0h04Hsx4=
+github.com/charmbracelet/x/powernap v0.0.0-20260127155452-b72a9a918687 h1:h1XMgTkpBt9kEJ+9DkARNBXEgaigUQ0cI2Bot7Awnt8=
+github.com/charmbracelet/x/powernap v0.0.0-20260127155452-b72a9a918687/go.mod h1:cmdl5zlP5mR8TF2Y68UKc7hdGUDiSJ2+4hk0h04Hsx4=
 github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
 github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
 github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=

internal/app/app.go 🔗

@@ -101,7 +101,7 @@ func New(ctx context.Context, conn *sql.DB, cfg *config.Config) (*App, error) {
 	app.setupEvents()
 
 	// Initialize LSP clients in the background.
-	app.initLSPClients(ctx)
+	go app.initLSPClients(ctx)
 
 	// Check for updates in the background.
 	go app.checkForUpdates(ctx)

internal/app/lsp.go 🔗

@@ -3,41 +3,108 @@ package app
 import (
 	"context"
 	"log/slog"
+	"os/exec"
+	"slices"
 	"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
 		}
-		go app.createAndStartLSPClient(ctx, name, clientConfig)
+
+		// 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)
+		}
+	}
+	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
+		}
+		go app.createAndStartLSPClient(
+			ctx, name,
+			toOurConfig(server),
+			slices.Contains(userConfiguredLSPs, name),
+		)
 	}
-	slog.Info("LSP clients initialization started in background")
 }
 
-// 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) {
-	slog.Debug("Creating LSP client", "name", name, "command", config.Command, "fileTypes", config.FileTypes, "args", config.Args)
+func toOurConfig(in *powernapconfig.ServerConfig) 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,
+	}
+}
 
-	// Check if any root markers exist in the working directory (config now has defaults)
-	if !lsp.HasRootMarkers(app.config.WorkingDir(), config.RootMarkers) {
-		slog.Debug("Skipping LSP client: no root markers found", "name", name, "rootMarkers", config.RootMarkers)
-		updateLSPState(name, lsp.StateDisabled, nil, nil, 0)
-		return
+// 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
+		}
 	}
 
-	// Update state to starting
+	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())
 	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

internal/config/config.go 🔗

@@ -257,6 +257,7 @@ type Options struct {
 	Attribution               *Attribution `json:"attribution,omitempty" jsonschema:"description=Attribution settings for generated content"`
 	DisableMetrics            bool         `json:"disable_metrics,omitempty" jsonschema:"description=Disable sending metrics,default=false"`
 	InitializeAs              string       `json:"initialize_as,omitempty" jsonschema:"description=Name of the context file to create/update during project initialization,default=AGENTS.md,example=AGENTS.md,example=CRUSH.md,example=CLAUDE.md,example=docs/LLMs.md"`
+	AutoLSP                   *bool        `json:"auto_lsp,omitempty" jsonschema:"description=Automatically setup LSPs based on root markers"`
 }
 
 type MCPs map[string]MCPConfig

internal/lsp/client.go 🔗

@@ -13,10 +13,12 @@ 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"
@@ -329,14 +331,15 @@ func (c *Client) HandlesFile(path string) bool {
 		return true
 	}
 
+	kind := powernap.DetectLanguage(path)
 	name := strings.ToLower(filepath.Base(path))
 	for _, filetype := range c.fileTypes {
 		suffix := strings.ToLower(filetype)
 		if !strings.HasPrefix(suffix, ".") {
 			suffix = "." + suffix
 		}
-		if strings.HasSuffix(name, suffix) {
-			slog.Debug("handles file", "name", c.name, "file", name, "filetype", filetype)
+		if strings.HasSuffix(name, suffix) || filetype == string(kind) {
+			slog.Debug("handles file", "name", c.name, "file", name, "filetype", filetype, "kind", kind)
 			return true
 		}
 	}
@@ -363,7 +366,7 @@ func (c *Client) OpenFile(ctx context.Context, filepath string) error {
 	}
 
 	// Notify the server about the opened document
-	if err = c.client.NotifyDidOpenTextDocument(ctx, uri, string(DetectLanguageID(uri)), 1, string(content)); err != nil {
+	if err = c.client.NotifyDidOpenTextDocument(ctx, uri, string(powernap.DetectLanguage(filepath)), 1, string(content)); err != nil {
 		return err
 	}
 
@@ -574,18 +577,66 @@ func (c *Client) FindReferences(ctx context.Context, filepath string, line, char
 	return c.client.FindReferences(ctx, filepath, line-1, character-1, includeDeclaration)
 }
 
-// HasRootMarkers checks if any of the specified root marker patterns exist in the given directory.
-// Uses glob patterns to match files, allowing for more flexible matching.
-func HasRootMarkers(dir string, rootMarkers []string) bool {
-	if len(rootMarkers) == 0 {
-		return true
+// 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
 	}
-	for _, pattern := range rootMarkers {
-		// Use fsext.GlobWithDoubleStar to find matches
-		matches, _, err := fsext.GlobWithDoubleStar(pattern, dir, 1)
-		if err == nil && len(matches) > 0 {
-			return true
+
+	type serverPatterns struct {
+		server   *powernapconfig.ServerConfig
+		patterns []string
+	}
+	normalized := make(map[string]serverPatterns, len(servers))
+	for name, server := range servers {
+		if len(server.RootMarkers) == 0 {
+			continue
+		}
+		patterns := make([]string, len(server.RootMarkers))
+		for i, p := range server.RootMarkers {
+			patterns[i] = filepath.ToSlash(p)
 		}
+		normalized[name] = serverPatterns{server: server, patterns: patterns}
 	}
-	return false
+
+	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 🔗

@@ -0,0 +1,111 @@
+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/language.go 🔗

@@ -1,132 +0,0 @@
-package lsp
-
-import (
-	"path/filepath"
-	"strings"
-
-	"github.com/charmbracelet/x/powernap/pkg/lsp/protocol"
-)
-
-func DetectLanguageID(uri string) protocol.LanguageKind {
-	ext := strings.ToLower(filepath.Ext(uri))
-	switch ext {
-	case ".abap":
-		return protocol.LangABAP
-	case ".bat":
-		return protocol.LangWindowsBat
-	case ".bib", ".bibtex":
-		return protocol.LangBibTeX
-	case ".clj":
-		return protocol.LangClojure
-	case ".coffee":
-		return protocol.LangCoffeescript
-	case ".c":
-		return protocol.LangC
-	case ".cpp", ".cxx", ".cc", ".c++":
-		return protocol.LangCPP
-	case ".cs":
-		return protocol.LangCSharp
-	case ".css":
-		return protocol.LangCSS
-	case ".d":
-		return protocol.LangD
-	case ".pas", ".pascal":
-		return protocol.LangDelphi
-	case ".diff", ".patch":
-		return protocol.LangDiff
-	case ".dart":
-		return protocol.LangDart
-	case ".dockerfile":
-		return protocol.LangDockerfile
-	case ".ex", ".exs":
-		return protocol.LangElixir
-	case ".erl", ".hrl":
-		return protocol.LangErlang
-	case ".fs", ".fsi", ".fsx", ".fsscript":
-		return protocol.LangFSharp
-	case ".gitcommit":
-		return protocol.LangGitCommit
-	case ".gitrebase":
-		return protocol.LangGitRebase
-	case ".go":
-		return protocol.LangGo
-	case ".groovy":
-		return protocol.LangGroovy
-	case ".hbs", ".handlebars":
-		return protocol.LangHandlebars
-	case ".hs":
-		return protocol.LangHaskell
-	case ".html", ".htm":
-		return protocol.LangHTML
-	case ".ini":
-		return protocol.LangIni
-	case ".java":
-		return protocol.LangJava
-	case ".js":
-		return protocol.LangJavaScript
-	case ".jsx":
-		return protocol.LangJavaScriptReact
-	case ".json":
-		return protocol.LangJSON
-	case ".tex", ".latex":
-		return protocol.LangLaTeX
-	case ".less":
-		return protocol.LangLess
-	case ".lua":
-		return protocol.LangLua
-	case ".makefile", "makefile":
-		return protocol.LangMakefile
-	case ".md", ".markdown":
-		return protocol.LangMarkdown
-	case ".m":
-		return protocol.LangObjectiveC
-	case ".mm":
-		return protocol.LangObjectiveCPP
-	case ".pl":
-		return protocol.LangPerl
-	case ".pm":
-		return protocol.LangPerl6
-	case ".php":
-		return protocol.LangPHP
-	case ".ps1", ".psm1":
-		return protocol.LangPowershell
-	case ".pug", ".jade":
-		return protocol.LangPug
-	case ".py":
-		return protocol.LangPython
-	case ".r":
-		return protocol.LangR
-	case ".cshtml", ".razor":
-		return protocol.LangRazor
-	case ".rb":
-		return protocol.LangRuby
-	case ".rs":
-		return protocol.LangRust
-	case ".scss":
-		return protocol.LangSCSS
-	case ".sass":
-		return protocol.LangSASS
-	case ".scala":
-		return protocol.LangScala
-	case ".shader":
-		return protocol.LangShaderLab
-	case ".sh", ".bash", ".zsh", ".ksh":
-		return protocol.LangShellScript
-	case ".sql":
-		return protocol.LangSQL
-	case ".swift":
-		return protocol.LangSwift
-	case ".ts":
-		return protocol.LangTypeScript
-	case ".tsx":
-		return protocol.LangTypeScriptReact
-	case ".xml":
-		return protocol.LangXML
-	case ".xsl":
-		return protocol.LangXSL
-	case ".yaml", ".yml":
-		return protocol.LangYAML
-	default:
-		return protocol.LanguageKind("") // Unknown language
-	}
-}

internal/lsp/rootmarkers_test.go 🔗

@@ -1,37 +0,0 @@
-package lsp
-
-import (
-	"os"
-	"path/filepath"
-	"testing"
-
-	"github.com/stretchr/testify/require"
-)
-
-func TestHasRootMarkers(t *testing.T) {
-	t.Parallel()
-
-	// Create a temporary directory for testing
-	tmpDir := t.TempDir()
-
-	// Test with empty root markers (should return true)
-	require.True(t, HasRootMarkers(tmpDir, []string{}))
-
-	// Test with non-existent markers
-	require.False(t, HasRootMarkers(tmpDir, []string{"go.mod", "package.json"}))
-
-	// Create a go.mod file
-	goModPath := filepath.Join(tmpDir, "go.mod")
-	err := os.WriteFile(goModPath, []byte("module test"), 0o644)
-	require.NoError(t, err)
-
-	// Test with existing marker
-	require.True(t, HasRootMarkers(tmpDir, []string{"go.mod", "package.json"}))
-
-	// Test with only non-existent markers
-	require.False(t, HasRootMarkers(tmpDir, []string{"package.json", "Cargo.toml"}))
-
-	// Test with glob patterns
-	require.True(t, HasRootMarkers(tmpDir, []string{"*.mod"}))
-	require.False(t, HasRootMarkers(tmpDir, []string{"*.json"}))
-}

internal/tui/components/chat/sidebar/sidebar.go 🔗

@@ -480,8 +480,6 @@ func (m *sidebarCmp) filesBlock() string {
 func (m *sidebarCmp) lspBlock() string {
 	// Limit the number of LSPs shown
 	_, maxLSPs, _ := m.getDynamicLimits()
-	lspConfigs := config.Get().LSP.Sorted()
-	maxLSPs = min(len(lspConfigs), maxLSPs)
 
 	return lspcomponent.RenderLSPBlock(m.lspClients, lspcomponent.RenderOptions{
 		MaxWidth:    m.getMaxWidth(),

internal/tui/components/lsp/lsp.go 🔗

@@ -2,6 +2,8 @@ package lsp
 
 import (
 	"fmt"
+	"maps"
+	"slices"
 	"strings"
 
 	"charm.land/lipgloss/v2"
@@ -35,32 +37,32 @@ func RenderLSPList(lspClients *csync.Map[string, *lsp.Client], opts RenderOption
 		lspList = append(lspList, section, "")
 	}
 
-	lspConfigs := config.Get().LSP.Sorted()
-	if len(lspConfigs) == 0 {
+	// Get LSP states
+	lsps := slices.SortedFunc(maps.Values(app.GetLSPStates()), func(a, b app.LSPClientInfo) int {
+		return strings.Compare(a.Name, b.Name)
+	})
+	if len(lsps) == 0 {
 		lspList = append(lspList, t.S().Base.Foreground(t.Border).Render("None"))
 		return lspList
 	}
 
-	// Get LSP states
-	lspStates := app.GetLSPStates()
-
 	// Determine how many items to show
-	maxItems := len(lspConfigs)
+	maxItems := len(lsps)
 	if opts.MaxItems > 0 {
-		maxItems = min(opts.MaxItems, len(lspConfigs))
+		maxItems = min(opts.MaxItems, len(lsps))
 	}
 
-	for i, l := range lspConfigs {
+	for i, info := range lsps {
 		if i >= maxItems {
 			break
 		}
 
-		icon, description := iconAndDescription(l, t, lspStates)
+		icon, description := iconAndDescription(t, info)
 
 		// Calculate diagnostic counts if we have LSP clients
 		var extraContent string
 		if lspClients != nil {
-			if client, ok := lspClients.Get(l.Name); ok {
+			if client, ok := lspClients.Get(info.Name); ok {
 				counts := client.GetDiagnosticCounts()
 				errs := []string{}
 				if counts.Error > 0 {
@@ -83,7 +85,7 @@ func RenderLSPList(lspClients *csync.Map[string, *lsp.Client], opts RenderOption
 			core.Status(
 				core.StatusOpts{
 					Icon:         icon.String(),
-					Title:        l.Name,
+					Title:        info.Name,
 					Description:  description,
 					ExtraContent: extraContent,
 				},
@@ -95,12 +97,7 @@ func RenderLSPList(lspClients *csync.Map[string, *lsp.Client], opts RenderOption
 	return lspList
 }
 
-func iconAndDescription(l config.LSP, t *styles.Theme, states map[string]app.LSPClientInfo) (lipgloss.Style, string) {
-	if l.LSP.Disabled {
-		return t.ItemOfflineIcon.Foreground(t.FgMuted), t.S().Subtle.Render("disabled")
-	}
-
-	info := states[l.Name]
+func iconAndDescription(t *styles.Theme, info app.LSPClientInfo) (lipgloss.Style, string) {
 	switch info.State {
 	case lsp.StateStarting:
 		return t.ItemBusyIcon, t.S().Subtle.Render("starting...")