From 72d91897231aaa54b25581a74c87e1c14ed19491 Mon Sep 17 00:00:00 2001 From: Quickbeard Date: Fri, 31 Jul 2026 14:41:25 +0700 Subject: [PATCH 1/2] fix(tui): stop defaulting to the Zen provider in the model fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With no config `model` pin and no saved recents (a fresh `codevhub install` — both writers dropped the pin in PR #142 / hub PR #221), the TUI's fallbackModel took sync.data.provider[0]. The hosted Zen provider self-registers (free models need no auth) ahead of config-only providers, so a gateway-only setup started on "Big Pickle · OpenCode Zen" and would send messages to upstream's hosted gateway. Extract useConnected's predicate into isConnectedProvider and make the fallback prefer the first provider that passes it — the same "a real provider is configured" test the startup gate and footer indicator use. When no provider qualifies the old first-provider behavior is kept. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 11 +++++++ DIVERGENCES.md | 1 + packages/tui/src/component/use-connected.tsx | 14 +++++---- packages/tui/src/context/local.tsx | 7 ++++- .../tui/test/component/use-connected.test.ts | 30 +++++++++++++++++++ 5 files changed, 56 insertions(+), 7 deletions(-) create mode 100644 packages/tui/test/component/use-connected.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 79750d1186..f52995a382 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,17 @@ file is the human-readable summary of what changed and why. ## Unreleased +### TUI + +- **A fresh install no longer defaults to "Big Pickle · OpenCode Zen"** — with + no `model` in the config and no saved model selection (e.g. right after + `codevhub install`, which stopped pinning a model in the same round as our + setup flow), the fallback picked the first provider in the list, and + upstream's hosted Zen provider always registers itself first. The fallback + now prefers the first deliberately-configured provider — the same "a real + provider is configured" test the startup gate and the footer indicator use — + so a gateway-only setup starts on a gateway model. + ## 1.18.4-4 ### TUI diff --git a/DIVERGENCES.md b/DIVERGENCES.md index dd19b81043..e691ac513c 100644 --- a/DIVERGENCES.md +++ b/DIVERGENCES.md @@ -119,6 +119,7 @@ from the squash message first, or use a plain merge. | `packages/tui/src/component/dialog-provider.tsx`, `packages/tui/src/component/dialog-model.tsx`, `packages/opencode/src/cli/cmd/providers.ts` | Filter `opencode`/`opencode-go` out of provider pickers and the `/model` dialog (model list + Favorites/Recent; the model dialog's opencode-first sort became a plain name sort) | Upstream's hosted offerings (Zen/Go) are detours for gateway users. | Keep our filters. | | `packages/tui/src/component/dialog-provider.tsx` | The "Other" custom-provider option routes into the fork-only `runCustomProviderFlow` (component/dialog-custom-provider.tsx, new file) — id → API key → optional base URL → model discovery via `/v1/models` → `provider.` block PATCHed into global codev.json (key stays in auth.json) — instead of upstream's credential-only `ApiMethod`; the local `promptCustomProviderID` helper moved into that flow (upstream's "only stores a credential" description no longer holds). `ApiMethod`'s `custom` prop is now unreferenced but left in place. | An empty base URL keeps upstream's credential-only behavior; with one, the provider is usable without hand-editing codev.json. The configure gate's "Custom provider" option shares the same flow. | Keep our `onSelect` + import and the helper deletion; take upstream's changes to `ApiMethod`/other methods (the flow only wraps the entry point). | | `packages/tui/src/component/prompt/index.tsx` | Gated the prompt footer's model/provider indicator on `useConnected()` (the shared "a real provider is configured" predicate) | Don't advertise the free hosted Zen default (e.g. "Big Pickle · OpenCode Zen") in the footer while nothing is configured — same rationale and predicate as `app.tsx`'s startup gate and the provider/model filters. A configured gateway/real provider, or a deliberately-authenticated opencode/opencode-go, still shows its model. | Keep the extra `Show` condition; take upstream's surrounding changes. | +| `packages/tui/src/component/use-connected.tsx`, `packages/tui/src/context/local.tsx` | use-connected.tsx: the provider predicate is extracted into an exported `isConnectedProvider`; `useConnected` now delegates to it (same logic). local.tsx: `fallbackModel`'s last resort is `sync.data.provider.find(isConnectedProvider) ?? sync.data.provider[0]` instead of a blind `sync.data.provider[0]` (covered by fork-only `test/component/use-connected.test.ts`, not tracked here). | The hosted Zen provider self-registers (free models need no auth) and is inserted ahead of config-only providers, so with no config `model` pin and empty recents (a fresh `codevhub install` — the pin was dropped in PR #142 / hub PR #221) the TUI defaulted to "Big Pickle · OpenCode Zen" and would send messages to upstream's hosted gateway instead of the configured one. Same predicate as `app.tsx`'s startup gate and the footer indicator; when no provider qualifies the old first-provider behavior is kept. | Keep the predicate export and the `find` fallback; take upstream's surrounding changes to either file. If upstream reworks `fallbackModel`, re-apply the connected-first selection to its last-resort branch. | | `packages/tui/src/component/prompt/index.tsx` | The busy-state footer arm (`status().type !== "idle"`) also renders `props.hint` after the `esc interrupt` text, gated on `status().type !== "retry"` | The hidden-sidebar hint (see the `routes/session/index.tsx` hint row) only rendered in the footer `Switch`'s idle fallback arm, so it vanished for the whole duration of a running turn — exactly when todos are being created. Retry keeps its `space-between` error row uncluttered. | Keep the `{props.hint}` line in the busy `Match` arm (additive); take upstream's surrounding changes. | | `packages/tui/src/component/prompt/index.tsx` | Removed the editor open-file label from the footer's right-side group and lifted the state behind it out of `Prompt`: the local `dismissedEditorSelectionKey` signal and the `editorContext`/`editorPath`/`editorSelectionLabel`/`editorFileLabel`/`editorFileLabelDisplay`/`editorContextLabelState` memos are gone, replaced by `useEditorLabel()` (fork-only `src/context/editor-label.tsx`) — `selection()` feeds the `prompt.editor_context.clear` `enabled` predicate and the submit-time editor part, `state()` replaces `editor.labelState()` there, and `dismiss()` replaces the local `dismissEditorContext`. The pure helpers `hasEditorRangeSelection`/`getEditorRangeLabel` moved to that context module and are imported back for `formatEditorContext`. | The label now lives in the sidebar footer (see its row above). The dismissal state had to move with it: the sidebar must label the *effective* selection — the one this file actually attaches on submit — or it would advertise context the prompt has dismissed and won't send. | Keep the deletions, the `useEditorLabel` import + its three call sites, and the helper import from `../../context/editor-label`; take upstream's surrounding changes. If upstream reworks the editor-context label, apply the change to `editorContextFileLabel` in the fork-only sidebar module rather than restoring the footer render. | | `packages/tui/src/routes/session/index.tsx`, `packages/tui/src/component/prompt/index.tsx` | Left accent border removed entirely: the user-message block's `border={["left"]}`/`borderColor` (agent accent)/`customBorderChars` props and the prompt input box's two bordered boxes (main box with its `bottomLeft: "╹"` char, plus the height-1 `╹` terminator row) are replaced with `paddingLeft={1}`; the now-unused `borderHighlight` memo and the `tint`/`SplitBorder` imports are removed from the prompt (the `highlight`/`agentMetaAlpha` memos stay — they still color the agent name label); the fork's in-scrollbox banner box gains `paddingLeft={1}` so the banner/tips left edge lines up with the prompt/message blocks' background edge (the removed border glyph used to occupy that aligned column) | No vertical accent line next to sent messages or the prompt — removed rather than recolored to the background because background-colored glyphs render oddly on some terminals (e.g. PowerShell). `paddingLeft={1}` keeps the same 1-column offset, so text alignment with other transcript blocks is unchanged. | Keep our `paddingLeft={1}` boxes and the `borderHighlight`/`tint`/`SplitBorder` removals; take upstream's surrounding changes. If upstream adds new accent-colored left borders to these blocks, replace them with `paddingLeft={1}` the same way. | diff --git a/packages/tui/src/component/use-connected.tsx b/packages/tui/src/component/use-connected.tsx index f59e95c05f..014abcee66 100644 --- a/packages/tui/src/component/use-connected.tsx +++ b/packages/tui/src/component/use-connected.tsx @@ -1,12 +1,14 @@ import { createMemo } from "solid-js" import { useSync } from "../context/sync" +// True unless the entry is the hosted Zen provider's free-tier auto-registration +// (its free models register without any auth). A deliberately-authenticated +// opencode still passes via its paid models. +export function isConnectedProvider(provider: { id: string; models: Record }) { + return provider.id !== "opencode" || Object.values(provider.models).some((model) => model.cost?.input !== 0) +} + export function useConnected() { const sync = useSync() - return createMemo(() => - sync.data.provider.some( - (provider) => - provider.id !== "opencode" || Object.values(provider.models).some((model) => model.cost?.input !== 0), - ), - ) + return createMemo(() => sync.data.provider.some(isConnectedProvider)) } diff --git a/packages/tui/src/context/local.tsx b/packages/tui/src/context/local.tsx index a05d315166..66c0f4c925 100644 --- a/packages/tui/src/context/local.tsx +++ b/packages/tui/src/context/local.tsx @@ -13,6 +13,7 @@ import { useTheme } from "./theme" import { useToast } from "../ui/toast" import { useRoute } from "./route" import { usePermission } from "./permission" +import { isConnectedProvider } from "../component/use-connected" export type LocalTheme = { secondary: RGBA @@ -221,7 +222,11 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ } } - const provider = sync.data.provider[0] + // The hosted Zen provider self-registers (free models need no auth) and + // lands first in the list, so a blind [0] would default a gateway-only + // setup to a Zen model. Prefer the first deliberately-configured + // provider — same predicate as the startup gate and prompt footer. + const provider = sync.data.provider.find(isConnectedProvider) ?? sync.data.provider[0] if (!provider) return undefined const defaultModel = sync.data.provider_default[provider.id] const firstModel = Object.values(provider.models)[0] diff --git a/packages/tui/test/component/use-connected.test.ts b/packages/tui/test/component/use-connected.test.ts new file mode 100644 index 0000000000..198942e18d --- /dev/null +++ b/packages/tui/test/component/use-connected.test.ts @@ -0,0 +1,30 @@ +import { expect, test } from "bun:test" +import { isConnectedProvider } from "../../src/component/use-connected" + +const zenFree = { + id: "opencode", + models: { "big-pickle": { cost: { input: 0 } } }, +} +const gateway = { + id: "netgate", + models: { "MiniMax/MiniMax-M3": {} }, +} + +test("the free Zen auto-registration does not count as connected", () => { + expect(isConnectedProvider(zenFree)).toBe(false) +}) + +test("a deliberately-authenticated opencode counts via its paid models", () => { + expect( + isConnectedProvider({ + id: "opencode", + models: { "big-pickle": { cost: { input: 0 } }, "heavy-pickle": { cost: { input: 3 } } }, + }), + ).toBe(true) +}) + +test("fallbackModel's last resort prefers a configured provider over the Zen registration", () => { + expect(isConnectedProvider(gateway)).toBe(true) + // Zen registers first in the provider list; find(isConnectedProvider) must skip it. + expect([zenFree, gateway].find(isConnectedProvider)).toBe(gateway) +}) From daed3f4a9efaa86d6107b2a028367c3c80402591 Mon Sep 17 00:00:00 2001 From: Quickbeard Date: Fri, 31 Jul 2026 14:59:59 +0700 Subject: [PATCH 2/2] docs(gateway): correct the hub's model-default mechanism in the comment The hub's models-map order never carried the first-launch choice; hub PR #229 seeds the state dir's recents (when empty) instead. Co-Authored-By: Claude Fable 5 --- packages/codev-gateway/src/config-block.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/codev-gateway/src/config-block.ts b/packages/codev-gateway/src/config-block.ts index c1df582484..93035e2552 100644 --- a/packages/codev-gateway/src/config-block.ts +++ b/packages/codev-gateway/src/config-block.ts @@ -34,8 +34,9 @@ const MODEL_MODALITIES: { attachment: boolean; modalities: { input: Modality[]; // then the state dir's model.json recents), so the wizard's one-time choice // would silently undo every later /models switch. The configure flow seeds // that choice into the recents instead, which the next switch displaces; the -// hub leads its models map with the chosen id for the same effect. A config -// written by an older hub still carries a pin — nothing here removes one. +// hub seeds the same recents file when it is empty (hub PR #229 — its map +// order never carried the choice). A config written by an older hub still +// carries a pin — nothing here removes one. export function buildGatewayConfig(input: { apiKey: string; baseUrl: string; model: string; models?: string[] }) { const ids = [...new Set([input.model, ...(input.models ?? [])])] return {