Skip to content

Remote TUI uses the client’s local model preference instead of the connected server’s default model #55384

Open

Description

@wjzhang642

Description

When using the TUI with a remote server via opencode --server, the model catalog and the default model can come from different places. The /model dialog correctly lists models returned by the connected remote server, but the model shown in the TUI for a new session may come from the client machine's local model preferences/configuration (for example, its recent model). This can result in the TUI displaying a model associated with the client machine instead of the default model configured on the remote server.

Plugins

No response

OpenCode version

v2 branch (https://github.com/anomalyco/opencode/tree/v2)

Steps to reproduce

  1. Start opencode serve on server A with a model configuration different from the client machine's local model preferences.
  2. From server B, run opencode --server <server-A-url>.
  3. Open a new TUI session.
  4. Observe the model displayed in the TUI, then open /model.

The TUI default model can reflect server B's local model preference, while the /model list contains the models configured and returned by server A.

Screenshot and/or share link

No response

Operating System

No response

Terminal

No response

Activity

  1. opencode-agent commented on Oct 14, 2026

    @opencode-agent
    Contributor

    Thanks for the report. I could reproduce this on 2.0.29, and it still happens on the latest dev (34d8ded).

    What I ran:

    • The server's config defines two models, "Server Default Model" and "Client Recent Model", and has no top-level model setting.
    • The TUI connects to that server. On the client side, its own ~/.local/state/opencode/model.json lists "Client Recent Model" as the most recent model.
    • On a new session, the TUI shows Build · Client Recent Model, while /model lists the server's models. The recording and screenshots below show this, and the script below reproduces it.

    One thing that may help in the meantime: when the server's config sets model explicitly (e.g. "model": "provider/model" in its opencode config), the TUI does show the server's model. The client's local recent list only wins when the server's default isn't set that way, for example when it falls back to the catalog or a plugin sets it.

    The likely cause is that the TUI's model choice for a new session (packages/tui/src/context/local.tsx) checks the server's config model and then the client's local recent list, but never asks the server for its own default model.

    Related: #43055, where the v2 TUI ignored the configured default model. It's the same model-choice code, but a different trigger.

    recording.mp4

    Screenshot

    Screenshot

    Reproduction script (drive.ts)
    // Repro for anomalyco/opencode#55384: the TUI connected to a server shows the model from the
    // *client's* local recent-model list (XDG_STATE_HOME/opencode/model.json) instead of the
    // server's default model, while /model lists the server's models.
    //
    // Server config: two models, "Server Default Model" (server default) and "Client Recent Model".
    // Client state: model.json lists "Client Recent Model" as the most recent model.
    // CASE=noconfig removes the explicit `model` from the server config, so the server default
    // comes from the catalog (GET /api/model/default) instead.
    import { Effect } from "effect"
    import { mkdir, writeFile } from "node:fs/promises"
    import { join } from "node:path"
    import { OpenCodeDriver } from "opencode-drive"
    
    const noConfig = process.env.CASE === "noconfig"
    
    const model = (name: string) => ({
      name,
      capabilities: { tools: true, input: ["text"], output: ["text"] },
      limit: { context: 128_000, output: 16_000 },
    })
    
    export default OpenCodeDriver.use(
      {
        ...(process.env.OC_DEV ? { opencode: { dev: process.env.OC_DEV } } : {}),
        tui: { recording: true, viewport: { cols: 120, rows: 36 } },
        config: {
          providers: {
            simulation: {
              name: "Simulation",
              package: "@opencode-ai/ai/providers/openai/chat",
              settings: { apiKey: "sim-key" },
              models: {
                "gpt-sim-model": model("Server Default Model"),
                "client-recent": model("Client Recent Model"),
              },
            },
          },
        },
        setup: ({ config }) =>
          Effect.sync(() => {
            if (noConfig) delete config.model
            else config.model = "simulation/gpt-sim-model"
          }),
      },
      ({ ui, artifacts, opencode }) =>
        Effect.gen(function* () {
          yield* ui.screenshot("01-before-client-preference")
          // Client-side preference file, as written by the TUI on the client machine.
          const dir = join(artifacts, "home", ".local", "state", "opencode")
          yield* Effect.promise(async () => {
            await mkdir(dir, { recursive: true })
            await writeFile(
              join(dir, "model.json"),
              JSON.stringify({ recent: [{ providerID: "simulation", modelID: "client-recent" }], favorite: [], variant: {} }),
            )
          })
          yield* Effect.sleep(1500)
          yield* ui.screenshot("02-new-session-model")
          yield* ui.press("x", { ctrl: true })
          yield* ui.press("m")
          yield* Effect.sleep(1000)
          yield* ui.screenshot("03-model-dialog")
          yield* ui.press("escape")
          yield* Effect.sleep(300)
          const frame = yield* ui.capture()
          yield* Effect.log(String((frame as any).text ?? JSON.stringify(frame)).slice(0, 4000))
        }),
    )

    Run it with opencode-drive: opencode-drive run ./drive.ts. It drives opencode from your PATH; set OC_DEV to the path of an OpenCode checkout to run that from source instead.

  2. dajiaohuang commented on Oct 15, 2026

    @dajiaohuang

    I traced this on dev at d0732eadf8. The server's explicit root model already wins over client recents. With no root model, a valid client recent is deliberately selected by the TUI and sent explicitly in session.create; /api/model/default still returns a model, even when it is only the catalog's automatic fallback. A plugin can also set that endpoint's default without a root model.

    Should a remote connection's new sessions prefer every server-resolved default over a valid client recent, or should recents still win over the automatic catalog fallback? The current endpoint does not distinguish a plugin-selected default from an automatic fallback. This also intersects with the opposite precedence requested for Mini in #50620 (recents before server default); that PR is not a maintainer decision.

    I verified the config/plugin/catalog cases through the real local server endpoint, and the selection through the production TUI context harness, not a deployed terminal. Clarifying this precedence would let a fix preserve the intended last-used-model behavior rather than silently changing it for all connections.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions