diff --git a/src/ModelApp.tsx b/src/ModelApp.tsx
index ff6f8ea..6938538 100644
--- a/src/ModelApp.tsx
+++ b/src/ModelApp.tsx
@@ -347,13 +347,29 @@ export function ModelApp() {
)}
{phase === "done" && chosenModel && (
-
- {"Default model updated to "}
- {chosenModel}
- {" for "}
- {formatToolList(tools.map((t) => TOOL_LABEL[t]))}
- {"."}
-
+
+
+ {"Default model updated to "}
+ {chosenModel}
+ {" for "}
+ {formatToolList(tools.map((t) => TOOL_LABEL[t]))}
+ {"."}
+
+ {/* Their configs carry no model pin (see configureOpenCodeKind),
+ so a model already selected in-CLI keeps winning there —
+ say so rather than let the line above imply otherwise. */}
+ {tools.some((t) => t === "opencode" || t === "codev-code") && (
+
+ {"In "}
+ {formatToolList(
+ tools
+ .filter((t) => t === "opencode" || t === "codev-code")
+ .map((t) => TOOL_LABEL[t]),
+ )}
+ {", switch models anytime with /models."}
+
+ )}
+
)}
)}
diff --git a/src/lib/configure.ts b/src/lib/configure.ts
index 33513b5..2e28b8a 100644
--- a/src/lib/configure.ts
+++ b/src/lib/configure.ts
@@ -924,9 +924,16 @@ function configureOpenCodeKind(
const provider = resolveProvider(creds);
// Fall back to [defaultModel] when `models` is unset so callers that don't
// know about the list (e.g. older fixtures, the fallback path with no
- // fetched list) still produce a valid one-entry map.
- const allModels =
- creds.models && creds.models.length > 0 ? creds.models : [defaultModel];
+ // fetched list) still produce a valid one-entry map. The chosen model leads
+ // the map: with no top-level pin (see below) a first launch with no saved
+ // selection falls through to the provider's first model, so ordering is
+ // what carries the choice.
+ const allModels = [
+ ...new Set([
+ defaultModel,
+ ...(creds.models && creds.models.length > 0 ? creds.models : []),
+ ]),
+ ];
// A custom-provider model with no `limit` defaults to context 0, which both
// mis-sizes the window and disables OpenCode's auto-compaction entirely.
@@ -959,9 +966,15 @@ function configureOpenCodeKind(
writeJson(sourcePath, {
[OPENCODE_K.schema]: OPENCODE_SCHEMA_URL,
...(mcp !== undefined ? { [OPENCODE_K.mcp]: mcp } : {}),
- // Top-level `model` pins the initial active model OpenCode uses on
- // launch. Format is `/` per OpenCode's schema.
- [OPENCODE_K.model]: `${provider.id}/${defaultModel}`,
+ // No top-level `model`. The install-time model choice exists for Claude
+ // Code and Codex, which can only run one model at a time; OpenCode and
+ // CoDev Code switch freely in-CLI (docs/hub/installation). A pin there
+ // outranks their saved selection on every launch — it beats the recent
+ // models in their state dir — so each in-CLI switch reverted on restart
+ // and only hand-editing the config undid it. The chosen model leads the
+ // models map instead, which decides the first launch and yields to any
+ // later selection. This is why `codevhub model` steers only Claude Code
+ // and Codex.
// OpenCode has no percentage trigger; it compacts at `context − reserved`.
// Reserve the headroom that lands the trigger at ~85% of the window, to
// match Claude Code and Codex.
diff --git a/tests/ModelApp.test.tsx b/tests/ModelApp.test.tsx
index cf03c8c..79c97ca 100644
--- a/tests/ModelApp.test.tsx
+++ b/tests/ModelApp.test.tsx
@@ -126,6 +126,11 @@ describe("ModelApp", () => {
expect(history).toContain(
"Default model updated to new-alpha for Claude Code and OpenCode.",
);
+ // OpenCode's config carries no model pin (it switches in-CLI), so the
+ // line above must not read as authoritative for it.
+ expect(history).toContain(
+ "In OpenCode, switch models anytime with /models.",
+ );
expect(configureClaude).toHaveBeenCalledWith({
apiKey: "sk-existing",
baseUrl: undefined,
diff --git a/tests/lib/configure.test.ts b/tests/lib/configure.test.ts
index d2a0174..57c1175 100644
--- a/tests/lib/configure.test.ts
+++ b/tests/lib/configure.test.ts
@@ -398,8 +398,9 @@ describe("configureOpenCode", () => {
expect(config.provider.netgate.models["chosen-model"].name).toBe(
"chosen-model",
);
- // Top-level `model` pins the active default in / form.
- expect(config.model).toBe("netgate/chosen-model");
+ // No top-level `model`: OpenCode and CoDev Code switch models in-CLI, and
+ // a pin would outrank that selection on every launch.
+ expect(config.model).toBeUndefined();
// Declares the gateway window so OpenCode sizes context correctly and its
// auto-compaction fires (a model with no `limit` defaults to context 0,
// which disables compaction). `output` is required alongside `context`.
@@ -443,8 +444,10 @@ describe("configureOpenCode", () => {
output: ["text"],
});
}
- // Top-level default still points at the chosen one.
- expect(config.model).toBe("netgate/model-a");
+ // With no pin, the map order carries the choice: a launch with nothing
+ // selected yet takes the provider's first model.
+ expect(config.model).toBeUndefined();
+ expect(Object.keys(map)[0]).toBe("model-a");
});
test("falls back to [model] when `models` is absent (older call sites)", async () => {
@@ -596,8 +599,9 @@ describe("configureCodevCode", () => {
expect(config.provider.netgate.models["chosen-model"].name).toBe(
"chosen-model",
);
- // Top-level `model` pins the active default in / form.
- expect(config.model).toBe("netgate/chosen-model");
+ // No top-level `model`: OpenCode and CoDev Code switch models in-CLI, and
+ // a pin would outrank that selection on every launch.
+ expect(config.model).toBeUndefined();
// The fork shares OpenCode's window/compaction handling, so the same
// limit + compaction blocks must land in its config.
expect(config.provider.netgate.models["chosen-model"].limit).toEqual({
@@ -1652,7 +1656,7 @@ describe("custom provider identity", () => {
expect(config.model_providers.netgate).toBeUndefined();
});
- test("opencode writes the custom id in both the provider map and `model`", async () => {
+ test("opencode writes the custom id in the provider map", async () => {
const { configureOpenCode } = await import("@/lib/configure.js");
configureOpenCode(custom);
@@ -1662,7 +1666,7 @@ describe("custom provider identity", () => {
"utf-8",
),
);
- expect(config.model).toBe("acme-ai/m");
+ expect(config.provider["acme-ai"].models.m).toBeDefined();
expect(config.provider["acme-ai"].name).toBe("Acme AI");
expect(config.provider["acme-ai"].options.apiKey).toBe("sk-user");
expect(config.provider.netgate).toBeUndefined();
@@ -1675,7 +1679,7 @@ describe("custom provider identity", () => {
const config = JSON.parse(
readFileSync(join(tempDir, ".config", "codev", "codev.json"), "utf-8"),
);
- expect(config.model).toBe("acme-ai/m");
+ expect(config.provider["acme-ai"].models.m).toBeDefined();
expect(config.provider["acme-ai"].name).toBe("Acme AI");
});