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// subscription/OAuth only lists providers that actually issue tokens
238// usable against the same API the model picker drives (Google's
239// consumer Gemini Advanced login does not, and DeepSeek has no
240// 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 }
248 sort.Slice(providers, func(a, b int) bool {
249 return providerLabel(providers[a]) < providerLabel(providers[b])
250 })
251 return providers
252}
253
254// providerLabel returns the user-facing label for a provider id.
255func providerLabel(id string) string { return provider.ProviderLabel(id) }
256
257func providerPickerTag(method, status string) string {
258 switch method {
259 case "apikey":
260 // In the API-key picker, only call out an existing subscription so
261 // users know choosing this provider will add/replace API-key auth
262 // while subscription auth is still configured. Unconfigured rows do
263 // not need a redundant "api key" suffix.
264 if status == "oauth" {
265 return " (subscription configured)"
266 }
267 case "oauth":
268 // In the subscription picker, only call out an existing API key.
269 if status == "apikey" {
270 return " (api key configured)"
271 }
272 }
273 return ""
274}
275
276func (d *loginDialog) providerPage(total int) (start, end int) {
277 if total <= loginProviderPageSize {
278 return 0, total
279 }
280 if d.cursor < 0 {
281 d.cursor = 0
282 }
283 if d.cursor >= total {
284 d.cursor = total - 1
285 }
286 start = (d.cursor / loginProviderPageSize) * loginProviderPageSize
287 end = start + loginProviderPageSize
288 if end > total {
289 end = total
290 }
291 return start, end
292}
293
294// renderStatusLines returns an overview of the current login
295// state for each provider, one row per provider, suitable to
296// insert between the frame header and the picker body. Logged-
297// in providers get a green checkmark in front; providers with
298// no credentials render as a muted dash so the list layout
299// stays aligned across first-run and re-login cases.
300//
301// Returns nil when neither provider is logged in (first-run
302// case — a pair of "not logged in" rows is just noise when the
303// user is about to pick a method anyway).
304func (d *loginDialog) renderStatusLines(th tui.Theme) []string {
305 anth := d.status["anthropic"]
306 op := d.status["openai"]
307 codex := d.status["openai-codex"]
308 kimi := d.status["kimi"]
309 ds := d.status["deepseek"]
310 goog := d.status["google"]
311 gh := d.status["github-copilot"]
312 if anth == "" && op == "" && codex == "" && kimi == "" && ds == "" && goog == "" && gh == "" {
313 return nil
314 }
315 row := func(id, method string) string {
316 label := providerLabel(id)
317 var mark, body string
318 switch method {
319 case "apikey":
320 mark = th.FG256(th.Tool, "✓")
321 body = th.FG256(th.Muted, label+": api key")
322 case "oauth":
323 mark = th.FG256(th.Tool, "✓")
324 body = th.FG256(th.Muted, label+": subscription")
325 default:
326 mark = th.FG256(th.Muted, "–")
327 body = th.FG256(th.Muted, label+": not logged in")
328 }
329 return " " + mark + " " + body
330 }
331 out := []string{
332 row("anthropic", anth),
333 row("openai", op),
334 row("openai-codex", codex),
335 row("kimi", kimi),
336 row("deepseek", ds),
337 row("google", goog),
338 row("github-copilot", gh),
339 }
340 for _, p := range providersForMethod("apikey") {
341 switch p {
342 case "anthropic", "openai", "openai-codex", "kimi", "deepseek", "google", "github-copilot":
343 continue
344 }
345 if method := d.status[p]; method != "" {
346 out = append(out, row(p, method))
347 }
348 }
349 out = append(out, "")
350 return out
351}
352
353// Key is the result of handling a key press.
354type loginDialogAction struct {
355 StartAPIKey bool
356 StartOAuth bool
357 StartManual bool
358 Provider string
359 Close bool
360 SubmitCode string
361}
362
363// HandleKey advances the dialog and returns an action to apply, if any.
364func (d *loginDialog) HandleKey(k tui.Key) loginDialogAction {
365 switch d.step {
366 case loginStepMethod:
367 return d.handleMethodKey(k)
368 case loginStepProvider:
369 return d.handleProviderKey(k)
370 case loginStepWaiting:
371 return d.handleWaitingKey(k)
372 case loginStepPasteCode:
373 return d.handlePasteCodeKey(k)
374 case loginStepInfo:
375 d.Close()
376 return loginDialogAction{Close: true}
377 case loginStepDone:
378 d.Close()
379 return loginDialogAction{Close: true}
380 }
381 return loginDialogAction{}
382}
383
384func (d *loginDialog) handleMethodKey(k tui.Key) loginDialogAction {
385 max := 2
386 switch k.Kind {
387 case tui.KeyUp:
388 if d.cursor > 0 {
389 d.cursor--
390 }
391 case tui.KeyDown:
392 if d.cursor < max-1 {
393 d.cursor++
394 }
395 case tui.KeyEsc:
396 d.Close()
397 return loginDialogAction{Close: true}
398 case tui.KeyEnter:
399 if d.cursor == 0 {
400 d.method = "apikey"
401 } else {
402 d.method = "oauth"
403 }
404 d.step = loginStepProvider
405 d.cursor = 0
406 }
407 return loginDialogAction{}
408}
409
410func (d *loginDialog) handleProviderKey(k tui.Key) loginDialogAction {
411 switch k.Kind {
412 case tui.KeyUp:
413 if d.cursor > 0 {
414 d.cursor--
415 }
416 case tui.KeyDown:
417 providers := providersForMethod(d.method)
418 if d.cursor < len(providers)-1 {
419 d.cursor++
420 }
421 case tui.KeyPageUp:
422 d.cursor -= loginProviderPageSize
423 if d.cursor < 0 {
424 d.cursor = 0
425 }
426 case tui.KeyPageDown:
427 providers := providersForMethod(d.method)
428 d.cursor += loginProviderPageSize
429 if d.cursor >= len(providers) {
430 d.cursor = len(providers) - 1
431 }
432 case tui.KeyEsc:
433 d.Close()
434 return loginDialogAction{Close: true}
435 case tui.KeyEnter:
436 providers := providersForMethod(d.method)
437 if d.cursor < 0 || d.cursor >= len(providers) {
438 return loginDialogAction{}
439 }
440 d.provider = providers[d.cursor]
441 d.step = loginStepWaiting
442 if d.method == "apikey" {
443 return loginDialogAction{StartAPIKey: true, Provider: d.provider}
444 }
445 return loginDialogAction{StartOAuth: true, Provider: d.provider}
446 }
447 return loginDialogAction{}
448}
449
450// ShowWaiting transitions to the waiting state with the given URL.
451// No-op if the user has already dismissed the dialog.
452func (d *loginDialog) ShowWaiting(url string) {
453 if d.step == loginStepClosed {
454 return
455 }
456 d.step = loginStepWaiting
457 d.url = url
458}
459
460// ShowInfo transitions to an informational setup dialog.
461// No-op if the user has already dismissed the dialog.
462func (d *loginDialog) ShowInfo(title string, lines []string) {
463 if d.step == loginStepClosed {
464 return
465 }
466 d.step = loginStepInfo
467 d.infoTitle = title
468 d.infoLines = lines
469}
470
471// ShowResult transitions to the done state with the given outcome.
472// No-op if the user has already dismissed the dialog.
473func (d *loginDialog) ShowResult(success bool, message string) {
474 if d.step == loginStepClosed {
475 return
476 }
477 d.step = loginStepDone
478 d.success = success
479 d.message = message
480}
481
482func (d *loginDialog) handleWaitingKey(k tui.Key) loginDialogAction {
483 if k.Kind == tui.KeyEsc {
484 d.Close()
485 return loginDialogAction{Close: true}
486 }
487 if d.codeEd == nil {
488 return loginDialogAction{}
489 }
490 if submit := d.codeEd.HandleKey(k); submit {
491 code := d.codeEd.SubmitValue()
492 d.codeEd.Clear()
493 return loginDialogAction{SubmitCode: code}
494 }
495 return loginDialogAction{}
496}
497
498func (d *loginDialog) handlePasteCodeKey(k tui.Key) loginDialogAction {
499 if k.Kind == tui.KeyEsc {
500 d.Close()
501 return loginDialogAction{Close: true}
502 }
503 if d.codeEd == nil {
504 return loginDialogAction{}
505 }
506 if submit := d.codeEd.HandleKey(k); submit {
507 code := d.codeEd.SubmitValue()
508 d.codeEd.Clear()
509 return loginDialogAction{SubmitCode: code}
510 }
511 return loginDialogAction{}
512}
513
514// CursorPos returns the absolute row/col inside the dialog where the
515// terminal cursor should sit (paste-code step). Returns -1, -1 if the
516// dialog is not in an input-expecting state. The host uses this to
517// place the real blinking cursor on the code input.
518func (d *loginDialog) CursorPos(width int) (row, col int) {
519 if d.codeEd == nil {
520 return -1, -1
521 }
522 if d.step != loginStepPasteCode && d.step != loginStepWaiting {
523 return -1, -1
524 }
525 _, eRow, eCol := d.codeEd.Render(width - 2)
526 wrapW := width - 2
527 if wrapW < 20 {
528 wrapW = 20
529 }
530 urlLines := len(tui.WrapANSILine(d.url, wrapW))
531 // interactive.redraw wraps dialog output with padDialogFrame, which
532 // injects a blank row after the frame header. Count that row here so
533 // the real terminal cursor lands on the editor input instead of the
534 // prompt above it.
535 baseOffset := 1 /*frameHeader*/ + 1 /*padDialogFrame blank*/ + 1 /*hint*/ + urlLines + 1 /*blank*/ + 1 /*prompt*/
536 return baseOffset + eRow, eCol
537}
538
539func max0(x int) int {
540 if x < 0 {
541 return 0
542 }
543 return x
544}