diff --git a/internal/agent/modes/interactive.go b/internal/agent/modes/interactive.go index e33e51f..26b386b 100644 --- a/internal/agent/modes/interactive.go +++ b/internal/agent/modes/interactive.go @@ -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 diff --git a/internal/agent/modes/login_dialog.go b/internal/agent/modes/login_dialog.go index 571cb08..6872f14 100644 --- a/internal/agent/modes/login_dialog.go +++ b/internal/agent/modes/login_dialog.go @@ -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