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