v0.1.88 14ffdb6

login_dialog.go RawHistoryFolder

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

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 476

↑↓ moveenter openesc close