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