wip

Kujtim Hoxha created

Change summary

cmd/root.go                                 |   1 
internal/app/app.go                         |   4 
internal/app/lsp.go                         |  18 +
internal/lsp/setup/install.go               | 208 +--------------------
internal/lsp/setup/service.go               | 172 ++++++++++++++++++
internal/tui/components/dialog/lsp_setup.go | 213 +++++++++++++++++-----
internal/tui/tui.go                         |  26 --
7 files changed, 375 insertions(+), 267 deletions(-)

Detailed changes

cmd/root.go 🔗

@@ -257,6 +257,7 @@ func setupSubscriptions(app *app.App, parentCtx context.Context) (chan tea.Msg,
 	setupSubscriber(ctx, &wg, "messages", app.Messages.Subscribe, ch)
 	setupSubscriber(ctx, &wg, "permissions", app.Permissions.Subscribe, ch)
 	setupSubscriber(ctx, &wg, "coderAgent", app.CoderAgent.Subscribe, ch)
+	setupSubscriber(ctx, &wg, "lspSetup", app.LSPSetup.Subscribe, ch)
 
 	cleanupFunc := func() {
 		logging.Info("Cancelling all subscriptions")

internal/app/app.go 🔗

@@ -16,6 +16,7 @@ import (
 	"github.com/opencode-ai/opencode/internal/llm/agent"
 	"github.com/opencode-ai/opencode/internal/logging"
 	"github.com/opencode-ai/opencode/internal/lsp"
+	"github.com/opencode-ai/opencode/internal/lsp/setup"
 	"github.com/opencode-ai/opencode/internal/message"
 	"github.com/opencode-ai/opencode/internal/permission"
 	"github.com/opencode-ai/opencode/internal/session"
@@ -27,6 +28,7 @@ type App struct {
 	Messages    message.Service
 	History     history.Service
 	Permissions permission.Service
+	LSPSetup    setup.Service
 
 	CoderAgent agent.Service
 
@@ -47,12 +49,14 @@ func New(ctx context.Context, conn *sql.DB) (*App, error) {
 	sessions := session.NewService(q)
 	messages := message.NewService(q)
 	files := history.NewService(q, conn)
+	lspSetup := setup.NewService()
 
 	app := &App{
 		Sessions:    sessions,
 		Messages:    messages,
 		History:     files,
 		Permissions: permission.NewPermissionService(),
+		LSPSetup:    lspSetup,
 		LSPClients:  make(map[string]*lsp.Client),
 	}
 

internal/app/lsp.go 🔗

@@ -7,6 +7,8 @@ import (
 	"github.com/opencode-ai/opencode/internal/config"
 	"github.com/opencode-ai/opencode/internal/logging"
 	"github.com/opencode-ai/opencode/internal/lsp"
+	"github.com/opencode-ai/opencode/internal/lsp/protocol"
+	"github.com/opencode-ai/opencode/internal/lsp/setup"
 	"github.com/opencode-ai/opencode/internal/lsp/watcher"
 )
 
@@ -34,6 +36,22 @@ func (app *App) CheckAndSetupLSP(ctx context.Context) bool {
 	return true
 }
 
+// ConfigureLSP configures LSP with the provided servers
+func (app *App) ConfigureLSP(ctx context.Context, servers map[protocol.LanguageKind]setup.LSPServerInfo) error {
+	// Save the configuration using the LSP setup service
+	err := app.LSPSetup.SaveConfiguration(ctx, servers)
+	if err != nil {
+		logging.Error("Failed to save LSP configuration", err)
+		return err
+	}
+	
+	// Initialize LSP clients with the new configuration
+	app.InitLSPClients(ctx)
+	
+	logging.Info("LSP configuration updated successfully")
+	return nil
+}
+
 // 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) {
 	logging.Info("Creating LSP client", "name", name, "command", command, "args", args)

internal/lsp/setup/install.go 🔗

@@ -5,10 +5,10 @@ import (
 	"fmt"
 	"io"
 	"os/exec"
-	"runtime"
 	"strings"
 
-	"github.com/opencode-ai/opencode/internal/logging"
+	"github.com/opencode-ai/opencode/internal/config"
+	"github.com/opencode-ai/opencode/internal/lsp/protocol"
 )
 
 // InstallationResult represents the result of an LSP server installation
@@ -121,197 +121,21 @@ func VerifyInstallation(serverName string) bool {
 	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),
-				})
-			}
+// UpdateLSPConfig updates the LSP configuration in the config file
+func UpdateLSPConfig(servers map[protocol.LanguageKind]LSPServerInfo) error {
+	// Create a map for the LSP configuration
+	lspConfig := make(map[string]config.LSPConfig)
+
+	for lang, server := range servers {
+		langStr := string(lang)
+
+		lspConfig[langStr] = config.LSPConfig{
+			Disabled: false,
+			Command:  server.Command,
+			Args:     server.Args,
+			Options:  server.Options,
 		}
 	}
 
-	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
+	return config.SaveLocalLSPConfig(lspConfig)
 }

internal/lsp/setup/service.go 🔗

@@ -0,0 +1,172 @@
+package setup
+
+import (
+	"context"
+
+	"github.com/opencode-ai/opencode/internal/lsp/protocol"
+	"github.com/opencode-ai/opencode/internal/pubsub"
+)
+
+// LSPSetupEvent represents an event related to LSP setup
+type LSPSetupEvent struct {
+	Type        LSPSetupEventType
+	Language    protocol.LanguageKind
+	ServerName  string
+	Success     bool
+	Error       error
+	Description string
+}
+
+// LSPSetupEventType defines the type of LSP setup event
+type LSPSetupEventType string
+
+const (
+	// EventLanguageDetected is emitted when a language is detected in the workspace
+	EventLanguageDetected LSPSetupEventType = "language_detected"
+	// EventServerDiscovered is emitted when an LSP server is discovered
+	EventServerDiscovered LSPSetupEventType = "server_discovered"
+	// EventServerInstalled is emitted when an LSP server is installed
+	EventServerInstalled LSPSetupEventType = "server_installed"
+	// EventServerInstallFailed is emitted when an LSP server installation fails
+	EventServerInstallFailed LSPSetupEventType = "server_install_failed"
+	// EventSetupCompleted is emitted when the LSP setup is completed
+	EventSetupCompleted LSPSetupEventType = "setup_completed"
+)
+
+// Service defines the interface for the LSP setup service
+type Service interface {
+	pubsub.Suscriber[LSPSetupEvent]
+	
+	// DetectLanguages detects languages in the workspace
+	DetectLanguages(ctx context.Context, workspaceDir string) (map[protocol.LanguageKind]int, error)
+	
+	// GetPrimaryLanguages returns the top N languages in the project
+	GetPrimaryLanguages(languages map[protocol.LanguageKind]int, limit int) []LanguageScore
+	
+	// DetectMonorepo checks if the workspace is a monorepo
+	DetectMonorepo(ctx context.Context, workspaceDir string) (bool, []string)
+	
+	// DiscoverInstalledLSPs discovers installed LSP servers
+	DiscoverInstalledLSPs(ctx context.Context) LSPServerMap
+	
+	// GetRecommendedLSPServers returns recommended LSP servers for languages
+	GetRecommendedLSPServers(ctx context.Context, languages []LanguageScore) LSPServerMap
+	
+	// InstallLSPServer installs an LSP server
+	InstallLSPServer(ctx context.Context, server LSPServerInfo) InstallationResult
+	
+	// VerifyInstallation verifies that an LSP server is correctly installed
+	VerifyInstallation(ctx context.Context, serverName string) bool
+	
+	// SaveConfiguration saves the LSP configuration
+	SaveConfiguration(ctx context.Context, servers map[protocol.LanguageKind]LSPServerInfo) error
+}
+
+type service struct {
+	*pubsub.Broker[LSPSetupEvent]
+}
+
+// NewService creates a new LSP setup service
+func NewService() Service {
+	broker := pubsub.NewBroker[LSPSetupEvent]()
+	return &service{
+		Broker: broker,
+	}
+}
+
+// DetectLanguages detects languages in the workspace
+func (s *service) DetectLanguages(ctx context.Context, workspaceDir string) (map[protocol.LanguageKind]int, error) {
+	languages, err := DetectProjectLanguages(workspaceDir)
+	if err != nil {
+		return nil, err
+	}
+	
+	// Emit events for detected languages
+	for lang, score := range languages {
+		if lang != "" && score > 0 {
+			s.Publish(pubsub.CreatedEvent, LSPSetupEvent{
+				Type:        EventLanguageDetected,
+				Language:    lang,
+				Description: "Language detected in workspace",
+			})
+		}
+	}
+	
+	return languages, nil
+}
+
+// GetPrimaryLanguages returns the top N languages in the project
+func (s *service) GetPrimaryLanguages(languages map[protocol.LanguageKind]int, limit int) []LanguageScore {
+	return GetPrimaryLanguages(languages, limit)
+}
+
+// DetectMonorepo checks if the workspace is a monorepo
+func (s *service) DetectMonorepo(ctx context.Context, workspaceDir string) (bool, []string) {
+	return DetectMonorepo(workspaceDir)
+}
+
+// DiscoverInstalledLSPs discovers installed LSP servers
+func (s *service) DiscoverInstalledLSPs(ctx context.Context) LSPServerMap {
+	servers := DiscoverInstalledLSPs()
+	
+	// Emit events for discovered servers
+	for lang, serverList := range servers {
+		for _, server := range serverList {
+			s.Publish(pubsub.CreatedEvent, LSPSetupEvent{
+				Type:        EventServerDiscovered,
+				Language:    lang,
+				ServerName:  server.Name,
+				Description: "LSP server discovered",
+			})
+		}
+	}
+	
+	return servers
+}
+
+// GetRecommendedLSPServers returns recommended LSP servers for languages
+func (s *service) GetRecommendedLSPServers(ctx context.Context, languages []LanguageScore) LSPServerMap {
+	return GetRecommendedLSPServers(languages)
+}
+
+// InstallLSPServer installs an LSP server
+func (s *service) InstallLSPServer(ctx context.Context, server LSPServerInfo) InstallationResult {
+	result := InstallLSPServer(ctx, server)
+	
+	// Emit event based on installation result
+	eventType := EventServerInstalled
+	if !result.Success {
+		eventType = EventServerInstallFailed
+	}
+	
+	s.Publish(pubsub.CreatedEvent, LSPSetupEvent{
+		Type:        eventType,
+		ServerName:  server.Name,
+		Success:     result.Success,
+		Error:       result.Error,
+		Description: result.Output,
+	})
+	
+	return result
+}
+
+// VerifyInstallation verifies that an LSP server is correctly installed
+func (s *service) VerifyInstallation(ctx context.Context, serverName string) bool {
+	return VerifyInstallation(serverName)
+}
+
+// SaveConfiguration saves the LSP configuration
+func (s *service) SaveConfiguration(ctx context.Context, servers map[protocol.LanguageKind]LSPServerInfo) error {
+	// Update the LSP configuration
+	err := UpdateLSPConfig(servers)
+	
+	// Emit setup completed event
+	s.Publish(pubsub.CreatedEvent, LSPSetupEvent{
+		Type:        EventSetupCompleted,
+		Success:     err == nil,
+		Error:       err,
+		Description: "LSP setup completed",
+	})
+	
+	return err
+}

internal/tui/components/dialog/lsp_setup.go 🔗

@@ -3,7 +3,6 @@ package dialog
 import (
 	"context"
 	"fmt"
-	"os/exec"
 	"sort"
 	"strings"
 
@@ -15,6 +14,7 @@ import (
 	"github.com/opencode-ai/opencode/internal/config"
 	"github.com/opencode-ai/opencode/internal/lsp/protocol"
 	"github.com/opencode-ai/opencode/internal/lsp/setup"
+	"github.com/opencode-ai/opencode/internal/pubsub"
 	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"
@@ -52,6 +52,7 @@ type LSPSetupWizard struct {
 	keys           lspSetupKeyMap
 	error          string
 	program        *tea.Program
+	setupService   setup.Service
 }
 
 // LSPItem represents an item in the language or server list
@@ -99,7 +100,7 @@ func (i LSPItem) Render(selected bool, width int) string {
 }
 
 // NewLSPSetupWizard creates a new LSPSetupWizard
-func NewLSPSetupWizard(ctx context.Context) *LSPSetupWizard {
+func NewLSPSetupWizard(ctx context.Context, setupService setup.Service) *LSPSetupWizard {
 	s := spinner.New()
 	s.Spinner = spinner.Dot
 	s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("205"))
@@ -113,6 +114,7 @@ func NewLSPSetupWizard(ctx context.Context) *LSPSetupWizard {
 		installOutput:  make([]string, 0, 10), // Initialize with capacity for 10 lines
 		spinner:        s,
 		keys:           DefaultLSPSetupKeyMap(),
+		setupService:   setupService,
 	}
 }
 
@@ -181,18 +183,18 @@ func (m *LSPSetupWizard) Init() tea.Cmd {
 
 // detectLanguages is a command that detects languages in the workspace
 func (m *LSPSetupWizard) detectLanguages() tea.Msg {
-	languages, err := setup.DetectProjectLanguages(config.WorkingDirectory())
+	languages, err := m.setupService.DetectLanguages(m.ctx, config.WorkingDirectory())
 	if err != nil {
 		return lspSetupErrorMsg{err: err}
 	}
 
-	isMonorepo, projectDirs := setup.DetectMonorepo(config.WorkingDirectory())
+	isMonorepo, projectDirs := m.setupService.DetectMonorepo(m.ctx, config.WorkingDirectory())
 
-	primaryLangs := setup.GetPrimaryLanguages(languages, 10)
+	primaryLangs := m.setupService.GetPrimaryLanguages(languages, 10)
 
-	availableLSPs := setup.DiscoverInstalledLSPs()
+	availableLSPs := m.setupService.DiscoverInstalledLSPs(m.ctx)
 
-	recommendedLSPs := setup.GetRecommendedLSPServers(primaryLangs)
+	recommendedLSPs := m.setupService.GetRecommendedLSPServers(m.ctx, primaryLangs)
 	for lang, servers := range recommendedLSPs {
 		if _, ok := availableLSPs[lang]; !ok {
 			availableLSPs[lang] = servers
@@ -299,6 +301,18 @@ func (m *LSPSetupWizard) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 
 	case lspSetupErrorMsg:
 		m.error = msg.err.Error()
+		m.installing = false
+
+		// If we're in the installation step, stay there to show the error
+		if m.step == StepInstallation {
+			m.step = StepInstallation
+		}
+		m.installing = false
+
+		// If we're in the installation step, stay there to show the error
+		if m.step == StepInstallation {
+			m.step = StepInstallation
+		}
 		return m, nil
 
 	case lspSetupInstallMsg:
@@ -316,14 +330,68 @@ func (m *LSPSetupWizard) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			m.addOutputLine(fmt.Sprintf("✗ Failed to install %s for %s", msg.result.ServerName, msg.language))
 		}
 
+		// Continue with the next installation
+		if len(m.installResults) < len(m.selectedLSPs) {
+			return m, tea.Batch(
+				m.spinner.Tick,
+				m.installNextServer(),
+			)
+		}
+
+		// All installations are complete
+		m.installing = false
+		m.step = StepInstallation
+		return m, nil
+
+	case lspSetupInstallDoneMsg:
 		m.installing = false
+		m.step = StepInstallation
+		return m, nil
 
-		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()
+	case pubsub.Event[setup.LSPSetupEvent]:
+		// Handle LSP setup events
+		event := msg.Payload
+		switch event.Type {
+		case setup.EventLanguageDetected:
+			// Language detected, update UI if needed
+			m.addOutputLine(fmt.Sprintf("Detected language: %s", event.Language))
+		case setup.EventServerDiscovered:
+			// Server discovered, update UI if needed
+			m.addOutputLine(fmt.Sprintf("Discovered server: %s for %s", event.ServerName, event.Language))
+		case setup.EventServerInstalled:
+			// Server installed, update the installation results
+			if _, ok := m.installResults[event.Language]; !ok {
+				m.installResults[event.Language] = setup.InstallationResult{
+					ServerName: event.ServerName,
+					Success:    event.Success,
+					Error:      event.Error,
+					Output:     event.Description,
+				}
+				m.addOutputLine(fmt.Sprintf("✓ Successfully installed %s for %s", event.ServerName, event.Language))
+			}
+		case setup.EventServerInstallFailed:
+			// Server installation failed, update the installation results
+			if _, ok := m.installResults[event.Language]; !ok {
+				m.installResults[event.Language] = setup.InstallationResult{
+					ServerName: event.ServerName,
+					Success:    false,
+					Error:      event.Error,
+					Output:     event.Description,
+				}
+				m.addOutputLine(fmt.Sprintf("✗ Failed to install %s for %s: %s",
+					event.ServerName, event.Language, event.Error))
+			}
+		case setup.EventSetupCompleted:
+			// Setup completed, update UI if needed
+			if event.Success {
+				m.addOutputLine("LSP setup completed successfully")
+				// If we're in the installation step and all servers are installed, we can move to the next step
+				if m.installing && len(m.installResults) == len(m.selectedLSPs) {
+					m.installing = false
+				}
+			} else {
+				m.addOutputLine(fmt.Sprintf("LSP setup failed: %s", event.Error))
+			}
 		}
 	}
 
@@ -401,6 +469,10 @@ func (m *LSPSetupWizard) handleEnter() (tea.Model, tea.Cmd) {
 		// Start installation
 		m.step = StepInstallation
 		m.installing = true
+		m.installResults = make(map[protocol.LanguageKind]setup.InstallationResult)
+		m.installOutput = []string{} // Clear previous output
+		m.addOutputLine("Starting LSP server installation...")
+
 		// Start the spinner and begin installation
 		return m, tea.Batch(
 			m.spinner.Tick,
@@ -591,6 +663,7 @@ func (m *LSPSetupWizard) renderConfirmation(baseStyle lipgloss.Style, t theme.Th
 	)
 }
 
+// renderInstallation renders the installation/summary step
 // renderInstallation renders the installation/summary step
 func (m *LSPSetupWizard) renderInstallation(baseStyle lipgloss.Style, t theme.Theme, maxWidth int) string {
 	if m.installing {
@@ -601,7 +674,48 @@ func (m *LSPSetupWizard) renderInstallation(baseStyle lipgloss.Style, t theme.Th
 			Width(maxWidth).
 			Padding(1, 1)
 
-		spinnerText := m.spinner.View() + " Installing " + m.currentInstall + "..."
+		// Show progress for all servers
+		var progressLines []string
+
+		// 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])
+		})
+
+		for _, lang := range languages {
+			server := m.selectedLSPs[lang]
+			status := "⋯" // Pending
+			statusColor := t.TextMuted()
+
+			if result, ok := m.installResults[lang]; ok {
+				if result.Success {
+					status = "✓" // Success
+					statusColor = t.Success()
+				} else {
+					status = "✗" // Failed
+					statusColor = t.Error()
+				}
+			} else if m.currentInstall == fmt.Sprintf("%s for %s", server.Name, lang) {
+				status = m.spinner.View() // In progress
+				statusColor = t.Primary()
+			}
+
+			line := fmt.Sprintf("%s %s: %s",
+				baseStyle.Foreground(statusColor).Render(status),
+				lang,
+				server.Name)
+
+			progressLines = append(progressLines, line)
+		}
+
+		progressText := strings.Join(progressLines, "\n")
+		progressContent := spinnerStyle.Render(progressText)
 
 		// Show output if available
 		var content string
@@ -617,11 +731,11 @@ func (m *LSPSetupWizard) renderInstallation(baseStyle lipgloss.Style, t theme.Th
 
 			content = lipgloss.JoinVertical(
 				lipgloss.Left,
-				spinnerStyle.Render(spinnerText),
+				progressContent,
 				outputContent,
 			)
 		} else {
-			content = spinnerStyle.Render(spinnerText)
+			content = progressContent
 		}
 
 		return content
@@ -821,57 +935,47 @@ 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 {
+				if m.setupService.VerifyInstallation(m.ctx, server.Command) {
 					// 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,
+					return lspSetupInstallMsg{
+						language: lang,
+						result: setup.InstallationResult{
+							ServerName: server.Name,
+							Success:    true,
+							Output:     output,
+						},
+						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,
-						}
-					},
-				)
+				// Return a command that will perform the installation
+				return installServerCmd(m.ctx, lang, server, m.setupService)
 			}
 		}
 
 		// All servers have been installed
-		m.installing = false
-		m.step = StepInstallation
-		return nil
+		return lspSetupInstallDoneMsg{}
+	}
+}
+
+// installServerCmd creates a command that installs an LSP server
+func installServerCmd(ctx context.Context, lang protocol.LanguageKind, server setup.LSPServerInfo, setupService setup.Service) tea.Cmd {
+	return func() tea.Msg {
+		// Perform installation using the service
+		result := setupService.InstallLSPServer(ctx, server)
+
+		// Return result as a message
+		return lspSetupInstallMsg{
+			language: lang,
+			result:   result,
+			output:   result.Output,
+		}
 	}
 }
 
@@ -932,6 +1036,9 @@ type lspSetupInstallMsg struct {
 	output   string // Installation output
 }
 
+// lspSetupInstallDoneMsg is sent when all installations are complete
+type lspSetupInstallDoneMsg struct{}
+
 // CloseLSPSetupMsg is a message that is sent when the LSP setup wizard is closed
 type CloseLSPSetupMsg struct {
 	Configure bool

internal/tui/tui.go 🔗

@@ -12,7 +12,6 @@ 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"
@@ -392,8 +391,8 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	case dialog.ShowLSPSetupMsg:
 		a.showLSPSetupDialog = msg.Show
 		if a.showLSPSetupDialog {
-			// Initialize the LSP setup wizard
-			a.lspSetupDialog = dialog.NewLSPSetupWizard(context.Background())
+			// Initialize the LSP setup wizard with the app's LSP setup service
+			a.lspSetupDialog = dialog.NewLSPSetupWizard(context.Background(), a.app.LSPSetup)
 			a.lspSetupDialog.SetSize(a.width, a.height)
 			return a, a.lspSetupDialog.Init()
 		}
@@ -402,30 +401,13 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	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)
+			// Use the app's ConfigureLSP method to handle the configuration
+			err := a.app.ConfigureLSP(context.Background(), msg.Servers)
 			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