.opencode.json 🔗
@@ -1,8 +1,3 @@
{
- "$schema": "./opencode-schema.json",
- "lsp": {
- "gopls": {
- "command": "gopls"
- }
- }
+ "$schema": "./opencode-schema.json"
}
Kujtim Hoxha created
.opencode.json | 7
go.mod | 2
go.sum | 2
internal/app/app.go | 6
internal/app/lsp.go | 21
internal/config/config.go | 8
internal/config/lsp.go | 87 ++
internal/llm/agent/agent.go | 23
internal/lsp/setup/detect.go | 384 +++++++++
internal/lsp/setup/discover.go | 522 ++++++++++++
internal/lsp/setup/install.go | 317 +++++++
internal/tui/components/dialog/commands.go | 2
internal/tui/components/dialog/lsp_setup.go | 944 +++++++++++++++++++++++
internal/tui/components/util/simple-list.go | 6
internal/tui/tui.go | 111 ++
15 files changed, 2,414 insertions(+), 28 deletions(-)
@@ -1,8 +1,3 @@
{
- "$schema": "./opencode-schema.json",
- "lsp": {
- "gopls": {
- "command": "gopls"
- }
- }
+ "$schema": "./opencode-schema.json"
}
@@ -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
@@ -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=
@@ -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()
@@ -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
}
@@ -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"`
}
@@ -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
+}
@@ -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,
@@ -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
+}
@@ -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")
+}
@@ -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
+}
@@ -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",
@@ -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
+}
@@ -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
}
@@ -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 {