feat(tui): /login shows current status for each provider
Running /login used to drop straight into the method / provider
pickers with no indication of what's already logged in. Easy to
accidentally log out a working subscription because you
forgot which provider you'd authenticated with last.
The dialog now takes a snapshot of auth.json when Open() runs
and renders it as a two-line header above both the method step
and the provider step:
login
\u2713 anthropic: subscription
\u2713 openai: api key
choose login method (\u2191/\u2193, enter, esc to cancel):
api key
subscription (claude pro/max \u00b7 chatgpt plus/pro)
Logged-in providers get a green check + their method
("api key" or "subscription"); providers with no credentials
get a muted \u2013 dash + "not logged in". When NEITHER provider is
logged in (first-run, fresh box) the status block is
suppressed entirely \u2014 a pair of "not logged in" rows there is
just noise when the user is already seconds away from picking
a method.
On the provider step each row also gets an inline method tag
so picking it implicitly replaces the existing credential:
login \u00b7 oauth
\u2713 anthropic: subscription
\u2713 openai: api key
choose provider:
anthropic (subscription)
openai (api key)
Implementation:
- New d.status map on loginDialog, populated by Open() from
auth.Credentials.Method(provider) which already returns
exactly the three states we care about: "apikey", "oauth",
or "".
- Open() gained a zotHome string arg so the dialog can
compute auth.json's path without importing the agent
package (which would be a cyclic import; modes is inside
agent). Both callers in interactive.go now pass
i.cfg.ZotHome.
- renderStatusLines() centralises the two-row block so
loginStepMethod and loginStepProvider share it.
Tests: the whole package compiles and vet-clean; go test
./... still passes. No new tests because the dialog is pure
rendering off captured state and the captured-state path is a
one-liner.- commit
- 755ca7ccdf11d0c2e410b8ee4df14854bb074b5c
- author
- patriceckhart · · 2026-04-21 14:56 UTC
- parent
- cb8526c
- signature
- unsigned
- refs
- tag: v0.0.78
2 files · +84 -5
M internal/agent/modes/interactive.go +2 -2
| @@ -353,7 +353,7 @@ func (i *Interactive) Run(ctx context.Context) error { | |||
| 353 | 353 | // want to dismiss it (e.g. to check /help or /exit first). | |
| 354 | 354 | if i.agent == nil { | |
| 355 | 355 | i.statusErr = "not logged in. pick a login method below or press esc to dismiss." | |
| 356 | - | i.dialog.Open() | |
| 356 | + | i.dialog.Open(i.cfg.ZotHome) | |
| 357 | 357 | } | |
| 358 | 358 | ||
| 359 | 359 | // Input goroutine. | |
| @@ -1420,7 +1420,7 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) { | |||
| 1420 | 1420 | i.scrollOffset = 0 | |
| 1421 | 1421 | i.mu.Unlock() | |
| 1422 | 1422 | case "/login": | |
| 1423 | - | i.dialog.Open() | |
| 1423 | + | i.dialog.Open(i.cfg.ZotHome) | |
| 1424 | 1424 | case "/logout": | |
| 1425 | 1425 | if len(parts) >= 2 { | |
| 1426 | 1426 | // Explicit target: /logout anthropic | openai | all | |
| @@ -353,7 +353,7 @@ func (i *Interactive) Run(ctx context.Context) error { | |||||
| 353 | // want to dismiss it (e.g. to check /help or /exit first). | 353 | // want to dismiss it (e.g. to check /help or /exit first). | ||
| 354 | if i.agent == nil { | 354 | if i.agent == nil { | ||
| 355 | i.statusErr = "not logged in. pick a login method below or press esc to dismiss." | 355 | i.statusErr = "not logged in. pick a login method below or press esc to dismiss." | ||
| 356 | - | i.dialog.Open() | 356 | + | i.dialog.Open(i.cfg.ZotHome) |
| 357 | } | 357 | } | ||
| 358 | 358 | ||||
| 359 | // Input goroutine. | 359 | // Input goroutine. | ||
| @@ -1420,7 +1420,7 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) { | |||||
| 1420 | i.scrollOffset = 0 | 1420 | i.scrollOffset = 0 | ||
| 1421 | i.mu.Unlock() | 1421 | i.mu.Unlock() | ||
| 1422 | case "/login": | 1422 | case "/login": | ||
| 1423 | - | i.dialog.Open() | 1423 | + | i.dialog.Open(i.cfg.ZotHome) |
| 1424 | case "/logout": | 1424 | case "/logout": | ||
| 1425 | if len(parts) >= 2 { | 1425 | if len(parts) >= 2 { | ||
| 1426 | // Explicit target: /logout anthropic | openai | all | 1426 | // Explicit target: /logout anthropic | openai | all | ||
M internal/agent/modes/login_dialog.go +82 -3
| @@ -2,7 +2,9 @@ package modes | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "fmt" | |
| 5 | + | "path/filepath" | |
| 5 | 6 | ||
| 7 | + | "github.com/patriceckhart/zot/internal/auth" | |
| 6 | 8 | "github.com/patriceckhart/zot/internal/tui" | |
| 7 | 9 | ) | |
| 8 | 10 | ||
| @@ -30,6 +32,14 @@ type loginDialog struct { | |||
| 30 | 32 | success bool | |
| 31 | 33 | url string | |
| 32 | 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 | |
| 33 | 43 | } | |
| 34 | 44 | ||
| 35 | 45 | func newLoginDialog() *loginDialog { | |
| @@ -39,8 +49,13 @@ func newLoginDialog() *loginDialog { | |||
| 39 | 49 | // Active reports whether the dialog consumes input. | |
| 40 | 50 | func (d *loginDialog) Active() bool { return d != nil && d.step != loginStepClosed } | |
| 41 | 51 | ||
| 42 | - | // Open starts the dialog from scratch. | |
| 43 | - | func (d *loginDialog) Open() { | |
| 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. | |
| 58 | + | func (d *loginDialog) Open(zotHome string) { | |
| 44 | 59 | d.step = loginStepMethod | |
| 45 | 60 | d.method = "" | |
| 46 | 61 | d.provider = "" | |
| @@ -48,6 +63,16 @@ func (d *loginDialog) Open() { | |||
| 48 | 63 | d.success = false | |
| 49 | 64 | d.url = "" | |
| 50 | 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 | + | } | |
| 51 | 76 | } | |
| 52 | 77 | ||
| 53 | 78 | // Close hides the dialog. | |
| @@ -69,6 +94,9 @@ func (d *loginDialog) Render(th tui.Theme, width int) []string { | |||
| 69 | 94 | "subscription (claude pro/max · chatgpt plus/pro)", | |
| 70 | 95 | } | |
| 71 | 96 | lines = append(lines, frameHeader(th, "login", width)) | |
| 97 | + | for _, l := range d.renderStatusLines(th) { | |
| 98 | + | lines = append(lines, l) | |
| 99 | + | } | |
| 72 | 100 | lines = append(lines, th.FG256(th.Muted, "choose login method (↑/↓, enter, esc to cancel):")) | |
| 73 | 101 | for i, o := range opts { | |
| 74 | 102 | plain := " " + o | |
| @@ -82,9 +110,22 @@ func (d *loginDialog) Render(th tui.Theme, width int) []string { | |||
| 82 | 110 | case loginStepProvider: | |
| 83 | 111 | opts := []string{"anthropic", "openai"} | |
| 84 | 112 | lines = append(lines, frameHeader(th, "login · "+d.method, width)) | |
| 113 | + | for _, l := range d.renderStatusLines(th) { | |
| 114 | + | lines = append(lines, l) | |
| 115 | + | } | |
| 85 | 116 | lines = append(lines, th.FG256(th.Muted, "choose provider:")) | |
| 86 | 117 | for i, o := range opts { | |
| 87 | - | plain := " " + o | |
| 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 | |
| 88 | 129 | if i == d.cursor { | |
| 89 | 130 | lines = append(lines, th.PadHighlight(plain, width)) | |
| 90 | 131 | } else { | |
| @@ -114,6 +155,44 @@ func (d *loginDialog) Render(th tui.Theme, width int) []string { | |||
| 114 | 155 | return lines | |
| 115 | 156 | } | |
| 116 | 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). | |
| 168 | + | func (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 | + | ||
| 117 | 196 | // Key is the result of handling a key press. | |
| 118 | 197 | type loginDialogAction struct { | |
| 119 | 198 | StartAPIKey bool | |
| @@ -2,7 +2,9 @@ package modes | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "fmt" | 4 | "fmt" | ||
| 5 | + | "path/filepath" | |||
| 5 | 6 | ||||
| 7 | + | "github.com/patriceckhart/zot/internal/auth" | |||
| 6 | "github.com/patriceckhart/zot/internal/tui" | 8 | "github.com/patriceckhart/zot/internal/tui" | ||
| 7 | ) | 9 | ) | ||
| 8 | 10 | ||||
| @@ -30,6 +32,14 @@ type loginDialog struct { | |||||
| 30 | success bool | 32 | success bool | ||
| 31 | url string | 33 | url string | ||
| 32 | cursor int | 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 | |||
| 33 | } | 43 | } | ||
| 34 | 44 | ||||
| 35 | func newLoginDialog() *loginDialog { | 45 | func newLoginDialog() *loginDialog { | ||
| @@ -39,8 +49,13 @@ func newLoginDialog() *loginDialog { | |||||
| 39 | // Active reports whether the dialog consumes input. | 49 | // Active reports whether the dialog consumes input. | ||
| 40 | func (d *loginDialog) Active() bool { return d != nil && d.step != loginStepClosed } | 50 | func (d *loginDialog) Active() bool { return d != nil && d.step != loginStepClosed } | ||
| 41 | 51 | ||||
| 42 | - | // Open starts the dialog from scratch. | 52 | + | // Open starts the dialog from scratch and captures the current |
| 43 | - | func (d *loginDialog) Open() { | 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. | |||
| 58 | + | func (d *loginDialog) Open(zotHome string) { | |||
| 44 | d.step = loginStepMethod | 59 | d.step = loginStepMethod | ||
| 45 | d.method = "" | 60 | d.method = "" | ||
| 46 | d.provider = "" | 61 | d.provider = "" | ||
| @@ -48,6 +63,16 @@ func (d *loginDialog) Open() { | |||||
| 48 | d.success = false | 63 | d.success = false | ||
| 49 | d.url = "" | 64 | d.url = "" | ||
| 50 | d.cursor = 0 | 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 | + | } | |||
| 51 | } | 76 | } | ||
| 52 | 77 | ||||
| 53 | // Close hides the dialog. | 78 | // Close hides the dialog. | ||
| @@ -69,6 +94,9 @@ func (d *loginDialog) Render(th tui.Theme, width int) []string { | |||||
| 69 | "subscription (claude pro/max · chatgpt plus/pro)", | 94 | "subscription (claude pro/max · chatgpt plus/pro)", | ||
| 70 | } | 95 | } | ||
| 71 | lines = append(lines, frameHeader(th, "login", width)) | 96 | lines = append(lines, frameHeader(th, "login", width)) | ||
| 97 | + | for _, l := range d.renderStatusLines(th) { | |||
| 98 | + | lines = append(lines, l) | |||
| 99 | + | } | |||
| 72 | lines = append(lines, th.FG256(th.Muted, "choose login method (↑/↓, enter, esc to cancel):")) | 100 | lines = append(lines, th.FG256(th.Muted, "choose login method (↑/↓, enter, esc to cancel):")) | ||
| 73 | for i, o := range opts { | 101 | for i, o := range opts { | ||
| 74 | plain := " " + o | 102 | plain := " " + o | ||
| @@ -82,9 +110,22 @@ func (d *loginDialog) Render(th tui.Theme, width int) []string { | |||||
| 82 | case loginStepProvider: | 110 | case loginStepProvider: | ||
| 83 | opts := []string{"anthropic", "openai"} | 111 | opts := []string{"anthropic", "openai"} | ||
| 84 | lines = append(lines, frameHeader(th, "login · "+d.method, width)) | 112 | lines = append(lines, frameHeader(th, "login · "+d.method, width)) | ||
| 113 | + | for _, l := range d.renderStatusLines(th) { | |||
| 114 | + | lines = append(lines, l) | |||
| 115 | + | } | |||
| 85 | lines = append(lines, th.FG256(th.Muted, "choose provider:")) | 116 | lines = append(lines, th.FG256(th.Muted, "choose provider:")) | ||
| 86 | for i, o := range opts { | 117 | for i, o := range opts { | ||
| 87 | - | plain := " " + o | 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 | |||
| 88 | if i == d.cursor { | 129 | if i == d.cursor { | ||
| 89 | lines = append(lines, th.PadHighlight(plain, width)) | 130 | lines = append(lines, th.PadHighlight(plain, width)) | ||
| 90 | } else { | 131 | } else { | ||
| @@ -114,6 +155,44 @@ func (d *loginDialog) Render(th tui.Theme, width int) []string { | |||||
| 114 | return lines | 155 | return lines | ||
| 115 | } | 156 | } | ||
| 116 | 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). | |||
| 168 | + | func (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 | + | ||||
| 117 | // Key is the result of handling a key press. | 196 | // Key is the result of handling a key press. | ||
| 118 | type loginDialogAction struct { | 197 | type loginDialogAction struct { | ||
| 119 | StartAPIKey bool | 198 | StartAPIKey bool | ||