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