v0.2.6 917da8c

login_dialog.go RawHistoryFolder

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

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