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"fmt" 10"maps" 11"slices" 12"strconv" 13"strings" 14"time" 15 16 tea"charm.land/bubbletea/v2" 17) 18 19// signInFor finds the daemon sign-in a profile extension or provider name uses. 20func (m LoginModel )signInFor (provider string ) (daemon.SignIn ,bool ) { 21i := slices .IndexFunc (m .SignIns ,func (login daemon.SignIn )bool {return login .Provider == provider }) 22if i < 0 { 23return daemon.SignIn {},false 24 } 25return m .SignIns [i ],true 26} 27 28// signedOut reports a sign-in provider with no stored account, which cannot run 29// until the user signs in again. 30func (m LoginModel )signedOut (provider string )bool { 31if _ ,known := m .signInFor (provider );! known { 32return false 33 } 34return ! slices .ContainsFunc (m .Accounts ,func (account daemon.Account )bool {return account .Provider == provider }) 35} 36 37// accountAt resolves a chooser row back to the account it lists. 38func (m LoginModel )accountAt (id string ) (daemon.Account ,bool ) { 39index ,err := strconv .Atoi (strings .TrimPrefix (id ,"account:" )) 40if err != nil || index < 0 || index >= len (m .Accounts ) { 41return daemon.Account {},false 42 } 43return m .Accounts [index ],true 44} 45 46func (m * LoginModel )buildChoosePicker () { 47var items []PickerItem 48for _ ,name := range slices .Sorted (maps .Keys (m .Profiles .Providers )) { 49settings := m .Profiles .Providers [name ] 50extension := cmp .Or (settings .Extension ,"openai" ) 51detail := fmt .Sprintf ("%s · %s" ,settings .Model ,extension ) 52if _ ,signIn := m .signInFor (extension );signIn && hasKey (settings ) { 53detail += " · API key" 54 }else if m .signedOut (extension ) { 55detail += " · signed out" 56 } 57if name == m .Profiles .Active { 58detail += " · active" 59 } 60items = append (items ,PickerItem {ID :"use:" + name ,Label :name ,Detail :detail ,Note :"a saved provider profile; enter makes it the one new sessions use" }) 61 } 62for index ,account := range m .Accounts { 63note := "a signed-in account; enter picks the profile it runs under" 64if account .Kind == "service" { 65note = "service account for tools; its tokens stay in creds.json" 66 } 67items = append (items ,PickerItem {ID :"account:" + strconv .Itoa (index ),Label :account .Label ,Detail :account .Detail ,Note :note }) 68 } 69for _ ,p := range customProviders { 70items = append (items ,PickerItem {ID :p .ID ,Label :p .Label ,Detail :p .Detail ,Note :"creates or updates a provider profile" }) 71 } 72for _ ,login := range m .SignIns { 73note := "signs in through the daemon; its tokens stay in the daemon's credential store" 74if login .Kind == "service" { 75note = "service sign-in for tools; its tokens stay in creds.json" 76 } 77items = append (items ,PickerItem {ID :"signin:" + login .Provider ,Label :login .Label ,Detail :login .Detail ,Note :note }) 78 } 79m .ChoosePicker = newLoginPick ("Provider for new sessions" ,items ,"use:" + m .Profiles .Active ,m .Width ,m .Height ) 80} 81 82// removalFor maps a chooser row to what removing it would delete. 83func (m LoginModel )removalFor (id string ) (removal ,bool ) { 84if name ,ok := strings .CutPrefix (id ,"use:" );ok { 85if _ ,saved := m .Profiles .Providers [name ];saved { 86return removal {Kind :"provider" ,ID :name ,Label :name },true 87 } 88 } 89if account ,ok := m .accountAt (id );ok { 90return removal {Kind :"account" ,Provider :account .Provider ,ID :account .ID ,Label :account .Label },true 91 } 92return removal {},false 93} 94 95func (m * LoginModel )confirmRemoval (target removal ) tea.Cmd { 96detail := "This removes the provider and its saved key" 97if target .Kind == "account" { 98detail = "This removes its saved tokens" 99 } 100m .Removing = target 101m .Step = StepRemove 102m .confirm .ask ("remove" ,target .ID ,"remove" ,detail + ". Remove " + target .Kind + " " + target .Label + "?" ) 103return nil 104} 105 106// confirmKey settles a key on the removal question: enter removes, esc keeps 107// what is there, and any other key is ignored. 108func (m * LoginModel )confirmKey (msg tea.KeyPressMsg ) tea.Cmd { 109if m .confirm .key (msg ) { 110m .confirm .dismiss () 111m .Step = StepSaving 112return m .removeCmd (m .Removing ) 113 } 114if m .confirm .asking () { 115return nil 116 } 117return m .backToChoose () 118} 119 120// reloadCmd changes the daemon's accounts or the saved profiles, then re-reads 121// what /login can choose from. 122func (m LoginModel )reloadCmd (change func (context.Context )error ) tea.Cmd { 123return func () tea.Msg { 124ctx := context .Background () 125if err := change (ctx );err != nil { 126return signInsLoadedMsg {Mutation :true ,Err :err ,Gen :m .Generation } 127 } 128result := m .listSignIns (m .readCtx ,m .Generation ) 129result .Mutation = true 130return result 131 } 132} 133 134func (m LoginModel )removeCmd (target removal ) tea.Cmd { 135return m .reloadCmd (func (ctx context.Context )error { 136if target .Kind == "account" { 137return daemon .RemoveAccount (ctx ,m .Conn ,target .ID ) 138 } 139return daemon .DeleteProvider (ctx ,m .Conn ,target .ID ,m .Profiles ) 140 }) 141} 142 143func (m LoginModel )selectAccountCmd (account daemon.Account ) tea.Cmd { 144return m .reloadCmd (func (ctx context.Context )error { 145return daemon .SelectProfileAccount (ctx ,m .Conn ,m .Name ,account ,m .Profiles ) 146 }) 147} 148 149func (m * LoginModel )chooseAccount (account daemon.Account ) tea.Cmd { 150if account .Kind == "service" { 151m .Status = fmt .Sprintf ("%s is ready for use with tools; ctrl+d removes it" ,account .Label ) 152return nil 153 } 154var names []string 155for name ,profile := range m .Profiles .Providers { 156if profile .Extension == account .Provider { 157names = append (names ,name ) 158 } 159 } 160slices .Sort (names ) 161if len (names )== 0 { 162m .Name = account .Provider 163m .Draft = config.Settings {Extension :account .Provider ,Protocol :providerProtocol (account .Provider ),AccountID :& account .ID } 164m .Step = StepOAuthModels 165return m .fetchCatalogCmd (account .Provider ,"" ,m .Generation ) 166 } 167if len (names )== 1 { 168m .Name = names [0 ] 169m .Step = StepSaving 170return m .selectAccountCmd (account ) 171 } 172m .SelectedAccount = & account 173m .Step = StepAccountProfile 174items := make ([]PickerItem ,0 ,len (names )) 175for _ ,name := range names { 176items = append (items ,PickerItem {ID :name ,Label :name ,Detail :"use " + account .Label ,Note :"the profile this account signs in for" }) 177 } 178m .ChoosePicker = newLoginPick ("Choose the profile for this account" ,items ,m .Profiles .Active ,m .Width ,m .Height ) 179return m .ChoosePicker .Init () 180} 181 182func providerProtocol (provider string )string { 183if provider == "claude" { 184return "chat_completions" 185 } 186return "responses" 187} 188 189func (m * LoginModel )backToChoose () tea.Cmd { 190m .Step = StepChoose 191m .buildChoosePicker () 192return m .ChoosePicker .Init () 193} 194 195// startSignIn hands the provider to the daemon, which owns the PKCE pair, the 196// callback listener, and the token exchange. 197func (m * LoginModel )startSignIn (login daemon.SignIn ) tea.Cmd { 198m .Provider = login .Provider 199m .Name = login .Provider 200m .Draft = config.Settings {Extension :login .Provider ,Protocol :providerProtocol (login .Provider )} 201if saved ,ok := m .Profiles .Providers [login .Provider ];ok { 202m .Draft = saved 203 } 204m .Generation = nextPageGeneration () 205m .LoginFields = slices .Clone (login .Fields ) 206m .LoginValues = map [string ]json.RawMessage {} 207m .LoginFieldIndex = 0 208m .LoginFlow = "" 209m .LoginInstructions = "" 210m .LoginID = "" 211m .LoginETag = "" 212m .SignInURL = "" 213m .Error = "" 214if len (login .Flows )> 1 { 215m .Step = StepOAuthFlow 216items := make ([]PickerItem ,0 ,len (login .Flows )) 217for _ ,flow := range login .Flows { 218items = append (items ,PickerItem {ID :flow ,Label :flow }) 219 } 220m .ChoosePicker = newLoginPick ("Choose a sign-in flow" ,items ,login .Flows [0 ],m .Width ,m .Height ) 221return m .ChoosePicker .Init () 222 } 223if len (login .Flows )== 1 { 224m .LoginFlow = login .Flows [0 ] 225 } 226return m .nextLoginField () 227} 228 229func (m * LoginModel )nextLoginField () tea.Cmd { 230for m .LoginFieldIndex < len (m .LoginFields ) { 231field := m .LoginFields [m .LoginFieldIndex ] 232if field .Type == "hidden" { 233m .LoginValues [field .Name ]= slices .Clone (field .Default ) 234m .LoginFieldIndex ++ 235continue 236 } 237m .Step = StepOAuthFields 238if field .Type == "choice" || field .Type == "boolean" { 239items := []PickerItem {} 240if field .Type == "boolean" { 241items = []PickerItem {{ID :"false" ,Label :"false" }, {ID :"true" ,Label :"true" }} 242 }else { 243for _ ,choice := range field .Choices { 244items = append (items ,PickerItem {ID :choice .Label ,Label :choice .Label }) 245 } 246 } 247initial := "" 248if index := daemon .FormChoiceDefault (field );index < len (items ) { 249initial = items [index ].ID 250 } 251m .ChoosePicker = newLoginPick (field .Label ,items ,initial ,m .Width ,m .Height ) 252return m .ChoosePicker .Init () 253 } 254initial := "" 255_ = json .Unmarshal (field .Default ,& initial ) 256m .TextInput .Placeholder = field .Label 257return m .promptInput (initial ,field .Type == "secret" ) 258 } 259return m .beginLoginCmd () 260} 261 262func (m * LoginModel )beginLoginCmd () tea.Cmd { 263m .Step = StepOAuth 264m .Status = "starting sign-in…" 265m .TextInput .Placeholder = "" 266m .promptInput ("" ,false ) 267conn ,provider ,flow ,gen ,lifetime := m .Conn ,m .Provider ,m .LoginFlow ,m .Generation ,m .readCtx 268values := maps .Clone (m .LoginValues ) 269m .LoginValues = nil 270m .LoginFields = nil 271return func () tea.Msg { 272started ,err := daemon .StartProviderLogin (context .Background (),conn ,provider ,flow ,values ) 273// Cleanup also runs if the program has already stopped receiving messages. 274if lifetime .Err ()!= nil && started .ID != "" { 275if cleanupErr := daemon .CancelSignIn (context .Background (),conn ,started .ID );cleanupErr == nil { 276return signInStartedMsg {Err :lifetime .Err (),Gen :gen } 277 } 278 } 279return signInStartedMsg {ID :started .ID ,URL :started .URL ,ETag :started .ETag ,Err :err ,Gen :gen } 280 } 281} 282 283func (m * LoginModel )acceptLoginField (text string ) tea.Cmd { 284field := m .LoginFields [m .LoginFieldIndex ] 285value ,err := daemon .ParseActionField (field ,text ) 286if err != nil { 287m .Error = err .Error () 288return nil 289 } 290if value != nil { 291m .LoginValues [field .Name ]= value 292 } 293m .LoginFieldIndex ++ 294m .Error = "" 295m .TextInput .SetValue ("" ) 296return m .nextLoginField () 297} 298 299func (m LoginModel )pollSignInCmd (id string ,gen int ) tea.Cmd { 300return func () tea.Msg { 301status ,err := daemon .PollSignIn (m .readCtx ,m .Conn ,id ) 302return signInStatusMsg {ID :id ,State :status .State ,Message :status .Message ,ETag :status .ETag ,Instructions :status .Instructions ,Accounts :status .Accounts ,URL :status .URL ,Err :err ,Gen :gen } 303 } 304} 305 306func signInPollTickCmd (id string ,gen int ) tea.Cmd { 307return tea .Tick (signInPollInterval ,func (time.Time ) tea.Msg { 308return signInPollMsg {ID :id ,Gen :gen } 309 }) 310} 311 312func (m LoginModel )inputSignInCmd (id ,input string ,gen int ) tea.Cmd { 313return func () tea.Msg { 314if err := daemon .SignInInput (context .Background (),m .Conn ,id ,input ,m .LoginETag );err != nil { 315return signInStatusMsg {ID :id ,Err :err ,Gen :gen } 316 } 317return signInPollMsg {ID :id ,Gen :gen } 318 } 319} 320 321// cancelSignInCmd drops a sign-in this client no longer waits for, closing the 322// daemon's callback listener. 323func (m LoginModel )cancelSignInCmd (id string ) tea.Cmd { 324if id == "" || m .Conn == nil { 325return nil 326 } 327return func () tea.Msg { 328_ = daemon .CancelSignIn (context .Background (),m .Conn ,id ) 329return nil 330 } 331} 332 333// endSignIn forgets the sign-in in flight and returns the command cancelling it. 334func (m * LoginModel )endSignIn () tea.Cmd { 335id := m .LoginID 336m .LoginID = "" 337m .SignInURL = "" 338m .Status = "" 339return m .cancelSignInCmd (id ) 340} 341 342// Close cancels a sign-in still in flight when the login screen goes away. 343func (m * LoginModel )Close () tea.Cmd { 344if m .cancelReads != nil { 345m .cancelReads () 346 } 347m .closed = true 348m .Generation = nextPageGeneration () 349m .LoginValues = nil 350m .LoginFields = nil 351m .TextInput .SetValue ("" ) 352m .Draft .APIKey = "" 353return m .endSignIn () 354} 355 356// listSignIns reads what the daemon can sign in with, the accounts it holds, 357// and which profiles it holds an api key for. 358func (m LoginModel )listSignIns (ctx context.Context ,gen int )signInsLoadedMsg { 359listed ,err := daemon .SignInList (ctx ,m .Conn ) 360if err != nil { 361return signInsLoadedMsg {Err :err ,Gen :gen } 362 } 363settings ,err := daemon .GetSettings (ctx ,m .Conn ) 364return signInsLoadedMsg {Listed :listed ,Profiles :& settings .Profiles ,Err :err ,Gen :gen } 365} 366 367// Startup already read profiles; only authentication choices need loading. 368func (m LoginModel )initWithProfiles (profiles config.Profiles ) tea.Cmd { 369conn ,ctx ,gen := m .Conn ,m .readCtx ,m .Generation 370profiles .Providers = maps .Clone (profiles .Providers ) 371return func () tea.Msg { 372listed ,err := daemon .SignInList (ctx ,conn ) 373return signInsLoadedMsg {Listed :listed ,Profiles :& profiles ,Err :err ,Gen :gen } 374 } 375}