okami.mom/albedo

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

dawnadd colab extension47735ec

main
12.1 KiB375 linesraw
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) {
21	i := slices.IndexFunc(m.SignIns, func(login daemon.SignIn) bool { return login.Provider == provider })
22	if i < 0 {
23		return daemon.SignIn{}, false
24	}
25	return 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 {
31	if _, known := m.signInFor(provider); !known {
32		return false
33	}
34	return !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) {
39	index, err := strconv.Atoi(strings.TrimPrefix(id, "account:"))
40	if err != nil || index < 0 || index >= len(m.Accounts) {
41		return daemon.Account{}, false
42	}
43	return m.Accounts[index], true
44}
45
46func (m *LoginModel) buildChoosePicker() {
47	var items []PickerItem
48	for _, name := range slices.Sorted(maps.Keys(m.Profiles.Providers)) {
49		settings := m.Profiles.Providers[name]
50		extension := cmp.Or(settings.Extension, "openai")
51		detail := fmt.Sprintf("%s · %s", settings.Model, extension)
52		if _, signIn := m.signInFor(extension); signIn && hasKey(settings) {
53			detail += " · API key"
54		} else if m.signedOut(extension) {
55			detail += " · signed out"
56		}
57		if name == m.Profiles.Active {
58			detail += " · active"
59		}
60		items = append(items, PickerItem{ID: "use:" + name, Label: name, Detail: detail, Note: "a saved provider profile; enter makes it the one new sessions use"})
61	}
62	for index, account := range m.Accounts {
63		note := "a signed-in account; enter picks the profile it runs under"
64		if account.Kind == "service" {
65			note = "service account for tools; its tokens stay in creds.json"
66		}
67		items = append(items, PickerItem{ID: "account:" + strconv.Itoa(index), Label: account.Label, Detail: account.Detail, Note: note})
68	}
69	for _, p := range customProviders {
70		items = append(items, PickerItem{ID: p.ID, Label: p.Label, Detail: p.Detail, Note: "creates or updates a provider profile"})
71	}
72	for _, login := range m.SignIns {
73		note := "signs in through the daemon; its tokens stay in the daemon's credential store"
74		if login.Kind == "service" {
75			note = "service sign-in for tools; its tokens stay in creds.json"
76		}
77		items = append(items, PickerItem{ID: "signin:" + login.Provider, Label: login.Label, Detail: login.Detail, Note: note})
78	}
79	m.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) {
84	if name, ok := strings.CutPrefix(id, "use:"); ok {
85		if _, saved := m.Profiles.Providers[name]; saved {
86			return removal{Kind: "provider", ID: name, Label: name}, true
87		}
88	}
89	if account, ok := m.accountAt(id); ok {
90		return removal{Kind: "account", Provider: account.Provider, ID: account.ID, Label: account.Label}, true
91	}
92	return removal{}, false
93}
94
95func (m *LoginModel) confirmRemoval(target removal) tea.Cmd {
96	detail := "This removes the provider and its saved key"
97	if target.Kind == "account" {
98		detail = "This removes its saved tokens"
99	}
100	m.Removing = target
101	m.Step = StepRemove
102	m.confirm.ask("remove", target.ID, "remove", detail+". Remove "+target.Kind+" "+target.Label+"?")
103	return 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 {
109	if m.confirm.key(msg) {
110		m.confirm.dismiss()
111		m.Step = StepSaving
112		return m.removeCmd(m.Removing)
113	}
114	if m.confirm.asking() {
115		return nil
116	}
117	return 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 {
123	return func() tea.Msg {
124		ctx := context.Background()
125		if err := change(ctx); err != nil {
126			return signInsLoadedMsg{Mutation: true, Err: err, Gen: m.Generation}
127		}
128		result := m.listSignIns(m.readCtx, m.Generation)
129		result.Mutation = true
130		return result
131	}
132}
133
134func (m LoginModel) removeCmd(target removal) tea.Cmd {
135	return m.reloadCmd(func(ctx context.Context) error {
136		if target.Kind == "account" {
137			return daemon.RemoveAccount(ctx, m.Conn, target.ID)
138		}
139		return daemon.DeleteProvider(ctx, m.Conn, target.ID, m.Profiles)
140	})
141}
142
143func (m LoginModel) selectAccountCmd(account daemon.Account) tea.Cmd {
144	return m.reloadCmd(func(ctx context.Context) error {
145		return daemon.SelectProfileAccount(ctx, m.Conn, m.Name, account, m.Profiles)
146	})
147}
148
149func (m *LoginModel) chooseAccount(account daemon.Account) tea.Cmd {
150	if account.Kind == "service" {
151		m.Status = fmt.Sprintf("%s is ready for use with tools; ctrl+d removes it", account.Label)
152		return nil
153	}
154	var names []string
155	for name, profile := range m.Profiles.Providers {
156		if profile.Extension == account.Provider {
157			names = append(names, name)
158		}
159	}
160	slices.Sort(names)
161	if len(names) == 0 {
162		m.Name = account.Provider
163		m.Draft = config.Settings{Extension: account.Provider, Protocol: providerProtocol(account.Provider), AccountID: &account.ID}
164		m.Step = StepOAuthModels
165		return m.fetchCatalogCmd(account.Provider, "", m.Generation)
166	}
167	if len(names) == 1 {
168		m.Name = names[0]
169		m.Step = StepSaving
170		return m.selectAccountCmd(account)
171	}
172	m.SelectedAccount = &account
173	m.Step = StepAccountProfile
174	items := make([]PickerItem, 0, len(names))
175	for _, name := range names {
176		items = append(items, PickerItem{ID: name, Label: name, Detail: "use " + account.Label, Note: "the profile this account signs in for"})
177	}
178	m.ChoosePicker = newLoginPick("Choose the profile for this account", items, m.Profiles.Active, m.Width, m.Height)
179	return m.ChoosePicker.Init()
180}
181
182func providerProtocol(provider string) string {
183	if provider == "claude" {
184		return "chat_completions"
185	}
186	return "responses"
187}
188
189func (m *LoginModel) backToChoose() tea.Cmd {
190	m.Step = StepChoose
191	m.buildChoosePicker()
192	return 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 {
198	m.Provider = login.Provider
199	m.Name = login.Provider
200	m.Draft = config.Settings{Extension: login.Provider, Protocol: providerProtocol(login.Provider)}
201	if saved, ok := m.Profiles.Providers[login.Provider]; ok {
202		m.Draft = saved
203	}
204	m.Generation = nextPageGeneration()
205	m.LoginFields = slices.Clone(login.Fields)
206	m.LoginValues = map[string]json.RawMessage{}
207	m.LoginFieldIndex = 0
208	m.LoginFlow = ""
209	m.LoginInstructions = ""
210	m.LoginID = ""
211	m.LoginETag = ""
212	m.SignInURL = ""
213	m.Error = ""
214	if len(login.Flows) > 1 {
215		m.Step = StepOAuthFlow
216		items := make([]PickerItem, 0, len(login.Flows))
217		for _, flow := range login.Flows {
218			items = append(items, PickerItem{ID: flow, Label: flow})
219		}
220		m.ChoosePicker = newLoginPick("Choose a sign-in flow", items, login.Flows[0], m.Width, m.Height)
221		return m.ChoosePicker.Init()
222	}
223	if len(login.Flows) == 1 {
224		m.LoginFlow = login.Flows[0]
225	}
226	return m.nextLoginField()
227}
228
229func (m *LoginModel) nextLoginField() tea.Cmd {
230	for m.LoginFieldIndex < len(m.LoginFields) {
231		field := m.LoginFields[m.LoginFieldIndex]
232		if field.Type == "hidden" {
233			m.LoginValues[field.Name] = slices.Clone(field.Default)
234			m.LoginFieldIndex++
235			continue
236		}
237		m.Step = StepOAuthFields
238		if field.Type == "choice" || field.Type == "boolean" {
239			items := []PickerItem{}
240			if field.Type == "boolean" {
241				items = []PickerItem{{ID: "false", Label: "false"}, {ID: "true", Label: "true"}}
242			} else {
243				for _, choice := range field.Choices {
244					items = append(items, PickerItem{ID: choice.Label, Label: choice.Label})
245				}
246			}
247			initial := ""
248			if index := daemon.FormChoiceDefault(field); index < len(items) {
249				initial = items[index].ID
250			}
251			m.ChoosePicker = newLoginPick(field.Label, items, initial, m.Width, m.Height)
252			return m.ChoosePicker.Init()
253		}
254		initial := ""
255		_ = json.Unmarshal(field.Default, &initial)
256		m.TextInput.Placeholder = field.Label
257		return m.promptInput(initial, field.Type == "secret")
258	}
259	return m.beginLoginCmd()
260}
261
262func (m *LoginModel) beginLoginCmd() tea.Cmd {
263	m.Step = StepOAuth
264	m.Status = "starting sign-in…"
265	m.TextInput.Placeholder = ""
266	m.promptInput("", false)
267	conn, provider, flow, gen, lifetime := m.Conn, m.Provider, m.LoginFlow, m.Generation, m.readCtx
268	values := maps.Clone(m.LoginValues)
269	m.LoginValues = nil
270	m.LoginFields = nil
271	return func() tea.Msg {
272		started, err := daemon.StartProviderLogin(context.Background(), conn, provider, flow, values)
273		// Cleanup also runs if the program has already stopped receiving messages.
274		if lifetime.Err() != nil && started.ID != "" {
275			if cleanupErr := daemon.CancelSignIn(context.Background(), conn, started.ID); cleanupErr == nil {
276				return signInStartedMsg{Err: lifetime.Err(), Gen: gen}
277			}
278		}
279		return 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 {
284	field := m.LoginFields[m.LoginFieldIndex]
285	value, err := daemon.ParseActionField(field, text)
286	if err != nil {
287		m.Error = err.Error()
288		return nil
289	}
290	if value != nil {
291		m.LoginValues[field.Name] = value
292	}
293	m.LoginFieldIndex++
294	m.Error = ""
295	m.TextInput.SetValue("")
296	return m.nextLoginField()
297}
298
299func (m LoginModel) pollSignInCmd(id string, gen int) tea.Cmd {
300	return func() tea.Msg {
301		status, err := daemon.PollSignIn(m.readCtx, m.Conn, id)
302		return 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 {
307	return tea.Tick(signInPollInterval, func(time.Time) tea.Msg {
308		return signInPollMsg{ID: id, Gen: gen}
309	})
310}
311
312func (m LoginModel) inputSignInCmd(id, input string, gen int) tea.Cmd {
313	return func() tea.Msg {
314		if err := daemon.SignInInput(context.Background(), m.Conn, id, input, m.LoginETag); err != nil {
315			return signInStatusMsg{ID: id, Err: err, Gen: gen}
316		}
317		return 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 {
324	if id == "" || m.Conn == nil {
325		return nil
326	}
327	return func() tea.Msg {
328		_ = daemon.CancelSignIn(context.Background(), m.Conn, id)
329		return nil
330	}
331}
332
333// endSignIn forgets the sign-in in flight and returns the command cancelling it.
334func (m *LoginModel) endSignIn() tea.Cmd {
335	id := m.LoginID
336	m.LoginID = ""
337	m.SignInURL = ""
338	m.Status = ""
339	return 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 {
344	if m.cancelReads != nil {
345		m.cancelReads()
346	}
347	m.closed = true
348	m.Generation = nextPageGeneration()
349	m.LoginValues = nil
350	m.LoginFields = nil
351	m.TextInput.SetValue("")
352	m.Draft.APIKey = ""
353	return 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 {
359	listed, err := daemon.SignInList(ctx, m.Conn)
360	if err != nil {
361		return signInsLoadedMsg{Err: err, Gen: gen}
362	}
363	settings, err := daemon.GetSettings(ctx, m.Conn)
364	return 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 {
369	conn, ctx, gen := m.Conn, m.readCtx, m.Generation
370	profiles.Providers = maps.Clone(profiles.Providers)
371	return func() tea.Msg {
372		listed, err := daemon.SignInList(ctx, conn)
373		return signInsLoadedMsg{Listed: listed, Profiles: &profiles, Err: err, Gen: gen}
374	}
375}