detached 755ca7c

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 {
353353 // want to dismiss it (e.g. to check /help or /exit first).
354354 if i.agent == nil {
355355 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)
357357 }
358358
359359 // Input goroutine.
@@ -1420,7 +1420,7 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
14201420 i.scrollOffset = 0
14211421 i.mu.Unlock()
14221422 case "/login":
1423- i.dialog.Open()
1423+ i.dialog.Open(i.cfg.ZotHome)
14241424 case "/logout":
14251425 if len(parts) >= 2 {
14261426 // 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 = 01420 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 | all1426 // Explicit target: /logout anthropic | openai | all
M internal/agent/modes/login_dialog.go +82 -3
@@ -2,7 +2,9 @@ package modes
22
33 import (
44 "fmt"
5+ "path/filepath"
56
7+ "github.com/patriceckhart/zot/internal/auth"
68 "github.com/patriceckhart/zot/internal/tui"
79 )
810
@@ -30,6 +32,14 @@ type loginDialog struct {
3032 success bool
3133 url string
3234 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
3343 }
3444
3545 func newLoginDialog() *loginDialog {
@@ -39,8 +49,13 @@ func newLoginDialog() *loginDialog {
3949 // Active reports whether the dialog consumes input.
4050 func (d *loginDialog) Active() bool { return d != nil && d.step != loginStepClosed }
4151
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) {
4459 d.step = loginStepMethod
4560 d.method = ""
4661 d.provider = ""
@@ -48,6 +63,16 @@ func (d *loginDialog) Open() {
4863 d.success = false
4964 d.url = ""
5065 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+ }
5176 }
5277
5378 // Close hides the dialog.
@@ -69,6 +94,9 @@ func (d *loginDialog) Render(th tui.Theme, width int) []string {
6994 "subscription (claude pro/max · chatgpt plus/pro)",
7095 }
7196 lines = append(lines, frameHeader(th, "login", width))
97+ for _, l := range d.renderStatusLines(th) {
98+ lines = append(lines, l)
99+ }
72100 lines = append(lines, th.FG256(th.Muted, "choose login method (↑/↓, enter, esc to cancel):"))
73101 for i, o := range opts {
74102 plain := " " + o
@@ -82,9 +110,22 @@ func (d *loginDialog) Render(th tui.Theme, width int) []string {
82110 case loginStepProvider:
83111 opts := []string{"anthropic", "openai"}
84112 lines = append(lines, frameHeader(th, "login · "+d.method, width))
113+ for _, l := range d.renderStatusLines(th) {
114+ lines = append(lines, l)
115+ }
85116 lines = append(lines, th.FG256(th.Muted, "choose provider:"))
86117 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
88129 if i == d.cursor {
89130 lines = append(lines, th.PadHighlight(plain, width))
90131 } else {
@@ -114,6 +155,44 @@ func (d *loginDialog) Render(th tui.Theme, width int) []string {
114155 return lines
115156 }
116157
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+
117196 // Key is the result of handling a key press.
118197 type loginDialogAction struct {
119198 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 bool32 success bool
31 url string33 url string
32 cursor int34 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 = loginStepMethod59 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 = false63 d.success = false
49 d.url = ""64 d.url = ""
50 d.cursor = 065 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 := " " + o102 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 := " " + o118+ // 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 lines155 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 bool198 StartAPIKey bool

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close