v0.2.97 d25ea2b

login_dialog.go RawHistoryFolder

1package modes
2
3import (
4	"fmt"
5	"path/filepath"
6	"sort"
7
8	"github.com/patriceckhart/zot/packages/provider"
9	"github.com/patriceckhart/zot/packages/provider/auth"
10	"github.com/patriceckhart/zot/packages/tui"
11)
12
13// loginStep is the current node in the login dialog state machine.
14type loginStep int
15
16// loginStepClosed is the zero value on purpose: a freshly-constructed
17// dialog must default to closed so nothing shows up until Open() is
18// explicitly called.
19const (
20	loginStepClosed    loginStep = iota
21	loginStepMethod              // pick apikey vs subscription
22	loginStepProvider            // pick anthropic vs openai vs kimi
23	loginStepWaiting             // browser open, waiting for callback
24	loginStepPasteCode           // user pastes the auth code here
25	loginStepInfo                // informational setup guidance
26	loginStepDone                // success or error, waiting for key to dismiss
27)
28
29const loginProviderPageSize = 8
30
31// loginDialog is a tiny inline dialog rendered above the editor while
32// the user picks their login method and provider.
33type loginDialog struct {
34	step      loginStep
35	method    string // "apikey" | "oauth"
36	provider  string // "anthropic" | "openai" | "openai-codex" | "kimi" | "google"
37	message   string
38	success   bool
39	url       string
40	cursor    int
41	codeEd    *tui.Editor
42	infoTitle string
43	infoLines []string
44
45	// status is a snapshot of the current login state for each
46	// provider, captured when Open() runs. Rendered above the
47	// method picker so the user can see whether they're already
48	// logged in (and how) before starting a new flow. Keys:
49	// "anthropic", "openai", "openai-codex", "kimi", "google". Value is
50	// "apikey", "oauth", or "" (not logged in).
51	status map[string]string
52}
53
54func newLoginDialog() *loginDialog {
55	return &loginDialog{}
56}
57
58// Active reports whether the dialog consumes input.
59func (d *loginDialog) Active() bool { return d != nil && d.step != loginStepClosed }
60
61// Open starts the dialog from scratch and captures the current
62// login status for each provider so the picker can show it.
63// zotHome is the zot state directory ($ZOT_HOME); auth.json
64// lives inside it. Passing the path in (instead of importing
65// the agent package to call AuthPath()) avoids a cyclic import
66// between agent and agent/modes.
67func (d *loginDialog) Open(zotHome string) {
68	d.step = loginStepMethod
69	d.method = ""
70	d.provider = ""
71	d.message = ""
72	d.success = false
73	d.url = ""
74	d.cursor = 0
75	d.infoTitle = ""
76	d.infoLines = nil
77	d.status = map[string]string{}
78	for _, p := range providersForMethod("apikey") {
79		d.status[p] = ""
80	}
81	for _, p := range providersForMethod("oauth") {
82		d.status[p] = ""
83	}
84	// Best-effort: if the auth file can't be read, treat every
85	// provider as not-logged-in. The status line just won't show
86	// anything useful in that case, which is fine — the user
87	// was about to log in anyway.
88	path := filepath.Join(zotHome, "auth.json")
89	if creds, err := auth.NewStore(path).Load(); err == nil {
90		d.status["anthropic"] = creds.Method("anthropic")
91		d.status["openai"] = ""
92		if creds.OpenAI.APIKey != "" {
93			d.status["openai"] = "apikey"
94		}
95		d.status["openai-codex"] = ""
96		if creds.OpenAI.OAuth != nil {
97			d.status["openai-codex"] = "oauth"
98		}
99		d.status["kimi"] = creds.Method("kimi")
100		d.status["deepseek"] = creds.Method("deepseek")
101		d.status["google"] = creds.Method("google")
102		d.status["github-copilot"] = creds.Method("github-copilot")
103		for p := range creds.AdditionalAPIKeyCreds {
104			d.status[p] = creds.Method(p)
105		}
106	}
107}
108
109// Close hides the dialog.
110func (d *loginDialog) Close() {
111	d.step = loginStepClosed
112}
113
114// Render returns the dialog lines or nil when inactive.
115func (d *loginDialog) Render(th tui.Theme, width int) []string {
116	if !d.Active() {
117		return nil
118	}
119	var lines []string
120
121	switch d.step {
122	case loginStepMethod:
123		opts := []string{
124			"api key",
125			"subscription (claude pro/max - chatgpt plus/pro - chatgpt codex - kimi code - github copilot)",
126		}
127		lines = append(lines, frameHeader(th, "login", width))
128		for _, l := range d.renderStatusLines(th) {
129			lines = append(lines, l)
130		}
131		lines = append(lines, th.FG256(th.Muted, "choose login method (↑/↓, enter, esc to cancel):"))
132		for i, o := range opts {
133			plain := "  " + o
134			if i == d.cursor {
135				lines = append(lines, th.PadHighlight(plain, width))
136			} else {
137				lines = append(lines, th.FG256(th.Muted, plain))
138			}
139		}
140		lines = append(lines, frameRule(th, width))
141	case loginStepProvider:
142		opts := providersForMethod(d.method)
143		lines = append(lines, frameHeader(th, "login - "+d.method, width))
144		for _, l := range d.renderStatusLines(th) {
145			lines = append(lines, l)
146		}
147		lines = append(lines, th.FG256(th.Muted, "pick a provider (↑/↓, enter, esc to cancel)"))
148		start, end := d.providerPage(len(opts))
149		for i := start; i < end; i++ {
150			o := opts[i]
151			tag := providerPickerTag(d.method, d.status[o])
152			providerName := providerLabel(o)
153			if d.method == "oauth" && o == "xai" {
154				providerName = "Sign in with SuperGrok or X Premium"
155			}
156			label := "  " + providerName
157			plain := label + tag
158			if i == d.cursor {
159				lines = append(lines, th.PadHighlight(plain, width))
160			} else {
161				lines = append(lines, th.FG256(th.Muted, label)+th.FG256(th.Accent, tag))
162			}
163		}
164		if len(opts) > loginProviderPageSize {
165			lines = append(lines, th.FG256(th.Muted, fmt.Sprintf("  (%d/%d)", d.cursor+1, len(opts))))
166		}
167		lines = append(lines, frameRule(th, width))
168	case loginStepWaiting:
169		lines = append(lines, frameHeader(th, "login - "+d.method+" - "+providerLabel(d.provider), width))
170		lines = append(lines, th.FG256(th.Muted, "open this URL in a browser:"))
171		wrapW := width - 2
172		if wrapW < 20 {
173			wrapW = 20
174		}
175		for _, seg := range tui.WrapANSILine(d.url, wrapW) {
176			lines = append(lines, th.FG256(th.Accent, seg))
177		}
178		lines = append(lines, "")
179		if d.provider == "kimi" || d.provider == "xai" || d.provider == "github-copilot" {
180			lines = append(lines, th.FG256(th.Muted, "complete sign-in in the browser - esc cancels"))
181			lines = append(lines, frameRule(th, width))
182			break
183		}
184		lines = append(lines, th.FG256(th.Muted, "paste the authorization code (or full redirect URL / code#state):"))
185		if d.codeEd == nil {
186			d.codeEd = tui.NewEditor(th.AccentBar(th.Accent))
187		}
188		edLines, _, _ := d.codeEd.Render(width - 2)
189		for _, l := range edLines {
190			lines = append(lines, l)
191		}
192		lines = append(lines, "")
193		lines = append(lines, th.FG256(th.Muted, "enter submits - esc cancels - waiting for browser callback in background"))
194		lines = append(lines, frameRule(th, width))
195	case loginStepPasteCode:
196		lines = append(lines, frameHeader(th, "login - "+d.method+" - "+providerLabel(d.provider)+" - paste code", width))
197		lines = append(lines, th.FG256(th.Muted, "open this URL in any browser:"))
198		wrapW := width - 2
199		if wrapW < 20 {
200			wrapW = 20
201		}
202		for _, seg := range tui.WrapANSILine(d.url, wrapW) {
203			lines = append(lines, th.FG256(th.Accent, seg))
204		}
205		lines = append(lines, "")
206		lines = append(lines, th.FG256(th.Muted, "paste the authorization code (or full redirect URL / code#state):"))
207		if d.codeEd == nil {
208			d.codeEd = tui.NewEditor(th.AccentBar(th.Accent))
209		}
210		edLines, _, _ := d.codeEd.Render(width - 2)
211		for _, l := range edLines {
212			lines = append(lines, l)
213		}
214		lines = append(lines, "")
215		lines = append(lines, th.FG256(th.Muted, "enter submits - esc cancels"))
216		lines = append(lines, frameRule(th, width))
217	case loginStepInfo:
218		title := d.infoTitle
219		if title == "" {
220			title = "login - setup"
221		}
222		lines = append(lines, frameHeader(th, title, width))
223		for _, l := range d.infoLines {
224			lines = append(lines, l)
225		}
226		lines = append(lines, "")
227		lines = append(lines, th.FG256(th.Muted, "press any key to close"))
228		lines = append(lines, frameRule(th, width))
229	case loginStepDone:
230		title := "login - failed"
231		body := th.FG256(th.Error, d.message)
232		if d.success {
233			title = "login - success"
234			body = th.FG256(th.Tool, fmt.Sprintf("logged in to %s via %s", providerLabel(d.provider), d.method))
235		}
236		lines = append(lines, frameHeader(th, title, width))
237		lines = append(lines, body)
238		lines = append(lines, th.FG256(th.Muted, "press any key to close"))
239		lines = append(lines, frameRule(th, width))
240	}
241	return lines
242}
243
244// providersForMethod returns the providers offered for a given login
245// method. API-key is the universal path so it lists every provider
246// (including custom providers from models.json); subscription/OAuth
247// only lists providers that actually issue tokens usable against the
248// same API the model picker drives (Google's consumer Gemini Advanced
249// login does not, and DeepSeek has no subscription product at all).
250func providersForMethod(method string) []string {
251	var providers []string
252	if method == "oauth" {
253		providers = []string{"anthropic", "openai-codex", "kimi", "xai", "github-copilot"}
254	} else {
255		providers = auth.APIKeyProviders()
256		// Append custom providers from models.json so they appear
257		// in the /login picker alongside built-in providers. Dedupe in
258		// case models.json also adds metadata for a built-in provider.
259		seen := map[string]bool{}
260		for _, name := range providers {
261			seen[name] = true
262		}
263		for name := range provider.CustomProviders() {
264			if !seen[name] {
265				providers = append(providers, name)
266				seen[name] = true
267			}
268		}
269	}
270	sort.Slice(providers, func(a, b int) bool {
271		return providerLabel(providers[a]) < providerLabel(providers[b])
272	})
273	return providers
274}
275
276// providerLabel returns the user-facing label for a provider id.
277func providerLabel(id string) string { return provider.ProviderLabel(id) }
278
279func providerPickerTag(method, status string) string {
280	switch method {
281	case "apikey":
282		// In the API-key picker, only call out an existing subscription so
283		// users know choosing this provider will add/replace API-key auth
284		// while subscription auth is still configured. Unconfigured rows do
285		// not need a redundant "api key" suffix.
286		if status == "oauth" {
287			return "  (subscription configured)"
288		}
289	case "oauth":
290		// In the subscription picker, only call out an existing API key.
291		if status == "apikey" {
292			return "  (api key configured)"
293		}
294	}
295	return ""
296}
297
298func (d *loginDialog) providerPage(total int) (start, end int) {
299	if total <= loginProviderPageSize {
300		return 0, total
301	}
302	if d.cursor < 0 {
303		d.cursor = 0
304	}
305	if d.cursor >= total {
306		d.cursor = total - 1
307	}
308	start = (d.cursor / loginProviderPageSize) * loginProviderPageSize
309	end = start + loginProviderPageSize
310	if end > total {
311		end = total
312	}
313	return start, end
314}
315
316// renderStatusLines returns an overview of the current login
317// state for each provider, one row per provider, suitable to
318// insert between the frame header and the picker body. Logged-
319// in providers get a green checkmark in front; providers with
320// no credentials render as a muted dash so the list layout
321// stays aligned across first-run and re-login cases.
322//
323// Returns nil when neither provider is logged in (first-run
324// case — a pair of "not logged in" rows is just noise when the
325// user is about to pick a method anyway).
326func (d *loginDialog) renderStatusLines(th tui.Theme) []string {
327	anth := d.status["anthropic"]
328	op := d.status["openai"]
329	codex := d.status["openai-codex"]
330	kimi := d.status["kimi"]
331	ds := d.status["deepseek"]
332	goog := d.status["google"]
333	gh := d.status["github-copilot"]
334	if anth == "" && op == "" && codex == "" && kimi == "" && ds == "" && goog == "" && gh == "" {
335		return nil
336	}
337	row := func(id, method string) string {
338		label := providerLabel(id)
339		var mark, body string
340		switch method {
341		case "apikey":
342			mark = th.FG256(th.Tool, "✓")
343			body = th.FG256(th.Muted, label+": api key")
344		case "oauth":
345			mark = th.FG256(th.Tool, "✓")
346			body = th.FG256(th.Muted, label+": subscription")
347		default:
348			mark = th.FG256(th.Muted, "–")
349			body = th.FG256(th.Muted, label+": not logged in")
350		}
351		return "  " + mark + " " + body
352	}
353	out := []string{
354		row("anthropic", anth),
355		row("openai", op),
356		row("openai-codex", codex),
357		row("kimi", kimi),
358		row("deepseek", ds),
359		row("google", goog),
360		row("github-copilot", gh),
361	}
362	for _, p := range providersForMethod("apikey") {
363		switch p {
364		case "anthropic", "openai", "openai-codex", "kimi", "deepseek", "google", "github-copilot":
365			continue
366		}
367		if method := d.status[p]; method != "" {
368			out = append(out, row(p, method))
369		}
370	}
371	out = append(out, "")
372	return out
373}
374
375// Key is the result of handling a key press.
376type loginDialogAction struct {
377	StartAPIKey bool
378	StartOAuth  bool
379	StartManual bool
380	Provider    string
381	Close       bool
382	SubmitCode  string
383}
384
385// HandleKey advances the dialog and returns an action to apply, if any.
386func (d *loginDialog) HandleKey(k tui.Key) loginDialogAction {
387	switch d.step {
388	case loginStepMethod:
389		return d.handleMethodKey(k)
390	case loginStepProvider:
391		return d.handleProviderKey(k)
392	case loginStepWaiting:
393		return d.handleWaitingKey(k)
394	case loginStepPasteCode:
395		return d.handlePasteCodeKey(k)
396	case loginStepInfo:
397		d.Close()
398		return loginDialogAction{Close: true}
399	case loginStepDone:
400		d.Close()
401		return loginDialogAction{Close: true}
402	}
403	return loginDialogAction{}
404}
405
406func (d *loginDialog) handleMethodKey(k tui.Key) loginDialogAction {
407	max := 2
408	switch k.Kind {
409	case tui.KeyUp:
410		if d.cursor > 0 {
411			d.cursor--
412		}
413	case tui.KeyDown:
414		if d.cursor < max-1 {
415			d.cursor++
416		}
417	case tui.KeyEsc:
418		d.Close()
419		return loginDialogAction{Close: true}
420	case tui.KeyEnter:
421		if d.cursor == 0 {
422			d.method = "apikey"
423		} else {
424			d.method = "oauth"
425		}
426		d.step = loginStepProvider
427		d.cursor = 0
428	}
429	return loginDialogAction{}
430}
431
432func (d *loginDialog) handleProviderKey(k tui.Key) loginDialogAction {
433	switch k.Kind {
434	case tui.KeyUp:
435		if d.cursor > 0 {
436			d.cursor--
437		}
438	case tui.KeyDown:
439		providers := providersForMethod(d.method)
440		if d.cursor < len(providers)-1 {
441			d.cursor++
442		}
443	case tui.KeyPageUp:
444		d.cursor -= loginProviderPageSize
445		if d.cursor < 0 {
446			d.cursor = 0
447		}
448	case tui.KeyPageDown:
449		providers := providersForMethod(d.method)
450		d.cursor += loginProviderPageSize
451		if d.cursor >= len(providers) {
452			d.cursor = len(providers) - 1
453		}
454	case tui.KeyEsc:
455		d.Close()
456		return loginDialogAction{Close: true}
457	case tui.KeyEnter:
458		providers := providersForMethod(d.method)
459		if d.cursor < 0 || d.cursor >= len(providers) {
460			return loginDialogAction{}
461		}
462		d.provider = providers[d.cursor]
463		d.step = loginStepWaiting
464		if d.method == "apikey" {
465			return loginDialogAction{StartAPIKey: true, Provider: d.provider}
466		}
467		return loginDialogAction{StartOAuth: true, Provider: d.provider}
468	}
469	return loginDialogAction{}
470}
471
472// ShowWaiting transitions to the waiting state with the given URL.
473// No-op if the user has already dismissed the dialog.
474func (d *loginDialog) ShowWaiting(url string) {
475	if d.step == loginStepClosed {
476		return
477	}
478	d.step = loginStepWaiting
479	d.url = url
480}
481
482// ShowInfo transitions to an informational setup dialog.
483// No-op if the user has already dismissed the dialog.
484func (d *loginDialog) ShowInfo(title string, lines []string) {
485	if d.step == loginStepClosed {
486		return
487	}
488	d.step = loginStepInfo
489	d.infoTitle = title
490	d.infoLines = lines
491}
492
493// ShowResult transitions to the done state with the given outcome.
494// No-op if the user has already dismissed the dialog.
495func (d *loginDialog) ShowResult(success bool, message string) {
496	if d.step == loginStepClosed {
497		return
498	}
499	d.step = loginStepDone
500	d.success = success
501	d.message = message
502}
503
504func (d *loginDialog) handleWaitingKey(k tui.Key) loginDialogAction {
505	if k.Kind == tui.KeyEsc {
506		d.Close()
507		return loginDialogAction{Close: true}
508	}
509	if d.codeEd == nil {
510		return loginDialogAction{}
511	}
512	if submit := d.codeEd.HandleKey(k); submit {
513		code := d.codeEd.SubmitValue()
514		d.codeEd.Clear()
515		return loginDialogAction{SubmitCode: code}
516	}
517	return loginDialogAction{}
518}
519
520func (d *loginDialog) handlePasteCodeKey(k tui.Key) loginDialogAction {
521	if k.Kind == tui.KeyEsc {
522		d.Close()
523		return loginDialogAction{Close: true}
524	}
525	if d.codeEd == nil {
526		return loginDialogAction{}
527	}
528	if submit := d.codeEd.HandleKey(k); submit {
529		code := d.codeEd.SubmitValue()
530		d.codeEd.Clear()
531		return loginDialogAction{SubmitCode: code}
532	}
533	return loginDialogAction{}
534}
535
536// CursorPos returns the absolute row/col inside the dialog where the
537// terminal cursor should sit (paste-code step). Returns -1, -1 if the
538// dialog is not in an input-expecting state. The host uses this to
539// place the real blinking cursor on the code input.
540func (d *loginDialog) CursorPos(width int) (row, col int) {
541	if d.codeEd == nil {
542		return -1, -1
543	}
544	if d.step != loginStepPasteCode && d.step != loginStepWaiting {
545		return -1, -1
546	}
547	_, eRow, eCol := d.codeEd.Render(width - 2)
548	wrapW := width - 2
549	if wrapW < 20 {
550		wrapW = 20
551	}
552	urlLines := len(tui.WrapANSILine(d.url, wrapW))
553	// interactive.redraw wraps dialog output with padDialogFrame, which
554	// injects a blank row after the frame header. Count that row here so
555	// the real terminal cursor lands on the editor input instead of the
556	// prompt above it.
557	baseOffset := 1 /*frameHeader*/ + 1 /*padDialogFrame blank*/ + 1 /*hint*/ + urlLines + 1 /*blank*/ + 1 /*prompt*/
558	return baseOffset + eRow, eCol
559}
560
561func max0(x int) int {
562	if x < 0 {
563		return 0
564	}
565	return x
566}

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