v0.31.0 9467429

login_dialog.go RawHistoryFolder

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}

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