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