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}