detached 205e362

feat: provider labels in login/logout, graceful unknown provider fallback, model picker filtering

Login and logout dialogs show descriptive labels (Anthropic Claude Pro/Max, OpenAI ChatGPT Plus/Pro). Unknown saved providers fall back to an available one instead of crashing. Model picker only shows models from logged-in providers.
commit
205e362ed42d1238bbe1b9941fdcf74378f9bc7d
author
patriceckhart · · 2026-04-25 17:25 UTC
parent
d53fbf4
signature
unsigned
refs
tag: v0.1.14
5 files · +77 -15
M internal/agent/build.go +13 -1
@@ -129,7 +129,19 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
129129 // User-requested provider (explicit > config > default).
130130 provName := firstNonEmpty(args.Provider, cfg.Provider, "anthropic")
131131 if provName != "anthropic" && provName != "openai" && provName != "ollama" {
132- return Resolved{}, fmt.Errorf("provider must be anthropic, openai, or ollama (got %q)", provName)
132+ // Unknown provider (maybe removed or renamed). Fall back to
133+ // the first provider that has credentials, or anthropic.
134+ provName = "anthropic"
135+ if _, _, _, err := ResolveCredentialFull("openai", ""); err == nil {
136+ provName = "openai"
137+ }
138+ if _, _, _, err := ResolveCredentialFull("anthropic", ""); err == nil {
139+ provName = "anthropic"
140+ }
141+ // Reset the saved config so this doesn't keep happening.
142+ cfg.Provider = provName
143+ cfg.Model = ""
144+ _ = SaveConfig(cfg)
133145 }
134146
135147 var (
@@ -129,7 +129,19 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
129 // User-requested provider (explicit > config > default).129 // User-requested provider (explicit > config > default).
130 provName := firstNonEmpty(args.Provider, cfg.Provider, "anthropic")130 provName := firstNonEmpty(args.Provider, cfg.Provider, "anthropic")
131 if provName != "anthropic" && provName != "openai" && provName != "ollama" {131 if provName != "anthropic" && provName != "openai" && provName != "ollama" {
132- return Resolved{}, fmt.Errorf("provider must be anthropic, openai, or ollama (got %q)", provName)132+ // Unknown provider (maybe removed or renamed). Fall back to
133+ // the first provider that has credentials, or anthropic.
134+ provName = "anthropic"
135+ if _, _, _, err := ResolveCredentialFull("openai", ""); err == nil {
136+ provName = "openai"
137+ }
138+ if _, _, _, err := ResolveCredentialFull("anthropic", ""); err == nil {
139+ provName = "anthropic"
140+ }
141+ // Reset the saved config so this doesn't keep happening.
142+ cfg.Provider = provName
143+ cfg.Model = ""
144+ _ = SaveConfig(cfg)
133 }145 }
134 146
135 var (147 var (
M internal/agent/cli.go +12 -1
@@ -587,7 +587,18 @@ func runInteractive(ctx context.Context, args Args, version string) error {
587587 AuthManager: mgr,
588588 BuildAgent: buildAgent,
589589 BuildAgentFor: buildAgentFor,
590- LoadSession: loadSession,
590+ LoggedInProviders: func() []string {
591+ var out []string
592+ for _, p := range []string{"anthropic", "openai"} {
593+ if _, _, err := ResolveCredential(p, ""); err == nil {
594+ out = append(out, p)
595+ }
596+ }
597+ // Ollama models are always available (no auth needed).
598+ out = append(out, "ollama")
599+ return out
600+ },
601+ LoadSession: loadSession,
591602 CurrentSessionPath: func() string {
592603 if sess == nil {
593604 return ""
@@ -587,7 +587,18 @@ func runInteractive(ctx context.Context, args Args, version string) error {
587 AuthManager: mgr,587 AuthManager: mgr,
588 BuildAgent: buildAgent,588 BuildAgent: buildAgent,
589 BuildAgentFor: buildAgentFor,589 BuildAgentFor: buildAgentFor,
590- LoadSession: loadSession,590+ LoggedInProviders: func() []string {
591+ var out []string
592+ for _, p := range []string{"anthropic", "openai"} {
593+ if _, _, err := ResolveCredential(p, ""); err == nil {
594+ out = append(out, p)
595+ }
596+ }
597+ // Ollama models are always available (no auth needed).
598+ out = append(out, "ollama")
599+ return out
600+ },
601+ LoadSession: loadSession,
591 CurrentSessionPath: func() string {602 CurrentSessionPath: func() string {
592 if sess == nil {603 if sess == nil {
593 return ""604 return ""
M internal/agent/modes/interactive.go +16 -3
@@ -53,6 +53,11 @@ type InteractiveConfig struct {
5353 // If providerOverride is empty, the current provider is kept.
5454 BuildAgentFor func(providerOverride, modelOverride string) (*core.Agent, string, string, error)
5555
56+ // LoggedInProviders returns the list of provider names that
57+ // currently have credentials. Used by /model to filter the
58+ // picker to only show reachable models.
59+ LoggedInProviders func() []string
60+
5661 // ZotHome is the root directory for sessions/, used by /sessions
5762 // and the update-check cache.
5863 ZotHome string
@@ -1764,7 +1769,11 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
17641769 if len(parts) >= 2 {
17651770 i.applyModelSelection("", parts[1])
17661771 } else {
1767- i.modelDialog.Open(i.cfg.Model)
1772+ var loggedIn []string
1773+ if i.cfg.LoggedInProviders != nil {
1774+ loggedIn = i.cfg.LoggedInProviders()
1775+ }
1776+ i.modelDialog.Open(i.cfg.Model, loggedIn)
17681777 }
17691778 case "/sessions":
17701779 i.sessionDialog.Open(i.cfg.ZotHome, i.cfg.CWD)
@@ -1892,10 +1901,14 @@ func (i *Interactive) openLogoutDialog() {
18921901 var items []logoutItem
18931902 for _, p := range []string{"anthropic", "openai"} {
18941903 if creds.Has(p) {
1904+ method := creds.Method(p)
1905+ if method == "oauth" {
1906+ method = "subscription"
1907+ }
18951908 items = append(items, logoutItem{
1896- label: p,
1909+ label: providerLabel(p),
18971910 target: p,
1898- method: creds.Method(p),
1911+ method: method,
18991912 })
19001913 }
19011914 }
@@ -53,6 +53,11 @@ type InteractiveConfig struct {
53 // If providerOverride is empty, the current provider is kept.53 // If providerOverride is empty, the current provider is kept.
54 BuildAgentFor func(providerOverride, modelOverride string) (*core.Agent, string, string, error)54 BuildAgentFor func(providerOverride, modelOverride string) (*core.Agent, string, string, error)
55 55
56+ // LoggedInProviders returns the list of provider names that
57+ // currently have credentials. Used by /model to filter the
58+ // picker to only show reachable models.
59+ LoggedInProviders func() []string
60+
56 // ZotHome is the root directory for sessions/, used by /sessions61 // ZotHome is the root directory for sessions/, used by /sessions
57 // and the update-check cache.62 // and the update-check cache.
58 ZotHome string63 ZotHome string
@@ -1764,7 +1769,11 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
1764 if len(parts) >= 2 {1769 if len(parts) >= 2 {
1765 i.applyModelSelection("", parts[1])1770 i.applyModelSelection("", parts[1])
1766 } else {1771 } else {
1767- i.modelDialog.Open(i.cfg.Model)1772+ var loggedIn []string
1773+ if i.cfg.LoggedInProviders != nil {
1774+ loggedIn = i.cfg.LoggedInProviders()
1775+ }
1776+ i.modelDialog.Open(i.cfg.Model, loggedIn)
1768 }1777 }
1769 case "/sessions":1778 case "/sessions":
1770 i.sessionDialog.Open(i.cfg.ZotHome, i.cfg.CWD)1779 i.sessionDialog.Open(i.cfg.ZotHome, i.cfg.CWD)
@@ -1892,10 +1901,14 @@ func (i *Interactive) openLogoutDialog() {
1892 var items []logoutItem1901 var items []logoutItem
1893 for _, p := range []string{"anthropic", "openai"} {1902 for _, p := range []string{"anthropic", "openai"} {
1894 if creds.Has(p) {1903 if creds.Has(p) {
1904+ method := creds.Method(p)
1905+ if method == "oauth" {
1906+ method = "subscription"
1907+ }
1895 items = append(items, logoutItem{1908 items = append(items, logoutItem{
1896- label: p,1909+ label: providerLabel(p),
1897 target: p,1910 target: p,
1898- method: creds.Method(p),1911+ method: method,
1899 })1912 })
1900 }1913 }
1901 }1914 }
M internal/agent/modes/login_dialog.go +20 -8
@@ -127,7 +127,7 @@ func (d *loginDialog) Render(th tui.Theme, width int) []string {
127127 case "oauth":
128128 tag = " (subscription)"
129129 }
130- plain := " " + o + tag
130+ plain := " " + providerLabel(o) + tag
131131 if i == d.cursor {
132132 lines = append(lines, th.PadHighlight(plain, width))
133133 } else {
@@ -136,7 +136,7 @@ func (d *loginDialog) Render(th tui.Theme, width int) []string {
136136 }
137137 lines = append(lines, frameRule(th, width))
138138 case loginStepWaiting:
139- lines = append(lines, frameHeader(th, "login - "+d.method+" - "+d.provider, width))
139+ lines = append(lines, frameHeader(th, "login - "+d.method+" - "+providerLabel(d.provider), width))
140140 lines = append(lines, th.FG256(th.Muted, "open this URL in a browser:"))
141141 wrapW := width - 2
142142 if wrapW < 20 {
@@ -158,7 +158,7 @@ func (d *loginDialog) Render(th tui.Theme, width int) []string {
158158 lines = append(lines, th.FG256(th.Muted, "enter submits - esc cancels - waiting for browser callback in background"))
159159 lines = append(lines, frameRule(th, width))
160160 case loginStepPasteCode:
161- lines = append(lines, frameHeader(th, "login - "+d.method+" - "+d.provider+" - paste code", width))
161+ lines = append(lines, frameHeader(th, "login - "+d.method+" - "+providerLabel(d.provider)+" - paste code", width))
162162 lines = append(lines, th.FG256(th.Muted, "open this URL in any browser:"))
163163 wrapW := width - 2
164164 if wrapW < 20 {
@@ -184,7 +184,7 @@ func (d *loginDialog) Render(th tui.Theme, width int) []string {
184184 body := th.FG256(th.Error, d.message)
185185 if d.success {
186186 title = "login - success"
187- body = th.FG256(th.Tool, fmt.Sprintf("logged in to %s via %s", d.provider, d.method))
187+ body = th.FG256(th.Tool, fmt.Sprintf("logged in to %s via %s", providerLabel(d.provider), d.method))
188188 }
189189 lines = append(lines, frameHeader(th, title, width))
190190 lines = append(lines, body)
@@ -194,6 +194,17 @@ func (d *loginDialog) Render(th tui.Theme, width int) []string {
194194 return lines
195195 }
196196
197+// providerLabel returns the user-facing label for a provider id.
198+func providerLabel(id string) string {
199+ switch id {
200+ case "anthropic":
201+ return "Anthropic (Claude Pro/Max)"
202+ case "openai":
203+ return "OpenAI (ChatGPT Plus/Pro)"
204+ }
205+ return id
206+}
207+
197208 // renderStatusLines returns an overview of the current login
198209 // state for each provider, one row per provider, suitable to
199210 // insert between the frame header and the picker body. Logged-
@@ -210,18 +221,19 @@ func (d *loginDialog) renderStatusLines(th tui.Theme) []string {
210221 if anth == "" && op == "" {
211222 return nil
212223 }
213- row := func(name, method string) string {
224+ row := func(id, method string) string {
225+ label := providerLabel(id)
214226 var mark, body string
215227 switch method {
216228 case "apikey":
217229 mark = th.FG256(th.Tool, "✓")
218- body = th.FG256(th.Muted, name+": api key")
230+ body = th.FG256(th.Muted, label+": api key")
219231 case "oauth":
220232 mark = th.FG256(th.Tool, "✓")
221- body = th.FG256(th.Muted, name+": subscription")
233+ body = th.FG256(th.Muted, label+": subscription")
222234 default:
223235 mark = th.FG256(th.Muted, "–")
224- body = th.FG256(th.Muted, name+": not logged in")
236+ body = th.FG256(th.Muted, label+": not logged in")
225237 }
226238 return " " + mark + " " + body
227239 }
@@ -127,7 +127,7 @@ func (d *loginDialog) Render(th tui.Theme, width int) []string {
127 case "oauth":127 case "oauth":
128 tag = " (subscription)"128 tag = " (subscription)"
129 }129 }
130- plain := " " + o + tag130+ plain := " " + providerLabel(o) + tag
131 if i == d.cursor {131 if i == d.cursor {
132 lines = append(lines, th.PadHighlight(plain, width))132 lines = append(lines, th.PadHighlight(plain, width))
133 } else {133 } else {
@@ -136,7 +136,7 @@ func (d *loginDialog) Render(th tui.Theme, width int) []string {
136 }136 }
137 lines = append(lines, frameRule(th, width))137 lines = append(lines, frameRule(th, width))
138 case loginStepWaiting:138 case loginStepWaiting:
139- lines = append(lines, frameHeader(th, "login - "+d.method+" - "+d.provider, width))139+ lines = append(lines, frameHeader(th, "login - "+d.method+" - "+providerLabel(d.provider), width))
140 lines = append(lines, th.FG256(th.Muted, "open this URL in a browser:"))140 lines = append(lines, th.FG256(th.Muted, "open this URL in a browser:"))
141 wrapW := width - 2141 wrapW := width - 2
142 if wrapW < 20 {142 if wrapW < 20 {
@@ -158,7 +158,7 @@ func (d *loginDialog) Render(th tui.Theme, width int) []string {
158 lines = append(lines, th.FG256(th.Muted, "enter submits - esc cancels - waiting for browser callback in background"))158 lines = append(lines, th.FG256(th.Muted, "enter submits - esc cancels - waiting for browser callback in background"))
159 lines = append(lines, frameRule(th, width))159 lines = append(lines, frameRule(th, width))
160 case loginStepPasteCode:160 case loginStepPasteCode:
161- lines = append(lines, frameHeader(th, "login - "+d.method+" - "+d.provider+" - paste code", width))161+ lines = append(lines, frameHeader(th, "login - "+d.method+" - "+providerLabel(d.provider)+" - paste code", width))
162 lines = append(lines, th.FG256(th.Muted, "open this URL in any browser:"))162 lines = append(lines, th.FG256(th.Muted, "open this URL in any browser:"))
163 wrapW := width - 2163 wrapW := width - 2
164 if wrapW < 20 {164 if wrapW < 20 {
@@ -184,7 +184,7 @@ func (d *loginDialog) Render(th tui.Theme, width int) []string {
184 body := th.FG256(th.Error, d.message)184 body := th.FG256(th.Error, d.message)
185 if d.success {185 if d.success {
186 title = "login - success"186 title = "login - success"
187- body = th.FG256(th.Tool, fmt.Sprintf("logged in to %s via %s", d.provider, d.method))187+ body = th.FG256(th.Tool, fmt.Sprintf("logged in to %s via %s", providerLabel(d.provider), d.method))
188 }188 }
189 lines = append(lines, frameHeader(th, title, width))189 lines = append(lines, frameHeader(th, title, width))
190 lines = append(lines, body)190 lines = append(lines, body)
@@ -194,6 +194,17 @@ func (d *loginDialog) Render(th tui.Theme, width int) []string {
194 return lines194 return lines
195 }195 }
196 196
197+// providerLabel returns the user-facing label for a provider id.
198+func providerLabel(id string) string {
199+ switch id {
200+ case "anthropic":
201+ return "Anthropic (Claude Pro/Max)"
202+ case "openai":
203+ return "OpenAI (ChatGPT Plus/Pro)"
204+ }
205+ return id
206+}
207+
197 // renderStatusLines returns an overview of the current login208 // renderStatusLines returns an overview of the current login
198 // state for each provider, one row per provider, suitable to209 // state for each provider, one row per provider, suitable to
199 // insert between the frame header and the picker body. Logged-210 // insert between the frame header and the picker body. Logged-
@@ -210,18 +221,19 @@ func (d *loginDialog) renderStatusLines(th tui.Theme) []string {
210 if anth == "" && op == "" {221 if anth == "" && op == "" {
211 return nil222 return nil
212 }223 }
213- row := func(name, method string) string {224+ row := func(id, method string) string {
225+ label := providerLabel(id)
214 var mark, body string226 var mark, body string
215 switch method {227 switch method {
216 case "apikey":228 case "apikey":
217 mark = th.FG256(th.Tool, "✓")229 mark = th.FG256(th.Tool, "✓")
218- body = th.FG256(th.Muted, name+": api key")230+ body = th.FG256(th.Muted, label+": api key")
219 case "oauth":231 case "oauth":
220 mark = th.FG256(th.Tool, "✓")232 mark = th.FG256(th.Tool, "✓")
221- body = th.FG256(th.Muted, name+": subscription")233+ body = th.FG256(th.Muted, label+": subscription")
222 default:234 default:
223 mark = th.FG256(th.Muted, "–")235 mark = th.FG256(th.Muted, "–")
224- body = th.FG256(th.Muted, name+": not logged in")236+ body = th.FG256(th.Muted, label+": not logged in")
225 }237 }
226 return " " + mark + " " + body238 return " " + mark + " " + body
227 }239 }
M internal/agent/modes/model_dialog.go +16 -2
@@ -38,9 +38,23 @@ func newModelDialog() *modelDialog {
3838
3939 // Open shows the dialog. current is the currently active model id so
4040 // it can be pre-selected.
41-func (d *modelDialog) Open(current string) {
41+func (d *modelDialog) Open(current string, loggedInProviders []string) {
4242 d.active = true
43- d.all = sortedModels(provider.Active())
43+ all := provider.Active()
44+ if len(loggedInProviders) > 0 {
45+ provSet := map[string]bool{}
46+ for _, p := range loggedInProviders {
47+ provSet[p] = true
48+ }
49+ var filtered []provider.Model
50+ for _, m := range all {
51+ if provSet[m.Provider] {
52+ filtered = append(filtered, m)
53+ }
54+ }
55+ all = filtered
56+ }
57+ d.all = sortedModels(all)
4458 d.current = current
4559 d.query = ""
4660 d.refilter()
@@ -38,9 +38,23 @@ func newModelDialog() *modelDialog {
38 38
39 // Open shows the dialog. current is the currently active model id so39 // Open shows the dialog. current is the currently active model id so
40 // it can be pre-selected.40 // it can be pre-selected.
41-func (d *modelDialog) Open(current string) {41+func (d *modelDialog) Open(current string, loggedInProviders []string) {
42 d.active = true42 d.active = true
43- d.all = sortedModels(provider.Active())43+ all := provider.Active()
44+ if len(loggedInProviders) > 0 {
45+ provSet := map[string]bool{}
46+ for _, p := range loggedInProviders {
47+ provSet[p] = true
48+ }
49+ var filtered []provider.Model
50+ for _, m := range all {
51+ if provSet[m.Provider] {
52+ filtered = append(filtered, m)
53+ }
54+ }
55+ all = filtered
56+ }
57+ d.all = sortedModels(all)
44 d.current = current58 d.current = current
45 d.query = ""59 d.query = ""
46 d.refilter()60 d.refilter()

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