v0.4.3 2a19df1

login_dialog.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 477

↑↓ moveenter openesc close