From 479178e94e48654380da7b09eca973892ef05810 Mon Sep 17 00:00:00 2001 From: dawn <90008@klbr.net> Date: Thu, 8 Oct 2026 02:56:38 +0300 Subject: [PATCH] tui: login on listFrame/listView, removal through the shared confirm on ctrl+d Signed-off-by: dawn <90008@klbr.net> --- cli/internal/tui/login.go | 43 ++---- cli/internal/tui/login_accounts.go | 47 +++--- cli/internal/tui/login_list_test.go | 79 ++++++++++ cli/internal/tui/login_pick.go | 122 ++++++++++++++++ cli/internal/tui/login_provider.go | 11 +- cli/internal/tui/login_view.go | 217 +++++++++++++++++----------- cli/internal/tui/picker.go | 2 + cli/internal/tui/shot_login_test.go | 122 ++++++++++++++++ 8 files changed, 502 insertions(+), 141 deletions(-) create mode 100644 cli/internal/tui/login_list_test.go create mode 100644 cli/internal/tui/login_pick.go create mode 100644 cli/internal/tui/shot_login_test.go diff --git a/cli/internal/tui/login.go b/cli/internal/tui/login.go index e67b863d..bb385426 100644 --- a/cli/internal/tui/login.go +++ b/cli/internal/tui/login.go @@ -74,14 +74,14 @@ type LoginModel struct { Accounts []daemon.Account Catalog []string TextInput textinput.Model - ChoosePicker PickerModel - ProtocolPicker PickerModel - ModelPicker PickerModel - ConfirmPicker PickerModel - Generation int - Width int - Height int - Step LoginStep + ChoosePicker loginPick + ProtocolPicker loginPick + ModelPicker loginPick + confirm + Generation int + Width int + Height int + Step LoginStep } // NewLoginModel panics if conn is nil. A nil browser opener disables automatic @@ -258,7 +258,10 @@ func (m LoginModel) Update(msg tea.Msg) (LoginModel, tea.Cmd) { case tea.KeyPressMsg: key := msg.String() - if m.Step == StepChoose && (key == "delete" || key == "d") { + switch { + case m.Step == StepRemove: + return m, m.confirmKey(msg) + case m.Step == StepChoose && key == "ctrl+d": if item, ok := m.ChoosePicker.Highlighted(); ok { if target, ok := m.removalFor(item.ID); ok { return m, m.confirmRemoval(target) @@ -267,7 +270,7 @@ func (m LoginModel) Update(msg tea.Msg) (LoginModel, tea.Cmd) { return m, nil } if key == "esc" || key == "ctrl+c" || key == "ctrl+d" { - pickerStep := m.Step == StepChoose || m.Step == StepAccountProfile || m.Step == StepOAuthFlow || m.Step == StepProtocol || m.Step == StepModels || m.Step == StepOAuthModels || m.Step == StepRemove + pickerStep := m.Step == StepChoose || m.Step == StepAccountProfile || m.Step == StepOAuthFlow || m.Step == StepProtocol || m.Step == StepModels || m.Step == StepOAuthModels if !pickerStep || key == "ctrl+d" { cancel := m.Close() m.LoginValues = nil @@ -310,13 +313,6 @@ func (m LoginModel) Update(msg tea.Msg) (LoginModel, tea.Cmd) { } } - 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() @@ -354,8 +350,6 @@ func (m LoginModel) Update(msg tea.Msg) (LoginModel, tea.Cmd) { } m.Step = StepBaseURL return m, m.promptInput(m.Draft.BaseURL, false) - case StepRemove: - return m, m.backToChoose() } } @@ -363,18 +357,13 @@ func (m LoginModel) Update(msg tea.Msg) (LoginModel, tea.Cmd) { if m.Step == StepOAuthFields && m.LoginFieldIndex < len(m.LoginFields) { field := m.LoginFields[m.LoginFieldIndex] if field.Type == "choice" || field.Type == "boolean" { - var cmd tea.Cmd - m.ChoosePicker, cmd = m.ChoosePicker.Update(msg) - return m, cmd + return m, m.pickUpdate(msg) } } switch m.Step { - case StepChoose, StepAccountProfile, StepOAuthFlow, StepProtocol, StepRemove, StepModels, StepOAuthModels: - p := m.pickerFor() - var cmd tea.Cmd - *p, cmd = p.Update(msg) - return m, cmd + case StepChoose, StepAccountProfile, StepOAuthFlow, StepProtocol, StepModels, StepOAuthModels: + return m, m.pickUpdate(msg) } if key, ok := msg.(tea.KeyPressMsg); ok && key.String() == "enter" { diff --git a/cli/internal/tui/login_accounts.go b/cli/internal/tui/login_accounts.go index 5aa6c41c..731b6112 100644 --- a/cli/internal/tui/login_accounts.go +++ b/cli/internal/tui/login_accounts.go @@ -45,9 +45,6 @@ func (m LoginModel) accountAt(id string) (daemon.Account, bool) { 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") @@ -60,20 +57,18 @@ func (m *LoginModel) buildChoosePicker() { if name == m.Profiles.Active { detail += " · active" } - add("use:"+name, name, detail) + items = append(items, PickerItem{ID: "use:" + name, Label: name, Detail: detail, Note: "a saved provider profile; enter makes it the one new sessions use"}) } for index, account := range m.Accounts { - add("account:"+strconv.Itoa(index), account.Label, account.Detail) + items = append(items, PickerItem{ID: "account:" + strconv.Itoa(index), Label: account.Label, Detail: account.Detail, Note: "a signed-in account; enter picks the profile it runs under"}) } for _, p := range customProviders { - add(p.ID, p.Label, p.Detail) + items = append(items, PickerItem{ID: p.ID, Label: p.Label, Detail: p.Detail, Note: "creates or updates a provider profile"}) } for _, login := range m.SignIns { - add("signin:"+login.Provider, login.Label, login.Detail) + items = append(items, PickerItem{ID: "signin:" + login.Provider, Label: login.Label, Detail: login.Detail, Note: "signs in through the daemon; its tokens stay in the daemon's credential store"}) } - - m.ChoosePicker = NewPickerModel("Provider for new sessions", items, false, "use:"+m.Profiles.Active) - m.ChoosePicker.SetSize(m.Width, m.Height) + m.ChoosePicker = newLoginPick("Provider for new sessions", items, "use:"+m.Profiles.Active, m.Width, m.Height) } // removalFor maps a chooser row to what removing it would delete. @@ -96,13 +91,22 @@ func (m *LoginModel) confirmRemoval(target removal) tea.Cmd { } m.Removing = target m.Step = StepRemove - items := []PickerItem{ - {ID: "keep", Label: "keep", Detail: ""}, - {ID: "remove", Label: "remove", Detail: detail}, + m.confirm.ask("remove", target.ID, "remove", detail+". Remove "+target.Kind+" "+target.Label+"?") + return nil +} + +// confirmKey settles a key on the removal question: enter removes, esc keeps +// what is there, and any other key is ignored. +func (m *LoginModel) confirmKey(msg tea.KeyPressMsg) tea.Cmd { + if m.confirm.key(msg) { + m.confirm.dismiss() + m.Step = StepSaving + return m.removeCmd(m.Removing) + } + if m.confirm.asking() { + return nil } - m.ConfirmPicker = NewPickerModel(detail+". Remove "+target.Kind+" "+target.Label+"?", items, false, "keep") - m.ConfirmPicker.SetSize(m.Width, m.Height) - return m.ConfirmPicker.Init() + return m.backToChoose() } // reloadCmd changes the daemon's accounts or the saved profiles, then re-reads @@ -157,10 +161,9 @@ func (m *LoginModel) chooseAccount(account daemon.Account) tea.Cmd { m.Step = StepAccountProfile items := make([]PickerItem, 0, len(names)) for _, name := range names { - items = append(items, PickerItem{ID: name, Label: name, Detail: "use " + account.Label}) + items = append(items, PickerItem{ID: name, Label: name, Detail: "use " + account.Label, Note: "the profile this account signs in for"}) } - m.ChoosePicker = NewPickerModel("Choose the profile for this account", items, false, m.Profiles.Active) - m.ChoosePicker.SetSize(m.Width, m.Height) + m.ChoosePicker = newLoginPick("Choose the profile for this account", items, m.Profiles.Active, m.Width, m.Height) return m.ChoosePicker.Init() } @@ -202,8 +205,7 @@ func (m *LoginModel) startSignIn(login daemon.SignIn) tea.Cmd { for _, flow := range login.Flows { items = append(items, PickerItem{ID: flow, Label: flow}) } - m.ChoosePicker = NewPickerModel("Choose a sign-in flow", items, false, login.Flows[0]) - m.ChoosePicker.SetSize(m.Width, m.Height) + m.ChoosePicker = newLoginPick("Choose a sign-in flow", items, login.Flows[0], m.Width, m.Height) return m.ChoosePicker.Init() } if len(login.Flows) == 1 { @@ -234,8 +236,7 @@ func (m *LoginModel) nextLoginField() tea.Cmd { if index := daemon.FormChoiceDefault(field); index < len(items) { initial = items[index].ID } - m.ChoosePicker = NewPickerModel(field.Label, items, false, initial) - m.ChoosePicker.SetSize(m.Width, m.Height) + m.ChoosePicker = newLoginPick(field.Label, items, initial, m.Width, m.Height) return m.ChoosePicker.Init() } initial := "" diff --git a/cli/internal/tui/login_list_test.go b/cli/internal/tui/login_list_test.go new file mode 100644 index 00000000..1f3817c8 --- /dev/null +++ b/cli/internal/tui/login_list_test.go @@ -0,0 +1,79 @@ +// Login's lists and removal question are keystroke behaviour the e2e suite +// reaches only through the daemon's flows: a letter typed into the always-live +// search must not remove a provider (the old bare "d" did), removal waits for +// enter, and a text step takes letters as input rather than as actions. +package tui + +import ( + "strings" + "testing" + + "github.com/charmbracelet/x/ansi" + + tea "charm.land/bubbletea/v2" +) + +func TestLoginRemovalAsksThenTakesEnterOnly(t *testing.T) { + m := loginModel(120, 30) + m, _ = m.Update(tea.KeyPressMsg{Code: 'd', Mod: tea.ModCtrl}) + if m.Step != StepRemove || !m.confirm.asking() { + t.Fatalf("ctrl+d did not ask about the highlighted provider (step %v):\n%s", m.Step, ansi.Strip(m.View())) + } + m, cmd := m.Update(tea.KeyPressMsg{Code: 'x', Text: "x"}) + if cmd != nil || m.Step != StepRemove { + t.Fatal("a stray key changed the removal question") + } + m, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyEscape}) + if m.Step != StepChoose || m.confirm.asking() { + t.Fatalf("esc did not keep the provider (step %v)", m.Step) + } + m, _ = m.Update(tea.KeyPressMsg{Code: 'd', Mod: tea.ModCtrl}) + m, cmd = m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + if m.Step != StepSaving || cmd == nil { + t.Fatalf("enter did not remove (step %v)", m.Step) + } +} + +func TestLoginLetterInTheSearchFiltersInsteadOfRemoving(t *testing.T) { + m := loginModel(120, 30) + m, _ = m.Update(tea.KeyPressMsg{Code: 'd', Text: "d"}) + if m.Step != StepChoose || m.confirm.asking() { + t.Fatalf("a bare letter started a removal (step %v)", m.Step) + } + if m.ChoosePicker.input.Value() != "d" { + t.Fatalf("the letter did not reach the search: %q", m.ChoosePicker.input.Value()) + } +} + +func TestLoginFilterFuzzilyFindsAProvider(t *testing.T) { + m := loginModel(120, 30) + for _, r := range "cla" { + m, _ = m.Update(tea.KeyPressMsg{Code: r, Text: string(r)}) + } + view := ansi.Strip(m.View()) + if !strings.Contains(view, "claude-team") || strings.Contains(view, "Antigravity") { + t.Fatalf("the search did not narrow to the claude providers:\n%s", view) + } + if item, ok := m.ChoosePicker.Highlighted(); !ok || !strings.HasPrefix(item.ID, "use:claude") && !strings.HasPrefix(item.ID, "signin:claude") { + t.Fatalf("the best match is not a claude row: %+v", item) + } +} + +func TestLoginDetailShowsWithoutEnter(t *testing.T) { + m := loginModel(120, 30) + view := ansi.Strip(m.View()) + if !strings.Contains(view, "a saved provider profile") { + t.Fatalf("the highlighted provider's detail is not in the pane:\n%s", view) + } +} + +func TestLoginKeyEntryTakesLettersAsInput(t *testing.T) { + m := loginModel(120, 30) + m.askAPIKey() + for _, r := range "dx" { + m, _ = m.Update(tea.KeyPressMsg{Code: r, Text: string(r)}) + } + if m.TextInput.Value() != "dx" || m.Step != StepAPIKey { + t.Fatalf("typing into the key step went elsewhere: %q on step %v", m.TextInput.Value(), m.Step) + } +} diff --git a/cli/internal/tui/login_pick.go b/cli/internal/tui/login_pick.go new file mode 100644 index 00000000..ade5f525 --- /dev/null +++ b/cli/internal/tui/login_pick.go @@ -0,0 +1,122 @@ +package tui + +import ( + "slices" + + "charm.land/bubbles/v2/textinput" + tea "charm.land/bubbletea/v2" +) + +// loginPick is a list a login step chooses from: its items, filtered and +// drawn by the shared listView like every other list screen. +type loginPick struct { + items []PickerItem + listView +} + +// newLoginPick lists items under title, with the row keyed initial highlighted. +func newLoginPick(title string, items []PickerItem, initial string, width, height int) loginPick { + p := loginPick{items: items, listView: newListView("Search")} + p.setSize(width, height) + rows := make([]listEntry, len(items)) + for i, item := range items { + rows[i] = listEntry{ + key: item.ID, + section: title, + name: item.Label, + desc: item.Detail, + detail: func(w int) []string { return loginPane(item, w) }, + } + } + p.setRows(rows) + if i := slices.IndexFunc(p.shown, func(s listShown) bool { return p.rows[s.row].key == initial }); i >= 0 { + p.Cursor = i + } + return p +} + +// Highlighted is the item under the cursor. +func (p loginPick) Highlighted() (PickerItem, bool) { + row, ok := p.highlighted() + if !ok { + return PickerItem{}, false + } + i := slices.IndexFunc(p.items, func(item PickerItem) bool { return item.ID == row.key }) + if i < 0 { + return PickerItem{}, false + } + return p.items[i], true +} + +// Init starts the cursor blinking in the search line. +func (p loginPick) Init() tea.Cmd { return textinput.Blink } + +// loginPane is an item's detail: what it is, what it holds, and what enter +// or a key would do with it. +func loginPane(item PickerItem, width int) []string { + lines := paneTitle(item.Label, "", width) + if item.Detail != "" { + lines = append(lines, factRows("details", item.Detail, width)...) + } + if item.Note != "" { + lines = append(lines, "") + lines = append(lines, paneNote(item.Note, width)...) + } + return lines +} + +// pickerFor returns the list the current step shows. +func (m *LoginModel) pickerFor() *loginPick { + switch m.Step { + case StepProtocol: + return &m.ProtocolPicker + case StepModels, StepOAuthModels: + return &m.ModelPicker + } + return &m.ChoosePicker +} + +// listing reports whether the step shows a list rather than a question. A +// catalog still loading is a question until its models arrive. +func (m LoginModel) listing() bool { + switch m.Step { + case StepChoose, StepAccountProfile, StepOAuthFlow, StepProtocol: + return true + case StepModels, StepOAuthModels: + return m.Catalog != nil + case StepOAuthFields: + return m.choiceField() + } + return false +} + +// choiceField reports the sign-in field being asked is a list of choices. +func (m LoginModel) choiceField() bool { + if m.Step != StepOAuthFields || m.LoginFieldIndex >= len(m.LoginFields) { + return false + } + field := m.LoginFields[m.LoginFieldIndex] + return field.Type == "choice" || field.Type == "boolean" +} + +// pickUpdate routes a message to the list the step shows. Enter and esc +// answer with the messages the step already handles; every other key moves +// or types in the search. +func (m *LoginModel) pickUpdate(msg tea.Msg) tea.Cmd { + p := m.pickerFor() + key, ok := msg.(tea.KeyPressMsg) + if !ok { + return p.update(msg) + } + switch key.String() { + case "enter": + item, found := p.Highlighted() + if !found || !m.listing() { + return nil + } + return func() tea.Msg { return PickerSelectMsg{ID: item.ID} } + case "esc", "ctrl+c": + return func() tea.Msg { return PickerCancelMsg{} } + } + return p.update(msg) +} diff --git a/cli/internal/tui/login_provider.go b/cli/internal/tui/login_provider.go index 01e3350f..7cf8b1f2 100644 --- a/cli/internal/tui/login_provider.go +++ b/cli/internal/tui/login_provider.go @@ -209,19 +209,16 @@ func (m *LoginModel) buildProtocolPicker() { {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) + m.ProtocolPicker = newLoginPick("api protocol", items, m.Draft.Protocol, 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: "model:" + name, Label: name, Note: "from the models.dev catalog"}) } - 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) + items = append(items, PickerItem{ID: "manual", Label: "enter a model ID manually", Note: "type a model ID yourself"}) + m.ModelPicker = newLoginPick("model", items, "model:"+m.Draft.Model, m.Width, m.Height) } // hasKey reports a profile with an api key, entered now or held by the daemon. diff --git a/cli/internal/tui/login_view.go b/cli/internal/tui/login_view.go index 7108f7a1..279eab5b 100644 --- a/cli/internal/tui/login_view.go +++ b/cli/internal/tui/login_view.go @@ -3,7 +3,6 @@ package tui import ( "albedo/cli/internal/config" "cmp" - "strings" tea "charm.land/bubbletea/v2" "github.com/charmbracelet/x/ansi" @@ -18,12 +17,18 @@ func (m LoginModel) openBrowserCmd(urlStr string) tea.Cmd { } } +// inputWidth leaves the frame's margin and the field's label their room. func (m LoginModel) inputWidth() int { - // Ink includes its cursor cell in width; Bubbles tracks the cursor separately. + return max(8, m.Width-10-ansi.StringWidth(m.fieldLabel())) +} + +// fieldLabel is what the text input is asked for. +func (m LoginModel) fieldLabel() string { if m.Step == StepOAuth { - return max(8, m.Width-25) + return "callback URL or code" } - return max(8, m.Width-19) + label, _ := m.question() + return label } // promptInput re-arms the text input for the next question and reports the @@ -34,19 +39,6 @@ func (m *LoginModel) promptInput(value string, secret bool) tea.Cmd { 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 @@ -57,93 +49,150 @@ 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) + for _, p := range []*loginPick{&m.ChoosePicker, &m.ProtocolPicker, &m.ModelPicker} { + p.setSize(width, height) } } +// View is the login step in the list frame: a list for a choice, the +// question's input in the search line for a text step, and the step's text +// above it. Errors and progress sit in the footer. func (m LoginModel) View() string { - var b strings.Builder - line := func(s string) { - b.WriteString(s) - b.WriteByte('\n') + width := cmp.Or(m.Width, 80) + title := brand("albedo") + " " + DefaultStyles.Muted.Render("/login") + right := DefaultStyles.Faint.Render(m.Name) + if m.Step == StepChoose { + right = DefaultStyles.Faint.Render(config.HomeDir()) } - line(titleRule(m.Width, brand("albedo")+" "+DefaultStyles.Muted.Render("/login"), DefaultStyles.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() + footer := m.footer(width) + if m.listing() { + lv := m.pickerFor().listView + lv.Empty = "nothing to choose" + return lv.frame(title, right, footer).view(m.Width, m.Height) } - line(DefaultStyles.Faint.Render(ansi.Wrap(location, m.Width, ""))) - if m.Error != "" { - line(DefaultStyles.Error.Render(ansi.Wrap(m.Error, m.Width, ""))) + f := listFrame{ + title: title, + right: right, + filter: promptLead() + DefaultStyles.Muted.Render(m.fieldLabel()+" ") + m.TextInput.View(), + list: func(w, _ int) []string { return m.body(w) }, + footer: footer, } + return f.view(m.Width, m.Height) +} +// body is the text above a question: what it asks, what it takes, and what +// a sign-in or save is doing. +func (m LoginModel) body(width int) []string { switch m.Step { - case StepChoose, StepAccountProfile, StepOAuthFlow: - 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(DefaultStyles.Faint.Render("loading models…")) - break - } - if m.CatalogNote != "" { - line(DefaultStyles.Faint.Render(m.CatalogNote)) - } - b.WriteString(m.ModelPicker.View()) - case StepOAuthFields: - if m.LoginFieldIndex < len(m.LoginFields) { - field := m.LoginFields[m.LoginFieldIndex] - if field.Type == "choice" || field.Type == "boolean" { - line(m.ChoosePicker.View()) - } else { - line(field.Label) - if field.Description != "" { - line(DefaultStyles.Faint.Render(field.Description)) - } - line(m.TextInput.View()) - b.WriteString(keyHints(hint{"enter", "continue"}, hint{"esc", "cancel"})) - } + kind := m.Removing.Kind + return []string{DefaultStyles.Bold.Render(m.Removing.Label), DefaultStyles.Faint.Render(kind)} + case StepSaving: + state := "saving provider…" + if m.Removing.Kind != "" { + state = "removing " + m.Removing.Kind + "…" } + return []string{DefaultStyles.Faint.Render(state)} case StepOAuth: - line(cmp.Or(m.Status, "starting sign-in…")) + lines := []string{DefaultStyles.Muted.Render(cmp.Or(m.Status, "starting sign-in…"))} if m.LoginInstructions != "" { - line(DefaultStyles.Faint.Render(ansi.Wrap(m.LoginInstructions, m.Width, ""))) + lines = append(lines, paneNote(m.LoginInstructions, width)...) } if m.SignInURL != "" { - line(DefaultStyles.Faint.Render(ansi.Hardwrap(m.SignInURL, m.Width, true))) + lines = append(lines, "", DefaultStyles.Faint.Render(ansi.Hardwrap(m.SignInURL, width, true))) } - b.WriteString("Callback URL or code: ") - line(m.TextInput.View()) - b.WriteString(ansi.Wrap(DefaultStyles.Faint.Render("Browser sign-in completes automatically")+DefaultStyles.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 + "…" + lines = append(lines, "") + return append(lines, paneNote("browser sign-in finishes on its own; paste the callback URL or code if it stalls", width)...) + case StepModels, StepOAuthModels: + return []string{DefaultStyles.Faint.Render("loading models…")} + } + _, notes := m.question() + var lines []string + for _, note := range notes { + lines = append(lines, paneNote(note, width)...) + } + return lines +} + +// question is the label a text step asks for, and what it says beside it. +func (m LoginModel) question() (string, []string) { + switch m.Step { + case StepName: + var notes []string + for _, login := range m.SignIns { + notes = append(notes, "use "+login.Provider+" to sign in") } - b.WriteString(DefaultStyles.Faint.Render(state)) - default: - stepLabels := map[LoginStep]string{ - StepName: "provider name", StepBaseURL: "API base URL", - StepAPIKey: "API key", StepModel: "model ID", - StepProject: "Google Cloud project ID", StepLocation: "Vertex location", + return "provider name", notes + case StepBaseURL: + if m.optionalKey() { + return "API base URL", []string{"blank uses your daemon's environment"} } - 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"}) + return "API base URL", nil + case StepAPIKey: + return "API key", nil + case StepProject: + return "Google Cloud project ID", nil + case StepLocation: + return "Vertex location", nil + case StepModel: + return "model ID", nil + case StepOAuthFields: + if m.LoginFieldIndex < len(m.LoginFields) { + field := m.LoginFields[m.LoginFieldIndex] + return field.Label, []string{field.Description} } - if m.Step == StepName { - for _, login := range m.SignIns { - hints = append(hints, hint{"", "use " + login.Provider + " to sign in"}) + } + return "", nil +} + +// hints are the keys for the step, most important first. +func (m LoginModel) hints() []hint { + switch { + case m.Step == StepSaving: + return nil + case (m.Step == StepModels || m.Step == StepOAuthModels) && !m.listing(): + return []hint{{"esc", "back"}} + case m.listing(): + hints := []hint{{"↑↓", "move"}, {"enter", m.enterVerb()}} + if item, ok := m.ChoosePicker.Highlighted(); ok && m.Step == StepChoose { + if _, removable := m.removalFor(item.ID); removable { + hints = append(hints, hint{"ctrl+d", "remove"}) } } - hints = append(hints, hint{"enter", "continue"}, hint{"esc", "cancel"}) - b.WriteString(ansi.Wrap(keyHints(hints...), m.Width, "")) + return append(hints, hint{"esc", "back"}) + case m.Step == StepOAuth: + return []hint{{"enter", "submit code"}, {"esc", "cancel"}} + case m.Step == StepAPIKey && m.Draft.APIKey != "": + return []hint{{"enter", "keeps the saved key"}, {"esc", "cancel"}} + } + return []hint{{"enter", "continue"}, {"esc", "cancel"}} +} + +// enterVerb says what enter does on the list the step shows. +func (m LoginModel) enterVerb() string { + switch m.Step { + case StepAccountProfile: + return "use" + case StepOAuthFlow: + return "sign in" + case StepModels, StepOAuthModels: + return "use model" + } + return "choose" +} + +// footer is the keys, with an error on a row of its own above them so the +// keys keep their labels. +func (m LoginModel) footer(width int) string { + if m.Step == StepRemove { + return m.confirm.footer(width, m.Error) + } + if m.Error != "" { + return " " + DefaultStyles.Error.Render(ansi.Truncate(m.Error, max(1, width-2), "…")) + "\n" + footerLine(width, m.hints(), "", false) + } + status := "" + if m.CatalogNote != "" && (m.Step == StepModels || m.Step == StepOAuthModels) { + status = DefaultStyles.Faint.Render(m.CatalogNote) } - return b.String() + return footerLine(width, m.hints(), status, false) } diff --git a/cli/internal/tui/picker.go b/cli/internal/tui/picker.go index d7f84fa4..c1876cae 100644 --- a/cli/internal/tui/picker.go +++ b/cli/internal/tui/picker.go @@ -16,6 +16,8 @@ type PickerItem struct { // Group ranks its items apart from the other groups under a search; // groups keep the order they were listed in. Group int + // Note is what a detail pane says about the item, where a list has one. + Note string // hits are the characters of Label the search matched. hits []int } diff --git a/cli/internal/tui/shot_login_test.go b/cli/internal/tui/shot_login_test.go new file mode 100644 index 00000000..c26b13f9 --- /dev/null +++ b/cli/internal/tui/shot_login_test.go @@ -0,0 +1,122 @@ +// The login steps in every state a person sees, for the shot gallery. A plain +// test run draws each state and writes nothing. +package tui + +import ( + "albedo/cli/internal/config" + "albedo/cli/internal/daemon" + + tea "charm.land/bubbletea/v2" +) + +func init() { + registerShots("login", + shotState{"provider-list", loginShot(nil)}, + shotState{"provider-filter", loginShot(nil, typedKeys("cla")...)}, + shotState{"provider-no-match", loginShot(nil, typedKeys("zzz")...)}, + shotState{"provider-remove-ask", loginShot(nil, tea.KeyPressMsg{Code: 'd', Mod: tea.ModCtrl})}, + shotState{"provider-remove-stray-key", loginShot(nil, tea.KeyPressMsg{Code: 'd', Mod: tea.ModCtrl}, tea.KeyPressMsg{Code: 'x', Text: "x"})}, + shotState{"provider-error", loginShot(func(m *LoginModel) { m.Error = "daemon refused the change" })}, + shotState{"first-run", loginEmpty(nil)}, + shotState{"first-run-sign-in-hint", loginEmpty(func(m *LoginModel) { m.Step = StepName })}, + shotState{"load-failed", loginEmpty(func(m *LoginModel) { m.Error = "daemon connection unavailable" })}, + shotState{"sign-in-flows", loginShot(func(m *LoginModel) { + login, _ := m.signInFor("claude") + m.startSignIn(login) + })}, + shotState{"sign-in-field", loginShot(func(m *LoginModel) { + m.LoginFields = []daemon.FormField{{Type: "secret", Name: "token", Label: "paste the token", Description: "from the provider's keys page"}} + m.LoginFieldIndex = 0 + m.nextLoginField() + })}, + shotState{"account-profile", loginShot(func(m *LoginModel) { m.chooseAccount(m.Accounts[0]) })}, + shotState{"api-key", loginShot(func(m *LoginModel) { m.askAPIKey() }, typedKeys("sk-live-abc123")...)}, + shotState{"api-key-error", loginShot(func(m *LoginModel) { + m.askAPIKey() + m.Error = "Enter an API key without spaces or control characters." + })}, + shotState{"base-url", loginShot(func(m *LoginModel) { m.Name = "work"; m.askEndpoint() })}, + shotState{"project", loginShot(func(m *LoginModel) { + m.Draft.Extension = "vertex" + m.Name = "vertex" + m.askProject() + })}, + shotState{"protocol", loginShot(func(m *LoginModel) { + m.Step = StepProtocol + m.buildProtocolPicker() + })}, + shotState{"browser-wait", loginShot(func(m *LoginModel) { + m.Step = StepOAuth + m.Status = "waiting for the browser…" + m.SignInURL = "https://login.example/authorize?client_id=albedo&state=7f3c" + m.LoginInstructions = "Finish signing in in your browser." + })}, + shotState{"device-code", loginShot(func(m *LoginModel) { + m.Step = StepOAuth + m.Status = "enter the code to finish" + m.LoginInstructions = "Open https://login.example/device and enter ABCD-1234." + })}, + shotState{"models", loginShot(func(m *LoginModel) { + m.Step = StepModels + *m, _ = m.Update(loginModelsLoadedMsg{Gen: m.Generation, Models: []string{"gpt-5", "gpt-5-mini", "gpt-4.1"}}) + })}, + shotState{"models-loading", loginShot(func(m *LoginModel) { m.Step = StepModels })}, + shotState{"models-note", loginShot(func(m *LoginModel) { + m.Step = StepModels + *m, _ = m.Update(loginModelsLoadedMsg{Gen: m.Generation, Note: "No matching models in models.dev. Enter a model ID manually."}) + })}, + shotState{"saving", loginShot(func(m *LoginModel) { m.Step = StepSaving })}, + shotState{"removing", loginShot(func(m *LoginModel) { + m.Removing = removal{Kind: "account", Label: "mayer@chernobog"} + m.Step = StepSaving + })}, + ) +} + +// loginModel is the login screen at a terminal size, listing the fixture +// providers, accounts and sign-ins it would list from the daemon. +func loginModel(width, height int) LoginModel { + m := NewLoginModel(&daemon.Connection{}, "", nil) + m.SetSize(width, height) + profiles := config.Profiles{Active: "work", Providers: map[string]config.Settings{ + "work": {Extension: "openai", Model: "gpt-5", BaseURL: "https://api.openai.com/v1", Protocol: "responses", HasKey: true}, + "claude": {Extension: "claude", Model: "claude-sonnet-4", Protocol: "chat_completions"}, + "claude-team": {Extension: "claude", Model: "claude-opus-4", Protocol: "chat_completions"}, + }} + listed := daemon.SignIns{ + Logins: []daemon.SignIn{ + {Provider: "claude", Label: "Claude", Detail: "console billing or your subscription", Protocol: "chat_completions", Flows: []string{"browser", "device"}}, + {Provider: "antigravity", Label: "Antigravity", Detail: "Google account", Flows: []string{"browser"}}, + }, + Accounts: []daemon.Account{{Provider: "claude", ID: "acc-1", Label: "mayer@chernobog", Detail: "Max plan"}}, + } + m, _ = m.Update(signInsLoadedMsg{Gen: m.Generation, Listed: listed, Profiles: &profiles}) + return m +} + +// loginEmpty is the first-run login: nothing saved, so it asks for a name. +func loginEmpty(setup func(*LoginModel)) func(width, height int) string { + return func(width, height int) string { + m := NewLoginModel(&daemon.Connection{}, "", nil) + m.SetSize(width, height) + m, _ = m.Update(signInsLoadedMsg{Gen: m.Generation, Listed: daemon.SignIns{Logins: []daemon.SignIn{{Provider: "claude", Label: "Claude"}}}, Profiles: &config.Profiles{}}) + if setup != nil { + setup(&m) + } + return m.View() + } +} + +// loginShot draws the login screen after setup and keys. +func loginShot(setup func(*LoginModel), keys ...tea.Msg) func(width, height int) string { + return func(width, height int) string { + m := loginModel(width, height) + if setup != nil { + setup(&m) + } + for _, key := range keys { + m, _ = m.Update(key) + } + return m.View() + } +} -- knotmirror 0.0.1