v0.1.67 ef93175

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" | "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", "kimi", "google". Value is "apikey", "oauth", or ""
43	// (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": "", "kimi": "", "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"] = creds.Method("openai")
77		d.status["kimi"] = creds.Method("kimi")
78		d.status["google"] = creds.Method("google")
79	}
80}
81
82// Close hides the dialog.
83func (d *loginDialog) Close() {
84	d.step = loginStepClosed
85}
86
87// Render returns the dialog lines or nil when inactive.
88func (d *loginDialog) Render(th tui.Theme, width int) []string {
89	if !d.Active() {
90		return nil
91	}
92	var lines []string
93
94	switch d.step {
95	case loginStepMethod:
96		opts := []string{
97			"api key",
98			"subscription (claude pro/max - chatgpt plus/pro - kimi code)",
99		}
100		lines = append(lines, frameHeader(th, "login", width))
101		for _, l := range d.renderStatusLines(th) {
102			lines = append(lines, l)
103		}
104		lines = append(lines, th.FG256(th.Muted, "choose login method (↑/↓, enter, esc to cancel):"))
105		for i, o := range opts {
106			plain := "  " + o
107			if i == d.cursor {
108				lines = append(lines, th.PadHighlight(plain, width))
109			} else {
110				lines = append(lines, th.FG256(th.Muted, plain))
111			}
112		}
113		lines = append(lines, frameRule(th, width))
114	case loginStepProvider:
115		opts := []string{"anthropic", "openai", "kimi", "google"}
116		lines = append(lines, frameHeader(th, "login - "+d.method, width))
117		for _, l := range d.renderStatusLines(th) {
118			lines = append(lines, l)
119		}
120		lines = append(lines, th.FG256(th.Muted, "choose provider:"))
121		for i, o := range opts {
122			// Annotate each provider with its current login
123			// state so the user can see at a glance which will
124			// be replaced if they pick it.
125			tag := ""
126			switch d.status[o] {
127			case "apikey":
128				tag = "  (api key)"
129			case "oauth":
130				tag = "  (subscription)"
131			}
132			plain := "  " + providerLabel(o) + tag
133			if i == d.cursor {
134				lines = append(lines, th.PadHighlight(plain, width))
135			} else {
136				lines = append(lines, th.FG256(th.Muted, plain))
137			}
138		}
139		lines = append(lines, frameRule(th, width))
140	case loginStepWaiting:
141		lines = append(lines, frameHeader(th, "login - "+d.method+" - "+providerLabel(d.provider), width))
142		lines = append(lines, th.FG256(th.Muted, "open this URL in a browser:"))
143		wrapW := width - 2
144		if wrapW < 20 {
145			wrapW = 20
146		}
147		for _, seg := range tui.WrapANSILine(d.url, wrapW) {
148			lines = append(lines, th.FG256(th.Accent, seg))
149		}
150		lines = append(lines, "")
151		lines = append(lines, th.FG256(th.Muted, "paste the authorization code (or full redirect URL / code#state):"))
152		if d.codeEd == nil {
153			d.codeEd = tui.NewEditor(th.AccentBar(th.Accent))
154		}
155		edLines, _, _ := d.codeEd.Render(width - 2)
156		for _, l := range edLines {
157			lines = append(lines, l)
158		}
159		lines = append(lines, "")
160		lines = append(lines, th.FG256(th.Muted, "enter submits - esc cancels - waiting for browser callback in background"))
161		lines = append(lines, frameRule(th, width))
162	case loginStepPasteCode:
163		lines = append(lines, frameHeader(th, "login - "+d.method+" - "+providerLabel(d.provider)+" - paste code", width))
164		lines = append(lines, th.FG256(th.Muted, "open this URL in any browser:"))
165		wrapW := width - 2
166		if wrapW < 20 {
167			wrapW = 20
168		}
169		for _, seg := range tui.WrapANSILine(d.url, wrapW) {
170			lines = append(lines, th.FG256(th.Accent, seg))
171		}
172		lines = append(lines, "")
173		lines = append(lines, th.FG256(th.Muted, "paste the authorization code (or full redirect URL / code#state):"))
174		if d.codeEd == nil {
175			d.codeEd = tui.NewEditor(th.AccentBar(th.Accent))
176		}
177		edLines, _, _ := d.codeEd.Render(width - 2)
178		for _, l := range edLines {
179			lines = append(lines, l)
180		}
181		lines = append(lines, "")
182		lines = append(lines, th.FG256(th.Muted, "enter submits - esc cancels"))
183		lines = append(lines, frameRule(th, width))
184	case loginStepDone:
185		title := "login - failed"
186		body := th.FG256(th.Error, d.message)
187		if d.success {
188			title = "login - success"
189			body = th.FG256(th.Tool, fmt.Sprintf("logged in to %s via %s", providerLabel(d.provider), d.method))
190		}
191		lines = append(lines, frameHeader(th, title, width))
192		lines = append(lines, body)
193		lines = append(lines, th.FG256(th.Muted, "press any key to close"))
194		lines = append(lines, frameRule(th, width))
195	}
196	return lines
197}
198
199// providerLabel returns the user-facing label for a provider id.
200func providerLabel(id string) string {
201	switch id {
202	case "anthropic":
203		return "Anthropic (Claude Pro/Max)"
204	case "openai":
205		return "OpenAI (ChatGPT Plus/Pro)"
206	case "kimi":
207		return "Kimi Code"
208	case "google":
209		return "Google (Gemini API key)"
210	}
211	return id
212}
213
214// renderStatusLines returns an overview of the current login
215// state for each provider, one row per provider, suitable to
216// insert between the frame header and the picker body. Logged-
217// in providers get a green checkmark in front; providers with
218// no credentials render as a muted dash so the list layout
219// stays aligned across first-run and re-login cases.
220//
221// Returns nil when neither provider is logged in (first-run
222// case — a pair of "not logged in" rows is just noise when the
223// user is about to pick a method anyway).
224func (d *loginDialog) renderStatusLines(th tui.Theme) []string {
225	anth := d.status["anthropic"]
226	op := d.status["openai"]
227	kimi := d.status["kimi"]
228	goog := d.status["google"]
229	if anth == "" && op == "" && kimi == "" && goog == "" {
230		return nil
231	}
232	row := func(id, method string) string {
233		label := providerLabel(id)
234		var mark, body string
235		switch method {
236		case "apikey":
237			mark = th.FG256(th.Tool, "✓")
238			body = th.FG256(th.Muted, label+": api key")
239		case "oauth":
240			mark = th.FG256(th.Tool, "✓")
241			body = th.FG256(th.Muted, label+": subscription")
242		default:
243			mark = th.FG256(th.Muted, "–")
244			body = th.FG256(th.Muted, label+": not logged in")
245		}
246		return "  " + mark + " " + body
247	}
248	return []string{
249		row("anthropic", anth),
250		row("openai", op),
251		row("kimi", kimi),
252		row("google", goog),
253		"",
254	}
255}
256
257// Key is the result of handling a key press.
258type loginDialogAction struct {
259	StartAPIKey bool
260	StartOAuth  bool
261	StartManual bool
262	Provider    string
263	Close       bool
264	SubmitCode  string
265}
266
267// HandleKey advances the dialog and returns an action to apply, if any.
268func (d *loginDialog) HandleKey(k tui.Key) loginDialogAction {
269	switch d.step {
270	case loginStepMethod:
271		return d.handleMethodKey(k)
272	case loginStepProvider:
273		return d.handleProviderKey(k)
274	case loginStepWaiting:
275		return d.handleWaitingKey(k)
276	case loginStepPasteCode:
277		return d.handlePasteCodeKey(k)
278	case loginStepDone:
279		d.Close()
280		return loginDialogAction{Close: true}
281	}
282	return loginDialogAction{}
283}
284
285func (d *loginDialog) handleMethodKey(k tui.Key) loginDialogAction {
286	max := 2
287	switch k.Kind {
288	case tui.KeyUp:
289		if d.cursor > 0 {
290			d.cursor--
291		}
292	case tui.KeyDown:
293		if d.cursor < max-1 {
294			d.cursor++
295		}
296	case tui.KeyEsc:
297		d.Close()
298		return loginDialogAction{Close: true}
299	case tui.KeyEnter:
300		if d.cursor == 0 {
301			d.method = "apikey"
302		} else {
303			d.method = "oauth"
304		}
305		d.step = loginStepProvider
306		d.cursor = 0
307	}
308	return loginDialogAction{}
309}
310
311func (d *loginDialog) handleProviderKey(k tui.Key) loginDialogAction {
312	switch k.Kind {
313	case tui.KeyUp:
314		if d.cursor > 0 {
315			d.cursor--
316		}
317	case tui.KeyDown:
318		if d.cursor < 3 {
319			d.cursor++
320		}
321	case tui.KeyEsc:
322		d.Close()
323		return loginDialogAction{Close: true}
324	case tui.KeyEnter:
325		providers := []string{"anthropic", "openai", "kimi", "google"}
326		d.provider = providers[d.cursor]
327		d.step = loginStepWaiting
328		// Google has no subscription OAuth path (Gemini Advanced does
329		// not issue API tokens). If the user chose subscription + google,
330		// quietly downgrade to api-key so they get a usable form instead
331		// of a hard error.
332		if d.provider == "google" {
333			d.method = "apikey"
334		}
335		if d.method == "apikey" {
336			return loginDialogAction{StartAPIKey: true, Provider: d.provider}
337		}
338		return loginDialogAction{StartOAuth: true, Provider: d.provider}
339	}
340	return loginDialogAction{}
341}
342
343// ShowWaiting transitions to the waiting state with the given URL.
344// No-op if the user has already dismissed the dialog.
345func (d *loginDialog) ShowWaiting(url string) {
346	if d.step == loginStepClosed {
347		return
348	}
349	d.step = loginStepWaiting
350	d.url = url
351}
352
353// ShowResult transitions to the done state with the given outcome.
354// No-op if the user has already dismissed the dialog.
355func (d *loginDialog) ShowResult(success bool, message string) {
356	if d.step == loginStepClosed {
357		return
358	}
359	d.step = loginStepDone
360	d.success = success
361	d.message = message
362}
363
364func (d *loginDialog) handleWaitingKey(k tui.Key) loginDialogAction {
365	if k.Kind == tui.KeyEsc {
366		d.Close()
367		return loginDialogAction{Close: true}
368	}
369	if d.codeEd == nil {
370		return loginDialogAction{}
371	}
372	if submit := d.codeEd.HandleKey(k); submit {
373		code := d.codeEd.SubmitValue()
374		d.codeEd.Clear()
375		return loginDialogAction{SubmitCode: code}
376	}
377	return loginDialogAction{}
378}
379
380func (d *loginDialog) handlePasteCodeKey(k tui.Key) loginDialogAction {
381	if k.Kind == tui.KeyEsc {
382		d.Close()
383		return loginDialogAction{Close: true}
384	}
385	if d.codeEd == nil {
386		return loginDialogAction{}
387	}
388	if submit := d.codeEd.HandleKey(k); submit {
389		code := d.codeEd.SubmitValue()
390		d.codeEd.Clear()
391		return loginDialogAction{SubmitCode: code}
392	}
393	return loginDialogAction{}
394}
395
396// CursorPos returns the absolute row/col inside the dialog where the
397// terminal cursor should sit (paste-code step). Returns -1, -1 if the
398// dialog is not in an input-expecting state. The host uses this to
399// place the real blinking cursor on the code input.
400func (d *loginDialog) CursorPos(width int) (row, col int) {
401	if d.codeEd == nil {
402		return -1, -1
403	}
404	if d.step != loginStepPasteCode && d.step != loginStepWaiting {
405		return -1, -1
406	}
407	_, eRow, eCol := d.codeEd.Render(width - 2)
408	wrapW := width - 2
409	if wrapW < 20 {
410		wrapW = 20
411	}
412	urlLines := len(tui.WrapANSILine(d.url, wrapW))
413	baseOffset := 1 /*frameHeader*/ + 1 /*hint*/ + urlLines + 1 /*blank*/ + 1 /*prompt*/
414	return baseOffset + eRow, eCol
415}
416
417func max0(x int) int {
418	if x < 0 {
419		return 0
420	}
421	return x
422}

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