From d08e58279db42b9892ad32e0fd8cdf086b4027d5 Mon Sep 17 00:00:00 2001 From: adamdottv <2363879+adamdottv@users.noreply.github.com> Date: Thu, 1 May 2025 06:26:20 -0500 Subject: [PATCH] feat: lsp discovery --- cmd/root.go | 7 + internal/llm/agent/tools.go | 1 + internal/llm/tools/lsp.go | 49 ++++ internal/lsp/client.go | 4 +- internal/lsp/discovery/integration.go | 72 ++++++ internal/lsp/discovery/language.go | 298 +++++++++++++++++++++++ internal/lsp/discovery/server.go | 306 ++++++++++++++++++++++++ internal/lsp/discovery/tool/lsp_tool.go | 92 +++++++ internal/tui/tui.go | 8 +- 9 files changed, 830 insertions(+), 7 deletions(-) create mode 100644 internal/llm/tools/lsp.go create mode 100644 internal/lsp/discovery/integration.go create mode 100644 internal/lsp/discovery/language.go create mode 100644 internal/lsp/discovery/server.go create mode 100644 internal/lsp/discovery/tool/lsp_tool.go diff --git a/cmd/root.go b/cmd/root.go index ab81f712..f288c9f6 100644 --- a/cmd/root.go +++ b/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 { diff --git a/internal/llm/agent/tools.go b/internal/llm/agent/tools.go index e6b0119a..df1dd1b6 100644 --- a/internal/llm/agent/tools.go +++ b/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..., ) diff --git a/internal/llm/tools/lsp.go b/internal/llm/tools/lsp.go new file mode 100644 index 00000000..c2b4b04f --- /dev/null +++ b/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 +} + diff --git a/internal/lsp/client.go b/internal/lsp/client.go index 355b05d3..a9ad50e3 100644 --- a/internal/lsp/client.go +++ b/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) } }() diff --git a/internal/lsp/discovery/integration.go b/internal/lsp/discovery/integration.go new file mode 100644 index 00000000..2694fe58 --- /dev/null +++ b/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 +} \ No newline at end of file diff --git a/internal/lsp/discovery/language.go b/internal/lsp/discovery/language.go new file mode 100644 index 00000000..5e0a8d1a --- /dev/null +++ b/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)) +} \ No newline at end of file diff --git a/internal/lsp/discovery/server.go b/internal/lsp/discovery/server.go new file mode 100644 index 00000000..2b7d4eeb --- /dev/null +++ b/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 +} \ No newline at end of file diff --git a/internal/lsp/discovery/tool/lsp_tool.go b/internal/lsp/discovery/tool/lsp_tool.go new file mode 100644 index 00000000..c1f2f73a --- /dev/null +++ b/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 +} \ No newline at end of file diff --git a/internal/tui/tui.go b/internal/tui/tui.go index ff343b75..cfaa7817 100644 --- a/internal/tui/tui.go +++ b/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