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.
This commit is contained in:
patriceckhart 2026-04-21 16:56:49 +02:00
parent cb8526c061
commit 755ca7ccdf
2 changed files with 84 additions and 5 deletions

View file

@ -353,7 +353,7 @@ func (i *Interactive) Run(ctx context.Context) error {
// want to dismiss it (e.g. to check /help or /exit first).
if i.agent == nil {
i.statusErr = "not logged in. pick a login method below or press esc to dismiss."
i.dialog.Open()
i.dialog.Open(i.cfg.ZotHome)
}
// Input goroutine.
@ -1420,7 +1420,7 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
i.scrollOffset = 0
i.mu.Unlock()
case "/login":
i.dialog.Open()
i.dialog.Open(i.cfg.ZotHome)
case "/logout":
if len(parts) >= 2 {
// Explicit target: /logout anthropic | openai | all

View file

@ -2,7 +2,9 @@ package modes
import (
"fmt"
"path/filepath"
"github.com/patriceckhart/zot/internal/auth"
"github.com/patriceckhart/zot/internal/tui"
)
@ -30,6 +32,14 @@ type loginDialog struct {
success bool
url string
cursor int
// status is a snapshot of the current login state for each
// provider, captured when Open() runs. Rendered above the
// method picker so the user can see whether they're already
// logged in (and how) before starting a new flow. Keys:
// "anthropic", "openai". Value is "apikey", "oauth", or ""
// (not logged in).
status map[string]string
}
func newLoginDialog() *loginDialog {
@ -39,8 +49,13 @@ func newLoginDialog() *loginDialog {
// Active reports whether the dialog consumes input.
func (d *loginDialog) Active() bool { return d != nil && d.step != loginStepClosed }
// Open starts the dialog from scratch.
func (d *loginDialog) Open() {
// Open starts the dialog from scratch and captures the current
// login status for each provider so the picker can show it.
// zotHome is the zot state directory ($ZOT_HOME); auth.json
// lives inside it. Passing the path in (instead of importing
// the agent package to call AuthPath()) avoids a cyclic import
// between agent and agent/modes.
func (d *loginDialog) Open(zotHome string) {
d.step = loginStepMethod
d.method = ""
d.provider = ""
@ -48,6 +63,16 @@ func (d *loginDialog) Open() {
d.success = false
d.url = ""
d.cursor = 0
d.status = map[string]string{"anthropic": "", "openai": ""}
// Best-effort: if the auth file can't be read, treat every
// provider as not-logged-in. The status line just won't show
// anything useful in that case, which is fine — the user
// was about to log in anyway.
path := filepath.Join(zotHome, "auth.json")
if creds, err := auth.NewStore(path).Load(); err == nil {
d.status["anthropic"] = creds.Method("anthropic")
d.status["openai"] = creds.Method("openai")
}
}
// Close hides the dialog.
@ -69,6 +94,9 @@ func (d *loginDialog) Render(th tui.Theme, width int) []string {
"subscription (claude pro/max · chatgpt plus/pro)",
}
lines = append(lines, frameHeader(th, "login", width))
for _, l := range d.renderStatusLines(th) {
lines = append(lines, l)
}
lines = append(lines, th.FG256(th.Muted, "choose login method (↑/↓, enter, esc to cancel):"))
for i, o := range opts {
plain := " " + o
@ -82,9 +110,22 @@ func (d *loginDialog) Render(th tui.Theme, width int) []string {
case loginStepProvider:
opts := []string{"anthropic", "openai"}
lines = append(lines, frameHeader(th, "login · "+d.method, width))
for _, l := range d.renderStatusLines(th) {
lines = append(lines, l)
}
lines = append(lines, th.FG256(th.Muted, "choose provider:"))
for i, o := range opts {
plain := " " + o
// Annotate each provider with its current login
// state so the user can see at a glance which will
// be replaced if they pick it.
tag := ""
switch d.status[o] {
case "apikey":
tag = " (api key)"
case "oauth":
tag = " (subscription)"
}
plain := " " + o + tag
if i == d.cursor {
lines = append(lines, th.PadHighlight(plain, width))
} else {
@ -114,6 +155,44 @@ func (d *loginDialog) Render(th tui.Theme, width int) []string {
return lines
}
// renderStatusLines returns an overview of the current login
// state for each provider, one row per provider, suitable to
// insert between the frame header and the picker body. Logged-
// in providers get a green checkmark in front; providers with
// no credentials render as a muted dash so the list layout
// stays aligned across first-run and re-login cases.
//
// Returns nil when neither provider is logged in (first-run
// case — a pair of "not logged in" rows is just noise when the
// user is about to pick a method anyway).
func (d *loginDialog) renderStatusLines(th tui.Theme) []string {
anth := d.status["anthropic"]
op := d.status["openai"]
if anth == "" && op == "" {
return nil
}
row := func(name, method string) string {
var mark, body string
switch method {
case "apikey":
mark = th.FG256(th.Tool, "✓")
body = th.FG256(th.Muted, name+": api key")
case "oauth":
mark = th.FG256(th.Tool, "✓")
body = th.FG256(th.Muted, name+": subscription")
default:
mark = th.FG256(th.Muted, "")
body = th.FG256(th.Muted, name+": not logged in")
}
return " " + mark + " " + body
}
return []string{
row("anthropic", anth),
row("openai", op),
"",
}
}
// Key is the result of handling a key press.
type loginDialogAction struct {
StartAPIKey bool