1package modes
2
3import (
4 "fmt"
5 "path/filepath"
6 "sort"
7
8 "github.com/patriceckhart/zot/packages/provider"
9 "github.com/patriceckhart/zot/packages/provider/auth"
10 "github.com/patriceckhart/zot/packages/tui"
11)
12
13// loginStep is the current node in the login dialog state machine.
14type loginStep int
15
16// loginStepClosed is the zero value on purpose: a freshly-constructed
17// dialog must default to closed so nothing shows up until Open() is
18// explicitly called.
19const (
20 loginStepClosed loginStep = iota
21 loginStepMethod // pick apikey vs subscription
22 loginStepProvider // pick anthropic vs openai vs kimi
23 loginStepWaiting // browser open, waiting for callback
24 loginStepPasteCode // user pastes the auth code here
25 loginStepInfo // informational setup guidance
26 loginStepDone // success or error, waiting for key to dismiss
27)
28
29const loginProviderPageSize = 8
30
31// loginDialog is a tiny inline dialog rendered above the editor while
32// the user picks their login method and provider.
33type loginDialog struct {
34 step loginStep
35 method string // "apikey" | "oauth"
36 provider string // "anthropic" | "openai" | "openai-codex" | "kimi" | "google"
37 message string
38 success bool
39 url string
40 cursor int
41 codeEd *tui.Editor
42 infoTitle string
43 infoLines []string
44
45 // status is a snapshot of the current login state for each
46 // provider, captured when Open() runs. Rendered above the
47 // method picker so the user can see whether they're already
48 // logged in (and how) before starting a new flow. Keys:
49 // "anthropic", "openai", "openai-codex", "kimi", "google". Value is
50 // "apikey", "oauth", or "" (not logged in).
51 status map[string]string
52}
53
54func newLoginDialog() *loginDialog {
55 return &loginDialog{}
56}
57
58// Active reports whether the dialog consumes input.
59func (d *loginDialog) Active() bool { return d != nil && d.step != loginStepClosed }
60
61// Open starts the dialog from scratch and captures the current
62// login status for each provider so the picker can show it.
63// zotHome is the zot state directory ($ZOT_HOME); auth.json
64// lives inside it. Passing the path in (instead of importing
65// the agent package to call AuthPath()) avoids a cyclic import
66// between agent and agent/modes.
67func (d *loginDialog) Open(zotHome string) {
68 d.step = loginStepMethod
69 d.method = ""
70 d.provider = ""
71 d.message = ""
72 d.success = false
73 d.url = ""
74 d.cursor = 0
75 d.infoTitle = ""
76 d.infoLines = nil
77 d.status = map[string]string{}
78 for _, p := range providersForMethod("apikey") {
79 d.status[p] = ""
80 }
81 for _, p := range providersForMethod("oauth") {
82 d.status[p] = ""
83 }
84 // Best-effort: if the auth file can't be read, treat every
85 // provider as not-logged-in. The status line just won't show
86 // anything useful in that case, which is fine — the user
87 // was about to log in anyway.
88 path := filepath.Join(zotHome, "auth.json")
89 if creds, err := auth.NewStore(path).Load(); err == nil {
90 d.status["anthropic"] = creds.Method("anthropic")
91 d.status["openai"] = ""
92 if creds.OpenAI.APIKey != "" {
93 d.status["openai"] = "apikey"
94 }
95 d.status["openai-codex"] = ""
96 if creds.OpenAI.OAuth != nil {
97 d.status["openai-codex"] = "oauth"
98 }
99 d.status["kimi"] = creds.Method("kimi")
100 d.status["deepseek"] = creds.Method("deepseek")
101 d.status["google"] = creds.Method("google")
102 d.status["github-copilot"] = creds.Method("github-copilot")
103 for p := range creds.AdditionalAPIKeyCreds {
104 d.status[p] = creds.Method(p)
105 }
106 }
107}
108
109// Close hides the dialog.
110func (d *loginDialog) Close() {
111 d.step = loginStepClosed
112}
113
114// Render returns the dialog lines or nil when inactive.
115func (d *loginDialog) Render(th tui.Theme, width int) []string {
116 if !d.Active() {
117 return nil
118 }
119 var lines []string
120
121 switch d.step {
122 case loginStepMethod:
123 opts := []string{
124 "api key",
125 "subscription (claude pro/max - chatgpt plus/pro - chatgpt codex - kimi code - github copilot)",
126 }
127 lines = append(lines, frameHeader(th, "login", width))
128 for _, l := range d.renderStatusLines(th) {
129 lines = append(lines, l)
130 }
131 lines = append(lines, th.FG256(th.Muted, "choose login method (↑/↓, enter, esc to cancel):"))
132 for i, o := range opts {
133 plain := " " + o
134 if i == d.cursor {
135 lines = append(lines, th.PadHighlight(plain, width))
136 } else {
137 lines = append(lines, th.FG256(th.Muted, plain))
138 }
139 }
140 lines = append(lines, frameRule(th, width))
141 case loginStepProvider:
142 opts := providersForMethod(d.method)
143 lines = append(lines, frameHeader(th, "login - "+d.method, width))
144 for _, l := range d.renderStatusLines(th) {
145 lines = append(lines, l)
146 }
147 lines = append(lines, th.FG256(th.Muted, "pick a provider (↑/↓, enter, esc to cancel)"))
148 start, end := d.providerPage(len(opts))
149 for i := start; i < end; i++ {
150 o := opts[i]
151 tag := providerPickerTag(d.method, d.status[o])
152 label := " " + providerLabel(o)
153 plain := label + tag
154 if i == d.cursor {
155 lines = append(lines, th.PadHighlight(plain, width))
156 } else {
157 lines = append(lines, th.FG256(th.Muted, label)+th.FG256(th.Accent, tag))
158 }
159 }
160 if len(opts) > loginProviderPageSize {
161 lines = append(lines, th.FG256(th.Muted, fmt.Sprintf(" (%d/%d)", d.cursor+1, len(opts))))
162 }
163 lines = append(lines, frameRule(th, width))
164 case loginStepWaiting:
165 lines = append(lines, frameHeader(th, "login - "+d.method+" - "+providerLabel(d.provider), width))
166 lines = append(lines, th.FG256(th.Muted, "open this URL in a browser:"))
167 wrapW := width - 2
168 if wrapW < 20 {
169 wrapW = 20
170 }
171 for _, seg := range tui.WrapANSILine(d.url, wrapW) {
172 lines = append(lines, th.FG256(th.Accent, seg))
173 }
174 lines = append(lines, "")
175 lines = append(lines, th.FG256(th.Muted, "paste the authorization code (or full redirect URL / code#state):"))
176 if d.codeEd == nil {
177 d.codeEd = tui.NewEditor(th.AccentBar(th.Accent))
178 }
179 edLines, _, _ := d.codeEd.Render(width - 2)
180 for _, l := range edLines {
181 lines = append(lines, l)
182 }
183 lines = append(lines, "")
184 lines = append(lines, th.FG256(th.Muted, "enter submits - esc cancels - waiting for browser callback in background"))
185 lines = append(lines, frameRule(th, width))
186 case loginStepPasteCode:
187 lines = append(lines, frameHeader(th, "login - "+d.method+" - "+providerLabel(d.provider)+" - paste code", width))
188 lines = append(lines, th.FG256(th.Muted, "open this URL in any browser:"))
189 wrapW := width - 2
190 if wrapW < 20 {
191 wrapW = 20
192 }
193 for _, seg := range tui.WrapANSILine(d.url, wrapW) {
194 lines = append(lines, th.FG256(th.Accent, seg))
195 }
196 lines = append(lines, "")
197 lines = append(lines, th.FG256(th.Muted, "paste the authorization code (or full redirect URL / code#state):"))
198 if d.codeEd == nil {
199 d.codeEd = tui.NewEditor(th.AccentBar(th.Accent))
200 }
201 edLines, _, _ := d.codeEd.Render(width - 2)
202 for _, l := range edLines {
203 lines = append(lines, l)
204 }
205 lines = append(lines, "")
206 lines = append(lines, th.FG256(th.Muted, "enter submits - esc cancels"))
207 lines = append(lines, frameRule(th, width))
208 case loginStepInfo:
209 title := d.infoTitle
210 if title == "" {
211 title = "login - setup"
212 }
213 lines = append(lines, frameHeader(th, title, width))
214 for _, l := range d.infoLines {
215 lines = append(lines, l)
216 }
217 lines = append(lines, "")
218 lines = append(lines, th.FG256(th.Muted, "press any key to close"))
219 lines = append(lines, frameRule(th, width))
220 case loginStepDone:
221 title := "login - failed"
222 body := th.FG256(th.Error, d.message)
223 if d.success {
224 title = "login - success"
225 body = th.FG256(th.Tool, fmt.Sprintf("logged in to %s via %s", providerLabel(d.provider), d.method))
226 }
227 lines = append(lines, frameHeader(th, title, width))
228 lines = append(lines, body)
229 lines = append(lines, th.FG256(th.Muted, "press any key to close"))
230 lines = append(lines, frameRule(th, width))
231 }
232 return lines
233}
234
235// providersForMethod returns the providers offered for a given login
236// method. API-key is the universal path so it lists every provider
237// (including custom providers from models.json); subscription/OAuth
238// only lists providers that actually issue tokens usable against the
239// same API the model picker drives (Google's consumer Gemini Advanced
240// login does not, and DeepSeek has no subscription product at all).
241func providersForMethod(method string) []string {
242 var providers []string
243 if method == "oauth" {
244 providers = []string{"anthropic", "openai-codex", "kimi", "github-copilot"}
245 } else {
246 providers = auth.APIKeyProviders()
247 // Append custom providers from models.json so they appear
248 // in the /login picker alongside built-in providers. Dedupe in
249 // case models.json also adds metadata for a built-in provider.
250 seen := map[string]bool{}
251 for _, name := range providers {
252 seen[name] = true
253 }
254 for name := range provider.CustomProviders() {
255 if !seen[name] {
256 providers = append(providers, name)
257 seen[name] = true
258 }
259 }
260 }
261 sort.Slice(providers, func(a, b int) bool {
262 return providerLabel(providers[a]) < providerLabel(providers[b])
263 })
264 return providers
265}
266
267// providerLabel returns the user-facing label for a provider id.
268func providerLabel(id string) string { return provider.ProviderLabel(id) }
269
270func providerPickerTag(method, status string) string {
271 switch method {
272 case "apikey":
273 // In the API-key picker, only call out an existing subscription so
274 // users know choosing this provider will add/replace API-key auth
275 // while subscription auth is still configured. Unconfigured rows do
276 // not need a redundant "api key" suffix.
277 if status == "oauth" {
278 return " (subscription configured)"
279 }
280 case "oauth":
281 // In the subscription picker, only call out an existing API key.
282 if status == "apikey" {
283 return " (api key configured)"
284 }
285 }
286 return ""
287}
288
289func (d *loginDialog) providerPage(total int) (start, end int) {
290 if total <= loginProviderPageSize {
291 return 0, total
292 }
293 if d.cursor < 0 {
294 d.cursor = 0
295 }
296 if d.cursor >= total {
297 d.cursor = total - 1
298 }
299 start = (d.cursor / loginProviderPageSize) * loginProviderPageSize
300 end = start + loginProviderPageSize
301 if end > total {
302 end = total
303 }
304 return start, end
305}
306
307// renderStatusLines returns an overview of the current login
308// state for each provider, one row per provider, suitable to
309// insert between the frame header and the picker body. Logged-
310// in providers get a green checkmark in front; providers with
311// no credentials render as a muted dash so the list layout
312// stays aligned across first-run and re-login cases.
313//
314// Returns nil when neither provider is logged in (first-run
315// case — a pair of "not logged in" rows is just noise when the
316// user is about to pick a method anyway).
317func (d *loginDialog) renderStatusLines(th tui.Theme) []string {
318 anth := d.status["anthropic"]
319 op := d.status["openai"]
320 codex := d.status["openai-codex"]
321 kimi := d.status["kimi"]
322 ds := d.status["deepseek"]
323 goog := d.status["google"]
324 gh := d.status["github-copilot"]
325 if anth == "" && op == "" && codex == "" && kimi == "" && ds == "" && goog == "" && gh == "" {
326 return nil
327 }
328 row := func(id, method string) string {
329 label := providerLabel(id)
330 var mark, body string
331 switch method {
332 case "apikey":
333 mark = th.FG256(th.Tool, "✓")
334 body = th.FG256(th.Muted, label+": api key")
335 case "oauth":
336 mark = th.FG256(th.Tool, "✓")
337 body = th.FG256(th.Muted, label+": subscription")
338 default:
339 mark = th.FG256(th.Muted, "–")
340 body = th.FG256(th.Muted, label+": not logged in")
341 }
342 return " " + mark + " " + body
343 }
344 out := []string{
345 row("anthropic", anth),
346 row("openai", op),
347 row("openai-codex", codex),
348 row("kimi", kimi),
349 row("deepseek", ds),
350 row("google", goog),
351 row("github-copilot", gh),
352 }
353 for _, p := range providersForMethod("apikey") {
354 switch p {
355 case "anthropic", "openai", "openai-codex", "kimi", "deepseek", "google", "github-copilot":
356 continue
357 }
358 if method := d.status[p]; method != "" {
359 out = append(out, row(p, method))
360 }
361 }
362 out = append(out, "")
363 return out
364}
365
366// Key is the result of handling a key press.
367type loginDialogAction struct {
368 StartAPIKey bool
369 StartOAuth bool
370 StartManual bool
371 Provider string
372 Close bool
373 SubmitCode string
374}
375
376// HandleKey advances the dialog and returns an action to apply, if any.
377func (d *loginDialog) HandleKey(k tui.Key) loginDialogAction {
378 switch d.step {
379 case loginStepMethod:
380 return d.handleMethodKey(k)
381 case loginStepProvider:
382 return d.handleProviderKey(k)
383 case loginStepWaiting:
384 return d.handleWaitingKey(k)
385 case loginStepPasteCode:
386 return d.handlePasteCodeKey(k)
387 case loginStepInfo:
388 d.Close()
389 return loginDialogAction{Close: true}
390 case loginStepDone:
391 d.Close()
392 return loginDialogAction{Close: true}
393 }
394 return loginDialogAction{}
395}
396
397func (d *loginDialog) handleMethodKey(k tui.Key) loginDialogAction {
398 max := 2
399 switch k.Kind {
400 case tui.KeyUp:
401 if d.cursor > 0 {
402 d.cursor--
403 }
404 case tui.KeyDown:
405 if d.cursor < max-1 {
406 d.cursor++
407 }
408 case tui.KeyEsc:
409 d.Close()
410 return loginDialogAction{Close: true}
411 case tui.KeyEnter:
412 if d.cursor == 0 {
413 d.method = "apikey"
414 } else {
415 d.method = "oauth"
416 }
417 d.step = loginStepProvider
418 d.cursor = 0
419 }
420 return loginDialogAction{}
421}
422
423func (d *loginDialog) handleProviderKey(k tui.Key) loginDialogAction {
424 switch k.Kind {
425 case tui.KeyUp:
426 if d.cursor > 0 {
427 d.cursor--
428 }
429 case tui.KeyDown:
430 providers := providersForMethod(d.method)
431 if d.cursor < len(providers)-1 {
432 d.cursor++
433 }
434 case tui.KeyPageUp:
435 d.cursor -= loginProviderPageSize
436 if d.cursor < 0 {
437 d.cursor = 0
438 }
439 case tui.KeyPageDown:
440 providers := providersForMethod(d.method)
441 d.cursor += loginProviderPageSize
442 if d.cursor >= len(providers) {
443 d.cursor = len(providers) - 1
444 }
445 case tui.KeyEsc:
446 d.Close()
447 return loginDialogAction{Close: true}
448 case tui.KeyEnter:
449 providers := providersForMethod(d.method)
450 if d.cursor < 0 || d.cursor >= len(providers) {
451 return loginDialogAction{}
452 }
453 d.provider = providers[d.cursor]
454 d.step = loginStepWaiting
455 if d.method == "apikey" {
456 return loginDialogAction{StartAPIKey: true, Provider: d.provider}
457 }
458 return loginDialogAction{StartOAuth: true, Provider: d.provider}
459 }
460 return loginDialogAction{}
461}
462
463// ShowWaiting transitions to the waiting state with the given URL.
464// No-op if the user has already dismissed the dialog.
465func (d *loginDialog) ShowWaiting(url string) {
466 if d.step == loginStepClosed {
467 return
468 }
469 d.step = loginStepWaiting
470 d.url = url
471}
472
473// ShowInfo transitions to an informational setup dialog.
474// No-op if the user has already dismissed the dialog.
475func (d *loginDialog) ShowInfo(title string, lines []string) {
476 if d.step == loginStepClosed {
477 return
478 }
479 d.step = loginStepInfo
480 d.infoTitle = title
481 d.infoLines = lines
482}
483
484// ShowResult transitions to the done state with the given outcome.
485// No-op if the user has already dismissed the dialog.
486func (d *loginDialog) ShowResult(success bool, message string) {
487 if d.step == loginStepClosed {
488 return
489 }
490 d.step = loginStepDone
491 d.success = success
492 d.message = message
493}
494
495func (d *loginDialog) handleWaitingKey(k tui.Key) loginDialogAction {
496 if k.Kind == tui.KeyEsc {
497 d.Close()
498 return loginDialogAction{Close: true}
499 }
500 if d.codeEd == nil {
501 return loginDialogAction{}
502 }
503 if submit := d.codeEd.HandleKey(k); submit {
504 code := d.codeEd.SubmitValue()
505 d.codeEd.Clear()
506 return loginDialogAction{SubmitCode: code}
507 }
508 return loginDialogAction{}
509}
510
511func (d *loginDialog) handlePasteCodeKey(k tui.Key) loginDialogAction {
512 if k.Kind == tui.KeyEsc {
513 d.Close()
514 return loginDialogAction{Close: true}
515 }
516 if d.codeEd == nil {
517 return loginDialogAction{}
518 }
519 if submit := d.codeEd.HandleKey(k); submit {
520 code := d.codeEd.SubmitValue()
521 d.codeEd.Clear()
522 return loginDialogAction{SubmitCode: code}
523 }
524 return loginDialogAction{}
525}
526
527// CursorPos returns the absolute row/col inside the dialog where the
528// terminal cursor should sit (paste-code step). Returns -1, -1 if the
529// dialog is not in an input-expecting state. The host uses this to
530// place the real blinking cursor on the code input.
531func (d *loginDialog) CursorPos(width int) (row, col int) {
532 if d.codeEd == nil {
533 return -1, -1
534 }
535 if d.step != loginStepPasteCode && d.step != loginStepWaiting {
536 return -1, -1
537 }
538 _, eRow, eCol := d.codeEd.Render(width - 2)
539 wrapW := width - 2
540 if wrapW < 20 {
541 wrapW = 20
542 }
543 urlLines := len(tui.WrapANSILine(d.url, wrapW))
544 // interactive.redraw wraps dialog output with padDialogFrame, which
545 // injects a blank row after the frame header. Count that row here so
546 // the real terminal cursor lands on the editor input instead of the
547 // prompt above it.
548 baseOffset := 1 /*frameHeader*/ + 1 /*padDialogFrame blank*/ + 1 /*hint*/ + urlLines + 1 /*blank*/ + 1 /*prompt*/
549 return baseOffset + eRow, eCol
550}
551
552func max0(x int) int {
553 if x < 0 {
554 return 0
555 }
556 return x
557}