diff --git a/cmd/codeaf/chatv3.go b/cmd/codeaf/chatv3.go index 472c918c57..424f955ce5 100644 --- a/cmd/codeaf/chatv3.go +++ b/cmd/codeaf/chatv3.go @@ -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, diff --git a/cmd/codeaf/chatv3_host.go b/cmd/codeaf/chatv3_host.go index e373394832..ab21d77550 100644 --- a/cmd/codeaf/chatv3_host.go +++ b/cmd/codeaf/chatv3_host.go @@ -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 diff --git a/cmd/codeaf/chatv3_local.go b/cmd/codeaf/chatv3_local.go index d4963b1c88..6d83eb5d4d 100644 --- a/cmd/codeaf/chatv3_local.go +++ b/cmd/codeaf/chatv3_local.go @@ -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() diff --git a/cmd/codeaf/chatv3_local_test.go b/cmd/codeaf/chatv3_local_test.go index a040c45132..6b32540165 100644 --- a/cmd/codeaf/chatv3_local_test.go +++ b/cmd/codeaf/chatv3_local_test.go @@ -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") } diff --git a/cmd/codeaf/chatv3_modelshelf.go b/cmd/codeaf/chatv3_modelshelf.go index dfe83fea92..adc008864d 100644 --- a/cmd/codeaf/chatv3_modelshelf.go +++ b/cmd/codeaf/chatv3_modelshelf.go @@ -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. diff --git a/cmd/codeaf/chatv3_modelshelf_test.go b/cmd/codeaf/chatv3_modelshelf_test.go index dc0a132ecb..db84a77daf 100644 --- a/cmd/codeaf/chatv3_modelshelf_test.go +++ b/cmd/codeaf/chatv3_modelshelf_test.go @@ -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) + } +} diff --git a/cmd/codeaf/chatv3_process.go b/cmd/codeaf/chatv3_process.go index c06d813f1e..17bf59f444 100644 --- a/cmd/codeaf/chatv3_process.go +++ b/cmd/codeaf/chatv3_process.go @@ -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.") } @@ -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. diff --git a/cmd/codeaf/chatv3_servicenotice_test.go b/cmd/codeaf/chatv3_servicenotice_test.go index e9cf0566bc..dc7a883d4c 100644 --- a/cmd/codeaf/chatv3_servicenotice_test.go +++ b/cmd/codeaf/chatv3_servicenotice_test.go @@ -6,6 +6,7 @@ import ( "net/http" "net/http/httptest" "sync" + "sync/atomic" "testing" "github.com/Agent-Field/codeaf/internal/catalog" @@ -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()) + } +} diff --git a/docs/changes/unreleased/1789-model-picker-confirmation.md b/docs/changes/unreleased/1789-model-picker-confirmation.md new file mode 100644 index 0000000000..c0777ce64c --- /dev/null +++ b/docs/changes/unreleased/1789-model-picker-confirmation.md @@ -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. diff --git a/internal/config/sourcecancel_test.go b/internal/config/sourcecancel_test.go new file mode 100644 index 0000000000..758622a20e --- /dev/null +++ b/internal/config/sourcecancel_test.go @@ -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) + } +} diff --git a/internal/config/sources.go b/internal/config/sources.go index f2a2465f3e..ef7a799cf8 100644 --- a/internal/config/sources.go +++ b/internal/config/sources.go @@ -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 } @@ -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 @@ -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 @@ -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 @@ -456,7 +459,7 @@ 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 @@ -464,7 +467,7 @@ func ConnectService(ctx context.Context, profileDir string, row PersistedSource, 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 @@ -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 @@ -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) +} diff --git a/internal/e2e/tui_e2e_test.go b/internal/e2e/tui_e2e_test.go index c97a69b13d..47d1b39401 100644 --- a/internal/e2e/tui_e2e_test.go +++ b/internal/e2e/tui_e2e_test.go @@ -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 diff --git a/internal/e2e/tuiwords_test.go b/internal/e2e/tuiwords_test.go index 2c27e66a76..6a13bfb558 100644 --- a/internal/e2e/tuiwords_test.go +++ b/internal/e2e/tuiwords_test.go @@ -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", @@ -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", }, diff --git a/internal/manual/chat/accounts.md b/internal/manual/chat/accounts.md index 2b7dff07aa..5bdfcd681d 100644 --- a/internal/manual/chat/accounts.md +++ b/internal/manual/chat/accounts.md @@ -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`, diff --git a/internal/manual/chat/commands.md b/internal/manual/chat/commands.md index 6eabae9741..62793aa19d 100644 --- a/internal/manual/chat/commands.md +++ b/internal/manual/chat/commands.md @@ -1042,11 +1042,12 @@ place with a short list of models under it. It is bottom-anchored, so the conver shrinks above it and nothing pops up over what you were reading. Pressing the model's name on the legend line above the box opens the same picker. -With only the default provider in the list, models have no provider heading. The -default provider stays in the list even without its key: adding a direct provider -such as Ollama therefore draws headings, including the default provider's. Models -sit under their provider's name as a dim heading, default provider first; a custom -provider's heading is the name you gave it. +With only OpenRouter connected, models have no provider heading. OpenRouter is +absent without its key: with only Ollama connected, the list contains only Ollama's +installed models. When direct providers are connected, models sit under their +provider's name as a dim heading; OpenRouter comes first when it has a key, followed +by the connected providers in your saved order. A custom provider's heading is the +name you gave it. `/model ` switches straight to that slug: no list, no confirmation, and no check that the slug exists in any list. If the slug is in no known list, the context window is @@ -1068,15 +1069,14 @@ thinking control takes. enter switches. provider or `auto`; enter pins, ← or tab walks back out. *Providers → Pinning one provider yourself* has the rest. -**Enter chooses and the list stays up; esc is the way out.** Pressing enter on a row -switches to it there and then and leaves the list on screen, so you can compare two models -by their prices, switch, and switch back without reopening anything — and the mark moves to -whatever you just chose. The same is true of a provider inside a fold: enter pins it, the -list stays. +**Enter on a model selects it and closes the list immediately.** The keyboard returns +to your half-typed draft or the settings page that opened the list. To compare another +model, reopen the picker. Enter with no matching model leaves the list open and changes +nothing. Enter inside a provider fold keeps its controls open, including when it opens +OpenRouter's machines or pins a provider. -esc itself changes **nothing** — it closes the list and gives your half-typed draft and the -frame back as they were. What enter already did is already done; esc does not undo it. The -filter is forgotten when the picker closes. +Esc closes without choosing another model and restores the draft and frame. Provider +changes already made stay done. The filter is forgotten when the picker closes. ## Where the /model cursor opens — Enter confirms the model in use @@ -1127,20 +1127,24 @@ row under the cursor. Choosing a model in `/model` sets it on the agent, teaches the surface its context window and tells the session — compaction fires at a fraction of that window, so this is not decoration — notes `model · `, and writes the choice into your profile, so the next `codeaf` -opens on it. Over `--host` the switch takes for the session and is not written down: the +opens on it if it is still in the available model list. Otherwise a local launch chooses +an available chat model; with no known models, it holds the draft until discovery supplies +one. Over `--host` the switch takes for the session and is not written down: the model a remote session opens on is that machine's to resolve. ## What the model picker lists, and what it will not do At launch, codeaf fetches a connected provider's model list once if it has an empty cached list. After that, `ctrl+r` asks for a fresh list (see "Refreshing the model list" below). -The list is what is already known, tried in this order, -each rung used only when the one above it came back empty after filtering: - -1. the catalog handed in at launch, -2. `~/.codeaf/v3/models.json`, -3. five names this build remembers: `deepseek/deepseek-v4-flash`, `openai/gpt-4.1-mini`, - `anthropic/claude-sonnet-4.5`, `google/gemini-2.5-flash`, `moonshotai/kimi-k3`. +The list offers models only from usable provider connections: a key, a signed-in +account, or an explicitly anonymous connection such as Ollama. With only Ollama +connected, only its installed models appear. With several providers connected, their +models appear together. An unconnected provider contributes no rows, even when its +public catalog or cache is available. + +Each provider contributes its known catalog, or its own cached list while discovery +is warming. A known catalog is authoritative; an empty list never adds built-in +model guesses. Slots still keep only models with their required capabilities. Filtering is over the model's **name** and nothing else — no word in the box means anything but itself. It splits your text on whitespace and every word must match, each diff --git a/internal/manual/chat/getting-started.md b/internal/manual/chat/getting-started.md index ef19da62fd..0f7b8ffbbf 100644 --- a/internal/manual/chat/getting-started.md +++ b/internal/manual/chat/getting-started.md @@ -13,23 +13,37 @@ terminal* page, under *How do I install or update codeaf*. ## Getting started — first time setup, what happens the first time I run codeaf -The first time `codeaf` opens a new local conversation on a profile with nothing in it, -setup appears in the chat instead of an empty prompt and a provider error. When the -default provider needs a key and no daily limit is configured, it has **two screens** — under a minute, -nothing else on the frame: - -1. **connect openrouter** — the default provider; `enter` signs in in your browser, and pasting an existing key also works -2. **Basic settings** — one screen with two controls on it, **Daily limit** and - **Chat model**, each already showing the value that is in force - -**With a key already found, there is no setup screen.** When a provider key is saved in -the profile or set in the environment, such as `OPENROUTER_API_KEY`, a plain launch skips -the connection screen and **Basic settings**. With nothing elsewhere to show, it -opens the chat's greeting, `What would you like to work on?`; when other conversations -are available, it opens home. `/budget` sets a daily limit -later. A `--no-host` launch on such a profile skips only the connection screen, -and still opens **Basic settings** while no daily limit is set. A resumed conversation, -or one on another machine, never opens first-run setup. +The first time `codeaf` opens a new local conversation with no provider connected, +setup begins with **choose a model provider**. Its one flat list shows all nine options: +**OpenRouter**, **Ollama**, **Codex**, **DeepSeek**, **Z.ai**, **Moonshot**, **MiniMax**, +**Alibaba Qwen**, and **Custom OpenAI-compatible API**. There is no scrolling viewport +or page counter. Arrow keys or the mouse wheel move the selection; `home` and `end` +jump to either end. Press `enter` or click a row to choose it. No key is collected on the chooser. + +Choosing a provider opens its connection screen, followed by **Basic settings**: + +1. **Connect the chosen provider** — OpenRouter offers browser sign-in or an existing + key; Codex signs in to a ChatGPT plan in your browser. Both connection screens + say `enter connects in browser` and wait for Enter before opening sign-in; Ollama checks the local server + and loads its installed models without asking for a key. Other providers use their + existing region and key flow. Custom API asks for an address and name, then a key + only if that server requires one. Keys are masked on screen. +2. **Basic settings** — **Daily limit** and **Chat model**, each showing its current value. + +**With a key already found**, a plain launch skips the chooser and **Basic settings**. +With nothing elsewhere to show it opens `What would you like to work on?`; with other +conversations it opens home. `/budget` sets a daily limit later. `--no-host` still shows +**Basic settings** when the daily limit is unset. A working direct connection also +bypasses provider selection. + +## Back to provider selection and existing connections + +`alt+left` returns from a connection screen to provider selection. **Back** is also +clickable on the direct-provider forms. Leaving an unfinished connection cancels it; +a late browser response cannot switch the model or advance setup. Provider connection +checks use the same checks as `/connect`; some direct services need a small model probe +when they do not expose a model listing. Ollama's setup only lists models. + The second screen's way out is **`Start a conversation`**. Every control on it opens on the value you already have, so pressing `enter` there agrees to exactly what is on the @@ -48,14 +62,15 @@ returns on the next local interactive launch because that model cannot work with conversation on a connected direct provider's model does not owe OpenRouter a key, so that step stays away. -Codex is deliberately not another first-run step. After setup, its browser sign-in is -available from the Codex row in `/connect`, or from `codeaf connect codex` without -opening the chat. +Codex is offered on the provider chooser. You can also add it later through `/connect` +or `codeaf connect codex`. There is no separate More providers or Skip for now row. +`esc` skips setup on the provider list or an idle connection screen. While a browser +sign-in or provider check is running, `esc` cancels it; a second `esc` skips setup. -The header reads `codeaf`, with `setting up · 1 of 2` under the wordmark on the first of two -steps. With only one step it reads `setting up`, without a count. The foot names the keys that work on the row you -are standing on — `tab` walks the rows, `?` opens a control's detail — and on a narrow -window it is cut by whole clauses rather than mid-word. +The chooser reads `setting up` without a count. After a provider is chosen, the +connection screen retains `setting up · 1 of 2` when both connection and controls are +needed; a connection-only flow reads `setting up`. Existing working connections bypass +the provider chooser, including while their model catalog is loading. **On a window too short for the whole screen the explanations are what go**, a whole sentence at a time and never half of one. The two values, `Start a conversation` and @@ -64,7 +79,7 @@ can answer and leave. ## Set up my api key — the default provider's openrouter key step, and what happens with no key -On a local interactive launch using codeaf's built-in default model provider, the first step reads +On a local interactive launch using codeaf's built-in default model provider, choose **OpenRouter** on the provider chooser to open *connect openrouter*. Press `enter`: codeaf opens OpenRouter in your browser, waits on a random return address bound only to `127.0.0.1`, and uses an S256 proof key for the trip. After you sign in and approve it, OpenRouter makes a user-controlled API key for the default provider in this @@ -72,10 +87,20 @@ profile and sends the browser back to codeaf. The browser says it is connected, continues, and the running conversation can use the key immediately. No prompt is sent and no model is called during the connection. -The address is also written on the waiting screen. If the browser cannot be opened, select -or click that address yourself. `esc` while waiting cancels the return listener and leaves +The waiting screen shows **open sign-in page**, a short hyperlink whose target is the +complete authorization URL. If the browser cannot be opened, follow that link or press +`ctrl+y` to copy the whole URL and paste it in your browser. `esc` while waiting cancels the return listener and leaves you on the default provider's OpenRouter step; another `enter` tries again. +## Opening or copying a browser sign-in link — long authorization URLs + +OpenRouter and Codex setup show **open sign-in page** as one clickable line. The full +URL, including every sign-in parameter, is the hyperlink target; it is not split across +visible rows. `ctrl+y` copies that complete URL while waiting and says `sign-in link copied`. +This also works when the terminal does not support clickable hyperlinks. `esc` cancels +the sign-in; `alt+left` returns to the provider list. Outside setup, browser sign-in cards +also use **open sign-in page** and a click on the waiting card copies its full URL. + ## What the setup screen says when something goes wrong Every refusal on this screen is a sentence about what happened and what to do — never a @@ -98,7 +123,7 @@ can act on. ## Paste an existing OpenRouter API key for the default provider instead of connecting in the browser -Already have a key? Paste it on the same first screen instead of pressing `enter` on an +Already have a key? Choose **OpenRouter** and paste it on the connection screen instead of pressing `enter` on an empty box. The key is masked while it is typed, and the manual-key address remains on the screen: `https://openrouter.ai/settings/keys`. A pasted key is checked for **shape only** — it has to start with `sk-`, be at least 20 characters long, and hold no spaces. Nothing is @@ -116,9 +141,8 @@ conversation takes it at once — the next message rides it, no restart. `esc` on the idle step skips setup. When the conversation is using the default provider, it then says one dim line: -`openrouter is not connected · enter on your message connects in a browser, or export -OPENROUTER_API_KEY`. Your draft is not sacrificed to a provider error: type it normally and -press `enter`, and the one-step connection opens over the conversation before the draft is +`no model provider is connected · enter on your message chooses a provider, or use /connect`. Your draft is not sacrificed to a provider error: type it normally and +press `enter`, and the provider chooser opens over the conversation before the draft is cleared. Connect, then press `enter` again to send those same words. When the conversation is on a connected direct provider's model, pressing `enter` sends @@ -305,9 +329,9 @@ closes — finished or skipped — `setup_seen_at` is written into `config.json` and no later launch asks those preference questions again. Skipping with `esc` counts as shown. -The **OpenRouter connection is a prerequisite, not a preference**, and is not suppressed by -that marker. It returns as a one-step screen on a later eligible launch while the key is -still missing. It can also return in the same launch when an unsent model message reaches +The **provider connection is a prerequisite, not a preference**, and is not suppressed by +that marker. The provider chooser returns on a later eligible launch while the conversation +has no usable provider. It can also return in the same launch when an unsent model message reaches `enter`; the draft stays in the box. That prerequisite is only for the default provider during first run. A second provider is @@ -337,7 +361,7 @@ Every answer went through a settings row, so every answer has a door: | What you answered | Where to change it later | | --- | --- | -| the default provider's openrouter key | clear or remove it and the next local interactive launch offers **connect openrouter** again; `/settings`, Connections category, the **openrouter key** row still accepts a pasted replacement | +| the default provider's openrouter key | clear or remove it and the next local interactive launch offers the provider chooser again; `/settings`, Connections category, the **openrouter key** row still accepts a pasted replacement | | the crew | nothing was asked — it is auto. `/crew` shows it, and `/crew pin ` pins a seat | | the daily limit | `/budget` (also `/limits`), or `/settings` → **Spending**. `CODEAF_DAILY_BUDGET` in your shell outranks the row | | the model you talk to | `/model`, or the **Chat model** row on the setup screen — the same settings row either way | diff --git a/internal/manual/chat/keys.md b/internal/manual/chat/keys.md index 9ec99a63f1..d6994dc775 100644 --- a/internal/manual/chat/keys.md +++ b/internal/manual/chat/keys.md @@ -1727,7 +1727,7 @@ follows what you type. Only these keys are taken from you: in the status row, or by `enter` on the **your model** row of the settings panel's Models category (the same list and the same keys, drawn in the panel's place): -`esc` close · `enter` switch to the highlighted model · `ctrl+t` cycle the reasoning +`esc` close · `enter` switch to the highlighted model and close the list · `ctrl+t` cycle the reasoning effort · `ctrl+r` fetch the newest model list (`/model` only — not the settings panel's rows) · `tab` and `→` open the providers under the model the cursor is on and move the cursor into them, `tab` and `←` close them and put it back on the model · diff --git a/internal/manual/chat/models-and-cost.md b/internal/manual/chat/models-and-cost.md index 69d55f662b..e773b67333 100644 --- a/internal/manual/chat/models-and-cost.md +++ b/internal/manual/chat/models-and-cost.md @@ -80,6 +80,25 @@ Over `--host`, the picker and its prices are this laptop's catalog, while the co window used for compaction comes from the far machine's catalog. The machine doing the work owns that execution limit even when the two catalog caches differ. +## Which model is the default — Ollama only, a removed model, or no available models + +On a local launch, the opening model comes from the same available chat list as +`/model`. A saved choice, environment value, command-line choice or shipped preference +is kept only if that model is listed. Otherwise codeaf selects the first available +chat model in provider order. With only Ollama connected, that is an installed Ollama +model. With several providers, the default still belongs to their combined list. +Automatic replacements do not overwrite your saved preference. + +While every catalog is cold or empty, no default model is displayed and a message +stays in the draft. The picker opens with: +`no available model · connect a provider or refresh /model`. +When a provider's list arrives, codeaf selects an available model. Press Enter again +to send the draft. Refreshing a list or removing a connection also replaces a model +that is no longer listed; existing requests finish on the model they started with. + +Remote sessions keep the engine's opening model; this laptop's catalog does not +choose a default for another machine. + ## Sign in with ChatGPT and use my Codex plan — models, context window, price, limits and expiry Open `/connect`, choose **Codex**, and finish the browser sign-in. This signs in the way @@ -118,8 +137,8 @@ An expired sign-in says: codex sign-in has expired · /connect or codeaf connect codex signs in again ``` -This sign-in does not add an OpenAI API key, cannot connect a custom endpoint, does not -put Codex on first-run setup, and does not replace codeaf's own instructions with the +Codex is available on the first-run provider chooser and later through `/connect`. +This sign-in does not add an OpenAI API key, cannot connect a custom endpoint, and does not replace codeaf's own instructions with the Codex CLI's base instructions. Use the custom-service row for an OpenAI-compatible API. ## Can I switch models while it is replying — I changed the model in the middle of an answer, does it change now or wait? @@ -177,10 +196,9 @@ written anywhere: the model a remote session opens on is resolved on that machin that machine's profile. And reasoning effort is kept per model for the session, not written to the profile. -esc closes the picker and **undoes nothing**. It gives your half-typed draft and the frame -back as they were — the picker holds its own filter text, and the filter is forgotten when -it closes — but the model in use does not come back: enter already switched it, then and -there, and esc is only the way out. To go back to the model you were on, choose it. +Enter on a model switches to it and closes the picker. Your half-typed draft and the +frame return as they were; the filter text is forgotten. Esc closes the picker without +choosing another model. To go back to the model you were on, open the picker and choose it. ## Moving and filtering in the model picker @@ -194,8 +212,8 @@ Type to filter. The keys: | ctrl+t | walk the reasoning effort of the model under the cursor | | tab, → | open the providers — the providers serving the model under the cursor — and move the cursor into them | | tab, ← | close them again, back on the model | -| enter | switch to the row under the cursor — or, on an open provider, pin it — and **leave the list up** | -| esc | close it; what enter already did stays done | +| enter | choose the highlighted model and close the list; on an open provider, pin it and keep its controls open | +| esc | close the list without choosing another model; provider changes already made stay done | | alt+s, alt+shift+s | order the list by the next column, and turn that column round | ## Why left and right arrows do the wrong thing in the model picker — the caret and the providers share one pair of keys @@ -242,10 +260,29 @@ and the task composer walk three rows a notch, clamped at both ends, while the p beneath stays put. The cursor's row stays on screen with headings and extra lines included in the window's size. -**Enter does not close the list.** It switches, the mark moves to the row you chose, and -the list stays where it is — so two models can be compared on their prices, chosen between, -and changed back without reopening anything. `esc` is the way out, and it undoes nothing: -what enter did is already done. +**Enter on a model chooses it and closes the list immediately.** This applies to the +conversation, a task's model, settings slots and roles, home's draft and the task composer. +The keyboard returns to the draft or page you opened the list from. To compare another +model, reopen the list. With no matching model, Enter leaves the list open and changes +nothing. Enter inside a provider fold keeps those controls open, including when it opens +the machines under OpenRouter. + +## Which models appear — only connected providers, Ollama without OpenRouter, multiple providers + +Model lists offer only models from connections with a key, a signed-in account, or an +explicitly anonymous endpoint such as connected Ollama. A public catalog or a leftover +cache does not connect a provider. With only Ollama connected, only its installed models +appear; with only OpenRouter connected, only its models appear. With several providers +connected, their lists appear together in provider groups. Removing a connection or its +key removes its models from subsequent lists. A cold catalog never adds built-in guesses. + +This applies to `/model`, task rooms, settings slots and roles, Home drafts and the task +composer. Each slot still keeps only models with the capabilities it needs. A provider +that cannot list models has a non-selectable notice; `+ add a provider` opens connection +setup and is not a model. That menu includes all nine initial provider options, including +OpenRouter, and labels each `connected` or `not connected`. You can add OpenRouter after +Ollama without repeating onboarding; both catalogs then appear together. `ctrl+r` +refreshes the known lists. ## Searching the model picker by name @@ -262,10 +299,9 @@ At launch, codeaf fetches a connected provider's model list once if it has an em Pressing `ctrl+r` in the picker asks for a fresh list (the *commands* page, "Refreshing the model list"). Otherwise the list comes from what is already known, in this order: the -catalog the door passed in, then `~/.codeaf/v3/models.json`, then five names this build -remembers (`deepseek/deepseek-v4-flash`, `openai/gpt-4.1-mini`, -`anthropic/claude-sonnet-4.5`, `google/gemini-2.5-flash`, `moonshotai/kimi-k3`). Each rung is -tried only when the one above it came back empty after filtering. +catalog the door passed in, or that provider's cached list while discovery is warming. +A known catalog is authoritative. A cold list offers no +built-in guesses; connect a provider or refresh to discover its models. **The `/model` you typed stays in the box**, drawn as the chip it was, with the filter after it: `› /model filter by name`. The list is a different box from the one you typed the command diff --git a/internal/manual/chat/services.md b/internal/manual/chat/services.md index 959c559eb1..109346d4df 100644 --- a/internal/manual/chat/services.md +++ b/internal/manual/chat/services.md @@ -7,11 +7,13 @@ something else again: long-running background processes, covered by their own pa ## Add a key — connect a provider, add an api key, use a different provider An api key for another provider, or another model provider, is added here. Open `/connect` or -`/connections`. The `providers` group lists DeepSeek, Z.ai, Moonshot, MiniMax, Alibaba Qwen, Codex, -Ollama and **Custom OpenAI-compatible API**, followed by any provider already connected and, once +`/connections`. The `providers` group includes all nine setup options: OpenRouter, Ollama, +Codex, DeepSeek, Z.ai, Moonshot, MiniMax, Alibaba Qwen and **Custom OpenAI-compatible API**. +Connected providers appear first, followed by the remaining options and any saved custom instances. Once a custom provider is connected, a `+ add a provider` row. Codex says `browser`; it signs -in a ChatGPT plan instead of asking for an API key. Ollama needs no key. The other named vendors -ask for theirs. +in a ChatGPT plan instead of asking for an API key. OpenRouter offers the same browser/key +screen as setup: `enter connects in browser`, or paste an existing key. Ollama needs no key. +The other named vendors ask for theirs. Pick a row and answer its fields. A successful listed provider says `deepseek-direct is connected · 6 models`; one without a list says only `deepseek-direct is connected`. A provider with more than one billing door names the one it @@ -22,12 +24,28 @@ then shows the provider, door, safe spelling of its key, region and order. The default provider remains first. With two or more providers, `/model` groups models by provider in that order; with only the default provider, the picker remains ungrouped. +## Add OpenRouter after Ollama — add another provider from the model menu + +Open `/model` and choose `+ add a provider`. Its provider list shows the same nine +options as initial setup, with `connected` or `not connected` beside each. Local servers +found on this machine appear separately. An unconnected row starts its connection; +a connected row says `enter manages` and opens `/connect` focused on that provider. +The custom API option says `connected · add another` when a custom connection exists; +it always adds another address, while `/connect` manages saved custom instances. + +To add OpenRouter while using Ollama, select **OpenRouter**, then press Enter to sign in +in your browser or paste an existing key and press Enter to save it. `esc close` +returns to the provider menu; during browser sign-in, Esc cancels the attempt first. +This does not repeat onboarding or replace your draft. Ollama stays connected, and +`/model` combines both providers' available models. Adding a provider does not change a +current model that is still available. + ## Using codeaf with only a direct provider — no OpenRouter key at all Yes. When the conversation is on a model from a connected provider, that provider can carry the turn without an OpenRouter key. Pressing `enter` sends the message; the setup screen does not open, and codeaf does not show -`openrouter is not connected · enter on your message connects in a browser, or export OPENROUTER_API_KEY`. +`no model provider is connected · enter on your message chooses a provider, or use /connect`. Ollama counts as connected without a key because its local provider explicitly needs none. The small background calls follow the same road — naming a session, titling a task, the @@ -198,6 +216,12 @@ the disconnected sentence first and then says `this conversation was on deepseek-direct/deepseek-v4-pro · it is now on ~deepseek/deepseek-v4-flash-latest`, or, when nothing can replace it, `this conversation was on deepseek-direct/deepseek-v4-pro and nothing else here can take it · connect a provider or pick a model`. +OpenRouter uses the same two-Enter confirmation. A saved profile key is cleared from the +profile and the running session; its models disappear, while Ollama and other connected +providers remain usable. A shell credential cannot be removed by the menu: it names the +shell variable and asks you to unset it and restart. A provider answering the current +turn must finish before it can be disconnected. + ## Model names carry the provider they came from The default provider's model ids remain unchanged and unqualified. A model from another diff --git a/internal/manual/chat/starting-codeaf.md b/internal/manual/chat/starting-codeaf.md index 030ce40e72..9db6c071d8 100644 --- a/internal/manual/chat/starting-codeaf.md +++ b/internal/manual/chat/starting-codeaf.md @@ -54,7 +54,7 @@ moved by hand: If you have seen “CodeAF” as the name of a coding harness in a benchmark table, that is a different program and nothing here talks to it. -## Starting it +## Starting it — how do I start codeaf | What you type | What you get | | --- | --- | @@ -64,19 +64,19 @@ that is a different program and nothing here talks to it. | `codeaf resume` | the chat, opened on the picker of earlier conversations | | `codeaf chat --host devbox` | the chat here, the work on another machine | -**The very first launch on a machine with nothing configured** opens on a short setup -instead — connect OpenRouter in your browser, check the chat model, set the spending rails — -and then on the empty conversation. The preference questions are shown once. The -OpenRouter step returns on any later local interactive launch while no key exists, -including a named or resumed conversation using the default service, and `enter` on an -unsent message brings it back without clearing the draft. A conversation on a connected -direct service's model sends without an OpenRouter key and does not open that step. The -getting-started page has the whole flow. First run does not offer Codex; connect a -ChatGPT plan later from the Codex row in `/connect` or with `codeaf connect codex`. +**The very first launch on a machine with nothing configured** begins with **choose a +model provider**. One flat list shows all nine options: OpenRouter, Ollama, Codex, DeepSeek, +Z.ai, Moonshot, MiniMax, Alibaba Qwen and the custom API connection. +The chosen provider's connection screen follows, then the chat model and daily limit. +Ollama asks for no key. OpenRouter and Codex say `enter connects in browser` on their +connection screens and wait for Enter before opening sign-in. `alt+left` returns to provider choice; `esc` skips an idle setup +screen. The preference questions appear once. When a later local conversation still +needs a provider, the chooser returns without clearing its draft. A working connection +bypasses provider selection. The getting-started page describes the whole flow. A `--once` or piped run cannot open a browser. When its model uses the keyless default service it stops at the door with `codeaf chat needs a model to talk with.` Its next line -says to run bare `codeaf` in a terminal to connect OpenRouter, or to export +says to run bare `codeaf` in a terminal to choose a model provider, or to export `OPENROUTER_API_KEY`. A connected direct service can carry that run instead. A custom `CODEAF_BASE_URL` is never offered the OpenRouter connection. That variable still changes only the default service. To add a supported second place diff --git a/internal/manual/chatdisplaytruth_test.go b/internal/manual/chatdisplaytruth_test.go index fce1c6b799..2e63ae4471 100644 --- a/internal/manual/chatdisplaytruth_test.go +++ b/internal/manual/chatdisplaytruth_test.go @@ -27,15 +27,17 @@ func TestHomeManualQualifiesTheFreshProjectConversationTab(t *testing.T) { } } -func TestModelManualCountsTheDefaultProviderWithoutAKey(t *testing.T) { +func TestModelManualListsOnlyUsableProviderConnections(t *testing.T) { answer := chatSection(t, "commands", "/model — pick a model") - for _, fact := range []string{"only the default provider", "even without its key", "Ollama", "headings"} { + for _, fact := range []string{"only OpenRouter connected", "absent without its key", "only Ollama connected", "installed models", "connected providers", "selects it and closes the list immediately"} { if !strings.Contains(answer, fact) { t.Errorf("model picker does not explain %q:\n%s", fact, answer) } } - if strings.Contains(answer, "With one connected provider") { - t.Error("model picker still counts connected providers instead of its registered default") + for _, stale := range []string{"even without its key", "Enter chooses and the list stays up"} { + if strings.Contains(answer, stale) { + t.Errorf("model picker still promises retired behavior: %q", stale) + } } } diff --git a/internal/modelsource/modelsource.go b/internal/modelsource/modelsource.go index 30cb159da0..727635266b 100644 --- a/internal/modelsource/modelsource.go +++ b/internal/modelsource/modelsource.go @@ -179,6 +179,12 @@ type Connected struct { PlanPaused string } +// HasCredentials reports whether the connection can authenticate, or explicitly +// accepts anonymous requests. A public catalog alone never connects an account. +func (c Connected) HasCredentials() bool { + return strings.TrimSpace(c.Key) != "" || c.Source.KeyOptional +} + // Set is the services this profile talks to, in the person's own order, the // default service first and always present. type Set struct { diff --git a/internal/tui3/addprovider.go b/internal/tui3/addprovider.go index a0c879eb2b..e4a5b60002 100644 --- a/internal/tui3/addprovider.go +++ b/internal/tui3/addprovider.go @@ -188,7 +188,8 @@ type addProviderItem struct { // flow the add row started. custom bool // sourceID is the vendored provider a row starts (startModelConnect). - sourceID string + sourceID string + connected bool } type addProviderPanel struct { @@ -209,7 +210,7 @@ type addProviderPanel struct { err string } -func (p *addProviderPanel) rebuild(probes []LocalServerProbe, catalog []modelsource.Source) { +func (p *addProviderPanel) rebuild(probes []LocalServerProbe, catalog []modelsource.Source, sources modelsource.Set) { var items []addProviderItem if p.loading { items = append(items, addProviderItem{heading: true, title: "looking on this machine…"}) @@ -225,27 +226,39 @@ func (p *addProviderPanel) rebuild(probes []LocalServerProbe, catalog []modelsou } } items = append(items, addProviderItem{heading: true, title: "providers"}) - if len(catalog) == 0 { - catalog = modelsource.Vendored() - } - for _, source := range catalog { + for _, source := range providerCatalog(catalog) { + service, held := sources.ByID(source.ID) + connected := held && service.HasCredentials() + detail := "key" switch { - case strings.EqualFold(source.ID, modelsource.CustomID), strings.EqualFold(source.ID, modelsource.DefaultID): - // Custom has its own typed-address row below; openrouter is the - // default service and connects through its own key, not here. - case source.ID == "codex": - items = append(items, addProviderItem{title: source.Name, detail: "browser", sourceID: source.ID}) + case source.ID == modelsource.DefaultID || source.ID == "codex": + detail = "browser" case source.KeyOptional: - items = append(items, addProviderItem{title: source.Name, detail: "address", sourceID: source.ID}) + detail = "address" case len(source.Regions) > 0: - items = append(items, addProviderItem{title: source.Name, detail: "region · key", sourceID: source.ID}) - default: - items = append(items, addProviderItem{title: source.Name, detail: "key", sourceID: source.ID}) + detail = "region · key" + case source.ID == modelsource.CustomID: + detail = "address · key" + } + custom := source.ID == modelsource.CustomID + if custom { + for _, instance := range customInstances(sources) { + connected = connected || instance.HasCredentials() + } } + if connected { + detail = "connected · enter manages" + if custom { + detail = "connected · add another" + } + } else { + detail = "not connected · " + detail + } + items = append(items, addProviderItem{ + title: source.Name, detail: detail, sourceID: source.ID, + custom: custom, connected: connected, + }) } - items = append(items, addProviderItem{ - title: "any OpenAI-compatible server", detail: "address · key", custom: true, - }) p.items = items p.cursor = 0 for i, it := range p.items { @@ -323,6 +336,10 @@ func (a *app) addPanelKey(msg tea.KeyPressMsg) tea.Cmd { switch { case item.custom: return a.startCustomAdd(false) + case item.connected: + p.close() + a.openModelConnection(item.sourceID) + return nil case item.sourceID != "": source, found := a.modelSource(item.sourceID) if !found { @@ -446,7 +463,7 @@ func (a *app) openAddProvider(inSheet bool) tea.Cmd { p.probeContext, p.probeCancel = context.WithCancel(ctx) p.open = true p.loading = true - p.rebuild(nil, nil) + p.rebuild(nil, a.modelCatalog, a.sources) a.touch() // THE FRAME AND THE WALK GO OUT TOGETHER: the panel is up this frame, and // the probe's landing rebuilds it with what the machine answered. diff --git a/internal/tui3/addprovider_scroll_test.go b/internal/tui3/addprovider_scroll_test.go index 56180f30b5..ae0ef0103a 100644 --- a/internal/tui3/addprovider_scroll_test.go +++ b/internal/tui3/addprovider_scroll_test.go @@ -5,6 +5,8 @@ import ( "fmt" "strings" "testing" + + "github.com/Agent-Field/codeaf/internal/modelsource" ) func TestAddProviderSelectionStaysVisibleInShortTerminal(t *testing.T) { @@ -12,7 +14,7 @@ func TestAddProviderSelectionStaysVisibleInShortTerminal(t *testing.T) { for _, height := range []int{1, 3, 5} { t.Run(fmt.Sprintf("rows_%d", height), func(t *testing.T) { p := addProviderPanel{open: true} - p.rebuild(nil, nil) + p.rebuild(nil, nil, modelsource.Set{}) assertVisible := func() { t.Helper() selected, ok := p.current() diff --git a/internal/tui3/addprovider_test.go b/internal/tui3/addprovider_test.go index 0342c60f25..0d3c45633c 100644 --- a/internal/tui3/addprovider_test.go +++ b/internal/tui3/addprovider_test.go @@ -9,6 +9,8 @@ import ( "strconv" "strings" "testing" + + "github.com/Agent-Field/codeaf/internal/modelsource" ) func TestProbeOpenAIEndpointSuccess(t *testing.T) { @@ -101,7 +103,7 @@ func TestProbeLoopbackPort(t *testing.T) { func TestAddProviderPanelRebuild(t *testing.T) { var p addProviderPanel - p.rebuild(nil, nil) + p.rebuild(nil, nil, modelsource.Set{}) if len(p.items) == 0 { t.Fatal("expected items, got none") } @@ -115,7 +117,7 @@ func TestAddProviderPanelRebuild(t *testing.T) { probes := []LocalServerProbe{ {Port: 8317, Name: "127.0.0.1:8317", Address: "http://127.0.0.1:8317/v1", Models: 12}, } - p.rebuild(probes, nil) + p.rebuild(probes, nil, modelsource.Set{}) if p.items[0].title != "found on this machine" { t.Fatalf("expected 'found on this machine' heading, got %q", p.items[0].title) } diff --git a/internal/tui3/app.go b/internal/tui3/app.go index f1ff7aa811..d42884fa58 100644 --- a/internal/tui3/app.go +++ b/internal/tui3/app.go @@ -834,13 +834,14 @@ type ( err error } modelConnectResultMsg struct { - service string - name string - written string - keyEnv string - outcome modelsource.Outcome - models []Model - err error + setupAttempt *setupProviderAttempt + service string + name string + written string + keyEnv string + outcome modelsource.Outcome + models []Model + err error // browser says this result owns a waiting browser card. Word is the // shared terminal-and-panel sentence that settles that card. browser bool @@ -2265,9 +2266,10 @@ type app struct { // models is the door's model list, asked for at the moment the picker // opens rather than at boot — a lazily warmed catalog may have arrived in // between, and it must never be waited for. Nil falls through to the cache - // and the built-ins (see [app.modelList]). - models func() []Model - modelsForService func(modelsource.Connected) []Model + // while warming (see [app.modelList]). + models func() []Model + requireListedModel bool + modelsForService func(modelsource.Connected) []Model // refreshModels is the door's fetch of today's list ([Options. // RefreshModels]), nil where the door has none — which removes the key. // modelsFetching is whether one is out, kept here rather than on the @@ -2981,6 +2983,7 @@ func newApp(ctx context.Context, opts Options) *app { updateAuto: opts.UpdateAuto, restart: opts.Restart, models: opts.Models, + requireListedModel: opts.RequireListedModel, modelsForService: opts.ModelsForService, sources: opts.Sources, refreshModels: opts.RefreshModels, @@ -3150,6 +3153,7 @@ func newApp(ctx context.Context, opts Options) *app { } if a.agent != nil { a.model = a.agent.Model() + a.ensureAvailableModel() // A resumed session is already named, and the name is a fact about the // conversation on screen: it belongs in the first frame, not after the // next turn (session's title.go re-names nothing). @@ -3694,7 +3698,7 @@ func (a *app) route(msg tea.Msg) (tea.Model, tea.Cmd) { } selected, hadSelection := p.current() p.loading = false - p.rebuild(msg.probes, nil) + p.rebuild(msg.probes, a.modelCatalog, a.sources) if hadSelection { for i, item := range p.items { if !item.heading && (selected.custom && item.custom || selected.sourceID != "" && item.sourceID == selected.sourceID) { @@ -4453,6 +4457,9 @@ func (a *app) route(msg tea.Msg) (tea.Model, tea.Cmd) { // The controls screen answers a press on its own rows the way the // keys would (onboarding.go's [app.setupPress]); the key step, which // is one box, takes nothing from the pointer. + if msg.Mouse().Button == tea.MouseLeft && a.setup.step() == setupKey { + return a, a.setupProviderPress(msg.Mouse().X, msg.Mouse().Y) + } if msg.Mouse().Button == tea.MouseLeft && a.setupPress(msg.Mouse().X, msg.Mouse().Y) { return a, a.endSetup(false) } @@ -5344,6 +5351,9 @@ func (a *app) route(msg tea.Msg) (tea.Model, tea.Cmd) { return a, a.adoptCodexFlow(msg) case modelConnectResultMsg: + if msg.setupAttempt != nil { + return a, a.adoptSetupProviderResult(msg) + } a.adoptModelConnectResult(msg) return a, nil @@ -6548,6 +6558,7 @@ func (a *app) settle() tea.Cmd { a.state = stateIdle } a.applyDeferredModelServiceMove() + a.ensureAvailableModel() // A turn that is over is a turn nothing is outstanding on: the clock stops // here rather than at the next turn's start, so a session left idle for an // hour cannot open its next turn holding an hour-old anchor. @@ -6987,6 +6998,10 @@ func (a *app) submitting(text string, start func() (<-chan session.Event, error) // plain carries the words the person demoted with backspace, as ranges into // shown, so the transcript leaves them plain (entry.plainTags). func (a *app) submittingShown(text, shown string, plain []segment, start func() (<-chan session.Event, error)) tea.Cmd { + if !a.ensureAvailableModel() { + a.note(noAvailableModelWord) + return nil + } if a.deferHosted(func() tea.Cmd { return a.submittingShown(text, shown, plain, start) }) { return nil } diff --git a/internal/tui3/chrome_test.go b/internal/tui3/chrome_test.go index 5ae711a5bf..2a09954fcc 100644 --- a/internal/tui3/chrome_test.go +++ b/internal/tui3/chrome_test.go @@ -446,13 +446,9 @@ func TestASettingsSelectSubmenuSwitchesTheModel(t *testing.T) { t.Fatalf("the walk did not reach the other model, it is on %q", chosen) } drive(t, a, key("enter")) - // ENTER WRITES AND LEAVES THE LIST UP ([app.pickerKey] argues it). - if a.sheet.sel == nil { - t.Fatal("enter closed the submenu; esc is the way out now") - } - drive(t, a, key("esc")) + // Enter confirms the model and returns to its settings row. if a.sheet.sel != nil { - t.Fatal("esc left the submenu open") + t.Fatal("enter left the submenu open") } if a.model != "anthropic/claude-sonnet-4.5" { t.Fatalf("the session is on %q", a.model) diff --git a/internal/tui3/composerlayer.go b/internal/tui3/composerlayer.go index 377bf7133f..c4982a3460 100644 --- a/internal/tui3/composerlayer.go +++ b/internal/tui3/composerlayer.go @@ -556,12 +556,12 @@ func (a *app) composerPickKey(msg tea.KeyPressMsg) tea.Cmd { switch msg.String() { case "esc": a.composer.pick.close() - // ENTER CHOOSES AND LEAVES THE LIST UP ([app.pickerKey] argues it), and esc - // is the way out. + // ENTER CONFIRMS AND CLOSES THE MODEL LIST, so the task draft shows its + // chosen execution model immediately. case "enter": if chosen, ok := a.composer.pick.choice(); ok { a.composer.model = chosen.ID - a.restatePicker(&a.composer.pick, chosen.ID) + a.composer.pick.close() } default: // THE FOLD AND THE SORT ARE THE LIST'S OWN KEY MAP and not this door's diff --git a/internal/tui3/composerlayer_test.go b/internal/tui3/composerlayer_test.go index c3696b7740..e3d204f5e4 100644 --- a/internal/tui3/composerlayer_test.go +++ b/internal/tui3/composerlayer_test.go @@ -253,13 +253,8 @@ func TestAltOOpensTheOneModelListScopedToTheExecutionSlot(t *testing.T) { t.Skip("this lab's catalog offers no model to pick") } a.placeKeyPress(key("enter")) - // ENTER CHOOSES AND LEAVES THE LIST UP ([app.pickerKey] argues it). - if !a.composer.pick.open { - t.Fatal("enter closed the list; esc is the way out now") - } - a.placeKeyPress(key("esc")) if a.composer.pick.open { - t.Fatal("esc left the list open") + t.Fatal("enter must close the model list after choosing the execution model") } if a.composer.model != chosen.ID { t.Fatalf("the layer bound %q rather than %q", a.composer.model, chosen.ID) diff --git a/internal/tui3/connect.go b/internal/tui3/connect.go index caa05a328a..44d451694e 100644 --- a/internal/tui3/connect.go +++ b/internal/tui3/connect.go @@ -765,16 +765,15 @@ type connectCard struct { // connectLinkPress copies a waiting card's sign-in link, and reports whether // the press was one it wanted. // -// THE WHOLE CARD IS THE TARGET, not the two rows the link happens to wrap over. +// THE WHOLE CARD IS THE TARGET, not only the row carrying the sign-in link. // It is the argument a thinking block makes for taking a click anywhere on // itself (app.go): the card has no other gesture, and asking somebody to land // on a particular row of a wrapped address is asking them to aim. // // It exists because a sign-in link is the one thing on this surface that a // person needs somewhere ELSE — in the browser on their laptop, when the -// session is on a machine three hops away that has no browser at all. Copy -// mode can reach it and it reaches it as the frame drew it: two rows, indented, -// with the address split across them. Here it is one link, whole. +// session is on a machine three hops away that has no browser at all. The visible text is a short hyperlink; +// the card copies its full target even on a terminal without hyperlink support. func (a *app) connectLinkPress(i int) (tea.Cmd, bool) { if !a.connectLinkable(i) { return nil, false @@ -1043,14 +1042,9 @@ func (a *app) connectRows(e *entry, width int) []string { if card.link == "" { return out } - // THE LINK IS WRAPPED AND NEVER CUT. It has no spaces in it, so it breaks - // at the frame's width rather than at a word — and a link with its tail - // truncated away is a link nobody can use, which is the one thing this - // row exists to prevent. The hyperlink is applied to each line after the - // layout is done with it: an OSC 8 occupies no cells (opener.go). - for _, line := range wrap(card.link, width-2) { - out = append(out, a.pal.dim(" "+linkify(line, card.link))) - } + // A short label stays on one row while the hyperlink retains the full + // sign-in target. Clicking the waiting card still copies that target whole. + out = append(out, a.pal.dim(" "+linkify(fit(signInLinkWord, max(1, width-2)), card.link))) if card.copied { out = append(out, a.pal.dim(fit(" copied — paste it wherever you can sign in", width))) } diff --git a/internal/tui3/connect_test.go b/internal/tui3/connect_test.go index d12fdd92a4..09cd818aa2 100644 --- a/internal/tui3/connect_test.go +++ b/internal/tui3/connect_test.go @@ -446,11 +446,10 @@ func TestTheHandoffOpensTheBrowserAndWritesTheLinkDown(t *testing.T) { if !strings.Contains(screen, "waiting in your browser") { t.Fatalf("the surface does not say what it is waiting for:\n%s", screen) } - if !strings.Contains(screen, testAuthLink) { - t.Fatalf("the link is not on screen as text:\n%s", screen) + if !strings.Contains(screen, signInLinkWord) { + t.Fatalf("the sign-in link label is not on screen:\n%s", screen) } - // AND IT IS A HYPERLINK where the sequence is safe — the plain text above is - // what a person selects, this is what a modern terminal makes clickable. + // The short label is a hyperlink to the complete sign-in address. painted := strings.Join(rowTexts(a), "\n") if !strings.Contains(painted, "\x1b]8;;"+testAuthLink) { t.Fatal("the link on screen carries no hyperlink") @@ -472,7 +471,7 @@ func TestAFailedHandoffStillLeavesTheLink(t *testing.T) { Kind: session.EventConnectAuth, Service: "google", AuthURL: testAuthLink, })) screen := strings.Join(plainRows(a), "\n") - if !strings.Contains(screen, testAuthLink) { + if !strings.Contains(screen, signInLinkWord) { t.Fatalf("a failed handoff took the link with it:\n%s", screen) } } @@ -508,7 +507,7 @@ func TestAFinishedSignInSettlesTheWaitingBlock(t *testing.T) { if strings.Contains(screen, "waiting in your browser") { t.Fatalf("the waiting line survived the outcome:\n%s", screen) } - if strings.Contains(screen, testAuthLink) { + if strings.Contains(screen, signInLinkWord) { t.Fatalf("the link survived the sign-in it was for:\n%s", screen) } if a.connectAnimating() { diff --git a/internal/tui3/connectionswitcher_test.go b/internal/tui3/connectionswitcher_test.go index ec8dba904f..a61c80f03f 100644 --- a/internal/tui3/connectionswitcher_test.go +++ b/internal/tui3/connectionswitcher_test.go @@ -13,8 +13,7 @@ import ( // the custom connections the profile holds, in persisted order. The default // service's PreferredModel answers the Models door (a.models), so a test whose // walk reaches the default side passes []Model{{ID: config.DefaultModel}} and -// the move lands on the bare default model rather than on whatever -// BuiltinModels happens to list first. +// the move lands on the bare default model that this connection lists. func connectionDefaultService() modelsource.Connected { return testDefaultService("sk-default-1234567890") } diff --git a/internal/tui3/credits.go b/internal/tui3/credits.go index c8388b0e53..0e74eba3b2 100644 --- a/internal/tui3/credits.go +++ b/internal/tui3/credits.go @@ -165,7 +165,8 @@ func (a *app) refreshCreditWarnings() { want := config.ChatDefaultAt(a.profileDir) if a.readCredits != nil && !a.creditsExpired && !a.creditSwitching && a.implicitTalk && a.model != want && (a.model == config.DefaultModel || a.model == config.FreeChatModel) && - a.freshAndEmpty() && config.ChatModelAt(a.profileDir) == "" { + a.freshAndEmpty() && config.ChatModelAt(a.profileDir) == "" && + (!a.requireListedModel || a.isAvailableModel(want)) { a.creditSwitching = true a.switchModel(want, 0) a.creditSwitching = false diff --git a/internal/tui3/customaddress_flow_test.go b/internal/tui3/customaddress_flow_test.go index ba614c60b7..43c2635cf2 100644 --- a/internal/tui3/customaddress_flow_test.go +++ b/internal/tui3/customaddress_flow_test.go @@ -108,7 +108,7 @@ func TestCustomAddressFlowPrefillsTheDiscoveredRow(t *testing.T) { a := modelServiceTestApp(t, t.TempDir(), "sample", modelsource.NewSet(), nil) a.openAddProvider(false) a.addPanel.loading = false - a.addPanel.rebuild([]LocalServerProbe{{Name: "local", Address: "http://127.0.0.1:9999/v1"}}, nil) + a.addPanel.rebuild([]LocalServerProbe{{Name: "local", Address: "http://127.0.0.1:9999/v1"}}, nil, a.sources) for i, item := range a.addPanel.items { if item.probe != nil { a.addPanel.cursor = i diff --git a/internal/tui3/detach.go b/internal/tui3/detach.go index 1630eccccc..62df3afe27 100644 --- a/internal/tui3/detach.go +++ b/internal/tui3/detach.go @@ -554,6 +554,7 @@ func (a *app) attachConversation(conv Conversation, side *aside) tea.Cmd { a.resetMeters() if agent != nil { a.model = agent.Model() + a.ensureAvailableModel() a.title = strings.TrimSpace(agent.Title()) // A cached earned name survives an agent whose snapshot is still arriving. if a.title == "" && side != nil { diff --git a/internal/tui3/firstrun.go b/internal/tui3/firstrun.go index 281f3b6a71..1f4f965cc9 100644 --- a/internal/tui3/firstrun.go +++ b/internal/tui3/firstrun.go @@ -12,6 +12,7 @@ import ( "github.com/Agent-Field/codeaf/internal/config" "github.com/Agent-Field/codeaf/internal/credits" + "github.com/Agent-Field/codeaf/internal/modelsource" ) // THE FIRST-RUN SETUP, AND THE MODEL DOOR THAT MAY COME BACK. @@ -19,7 +20,7 @@ import ( // A fresh install used to open on an empty chat and the first thing the product // said was a provider error. Now the door lets that launch open with no key // (cmd/codeaf's chatv3.go) and this screen asks for what a first day needs, in -// TWO steps: the key every model call rides, and then one screen of controls — +// A provider choice precedes TWO steps: its connection, and one screen of controls — // the day's spending limit and the model you talk to. The crew is not asked: // a task's crew is picked per task, and /crew is where it is seen. Under a // minute; every control opens on the value already in force; the way out is @@ -48,9 +49,9 @@ import ( // only before anything has been typed. A returning key door may stand over // an existing conversation, but an attempted send opens it before the draft // is cleared, so connecting and pressing enter again sends the same words. -// - IT SPENDS NOTHING. A pasted key is checked only for shape. The browser -// exchange creates a key but makes no model call, so no prompt is sent and -// no model charge can be made during setup. +// - THE CONNECTION USES ITS EXISTING CHECKS. OpenRouter checks a pasted key +// only for shape, Ollama lists installed models, and direct providers use +// the same account checks as /connect, including their small model probes. // // IT PRECEDES THE WELCOME BOX. The box is what an empty conversation shows; this // is what it shows before that, and the box's arrival animation starts fresh the @@ -73,6 +74,16 @@ const ( // had one, which is every launch but the first. type setupFlow struct { open bool + // A later provider connection returns to its menu without completing setup. + connection bool + returnAdd bool + // Provider selection precedes the numbered connection and controls steps. + provider string + providerAt int + providerHits []setupProviderHit + providerAttempt *setupProviderAttempt + providerBusy bool + providerLink string // steps is the questions still worth asking, in order; at is the index of // the one on screen. steps []setupStep @@ -248,9 +259,27 @@ func (a *app) endSetup(skipped bool) tea.Cmd { if !a.setup.open { return nil } + if a.setup.connection { + fromAdd := a.setup.returnAdd + a.cancelSetupAuth() + a.cancelSetupProvider() + a.setup = setupFlow{} + var menu tea.Cmd + if fromAdd { + menu = a.openAddProvider(false) + } else { + a.openModelConnection(modelsource.DefaultID) + } + a.touch() + if !skipped { + return tea.Batch(menu, a.modelServiceMenuChoice(modelsource.DefaultID, "refresh")) + } + return menu + } dir := strings.TrimSpace(a.profileDir) _ = config.MarkSetupSeen(dir, a.now()) a.cancelSetupAuth() + a.cancelSetupProvider() // THE QUESTIONS THIS ESC WALKED PAST GET A DOOR. `setup_seen_at` is stamped // whichever way this screen ended and only the key-only form ever reopens, // so the chat model and the day's limit are retired here — silently, until this @@ -312,6 +341,9 @@ const setupNoKeyWord = "no openrouter key yet · paste one into /settings, or ex // ([app.endSetup]). const setupSkipKeysWord = "esc skips setup" +// Browser providers name the same action before opening the sign-in page. +const setupBrowserConnectKeysWord = "enter connects in browser" + // setupLaterWord leads the line [app.endSetup] leaves behind when esc walked // past a question. The doors follow it, and only the doors onto questions this // person was NOT asked — a line naming a question somebody just answered would @@ -338,7 +370,7 @@ func setupStepLater(step setupStep) string { // setupNoKeyConnectWord is the local default-provider form. It points at the // next ordinary act rather than at a buried settings row: the draft is kept, // and enter brings the browser connection back before anything is submitted. -const setupNoKeyConnectWord = "openrouter is not connected · enter on your message connects in a browser, or export " + config.APIKeyEnv +const setupNoKeyConnectWord = "no model provider is connected · enter on your message chooses a provider, or use /connect" // ── the keyboard ──────────────────────────────────────────────────────────── @@ -355,6 +387,26 @@ func (a *app) setupKeyPress(msg tea.KeyPressMsg) (tea.Cmd, bool) { } s := &a.setup name := msg.String() + if s.step() == setupKey { + link := s.providerLink + if s.provider == modelsource.DefaultID { + link = s.authLink + } + if name == "ctrl+y" && link != "" { + s.refusal = "sign-in link copied" + a.touch() + return tea.Raw(osc52(link, a.tmux)), true + } + if s.provider == "" { + return a.setupProviderKey(msg), true + } + if name == "alt+left" { + return a.backSetupProvider(), true + } + if s.provider != modelsource.DefaultID { + return a.setupServiceKey(msg), true + } + } if s.step() == setupKey && (s.authStarting || s.authFlow != nil) { if name == "esc" { a.cancelSetupAuth() @@ -490,6 +542,17 @@ func (a *app) setupPaste(text string) bool { } return true } + if a.setup.provider == "" { + return true + } + if a.setup.provider != modelsource.DefaultID { + if entry := a.connPanel.entry; entry != nil && !entry.choosing() && !a.setup.providerBusy { + entry.box.insert(strings.TrimSpace(text)) + a.setup.refusal = "" + a.touch() + } + return true + } a.setup.text += strings.TrimSpace(text) a.setup.refusal = "" a.touch() @@ -623,6 +686,10 @@ func (a *app) setupCommit() bool { s := &a.setup key := strings.TrimSpace(s.text) if key == "" { + if s.connection { + s.refusal = "paste a key to connect" + return false + } return true } if !config.LooksLikeAPIKey(key) { @@ -738,6 +805,12 @@ func (a *app) setupFrame(width, height int) ([]string, int, int) { if s.step() == setupControls { return a.setupControlsFrame(width, height) } + if s.provider == "" { + return a.setupProvidersFrame(width, height) + } + if s.provider != modelsource.DefaultID { + return a.setupServiceFrame(width, height) + } // ── ONE RULE, ONE MEASURE, FOR THE TWO SCREENS THE WORDMARK IS DRAWN ON ─── // // This block and the greeting that replaces it are the ONLY two screens that @@ -803,9 +876,7 @@ func (a *app) setupFrame(width, height int) ([]string, int, int) { } if s.authLink != "" { add("") - for _, line := range wrap(s.authLink, inner) { - add(pal.dim(linkify(line, s.authLink))) - } + add(pal.dim(linkify(fit(signInLinkWord, inner), s.authLink))) } default: heading := "your openrouter key" @@ -833,6 +904,7 @@ func (a *app) setupFrame(width, height int) ([]string, int, int) { } else { add("") } + add(pal.dim("Back · alt+left")) add(pal.dim(a.setupKeysWord())) // A SHADE ABOVE THE MIDDLE, WHICH IS WHERE A CENTRED THING LOOKS CENTRED, and @@ -866,6 +938,7 @@ func (a *app) setupFrame(width, height int) ([]string, int, int) { lines = lines[over:] caretY -= over } + a.setup.providerHits = []setupProviderHit{{x: lead, y: top + len(body) - 2 - max(0, top+len(body)-height), width: inner, at: -1}} a.caret = caretRow >= 0 return lines, lead + caretX, caretY } @@ -915,6 +988,9 @@ const setupLead = "› " // setupTitle is the dim line over the question: where in the flow this is, in // the fewest words. One question needs no count. func setupTitle(s *setupFlow) string { + if s.connection { + return "connect a provider" + } if len(s.steps) <= 1 { return "setting up" } @@ -949,26 +1025,31 @@ const ( // (onboarding.go's [app.setupControlsKeys]). func (a *app) setupKeysWord() string { s := &a.setup + exit := setupSkipKeysWord + if s.connection { + exit = "esc close" + } if s.step() == setupControls { width, _ := a.size() return a.setupControlsKeys(max(width-2*setupMargin, 1)) } if s.authStarting || s.authFlow != nil { + if s.authLink != "" { + return "ctrl+y copies link · esc cancel" + } return "esc cancel" } if strings.TrimSpace(s.text) == "" { if a.routerConnect != nil { - // `esc skips setup`, IN THE SAME WORDS AS EVERY OTHER BRANCH. It read - // `esc not now` here alone, which is a promise about a later — and - // what esc actually does is stamp `setup_seen_at` and retire the - // controls screen for good ([app.endSetup]). The key is named for what - // it does, and the note it leaves behind says where those choices live - // afterwards. - return "enter connects in browser · paste a key · " + setupSkipKeysWord + // Onboarding skips; a later connection returns to its provider menu. + return setupBrowserConnectKeysWord + " · paste a key · " + exit + } + if s.connection { + return "paste a key · " + exit } - return "enter goes on without a key · " + setupSkipKeysWord + return "enter goes on without a key · " + exit } - return "enter saves it · " + setupSkipKeysWord + return "enter saves it · " + exit } // maskTyped is the key as it is being typed: one bullet per character and the @@ -994,16 +1075,26 @@ func maskTyped(text string) string { // the environment still outranks the file and the session must get the one // Load would. func (a *app) handAPIKey() { - if a.applyAPIKey == nil { - return - } key := config.APIKeyAt(a.profileDir) - if key == "" { + // A KEY WRITE CHANGES THE PICKER'S ACCESS TOO. The resolved default row + // otherwise retains its launch-time key until another provider connects. + a.sources = a.sources.WithDefaultKey(key) + if a.applyModelSources != nil { + a.applyModelSources(a.sources) + } + if a.at(pageSettings) { + a.sheet.sources = a.sources + a.sheet.build() + } + a.ensureAvailableModel() + if a.applyAPIKey == nil { return } if err := a.applyAPIKey(key); err != nil { a.note("the key is saved but this conversation could not take it · " + err.Error()) } - a.refreshCreditWarnings() - a.askCredits(credits.KeyChanged) + if key != "" { + a.refreshCreditWarnings() + a.askCredits(credits.KeyChanged) + } } diff --git a/internal/tui3/firstrun_test.go b/internal/tui3/firstrun_test.go index 84dded1d1a..142d1a553b 100644 --- a/internal/tui3/firstrun_test.go +++ b/internal/tui3/firstrun_test.go @@ -35,7 +35,7 @@ func (f *setupOpenRouterFlow) Cancel() { f.cancelled = true } // profile `seed` has prepared first. It is [sheetApp] with the setup allowed // and the seed run BEFORE the app, because the setup is decided inside newApp // and a file written afterwards would be a file it never saw. -func setupApp(t *testing.T, seed func(dir string)) (*app, string, *[]string) { +func setupProviderApp(t *testing.T, seed func(dir string)) (*app, string, *[]string) { t.Helper() for _, pin := range []string{config.APIKeyEnv, "OPENAI_API_KEY", "CODEAF_DAILY_BUDGET", "CODEAF_PROFILE_DIR"} { t.Setenv(pin, "") @@ -68,6 +68,16 @@ func setupApp(t *testing.T, seed func(dir string)) (*app, string, *[]string) { return a, dir, handed } +// Key-entry tests start after the person chooses OpenRouter. Provider tests use +// setupProviderApp to exercise the actual first frame. +func setupApp(t *testing.T, seed func(dir string)) (*app, string, *[]string) { + a, dir, handed := setupProviderApp(t, seed) + if a.setup.open && a.setup.step() == setupKey { + a.selectSetupProvider("openrouter") + } + return a, dir, handed +} + // pressSetup feeds one key through Update and drops the command: the setup's // keys start nothing but the welcome box's clock, which these tests read as a // value rather than run. @@ -117,16 +127,26 @@ func TestTheSetupOpensOverAnEmptyProfileAndNotOverAConfiguredOne(t *testing.T) { } } -func TestC19FirstrunRendersOpenRouterAndNoCodexOffer(t *testing.T) { - // C19: this is the frame a person sees on a fresh profile, not merely a - // constructor seam. Codex belongs behind /connect and is absent here. - a, _, _ := setupApp(t, nil) +func TestFirstRunOffersSupportedProvidersBeforeAskingForAKey(t *testing.T) { + a, _, _ := setupProviderApp(t, nil) screen := setupScreen(a) - if !strings.Contains(screen, "openrouter") { - t.Fatalf("first-run setup lost its OpenRouter offer:\n%s", screen) + for _, want := range []string{setupProviderHeading, "OpenRouter", "Ollama", "Codex", "DeepSeek", "Z.ai", "Moonshot", "MiniMax", "Alibaba Qwen", "Custom OpenAI-compatible API"} { + if !strings.Contains(screen, want) { + t.Fatalf("missing %q: %s", want, screen) + } } - if strings.Contains(strings.ToLower(screen), "codex") { - t.Fatalf("first-run setup exposed Codex:\n%s", screen) + if strings.Contains(screen, "1 of 2") || strings.Contains(screen, "your openrouter key") { + t.Fatal("provider choice must precede key entry") + } + rows := a.setupProviderRows() + seen := map[string]bool{} + for _, row := range rows { + seen[row.id] = true + } + for _, id := range []string{"z-ai", "moonshot", "minimax", "qwen", "custom"} { + if !seen[id] { + t.Fatalf("supported provider %q missing from the provider list", id) + } } } @@ -157,7 +177,7 @@ func TestEnterConnectsOpenRouterInTheBrowserAndHandsTheKeyToThisProcess(t *testi if wait == nil || opened != flow.url { t.Fatalf("the ready flow opened %q and returned wait %v", opened, wait != nil) } - if screen := setupScreen(a); !strings.Contains(screen, "finish connecting openrouter") || !strings.Contains(screen, flow.url) { + if screen := setupScreen(a); !strings.Contains(screen, "finish connecting openrouter") || !strings.Contains(screen, signInLinkWord) { t.Fatalf("the wait must carry the browser address; got:\n%s", screen) } // A key landing here goes on to the controls screen, and what comes back is @@ -224,7 +244,7 @@ func TestAMissingDefaultProviderReturnsOverAResumedProfileAndKeepsTheDraft(t *te if !a.setup.open || a.input.String() != "keep these exact words" { t.Fatalf("enter must reopen the provider without clearing the draft; open=%v draft=%q", a.setup.open, a.input.String()) } - if got := noteSaying(t, a, "connects in a browser"); !strings.Contains(got, config.APIKeyEnv) { + if got := noteSaying(t, a, "chooses a provider"); !strings.Contains(got, "/connect") { t.Fatalf("the not-now note must name both direct roads, got %q", got) } } diff --git a/internal/tui3/focus_test.go b/internal/tui3/focus_test.go index 0d9eaa0bd9..d3548201ae 100644 --- a/internal/tui3/focus_test.go +++ b/internal/tui3/focus_test.go @@ -209,12 +209,10 @@ func TestPressingTheModelNameOpensThePickerAndKeepsTheDraft(t *testing.T) { t.Fatalf("opening the picker took the draft: %q", a.input.String()) } - // And switching keeps it too: the box is suspended, never emptied. Enter - // chooses and leaves the list up, so esc is what puts the draft back in - // front of the keyboard ([app.pickerKey]). - drive(t, a, key("enter"), key("esc")) + // Switching keeps the suspended draft and returns its keyboard on Enter. + drive(t, a, key("enter")) if a.pick.open { - t.Fatal("esc did not close the picker") + t.Fatal("enter did not close the picker") } if a.input.String() != "half a sentence" { t.Fatalf("switching the model took the draft: %q", a.input.String()) diff --git a/internal/tui3/homedraft.go b/internal/tui3/homedraft.go index 45b05a1783..7051e55893 100644 --- a/internal/tui3/homedraft.go +++ b/internal/tui3/homedraft.go @@ -414,9 +414,8 @@ func (a *app) targetPickKey(msg tea.KeyPressMsg) tea.Cmd { switch msg.String() { case "esc": a.target.pick.close() - // ENTER CHOOSES AND LEAVES THE LIST UP, which is /model's own rule and for - // its reason ([app.pickerKey]): the list is a table, and a table that shuts - // on the first press cannot be compared against. `esc` is the way out. + // ENTER CONFIRMS AND CLOSES THE MODEL LIST, returning the keyboard to + // the draft without sending it. case "enter": chosen, ok := a.target.pick.choice() // ENTER ON A PROVIDER PINS THE PROVIDER — and pins the model under it @@ -435,7 +434,7 @@ func (a *app) targetPickKey(msg tea.KeyPressMsg) tea.Cmd { } if ok { a.pinTargetModel(chosen.ID) - a.restatePicker(&a.target.pick, a.targetModel()) + a.target.pick.close() } // The rung the next conversation starts at, held on the draft until there // is an agent to spend it on ([targetDraft.levels]). diff --git a/internal/tui3/homeslash_test.go b/internal/tui3/homeslash_test.go index bcbe274745..16c543c1a7 100644 --- a/internal/tui3/homeslash_test.go +++ b/internal/tui3/homeslash_test.go @@ -199,6 +199,8 @@ func TestHomeSlashSmokeWalks(t *testing.T) { lab := newHomeLab(t) mine := lab.session("-tmp-alpha", "aaaa000000000001", "porting the resume picker", "/tmp/alpha", time.Now()) a := lab.app(mine) + // The picker needs a listed model; a cold catalog offers no guesses. + a.models = func() []Model { return pickerCatalog } a.openHome() runCmd(a.openHome()) @@ -225,9 +227,13 @@ func TestHomeSlashSmokeWalks(t *testing.T) { t.Fatalf("the foot on a model row does not name %q: %q", want, a.targetPickFoot()) } } - runCmd(a.key(key("esc"))) - if a.target.pick.open { - t.Fatal("esc through the router did not close the model list") + chosen, ok := a.target.pick.choice() + if !ok { + t.Fatal("the home model list offered no selectable model") + } + runCmd(a.key(key("enter"))) + if a.target.pick.open || !a.at(pageHome) || a.targetModel() != chosen.ID { + t.Fatal("enter must choose the draft's model and return to home") } // /help answers with a note, and the note is READ where it was typed. diff --git a/internal/tui3/input.go b/internal/tui3/input.go index 9933e88174..7e54690bbc 100644 --- a/internal/tui3/input.go +++ b/internal/tui3/input.go @@ -1503,6 +1503,14 @@ func (a *app) enterLine() tea.Cmd { a.openSetup(false) return nil } + // DISCOVERY MUST CHOOSE A REAL MODEL BEFORE THE DRAFT LEAVES THE BOX. + // The surface can open while catalogs warm, but a guessed launch model + // must never receive the message in the meantime. + if !strings.HasPrefix(line, "/") && (line != "" || held) && !a.ensureAvailableModel() { + a.note(noAvailableModelWord) + a.openPicker() + return nil + } // A TAG IS READ BEFORE THE DRAFT IS CLEARED. More than one cannot choose a // winner safely: falling back to an ordinary send is precisely the failure // these alternate doors exist to prevent, so the words stay in the box. diff --git a/internal/tui3/lanes_test.go b/internal/tui3/lanes_test.go index 51a6a5267c..814737b857 100644 --- a/internal/tui3/lanes_test.go +++ b/internal/tui3/lanes_test.go @@ -460,8 +460,8 @@ func TestEnterOnALanePinsItAndAutoTakesItBack(t *testing.T) { drive(t, a, key("down"), key("right")) // openrouter, then into its machines drive(t, a, key("enter")) - // ENTER CHOOSES AND LEAVES THE LIST UP ([app.pickerKey] argues it), so the - // pin is written with the list still on screen and esc is the way out. + // Enter on a provider keeps its controls open, so the pin is written + // with the list still on screen and Esc is the way out. if !a.pick.open { t.Fatal("pinning a lane closed the picker") } diff --git a/internal/tui3/lanewalk_test.go b/internal/tui3/lanewalk_test.go index 1fb9ac1a47..c1bf466dc6 100644 --- a/internal/tui3/lanewalk_test.go +++ b/internal/tui3/lanewalk_test.go @@ -81,8 +81,8 @@ func TestArrowWalksIntoTheFoldAndBringsItIntoView(t *testing.T) { // With a machine pinned, the walk lands on THAT row instead. The cursor is // already on cloudflare — the second `→` walked it there — so enter pins it. - // Enter chooses and leaves the list up, so it is closed before it is - // opened again ([app.pickerKey]). + // Enter on a provider keeps its controls open, so Esc closes the list + // before it is opened again. drive(t, a, key("enter"), key("esc")) typeLine(t, a, "/model") drive(t, a, key("right")) diff --git a/internal/tui3/learned.go b/internal/tui3/learned.go index 2bb77ec441..0867feb2ec 100644 --- a/internal/tui3/learned.go +++ b/internal/tui3/learned.go @@ -28,11 +28,9 @@ package tui3 // // WHAT THAT IS, IS THE CALLER'S OWN FALLBACK AND NOT ALWAYS ABSENCE. A // picture nobody has stat'd draws the nothing it already drew for a file it -// could not stat. A model list nobody has read falls to the rung BELOW it, -// which is [BuiltinModels] — five names rather than none — so a frame that -// met that miss shows different rows, not an empty box. Both are the answer -// the caller already had for "the list is not here"; neither is a blank -// where something used to be. +// could not stat. A model list nobody has read offers no invented rows; +// its provider's discovery can fill the list on the next frame. Both are +// the caller's own answer for a fact that has not arrived yet. // // - [learned.learn] IS THE LOOP'S DOOR. It reads the name NOW, on the calling // goroutine, and files what came back. Every caller of it is `open`, a tick, diff --git a/internal/tui3/modelaccess_test.go b/internal/tui3/modelaccess_test.go new file mode 100644 index 0000000000..a2b22c87bb --- /dev/null +++ b/internal/tui3/modelaccess_test.go @@ -0,0 +1,143 @@ +package tui3 + +import ( + "reflect" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/config" + "github.com/Agent-Field/codeaf/internal/modelsource" +) + +// A PUBLIC CATALOG IS NOT A CONNECTION. These fixtures leave public and cached +// rows available even when credentials disappear, then drive the actual picker. +func TestModelPickersOfferOnlyModelsFromUsableConnections(t *testing.T) { + ollama := testModelSource(t, "ollama") + local := modelsource.Connected{Source: ollama, Address: ollama.Address} + direct := testDirectService("https://direct.example/v1") + anonymous := direct + anonymous.Source.ID, anonymous.Source.Written = "custom", "lab" + anonymous.Source.KeyOptional, anonymous.Key = true, "" + for _, test := range []struct { + name string + key string + others []modelsource.Connected + want []string + }{ + {name: "no connections"}, + {name: "OpenRouter only", key: "router-key", want: []string{"vendor/cloud"}}, + {name: "Ollama only", others: []modelsource.Connected{local}, want: []string{"ollama/qwen3:0.6b"}}, + {name: "both", key: "router-key", others: []modelsource.Connected{local}, want: []string{"vendor/cloud", "ollama/qwen3:0.6b"}}, + {name: "two direct providers", others: []modelsource.Connected{direct, local}, want: []string{"deepseek-direct/direct-chat", "ollama/qwen3:0.6b"}}, + {name: "anonymous custom", others: []modelsource.Connected{anonymous}, want: []string{"lab/local-chat"}}, + {name: "missing direct key", others: []modelsource.Connected{func() modelsource.Connected { c := direct; c.Key = " "; return c }(), local}, want: []string{"ollama/qwen3:0.6b"}}, + } { + t.Run(test.name, func(t *testing.T) { + services := append([]modelsource.Connected{testDefaultService(test.key)}, test.others...) + a := modelServiceTestApp(t, t.TempDir(), "vendor/cloud", modelsource.NewSet(services...), []Model{{ID: "vendor/cloud"}}) + // An old public cache remains readable; access must be checked first. + if err := WriteModelCache([]Model{{ID: "vendor/cached-cloud"}}); err != nil { + t.Fatal(err) + } + a.refreshLearning() + a.sourceModels["ollama"] = []Model{{ID: "qwen3:0.6b"}} + a.sourceModels["deepseek"] = []Model{{ID: "direct-chat"}} + a.sourceModels["custom"] = []Model{{ID: "local-chat"}} + assertList := func(models []Model) { + t.Helper() + var ids []string + for _, model := range models { + if !model.AddProvider && !model.Unavailable { + ids = append(ids, model.ID) + } + } + if !reflect.DeepEqual(ids, test.want) { + t.Fatalf("selectable models = %v, want %v", ids, test.want) + } + } + assertList(a.modelList()) + assertList(a.modelsFor(nil)) + a.openPicker() + assertList(a.pick.all) + if test.key == "" && strings.Contains(strings.Join(groupPickerLines(a), "\n"), "vendor/cloud") { + t.Fatal("the unconnected public catalog is still drawn") + } + // A task room goes through the same concrete list and access rule. + a.tasks = map[uint64]*taskNode{8: {model: "vendor/cloud"}} + a.openTaskPicker(8) + assertList(a.pick.all) + }) + } +} + +func TestRemovingAndRestoringAProvidersKeyChangesItsPickerModels(t *testing.T) { + t.Setenv(config.APIKeyEnv, "") + t.Setenv("OPENAI_API_KEY", "") + dir := t.TempDir() + a := modelServiceTestApp(t, dir, "vendor/cloud", modelsource.NewSet(testDefaultService("")), []Model{{ID: "vendor/cloud"}}) + for _, key := range []string{"router-key", "", "replacement-key"} { + if err := config.WriteAPIKey(dir, key); err != nil { + t.Fatal(err) + } + a.handAPIKey() + want := 0 + if key != "" { + want = 1 + } + if got := len(a.modelList()); got != want { + t.Fatalf("after key %q, %d models, want %d", key, got, want) + } + } +} + +func TestAnEmptyProviderShelfDoesNotResurrectItsOldPickerCache(t *testing.T) { + ollama := testModelSource(t, "ollama") + local := modelsource.Connected{Source: ollama, Address: ollama.Address} + a := modelServiceTestApp(t, t.TempDir(), "ollama/removed", modelsource.NewSet(testDefaultService(""), local), nil) + if err := WriteModelCacheFor("ollama", local.Address, []Model{{ID: "removed"}}); err != nil { + t.Fatal(err) + } + a.refreshLearning() + a.sourceModels["ollama"] = []Model{{ID: "removed"}} + a.modelsForService = func(modelsource.Connected) []Model { return nil } + for _, model := range a.modelList() { + if !model.Unavailable { + t.Fatalf("empty current shelf resurrected %q", model.ID) + } + } +} + +func TestAKnownCatalogDoesNotBorrowOlderCachedModels(t *testing.T) { + a := modelServiceTestApp(t, t.TempDir(), "vendor/current", modelsource.NewSet(testDefaultService("router-key")), []Model{{ID: "vendor/current"}}) + if err := WriteModelCache([]Model{{ID: "vendor/removed", Input: []string{"image"}, Output: []string{"image"}}}); err != nil { + t.Fatal(err) + } + a.refreshLearning() + if models := a.modelsFor(func(model Model) bool { return model.ID == "vendor/removed" }); len(models) != 0 { + t.Fatalf("a filtered current catalog borrowed old rows: %+v", models) + } + a.models = func() []Model { return []Model{} } + if models := a.modelList(); len(models) != 0 { + t.Fatalf("an empty current catalog borrowed old rows: %+v", models) + } +} + +func TestTheDefaultPickerCannotBorrowAnotherAddressesCache(t *testing.T) { + service := testDefaultService("custom-key") + service.Source.Address, service.Address = "https://other.example/v1", "https://other.example/v1" + a := modelServiceTestApp(t, t.TempDir(), "vendor/model", modelsource.NewSet(service), nil) + if err := WriteModelCache([]Model{{ID: "vendor/public-router-row"}}); err != nil { + t.Fatal(err) + } + a.refreshLearning() + if models := a.modelList(); len(models) != 0 { + t.Fatalf("another base borrowed the public router cache: %+v", models) + } + if err := WriteModelCacheFor(service.Source.ID, service.Address, []Model{{ID: "vendor/own-row"}}); err != nil { + t.Fatal(err) + } + a.refreshLearning() + if models := a.modelList(); len(models) != 1 || models[0].ID != "vendor/own-row" { + t.Fatalf("the default provider could not read its own cache: %+v", models) + } +} diff --git a/internal/tui3/modeldefault.go b/internal/tui3/modeldefault.go new file mode 100644 index 0000000000..7905a60ce0 --- /dev/null +++ b/internal/tui3/modeldefault.go @@ -0,0 +1,70 @@ +package tui3 + +import ( + "strings" + + "github.com/Agent-Field/codeaf/internal/roles" +) + +const noAvailableModelWord = "no available model · connect a provider or refresh /model" + +// availableConversationModel keeps a preferred model only when it is listed. +// Otherwise the first available chat row in provider order supplies the default. +// Notice and connection rows never qualify, and a thinking level is kept only +// when the model it belongs to is still available. +func availableConversationModel(preferred string, rows []Model) (Model, bool) { + bare, _ := roles.SplitEffort(strings.TrimSpace(preferred)) + var first Model + for _, row := range rows { + if row.Unavailable || row.AddProvider || strings.TrimSpace(row.ID) == "" || !chatModel(row) { + continue + } + if first.ID == "" { + first = row + } + if strings.EqualFold(row.ID, bare) { + row.ID = strings.TrimSpace(preferred) + return row, true + } + } + return first, first.ID != "" +} + +func (a *app) isAvailableModel(id string) bool { + model, ok := availableConversationModel(id, a.modelList()) + return ok && strings.EqualFold(model.ID, strings.TrimSpace(id)) +} + +// ensureAvailableModel reconciles a local conversation with its picker catalog. +// A launch preference is not evidence of availability. An automatic choice uses +// the ordinary model-change road without saving a preference the person did not +// choose. With no rows, only the display is cleared: the engine has no unset-model +// door, and the send gates below keep that old launch preference from being used. +func (a *app) ensureAvailableModel() bool { + if !a.requireListedModel || a.agent == nil { + return true + } + // An answering request keeps its model until it settles. The turn-end path + // reconciles the next request after the catalog notification has landed. + if a.state == stateWorking { + return a.model != "" + } + preferred := a.model + if preferred == "" { + preferred = a.agent.Model() + } + model, ok := availableConversationModel(preferred, a.modelList()) + if !ok { + a.model, a.ctxWindow = "", 0 + a.hudStale = true + return false + } + if model.ID != a.model { + wasSwitching := a.creditSwitching + a.creditSwitching = true + a.switchModel(model.ID, model.ContextLength) + a.creditSwitching = wasSwitching + a.hudStale = true + } + return true +} diff --git a/internal/tui3/modeldefault_test.go b/internal/tui3/modeldefault_test.go new file mode 100644 index 0000000000..22b9fbf800 --- /dev/null +++ b/internal/tui3/modeldefault_test.go @@ -0,0 +1,169 @@ +package tui3 + +import ( + "context" + "testing" + + "github.com/Agent-Field/codeaf/internal/config" + "github.com/Agent-Field/codeaf/internal/credits" + "github.com/Agent-Field/codeaf/internal/modelsource" + "github.com/Agent-Field/codeaf/internal/modelsource/sourcestub" + "github.com/Agent-Field/codeaf/internal/session" +) + +func TestTheOpeningModelAlwaysBelongsToItsAvailablePicker(t *testing.T) { + ollama := testModelSource(t, "ollama") + local := modelsource.Connected{Source: ollama, Address: ollama.Address} + for _, test := range []struct { + name, key, preferred, want string + local bool + cloud, installed []Model + }{ + {name: "Ollama only", preferred: config.DefaultModel, local: true, installed: []Model{{ID: "qwen3:0.6b"}, {ID: "llama3.2:1b"}}, want: "ollama/qwen3:0.6b"}, + {name: "OpenRouter only without shipped default", key: "key", preferred: config.DefaultModel, cloud: []Model{{ID: "vendor/cloud"}}, want: "vendor/cloud"}, + {name: "listed shipped default", key: "key", preferred: config.DefaultModel, cloud: []Model{{ID: "vendor/cloud"}, {ID: config.DefaultModel}}, want: config.DefaultModel}, + {name: "saved installed choice", preferred: "ollama/qwen3:0.6b", local: true, installed: []Model{{ID: "llama3.2:1b"}, {ID: "qwen3:0.6b"}}, want: "ollama/qwen3:0.6b"}, + {name: "saved removed choice", preferred: "ollama/removed", local: true, installed: []Model{{ID: "qwen3:0.6b"}}, want: "ollama/qwen3:0.6b"}, + {name: "both keep saved local choice", key: "key", preferred: "ollama/qwen3:0.6b", local: true, cloud: []Model{{ID: "vendor/cloud"}}, installed: []Model{{ID: "qwen3:0.6b"}}, want: "ollama/qwen3:0.6b"}, + {name: "both replace unlisted choice", key: "key", preferred: "removed", local: true, cloud: []Model{{ID: "vendor/cloud"}}, installed: []Model{{ID: "qwen3:0.6b"}}, want: "vendor/cloud"}, + {name: "no connections", preferred: config.DefaultModel, cloud: []Model{{ID: config.DefaultModel}}}, + {name: "empty connected catalog", key: "key", preferred: config.DefaultModel, cloud: []Model{}}, + {name: "nonchat rows cannot become defaults", key: "key", preferred: config.DefaultModel, cloud: []Model{{ID: "vendor/speech", Input: []string{"text"}, Output: []string{"audio"}}}}, + } { + t.Run(test.name, func(t *testing.T) { + t.Setenv("CODEAF_HOME", t.TempDir()) + services := []modelsource.Connected{testDefaultService(test.key)} + if test.local { + services = append(services, local) + } + agent := &fakeAgent{model: test.preferred} + saved := "" + a := newApp(t.Context(), Options{Agent: agent, ProfileDir: t.TempDir(), Sources: modelsource.NewSet(services...), RequireListedModel: true, + Models: func() []Model { return test.cloud }, + ModelsForService: func(modelsource.Connected) []Model { return test.installed }, + SaveModel: func(id string) error { saved = id; return nil }, + }) + if a.model != test.want { + t.Fatalf("opening model = %q, want %q", a.model, test.want) + } + if test.want != "" && (!a.isAvailableModel(a.model) || agent.Model() != test.want) { + t.Fatalf("default is not on both the engine and picker: %q/%q", a.model, agent.Model()) + } + if saved != "" { + t.Fatalf("automatic default overwrote the preference with %q", saved) + } + }) + } +} + +func TestAColdCatalogHoldsTheDraftThenSuppliesTheEngineDefault(t *testing.T) { + t.Setenv("CODEAF_HOME", t.TempDir()) + var rows []Model + agent := &fakeAgent{model: config.DefaultModel} + a := newApp(t.Context(), Options{Agent: agent, ProfileDir: t.TempDir(), Sources: modelsource.NewSet(testDefaultService("key")), RequireListedModel: true, Models: func() []Model { return rows }}) + a.input.insert("keep this draft") + if cmd := a.enterLine(); cmd != nil || len(agent.sent) != 0 || a.input.String() != "keep this draft" || a.model != "" { + t.Fatalf("cold send: cmd=%v sent=%v draft=%q model=%q", cmd != nil, agent.sent, a.input.String(), a.model) + } + rows = []Model{{ID: "vendor/real", ContextLength: 32768}} + a.serviceModelsLanded(modelsource.DefaultID, config.DefaultBaseURL) + if a.model != "vendor/real" || agent.Model() != a.model || agent.window != 32768 || a.input.String() != "keep this draft" { + t.Fatalf("landed default = %q/%q window=%d draft=%q", a.model, agent.Model(), agent.window, a.input.String()) + } + a.pick.close() + cmd := a.enterLine() + if cmd == nil { + t.Fatal("the available default did not release the draft") + } + spend(t, a, cmd) + if len(agent.sent) != 1 || agent.sent[0] != "keep this draft" { + t.Fatalf("send = %v", agent.sent) + } +} + +func TestRemovingTheDefaultModelChoosesAnotherListedModel(t *testing.T) { + t.Setenv("CODEAF_HOME", t.TempDir()) + rows := []Model{{ID: "vendor/old"}, {ID: "vendor/replacement"}} + agent := &fakeAgent{model: "vendor/old"} + a := newApp(t.Context(), Options{Agent: agent, ProfileDir: t.TempDir(), Sources: modelsource.NewSet(testDefaultService("key")), RequireListedModel: true, Models: func() []Model { return rows }}) + rows = []Model{{ID: "vendor/replacement"}} + a.serviceModelsLanded(modelsource.DefaultID, config.DefaultBaseURL) + if a.model != "vendor/replacement" || agent.Model() != a.model { + t.Fatalf("replacement = %q/%q", a.model, agent.Model()) + } + rows = []Model{} + a.serviceModelsLanded(modelsource.DefaultID, config.DefaultBaseURL) + if a.model != "" { + t.Fatalf("empty catalog left %q as the displayed default", a.model) + } + called := false + if cmd := a.submitting("blocked", func() (<-chan session.Event, error) { called = true; return nil, nil }); cmd != nil || called { + t.Fatal("an alternate send bypassed the empty catalog") + } +} + +func TestListedDefaultsKeepEffortAndNeverSelectPickerControls(t *testing.T) { + rows := []Model{{Unavailable: true, Notice: "warming"}, {AddProvider: true}, {ID: "ollama/qwen3:0.6b"}} + if model, ok := availableConversationModel("ollama/qwen3:0.6b:high", rows); !ok || model.ID != "ollama/qwen3:0.6b:high" { + t.Fatalf("listed effort = %+v/%t", model, ok) + } + if model, ok := availableConversationModel("removed:high", rows); !ok || model.ID != "ollama/qwen3:0.6b" { + t.Fatalf("replacement retained removed effort: %+v/%t", model, ok) + } +} + +func TestCreditDefaultsCannotSelectAnUnlistedModel(t *testing.T) { + t.Setenv("CODEAF_HOME", t.TempDir()) + dir := t.TempDir() + agent := &fakeAgent{model: config.FreeChatModel} + a := newApp(t.Context(), Options{Agent: agent, ProfileDir: dir, Sources: modelsource.NewSet(testDefaultService("key")), RequireListedModel: true, ImplicitTalk: true, + Models: func() []Model { return []Model{{ID: config.FreeChatModel}} }, + }) + a.readCredits = func(context.Context) (credits.Reading, error) { return credits.Reading{}, nil } + a.refreshCreditWarnings() + if a.model != config.FreeChatModel || agent.Model() != a.model { + t.Fatalf("credit reading chose an unlisted default: %q/%q", a.model, agent.Model()) + } +} + +func TestAnAvailableOpeningModelIsTheModelSentToItsRealProvider(t *testing.T) { + t.Setenv("CODEAF_HOME", t.TempDir()) + server := sourcestub.New("actually-listed") + defer server.Close() + service := testDirectService(server.URL()) + sources := modelsource.NewSet(testDefaultService(""), service) + agent, err := session.New(session.Config{Workspace: t.TempDir(), Model: config.DefaultModel, Sources: sources}) + if err != nil { + t.Fatal(err) + } + defer agent.Close() + a := newApp(t.Context(), Options{Agent: agent, ProfileDir: t.TempDir(), Sources: sources, RequireListedModel: true, + Models: func() []Model { return []Model{{ID: config.DefaultModel}} }, + ModelsForService: func(modelsource.Connected) []Model { return []Model{{ID: "actually-listed"}} }, + }) + if a.model != "deepseek-direct/actually-listed" { + t.Fatalf("default = %q", a.model) + } + drainModelServiceTurn(t, agent, "answer on the available default") + requests := completionRequests(server) + if len(requests) != 1 || completionModel(t, requests[0]) != "actually-listed" { + t.Fatalf("provider requests = %+v", requests) + } +} + +func TestARefreshDefersDefaultReplacementUntilTheAnswerSettles(t *testing.T) { + t.Setenv("CODEAF_HOME", t.TempDir()) + rows := []Model{{ID: "vendor/old"}, {ID: "vendor/replacement"}} + agent := &fakeAgent{model: "vendor/old"} + a := newApp(t.Context(), Options{Agent: agent, ProfileDir: t.TempDir(), Sources: modelsource.NewSet(testDefaultService("key")), RequireListedModel: true, Models: func() []Model { return rows }}) + a.state = stateWorking + rows = []Model{{ID: "vendor/replacement"}} + a.serviceModelsLanded(modelsource.DefaultID, config.DefaultBaseURL) + if a.model != "vendor/old" || agent.Model() != a.model { + t.Fatal("the catalog change interrupted an answering request") + } + a.settle() + if a.model != "vendor/replacement" || agent.Model() != a.model { + t.Fatalf("settled default = %q/%q", a.model, agent.Model()) + } +} diff --git a/internal/tui3/modelrefresh.go b/internal/tui3/modelrefresh.go index d54f8b54b0..40bcd3b7e7 100644 --- a/internal/tui3/modelrefresh.go +++ b/internal/tui3/modelrefresh.go @@ -205,6 +205,7 @@ func (a *app) modelsFetched(msg modelsFetchedMsg) { // The completion is also a refresh boundary for doors without a live // subscription and for notifications still queued behind this message. a.modelLists.refresh() + a.ensureAvailableModel() if a.pick.open { a.pick.restock(a.modelPickerList()) } @@ -233,8 +234,9 @@ func (a *app) modelsFetched(msg modelsFetchedMsg) { // // IT IS AFTER THE FAILURE CHECK because a fetch that failed wrote nothing: // dropping the memo there would throw away a good reading to punish a bad - // call, and the next frame would fall to the built-ins. + // call, leaving the next frame with no known rows. a.forgetModelList("", modelcatalog.DefaultBaseURL) + a.ensureAvailableModel() a.refreshCreditWarnings() if a.pick.open && a.pick.refresh { a.pick.restock(a.modelPickerList()) diff --git a/internal/tui3/models.go b/internal/tui3/models.go index c85266c346..4f539c707f 100644 --- a/internal/tui3/models.go +++ b/internal/tui3/models.go @@ -27,11 +27,10 @@ import ( // as [Options.Models] (see cmd/codeaf/chatv3.go); // 2. this package's own cache, ~/.codeaf/v3/models.json, written whenever a // catalog fetch elsewhere succeeded; -// 3. [BuiltinModels], five names this build remembers. // -// The third rung is what makes the first launch on a cold machine still open a -// picker rather than an empty box, and the second is what makes the launch -// after it show the whole catalog instantly. +// Only connections with credentials, or an explicitly anonymous service, may +// contribute rows. A cold list stays empty until discovery answers; built-in +// names are not evidence that this account can use a model. // // [app.modelList] is where that order is actually applied; the pieces live here. @@ -208,7 +207,7 @@ func readModelCacheName(name string) []Model { // cachedModelsFor is THE FRAME'S DOOR onto a service's cached rows: the memo, // and nothing else. An empty answer is "nobody has read that file yet" as much // as it is "the file holds nothing", and both draw the same thing — the rung -// below, which is the built-ins (see this file's head). The loop fills the memo +// below, which holds no invented models (see this file's head). The loop fills the memo // at `open` ([app.learnModelLists]), on the pulse's beat and whenever a fetch // rewrites a file ([app.forgetModelList]). func (a *app) cachedModelsFor(source, base string) []Model { @@ -281,6 +280,7 @@ func (a *app) serviceModelsLanded(source, address string) { a.forgetModelList("", modelcatalog.DefaultBaseURL) a.modelLists.learn(modelCacheNameFor("", modelcatalog.DefaultBaseURL)) } + a.ensureAvailableModel() if a.pick.open { a.pick.restock(a.modelPickerList()) } @@ -331,8 +331,8 @@ func WriteModelCacheFor(source, base string, models []Model) error { return os.Rename(name, path) } -// BuiltinModels is the last rung: names this build remembers, in the order a -// person is most likely to want them. No context lengths — these are not rows +// BuiltinModels seeds model-author names for connection-name collision checks. +// These names are never offered as picker rows. No context lengths — they are not rows // anybody fetched, and inventing a window for a model this process has never // heard back from is exactly the guess [Model.ContextLength]'s zero exists to // avoid. @@ -461,7 +461,7 @@ func keepModels(models []Model, keep modelFilter) []Model { // // The list a slot actually draws is resolved by [app.modelsFor], which applies // that slot's own predicate to EVERY rung of the source order — the door's -// catalog, the disk cache and the built-ins alike — because the rule is about +// catalog and disk cache alike — because the rule is about // what a row IS and not about where it came from. func chatModels(models []Model) []Model { return keepModels(models, chatModel) } diff --git a/internal/tui3/modelservices.go b/internal/tui3/modelservices.go index 5e62a035fe..b0f9512f27 100644 --- a/internal/tui3/modelservices.go +++ b/internal/tui3/modelservices.go @@ -36,6 +36,7 @@ const ( // on the Providers tab. The profile is not touched until every answer is here // and internal/config has accepted the service. type modelConnectDraft struct { + setupAttempt *setupProviderAttempt source modelsource.Source row config.PersistedSource step modelConnectStep @@ -97,9 +98,9 @@ func (a *app) modelsForConnectedService(service modelsource.Connected) []Model { return nil } if a.modelsForService != nil { - if models := cleanModels(a.modelsForService(service)); len(models) > 0 { - return models - } + // THE SHELF OWNS THIS PROVIDER'S ANSWER. Falling through an empty + // current list would resurrect models from an older surface cache. + return cleanModels(a.modelsForService(service)) } if models := cleanModels(a.sourceModels[service.Source.ID]); len(models) > 0 { return models @@ -149,9 +150,7 @@ func (a *app) connectionRows() []connect.Status { func (a *app) modelConnectionRows() []connect.Status { connected := make(map[string]modelsource.Connected) for _, service := range a.sources.All() { - if !strings.EqualFold(service.Source.ID, modelsource.DefaultID) { - connected[strings.ToLower(service.Source.ID)] = service - } + connected[strings.ToLower(service.Source.ID)] = service } rows := make([]connect.Status, 0, len(a.modelCatalog)+len(connected)) seen := make(map[string]bool) @@ -165,10 +164,12 @@ func (a *app) modelConnectionRows() []connect.Status { if held { source = service.Source } - rows = append(rows, modelConnectionStatus(source, held)) + rows = append(rows, modelConnectionStatus(source, held && service.HasCredentials())) } - for _, source := range a.modelCatalog { - appendSource(source) + if len(a.modelCatalog) > 0 || !a.sources.Empty() { + for _, source := range providerCatalog(a.modelCatalog) { + appendSource(source) + } } for _, service := range a.sources.All() { if !strings.EqualFold(service.Source.ID, modelsource.DefaultID) { @@ -209,8 +210,11 @@ func modelConnectionStatus(source modelsource.Source, held bool) connect.Status switch { case source.ID == "ollama": need = "" - case source.ID == "codex": + case source.ID == "codex" || source.ID == modelsource.DefaultID: need = "browser" + if source.ID == modelsource.DefaultID { + need = "browser · key" + } case modelsource.IsCustomID(source.ID): need = "address · key" case len(source.Regions) > 0: @@ -235,7 +239,7 @@ func modelConnectionStatus(source modelsource.Source, held bool) connect.Status } if source.ID == "ollama" { service.Auth = "none" - } else if source.ID == "codex" { + } else if source.ID == "codex" || source.ID == modelsource.DefaultID { service.Auth = connect.AuthBrowser } return connect.Status{Service: service, Connected: held, Account: source.Written, KeyEnv: source.KeyEnv} @@ -273,6 +277,9 @@ func (a *app) startModelConnect(row connect.Status, fromSheet bool) tea.Cmd { if !ok { return nil } + if id == modelsource.DefaultID { + return a.openDefaultProviderConnection() + } persisted := config.PersistedSource{ID: source.ID, Written: source.Written, Order: a.nextModelServiceOrder()} editing := false for _, existing := range config.PersistedSources(a.profileDir) { @@ -293,6 +300,9 @@ func (a *app) startModelConnect(row connect.Status, fromSheet bool) tea.Cmd { if !editing { draft.renamedFrom = "" } + if a.setup.open && a.setup.step() == setupKey && a.setup.provider == source.ID { + draft.setupAttempt = a.setup.providerAttempt + } a.modelDraft = draft switch { case len(source.Regions) > 0: @@ -322,8 +332,41 @@ func (a *app) startModelConnect(row connect.Status, fromSheet bool) tea.Cmd { } } +// The default provider reuses setup's browser and masked-key form without +// revisiting onboarding or changing its completion marker. +func (a *app) openDefaultProviderConnection() tea.Cmd { + if a.hosted() { + a.note(connectRemoteWord) + return nil + } + fromAdd := a.addPanel.open + a.addPanel.close() + a.connPanel.close() + a.closeLists() + a.setup = setupFlow{open: true, provider: modelsource.DefaultID, + steps: []setupStep{setupKey}, connection: true, returnAdd: fromAdd} + a.touch() + return nil +} + +// Connected picker rows lead to the existing two-press disconnect panel. +func (a *app) openModelConnection(id string) { + a.openConnect() + for at := range a.connPanel.hits { + if row, ok := a.connPanel.at(at); ok && row.ID == modelConnectionID(id) { + a.connPanel.cursor = at + a.connPanel.follow(connectRowsMax) + break + } + } +} + func (a *app) beginCodexConnect(draft modelConnectDraft) tea.Cmd { connect, ctx := a.codexConnect, a.ctx + if draft.setupAttempt != nil { + ctx = draft.setupAttempt.ctx + a.setup.providerBusy = true + } if ctx == nil { ctx = context.Background() } @@ -341,11 +384,19 @@ func (a *app) beginCodexConnect(draft modelConnectDraft) tea.Cmd { // model-service adoption path, so the picker, live sources and preferred-model // move have one implementation. func (a *app) adoptCodexFlow(msg codexFlowMsg) tea.Cmd { + attempt := msg.draft.setupAttempt + if attempt != nil && !a.setupProviderCurrent(attempt) { + if msg.flow != nil { + msg.flow.Cancel() + } + return nil + } if msg.err != nil || msg.flow == nil { reason := "the browser sign-in did not start" if msg.err != nil { reason = codexFailureReason(msg.err) } + a.setup.providerBusy = false a.modelServiceMessage("codex did not connect · " + reason) return nil } @@ -353,17 +404,32 @@ func (a *app) adoptCodexFlow(msg codexFlowMsg) tea.Cmd { a.codexFlow.Cancel() } a.codexFlow = msg.flow - a.openConnectFlow("codex", "codex", msg.flow.URL()) + if attempt != nil { + a.setup.providerLink = msg.flow.URL() + if err := processOpener(msg.flow.URL()); err != nil { + a.setup.refusal = "could not open your browser · open the link above" + } + a.touch() + } else { + a.openConnectFlow("codex", "codex", msg.flow.URL()) + } flow, ctx, dir := msg.flow, a.ctx, a.profileDir + if attempt != nil { + ctx = attempt.ctx + } if ctx == nil { ctx = context.Background() } return func() tea.Msg { defer flow.Cancel() tokens, err := flow.Wait(ctx) + if err == nil { + err = ctx.Err() + } if err != nil { return modelConnectResultMsg{ - service: "codex", name: "Codex", written: "codex", browser: true, + setupAttempt: attempt, + service: "codex", name: "Codex", written: "codex", browser: true, word: "codex did not connect · " + codexFailureReason(err), err: err, } } @@ -378,7 +444,7 @@ func (a *app) adoptCodexFlow(msg codexFlowMsg) tea.Cmd { } return modelConnectResultMsg{ service: "codex", name: "Codex", written: "codex", outcome: outcome, - models: models, err: err, browser: true, word: word, + models: models, err: err, browser: true, word: word, setupAttempt: attempt, } } } @@ -699,6 +765,11 @@ func (a *app) beginModelConnect(draft modelConnectDraft) tea.Cmd { if ctx == nil { ctx = context.Background() } + if draft.setupAttempt != nil { + ctx = draft.setupAttempt.ctx + a.setup.providerBusy = true + a.touch() + } dir := a.profileDir instance := draft.row.ID authors := modelAuthorSegments(a.defaultServiceModels()) @@ -745,7 +816,7 @@ func (a *app) beginModelConnect(draft modelConnectDraft) tea.Cmd { return modelConnectResultMsg{ service: instance, name: draft.source.Name, written: draft.row.Written, keyEnv: draft.row.KeyEnv, outcome: outcome, models: models, err: err, - renamedFrom: draft.renamedFrom, + renamedFrom: draft.renamedFrom, setupAttempt: draft.setupAttempt, } } } @@ -902,6 +973,7 @@ func (a *app) adoptModelConnectResult(msg modelConnectResultMsg) { } else if renamedNext != "" { a.moveConversationToConnectedModel(renamedNext) } + a.ensureAvailableModel() if a.connPanel.open { a.connPanel.adopt(a.connectionRows()) } @@ -1035,7 +1107,10 @@ func (a *app) moveConversationToConnectedModel(next string) { } was := a.model a.switchModel(next, 0) - a.modelServiceFollowup(serviceMovedWord(was, next)) + // A first connection has no previous model to name. + if strings.TrimSpace(was) != "" { + a.modelServiceFollowup(serviceMovedWord(was, next)) + } } // applyDeferredModelServiceMove spends the one pending move only after the @@ -1154,6 +1229,11 @@ func serviceStrandedWord(was string) string { func (a *app) modelServiceMessage(line string) { line = strings.TrimSpace(line) + if a.setup.open && a.setup.step() == setupKey && a.setup.provider != "" && a.setup.provider != modelsource.DefaultID { + a.setup.refusal = line + a.touch() + return + } if a.addPanel.open { a.addPanel.err = line return @@ -1186,7 +1266,11 @@ func (a *app) modelServiceFollowup(line string) { func (a *app) disconnectModelService(id string) { connected, ok := a.sources.ByID(id) - if !ok || strings.EqualFold(id, modelsource.DefaultID) { + if !ok { + return + } + if id == modelsource.DefaultID { + a.disconnectDefaultProvider() return } written := strings.ToLower(strings.TrimSpace(connected.Source.Written)) @@ -1214,6 +1298,7 @@ func (a *app) disconnectModelService(id string) { } else { a.modelServiceFollowup(serviceStrandedWord(was)) } + a.ensureAvailableModel() if a.connPanel.open { a.connPanel.adopt(a.connectionRows()) } @@ -1223,6 +1308,28 @@ func (a *app) disconnectModelService(id string) { } } +func (a *app) disconnectDefaultProvider() { + service, _ := a.sources.For(a.conversationModel()) + if a.state == stateWorking && service.Source.ID == modelsource.DefaultID { + a.modelServiceMessage(serviceAnsweringWord(modelsource.DefaultID)) + return + } + if source := config.APIKeySourceAt(a.profileDir); source != "" && source != config.APIKeySourceProfile { + a.modelServiceMessage("openrouter is connected through " + source + " · unset it and restart to disconnect") + return + } + row, ok := a.registry().Row(config.KeyAPIKey) + if !ok { + return + } + if err := row.Apply(""); err != nil { + a.modelServiceMessage(err.Error()) + return + } + a.modelServiceMessage(serviceDisconnectedWord(modelsource.DefaultID)) + a.refreshConnect() +} + func (a *app) modelIsDirect(model string) bool { if a.sources.Empty() { return false @@ -1241,7 +1348,7 @@ func (a *app) defaultServiceHasKey() bool { return true } service := a.sources.Default() - return strings.TrimSpace(service.Key) != "" || service.Source.KeyOptional + return service.HasCredentials() } // defaultProviderNeeded is the ONE answer to "does this person still owe us an @@ -1254,7 +1361,19 @@ func (a *app) defaultProviderNeeded() bool { if a.routerConnect == nil || a.defaultServiceHasKey() { return false } - return !a.connectedServiceCarriesModel() + if a.connectedServiceCarriesModel() { + return false + } + // A cold catalog may not have chosen a model yet. A saved connection still + // bypasses the provider question while its own models are being discovered. + if strings.TrimSpace(a.model) == "" { + for _, service := range a.sources.All() { + if service.Source.ID != modelsource.DefaultID && service.HasCredentials() { + return false + } + } + } + return true } // connectedServiceCarriesModel is the service half of the prerequisite: the @@ -1266,7 +1385,7 @@ func (a *app) connectedServiceCarriesModel() bool { if service.Source.ID == "" || strings.EqualFold(service.Source.ID, modelsource.DefaultID) { return false } - return strings.TrimSpace(service.Key) != "" || service.Source.KeyOptional + return service.HasCredentials() } func modelUsesService(model, written string) bool { @@ -1276,24 +1395,8 @@ func modelUsesService(model, written string) bool { } func (a *app) reachableModelAfterDisconnect() (string, bool) { - services := a.sources.All() - if len(services) == 0 { - return "", false - } - if strings.TrimSpace(services[0].Key) != "" { - return config.ChatDefaultAt(a.profileDir), true - } - for _, service := range services[1:] { - if strings.TrimSpace(service.Key) == "" && service.Source.ID != "ollama" { - continue - } - for _, model := range a.sourceModels[service.Source.ID] { - if chatModel(model) { - return service.Qualify(model.ID), true - } - } - } - return "", false + model, ok := availableConversationModel(config.ChatDefaultAt(a.profileDir), a.modelList()) + return model.ID, ok } // modelServiceRows is the Providers tab's compact reading: one ordinary sheet diff --git a/internal/tui3/modelservices_test.go b/internal/tui3/modelservices_test.go index 2acba94a2c..ff06771e7e 100644 --- a/internal/tui3/modelservices_test.go +++ b/internal/tui3/modelservices_test.go @@ -1644,8 +1644,8 @@ func TestDirectOnlyCommandsInventNoDefaultProviderUsage(t *testing.T) { if !strings.Contains(rendered, "deepseek-direct") || !strings.Contains(rendered, "deepseek-direct/deepseek-v4-pro") { t.Fatalf("/model lost the connected service and its model:\n%s", rendered) } - if !strings.Contains(rendered, "openrouter") || !strings.Contains(rendered, config.DefaultModel) { - t.Fatalf("/model lost the keyless default service's existing group or model:\n%s", rendered) + if strings.Contains(rendered, "openrouter") || strings.Contains(rendered, config.DefaultModel) { + t.Fatalf("/model offered the unconnected default service's group or model:\n%s", rendered) } } diff --git a/internal/tui3/onboarding.go b/internal/tui3/onboarding.go index 6cf0083adf..e49ea92e24 100644 --- a/internal/tui3/onboarding.go +++ b/internal/tui3/onboarding.go @@ -502,6 +502,15 @@ func (a *app) setupPress(x, y int) bool { // list — the one thing on the screen that scrolls — and nothing else. func (a *app) setupWheel(down bool) { s := &a.setup + if s.step() == setupKey && s.provider == "" { + delta := -1 + if down { + delta = 1 + } + s.providerAt = moveCursor(s.providerAt, delta, len(a.setupProviderRows())) + a.touch() + return + } if s.step() != setupControls || !s.modelOpen { return } diff --git a/internal/tui3/onboardingproviders.go b/internal/tui3/onboardingproviders.go new file mode 100644 index 0000000000..9f5751fa73 --- /dev/null +++ b/internal/tui3/onboardingproviders.go @@ -0,0 +1,360 @@ +package tui3 + +import ( + "context" + "strings" + + tea "charm.land/bubbletea/v2" + "github.com/Agent-Field/codeaf/internal/modelsource" + "github.com/charmbracelet/x/ansi" +) + +const setupProviderHeading = "choose a model provider" +const setupProviderSentence = "connect the provider you want to use. you can add more later with /connect." + +// The connection attempt carries cancellation through address probes, sign-in, +// and model discovery. Results from a screen the person left are never adopted. +type setupProviderAttempt struct { + ctx context.Context + cancel context.CancelFunc +} + +type setupProviderHit struct{ x, y, width, at int } +type setupProviderRow struct{ id, name string } + +// All provider doors use the same supported catalog and display order. +func providerCatalog(catalog []modelsource.Source) []modelsource.Source { + if len(catalog) == 0 { + catalog = modelsource.Vendored() + } + byID := map[string]modelsource.Source{modelsource.DefaultID: modelsource.DefaultSource("")} + for _, source := range catalog { + byID[source.ID] = source + } + var rows []modelsource.Source + seen := map[string]bool{} + for _, id := range []string{modelsource.DefaultID, "ollama", "codex", "deepseek"} { + if source, ok := byID[id]; ok { + rows = append(rows, source) + seen[id] = true + } + } + for _, source := range catalog { + if !seen[source.ID] { + rows = append(rows, source) + seen[source.ID] = true + } + } + return rows +} + +func (a *app) setupProviderRows() []setupProviderRow { + var rows []setupProviderRow + for _, source := range providerCatalog(a.modelCatalog) { + rows = append(rows, setupProviderRow{source.ID, source.Name}) + } + return rows +} + +func (a *app) setupProviderKey(msg tea.KeyPressMsg) tea.Cmd { + s := &a.setup + rows := a.setupProviderRows() + switch msg.String() { + case "esc": + return a.endSetup(true) + case "up", "ctrl+p": + s.providerAt = moveCursor(s.providerAt, -1, len(rows)) + case "down", "ctrl+n", "tab": + s.providerAt = moveCursor(s.providerAt, 1, len(rows)) + case "home", "pgup": + s.providerAt = 0 + case "end", "pgdown": + s.providerAt = len(rows) - 1 + case "enter": + if len(rows) > 0 { + return a.selectSetupProvider(rows[clampIndex(s.providerAt, len(rows))].id) + } + } + a.touch() + return nil +} + +func (a *app) selectSetupProvider(id string) tea.Cmd { + s := &a.setup + a.cancelSetupProvider() + s.provider, s.text, s.refusal = id, "", "" + s.providerHits = nil + a.touch() + if id != modelsource.DefaultID && id != "codex" { + return a.startSetupProvider() + } + return nil +} + +func (a *app) startSetupProvider() tea.Cmd { + a.cancelSetupProvider() + ctx := a.ctx + if ctx == nil { + ctx = context.Background() + } + ctx, cancel := context.WithCancel(ctx) + a.setup.providerAttempt = &setupProviderAttempt{ctx: ctx, cancel: cancel} + if len(a.modelCatalog) == 0 { + a.modelCatalog = modelsource.Vendored() + } + source, ok := a.modelSource(a.setup.provider) + if !ok { + a.setup.refusal = "that provider is unavailable here" + return nil + } + return a.startModelConnect(modelConnectionStatus(source, false), false) +} + +func (a *app) cancelSetupProvider() { + if a.setup.providerAttempt != nil { + a.setup.providerAttempt.cancel() + a.setup.providerAttempt = nil + if a.codexFlow != nil { + a.codexFlow.Cancel() + a.codexFlow = nil + } + a.cancelModelEntry(a.connPanel.entry) + a.modelDraft = nil + a.connPanel.entry = nil + } + a.setup.providerBusy, a.setup.providerLink = false, "" +} + +func (a *app) backSetupProvider() tea.Cmd { + if a.setup.connection { + return a.endSetup(true) + } + a.cancelSetupAuth() + a.cancelSetupProvider() + a.setup.provider, a.setup.text, a.setup.refusal = "", "", "" + a.setup.providerHits = nil + a.touch() + return nil +} + +func (a *app) setupProviderCurrent(attempt *setupProviderAttempt) bool { + return a.setup.open && a.setup.step() == setupKey && a.setup.providerAttempt == attempt && attempt.ctx.Err() == nil +} + +func (a *app) setupServiceKey(msg tea.KeyPressMsg) tea.Cmd { + s := &a.setup + switch msg.String() { + case "esc": + if s.providerBusy { + a.cancelSetupProvider() + s.refusal = "cancelled · enter tries again" + a.touch() + return nil + } + return a.endSetup(true) + case "enter": + if !s.providerBusy && a.connPanel.entry == nil { + s.refusal = "" + return a.startSetupProvider() + } + } + if s.providerBusy { + return nil + } + s.refusal = "" + if a.connPanel.entry != nil { + return a.connectEntryKey(msg) + } + return nil +} + +func (a *app) adoptSetupProviderResult(msg modelConnectResultMsg) tea.Cmd { + if !a.setupProviderCurrent(msg.setupAttempt) { + return nil + } + a.setup.providerBusy, a.setup.providerLink = false, "" + // The ordinary connection adoption owns the model switch and profile reload. + if msg.err != nil { + if msg.browser { + a.codexFlow = nil + a.setup.refusal = msg.word + } else { + a.setup.refusal = setupSaid(msg.err, "could not connect · check the provider and try again") + } + a.touch() + return nil + } + a.adoptModelConnectResult(msg) + if msg.err == nil && msg.outcome.Kind == modelsource.OutcomeConnected { + a.cancelSetupProvider() + return a.advanceSetup() + } + if msg.browser { + a.setup.refusal = msg.word + } + a.touch() + return nil +} + +// The pointer reads only the rows actually drawn, including a short window. +func (a *app) setupProviderPress(x, y int) tea.Cmd { + for _, hit := range a.setup.providerHits { + if y != hit.y || x < hit.x || x >= hit.x+hit.width { + continue + } + if hit.at == -1 { + return a.backSetupProvider() + } + if hit.at == -2 { + return a.setupServiceKey(tea.KeyPressMsg{Code: tea.KeyEnter}) + } + if a.setup.provider == "" { + a.setup.providerAt = hit.at + return a.selectSetupProvider(a.setupProviderRows()[hit.at].id) + } + if entry := a.connPanel.entry; entry != nil && entry.choosing() { + entry.at = hit.at + return a.setupServiceKey(tea.KeyPressMsg{Code: tea.KeyEnter}) + } + } + return nil +} + +func (a *app) setupProvidersFrame(width, height int) ([]string, int, int) { + inner := max(1, min(width-4, welcomeUnitWidth)) + rows := a.setupProviderRows() + a.setup.providerAt = clampIndex(a.setup.providerAt, len(rows)) + heading := setupProviderHeading + body := a.setupProviderHead(height) + body = append(body, a.pal.dim("setting up"), "", a.pal.ink(heading)) + if height >= 18 { + for _, line := range wrap(setupProviderSentence, inner) { + body = append(body, a.pal.dim(line)) + } + } + body = append(body, "") + hits := []setupProviderHit{} + for at := range rows { + mark, ink := " ", a.pal.dim + if at == a.setup.providerAt { + mark, ink = setupLead, a.pal.ink + } + hits = append(hits, setupProviderHit{y: len(body), at: at}) + body = append(body, ink(mark+rows[at].name)) + } + footer := "↑/↓ choose · enter selects · " + setupSkipKeysWord + if inner < 58 { + footer = "enter chooses · " + setupSkipKeysWord + } + if height >= len(rows)+2 { + body = append(body, "") + } + body = append(body, a.pal.dim(footer)) + return a.setupProviderBlock(body, hits, width, height, -1, 0) +} + +func (a *app) setupServiceFrame(width, height int) ([]string, int, int) { + s := &a.setup + inner := max(1, min(width-4, welcomeUnitWidth)) + source, _ := a.modelSource(s.provider) + name := strings.ToLower(source.Name) + body := a.setupProviderHead(height) + body = append(body, a.pal.dim(setupTitle(s)), "", a.pal.ink("connect "+name)) + hits := []setupProviderHit{} + caret, caretX := -1, 0 + if s.providerBusy { + word := "connecting · checking the provider" + if s.provider == "codex" { + word = "finish signing in in your browser" + } + body = append(body, a.pal.dim(word)) + if s.providerLink != "" { + body = append(body, a.pal.dim(linkify(fit(signInLinkWord, inner), s.providerLink))) + } + } else if entry := a.connPanel.entry; entry != nil { + body = append(body, a.pal.dim("your "+entry.blank), "") + if entry.choosing() { + for at, choice := range entry.choices { + mark := " " + if at == entry.at { + mark = setupLead + } + hits = append(hits, setupProviderHit{y: len(body), at: at}) + body = append(body, a.pal.ink(mark+choice.Name)) + } + } else { + shown := entry.box.String() + if entry.secret { + shown = maskTyped(shown) + } + caret, caretX = len(body), ansi.StringWidth(setupLead+shown) + body = append(body, a.pal.accent(setupLead)+a.pal.ink(shown)) + } + } else if s.provider == "codex" { + for _, line := range wrap("sign in once in your browser with your ChatGPT plan.", inner) { + body = append(body, a.pal.dim(line)) + } + } else { + hits = append(hits, setupProviderHit{y: len(body), at: -2}) + body = append(body, a.pal.ink("enter tries again")) + } + if s.refusal != "" { + for _, line := range wrap(s.refusal, inner) { + body = append(body, a.pal.accent(line)) + } + } + body = append(body, "") + hits = append(hits, setupProviderHit{y: len(body), at: -1}) + body = append(body, a.pal.dim("Back · alt+left")) + footer := "enter continues · " + setupSkipKeysWord + if s.provider == "codex" && !s.providerBusy { + footer = setupBrowserConnectKeysWord + " · " + setupSkipKeysWord + hits = append(hits, setupProviderHit{y: len(body), at: -2}) + } + if s.providerBusy { + footer = "esc cancels · alt+left back" + if s.providerLink != "" { + footer = "ctrl+y copies link · esc cancels" + } + } + body = append(body, a.pal.dim(footer)) + return a.setupProviderBlock(body, hits, width, height, caret, caretX) +} + +// A provider form shares the welcome measure. When height runs out, the head +// gives way before the answer and navigation at the foot. +func (a *app) setupProviderBlock(body []string, hits []setupProviderHit, width, height, caret, caretX int) ([]string, int, int) { + lead := max(0, (width-max(1, min(width-4, welcomeUnitWidth)))/2) + dropped := max(0, len(body)-height) + body = body[dropped:] + top := max(0, welcomeAbove(len(body), height-len(body))) + a.setup.providerHits = nil + for _, hit := range hits { + if hit.y < dropped { + continue + } + hit.x, hit.y, hit.width = lead, top+hit.y-dropped, max(1, width-lead*2) + a.setup.providerHits = append(a.setup.providerHits, hit) + } + lines := make([]string, top) + for _, line := range body { + lines = append(lines, strings.Repeat(" ", lead)+ansi.Truncate(line, max(1, width-lead), "")) + } + for len(lines) < height { + lines = append(lines, "") + } + a.caret = caret >= dropped + return lines, lead + caretX, top + caret - dropped +} + +// The wordmark keeps the welcome screen's measure and yields on short windows. +func (a *app) setupProviderHead(height int) []string { + if height < 24 { + return nil + } + body := []string{} + for _, row := range wordmarkRows(a.pal.ascii) { + body = append(body, a.pal.muted(row)) + } + return append(body, "") +} diff --git a/internal/tui3/onboardingproviders_test.go b/internal/tui3/onboardingproviders_test.go new file mode 100644 index 0000000000..2289da617a --- /dev/null +++ b/internal/tui3/onboardingproviders_test.go @@ -0,0 +1,256 @@ +package tui3 + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "reflect" + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + "github.com/Agent-Field/codeaf/internal/config" + "github.com/Agent-Field/codeaf/internal/modelsource" +) + +func TestProviderChooserBackSkipPasteAndShortPointerRows(t *testing.T) { + a, dir, _ := setupProviderApp(t, nil) + a.setupPaste("must not become a hidden key") + if a.setup.text != "" { + t.Fatal("chooser collected a secret") + } + a.selectSetupProvider("openrouter") + a.setupPaste("sk-or-v1-a-secret-that-will-be-discarded") + a.setupKeyPress(tea.KeyPressMsg{Code: tea.KeyLeft, Mod: tea.ModAlt}) + if a.setup.provider != "" || a.setup.text != "" || config.APIKeyAt(dir) != "" { + t.Fatal("back saved or retained a secret") + } + + a.width, a.height = 40, 12 + a.setup.providerAt = len(a.setupProviderRows()) - 1 + a.touch() + screen := setupScreen(a) + if strings.Contains(screen, "Skip for now") || strings.Contains(screen, "More providers") || !strings.Contains(screen, setupSkipKeysWord) { + t.Fatal(screen) + } + hit := a.setup.providerHits[len(a.setup.providerHits)-1] + a.setupProviderPress(hit.x, hit.y) + if a.setup.provider != "custom" || a.connPanel.entry == nil || a.connPanel.entry.blank != "base URL" { + t.Fatal("short-screen click did not select the provider") + } + a.backSetupProvider() + a.setupProviderKey(key("esc")) + if a.setup.open || config.SetupSeenAt(dir).IsZero() { + t.Fatal("Esc did not skip setup") + } + +} + +func TestProviderChooserReusesRegionsAndMasksKeys(t *testing.T) { + for _, id := range []string{"z-ai", "moonshot", "qwen", "deepseek", "minimax"} { + t.Run(id, func(t *testing.T) { + a, _, _ := setupProviderApp(t, nil) + a.selectSetupProvider(id) + entry := a.connPanel.entry + if entry == nil { + t.Fatal("provider offered no input") + } + if entry.choosing() { + a.setupServiceKey(key("down")) + expected := entry.value() + a.setupServiceKey(key("enter")) + if a.modelDraft.row.Region != expected { + t.Fatal("region was not applied") + } + } + a.setupPaste("a-secret-key-that-must-be-masked") + screen := setupScreen(a) + if strings.Contains(screen, "a-secret-key") || !strings.Contains(screen, "your key") { + t.Fatal(screen) + } + a.backSetupProvider() + if a.modelDraft != nil || a.connPanel.entry != nil { + t.Fatal("back left the entry alive") + } + }) + } +} + +func TestOnboardingAnonymousCustomConnectsAndUsesItsListedModel(t *testing.T) { + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/models" { + t.Errorf("unexpected connection request %s", r.URL.Path) + http.NotFound(w, r) + return + } + if r.Header.Get("Authorization") != "" { + t.Error("anonymous server received a credential") + } + json.NewEncoder(w).Encode(map[string]any{"data": []any{map[string]string{"id": "installed-one"}, map[string]string{"id": "installed-two"}}}) + })) + defer backend.Close() + a, dir, _ := setupProviderApp(t, nil) + a.sources = modelsource.NewSet(testDefaultService("")) + a.model = "" + a.serviceModelRefresh = func(_ context.Context, _ modelsource.Connected, seed []Model) ([]Model, error) { return seed, nil } + a.selectSetupProvider("custom") + a.setupPaste(backend.URL + "/v1") + cmd := a.setupServiceKey(key("enter")) + if cmd == nil { + t.Fatal("address check did not start") + } + a.Update(cmd()) + if entry := a.connPanel.entry; entry == nil || entry.blank != "name" { + t.Fatal("anonymous address did not advance to name") + } + a.connPanel.entry.box.setText("local-test") + cmd = a.setupServiceKey(key("enter")) + if cmd == nil || !a.setup.providerBusy { + t.Fatal("anonymous endpoint asked for a key") + } + a.Update(cmd()) + if a.setup.step() != setupControls { + t.Fatalf("connection did not reach controls: %s", setupScreen(a)) + } + if a.model != "local-test/installed-one" || a.agent.(*fakeAgent).model != a.model { + t.Fatalf("surface %q, engine %q", a.model, a.agent.(*fakeAgent).model) + } + source, ok := config.ResolveSources(dir, "", config.DefaultBaseURL).ByID("custom") + if !ok || !source.HasCredentials() || !source.Source.KeyOptional { + t.Fatal("anonymous connection was not saved") + } + for _, entry := range a.entries { + if strings.Contains(entry.text, "this conversation was on ·") { + t.Fatal("first connection invented an empty previous model") + } + } + if got := a.modelList(); len(got) != 2 { + t.Fatalf("listed models: %#v", got) + } + a.routerConnect = func(context.Context) (OpenRouterFlow, error) { return nil, nil } + a.model = "" + if a.defaultProviderNeeded() { + t.Fatal("saved anonymous connection opened chooser while catalog was cold") + } +} + +func TestOnboardingOllamaNeedsNoKeyAndCancelledResultsCannotSwitchModel(t *testing.T) { + a, _, _ := setupProviderApp(t, nil) + cmd := a.selectSetupProvider("ollama") + attempt := a.setup.providerAttempt + if cmd == nil || !a.setup.providerBusy || a.connPanel.entry != nil || !a.modelDraft.source.KeyOptional { + t.Fatal("Ollama did not start without a key") + } + a.backSetupProvider() + if attempt.ctx.Err() == nil { + t.Fatal("back did not cancel connection") + } + original := a.model + a.Update(modelConnectResultMsg{setupAttempt: attempt, service: "ollama", models: []Model{{ID: "installed"}}, outcome: modelsource.Outcome{Kind: modelsource.OutcomeConnected}}) + if a.model != original || a.setup.provider != "" || a.setup.step() != setupKey { + t.Fatal("late result moved the model or advanced setup") + } +} + +func TestOnboardingCodexSignInIsVisibleAndLateFlowsAreCancelled(t *testing.T) { + a, dir, _ := setupProviderApp(t, nil) + flow := &panelCodexFlow{url: "https://auth.example/onboarding"} + a.codexConnect = func(context.Context) (CodexFlow, error) { return flow, nil } + old := processOpener + processOpener = func(string) error { return nil } + t.Cleanup(func() { processOpener = old }) + if cmd := a.selectSetupProvider("codex"); cmd != nil || a.setup.providerBusy || a.setup.providerAttempt != nil { + t.Fatal("choosing Codex opened sign-in before confirming its connection screen") + } + if screen := setupScreen(a); !strings.Contains(screen, setupBrowserConnectKeysWord) || strings.Contains(screen, signInLinkWord) { + t.Fatal(screen) + } + cmd := a.setupServiceKey(key("enter")) + if cmd == nil || !a.setup.providerBusy { + t.Fatal("Enter did not start Codex browser sign-in") + } + flowMsg := cmd() + _, wait := a.Update(flowMsg) + if wait == nil || !strings.Contains(setupScreen(a), signInLinkWord) { + t.Fatal("browser link is hidden behind setup") + } + a.backSetupProvider() + if !flow.cancelled { + t.Fatal("back did not cancel browser flow") + } + a.Update(wait()) + if _, ok := config.ResolveSources(dir, "", config.DefaultBaseURL).ByID("codex"); ok { + t.Fatal("cancelled browser answer saved a connection") + } + a.Update(flowMsg) + if !flow.cancelled || a.codexFlow != nil { + t.Fatal("late flow was installed after back") + } +} + +func TestProviderChooserShowsEverySupportedProviderInOneFlatList(t *testing.T) { + a, _, _ := setupProviderApp(t, nil) + rows := a.setupProviderRows() + if len(rows) != len(modelsource.Vendored())+1 { + t.Fatalf("registry providers=%v", rows) + } + for _, size := range []struct{ width, height int }{{120, 40}, {80, 24}, {40, 18}, {40, 12}, {40, 10}} { + a.width, a.height = size.width, size.height + for _, selected := range []int{0, len(rows) - 1} { + a.setup.providerAt = selected + a.touch() + screen := setupScreen(a) + if len(a.setup.providerHits) != len(rows) || strings.Contains(screen, "More providers") || strings.Contains(screen, "Skip for now") || strings.Contains(screen, "of 9") { + t.Fatalf("flat list at %dx%d: %s", size.width, size.height, screen) + } + for at, row := range rows { + if a.setup.providerHits[at].at != at || !strings.Contains(screen, row.name) { + t.Fatalf("%q missing at %dx%d: %s", row.id, size.width, size.height, screen) + } + } + } + } +} + +func TestBrowserSetupLinksStayOnOneRowAndCopyTheWholeAuthorizationURL(t *testing.T) { + target := "https://auth.example/authorize?state=" + strings.Repeat("proof", 120) + "&redirect_uri=http%3A%2F%2Flocalhost%3A9999" + for _, provider := range []string{"codex", "openrouter"} { + t.Run(provider, func(t *testing.T) { + a, _, _ := setupProviderApp(t, nil) + a.setup.provider = provider + if provider == "codex" { + a.modelCatalog = modelsource.Vendored() + a.setup.providerBusy = true + a.setup.providerLink = target + } else { + a.setup.authFlow = &setupOpenRouterFlow{url: target} + a.setup.authLink = target + } + for _, width := range []int{40, 80, 120} { + a.width, a.height = width, 30 + a.touch() + rendered, _, _ := a.frame() + if strings.Count(rendered, linkOpen(target)) != 1 || strings.Count(plain(rendered), signInLinkWord) != 1 || strings.Contains(plain(rendered), "auth.example") { + t.Fatalf("URL was split or exposed: %q", rendered) + } + } + cmd, handled := a.setupKeyPress(tea.KeyPressMsg{Code: 'y', Mod: tea.ModCtrl}) + if !handled || cmd == nil || !reflect.DeepEqual(cmd(), tea.Raw(osc52(target, a.tmux))()) { + t.Fatal("copy did not carry the complete authorization URL") + } + }) + } +} + +func TestAllBrowserWaitingCardsUseOneShortLinkWithTheCompleteTarget(t *testing.T) { + a := newTestApp(nil) + target := "https://accounts.example/authorize?state=" + strings.Repeat("x", 700) + e := &entry{kind: entryConnect, conn: &connectCard{name: "Google", state: connectWaiting, link: target}} + for _, width := range []int{24, 40, 80} { + rows := a.connectRows(e, width) + if len(rows) != 2 || !strings.Contains(rows[1], linkOpen(target)) || !strings.Contains(plain(rows[1]), signInLinkWord) || strings.Contains(plain(rows[1]), "accounts.example") { + t.Fatalf("waiting card: %q", rows) + } + } +} diff --git a/internal/tui3/opener.go b/internal/tui3/opener.go index a43c9cf0c1..c9a6bbff58 100644 --- a/internal/tui3/opener.go +++ b/internal/tui3/opener.go @@ -20,7 +20,8 @@ import ( // - WRITE IT DOWN. The link is drawn as text, always, whether or not the // handoff worked — and the text is wrapped in OSC 8 where the sequence is // safe, so a terminal that understands hyperlinks makes it clickable and one -// that does not shows exactly the characters a person can select and copy. +// that does not keeps the plain label. Sign-in cards and the setup copy +// shortcut also hand back the complete target without relying on hyperlinks. // THIS IS THE SSH CASE and it is not an edge: a browser opened on the far // end of a connection is a browser nobody is sitting at. // @@ -110,6 +111,9 @@ func linkOpen(uri string) string { // close with bytes that do not match what opened. func linkClose() string { return ansi.ResetHyperlink() } +// Short visible text keeps a browser sign-in on one row; its target stays whole. +const signInLinkWord = "open sign-in page" + // linkify wraps a label as a hyperlink to uri, and returns the label untouched // when the sequence would not be safe. // diff --git a/internal/tui3/ordinarylaunch_test.go b/internal/tui3/ordinarylaunch_test.go index 9adf650b65..38010ac365 100644 --- a/internal/tui3/ordinarylaunch_test.go +++ b/internal/tui3/ordinarylaunch_test.go @@ -109,14 +109,14 @@ func TestTheSetupOpensOnAnOrdinaryLaunchWithNoKey(t *testing.T) { t.Fatal("a fresh install launched the ordinary way was shown no setup at all") } screen := ordinaryScreen(a) - if !strings.Contains(screen, "connect openrouter") { + if !strings.Contains(screen, setupProviderHeading) { t.Fatalf("the provider-connection step is not on the screen:\n%s", screen) } if !strings.Contains(screen, "setting up") { t.Fatalf("the setup's own title is not on the screen:\n%s", screen) } - if !strings.Contains(screen, "default provider") { - t.Fatalf("the OpenRouter connection was not scoped to the default provider:\n%s", screen) + if !strings.Contains(screen, setupProviderSentence) { + t.Fatalf("the chooser did not explain connecting a provider:\n%s", screen) } }) } diff --git a/internal/tui3/palette.go b/internal/tui3/palette.go index e335e3f5dd..3ff991b68f 100644 --- a/internal/tui3/palette.go +++ b/internal/tui3/palette.go @@ -34,13 +34,11 @@ import ( // - It is bottom-anchored and takes the input line's place. The conversation // shrinks above it; nothing pops up over the middle of what somebody was // reading. -// - ENTER APPLIES AND THE LIST STAYS UP; esc only closes, and undoes nothing. -// Two models can be compared on their prices, chosen between and changed -// back without the list going away ([app.pickerKey] argues it). What esc -// does give back is the draft that was being typed and the frame — the -// picker holds its own filter text, and the person's half-written sentence -// is never in it. It used to close on enter and restore the model in use, -// which made every comparison a round trip. +// - ENTER ON A MODEL APPLIES AND CLOSES; esc closes without choosing a model. +// Both return the draft and the frame because the picker holds its own +// filter text; the person's half-written sentence is never in it. A +// confirmed choice is immediately visible in the conversation or parent +// page, and another comparison starts by reopening the list. const pickerRows = 12 // picker is the overlay's whole state. The zero value is closed. @@ -164,9 +162,8 @@ type picker struct { // underneath may move it, which is the whole of the freeze on this list. // // THE ONE THING THAT MOVES IT IS THE PERSON ([picker.restate]). Enter - // chooses and leaves the list up, so the model in use can change while it - // is open; a mark left on the row they had just left would be the one thing - // on this list that was no longer true. + // on a provider can also choose its model while leaving the controls open; + // the mark must follow that choice until the list is closed. current string // held is each model's row facts, frozen the first time this list drew @@ -2907,14 +2904,14 @@ func (a *app) openTaskPicker(id uint64) { // modelList is the source order stated in models.go, applied once here: the // door's list (the catalog, when it can answer without a fetch), then the disk -// cache, then the built-ins. Each rung is tried only if the one above it came +// cache. Each rung is tried only if the one above it came // back empty, and none of them can block. // // EVERY RUNG IS FILTERED THE SAME WAY ([chatModels]): a row on offer here is a // model you can talk to. The filter sits at the join rather than on any one -// source because all three of them have carried a drawing model at some point — +// source because both have carried a drawing model at some point — // the door's catalog publishes them, the cache is a file the door wrote before -// this rule existed — and a rule enforced at two of three places is a rule with +// this rule existed — and a rule enforced on only one source is a rule with // a way round it. func (a *app) modelList() []Model { return a.modelsFor(chatModel) } @@ -2945,11 +2942,25 @@ func (a *app) modelPickerList() []Model { // model falls through for /model. func (a *app) modelsFor(keep modelFilter) []Model { services := a.sources.All() - if len(services) < 2 { + if len(services) == 0 { + // Older doors supply their catalog without service metadata. Local + // launches always resolve a set and take the access checks below. + return a.modelsForDefault(keep) + } + if len(services) == 1 { + if !services[0].HasCredentials() { + return nil + } return a.modelsForDefault(keep) } grouped := make([]Model, 0) for order, service := range services { + // ACCESS COMES BEFORE THE CATALOG AND ITS CACHE. The default service + // is always in the routing set, even with no key, and a disconnected + // account's cached names are not models this person can choose. + if !service.HasCredentials() { + continue + } var models []Model if order == 0 { models = a.modelsForDefault(keep) @@ -2997,18 +3008,22 @@ func (a *app) modelsFor(keep modelFilter) []Model { return grouped } -// modelsForDefault is the exact pre-service ladder. Keeping it whole makes the -// one-service path and each slot's fallback byte-for-byte what they were. +// modelsForDefault reads the default provider's known catalog or cache. No +// built-in model is evidence that this account can use it, so a cold list stays +// empty until discovery answers. func (a *app) modelsForDefault(keep modelFilter) []Model { if a.models != nil { - if list := keepModels(a.models(), keep); len(list) > 0 { - return list + if models := a.models(); models != nil { + // A known catalog is authoritative even when this slot has no + // matches; older cached rows must not enlarge what it serves. + return keepModels(cleanModels(models), keep) } } - if list := keepModels(a.cachedModels(), keep); len(list) > 0 { - return list + if !a.sources.Empty() { + service := a.sources.Default() + return keepModels(a.cachedModelsFor(service.Source.ID, service.Address), keep) } - return keepModels(BuiltinModels(), keep) + return keepModels(a.cachedModels(), keep) } // nonChatWarning is what `/model ` says instead of switching, and it is @@ -3182,17 +3197,10 @@ func (a *app) pickerKey(msg tea.KeyPressMsg) tea.Cmd { case "esc": a.pick.close() - // ── ENTER CHOOSES AND THE LIST STAYS OPEN ─────────────────────────────── - // - // It used to close on the press, which made every choice final and every - // comparison a round trip: pick a model, watch the list vanish, type - // `/model` again to see what the other one cost. The list is a TABLE now — - // a thing built to be read down and compared — and a table that shuts the - // moment you touch a row is a table you can use once. - // - // So enter applies and leaves it up, and `esc` is the way out. Applying is - // safe to repeat: switching a model twice lands on the second, and pinning - // a provider twice writes the second row. + // ENTER CONFIRMS A MODEL AND CLOSES THE LIST, so the next keystroke belongs + // to the draft again and the completed choice is visible in the conversation. + // Provider rows retain their navigation because a container can open another + // level instead of choosing a model. case "enter": chosen, ok := a.pick.choice() task := a.pick.task @@ -3215,7 +3223,7 @@ func (a *app) pickerKey(msg tea.KeyPressMsg) tea.Cmd { if ok { // THE LAST ROW IS NOT A MODEL. It is the door to connect another // provider, and enter on it opens that flow with the list behind - // it — a model row applies and stays, this row opens and leaves. + // it; neither kind of choice leaves this model list open. if chosen.AddProvider { a.pick.close() return a.openAddProvider(false) @@ -3228,7 +3236,7 @@ func (a *app) pickerKey(msg tea.KeyPressMsg) tea.Cmd { } else { a.switchModel(chosen.ID, chosen.ContextLength) } - a.restatePicker(&a.pick, a.model) + a.pick.close() } // The reasoning cycle sits above the filter's default branch on purpose: it diff --git a/internal/tui3/palette_test.go b/internal/tui3/palette_test.go index e8626f3656..4cf2b994bb 100644 --- a/internal/tui3/palette_test.go +++ b/internal/tui3/palette_test.go @@ -109,6 +109,10 @@ func TestTheFilterRanksAPrefixAboveASubstring(t *testing.T) { if _, ok := a.pick.choice(); ok { t.Fatal("enter on an empty list must choose nothing") } + drive(t, a, key("enter")) + if !a.pick.open || a.model != "moonshotai/kimi-k3" { + t.Fatal("enter without a matching model must leave the picker and model alone") + } } // THE MATCHED LETTERS CARRY THE EMPHASIS, AND NOTHING ELSE ON THE ROW DOES. @@ -138,29 +142,23 @@ func TestTheFilterCarriesTheMatchedLettersInBold(t *testing.T) { } } -// ENTER APPLIES AND THE LIST STAYS UP. It used to close on the press, which -// made every choice final and every comparison a round trip; the list is a -// table now, and esc is the way out ([app.pickerKey]). -func TestEnterAppliesTheChoiceAndLeavesTheListOpen(t *testing.T) { +// ENTER CONFIRMS AND RETURNS THE KEYBOARD TO THE DRAFT, so choosing a model +// cannot leave subsequent typing in the filter box. +func TestEnterAppliesTheChoiceAndClosesTheList(t *testing.T) { agent := &fakeAgent{model: "moonshotai/kimi-k3"} a := pickerApp(t, agent, pickerCatalog) - typeLine(t, a, "/model") + a.input.setText("half a thought") + a.openPicker() typeInto(t, a, "gpt") drive(t, a, key("down")) // gpt-5-classic → openai/gpt-4.1-mini drive(t, a, key("enter")) - if !a.pick.open { - t.Fatal("enter has to leave the picker open") - } - // AND THE MARK FOLLOWS THE CHOICE, because the row it used to sit on is no - // longer the model in use ([picker.restate]). - if a.pick.current != "openai/gpt-4.1-mini" { - t.Fatalf("the list still marks %q", a.pick.current) - } - drive(t, a, key("esc")) if a.pick.open { - t.Fatal("esc has to close the picker") + t.Fatal("enter has to close the picker after choosing the model") + } + if a.input.String() != "half a thought" { + t.Fatalf("choosing a model changed the draft to %q", a.input.String()) } if agent.model != "openai/gpt-4.1-mini" { t.Fatalf("model is %q, want openai/gpt-4.1-mini", agent.model) @@ -281,14 +279,13 @@ func TestThePickerIsBottomAnchoredAndMarksTheCurrentModel(t *testing.T) { } } -func TestTheModelListFallsBackToTheCacheThenTheBuiltins(t *testing.T) { +func TestTheModelListUsesKnownRowsAndNeverInventsBuiltins(t *testing.T) { t.Setenv("CODEAF_HOME", t.TempDir()) a := newTestApp(&fakeAgent{model: "m"}) - // Nothing from the door, nothing on disk: the built-ins are the floor, and - // the picker still opens onto a list. - if got := a.modelList(); len(got) != len(BuiltinModels()) || got[0].ID != BuiltinModels()[0].ID { - t.Fatalf("with no source the list is %v, want the built-ins", got) + // Nothing from the door or disk means no known model can be offered. + if got := a.modelList(); len(got) != 0 { + t.Fatalf("with no known catalog the list invented models: %v", got) } cached := []Model{{ID: "cached/one", ContextLength: 32_000}, {ID: "cached/two"}} diff --git a/internal/tui3/pickerdirectonly_test.go b/internal/tui3/pickerdirectonly_test.go index a32c47725a..be18d6b26c 100644 --- a/internal/tui3/pickerdirectonly_test.go +++ b/internal/tui3/pickerdirectonly_test.go @@ -11,7 +11,7 @@ import ( "github.com/Agent-Field/codeaf/internal/session" ) -func TestDirectOnlyPickerKeepsTheUnkeyedDefaultHeadingAndManual(t *testing.T) { +func TestDirectOnlyPickerOmitsTheUnkeyedDefaultAndExplainsItsModels(t *testing.T) { t.Setenv(config.APIKeyEnv, "") t.Setenv("OPENAI_API_KEY", "") dir := t.TempDir() @@ -41,12 +41,11 @@ func TestDirectOnlyPickerKeepsTheUnkeyedDefaultHeadingAndManual(t *testing.T) { } a.openPicker() frame := plain(strings.Join(a.pick.rows(100, a.pick.height(100), a.pal, -1, func(string) string { return "" }), "\n")) - defaultAt, directAt := strings.Index(frame, "openrouter"), strings.Index(frame, "ollama") - if defaultAt < 0 || directAt <= defaultAt || !strings.Contains(frame, "fake-small") { - t.Fatalf("direct-only picker lost its default-first headings:\n%s", frame) + if strings.Contains(frame, "openrouter") || strings.Contains(frame, config.DefaultModel) || !strings.Contains(frame, "ollama/fake-small") { + t.Fatalf("direct-only picker must offer only the connected local model:\n%s", frame) } page, ok := manual.Chat().Page("commands") - if !ok || !strings.Contains(page, "even without its key") || !strings.Contains(page, "Ollama") { - t.Fatal("manual does not explain why a direct-only picker retains both headings") + if !ok || !strings.Contains(page, "OpenRouter is\nabsent without its key") || !strings.Contains(page, "only Ollama connected") { + t.Fatal("manual does not explain why a direct-only picker omits the unconnected provider") } } diff --git a/internal/tui3/pickersort_test.go b/internal/tui3/pickersort_test.go index 928be631ae..d55702a714 100644 --- a/internal/tui3/pickersort_test.go +++ b/internal/tui3/pickersort_test.go @@ -476,6 +476,8 @@ func TestBackStandsBesideThePickAtHome(t *testing.T) { lab := newHomeLab(t) mine := lab.session("-tmp-alpha", "aaaa000000000001", "a session", "/tmp/alpha", time.Now()) a := lab.app(mine) + // The picker needs a listed model; a cold catalog offers no guesses. + a.models = func() []Model { return pickerCatalog } a.openHome() runCmd(a.openHome()) typeHome(a, "/model") diff --git a/internal/tui3/provideraccess_test.go b/internal/tui3/provideraccess_test.go new file mode 100644 index 0000000000..2760c34cc3 --- /dev/null +++ b/internal/tui3/provideraccess_test.go @@ -0,0 +1,233 @@ +package tui3 + +import ( + "context" + "reflect" + "strings" + "testing" + "time" + + tea "charm.land/bubbletea/v2" + "github.com/Agent-Field/codeaf/internal/config" + "github.com/Agent-Field/codeaf/internal/modelsource" +) + +func providerAccessApp(t *testing.T) (*app, string) { + t.Helper() + t.Setenv(config.APIKeyEnv, "") + t.Setenv("OPENAI_API_KEY", "") + dir := t.TempDir() + if err := config.WriteSources(dir, []config.PersistedSource{{ID: "ollama", Written: "ollama", Order: 1}}); err != nil { + t.Fatal(err) + } + ollama := modelsource.Connected{Source: testModelSource(t, "ollama"), Address: "http://localhost:11434/v1"} + a := modelServiceTestApp(t, dir, "ollama/installed", modelsource.NewSet(testDefaultService(""), ollama), []Model{{ID: "vendor/cloud"}}) + a.sourceModels = map[string][]Model{"ollama": {{ID: "installed"}}} + return a, dir +} + +func selectAddProvider(t *testing.T, a *app, id string) { + t.Helper() + for at, item := range a.addPanel.items { + if item.sourceID == id { + a.addPanel.cursor = at + a.addPanelKey(key("enter")) + return + } + } + t.Fatalf("provider %q is absent", id) +} + +func TestLaterProviderMenusShowTheInitialCatalogAndActualConnectionStatus(t *testing.T) { + a, _ := providerAccessApp(t) + // A persisted row without credentials must not acquire a connected badge. + services := append(a.sources.All(), modelsource.Connected{Source: testModelSource(t, "deepseek")}) + a.sources = modelsource.NewSet(services...) + for _, routerKey := range []string{"", "router-key"} { + a.sources = a.sources.WithDefaultKey(routerKey) + a.openAddProvider(false) + var ids, names []string + for _, item := range a.addPanel.items { + if item.heading || item.probe != nil { + continue + } + ids, names = append(ids, item.sourceID), append(names, item.title) + want := item.sourceID == "ollama" || item.sourceID == modelsource.DefaultID && routerKey != "" + if item.connected != want || !strings.Contains(item.detail, map[bool]string{true: "connected ·", false: "not connected ·"}[want]) { + t.Fatalf("%s status = %q, connected=%v, want %v", item.sourceID, item.detail, item.connected, want) + } + } + var initialIDs, initialNames []string + for _, row := range a.setupProviderRows() { + initialIDs, initialNames = append(initialIDs, row.id), append(initialNames, row.name) + } + if len(ids) != 9 || !reflect.DeepEqual(ids, initialIDs) || !reflect.DeepEqual(names, initialNames) { + t.Fatalf("later catalog = %v/%v, initial = %v/%v", ids, names, initialIDs, initialNames) + } + seen := map[string]bool{} + for _, row := range a.modelConnectionRows() { + id, _ := modelConnectionSource(row.ID) + seen[id] = true + if id == modelsource.DefaultID && row.Connected != (routerKey != "") || id == "ollama" && !row.Connected || id == "deepseek" && row.Connected { + t.Fatalf("/connect row has incorrect status: %+v", row) + } + } + for _, id := range initialIDs { + if !seen[id] { + t.Fatalf("/connect omits initial provider %s", id) + } + } + a.addPanel.close() + } +} + +func TestAddingOpenRouterFromTheModelMenuPreservesOllamaAndHandsOverTheKey(t *testing.T) { + a, dir := providerAccessApp(t) + a.input.setText("keep this draft") + a.openPicker() + for at, row := range a.pick.list { + if a.pick.all[row.hit].AddProvider { + a.pick.cursor = at + break + } + } + a.pickerKey(key("enter")) + selectAddProvider(t, a, modelsource.DefaultID) + if !a.setup.connection || a.setup.returnAdd != true || a.pick.open || a.addPanel.open { + t.Fatal("/model add row did not open the later OpenRouter connection form") + } + if strings.Contains(a.setupKeysWord(), "skips setup") || setupTitle(&a.setup) != "connect a provider" { + t.Fatal("later connection still describes first-run setup") + } + var handed []string + a.applyAPIKey = func(key string) error { handed = append(handed, key); return nil } + const secret = "sk-or-v1-provider-menu-regression-key" + a.setupPaste(secret) + a.setupKeyPress(key("enter")) + if config.APIKeyAt(dir) != secret || !reflect.DeepEqual(handed, []string{secret}) { + t.Fatal("OpenRouter key did not reach the profile and live session") + } + if !config.SetupSeenAt(dir).IsZero() || a.setup.open || !a.addPanel.open || string(a.input.value) != "keep this draft" { + t.Fatal("adding a provider revisited onboarding or changed the draft") + } + if rows := config.PersistedSources(dir); len(rows) != 1 || rows[0].ID != "ollama" { + t.Fatalf("OpenRouter key was written as a direct provider or removed Ollama: %+v", rows) + } + var ids []string + for _, model := range a.modelList() { + ids = append(ids, model.ID) + } + if !reflect.DeepEqual(ids, []string{"vendor/cloud", "ollama/installed"}) { + t.Fatalf("combined picker catalog = %v", ids) + } + selectAddProvider(t, a, modelsource.DefaultID) + if !a.connPanel.open || a.setup.open { + t.Fatal("a connected OpenRouter row did not open management") + } + row, ok := a.connPanel.choice() + if !ok || row.ID != modelConnectionID(modelsource.DefaultID) || !row.Connected { + t.Fatalf("management focused the wrong provider: %+v", row) + } + a.connectAct(a.connPanel.cursor) + if config.APIKeyAt(dir) == "" { + t.Fatal("first Enter disconnected without confirmation") + } + a.requireListedModel = true + a.model = "vendor/cloud" + a.connectAct(a.connPanel.cursor) + if a.model != "ollama/installed" || a.agent.Model() != "ollama/installed" { + t.Fatal("removing OpenRouter did not move the next request to available Ollama") + } + if config.APIKeyAt(dir) != "" || !reflect.DeepEqual(handed, []string{secret, ""}) { + t.Fatal("disconnect did not revoke the profile and live key") + } + if models := a.modelList(); len(models) != 1 || models[0].ID != "ollama/installed" { + t.Fatalf("disconnect lost Ollama or retained cloud models: %+v", models) + } + a.addPanel.close() +} + +func TestLaterOpenRouterBrowserConnectionReturnsWithoutOnboarding(t *testing.T) { + for _, cancel := range []bool{false, true} { + t.Run(map[bool]string{false: "success", true: "cancel"}[cancel], func(t *testing.T) { + a, dir := providerAccessApp(t) + flow := &setupOpenRouterFlow{url: "https://example.com/sign-in", key: "sk-or-v1-later-browser-regression-key"} + a.routerConnect = func(context.Context) (OpenRouterFlow, error) { return flow, nil } + old := processOpener + processOpener = func(string) error { return nil } + t.Cleanup(func() { processOpener = old }) + a.openConnect() + a.openModelConnection(modelsource.DefaultID) + begin := a.connectAct(a.connPanel.cursor) + if begin != nil || a.setup.authStarting || !strings.Contains(a.setupKeysWord(), setupBrowserConnectKeysWord) { + t.Fatal("choosing OpenRouter bypassed browser confirmation") + } + begin, _ = a.setupKeyPress(key("enter")) + _, wait := a.update(begin()) + if cancel { + a.setupKeyPress(key("esc")) + a.setupKeyPress(key("esc")) + } + a.update(wait()) + if a.setup.open || !a.connPanel.open || !config.SetupSeenAt(dir).IsZero() { + t.Fatalf("browser return: setup=%v connection=%v panel=%v seen=%v refusal=%q", a.setup.open, a.setup.connection, a.connPanel.open, config.SetupSeenAt(dir), a.setup.refusal) + } + if got := config.APIKeyAt(dir); cancel && got != "" || !cancel && got != flow.key { + t.Fatal("browser result did not honor completion/cancellation") + } + if cancel && !flow.cancelled { + t.Fatal("browser flow was not cancelled") + } + }) + } +} + +func TestOpenRouterDisconnectPreservesShellKeysAndAnAnsweringTurn(t *testing.T) { + a, dir := providerAccessApp(t) + t.Setenv(config.APIKeyEnv, "sk-or-v1-shell-key") + a.handAPIKey() + a.disconnectModelService(modelsource.DefaultID) + if !a.sources.Default().HasCredentials() || config.APIKeyAt(dir) == "" { + t.Fatal("disconnect pretended to remove a shell credential") + } + t.Setenv(config.APIKeyEnv, "") + if err := config.WriteAPIKey(dir, "sk-or-v1-profile-key"); err != nil { + t.Fatal(err) + } + a.handAPIKey() + a.model, a.state = "vendor/cloud", stateWorking + a.disconnectModelService(modelsource.DefaultID) + if config.APIKeyAt(dir) == "" { + t.Fatal("disconnect revoked a provider answering the current turn") + } +} + +func TestLaterOpenRouterConnectionRefreshesItsCatalog(t *testing.T) { + a, _ := providerAccessApp(t) + refreshed := false + a.refreshModels = func(context.Context) ([]Model, time.Time, error) { + refreshed = true + return []Model{{ID: "vendor/new"}}, time.Now(), nil + } + a.openModelConnection(modelsource.DefaultID) + a.connectAct(a.connPanel.cursor) + a.setupPaste("sk-or-v1-refresh-catalog-regression-key") + cmd, _ := a.setupKeyPress(key("enter")) + if cmd == nil { + t.Fatal("connection did not schedule catalog discovery") + } + // The connection returned a catalog command without starting another sign-in. + msg := cmd() + if batch, ok := msg.(tea.BatchMsg); ok { + for _, command := range batch { + if command != nil { + a.Update(command()) + } + } + } else { + a.Update(msg) + } + if !refreshed || !a.sources.Default().HasCredentials() { + t.Fatal("connected OpenRouter did not discover its own models") + } +} diff --git a/internal/tui3/reasoning_test.go b/internal/tui3/reasoning_test.go index afb6e23364..80ed015715 100644 --- a/internal/tui3/reasoning_test.go +++ b/internal/tui3/reasoning_test.go @@ -238,9 +238,8 @@ func TestTheLevelIsPerModelAndSurvivesASwitchAwayAndBack(t *testing.T) { typeInto(t, a, "sonnet") drive(t, a, ctrlT()) // low drive(t, a, ctrlT()) // medium - // Enter chooses and leaves the list up ([app.pickerKey]); the frame is read - // below for what the SEAM says, so the list is closed first. - drive(t, a, key("enter"), key("esc")) + // Enter confirms the model and closes the list, exposing the seam below. + drive(t, a, key("enter")) if agent.model != "anthropic/claude-sonnet-4.5" { t.Fatalf("model is %q, want the sonnet row", agent.model) } diff --git a/internal/tui3/resizepaint_test.go b/internal/tui3/resizepaint_test.go index 45c4627995..a1b805285e 100644 --- a/internal/tui3/resizepaint_test.go +++ b/internal/tui3/resizepaint_test.go @@ -155,7 +155,7 @@ func (w resizeOutput) Write(p []byte) (int, error) { // THE REPAINT REACHES THE TERMINAL. Commands alone cannot prove that Bubble // Tea clears physical cells before revealing a shorter greeting after setup. func TestTheRealRendererClearsAResizedSetupBeforeTheGreeting(t *testing.T) { - a, _, _ := setupApp(t, nil) + a, _, _ := setupProviderApp(t, nil) a.routerConnect = func(context.Context) (OpenRouterFlow, error) { return nil, nil } a.width, a.height = 80, 24 model := &resizeTerminal{app: a, cleared: make(chan struct{}, 1)} @@ -193,9 +193,9 @@ func TestTheRealRendererClearsAResizedSetupBeforeTheGreeting(t *testing.T) { } } } - awaitText("connect openrouter") + awaitText(setupProviderHeading) program.Send(tea.WindowSizeMsg{Width: 160, Height: 45}) - awaitText("connect openrouter") + awaitText(setupProviderHeading) program.Send(resizeSettledMsg{}) select { case <-model.cleared: diff --git a/internal/tui3/roommodel_test.go b/internal/tui3/roommodel_test.go index d315d1c174..8f98f59090 100644 --- a/internal/tui3/roommodel_test.go +++ b/internal/tui3/roommodel_test.go @@ -178,6 +178,8 @@ func TestATaskModelNeverWearsTheConversationsReasoningSuffix(t *testing.T) { // else, and the conversation's own model is untouched by it. func TestPressingARunningTasksModelRetargetsThatTaskAlone(t *testing.T) { a, fake := roomModelApp(t, "z-ai/glm-5.2") + // A room can choose only rows a provider actually listed. + a.models = func() []Model { return []Model{{ID: "z-ai/glm-5.2"}, {ID: "openai/gpt-4.1-mini"}} } a.width, a.height = 120, 24 a.touch() @@ -217,6 +219,9 @@ func TestPressingARunningTasksModelRetargetsThatTaskAlone(t *testing.T) { if a.model != before { t.Fatalf("retargeting a task moved the conversation's model to %q", a.model) } + if a.pick.open || a.room == nil || a.room.id != 9 { + t.Fatal("choosing a task model must close the picker and keep its room open") + } // And it is written down where every other model change is. want, found := "task 9 · model · "+chosen.ID, false for _, e := range a.entries { diff --git a/internal/tui3/settings.go b/internal/tui3/settings.go index d3c94cb4e2..cdaceb66af 100644 --- a/internal/tui3/settings.go +++ b/internal/tui3/settings.go @@ -3045,9 +3045,8 @@ func (a *app) sheetSelectKey(msg tea.KeyPressMsg) { switch msg.String() { case "esc": s.sel = nil - // ENTER WRITES AND LEAVES THE LIST UP, which is /model's own rule and for - // its reason ([app.pickerKey]); esc is the way out and still changes - // nothing itself. + // ENTER CONFIRMS AND CLOSES THE MODEL LIST, as it does in the conversation, + // so the settings row immediately shows the model that was chosen. case "enter": // ENTER INSIDE AN OPEN FOLD IS A LANE AND NOT A SLOT. if lane, onLane := sel.pick.laneUnder(); onLane { @@ -3060,7 +3059,7 @@ func (a *app) sheetSelectKey(msg tea.KeyPressMsg) { if !ok || !found { return } - a.restatePicker(&sel.pick, chosen) + s.sel = nil // A role writes ONE PAIR of the row it shares with every other pin; // everything else writes the row whole. if role != "" { diff --git a/internal/tui3/settingsfix_test.go b/internal/tui3/settingsfix_test.go index ffe60ea1b8..887e45b979 100644 --- a/internal/tui3/settingsfix_test.go +++ b/internal/tui3/settingsfix_test.go @@ -107,11 +107,9 @@ func TestASettingsSlotOpensTheModelPickerAndWritesTheRow(t *testing.T) { // Enter writes the row through the registry, which for the conversation's // own slot is the running session — one door, the same one /model takes. drive(t, a, key("enter")) - // ENTER WRITES AND LEAVES THE LIST UP ([app.pickerKey] argues it). - if a.sheet.sel == nil { - t.Fatal("enter closed the picker; esc is the way out now") + if a.sheet.sel != nil || !a.at(pageSettings) { + t.Fatal("enter must close the model list and return to settings") } - drive(t, a, key("esc")) if a.model != "anthropic/claude-sonnet-4.5" { t.Fatalf("the slot wrote %q", a.model) } diff --git a/internal/tui3/settingsroles_test.go b/internal/tui3/settingsroles_test.go index cdb35681bd..47f37f19bb 100644 --- a/internal/tui3/settingsroles_test.go +++ b/internal/tui3/settingsroles_test.go @@ -252,9 +252,9 @@ func TestPinningARoleWritesThePinnedRolesRow(t *testing.T) { t.Fatalf("pinning the planner moved the designer to %q", got) } - // The list a role is pinned from stays open ([app.pickerKey]); the panel - // behind it is what this reads. - drive(t, a, key("esc")) + if a.sheet.sel != nil || !a.at(pageSettings) { + t.Fatal("choosing a role model must return to the settings panel") + } cursorToRole(t, a, roles.RolePlanner) a.touch() screen := plain(frame(a)) diff --git a/internal/tui3/tui3.go b/internal/tui3/tui3.go index cb65d91a2f..b757dc8b42 100644 --- a/internal/tui3/tui3.go +++ b/internal/tui3/tui3.go @@ -827,14 +827,19 @@ type Options struct { // slice because the door's list may be warming: it is called the moment the // picker opens, so a catalog that resolved after boot is on offer, and it // MUST NOT block — a picker that waits on a fetch is a picker that answered - // a question with a spinner. Nil, or an empty answer, falls through to - // ~/.codeaf/v3/models.json and then to [BuiltinModels] (see models.go). + // a question with a spinner. Nil falls through to the provider's cached + // list; a known empty list stays empty, and no built-ins are offered. // // THE ONE FETCH IS ASKED FOR, AND IT STILL DOES NOT BLOCK: [Options. // RefreshModels] runs as a command off the loop while the picker keeps // answering, and this function goes on returning what it returned until // the door has swapped in what that fetch brought back. Models func() []Model + // RequireListedModel makes the opening model and every automatic replacement + // come from the same available chat catalog as /model. A cold or empty list + // shows no model and holds sends until discovery or a connection supplies one. + // Local doors own these catalogs; remote doors leave the engine's choice alone. + RequireListedModel bool // ModelsForService is the process shelf's never-waiting reading for one // connected service. Keeping it beside Models makes the picker read one // shelf for every group instead of a surface-only map that a restart happens