okami.mom/albedo
git clone https://git.nkp.pet/okami.mom/albedo
799087c
main
1package tui 2 3import ( 4"albedo/cli/internal/daemon" 5"context" 6"errors" 7"fmt" 8"slices" 9"strings" 10 11 tea"charm.land/bubbletea/v2" 12) 13 14type ExtensionItem = daemon.ExtensionSummary 15 16type ExtensionPickerDoneMsg struct {} 17type ExtensionPickerChangedMsg struct {} 18 19type extensionsLoadedMsg struct { 20Err error 21Extensions []ExtensionItem 22Gen int 23} 24 25type extensionToggledMsg struct { 26Err error 27Extensions []ExtensionItem 28Notice string 29Gen int 30} 31 32// The scopes a change can be made in, and the confirmation each asks. 33const ( 34scopeGlobal = "global" 35scopeSession = "session" 36scopeInherit = "inherit" 37) 38 39// ExtensionPickerModel lists the extensions with the global default and 40// this session's own choice side by side. Enter changes the global default; 41// shift+enter changes this session only. 42type ExtensionPickerModel struct { 43Conn * daemon.Connection 44SessionID string 45Extensions []ExtensionItem 46pageStatus 47listView 48confirm 49} 50 51func NewExtensionPickerModel (conn * daemon.Connection ,sessionID string )ExtensionPickerModel { 52return ExtensionPickerModel { 53Conn :conn , 54SessionID :sessionID , 55pageStatus :newPageStatus (true ), 56listView :newListView ("Search extensions" ), 57 } 58} 59 60func (m * ExtensionPickerModel )SetSize (width ,height int ) { 61m .pageStatus .SetSize (width ,height ) 62m .listView .setSize (width ,height ) 63} 64 65// Confirming reports a question waiting for enter or esc. 66func (m ExtensionPickerModel )Confirming ()bool {return m .confirm .asking () } 67 68func (m ExtensionPickerModel )Init () tea.Cmd { 69return m .loadExtensionsCmd (m .Generation ) 70} 71 72func (m ExtensionPickerModel )loadExtensionsCmd (gen int ) tea.Cmd { 73return func () tea.Msg { 74if m .Conn == nil { 75return extensionsLoadedMsg {Err :errors .New ("daemon connection unavailable" ),Gen :gen } 76 } 77items ,err := daemon .ListExtensions (context .Background (),m .Conn ,m .SessionID ) 78return extensionsLoadedMsg {Extensions :items ,Err :err ,Gen :gen } 79 } 80} 81 82// changeExtensionCmd sends one change: scope "global" or "session" with a 83// value, or "inherit" to drop this session's choice. 84func (m ExtensionPickerModel )changeExtensionCmd (name ,scope string ,enabled bool ,gen int ) tea.Cmd { 85return func () tea.Msg { 86if m .Conn == nil { 87return extensionToggledMsg {Err :errors .New ("daemon connection unavailable" ),Gen :gen } 88 } 89etag := "" 90for _ ,item := range m .Extensions { 91if item .Name == name { 92etag = item .SessionETag 93if scope == "global" { 94etag = item .GlobalETag 95 } 96 } 97 } 98body := daemon.ExtensionSelectionRequest {Name :name ,Scope :scope ,ETag :etag } 99if scope != "inherit" { 100body .Enabled = & enabled 101 } 102updated ,err := daemon .SelectExtension (context .Background (),m .Conn ,m .SessionID ,body ) 103if err != nil { 104return extensionToggledMsg {Err :err ,Gen :gen } 105 } 106notice := "Saved the global default. Reload sessions to apply it." 107if scope != "global" { 108reloaded ,reloadErr := daemon .ReloadSession (context .Background (),m .Conn ,m .SessionID , daemon.ReloadRequest {Target :"session" }) 109switch { 110case reloadErr != nil : 111notice = "Selection saved; reload failed: " + reloadErr .Error () 112if _ ,uncertain := errors.AsType [* daemon.UncertainOutcomeError ](reloadErr );uncertain { 113notice = "Selection saved; reload not confirmed: " + reloadErr .Error () 114 } 115case reloaded .Session == nil : 116notice = "Selection saved; reload returned no session outcome." 117case reloaded .Session .State != "applied" : 118notice = "Selection saved; session reload " + reloaded .Session .State + ". " + reloaded .Message () 119default : 120notice = reloaded .Message () 121 } 122 } 123return extensionToggledMsg {Extensions :updated ,Notice :notice ,Gen :gen } 124 } 125} 126 127func (m ExtensionPickerModel )capabilitiesList (ext ExtensionItem ) []string { 128var caps []string 129if ext .Context { 130caps = append (caps ,"context" ) 131 } 132if len (ext .Tools )> 0 { 133caps = append (caps ,fmt .Sprintf ("tools (%s)" ,strings .Join (ext .Tools ,", " ))) 134 } 135if len (ext .PythonModules )> 0 { 136caps = append (caps ,fmt .Sprintf ("python modules (%s)" ,strings .Join (ext .PythonModules ,", " ))) 137 } 138return caps 139} 140 141// current is the extension under the cursor. 142func (m ExtensionPickerModel )current () (ExtensionItem ,bool ) { 143row ,ok := m .highlighted () 144if ! ok { 145return ExtensionItem {},false 146 } 147i := slices .IndexFunc (m .Extensions ,func (e ExtensionItem )bool {return e .Name == row .key }) 148if i < 0 { 149return ExtensionItem {},false 150 } 151return m .Extensions [i ],true 152} 153 154func (m * ExtensionPickerModel )setExtensions (items []ExtensionItem ) { 155m .Extensions = items 156rows := make ([]listEntry ,len (items )) 157for i ,ext := range items { 158rows [i ]= m .entry (ext ) 159 } 160m .setRows (rows ) 161} 162 163// askToggle asks before changing an extension in scope, or before dropping 164// this session's own choice. 165func (m * ExtensionPickerModel )askToggle (scope string ) { 166ext ,ok := m .current () 167if ! ok { 168return 169 } 170if ext .Quarantined != "" { 171m .Error = ext .Quarantined 172return 173 } 174m .Error = "" 175on := ext .GlobalEnabled 176if scope == scopeSession { 177on = ext .Enabled 178 } 179verb := "enable" 180if on { 181verb = "disable" 182 } 183switch scope { 184case scopeInherit : 185m .confirm .ask (scope ,ext .Name ,"follow global" ,fmt .Sprintf ("This session will follow the global default for %s. Remove its own choice?" ,ext .Name )) 186case scopeSession : 187m .confirm .ask (scope ,ext .Name ,verb ,fmt .Sprintf ("This will reload this session. %s %s for this session only?" ,strings .ToUpper (verb [:1 ])+ verb [1 :],ext .Name )) 188default : 189m .confirm .ask (scope ,ext .Name ,verb ,fmt .Sprintf ("Sessions following global defaults will use this change. %s %s globally?" ,strings .ToUpper (verb [:1 ])+ verb [1 :],ext .Name )) 190 } 191} 192 193// save sends the change the confirmation asked about. 194func (m * ExtensionPickerModel )save () tea.Cmd { 195ext ,ok := m .current () 196if ! ok || ext .Name != m .confirm .target { 197m .confirm .dismiss () 198return nil 199 } 200m .Saving ,m .Error = true ,"" 201m .Generation = nextPageGeneration () 202enabled := ! ext .GlobalEnabled 203switch m .confirm .what { 204case scopeSession : 205enabled = ! ext .Enabled 206case scopeInherit : 207enabled = false 208 } 209return m .changeExtensionCmd (ext .Name ,m .confirm .what ,enabled ,m .Generation ) 210} 211 212func (m ExtensionPickerModel )Update (msg tea.Msg ) (ExtensionPickerModel , tea.Cmd ) { 213switch msg := msg .(type ) { 214case extensionsLoadedMsg : 215if ! m .settle (msg .Gen ,msg .Err ,& m .Loading ) { 216return m ,nil 217 } 218m .Error = "" 219m .setExtensions (msg .Extensions ) 220return m ,nil 221 222case extensionToggledMsg : 223if msg .Gen == m .Generation { 224if apiErr ,ok := errors.AsType [* daemon.APIError ](msg .Err );ok && apiErr .StatusCode == 412 { 225m .Saving ,m .Loading ,m .Error = false ,true ,"" 226m .confirm .dismiss () 227m .Notice = "The configuration changed. Review the refreshed choices before saving." 228m .Generation = nextPageGeneration () 229return m ,m .loadExtensionsCmd (m .Generation ) 230 } 231 } 232if ! m .settle (msg .Gen ,msg .Err ,& m .Saving ) { 233m .confirm .failed = msg .Gen == m .Generation 234return m ,nil 235 } 236m .confirm .dismiss () 237m .setExtensions (msg .Extensions ) 238m .Error = "" 239m .Notice = msg .Notice 240return m ,func () tea.Msg {return ExtensionPickerChangedMsg {} } 241 242case tea.KeyPressMsg : 243key := msg .String () 244switch { 245case m .confirm .asking ()&& ! m .Saving : 246if m .confirm .key (msg ) { 247cmd := m .save () 248return m ,cmd 249 } 250if ! m .confirm .asking () { 251m .Error = "" 252 } 253return m ,nil 254case key == "esc" || key == "ctrl+c" || key == "ctrl+d" : 255return m ,func () tea.Msg {return ExtensionPickerDoneMsg {} } 256case m .Loading || m .Saving : 257return m ,nil 258 } 259ext ,_ := m .current () 260switch key { 261case "enter" : 262m .askToggle (scopeGlobal ) 263case "shift+enter" ,"alt+enter" : 264m .askToggle (scopeSession ) 265case "ctrl+x" : 266if ext .Overridden { 267m .askToggle (scopeInherit ) 268 } 269case "ctrl+w" : 270if ext .Name == "webhooks" && ext .Enabled { 271return m ,func () tea.Msg {return ChatOpenWebhooksPageMsg {} } 272 } 273case "ctrl+r" : 274m .Loading ,m .Error ,m .Notice = true ,"" ,"" 275m .Generation = nextPageGeneration () 276return m ,m .loadExtensionsCmd (m .Generation ) 277default : 278cmd := m .listView .update (msg ) 279return m ,cmd 280 } 281return m ,nil 282 } 283cmd := m .listView .update (msg ) 284return m ,cmd 285} 286 287// entry is how an extension reads in the list: its global default in front, 288// and this session's own choice at the edge when it differs. 289func (m ExtensionPickerModel )entry (ext ExtensionItem )listEntry { 290lead ,tag := DefaultStyles .Faint .Render ("off" ),"" 291switch { 292case ext .Quarantined != "" : 293lead ,tag = DefaultStyles .Error .Render ("bad" ),DefaultStyles .Error .Render ("quarantined" ) 294case ext .GlobalEnabled : 295lead = DefaultStyles .Success .Render ("on" ) 296 } 297if ext .Overridden && ext .Quarantined == "" { 298tag = DefaultStyles .Muted .Render ("this session: " + onOff (ext .Enabled )) 299 } 300return listEntry { 301key :ext .Name , 302lead :lead , 303name :ext .Name , 304desc :ext .Description , 305tag :tag , 306search :append (slices .Clone (ext .Plugins ),ext .Requires ... ), 307detail :func (width int ) []string {return m .details (ext ,width ) }, 308 } 309} 310 311func onOff (on bool )string { 312if on { 313return "on" 314 } 315return "off" 316} 317 318// details is the pane: both choices, what the extension provides, and what 319// each key would change. 320func (m ExtensionPickerModel )details (ext ExtensionItem ,width int ) []string { 321lines := paneTitle (ext .Name ,ext .Description ,width ) 322session := "follows global" 323if ext .Overridden { 324session = onOff (ext .Enabled )+ " (its own choice)" 325 } 326lines = append (lines ,factRows ("global" ,onOff (ext .GlobalEnabled )+ " default" ,width )... ) 327lines = append (lines ,factRows ("session" ,session ,width )... ) 328lines = append (lines ,factRows ("plugins" ,joinOr (ext .Plugins ,", " ,"not reported" ),width )... ) 329lines = append (lines ,factRows ("provides" ,joinOr (m .capabilitiesList (ext )," · " ,"not reported" ),width )... ) 330lines = append (lines ,factRows ("requires" ,joinOr (ext .Requires ,", " ,"none" ),width )... ) 331if ext .Quarantined != "" { 332lines = append (lines ,"" ) 333for _ ,l := range svWrap ("quarantined: " + ext .Quarantined ,width ,6 ) { 334lines = append (lines ,DefaultStyles .Error .Render (l )) 335 } 336return lines 337 } 338lines = append (lines ,"" ) 339lines = append (lines ,paneNote ("A global change applies as sessions reload. A session change reloads this session and may lose Python variables that cannot be saved." ,width )... ) 340return lines 341} 342 343func joinOr (items []string ,sep ,empty string )string { 344if len (items )== 0 { 345return empty 346 } 347return strings .Join (items ,sep ) 348} 349 350func (m ExtensionPickerModel )footer (width int )string { 351if m .confirm .asking ()&& ! m .Saving { 352return m .confirm .footer (width ,m .Error ) 353 } 354ext ,_ := m .current () 355hints := []hint {{"↑↓" ,"move" }, {"enter" ,"global" }, {"shift+enter" ,"this session" }} 356if ext .Overridden { 357hints = append (hints ,hint {"ctrl+x" ,"follow global" }) 358 } 359if ext .Name == "webhooks" && ext .Enabled { 360hints = append (hints ,hint {"ctrl+w" ,"webhooks" }) 361 } 362if len (m .Extensions )== 0 && m .Error != "" { 363hints = append (hints ,hint {"ctrl+r" ,"retry" }) 364 } 365hints = append (hints ,hint {"esc" ,"back" }) 366 367var status string 368urgent := true 369switch { 370case m .Saving : 371status = DefaultStyles .Busy .Render ("saving…" ) 372case m .Loading : 373status ,urgent = DefaultStyles .Faint .Render ("loading…" ),false 374case m .Error != "" : 375status = DefaultStyles .Error .Render (m .Error ) 376case m .Notice != "" : 377status = DefaultStyles .Warning .Render (m .Notice ) 378default : 379on := 0 380for _ ,e := range m .Extensions { 381if e .Enabled { 382on ++ 383 } 384 } 385status ,urgent = DefaultStyles .Faint .Render (fmt .Sprintf ("%s · %d on here" ,counted (len (m .Extensions ),"extension" ),on )),false 386 } 387return footerLine (width ,hints ,status ,urgent ) 388} 389 390func (m ExtensionPickerModel )View ()string { 391lv := m .listView 392lv .Empty = "No extensions are available for this session." 393if m .Loading { 394lv .Empty = "loading extensions…" 395 } 396return lv .frame (brand ("albedo" )+ " " + DefaultStyles .Muted .Render ("/extensions" ),"" ,m .footer (max (1 ,m .Width ))).view (m .Width ,m .Height ) 397}