okami.mom/albedo
git clone https://git.nkp.pet/okami.mom/albedo
47735ec
main
1package tui 2 3import ( 4"albedo/cli/internal/config" 5"albedo/cli/internal/daemon" 6"cmp" 7"context" 8"encoding/json" 9"errors" 10"fmt" 11"slices" 12"strings" 13"time" 14 15"charm.land/bubbles/v2/textinput" 16 tea"charm.land/bubbletea/v2" 17) 18 19type LoginStep int 20 21const ( 22StepChoose LoginStep = iota 23StepName 24StepBaseURL 25StepAPIKey 26StepProject 27StepLocation 28StepProtocol 29StepModels 30StepModel 31StepOAuth 32StepOAuthModels 33StepRemove 34StepSaving 35StepAccountProfile 36StepOAuthFlow 37StepOAuthFields 38) 39 40// signInPollInterval is how often the daemon is asked about a running sign-in. 41const signInPollInterval = 300 * time .Millisecond 42 43// removal names something /login can delete: a provider in config.json or an 44// account in the daemon's credential store. 45type removal struct { 46Kind string // "provider" | "account" 47Provider string 48ID string // provider name or daemon account id 49Label string 50} 51 52type LoginModel struct { 53readCtx context.Context 54cancelReads context.CancelFunc 55closed bool 56Conn * daemon.Connection 57openBrowser func (url string ) 58Removing removal 59Profiles config.Profiles 60Hint string 61Name string 62Provider string // sign-in provider in flight, or just finished 63LoginETag string 64LoginFlow ,LoginInstructions string 65LoginFields []daemon.FormField 66LoginFieldIndex int 67LoginValues map [string ]json.RawMessage 68SelectedAccount * daemon.Account 69LoginID string // daemon id of the sign-in in flight 70SignInURL string 71Status string // daemon progress line while a sign-in runs 72CatalogNote string 73Error string 74Draft config.Settings 75SignIns []daemon.SignIn 76Accounts []daemon.Account 77Catalog []string 78TextInput textinput.Model 79ChoosePicker loginPick 80ProtocolPicker loginPick 81ModelPicker loginPick 82confirm 83Generation int 84Width int 85Height int 86Step LoginStep 87} 88 89// NewLoginModel panics if conn is nil. A nil browser opener disables automatic 90// browser launches. 91func NewLoginModel (conn * daemon.Connection ,nameHint string ,openBrowser func (string ))LoginModel { 92if conn == nil { 93panic ("tui.NewLoginModel requires a daemon connection" ) 94 } 95profiles := config.Profiles {Providers :map [string ]config.Settings {}} 96 97readCtx ,cancelReads := context .WithCancel (context .Background ()) 98m := LoginModel { 99Generation :nextPageGeneration (), 100readCtx :readCtx , 101cancelReads :cancelReads , 102Conn :conn , 103Step :StepChoose , 104Profiles :profiles , 105Hint :strings .TrimSpace (nameHint ), 106Draft : config.Settings {Extension :"openai" ,BaseURL :"https://api.openai.com/v1" ,Protocol :"responses" }, 107TextInput :newField (), 108openBrowser :openBrowser , 109 } 110m .buildChoosePicker () 111return m 112} 113 114func (m LoginModel )Init () tea.Cmd { 115gen := m .Generation 116return func () tea.Msg {return m .listSignIns (m .readCtx ,gen ) } 117} 118 119func (m LoginModel )Update (msg tea.Msg ) (LoginModel , tea.Cmd ) { 120switch msg := msg .(type ) { 121case signInsLoadedMsg : 122if msg .Gen != m .Generation { 123return m ,nil 124 } 125m .SignIns = msg .Listed .Logins 126m .Accounts = msg .Listed .Accounts 127if msg .Profiles != nil { 128m .Profiles = * msg .Profiles 129 } 130 131if msg .Err != nil && m .Error == "" { 132m .Error = msg .Err .Error () 133 } 134if m .Step == StepSaving { 135m .Removing = removal {} 136m .Step = StepChoose 137if msg .Err == nil { 138m .Error = "" 139 } 140return m ,m .backToChoose () 141 } 142m .buildChoosePicker () 143if m .Hint != "" { 144return m ,m .applyHint () 145 } 146if m .Step != StepChoose { 147return m ,nil 148 } 149if len (m .Profiles .Providers )== 0 && len (m .Accounts )== 0 { 150m .Step = StepName 151m .TextInput .Placeholder = "" 152return m ,m .promptInput ("" ,false ) 153 } 154return m ,m .ChoosePicker .Init () 155 156case signInStartedMsg : 157if msg .Gen != m .Generation || m .Step != StepOAuth { 158// Stale start: cancel the sign-in this client no longer shows. 159if msg .ID == m .LoginID { 160return m ,nil 161 } 162return m ,m .cancelSignInCmd (msg .ID ) 163 } 164m .LoginID = msg .ID 165m .LoginETag = msg .ETag 166if msg .Err != nil { 167m .Error = operationError (msg .Err ,"" ,"Sign-in may have started; check your accounts before starting another. Its outcome cannot be confirmed from this response." ) 168if msg .ID == "" { 169return m ,nil 170 } 171return m ,signInPollTickCmd (msg .ID ,m .Generation ) 172 } 173m .LoginID = msg .ID 174m .SignInURL = msg .URL 175m .Status = "" 176 177cmds := []tea.Cmd {textinput .Blink } 178if msg .URL != "" { 179cmds = append (cmds ,m .openBrowserCmd (msg .URL )) 180 } 181cmds = append (cmds ,m .pollSignInCmd (msg .ID ,m .Generation )) 182return m ,tea .Batch (cmds ... ) 183 184case signInPollMsg : 185if msg .Gen != m .Generation || m .Step != StepOAuth || msg .ID != m .LoginID { 186if msg .ID == m .LoginID { 187return m ,nil 188 } 189return m ,m .cancelSignInCmd (msg .ID ) 190 } 191return m ,m .pollSignInCmd (msg .ID ,msg .Gen ) 192 193case signInStatusMsg : 194if msg .Gen != m .Generation || m .Step != StepOAuth || msg .ID != m .LoginID { 195// A stale result: cancel the sign-in this client no longer shows. 196if msg .ID == m .LoginID { 197return m ,nil 198 } 199return m ,m .cancelSignInCmd (msg .ID ) 200 } 201if msg .Err != nil { 202m .Error = msg .Err .Error () 203if api ,ok := errors.AsType [* daemon.APIError ](msg .Err );ok && (api .StatusCode == 404 || api .StatusCode == 410 ) { 204return m ,m .endSignIn () 205 } 206return m ,signInPollTickCmd (msg .ID ,m .Generation ) 207 } 208m .LoginETag = msg .ETag 209m .LoginInstructions = msg .Instructions 210var browser tea.Cmd 211if m .SignInURL == "" && msg .URL != "" { 212m .SignInURL = msg .URL 213browser = m .openBrowserCmd (msg .URL ) 214 } 215m .Status = msg .Message 216switch msg .State { 217case "complete" : 218m .LoginID = "" 219for _ ,account := range msg .Accounts { 220if i := slices .IndexFunc (m .Accounts ,func (a daemon.Account )bool {return a .ID == account .ID });i >= 0 { 221m .Accounts [i ]= account 222 }else { 223m .Accounts = append (m .Accounts ,account ) 224 } 225 } 226if login ,ok := m .signInFor (m .Provider ); (ok && login .Kind == "service" )|| (len (msg .Accounts )> 0 && msg .Accounts [0 ].Kind == "service" ) { 227label := m .Provider 228if ok && login .Label != "" { 229label = login .Label 230 } 231m .Status = fmt .Sprintf ("Signed in to %s!" ,label ) 232m .Error = "" 233return m ,m .backToChoose () 234 } 235if len (msg .Accounts )> 0 { 236m .Draft .AccountID = & msg .Accounts [0 ].ID 237 } 238m .Step = StepOAuthModels 239m .Generation = nextPageGeneration () 240return m ,m .fetchCatalogCmd (m .Provider ,"" ,m .Generation ) 241case "failed" ,"cancelled" ,"expired" : 242m .LoginID = "" 243m .Error = msg .Message 244return m ,nil 245default : 246return m ,tea .Batch (browser ,signInPollTickCmd (msg .ID ,m .Generation )) 247 } 248 249case loginModelsLoadedMsg : 250if msg .Gen != m .Generation || (m .Step != StepModels && m .Step != StepOAuthModels ) { 251return m ,nil 252 } 253m .Catalog = msg .Models 254if m .Catalog == nil { 255m .Catalog = []string {} 256 } 257m .CatalogNote = msg .Note 258m .buildModelPicker () 259return m ,m .ModelPicker .Init () 260 261case providerSavedMsg : 262if msg .Gen != m .Generation || m .closed { 263return m ,nil 264 } 265if msg .Err != nil { 266m .Error = msg .Err .Error () 267if m .Name == msg .Name && m .Draft .Model != "" { 268m .Step = StepModel 269return m ,m .promptInput (m .Draft .Model ,false ) 270 } 271return m ,m .backToChoose () 272 } 273return m ,func () tea.Msg { 274return LoginDoneMsg {Gen :msg .Gen ,Name :msg .Name ,Settings :msg .Settings } 275 } 276 277case tea.KeyPressMsg : 278key := msg .String () 279switch { 280case m .Step == StepRemove : 281return m ,m .confirmKey (msg ) 282case m .Step == StepChoose && key == "ctrl+d" : 283if item ,ok := m .ChoosePicker .Highlighted ();ok { 284if target ,ok := m .removalFor (item .ID );ok { 285return m ,m .confirmRemoval (target ) 286 } 287 } 288return m ,nil 289 } 290if key == "esc" || key == "ctrl+c" || key == "ctrl+d" { 291pickerStep := m .Step == StepChoose || m .Step == StepAccountProfile || m .Step == StepOAuthFlow || m .Step == StepProtocol || m .Step == StepModels || m .Step == StepOAuthModels 292if ! pickerStep || key == "ctrl+d" { 293cancel := m .Close () 294m .LoginValues = nil 295m .LoginFields = nil 296m .TextInput .SetValue ("" ) 297m .Generation = nextPageGeneration () 298return m ,tea .Batch (cancel ,func () tea.Msg {return LoginCancelMsg {} }) 299 } 300 } 301 302case PickerSelectMsg : 303switch m .Step { 304case StepOAuthFlow : 305m .LoginFlow = msg .ID 306return m ,m .nextLoginField () 307case StepOAuthFields : 308return m ,m .acceptLoginField (msg .ID ) 309case StepAccountProfile : 310if m .SelectedAccount != nil { 311m .Name = msg .ID 312m .Step = StepSaving 313return m ,m .selectAccountCmd (* m .SelectedAccount ) 314 } 315case StepChoose : 316if p ,ok := customProviderByID (msg .ID );ok { 317return m ,m .startCustomProvider (p ) 318 } 319if provider ,ok := strings .CutPrefix (msg .ID ,"signin:" );ok { 320if login ,ok := m .signInFor (provider );ok { 321return m ,m .startSignIn (login ) 322 } 323 } 324// Choosing an account selects it; d removes it. 325if account ,ok := m .accountAt (msg .ID );ok { 326return m ,m .chooseAccount (account ) 327 } 328if name ,ok := strings .CutPrefix (msg .ID ,"use:" );ok { 329if s ,ok := m .Profiles .Providers [name ];ok { 330return m ,m .useProfile (name ,s ) 331 } 332 } 333 334case StepProtocol : 335m .Draft .Protocol = msg .ID 336return m ,m .advanceToModels () 337 338case StepModels ,StepOAuthModels : 339if msg .ID == "manual" { 340m .Step = StepModel 341m .TextInput .Placeholder = "" 342return m ,m .promptInput ("" ,false ) 343 } 344if modelName ,ok := strings .CutPrefix (msg .ID ,"model:" );ok { 345m .Draft .Model = modelName 346m .Step = StepSaving 347return m ,m .saveProviderCmd (m .Name ,m .Draft ) 348 } 349 } 350 351case PickerCancelMsg : 352switch m .Step { 353case StepOAuthFlow ,StepOAuthFields : 354return m ,m .backToChoose () 355case StepChoose ,StepOAuthModels : 356return m ,func () tea.Msg {return LoginCancelMsg {} } 357case StepProtocol : 358m .Step = StepAPIKey 359return m ,m .promptInput ("" ,true ) 360case StepModels : 361m .Generation = nextPageGeneration () 362if p ,_ := m .custom ();p .Scoped { 363return m ,m .askLocation () 364 } 365if m .isFixedProtocol () { 366m .Step = StepAPIKey 367return m ,m .promptInput (m .Draft .APIKey ,true ) 368 } 369m .Step = StepBaseURL 370return m ,m .promptInput (m .Draft .BaseURL ,false ) 371 } 372 } 373 374// A picker step routes everything to its picker; the rest to the input. 375if m .Step == StepOAuthFields && m .LoginFieldIndex < len (m .LoginFields ) { 376field := m .LoginFields [m .LoginFieldIndex ] 377if field .Type == "choice" || field .Type == "boolean" { 378return m ,m .pickUpdate (msg ) 379 } 380 } 381 382switch m .Step { 383case StepChoose ,StepAccountProfile ,StepOAuthFlow ,StepProtocol ,StepModels ,StepOAuthModels : 384return m ,m .pickUpdate (msg ) 385 } 386 387if key ,ok := msg .(tea.KeyPressMsg );ok && key .String ()== "enter" { 388switch m .Step { 389case StepName : 390m .TextInput .SetCursor (0 ) 391name ,err := config .ValidateProviderName (strings .TrimSpace (m .TextInput .Value ())) 392if err != nil { 393return m .fail (err .Error ()) 394 } 395m .Name = name 396m .Error = "" 397 398// A name that is a sign-in provider — or a saved profile for one — 399// signs in instead of asking for an api key, unless the profile 400// keeps a key or this is that extension's api-key flow. 401flow := m .Draft .Extension 402extension := name 403if saved ,known := m .Profiles .Providers [name ];known { 404extension = saved .Extension 405m .Draft = saved 406 } 407if login ,ok := m .signInFor (extension );ok && ! hasKey (m .Draft )&& extension != flow { 408return m ,m .startSignIn (login ) 409 } 410return m ,m .askEndpoint () 411 412case StepBaseURL : 413m .TextInput .SetCursor (0 ) 414val := cmp .Or (strings .TrimSpace (m .TextInput .Value ()),m .Draft .BaseURL ) 415endpoint := "" 416if p ,_ := m .custom ();val != "" || ! p .Optional { 417var err error 418if endpoint ,err = config .ValidateEndpoint (val );err != nil { 419return m .fail (err .Error ()) 420 } 421 } 422if endpoint != m .Draft .BaseURL { 423m .Draft .APIKey ,m .Draft .HasKey = "" ,false 424 } 425m .Draft .BaseURL = endpoint 426m .Error = "" 427return m ,m .askAPIKey () 428 429case StepAPIKey : 430m .TextInput .SetCursor (0 ) 431switch val := cmp .Or (m .TextInput .Value (),m .Draft .APIKey ); { 432case val == "" && m .Draft .HasKey : 433// Blank keeps the key the daemon holds. 434case val == "" && m .optionalKey (): 435case val == "" || strings .ContainsFunc (val ,func (r rune )bool {return r <= 0x20 || r == 0x7f }): 436return m .fail ("Enter an API key without spaces or control characters." ) 437default : 438m .Draft .APIKey = val 439 } 440m .Error = "" 441if m .isFixedProtocol () { 442return m ,m .advanceToModels () 443 } 444m .Step = StepProtocol 445m .buildProtocolPicker () 446return m ,m .ProtocolPicker .Init () 447 448case StepProject : 449m .TextInput .SetCursor (0 ) 450m .Draft .Project = optionalText (strings .TrimSpace (m .TextInput .Value ())) 451m .Error = "" 452return m ,m .askLocation () 453 454case StepLocation : 455m .TextInput .SetCursor (0 ) 456m .Draft .Location = optionalText (strings .TrimSpace (m .TextInput .Value ())) 457m .Error = "" 458return m ,m .advanceToModels () 459 460case StepOAuthFields : 461return m ,m .acceptLoginField (m .TextInput .Value ()) 462case StepOAuth : 463val := strings .TrimSpace (m .TextInput .Value ()) 464if val != "" && m .LoginID != "" { 465m .Status = "exchanging authorization code…" 466m .Error = "" 467m .Generation = nextPageGeneration () 468m .TextInput .SetValue ("" ) 469return m ,m .inputSignInCmd (m .LoginID ,val ,m .Generation ) 470 } 471 472case StepModel : 473m .TextInput .SetCursor (0 ) 474val := strings .TrimSpace (m .TextInput .Value ()) 475if err := validateModelID (val );err != nil { 476return m .fail (err .Error ()) 477 } 478m .Draft .Model = val 479m .Error = "" 480m .Step = StepSaving 481return m ,m .saveProviderCmd (m .Name ,m .Draft ) 482 } 483 } 484 485var cmd tea.Cmd 486m .TextInput ,cmd = m .TextInput .Update (msg ) 487return m ,cmd 488}