diff --git a/internal/config/docker_mcp.go b/internal/config/docker_mcp.go index 01445a9ae7583b8ac489542d08d745e49907521a..0bac24dff2aedb4fd27023f604e1978b7f936852 100644 --- a/internal/config/docker_mcp.go +++ b/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 { diff --git a/internal/ui/dialog/commands.go b/internal/ui/dialog/commands.go index b29711742ff540434465b9d4d30fa452860c4efc..429da6a638f1fd361ad8b609f69dbf712c0f6051 100644 --- a/internal/ui/dialog/commands.go +++ b/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{})) } diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index e87bc148c6a8e0286ed138cbd426697ac039a5d5..5b0c0350998a28b3490a7445d832bfbdec1fd3db 100644 --- a/internal/ui/model/ui.go +++ b/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")