adamdottv 9 месяцев назад
Родитель
Сommit
d08e58279d

+ 7 - 0
cmd/root.go

@@ -14,6 +14,7 @@ import (
 	"github.com/opencode-ai/opencode/internal/db"
 	"github.com/opencode-ai/opencode/internal/llm/agent"
 	"github.com/opencode-ai/opencode/internal/logging"
+	"github.com/opencode-ai/opencode/internal/lsp/discovery"
 	"github.com/opencode-ai/opencode/internal/pubsub"
 	"github.com/opencode-ai/opencode/internal/tui"
 	"github.com/opencode-ai/opencode/internal/version"
@@ -58,6 +59,12 @@ to assist developers in writing, debugging, and understanding code directly from
 			return err
 		}
 
+		// Run LSP auto-discovery
+		if err := discovery.IntegrateLSPServers(cwd); err != nil {
+			logging.Warn("Failed to auto-discover LSP servers", "error", err)
+			// Continue anyway, this is not a fatal error
+		}
+
 		// Connect DB, this will also run migrations
 		conn, err := db.Connect()
 		if err != nil {

+ 1 - 0
internal/llm/agent/tools.go

@@ -35,6 +35,7 @@ func CoderAgentTools(
 			tools.NewViewTool(lspClients),
 			tools.NewPatchTool(lspClients, permissions, history),
 			tools.NewWriteTool(lspClients, permissions, history),
+			tools.NewConfigureLspServerTool(),
 			NewAgentTool(sessions, messages, lspClients),
 		}, otherTools...,
 	)

+ 49 - 0
internal/llm/tools/lsp.go

@@ -0,0 +1,49 @@
+package tools
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+
+	"github.com/opencode-ai/opencode/internal/lsp/discovery/tool"
+)
+
+// ConfigureLspServerTool is a tool for configuring LSP servers
+type ConfigureLspServerTool struct{}
+
+// NewConfigureLspServerTool creates a new ConfigureLspServerTool
+func NewConfigureLspServerTool() *ConfigureLspServerTool {
+	return &ConfigureLspServerTool{}
+}
+
+// Info returns information about the tool
+func (t *ConfigureLspServerTool) Info() ToolInfo {
+	return ToolInfo{
+		Name:        "configureLspServer",
+		Description: "Searches for an LSP server for the given language",
+		Parameters: map[string]any{
+			"language": map[string]any{
+				"type":        "string",
+				"description": "The language identifier (e.g., \"go\", \"typescript\", \"python\")",
+			},
+		},
+		Required: []string{"language"},
+	}
+}
+
+// Run executes the tool
+func (t *ConfigureLspServerTool) Run(ctx context.Context, params ToolCall) (ToolResponse, error) {
+	result, err := tool.ConfigureLspServer(ctx, json.RawMessage(params.Input))
+	if err != nil {
+		return NewTextErrorResponse(err.Error()), nil
+	}
+
+	// Convert the result to JSON
+	resultJSON, err := json.MarshalIndent(result, "", "  ")
+	if err != nil {
+		return NewTextErrorResponse(fmt.Sprintf("Failed to marshal result: %v", err)), nil
+	}
+
+	return NewTextResponse(string(resultJSON)), nil
+}
+

+ 2 - 2
internal/lsp/client.go

@@ -96,10 +96,10 @@ func NewClient(ctx context.Context, command string, args ...string) (*Client, er
 	go func() {
 		scanner := bufio.NewScanner(stderr)
 		for scanner.Scan() {
-			fmt.Fprintf(os.Stderr, "LSP Server: %s\n", scanner.Text())
+			logging.Info("LSP Server", "message", scanner.Text())
 		}
 		if err := scanner.Err(); err != nil {
-			fmt.Fprintf(os.Stderr, "Error reading stderr: %v\n", err)
+			logging.Error("Error reading LSP stderr", "error", err)
 		}
 	}()
 

+ 72 - 0
internal/lsp/discovery/integration.go

@@ -0,0 +1,72 @@
+package discovery
+
+import (
+	"fmt"
+
+	"github.com/opencode-ai/opencode/internal/config"
+	"github.com/opencode-ai/opencode/internal/logging"
+)
+
+// IntegrateLSPServers discovers languages and LSP servers and integrates them into the application configuration
+func IntegrateLSPServers(workingDir string) error {
+	// Get the current configuration
+	cfg := config.Get()
+	if cfg == nil {
+		return fmt.Errorf("config not loaded")
+	}
+
+	// Check if this is the first run
+	shouldInit, err := config.ShouldShowInitDialog()
+	if err != nil {
+		return fmt.Errorf("failed to check initialization status: %w", err)
+	}
+
+	// Always run language detection, but log differently for first run vs. subsequent runs
+	if shouldInit || len(cfg.LSP) == 0 {
+		logging.Info("Running initial LSP auto-discovery...")
+	} else {
+		logging.Debug("Running LSP auto-discovery to detect new languages...")
+	}
+
+	// Configure LSP servers
+	servers, err := ConfigureLSPServers(workingDir)
+	if err != nil {
+		return fmt.Errorf("failed to configure LSP servers: %w", err)
+	}
+
+	// Update the configuration with discovered servers
+	for langID, serverInfo := range servers {
+		// Skip languages that already have a configured server
+		if _, exists := cfg.LSP[langID]; exists {
+			logging.Debug("LSP server already configured for language", "language", langID)
+			continue
+		}
+
+		if serverInfo.Available {
+			// Only add servers that were found
+			cfg.LSP[langID] = config.LSPConfig{
+				Disabled: false,
+				Command:  serverInfo.Path,
+				Args:     serverInfo.Args,
+			}
+			logging.Info("Added LSP server to configuration", 
+				"language", langID, 
+				"command", serverInfo.Command, 
+				"path", serverInfo.Path)
+		} else {
+			logging.Warn("LSP server not available", 
+				"language", langID, 
+				"command", serverInfo.Command, 
+				"installCmd", serverInfo.InstallCmd)
+		}
+	}
+
+	// Mark the project as initialized
+	if shouldInit {
+		if err := config.MarkProjectInitialized(); err != nil {
+			logging.Warn("Failed to mark project as initialized", "error", err)
+		}
+	}
+
+	return nil
+}

+ 298 - 0
internal/lsp/discovery/language.go

@@ -0,0 +1,298 @@
+package discovery
+
+import (
+	"os"
+	"path/filepath"
+	"strings"
+	"sync"
+
+	"github.com/opencode-ai/opencode/internal/logging"
+	"github.com/opencode-ai/opencode/internal/lsp"
+)
+
+// LanguageInfo stores information about a detected language
+type LanguageInfo struct {
+	// Language identifier (e.g., "go", "typescript", "python")
+	ID string
+
+	// Number of files detected for this language
+	FileCount int
+
+	// Project files associated with this language (e.g., go.mod, package.json)
+	ProjectFiles []string
+
+	// Whether this is likely a primary language in the project
+	IsPrimary bool
+}
+
+// ProjectFile represents a project configuration file
+type ProjectFile struct {
+	// File name or pattern to match
+	Name string
+
+	// Associated language ID
+	LanguageID string
+
+	// Whether this file strongly indicates the language is primary
+	IsPrimary bool
+}
+
+// Common project files that indicate specific languages
+var projectFilePatterns = []ProjectFile{
+	{Name: "go.mod", LanguageID: "go", IsPrimary: true},
+	{Name: "go.sum", LanguageID: "go", IsPrimary: false},
+	{Name: "package.json", LanguageID: "javascript", IsPrimary: true}, // Could be TypeScript too
+	{Name: "tsconfig.json", LanguageID: "typescript", IsPrimary: true},
+	{Name: "jsconfig.json", LanguageID: "javascript", IsPrimary: true},
+	{Name: "pyproject.toml", LanguageID: "python", IsPrimary: true},
+	{Name: "setup.py", LanguageID: "python", IsPrimary: true},
+	{Name: "requirements.txt", LanguageID: "python", IsPrimary: true},
+	{Name: "Cargo.toml", LanguageID: "rust", IsPrimary: true},
+	{Name: "Cargo.lock", LanguageID: "rust", IsPrimary: false},
+	{Name: "CMakeLists.txt", LanguageID: "cmake", IsPrimary: true},
+	{Name: "pom.xml", LanguageID: "java", IsPrimary: true},
+	{Name: "build.gradle", LanguageID: "java", IsPrimary: true},
+	{Name: "build.gradle.kts", LanguageID: "kotlin", IsPrimary: true},
+	{Name: "composer.json", LanguageID: "php", IsPrimary: true},
+	{Name: "Gemfile", LanguageID: "ruby", IsPrimary: true},
+	{Name: "Rakefile", LanguageID: "ruby", IsPrimary: true},
+	{Name: "mix.exs", LanguageID: "elixir", IsPrimary: true},
+	{Name: "rebar.config", LanguageID: "erlang", IsPrimary: true},
+	{Name: "dune-project", LanguageID: "ocaml", IsPrimary: true},
+	{Name: "stack.yaml", LanguageID: "haskell", IsPrimary: true},
+	{Name: "cabal.project", LanguageID: "haskell", IsPrimary: true},
+	{Name: "Makefile", LanguageID: "make", IsPrimary: false},
+	{Name: "Dockerfile", LanguageID: "dockerfile", IsPrimary: false},
+}
+
+// Map of file extensions to language IDs
+var extensionToLanguage = map[string]string{
+	".go":    "go",
+	".js":    "javascript",
+	".jsx":   "javascript",
+	".ts":    "typescript",
+	".tsx":   "typescript",
+	".py":    "python",
+	".rs":    "rust",
+	".java":  "java",
+	".c":     "c",
+	".cpp":   "cpp",
+	".h":     "c",
+	".hpp":   "cpp",
+	".rb":    "ruby",
+	".php":   "php",
+	".cs":    "csharp",
+	".fs":    "fsharp",
+	".swift": "swift",
+	".kt":    "kotlin",
+	".scala": "scala",
+	".hs":    "haskell",
+	".ml":    "ocaml",
+	".ex":    "elixir",
+	".exs":   "elixir",
+	".erl":   "erlang",
+	".lua":   "lua",
+	".r":     "r",
+	".sh":    "shell",
+	".bash":  "shell",
+	".zsh":   "shell",
+	".html":  "html",
+	".css":   "css",
+	".scss":  "scss",
+	".sass":  "sass",
+	".less":  "less",
+	".json":  "json",
+	".xml":   "xml",
+	".yaml":  "yaml",
+	".yml":   "yaml",
+	".md":    "markdown",
+	".dart":  "dart",
+}
+
+// Directories to exclude from scanning
+var excludedDirs = map[string]bool{
+	".git":         true,
+	"node_modules": true,
+	"vendor":       true,
+	"dist":         true,
+	"build":        true,
+	"target":       true,
+	".idea":        true,
+	".vscode":      true,
+	".github":      true,
+	".gitlab":      true,
+	"__pycache__":  true,
+	".next":        true,
+	".nuxt":        true,
+	"venv":         true,
+	"env":          true,
+	".env":         true,
+}
+
+// DetectLanguages scans a directory to identify programming languages used in the project
+func DetectLanguages(rootDir string) (map[string]LanguageInfo, error) {
+	languages := make(map[string]LanguageInfo)
+	var mutex sync.Mutex
+
+	// Walk the directory tree
+	err := filepath.Walk(rootDir, func(path string, info os.FileInfo, err error) error {
+		if err != nil {
+			return nil // Skip files that can't be accessed
+		}
+
+		// Skip excluded directories
+		if info.IsDir() {
+			if excludedDirs[info.Name()] || strings.HasPrefix(info.Name(), ".") {
+				return filepath.SkipDir
+			}
+			return nil
+		}
+
+		// Skip hidden files
+		if strings.HasPrefix(info.Name(), ".") {
+			return nil
+		}
+
+		// Check for project files
+		for _, pattern := range projectFilePatterns {
+			if info.Name() == pattern.Name {
+				mutex.Lock()
+				lang, exists := languages[pattern.LanguageID]
+				if !exists {
+					lang = LanguageInfo{
+						ID:           pattern.LanguageID,
+						FileCount:    0,
+						ProjectFiles: []string{},
+						IsPrimary:    pattern.IsPrimary,
+					}
+				}
+				lang.ProjectFiles = append(lang.ProjectFiles, path)
+				if pattern.IsPrimary {
+					lang.IsPrimary = true
+				}
+				languages[pattern.LanguageID] = lang
+				mutex.Unlock()
+				break
+			}
+		}
+
+		// Check file extension
+		ext := strings.ToLower(filepath.Ext(path))
+		if langID, ok := extensionToLanguage[ext]; ok {
+			mutex.Lock()
+			lang, exists := languages[langID]
+			if !exists {
+				lang = LanguageInfo{
+					ID:           langID,
+					FileCount:    0,
+					ProjectFiles: []string{},
+				}
+			}
+			lang.FileCount++
+			languages[langID] = lang
+			mutex.Unlock()
+		}
+
+		return nil
+	})
+
+	if err != nil {
+		return nil, err
+	}
+
+	// Determine primary languages based on file count if not already marked
+	determinePrimaryLanguages(languages)
+
+	// Log detected languages
+	for id, info := range languages {
+		if info.IsPrimary {
+			logging.Debug("Detected primary language", "language", id, "files", info.FileCount, "projectFiles", len(info.ProjectFiles))
+		} else {
+			logging.Debug("Detected secondary language", "language", id, "files", info.FileCount)
+		}
+	}
+
+	return languages, nil
+}
+
+// determinePrimaryLanguages marks languages as primary based on file count
+func determinePrimaryLanguages(languages map[string]LanguageInfo) {
+	// Find the language with the most files
+	var maxFiles int
+	for _, info := range languages {
+		if info.FileCount > maxFiles {
+			maxFiles = info.FileCount
+		}
+	}
+
+	// Mark languages with at least 20% of the max files as primary
+	threshold := max(maxFiles/5, 5) // At least 5 files to be considered primary
+
+	for id, info := range languages {
+		if !info.IsPrimary && info.FileCount >= threshold {
+			info.IsPrimary = true
+			languages[id] = info
+		}
+	}
+}
+
+// GetLanguageIDFromExtension returns the language ID for a given file extension
+func GetLanguageIDFromExtension(ext string) string {
+	ext = strings.ToLower(ext)
+	if langID, ok := extensionToLanguage[ext]; ok {
+		return langID
+	}
+	return ""
+}
+
+// GetLanguageIDFromProtocol converts a protocol.LanguageKind to our language ID string
+func GetLanguageIDFromProtocol(langKind string) string {
+	// Convert protocol language kind to our language ID
+	switch langKind {
+	case "go":
+		return "go"
+	case "typescript":
+		return "typescript"
+	case "typescriptreact":
+		return "typescript"
+	case "javascript":
+		return "javascript"
+	case "javascriptreact":
+		return "javascript"
+	case "python":
+		return "python"
+	case "rust":
+		return "rust"
+	case "java":
+		return "java"
+	case "c":
+		return "c"
+	case "cpp":
+		return "cpp"
+	default:
+		// Try to normalize the language kind
+		return strings.ToLower(langKind)
+	}
+}
+
+// GetLanguageIDFromPath determines the language ID from a file path
+func GetLanguageIDFromPath(path string) string {
+	// Check file extension first
+	ext := filepath.Ext(path)
+	if langID := GetLanguageIDFromExtension(ext); langID != "" {
+		return langID
+	}
+
+	// Check if it's a known project file
+	filename := filepath.Base(path)
+	for _, pattern := range projectFilePatterns {
+		if filename == pattern.Name {
+			return pattern.LanguageID
+		}
+	}
+
+	// Use LSP's detection as a fallback
+	uri := "file://" + path
+	langKind := lsp.DetectLanguageID(uri)
+	return GetLanguageIDFromProtocol(string(langKind))
+}

+ 306 - 0
internal/lsp/discovery/server.go

@@ -0,0 +1,306 @@
+package discovery
+
+import (
+	"fmt"
+	"os"
+	"os/exec"
+	"path/filepath"
+	"runtime"
+	"strings"
+
+	"github.com/opencode-ai/opencode/internal/logging"
+)
+
+// ServerInfo contains information about an LSP server
+type ServerInfo struct {
+	// Command to run the server
+	Command string
+
+	// Arguments to pass to the command
+	Args []string
+
+	// Command to install the server (for user guidance)
+	InstallCmd string
+
+	// Whether this server is available
+	Available bool
+
+	// Full path to the executable (if found)
+	Path string
+}
+
+// LanguageServerMap maps language IDs to their corresponding LSP servers
+var LanguageServerMap = map[string]ServerInfo{
+	"go": {
+		Command:    "gopls",
+		InstallCmd: "go install golang.org/x/tools/gopls@latest",
+	},
+	"typescript": {
+		Command:    "typescript-language-server",
+		Args:       []string{"--stdio"},
+		InstallCmd: "npm install -g typescript-language-server typescript",
+	},
+	"javascript": {
+		Command:    "typescript-language-server",
+		Args:       []string{"--stdio"},
+		InstallCmd: "npm install -g typescript-language-server typescript",
+	},
+	"python": {
+		Command:    "pylsp",
+		InstallCmd: "pip install python-lsp-server",
+	},
+	"rust": {
+		Command:    "rust-analyzer",
+		InstallCmd: "rustup component add rust-analyzer",
+	},
+	"java": {
+		Command:    "jdtls",
+		InstallCmd: "Install Eclipse JDT Language Server",
+	},
+	"c": {
+		Command:    "clangd",
+		InstallCmd: "Install clangd from your package manager",
+	},
+	"cpp": {
+		Command:    "clangd",
+		InstallCmd: "Install clangd from your package manager",
+	},
+	"php": {
+		Command:    "intelephense",
+		Args:       []string{"--stdio"},
+		InstallCmd: "npm install -g intelephense",
+	},
+	"ruby": {
+		Command:    "solargraph",
+		Args:       []string{"stdio"},
+		InstallCmd: "gem install solargraph",
+	},
+	"lua": {
+		Command:    "lua-language-server",
+		InstallCmd: "Install lua-language-server from your package manager",
+	},
+	"html": {
+		Command:    "vscode-html-language-server",
+		Args:       []string{"--stdio"},
+		InstallCmd: "npm install -g vscode-langservers-extracted",
+	},
+	"css": {
+		Command:    "vscode-css-language-server",
+		Args:       []string{"--stdio"},
+		InstallCmd: "npm install -g vscode-langservers-extracted",
+	},
+	"json": {
+		Command:    "vscode-json-language-server",
+		Args:       []string{"--stdio"},
+		InstallCmd: "npm install -g vscode-langservers-extracted",
+	},
+	"yaml": {
+		Command:    "yaml-language-server",
+		Args:       []string{"--stdio"},
+		InstallCmd: "npm install -g yaml-language-server",
+	},
+}
+
+// FindLSPServer searches for an LSP server for the given language
+func FindLSPServer(languageID string) (ServerInfo, error) {
+	// Get server info for the language
+	serverInfo, exists := LanguageServerMap[languageID]
+	if !exists {
+		return ServerInfo{}, fmt.Errorf("no LSP server defined for language: %s", languageID)
+	}
+
+	// Check if the command is in PATH
+	path, err := exec.LookPath(serverInfo.Command)
+	if err == nil {
+		serverInfo.Available = true
+		serverInfo.Path = path
+		logging.Debug("Found LSP server in PATH", "language", languageID, "command", serverInfo.Command, "path", path)
+		return serverInfo, nil
+	}
+
+	// If not in PATH, search in common installation locations
+	paths := getCommonLSPPaths(languageID, serverInfo.Command)
+	for _, searchPath := range paths {
+		if _, err := os.Stat(searchPath); err == nil {
+			// Found the server
+			serverInfo.Available = true
+			serverInfo.Path = searchPath
+			logging.Debug("Found LSP server in common location", "language", languageID, "command", serverInfo.Command, "path", searchPath)
+			return serverInfo, nil
+		}
+	}
+
+	// Server not found
+	logging.Debug("LSP server not found", "language", languageID, "command", serverInfo.Command)
+	return serverInfo, fmt.Errorf("LSP server for %s not found. Install with: %s", languageID, serverInfo.InstallCmd)
+}
+
+// getCommonLSPPaths returns common installation paths for LSP servers based on language and OS
+func getCommonLSPPaths(languageID, command string) []string {
+	var paths []string
+	homeDir, err := os.UserHomeDir()
+	if err != nil {
+		logging.Error("Failed to get user home directory", "error", err)
+		return paths
+	}
+
+	// Add platform-specific paths
+	switch runtime.GOOS {
+	case "darwin":
+		// macOS paths
+		paths = append(paths, 
+			fmt.Sprintf("/usr/local/bin/%s", command),
+			fmt.Sprintf("/opt/homebrew/bin/%s", command),
+			fmt.Sprintf("%s/.local/bin/%s", homeDir, command),
+		)
+	case "linux":
+		// Linux paths
+		paths = append(paths, 
+			fmt.Sprintf("/usr/bin/%s", command),
+			fmt.Sprintf("/usr/local/bin/%s", command),
+			fmt.Sprintf("%s/.local/bin/%s", homeDir, command),
+		)
+	case "windows":
+		// Windows paths
+		paths = append(paths, 
+			fmt.Sprintf("%s\\AppData\\Local\\Programs\\%s.exe", homeDir, command),
+			fmt.Sprintf("C:\\Program Files\\%s\\bin\\%s.exe", command, command),
+		)
+	}
+
+	// Add language-specific paths
+	switch languageID {
+	case "go":
+		gopath := os.Getenv("GOPATH")
+		if gopath == "" {
+			gopath = filepath.Join(homeDir, "go")
+		}
+		paths = append(paths, filepath.Join(gopath, "bin", command))
+		if runtime.GOOS == "windows" {
+			paths = append(paths, filepath.Join(gopath, "bin", command+".exe"))
+		}
+	case "typescript", "javascript", "html", "css", "json", "yaml", "php":
+		// Node.js global packages
+		if runtime.GOOS == "windows" {
+			paths = append(paths, 
+				fmt.Sprintf("%s\\AppData\\Roaming\\npm\\%s.cmd", homeDir, command),
+				fmt.Sprintf("%s\\AppData\\Roaming\\npm\\node_modules\\.bin\\%s.cmd", homeDir, command),
+			)
+		} else {
+			paths = append(paths, 
+				fmt.Sprintf("%s/.npm-global/bin/%s", homeDir, command),
+				fmt.Sprintf("%s/.nvm/versions/node/*/bin/%s", homeDir, command),
+				fmt.Sprintf("/usr/local/lib/node_modules/.bin/%s", command),
+			)
+		}
+	case "python":
+		// Python paths
+		if runtime.GOOS == "windows" {
+			paths = append(paths, 
+				fmt.Sprintf("%s\\AppData\\Local\\Programs\\Python\\Python*\\Scripts\\%s.exe", homeDir, command),
+				fmt.Sprintf("C:\\Python*\\Scripts\\%s.exe", command),
+			)
+		} else {
+			paths = append(paths, 
+				fmt.Sprintf("%s/.local/bin/%s", homeDir, command),
+				fmt.Sprintf("%s/.pyenv/shims/%s", homeDir, command),
+				fmt.Sprintf("/usr/local/bin/%s", command),
+			)
+		}
+	case "rust":
+		// Rust paths
+		if runtime.GOOS == "windows" {
+			paths = append(paths, 
+				fmt.Sprintf("%s\\.rustup\\toolchains\\*\\bin\\%s.exe", homeDir, command),
+				fmt.Sprintf("%s\\.cargo\\bin\\%s.exe", homeDir, command),
+			)
+		} else {
+			paths = append(paths, 
+				fmt.Sprintf("%s/.rustup/toolchains/*/bin/%s", homeDir, command),
+				fmt.Sprintf("%s/.cargo/bin/%s", homeDir, command),
+			)
+		}
+	}
+
+	// Add VSCode extensions path
+	vscodePath := getVSCodeExtensionsPath(homeDir)
+	if vscodePath != "" {
+		paths = append(paths, vscodePath)
+	}
+
+	// Expand any glob patterns in paths
+	var expandedPaths []string
+	for _, path := range paths {
+		if strings.Contains(path, "*") {
+			// This is a glob pattern, expand it
+			matches, err := filepath.Glob(path)
+			if err == nil {
+				expandedPaths = append(expandedPaths, matches...)
+			}
+		} else {
+			expandedPaths = append(expandedPaths, path)
+		}
+	}
+
+	return expandedPaths
+}
+
+// getVSCodeExtensionsPath returns the path to VSCode extensions directory
+func getVSCodeExtensionsPath(homeDir string) string {
+	var basePath string
+	
+	switch runtime.GOOS {
+	case "darwin":
+		basePath = filepath.Join(homeDir, "Library", "Application Support", "Code", "User", "globalStorage")
+	case "linux":
+		basePath = filepath.Join(homeDir, ".config", "Code", "User", "globalStorage")
+	case "windows":
+		basePath = filepath.Join(homeDir, "AppData", "Roaming", "Code", "User", "globalStorage")
+	default:
+		return ""
+	}
+	
+	// Check if the directory exists
+	if _, err := os.Stat(basePath); err != nil {
+		return ""
+	}
+	
+	return basePath
+}
+
+// ConfigureLSPServers detects languages and configures LSP servers
+func ConfigureLSPServers(rootDir string) (map[string]ServerInfo, error) {
+	// Detect languages in the project
+	languages, err := DetectLanguages(rootDir)
+	if err != nil {
+		return nil, fmt.Errorf("failed to detect languages: %w", err)
+	}
+	
+	// Find LSP servers for detected languages
+	servers := make(map[string]ServerInfo)
+	for langID, langInfo := range languages {
+		// Prioritize primary languages but include all languages that have server definitions
+		if !langInfo.IsPrimary && langInfo.FileCount < 3 {
+			// Skip non-primary languages with very few files
+			logging.Debug("Skipping non-primary language with few files", "language", langID, "files", langInfo.FileCount)
+			continue
+		}
+		
+		// Check if we have a server for this language
+		serverInfo, err := FindLSPServer(langID)
+		if err != nil {
+			logging.Warn("LSP server not found", "language", langID, "error", err)
+			continue
+		}
+		
+		// Add to the map of configured servers
+		servers[langID] = serverInfo
+		if langInfo.IsPrimary {
+			logging.Info("Configured LSP server for primary language", "language", langID, "command", serverInfo.Command, "path", serverInfo.Path)
+		} else {
+			logging.Info("Configured LSP server for secondary language", "language", langID, "command", serverInfo.Command, "path", serverInfo.Path)
+		}
+	}
+	
+	return servers, nil
+}

+ 92 - 0
internal/lsp/discovery/tool/lsp_tool.go

@@ -0,0 +1,92 @@
+package tool
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+
+	"github.com/opencode-ai/opencode/internal/config"
+	"github.com/opencode-ai/opencode/internal/logging"
+	"github.com/opencode-ai/opencode/internal/lsp/discovery"
+)
+
+// ConfigureLspServerRequest represents the request for the configureLspServer tool
+type ConfigureLspServerRequest struct {
+	// Language identifier (e.g., "go", "typescript", "python")
+	Language string `json:"language"`
+}
+
+// ConfigureLspServerResponse represents the response from the configureLspServer tool
+type ConfigureLspServerResponse struct {
+	// Whether the server was found
+	Found bool `json:"found"`
+
+	// Path to the server executable
+	Path string `json:"path,omitempty"`
+
+	// Command to run the server
+	Command string `json:"command,omitempty"`
+
+	// Arguments to pass to the command
+	Args []string `json:"args,omitempty"`
+
+	// Installation instructions if the server was not found
+	InstallInstructions string `json:"installInstructions,omitempty"`
+
+	// Whether the server was added to the configuration
+	Added bool `json:"added,omitempty"`
+}
+
+// ConfigureLspServer searches for an LSP server for the given language
+func ConfigureLspServer(ctx context.Context, rawArgs json.RawMessage) (any, error) {
+	var args ConfigureLspServerRequest
+	if err := json.Unmarshal(rawArgs, &args); err != nil {
+		return nil, fmt.Errorf("failed to parse arguments: %w", err)
+	}
+
+	if args.Language == "" {
+		return nil, fmt.Errorf("language parameter is required")
+	}
+
+	// Find the LSP server for the language
+	serverInfo, err := discovery.FindLSPServer(args.Language)
+	if err != nil {
+		// Server not found, return instructions
+		return ConfigureLspServerResponse{
+			Found:              false,
+			Command:            serverInfo.Command,
+			Args:               serverInfo.Args,
+			InstallInstructions: serverInfo.InstallCmd,
+			Added:              false,
+		}, nil
+	}
+
+	// Server found, update the configuration if available
+	added := false
+	if serverInfo.Available {
+		// Get the current configuration
+		cfg := config.Get()
+		if cfg != nil {
+			// Add the server to the configuration
+			cfg.LSP[args.Language] = config.LSPConfig{
+				Disabled: false,
+				Command:  serverInfo.Path,
+				Args:     serverInfo.Args,
+			}
+			added = true
+			logging.Info("Added LSP server to configuration", 
+				"language", args.Language, 
+				"command", serverInfo.Command, 
+				"path", serverInfo.Path)
+		}
+	}
+
+	// Return the server information
+	return ConfigureLspServerResponse{
+		Found:   true,
+		Path:    serverInfo.Path,
+		Command: serverInfo.Command,
+		Args:    serverInfo.Args,
+		Added:   added,
+	}, nil
+}

+ 3 - 5
internal/tui/tui.go

@@ -56,8 +56,8 @@ var keys = keyMap{
 	),
 
 	Models: key.NewBinding(
-		key.WithKeys("ctrl+m"),
-		key.WithHelp("ctrl+m", "model selection"),
+		key.WithKeys("ctrl+o"),
+		key.WithHelp("ctrl+o", "model selection"),
 	),
 
 	SwitchTheme: key.NewBinding(
@@ -385,10 +385,8 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			}
 			return a, nil
 		case key.Matches(msg, keys.SwitchTheme):
-			if !a.showQuit && !a.showPermissions && !a.showSessionDialog && !a.showCommandDialog {
-				// Show theme switcher dialog
+			if a.currentPage == page.ChatPage && !a.showQuit && !a.showPermissions && !a.showSessionDialog && !a.showCommandDialog {
 				a.showThemeDialog = true
-				// Theme list is dynamically loaded by the dialog component
 				return a, a.themeDialog.Init()
 			}
 			return a, nil