okami.mom/albedo

git clone https://git.nkp.pet/okami.mom/albedo

dawntui: login on listFrame/listView, removal through the shared confirm on ctrl+d479178e

main
5.3 KiB122 linesraw
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() {
13	registerShots("login",
14		shotState{"provider-list", loginShot(nil)},
15		shotState{"provider-filter", loginShot(nil, typedKeys("cla")...)},
16		shotState{"provider-no-match", loginShot(nil, typedKeys("zzz")...)},
17		shotState{"provider-remove-ask", loginShot(nil, tea.KeyPressMsg{Code: 'd', Mod: tea.ModCtrl})},
18		shotState{"provider-remove-stray-key", loginShot(nil, tea.KeyPressMsg{Code: 'd', Mod: tea.ModCtrl}, tea.KeyPressMsg{Code: 'x', Text: "x"})},
19		shotState{"provider-error", loginShot(func(m *LoginModel) { m.Error = "daemon refused the change" })},
20		shotState{"first-run", loginEmpty(nil)},
21		shotState{"first-run-sign-in-hint", loginEmpty(func(m *LoginModel) { m.Step = StepName })},
22		shotState{"load-failed", loginEmpty(func(m *LoginModel) { m.Error = "daemon connection unavailable" })},
23		shotState{"sign-in-flows", loginShot(func(m *LoginModel) {
24			login, _ := m.signInFor("claude")
25			m.startSignIn(login)
26		})},
27		shotState{"sign-in-field", loginShot(func(m *LoginModel) {
28			m.LoginFields = []daemon.FormField{{Type: "secret", Name: "token", Label: "paste the token", Description: "from the provider's keys page"}}
29			m.LoginFieldIndex = 0
30			m.nextLoginField()
31		})},
32		shotState{"account-profile", loginShot(func(m *LoginModel) { m.chooseAccount(m.Accounts[0]) })},
33		shotState{"api-key", loginShot(func(m *LoginModel) { m.askAPIKey() }, typedKeys("sk-live-abc123")...)},
34		shotState{"api-key-error", loginShot(func(m *LoginModel) {
35			m.askAPIKey()
36			m.Error = "Enter an API key without spaces or control characters."
37		})},
38		shotState{"base-url", loginShot(func(m *LoginModel) { m.Name = "work"; m.askEndpoint() })},
39		shotState{"project", loginShot(func(m *LoginModel) {
40			m.Draft.Extension = "vertex"
41			m.Name = "vertex"
42			m.askProject()
43		})},
44		shotState{"protocol", loginShot(func(m *LoginModel) {
45			m.Step = StepProtocol
46			m.buildProtocolPicker()
47		})},
48		shotState{"browser-wait", loginShot(func(m *LoginModel) {
49			m.Step = StepOAuth
50			m.Status = "waiting for the browser…"
51			m.SignInURL = "https://login.example/authorize?client_id=albedo&state=7f3c"
52			m.LoginInstructions = "Finish signing in in your browser."
53		})},
54		shotState{"device-code", loginShot(func(m *LoginModel) {
55			m.Step = StepOAuth
56			m.Status = "enter the code to finish"
57			m.LoginInstructions = "Open https://login.example/device and enter ABCD-1234."
58		})},
59		shotState{"models", loginShot(func(m *LoginModel) {
60			m.Step = StepModels
61			*m, _ = m.Update(loginModelsLoadedMsg{Gen: m.Generation, Models: []string{"gpt-5", "gpt-5-mini", "gpt-4.1"}})
62		})},
63		shotState{"models-loading", loginShot(func(m *LoginModel) { m.Step = StepModels })},
64		shotState{"models-note", loginShot(func(m *LoginModel) {
65			m.Step = StepModels
66			*m, _ = m.Update(loginModelsLoadedMsg{Gen: m.Generation, Note: "No matching models in models.dev. Enter a model ID manually."})
67		})},
68		shotState{"saving", loginShot(func(m *LoginModel) { m.Step = StepSaving })},
69		shotState{"removing", loginShot(func(m *LoginModel) {
70			m.Removing = removal{Kind: "account", Label: "mayer@chernobog"}
71			m.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 {
79	m := NewLoginModel(&daemon.Connection{}, "", nil)
80	m.SetSize(width, height)
81	profiles := 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	}}
86	listed := daemon.SignIns{
87		Logins: []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		},
91		Accounts: []daemon.Account{{Provider: "claude", ID: "acc-1", Label: "mayer@chernobog", Detail: "Max plan"}},
92	}
93	m, _ = m.Update(signInsLoadedMsg{Gen: m.Generation, Listed: listed, Profiles: &profiles})
94	return 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 {
99	return func(width, height int) string {
100		m := NewLoginModel(&daemon.Connection{}, "", nil)
101		m.SetSize(width, height)
102		m, _ = m.Update(signInsLoadedMsg{Gen: m.Generation, Listed: daemon.SignIns{Logins: []daemon.SignIn{{Provider: "claude", Label: "Claude"}}}, Profiles: &config.Profiles{}})
103		if setup != nil {
104			setup(&m)
105		}
106		return 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 {
112	return func(width, height int) string {
113		m := loginModel(width, height)
114		if setup != nil {
115			setup(&m)
116		}
117		for _, key := range keys {
118			m, _ = m.Update(key)
119		}
120		return m.View()
121	}
122}