Files
zero/internal/tui/command_views.go
Vasanth T 250bf24c9d feat: add MCP manager UX (#188)
* feat: add MCP manager UX

* fix: harden MCP manager UX

* test: stabilize agent eval timeout on Windows

* fix: preserve MCP server metadata

* fix: clarify empty MCP view

* feat: make MCP command a manager view

* fix: make MCP manager usable in TUI

* fix: make MCP manager selectable in TUI

* fix: simplify MCP manager popup

* feat: add searchable MCP marketplace

* fix: address MCP review issues

* fix: address MCP follow-up review

* feat: add MCP setup wizard

* feat: detect MCP setup requests in chat

* fix: address MCP manager review feedback

* fix: avoid hijacking MCP chat prompts

* fix: persist MCP view cache fallback
2026-06-13 22:20:42 +05:30

543 lines
15 KiB
Go

package tui
import (
"context"
"fmt"
"sort"
"strings"
tea "github.com/charmbracelet/bubbletea"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/providercatalog"
"github.com/Gitlawb/zero/internal/zerocommands"
)
func (m model) toolsText() string {
registered := m.registry.All()
if len(registered) == 0 {
return renderCommandOutput(commandOutput{
Title: "Tools",
Status: commandStatusWarning,
Sections: []commandSection{{
Title: "Registry",
Lines: []string{"registered tools: 0"},
}},
})
}
names := make([]string, 0, len(registered))
for _, tool := range registered {
names = append(names, commandBullet(tool.Name()))
}
sort.Strings(names)
return renderCommandOutput(commandOutput{
Title: "Tools",
Status: commandStatusOK,
Sections: []commandSection{
{
Title: "Registry",
Lines: []string{fmt.Sprintf("registered tools: %d", len(names))},
},
{
Title: "Available",
Lines: names,
},
},
})
}
func (m *model) mcpText() string {
width := 0
if m.width > 0 {
width = chatWidth(m.width)
}
return renderMCPView(m.mcpViewState(), width)
}
func (m *model) refreshMCPViewState() {
m.mcpViewStateCache = BuildMCPViewState(MCPStateOptions{
Config: m.mcpConfig,
Registry: m.registry,
PermissionStore: m.mcpPermissionStore,
PermissionMode: string(m.permissionMode),
TokenStore: m.mcpTokenStore,
})
m.mcpViewStateReady = true
}
func (m *model) mcpViewState() MCPViewState {
if m.mcpViewStateReady {
return m.mcpViewStateCache
}
// Older tests may construct a zero-value model; keep that path useful, while
// production refreshes the cache before any MCP view can render.
m.refreshMCPViewState()
return m.mcpViewStateCache
}
func (m model) startMCPTranscriptCommand(args string) (model, tea.Cmd) {
args = strings.TrimSpace(args)
if args == "" {
m.transcript = appendTranscriptRow(m.transcript, transcriptRow{kind: rowSystem, tool: "mcp", text: m.mcpText()})
return m, nil
}
parsedArgs, err := splitMCPCommandArgs(args)
if err != nil {
text := strings.Join([]string{
"MCP action failed",
err.Error(),
"",
m.mcpText(),
}, "\n")
m.transcript = appendTranscriptRow(m.transcript, transcriptRow{kind: rowSystem, tool: "mcp", text: text})
return m, nil
}
return m.startMCPCommand(mcpCommandRequest{origin: mcpCommandOriginTranscript, raw: args, args: parsedArgs})
}
func (m model) startMCPCommand(request mcpCommandRequest) (model, tea.Cmd) {
if m.mcpCommand == nil {
result := MCPCommandResult{
ExitCode: 1,
Error: "MCP action unavailable",
Config: m.mcpConfig,
}
return m.applyMCPCommandResultMessage(mcpCommandResultMsg{request: request, result: result}), nil
}
m.cancelMCPCommand()
ctx := m.ctx
if ctx == nil {
ctx = context.Background()
}
ctx, cancel := context.WithCancel(ctx)
m.mcpCommandSeq++
request.id = m.mcpCommandSeq
request.args = append([]string{}, request.args...)
m.mcpCommandCancel = cancel
runner := m.mcpCommand
return m, func() tea.Msg {
return mcpCommandResultMsg{
request: request,
result: runner(ctx, request.args),
}
}
}
func (m *model) cancelMCPCommand() {
if m.mcpCommandCancel != nil {
m.mcpCommandCancel()
m.mcpCommandCancel = nil
m.mcpCommandSeq++
}
}
func (m model) applyMCPCommandResultMessage(msg mcpCommandResultMsg) model {
if msg.request.id != 0 && msg.request.id != m.mcpCommandSeq {
return m
}
m.mcpCommandCancel = nil
switch msg.request.origin {
case mcpCommandOriginManager:
text := ""
m, text = m.applyMCPCommandResult(strings.Join(msg.request.args, " "), msg.result)
m.mcpManager = &mcpManagerState{selected: msg.request.managerSelected, query: msg.request.managerQuery}
if items := m.mcpManagerItems(); len(items) > 0 {
m.mcpManager.selected = clampInt(m.mcpManager.selected, 0, len(items)-1)
}
if text != "" {
m.transcript = appendTranscriptRow(m.transcript, transcriptRow{kind: rowSystem, tool: "mcp", text: text})
}
case mcpCommandOriginWizard:
m = m.applyMCPAddWizardSaveResult(msg.result, msg.request.wizardDisabled)
default:
text := ""
m, text = m.applyMCPCommandResult(msg.request.raw, msg.result)
m.transcript = appendTranscriptRow(m.transcript, transcriptRow{kind: rowSystem, tool: "mcp", text: text})
}
return m
}
func (m model) applyMCPCommandResult(args string, result MCPCommandResult) (model, string) {
if result.ExitCode != 0 || strings.TrimSpace(result.Error) != "" {
message := strings.TrimSpace(result.Error)
if message == "" {
message = strings.TrimSpace(result.Output)
}
if message == "" {
message = "MCP command failed"
}
return m, strings.Join([]string{
"MCP action failed",
message,
"",
m.mcpText(),
}, "\n")
}
if len(result.Config.Servers) > 0 || len(m.mcpConfig.Servers) > 0 {
m.mcpConfig = result.Config
m.refreshMCPViewState()
}
output := strings.TrimSpace(result.Output)
if output == "" {
output = "zero mcp " + args
}
return m, strings.Join([]string{
"MCP action complete",
output,
"",
m.mcpText(),
}, "\n")
}
func splitMCPCommandArgs(args string) ([]string, error) {
args = strings.TrimSpace(args)
if args == "" {
return nil, nil
}
out := []string{}
var current strings.Builder
var quote rune
hasToken := false
runes := []rune(args)
for index := 0; index < len(runes); index++ {
r := runes[index]
if quote != 0 {
if r == quote {
quote = 0
hasToken = true
continue
}
if r == '\\' && index+1 < len(runes) && runes[index+1] == quote {
index++
current.WriteRune(runes[index])
hasToken = true
continue
}
current.WriteRune(r)
hasToken = true
continue
}
switch {
case r == '\'' || r == '"':
quote = r
hasToken = true
case r == '\\' && index+1 < len(runes) && (runes[index+1] == '\'' || runes[index+1] == '"' || strings.TrimSpace(string(runes[index+1])) == ""):
index++
current.WriteRune(runes[index])
hasToken = true
case strings.TrimSpace(string(r)) == "":
if hasToken {
out = append(out, current.String())
current.Reset()
hasToken = false
}
default:
current.WriteRune(r)
hasToken = true
}
}
if quote != 0 {
return nil, fmt.Errorf("unterminated quote in MCP command")
}
if hasToken {
out = append(out, current.String())
}
return out, nil
}
func (m model) permissionsText() string {
stateLines := []string{
"Permission mode: " + string(m.permissionMode),
}
if m.sandboxStore == nil {
return renderCommandOutput(commandOutput{
Title: "Permissions",
Status: commandStatusWarning,
Sections: []commandSection{
{Title: "State", Lines: stateLines},
{Title: "Sandbox grants:", Lines: []string{"persistent grants: unavailable"}},
},
})
}
grants, err := m.sandboxStore.List()
if err != nil {
return renderCommandOutput(commandOutput{
Title: "Permissions",
Status: commandStatusBlocked,
Sections: []commandSection{
{Title: "State", Lines: stateLines},
{Title: "Sandbox grants:", Lines: []string{"error: " + err.Error()}},
},
})
}
snapshots := zerocommands.SandboxGrantSnapshots(grants)
grantLines := []string{fmt.Sprintf("persistent grants: %d", len(snapshots))}
if len(snapshots) == 0 {
grantLines = append(grantLines, "none")
} else {
for _, grant := range snapshots {
line := fmt.Sprintf("%s [%s/%s]", grant.ToolName, grant.Decision, grant.MaxAutonomy)
if grant.ApprovedAt != "" {
line += " approved " + grant.ApprovedAt
}
if grant.Reason != "" {
line += " - " + grant.Reason
}
grantLines = append(grantLines, commandBullet(line))
}
}
return renderCommandOutput(commandOutput{
Title: "Permissions",
Status: commandStatusOK,
Sections: []commandSection{
{Title: "State", Lines: stateLines},
{Title: "Sandbox grants:", Lines: grantLines},
},
})
}
func (m model) providerText() string {
profileLines := []string{
"provider: " + displayValue(m.providerName, "none"),
"model: " + displayValue(m.modelName, "none"),
}
if !config.HasProviderProfile(m.providerProfile) {
profileLines = append(profileLines, "profile: not configured")
return renderCommandOutput(commandOutput{
Title: "Provider",
Status: commandStatusWarning,
Sections: []commandSection{
{Title: "Active", Lines: profileLines},
{Title: "Next actions", Lines: []string{
"zero providers catalog",
"zero providers setup openai --set-active",
"zero providers add openai --api-key-env OPENAI_API_KEY --set-active",
}},
},
})
}
snapshot := zerocommands.ProviderSnapshotFromProfile(m.providerProfile, true)
profileLines = append(profileLines,
"active: "+boolText(snapshot.Active),
"kind: "+displayValue(snapshot.ProviderKind, "unknown"),
"api model: "+displayValue(snapshot.APIModel, "unknown"),
"base url: "+displayValue(snapshot.BaseURL, "default"),
"api key: "+apiKeyState(snapshot.APIKeySet),
)
if snapshot.Message != "" {
profileLines = append(profileLines, "provider status: "+snapshot.Status+" - "+snapshot.Message)
}
status := commandStatusOK
actionLines := providerNextActionLines(m.providerProfile, snapshot, m.providerName)
if providerCredentialRequired(m.providerProfile, snapshot.ProviderKind) && !providerProfileHasCredential(m.providerProfile) {
status = commandStatusWarning
}
return renderCommandOutput(commandOutput{
Title: "Provider",
Status: status,
Sections: []commandSection{
{Title: "Active", Lines: profileLines},
{Title: "Next actions", Lines: actionLines},
},
})
}
func providerNextActionLines(profile config.ProviderProfile, snapshot zerocommands.ProviderSnapshot, activeName string) []string {
providerName := firstProviderDisplayValue(snapshot.Name, activeName, profile.Name, providerSetupCatalogID(profile, snapshot.ProviderKind), "openai")
setupID := providerSetupCatalogID(profile, snapshot.ProviderKind)
lines := []string{}
if providerCredentialRequired(profile, snapshot.ProviderKind) && !providerProfileHasCredential(profile) {
if envName := providerCredentialEnvName(profile, snapshot.ProviderKind); envName != "" {
lines = append(lines,
"set "+envName+" in your environment",
"zero providers add "+setupID+" --api-key-env "+envName+" --set-active",
)
} else {
lines = append(lines, "set provider credentials in your environment")
}
}
return append(lines,
"zero providers check "+providerName+" --connectivity",
"zero providers catalog",
"zero providers setup "+setupID+" --set-active",
)
}
func providerProfileHasCredential(profile config.ProviderProfile) bool {
return strings.TrimSpace(profile.APIKey) != "" || strings.TrimSpace(profile.AuthHeaderValue) != ""
}
func providerCredentialRequired(profile config.ProviderProfile, providerKind string) bool {
if descriptor, ok := providerCatalogDescriptor(profile); ok {
return descriptor.RequiresAuth
}
switch config.ProviderKind(strings.TrimSpace(providerKind)) {
case config.ProviderKindOpenAI, config.ProviderKindOpenAICompatible, config.ProviderKindAnthropic, config.ProviderKindAnthropicCompat, config.ProviderKindGoogle:
return true
default:
return false
}
}
func providerCredentialEnvName(profile config.ProviderProfile, providerKind string) string {
if envName := strings.TrimSpace(profile.APIKeyEnv); envName != "" {
return envName
}
if descriptor, ok := providerCatalogDescriptor(profile); ok && len(descriptor.AuthEnvVars) > 0 {
return descriptor.AuthEnvVars[0]
}
switch config.ProviderKind(strings.TrimSpace(providerKind)) {
case config.ProviderKindOpenAI, config.ProviderKindOpenAICompatible:
return "OPENAI_API_KEY"
case config.ProviderKindAnthropic, config.ProviderKindAnthropicCompat:
return "ANTHROPIC_API_KEY"
case config.ProviderKindGoogle:
return "GEMINI_API_KEY"
default:
return ""
}
}
func providerSetupCatalogID(profile config.ProviderProfile, providerKind string) string {
if catalogID := strings.TrimSpace(profile.CatalogID); catalogID != "" {
return catalogID
}
switch config.ProviderKind(strings.TrimSpace(providerKind)) {
case config.ProviderKindOpenAI:
return "openai"
case config.ProviderKindAnthropic:
return "anthropic"
case config.ProviderKindGoogle:
return "google"
case config.ProviderKindOpenAICompatible:
return "custom-openai-compatible"
case config.ProviderKindAnthropicCompat:
return "custom-anthropic-compatible"
default:
return firstProviderDisplayValue(profile.Name, "openai")
}
}
func providerCatalogDescriptor(profile config.ProviderProfile) (providercatalog.Descriptor, bool) {
catalogID := strings.TrimSpace(profile.CatalogID)
if catalogID == "" {
return providercatalog.Descriptor{}, false
}
descriptor, err := providercatalog.Require(catalogID)
if err != nil {
return providercatalog.Descriptor{}, false
}
return descriptor, true
}
func firstProviderDisplayValue(values ...string) string {
for _, value := range values {
value = strings.TrimSpace(value)
if value != "" {
return value
}
}
return ""
}
func (m model) modelText(args string) string {
return renderCommandOutput(commandOutput{
Title: "Model",
Status: commandStatusOK,
Sections: []commandSection{{
Title: "Active",
Lines: []string{
"model: " + displayValue(m.modelName, "none"),
"provider: " + displayValue(m.providerName, "none"),
"effort: " + m.effortDisplay(),
},
}},
Hints: []string{"use /model list to inspect models or /model <id> to switch this TUI session"},
})
}
func (m model) contextText() string {
toolCount := len(m.registry.All())
return renderCommandOutput(commandOutput{
Title: "Context",
Status: commandStatusOK,
Sections: []commandSection{
{
Title: "Runtime",
Lines: []string{
"cwd: " + displayValue(m.cwd, "unknown"),
"provider: " + displayValue(m.providerName, "none"),
"model: " + displayValue(m.modelName, "none"),
"permission mode: " + string(m.permissionMode),
"effort: " + m.effortDisplay(),
"style: " + m.responseStyle,
"usage: " + m.usageSummaryText(),
fmt.Sprintf("max turns: %d", m.agentOptions.MaxTurns),
},
},
{
Title: "Session",
Lines: []string{
"active session: " + displayValue(m.activeSession.SessionID, "none"),
"session root: " + displayValue(m.sessionStore.RootDir, "unknown"),
"compaction: " + m.compactionStatus(),
},
},
{
Title: "Tools",
Lines: []string{
fmt.Sprintf("registered tools: %d", toolCount),
},
},
},
})
}
func (m model) configText() string {
return renderCommandOutput(commandOutput{
Title: "Config",
Status: commandStatusOK,
Sections: []commandSection{
{
Title: "Runtime",
Lines: []string{
"runtime: go",
fmt.Sprintf("max turns: %d", m.agentOptions.MaxTurns),
"permission mode: " + string(m.permissionMode),
},
},
{
Title: "Provider",
Lines: []string{
"provider: " + displayValue(m.providerName, "none"),
"model: " + displayValue(m.modelName, "none"),
"api key: " + apiKeyState(strings.TrimSpace(m.providerProfile.APIKey) != ""),
},
},
},
})
}
func (m model) debugText() string {
state := "idle"
if m.pending {
state = "running"
}
return renderCommandOutput(commandOutput{
Title: "Debug",
Status: commandStatusInfo,
Sections: []commandSection{{
Title: "Runtime",
Lines: []string{
"run state: " + state,
"active run: " + fmt.Sprint(m.activeRunID),
"pending permission: " + boolText(m.pendingPermission != nil),
},
}},
})
}