okami.mom/albedo
git clone https://git.nkp.pet/okami.mom/albedo
479178e
main
1// The login steps in every state a person sees, for the shot gallery. A plain 2// test run draws each state and writes nothing. 3package tui 4 5import ( 6"albedo/cli/internal/config" 7"albedo/cli/internal/daemon" 8 9 tea"charm.land/bubbletea/v2" 10) 11 12func init () { 13registerShots ("login" , 14shotState {"provider-list" ,loginShot (nil )}, 15shotState {"provider-filter" ,loginShot (nil ,typedKeys ("cla" )... )}, 16shotState {"provider-no-match" ,loginShot (nil ,typedKeys ("zzz" )... )}, 17shotState {"provider-remove-ask" ,loginShot (nil , tea.KeyPressMsg {Code :'d' ,Mod :tea .ModCtrl })}, 18shotState {"provider-remove-stray-key" ,loginShot (nil , tea.KeyPressMsg {Code :'d' ,Mod :tea .ModCtrl }, tea.KeyPressMsg {Code :'x' ,Text :"x" })}, 19shotState {"provider-error" ,loginShot (func (m * LoginModel ) {m .Error = "daemon refused the change" })}, 20shotState {"first-run" ,loginEmpty (nil )}, 21shotState {"first-run-sign-in-hint" ,loginEmpty (func (m * LoginModel ) {m .Step = StepName })}, 22shotState {"load-failed" ,loginEmpty (func (m * LoginModel ) {m .Error = "daemon connection unavailable" })}, 23shotState {"sign-in-flows" ,loginShot (func (m * LoginModel ) { 24login ,_ := m .signInFor ("claude" ) 25m .startSignIn (login ) 26 })}, 27shotState {"sign-in-field" ,loginShot (func (m * LoginModel ) { 28m .LoginFields = []daemon.FormField {{Type :"secret" ,Name :"token" ,Label :"paste the token" ,Description :"from the provider's keys page" }} 29m .LoginFieldIndex = 0 30m .nextLoginField () 31 })}, 32shotState {"account-profile" ,loginShot (func (m * LoginModel ) {m .chooseAccount (m .Accounts [0 ]) })}, 33shotState {"api-key" ,loginShot (func (m * LoginModel ) {m .askAPIKey () },typedKeys ("sk-live-abc123" )... )}, 34shotState {"api-key-error" ,loginShot (func (m * LoginModel ) { 35m .askAPIKey () 36m .Error = "Enter an API key without spaces or control characters." 37 })}, 38shotState {"base-url" ,loginShot (func (m * LoginModel ) {m .Name = "work" ;m .askEndpoint () })}, 39shotState {"project" ,loginShot (func (m * LoginModel ) { 40m .Draft .Extension = "vertex" 41m .Name = "vertex" 42m .askProject () 43 })}, 44shotState {"protocol" ,loginShot (func (m * LoginModel ) { 45m .Step = StepProtocol 46m .buildProtocolPicker () 47 })}, 48shotState {"browser-wait" ,loginShot (func (m * LoginModel ) { 49m .Step = StepOAuth 50m .Status = "waiting for the browser…" 51m .SignInURL = "https://login.example/authorize?client_id=albedo&state=7f3c" 52m .LoginInstructions = "Finish signing in in your browser." 53 })}, 54shotState {"device-code" ,loginShot (func (m * LoginModel ) { 55m .Step = StepOAuth 56m .Status = "enter the code to finish" 57m .LoginInstructions = "Open https://login.example/device and enter ABCD-1234." 58 })}, 59shotState {"models" ,loginShot (func (m * LoginModel ) { 60m .Step = StepModels 61* m ,_ = m .Update (loginModelsLoadedMsg {Gen :m .Generation ,Models : []string {"gpt-5" ,"gpt-5-mini" ,"gpt-4.1" }}) 62 })}, 63shotState {"models-loading" ,loginShot (func (m * LoginModel ) {m .Step = StepModels })}, 64shotState {"models-note" ,loginShot (func (m * LoginModel ) { 65m .Step = StepModels 66* m ,_ = m .Update (loginModelsLoadedMsg {Gen :m .Generation ,Note :"No matching models in models.dev. Enter a model ID manually." }) 67 })}, 68shotState {"saving" ,loginShot (func (m * LoginModel ) {m .Step = StepSaving })}, 69shotState {"removing" ,loginShot (func (m * LoginModel ) { 70m .Removing = removal {Kind :"account" ,Label :"mayer@chernobog" } 71m .Step = StepSaving 72 })}, 73 ) 74} 75 76// loginModel is the login screen at a terminal size, listing the fixture 77// providers, accounts and sign-ins it would list from the daemon. 78func loginModel (width ,height int )LoginModel { 79m := NewLoginModel (& daemon.Connection {},"" ,nil ) 80m .SetSize (width ,height ) 81profiles := config.Profiles {Active :"work" ,Providers :map [string ]config.Settings { 82"work" : {Extension :"openai" ,Model :"gpt-5" ,BaseURL :"https://api.openai.com/v1" ,Protocol :"responses" ,HasKey :true }, 83"claude" : {Extension :"claude" ,Model :"claude-sonnet-4" ,Protocol :"chat_completions" }, 84"claude-team" : {Extension :"claude" ,Model :"claude-opus-4" ,Protocol :"chat_completions" }, 85 }} 86listed := daemon.SignIns { 87Logins : []daemon.SignIn { 88 {Provider :"claude" ,Label :"Claude" ,Detail :"console billing or your subscription" ,Protocol :"chat_completions" ,Flows : []string {"browser" ,"device" }}, 89 {Provider :"antigravity" ,Label :"Antigravity" ,Detail :"Google account" ,Flows : []string {"browser" }}, 90 }, 91Accounts : []daemon.Account {{Provider :"claude" ,ID :"acc-1" ,Label :"mayer@chernobog" ,Detail :"Max plan" }}, 92 } 93m ,_ = m .Update (signInsLoadedMsg {Gen :m .Generation ,Listed :listed ,Profiles :& profiles }) 94return m 95} 96 97// loginEmpty is the first-run login: nothing saved, so it asks for a name. 98func loginEmpty (setup func (* LoginModel ))func (width ,height int )string { 99return func (width ,height int )string { 100m := NewLoginModel (& daemon.Connection {},"" ,nil ) 101m .SetSize (width ,height ) 102m ,_ = m .Update (signInsLoadedMsg {Gen :m .Generation ,Listed : daemon.SignIns {Logins : []daemon.SignIn {{Provider :"claude" ,Label :"Claude" }}},Profiles :& config.Profiles {}}) 103if setup != nil { 104setup (& m ) 105 } 106return m .View () 107 } 108} 109 110// loginShot draws the login screen after setup and keys. 111func loginShot (setup func (* LoginModel ),keys ... tea.Msg )func (width ,height int )string { 112return func (width ,height int )string { 113m := loginModel (width ,height ) 114if setup != nil { 115setup (& m ) 116 } 117for _ ,key := range keys { 118m ,_ = m .Update (key ) 119 } 120return m .View () 121 } 122}