v0.1.10 8a38171

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

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