diff --git a/.opencode.json b/.opencode.json index c4d1547a0c62aad24a470af1d503c225a5b5955b..a5c9179a007ce361a8a953c26acb1aeb75cfa505 100644 --- a/.opencode.json +++ b/.opencode.json @@ -1,8 +1,3 @@ { - "$schema": "./opencode-schema.json", - "lsp": { - "gopls": { - "command": "gopls" - } - } + "$schema": "./opencode-schema.json" } diff --git a/go.mod b/go.mod index dc8eaadd3165d43feac796e339afbc771e64ff7f..eb818b70a3c7a575f2b0cbe76a40de3e554546a3 100644 --- a/go.mod +++ b/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 diff --git a/go.sum b/go.sum index d5396bb8854473e96747e7d7f49911e44b6b9d27..62f7a0e6199b360f662df27647233b81ea05b439 100644 --- a/go.sum +++ b/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= diff --git a/internal/app/app.go b/internal/app/app.go index abdc1431db585694021b66df6490c3f50e41bd64..ffa0109d6ed92951507c36a830a7198a785e6177 100644 --- a/internal/app/app.go +++ b/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() diff --git a/internal/app/lsp.go b/internal/app/lsp.go index 872532fd80aa6d99adc0e34ee1ecf25de34df253..090cb86320c6ac3240dcbedd1ea699a484f5d4bf 100644 --- a/internal/app/lsp.go +++ b/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 } diff --git a/internal/config/config.go b/internal/config/config.go index ff7585db9a6008d082191b252527bd11107ad74d..ba7e41f2a933ea7b68c25ba20860f39cd6a4dec3 100644 --- a/internal/config/config.go +++ b/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"` } diff --git a/internal/config/lsp.go b/internal/config/lsp.go new file mode 100644 index 0000000000000000000000000000000000000000..d3fe2bdac3226ccbc172a4fd886a22537e9c55d9 --- /dev/null +++ b/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 +} \ No newline at end of file diff --git a/internal/llm/agent/agent.go b/internal/llm/agent/agent.go index 0ac7f65ff37f2cbab3fd45e5ea963542f431e75c..ea6da7c48eb1370eccffc99bfdc9a6c028692726 100644 --- a/internal/llm/agent/agent.go +++ b/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, diff --git a/internal/lsp/setup/detect.go b/internal/lsp/setup/detect.go new file mode 100644 index 0000000000000000000000000000000000000000..6a62b7fe7bdb88666e25dc786d4d4c26c136c602 --- /dev/null +++ b/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 +} diff --git a/internal/lsp/setup/discover.go b/internal/lsp/setup/discover.go new file mode 100644 index 0000000000000000000000000000000000000000..38da0a67d55bc0cd53d921985d8a956100475fd0 --- /dev/null +++ b/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") +} diff --git a/internal/lsp/setup/install.go b/internal/lsp/setup/install.go new file mode 100644 index 0000000000000000000000000000000000000000..872c047ea7a6e972ec781ffff585adfeaa43f9bb --- /dev/null +++ b/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 +} diff --git a/internal/tui/components/dialog/commands.go b/internal/tui/components/dialog/commands.go index 25069b8a6dd39633e46b8627ff4a066bc52b1239..8bd2e74d193c785d0804f6f4363cffce3d02bb7f 100644 --- a/internal/tui/components/dialog/commands.go +++ b/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", diff --git a/internal/tui/components/dialog/lsp_setup.go b/internal/tui/components/dialog/lsp_setup.go new file mode 100644 index 0000000000000000000000000000000000000000..494b69dc49ec9dfd4f245a4373a7953282821a6c --- /dev/null +++ b/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 +} diff --git a/internal/tui/components/util/simple-list.go b/internal/tui/components/util/simple-list.go index 7aad2494c6f93f084d52c3e11fa80d5b795ca217..b76e9d389f408a26b025fb8b48730dda847a8074 100644 --- a/internal/tui/components/util/simple-list.go +++ b/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 } diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 060b8c79c8572a0508ebcd95a148ff0743bc7009..518b1134f470df864152ad174bacf389445c5d06 100644 --- a/internal/tui/tui.go +++ b/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 {