Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
c7e2018
fix(chat): close model pickers after confirming a model
ZeroPoint95 Oct 7, 2026
b411709
docs: align model picker commentary with confirmation behavior
ZeroPoint95 Oct 7, 2026
88545b4
test(chat): confirm model selection returns to settings and draft
ZeroPoint95 Oct 7, 2026
4ec68eb
docs: record model picker confirmation behavior
ZeroPoint95 Oct 7, 2026
6b83711
fix(chat): list models only from usable provider connections
ZeroPoint95 Oct 7, 2026
d374002
docs(chat): remove the unconnected-provider picker contract
ZeroPoint95 Oct 7, 2026
953b0f5
docs(chat): pin the new picker behavior in the manual
ZeroPoint95 Oct 7, 2026
1bc5933
test(chat): give picker interactions explicit available catalogs
ZeroPoint95 Oct 7, 2026
c4ed11c
fix(chat): choose defaults from available provider catalogs
ZeroPoint95 Oct 7, 2026
931c86f
fix(chat): honor the balance preference only when it is listed
ZeroPoint95 Oct 7, 2026
17029ef
fix(chat): reconcile defaults after connection changes settle
ZeroPoint95 Oct 7, 2026
2268e88
Choose a supported model provider before first-run connection
ZeroPoint95 Oct 7, 2026
eb43110
Omit a previous-model note on first provider connection
ZeroPoint95 Oct 7, 2026
60dd564
Scroll supported providers and show short browser sign-in links
ZeroPoint95 Oct 7, 2026
1c1b1b1
Show all setup providers and confirm Codex browser connection
ZeroPoint95 Oct 7, 2026
2b64cd2
Put OpenRouter first in provider setup
ZeroPoint95 Oct 7, 2026
9376454
Include every supported provider in later connection menus
ZeroPoint95 Oct 7, 2026
84cd8ac
Select the default provider in fresh-install terminal acceptance
ZeroPoint95 Oct 7, 2026
2728749
Merge dev and preserve provider chooser setup documentation
ZeroPoint95 Oct 7, 2026
6c83bb1
Merge branch 'dev' into zeropoint95/fm-feedback-fixes
ZeroPoint95 Oct 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion cmd/codeaf/chatv3.go
Original file line number Diff line number Diff line change
Expand Up @@ -507,7 +507,8 @@ func openChatV3(name string, args []string, pickSession bool) error {
// resolved while the person was reading is a catalog the picker can
// use, and one that has not resolved answers nil instead of waiting.
// It reads the shelf, which ctrl+r in /model refills with today's list.
Models: func() []tui3.Model { return v3Models(proc.Shelf) },
Models: proc.Shelf.pickerModels,
RequireListedModel: true,
RefreshModels: proc.refreshDefaultModels,
ModelsForService: proc.Shelf.modelsForService,
RefreshModelsForService: proc.Shelf.refreshService,
Expand Down
2 changes: 1 addition & 1 deletion cmd/codeaf/chatv3_host.go
Original file line number Diff line number Diff line change
Expand Up @@ -717,7 +717,7 @@ func hostOptions(fleet *engineFleet, welcome remote.Welcome, pick bool) (tui3.Op
// timer that is already armed around a far process.
BashBackgroundAfterSeconds: welcome.BashBackgroundAfterSeconds,
ContextWindow: v3Window(models, welcome.Model),
Models: func() []tui3.Model { return v3Models(shelf) },
Models: shelf.pickerModels,
RefreshModels: shelf.refresh,
ProviderFetchError: shelf.fetchErrorFor,
// /export writes on THIS machine (host.go's honesty table), so its row
Expand Down
1 change: 1 addition & 0 deletions cmd/codeaf/chatv3_local.go
Original file line number Diff line number Diff line change
Expand Up @@ -400,6 +400,7 @@ func localDoors(options *tui3.Options, welcome remote.Welcome, settings config.C
}
}
options.EngineRoad = true
options.RequireListedModel = true
options.ReadCredits = v3LocalCreditReader(settings)
options.Connections = v3Connections(v3Connect(profileDir))
options.Harnesses = subharness.Default()
Expand Down
3 changes: 3 additions & 0 deletions cmd/codeaf/chatv3_local_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,9 @@ func TestPlainLaunchReadsClosedTeamReportFromEngineProfile(t *testing.T) {
t.Fatal("the engine did not hand teams to the plain launch")
}
localDoors(&options, welcome, settings)
if !options.RequireListedModel {
t.Fatal("the plain launch can still use an unlisted default model")
}
if options.Teams.History == nil {
t.Fatal("the plain launch has no history door")
}
Expand Down
14 changes: 14 additions & 0 deletions cmd/codeaf/chatv3_modelshelf.go
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,20 @@ func (s *v3ModelShelf) setSources(sources modelsource.Set) {
}
}

// pickerModels excludes capability fallbacks from selectable rows. Those facts
// help the engine describe a known model, but only a provider's listing proves
// that a model belongs in this account's menu.
func (s *v3ModelShelf) pickerModels() []tui3.Model {
if s == nil {
return nil
}
models := s.current.Load()
if models == nil || models.FetchedAtNow().IsZero() {
return nil
}
return v3Models(models)
}

// modelsForService is the never-waiting half of the connected-service shelf
// seam. The default keeps reading the atomic launch catalog; another service
// reads only its own compartment.
Expand Down
22 changes: 22 additions & 0 deletions cmd/codeaf/chatv3_modelshelf_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -245,3 +245,25 @@ func TestTheShelfTakesTodaysListAndKeepsYesterdaysOnFailure(t *testing.T) {
t.Fatalf("~/.codeaf/v3/models.json is not today's list: %+v", cached)
}
}

func TestPickerModelsExcludeCapabilityFallbacksUntilAProviderListsThem(t *testing.T) {
t.Setenv("CODEAF_HOME", t.TempDir())
options := catalog.Options{BaseURL: catalog.DefaultBaseURL, Dir: t.TempDir(), HTTPClient: shelfRouter("", errors.New("offline"))}
models := catalog.Load(t.Context(), options)
shelf := newV3ModelShelf(models, options)
if len(v3Models(shelf)) == 0 {
t.Fatal("the fixture must carry engine capability fallbacks")
}
if offered := shelf.pickerModels(); len(offered) != 0 {
t.Fatalf("unlisted capability fallbacks reached the picker: %+v", offered)
}
options.HTTPClient = shelfRouter(shelfNewRow, nil)
listed, err := catalog.Refresh(t.Context(), options)
if err != nil {
t.Fatal(err)
}
shelf.current.Store(listed)
if offered := shelf.pickerModels(); len(offered) != 1 || offered[0].ID != "vendor/shipped-this-morning" {
t.Fatalf("listed provider rows did not reach the picker: %+v", offered)
}
}
11 changes: 10 additions & 1 deletion cmd/codeaf/chatv3_process.go
Original file line number Diff line number Diff line change
Expand Up @@ -229,7 +229,7 @@ func openV3ProcessWith(door string, askKey bool) (*v3Process, error) {
}
fmt.Fprintln(os.Stderr, "codeaf "+door+" needs a model to talk with.")
if keyless, loadErr := config.LoadKeyless(); loadErr == nil && v3UsesDefaultOpenRouter(keyless) {
fmt.Fprintln(os.Stderr, "run `codeaf` in a terminal to connect OpenRouter, or export "+config.APIKeyEnv+" and run it again.")
fmt.Fprintln(os.Stderr, "run `codeaf` in a terminal to choose a model provider, or export "+config.APIKeyEnv+" and run it again.")
} else {
fmt.Fprintln(os.Stderr, "export "+config.APIKeyEnv+" and run it again.")
}
Expand Down Expand Up @@ -462,6 +462,15 @@ func (p *v3Process) warmEmptyProviders(ctx context.Context) {
return
}
p.Shelf.warmAll(ctx, true, p.noteServiceModels)
// The default catalog warms independently. Deliver its answer on the same
// subscription as direct providers so a cold opening can acquire a model
// without a keystroke or a second fetch.
service := p.Shelf.sourcesNow().Default()
if service.HasCredentials() {
if models := p.Shelf.current.Load(); models != nil && models.Warmed(ctx) {
p.noteServiceModels(service)
}
}
}

// registerServiceNotice subscribes one window and returns its removal function.
Expand Down
31 changes: 31 additions & 0 deletions cmd/codeaf/chatv3_servicenotice_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"net/http"
"net/http/httptest"
"sync"
"sync/atomic"
"testing"

"github.com/Agent-Field/codeaf/internal/catalog"
Expand Down Expand Up @@ -99,3 +100,33 @@ func TestProviderWarmReportsFailureBeforeTheNextProviderFinishes(t *testing.T) {
t.Fatal("failed provider's reason missing")
}
}

func TestTheColdDefaultCatalogNotifiesTheSurfaceWithoutAnotherFetch(t *testing.T) {
t.Setenv("CODEAF_HOME", t.TempDir())
var requests atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests.Add(1)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"data":[{"id":"available-default"}]}`))
}))
defer server.Close()
options := catalog.Options{Dir: t.TempDir(), BaseURL: server.URL, APIKey: "synthetic"}
models := catalog.LoadLazy(t.Context(), options)
defer models.Close()
shelf := newV3ModelShelf(models, options)
service := modelsource.Connected{Source: modelsource.DefaultSource(server.URL), Address: server.URL, Key: "synthetic"}
shelf.setSources(modelsource.NewSet(service))
p := &v3Process{Shelf: shelf}
notices := 0
remove := p.registerServiceNotice(func(source, address string) {
if source != modelsource.DefaultID || address != server.URL {
t.Errorf("notice = %q at %q", source, address)
}
notices++
})
defer remove()
p.warmEmptyProviders(t.Context())
if rows := shelf.pickerModels(); len(rows) != 1 || rows[0].ID != "available-default" || notices != 1 || requests.Load() != 1 {
t.Fatalf("default warm: rows=%+v notices=%d requests=%d", rows, notices, requests.Load())
}
}
23 changes: 23 additions & 0 deletions docs/changes/unreleased/1789-model-picker-confirmation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
---
kind: changed
title: Choose a provider during setup and pick only available models
pr: 1789
surface: [chat, docs]
invalidates:
- "The later add-provider and /connect menus used to omit OpenRouter and treated every saved provider row as connected. Both now include all nine initial setup options and read actual credential availability; the add menu labels connected and not connected rows. OpenRouter can be connected after Ollama through its existing browser/key form without repeating onboarding, losing the draft or removing Ollama. Its profile key can also be disconnected with two-Enter confirmation and is revoked in the running session; shell keys remain connected until unset and restarted."
- "Provider selection used to start on Ollama. OpenRouter is now first and initially selected, with Ollama second; the other seven options keep their order."
- "Choosing Codex during setup used to start browser sign-in immediately. Codex now has the same enter connects in browser confirmation as OpenRouter; selecting the provider opens its connection screen and Enter starts sign-in."
- "Provider setup used to divide supported providers between a first page and More providers, with a selectable Skip for now row. It now has one flat list showing all nine providers, with no scrolling viewport or page counter; Esc skips setup and no provider row skips it."
- "Browser authorization URLs used to wrap across several visible rows. Codex, OpenRouter and the shared browser sign-in cards now show one short hyperlink retaining the complete URL. Setup adds Ctrl+Y to copy the complete sign-in URL; waiting cards retain their full-link copy action."
- "A fresh profile used to ask for OpenRouter before offering any other model provider. Setup now begins with a choice of supported providers, retains the numbered connection and controls screens, and connects Ollama without asking for a key. Back cancels unfinished connections, and working connections bypass the chooser."
- "Enter used to apply a model and leave the picker open until Esc. It now selects the model and closes the list immediately, returning to the conversation, task room, settings, Home draft or task composer."
- "OpenRouter's public catalog used to appear even without an OpenRouter key. Every model picker now includes only provider connections with credentials or explicit anonymous access, including Ollama, and combines their own catalogs when several are connected."
- "A cold picker used to offer five built-in guesses, and filtered or empty catalogs could fall through to older rows. Picker lists now use only the current provider's known catalog or its own cache while warming, with no built-in guesses."
- "A local launch used to display its configured or shipped model even when the picker did not list it. Its default now comes from the available chat catalog; an absent choice is replaced by a listed model, and no known model leaves the draft held until discovery supplies one."
---

The draft is preserved when the model is chosen. A list with no matching model
stays open; Enter inside provider controls keeps their existing navigation.

The fresh-install terminal test confirms the initially selected OpenRouter row
with Enter before checking its connection screen and setup count.
25 changes: 25 additions & 0 deletions internal/config/sourcecancel_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package config

import (
"context"
"errors"
"testing"

"github.com/Agent-Field/codeaf/internal/codexauth"
"github.com/Agent-Field/codeaf/internal/modelsource"
)

func TestCancelledConnectionsCannotSaveCredentials(t *testing.T) {
ctx, cancel := context.WithCancel(t.Context())
cancel()
dir := t.TempDir()
src := modelsource.Source{ID: "custom", Written: "local", KeyOptional: true}
_, err := ConnectService(ctx, dir, PersistedSource{ID: "custom", Written: "local", Address: "http://localhost:1", KeyOptional: true}, src, nil)
if !errors.Is(err, context.Canceled) || len(PersistedSources(dir)) != 0 {
t.Fatalf("cancelled service persisted: %v", err)
}
_, err = ConnectCodex(ctx, dir, codexauth.Tokens{AccessToken: "cancelled-secret", RefreshToken: "cancelled-refresh"})
if !errors.Is(err, context.Canceled) || codexauth.Connected(dir) || len(PersistedSources(dir)) != 0 {
t.Fatalf("cancelled sign-in persisted: %v", err)
}
}
25 changes: 18 additions & 7 deletions internal/config/sources.go
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,9 @@ func CodexRememberedModels(service modelsource.Connected, profileDir string) []c
// ConnectCodex keeps a completed browser sign-in, persists its service row and
// seeds the picker from the account's own visible model list.
func ConnectCodex(ctx context.Context, profileDir string, tokens codexauth.Tokens) (modelsource.Outcome, error) {
if err := ctx.Err(); err != nil {
return modelsource.Outcome{}, err
}
if err := codexauth.Save(profileDir, tokens); err != nil {
return modelsource.Outcome{}, err
}
Expand Down Expand Up @@ -411,7 +414,7 @@ func ConnectService(ctx context.Context, profileDir string, row PersistedSource,
address := resolvedSourceAddress(row, src)
listing := src.Probe
if listing.Method == "" && listing.Address == "" {
if err := persistConnectedSource(profileDir, row); err != nil {
if err := persistSourceUnlessCancelled(ctx, profileDir, row); err != nil {
return modelsource.Outcome{}, err
}
return modelsource.Outcome{Kind: modelsource.OutcomeConnected}, nil
Expand All @@ -427,13 +430,13 @@ func ConnectService(ctx context.Context, profileDir string, row PersistedSource,
}
listed := true
row.Listed = &listed
if err := persistConnectedSource(profileDir, row); err != nil {
if err := persistSourceUnlessCancelled(ctx, profileDir, row); err != nil {
return modelsource.Outcome{}, err
}
return outcome, nil
}
if paymentrefusal.Matches(status, body) {
if err := persistConnectedSource(profileDir, row); err != nil {
if err := persistSourceUnlessCancelled(ctx, profileDir, row); err != nil {
return modelsource.Outcome{}, err
}
return modelsource.Outcome{Kind: modelsource.OutcomeAccountCannotPay, VendorSaid: withoutExactSecret(vendorWords(body), key)}, nil
Expand All @@ -446,7 +449,7 @@ func ConnectService(ctx context.Context, profileDir string, row PersistedSource,
row.Listed = &listed
fallback := src.FallbackProbe()
if fallback.Method == "" && fallback.Address == "" {
if err := persistConnectedSource(profileDir, row); err != nil {
if err := persistSourceUnlessCancelled(ctx, profileDir, row); err != nil {
return modelsource.Outcome{}, err
}
return modelsource.Outcome{Kind: modelsource.OutcomeConnected}, nil
Expand All @@ -456,15 +459,15 @@ func ConnectService(ctx context.Context, profileDir string, row PersistedSource,
return modelsource.Outcome{Kind: modelsource.OutcomeUnanswered}, nil
}
if paymentrefusal.Matches(status, body) {
if err := persistConnectedSource(profileDir, row); err != nil {
if err := persistSourceUnlessCancelled(ctx, profileDir, row); err != nil {
return modelsource.Outcome{}, err
}
return modelsource.Outcome{Kind: modelsource.OutcomeAccountCannotPay, VendorSaid: withoutExactSecret(vendorWords(body), key)}, nil
}
if !acceptsStatus(fallback.Accepts, status) {
return modelsource.Outcome{Kind: modelsource.OutcomeRefused, VendorSaid: withoutExactSecret(vendorWords(body), key)}, nil
}
if err := persistConnectedSource(profileDir, row); err != nil {
if err := persistSourceUnlessCancelled(ctx, profileDir, row); err != nil {
return modelsource.Outcome{}, err
}
return modelsource.Outcome{Kind: modelsource.OutcomeConnected}, nil
Expand Down Expand Up @@ -544,7 +547,7 @@ func connectAtDoor(ctx context.Context, profileDir string, row PersistedSource,
row.Listed = &value
}
}
if err := persistConnectedSource(profileDir, row); err != nil {
if err := persistSourceUnlessCancelled(ctx, profileDir, row); err != nil {
return modelsource.Outcome{}, err
}
return outcome, nil
Expand Down Expand Up @@ -700,3 +703,11 @@ func DisconnectService(profileDir, id string) error {
}
return nil
}

// A cancelled connection must not save an answer from a network trip it left.
func persistSourceUnlessCancelled(ctx context.Context, dir string, row PersistedSource) error {
if err := ctx.Err(); err != nil {
return err
}
return persistConnectedSource(dir, row)
}
5 changes: 4 additions & 1 deletion internal/e2e/tui_e2e_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -231,7 +231,10 @@ func testFreshInstallSetup(t *testing.T) {
// the heading of the step, and the sentence under it that says what pressing
// enter will and will not do.
screen := r.waitFor(20*time.Second,
say(t, "setupTitleWord"), say(t, "setupConnectHeading"), say(t, "setupConnectSentence"))
say(t, "setupTitleWord"), say(t, "setupProviderHeading"))
// OpenRouter is initially selected; Enter must open its connection step.
r.keys("Enter")
screen = r.waitFor(20*time.Second, say(t, "setupConnectHeading"), say(t, "setupConnectSentence"))
t.Logf("a fresh install, launched the ordinary way, is shown the door:\n%s", screen)

// AND IT IS ASKING FOR BOTH. A machine with nothing on it has answered no
Expand Down
6 changes: 5 additions & 1 deletion internal/e2e/tuiwords_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -864,6 +864,10 @@ var tuiWords = map[string]tuiWord{
screen: "setting up",
why: "the dim line over the first-run question, which says where in the flow this is",
},
"setupProviderHeading": {
screen: "choose a model provider",
why: "a fresh install chooses a supported provider before connection or key entry",
},
"setupConnectHeading": {
screen: "connect openrouter",
why: "the heading of the step a fresh install meets first — the whole subject of #322",
Expand Down Expand Up @@ -1151,7 +1155,7 @@ var tuiWords = map[string]tuiWord{
},

"setupNotConnectedNote": {
screen: "openrouter is not connected",
screen: "no model provider is connected",
why: "the dim line the conversation says after esc, which is the other half of a front door: " +
"a person who declined is told the next direct road rather than left on an empty screen",
},
Expand Down
9 changes: 5 additions & 4 deletions internal/manual/chat/accounts.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,12 +71,13 @@ Browser accounts open the account's sign-in page; key accounts collect a key in
message box without starting a browser trip. Neither route writes a credential into
the conversation.

## Signing in through a browser — it opens and the address stays on screen
## Signing in through a browser — open or copy the sign-in link

codeaf starts a loopback listener, opens the sign-in address with this machine's
browser, and writes the address down under `waiting in your browser…` as well. The two
are not alternatives: if this machine has no browser, the written address is still a
way through. The waiting card has a copy affordance; after it is copied the card reads
browser, and shows **open sign-in page** under `waiting in your browser…`. The short
text links to the complete authorization URL without wrapping it across rows. If this
machine has no browser, follow that link in a terminal that supports hyperlinks, or
click the waiting card to copy the full URL. After it is copied the card reads
`copied — paste it wherever you can sign in`.

The loopback addresses tried, in order, are `127.0.0.1:8765`,
Expand Down
Loading
Loading