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 providerName := providerLabel(o)
153 if d.method == "oauth" && o == "xai" {
154 providerName = "Sign in with SuperGrok or X Premium"
155 }
156 label := " " + providerName
157 plain := label + tag
158 if i == d.cursor {
159 lines = append(lines, th.PadHighlight(plain, width))
160 } else {
161 lines = append(lines, th.FG256(th.Muted, label)+th.FG256(th.Accent, tag))
162 }
163 }
164 if len(opts) > loginProviderPageSize {
165 lines = append(lines, th.FG256(th.Muted, fmt.Sprintf(" (%d/%d)", d.cursor+1, len(opts))))
166 }
167 lines = append(lines, frameRule(th, width))
168 case loginStepWaiting:
169 lines = append(lines, frameHeader(th, "login - "+d.method+" - "+providerLabel(d.provider), width))
170 lines = append(lines, th.FG256(th.Muted, "open this URL in a browser:"))
171 wrapW := width - 2
172 if wrapW < 20 {
173 wrapW = 20
174 }
175 for _, seg := range tui.WrapANSILine(d.url, wrapW) {
176 lines = append(lines, th.FG256(th.Accent, seg))
177 }
178 lines = append(lines, "")
179 if d.provider == "kimi" || d.provider == "xai" || d.provider == "github-copilot" {
180 lines = append(lines, th.FG256(th.Muted, "complete sign-in in the browser - esc cancels"))
181 lines = append(lines, frameRule(th, width))
182 break
183 }
184 lines = append(lines, th.FG256(th.Muted, "paste the authorization code (or full redirect URL / code#state):"))
185 if d.codeEd == nil {
186 d.codeEd = tui.NewEditor(th.AccentBar(th.Accent))
187 }
188 edLines, _, _ := d.codeEd.Render(width - 2)
189 for _, l := range edLines {
190 lines = append(lines, l)
191 }
192 lines = append(lines, "")
193 lines = append(lines, th.FG256(th.Muted, "enter submits - esc cancels - waiting for browser callback in background"))
194 lines = append(lines, frameRule(th, width))
195 case loginStepPasteCode:
196 lines = append(lines, frameHeader(th, "login - "+d.method+" - "+providerLabel(d.provider)+" - paste code", width))
197 lines = append(lines, th.FG256(th.Muted, "open this URL in any browser:"))
198 wrapW := width - 2
199 if wrapW < 20 {
200 wrapW = 20
201 }
202 for _, seg := range tui.WrapANSILine(d.url, wrapW) {
203 lines = append(lines, th.FG256(th.Accent, seg))
204 }
205 lines = append(lines, "")
206 lines = append(lines, th.FG256(th.Muted, "paste the authorization code (or full redirect URL / code#state):"))
207 if d.codeEd == nil {
208 d.codeEd = tui.NewEditor(th.AccentBar(th.Accent))
209 }
210 edLines, _, _ := d.codeEd.Render(width - 2)
211 for _, l := range edLines {
212 lines = append(lines, l)
213 }
214 lines = append(lines, "")
215 lines = append(lines, th.FG256(th.Muted, "enter submits - esc cancels"))
216 lines = append(lines, frameRule(th, width))
217 case loginStepInfo:
218 title := d.infoTitle
219 if title == "" {
220 title = "login - setup"
221 }
222 lines = append(lines, frameHeader(th, title, width))
223 for _, l := range d.infoLines {
224 lines = append(lines, l)
225 }
226 lines = append(lines, "")
227 lines = append(lines, th.FG256(th.Muted, "press any key to close"))
228 lines = append(lines, frameRule(th, width))
229 case loginStepDone:
230 title := "login - failed"
231 body := th.FG256(th.Error, d.message)
232 if d.success {
233 title = "login - success"
234 body = th.FG256(th.Tool, fmt.Sprintf("logged in to %s via %s", providerLabel(d.provider), d.method))
235 }
236 lines = append(lines, frameHeader(th, title, width))
237 lines = append(lines, body)
238 lines = append(lines, th.FG256(th.Muted, "press any key to close"))
239 lines = append(lines, frameRule(th, width))
240 }
241 return lines
242}
243
244// providersForMethod returns the providers offered for a given login
245// method. API-key is the universal path so it lists every provider
246// (including custom providers from models.json); subscription/OAuth
247// only lists providers that actually issue tokens usable against the
248// same API the model picker drives (Google's consumer Gemini Advanced
249// login does not, and DeepSeek has no subscription product at all).
250func providersForMethod(method string) []string {
251 var providers []string
252 if method == "oauth" {
253 providers = []string{"anthropic", "openai-codex", "kimi", "xai", "github-copilot"}
254 } else {
255 providers = auth.APIKeyProviders()
256 // Append custom providers from models.json so they appear
257 // in the /login picker alongside built-in providers. Dedupe in
258 // case models.json also adds metadata for a built-in provider.
259 seen := map[string]bool{}
260 for _, name := range providers {
261 seen[name] = true
262 }
263 for name := range provider.CustomProviders() {
264 if !seen[name] {
265 providers = append(providers, name)
266 seen[name] = true
267 }
268 }
269 }
270 sort.Slice(providers, func(a, b int) bool {
271 return providerLabel(providers[a]) < providerLabel(providers[b])
272 })
273 return providers
274}
275
276// providerLabel returns the user-facing label for a provider id.
277func providerLabel(id string) string { return provider.ProviderLabel(id) }
278
279func providerPickerTag(method, status string) string {
280 switch method {
281 case "apikey":
282 // In the API-key picker, only call out an existing subscription so
283 // users know choosing this provider will add/replace API-key auth
284 // while subscription auth is still configured. Unconfigured rows do
285 // not need a redundant "api key" suffix.
286 if status == "oauth" {
287 return " (subscription configured)"
288 }
289 case "oauth":
290 // In the subscription picker, only call out an existing API key.
291 if status == "apikey" {
292 return " (api key configured)"
293 }
294 }
295 return ""
296}
297
298func (d *loginDialog) providerPage(total int) (start, end int) {
299 if total <= loginProviderPageSize {
300 return 0, total
301 }
302 if d.cursor < 0 {
303 d.cursor = 0
304 }
305 if d.cursor >= total {
306 d.cursor = total - 1
307 }
308 start = (d.cursor / loginProviderPageSize) * loginProviderPageSize
309 end = start + loginProviderPageSize
310 if end > total {
311 end = total
312 }
313 return start, end
314}
315
316// renderStatusLines returns an overview of the current login
317// state for each provider, one row per provider, suitable to
318// insert between the frame header and the picker body. Logged-
319// in providers get a green checkmark in front; providers with
320// no credentials render as a muted dash so the list layout
321// stays aligned across first-run and re-login cases.
322//
323// Returns nil when neither provider is logged in (first-run
324// case — a pair of "not logged in" rows is just noise when the
325// user is about to pick a method anyway).
326func (d *loginDialog) renderStatusLines(th tui.Theme) []string {
327 anth := d.status["anthropic"]
328 op := d.status["openai"]
329 codex := d.status["openai-codex"]
330 kimi := d.status["kimi"]
331 ds := d.status["deepseek"]
332 goog := d.status["google"]
333 gh := d.status["github-copilot"]
334 if anth == "" && op == "" && codex == "" && kimi == "" && ds == "" && goog == "" && gh == "" {
335 return nil
336 }
337 row := func(id, method string) string {
338 label := providerLabel(id)
339 var mark, body string
340 switch method {
341 case "apikey":
342 mark = th.FG256(th.Tool, "✓")
343 body = th.FG256(th.Muted, label+": api key")
344 case "oauth":
345 mark = th.FG256(th.Tool, "✓")
346 body = th.FG256(th.Muted, label+": subscription")
347 default:
348 mark = th.FG256(th.Muted, "–")
349 body = th.FG256(th.Muted, label+": not logged in")
350 }
351 return " " + mark + " " + body
352 }
353 out := []string{
354 row("anthropic", anth),
355 row("openai", op),
356 row("openai-codex", codex),
357 row("kimi", kimi),
358 row("deepseek", ds),
359 row("google", goog),
360 row("github-copilot", gh),
361 }
362 for _, p := range providersForMethod("apikey") {
363 switch p {
364 case "anthropic", "openai", "openai-codex", "kimi", "deepseek", "google", "github-copilot":
365 continue
366 }
367 if method := d.status[p]; method != "" {
368 out = append(out, row(p, method))
369 }
370 }
371 out = append(out, "")
372 return out
373}
374
375// Key is the result of handling a key press.
376type loginDialogAction struct {
377 StartAPIKey bool
378 StartOAuth bool
379 StartManual bool
380 Provider string
381 Close bool
382 SubmitCode string
383}
384
385// HandleKey advances the dialog and returns an action to apply, if any.
386func (d *loginDialog) HandleKey(k tui.Key) loginDialogAction {
387 switch d.step {
388 case loginStepMethod:
389 return d.handleMethodKey(k)
390 case loginStepProvider:
391 return d.handleProviderKey(k)
392 case loginStepWaiting:
393 return d.handleWaitingKey(k)
394 case loginStepPasteCode:
395 return d.handlePasteCodeKey(k)
396 case loginStepInfo:
397 d.Close()
398 return loginDialogAction{Close: true}
399 case loginStepDone:
400 d.Close()
401 return loginDialogAction{Close: true}
402 }
403 return loginDialogAction{}
404}
405
406func (d *loginDialog) handleMethodKey(k tui.Key) loginDialogAction {
407 max := 2
408 switch k.Kind {
409 case tui.KeyUp:
410 if d.cursor > 0 {
411 d.cursor--
412 }
413 case tui.KeyDown:
414 if d.cursor < max-1 {
415 d.cursor++
416 }
417 case tui.KeyEsc:
418 d.Close()
419 return loginDialogAction{Close: true}
420 case tui.KeyEnter:
421 if d.cursor == 0 {
422 d.method = "apikey"
423 } else {
424 d.method = "oauth"
425 }
426 d.step = loginStepProvider
427 d.cursor = 0
428 }
429 return loginDialogAction{}
430}
431
432func (d *loginDialog) handleProviderKey(k tui.Key) loginDialogAction {
433 switch k.Kind {
434 case tui.KeyUp:
435 if d.cursor > 0 {
436 d.cursor--
437 }
438 case tui.KeyDown:
439 providers := providersForMethod(d.method)
440 if d.cursor < len(providers)-1 {
441 d.cursor++
442 }
443 case tui.KeyPageUp:
444 d.cursor -= loginProviderPageSize
445 if d.cursor < 0 {
446 d.cursor = 0
447 }
448 case tui.KeyPageDown:
449 providers := providersForMethod(d.method)
450 d.cursor += loginProviderPageSize
451 if d.cursor >= len(providers) {
452 d.cursor = len(providers) - 1
453 }
454 case tui.KeyEsc:
455 d.Close()
456 return loginDialogAction{Close: true}
457 case tui.KeyEnter:
458 providers := providersForMethod(d.method)
459 if d.cursor < 0 || d.cursor >= len(providers) {
460 return loginDialogAction{}
461 }
462 d.provider = providers[d.cursor]
463 d.step = loginStepWaiting
464 if d.method == "apikey" {
465 return loginDialogAction{StartAPIKey: true, Provider: d.provider}
466 }
467 return loginDialogAction{StartOAuth: true, Provider: d.provider}
468 }
469 return loginDialogAction{}
470}
471
472// ShowWaiting transitions to the waiting state with the given URL.
473// No-op if the user has already dismissed the dialog.
474func (d *loginDialog) ShowWaiting(url string) {
475 if d.step == loginStepClosed {
476 return
477 }
478 d.step = loginStepWaiting
479 d.url = url
480}
481
482// ShowInfo transitions to an informational setup dialog.
483// No-op if the user has already dismissed the dialog.
484func (d *loginDialog) ShowInfo(title string, lines []string) {
485 if d.step == loginStepClosed {
486 return
487 }
488 d.step = loginStepInfo
489 d.infoTitle = title
490 d.infoLines = lines
491}
492
493// ShowResult transitions to the done state with the given outcome.
494// No-op if the user has already dismissed the dialog.
495func (d *loginDialog) ShowResult(success bool, message string) {
496 if d.step == loginStepClosed {
497 return
498 }
499 d.step = loginStepDone
500 d.success = success
501 d.message = message
502}
503
504func (d *loginDialog) handleWaitingKey(k tui.Key) loginDialogAction {
505 if k.Kind == tui.KeyEsc {
506 d.Close()
507 return loginDialogAction{Close: true}
508 }
509 if d.codeEd == nil {
510 return loginDialogAction{}
511 }
512 if submit := d.codeEd.HandleKey(k); submit {
513 code := d.codeEd.SubmitValue()
514 d.codeEd.Clear()
515 return loginDialogAction{SubmitCode: code}
516 }
517 return loginDialogAction{}
518}
519
520func (d *loginDialog) handlePasteCodeKey(k tui.Key) loginDialogAction {
521 if k.Kind == tui.KeyEsc {
522 d.Close()
523 return loginDialogAction{Close: true}
524 }
525 if d.codeEd == nil {
526 return loginDialogAction{}
527 }
528 if submit := d.codeEd.HandleKey(k); submit {
529 code := d.codeEd.SubmitValue()
530 d.codeEd.Clear()
531 return loginDialogAction{SubmitCode: code}
532 }
533 return loginDialogAction{}
534}
535
536// CursorPos returns the absolute row/col inside the dialog where the
537// terminal cursor should sit (paste-code step). Returns -1, -1 if the
538// dialog is not in an input-expecting state. The host uses this to
539// place the real blinking cursor on the code input.
540func (d *loginDialog) CursorPos(width int) (row, col int) {
541 if d.codeEd == nil {
542 return -1, -1
543 }
544 if d.step != loginStepPasteCode && d.step != loginStepWaiting {
545 return -1, -1
546 }
547 _, eRow, eCol := d.codeEd.Render(width - 2)
548 wrapW := width - 2
549 if wrapW < 20 {
550 wrapW = 20
551 }
552 urlLines := len(tui.WrapANSILine(d.url, wrapW))
553 // interactive.redraw wraps dialog output with padDialogFrame, which
554 // injects a blank row after the frame header. Count that row here so
555 // the real terminal cursor lands on the editor input instead of the
556 // prompt above it.
557 baseOffset := 1 /*frameHeader*/ + 1 /*padDialogFrame blank*/ + 1 /*hint*/ + urlLines + 1 /*blank*/ + 1 /*prompt*/
558 return baseOffset + eRow, eCol
559}
560
561func max0(x int) int {
562 if x < 0 {
563 return 0
564 }
565 return x
566}