okami.mom/albedo
git clone https://git.nkp.pet/okami.mom/albedo
96704f7
main
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 { 22Prefs config.CapabilityPrefs 23Conn * daemon.Connection 24Form * mcpForm 25SessionID ,Kind string 26Items []capabilityItem 27Revision string 28SessionETag ,GlobalETag ,MCPETag string 29Diagnostics []string 30ExtensionEnabled bool 31pageStatus 32listView 33confirm 34} 35 36type capabilityItem struct { 37ID ,Title ,Detail string 38Candidate daemon.CatalogCandidate 39Secrets daemon.MCPSecretNames 40Server config.MCPServer 41} 42type capabilityLoadedMsg struct { 43SessionETag ,GlobalETag ,MCPETag string 44Prefs config.CapabilityPrefs 45Revision string 46Diagnostics []string 47Err error 48Items []capabilityItem 49Gen int 50ExtensionEnabled bool 51} 52type capabilitySavedMsg struct { 53Err error 54Warning string 55Gen 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 ( 65actionEnableExtension = "extension" 66actionDeleteServer = "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 { 77noun := capabilityNouns [kind ] 78return CapabilityPageModel { 79Conn :conn ,SessionID :sessionID ,Kind :kind , 80pageStatus :newPageStatus (true ), 81listView :newListView ("Search " + noun + "s" ), 82 } 83} 84 85func (m * CapabilityPageModel )SetSize (width ,height int ) { 86m .pageStatus .SetSize (width ,height ) 87m .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 { 96kind := m .Kind 97return func () tea.Msg { 98catalog ,err := daemon .GetCapabilityCatalog (context .Background (),m .Conn ,m .SessionID ) 99if err != nil { 100return capabilityLoadedMsg {Gen :gen ,Err :err } 101 } 102settings ,err := daemon .GetSettings (context .Background (),m .Conn ) 103if err != nil { 104return capabilityLoadedMsg {Gen :gen ,Err :err } 105 } 106configuration ,err := daemon .GetSessionConfiguration (context .Background (),m .Conn ,m .SessionID ) 107if err != nil { 108return capabilityLoadedMsg {Gen :gen ,Err :err } 109 } 110items := []capabilityItem {} 111switch kind { 112case "mcp" : 113items = mcpItems (settings ) 114for i := range items { 115for _ ,candidate := range catalog .Candidates { 116if candidate .Kind == "mcp" && candidate .PreferenceKey != nil && * candidate .PreferenceKey == "mcp:" + items [i ].ID { 117items [i ].Candidate = candidate 118 } 119 } 120 } 121case "skills" ,"instructions" : 122for _ ,candidate := range catalog .Candidates { 123if candidate .Kind == kind { 124items = append (items ,capabilityItem {ID :candidate .ID ,Title :candidate .Title ,Detail :candidate .Source ,Candidate :candidate }) 125 } 126 } 127default : 128return capabilityLoadedMsg {Gen :gen ,Err :errors .New ("unknown capability page" )} 129 } 130slices .SortFunc (items ,func (a ,b capabilityItem )int {return strings .Compare (a .ID ,b .ID ) }) 131enabled := catalog .Extensions [kind ] 132return 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 { 139items := make ([]capabilityItem ,0 ,len (settings .MCP )) 140for name ,server := range settings .MCP { 141address := server .URL 142if server .Type == "stdio" { 143address = server .Command 144 } 145items = append (items ,capabilityItem {ID :name ,Title :name ,Detail :server .Type + " · " + address ,Server :server ,Secrets :settings .Credentials .MCP [name ]}) 146 } 147return items 148} 149 150func (m CapabilityPageModel )enableExtensionCmd (gen int ) tea.Cmd { 151return 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 )}) 153if err != nil { 154return capabilitySavedMsg {Gen :gen ,Err :err } 155 } 156reloaded ,reloadErr := daemon .ReloadSession (context .Background (),m .Conn ,m .SessionID , daemon.ReloadRequest {Target :"session" }) 157warning := reloaded .Message () 158switch { 159case reloadErr != nil : 160warning = "Extension enabled; reload failed: " + reloadErr .Error () 161if _ ,uncertain := errors.AsType [* daemon.UncertainOutcomeError ](reloadErr );uncertain { 162warning = "Extension enabled; reload not confirmed: " + reloadErr .Error () 163 } 164case reloaded .Session == nil : 165warning = "Extension enabled; reload returned no session outcome." 166case reloaded .Session .State != "applied" : 167warning = "Extension enabled; session reload " + reloaded .Session .State + ". " + reloaded .Message () 168 } 169return 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 { 176etag := m .SessionETag 177if scope == scopeGlobal { 178etag = m .GlobalETag 179 } 180return func () tea.Msg { 181var result daemon.ReloadResult 182var err error 183if m .Kind == "mcp" { 184result ,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 { 186result ,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 } 188return 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 { 194return func () tea.Msg { 195result ,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 }) 196return 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 { 201return func () tea.Msg { 202result ,err := daemon .SaveMCP (context .Background (),m .Conn ,m .SessionID , daemon.MCPUpdateRequest {Name :name ,ETag :m .MCPETag ,Server :nil ,Secrets : daemon.MCPSecretsPatch {}}) 203return 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 ) { 210m .Error ,m .Notice = "" ,"" 211if ! edit { 212m .Form = newMCPForm ("" , config.MCPServer {Type :"http" }, daemon.MCPSecretNames {}) 213return 214 } 215item ,ok := m .current () 216if ! ok { 217return 218 } 219m .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 ) { 225m .Saving ,m .Error = true ,"" 226m .Generation = nextPageGeneration () 227return m ,cmd (m .Generation ) 228} 229 230func (m CapabilityPageModel )Update (msg tea.Msg ) (CapabilityPageModel , tea.Cmd ) { 231switch msg := msg .(type ) { 232case capabilityLoadedMsg : 233if ! m .settle (msg .Gen ,msg .Err ,& m .Loading ) { 234return m ,nil 235 } 236m .Error = "" 237m .Items ,m .Prefs ,m .ExtensionEnabled = msg .Items ,msg .Prefs ,msg .ExtensionEnabled 238m .Revision ,m .Diagnostics = msg .Revision ,msg .Diagnostics 239m .SessionETag ,m .GlobalETag ,m .MCPETag = msg .SessionETag ,msg .GlobalETag ,msg .MCPETag 240m .refreshRows () 241return m ,nil 242 243case capabilitySavedMsg : 244if msg .Gen == m .Generation { 245if apiErr ,ok := errors.AsType [* daemon.APIError ](msg .Err );ok && apiErr .Code == "catalog_changed" { 246m .Saving ,m .Loading ,m .Error = false ,true ,"" 247m .confirm .dismiss () 248m .Notice = "The list changed. Review it before trying again." 249m .Generation = nextPageGeneration () 250return m ,m .loadCmd (m .Generation ) 251 } 252 } 253if ! m .settle (msg .Gen ,msg .Err ,& m .Saving ) { 254m .confirm .failed = msg .Gen == m .Generation 255return m ,nil 256 } 257m .Form = nil 258m .confirm .dismiss () 259m .Notice = "Saved. Reload the session to apply the selection." 260if msg .Warning != "" { 261m .Notice = msg .Warning 262 } 263m .Loading ,m .Generation = true ,nextPageGeneration () 264return m ,tea .Batch (m .loadCmd (m .Generation ),func () tea.Msg {return CapabilityPageChangedMsg {} }) 265 266case tea.PasteMsg : 267if m .Form == nil || m .Saving { 268return m ,nil 269 } 270_ ,cmd := m .Form .update (msg ) 271return m ,cmd 272 273case tea.KeyPressMsg : 274switch { 275case m .confirm .asking ()&& ! m .Saving : 276if m .confirm .key (msg ) { 277return m .confirmed () 278 } 279if ! m .confirm .asking () { 280m .Error = "" 281 } 282return m ,nil 283case m .Form != nil : 284return m .formKey (msg ) 285case msg .String ()== "esc" || msg .String ()== "ctrl+c" || (msg .String ()== "ctrl+d" && m .Kind != "mcp" ): 286return m ,func () tea.Msg {return CapabilityPageDoneMsg {} } 287case m .Loading || m .Saving : 288return m ,nil 289 } 290return m .listKey (msg ) 291 } 292cmd := m .listView .update (msg ) 293return 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 ) { 298if m .Saving { 299return m ,nil 300 } 301if msg .String ()== "esc" { 302m .Form ,m .Error = nil ,"" 303return m ,nil 304 } 305submit ,cmd := m .Form .update (msg ) 306if ! submit { 307return m ,cmd 308 } 309sub ,err := m .Form .submission (m .Items ) 310if err != nil { 311m .Error = err .Error () 312return m ,nil 313 } 314return 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 ) { 320mcp := m .Kind == "mcp" 321item ,ok := m .current () 322switch msg .String () { 323case "enter" : 324m .askToggle (scopeGlobal ) 325case "shift+enter" ,"alt+enter" : 326m .askToggle (scopeSession ) 327case "ctrl+x" : 328if ! mcp && ok && m .canClear (item ) { 329m .askToggle (scopeInherit ) 330 } 331case "ctrl+o" : 332if mcp { 333m .openForm (false ) 334 } 335case "ctrl+e" : 336if mcp && ok { 337m .openForm (true ) 338 } 339case "ctrl+d" : 340if mcp && ok { 341m .confirm .ask (actionDeleteServer ,item .ID ,"delete" ,fmt .Sprintf ("Its stored credentials will also be removed. Delete MCP server %s?" ,item .Title )) 342 } 343case "ctrl+g" : 344if mcp && ok && serverDisabled (item ) { 345return m .enableServer (item ) 346 } 347case "ctrl+t" : 348if ! m .ExtensionEnabled { 349m .confirm .ask (actionEnableExtension ,m .Kind ,"enable" ,fmt .Sprintf ("Enable the %s extension? This reloads this session to apply it." ,m .Kind )) 350 } 351case "ctrl+r" : 352m .Loading ,m .Error ,m .Notice = true ,"" ,"" 353m .Generation = nextPageGeneration () 354return m ,m .loadCmd (m .Generation ) 355default : 356cmd := m .listView .update (msg ) 357return m ,cmd 358 } 359return 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 ) { 364server := item .Server 365enabled := true 366server .Enabled = & enabled 367return m .save (func (gen int ) tea.Cmd { 368return 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 ) { 375item ,ok := m .current () 376if ! ok || (scope != scopeInherit && ! m .canToggle (item ,scope )) { 377return 378 } 379m .Error = "" 380if scope == scopeInherit { 381m .confirm .ask (scope ,item .ID ,"follow global" ,fmt .Sprintf ("This session will follow the global default for %s. Remove its own choice?" ,item .Title )) 382return 383 } 384verb := "enable" 385if m .enabledIn (item ,scope ) { 386verb = "disable" 387 } 388sentence := strings .ToUpper (verb [:1 ])+ verb [1 :] 389prompt := fmt .Sprintf ("Sessions following global defaults will use this change. %s %s globally?" ,sentence ,item .Title ) 390if scope == scopeSession { 391prompt = fmt .Sprintf ("%s %s for this session only? Reload this session to apply it." ,sentence ,item .Title ) 392 } 393m .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 ) { 399target ,what := m .confirm .target ,m .confirm .what 400switch what { 401case actionEnableExtension : 402return m .save (m .enableExtensionCmd ) 403case actionDeleteServer : 404return m .save (func (gen int ) tea.Cmd {return m .deleteMCPCmd (target ,gen ) }) 405 } 406item ,ok := m .itemNamed (target ) 407if ! ok { 408m .confirm .dismiss () 409return m ,nil 410 } 411if what == scopeInherit { 412return m .save (func (gen int ) tea.Cmd {return m .choiceCmd (item ,scopeSession ,nil ,gen ) }) 413 } 414next := ! m .enabledIn (item ,what ) 415return m .save (func (gen int ) tea.Cmd {return m .choiceCmd (item ,what ,& next ,gen ) }) 416} 417 418func (m CapabilityPageModel )current () (capabilityItem ,bool ) { 419row ,ok := m .highlighted () 420if ! ok { 421return capabilityItem {},false 422 } 423return m .itemNamed (row .key ) 424} 425 426func (m CapabilityPageModel )itemNamed (id string ) (capabilityItem ,bool ) { 427i := slices .IndexFunc (m .Items ,func (item capabilityItem )bool {return item .ID == id }) 428if i < 0 { 429return capabilityItem {},false 430 } 431return m .Items [i ],true 432} 433 434// refreshRows rebuilds the list from the items, keeping the cursor on its row. 435func (m * CapabilityPageModel )refreshRows () { 436rows := make ([]listEntry ,len (m .Items )) 437for i ,item := range m .Items { 438rows [i ]= m .entry (item ) 439 } 440m .setRows (rows ) 441} 442 443func (m CapabilityPageModel )canToggle (item capabilityItem ,scope string )bool { 444if m .Kind == "mcp" { 445return true 446 } 447candidate := item .Candidate 448return 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 { 453candidate := item .Candidate 454return 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 { 460if item .Candidate .ID != "" || m .Kind != "mcp" { 461if scope == scopeGlobal { 462return item .Candidate .GlobalPreference == nil || * item .Candidate .GlobalPreference 463 } 464return item .Candidate .EffectiveEnabled 465 } 466if scope == scopeGlobal { 467if enabled ,ok := m .Prefs .Global ["mcp" ][item .ID ];ok { 468return enabled 469 } 470 } 471return 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 { 476if s == nil { 477return "" 478 } 479return * s 480} 481 482// serverDisabled is whether extensions.json switches an MCP server off. 483func serverDisabled (item capabilityItem )bool { 484return 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 { 490if m .Kind == "mcp" { 491return m .enabledIn (item ,scopeSession )!= m .enabledIn (item ,scopeGlobal ) 492 } 493return 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 { 499lead ,tag := DefaultStyles .Faint .Render ("off" ),"" 500if m .enabledIn (item ,scopeGlobal )&& ! serverDisabled (item ) { 501lead = DefaultStyles .Success .Render ("on" ) 502 } 503entry := listEntry {key :item .ID ,lead :lead ,name :item .Title ,detail :func (width int ) []string {return m .details (item ,width ) }} 504if m .Kind == "mcp" { 505entry .desc = item .Detail 506entry .search = []string {item .Server .URL ,item .Server .Command } 507 }else { 508candidate := item .Candidate 509entry .desc = valueOr (candidate .Description ) 510entry .search = []string {candidate .ID ,candidate .Source } 511switch { 512case ! candidate .Valid : 513entry .lead = DefaultStyles .Warning .Render ("invalid" ) 514case candidate .ShadowedBy != nil : 515entry .lead = DefaultStyles .Faint .Render ("shadowed" ) 516 } 517 } 518if m .hasSessionChoice (item ) { 519tag = DefaultStyles .Muted .Render ("this session: " + onOff (m .enabledIn (item ,scopeSession ))) 520 } 521entry .tag = tag 522return 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 { 528if m .Kind == "mcp" { 529return m .serverDetails (item ,width ) 530 } 531return m .candidateDetails (item ,width ) 532} 533 534func (m CapabilityPageModel )candidateDetails (item capabilityItem ,width int ) []string { 535c := item .Candidate 536lines := paneTitle (c .Title ,valueOr (c .Description ),width ) 537lines = append (lines ,factRows ("source" ,c .Source ,width )... ) 538if c .ResolvedSource != nil { 539lines = append (lines ,factRows ("resolved" ,* c .ResolvedSource ,width )... ) 540 } 541validity ,shadow := "valid" ,"not shadowed" 542if ! c .Valid { 543validity = "invalid" 544 } 545if c .ShadowedBy != nil { 546shadow = "shadowed by " + * c .ShadowedBy 547 } 548lines = append (lines ,factRows ("validity" ,validity ,width )... ) 549lines = append (lines ,factRows ("shadowing" ,shadow ,width )... ) 550global := onOff (m .enabledIn (item ,scopeGlobal )) 551if c .GlobalPreference == nil { 552global += " (default)" 553 } 554session := "follows global" 555if c .SessionOverride != nil { 556session = onOff (* c .SessionOverride )+ " (its own choice)" 557 } 558lines = append (lines ,factRows ("global" ,global ,width )... ) 559lines = append (lines ,factRows ("session" ,session ,width )... ) 560lines = append (lines ,factRows ("enabled" ,onOff (c .EffectiveEnabled )+ " · eligible on reload: " + onOff (c .Eligible ),width )... ) 561if c .Diagnostic != nil { 562lines = append (lines ,factRows ("problem" ,* c .Diagnostic ,width )... ) 563 } 564for _ ,diagnostic := range m .Diagnostics { 565lines = append (lines ,factRows ("discovery" ,diagnostic ,width )... ) 566 } 567return append (append (lines ,"" ),paneNote ("Changes apply when the session reloads." ,width )... ) 568} 569 570func (m CapabilityPageModel )serverDetails (item capabilityItem ,width int ) []string { 571server := item .Server 572lines := paneTitle (item .ID ,item .Detail ,width ) 573lines = append (lines ,factRows ("transport" ,server .Type ,width )... ) 574if server .Type == "stdio" { 575lines = append (lines ,factRows ("command" ,joinCommand (server .Command ,server .Args ),width )... ) 576if server .CWD != "" { 577lines = append (lines ,factRows ("cwd" ,server .CWD ,width )... ) 578 } 579 }else { 580lines = append (lines ,factRows ("url" ,server .URL ,width )... ) 581 } 582file := "enabled in extensions.json" 583if serverDisabled (item ) { 584file = "disabled in extensions.json" 585 } 586lines = append (lines ,factRows ("config" ,file ,width )... ) 587lines = append (lines ,factRows ("global" ,onOff (m .enabledIn (item ,scopeGlobal )),width )... ) 588lines = append (lines ,factRows ("session" ,onOff (m .enabledIn (item ,scopeSession )),width )... ) 589lines = append (lines ,factRows ("tools" ,serverTools (server ),width )... ) 590timeouts := fmt .Sprintf ("startup %s · call %s" ,timeoutLabel (server .StartupTimeoutMs ),timeoutLabel (server .CallTimeoutMs )) 591lines = append (lines ,factRows ("timeouts" ,timeouts ,width )... ) 592if server .BearerTokenEnvVar != "" { 593lines = append (lines ,factRows ("token env" ,server .BearerTokenEnvVar ,width )... ) 594 } 595lines = append (lines ,factRows ("secrets" ,storedLabel (item .Secrets ),width )... ) 596return 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 { 601if len (server .EnabledTools )> 0 { 602return "only " + strings .Join (server .EnabledTools ,", " ) 603 } 604if len (server .DisabledTools )> 0 { 605return "all except " + strings .Join (server .DisabledTools ,", " ) 606 } 607return "all" 608} 609 610func timeoutLabel (ms int )string { 611if ms <= 0 { 612return "default" 613 } 614return 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 { 620if ! names .Any () { 621return "none stored" 622 } 623var parts []string 624if names .BearerToken { 625parts = append (parts ,"bearer token" ) 626 } 627for _ ,header := range names .Headers { 628parts = append (parts ,"header " + header ) 629 } 630for _ ,env := range names .Env { 631parts = append (parts ,"env " + env ) 632 } 633return "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 { 639mcp := m .Kind == "mcp" 640hints := []hint {{"↑↓" ,"move" }} 641if m .extensionOff () { 642hints = append (hints ,hint {"ctrl+t" ,"enable extension" }) 643 } 644if ok { 645if m .canToggle (item ,scopeGlobal ) { 646hints = append (hints ,hint {"enter" ,"global" }) 647 } 648if m .canToggle (item ,scopeSession ) { 649hints = append (hints ,hint {"shift+enter" ,"this session" }) 650 } 651if ! mcp && m .canClear (item ) { 652hints = append (hints ,hint {"ctrl+x" ,"follow global" }) 653 } 654if mcp { 655hints = append (hints ,hint {"ctrl+e" ,"edit" },hint {"ctrl+d" ,"delete" }) 656 } 657if serverDisabled (item ) { 658hints = append (hints ,hint {"ctrl+g" ,"enable server" }) 659 } 660 } 661if mcp { 662hints = append (hints ,hint {"ctrl+o" ,"add server" }) 663 } 664return 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 { 669return ! 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 ) { 675if m .Form != nil && ! m .Saving && m .Error == "" { 676return "" ,false 677 } 678switch { 679case m .Saving : 680return DefaultStyles .Busy .Render ("saving and reloading…" ),true 681case m .Loading : 682return DefaultStyles .Faint .Render ("loading…" ),false 683case m .Error != "" : 684return DefaultStyles .Error .Render (m .Error ),true 685case m .Notice != "" : 686return DefaultStyles .Warning .Render (m .Notice ),true 687case m .extensionOff (): 688return DefaultStyles .Warning .Render ("extension is off" ),true 689case len (m .Diagnostics )> 0 : 690return DefaultStyles .Warning .Render (m .diagnosticLine ()),true 691 } 692return DefaultStyles .Faint .Render (counted (len (m .Items ),capabilityNouns [m .Kind ])),false 693} 694 695func (m CapabilityPageModel )diagnosticLine ()string { 696if len (m .Diagnostics )== 1 { 697return m .Diagnostics [0 ] 698 } 699return fmt .Sprintf ("%d discovery diagnostics: %s" ,len (m .Diagnostics ),m .Diagnostics [0 ]) 700} 701 702func (m CapabilityPageModel )footer (width int )string { 703if m .confirm .asking ()&& ! m .Saving { 704return m .confirm .footer (width ,m .Error ) 705 } 706status ,urgent := m .status () 707if m .Form != nil { 708keys := m .Form .footer (width ) 709if status == "" { 710return keys 711 } 712return " " + ansi .Truncate (status ,max (1 ,width - 2 ),"…" )+ "\n" + keys 713 } 714item ,ok := m .current () 715return 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 { 721item ,ok := m .current () 722switch { 723case ! ok : 724return "" 725case m .Kind == "mcp" : 726return item .Detail 727 } 728return 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 { 733rows := m .Form .view (width ) 734return rows [:min (len (rows ),height )] 735} 736 737func (m CapabilityPageModel )View ()string { 738width := max (1 ,m .Width ) 739lv := m .listView 740noun := capabilityNouns [m .Kind ] 741lv .Empty = "No " + noun + "s found." 742switch { 743case m .Loading : 744lv .Empty = "loading " + noun + "s…" 745case m .Error != "" && len (m .Items )== 0 : 746lv .Empty = "Could not load " + noun + "s; the reason is in the footer." 747 } 748frame := lv .frame (brand ("albedo" )+ " " + DefaultStyles .Muted .Render ("/" + m .Kind ),"" ,m .footer (width )) 749if m .Form != nil { 750frame .filter ,frame .pane ,frame .list = "" ,nil ,m .formList 751 }else { 752frame .summary = m .summary () 753 } 754return frame .view (m .Width ,m .Height ) 755} 756 757func capabilityChoiceID (item capabilityItem ,scope string )string { 758if scope == scopeGlobal { 759return item .Candidate .ID 760 } 761return item .ID 762} 763 764func (m CapabilityPageModel )storedSecrets (name string ) daemon.MCPSecretNames { 765if item ,ok := m .itemNamed (name );ok { 766return item .Secrets 767 } 768return daemon.MCPSecretNames {} 769}