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