v0.0.88 2cf21d7

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	loginStepDone               // success or error, waiting for key to dismiss
23)
24
25// loginDialog is a tiny inline dialog rendered above the editor while
26// the user picks their login method and provider.
27type loginDialog struct {
28	step     loginStep
29	method   string // "apikey" | "oauth"
30	provider string // "anthropic" | "openai"
31	message  string
32	success  bool
33	url      string
34	cursor   int
35
36	// status is a snapshot of the current login state for each
37	// provider, captured when Open() runs. Rendered above the
38	// method picker so the user can see whether they're already
39	// logged in (and how) before starting a new flow. Keys:
40	// "anthropic", "openai". Value is "apikey", "oauth", or ""
41	// (not logged in).
42	status map[string]string
43}
44
45func newLoginDialog() *loginDialog {
46	return &loginDialog{}
47}
48
49// Active reports whether the dialog consumes input.
50func (d *loginDialog) Active() bool { return d != nil && d.step != loginStepClosed }
51
52// Open starts the dialog from scratch and captures the current
53// login status for each provider so the picker can show it.
54// zotHome is the zot state directory ($ZOT_HOME); auth.json
55// lives inside it. Passing the path in (instead of importing
56// the agent package to call AuthPath()) avoids a cyclic import
57// between agent and agent/modes.
58func (d *loginDialog) Open(zotHome string) {
59	d.step = loginStepMethod
60	d.method = ""
61	d.provider = ""
62	d.message = ""
63	d.success = false
64	d.url = ""
65	d.cursor = 0
66	d.status = map[string]string{"anthropic": "", "openai": ""}
67	// Best-effort: if the auth file can't be read, treat every
68	// provider as not-logged-in. The status line just won't show
69	// anything useful in that case, which is fine — the user
70	// was about to log in anyway.
71	path := filepath.Join(zotHome, "auth.json")
72	if creds, err := auth.NewStore(path).Load(); err == nil {
73		d.status["anthropic"] = creds.Method("anthropic")
74		d.status["openai"] = creds.Method("openai")
75	}
76}
77
78// Close hides the dialog.
79func (d *loginDialog) Close() {
80	d.step = loginStepClosed
81}
82
83// Render returns the dialog lines or nil when inactive.
84func (d *loginDialog) Render(th tui.Theme, width int) []string {
85	if !d.Active() {
86		return nil
87	}
88	var lines []string
89
90	switch d.step {
91	case loginStepMethod:
92		opts := []string{
93			"api key",
94			"subscription (claude pro/max - chatgpt plus/pro)",
95		}
96		lines = append(lines, frameHeader(th, "login", width))
97		for _, l := range d.renderStatusLines(th) {
98			lines = append(lines, l)
99		}
100		lines = append(lines, th.FG256(th.Muted, "choose login method (↑/↓, enter, esc to cancel):"))
101		for i, o := range opts {
102			plain := "  " + o
103			if i == d.cursor {
104				lines = append(lines, th.PadHighlight(plain, width))
105			} else {
106				lines = append(lines, th.FG256(th.Muted, plain))
107			}
108		}
109		lines = append(lines, frameRule(th, width))
110	case loginStepProvider:
111		opts := []string{"anthropic", "openai"}
112		lines = append(lines, frameHeader(th, "login - "+d.method, width))
113		for _, l := range d.renderStatusLines(th) {
114			lines = append(lines, l)
115		}
116		lines = append(lines, th.FG256(th.Muted, "choose provider:"))
117		for i, o := range opts {
118			// Annotate each provider with its current login
119			// state so the user can see at a glance which will
120			// be replaced if they pick it.
121			tag := ""
122			switch d.status[o] {
123			case "apikey":
124				tag = "  (api key)"
125			case "oauth":
126				tag = "  (subscription)"
127			}
128			plain := "  " + o + tag
129			if i == d.cursor {
130				lines = append(lines, th.PadHighlight(plain, width))
131			} else {
132				lines = append(lines, th.FG256(th.Muted, plain))
133			}
134		}
135		lines = append(lines, frameRule(th, width))
136	case loginStepWaiting:
137		lines = append(lines, frameHeader(th, "login - "+d.method+" - "+d.provider, width))
138		lines = append(lines, th.FG256(th.FG, "opening browser..."))
139		lines = append(lines, th.FG256(th.Muted, d.url))
140		lines = append(lines, "")
141		lines = append(lines, th.FG256(th.Muted, "waiting for callback. press esc to cancel."))
142		lines = append(lines, frameRule(th, width))
143	case loginStepDone:
144		title := "login - failed"
145		body := th.FG256(th.Error, d.message)
146		if d.success {
147			title = "login - success"
148			body = th.FG256(th.Tool, fmt.Sprintf("logged in to %s via %s", d.provider, d.method))
149		}
150		lines = append(lines, frameHeader(th, title, width))
151		lines = append(lines, body)
152		lines = append(lines, th.FG256(th.Muted, "press any key to close"))
153		lines = append(lines, frameRule(th, width))
154	}
155	return lines
156}
157
158// renderStatusLines returns an overview of the current login
159// state for each provider, one row per provider, suitable to
160// insert between the frame header and the picker body. Logged-
161// in providers get a green checkmark in front; providers with
162// no credentials render as a muted dash so the list layout
163// stays aligned across first-run and re-login cases.
164//
165// Returns nil when neither provider is logged in (first-run
166// case — a pair of "not logged in" rows is just noise when the
167// user is about to pick a method anyway).
168func (d *loginDialog) renderStatusLines(th tui.Theme) []string {
169	anth := d.status["anthropic"]
170	op := d.status["openai"]
171	if anth == "" && op == "" {
172		return nil
173	}
174	row := func(name, method string) string {
175		var mark, body string
176		switch method {
177		case "apikey":
178			mark = th.FG256(th.Tool, "✓")
179			body = th.FG256(th.Muted, name+": api key")
180		case "oauth":
181			mark = th.FG256(th.Tool, "✓")
182			body = th.FG256(th.Muted, name+": subscription")
183		default:
184			mark = th.FG256(th.Muted, "–")
185			body = th.FG256(th.Muted, name+": not logged in")
186		}
187		return "  " + mark + " " + body
188	}
189	return []string{
190		row("anthropic", anth),
191		row("openai", op),
192		"",
193	}
194}
195
196// Key is the result of handling a key press.
197type loginDialogAction struct {
198	StartAPIKey bool
199	StartOAuth  bool
200	Provider    string
201	Close       bool
202}
203
204// HandleKey advances the dialog and returns an action to apply, if any.
205func (d *loginDialog) HandleKey(k tui.Key) loginDialogAction {
206	switch d.step {
207	case loginStepMethod:
208		return d.handleMethodKey(k)
209	case loginStepProvider:
210		return d.handleProviderKey(k)
211	case loginStepWaiting:
212		if k.Kind == tui.KeyEsc {
213			d.Close()
214			return loginDialogAction{Close: true}
215		}
216	case loginStepDone:
217		d.Close()
218		return loginDialogAction{Close: true}
219	}
220	return loginDialogAction{}
221}
222
223func (d *loginDialog) handleMethodKey(k tui.Key) loginDialogAction {
224	max := 2
225	switch k.Kind {
226	case tui.KeyUp:
227		if d.cursor > 0 {
228			d.cursor--
229		}
230	case tui.KeyDown:
231		if d.cursor < max-1 {
232			d.cursor++
233		}
234	case tui.KeyEsc:
235		d.Close()
236		return loginDialogAction{Close: true}
237	case tui.KeyEnter:
238		if d.cursor == 0 {
239			d.method = "apikey"
240		} else {
241			d.method = "oauth"
242		}
243		d.step = loginStepProvider
244		d.cursor = 0
245	}
246	return loginDialogAction{}
247}
248
249func (d *loginDialog) handleProviderKey(k tui.Key) loginDialogAction {
250	switch k.Kind {
251	case tui.KeyUp:
252		if d.cursor > 0 {
253			d.cursor--
254		}
255	case tui.KeyDown:
256		if d.cursor < 1 {
257			d.cursor++
258		}
259	case tui.KeyEsc:
260		d.Close()
261		return loginDialogAction{Close: true}
262	case tui.KeyEnter:
263		providers := []string{"anthropic", "openai"}
264		d.provider = providers[d.cursor]
265		d.step = loginStepWaiting
266		if d.method == "apikey" {
267			return loginDialogAction{StartAPIKey: true, Provider: d.provider}
268		}
269		return loginDialogAction{StartOAuth: true, Provider: d.provider}
270	}
271	return loginDialogAction{}
272}
273
274// ShowWaiting transitions to the waiting state with the given URL.
275// No-op if the user has already dismissed the dialog.
276func (d *loginDialog) ShowWaiting(url string) {
277	if d.step == loginStepClosed {
278		return
279	}
280	d.step = loginStepWaiting
281	d.url = url
282}
283
284// ShowResult transitions to the done state with the given outcome.
285// No-op if the user has already dismissed the dialog.
286func (d *loginDialog) ShowResult(success bool, message string) {
287	if d.step == loginStepClosed {
288		return
289	}
290	d.step = loginStepDone
291	d.success = success
292	d.message = message
293}
294
295func max0(x int) int {
296	if x < 0 {
297		return 0
298	}
299	return x
300}

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