fix(ui/docker): don't block ui when checking for docker desktop

Christian Rocha created

Change summary

internal/config/docker_mcp.go  | 33 +++++++++++++++++++++++++++++++++
internal/ui/dialog/commands.go | 36 ++++++++++++++++++++++++++++++++++--
internal/ui/model/ui.go        | 11 ++++++-----
3 files changed, 73 insertions(+), 7 deletions(-)

Detailed changes

internal/config/docker_mcp.go 🔗

@@ -4,6 +4,7 @@ import (
 	"context"
 	"fmt"
 	"os/exec"
+	"sync"
 	"time"
 )
 
@@ -12,6 +13,15 @@ var dockerMCPVersionRunner = func(ctx context.Context) error {
 	return cmd.Run()
 }
 
+const dockerMCPAvailabilityTTL = 10 * time.Second
+
+var dockerMCPAvailabilityCache struct {
+	mu        sync.Mutex
+	available bool
+	checkedAt time.Time
+	known     bool
+}
+
 // DockerMCPName is the name of the Docker MCP configuration.
 const DockerMCPName = "docker"
 
@@ -25,6 +35,29 @@ func IsDockerMCPAvailable() bool {
 	return err == nil
 }
 
+func DockerMCPAvailabilityCached() (available bool, known bool) {
+	dockerMCPAvailabilityCache.mu.Lock()
+	defer dockerMCPAvailabilityCache.mu.Unlock()
+
+	if !dockerMCPAvailabilityCache.known {
+		return false, false
+	}
+	if time.Since(dockerMCPAvailabilityCache.checkedAt) > dockerMCPAvailabilityTTL {
+		return dockerMCPAvailabilityCache.available, false
+	}
+	return dockerMCPAvailabilityCache.available, true
+}
+
+func RefreshDockerMCPAvailability() bool {
+	available := IsDockerMCPAvailable()
+	dockerMCPAvailabilityCache.mu.Lock()
+	dockerMCPAvailabilityCache.available = available
+	dockerMCPAvailabilityCache.checkedAt = time.Now()
+	dockerMCPAvailabilityCache.known = true
+	dockerMCPAvailabilityCache.mu.Unlock()
+	return available
+}
+
 // IsDockerMCPEnabled checks if Docker MCP is already configured.
 func (c *Config) IsDockerMCPEnabled() bool {
 	if c.MCP == nil {

internal/ui/dialog/commands.go 🔗

@@ -37,6 +37,10 @@ const (
 )
 
 // Commands represents a dialog that shows available commands.
+type dockerMCPAvailabilityCheckedMsg struct {
+	available bool
+}
+
 type Commands struct {
 	com    *common.Common
 	keyMap struct {
@@ -66,6 +70,9 @@ type Commands struct {
 
 	customCommands []commands.CustomCommand
 	mcpPrompts     []commands.MCPPrompt
+
+	dockerMCPAvailable   *bool
+	dockerMCPCheckInFlight bool
 }
 
 var _ Dialog = (*Commands)(nil)
@@ -126,6 +133,10 @@ func NewCommands(com *common.Common, sessionID string, hasSession, hasTodos, has
 	closeKey.SetHelp("esc", "cancel")
 	c.keyMap.Close = closeKey
 
+	if available, known := config.DockerMCPAvailabilityCached(); known {
+		c.dockerMCPAvailable = &available
+	}
+
 	// Set initial commands
 	c.setCommandItems(c.selected)
 
@@ -145,6 +156,13 @@ func (c *Commands) ID() string {
 // HandleMsg implements [Dialog].
 func (c *Commands) HandleMsg(msg tea.Msg) Action {
 	switch msg := msg.(type) {
+	case dockerMCPAvailabilityCheckedMsg:
+		c.dockerMCPAvailable = &msg.available
+		c.dockerMCPCheckInFlight = false
+		if c.selected == SystemCommands {
+			c.setCommandItems(c.selected)
+		}
+		return nil
 	case spinner.TickMsg:
 		if c.loading {
 			var cmd tea.Cmd
@@ -207,6 +225,20 @@ func (c *Commands) HandleMsg(msg tea.Msg) Action {
 	return nil
 }
 
+func checkDockerMCPAvailabilityCmd() tea.Cmd {
+	return func() tea.Msg {
+		return dockerMCPAvailabilityCheckedMsg{available: config.RefreshDockerMCPAvailability()}
+	}
+}
+
+func (c *Commands) InitialCmd() tea.Cmd {
+	if c.dockerMCPAvailable != nil || c.dockerMCPCheckInFlight {
+		return nil
+	}
+	c.dockerMCPCheckInFlight = true
+	return checkDockerMCPAvailabilityCmd()
+}
+
 // Cursor returns the cursor position relative to the dialog.
 func (c *Commands) Cursor() *tea.Cursor {
 	return InputCursor(c.com.Styles, c.input.Cursor())
@@ -446,8 +478,8 @@ func (c *Commands) defaultCommands() []*CommandItem {
 		commands = append(commands, NewCommandItem(c.com.Styles, "open_external_editor", "Open External Editor", "ctrl+o", ActionExternalEditor{}))
 	}
 
-	// Add Docker MCP command if available and not already enabled
-	if config.IsDockerMCPAvailable() && !cfg.IsDockerMCPEnabled() {
+	// Add Docker MCP command if available and not already enabled.
+	if !cfg.IsDockerMCPEnabled() && c.dockerMCPAvailable != nil && *c.dockerMCPAvailable {
 		commands = append(commands, NewCommandItem(c.com.Styles, "enable_docker_mcp", "Enable Docker MCP Catalog", "", ActionEnableDockerMCP{}))
 	}
 

internal/ui/model/ui.go 🔗

@@ -3017,7 +3017,7 @@ func (m *UI) openCommandsDialog() tea.Cmd {
 
 	m.dialog.OpenDialog(commands)
 
-	return nil
+	return commands.InitialCmd()
 }
 
 // openReasoningDialog opens the reasoning effort dialog.
@@ -3473,16 +3473,17 @@ func (m *UI) enableDockerMCP() tea.Msg {
 
 	ctx := context.Background()
 	if err := mcp.InitializeSingle(ctx, config.DockerMCPName, store); err != nil {
-		// Roll back in-memory state when startup fails.
+		// Roll back runtime and in-memory state when startup fails.
+		disableErr := mcp.DisableSingle(store, config.DockerMCPName)
 		delete(store.Config().MCP, config.DockerMCPName)
-		return util.ReportError(fmt.Errorf("docker MCP enabled but failed to start: %w", err))()
+		return util.ReportError(fmt.Errorf("failed to start docker MCP: %w", errors.Join(err, disableErr)))()
 	}
 
 	if err := store.PersistDockerMCPConfig(mcpConfig); err != nil {
 		// Roll back runtime and in-memory state if persistence fails.
-		_ = mcp.DisableSingle(store, config.DockerMCPName)
+		disableErr := mcp.DisableSingle(store, config.DockerMCPName)
 		delete(store.Config().MCP, config.DockerMCPName)
-		return util.ReportError(fmt.Errorf("docker MCP started but failed to persist configuration: %w", err))()
+		return util.ReportError(fmt.Errorf("docker MCP started but failed to persist configuration: %w", errors.Join(err, disableErr)))()
 	}
 
 	return util.NewInfoMsg("Docker MCP enabled and started successfully")