返回 DeepSeek-Reasonix
mcp_manager_view.go
根目录 / internal / cli / mcp_manager_view.go
1 package cli
2
3 // mcp_manager_view.go renders the /mcp manager overlay and its display strings.
4
5 import (
6 "fmt"
7 "strings"
8
9 "reasonix/internal/config"
10 "reasonix/internal/mcpdiag"
11 )
12
13 func (m chatTUI) renderMCPManager() string {
14 if m.mcp == nil {
15 return ""
16 }
17 return m.mcp.render(m.width)
18 }
19
20 func (p *mcpManager) render(width int) string {
21 w := max(viewWidth(width), 40)
22 switch p.stage {
23 case mcpStageDetail:
24 return managerContentPanelStyle(w).Render(p.renderDetail(w))
25 case mcpStageTools:
26 return managerContentPanelStyle(w).Render(p.renderTools(w))
27 case mcpStageLogs:
28 return managerContentPanelStyle(w).Render(p.renderLogs(w))
29 case mcpStageConfirmRemove:
30 return managerContentPanelStyle(w).Render(p.renderConfirmRemove(w))
31 case mcpStageConfirmClearAuth:
32 return managerContentPanelStyle(w).Render(p.renderConfirmClearAuth(w))
33 default:
34 return managerContentPanelStyle(w).Render(p.renderList(w))
35 }
36 }
37
38 func (p *mcpManager) footerHint() string {
39 switch p.stage {
40 case mcpStageDetail:
41 return "↑/↓ navigate · r refresh · Enter to select · Esc to back"
42 case mcpStageTools, mcpStageLogs:
43 return "Esc to back"
44 case mcpStageConfirmRemove, mcpStageConfirmClearAuth:
45 return "Enter to select · y confirm · n cancel · Esc to back"
46 default:
47 if len(p.snapshot.servers) == 0 {
48 return "↑/↓ navigate · Enter to confirm · Esc to cancel"
49 }
50 return "↑/↓ navigate · r refresh · Enter for details · Esc to close"
51 }
52 }
53
54 func (p *mcpManager) renderList(width int) string {
55 var b strings.Builder
56 fmt.Fprintf(&b, "%s\n", viewHeader("Manage MCP servers"))
57 fmt.Fprintf(&b, "%s\n\n", viewMeta(fmt.Sprintf("%d servers", len(p.snapshot.servers))))
58 if p.snapshot.err != "" {
59 fmt.Fprintf(&b, "%s\n\n", yellow("config: "+p.snapshot.err))
60 }
61 if len(p.snapshot.servers) == 0 {
62 b.WriteString(viewMeta("No MCP servers configured. Use /mcp add <name> ... to add one.") + "\n\n")
63 return b.String()
64 }
65 start, end := visibleRange(len(p.snapshot.servers), p.sel, mcpListMaxRows)
66 if start > 0 {
67 fmt.Fprintf(&b, "%s\n", viewMeta(fmt.Sprintf("↑ %d more above", start)))
68 }
69 lastGroup := ""
70 for i := start; i < end; i++ {
71 s := p.snapshot.servers[i]
72 group := mcpServerGroupLabel(s)
73 if group != lastGroup {
74 if lastGroup != "" {
75 b.WriteByte('\n')
76 }
77 header := group
78 if group == "Global MCPs" && p.snapshot.configPath != "" {
79 header += " (" + p.snapshot.configPath + ")"
80 }
81 fmt.Fprintf(&b, " %s\n", bold(header))
82 lastGroup = group
83 }
84 b.WriteString(p.renderListRow(i, s, width) + "\n")
85 }
86 if end < len(p.snapshot.servers) {
87 fmt.Fprintf(&b, "%s\n", viewMeta(fmt.Sprintf("↓ %d more below", len(p.snapshot.servers)-end)))
88 }
89 return strings.TrimRight(b.String(), "\n")
90 }
91
92 func (p *mcpManager) renderListRow(i int, s mcpServerView, width int) string {
93 prefix := " "
94 if i == p.sel {
95 prefix = accent(" › ")
96 }
97 nameWidth := min(28, max(12, width/3))
98 name := compactMiddle(s.Name, nameWidth)
99 status := mcpStatusLabel(s)
100 meta := fmt.Sprintf("%s · %s", status, countText(s.Tools, "tool"))
101 if s.Prompts > 0 {
102 meta += " · " + countText(s.Prompts, "prompt")
103 }
104 if s.Resources > 0 {
105 meta += " · " + countText(s.Resources, "resource")
106 }
107 if s.Transport != "" {
108 meta = s.Transport + " · " + meta
109 }
110 used := visibleWidth(prefix) + nameWidth + 3
111 meta = viewCompactText(meta, viewBudget(width, used))
112 name = padRight(name, nameWidth)
113 if i == p.sel {
114 name = bold(name)
115 }
116 return fmt.Sprintf("%s%s · %s", prefix, name, viewMeta(meta))
117 }
118
119 func (p *mcpManager) renderDetail(width int) string {
120 v, ok := p.selectedServer()
121 if !ok {
122 return "MCP server not found\n\n" + dim("Esc to back")
123 }
124 actions := mcpActionsFor(v, p.snapshot.configPath)
125 if p.action >= len(actions) {
126 p.action = max(0, len(actions)-1)
127 }
128 var b strings.Builder
129 fmt.Fprintf(&b, "%s MCP Server\n\n", bold(titleText(v.Name)))
130 writeMCPDetailField(&b, "Status", mcpStatusLabel(v))
131 if auth := mcpAuthLabel(v); auth != "" {
132 writeMCPDetailField(&b, "Auth", auth)
133 }
134 writeMCPDetailField(&b, "Transport", fallbackText(v.Transport, "unknown"))
135 if v.BuiltIn {
136 writeMCPDetailField(&b, "Config location", "built-in")
137 } else if v.Source == config.MCPSourceProjectMCPJSON {
138 writeMCPDetailField(&b, "Config location", "current project .mcp.json")
139 } else if v.Source == config.MCPSourceProjectConfig {
140 writeMCPDetailField(&b, "Config location", "current project reasonix.toml")
141 } else {
142 loc := fallbackText(p.snapshot.configPath, "not saved")
143 if loc != "not saved" {
144 loc = viewCompactPath(loc, viewBudget(width, 18))
145 }
146 writeMCPDetailField(&b, "Config location", loc)
147 }
148 writeMCPDetailField(&b, "Capabilities", mcpCapabilitiesText(v))
149 writeMCPDetailField(&b, "Tools", countText(v.Tools, "tool"))
150 if line := mcpCommandLine(v); line != "" {
151 writeMCPDetailField(&b, mcpCommandLabel(v), viewCompactText(line, viewBudget(width, 18)))
152 }
153 if len(v.EnvKeys) > 0 {
154 writeMCPDetailField(&b, "Env", strings.Join(v.EnvKeys, ", "))
155 }
156 if v.Error != "" {
157 writeMCPDetailField(&b, "Error", viewCompactText(v.Error, viewBudget(width, 18)))
158 }
159 b.WriteByte('\n')
160 if len(actions) == 0 {
161 b.WriteString(viewMeta("No actions available.") + "\n")
162 } else {
163 for i, a := range actions {
164 b.WriteString(rowLine(i == p.action, i+1, "", a.label, false) + "\n")
165 }
166 }
167 return strings.TrimRight(b.String(), "\n")
168 }
169
170 func (p *mcpManager) renderTools(width int) string {
171 v, ok := p.selectedServer()
172 if !ok {
173 return "MCP server not found\n\n" + dim("Esc to back")
174 }
175 var b strings.Builder
176 fmt.Fprintf(&b, "%s tools\n\n", bold(v.Name))
177 if len(v.ToolList) == 0 {
178 b.WriteString(viewMeta("Current connection did not return tool details.") + "\n")
179 } else {
180 limit := len(v.ToolList)
181 if limit > mcpToolMaxRows {
182 limit = mcpToolMaxRows
183 }
184 for _, t := range v.ToolList[:limit] {
185 desc := t.Description
186 if t.SchemaError != "" {
187 desc = "unavailable: " + t.SchemaError
188 }
189 desc = viewCompactText(desc, viewBudget(width, 24))
190 fmt.Fprintf(&b, " %-20s %s\n", t.Name, viewMeta(desc))
191 }
192 if extra := len(v.ToolList) - limit; extra > 0 {
193 fmt.Fprintf(&b, "%s\n", viewMore(extra, "tools"))
194 }
195 }
196 return strings.TrimRight(b.String(), "\n")
197 }
198
199 func (p *mcpManager) renderLogs(width int) string {
200 v, ok := p.selectedServer()
201 if !ok {
202 return "MCP server not found\n\n" + dim("Esc to back")
203 }
204 var b strings.Builder
205 fmt.Fprintf(&b, "%s logs\n\n", bold(v.Name))
206 if strings.TrimSpace(v.Error) == "" {
207 b.WriteString(viewMeta("No failure log recorded for this MCP.") + "\n")
208 } else {
209 b.WriteString(viewProtectLines(v.Error, viewBudget(width, 2)) + "\n")
210 }
211 return strings.TrimRight(b.String(), "\n")
212 }
213
214 func (p *mcpManager) renderConfirmRemove(width int) string {
215 v, ok := p.selectedServer()
216 if !ok {
217 return "MCP server not found\n\n" + dim("Esc to back")
218 }
219 var b strings.Builder
220 fmt.Fprintf(&b, "Remove MCP server %q?\n", v.Name)
221 b.WriteString(viewMeta("This removes it from Reasonix config. It cannot be undone from this panel.") + "\n\n")
222 b.WriteString(rowLine(p.confirm == 0, 1, "", "Confirm remove", false) + "\n")
223 b.WriteString(rowLine(p.confirm == 1, 2, "", "Cancel", false) + "\n")
224 return strings.TrimRight(b.String(), "\n")
225 }
226
227 func (p *mcpManager) renderConfirmClearAuth(width int) string {
228 v, ok := p.selectedServer()
229 if !ok {
230 return "MCP server not found\n\n" + dim("Esc to back")
231 }
232 var b strings.Builder
233 fmt.Fprintf(&b, "Clear authentication for MCP server %q?\n", v.Name)
234 hint := "This removes local auth-like headers, environment values, and URL tokens; the server stays in config."
235 b.WriteString(viewMeta(viewCompactText(hint, viewBudget(width, 0))) + "\n\n")
236 b.WriteString(rowLine(p.confirm == 0, 1, "", "Confirm clear authentication", false) + "\n")
237 b.WriteString(rowLine(p.confirm == 1, 2, "", "Cancel", false) + "\n")
238 return strings.TrimRight(b.String(), "\n")
239 }
240
241 func mcpActionsFor(v mcpServerView, configPath string) []mcpActionItem {
242 var out []mcpActionItem
243 managed := v.BuiltIn || v.Source == config.MCPSourcePluginPackage
244 if v.Tools > 0 || len(v.ToolList) > 0 {
245 out = append(out, mcpActionItem{mcpActionViewTools, "View tools"})
246 }
247 if v.Status == "failed" {
248 if mcpAuthStatus(v) == mcpdiag.AuthRequired {
249 out = append(out, mcpActionItem{mcpActionAuth, "Authenticate"})
250 } else {
251 out = append(out, mcpActionItem{mcpActionConnect, "Retry"})
252 }
253 if mcpCanClearAuth(v) {
254 out = append(out, mcpActionItem{mcpActionClearAuth, "Clear authentication"})
255 }
256 out = appendMCPFailureSecondaryActions(out, v, configPath)
257 return out
258 }
259 switch v.Status {
260 case "connected":
261 out = append(out, mcpActionItem{mcpActionConnect, "Reconnect"})
262 case "deferred":
263 out = append(out, mcpActionItem{mcpActionConnect, "Reconnect"})
264 case "disabled":
265 out = append(out, mcpActionItem{mcpActionConnect, "Enable and connect"})
266 }
267 out = appendMCPConfigActions(out, v, configPath)
268 if mcpCanClearAuth(v) {
269 out = append(out, mcpActionItem{mcpActionClearAuth, "Clear authentication"})
270 }
271 if v.Status != "disabled" {
272 out = append(out, mcpActionItem{mcpActionDisable, "Disable for this session"})
273 }
274 if !managed {
275 out = append(out, mcpActionItem{mcpActionRemove, "Remove server"})
276 }
277 return out
278 }
279
280 func appendMCPFailureSecondaryActions(out []mcpActionItem, v mcpServerView, configPath string) []mcpActionItem {
281 managed := v.BuiltIn || v.Source == config.MCPSourcePluginPackage
282 if strings.TrimSpace(v.Error) != "" {
283 out = append(out, mcpActionItem{mcpActionLogs, "View logs"})
284 }
285 out = appendMCPConfigActions(out, v, configPath)
286 if v.Status != "disabled" {
287 out = append(out, mcpActionItem{mcpActionDisable, "Disable for this session"})
288 }
289 if !managed {
290 out = append(out, mcpActionItem{mcpActionRemove, "Remove server"})
291 }
292 return out
293 }
294
295 func appendMCPConfigActions(out []mcpActionItem, v mcpServerView, configPath string) []mcpActionItem {
296 if v.Configured {
297 path := mcpConfigPathForView(v, configPath)
298 if !v.BuiltIn && v.Source != config.MCPSourcePluginPackage && path != "" {
299 out = append(out, mcpActionItem{mcpActionEdit, "Edit config"})
300 }
301 }
302 return out
303 }
304
305 func mcpConfigPathForView(v mcpServerView, fallback string) string {
306 if path := strings.TrimSpace(v.ConfigPath); path != "" {
307 return path
308 }
309 return strings.TrimSpace(fallback)
310 }
311
312 func writeMCPDetailField(b *strings.Builder, label, value string) {
313 if strings.TrimSpace(value) == "" {
314 return
315 }
316 fmt.Fprintf(b, "%-16s %s\n", label+":", value)
317 }
318
319 func mcpStatusLabel(v mcpServerView) string {
320 switch {
321 case v.Status == "connected":
322 return green("✓ connected")
323 case v.Status == "failed" && mcpAuthStatus(v) == mcpdiag.AuthRequired:
324 return yellow("⚠ needs authentication")
325 case v.Status == "failed":
326 return red("✕ failed")
327 case v.Status == "deferred":
328 return "○ preparing in background"
329 case v.Status == "initializing":
330 return "◌ connecting..."
331 case v.Status == "disabled":
332 return "○ disabled"
333 default:
334 return viewMeta("unknown")
335 }
336 }
337
338 func mcpServerGroupLabel(v mcpServerView) string {
339 switch mcpServerGroupRank(v) {
340 case 0:
341 return "Managed MCPs"
342 case 1:
343 return "Project MCPs"
344 default:
345 return "Global MCPs"
346 }
347 }
348
349 func mcpAuthLabel(v mcpServerView) string {
350 switch {
351 case v.Status == "connected":
352 return green("✓ authenticated")
353 case mcpAuthStatus(v) == mcpdiag.AuthRequired:
354 return red("✕ not authenticated")
355 case mcpAuthStatus(v) == mcpdiag.AuthPossible:
356 return yellow("may need authorization")
357 default:
358 return ""
359 }
360 }
361
362 func mcpCapabilitiesText(v mcpServerView) string {
363 var caps []string
364 if v.HasTools || v.Tools > 0 {
365 caps = append(caps, "tools")
366 }
367 if v.Prompts > 0 {
368 caps = append(caps, "prompts")
369 }
370 if v.Resources > 0 {
371 caps = append(caps, "resources")
372 }
373 if len(caps) == 0 {
374 return "none"
375 }
376 return strings.Join(caps, ", ")
377 }
378
379 func mcpCommandLabel(v mcpServerView) string {
380 if v.Transport == "http" || v.Transport == "sse" {
381 return "URL"
382 }
383 return "Command"
384 }
385
386 func mcpCommandLine(v mcpServerView) string {
387 if v.Transport == "http" || v.Transport == "sse" {
388 return strings.TrimSpace(v.URL)
389 }
390 return strings.TrimSpace(v.Command + " " + strings.Join(v.Args, " "))
391 }
392
392 lines GO