Something went wrong. Try again.
small gleam coding and persistent agent daemon with a detachable cli and a canvas ui forked from okami.mom/albedo
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945package tui
import ( "albedo/cli/internal/config" "albedo/cli/internal/daemon" "cmp" "context" "fmt" "maps" "net/url" "slices" "strconv" "strings" "time"
"charm.land/bubbles/v2/textinput" tea "charm.land/bubbletea/v2" "github.com/charmbracelet/x/ansi")
type LoginDoneMsg struct { Name string Settings config.Settings}
type LoginCancelMsg struct{}
type LoginStep int
const ( StepChoose LoginStep = iota StepName StepBaseURL StepAPIKey StepProtocol StepModels StepModel StepOAuth StepOAuthModels StepRemove StepSaving)
type customProvider struct { ID string Label string Detail string Extension string DefaultName string DefaultURL string FixedProtocol string // FixedEndpoint skips the base url: the extension knows where it sends. FixedEndpoint bool}
var customProviders = []customProvider{ { ID: "add-alibaba", Label: "add or update an Alibaba provider", Detail: "token plan · Qwen, DeepSeek, GLM", Extension: "alibaba", DefaultName: "alibaba", DefaultURL: "https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1", FixedProtocol: "chat_completions", }, { ID: "add-anthropic", Label: "add or update an Anthropic API key", Detail: "console billing · Claude models", Extension: "claude", DefaultName: "anthropic", FixedProtocol: "chat_completions", FixedEndpoint: true, }, { ID: "add-openai", Label: "add or update an OpenAI-compatible provider", Extension: "openai", DefaultURL: "https://api.openai.com/v1", },}
func customProviderByID(id string) (customProvider, bool) { for _, p := range customProviders { if p.ID == id { return p, true } } return customProvider{}, false}
// signInPollInterval is how often the daemon is asked about a running sign-in.const signInPollInterval = 300 * time.Millisecond
type loginModelsLoadedMsg struct { Err error Note string Models []string Gen int}
// signInsLoadedMsg carries the daemon's sign-ins and accounts, after the first// read and after every change. Profiles comes from the same settings snapshot.type signInsLoadedMsg struct { Err error Profiles *config.Profiles Listed daemon.SignIns Gen int}
type signInStartedMsg struct { Err error ID string URL string Gen int}
type signInStatusMsg struct { Err error ID string State string Message string Gen int}
type signInPollMsg struct { ID string Gen int}
// removal names something /login can delete: a provider in config.json or an// account in the daemon's credential store.type removal struct { Kind string // "provider" | "account" Provider string ID string // provider name or daemon account id Label string}
type providerSavedMsg struct { Err error Name string Settings config.Settings}
type LoginModel struct { Styles Styles Conn *daemon.Connection openBrowser func(url string) Removing removal Profiles config.Profiles Hint string Name string Provider string // sign-in provider in flight, or just finished LoginID string // daemon id of the sign-in in flight SignInURL string Status string // daemon progress line while a sign-in runs CatalogNote string Error string Draft config.Settings SignIns []daemon.SignIn Accounts []daemon.Account Catalog []string TextInput textinput.Model ChoosePicker PickerModel ProtocolPicker PickerModel ModelPicker PickerModel ConfirmPicker PickerModel Generation int Width int Height int Step LoginStep}
func (m LoginModel) openBrowserCmd(urlStr string) tea.Cmd { return func() tea.Msg { if m.openBrowser != nil { m.openBrowser(urlStr) } return nil }}
// NewLoginModel panics if conn is nil. A nil browser opener disables automatic// browser launches.func NewLoginModel(conn *daemon.Connection, nameHint string, openBrowser func(string)) LoginModel { if conn == nil { panic("tui.NewLoginModel requires a daemon connection") } profiles := config.Profiles{Providers: map[string]config.Settings{}}
m := LoginModel{ Conn: conn, Step: StepChoose, Profiles: profiles, Hint: strings.TrimSpace(nameHint), Draft: config.Settings{Extension: "openai", BaseURL: "https://api.openai.com/v1", Protocol: "responses"}, TextInput: newField(), Styles: DefaultStyles, openBrowser: openBrowser, } m.buildChoosePicker() return m}
func (m LoginModel) inputWidth() int { // Ink includes its cursor cell in width; Bubbles tracks the cursor separately. if m.Step == StepOAuth { return max(8, m.Width-25) } return max(8, m.Width-19)}
// promptInput re-arms the text input for the next question and reports the// blink command that shows its cursor. The placeholder is left as the// previous step left it.func (m *LoginModel) promptInput(value string, secret bool) tea.Cmd { m.TextInput.SetWidth(m.inputWidth()) return ask(&m.TextInput, value, secret)}
// pickerFor returns the picker the current step drives.func (m *LoginModel) pickerFor() *PickerModel { switch m.Step { case StepProtocol: return &m.ProtocolPicker case StepRemove: return &m.ConfirmPicker case StepModels, StepOAuthModels: return &m.ModelPicker } return &m.ChoosePicker}
// fail shows an error on the current step and keeps waiting for input.func (m LoginModel) fail(err string) (LoginModel, tea.Cmd) { m.Error = err return m, nil}
func (m *LoginModel) SetSize(width, height int) { m.Width, m.Height = width, height m.TextInput.SetWidth(m.inputWidth()) m.TextInput.SetValue(m.TextInput.Value()) for _, p := range []*PickerModel{&m.ChoosePicker, &m.ProtocolPicker, &m.ModelPicker, &m.ConfirmPicker} { p.SetSize(width, height) }}
// custom finds the api-key provider the draft's extension belongs to.func (m LoginModel) custom() (customProvider, bool) { i := slices.IndexFunc(customProviders, func(p customProvider) bool { return p.Extension == m.Draft.Extension }) if i < 0 { return customProvider{}, false } return customProviders[i], true}
func (m LoginModel) isFixedProtocol() bool { p, ok := m.custom() return ok && p.FixedProtocol != ""}
// askEndpoint asks for the base url, or straight for the api key when the// extension has a fixed endpoint.func (m *LoginModel) askEndpoint() tea.Cmd { if p, ok := m.custom(); ok && p.FixedEndpoint { m.Step = StepAPIKey m.TextInput.Placeholder = "" return m.promptInput("", true) } m.Step = StepBaseURL return m.promptInput(m.Draft.BaseURL, false)}
func (m *LoginModel) advanceToModels() tea.Cmd { m.Catalog = nil m.CatalogNote = "" m.Step = StepModels m.Generation++ return m.fetchCatalogCmd(m.Draft.Extension, m.Draft.BaseURL, m.Generation)}
func (m *LoginModel) startCustomProvider(p customProvider) tea.Cmd { m.Name = p.DefaultName m.Draft = config.Settings{Extension: p.Extension, BaseURL: p.DefaultURL, Protocol: cmp.Or(p.FixedProtocol, "responses")} m.Step = StepName m.TextInput.Placeholder = p.DefaultName return m.promptInput("", false)}
// useProfile re-selects a saved profile: a sign-in provider with no accounts// left cannot run without an api key, so it signs in first.func (m *LoginModel) useProfile(name string, settings config.Settings) tea.Cmd { if login, ok := m.signInFor(settings.Extension); ok && !hasKey(settings) && m.signedOut(settings.Extension) { return m.startSignIn(login) } m.Step = StepSaving return m.saveProviderCmd(name, settings)}
// signInFor finds the daemon sign-in a profile extension or provider name uses.func (m LoginModel) signInFor(provider string) (daemon.SignIn, bool) { i := slices.IndexFunc(m.SignIns, func(login daemon.SignIn) bool { return login.Provider == provider }) if i < 0 { return daemon.SignIn{}, false } return m.SignIns[i], true}
// signedOut reports a sign-in provider with no stored account, which cannot run// until the user signs in again.func (m LoginModel) signedOut(provider string) bool { if _, known := m.signInFor(provider); !known { return false } return !slices.ContainsFunc(m.Accounts, func(account daemon.Account) bool { return account.Provider == provider })}
// accountAt resolves a chooser row back to the account it lists.func (m LoginModel) accountAt(id string) (daemon.Account, bool) { index, err := strconv.Atoi(strings.TrimPrefix(id, "account:")) if err != nil || index < 0 || index >= len(m.Accounts) { return daemon.Account{}, false } return m.Accounts[index], true}
func (m *LoginModel) buildChoosePicker() { var items []PickerItem add := func(id, label, detail string) { items = append(items, PickerItem{ID: id, Label: label, Detail: detail}) } for _, name := range slices.Sorted(maps.Keys(m.Profiles.Providers)) { settings := m.Profiles.Providers[name] extension := cmp.Or(settings.Extension, "openai") detail := fmt.Sprintf("%s · %s", settings.Model, extension) if _, signIn := m.signInFor(extension); signIn && hasKey(settings) { detail += " · API key" } else if m.signedOut(extension) { detail += " · signed out" } if name == m.Profiles.Active { detail += " · active" } add("use:"+name, name, detail) } for index, account := range m.Accounts { add("account:"+strconv.Itoa(index), account.Label, account.Detail) } for _, p := range customProviders { add(p.ID, p.Label, p.Detail) } for _, login := range m.SignIns { add("signin:"+login.Provider, login.Label, login.Detail) }
m.ChoosePicker = NewPickerModel("Provider for new sessions", items, false, "use:"+m.Profiles.Active) m.ChoosePicker.SetSize(m.Width, m.Height)}
// removalFor maps a chooser row to what removing it would delete.func (m LoginModel) removalFor(id string) (removal, bool) { if name, ok := strings.CutPrefix(id, "use:"); ok { if _, saved := m.Profiles.Providers[name]; saved { return removal{Kind: "provider", ID: name, Label: name}, true } } if account, ok := m.accountAt(id); ok { return removal{Kind: "account", Provider: account.Provider, ID: account.ID, Label: account.Label}, true } return removal{}, false}
func (m *LoginModel) confirmRemoval(target removal) tea.Cmd { detail := "This removes the provider and its saved key" if target.Kind == "account" { detail = "This removes its saved tokens" } m.Removing = target m.Step = StepRemove items := []PickerItem{ {ID: "keep", Label: "keep", Detail: ""}, {ID: "remove", Label: "remove", Detail: detail}, } m.ConfirmPicker = NewPickerModel(detail+". Remove "+target.Kind+" "+target.Label+"?", items, false, "keep") m.ConfirmPicker.SetSize(m.Width, m.Height) return m.ConfirmPicker.Init()}
// reloadCmd changes the daemon's accounts or the saved profiles, then re-reads// what /login can choose from.func (m LoginModel) reloadCmd(change func(context.Context) error) tea.Cmd { return func() tea.Msg { ctx := context.Background() if err := change(ctx); err != nil { return signInsLoadedMsg{Err: err, Gen: m.Generation} } return m.listSignIns(ctx, m.Generation) }}
func (m LoginModel) removeCmd(target removal) tea.Cmd { return m.reloadCmd(func(ctx context.Context) error { if target.Kind == "account" { return daemon.RemoveAccount(ctx, m.Conn, target.Provider, target.ID) } return daemon.DeleteProvider(ctx, m.Conn, target.ID) })}
func (m LoginModel) selectAccountCmd(account daemon.Account) tea.Cmd { return m.reloadCmd(func(ctx context.Context) error { return daemon.SelectAccount(ctx, m.Conn, account.Provider, account.ID) })}
func (m *LoginModel) backToChoose() tea.Cmd { m.Step = StepChoose m.buildChoosePicker() return m.ChoosePicker.Init()}
// startSignIn hands the provider to the daemon, which owns the PKCE pair, the// callback listener, and the token exchange.func (m *LoginModel) startSignIn(login daemon.SignIn) tea.Cmd { m.Provider = login.Provider m.Name = login.Provider m.Draft = config.Settings{Extension: login.Provider, Protocol: login.Protocol} if saved, ok := m.Profiles.Providers[login.Provider]; ok { m.Draft.Model = saved.Model } m.Step = StepOAuth m.Generation++ // The cursor stays steady here; only the daemon's sign-in command runs. m.TextInput.Placeholder = "" m.promptInput("", false) gen := m.Generation return func() tea.Msg { started, err := daemon.StartSignIn(context.Background(), m.Conn, login.Provider) return signInStartedMsg{ID: started.ID, URL: started.URL, Err: err, Gen: gen} }}
func (m LoginModel) pollSignInCmd(id string, gen int) tea.Cmd { return func() tea.Msg { status, err := daemon.PollSignIn(context.Background(), m.Conn, id) return signInStatusMsg{ID: id, State: status.State, Message: status.Message, Err: err, Gen: gen} }}
func signInPollTickCmd(id string, gen int) tea.Cmd { return tea.Tick(signInPollInterval, func(time.Time) tea.Msg { return signInPollMsg{ID: id, Gen: gen} })}
func (m LoginModel) inputSignInCmd(id, input string, gen int) tea.Cmd { return func() tea.Msg { if err := daemon.SignInInput(context.Background(), m.Conn, id, input); err != nil { return signInStatusMsg{ID: id, Err: err, Gen: gen} } return signInPollMsg{ID: id, Gen: gen} }}
// cancelSignInCmd drops a sign-in this client no longer waits for, closing the// daemon's callback listener.func (m LoginModel) cancelSignInCmd(id string) tea.Cmd { if id == "" || m.Conn == nil { return nil } return func() tea.Msg { _ = daemon.CancelSignIn(context.Background(), m.Conn, id) return nil }}
// endSignIn forgets the sign-in in flight and returns the command cancelling it.func (m *LoginModel) endSignIn() tea.Cmd { id := m.LoginID m.LoginID = "" m.SignInURL = "" m.Status = "" return m.cancelSignInCmd(id)}
// Close cancels a sign-in still in flight when the login screen goes away.func (m *LoginModel) Close() tea.Cmd { return m.endSignIn()}
// applyHint routes a name given to `/login <name>`: a saved profile is chosen as// it stands, a sign-in provider starts its sign-in, anything else is a new// openai-compatible provider.func (m *LoginModel) applyHint() tea.Cmd { hint := m.Hint m.Hint = "" name, err := config.ValidateProviderName(hint) if err != nil { m.Error = err.Error() m.Step = StepChoose return m.ChoosePicker.Init() } m.Name = name if saved, ok := m.Profiles.Providers[name]; ok { m.Draft = saved return m.useProfile(name, saved) } if login, ok := m.signInFor(name); ok { return m.startSignIn(login) } return m.askEndpoint()}
func (m *LoginModel) buildProtocolPicker() { items := []PickerItem{ {ID: "responses", Label: "responses", Detail: "OpenAI Responses API"}, {ID: "chat_completions", Label: "chat completions", Detail: "widely supported by compatible endpoints"}, } m.ProtocolPicker = NewPickerModel("api protocol", items, false, m.Draft.Protocol) m.ProtocolPicker.SetSize(m.Width, m.Height)}
func (m *LoginModel) buildModelPicker() { items := make([]PickerItem, 0, len(m.Catalog)+1) for _, name := range m.Catalog { items = append(items, PickerItem{ID: "model:" + name, Label: name}) } items = append(items, PickerItem{ID: "manual", Label: "enter a model ID manually"})
m.ModelPicker = NewPickerModel("model", items, true, "model:"+m.Draft.Model) m.ModelPicker.SetSize(m.Width, m.Height)}
func (m LoginModel) Init() tea.Cmd { gen := m.Generation return func() tea.Msg { return m.listSignIns(context.Background(), gen) }}
// listSignIns reads what the daemon can sign in with, the accounts it holds,// and which profiles it holds an api key for.func (m LoginModel) listSignIns(ctx context.Context, gen int) signInsLoadedMsg { listed, err := daemon.SignInList(ctx, m.Conn) if err != nil { return signInsLoadedMsg{Err: err, Gen: gen} } settings, err := daemon.GetSettings(ctx, m.Conn) return signInsLoadedMsg{Listed: listed, Profiles: &settings.Profiles, Err: err, Gen: gen}}
// hasKey reports a profile with an api key, entered now or held by the daemon.func hasKey(settings config.Settings) bool { return settings.APIKey != "" || settings.HasKey}
func (m LoginModel) fetchCatalogCmd(ext, endpoint string, gen int) tea.Cmd { return func() tea.Msg { note := "Could not load the models.dev catalog. Enter a model ID manually." if m.Conn == nil { return loginModelsLoadedMsg{Note: note, Gen: gen} } path := fmt.Sprintf("/models/%s?endpoint=%s", url.PathEscape(ext), url.QueryEscape(endpoint)) names, err := daemon.Request[[]string](context.Background(), m.Conn, path, nil) if err != nil { return loginModelsLoadedMsg{Err: err, Note: note, Gen: gen} } if len(names) == 0 { note = "No matching models in models.dev. Enter a model ID manually." } return loginModelsLoadedMsg{Models: names, Note: note, Gen: gen} }}
// saveProviderCmd saves the profile and optional key through the daemon.func (m LoginModel) saveProviderCmd(name string, settings config.Settings) tea.Cmd { return func() tea.Msg { validated, err := settings.Validate() if err != nil { return providerSavedMsg{Name: name, Settings: settings, Err: err} } err = daemon.SaveProvider(context.Background(), m.Conn, name, validated) return providerSavedMsg{Name: name, Settings: validated, Err: err} }}
func (m LoginModel) Update(msg tea.Msg) (LoginModel, tea.Cmd) { switch msg := msg.(type) { case signInsLoadedMsg: if msg.Gen != m.Generation { return m, nil } m.SignIns = msg.Listed.Logins m.Accounts = msg.Listed.Accounts if msg.Profiles != nil { m.Profiles = *msg.Profiles }
if msg.Err != nil && m.Error == "" { m.Error = msg.Err.Error() } if m.Step == StepSaving { m.Removing = removal{} m.Step = StepChoose if msg.Err == nil { m.Error = "" } return m, m.backToChoose() } m.buildChoosePicker() if m.Hint != "" { return m, m.applyHint() } if m.Step != StepChoose { return m, nil } if len(m.Profiles.Providers) == 0 && len(m.Accounts) == 0 { m.Step = StepName m.TextInput.Placeholder = "" return m, m.promptInput("", false) } return m, m.ChoosePicker.Init()
case signInStartedMsg: if msg.Gen != m.Generation || m.Step != StepOAuth { // Stale start: cancel the sign-in this client no longer shows. return m, m.cancelSignInCmd(msg.ID) } if msg.Err != nil { m.Error = msg.Err.Error() return m, nil } m.LoginID = msg.ID m.SignInURL = msg.URL m.Status = ""
cmds := []tea.Cmd{textinput.Blink} if msg.URL != "" { cmds = append(cmds, m.openBrowserCmd(msg.URL)) } cmds = append(cmds, m.pollSignInCmd(msg.ID, m.Generation)) return m, tea.Batch(cmds...)
case signInPollMsg: if msg.Gen != m.Generation || m.Step != StepOAuth || msg.ID != m.LoginID { return m, m.cancelSignInCmd(msg.ID) } return m, m.pollSignInCmd(msg.ID, msg.Gen)
case signInStatusMsg: if msg.Gen != m.Generation || m.Step != StepOAuth || msg.ID != m.LoginID { // A stale result: cancel the sign-in this client no longer shows. return m, m.cancelSignInCmd(msg.ID) } if msg.Err != nil { m.Error = msg.Err.Error() return m, m.endSignIn() } m.Status = msg.Message switch msg.State { case "done": m.LoginID = "" m.Step = StepOAuthModels m.Generation++ return m, m.fetchCatalogCmd(m.Provider, "", m.Generation) case "failed": m.LoginID = "" m.Error = msg.Message return m, nil default: return m, signInPollTickCmd(msg.ID, m.Generation) }
case loginModelsLoadedMsg: if msg.Gen != m.Generation || (m.Step != StepModels && m.Step != StepOAuthModels) { return m, nil } m.Catalog = msg.Models if m.Catalog == nil { m.Catalog = []string{} } m.CatalogNote = msg.Note m.buildModelPicker() return m, m.ModelPicker.Init()
case providerSavedMsg: if msg.Err != nil { m.Error = msg.Err.Error() if m.Name == msg.Name && m.Draft.Model != "" { m.Step = StepModel return m, m.promptInput(m.Draft.Model, false) } return m, m.backToChoose() } return m, func() tea.Msg { return LoginDoneMsg{Name: msg.Name, Settings: msg.Settings} }
case tea.KeyPressMsg: key := msg.String() if m.Step == StepChoose && (key == "delete" || key == "d") { if item, ok := m.ChoosePicker.Highlighted(); ok { if target, ok := m.removalFor(item.ID); ok { return m, m.confirmRemoval(target) } } return m, nil } if key == "esc" || key == "ctrl+c" || key == "ctrl+d" { pickerStep := m.Step == StepChoose || m.Step == StepProtocol || m.Step == StepModels || m.Step == StepOAuthModels || m.Step == StepRemove if !pickerStep || key == "ctrl+d" { cancel := m.Close() m.Generation++ return m, tea.Batch(cancel, func() tea.Msg { return LoginCancelMsg{} }) } }
case PickerSelectMsg: switch m.Step { case StepChoose: if p, ok := customProviderByID(msg.ID); ok { return m, m.startCustomProvider(p) } if provider, ok := strings.CutPrefix(msg.ID, "signin:"); ok { if login, ok := m.signInFor(provider); ok { return m, m.startSignIn(login) } } // Choosing an account selects it; d removes it. if account, ok := m.accountAt(msg.ID); ok { m.Step = StepSaving return m, m.selectAccountCmd(account) } if name, ok := strings.CutPrefix(msg.ID, "use:"); ok { if s, ok := m.Profiles.Providers[name]; ok { return m, m.useProfile(name, s) } }
case StepRemove: if msg.ID == "remove" { m.Step = StepSaving return m, m.removeCmd(m.Removing) } return m, m.backToChoose()
case StepProtocol: m.Draft.Protocol = msg.ID return m, m.advanceToModels()
case StepModels, StepOAuthModels: if msg.ID == "manual" { m.Step = StepModel m.TextInput.Placeholder = "" return m, m.promptInput("", false) } if modelName, ok := strings.CutPrefix(msg.ID, "model:"); ok { m.Draft.Model = modelName m.Step = StepSaving return m, m.saveProviderCmd(m.Name, m.Draft) } }
case PickerCancelMsg: switch m.Step { case StepChoose, StepOAuthModels: return m, func() tea.Msg { return LoginCancelMsg{} } case StepProtocol: m.Step = StepAPIKey return m, m.promptInput("", true) case StepModels: m.Generation++ if m.isFixedProtocol() { m.Step = StepAPIKey return m, m.promptInput(m.Draft.APIKey, true) } m.Step = StepBaseURL return m, m.promptInput(m.Draft.BaseURL, false) case StepRemove: return m, m.backToChoose() } }
// A picker step routes everything to its picker; the rest to the input. switch m.Step { case StepChoose, StepProtocol, StepRemove, StepModels, StepOAuthModels: p := m.pickerFor() var cmd tea.Cmd *p, cmd = p.Update(msg) return m, cmd }
if key, ok := msg.(tea.KeyPressMsg); ok && key.String() == "enter" { switch m.Step { case StepName: m.TextInput.SetCursor(0) name, err := config.ValidateProviderName(strings.TrimSpace(m.TextInput.Value())) if err != nil { return m.fail(err.Error()) } m.Name = name m.Error = ""
// A name that is a sign-in provider — or a saved profile for one — // signs in instead of asking for an api key, unless the profile // keeps a key or this is that extension's api-key flow. flow := m.Draft.Extension extension := name if saved, known := m.Profiles.Providers[name]; known { extension = saved.Extension m.Draft = saved } if login, ok := m.signInFor(extension); ok && !hasKey(m.Draft) && extension != flow { return m, m.startSignIn(login) } return m, m.askEndpoint()
case StepBaseURL: m.TextInput.SetCursor(0) val := cmp.Or(strings.TrimSpace(m.TextInput.Value()), m.Draft.BaseURL) endpoint, err := config.ValidateEndpoint(val) if err != nil { return m.fail(err.Error()) } if endpoint != m.Draft.BaseURL { m.Draft.APIKey, m.Draft.HasKey = "", false } m.Draft.BaseURL = endpoint m.Error = "" m.Step = StepAPIKey m.TextInput.Placeholder = "" return m, m.promptInput("", true)
case StepAPIKey: m.TextInput.SetCursor(0) switch val := cmp.Or(m.TextInput.Value(), m.Draft.APIKey); { case val == "" && m.Draft.HasKey: // Blank keeps the key the daemon holds. case val == "" || strings.ContainsFunc(val, func(r rune) bool { return r <= 0x20 || r == 0x7f }): return m.fail("Enter an API key without spaces or control characters.") default: m.Draft.APIKey = val } m.Error = "" if m.isFixedProtocol() { return m, m.advanceToModels() } m.Step = StepProtocol m.buildProtocolPicker() return m, m.ProtocolPicker.Init()
case StepOAuth: val := strings.TrimSpace(m.TextInput.Value()) if val != "" && m.LoginID != "" { m.Status = "exchanging authorization code…" m.Error = "" return m, m.inputSignInCmd(m.LoginID, val, m.Generation) }
case StepModel: m.TextInput.SetCursor(0) val := strings.TrimSpace(m.TextInput.Value()) if err := validateModelID(val); err != nil { return m.fail(err.Error()) } m.Draft.Model = val m.Error = "" m.Step = StepSaving return m, m.saveProviderCmd(m.Name, m.Draft) } }
var cmd tea.Cmd m.TextInput, cmd = m.TextInput.Update(msg) return m, cmd}
func (m LoginModel) View() string { var b strings.Builder line := func(s string) { b.WriteString(s) b.WriteByte('\n') } line(titleRule(m.Width, brand("albedo")+" "+m.Styles.Muted.Render("/login"), m.Styles.Faint.Render(m.Name))) location := "Provider configuration stored in " + config.HomeDir() if m.Width > 0 && ansi.StringWidth(location) > m.Width && ansi.StringWidth(config.HomeDir()) <= m.Width { location = "Provider configuration stored in\n" + config.HomeDir() } line(m.Styles.Faint.Render(ansi.Wrap(location, m.Width, ""))) if m.Error != "" { line(m.Styles.Error.Render(ansi.Wrap(m.Error, m.Width, ""))) }
switch m.Step { case StepChoose: line(m.ChoosePicker.View()) b.WriteString(ansi.Wrap(keyHints(hint{"enter", "select"}, hint{"d", "remove"}, hint{"esc", "cancel"}), m.Width, "")) case StepProtocol: b.WriteString(m.ProtocolPicker.View()) case StepRemove: b.WriteString(m.ConfirmPicker.View()) case StepModels, StepOAuthModels: if m.Catalog == nil { b.WriteString(m.Styles.Faint.Render("loading models…")) break } if m.CatalogNote != "" { line(m.Styles.Faint.Render(m.CatalogNote)) } b.WriteString(m.ModelPicker.View()) case StepOAuth: line(cmp.Or(m.Status, "starting sign-in…")) if m.SignInURL != "" { line(m.Styles.Faint.Render(ansi.Hardwrap(m.SignInURL, m.Width, true))) } b.WriteString("Callback URL or code: ") line(m.TextInput.View()) b.WriteString(ansi.Wrap(m.Styles.Faint.Render("Browser sign-in completes automatically")+m.Styles.Decor.Render(" · ")+keyHints(hint{"enter", "submit code"}, hint{"esc", "cancel"}), m.Width, "")) case StepSaving: state := "saving provider…" if m.Removing.Kind != "" { state = "removing " + m.Removing.Kind + "…" } b.WriteString(m.Styles.Faint.Render(state)) default: stepLabels := map[LoginStep]string{ StepName: "provider name", StepBaseURL: "API base URL", StepAPIKey: "API key", StepModel: "model ID", } line(stepLabels[m.Step] + ": " + m.TextInput.View()) var hints []hint if m.Step == StepAPIKey && m.Draft.APIKey != "" { hints = append(hints, hint{"enter", "keeps the saved key"}) } if m.Step == StepName { for _, login := range m.SignIns { hints = append(hints, hint{"", "use " + login.Provider + " to sign in"}) } } hints = append(hints, hint{"enter", "continue"}, hint{"esc", "cancel"}) b.WriteString(ansi.Wrap(keyHints(hints...), m.Width, "")) } return b.String()}