v0.48.1 deaab59

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

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