v0.3.29 f3d8eb6

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	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}

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