detached edbbcc1

fix(provider): include full catalog in model picker, gate by credentials

Active() captured Catalog into a package var initializer, which runs
before the init() functions in catalog_builtin.go/extra_models.go append
the extended catalog. The picker therefore only ever saw the curated
seed list, dropping openrouter and every other extra provider. Defer the
Catalog read to call time so Active() reflects the fully-assembled list.

Also make the model dialog filter strictly by logged-in providers: an
empty credential set now yields an empty picker (with a /login hint)
instead of dumping the entire ~900-model catalog.
commit
edbbcc1086ea497e96a97b13a6213c40cade145b
author
patriceckhart · · 2026-05-29 09:26 UTC
parent
5470345
signature
unsigned
2 files · +39 -19
M packages/agent/modes/model_dialog.go +19 -14
@@ -48,21 +48,22 @@ func newModelDialog() *modelDialog {
4848 // it can be pre-selected.
4949 func (d *modelDialog) Open(current string, loggedInProviders []string) {
5050 d.active = true
51- all := provider.Active()
52- if len(loggedInProviders) > 0 {
53- provSet := map[string]bool{}
54- for _, p := range loggedInProviders {
55- provSet[p] = true
56- }
57- var filtered []provider.Model
58- for _, m := range all {
59- if provSet[m.Provider] {
60- filtered = append(filtered, m)
61- }
51+ // Only surface models the user can actually reach: a provider is
52+ // shown only when it has a resolvable credential (api key, oauth
53+ // subscription, or the always-available ollama). An empty
54+ // loggedInProviders list therefore yields an empty picker rather
55+ // than dumping the entire ~900-model catalog.
56+ provSet := map[string]bool{}
57+ for _, p := range loggedInProviders {
58+ provSet[p] = true
59+ }
60+ var filtered []provider.Model
61+ for _, m := range provider.Active() {
62+ if provSet[m.Provider] {
63+ filtered = append(filtered, m)
6264 }
63- all = filtered
6465 }
65- d.all = sortedModels(all)
66+ d.all = sortedModels(filtered)
6667 d.current = current
6768 d.query = ""
6869 d.provW, d.idW = columnWidths(d.all)
@@ -148,7 +149,11 @@ func (d *modelDialog) Render(th tui.Theme, width int) []string {
148149 lines = append(lines, th.FG256(th.Muted, hint))
149150
150151 if len(d.view) == 0 {
151- lines = append(lines, th.FG256(th.Muted, " no models match "+fmt.Sprintf("%q", d.query)))
152+ msg := " no models match " + fmt.Sprintf("%q", d.query)
153+ if len(d.all) == 0 {
154+ msg = " no credentials found - run /login to add an api key or subscription"
155+ }
156+ lines = append(lines, th.FG256(th.Muted, msg))
152157 lines = append(lines, frameRule(th, width))
153158 return lines
154159 }
@@ -48,21 +48,22 @@ func newModelDialog() *modelDialog {
48 // it can be pre-selected.48 // it can be pre-selected.
49 func (d *modelDialog) Open(current string, loggedInProviders []string) {49 func (d *modelDialog) Open(current string, loggedInProviders []string) {
50 d.active = true50 d.active = true
51- all := provider.Active()51+ // Only surface models the user can actually reach: a provider is
52- if len(loggedInProviders) > 0 {52+ // shown only when it has a resolvable credential (api key, oauth
53- provSet := map[string]bool{}53+ // subscription, or the always-available ollama). An empty
54- for _, p := range loggedInProviders {54+ // loggedInProviders list therefore yields an empty picker rather
55- provSet[p] = true55+ // than dumping the entire ~900-model catalog.
56- }56+ provSet := map[string]bool{}
57- var filtered []provider.Model57+ for _, p := range loggedInProviders {
58- for _, m := range all {58+ provSet[p] = true
59- if provSet[m.Provider] {59+ }
60- filtered = append(filtered, m)60+ var filtered []provider.Model
61- }61+ for _, m := range provider.Active() {
62+ if provSet[m.Provider] {
63+ filtered = append(filtered, m)
62 }64 }
63- all = filtered
64 }65 }
65- d.all = sortedModels(all)66+ d.all = sortedModels(filtered)
66 d.current = current67 d.current = current
67 d.query = ""68 d.query = ""
68 d.provW, d.idW = columnWidths(d.all)69 d.provW, d.idW = columnWidths(d.all)
@@ -148,7 +149,11 @@ func (d *modelDialog) Render(th tui.Theme, width int) []string {
148 lines = append(lines, th.FG256(th.Muted, hint))149 lines = append(lines, th.FG256(th.Muted, hint))
149 150
150 if len(d.view) == 0 {151 if len(d.view) == 0 {
151- lines = append(lines, th.FG256(th.Muted, " no models match "+fmt.Sprintf("%q", d.query)))152+ msg := " no models match " + fmt.Sprintf("%q", d.query)
153+ if len(d.all) == 0 {
154+ msg = " no credentials found - run /login to add an api key or subscription"
155+ }
156+ lines = append(lines, th.FG256(th.Muted, msg))
152 lines = append(lines, frameRule(th, width))157 lines = append(lines, frameRule(th, width))
153 return lines158 return lines
154 }159 }
M packages/provider/models.go +20 -5
@@ -332,8 +332,9 @@ var DefaultModel = Catalog[0] // claude-sonnet-4-5
332332 // models loaded via SetLiveModels.
333333
334334 var (
335- activeMu sync.RWMutex
336- active []Model = Catalog // default: just the static catalog
335+ activeMu sync.RWMutex
336+ active []Model // live overlay merged in via SetLiveModels; nil = none yet
337+ activeSet bool // true once SetLiveModels has run (even with empty live)
337338 )
338339
339340 // SetLiveModels replaces the "live" overlay used by the active catalog.
@@ -342,19 +343,33 @@ var (
342343 func SetLiveModels(live []Model) {
343344 activeMu.Lock()
344345 defer activeMu.Unlock()
346+ activeSet = true
345347 if len(live) == 0 {
346- active = Catalog
348+ active = nil
347349 return
348350 }
349351 active = MergeCatalog(live)
350352 }
351353
352354 // Active returns the current merged catalog.
355+//
356+// When no live overlay has been set it returns the fully-assembled
357+// static Catalog. Reading Catalog here (rather than capturing it into
358+// a package-level var initializer) is load-bearing: the extended
359+// catalog in catalog_builtin.go / extra_models.go is appended from
360+// init() functions, which run AFTER package-level var initializers.
361+// Snapshotting Catalog at var-init time would freeze the picker to the
362+// curated seed list and drop every extra provider (openrouter, groq,
363+// xai, ...). Deferring the read to call time avoids that ordering trap.
353364 func Active() []Model {
354365 activeMu.RLock()
355366 defer activeMu.RUnlock()
356- out := make([]Model, len(active))
357- copy(out, active)
367+ src := active
368+ if !activeSet || src == nil {
369+ src = Catalog
370+ }
371+ out := make([]Model, len(src))
372+ copy(out, src)
358373 return out
359374 }
360375
@@ -332,8 +332,9 @@ var DefaultModel = Catalog[0] // claude-sonnet-4-5
332 // models loaded via SetLiveModels.332 // models loaded via SetLiveModels.
333 333
334 var (334 var (
335- activeMu sync.RWMutex335+ activeMu sync.RWMutex
336- active []Model = Catalog // default: just the static catalog336+ active []Model // live overlay merged in via SetLiveModels; nil = none yet
337+ activeSet bool // true once SetLiveModels has run (even with empty live)
337 )338 )
338 339
339 // SetLiveModels replaces the "live" overlay used by the active catalog.340 // SetLiveModels replaces the "live" overlay used by the active catalog.
@@ -342,19 +343,33 @@ var (
342 func SetLiveModels(live []Model) {343 func SetLiveModels(live []Model) {
343 activeMu.Lock()344 activeMu.Lock()
344 defer activeMu.Unlock()345 defer activeMu.Unlock()
346+ activeSet = true
345 if len(live) == 0 {347 if len(live) == 0 {
346- active = Catalog348+ active = nil
347 return349 return
348 }350 }
349 active = MergeCatalog(live)351 active = MergeCatalog(live)
350 }352 }
351 353
352 // Active returns the current merged catalog.354 // Active returns the current merged catalog.
355+//
356+// When no live overlay has been set it returns the fully-assembled
357+// static Catalog. Reading Catalog here (rather than capturing it into
358+// a package-level var initializer) is load-bearing: the extended
359+// catalog in catalog_builtin.go / extra_models.go is appended from
360+// init() functions, which run AFTER package-level var initializers.
361+// Snapshotting Catalog at var-init time would freeze the picker to the
362+// curated seed list and drop every extra provider (openrouter, groq,
363+// xai, ...). Deferring the read to call time avoids that ordering trap.
353 func Active() []Model {364 func Active() []Model {
354 activeMu.RLock()365 activeMu.RLock()
355 defer activeMu.RUnlock()366 defer activeMu.RUnlock()
356- out := make([]Model, len(active))367+ src := active
357- copy(out, active)368+ if !activeSet || src == nil {
369+ src = Catalog
370+ }
371+ out := make([]Model, len(src))
372+ copy(out, src)
358 return out373 return out
359 }374 }
360 375

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