Files
notebook-ai-extension/docs/superpowers/plans/2026-07-23-cell-toolbar-actions-v3.md

24 KiB

Cell Toolbar Actions v3 — 实现计划

For agentic workers: REQUIRED SUB-SKILL: superpowers:subagent-driven-development or superpowers:executing-plans. Steps use checkbox (- [ ]) syntax.

Goal: 把全部 server 连接字段(URL/User/Password/Timeout)从 JupyterLab Settings 移到启动环境变量;opencode_bridge 插件完全不再使用 JupyterLab Settings Editor(schema/plugin.json properties: {});inline 输入框增加完全动态的模型选择器(无 settings 默认,默认第一项)。

Architecture: config.py 4 字段全走 os.environ.get(ENV) or DEFAULT(jupyter_settings 参数保留仅为 API 兼容,实际未读);schema/plugin.json properties: {}(schema 只保留 jupyter.lab.toolbars 贡献);src/types.ts 整个 OpenCodeSettings 类型 / DEFAULT_OPENCODE_SETTINGS / readOpenCodeSettings 删除;setOpenCodeRuntime 改名为 setOpenCodeServerSettings({ serverSettings }),只设 Jupyter ServerConnection.ISettings,模块级 _settings 移除只留 _serverSettings_providers;index.ts 去掉 ISettingRegistrysettingRegistry.load(...) 整段,激活时直接 setOpenCodeServerSettings(serverSettings) + 注册 toolbar factory + 拉 providers 写缓存;OpenCodeInlinePrompt 构造加 providers(无 defaultProviderId/defaultModelId),默认选中第一项,providers 为 null/空时不渲染 select,onSubmit(text, providerId?, modelId?) 把选中带回;OpenCodeCellActions._onSubmit 用 prompt 回传的 providerId/modelId 写进 OpenCodeRequest,无 settings 兜底(providers 拉取失败时提交时省略 provider/model 字段,OpenCode 用默认)。

Tech Stack: TypeScript / Python 3.12 / jest / pytest-jupyter / stylelint / prettier

Spec: docs/superpowers/specs/2026-07-23-cell-toolbar-actions-v3-design.md(已批准)

Global Constraints

  • schema/plugin.json properties: {}(删全部 properties;jupyter.lab.toolbars 贡献保留:item 名 opencode-cell-actions,rank 100)。
  • src/types.ts 删除 OpenCodeSettings / DEFAULT_OPENCODE_SETTINGS / readOpenCodeSettings 整组;src/__tests__/opencode_bridge.spec.ts 中相关测试删除(若文件仅含 types 测试则整文件 git rm)。
  • opencode_bridge/config.py 新增 ENV_TIMEOUT = "OPENCODE_BRIDGE_TIMEOUT"DEFAULT_REQUEST_TIMEOUT = 120;resolve_config 4 个字段(url/user/password/request_timeout_seconds)全走 os.environ.get(ENV) or DEFAULT,不再 bridge.get(...) 兜底。
  • setOpenCodeRuntimesetOpenCodeServerSettings,签名 { serverSettings: ServerConnection.ISettings };模块级 _settings 删除,只留 _serverSettings(给 callOpenCodeEdit 拼 baseUrl)与 _providers(给 inline prompt 渲染 select)。
  • index.ts optional 去掉 ISettingRegistry;删除整个 settingRegistry.load(...).then(setOpenCodeRuntime) 段;激活时:setOpenCodeServerSettings(serverSettings) + 注册 toolbar factory + callOpenCodeProviders(...).then(setOpenCodeProviders)
  • 模型选择器 value 格式: providerId|modelId(| 分隔,split("|", 2) 解析)。
  • 默认选中: 第一项(无 settings 可作默认);若 providersnull 或空 → 不渲染 select,提交时 providerId/modelIdundefined不发这两个字段。
  • Providers 缓存模块级: let _providers: OpenCodeProvidersResponse | null = null; export function setOpenCodeProviders(p) { _providers = p; } 放在 opencode_cell_actions.ts(setOpenCodeServerSettings 同文件),index.tscallOpenCodeProviders(...).then(...) 里调。
  • 工具栏 item 名 opencode-cell-actions / rank 100 不变。
  • 不发 mode 字段(v2 约束保持)。
  • 代码风格 / 单测 / CSS 约束同前。

Task 1: 4 字段移出 settings(server config + types + schema)

Files:

  • Modify: schema/plugin.json
  • Modify: src/types.ts
  • Modify: opencode_bridge/config.py
  • (可能)Modify: src/__tests__/opencode_client.spec.ts(若它引用了被删的 settings 字段)

Interfaces:

  • Consumes: 无新依赖。

  • Produces: OpenCodeSettings = { opencodeProvider: string; opencodeModel: string };resolve_config({}) 在 4 个 env 变量未设时返回全默认;schema/plugin.json properties 只剩 opencodeProvider/opencodeModel

  • Step 1: 改 src/types.ts

把当前 OpenCodeSettings / DEFAULT_OPENCODE_SETTINGS / readOpenCodeSettings 改为:

/** Frontend view of the 2 remaining schema/plugin.json fields. */
export interface OpenCodeSettings {
  opencodeProvider: string;
  opencodeModel: string;
}

export const DEFAULT_OPENCODE_SETTINGS: OpenCodeSettings = {
  opencodeProvider: '',
  opencodeModel: ''
};

export function readOpenCodeSettings(composite: unknown): OpenCodeSettings {
  const c = (composite ?? {}) as Partial<OpenCodeSettings>;
  return {
    opencodeProvider: c.opencodeProvider || '',
    opencodeModel: c.opencodeModel || ''
  };
}
  • Step 2: 改 schema/plugin.json

properties删除 4 个:

  • opencodeServerUrl
  • opencodeServerUser
  • opencodeServerPassword
  • requestTimeoutSeconds

保留 opencodeProvider / opencodeModel。可更新它们的 description 注明 "Used as default selection in the inline model picker"。

  • Step 3: 改 opencode_bridge/config.py

完整新内容:

"""Configuration resolution for the opencode-bridge extension.

All connection fields (URL / user / password / request timeout) are resolved
exclusively from environment variables + built-in defaults at server startup.
They are NOT stored in the JupyterLab plugin settings.

Env vars:
  OPENCODE_BRIDGE_URL       — OpenCode Serve base URL (default http://127.0.0.1:4096)
  OPENCODE_BRIDGE_USER      — HTTP Basic Auth username (default 'opencode')
  OPENCODE_BRIDGE_PASSWORD  — HTTP Basic Auth password (default '' = no auth)
  OPENCODE_BRIDGE_TIMEOUT   — request timeout in seconds (default 120)
"""
from __future__ import annotations

import os
from typing import NamedTuple, Optional, Tuple


ENV_URL = "OPENCODE_BRIDGE_URL"
ENV_USER = "OPENCODE_BRIDGE_USER"
ENV_PASSWORD = "OPENCODE_BRIDGE_PASSWORD"
ENV_TIMEOUT = "OPENCODE_BRIDGE_TIMEOUT"

DEFAULT_URL = "http://127.0.0.1:4096"
DEFAULT_USER = "opencode"
DEFAULT_REQUEST_TIMEOUT = 120


class OpenCodeConfig(NamedTuple):
    url: str
    user: str
    password: str
    request_timeout_seconds: int

    @property
    def auth(self) -> Optional[Tuple[str, str]]:
        """Return (user, password) for HTTP Basic Auth, or None if no password set."""
        if not self.password:
            return None
        return (self.user, self.password)


def resolve_config(jupyter_settings: dict) -> OpenCodeConfig:
    """Resolve OpenCode connection config from environment variables + defaults.

    The `jupyter_settings` argument is accepted for API compatibility but no
    fields are read from it — all connection config is now startup-only
    (environment variables).
    """
    _ = jupyter_settings  # intentionally unused; see module docstring
    return OpenCodeConfig(
        url=os.environ.get(ENV_URL) or DEFAULT_URL,
        user=os.environ.get(ENV_USER) or DEFAULT_USER,
        password=os.environ.get(ENV_PASSWORD) or "",
        request_timeout_seconds=int(
            os.environ.get(ENV_TIMEOUT) or DEFAULT_REQUEST_TIMEOUT
        )
    )
  • Step 4: 检查 opencode_client.spec.ts

grep -n "opencodeServerUrl\|opencodeServerUser\|opencodeServerPassword\|requestTimeoutSeconds" src/__tests__/opencode_client.spec.ts —— 若有引用,删除。opencode_bridge.spec.tsreadOpenCodeSettings 测试只覆盖 provider/model,不受影响。

  • Step 5: 跑测试 + build
.venv/bin/python -m pytest opencode_bridge/tests/ -q
YARN_NODE_LINKER=node-modules PATH="$PWD/.venv/bin:$PATH" .venv/bin/jlpm test
YARN_NODE_LINKER=node-modules PATH="$PWD/.venv/bin:$PATH" .venv/bin/jlpm build

Expected: pytest 全过(32 passed);jest 全过(25+);build 成功。

  • Step 6: Commit
git add schema/plugin.json src/types.ts opencode_bridge/config.py src/__tests__/opencode_client.spec.ts
git commit -m "refactor: move server connection config to startup env vars

URL, user, password, and request timeout are no longer in the JupyterLab
plugin settings — they are resolved exclusively from environment variables
(OPENCODE_BRIDGE_URL/USER/PASSWORD/TIMEOUT) at jupyter server startup,
with built-in defaults. The Settings Editor now only carries
opencodeProvider/opencodeModel (the defaults for the new inline model
picker, added in the next commit)."

Task 2: 模型选择器(TDD)

Files:

  • Modify: src/components/opencode_cell_actions.ts(加 providers 缓存 + 注入)
  • Modify: src/components/opencode_inline_prompt.ts(加 select + onSubmit 带 provider/model)
  • Modify: src/index.ts(callOpenCodeProviders 成功后调 setOpenCodeProviders)
  • Modify: style/base.css(select 样式)
  • Modify: src/__tests__/opencode_cell_actions.spec.ts(补 select 相关断言)

Interfaces:

  • Consumes: OpenCodeProvidersResponse(从 ../types);OpenCodeSettings 缩为 2 字段。

  • Produces: setOpenCodeProviders(p: OpenCodeProvidersResponse | null): void(在 opencode_cell_actions.ts);OpenCodeInlinePrompt 构造 options 加 providers/defaultProviderId?/defaultModelId?,onSubmit 签名变 (text: string, providerId?: string, modelId?: string) => void;OpenCodeCellActions 用 prompt 回调里的 provider/model 覆盖 OpenCodeRequest.providerId/modelId

  • Step 1: 改 opencode_cell_actions.ts —— 扩导出与新签名

在模块顶部 setOpenCodeRuntime 后加:

let _providers: OpenCodeProvidersResponse | null = null;

export function setOpenCodeProviders(
  p: OpenCodeProvidersResponse | null
): void {
  _providers = p;
}

opencode_cell_actions.ts 顶部 import:

import type { OpenCodeProvidersResponse } from '../types';

_showPrompt 改为:

  private _showPrompt(): void {
    if (this._prompt) {
      return;
    }
    const defaultProviderId = _settings?.opencodeProvider || undefined;
    const defaultModelId = _settings?.opencodeModel || undefined;
    const prompt = new OpenCodeInlinePrompt(this._cell, {
      disabled: !this._context || this._status === 'loading',
      providers: _providers,
      defaultProviderId,
      defaultModelId,
      onSubmit: (text: string, providerId?: string, modelId?: string) => {
        void this._onSubmit(text, providerId, modelId);
      },
      onCancel: () => {
        this._hidePrompt();
      }
    });
    Widget.attach(prompt, this._cell.node);
    this._prompt = prompt;
  }

_onSubmit 改为(签名加 provider/model 入参,用它们覆盖 settings):

  private async _onSubmit(
    text: string,
    providerIdOverride?: string,
    modelIdOverride?: string
  ): Promise<void> {
    if (!this._context) {
      return;
    }
    if (!_settings || !_serverSettings) {
      Notification.error('OpenCode 运行时未初始化,请检查 settings');
      return;
    }

    // Per-request selection from the inline picker takes precedence over
    // settings defaults; empty strings from the picker are treated as
    // "use whatever the server default is" (not sent).
    const providerId =
      providerIdOverride || _settings.opencodeProvider || undefined;
    const modelId = modelIdOverride || _settings.opencodeModel || undefined;

    const request: OpenCodeRequest = {
      prompt: text,
      context: this._context,
      providerId,
      modelId
    };
    // ...rest unchanged (status, callOpenCodeEdit, error handling)
  • Step 2: 改 opencode_inline_prompt.ts —— 加 select + 新 onSubmit 签名

完整新内容:

/**
 * Inline prompt widget attached to a cell's DOM when the AI button is clicked.
 * Renders a model selector, a textarea, and send/cancel buttons. The owning
 * cell is passed so we can resolve the cell context at submit time. submit
 * and cancel are callbacks owned by OpenCodeCellActions.
 */
import { CodeCell } from '@jupyterlab/cells';
import { Widget } from '@lumino/widgets';

import type { OpenCodeProvidersResponse } from '../types';

export interface IOpenCodeInlinePromptOptions {
  onSubmit: (text: string, providerId?: string, modelId?: string) => void;
  onCancel: () => void;
  disabled: boolean;
  providers: OpenCodeProvidersResponse | null;
  defaultProviderId?: string;
  defaultModelId?: string;
}

export class OpenCodeInlinePrompt extends Widget {
  private _textarea: HTMLTextAreaElement;
  private _sendBtn: HTMLButtonElement;
  private _cancelBtn: HTMLButtonElement;
  private _select: HTMLSelectElement | null = null;

  constructor(
    private _cell: CodeCell,
    options: IOpenCodeInlinePromptOptions
  ) {
    super();
    this.addClass('opencode-inline-prompt');

    // Model selector — only rendered if providers is a non-empty list.
    const flat = flattenProviders(options.providers);
    if (flat.length > 0) {
      this._select = document.createElement('select');
      this._select.className = 'opencode-model-select';
      for (const opt of flat) {
        const o = document.createElement('option');
        o.value = `${opt.providerId}|${opt.modelId}`;
        o.textContent = `${opt.providerId} / ${opt.modelId}`;
        this._select.appendChild(o);
      }
      // Default selection: match settings, otherwise first option.
      const desired = `${options.defaultProviderId || ''}|${
        options.defaultModelId || ''
      }`;
      const hasDefault = Array.from(this._select.options).some(
        o => o.value === desired
      );
      this._select.value = hasDefault
        ? desired
        : this._select.options[0].value;
    }

    this._textarea = document.createElement('textarea');
    this._textarea.placeholder = '向 AI 描述你想要的修改…';
    this._textarea.rows = 3;
    this._sendBtn = document.createElement('button');
    this._sendBtn.className = 'opencode-btn-send';
    this._sendBtn.textContent = '🚀 发送';
    this._sendBtn.disabled = options.disabled;
    this._sendBtn.addEventListener('click', () => {
      const text = this._textarea.value;
      if (!text.trim()) {
        return;
      }
      const [providerId, modelId] = this._readSelection();
      options.onSubmit(text, providerId, modelId);
    });
    this._cancelBtn = document.createElement('button');
    this._cancelBtn.className = 'opencode-btn-cancel';
    this._cancelBtn.textContent = '✕ 取消';
    this._cancelBtn.addEventListener('click', () => {
      options.onCancel();
    });

    const actions = document.createElement('div');
    actions.className = 'opencode-inline-actions';
    actions.appendChild(this._sendBtn);
    actions.appendChild(this._cancelBtn);

    if (this._select) {
      const label = document.createElement('label');
      label.className = 'opencode-model-label';
      const span = document.createElement('span');
      span.textContent = '模型:';
      label.appendChild(span);
      label.appendChild(this._select);
      this.node.appendChild(label);
    }
    this.node.appendChild(this._textarea);
    this.node.appendChild(actions);
  }

  setDisabled(disabled: boolean): void {
    this._sendBtn.disabled = disabled;
    if (this._select) {
      this._select.disabled = disabled;
    }
  }

  private _readSelection(): [string | undefined, string | undefined] {
    if (!this._select) {
      return [undefined, undefined];
    }
    const v = this._select.value;
    const idx = v.indexOf('|');
    if (idx < 0) {
      return [undefined, undefined];
    }
    return [v.slice(0, idx), v.slice(idx + 1)];
  }
}

function flattenProviders(
  providers: OpenCodeProvidersResponse | null
): { providerId: string; modelId: string }[] {
  if (!providers || !providers.providers) {
    return [];
  }
  const out: { providerId: string; modelId: string }[] = [];
  for (const p of providers.providers) {
    for (const m of p.models) {
      out.push({ providerId: p.id, modelId: m.id });
    }
  }
  return out;
}
  • Step 3: 改 src/index.ts —— fetch 成功后写缓存

callOpenCodeProviders(...).then(data => { ... console.log(...) }).catch(...).then 回调里,在打印日志之前加:

            .then(data => {
              setOpenCodeProviders(data);
              const lines: string[] = ['[opencode_bridge] Available OpenCode providers:'];
              // ...rest unchanged
            })

import 同步加 import { setOpenCodeProviders } from './components/opencode_cell_actions';(在已有的 import 行加)。

  • Step 4: 改 style/base.css —— 加 select 样式

.opencode-inline-prompt textarea 之前加:

.opencode-inline-prompt .opencode-model-label {
  display: flex;
  align-items: center;
  gap: 6px;
  font-size: var(--jp-ui-font-size1);
  color: var(--jp-ui-font-color1, #333);
}

.opencode-inline-prompt .opencode-model-select {
  flex: 1;
  padding: 2px 4px;
  font-size: var(--jp-ui-font-size1);
  font-family: inherit;
  border: 1px solid var(--jp-border-color2, #ccc);
  border-radius: 3px;
  background: var(--jp-layout-color1, #fff);
}

.opencode-inline-prompt .opencode-model-select:disabled {
  opacity: 0.5;
  cursor: default;
}
  • Step 5: 补单测 —— opencode_cell_actions.spec.ts

OpenCodeCellActions 的现有 it('clicking the button attaches an OpenCodeInlinePrompt to the cell', ...) 之后,新增以下测试(在同一个 describe 内):

it('renders a model selector when providers are cached', () => {
  setOpenCodeProviders({
    providers: [
      { id: 'anthropic', models: [{ id: 'claude-sonnet-4-20250514' }] },
      { id: 'openai', models: [{ id: 'gpt-5' }] }
    ]
  });
  const cell = makeFakeCell('x = 1');
  const actions = new OpenCodeCellActions(cell);
  Object.defineProperty(actions, 'parent', { value: cell, configurable: true });
  (actions as any).onAfterAttach({} as any);

  const btn = actions.node.querySelector('button') as HTMLButtonElement;
  btn.click();

  const select = cell.node.querySelector(
    '.opencode-inline-prompt .opencode-model-select'
  ) as HTMLSelectElement;
  expect(select).not.toBeNull();
  expect(select.options.length).toBe(2);
  expect(select.options[0].textContent).toBe('anthropic / claude-sonnet-4-20250514');
  expect(select.options[1].textContent).toBe('openai / gpt-5');
  // Default = first option when no settings match.
  expect(select.value).toBe('anthropic|claude-sonnet-4-20250514');
});

it('selects the settings default provider/model when it matches an option', () => {
  setOpenCodeProviders({
    providers: [
      { id: 'anthropic', models: [{ id: 'claude-sonnet-4-20250514' }] },
      { id: 'openai', models: [{ id: 'gpt-5' }] }
    ]
  });
  setOpenCodeRuntime({
    settings: {
      opencodeProvider: 'openai',
      opencodeModel: 'gpt-5'
    } as any,
    serverSettings: {} as any
  });
  const cell = makeFakeCell('x = 1');
  const actions = new OpenCodeCellActions(cell);
  Object.defineProperty(actions, 'parent', { value: cell, configurable: true });
  (actions as any).onAfterAttach({} as any);

  const btn = actions.node.querySelector('button') as HTMLButtonElement;
  btn.click();

  const select = cell.node.querySelector(
    '.opencode-inline-prompt .opencode-model-select'
  ) as HTMLSelectElement;
  expect(select.value).toBe('openai|gpt-5');
});

it('does not render the selector when providers are not cached', () => {
  setOpenCodeProviders(null);
  setOpenCodeRuntime({ settings: {} as any, serverSettings: {} as any });
  const cell = makeFakeCell('x = 1');
  const actions = new OpenCodeCellActions(cell);
  Object.defineProperty(actions, 'parent', { value: cell, configurable: true });
  (actions as any).onAfterAttach({} as any);

  const btn = actions.node.querySelector('button') as HTMLButtonElement;
  btn.click();

  const select = cell.node.querySelector(
    '.opencode-inline-prompt .opencode-model-select'
  );
  expect(select).toBeNull();
});

并在 describe 顶部 import 加上:

import {
  OpenCodeCellActions,
  setOpenCodeProviders,
  setOpenCodeRuntime
} from '../components/opencode_cell_actions';

每个测试结束后调用 setOpenCodeProviders(null) 重置,避免污染其他测试。建议在每个测试的 expect(...) 之后加 setOpenCodeProviders(null); 一行清理。

  • Step 6: 跑测试 + build
YARN_NODE_LINKER=node-modules PATH="$PWD/.venv/bin:$PATH" .venv/bin/jlpm test
YARN_NODE_LINKER=node-modules PATH="$PWD/.venv/bin:$PATH" .venv/bin/jlpm build

Expected: jest 28+ passed(原 25 + 3 新增);build 成功。

  • Step 7: Commit
git add src/components/opencode_cell_actions.ts src/components/opencode_inline_prompt.ts src/index.ts style/base.css src/__tests__/opencode_cell_actions.spec.ts
git commit -m "feat: model selector in inline prompt

The inline prompt panel now renders a <select> at the top, listing every
(provider, model) pair returned by GET /opencode-bridge/providers at
plugin activation. The default selection matches opencodeProvider /
opencodeModel from JupyterLab settings; the user can pick a different
model per request. The chosen value is sent as providerId/modelId in
the OpenCodeRequest, overriding the settings default for that call. If
the providers fetch failed at startup (e.g. opencode serve not running),
no selector is rendered and submissions fall back to the settings
defaults (or omit providerId/modelId so OpenCode picks its own default)."

Task 3: design.md 同步(CC)

Files:

  • Modify: design.md §2(给 inline 输入框段补一行模型选择器描述);新增 §6 "启动配置"(env 变量清单)。

  • Step 1: 改 design.md §2 item 2 的描述,在第 2 段里"多行 textarea(placeholder ...)"前插一句:

顶部一行 模型: <select>(来自 GET /opencode-bridge/providers 启动时缓存),默认选中 opencodeProvider/opencodeModel settings 对应项,用户可改;提交时把选中的 providerId/modelId 一起带回,覆盖 settings 默认。

  • Step 2: 新增 §6 "启动配置"(放在现有 §5 之后,或合并进 §5)
## ⚙️ 6. 启动配置(v3 修正)

所有 OpenCode Serve 连接参数在 `jupyter lab` 启动时通过环境变量指定,**不再**在 JupyterLab Settings Editor 中配置:

| 环境变量 | 默认 | 说明 |
|---|---|---|
| `OPENCODE_BRIDGE_URL` | `http://127.0.0.1:4096` | OpenCode Serve 地址 |
| `OPENCODE_BRIDGE_USER` | `opencode` | HTTP Basic Auth 用户名 |
| `OPENCODE_BRIDGE_PASSWORD` | `""`(无 auth) | HTTP Basic Auth 密码 |
| `OPENCODE_BRIDGE_TIMEOUT` | `120` | 请求 OpenCode Serve 超时(秒) |

JupyterLab Settings Editor **只剩** 2 项,均用于 inline 输入框模型选择器的"默认选中":
- `opencodeProvider`
- `opencodeModel`
  • Step 3: Commit
git add design.md
git commit -m "docs: design.md v3 (startup env config + inline model selector)"

Task 4: 全量验证(CC)

  • Step 1: 跑 pytest + jlpm test + jlpm build
.venv/bin/python -m pytest opencode_bridge/tests/ -q
YARN_NODE_LINKER=node-modules PATH="$PWD/.venv/bin:$PATH" .venv/bin/jlpm test
YARN_NODE_LINKER=node-modules PATH="$PWD/.venv/bin:$PATH" .venv/bin/jlpm build

Expected: pytest 全过(32+);jest 全过(28+);build 成功。

  • Step 2: 报告 v3 三个 commit + 验证结果

Task 5: 手动验收(用户执行)

export OPENCODE_BRIDGE_URL=http://127.0.0.1:4096 && jupyter lab(或带 User/Password/Timeout),打开 .ipynb:

  • active cell 右上角单按钮 → inline 面板顶部出现 模型: [select](列出 OpenCode Serve 的 provider/model)
  • 默认选中 opencodeProvider/opencodeModel settings 值
  • 切换选择 → 发送 → 这次请求的 providerId/modelId 是选中的
  • 在 Settings Editor 看 opencodeServerUrl 等 4 个字段已经没有了
  • 启动时 export 一个错误的 URL / 关掉 OpenCode Serve → 下拉框不出现,提交走 settings 兜底