# 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` 去掉 `ISettingRegistry` 与 `settingRegistry.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(...)` 兜底。 - `setOpenCodeRuntime` → `setOpenCodeServerSettings`,签名 `{ 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 可作默认);若 `providers` 为 `null` 或空 → **不渲染** select,提交时 `providerId`/`modelId` 为 `undefined` → **不发**这两个字段。 - Providers 缓存模块级: `let _providers: OpenCodeProvidersResponse | null = null; export function setOpenCodeProviders(p) { _providers = p; }` 放在 `opencode_cell_actions.ts`(`setOpenCodeServerSettings` 同文件),`index.ts` 在 `callOpenCodeProviders(...).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` 改为: ```ts /** 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; 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`** 完整新内容: ```python """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.ts` 的 `readOpenCodeSettings` 测试只覆盖 provider/model,不受影响。 - [ ] **Step 5: 跑测试 + build** ```bash .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** ```bash 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` 后加: ```ts let _providers: OpenCodeProvidersResponse | null = null; export function setOpenCodeProviders( p: OpenCodeProvidersResponse | null ): void { _providers = p; } ``` 在 `opencode_cell_actions.ts` 顶部 import: ```ts import type { OpenCodeProvidersResponse } from '../types'; ``` 把 `_showPrompt` 改为: ```ts 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): ```ts private async _onSubmit( text: string, providerIdOverride?: string, modelIdOverride?: string ): Promise { 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 签名** 完整新内容: ```ts /** * 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` 回调里,**在打印日志之前**加: ```ts .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` 之前加: ```css .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` 内): ```ts 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 加上: ```ts import { OpenCodeCellActions, setOpenCodeProviders, setOpenCodeRuntime } from '../components/opencode_cell_actions'; ``` 每个测试**结束后**调用 `setOpenCodeProviders(null)` 重置,避免污染其他测试。建议在每个测试的 `expect(...)` 之后加 `setOpenCodeProviders(null);` 一行清理。 - [ ] **Step 6: 跑测试 + build** ```bash 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** ```bash 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 `(来自 `GET /opencode-bridge/providers` 启动时缓存),默认选中 `opencodeProvider`/`opencodeModel` settings 对应项,用户可改;提交时把选中的 `providerId`/`modelId` 一起带回,覆盖 settings 默认。 - [ ] **Step 2: 新增 §6 "启动配置"**(放在现有 §5 之后,或合并进 §5) ```markdown ## ⚙️ 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** ```bash 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** ```bash .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 兜底