okami.mom/albedo

git clone https://git.nkp.pet/okami.mom/albedo

dawntui: skills, instructions and mcp on listView with global/session toggles and a pane without enter96704f7

main
25.9 KiB769 linesraw
1package tui
2
3import (
4	"albedo/cli/internal/config"
5	"albedo/cli/internal/daemon"
6	"context"
7	"errors"
8	"fmt"
9	"regexp"
10	"slices"
11	"strings"
12
13	tea "charm.land/bubbletea/v2"
14	"github.com/charmbracelet/x/ansi"
15)
16
17// CapabilityPageModel lists one kind of agent context: skills, instruction
18// files, or MCP servers. Enter changes the global default and shift+enter
19// changes this session only. MCP servers are added and edited through one
20// form (mcp_form.go) that opens over the list.
21type CapabilityPageModel struct {
22	Prefs                            config.CapabilityPrefs
23	Conn                             *daemon.Connection
24	Form                             *mcpForm
25	SessionID, Kind                  string
26	Items                            []capabilityItem
27	Revision                         string
28	SessionETag, GlobalETag, MCPETag string
29	Diagnostics                      []string
30	ExtensionEnabled                 bool
31	pageStatus
32	listView
33	confirm
34}
35
36type capabilityItem struct {
37	ID, Title, Detail string
38	Candidate         daemon.CatalogCandidate
39	Secrets           daemon.MCPSecretNames
40	Server            config.MCPServer
41}
42type capabilityLoadedMsg struct {
43	SessionETag, GlobalETag, MCPETag string
44	Prefs                            config.CapabilityPrefs
45	Revision                         string
46	Diagnostics                      []string
47	Err                              error
48	Items                            []capabilityItem
49	Gen                              int
50	ExtensionEnabled                 bool
51}
52type capabilitySavedMsg struct {
53	Err     error
54	Warning string
55	Gen     int
56}
57type CapabilityPageDoneMsg struct{}
58type CapabilityPageChangedMsg struct{}
59
60type ChatOpenCapabilityPageMsg struct{ Kind string }
61
62// The confirmations that are not a scope change; the scopes are the other
63// confirm.what values.
64const (
65	actionEnableExtension = "extension"
66	actionDeleteServer    = "delete"
67)
68
69// capabilityNouns names what each page lists, singular; counted pluralizes it.
70var capabilityNouns = map[string]string{"skills": "skill", "instructions": "instruction file", "mcp": "MCP server"}
71
72var mcpHeaderName = regexp.MustCompile(`^[!#$%&'*+.^_` + "`" + `|~0-9A-Za-z-]+$`)
73var mcpEnvName = regexp.MustCompile(`^[A-Za-z_][A-Za-z_0-9]*$`)
74var mcpName = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$`)
75
76func NewCapabilityPageModel(conn *daemon.Connection, sessionID, kind string) CapabilityPageModel {
77	noun := capabilityNouns[kind]
78	return CapabilityPageModel{
79		Conn: conn, SessionID: sessionID, Kind: kind,
80		pageStatus: newPageStatus(true),
81		listView:   newListView("Search " + noun + "s"),
82	}
83}
84
85func (m *CapabilityPageModel) SetSize(width, height int) {
86	m.pageStatus.SetSize(width, height)
87	m.listView.setSize(width, height)
88}
89
90// Confirming reports a question waiting for enter or esc.
91func (m CapabilityPageModel) Confirming() bool { return m.confirm.asking() }
92
93func (m CapabilityPageModel) Init() tea.Cmd { return m.loadCmd(m.Generation) }
94
95func (m CapabilityPageModel) loadCmd(gen int) tea.Cmd {
96	kind := m.Kind
97	return func() tea.Msg {
98		catalog, err := daemon.GetCapabilityCatalog(context.Background(), m.Conn, m.SessionID)
99		if err != nil {
100			return capabilityLoadedMsg{Gen: gen, Err: err}
101		}
102		settings, err := daemon.GetSettings(context.Background(), m.Conn)
103		if err != nil {
104			return capabilityLoadedMsg{Gen: gen, Err: err}
105		}
106		configuration, err := daemon.GetSessionConfiguration(context.Background(), m.Conn, m.SessionID)
107		if err != nil {
108			return capabilityLoadedMsg{Gen: gen, Err: err}
109		}
110		items := []capabilityItem{}
111		switch kind {
112		case "mcp":
113			items = mcpItems(settings)
114			for i := range items {
115				for _, candidate := range catalog.Candidates {
116					if candidate.Kind == "mcp" && candidate.PreferenceKey != nil && *candidate.PreferenceKey == "mcp:"+items[i].ID {
117						items[i].Candidate = candidate
118					}
119				}
120			}
121		case "skills", "instructions":
122			for _, candidate := range catalog.Candidates {
123				if candidate.Kind == kind {
124					items = append(items, capabilityItem{ID: candidate.ID, Title: candidate.Title, Detail: candidate.Source, Candidate: candidate})
125				}
126			}
127		default:
128			return capabilityLoadedMsg{Gen: gen, Err: errors.New("unknown capability page")}
129		}
130		slices.SortFunc(items, func(a, b capabilityItem) int { return strings.Compare(a.ID, b.ID) })
131		enabled := catalog.Extensions[kind]
132		return capabilityLoadedMsg{Items: items, Revision: catalog.Revision, Diagnostics: catalog.Diagnostics, Prefs: settings.Capabilities, ExtensionEnabled: enabled, SessionETag: configuration.ETag, GlobalETag: settings.ETags["capabilities"], MCPETag: settings.ETags["mcp"], Gen: gen}
133	}
134}
135
136// mcpItems lists the configured servers with the names of the secrets the
137// daemon holds for them (never their contents).
138func mcpItems(settings daemon.Settings) []capabilityItem {
139	items := make([]capabilityItem, 0, len(settings.MCP))
140	for name, server := range settings.MCP {
141		address := server.URL
142		if server.Type == "stdio" {
143			address = server.Command
144		}
145		items = append(items, capabilityItem{ID: name, Title: name, Detail: server.Type + " · " + address, Server: server, Secrets: settings.Credentials.MCP[name]})
146	}
147	return items
148}
149
150func (m CapabilityPageModel) enableExtensionCmd(gen int) tea.Cmd {
151	return func() tea.Msg {
152		_, err := daemon.SelectExtension(context.Background(), m.Conn, m.SessionID, daemon.ExtensionSelectionRequest{Name: m.Kind, Scope: scopeSession, ETag: m.SessionETag, Enabled: new(true)})
153		if err != nil {
154			return capabilitySavedMsg{Gen: gen, Err: err}
155		}
156		reloaded, reloadErr := daemon.ReloadSession(context.Background(), m.Conn, m.SessionID, daemon.ReloadRequest{Target: "session"})
157		warning := reloaded.Message()
158		switch {
159		case reloadErr != nil:
160			warning = "Extension enabled; reload failed: " + reloadErr.Error()
161			if _, uncertain := errors.AsType[*daemon.UncertainOutcomeError](reloadErr); uncertain {
162				warning = "Extension enabled; reload not confirmed: " + reloadErr.Error()
163			}
164		case reloaded.Session == nil:
165			warning = "Extension enabled; reload returned no session outcome."
166		case reloaded.Session.State != "applied":
167			warning = "Extension enabled; session reload " + reloaded.Session.State + ". " + reloaded.Message()
168		}
169		return capabilitySavedMsg{Gen: gen, Warning: warning}
170	}
171}
172
173// choiceCmd sends one selection in a scope: enabled sets it, nil drops this
174// session's own choice.
175func (m CapabilityPageModel) choiceCmd(item capabilityItem, scope string, enabled *bool, gen int) tea.Cmd {
176	etag := m.SessionETag
177	if scope == scopeGlobal {
178		etag = m.GlobalETag
179	}
180	return func() tea.Msg {
181		var result daemon.ReloadResult
182		var err error
183		if m.Kind == "mcp" {
184			result, err = daemon.SetCapability(context.Background(), m.Conn, m.SessionID, daemon.CapabilitySelectionRequest{Kind: m.Kind, Name: capabilityChoiceID(item, scope), Revision: m.Revision, ETag: etag, Scope: scope, Enabled: enabled})
185		} else {
186			result, err = daemon.SetCatalogCapability(context.Background(), m.Conn, m.SessionID, daemon.CatalogCapabilityRequest{Revision: m.Revision, ID: item.ID, Kind: m.Kind, ETag: etag, Scope: scope, Enabled: enabled})
187		}
188		return capabilitySavedMsg{Gen: gen, Err: err, Warning: strings.TrimSpace(result.Message + " " + result.Warning)}
189	}
190}
191
192// saveMCPCmd saves the definition and secrets together under the observed group validator.
193func (m CapabilityPageModel) saveMCPCmd(sub mcpSubmission, gen int) tea.Cmd {
194	return func() tea.Msg {
195		result, err := daemon.SaveMCP(context.Background(), m.Conn, m.SessionID, daemon.MCPUpdateRequest{Name: sub.Name, ETag: m.MCPETag, StoredSecrets: m.storedSecrets(sub.Name), Server: &sub.Server, Secrets: sub.Secrets})
196		return capabilitySavedMsg{Gen: gen, Err: err, Warning: strings.TrimSpace(result.Message + " " + result.Warning)}
197	}
198}
199
200func (m CapabilityPageModel) deleteMCPCmd(name string, gen int) tea.Cmd {
201	return func() tea.Msg {
202		result, err := daemon.SaveMCP(context.Background(), m.Conn, m.SessionID, daemon.MCPUpdateRequest{Name: name, ETag: m.MCPETag, Server: nil, Secrets: daemon.MCPSecretsPatch{}})
203		return capabilitySavedMsg{Gen: gen, Err: err, Warning: strings.TrimSpace(result.Message + " " + result.Warning)}
204	}
205}
206
207// openForm starts adding a server, or editing the highlighted one with its
208// stored credentials summarized (never shown).
209func (m *CapabilityPageModel) openForm(edit bool) {
210	m.Error, m.Notice = "", ""
211	if !edit {
212		m.Form = newMCPForm("", config.MCPServer{Type: "http"}, daemon.MCPSecretNames{})
213		return
214	}
215	item, ok := m.current()
216	if !ok {
217		return
218	}
219	m.Form = newMCPForm(item.ID, item.Server, item.Secrets)
220}
221
222// save starts the request cmd builds under a fresh generation, so its reply
223// is the only one that settles the save.
224func (m CapabilityPageModel) save(cmd func(int) tea.Cmd) (CapabilityPageModel, tea.Cmd) {
225	m.Saving, m.Error = true, ""
226	m.Generation = nextPageGeneration()
227	return m, cmd(m.Generation)
228}
229
230func (m CapabilityPageModel) Update(msg tea.Msg) (CapabilityPageModel, tea.Cmd) {
231	switch msg := msg.(type) {
232	case capabilityLoadedMsg:
233		if !m.settle(msg.Gen, msg.Err, &m.Loading) {
234			return m, nil
235		}
236		m.Error = ""
237		m.Items, m.Prefs, m.ExtensionEnabled = msg.Items, msg.Prefs, msg.ExtensionEnabled
238		m.Revision, m.Diagnostics = msg.Revision, msg.Diagnostics
239		m.SessionETag, m.GlobalETag, m.MCPETag = msg.SessionETag, msg.GlobalETag, msg.MCPETag
240		m.refreshRows()
241		return m, nil
242
243	case capabilitySavedMsg:
244		if msg.Gen == m.Generation {
245			if apiErr, ok := errors.AsType[*daemon.APIError](msg.Err); ok && apiErr.Code == "catalog_changed" {
246				m.Saving, m.Loading, m.Error = false, true, ""
247				m.confirm.dismiss()
248				m.Notice = "The list changed. Review it before trying again."
249				m.Generation = nextPageGeneration()
250				return m, m.loadCmd(m.Generation)
251			}
252		}
253		if !m.settle(msg.Gen, msg.Err, &m.Saving) {
254			m.confirm.failed = msg.Gen == m.Generation
255			return m, nil
256		}
257		m.Form = nil
258		m.confirm.dismiss()
259		m.Notice = "Saved. Reload the session to apply the selection."
260		if msg.Warning != "" {
261			m.Notice = msg.Warning
262		}
263		m.Loading, m.Generation = true, nextPageGeneration()
264		return m, tea.Batch(m.loadCmd(m.Generation), func() tea.Msg { return CapabilityPageChangedMsg{} })
265
266	case tea.PasteMsg:
267		if m.Form == nil || m.Saving {
268			return m, nil
269		}
270		_, cmd := m.Form.update(msg)
271		return m, cmd
272
273	case tea.KeyPressMsg:
274		switch {
275		case m.confirm.asking() && !m.Saving:
276			if m.confirm.key(msg) {
277				return m.confirmed()
278			}
279			if !m.confirm.asking() {
280				m.Error = ""
281			}
282			return m, nil
283		case m.Form != nil:
284			return m.formKey(msg)
285		case msg.String() == "esc" || msg.String() == "ctrl+c" || (msg.String() == "ctrl+d" && m.Kind != "mcp"):
286			return m, func() tea.Msg { return CapabilityPageDoneMsg{} }
287		case m.Loading || m.Saving:
288			return m, nil
289		}
290		return m.listKey(msg)
291	}
292	cmd := m.listView.update(msg)
293	return m, cmd
294}
295
296// formKey routes keys to the open form; esc closes it without saving.
297func (m CapabilityPageModel) formKey(msg tea.KeyPressMsg) (CapabilityPageModel, tea.Cmd) {
298	if m.Saving {
299		return m, nil
300	}
301	if msg.String() == "esc" {
302		m.Form, m.Error = nil, ""
303		return m, nil
304	}
305	submit, cmd := m.Form.update(msg)
306	if !submit {
307		return m, cmd
308	}
309	sub, err := m.Form.submission(m.Items)
310	if err != nil {
311		m.Error = err.Error()
312		return m, nil
313	}
314	return m.save(func(gen int) tea.Cmd { return m.saveMCPCmd(sub, gen) })
315}
316
317// listKey acts on the highlighted row, or hands the key to the filter. The
318// ctrl+o add key is the only one that differs from the shared list keys.
319func (m CapabilityPageModel) listKey(msg tea.KeyPressMsg) (CapabilityPageModel, tea.Cmd) {
320	mcp := m.Kind == "mcp"
321	item, ok := m.current()
322	switch msg.String() {
323	case "enter":
324		m.askToggle(scopeGlobal)
325	case "shift+enter", "alt+enter":
326		m.askToggle(scopeSession)
327	case "ctrl+x":
328		if !mcp && ok && m.canClear(item) {
329			m.askToggle(scopeInherit)
330		}
331	case "ctrl+o":
332		if mcp {
333			m.openForm(false)
334		}
335	case "ctrl+e":
336		if mcp && ok {
337			m.openForm(true)
338		}
339	case "ctrl+d":
340		if mcp && ok {
341			m.confirm.ask(actionDeleteServer, item.ID, "delete", fmt.Sprintf("Its stored credentials will also be removed. Delete MCP server %s?", item.Title))
342		}
343	case "ctrl+g":
344		if mcp && ok && serverDisabled(item) {
345			return m.enableServer(item)
346		}
347	case "ctrl+t":
348		if !m.ExtensionEnabled {
349			m.confirm.ask(actionEnableExtension, m.Kind, "enable", fmt.Sprintf("Enable the %s extension? This reloads this session to apply it.", m.Kind))
350		}
351	case "ctrl+r":
352		m.Loading, m.Error, m.Notice = true, "", ""
353		m.Generation = nextPageGeneration()
354		return m, m.loadCmd(m.Generation)
355	default:
356		cmd := m.listView.update(msg)
357		return m, cmd
358	}
359	return m, nil
360}
361
362// enableServer turns on a server that extensions.json has switched off.
363func (m CapabilityPageModel) enableServer(item capabilityItem) (CapabilityPageModel, tea.Cmd) {
364	server := item.Server
365	enabled := true
366	server.Enabled = &enabled
367	return m.save(func(gen int) tea.Cmd {
368		return m.saveMCPCmd(mcpSubmission{Name: item.ID, Server: server, Secrets: daemon.MCPSecretsPatch{}}, gen)
369	})
370}
371
372// askToggle asks before changing a row in one scope, or before dropping
373// this session's own choice.
374func (m *CapabilityPageModel) askToggle(scope string) {
375	item, ok := m.current()
376	if !ok || (scope != scopeInherit && !m.canToggle(item, scope)) {
377		return
378	}
379	m.Error = ""
380	if scope == scopeInherit {
381		m.confirm.ask(scope, item.ID, "follow global", fmt.Sprintf("This session will follow the global default for %s. Remove its own choice?", item.Title))
382		return
383	}
384	verb := "enable"
385	if m.enabledIn(item, scope) {
386		verb = "disable"
387	}
388	sentence := strings.ToUpper(verb[:1]) + verb[1:]
389	prompt := fmt.Sprintf("Sessions following global defaults will use this change. %s %s globally?", sentence, item.Title)
390	if scope == scopeSession {
391		prompt = fmt.Sprintf("%s %s for this session only? Reload this session to apply it.", sentence, item.Title)
392	}
393	m.confirm.ask(scope, item.ID, verb, prompt)
394}
395
396// confirmed runs the change the question asked about. The question stays up
397// while the change is in flight, so a failure can be retried with enter.
398func (m CapabilityPageModel) confirmed() (CapabilityPageModel, tea.Cmd) {
399	target, what := m.confirm.target, m.confirm.what
400	switch what {
401	case actionEnableExtension:
402		return m.save(m.enableExtensionCmd)
403	case actionDeleteServer:
404		return m.save(func(gen int) tea.Cmd { return m.deleteMCPCmd(target, gen) })
405	}
406	item, ok := m.itemNamed(target)
407	if !ok {
408		m.confirm.dismiss()
409		return m, nil
410	}
411	if what == scopeInherit {
412		return m.save(func(gen int) tea.Cmd { return m.choiceCmd(item, scopeSession, nil, gen) })
413	}
414	next := !m.enabledIn(item, what)
415	return m.save(func(gen int) tea.Cmd { return m.choiceCmd(item, what, &next, gen) })
416}
417
418func (m CapabilityPageModel) current() (capabilityItem, bool) {
419	row, ok := m.highlighted()
420	if !ok {
421		return capabilityItem{}, false
422	}
423	return m.itemNamed(row.key)
424}
425
426func (m CapabilityPageModel) itemNamed(id string) (capabilityItem, bool) {
427	i := slices.IndexFunc(m.Items, func(item capabilityItem) bool { return item.ID == id })
428	if i < 0 {
429		return capabilityItem{}, false
430	}
431	return m.Items[i], true
432}
433
434// refreshRows rebuilds the list from the items, keeping the cursor on its row.
435func (m *CapabilityPageModel) refreshRows() {
436	rows := make([]listEntry, len(m.Items))
437	for i, item := range m.Items {
438		rows[i] = m.entry(item)
439	}
440	m.setRows(rows)
441}
442
443func (m CapabilityPageModel) canToggle(item capabilityItem, scope string) bool {
444	if m.Kind == "mcp" {
445		return true
446	}
447	candidate := item.Candidate
448	return candidate.PreferenceKey != nil && candidate.ShadowedBy == nil && (candidate.Valid || m.enabledIn(item, scope))
449}
450
451// canClear is whether this session has its own choice to drop.
452func (m CapabilityPageModel) canClear(item capabilityItem) bool {
453	candidate := item.Candidate
454	return candidate.PreferenceKey != nil && candidate.ShadowedBy == nil && candidate.SessionOverride != nil
455}
456
457// enabledIn is whether the item is on in one scope: the global default, or
458// this session's state.
459func (m CapabilityPageModel) enabledIn(item capabilityItem, scope string) bool {
460	if item.Candidate.ID != "" || m.Kind != "mcp" {
461		if scope == scopeGlobal {
462			return item.Candidate.GlobalPreference == nil || *item.Candidate.GlobalPreference
463		}
464		return item.Candidate.EffectiveEnabled
465	}
466	if scope == scopeGlobal {
467		if enabled, ok := m.Prefs.Global["mcp"][item.ID]; ok {
468			return enabled
469		}
470	}
471	return item.Server.Enabled == nil || *item.Server.Enabled
472}
473
474// valueOr is the string a nullable field holds, or empty.
475func valueOr(s *string) string {
476	if s == nil {
477		return ""
478	}
479	return *s
480}
481
482// serverDisabled is whether extensions.json switches an MCP server off.
483func serverDisabled(item capabilityItem) bool {
484	return item.Server.Enabled != nil && !*item.Server.Enabled
485}
486
487// hasSessionChoice is whether this session differs from the global default
488// for the item, which the row shows at its right edge.
489func (m CapabilityPageModel) hasSessionChoice(item capabilityItem) bool {
490	if m.Kind == "mcp" {
491		return m.enabledIn(item, scopeSession) != m.enabledIn(item, scopeGlobal)
492	}
493	return item.Candidate.SessionOverride != nil
494}
495
496// entry is how an item reads in the list: its global state in front, and
497// this session's choice at the edge when it differs.
498func (m CapabilityPageModel) entry(item capabilityItem) listEntry {
499	lead, tag := DefaultStyles.Faint.Render("off"), ""
500	if m.enabledIn(item, scopeGlobal) && !serverDisabled(item) {
501		lead = DefaultStyles.Success.Render("on")
502	}
503	entry := listEntry{key: item.ID, lead: lead, name: item.Title, detail: func(width int) []string { return m.details(item, width) }}
504	if m.Kind == "mcp" {
505		entry.desc = item.Detail
506		entry.search = []string{item.Server.URL, item.Server.Command}
507	} else {
508		candidate := item.Candidate
509		entry.desc = valueOr(candidate.Description)
510		entry.search = []string{candidate.ID, candidate.Source}
511		switch {
512		case !candidate.Valid:
513			entry.lead = DefaultStyles.Warning.Render("invalid")
514		case candidate.ShadowedBy != nil:
515			entry.lead = DefaultStyles.Faint.Render("shadowed")
516		}
517	}
518	if m.hasSessionChoice(item) {
519		tag = DefaultStyles.Muted.Render("this session: " + onOff(m.enabledIn(item, scopeSession)))
520	}
521	entry.tag = tag
522	return entry
523}
524
525// details is the pane: what the item is, where it comes from, and both of
526// its states.
527func (m CapabilityPageModel) details(item capabilityItem, width int) []string {
528	if m.Kind == "mcp" {
529		return m.serverDetails(item, width)
530	}
531	return m.candidateDetails(item, width)
532}
533
534func (m CapabilityPageModel) candidateDetails(item capabilityItem, width int) []string {
535	c := item.Candidate
536	lines := paneTitle(c.Title, valueOr(c.Description), width)
537	lines = append(lines, factRows("source", c.Source, width)...)
538	if c.ResolvedSource != nil {
539		lines = append(lines, factRows("resolved", *c.ResolvedSource, width)...)
540	}
541	validity, shadow := "valid", "not shadowed"
542	if !c.Valid {
543		validity = "invalid"
544	}
545	if c.ShadowedBy != nil {
546		shadow = "shadowed by " + *c.ShadowedBy
547	}
548	lines = append(lines, factRows("validity", validity, width)...)
549	lines = append(lines, factRows("shadowing", shadow, width)...)
550	global := onOff(m.enabledIn(item, scopeGlobal))
551	if c.GlobalPreference == nil {
552		global += " (default)"
553	}
554	session := "follows global"
555	if c.SessionOverride != nil {
556		session = onOff(*c.SessionOverride) + " (its own choice)"
557	}
558	lines = append(lines, factRows("global", global, width)...)
559	lines = append(lines, factRows("session", session, width)...)
560	lines = append(lines, factRows("enabled", onOff(c.EffectiveEnabled)+" · eligible on reload: "+onOff(c.Eligible), width)...)
561	if c.Diagnostic != nil {
562		lines = append(lines, factRows("problem", *c.Diagnostic, width)...)
563	}
564	for _, diagnostic := range m.Diagnostics {
565		lines = append(lines, factRows("discovery", diagnostic, width)...)
566	}
567	return append(append(lines, ""), paneNote("Changes apply when the session reloads.", width)...)
568}
569
570func (m CapabilityPageModel) serverDetails(item capabilityItem, width int) []string {
571	server := item.Server
572	lines := paneTitle(item.ID, item.Detail, width)
573	lines = append(lines, factRows("transport", server.Type, width)...)
574	if server.Type == "stdio" {
575		lines = append(lines, factRows("command", joinCommand(server.Command, server.Args), width)...)
576		if server.CWD != "" {
577			lines = append(lines, factRows("cwd", server.CWD, width)...)
578		}
579	} else {
580		lines = append(lines, factRows("url", server.URL, width)...)
581	}
582	file := "enabled in extensions.json"
583	if serverDisabled(item) {
584		file = "disabled in extensions.json"
585	}
586	lines = append(lines, factRows("config", file, width)...)
587	lines = append(lines, factRows("global", onOff(m.enabledIn(item, scopeGlobal)), width)...)
588	lines = append(lines, factRows("session", onOff(m.enabledIn(item, scopeSession)), width)...)
589	lines = append(lines, factRows("tools", serverTools(server), width)...)
590	timeouts := fmt.Sprintf("startup %s · call %s", timeoutLabel(server.StartupTimeoutMs), timeoutLabel(server.CallTimeoutMs))
591	lines = append(lines, factRows("timeouts", timeouts, width)...)
592	if server.BearerTokenEnvVar != "" {
593		lines = append(lines, factRows("token env", server.BearerTokenEnvVar, width)...)
594	}
595	lines = append(lines, factRows("secrets", storedLabel(item.Secrets), width)...)
596	return append(append(lines, ""), paneNote("ctrl+e edits this server. Changes apply when the session reloads.", width)...)
597}
598
599// serverTools names which tools the server offers the session.
600func serverTools(server config.MCPServer) string {
601	if len(server.EnabledTools) > 0 {
602		return "only " + strings.Join(server.EnabledTools, ", ")
603	}
604	if len(server.DisabledTools) > 0 {
605		return "all except " + strings.Join(server.DisabledTools, ", ")
606	}
607	return "all"
608}
609
610func timeoutLabel(ms int) string {
611	if ms <= 0 {
612		return "default"
613	}
614	return fmt.Sprintf("%dms", ms)
615}
616
617// storedLabel names the secrets the daemon holds for a server, never their
618// contents.
619func storedLabel(names daemon.MCPSecretNames) string {
620	if !names.Any() {
621		return "none stored"
622	}
623	var parts []string
624	if names.BearerToken {
625		parts = append(parts, "bearer token")
626	}
627	for _, header := range names.Headers {
628		parts = append(parts, "header "+header)
629	}
630	for _, env := range names.Env {
631		parts = append(parts, "env "+env)
632	}
633	return "stored privately · " + strings.Join(parts, ", ")
634}
635
636// hints are the keys this page answers now; the ones that do not apply to
637// the highlighted row or this extension state are left out.
638func (m CapabilityPageModel) hints(item capabilityItem, ok bool) []hint {
639	mcp := m.Kind == "mcp"
640	hints := []hint{{"↑↓", "move"}}
641	if m.extensionOff() {
642		hints = append(hints, hint{"ctrl+t", "enable extension"})
643	}
644	if ok {
645		if m.canToggle(item, scopeGlobal) {
646			hints = append(hints, hint{"enter", "global"})
647		}
648		if m.canToggle(item, scopeSession) {
649			hints = append(hints, hint{"shift+enter", "this session"})
650		}
651		if !mcp && m.canClear(item) {
652			hints = append(hints, hint{"ctrl+x", "follow global"})
653		}
654		if mcp {
655			hints = append(hints, hint{"ctrl+e", "edit"}, hint{"ctrl+d", "delete"})
656		}
657		if serverDisabled(item) {
658			hints = append(hints, hint{"ctrl+g", "enable server"})
659		}
660	}
661	if mcp {
662		hints = append(hints, hint{"ctrl+o", "add server"})
663	}
664	return append(hints, hint{"ctrl+r", "refresh"}, hint{"esc", "back"})
665}
666
667// extensionOff is whether a loaded page found its extension switched off.
668func (m CapabilityPageModel) extensionOff() bool {
669	return !m.ExtensionEnabled && !m.Loading && m.Error == ""
670}
671
672// status is the footer's one line about the page: work in flight, the last
673// failure or notice, a reason the list is not live, or the count.
674func (m CapabilityPageModel) status() (string, bool) {
675	if m.Form != nil && !m.Saving && m.Error == "" {
676		return "", false
677	}
678	switch {
679	case m.Saving:
680		return DefaultStyles.Busy.Render("saving and reloading…"), true
681	case m.Loading:
682		return DefaultStyles.Faint.Render("loading…"), false
683	case m.Error != "":
684		return DefaultStyles.Error.Render(m.Error), true
685	case m.Notice != "":
686		return DefaultStyles.Warning.Render(m.Notice), true
687	case m.extensionOff():
688		return DefaultStyles.Warning.Render("extension is off"), true
689	case len(m.Diagnostics) > 0:
690		return DefaultStyles.Warning.Render(m.diagnosticLine()), true
691	}
692	return DefaultStyles.Faint.Render(counted(len(m.Items), capabilityNouns[m.Kind])), false
693}
694
695func (m CapabilityPageModel) diagnosticLine() string {
696	if len(m.Diagnostics) == 1 {
697		return m.Diagnostics[0]
698	}
699	return fmt.Sprintf("%d discovery diagnostics: %s", len(m.Diagnostics), m.Diagnostics[0])
700}
701
702func (m CapabilityPageModel) footer(width int) string {
703	if m.confirm.asking() && !m.Saving {
704		return m.confirm.footer(width, m.Error)
705	}
706	status, urgent := m.status()
707	if m.Form != nil {
708		keys := m.Form.footer(width)
709		if status == "" {
710			return keys
711		}
712		return " " + ansi.Truncate(status, max(1, width-2), "…") + "\n" + keys
713	}
714	item, ok := m.current()
715	return footerLine(width, m.hints(item, ok), status, urgent)
716}
717
718// summary is the highlighted row's source or address, for a width too
719// narrow for the detail pane.
720func (m CapabilityPageModel) summary() string {
721	item, ok := m.current()
722	switch {
723	case !ok:
724		return ""
725	case m.Kind == "mcp":
726		return item.Detail
727	}
728	return item.Candidate.Source
729}
730
731// formList is the open form in the list's place, clipped to its rows.
732func (m CapabilityPageModel) formList(width, height int) []string {
733	rows := m.Form.view(width)
734	return rows[:min(len(rows), height)]
735}
736
737func (m CapabilityPageModel) View() string {
738	width := max(1, m.Width)
739	lv := m.listView
740	noun := capabilityNouns[m.Kind]
741	lv.Empty = "No " + noun + "s found."
742	switch {
743	case m.Loading:
744		lv.Empty = "loading " + noun + "s…"
745	case m.Error != "" && len(m.Items) == 0:
746		lv.Empty = "Could not load " + noun + "s; the reason is in the footer."
747	}
748	frame := lv.frame(brand("albedo")+" "+DefaultStyles.Muted.Render("/"+m.Kind), "", m.footer(width))
749	if m.Form != nil {
750		frame.filter, frame.pane, frame.list = "", nil, m.formList
751	} else {
752		frame.summary = m.summary()
753	}
754	return frame.view(m.Width, m.Height)
755}
756
757func capabilityChoiceID(item capabilityItem, scope string) string {
758	if scope == scopeGlobal {
759		return item.Candidate.ID
760	}
761	return item.ID
762}
763
764func (m CapabilityPageModel) storedSecrets(name string) daemon.MCPSecretNames {
765	if item, ok := m.itemNamed(name); ok {
766		return item.Secrets
767	}
768	return daemon.MCPSecretNames{}
769}