on main help sign in
feat(lsp): auto-discover LSPs (#1834)

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

commitc81b02f440a1c442fe731122f5b2150547ab8fd3 authorCarlos Alexandro Becker <caarlos0@users.noreply.github.com> dateTue 27 Jan 2026, 16:26 (9 months ago) parentdf2c001
11 files changed+273−217
go.mod+1−1 go.sum+2−2 internal/app/app.go+1−1 internal/app/lsp.go+78−11 internal/config/config.go+1 internal/lsp/client.go+65−14 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+14−17
go.mod+1−1
@@ -31,7 +31,7 @@ require (
3131 github.com/charmbracelet/x/exp/ordered v0.1.0
3232 github.com/charmbracelet/x/exp/slice v0.0.0-20251201173703-9f73bfd934ff
3333 github.com/charmbracelet/x/exp/strings v0.1.0
34− github.com/charmbracelet/x/powernap v0.0.0-20260113142046-c1fa3de7983b
34+ github.com/charmbracelet/x/powernap v0.0.0-20260127155452-b72a9a918687
3535 github.com/charmbracelet/x/term v0.2.2
3636 github.com/denisbrodbeck/machineid v1.0.1
3737 github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec
go.sum+2−2
@@ -122,8 +122,8 @@ github.com/charmbracelet/x/exp/strings v0.1.0 h1:i69S2XI7uG1u4NLGeJPSYU++Nmjvpo9
122122github.com/charmbracelet/x/exp/strings v0.1.0/go.mod h1:/ehtMPNh9K4odGFkqYJKpIYyePhdp1hLBRvyY4bWkH8=
123123github.com/charmbracelet/x/json v0.2.0 h1:DqB+ZGx2h+Z+1s98HOuOyli+i97wsFQIxP2ZQANTPrQ=
124124github.com/charmbracelet/x/json v0.2.0/go.mod h1:opFIflx2YgXgi49xVUu8gEQ21teFAxyMwvOiZhIvWNM=
125−github.com/charmbracelet/x/powernap v0.0.0-20260113142046-c1fa3de7983b h1:5ye9hzBKH623bMVz5auIuY6K21loCdxpRmFle2O9R/8=
126−github.com/charmbracelet/x/powernap v0.0.0-20260113142046-c1fa3de7983b/go.mod h1:cmdl5zlP5mR8TF2Y68UKc7hdGUDiSJ2+4hk0h04Hsx4=
125+github.com/charmbracelet/x/powernap v0.0.0-20260127155452-b72a9a918687 h1:h1XMgTkpBt9kEJ+9DkARNBXEgaigUQ0cI2Bot7Awnt8=
126+github.com/charmbracelet/x/powernap v0.0.0-20260127155452-b72a9a918687/go.mod h1:cmdl5zlP5mR8TF2Y68UKc7hdGUDiSJ2+4hk0h04Hsx4=
127127github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
128128github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
129129github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
internal/app/app.go+1−1
@@ -101,7 +101,7 @@ func New(ctx context.Context, conn *sql.DB, cfg *config.Config) (*App, error) {
101101 app.setupEvents()
102102
103103 // Initialize LSP clients in the background.
104− app.initLSPClients(ctx)
104+ go app.initLSPClients(ctx)
105105
106106 // Check for updates in the background.
107107 go app.checkForUpdates(ctx)
internal/app/lsp.go+78−11
@@ -3,41 +3,108 @@ package app
33import (
44 "context"
55 "log/slog"
6+ "os/exec"
7+ "slices"
68 "time"
79
810 "github.com/charmbracelet/crush/internal/config"
911 "github.com/charmbracelet/crush/internal/lsp"
12+ powernapconfig "github.com/charmbracelet/x/powernap/pkg/config"
1013)
1114
1215// initLSPClients initializes LSP clients.
1316func (app *App) initLSPClients(ctx context.Context) {
17+ slog.Info("LSP clients initialization started")
18+
19+ manager := powernapconfig.NewManager()
20+ manager.LoadDefaults()
21+
22+ var userConfiguredLSPs []string
1423 for name, clientConfig := range app.config.LSP {
1524 if clientConfig.Disabled {
1625 slog.Info("Skipping disabled LSP client", "name", name)
26+ manager.RemoveServer(name)
1727 continue
1828 }
19− go app.createAndStartLSPClient(ctx, name, clientConfig)
29+
30+ // HACK: the user might have the command name in their config, instead
31+ // of the actual name. This finds out these cases, and adjusts the name
32+ // accordingly.
33+ if _, ok := manager.GetServer(name); !ok {
34+ for sname, server := range manager.GetServers() {
35+ if server.Command == name {
36+ name = sname
37+ break
38+ }
39+ }
40+ }
41+ userConfiguredLSPs = append(userConfiguredLSPs, name)
42+ manager.AddServer(name, &powernapconfig.ServerConfig{
43+ Command: clientConfig.Command,
44+ Args: clientConfig.Args,
45+ Environment: clientConfig.Env,
46+ FileTypes: clientConfig.FileTypes,
47+ RootMarkers: clientConfig.RootMarkers,
48+ InitOptions: clientConfig.InitOptions,
49+ Settings: clientConfig.Options,
50+ })
51+ }
52+
53+ servers := manager.GetServers()
54+ filtered := lsp.FilterMatching(app.config.WorkingDir(), servers)
55+
56+ for _, name := range userConfiguredLSPs {
57+ if _, ok := filtered[name]; !ok {
58+ updateLSPState(name, lsp.StateDisabled, nil, nil, 0)
59+ }
60+ }
61+ for name, server := range filtered {
62+ if app.config.Options.AutoLSP != nil && !*app.config.Options.AutoLSP && !slices.Contains(userConfiguredLSPs, name) {
63+ slog.Debug("Ignoring non user-define LSP client due to AutoLSP being disabled", "name", name)
64+ continue
65+ }
66+ go app.createAndStartLSPClient(
67+ ctx, name,
68+ toOurConfig(server),
69+ slices.Contains(userConfiguredLSPs, name),
70+ )
2071 }
21− slog.Info("LSP clients initialization started in background")
2272}
2373
24−// createAndStartLSPClient creates a new LSP client, initializes it, and starts its workspace watcher
25−func (app *App) createAndStartLSPClient(ctx context.Context, name string, config config.LSPConfig) {
26− slog.Debug("Creating LSP client", "name", name, "command", config.Command, "fileTypes", config.FileTypes, "args", config.Args)
74+func toOurConfig(in *powernapconfig.ServerConfig) config.LSPConfig {
75+ return config.LSPConfig{
76+ Command: in.Command,
77+ Args: in.Args,
78+ Env: in.Environment,
79+ FileTypes: in.FileTypes,
80+ RootMarkers: in.RootMarkers,
81+ InitOptions: in.InitOptions,
82+ Options: in.Settings,
83+ }
84+}
2785
28− // Check if any root markers exist in the working directory (config now has defaults)
29− if !lsp.HasRootMarkers(app.config.WorkingDir(), config.RootMarkers) {
30− slog.Debug("Skipping LSP client: no root markers found", "name", name, "rootMarkers", config.RootMarkers)
31− updateLSPState(name, lsp.StateDisabled, nil, nil, 0)
32− return
86+// createAndStartLSPClient creates a new LSP client, initializes it, and starts its workspace watcher.
87+func (app *App) createAndStartLSPClient(ctx context.Context, name string, config config.LSPConfig, userConfigured bool) {
88+ if !userConfigured {
89+ if _, err := exec.LookPath(config.Command); err != nil {
90+ slog.Warn("Default LSP config skipped: server not installed", "name", name, "error", err)
91+ return
92+ }
3393 }
3494
35− // Update state to starting
95+ slog.Debug("Creating LSP client", "name", name, "command", config.Command, "fileTypes", config.FileTypes, "args", config.Args)
96+
97+ // Update state to starting.
3698 updateLSPState(name, lsp.StateStarting, nil, nil, 0)
3799
38100 // Create LSP client.
39101 lspClient, err := lsp.New(ctx, name, config, app.config.Resolver())
40102 if err != nil {
103+ if !userConfigured {
104+ slog.Warn("Default LSP config skipped due to error", "name", name, "error", err)
105+ updateLSPState(name, lsp.StateDisabled, nil, nil, 0)
106+ return
107+ }
41108 slog.Error("Failed to create LSP client for", "name", name, "error", err)
42109 updateLSPState(name, lsp.StateError, err, nil, 0)
43110 return
internal/config/config.go+1
@@ -257,6 +257,7 @@ type Options struct {
257257 Attribution *Attribution `json:"attribution,omitempty" jsonschema:"description=Attribution settings for generated content"`
258258 DisableMetrics bool `json:"disable_metrics,omitempty" jsonschema:"description=Disable sending metrics,default=false"`
259259 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"`
260+ AutoLSP *bool `json:"auto_lsp,omitempty" jsonschema:"description=Automatically setup LSPs based on root markers"`
260261}
261262
262263type MCPs map[string]MCPConfig
internal/lsp/client.go+65−14
@@ -13,10 +13,12 @@ import (
1313 "sync/atomic"
1414 "time"
1515
16+ "github.com/bmatcuk/doublestar/v4"
1617 "github.com/charmbracelet/crush/internal/config"
1718 "github.com/charmbracelet/crush/internal/csync"
1819 "github.com/charmbracelet/crush/internal/fsext"
1920 "github.com/charmbracelet/crush/internal/home"
21+ powernapconfig "github.com/charmbracelet/x/powernap/pkg/config"
2022 powernap "github.com/charmbracelet/x/powernap/pkg/lsp"
2123 "github.com/charmbracelet/x/powernap/pkg/lsp/protocol"
2224 "github.com/charmbracelet/x/powernap/pkg/transport"
@@ -329,14 +331,15 @@ func (c *Client) HandlesFile(path string) bool {
329331 return true
330332 }
331333
334+ kind := powernap.DetectLanguage(path)
332335 name := strings.ToLower(filepath.Base(path))
333336 for _, filetype := range c.fileTypes {
334337 suffix := strings.ToLower(filetype)
335338 if !strings.HasPrefix(suffix, ".") {
336339 suffix = "." + suffix
337340 }
338− if strings.HasSuffix(name, suffix) {
339− slog.Debug("handles file", "name", c.name, "file", name, "filetype", filetype)
341+ if strings.HasSuffix(name, suffix) || filetype == string(kind) {
342+ slog.Debug("handles file", "name", c.name, "file", name, "filetype", filetype, "kind", kind)
340343 return true
341344 }
342345 }
@@ -363,7 +366,7 @@ func (c *Client) OpenFile(ctx context.Context, filepath string) error {
363366 }
364367
365368 // Notify the server about the opened document
366− if err = c.client.NotifyDidOpenTextDocument(ctx, uri, string(DetectLanguageID(uri)), 1, string(content)); err != nil {
369+ if err = c.client.NotifyDidOpenTextDocument(ctx, uri, string(powernap.DetectLanguage(filepath)), 1, string(content)); err != nil {
367370 return err
368371 }
369372
@@ -574,18 +577,66 @@ func (c *Client) FindReferences(ctx context.Context, filepath string, line, char
574577 return c.client.FindReferences(ctx, filepath, line-1, character-1, includeDeclaration)
575578}
576579
577−// HasRootMarkers checks if any of the specified root marker patterns exist in the given directory.
578−// Uses glob patterns to match files, allowing for more flexible matching.
579−func HasRootMarkers(dir string, rootMarkers []string) bool {
580− if len(rootMarkers) == 0 {
581− return true
580+// FilterMatching gets a list of configs and only returns the ones with
581+// matching root markers.
582+func FilterMatching(dir string, servers map[string]*powernapconfig.ServerConfig) map[string]*powernapconfig.ServerConfig {
583+ result := map[string]*powernapconfig.ServerConfig{}
584+ if len(servers) == 0 {
585+ return result
582586 }
583− for _, pattern := range rootMarkers {
584− // Use fsext.GlobWithDoubleStar to find matches
585− matches, _, err := fsext.GlobWithDoubleStar(pattern, dir, 1)
586− if err == nil && len(matches) > 0 {
587− return true
587+
588+ type serverPatterns struct {
589+ server *powernapconfig.ServerConfig
590+ patterns []string
591+ }
592+ normalized := make(map[string]serverPatterns, len(servers))
593+ for name, server := range servers {
594+ if len(server.RootMarkers) == 0 {
595+ continue
596+ }
597+ patterns := make([]string, len(server.RootMarkers))
598+ for i, p := range server.RootMarkers {
599+ patterns[i] = filepath.ToSlash(p)
588600 }
601+ normalized[name] = serverPatterns{server: server, patterns: patterns}
589602 }
590− return false
603+
604+ walker := fsext.NewFastGlobWalker(dir)
605+ _ = filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error {
606+ if err != nil {
607+ return nil
608+ }
609+
610+ if walker.ShouldSkip(path) {
611+ if d.IsDir() {
612+ return filepath.SkipDir
613+ }
614+ return nil
615+ }
616+
617+ relPath, err := filepath.Rel(dir, path)
618+ if err != nil {
619+ return nil
620+ }
621+ relPath = filepath.ToSlash(relPath)
622+
623+ for name, sp := range normalized {
624+ for _, pattern := range sp.patterns {
625+ matched, err := doublestar.Match(pattern, relPath)
626+ if err != nil || !matched {
627+ continue
628+ }
629+ result[name] = sp.server
630+ delete(normalized, name)
631+ break
632+ }
633+ }
634+
635+ if len(normalized) == 0 {
636+ return filepath.SkipAll
637+ }
638+ return nil
639+ })
640+
641+ return result
591642}
internal/lsp/filtermatching_test.gonew file+111
@@ -0,0 +1,111 @@
1+package lsp
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "testing"
7+
8+ powernapconfig "github.com/charmbracelet/x/powernap/pkg/config"
9+ "github.com/stretchr/testify/require"
10+)
11+
12+func TestFilterMatching(t *testing.T) {
13+ t.Parallel()
14+
15+ t.Run("matches servers with existing root markers", func(t *testing.T) {
16+ t.Parallel()
17+ tmpDir := t.TempDir()
18+
19+ require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "go.mod"), []byte("module test"), 0o644))
20+ require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "Cargo.toml"), []byte("[package]"), 0o644))
21+
22+ servers := map[string]*powernapconfig.ServerConfig{
23+ "gopls": {RootMarkers: []string{"go.mod", "go.work"}},
24+ "rust-analyzer": {RootMarkers: []string{"Cargo.toml"}},
25+ "typescript-lsp": {RootMarkers: []string{"package.json", "tsconfig.json"}},
26+ }
27+
28+ result := FilterMatching(tmpDir, servers)
29+
30+ require.Contains(t, result, "gopls")
31+ require.Contains(t, result, "rust-analyzer")
32+ require.NotContains(t, result, "typescript-lsp")
33+ })
34+
35+ t.Run("returns empty for empty servers", func(t *testing.T) {
36+ t.Parallel()
37+ tmpDir := t.TempDir()
38+
39+ result := FilterMatching(tmpDir, map[string]*powernapconfig.ServerConfig{})
40+
41+ require.Empty(t, result)
42+ })
43+
44+ t.Run("returns empty when no markers match", func(t *testing.T) {
45+ t.Parallel()
46+ tmpDir := t.TempDir()
47+
48+ servers := map[string]*powernapconfig.ServerConfig{
49+ "gopls": {RootMarkers: []string{"go.mod"}},
50+ "python": {RootMarkers: []string{"pyproject.toml"}},
51+ }
52+
53+ result := FilterMatching(tmpDir, servers)
54+
55+ require.Empty(t, result)
56+ })
57+
58+ t.Run("glob patterns work", func(t *testing.T) {
59+ t.Parallel()
60+ tmpDir := t.TempDir()
61+
62+ require.NoError(t, os.MkdirAll(filepath.Join(tmpDir, "src"), 0o755))
63+ require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "src", "main.go"), []byte("package main"), 0o644))
64+
65+ servers := map[string]*powernapconfig.ServerConfig{
66+ "gopls": {RootMarkers: []string{"**/*.go"}},
67+ "python": {RootMarkers: []string{"**/*.py"}},
68+ }
69+
70+ result := FilterMatching(tmpDir, servers)
71+
72+ require.Contains(t, result, "gopls")
73+ require.NotContains(t, result, "python")
74+ })
75+
76+ t.Run("servers with empty root markers are not included", func(t *testing.T) {
77+ t.Parallel()
78+ tmpDir := t.TempDir()
79+
80+ require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "go.mod"), []byte("module test"), 0o644))
81+
82+ servers := map[string]*powernapconfig.ServerConfig{
83+ "gopls": {RootMarkers: []string{"go.mod"}},
84+ "generic": {RootMarkers: []string{}},
85+ }
86+
87+ result := FilterMatching(tmpDir, servers)
88+
89+ require.Contains(t, result, "gopls")
90+ require.NotContains(t, result, "generic")
91+ })
92+
93+ t.Run("stops early when all servers match", func(t *testing.T) {
94+ t.Parallel()
95+ tmpDir := t.TempDir()
96+
97+ require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "go.mod"), []byte("module test"), 0o644))
98+ require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "Cargo.toml"), []byte("[package]"), 0o644))
99+
100+ servers := map[string]*powernapconfig.ServerConfig{
101+ "gopls": {RootMarkers: []string{"go.mod"}},
102+ "rust-analyzer": {RootMarkers: []string{"Cargo.toml"}},
103+ }
104+
105+ result := FilterMatching(tmpDir, servers)
106+
107+ require.Len(t, result, 2)
108+ require.Contains(t, result, "gopls")
109+ require.Contains(t, result, "rust-analyzer")
110+ })
111+}
internal/lsp/language.godeleted−132
@@ -1,132 +0,0 @@
1−package lsp
2−
3−import (
4− "path/filepath"
5− "strings"
6−
7− "github.com/charmbracelet/x/powernap/pkg/lsp/protocol"
8−)
9−
10−func DetectLanguageID(uri string) protocol.LanguageKind {
11− ext := strings.ToLower(filepath.Ext(uri))
12− switch ext {
13− case ".abap":
14− return protocol.LangABAP
15− case ".bat":
16− return protocol.LangWindowsBat
17− case ".bib", ".bibtex":
18− return protocol.LangBibTeX
19− case ".clj":
20− return protocol.LangClojure
21− case ".coffee":
22− return protocol.LangCoffeescript
23− case ".c":
24− return protocol.LangC
25− case ".cpp", ".cxx", ".cc", ".c++":
26− return protocol.LangCPP
27− case ".cs":
28− return protocol.LangCSharp
29− case ".css":
30− return protocol.LangCSS
31− case ".d":
32− return protocol.LangD
33− case ".pas", ".pascal":
34− return protocol.LangDelphi
35− case ".diff", ".patch":
36− return protocol.LangDiff
37− case ".dart":
38− return protocol.LangDart
39− case ".dockerfile":
40− return protocol.LangDockerfile
41− case ".ex", ".exs":
42− return protocol.LangElixir
43− case ".erl", ".hrl":
44− return protocol.LangErlang
45− case ".fs", ".fsi", ".fsx", ".fsscript":
46− return protocol.LangFSharp
47− case ".gitcommit":
48− return protocol.LangGitCommit
49− case ".gitrebase":
50− return protocol.LangGitRebase
51− case ".go":
52− return protocol.LangGo
53− case ".groovy":
54− return protocol.LangGroovy
55− case ".hbs", ".handlebars":
56− return protocol.LangHandlebars
57− case ".hs":
58− return protocol.LangHaskell
59− case ".html", ".htm":
60− return protocol.LangHTML
61− case ".ini":
62− return protocol.LangIni
63− case ".java":
64− return protocol.LangJava
65− case ".js":
66− return protocol.LangJavaScript
67− case ".jsx":
68− return protocol.LangJavaScriptReact
69− case ".json":
70− return protocol.LangJSON
71− case ".tex", ".latex":
72− return protocol.LangLaTeX
73− case ".less":
74− return protocol.LangLess
75− case ".lua":
76− return protocol.LangLua
77− case ".makefile", "makefile":
78− return protocol.LangMakefile
79− case ".md", ".markdown":
80− return protocol.LangMarkdown
81− case ".m":
82− return protocol.LangObjectiveC
83− case ".mm":
84− return protocol.LangObjectiveCPP
85− case ".pl":
86− return protocol.LangPerl
87− case ".pm":
88− return protocol.LangPerl6
89− case ".php":
90− return protocol.LangPHP
91− case ".ps1", ".psm1":
92− return protocol.LangPowershell
93− case ".pug", ".jade":
94− return protocol.LangPug
95− case ".py":
96− return protocol.LangPython
97− case ".r":
98− return protocol.LangR
99− case ".cshtml", ".razor":
100− return protocol.LangRazor
101− case ".rb":
102− return protocol.LangRuby
103− case ".rs":
104− return protocol.LangRust
105− case ".scss":
106− return protocol.LangSCSS
107− case ".sass":
108− return protocol.LangSASS
109− case ".scala":
110− return protocol.LangScala
111− case ".shader":
112− return protocol.LangShaderLab
113− case ".sh", ".bash", ".zsh", ".ksh":
114− return protocol.LangShellScript
115− case ".sql":
116− return protocol.LangSQL
117− case ".swift":
118− return protocol.LangSwift
119− case ".ts":
120− return protocol.LangTypeScript
121− case ".tsx":
122− return protocol.LangTypeScriptReact
123− case ".xml":
124− return protocol.LangXML
125− case ".xsl":
126− return protocol.LangXSL
127− case ".yaml", ".yml":
128− return protocol.LangYAML
129− default:
130− return protocol.LanguageKind("") // Unknown language
131− }
132−}
internal/lsp/rootmarkers_test.godeleted−37
@@ -1,37 +0,0 @@
1−package lsp
2−
3−import (
4− "os"
5− "path/filepath"
6− "testing"
7−
8− "github.com/stretchr/testify/require"
9−)
10−
11−func TestHasRootMarkers(t *testing.T) {
12− t.Parallel()
13−
14− // Create a temporary directory for testing
15− tmpDir := t.TempDir()
16−
17− // Test with empty root markers (should return true)
18− require.True(t, HasRootMarkers(tmpDir, []string{}))
19−
20− // Test with non-existent markers
21− require.False(t, HasRootMarkers(tmpDir, []string{"go.mod", "package.json"}))
22−
23− // Create a go.mod file
24− goModPath := filepath.Join(tmpDir, "go.mod")
25− err := os.WriteFile(goModPath, []byte("module test"), 0o644)
26− require.NoError(t, err)
27−
28− // Test with existing marker
29− require.True(t, HasRootMarkers(tmpDir, []string{"go.mod", "package.json"}))
30−
31− // Test with only non-existent markers
32− require.False(t, HasRootMarkers(tmpDir, []string{"package.json", "Cargo.toml"}))
33−
34− // Test with glob patterns
35− require.True(t, HasRootMarkers(tmpDir, []string{"*.mod"}))
36− require.False(t, HasRootMarkers(tmpDir, []string{"*.json"}))
37−}
internal/tui/components/chat/sidebar/sidebar.go−2
@@ -480,8 +480,6 @@ func (m *sidebarCmp) filesBlock() string {
480480func (m *sidebarCmp) lspBlock() string {
481481 // Limit the number of LSPs shown
482482 _, maxLSPs, _ := m.getDynamicLimits()
483− lspConfigs := config.Get().LSP.Sorted()
484− maxLSPs = min(len(lspConfigs), maxLSPs)
485483
486484 return lspcomponent.RenderLSPBlock(m.lspClients, lspcomponent.RenderOptions{
487485 MaxWidth: m.getMaxWidth(),
internal/tui/components/lsp/lsp.go+14−17
@@ -2,6 +2,8 @@ package lsp
22
33import (
44 "fmt"
5+ "maps"
6+ "slices"
57 "strings"
68
79 "charm.land/lipgloss/v2"
@@ -35,32 +37,32 @@ func RenderLSPList(lspClients *csync.Map[string, *lsp.Client], opts RenderOption
3537 lspList = append(lspList, section, "")
3638 }
3739
38− lspConfigs := config.Get().LSP.Sorted()
39− if len(lspConfigs) == 0 {
40+ // Get LSP states
41+ lsps := slices.SortedFunc(maps.Values(app.GetLSPStates()), func(a, b app.LSPClientInfo) int {
42+ return strings.Compare(a.Name, b.Name)
43+ })
44+ if len(lsps) == 0 {
4045 lspList = append(lspList, t.S().Base.Foreground(t.Border).Render("None"))
4146 return lspList
4247 }
4348
44− // Get LSP states
45− lspStates := app.GetLSPStates()
46−
4749 // Determine how many items to show
48− maxItems := len(lspConfigs)
50+ maxItems := len(lsps)
4951 if opts.MaxItems > 0 {
50− maxItems = min(opts.MaxItems, len(lspConfigs))
52+ maxItems = min(opts.MaxItems, len(lsps))
5153 }
5254
53− for i, l := range lspConfigs {
55+ for i, info := range lsps {
5456 if i >= maxItems {
5557 break
5658 }
5759
58− icon, description := iconAndDescription(l, t, lspStates)
60+ icon, description := iconAndDescription(t, info)
5961
6062 // Calculate diagnostic counts if we have LSP clients
6163 var extraContent string
6264 if lspClients != nil {
63− if client, ok := lspClients.Get(l.Name); ok {
65+ if client, ok := lspClients.Get(info.Name); ok {
6466 counts := client.GetDiagnosticCounts()
6567 errs := []string{}
6668 if counts.Error > 0 {
@@ -83,7 +85,7 @@ func RenderLSPList(lspClients *csync.Map[string, *lsp.Client], opts RenderOption
8385 core.Status(
8486 core.StatusOpts{
8587 Icon: icon.String(),
86− Title: l.Name,
88+ Title: info.Name,
8789 Description: description,
8890 ExtraContent: extraContent,
8991 },
@@ -95,12 +97,7 @@ func RenderLSPList(lspClients *csync.Map[string, *lsp.Client], opts RenderOption
9597 return lspList
9698}
9799
98−func iconAndDescription(l config.LSP, t *styles.Theme, states map[string]app.LSPClientInfo) (lipgloss.Style, string) {
99− if l.LSP.Disabled {
100− return t.ItemOfflineIcon.Foreground(t.FgMuted), t.S().Subtle.Render("disabled")
101− }
102−
103− info := states[l.Name]
100+func iconAndDescription(t *styles.Theme, info app.LSPClientInfo) (lipgloss.Style, string) {
104101 switch info.State {
105102 case lsp.StateStarting:
106103 return t.ItemBusyIcon, t.S().Subtle.Render("starting...")