wip vibecoded mostly

Kujtim Hoxha created

Change summary

.opencode.json                              |   7 
go.mod                                      |   2 
go.sum                                      |   2 
internal/app/app.go                         |   6 
internal/app/lsp.go                         |  21 
internal/config/config.go                   |   8 
internal/config/lsp.go                      |  87 ++
internal/llm/agent/agent.go                 |  23 
internal/lsp/setup/detect.go                | 384 +++++++++
internal/lsp/setup/discover.go              | 522 ++++++++++++
internal/lsp/setup/install.go               | 317 +++++++
internal/tui/components/dialog/commands.go  |   2 
internal/tui/components/dialog/lsp_setup.go | 944 +++++++++++++++++++++++
internal/tui/components/util/simple-list.go |   6 
internal/tui/tui.go                         | 111 ++
15 files changed, 2,414 insertions(+), 28 deletions(-)

Detailed changes

.opencode.json 🔗

@@ -1,8 +1,3 @@
 {
-  "$schema": "./opencode-schema.json",
-  "lsp": {
-    "gopls": {
-      "command": "gopls"
-    }
-  }
+  "$schema": "./opencode-schema.json"
 }

go.mod 🔗

@@ -33,6 +33,8 @@ require (
 	github.com/stretchr/testify v1.10.0
 )
 
+require github.com/sahilm/fuzzy v0.1.1 // indirect
+
 require (
 	cloud.google.com/go v0.116.0 // indirect
 	cloud.google.com/go/auth v0.13.0 // indirect

go.sum 🔗

@@ -199,6 +199,8 @@ github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7
 github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
 github.com/sagikazarmark/locafero v0.7.0 h1:5MqpDsTGNDhY8sGp0Aowyf0qKsPrhewaLSsFaodPcyo=
 github.com/sagikazarmark/locafero v0.7.0/go.mod h1:2za3Cg5rMaTMoG/2Ulr9AwtFaIppKXTRYnozin4aB5k=
+github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA=
+github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
 github.com/sebdah/goldie/v2 v2.5.3 h1:9ES/mNN+HNUbNWpVAlrzuZ7jE+Nrczbj8uFRjM7624Y=
 github.com/sebdah/goldie/v2 v2.5.3/go.mod h1:oZ9fp0+se1eapSRjfYbsV/0Hqhbuu3bJVvKI/NNtssI=
 github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=

internal/app/app.go 🔗

@@ -32,6 +32,9 @@ type App struct {
 
 	LSPClients map[string]*lsp.Client
 
+	// InitLSPClients initializes LSP clients from the configuration
+	InitLSPClients func(context.Context)
+
 	clientsMutex sync.RWMutex
 
 	watcherCancelFuncs []context.CancelFunc
@@ -53,6 +56,9 @@ func New(ctx context.Context, conn *sql.DB) (*App, error) {
 		LSPClients:  make(map[string]*lsp.Client),
 	}
 
+	// Set the InitLSPClients function
+	app.InitLSPClients = app.initLSPClients
+
 	// Initialize theme based on configuration
 	app.initTheme()
 

internal/app/lsp.go 🔗

@@ -13,20 +13,31 @@ import (
 func (app *App) initLSPClients(ctx context.Context) {
 	cfg := config.Get()
 
-	// Initialize LSP clients
 	for name, clientConfig := range cfg.LSP {
-		// Start each client initialization in its own goroutine
+		if clientConfig.Disabled {
+			logging.Info("Skipping disabled LSP client", "name", name)
+			continue
+		}
+		
 		go app.createAndStartLSPClient(ctx, name, clientConfig.Command, clientConfig.Args...)
 	}
 	logging.Info("LSP clients initialization started in background")
 }
 
+// CheckAndSetupLSP checks if LSP is configured and returns true if setup is needed
+func (app *App) CheckAndSetupLSP(ctx context.Context) bool {
+	if config.IsLSPConfigured() {
+		return false
+	}
+	
+	logging.Info("LSP not configured, setup needed")
+	return true
+}
+
 // createAndStartLSPClient creates a new LSP client, initializes it, and starts its workspace watcher
 func (app *App) createAndStartLSPClient(ctx context.Context, name string, command string, args ...string) {
-	// Create a specific context for initialization with a timeout
 	logging.Info("Creating LSP client", "name", name, "command", command, "args", args)
 	
-	// Create the LSP client
 	lspClient, err := lsp.NewClient(ctx, command, args...)
 	if err != nil {
 		logging.Error("Failed to create LSP client for", name, err)
@@ -37,11 +48,9 @@ func (app *App) createAndStartLSPClient(ctx context.Context, name string, comman
 	initCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
 	defer cancel()
 	
-	// Initialize with the initialization context
 	_, err = lspClient.InitializeLSPClient(initCtx, config.WorkingDirectory())
 	if err != nil {
 		logging.Error("Initialize failed", "name", name, "error", err)
-		// Clean up the client to prevent resource leaks
 		lspClient.Close()
 		return
 	}

internal/config/config.go 🔗

@@ -62,10 +62,10 @@ type Data struct {
 
 // LSPConfig defines configuration for Language Server Protocol integration.
 type LSPConfig struct {
-	Disabled bool     `json:"enabled"`
+	Disabled bool     `json:"disabled"`
 	Command  string   `json:"command"`
-	Args     []string `json:"args"`
-	Options  any      `json:"options"`
+	Args     []string `json:"args,omitempty"`
+	Options  any      `json:"options,omitempty"`
 }
 
 // TUIConfig defines the configuration for the Terminal User Interface.
@@ -91,7 +91,7 @@ type Config struct {
 	DebugLSP     bool                              `json:"debugLSP,omitempty"`
 	ContextPaths []string                          `json:"contextPaths,omitempty"`
 	TUI          TUIConfig                         `json:"tui"`
-	Shell        ShellConfig                       `json:"shell,omitempty"`
+	Shell        ShellConfig                       `json:"shell"`
 	AutoCompact  bool                              `json:"autoCompact,omitempty"`
 }
 

internal/config/lsp.go 🔗

@@ -0,0 +1,87 @@
+package config
+
+import (
+	"encoding/json"
+	"os"
+	"path/filepath"
+
+	"github.com/opencode-ai/opencode/internal/logging"
+	"github.com/opencode-ai/opencode/internal/lsp/protocol"
+)
+
+// LSPServerInfo contains information about an LSP server
+type LSPServerInfo struct {
+	Name        string   // Display name of the server
+	Command     string   // Command to execute
+	Args        []string // Arguments to pass to the command
+	InstallCmd  string   // Command to install the server
+	Description string   // Description of the server
+	Recommended bool     // Whether this is the recommended server for the language
+	Options     any      // Additional options for the server
+}
+
+// UpdateLSPConfig updates the LSP configuration with the provided servers in the local config file
+func UpdateLSPConfig(servers map[protocol.LanguageKind]LSPServerInfo) error {
+	// Create a map for the LSP configuration
+	lspConfig := make(map[string]LSPConfig)
+	
+	for lang, server := range servers {
+		langStr := string(lang)
+		
+		lspConfig[langStr] = LSPConfig{
+			Disabled: false,
+			Command:  server.Command,
+			Args:     server.Args,
+			Options:  server.Options,
+		}
+	}
+	
+	return SaveLocalLSPConfig(lspConfig)
+}
+
+// SaveLocalLSPConfig saves only the LSP configuration to the local config file
+func SaveLocalLSPConfig(lspConfig map[string]LSPConfig) error {
+	// Get the working directory
+	workingDir := WorkingDirectory()
+	
+	// Define the local config file path
+	configPath := filepath.Join(workingDir, ".opencode.json")
+	
+	// Create a new configuration with only the LSP settings
+	localConfig := make(map[string]any)
+	
+	// Read existing local config if it exists
+	if _, err := os.Stat(configPath); err == nil {
+		data, err := os.ReadFile(configPath)
+		if err == nil {
+			if err := json.Unmarshal(data, &localConfig); err != nil {
+				logging.Warn("Failed to parse existing local config", "error", err)
+				// Continue with empty config if we can't parse the existing one
+				localConfig = make(map[string]any)
+			}
+		}
+	}
+	
+	// Update only the LSP configuration
+	localConfig["lsp"] = lspConfig
+	
+	// Marshal the configuration to JSON
+	data, err := json.MarshalIndent(localConfig, "", "  ")
+	if err != nil {
+		return err
+	}
+	
+	// Write the configuration to the file
+	if err := os.WriteFile(configPath, data, 0644); err != nil {
+		return err
+	}
+	
+	logging.Info("LSP configuration saved to local config file", configPath)
+	return nil
+}
+
+// IsLSPConfigured checks if LSP is already configured
+func IsLSPConfigured() bool {
+	cfg := Get()
+	return len(cfg.LSP) > 0
+}

internal/llm/agent/agent.go 🔗

@@ -563,6 +563,29 @@ func (a *agent) Summarize(ctx context.Context, sessionID string) error {
 			a.Publish(pubsub.CreatedEvent, event)
 			return
 		}
+		session, err := a.sessions.Get(summarizeCtx, sessionID)
+		if err != nil {
+			event = AgentEvent{
+				Type:  AgentEventTypeError,
+				Error: fmt.Errorf("failed to get session: %w", err),
+				Done:  true,
+			}
+			a.Publish(pubsub.CreatedEvent, event)
+			return
+		}
+		if session.SummaryMessageID != "" {
+			summaryMsgInex := -1
+			for i, msg := range msgs {
+				if msg.ID == session.SummaryMessageID {
+					summaryMsgInex = i
+					break
+				}
+			}
+			if summaryMsgInex != -1 {
+				msgs = msgs[summaryMsgInex:]
+				msgs[0].Role = message.User
+			}
+		}
 
 		event = AgentEvent{
 			Type:     AgentEventTypeSummarize,

internal/lsp/setup/detect.go 🔗

@@ -0,0 +1,384 @@
+package setup
+
+import (
+	"os"
+	"path/filepath"
+	"sort"
+	"strings"
+
+	"github.com/opencode-ai/opencode/internal/logging"
+	"github.com/opencode-ai/opencode/internal/lsp/protocol"
+)
+
+// LanguageScore represents a language with its importance score in the project
+type LanguageScore struct {
+	Language protocol.LanguageKind
+	Score    int
+}
+
+// DetectProjectLanguages scans the workspace and returns a map of languages to their importance score
+func DetectProjectLanguages(workspaceDir string) (map[protocol.LanguageKind]int, error) {
+	languages := make(map[protocol.LanguageKind]int)
+
+	skipDirs := map[string]bool{
+		".git":         true,
+		"node_modules": true,
+		"vendor":       true,
+		"dist":         true,
+		"build":        true,
+		".idea":        true,
+		".vscode":      true,
+		".github":      true,
+		".gitlab":      true,
+		".next":        true,
+	}
+
+	err := filepath.Walk(workspaceDir, func(path string, info os.FileInfo, err error) error {
+		if err != nil {
+			return nil
+		}
+
+		if info.IsDir() {
+			if skipDirs[info.Name()] {
+				return filepath.SkipDir
+			}
+			return nil
+		}
+
+		// Skip files larger than 1MB to avoid processing large binary files
+		if info.Size() > 1024*1024 {
+			return nil
+		}
+
+		// Skip hidden files
+		if strings.HasPrefix(info.Name(), ".") {
+			return nil
+		}
+
+		// Detect language based on file extension
+		lang := detectLanguageFromPath(path)
+		if lang != "" {
+			languages[lang]++
+		}
+
+		return nil
+	})
+	if err != nil {
+		return nil, err
+	}
+
+	// Check for special project files to boost language scores
+	checkSpecialProjectFiles(workspaceDir, languages)
+
+	return languages, nil
+}
+
+// detectLanguageFromPath detects the language based on the file path
+func detectLanguageFromPath(path string) protocol.LanguageKind {
+	ext := strings.ToLower(filepath.Ext(path))
+	filename := strings.ToLower(filepath.Base(path))
+
+	// Special case for Dockerfiles which don't have extensions
+	if filename == "dockerfile" || strings.HasSuffix(filename, ".dockerfile") {
+		return protocol.LangDockerfile
+	}
+
+	// Special case for Makefiles
+	if filename == "makefile" || strings.HasSuffix(filename, ".mk") {
+		return protocol.LangMakefile
+	}
+
+	// Special case for shell scripts without extensions
+	if isShellScript(path) {
+		return protocol.LangShellScript
+	}
+
+	// Map file extensions to languages
+	switch ext {
+	case ".go":
+		return protocol.LangGo
+	case ".js":
+		return protocol.LangJavaScript
+	case ".jsx":
+		return protocol.LangJavaScriptReact
+	case ".ts":
+		return protocol.LangTypeScript
+	case ".tsx":
+		return protocol.LangTypeScriptReact
+	case ".py":
+		return protocol.LangPython
+	case ".java":
+		return protocol.LangJava
+	case ".c":
+		return protocol.LangC
+	case ".cpp", ".cc", ".cxx", ".c++":
+		return protocol.LangCPP
+	case ".cs":
+		return protocol.LangCSharp
+	case ".php":
+		return protocol.LangPHP
+	case ".rb":
+		return protocol.LangRuby
+	case ".rs":
+		return protocol.LangRust
+	case ".swift":
+		return protocol.LangSwift
+	case ".kt", ".kts":
+		return "kotlin"
+	case ".scala":
+		return protocol.LangScala
+	case ".html", ".htm":
+		return protocol.LangHTML
+	case ".css":
+		return protocol.LangCSS
+	case ".scss":
+		return protocol.LangSCSS
+	case ".sass":
+		return protocol.LangSASS
+	case ".less":
+		return protocol.LangLess
+	case ".json":
+		return protocol.LangJSON
+	case ".xml":
+		return protocol.LangXML
+	case ".yaml", ".yml":
+		return protocol.LangYAML
+	case ".md", ".markdown":
+		return protocol.LangMarkdown
+	case ".sh", ".bash", ".zsh":
+		return protocol.LangShellScript
+	case ".sql":
+		return protocol.LangSQL
+	case ".dart":
+		return protocol.LangDart
+	case ".lua":
+		return protocol.LangLua
+	case ".ex", ".exs":
+		return protocol.LangElixir
+	case ".erl":
+		return protocol.LangErlang
+	case ".hs":
+		return protocol.LangHaskell
+	case ".pl", ".pm":
+		return protocol.LangPerl
+	case ".r":
+		return protocol.LangR
+	case ".vue":
+		return "vue"
+	case ".svelte":
+		return "svelte"
+	}
+
+	return ""
+}
+
+// isShellScript checks if a file is a shell script by looking at its shebang
+func isShellScript(path string) bool {
+	// Open the file
+	file, err := os.Open(path)
+	if err != nil {
+		return false
+	}
+	defer file.Close()
+
+	// Read the first line
+	buf := make([]byte, 128)
+	n, err := file.Read(buf)
+	if err != nil || n < 2 {
+		return false
+	}
+
+	// Check for shebang
+	if buf[0] == '#' && buf[1] == '!' {
+		line := string(buf[:n])
+		return strings.Contains(line, "/bin/sh") ||
+			strings.Contains(line, "/bin/bash") ||
+			strings.Contains(line, "/bin/zsh") ||
+			strings.Contains(line, "/usr/bin/env sh") ||
+			strings.Contains(line, "/usr/bin/env bash") ||
+			strings.Contains(line, "/usr/bin/env zsh")
+	}
+
+	return false
+}
+
+// checkSpecialProjectFiles looks for special project files to boost language scores
+func checkSpecialProjectFiles(workspaceDir string, languages map[protocol.LanguageKind]int) {
+	// Check for package.json (Node.js/JavaScript/TypeScript)
+	if _, err := os.Stat(filepath.Join(workspaceDir, "package.json")); err == nil {
+		languages[protocol.LangJavaScript] += 10
+
+		// Check for TypeScript configuration
+		if _, err := os.Stat(filepath.Join(workspaceDir, "tsconfig.json")); err == nil {
+			languages[protocol.LangTypeScript] += 15
+		}
+	}
+
+	// Check for go.mod (Go)
+	if _, err := os.Stat(filepath.Join(workspaceDir, "go.mod")); err == nil {
+		languages[protocol.LangGo] += 20
+	}
+
+	// Check for requirements.txt or setup.py (Python)
+	if _, err := os.Stat(filepath.Join(workspaceDir, "requirements.txt")); err == nil {
+		languages[protocol.LangPython] += 15
+	}
+	if _, err := os.Stat(filepath.Join(workspaceDir, "setup.py")); err == nil {
+		languages[protocol.LangPython] += 15
+	}
+
+	// Check for pom.xml or build.gradle (Java)
+	if _, err := os.Stat(filepath.Join(workspaceDir, "pom.xml")); err == nil {
+		languages[protocol.LangJava] += 15
+	}
+	if _, err := os.Stat(filepath.Join(workspaceDir, "build.gradle")); err == nil {
+		languages[protocol.LangJava] += 15
+	}
+
+	// Check for Cargo.toml (Rust)
+	if _, err := os.Stat(filepath.Join(workspaceDir, "Cargo.toml")); err == nil {
+		languages[protocol.LangRust] += 20
+	}
+
+	// Check for composer.json (PHP)
+	if _, err := os.Stat(filepath.Join(workspaceDir, "composer.json")); err == nil {
+		languages[protocol.LangPHP] += 15
+	}
+
+	// Check for Gemfile (Ruby)
+	if _, err := os.Stat(filepath.Join(workspaceDir, "Gemfile")); err == nil {
+		languages[protocol.LangRuby] += 15
+	}
+
+	// Check for CMakeLists.txt (C/C++)
+	if _, err := os.Stat(filepath.Join(workspaceDir, "CMakeLists.txt")); err == nil {
+		languages[protocol.LangCPP] += 10
+		languages[protocol.LangC] += 5
+	}
+
+	// Check for pubspec.yaml (Dart/Flutter)
+	if _, err := os.Stat(filepath.Join(workspaceDir, "pubspec.yaml")); err == nil {
+		languages["dart"] += 20
+	}
+
+	// Check for mix.exs (Elixir)
+	if _, err := os.Stat(filepath.Join(workspaceDir, "mix.exs")); err == nil {
+		languages[protocol.LangElixir] += 20
+	}
+}
+
+// GetPrimaryLanguages returns the top N languages in the project
+func GetPrimaryLanguages(languages map[protocol.LanguageKind]int, limit int) []LanguageScore {
+	// Convert map to slice for sorting
+	var langScores []LanguageScore
+	for lang, score := range languages {
+		if lang != "" && score > 0 {
+			langScores = append(langScores, LanguageScore{
+				Language: lang,
+				Score:    score,
+			})
+		}
+	}
+
+	// Sort by score (descending)
+	sort.Slice(langScores, func(i, j int) bool {
+		return langScores[i].Score > langScores[j].Score
+	})
+
+	// Return top N languages or all if less than N
+	if len(langScores) <= limit {
+		return langScores
+	}
+	return langScores[:limit]
+}
+
+// DetectMonorepo checks if the workspace is a monorepo by looking for multiple project files
+func DetectMonorepo(workspaceDir string) (bool, []string) {
+	var projectDirs []string
+
+	// Common project files to look for
+	projectFiles := []string{
+		"package.json",
+		"go.mod",
+		"pom.xml",
+		"build.gradle",
+		"Cargo.toml",
+		"requirements.txt",
+		"setup.py",
+		"composer.json",
+		"Gemfile",
+		"pubspec.yaml",
+		"mix.exs",
+	}
+
+	// Skip directories that are typically not relevant
+	skipDirs := map[string]bool{
+		".git":         true,
+		"node_modules": true,
+		"vendor":       true,
+		"dist":         true,
+		"build":        true,
+		".idea":        true,
+		".vscode":      true,
+		".github":      true,
+		".gitlab":      true,
+	}
+
+	// Check for root project files
+	rootIsProject := false
+	for _, file := range projectFiles {
+		if _, err := os.Stat(filepath.Join(workspaceDir, file)); err == nil {
+			rootIsProject = true
+			break
+		}
+	}
+
+	// Walk through the workspace to find project files in subdirectories
+	err := filepath.Walk(workspaceDir, func(path string, info os.FileInfo, err error) error {
+		if err != nil {
+			return nil
+		}
+
+		// Skip the root directory since we already checked it
+		if path == workspaceDir {
+			return nil
+		}
+
+		// Skip files
+		if !info.IsDir() {
+			return nil
+		}
+
+		// Skip directories in the skipDirs list
+		if skipDirs[info.Name()] {
+			return filepath.SkipDir
+		}
+
+		// Check for project files in this directory
+		for _, file := range projectFiles {
+			if _, err := os.Stat(filepath.Join(path, file)); err == nil {
+				// Found a project file, add this directory to the list
+				relPath, err := filepath.Rel(workspaceDir, path)
+				if err == nil {
+					projectDirs = append(projectDirs, relPath)
+				}
+				return filepath.SkipDir // Skip subdirectories of this project
+			}
+		}
+
+		return nil
+	})
+	if err != nil {
+		logging.Warn("Error detecting monorepo", "error", err)
+	}
+
+	// It's a monorepo if we found multiple project directories
+	isMonorepo := len(projectDirs) > 0
+
+	// If the root is also a project, add it to the list
+	if rootIsProject {
+		projectDirs = append([]string{"."}, projectDirs...)
+	}
+
+	return isMonorepo, projectDirs
+}

internal/lsp/setup/discover.go 🔗

@@ -0,0 +1,522 @@
+package setup
+
+import (
+	"fmt"
+	"os"
+	"os/exec"
+	"path/filepath"
+	"runtime"
+	"strings"
+
+	"github.com/opencode-ai/opencode/internal/lsp/protocol"
+)
+
+// LSPServerInfo contains information about an LSP server
+type LSPServerInfo struct {
+	Name        string   // Display name of the server
+	Command     string   // Command to execute
+	Args        []string // Arguments to pass to the command
+	InstallCmd  string   // Command to install the server
+	Description string   // Description of the server
+	Recommended bool     // Whether this is the recommended server for the language
+	Options     any      // Additional options for the server
+}
+
+// LSPServerMap maps languages to their available LSP servers
+type LSPServerMap map[protocol.LanguageKind][]LSPServerInfo
+
+// ServerDefinition defines an LSP server configuration
+type ServerDefinition struct {
+	Name       string
+	Args       []string
+	InstallCmd string
+	Languages  []protocol.LanguageKind
+}
+
+// Common paths where LSP servers might be installed
+var (
+	// Common editor-specific paths
+	vscodePath = getVSCodeExtensionsPath()
+	neovimPath = getNeovimPluginsPath()
+
+	// Common package manager paths
+	npmBinPath       = getNpmGlobalBinPath()
+	pipBinPath       = getPipBinPath()
+	goBinPath        = getGoBinPath()
+	cargoInstallPath = getCargoInstallPath()
+
+	// Server definitions
+	serverDefinitions = []ServerDefinition{
+		{
+			Name:       "typescript-language-server",
+			Args:       []string{"--stdio"},
+			InstallCmd: "npm install -g typescript-language-server typescript",
+			Languages:  []protocol.LanguageKind{protocol.LangJavaScript, protocol.LangTypeScript, protocol.LangJavaScriptReact, protocol.LangTypeScriptReact},
+		},
+		{
+			Name:       "deno",
+			Args:       []string{"lsp"},
+			InstallCmd: "https://deno.com/#installation",
+			Languages:  []protocol.LanguageKind{protocol.LangJavaScript, protocol.LangTypeScript},
+		},
+		{
+			Name:       "pylsp",
+			Args:       []string{},
+			InstallCmd: "pip install python-lsp-server",
+			Languages:  []protocol.LanguageKind{protocol.LangPython},
+		},
+		{
+			Name:       "pyright",
+			Args:       []string{"--stdio"},
+			InstallCmd: "npm install -g pyright",
+			Languages:  []protocol.LanguageKind{protocol.LangPython},
+		},
+		{
+			Name:       "jedi-language-server",
+			Args:       []string{},
+			InstallCmd: "pip install jedi-language-server",
+			Languages:  []protocol.LanguageKind{protocol.LangPython},
+		},
+		{
+			Name:       "gopls",
+			Args:       []string{},
+			InstallCmd: "go install golang.org/x/tools/gopls@latest",
+			Languages:  []protocol.LanguageKind{protocol.LangGo},
+		},
+		{
+			Name:       "rust-analyzer",
+			Args:       []string{},
+			InstallCmd: "rustup component add rust-analyzer",
+			Languages:  []protocol.LanguageKind{protocol.LangRust},
+		},
+		{
+			Name:       "jdtls",
+			Args:       []string{},
+			InstallCmd: "Manual installation required: https://github.com/eclipse/eclipse.jdt.ls",
+			Languages:  []protocol.LanguageKind{protocol.LangJava},
+		},
+		{
+			Name:       "clangd",
+			Args:       []string{},
+			InstallCmd: "Manual installation required: Install via package manager or https://clangd.llvm.org/installation.html",
+			Languages:  []protocol.LanguageKind{protocol.LangC, protocol.LangCPP},
+		},
+		{
+			Name:       "omnisharp",
+			Args:       []string{"--languageserver"},
+			InstallCmd: "npm install -g omnisharp-language-server",
+			Languages:  []protocol.LanguageKind{protocol.LangCSharp},
+		},
+		{
+			Name:       "intelephense",
+			Args:       []string{"--stdio"},
+			InstallCmd: "npm install -g intelephense",
+			Languages:  []protocol.LanguageKind{protocol.LangPHP},
+		},
+		{
+			Name:       "solargraph",
+			Args:       []string{"stdio"},
+			InstallCmd: "gem install solargraph",
+			Languages:  []protocol.LanguageKind{protocol.LangRuby},
+		},
+		{
+			Name:       "vscode-html-language-server",
+			Args:       []string{"--stdio"},
+			InstallCmd: "npm install -g vscode-langservers-extracted",
+			Languages:  []protocol.LanguageKind{protocol.LangHTML},
+		},
+		{
+			Name:       "vscode-css-language-server",
+			Args:       []string{"--stdio"},
+			InstallCmd: "npm install -g vscode-langservers-extracted",
+			Languages:  []protocol.LanguageKind{protocol.LangCSS},
+		},
+		{
+			Name:       "vscode-json-language-server",
+			Args:       []string{"--stdio"},
+			InstallCmd: "npm install -g vscode-langservers-extracted",
+			Languages:  []protocol.LanguageKind{protocol.LangJSON},
+		},
+		{
+			Name:       "yaml-language-server",
+			Args:       []string{"--stdio"},
+			InstallCmd: "npm install -g yaml-language-server",
+			Languages:  []protocol.LanguageKind{protocol.LangYAML},
+		},
+		{
+			Name:       "lua-language-server",
+			Args:       []string{},
+			InstallCmd: "https://github.com/LuaLS/lua-language-server/wiki/Getting-Started",
+			Languages:  []protocol.LanguageKind{protocol.LangLua},
+		},
+		{
+			Name:       "docker-langserver",
+			Args:       []string{"--stdio"},
+			InstallCmd: "npm install -g dockerfile-language-server-nodejs",
+			Languages:  []protocol.LanguageKind{protocol.LangDockerfile},
+		},
+		{
+			Name:       "bash-language-server",
+			Args:       []string{"start"},
+			InstallCmd: "npm install -g bash-language-server",
+			Languages:  []protocol.LanguageKind{protocol.LangShellScript},
+		},
+		{
+			Name:       "vls",
+			Args:       []string{},
+			InstallCmd: "npm install -g @volar/vue-language-server",
+			Languages:  []protocol.LanguageKind{"vue"},
+		},
+		{
+			Name:       "svelteserver",
+			Args:       []string{"--stdio"},
+			InstallCmd: "npm install -g svelte-language-server",
+			Languages:  []protocol.LanguageKind{"svelte"},
+		},
+		{
+			Name:       "dart",
+			Args:       []string{"language-server"},
+			InstallCmd: "https://dart.dev/get-dart",
+			Languages:  []protocol.LanguageKind{protocol.LangDart},
+		},
+		{
+			Name:       "elixir-ls",
+			Args:       []string{},
+			InstallCmd: "https://github.com/elixir-lsp/elixir-ls#installation",
+			Languages:  []protocol.LanguageKind{protocol.LangElixir},
+		},
+	}
+
+	// Recommended servers by language
+	recommendedServers = map[protocol.LanguageKind]string{
+		protocol.LangJavaScript:      "typescript-language-server",
+		protocol.LangTypeScript:      "typescript-language-server",
+		protocol.LangJavaScriptReact: "typescript-language-server",
+		protocol.LangTypeScriptReact: "typescript-language-server",
+		protocol.LangPython:          "pylsp",
+		protocol.LangGo:              "gopls",
+		protocol.LangRust:            "rust-analyzer",
+		protocol.LangJava:            "jdtls",
+		protocol.LangC:               "clangd",
+		protocol.LangCPP:             "clangd",
+		protocol.LangCSharp:          "omnisharp",
+		protocol.LangPHP:             "intelephense",
+		protocol.LangRuby:            "solargraph",
+		protocol.LangHTML:            "vscode-html-language-server",
+		protocol.LangCSS:             "vscode-css-language-server",
+		protocol.LangJSON:            "vscode-json-language-server",
+		protocol.LangYAML:            "yaml-language-server",
+		protocol.LangLua:             "lua-language-server",
+		protocol.LangDockerfile:      "docker-langserver",
+		protocol.LangShellScript:     "bash-language-server",
+		"vue":                        "vls",
+		"svelte":                     "svelteserver",
+		protocol.LangDart:            "dart",
+		protocol.LangElixir:          "elixir-ls",
+	}
+)
+
+// DiscoverInstalledLSPs checks common locations for installed LSP servers
+func DiscoverInstalledLSPs() LSPServerMap {
+	result := make(LSPServerMap)
+
+	for _, def := range serverDefinitions {
+		for _, lang := range def.Languages {
+			checkAndAddServer(result, lang, def.Name, def.Args, def.InstallCmd)
+		}
+	}
+
+	return result
+}
+
+// checkAndAddServer checks if an LSP server is installed and adds it to the result map
+func checkAndAddServer(result LSPServerMap, lang protocol.LanguageKind, command string, args []string, installCmd string) {
+	// Check if the command exists in PATH
+	if path, err := exec.LookPath(command); err == nil {
+		server := LSPServerInfo{
+			Name:        command,
+			Command:     path,
+			Args:        args,
+			InstallCmd:  installCmd,
+			Description: fmt.Sprintf("%s language server", lang),
+			Recommended: isRecommendedServer(lang, command),
+		}
+
+		result[lang] = append(result[lang], server)
+	} else {
+		// Check in common editor-specific paths
+		if path := findInEditorPaths(command); path != "" {
+			server := LSPServerInfo{
+				Name:        command,
+				Command:     path,
+				Args:        args,
+				InstallCmd:  installCmd,
+				Description: fmt.Sprintf("%s language server", lang),
+				Recommended: isRecommendedServer(lang, command),
+			}
+			result[lang] = append(result[lang], server)
+		}
+	}
+}
+
+// findInEditorPaths checks for an LSP server in common editor-specific paths
+func findInEditorPaths(command string) string {
+	// Check in VSCode extensions
+	if vscodePath != "" {
+		// VSCode extensions can have different structures, so we need to search for the binary
+		matches, err := filepath.Glob(filepath.Join(vscodePath, "*", "**", command))
+		if err == nil && len(matches) > 0 {
+			for _, match := range matches {
+				if isExecutable(match) {
+					return match
+				}
+			}
+		}
+
+		// Check for node_modules/.bin in VSCode extensions
+		matches, err = filepath.Glob(filepath.Join(vscodePath, "*", "node_modules", ".bin", command))
+		if err == nil && len(matches) > 0 {
+			for _, match := range matches {
+				if isExecutable(match) {
+					return match
+				}
+			}
+		}
+	}
+
+	// Check in Neovim plugins
+	if neovimPath != "" {
+		matches, err := filepath.Glob(filepath.Join(neovimPath, "*", "**", command))
+		if err == nil && len(matches) > 0 {
+			for _, match := range matches {
+				if isExecutable(match) {
+					return match
+				}
+			}
+		}
+	}
+
+	// Check in npm global bin
+	if npmBinPath != "" {
+		path := filepath.Join(npmBinPath, command)
+		if isExecutable(path) {
+			return path
+		}
+	}
+
+	// Check in pip bin
+	if pipBinPath != "" {
+		path := filepath.Join(pipBinPath, command)
+		if isExecutable(path) {
+			return path
+		}
+	}
+
+	// Check in Go bin
+	if goBinPath != "" {
+		path := filepath.Join(goBinPath, command)
+		if isExecutable(path) {
+			return path
+		}
+	}
+
+	// Check in Cargo install
+	if cargoInstallPath != "" {
+		path := filepath.Join(cargoInstallPath, command)
+		if isExecutable(path) {
+			return path
+		}
+	}
+
+	return ""
+}
+
+// isExecutable checks if a file is executable
+func isExecutable(path string) bool {
+	info, err := os.Stat(path)
+	if err != nil {
+		return false
+	}
+
+	// On Windows, all files are "executable"
+	if runtime.GOOS == "windows" {
+		return !info.IsDir()
+	}
+
+	// On Unix-like systems, check the executable bit
+	return !info.IsDir() && (info.Mode()&0111 != 0)
+}
+
+// isRecommendedServer checks if a server is the recommended one for a language
+func isRecommendedServer(lang protocol.LanguageKind, command string) bool {
+	recommended, ok := recommendedServers[lang]
+	return ok && recommended == command
+}
+
+// GetRecommendedLSPServers returns the recommended LSP servers for the given languages
+func GetRecommendedLSPServers(languages []LanguageScore) LSPServerMap {
+	result := make(LSPServerMap)
+
+	for _, lang := range languages {
+		// Find the server definition for this language
+		for _, def := range serverDefinitions {
+			for _, defLang := range def.Languages {
+				if defLang == lang.Language && isRecommendedServer(lang.Language, def.Name) {
+					server := LSPServerInfo{
+						Name:        def.Name,
+						Command:     def.Name,
+						Args:        def.Args,
+						InstallCmd:  def.InstallCmd,
+						Description: fmt.Sprintf("%s Language Server", lang.Language),
+						Recommended: true,
+					}
+					result[lang.Language] = []LSPServerInfo{server}
+					break
+				}
+			}
+		}
+	}
+
+	return result
+}
+
+// Helper functions to get common paths
+
+func getVSCodeExtensionsPath() string {
+	var path string
+
+	switch runtime.GOOS {
+	case "windows":
+		path = filepath.Join(os.Getenv("USERPROFILE"), ".vscode", "extensions")
+	case "darwin":
+		path = filepath.Join(os.Getenv("HOME"), ".vscode", "extensions")
+	default: // Linux and others
+		path = filepath.Join(os.Getenv("HOME"), ".vscode", "extensions")
+	}
+
+	if _, err := os.Stat(path); err != nil {
+		// Try alternative locations
+		switch runtime.GOOS {
+		case "darwin":
+			altPath := filepath.Join(os.Getenv("HOME"), "Library", "Application Support", "Code", "User", "extensions")
+			if _, err := os.Stat(altPath); err == nil {
+				return altPath
+			}
+		case "linux":
+			altPath := filepath.Join(os.Getenv("HOME"), ".config", "Code", "User", "extensions")
+			if _, err := os.Stat(altPath); err == nil {
+				return altPath
+			}
+		}
+		return ""
+	}
+
+	return path
+}
+
+func getNeovimPluginsPath() string {
+	var paths []string
+
+	switch runtime.GOOS {
+	case "windows":
+		paths = []string{
+			filepath.Join(os.Getenv("LOCALAPPDATA"), "nvim", "plugged"),
+			filepath.Join(os.Getenv("LOCALAPPDATA"), "nvim", "site", "pack"),
+		}
+	default: // Linux, macOS, and others
+		paths = []string{
+			filepath.Join(os.Getenv("HOME"), ".local", "share", "nvim", "plugged"),
+			filepath.Join(os.Getenv("HOME"), ".local", "share", "nvim", "site", "pack"),
+			filepath.Join(os.Getenv("HOME"), ".config", "nvim", "plugged"),
+		}
+	}
+
+	for _, path := range paths {
+		if _, err := os.Stat(path); err == nil {
+			return path
+		}
+	}
+
+	return ""
+}
+
+func getNpmGlobalBinPath() string {
+	// Try to get the npm global bin path
+	cmd := exec.Command("npm", "config", "get", "prefix")
+	output, err := cmd.Output()
+	if err == nil {
+		prefix := strings.TrimSpace(string(output))
+		if prefix != "" {
+			if runtime.GOOS == "windows" {
+				return filepath.Join(prefix, "node_modules", ".bin")
+			}
+			return filepath.Join(prefix, "bin")
+		}
+	}
+
+	// Fallback to common locations
+	switch runtime.GOOS {
+	case "windows":
+		return filepath.Join(os.Getenv("APPDATA"), "npm")
+	default:
+		return filepath.Join(os.Getenv("HOME"), ".npm-global", "bin")
+	}
+}
+
+func getPipBinPath() string {
+	// Try to get the pip user bin path
+	var cmd *exec.Cmd
+	if runtime.GOOS == "windows" {
+		cmd = exec.Command("python", "-m", "site", "--user-base")
+	} else {
+		cmd = exec.Command("python3", "-m", "site", "--user-base")
+	}
+
+	output, err := cmd.Output()
+	if err == nil {
+		userBase := strings.TrimSpace(string(output))
+		if userBase != "" {
+			return filepath.Join(userBase, "bin")
+		}
+	}
+
+	// Fallback to common locations
+	switch runtime.GOOS {
+	case "windows":
+		return filepath.Join(os.Getenv("APPDATA"), "Python", "Scripts")
+	default:
+		return filepath.Join(os.Getenv("HOME"), ".local", "bin")
+	}
+}
+
+func getGoBinPath() string {
+	// Try to get the GOPATH
+	gopath := os.Getenv("GOPATH")
+	if gopath == "" {
+		// Fallback to default GOPATH
+		switch runtime.GOOS {
+		case "windows":
+			gopath = filepath.Join(os.Getenv("USERPROFILE"), "go")
+		default:
+			gopath = filepath.Join(os.Getenv("HOME"), "go")
+		}
+	}
+
+	return filepath.Join(gopath, "bin")
+}
+
+func getCargoInstallPath() string {
+	// Try to get the Cargo install path
+	cargoHome := os.Getenv("CARGO_HOME")
+	if cargoHome == "" {
+		// Fallback to default Cargo home
+		switch runtime.GOOS {
+		case "windows":
+			cargoHome = filepath.Join(os.Getenv("USERPROFILE"), ".cargo")
+		default:
+			cargoHome = filepath.Join(os.Getenv("HOME"), ".cargo")
+		}
+	}
+
+	return filepath.Join(cargoHome, "bin")
+}

internal/lsp/setup/install.go 🔗

@@ -0,0 +1,317 @@
+package setup
+
+import (
+	"context"
+	"fmt"
+	"io"
+	"os/exec"
+	"runtime"
+	"strings"
+
+	"github.com/opencode-ai/opencode/internal/logging"
+)
+
+// InstallationResult represents the result of an LSP server installation
+type InstallationResult struct {
+	ServerName string
+	Success    bool
+	Error      error
+	Output     string
+}
+
+// InstallLSPServer installs an LSP server for the given language
+func InstallLSPServer(ctx context.Context, server LSPServerInfo) InstallationResult {
+	result := InstallationResult{
+		ServerName: server.Name,
+		Success:    false,
+	}
+
+	// Check if the server is already installed
+	if _, err := exec.LookPath(server.Command); err == nil {
+		result.Success = true
+		result.Output = fmt.Sprintf("%s is already installed", server.Name)
+		return result
+	}
+
+	// Parse the installation command
+	installCmd, installArgs := parseInstallCommand(server.InstallCmd)
+
+	// If the installation command is a URL or instructions, return with error
+	if strings.HasPrefix(installCmd, "http") || strings.Contains(installCmd, "Manual installation") {
+		result.Error = fmt.Errorf("manual installation required: %s", server.InstallCmd)
+		result.Output = server.InstallCmd
+		return result
+	}
+
+	// Execute the installation command
+	cmd := exec.CommandContext(ctx, installCmd, installArgs...)
+
+	// Set up pipes for stdout and stderr
+	stdout, err := cmd.StdoutPipe()
+	if err != nil {
+		result.Error = fmt.Errorf("failed to create stdout pipe: %w", err)
+		return result
+	}
+
+	stderr, err := cmd.StderrPipe()
+	if err != nil {
+		result.Error = fmt.Errorf("failed to create stderr pipe: %w", err)
+		return result
+	}
+
+	// Start the command
+	if err := cmd.Start(); err != nil {
+		result.Error = fmt.Errorf("failed to start installation: %w", err)
+		return result
+	}
+
+	// Read output
+	stdoutBytes, _ := io.ReadAll(stdout)
+	stderrBytes, _ := io.ReadAll(stderr)
+
+	// Wait for the command to finish
+	if err := cmd.Wait(); err != nil {
+		result.Error = fmt.Errorf("installation failed: %w", err)
+		result.Output = fmt.Sprintf("stdout: %s\nstderr: %s", string(stdoutBytes), string(stderrBytes))
+		return result
+	}
+
+	// Check if the server is now installed
+	if _, err := exec.LookPath(server.Command); err != nil {
+		result.Error = fmt.Errorf("installation completed but server not found in PATH")
+		result.Output = fmt.Sprintf("stdout: %s\nstderr: %s", string(stdoutBytes), string(stderrBytes))
+		return result
+	}
+
+	result.Success = true
+	result.Output = fmt.Sprintf("Successfully installed %s\nstdout: %s\nstderr: %s",
+		server.Name, string(stdoutBytes), string(stderrBytes))
+
+	return result
+}
+
+// parseInstallCommand parses an installation command string into command and arguments
+func parseInstallCommand(installCmd string) (string, []string) {
+	parts := strings.Fields(installCmd)
+	if len(parts) == 0 {
+		return "", nil
+	}
+
+	return parts[0], parts[1:]
+}
+
+// GetInstallationCommands returns the installation commands for the given servers
+func GetInstallationCommands(servers LSPServerMap) map[string]string {
+	commands := make(map[string]string)
+
+	for _, serverList := range servers {
+		for _, server := range serverList {
+			if server.Recommended {
+				commands[server.Name] = server.InstallCmd
+			}
+		}
+	}
+
+	return commands
+}
+
+// VerifyInstallation verifies that an LSP server is correctly installed
+func VerifyInstallation(serverName string) bool {
+	_, err := exec.LookPath(serverName)
+	return err == nil
+}
+
+// GetPackageManager returns the appropriate package manager command for the current OS
+func GetPackageManager() string {
+	switch runtime.GOOS {
+	case "darwin":
+		// Check for Homebrew
+		if _, err := exec.LookPath("brew"); err == nil {
+			return "brew"
+		}
+		// Check for MacPorts
+		if _, err := exec.LookPath("port"); err == nil {
+			return "port"
+		}
+	case "linux":
+		// Check for apt (Debian/Ubuntu)
+		if _, err := exec.LookPath("apt"); err == nil {
+			return "apt"
+		}
+		// Check for dnf (Fedora)
+		if _, err := exec.LookPath("dnf"); err == nil {
+			return "dnf"
+		}
+		// Check for yum (CentOS/RHEL)
+		if _, err := exec.LookPath("yum"); err == nil {
+			return "yum"
+		}
+		// Check for pacman (Arch)
+		if _, err := exec.LookPath("pacman"); err == nil {
+			return "pacman"
+		}
+		// Check for zypper (openSUSE)
+		if _, err := exec.LookPath("zypper"); err == nil {
+			return "zypper"
+		}
+	case "windows":
+		// Check for Chocolatey
+		if _, err := exec.LookPath("choco"); err == nil {
+			return "choco"
+		}
+		// Check for Scoop
+		if _, err := exec.LookPath("scoop"); err == nil {
+			return "scoop"
+		}
+	}
+
+	return ""
+}
+
+// GetSystemInstallCommand returns the system-specific installation command for a package
+func GetSystemInstallCommand(packageName string) string {
+	packageManager := GetPackageManager()
+
+	switch packageManager {
+	case "brew":
+		return fmt.Sprintf("brew install %s", packageName)
+	case "port":
+		return fmt.Sprintf("sudo port install %s", packageName)
+	case "apt":
+		return fmt.Sprintf("sudo apt install -y %s", packageName)
+	case "dnf":
+		return fmt.Sprintf("sudo dnf install -y %s", packageName)
+	case "yum":
+		return fmt.Sprintf("sudo yum install -y %s", packageName)
+	case "pacman":
+		return fmt.Sprintf("sudo pacman -S --noconfirm %s", packageName)
+	case "zypper":
+		return fmt.Sprintf("sudo zypper install -y %s", packageName)
+	case "choco":
+		return fmt.Sprintf("choco install -y %s", packageName)
+	case "scoop":
+		return fmt.Sprintf("scoop install %s", packageName)
+	}
+
+	return ""
+}
+
+// InstallDependencies installs common dependencies for LSP servers
+func InstallDependencies(ctx context.Context) []InstallationResult {
+	results := []InstallationResult{}
+
+	// Check for Node.js and npm
+	if _, err := exec.LookPath("node"); err != nil {
+		// Node.js is not installed, try to install it
+		cmd := GetSystemInstallCommand("nodejs")
+		if cmd == "" {
+			results = append(results, InstallationResult{
+				ServerName: "nodejs",
+				Success:    false,
+				Error:      fmt.Errorf("Node.js is not installed and could not determine how to install it"),
+				Output:     "Please install Node.js manually: https://nodejs.org/",
+			})
+		} else {
+			// Execute the installation command
+			installCmd, installArgs := parseInstallCommand(cmd)
+			execCmd := exec.CommandContext(ctx, installCmd, installArgs...)
+
+			output, err := execCmd.CombinedOutput()
+			if err != nil {
+				results = append(results, InstallationResult{
+					ServerName: "nodejs",
+					Success:    false,
+					Error:      fmt.Errorf("failed to install Node.js: %w", err),
+					Output:     string(output),
+				})
+			} else {
+				results = append(results, InstallationResult{
+					ServerName: "nodejs",
+					Success:    true,
+					Output:     string(output),
+				})
+			}
+		}
+	}
+
+	// Check for Python and pip
+	pythonCmd := "python3"
+	if runtime.GOOS == "windows" {
+		pythonCmd = "python"
+	}
+
+	if _, err := exec.LookPath(pythonCmd); err != nil {
+		// Python is not installed, try to install it
+		cmd := GetSystemInstallCommand("python3")
+		if cmd == "" {
+			results = append(results, InstallationResult{
+				ServerName: "python",
+				Success:    false,
+				Error:      fmt.Errorf("python is not installed and could not determine how to install it"),
+				Output:     "Please install Python manually: https://www.python.org/",
+			})
+		} else {
+			// Execute the installation command
+			installCmd, installArgs := parseInstallCommand(cmd)
+			execCmd := exec.CommandContext(ctx, installCmd, installArgs...)
+
+			output, err := execCmd.CombinedOutput()
+			if err != nil {
+				results = append(results, InstallationResult{
+					ServerName: "python",
+					Success:    false,
+					Error:      fmt.Errorf("failed to install Python: %w", err),
+					Output:     string(output),
+				})
+			} else {
+				results = append(results, InstallationResult{
+					ServerName: "python",
+					Success:    true,
+					Output:     string(output),
+				})
+			}
+		}
+	}
+
+	// Check for Go
+	if _, err := exec.LookPath("go"); err != nil {
+		// Go is not installed, try to install it
+		cmd := GetSystemInstallCommand("golang")
+		if cmd == "" {
+			results = append(results, InstallationResult{
+				ServerName: "go",
+				Success:    false,
+				Error:      fmt.Errorf("go is not installed and could not determine how to install it"),
+				Output:     "Please install Go manually: https://golang.org/",
+			})
+		} else {
+			// Execute the installation command
+			installCmd, installArgs := parseInstallCommand(cmd)
+			execCmd := exec.CommandContext(ctx, installCmd, installArgs...)
+
+			output, err := execCmd.CombinedOutput()
+			if err != nil {
+				results = append(results, InstallationResult{
+					ServerName: "go",
+					Success:    false,
+					Error:      fmt.Errorf("failed to install Go: %w", err),
+					Output:     string(output),
+				})
+			} else {
+				results = append(results, InstallationResult{
+					ServerName: "go",
+					Success:    true,
+					Output:     string(output),
+				})
+			}
+		}
+	}
+
+	return results
+}
+
+// UpdateLSPConfig updates the LSP configuration in the config file
+func UpdateLSPConfig(servers LSPServerMap) error {
+	logging.Info("Updating LSP configuration with", len(servers), "servers")
+	return nil
+}

internal/tui/components/dialog/commands.go 🔗

@@ -168,7 +168,7 @@ func (c *commandDialogCmp) SetCommands(commands []Command) {
 
 // NewCommandDialogCmp creates a new command selection dialog
 func NewCommandDialogCmp() CommandDialog {
-	listView := utilComponents.NewSimpleList[Command](
+	listView := utilComponents.NewSimpleList(
 		[]Command{},
 		10,
 		"No commands available",

internal/tui/components/dialog/lsp_setup.go 🔗

@@ -0,0 +1,944 @@
+package dialog
+
+import (
+	"context"
+	"fmt"
+	"os/exec"
+	"sort"
+	"strings"
+
+	"github.com/charmbracelet/bubbles/key"
+	"github.com/charmbracelet/bubbles/spinner"
+	tea "github.com/charmbracelet/bubbletea"
+	"github.com/charmbracelet/lipgloss"
+
+	"github.com/opencode-ai/opencode/internal/config"
+	"github.com/opencode-ai/opencode/internal/lsp/protocol"
+	"github.com/opencode-ai/opencode/internal/lsp/setup"
+	utilComponents "github.com/opencode-ai/opencode/internal/tui/components/util"
+	"github.com/opencode-ai/opencode/internal/tui/styles"
+	"github.com/opencode-ai/opencode/internal/tui/theme"
+	"github.com/opencode-ai/opencode/internal/tui/util"
+)
+
+// LSPSetupStep represents the current step in the LSP setup wizard
+type LSPSetupStep int
+
+const (
+	StepIntroduction LSPSetupStep = iota
+	StepLanguageSelection
+	StepConfirmation
+	StepInstallation
+)
+
+// LSPSetupWizard is a component that guides users through LSP setup
+type LSPSetupWizard struct {
+	ctx            context.Context
+	step           LSPSetupStep
+	width, height  int
+	languages      map[protocol.LanguageKind]int
+	selectedLangs  map[protocol.LanguageKind]bool
+	availableLSPs  setup.LSPServerMap
+	selectedLSPs   map[protocol.LanguageKind]setup.LSPServerInfo
+	installResults map[protocol.LanguageKind]setup.InstallationResult
+	isMonorepo     bool
+	projectDirs    []string
+	langList       utilComponents.SimpleList[LSPItem]
+	serverList     utilComponents.SimpleList[LSPItem]
+	spinner        spinner.Model
+	installing     bool
+	currentInstall string
+	installOutput  []string // Store installation output
+	keys           lspSetupKeyMap
+	error          string
+	program        *tea.Program
+}
+
+// LSPItem represents an item in the language or server list
+type LSPItem struct {
+	title       string
+	description string
+	selected    bool
+	language    protocol.LanguageKind
+	server      setup.LSPServerInfo
+}
+
+// Render implements SimpleListItem interface
+func (i LSPItem) Render(selected bool, width int) string {
+	t := theme.CurrentTheme()
+	baseStyle := styles.BaseStyle()
+
+	descStyle := baseStyle.Width(width).Foreground(t.TextMuted())
+	itemStyle := baseStyle.Width(width).
+		Foreground(t.Text()).
+		Background(t.Background())
+
+	if selected {
+		itemStyle = itemStyle.
+			Background(t.Primary()).
+			Foreground(t.Background()).
+			Bold(true)
+		descStyle = descStyle.
+			Background(t.Primary()).
+			Foreground(t.Background())
+	}
+
+	title := i.title
+	if i.selected {
+		title = "[x] " + i.title
+	} else {
+		title = "[ ] " + i.title
+	}
+
+	titleStr := itemStyle.Padding(0, 1).Render(title)
+	if i.description != "" {
+		description := descStyle.Padding(0, 1).Render(i.description)
+		return lipgloss.JoinVertical(lipgloss.Left, titleStr, description)
+	}
+	return titleStr
+}
+
+// NewLSPSetupWizard creates a new LSPSetupWizard
+func NewLSPSetupWizard(ctx context.Context) *LSPSetupWizard {
+	s := spinner.New()
+	s.Spinner = spinner.Dot
+	s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("205"))
+
+	return &LSPSetupWizard{
+		ctx:            ctx,
+		step:           StepIntroduction,
+		selectedLangs:  make(map[protocol.LanguageKind]bool),
+		selectedLSPs:   make(map[protocol.LanguageKind]setup.LSPServerInfo),
+		installResults: make(map[protocol.LanguageKind]setup.InstallationResult),
+		installOutput:  make([]string, 0, 10), // Initialize with capacity for 10 lines
+		spinner:        s,
+		keys:           DefaultLSPSetupKeyMap(),
+	}
+}
+
+type lspSetupKeyMap struct {
+	Up     key.Binding
+	Down   key.Binding
+	Select key.Binding
+	Next   key.Binding
+	Back   key.Binding
+	Quit   key.Binding
+}
+
+// DefaultLSPSetupKeyMap returns the default key bindings for the LSP setup wizard
+func DefaultLSPSetupKeyMap() lspSetupKeyMap {
+	return lspSetupKeyMap{
+		Up: key.NewBinding(
+			key.WithKeys("up", "k"),
+			key.WithHelp("↑/k", "up"),
+		),
+		Down: key.NewBinding(
+			key.WithKeys("down", "j"),
+			key.WithHelp("↓/j", "down"),
+		),
+		Select: key.NewBinding(
+			key.WithKeys("space"),
+			key.WithHelp("space", "select"),
+		),
+		Next: key.NewBinding(
+			key.WithKeys("enter"),
+			key.WithHelp("enter", "next"),
+		),
+		Back: key.NewBinding(
+			key.WithKeys("esc"),
+			key.WithHelp("esc", "back/quit"),
+		),
+		Quit: key.NewBinding(
+			key.WithKeys("ctrl+c", "q"),
+			key.WithHelp("ctrl+c/q", "quit"),
+		),
+	}
+}
+
+// ShortHelp implements key.Map
+func (k lspSetupKeyMap) ShortHelp() []key.Binding {
+	return []key.Binding{
+		k.Up,
+		k.Down,
+		k.Select,
+		k.Next,
+		k.Back,
+	}
+}
+
+// FullHelp implements key.Map
+func (k lspSetupKeyMap) FullHelp() [][]key.Binding {
+	return [][]key.Binding{k.ShortHelp()}
+}
+
+// Init implements tea.Model
+func (m *LSPSetupWizard) Init() tea.Cmd {
+	return tea.Batch(
+		m.spinner.Tick,
+		m.detectLanguages,
+	)
+}
+
+// detectLanguages is a command that detects languages in the workspace
+func (m *LSPSetupWizard) detectLanguages() tea.Msg {
+	languages, err := setup.DetectProjectLanguages(config.WorkingDirectory())
+	if err != nil {
+		return lspSetupErrorMsg{err: err}
+	}
+
+	isMonorepo, projectDirs := setup.DetectMonorepo(config.WorkingDirectory())
+
+	primaryLangs := setup.GetPrimaryLanguages(languages, 10)
+
+	availableLSPs := setup.DiscoverInstalledLSPs()
+
+	recommendedLSPs := setup.GetRecommendedLSPServers(primaryLangs)
+	for lang, servers := range recommendedLSPs {
+		if _, ok := availableLSPs[lang]; !ok {
+			availableLSPs[lang] = servers
+		}
+	}
+
+	return lspSetupDetectMsg{
+		languages:     languages,
+		primaryLangs:  primaryLangs,
+		availableLSPs: availableLSPs,
+		isMonorepo:    isMonorepo,
+		projectDirs:   projectDirs,
+	}
+}
+
+// Update implements tea.Model
+func (m *LSPSetupWizard) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+	var cmds []tea.Cmd
+
+	// Handle space key directly for language selection
+	if keyMsg, ok := msg.(tea.KeyMsg); ok && keyMsg.String() == " " && m.step == StepLanguageSelection {
+		item, idx := m.langList.GetSelectedItem()
+		if idx != -1 {
+			m.selectedLangs[item.language] = !m.selectedLangs[item.language]
+			return m, m.updateLanguageSelection()
+		}
+	}
+
+	switch msg := msg.(type) {
+	case tea.KeyMsg:
+		switch {
+		case key.Matches(msg, m.keys.Quit):
+			return m, util.CmdHandler(CloseLSPSetupMsg{Configure: false})
+		case key.Matches(msg, m.keys.Back):
+			if m.step > StepIntroduction {
+				m.step--
+				return m, nil
+			}
+			return m, util.CmdHandler(CloseLSPSetupMsg{Configure: false})
+		case key.Matches(msg, m.keys.Next):
+			return m.handleEnter()
+		}
+
+	case tea.WindowSizeMsg:
+		m.width = msg.Width
+		m.height = msg.Height
+
+		// Update list dimensions
+		if m.langList != nil {
+			m.langList.SetMaxWidth(min(80, m.width-10))
+		}
+		if m.serverList != nil {
+			m.serverList.SetMaxWidth(min(80, m.width-10))
+		}
+
+	case spinner.TickMsg:
+		var cmd tea.Cmd
+		m.spinner, cmd = m.spinner.Update(msg)
+		if m.installing {
+			// Only continue ticking if we're still installing
+			cmds = append(cmds, cmd)
+		}
+
+	case lspSetupDetectMsg:
+		m.languages = msg.languages
+		m.availableLSPs = msg.availableLSPs
+		m.isMonorepo = msg.isMonorepo
+		m.projectDirs = msg.projectDirs
+
+		// Create language list items - only for languages with available servers
+		items := []LSPItem{}
+		for _, lang := range msg.primaryLangs {
+			// Check if servers are available for this language
+			hasServers := false
+			if servers, ok := m.availableLSPs[lang.Language]; ok && len(servers) > 0 {
+				hasServers = true
+			}
+
+			// Only add languages that have servers available
+			if hasServers {
+				item := LSPItem{
+					title:    string(lang.Language),
+					selected: false,
+					language: lang.Language,
+				}
+
+				items = append(items, item)
+
+				// Pre-select languages with high scores
+				if lang.Score > 10 {
+					m.selectedLangs[lang.Language] = true
+				}
+			}
+		}
+
+		// Create the language list
+		m.langList = utilComponents.NewSimpleList(items, 10, "No languages with available servers detected", true)
+
+		// Move to the next step
+		m.step = StepLanguageSelection
+
+		// Update the selection status in the list
+		return m, m.updateLanguageSelection()
+
+	case lspSetupErrorMsg:
+		m.error = msg.err.Error()
+		return m, nil
+
+	case lspSetupInstallMsg:
+		m.installResults[msg.language] = msg.result
+
+		// Add output from the installation result
+		if msg.output != "" {
+			m.addOutputLine(msg.output)
+		}
+
+		// Add success/failure message with clear formatting
+		if msg.result.Success {
+			m.addOutputLine(fmt.Sprintf("✓ Successfully installed %s for %s", msg.result.ServerName, msg.language))
+		} else {
+			m.addOutputLine(fmt.Sprintf("✗ Failed to install %s for %s", msg.result.ServerName, msg.language))
+		}
+
+		m.installing = false
+
+		if len(m.installResults) == len(m.selectedLSPs) {
+			// All installations are complete, move to the summary step
+			m.step = StepInstallation
+		} else {
+			// Continue with the next installation
+			return m, m.installNextServer()
+		}
+	}
+
+	// Handle list updates
+	if m.step == StepLanguageSelection {
+		u, cmd := m.langList.Update(msg)
+		m.langList = u.(utilComponents.SimpleList[LSPItem])
+		cmds = append(cmds, cmd)
+	}
+
+	return m, tea.Batch(cmds...)
+}
+
+// handleEnter handles the enter key press based on the current step
+func (m *LSPSetupWizard) handleEnter() (tea.Model, tea.Cmd) {
+	switch m.step {
+	case StepIntroduction: // Introduction
+		return m, m.detectLanguages
+
+	case StepLanguageSelection: // Language selection
+		// Check if any languages are selected
+		hasSelected := false
+
+		// Create a sorted list of languages for consistent ordering
+		var selectedLangs []protocol.LanguageKind
+		for lang, selected := range m.selectedLangs {
+			if selected {
+				selectedLangs = append(selectedLangs, lang)
+				hasSelected = true
+			}
+		}
+
+		// Sort languages alphabetically for consistent display
+		sort.Slice(selectedLangs, func(i, j int) bool {
+			return string(selectedLangs[i]) < string(selectedLangs[j])
+		})
+
+		// Auto-select servers for each language
+		for _, lang := range selectedLangs {
+			// Auto-select the recommended or first server for each language
+			if servers, ok := m.availableLSPs[lang]; ok && len(servers) > 0 {
+				// First try to find a recommended server
+				foundRecommended := false
+				for _, server := range servers {
+					if server.Recommended {
+						m.selectedLSPs[lang] = server
+						foundRecommended = true
+						break
+					}
+				}
+
+				// If no recommended server, use the first one
+				if !foundRecommended && len(servers) > 0 {
+					m.selectedLSPs[lang] = servers[0]
+				}
+			} else {
+				// No servers available for this language, deselect it
+				m.selectedLangs[lang] = false
+				// Update the UI to reflect this change
+				return m, m.updateLanguageSelection()
+			}
+		}
+
+		if !hasSelected {
+			// No language selected, show error
+			m.error = "Please select at least one language"
+			return m, nil
+		}
+
+		// Skip server selection and go directly to confirmation
+		m.step = StepConfirmation
+		return m, nil
+
+	case StepConfirmation: // Confirmation
+		// Start installation
+		m.step = StepInstallation
+		m.installing = true
+		// Start the spinner and begin installation
+		return m, tea.Batch(
+			m.spinner.Tick,
+			m.installNextServer(),
+		)
+
+	case StepInstallation: // Summary
+		// Save configuration and close
+		return m, util.CmdHandler(CloseLSPSetupMsg{
+			Configure: true,
+			Servers:   m.selectedLSPs,
+		})
+	}
+
+	return m, nil
+}
+
+// View implements tea.Model
+func (m *LSPSetupWizard) View() string {
+	t := theme.CurrentTheme()
+	baseStyle := styles.BaseStyle()
+
+	// Calculate width needed for content
+	maxWidth := min(80, m.width-10)
+
+	title := baseStyle.
+		Foreground(t.Primary()).
+		Bold(true).
+		Width(maxWidth).
+		Padding(0, 1).
+		Render("LSP Setup Wizard")
+
+	var content string
+
+	switch m.step {
+	case StepIntroduction: // Introduction
+		content = m.renderIntroduction(baseStyle, t, maxWidth)
+	case StepLanguageSelection: // Language selection
+		content = m.renderLanguageSelection(baseStyle, t, maxWidth)
+	case StepConfirmation: // Confirmation
+		content = m.renderConfirmation(baseStyle, t, maxWidth)
+	case StepInstallation: // Installation/Summary
+		content = m.renderInstallation(baseStyle, t, maxWidth)
+	}
+
+	// Add error message if any
+	if m.error != "" {
+		errorMsg := baseStyle.
+			Foreground(t.Error()).
+			Width(maxWidth).
+			Padding(1, 1).
+			Render("Error: " + m.error)
+
+		content = lipgloss.JoinVertical(
+			lipgloss.Left,
+			content,
+			errorMsg,
+		)
+	}
+
+	// Add help text
+	helpText := baseStyle.
+		Foreground(t.TextMuted()).
+		Width(maxWidth).
+		Padding(1, 1).
+		Render(m.getHelpText())
+
+	fullContent := lipgloss.JoinVertical(
+		lipgloss.Left,
+		title,
+		baseStyle.Width(maxWidth).Render(""),
+		content,
+		helpText,
+	)
+
+	return baseStyle.Padding(1, 2).
+		Border(lipgloss.RoundedBorder()).
+		BorderBackground(t.Background()).
+		BorderForeground(t.BorderNormal()).
+		Width(lipgloss.Width(fullContent) + 4).
+		Render(fullContent)
+}
+
+// renderIntroduction renders the introduction step
+func (m *LSPSetupWizard) renderIntroduction(baseStyle lipgloss.Style, t theme.Theme, maxWidth int) string {
+	explanation := baseStyle.
+		Foreground(t.Text()).
+		Width(maxWidth).
+		Padding(0, 1).
+		Render("OpenCode can automatically configure Language Server Protocol (LSP) integration for your project. LSP provides code intelligence features like error checking, diagnostics, and more.")
+
+	if m.languages == nil {
+		// Show spinner while detecting languages
+		spinner := baseStyle.
+			Foreground(t.Primary()).
+			Width(maxWidth).
+			Padding(1, 1).
+			Render(m.spinner.View() + " Detecting languages in your project...")
+
+		return lipgloss.JoinVertical(
+			lipgloss.Left,
+			explanation,
+			spinner,
+		)
+	}
+
+	nextPrompt := baseStyle.
+		Foreground(t.Primary()).
+		Width(maxWidth).
+		Padding(1, 1).
+		Render("Press Enter to continue")
+
+	return lipgloss.JoinVertical(
+		lipgloss.Left,
+		explanation,
+		nextPrompt,
+	)
+}
+
+// renderLanguageSelection renders the language selection step
+func (m *LSPSetupWizard) renderLanguageSelection(baseStyle lipgloss.Style, t theme.Theme, maxWidth int) string {
+	explanation := baseStyle.
+		Foreground(t.Text()).
+		Width(maxWidth).
+		Padding(0, 1).
+		Render("Select the languages you want to configure LSP for. Only languages with available servers are shown. Use Space to toggle selection, Enter to continue.")
+
+	// Show monorepo info if detected
+	monorepoInfo := ""
+	if m.isMonorepo {
+		monorepoInfo = baseStyle.
+			Foreground(t.TextMuted()).
+			Width(maxWidth).
+			Padding(0, 1).
+			Render(fmt.Sprintf("Monorepo detected with %d projects", len(m.projectDirs)))
+	}
+
+	// Set max width for the list
+	m.langList.SetMaxWidth(maxWidth)
+
+	// Render the language list
+	listView := m.langList.View()
+
+	return lipgloss.JoinVertical(
+		lipgloss.Left,
+		explanation,
+		monorepoInfo,
+		listView,
+	)
+}
+
+// renderConfirmation renders the confirmation step
+func (m *LSPSetupWizard) renderConfirmation(baseStyle lipgloss.Style, t theme.Theme, maxWidth int) string {
+	explanation := baseStyle.
+		Foreground(t.Text()).
+		Width(maxWidth).
+		Padding(0, 1).
+		Render("Review your LSP configuration. Press Enter to install missing servers and save the configuration.")
+
+	// Get languages in a sorted order for consistent display
+	var languages []protocol.LanguageKind
+	for lang := range m.selectedLSPs {
+		languages = append(languages, lang)
+	}
+
+	// Sort languages alphabetically
+	sort.Slice(languages, func(i, j int) bool {
+		return string(languages[i]) < string(languages[j])
+	})
+
+	// Build the configuration summary
+	var configLines []string
+	for _, lang := range languages {
+		server := m.selectedLSPs[lang]
+		configLines = append(configLines, fmt.Sprintf("%s: %s", lang, server.Name))
+	}
+
+	configSummary := baseStyle.
+		Foreground(t.Text()).
+		Width(maxWidth).
+		Padding(1, 1).
+		Render(strings.Join(configLines, "\n"))
+
+	return lipgloss.JoinVertical(
+		lipgloss.Left,
+		explanation,
+		configSummary,
+	)
+}
+
+// renderInstallation renders the installation/summary step
+func (m *LSPSetupWizard) renderInstallation(baseStyle lipgloss.Style, t theme.Theme, maxWidth int) string {
+	if m.installing {
+		// Show installation progress with proper styling
+		spinnerStyle := baseStyle.
+			Foreground(t.Primary()).
+			Background(t.Background()).
+			Width(maxWidth).
+			Padding(1, 1)
+
+		spinnerText := m.spinner.View() + " Installing " + m.currentInstall + "..."
+
+		// Show output if available
+		var content string
+		if len(m.installOutput) > 0 {
+			outputStyle := baseStyle.
+				Foreground(t.TextMuted()).
+				Background(t.Background()).
+				Width(maxWidth).
+				Padding(1, 1)
+
+			outputText := strings.Join(m.installOutput, "\n")
+			outputContent := outputStyle.Render(outputText)
+
+			content = lipgloss.JoinVertical(
+				lipgloss.Left,
+				spinnerStyle.Render(spinnerText),
+				outputContent,
+			)
+		} else {
+			content = spinnerStyle.Render(spinnerText)
+		}
+
+		return content
+	}
+
+	// Show installation results
+	explanation := baseStyle.
+		Foreground(t.Text()).
+		Width(maxWidth).
+		Padding(0, 1).
+		Render("LSP server installation complete. Press Enter to save the configuration and exit.")
+
+	// Build the installation summary
+	var resultLines []string
+	for lang, result := range m.installResults {
+		status := "✓"
+		statusColor := t.Success()
+		if !result.Success {
+			status = "✗"
+			statusColor = t.Error()
+		}
+
+		line := fmt.Sprintf("%s %s: %s",
+			baseStyle.Foreground(statusColor).Render(status),
+			lang,
+			result.ServerName)
+
+		resultLines = append(resultLines, line)
+	}
+
+	// Style the result summary with a header
+	resultHeader := baseStyle.
+		Foreground(t.Primary()).
+		Bold(true).
+		Width(maxWidth).
+		Padding(1, 1).
+		Render("Installation Results:")
+
+	resultSummary := baseStyle.
+		Width(maxWidth).
+		Padding(0, 2). // Indent the results
+		Render(strings.Join(resultLines, "\n"))
+
+	// Show output if available
+	var content string
+	if len(m.installOutput) > 0 {
+		// Create a header for the output section
+		outputHeader := baseStyle.
+			Foreground(t.Primary()).
+			Bold(true).
+			Width(maxWidth).
+			Padding(1, 1).
+			Render("Installation Output:")
+
+		// Style the output
+		outputStyle := baseStyle.
+			Foreground(t.TextMuted()).
+			Background(t.Background()).
+			Width(maxWidth).
+			Padding(0, 2) // Indent the output
+
+		outputText := strings.Join(m.installOutput, "\n")
+		outputContent := outputStyle.Render(outputText)
+
+		content = lipgloss.JoinVertical(
+			lipgloss.Left,
+			explanation,
+			baseStyle.Render(""), // Add a blank line for spacing
+			resultHeader,
+			resultSummary,
+			baseStyle.Render(""), // Add a blank line for spacing
+			outputHeader,
+			outputContent,
+		)
+	} else {
+		content = lipgloss.JoinVertical(
+			lipgloss.Left,
+			explanation,
+			baseStyle.Render(""), // Add a blank line for spacing
+			resultHeader,
+			resultSummary,
+		)
+	}
+
+	return content
+}
+
+// getHelpText returns the help text for the current step
+func (m *LSPSetupWizard) getHelpText() string {
+	switch m.step {
+	case StepIntroduction:
+		return "Enter: Continue • Esc: Quit"
+	case StepLanguageSelection:
+		return "↑/↓: Navigate • Space: Toggle selection • Enter: Continue • Esc: Quit"
+	case StepConfirmation:
+		return "Enter: Install and configure • Esc: Back"
+	case StepInstallation:
+		if m.installing {
+			return "Installing LSP servers..."
+		}
+		return "Enter: Save and exit • Esc: Back"
+	}
+
+	return ""
+}
+
+// updateLanguageSelection updates the selection status in the language list
+func (m *LSPSetupWizard) updateLanguageSelection() tea.Cmd {
+	return func() tea.Msg {
+		items := m.langList.GetItems()
+		updatedItems := make([]LSPItem, 0, len(items))
+
+		for _, item := range items {
+			// Only update the selected state, preserve the item otherwise
+			newItem := item
+			newItem.selected = m.selectedLangs[item.language]
+			updatedItems = append(updatedItems, newItem)
+		}
+
+		// Set the items - the selected index will be preserved by the SimpleList implementation
+		m.langList.SetItems(updatedItems)
+
+		return nil
+	}
+}
+
+// createServerListForLanguage creates the server list for a specific language
+func (m *LSPSetupWizard) createServerListForLanguage(lang protocol.LanguageKind) tea.Cmd {
+	return func() tea.Msg {
+		items := []LSPItem{}
+
+		if servers, ok := m.availableLSPs[lang]; ok {
+			for _, server := range servers {
+				description := server.Description
+				if server.Recommended {
+					description += " (Recommended)"
+				}
+
+				items = append(items, LSPItem{
+					title:       server.Name,
+					description: description,
+					language:    lang,
+					server:      server,
+				})
+			}
+		}
+
+		// If no servers available, add a placeholder
+		if len(items) == 0 {
+			items = append(items, LSPItem{
+				title:       "No LSP servers available for " + string(lang),
+				description: "You'll need to install a server manually",
+				language:    lang,
+			})
+		}
+
+		// Create the server list
+		m.serverList = utilComponents.NewSimpleList(items, 10, "No servers available", true)
+
+		// Move to the server selection step
+		m.step = 2
+
+		return nil
+	}
+}
+
+// getCurrentLanguage returns the language currently being configured
+func (m *LSPSetupWizard) getCurrentLanguage() protocol.LanguageKind {
+	items := m.serverList.GetItems()
+	if len(items) == 0 {
+		return ""
+	}
+	return items[0].language
+}
+
+// getNextLanguage returns the next language to configure after the current one
+func (m *LSPSetupWizard) getNextLanguage(currentLang protocol.LanguageKind) protocol.LanguageKind {
+	foundCurrent := false
+
+	for lang := range m.selectedLangs {
+		if m.selectedLangs[lang] {
+			if foundCurrent {
+				return lang
+			}
+
+			if lang == currentLang {
+				foundCurrent = true
+			}
+		}
+	}
+
+	return ""
+}
+
+// installNextServer installs the next server in the queue
+func (m *LSPSetupWizard) installNextServer() tea.Cmd {
+	return func() tea.Msg {
+		for lang, server := range m.selectedLSPs {
+			if _, ok := m.installResults[lang]; !ok {
+				if _, err := exec.LookPath(server.Command); err == nil {
+					// Server is already installed
+					output := fmt.Sprintf("%s is already installed", server.Name)
+					m.installResults[lang] = setup.InstallationResult{
+						ServerName: server.Name,
+						Success:    true,
+						Output:     output,
+					}
+
+					// Add output line
+					m.addOutputLine(output)
+
+					// Continue with next server immediately
+					return m.installNextServer()()
+				}
+
+				// Install this server
+				m.installing = true
+				m.currentInstall = fmt.Sprintf("%s for %s", server.Name, lang)
+
+				// Add initial output line
+				m.addOutputLine(fmt.Sprintf("Installing %s for %s...", server.Name, lang))
+
+				// Create a channel to receive the installation result
+				resultCh := make(chan setup.InstallationResult)
+
+				go func(l protocol.LanguageKind, s setup.LSPServerInfo) {
+					result := setup.InstallLSPServer(m.ctx, s)
+					resultCh <- result
+				}(lang, server)
+
+				// Return a command that will wait for the installation to complete
+				// and also keep the spinner updating
+				return tea.Batch(
+					m.spinner.Tick,
+					func() tea.Msg {
+						result := <-resultCh
+						return lspSetupInstallMsg{
+							language: lang,
+							result:   result,
+							output:   result.Output,
+						}
+					},
+				)
+			}
+		}
+
+		// All servers have been installed
+		m.installing = false
+		m.step = StepInstallation
+		return nil
+	}
+}
+
+// SetSize sets the size of the component
+func (m *LSPSetupWizard) SetSize(width, height int) {
+	m.width = width
+	m.height = height
+
+	// Update list max width if lists are initialized
+	if m.langList != nil {
+		m.langList.SetMaxWidth(min(80, width-10))
+	}
+	if m.serverList != nil {
+		m.serverList.SetMaxWidth(min(80, width-10))
+	}
+}
+
+// addOutputLine adds a line to the installation output, keeping only the last 10 lines
+func (m *LSPSetupWizard) addOutputLine(line string) {
+	// Split the line into multiple lines if it contains newlines
+	lines := strings.Split(line, "\n")
+	for _, l := range lines {
+		if l == "" {
+			continue
+		}
+
+		// Add the line to the output
+		m.installOutput = append(m.installOutput, l)
+
+		// Keep only the last 10 lines
+		if len(m.installOutput) > 10 {
+			m.installOutput = m.installOutput[len(m.installOutput)-10:]
+		}
+	}
+}
+
+// Bindings implements layout.Bindings
+func (m *LSPSetupWizard) Bindings() []key.Binding {
+	return m.keys.ShortHelp()
+}
+
+// Message types for the LSP setup wizard
+type lspSetupDetectMsg struct {
+	languages     map[protocol.LanguageKind]int
+	primaryLangs  []setup.LanguageScore
+	availableLSPs setup.LSPServerMap
+	isMonorepo    bool
+	projectDirs   []string
+}
+
+type lspSetupErrorMsg struct {
+	err error
+}
+
+type lspSetupInstallMsg struct {
+	language protocol.LanguageKind
+	result   setup.InstallationResult
+	output   string // Installation output
+}
+
+// CloseLSPSetupMsg is a message that is sent when the LSP setup wizard is closed
+type CloseLSPSetupMsg struct {
+	Configure bool
+	Servers   map[protocol.LanguageKind]setup.LSPServerInfo
+}
+
+// ShowLSPSetupMsg is a message that is sent to show the LSP setup wizard
+type ShowLSPSetupMsg struct {
+	Show bool
+}

internal/tui/components/util/simple-list.go 🔗

@@ -97,7 +97,11 @@ func (c *simpleListCmp[T]) GetSelectedItem() (T, int) {
 }
 
 func (c *simpleListCmp[T]) SetItems(items []T) {
-	c.selectedIdx = 0
+	// Preserve the selected index when updating items
+	// Only reset to 0 if the list is empty or the index is out of bounds
+	if len(items) == 0 || c.selectedIdx >= len(items) {
+		c.selectedIdx = 0
+	}
 	c.items = items
 }
 

internal/tui/tui.go 🔗

@@ -12,6 +12,7 @@ import (
 	"github.com/opencode-ai/opencode/internal/config"
 	"github.com/opencode-ai/opencode/internal/llm/agent"
 	"github.com/opencode-ai/opencode/internal/logging"
+	"github.com/opencode-ai/opencode/internal/lsp/protocol"
 	"github.com/opencode-ai/opencode/internal/permission"
 	"github.com/opencode-ai/opencode/internal/pubsub"
 	"github.com/opencode-ai/opencode/internal/session"
@@ -136,6 +137,9 @@ type appModel struct {
 	showMultiArgumentsDialog bool
 	multiArgumentsDialog     dialog.MultiArgumentsDialogCmp
 
+	showLSPSetupDialog bool
+	lspSetupDialog     *dialog.LSPSetupWizard
+
 	isCompacting      bool
 	compactingMessage string
 }
@@ -176,6 +180,12 @@ func (a appModel) Init() tea.Cmd {
 		return dialog.ShowInitDialogMsg{Show: shouldShow}
 	})
 
+	// Check if we should show the LSP setup dialog
+	cmds = append(cmds, func() tea.Msg {
+		shouldShow := a.app.CheckAndSetupLSP(context.Background())
+		return dialog.ShowLSPSetupMsg{Show: shouldShow}
+	})
+
 	return tea.Batch(cmds...)
 }
 
@@ -214,6 +224,15 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 
 		a.initDialog.SetSize(msg.Width, msg.Height)
 
+		if a.showLSPSetupDialog && a.lspSetupDialog != nil {
+			a.lspSetupDialog.SetSize(msg.Width, msg.Height)
+			lsp, lspCmd := a.lspSetupDialog.Update(msg)
+			if lsp, ok := lsp.(*dialog.LSPSetupWizard); ok {
+				a.lspSetupDialog = lsp
+			}
+			cmds = append(cmds, lspCmd)
+		}
+
 		if a.showMultiArgumentsDialog {
 			a.multiArgumentsDialog.SetSize(msg.Width, msg.Height)
 			args, argsCmd := a.multiArgumentsDialog.Update(msg)
@@ -370,6 +389,47 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		a.showInitDialog = msg.Show
 		return a, nil
 
+	case dialog.ShowLSPSetupMsg:
+		a.showLSPSetupDialog = msg.Show
+		if a.showLSPSetupDialog {
+			// Initialize the LSP setup wizard
+			a.lspSetupDialog = dialog.NewLSPSetupWizard(context.Background())
+			a.lspSetupDialog.SetSize(a.width, a.height)
+			return a, a.lspSetupDialog.Init()
+		}
+		return a, nil
+
+	case dialog.CloseLSPSetupMsg:
+		a.showLSPSetupDialog = false
+		if msg.Configure && len(msg.Servers) > 0 {
+			// Convert setup.LSPServerInfo to config.LSPServerInfo
+			configServers := make(map[protocol.LanguageKind]config.LSPServerInfo)
+			for lang, server := range msg.Servers {
+				configServers[lang] = config.LSPServerInfo{
+					Name:        server.Name,
+					Command:     server.Command,
+					Args:        server.Args,
+					InstallCmd:  server.InstallCmd,
+					Description: server.Description,
+					Recommended: server.Recommended,
+					Options:     server.Options,
+				}
+			}
+
+			// Update the LSP configuration
+			err := config.UpdateLSPConfig(configServers)
+			if err != nil {
+				logging.Error("Failed to update LSP configuration", "error", err)
+				return a, util.ReportError(err)
+			}
+
+			// Restart LSP clients
+			go a.app.InitLSPClients(context.Background())
+
+			return a, util.ReportInfo("LSP configuration updated successfully")
+		}
+		return a, nil
+
 	case dialog.CloseInitDialogMsg:
 		a.showInitDialog = false
 		if msg.Initialize {
@@ -427,7 +487,7 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		// If submitted, replace all named arguments and run the command
 		if msg.Submit {
 			content := msg.Content
-			
+
 			// Replace each named argument with its value
 			for name, value := range msg.Args {
 				placeholder := "$" + name
@@ -443,6 +503,15 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		return a, nil
 
 	case tea.KeyMsg:
+		// If LSP setup dialog is open, let it handle the key press
+		if a.showLSPSetupDialog && a.lspSetupDialog != nil {
+			lsp, cmd := a.lspSetupDialog.Update(msg)
+			if lsp, ok := lsp.(*dialog.LSPSetupWizard); ok {
+				a.lspSetupDialog = lsp
+			}
+			return a, cmd
+		}
+
 		// If multi-arguments dialog is open, let it handle the key press first
 		if a.showMultiArgumentsDialog {
 			args, cmd := a.multiArgumentsDialog.Update(msg)
@@ -473,6 +542,9 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			if a.showMultiArgumentsDialog {
 				a.showMultiArgumentsDialog = false
 			}
+			if a.showLSPSetupDialog {
+				a.showLSPSetupDialog = false
+			}
 			return a, nil
 		case key.Matches(msg, keys.SwitchSession):
 			if a.currentPage == page.ChatPage && !a.showQuit && !a.showPermissions && !a.showCommandDialog {
@@ -656,6 +728,12 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		}
 	}
 
+	if a.showLSPSetupDialog {
+		d, lspCmd := a.lspSetupDialog.Update(msg)
+		a.lspSetupDialog = d.(*dialog.LSPSetupWizard)
+		cmds = append(cmds, lspCmd)
+	}
+
 	s, _ := a.status.Update(msg)
 	a.status = s.(core.StatusCmp)
 	a.pages[a.currentPage], cmd = a.pages[a.currentPage].Update(msg)
@@ -668,15 +746,6 @@ func (a *appModel) RegisterCommand(cmd dialog.Command) {
 	a.commands = append(a.commands, cmd)
 }
 
-func (a *appModel) findCommand(id string) (dialog.Command, bool) {
-	for _, cmd := range a.commands {
-		if cmd.ID == id {
-			return cmd, true
-		}
-	}
-	return dialog.Command{}, false
-}
-
 func (a *appModel) moveToPage(pageID page.PageID) tea.Cmd {
 	if a.app.CoderAgent.IsBusy() {
 		// For now we don't move to any page if the agent is busy
@@ -865,6 +934,17 @@ func (a appModel) View() string {
 		)
 	}
 
+	if a.showLSPSetupDialog && a.lspSetupDialog != nil {
+		overlay := a.lspSetupDialog.View()
+		appView = layout.PlaceOverlay(
+			a.width/2-lipgloss.Width(overlay)/2,
+			a.height/2-lipgloss.Height(overlay)/2,
+			overlay,
+			appView,
+			true,
+		)
+	}
+
 	if a.showThemeDialog {
 		overlay := a.themeDialog.View()
 		row := lipgloss.Height(appView) / 2
@@ -951,6 +1031,17 @@ If there are Cursor rules (in .cursor/rules/ or .cursorrules) or Copilot rules (
 			}
 		},
 	})
+
+	model.RegisterCommand(dialog.Command{
+		ID:          "setup-lsp",
+		Title:       "Setup LSP",
+		Description: "Configure Language Server Protocol integration",
+		Handler: func(cmd dialog.Command) tea.Cmd {
+			return func() tea.Msg {
+				return dialog.ShowLSPSetupMsg{Show: true}
+			}
+		},
+	})
 	// Load custom commands
 	customCommands, err := dialog.LoadCustomCommands()
 	if err != nil {