summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authoradamdottv <[email protected]>2025-05-01 06:26:20 -0500
committeradamdottv <[email protected]>2025-05-01 06:26:20 -0500
commitd08e58279db42b9892ad32e0fd8cdf086b4027d5 (patch)
treee1f4b40f6afebfa46eaef6c863cc7d4d5ff1a4c6
parent7bc542abff85d18112b3e61556659a496d6dc668 (diff)
downloadopencode-d08e58279db42b9892ad32e0fd8cdf086b4027d5.tar.gz
opencode-d08e58279db42b9892ad32e0fd8cdf086b4027d5.zip
feat: lsp discovery
-rw-r--r--cmd/root.go7
-rw-r--r--internal/llm/agent/tools.go1
-rw-r--r--internal/llm/tools/lsp.go49
-rw-r--r--internal/lsp/client.go4
-rw-r--r--internal/lsp/discovery/integration.go72
-rw-r--r--internal/lsp/discovery/language.go298
-rw-r--r--internal/lsp/discovery/server.go306
-rw-r--r--internal/lsp/discovery/tool/lsp_tool.go92
-rw-r--r--internal/tui/tui.go8
9 files changed, 830 insertions, 7 deletions
diff --git a/cmd/root.go b/cmd/root.go
index ab81f7120..f288c9f69 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 e6b0119ae..df1dd1b66 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 000000000..c2b4b04f3
--- /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 355b05d30..a9ad50e37 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 000000000..2694fe58e
--- /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 000000000..5e0a8d1af
--- /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 000000000..2b7d4eeb6
--- /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 000000000..c1f2f73a3
--- /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 ff343b75a..cfaa78170 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