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