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

22 KiB

Cell Toolbar Actions v3 — 实现计划

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

Goal: 把 4 个 server 连接字段(URL/User/Password/Timeout)从 JupyterLab Settings 移到启动环境变量;Settings 只剩 opencodeProvider/opencodeModel;在 inline 输入框增加模型选择器。

Architecture: config.py 4 字段全走 os.environ.get(ENV) or DEFAULT(不再 bridge.get(...) 兜底);schema/plugin.json 删 4 个 property;src/types.ts OpenCodeSettings 缩为 {opencodeProvider, opencodeModel};前端 index.tscallOpenCodeProviders 结果通过 setOpenCodeProviders 写到 opencode_cell_actions.ts 模块级缓存;OpenCodeInlinePromptproviders/defaultProviderId/defaultModelId 构造参数,渲染 <select>,onSubmit(text, providerId, modelId) 带选中的 provider/model;OpenCodeCellActions 用它覆盖 OpenCodeRequestproviderId/modelId

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 opencodeServerUrl / opencodeServerUser / opencodeServerPassword / requestTimeoutSeconds 4 个 property;opencodeProvider / opencodeModel 保留。
  • src/types.ts OpenCodeSettings 缩为 { opencodeProvider: string; opencodeModel: string },DEFAULT_OPENCODE_SETTINGS 同步;readOpenCodeSettings 同步(其余字段不再读)。
  • opencode_bridge/config.py 新增 ENV_TIMEOUT = "OPENCODE_BRIDGE_TIMEOUT"DEFAULT_REQUEST_TIMEOUT = 120;resolve_config 4 个字段(url/user/password/request_timeout_seconds)全走 env+default,不再 bridge.get(...)
  • 模型选择器 value 格式: providerId|modelId(| 分隔,split("|", 2) 解析)。
  • 默认选中: 若 settings 的 opencodeProvider/opencodeModel 能在 providers 列表里找到对应项 → 选中它;否则选中第一项;若 providers 为 null/空 → 不渲染 select。
  • Providers 缓存模块级: let _providers: OpenCodeProvidersResponse | null = null; export function setOpenCodeProviders(p) { _providers = p; } 放在 opencode_cell_actions.ts(setOpenCodeRuntime 同文件),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 兜底