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}