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