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