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 loginStepDone // success or error, waiting for key to dismiss
23)
24
25// loginDialog is a tiny inline dialog rendered above the editor while
26// the user picks their login method and provider.
27type loginDialog struct {
28 step loginStep
29 method string // "apikey" | "oauth"
30 provider string // "anthropic" | "openai"
31 message string
32 success bool
33 url string
34 cursor int
35
36 // status is a snapshot of the current login state for each
37 // provider, captured when Open() runs. Rendered above the
38 // method picker so the user can see whether they're already
39 // logged in (and how) before starting a new flow. Keys:
40 // "anthropic", "openai". Value is "apikey", "oauth", or ""
41 // (not logged in).
42 status map[string]string
43}
44
45func newLoginDialog() *loginDialog {
46 return &loginDialog{}
47}
48
49// Active reports whether the dialog consumes input.
50func (d *loginDialog) Active() bool { return d != nil && d.step != loginStepClosed }
51
52// Open starts the dialog from scratch and captures the current
53// login status for each provider so the picker can show it.
54// zotHome is the zot state directory ($ZOT_HOME); auth.json
55// lives inside it. Passing the path in (instead of importing
56// the agent package to call AuthPath()) avoids a cyclic import
57// between agent and agent/modes.
58func (d *loginDialog) Open(zotHome string) {
59 d.step = loginStepMethod
60 d.method = ""
61 d.provider = ""
62 d.message = ""
63 d.success = false
64 d.url = ""
65 d.cursor = 0
66 d.status = map[string]string{"anthropic": "", "openai": ""}
67 // Best-effort: if the auth file can't be read, treat every
68 // provider as not-logged-in. The status line just won't show
69 // anything useful in that case, which is fine — the user
70 // was about to log in anyway.
71 path := filepath.Join(zotHome, "auth.json")
72 if creds, err := auth.NewStore(path).Load(); err == nil {
73 d.status["anthropic"] = creds.Method("anthropic")
74 d.status["openai"] = creds.Method("openai")
75 }
76}
77
78// Close hides the dialog.
79func (d *loginDialog) Close() {
80 d.step = loginStepClosed
81}
82
83// Render returns the dialog lines or nil when inactive.
84func (d *loginDialog) Render(th tui.Theme, width int) []string {
85 if !d.Active() {
86 return nil
87 }
88 var lines []string
89
90 switch d.step {
91 case loginStepMethod:
92 opts := []string{
93 "api key",
94 "subscription (claude pro/max - chatgpt plus/pro)",
95 }
96 lines = append(lines, frameHeader(th, "login", width))
97 for _, l := range d.renderStatusLines(th) {
98 lines = append(lines, l)
99 }
100 lines = append(lines, th.FG256(th.Muted, "choose login method (↑/↓, enter, esc to cancel):"))
101 for i, o := range opts {
102 plain := " " + o
103 if i == d.cursor {
104 lines = append(lines, th.PadHighlight(plain, width))
105 } else {
106 lines = append(lines, th.FG256(th.Muted, plain))
107 }
108 }
109 lines = append(lines, frameRule(th, width))
110 case loginStepProvider:
111 opts := []string{"anthropic", "openai"}
112 lines = append(lines, frameHeader(th, "login - "+d.method, width))
113 for _, l := range d.renderStatusLines(th) {
114 lines = append(lines, l)
115 }
116 lines = append(lines, th.FG256(th.Muted, "choose provider:"))
117 for i, o := range opts {
118 // Annotate each provider with its current login
119 // state so the user can see at a glance which will
120 // be replaced if they pick it.
121 tag := ""
122 switch d.status[o] {
123 case "apikey":
124 tag = " (api key)"
125 case "oauth":
126 tag = " (subscription)"
127 }
128 plain := " " + o + tag
129 if i == d.cursor {
130 lines = append(lines, th.PadHighlight(plain, width))
131 } else {
132 lines = append(lines, th.FG256(th.Muted, plain))
133 }
134 }
135 lines = append(lines, frameRule(th, width))
136 case loginStepWaiting:
137 lines = append(lines, frameHeader(th, "login - "+d.method+" - "+d.provider, width))
138 lines = append(lines, th.FG256(th.FG, "opening browser..."))
139 lines = append(lines, th.FG256(th.Muted, d.url))
140 lines = append(lines, "")
141 lines = append(lines, th.FG256(th.Muted, "waiting for callback. press esc to cancel."))
142 lines = append(lines, frameRule(th, width))
143 case loginStepDone:
144 title := "login - failed"
145 body := th.FG256(th.Error, d.message)
146 if d.success {
147 title = "login - success"
148 body = th.FG256(th.Tool, fmt.Sprintf("logged in to %s via %s", d.provider, d.method))
149 }
150 lines = append(lines, frameHeader(th, title, width))
151 lines = append(lines, body)
152 lines = append(lines, th.FG256(th.Muted, "press any key to close"))
153 lines = append(lines, frameRule(th, width))
154 }
155 return lines
156}
157
158// renderStatusLines returns an overview of the current login
159// state for each provider, one row per provider, suitable to
160// insert between the frame header and the picker body. Logged-
161// in providers get a green checkmark in front; providers with
162// no credentials render as a muted dash so the list layout
163// stays aligned across first-run and re-login cases.
164//
165// Returns nil when neither provider is logged in (first-run
166// case — a pair of "not logged in" rows is just noise when the
167// user is about to pick a method anyway).
168func (d *loginDialog) renderStatusLines(th tui.Theme) []string {
169 anth := d.status["anthropic"]
170 op := d.status["openai"]
171 if anth == "" && op == "" {
172 return nil
173 }
174 row := func(name, method string) string {
175 var mark, body string
176 switch method {
177 case "apikey":
178 mark = th.FG256(th.Tool, "✓")
179 body = th.FG256(th.Muted, name+": api key")
180 case "oauth":
181 mark = th.FG256(th.Tool, "✓")
182 body = th.FG256(th.Muted, name+": subscription")
183 default:
184 mark = th.FG256(th.Muted, "–")
185 body = th.FG256(th.Muted, name+": not logged in")
186 }
187 return " " + mark + " " + body
188 }
189 return []string{
190 row("anthropic", anth),
191 row("openai", op),
192 "",
193 }
194}
195
196// Key is the result of handling a key press.
197type loginDialogAction struct {
198 StartAPIKey bool
199 StartOAuth bool
200 Provider string
201 Close bool
202}
203
204// HandleKey advances the dialog and returns an action to apply, if any.
205func (d *loginDialog) HandleKey(k tui.Key) loginDialogAction {
206 switch d.step {
207 case loginStepMethod:
208 return d.handleMethodKey(k)
209 case loginStepProvider:
210 return d.handleProviderKey(k)
211 case loginStepWaiting:
212 if k.Kind == tui.KeyEsc {
213 d.Close()
214 return loginDialogAction{Close: true}
215 }
216 case loginStepDone:
217 d.Close()
218 return loginDialogAction{Close: true}
219 }
220 return loginDialogAction{}
221}
222
223func (d *loginDialog) handleMethodKey(k tui.Key) loginDialogAction {
224 max := 2
225 switch k.Kind {
226 case tui.KeyUp:
227 if d.cursor > 0 {
228 d.cursor--
229 }
230 case tui.KeyDown:
231 if d.cursor < max-1 {
232 d.cursor++
233 }
234 case tui.KeyEsc:
235 d.Close()
236 return loginDialogAction{Close: true}
237 case tui.KeyEnter:
238 if d.cursor == 0 {
239 d.method = "apikey"
240 } else {
241 d.method = "oauth"
242 }
243 d.step = loginStepProvider
244 d.cursor = 0
245 }
246 return loginDialogAction{}
247}
248
249func (d *loginDialog) handleProviderKey(k tui.Key) loginDialogAction {
250 switch k.Kind {
251 case tui.KeyUp:
252 if d.cursor > 0 {
253 d.cursor--
254 }
255 case tui.KeyDown:
256 if d.cursor < 1 {
257 d.cursor++
258 }
259 case tui.KeyEsc:
260 d.Close()
261 return loginDialogAction{Close: true}
262 case tui.KeyEnter:
263 providers := []string{"anthropic", "openai"}
264 d.provider = providers[d.cursor]
265 d.step = loginStepWaiting
266 if d.method == "apikey" {
267 return loginDialogAction{StartAPIKey: true, Provider: d.provider}
268 }
269 return loginDialogAction{StartOAuth: true, Provider: d.provider}
270 }
271 return loginDialogAction{}
272}
273
274// ShowWaiting transitions to the waiting state with the given URL.
275// No-op if the user has already dismissed the dialog.
276func (d *loginDialog) ShowWaiting(url string) {
277 if d.step == loginStepClosed {
278 return
279 }
280 d.step = loginStepWaiting
281 d.url = url
282}
283
284// ShowResult transitions to the done state with the given outcome.
285// No-op if the user has already dismissed the dialog.
286func (d *loginDialog) ShowResult(success bool, message string) {
287 if d.step == loginStepClosed {
288 return
289 }
290 d.step = loginStepDone
291 d.success = success
292 d.message = message
293}
294
295func max0(x int) int {
296 if x < 0 {
297 return 0
298 }
299 return x
300}