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, "alt+c copies URL - 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, "alt+c copies URL - 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, "alt+c copies URL - 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 CopyURL bool
432}
433
434// HandleKey advances the dialog and returns an action to apply, if any.
435func (d *loginDialog) HandleKey(k tui.Key) loginDialogAction {
436 switch d.step {
437 case loginStepMethod:
438 return d.handleMethodKey(k)
439 case loginStepProvider:
440 return d.handleProviderKey(k)
441 case loginStepLlamaURL, loginStepLlamaKey:
442 return d.handleLlamaKey(k)
443 case loginStepWaiting:
444 return d.handleWaitingKey(k)
445 case loginStepPasteCode:
446 return d.handlePasteCodeKey(k)
447 case loginStepInfo:
448 d.Close()
449 return loginDialogAction{Close: true}
450 case loginStepDone:
451 d.Close()
452 return loginDialogAction{Close: true}
453 }
454 return loginDialogAction{}
455}
456
457func (d *loginDialog) handleMethodKey(k tui.Key) loginDialogAction {
458 max := 2
459 switch k.Kind {
460 case tui.KeyUp:
461 if d.cursor > 0 {
462 d.cursor--
463 }
464 case tui.KeyDown:
465 if d.cursor < max-1 {
466 d.cursor++
467 }
468 case tui.KeyEsc:
469 d.Close()
470 return loginDialogAction{Close: true}
471 case tui.KeyEnter:
472 if d.cursor == 0 {
473 d.method = "apikey"
474 } else {
475 d.method = "oauth"
476 }
477 d.step = loginStepProvider
478 d.cursor = 0
479 d.providerQuery = ""
480 }
481 return loginDialogAction{}
482}
483
484func (d *loginDialog) handleProviderKey(k tui.Key) loginDialogAction {
485 switch k.Kind {
486 case tui.KeyUp:
487 if d.cursor > 0 {
488 d.cursor--
489 }
490 case tui.KeyDown:
491 providers := d.providerOptions()
492 if d.cursor < len(providers)-1 {
493 d.cursor++
494 }
495 case tui.KeyPageUp:
496 d.cursor -= loginProviderPageSize
497 if d.cursor < 0 {
498 d.cursor = 0
499 }
500 case tui.KeyPageDown:
501 providers := d.providerOptions()
502 if len(providers) > 0 {
503 d.cursor += loginProviderPageSize
504 if d.cursor >= len(providers) {
505 d.cursor = len(providers) - 1
506 }
507 }
508 case tui.KeyBackspace:
509 if len(d.providerQuery) > 0 {
510 runes := []rune(d.providerQuery)
511 d.providerQuery = string(runes[:len(runes)-1])
512 d.cursor = 0
513 }
514 case tui.KeyPaste:
515 d.providerQuery += singleLinePaste(k.Paste)
516 d.cursor = 0
517 case tui.KeyRune:
518 if !k.Alt && !k.Ctrl && k.Rune >= 0x20 && k.Rune < 0x7f {
519 d.providerQuery += string(k.Rune)
520 d.cursor = 0
521 }
522 case tui.KeyEsc:
523 d.Close()
524 return loginDialogAction{Close: true}
525 case tui.KeyEnter:
526 providers := d.providerOptions()
527 if d.cursor < 0 || d.cursor >= len(providers) {
528 return loginDialogAction{}
529 }
530 d.provider = providers[d.cursor]
531 if d.method == "apikey" && d.provider == provider.LlamaCPPProviderID {
532 d.step = loginStepLlamaURL
533 d.llamaEd = nil
534 d.message = ""
535 return loginDialogAction{}
536 }
537 d.step = loginStepWaiting
538 if d.method == "apikey" {
539 return loginDialogAction{StartAPIKey: true, Provider: d.provider}
540 }
541 return loginDialogAction{StartOAuth: true, Provider: d.provider}
542 }
543 return loginDialogAction{}
544}
545
546func (d *loginDialog) handleLlamaKey(k tui.Key) loginDialogAction {
547 if k.Kind == tui.KeyEsc {
548 d.Close()
549 return loginDialogAction{Close: true}
550 }
551 if d.llamaEd == nil {
552 d.llamaEd = tui.NewEditor("")
553 }
554 if !d.llamaEd.HandleKey(k) {
555 return loginDialogAction{}
556 }
557 value := strings.TrimSpace(d.llamaEd.SubmitValue())
558 d.llamaEd.Clear()
559 if d.step == loginStepLlamaURL {
560 normalized, err := provider.NormalizeLlamaCPPURL(value)
561 if err != nil {
562 d.message = err.Error()
563 return loginDialogAction{}
564 }
565 d.llamaURL = normalized
566 d.message = ""
567 d.llamaEd.Mask = true
568 d.step = loginStepLlamaKey
569 return loginDialogAction{}
570 }
571 return loginDialogAction{
572 SaveLlama: true, Provider: provider.LlamaCPPProviderID,
573 LlamaURL: d.llamaURL, LlamaAPIKey: value,
574 }
575}
576
577// ShowWaiting transitions to the waiting state with the given URL.
578// No-op if the user has already dismissed the dialog.
579func (d *loginDialog) ShowWaiting(url string) {
580 if d.step == loginStepClosed {
581 return
582 }
583 d.step = loginStepWaiting
584 d.url = url
585}
586
587// ShowInfo transitions to an informational setup dialog.
588// No-op if the user has already dismissed the dialog.
589func (d *loginDialog) ShowInfo(title string, lines []string) {
590 if d.step == loginStepClosed {
591 return
592 }
593 d.step = loginStepInfo
594 d.infoTitle = title
595 d.infoLines = lines
596}
597
598// ShowResult transitions to the done state with the given outcome.
599// No-op if the user has already dismissed the dialog.
600func (d *loginDialog) ShowResult(success bool, message string) {
601 if d.step == loginStepClosed {
602 return
603 }
604 d.step = loginStepDone
605 d.success = success
606 d.message = message
607}
608
609func (d *loginDialog) handleWaitingKey(k tui.Key) loginDialogAction {
610 if k.Kind == tui.KeyEsc {
611 d.Close()
612 return loginDialogAction{Close: true}
613 }
614 if k.Kind == tui.KeyRune && k.Alt && !k.Ctrl && (k.Rune == 'c' || k.Rune == 'C') {
615 return loginDialogAction{CopyURL: true}
616 }
617 if d.codeEd == nil {
618 return loginDialogAction{}
619 }
620 if submit := d.codeEd.HandleKey(k); submit {
621 code := d.codeEd.SubmitValue()
622 d.codeEd.Clear()
623 return loginDialogAction{SubmitCode: code}
624 }
625 return loginDialogAction{}
626}
627
628func (d *loginDialog) handlePasteCodeKey(k tui.Key) loginDialogAction {
629 if k.Kind == tui.KeyEsc {
630 d.Close()
631 return loginDialogAction{Close: true}
632 }
633 if k.Kind == tui.KeyRune && k.Alt && !k.Ctrl && (k.Rune == 'c' || k.Rune == 'C') {
634 return loginDialogAction{CopyURL: true}
635 }
636 if d.codeEd == nil {
637 return loginDialogAction{}
638 }
639 if submit := d.codeEd.HandleKey(k); submit {
640 code := d.codeEd.SubmitValue()
641 d.codeEd.Clear()
642 return loginDialogAction{SubmitCode: code}
643 }
644 return loginDialogAction{}
645}
646
647// CursorPos returns the absolute row/col inside the dialog where the
648// terminal cursor should sit for code, URL, and API-key input. Returns -1,
649// -1 if the dialog is not in an input-expecting state. The host uses this to
650// place the real blinking cursor on the active input.
651func (d *loginDialog) CursorPos(width int) (row, col int) {
652 if d.step == loginStepLlamaURL || d.step == loginStepLlamaKey {
653 if d.llamaEd == nil {
654 return -1, -1
655 }
656 _, eRow, eCol := d.llamaEd.Render(width - 2)
657 baseOffset := 1 /*frameHeader*/ + 1 /*padDialogFrame blank*/ + 1 /*prompt*/
658 if d.step == loginStepLlamaKey {
659 baseOffset += 2 // router line + blank
660 }
661 return baseOffset + eRow, eCol
662 }
663 if d.codeEd == nil {
664 return -1, -1
665 }
666 if d.step != loginStepPasteCode && d.step != loginStepWaiting {
667 return -1, -1
668 }
669 _, eRow, eCol := d.codeEd.Render(width - 2)
670 wrapW := width - 2
671 if wrapW < 20 {
672 wrapW = 20
673 }
674 urlLines := len(tui.WrapANSILine(d.url, wrapW))
675 // interactive.redraw wraps dialog output with padDialogFrame, which
676 // injects a blank row after the frame header. Count that row here so
677 // the real terminal cursor lands on the editor input instead of the
678 // prompt above it.
679 baseOffset := 1 /*frameHeader*/ + 1 /*padDialogFrame blank*/ + 1 /*hint*/ + urlLines + 1 /*blank*/ + 1 /*prompt*/
680 return baseOffset + eRow, eCol
681}