diff --git a/docs/superpowers/plans/2026-07-23-cell-toolbar-actions-v2.md b/docs/superpowers/plans/2026-07-23-cell-toolbar-actions-v2.md new file mode 100644 index 0000000..d2c89be --- /dev/null +++ b/docs/superpowers/plans/2026-07-23-cell-toolbar-actions-v2.md @@ -0,0 +1,1131 @@ +# Cell Toolbar Actions v2 — 实现计划 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans. Steps use checkbox (`- [ ]`) syntax. + +**Goal:** 在 v1 基础上把 3 按钮合并为单图标按钮 + inline 输入框;彻底删除 `mode` 概念(前端不发、服务端不读不返);修 settings 实时生效;成功时替换 cell 源码。 + +**Architecture:** 前端 `OpenCodeCellActions` 渲染单 🪄 图标;点击 → `OpenCodeInlinePrompt` widget 附加到 `cell.node` 显示输入框;提交 POST `/opencode-bridge/edit` body `{prompt, context, providerId?, modelId?}`(无 `mode`)。后端 `EditHandler` 用唯一统一系统提示词构造 OpenCode parts,`SessionManager` 保证 1 notebook 1 sid。`index.ts` 订阅 `settings.changed` 实时刷新 runtime。 + +**Tech Stack:** TypeScript / JupyterLab 4.5.10 / Python 3.12 (tornado) / jest / pytest-jupyter / stylelint / prettier + +**Spec:** `docs/superpowers/specs/2026-07-23-cell-toolbar-actions-v2-design.md`(已批准) + +## Global Constraints + +- 前端 request body **绝不**包含 `mode` 字段。 +- 服务端 `EditHandler` **绝不**读 `body["mode"]`、**绝不**在响应里回 `mode` 字段。 +- `OpenCodeMode` 类型别名删除;`OpenCodeRequest.mode` 和 `OpenCodeSuccess.mode` 字段删除。 +- `MODE_SYSTEM_PROMPTS` 字典删除;统一为单一 `UNIFIED_SYSTEM_PROMPT` 常量(包含子串 `"你是一个代码编辑助手"` 以兼容 `test_edit_handler` 既有断言)。 +- toolbar item 名 `opencode-cell-actions` 不变;rank 保持 100。 +- 工具按钮不显示在 markdown cell(non-CodeCell 返回空 `Widget`)。 +- inline 输入框在 cell 内的位置:`Widget.attach(inlineWidget, cell.node)` 追加到 cell DOM 末尾,CSS 给边框使其作为 cell 内的内嵌面板呈现。 +- 无新 npm / pip 依赖;REST 契约变化(去 mode)向后兼容(多余 `mode` 字段被忽略)。 +- 代码风格:prettier(single quotes、无 trailing commas、无 arrow parens);CSS 类名 kebab-case;Python PEP 8。 + +--- + +### Task 1: 后端删 mode + 统一系统提示词(TDD) + +**Files:** +- Modify: `opencode_bridge/routes.py` +- Modify: `opencode_bridge/tests/test_routes.py`(只改 `test_edit_handler`) + +**Interfaces:** +- Consumes: 无新外部依赖;`OpenCodeClient.send_message_sync(sid, parts, provider_id, model_id, system)` 不变;`SessionManager.get_or_create / invalidate` 不变。 +- Produces:`_build_request_body(prompt: str, context: dict) -> dict`(无 mode 参数);`UNIFIED_SYSTEM_PROMPT: str` 常量;`EditHandler.post` 接受 body `{prompt, context, providerId?, modelId?}`(无 mode)。 + +- [ ] **Step 1: 改写 `opencode_bridge/routes.py`** + +完整新内容(注意:删 `MODE_SYSTEM_PROMPTS`、`_build_request_body` 去 mode 参数、`` 守卫删除、`EditHandler` 不读不回 mode): + +```python +import json + +import logging + +from jupyter_server.base.handlers import APIHandler +from jupyter_server.utils import url_path_join +import tornado + +from .config import resolve_config +from .opencode_client import OpenCodeClient, OpenCodeError +from .session_manager import SessionManager + + +log = logging.getLogger("opencode_bridge.routes") + + +# Unified system prompt — the LLM (OpenCode) itself judges whether the user's +# natural-language instruction is an optimize / fix / edit request, based on +# the code context and the optional in the parts below. +UNIFIED_SYSTEM_PROMPT = ( + "你是一个代码编辑助手。基于提供的代码上下文(以及 traceback,如果有)," + "按照用户的指令修改代码。返回只包含修改后完整代码的回复," + "不要任何解释或 markdown 围栏。" +) + + +def make_client(handler: APIHandler) -> OpenCodeClient: + """Factory for OpenCodeClient. Tests monkey-patch this.""" + cfg = resolve_config(handler.settings.get("opencode_bridge", {})) + return OpenCodeClient(cfg) + + +def get_session_manager(handler: APIHandler) -> SessionManager: + """Return the SessionManager singleton for this web app, creating on first use. + + Stored in handler.settings["opencode_bridge_session_manager"] so it survives + across requests but is per-server-instance. Tests monkey-patch this. + """ + sm = handler.settings.get("opencode_bridge_session_manager") + if sm is None: + def client_factory() -> OpenCodeClient: + cfg = resolve_config(handler.settings.get("opencode_bridge", {})) + return OpenCodeClient(cfg) + sm = SessionManager(client_factory) + handler.settings["opencode_bridge_session_manager"] = sm + return sm + + +def _build_request_body(prompt: str, context: dict) -> dict: + """Build full request body for OpenCode POST /session/:id/message. + + Returns dict with 'parts' (list) and 'system' (str) keys. No mode concept: + the LLM interprets the user's natural-language instruction, with the code + context and the optional traceback in front of it. + """ + parts: list[dict] = [] + + if context.get("previousCode"): + parts.append({ + "type": "text", + "text": "\n%s\n\n" % context["previousCode"], + }) + + error = context.get("error") + if error: + parts.append({ + "type": "text", + "text": ( + "\n%s: %s\n" % (error["ename"], error["evalue"]) + + "\n".join(error.get("traceback", [])) + + "\n\n" + ), + }) + + parts.append({ + "type": "text", + "text": "\n%s\n\n" % ( + context.get("language", "python"), + context["source"], + ), + }) + + if prompt: + parts.append({"type": "text", "text": "\n%s\n" % prompt}) + + return {"parts": parts, "system": UNIFIED_SYSTEM_PROMPT} + + +def _strip_code_fence(s: str) -> str: + """Strip ```language ... ``` fences from LLM output.""" + s = s.strip() + if s.startswith("```"): + lines = s.split("\n") + if lines[0].startswith("```"): + lines = lines[1:] + if lines and lines[-1].startswith("```"): + lines = lines[:-1] + return "\n".join(lines).strip() + return s + + +class HelloRouteHandler(APIHandler): + # The following decorator should be present on all verb methods (head, get, post, + # patch, put, delete, options) to ensure only authorized user can request the + # Jupyter server + @tornado.web.authenticated + def get(self): + self.finish(json.dumps({ + "data": ( + "Hello, world!" + " This is the '/opencode-bridge/hello' endpoint." + " Try visiting me in your browser!" + ), + })) + + +class HealthHandler(APIHandler): + @tornado.web.authenticated + async def get(self): + try: + client = make_client(self) + data = await client.health() + self.finish(json.dumps({ + "ok": data.get("healthy", False), + "version": data.get("version"), + "endpoint": client.endpoint, + })) + except Exception as e: + log.exception("health check failed") + self.set_status(503) + self.finish(json.dumps({ + "ok": False, + "error": str(e), + "endpoint": make_client(self).endpoint, + })) + + +class ProvidersHandler(APIHandler): + @tornado.web.authenticated + async def get(self): + try: + data = await make_client(self).list_providers() + self.finish(json.dumps(data)) + except Exception as e: + log.exception("providers list failed") + self.set_status(502) + self.finish(json.dumps({"error": str(e)})) + + +class EditHandler(APIHandler): + @tornado.web.authenticated + async def post(self): + try: + body = json.loads(self.request.body) + prompt = body.get("prompt", "") + context = body["context"] + provider_id = body.get("providerId") or None + model_id = body.get("modelId") or None + notebook_path = context.get("notebookPath", "") + except (KeyError, json.JSONDecodeError) as e: + self.set_status(400) + self.finish(json.dumps({"error": "bad request: %s" % e})) + return + + if not notebook_path: + self.set_status(400) + self.finish(json.dumps({"error": "missing context.notebookPath"})) + return + + client = make_client(self) + sm = get_session_manager(self) + try: + sid = await sm.get_or_create(notebook_path) + request_body = _build_request_body(prompt, context) + result = await client.send_message_sync( + sid, + request_body["parts"], + provider_id=provider_id, + model_id=model_id, + system=request_body["system"], + ) + + text_parts = [ + p.get("text", "") + for p in result.get("parts", []) + if p.get("type") == "text" + ] + final_source = _strip_code_fence("\n".join(text_parts).strip()) + + self.finish(json.dumps({ + "ok": True, + "finalSource": final_source, + "sessionId": sid, + "notebookPath": notebook_path, + })) + except OpenCodeError as e: + # If session is invalid on OpenCode side, invalidate cache. + # 404 / 410 / "session not found" -> next get_or_create will recreate. + if "404" in str(e) or "not found" in str(e).lower(): + sm.invalidate(notebook_path) + log.warning("invalidated dead session for %s", notebook_path) + log.exception("edit failed") + self.set_status(502) + self.finish(json.dumps({"ok": False, "error": str(e)})) + except Exception as e: + log.exception("edit failed") + self.set_status(502) + self.finish(json.dumps({"ok": False, "error": str(e)})) + # NO finally delete — session is reused per notebook. + + +class SessionListHandler(APIHandler): + """List all active notebook -> session mappings. Debug endpoint.""" + + @tornado.web.authenticated + def get(self): + sm = get_session_manager(self) + self.finish(json.dumps({"sessions": sm.list_sessions()})) + + +class SessionReleaseHandler(APIHandler): + """Release the OpenCode session for a specific notebook. + + Query param: notebook= + """ + + @tornado.web.authenticated + async def delete(self): + notebook_path = self.get_query_argument("notebook", "") + if not notebook_path: + self.set_status(400) + self.finish(json.dumps({"error": "missing 'notebook' query parameter"})) + return + sm = get_session_manager(self) + deleted = await sm.release(notebook_path) + self.finish(json.dumps({ + "ok": True, + "notebookPath": notebook_path, + "deleted": deleted, + })) + + +def setup_route_handlers(web_app): + host_pattern = ".*$" + base_url = web_app.settings["base_url"] + + handlers = [ + (url_path_join(base_url, "opencode-bridge", "hello"), HelloRouteHandler), + (url_path_join(base_url, "opencode-bridge", "health"), HealthHandler), + (url_path_join(base_url, "opencode-bridge", "providers"), ProvidersHandler), + (url_path_join(base_url, "opencode-bridge", "edit"), EditHandler), + (url_path_join(base_url, "opencode-bridge", "sessions"), SessionListHandler), + (url_path_join(base_url, "opencode-bridge", "session"), SessionReleaseHandler), + ] + + web_app.add_handlers(host_pattern, handlers) +``` + +- [ ] **Step 2: 改 `test_edit_handler` 去掉请求体里的 `mode`** + +在 `opencode_bridge/tests/test_routes.py` 中,`test_edit_handler` 函数(约 line 108)把请求体构造: + +```python + body = json.dumps({ + "mode": "edit", + "prompt": "Add type hints", + "context": { + "notebookPath": "test.ipynb", + "cellId": "cell-1", + "language": "python", + "cellIndex": 0, + "totalCells": 1, + "source": "def foo(): return 42\n", + "previousCode": None, + "error": None, + }, + }) +``` + +改为(删除 `"mode": "edit",` 一行): + +```python + body = json.dumps({ + "prompt": "Add type hints", + "context": { + "notebookPath": "test.ipynb", + "cellId": "cell-1", + "language": "python", + "cellIndex": 0, + "totalCells": 1, + "source": "def foo(): return 42\n", + "previousCode": None, + "error": None, + }, + }) +``` + +其余断言(系统提示词含 "你是一个代码编辑助手"、`sessionId`、`notebookPath`、`send_message_sync` 走 SessionManager 而非直接 create/delete)保持不变 —— 新版服务端下它们仍应通过。 + +- [ ] **Step 3: 跑测试确认通过** + +Run: `.venv/bin/python -m pytest opencode_bridge/tests/test_routes.py -v` +Expected: `test_hello`, `test_health_handler`, `test_providers_handler`, `test_edit_handler`, `test_session_list_handler`, `test_session_release_handler` 全部通过(共 6 个)。 + +- [ ] **Step 4: Commit** + +```bash +git add opencode_bridge/routes.py opencode_bridge/tests/test_routes.py +git commit -m "refactor: drop mode from backend; unify system prompt + +Removes the optimize/fix/edit mode-routing on the server and collapses to +a single unified system prompt. The LLM itself judges the user's intent +from natural language + code context (+ optional traceback). The frontend +no longer sends mode; the response no longer echoes it." +``` + +--- + +### Task 2: 前端 types 去 mode + settings 实时生效 + +**Files:** +- Modify: `src/types.ts` +- Modify: `src/index.ts` +- (可能)Modify: `src/__tests__/opencode_client.spec.ts`(若它构造的 `OpenCodeRequest` 包含 `mode`) + +**Interfaces:** +- Consumes: 无新依赖;`ISettingRegistry` 已用;`setOpenCodeRuntime` 签名不变。 +- Produces: `OpenCodeMode` 类型别名**删除**;`OpenCodeRequest = { prompt, context, providerId?, modelId? }`(无 mode);`OpenCodeSuccess = { ok, finalSource, sessionId, notebookPath }`(无 mode);`index.ts` 订阅 `settings.changed` → 重新 `setOpenCodeRuntime`。 + +- [ ] **Step 1: 改 `src/types.ts`** + +把: +```ts +export type OpenCodeMode = 'optimize' | 'fix' | 'edit'; + +export interface OpenCodeRequest { + mode: OpenCodeMode; + prompt: string; + context: CellContext; + providerId?: string; + modelId?: string; +} + +export interface OpenCodeSuccess { + ok: true; + mode: OpenCodeMode; + finalSource: string; + sessionId: string; + notebookPath: string; +} +``` + +改为: +```ts +export interface OpenCodeRequest { + prompt: string; + context: CellContext; + providerId?: string; + modelId?: string; +} + +export interface OpenCodeSuccess { + ok: true; + finalSource: string; + sessionId: string; + notebookPath: string; +} +``` +(`OpenCodeMode` 类型别名整行删除。) + +- [ ] **Step 2: 改 `src/index.ts` 订阅 `settings.changed`** + +当前(在 `settingRegistry.load(...).then(settings => { ... setOpenCodeRuntime(...) ... })` 内): +```ts + void settingRegistry + .load(plugin.id) + .then(settings => { + const bridge = readOpenCodeSettings(settings.composite); + setOpenCodeRuntime({ + settings: bridge, + serverSettings: app.serviceManager.serverSettings + }); + console.log('opencode_bridge settings loaded:', bridge); + ... +``` + +把 `then(settings => { ... })` 回调改为: +```ts + void settingRegistry + .load(plugin.id) + .then(settings => { + const apply = () => { + const bridge = readOpenCodeSettings(settings.composite); + setOpenCodeRuntime({ + settings: bridge, + serverSettings: app.serviceManager.serverSettings + }); + console.log('opencode_bridge settings applied:', bridge); + }; + apply(); + settings.changed.connect(apply); + ... +``` +(把 `setOpenCodeRuntime` + log 包成 `apply`,先调一次,再 `settings.changed.connect(apply)` 订阅后续变更。`callOpenCodeProviders` 那段保持不变。) + +- [ ] **Step 3: 同步 `opencode_client.spec.ts`(若需要)** + +Read `src/__tests__/opencode_client.spec.ts`。若测试里构造的 `OpenCodeRequest` 包含 `mode` 字段,删除之;若 mock 的 `OpenCodeSuccess` 含 `mode`,删除之;若断言响应里含 `mode`,删除之。具体按文件实际内容改。 + +- [ ] **Step 4: 跑测试确认通过** + +Run: `YARN_NODE_LINKER=node-modules PATH="$PWD/.venv/bin:$PATH" .venv/bin/jlpm test` +Expected: 全过(若 opencode_client.spec.ts 报错"mode required"则回到 Step 3 修)。 + +- [ ] **Step 5: Commit** + +```bash +git add src/types.ts src/index.ts src/__tests__/opencode_client.spec.ts +git commit -m "refactor: drop mode from frontend types; react to settings changes + +OpenCodeMode removed; OpenCodeRequest and OpenCodeSuccess no longer carry a +mode field. index.ts now subscribes to settings.changed so JupyterLab +Settings Editor changes (provider, model, server URL, etc.) take effect on +the next request without a reload." +``` + +--- + +### Task 3: 前端组件改造为单按钮 + inline 输入框(TDD) + +**Files:** +- Modify: `src/components/opencode_cell_actions.ts` +- Create: `src/components/opencode_inline_prompt.ts`(新文件,inline 输入框 widget) +- Modify: `src/__tests__/opencode_cell_actions.spec.ts` + +**Interfaces:** +- Consumes: Task 2 后的 `OpenCodeRequest`(无 mode)、`OpenCodeResponse`、`OpenCodeSettings`、`callOpenCodeEdit`、`extractCellContext`;`Widget` / `Notification` / `CodeCell` 类型。 +- Produces: `class OpenCodeCellActions extends Widget` —— 渲染单个 🪄 图标按钮 + (按钮下方) inline 标签文本 "AI 智能编辑";点击 → 通过 `OpenCodeInlinePrompt` 切换 cell 内的输入面板;提交 `POST { prompt, context, providerId?, modelId? }`(无 mode);成功 → `cell.model.sharedModel.setSource(resp.finalSource)` + Notification;失败 → Notification。 +- 同步:导出 `setOpenCodeRuntime` 仍在 `opencode_cell_actions.ts`(保持调用方 import 路径不变)。 + +- [ ] **Step 1: 写新单测(覆盖新行为)** — 完整替换 `src/__tests__/opencode_cell_actions.spec.ts`: + +```typescript +/** + * Unit tests for OpenCodeCellActions (single AI button) and OpenCodeInlinePrompt + * (inline input panel inside the cell). + * + * Mocks @jupyterlab/cells, @jupyterlab/apputils, and @lumino/widgets at the + * test boundary so the real ESM packages are not loaded. + */ +jest.mock('@jupyterlab/cells', () => ({ + CodeCell: class CodeCell {} +})); + +jest.mock('@jupyterlab/apputils', () => ({ + Notification: { + info: jest.fn(), + error: jest.fn(), + success: jest.fn(), + warning: jest.fn() + } +})); + +jest.mock('@lumino/widgets', () => { + class Widget { + public node: HTMLElement = document.createElement('div'); + public id = ''; + public parent: Widget | null = null; + private _isDisposed = false; + public get isDisposed(): boolean { + return this._isDisposed; + } + public addClass(cls: string): void { + this.node.classList.add(cls); + } + public dispose(): void { + this._isDisposed = true; + } + public static attach(widget: Widget, host: HTMLElement): void { + host.appendChild(widget.node); + } + } + return { Widget }; +}); + +import { OpenCodeCellActions } from '../components/opencode_cell_actions'; +import { OpenCodeInlinePrompt } from '../components/opencode_inline_prompt'; +import { CodeCell } from '@jupyterlab/cells'; +import { Widget } from '@lumino/widgets'; + +function makeFakeOutputs(items: any[]): any { + return { + get length() { + return items.length; + }, + get(i: number) { + return items[i]; + } + }; +} + +function makeFakeCell( + source: string, + errorOutputs: any[] = [], + notebookPath = 'foo.ipynb', + cellIndex = 0, + totalCells = 1 +): CodeCell { + const model: any = { + id: 'cell-test', + type: 'code', + sharedModel: { + getSource: () => source, + setSource: jest.fn() + }, + outputs: makeFakeOutputs(errorOutputs), + contentChanged: { connect: jest.fn(), disconnect: jest.fn() }, + stateChanged: { connect: jest.fn(), disconnect: jest.fn() } + }; + const cell = new (CodeCell as unknown as new () => CodeCell)(); + (cell as any).model = model; + + const notebook: any = { widgets: [] as CodeCell[] }; + const panel: any = { + context: { path: notebookPath }, + content: notebook + }; + for (let i = 0; i < cellIndex; i++) { + notebook.widgets.push({ + model: { sharedModel: { getSource: () => '' } } + } as any); + } + notebook.widgets.push(cell); + for (let i = cellIndex + 1; i < totalCells; i++) { + notebook.widgets.push({ + model: { sharedModel: { getSource: () => '' } } + } as any); + } + + Object.defineProperty(cell, 'parent', { + value: notebook, + configurable: true + }); + Object.defineProperty(notebook, 'parent', { + value: panel, + configurable: true + }); + + return cell as CodeCell; +} + +describe('OpenCodeCellActions', () => { + it('renders a single AI button (not 3 buttons)', () => { + const cell = makeFakeCell('x = 1'); + const actions = new OpenCodeCellActions(cell); + const btns = actions.node.querySelectorAll('button'); + expect(btns.length).toBe(1); + expect(btns[0].textContent).toContain('🪄'); + }); + + it('disables the button when notebook panel cannot be resolved', () => { + const cell = new (CodeCell as unknown as new () => CodeCell)(); + (cell as any).model = { + id: 'orphan', + type: 'code', + sharedModel: { getSource: () => 'x = 1', setSource: jest.fn() }, + outputs: makeFakeOutputs([]), + contentChanged: { connect: jest.fn(), disconnect: jest.fn() }, + stateChanged: { connect: jest.fn(), disconnect: jest.fn() } + }; + const actions = new OpenCodeCellActions(cell); + const btn = actions.node.querySelector('button') as HTMLButtonElement; + expect(btn.disabled).toBe(true); + }); + + it('clicking the button attaches an OpenCodeInlinePrompt to the cell', () => { + const cell = makeFakeCell('x = 1'); + const actions = new OpenCodeCellActions(cell); + // Attach actions to cell so it can resolve parent chain + 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 prompt = cell.node.querySelector( + '.opencode-inline-prompt' + ) as HTMLElement; + expect(prompt).not.toBeNull(); + }); + + it('clicking the button twice detaches the inline prompt', () => { + 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(); + btn.click(); + const prompt = cell.node.querySelector('.opencode-inline-prompt'); + expect(prompt).toBeNull(); + }); + + it('disconnects model signals on dispose', () => { + const cell = makeFakeCell('x = 1'); + const actions = new OpenCodeCellActions(cell); + actions.dispose(); + const model = (cell as any).model; + expect(model.contentChanged.disconnect).toHaveBeenCalled(); + expect(model.stateChanged.disconnect).toHaveBeenCalled(); + }); +}); + +describe('OpenCodeInlinePrompt', () => { + it('renders a textarea, a send and a cancel button', () => { + const cell = makeFakeCell('x = 1'); + const prompt = new OpenCodeInlinePrompt(cell, { + onSubmit: jest.fn(), + onCancel: jest.fn(), + disabled: false + }); + expect(prompt.node.querySelector('textarea')).not.toBeNull(); + expect(prompt.node.querySelectorAll('button').length).toBe(2); + }); +}); +``` + +(注:本轮我们把 inline 输入框拆成独立 `OpenCodeInlinePrompt` widget。`cell.node.appendChild(actions.node)` 不在测试范围 —— 测试通过断言 `cell.node.querySelector('.opencode-inline-prompt')` 验证。Mock `Widget.attach` 追加到 `host.appendChild(widget.node)`。) + +- [ ] **Step 2: 跑测试确认失败** + +Run: `YARN_NODE_LINKER=node-modules PATH="$PWD/.venv/bin:$PATH" .venv/bin/jlpm test opencode_cell_actions` +Expected: FAIL —— `OpenCodeInlinePrompt` 模块找不到(因为还没建),以及现有 component 行为不匹配新断言(1 button 而非 3)。 + +- [ ] **Step 3: 新建 `src/components/opencode_inline_prompt.ts`** — inline 输入框 widget + +```typescript +/** + * Inline prompt widget attached to a cell's DOM when the AI button is clicked. + * Renders a textarea + send/cancel buttons. The owning cell is passed so + * we can resolve the cell context at submit time. submit/cancel are + * callbacks owned by OpenCodeCellActions. + */ +import { CodeCell } from '@jupyterlab/cells'; +import { Widget } from '@lumino/widgets'; + +export interface IOpenCodeInlinePromptOptions { + onSubmit: (text: string) => void; + onCancel: () => void; + disabled: boolean; +} + +export class OpenCodeInlinePrompt extends Widget { + private _textarea: HTMLTextAreaElement; + private _sendBtn: HTMLButtonElement; + private _cancelBtn: HTMLButtonElement; + + constructor( + private _cell: CodeCell, + options: IOpenCodeInlinePromptOptions + ) { + super(); + this.addClass('opencode-inline-prompt'); + 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()) { + options.onSubmit(text); + } + }); + 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); + + this.node.appendChild(this._textarea); + this.node.appendChild(actions); + } + + setDisabled(disabled: boolean): void { + this._sendBtn.disabled = disabled; + } +} +``` + +- [ ] **Step 4: 重写 `src/components/opencode_cell_actions.ts`** + +完整新内容: + +```typescript +/** + * Per-cell AI action button rendered inside JupyterLab's native Cell toolbar + * (top-right of the active cell). v2: a single icon button. Clicking it + * toggles an OpenCodeInlinePrompt panel attached to the cell's DOM. + * + * Registered in index.ts via IToolbarWidgetRegistry.addFactory('Cell', ...). + * + * The frontend does NO semantic processing: it gathers the cell's source / + * outputs / error, attaches the user's freeform instruction, and POSTs to + * the server. The server (and the LLM it forwards to) decides what to do. + */ +import type { CodeCell } from '@jupyterlab/cells'; +import { Notification } from '@jupyterlab/apputils'; +import type { NotebookPanel } from '@jupyterlab/notebook'; +import { ServerConnection } from '@jupyterlab/services'; +import { Widget } from '@lumino/widgets'; + +import { callOpenCodeEdit } from '../api/opencode_client'; +import { extractCellContext } from '../context/cell_context'; +import type { + CellContext, + OpenCodeRequest, + OpenCodeResponse, + OpenCodeSettings +} from '../types'; + +import { OpenCodeInlinePrompt } from './opencode_inline_prompt'; + +// Module-level runtime injection (set by index.ts after settings load). +let _settings: OpenCodeSettings | null = null; +let _serverSettings: ServerConnection.ISettings | null = null; + +export function setOpenCodeRuntime(args: { + settings: OpenCodeSettings; + serverSettings: ServerConnection.ISettings; +}): void { + _settings = args.settings; + _serverSettings = args.serverSettings; +} + +type Status = 'idle' | 'loading'; + +export class OpenCodeCellActions extends Widget { + private _cell: CodeCell; + private _context: CellContext | null = null; + private _status: Status = 'idle'; + private _prompt: OpenCodeInlinePrompt | null = null; + + constructor(cell: CodeCell) { + super(); + this._cell = cell; + this.addClass('opencode-cell-actions'); + this._cell.model.contentChanged.connect(this._onModelChange, this); + this._cell.model.stateChanged.connect(this._onModelChange, this); + this._render(); + this._onModelChange(); + } + + dispose(): void { + if (this.isDisposed) { + return; + } + this._cell.model.contentChanged.disconnect(this._onModelChange, this); + this._cell.model.stateChanged.disconnect(this._onModelChange, this); + this._hidePrompt(); + super.dispose(); + } + + private _onModelChange(): void { + this._context = extractCellContextFromCell(this._cell); + if (this._prompt) { + this._prompt.setDisabled(!this._context || this._status === 'loading'); + } + } + + private _render(): void { + const node = this.node; + node.textContent = ''; + + const baseDisabled = !this._context || this._status === 'loading'; + + const btn = document.createElement('button'); + btn.className = 'opencode-btn opencode-btn-ai'; + btn.textContent = '🪄 AI 智能编辑'; + btn.disabled = baseDisabled; + btn.title = baseDisabled + ? 'OpenCode: 等待 cell 上下文…' + : '让 AI 修改这个 cell 的代码'; + btn.addEventListener('click', () => { + this._togglePrompt(); + }); + node.appendChild(btn); + } + + private _togglePrompt(): void { + if (this._prompt) { + this._hidePrompt(); + } else { + this._showPrompt(); + } + } + + private _showPrompt(): void { + if (this._prompt) { + return; + } + const prompt = new OpenCodeInlinePrompt(this._cell, { + disabled: !this._context || this._status === 'loading', + onSubmit: (text: string) => { + void this._onSubmit(text); + }, + onCancel: () => { + this._hidePrompt(); + } + }); + Widget.attach(prompt, this._cell.node); + this._prompt = prompt; + } + + private _hidePrompt(): void { + if (this._prompt) { + this._prompt.dispose(); + this._prompt = null; + } + } + + private async _onSubmit(text: string): Promise { + if (!this._context) { + return; + } + if (!_settings || !_serverSettings) { + Notification.error('OpenCode 运行时未初始化,请检查 settings'); + return; + } + + const request: OpenCodeRequest = { + prompt: text, + context: this._context, + providerId: _settings.opencodeProvider || undefined, + modelId: _settings.opencodeModel || undefined + }; + + this._status = 'loading'; + if (this._prompt) { + this._prompt.setDisabled(true); + } + + try { + const resp = await callOpenCodeEdit(request, _serverSettings); + this._handleResponse(resp); + } catch (e) { + this._status = 'idle'; + if (this._prompt) { + this._prompt.setDisabled(false); + } + Notification.error(`OpenCode 失败: ${(e as Error).message}`); + } + } + + private _handleResponse(resp: OpenCodeResponse): void { + this._status = 'idle'; + if (resp.ok) { + this._cell.model.sharedModel.setSource(resp.finalSource); + Notification.info( + `OpenCode 完成 (${resp.finalSource.length} chars). Session: ${resp.sessionId.slice(0, 8)}…` + ); + this._hidePrompt(); + } else { + if (this._prompt) { + this._prompt.setDisabled(false); + } + Notification.error(`OpenCode 错误: ${resp.error}`); + } + } +} + +/** + * Resolve a CodeCell's parent NotebookPanel by walking the parent chain, + * then build the CellContext. + */ +function extractCellContextFromCell(cell: CodeCell): CellContext | null { + let node: Widget | null = cell.parent; + let notebookPanel: NotebookPanel | null = null; + while (node) { + const candidate = node as any; + if ( + candidate.context && + candidate.content && + Array.isArray(candidate.content.widgets) + ) { + notebookPanel = candidate; + break; + } + node = node.parent; + } + if (!notebookPanel) { + return null; + } + return extractCellContext(cell, notebookPanel); +} +``` + +- [ ] **Step 5: 跑测试确认通过** + +Run: `YARN_NODE_LINKER=node-modules PATH="$PWD/.venv/bin:$PATH" .venv/bin/jlpm test opencode_cell_actions` +Expected: 6 tests pass(5 actions + 1 inline prompt widget 的基础渲染测试)。 + +- [ ] **Step 6: 跑全套测试** + +Run: `YARN_NODE_LINKER=node-modules PATH="$PWD/.venv/bin:$PATH" .venv/bin/jlpm test` +Expected: 全过。 + +- [ ] **Step 7: Commit** + +```bash +git add src/components/opencode_cell_actions.ts src/components/opencode_inline_prompt.ts src/__tests__/opencode_cell_actions.spec.ts +git commit -m "feat: single AI action button with inline prompt; replace cell source + +v2: collapse 3 buttons (optimize/fix/edit) into one icon button. Clicking +it opens an OpenCodeInlinePrompt widget attached to the cell's DOM with a +textarea + send/cancel. On submit the request body is {prompt, context, +providerId?, modelId?} — no mode field, the backend (and the LLM) decides +what to do. On success the cell source is replaced via +sharedModel.setSource(resp.finalSource)." +``` + +--- + +### Task 4: CSS 改造 + client 单测同步 + +**Files:** +- Modify: `style/base.css`(单按钮 + inline 面板样式) +- (可能)Modify: `src/__tests__/opencode_client.spec.ts`(若它含 mode 断言,Task 2 漏掉的) + +**Interfaces:** +- 无。 + +- [ ] **Step 1: 重写 `style/base.css`** + +完整新内容(替换整个文件): + +```css +/* + See the JupyterLab Developer Guide for useful CSS Patterns: + + https://jupyterlab.readthedocs.io/en/stable/developer/css.html +*/ + +/* Single AI action button inside the native Cell toolbar (active cell, top-right). */ +.opencode-cell-actions { + display: flex; + align-items: center; +} + +.opencode-cell-actions .opencode-btn { + border: none; + background: transparent; + padding: 0 6px; + font-size: var(--jp-ui-font-size1); + line-height: 20px; + cursor: pointer; + white-space: nowrap; +} + +.opencode-cell-actions .opencode-btn:hover:enabled { + background: var(--jp-layout-color2); + border-radius: 2px; +} + +.opencode-cell-actions .opencode-btn:disabled { + opacity: 0.4; + cursor: default; +} + +/* Inline prompt panel attached to the cell when the AI button is clicked. */ +.opencode-inline-prompt { + display: flex; + flex-direction: column; + gap: 4px; + padding: 6px; + margin: 4px 8px; + border: 1px solid var(--jp-border-color2, #ddd); + border-radius: 4px; + background: var(--jp-layout-color1, #fff); +} + +.opencode-inline-prompt textarea { + width: 100%; + resize: vertical; + font-family: var(--jp-code-font-family, monospace); + font-size: var(--jp-code-font-size, 13px); + box-sizing: border-box; +} + +.opencode-inline-prompt .opencode-inline-actions { + display: flex; + justify-content: flex-end; + gap: 4px; +} + +.opencode-inline-prompt .opencode-inline-actions button { + border: 1px solid var(--jp-border-color2, #ccc); + background: var(--jp-layout-color2, #f7f7f7); + padding: 2px 8px; + border-radius: 3px; + cursor: pointer; + font-size: var(--jp-ui-font-size1); +} + +.opencode-inline-prompt .opencode-inline-actions button:hover:enabled { + background: var(--jp-layout-color3, #eaeaea); +} + +.opencode-inline-prompt .opencode-inline-actions button:disabled { + opacity: 0.4; + cursor: default; +} +``` + +- [ ] **Step 2: 最终全量验证** + +Run: `.venv/bin/python -m pytest opencode_bridge/tests/ -v` && `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: 三个全过。 + +- [ ] **Step 3: Commit** + +```bash +git add style/base.css +git commit -m "style: single AI button + inline prompt panel CSS" +``` + +--- + +### Task 5: design.md 同步(CC) + +**Files:** +- Modify: `design.md` §2(交互)+ §3(API contract) + +- [ ] **Step 1: 改写 §2** — 完整替换该节第 1 条(单按钮)+ 新增 inline 输入框描述 + +把 §2 第 1 条: +> 1. **悬浮 Toolbar(常驻)**:在每个 `CodeCell` 顶部渲染微型操作栏: +> * `✨ 优化`:自动提炼代码规范与性能。 +> * `🐛 智能排错`:(**仅当 Cell 有 Error Output 时亮起**)自动抓取 Traceback 并修复。 +> * `🪄 智能编辑`:展开内嵌 Prompt 输入框。 + +替换为: +``` +1. **原生 Cell Toolbar 单按钮(2026-07-23 v2 修正)**:不再自绘悬浮 toolbar,也合并了 v1 的三个 mode(优化/排错/编辑)为单个 `🪄 AI 智能编辑` 图标按钮。通过 `IToolbarWidgetRegistry.addFactory('Cell', 'opencode-cell-actions', ...)` 注入 JupyterLab 原生 cell toolbar,显示在 **active cell 右上角**(move up/down 按钮旁,`rank: 100` 排在 delete 右侧);仅 `CodeCell` 显示,随 active cell 切换移动。前端不做任何语义处理 —— 不再发 `mode` 字段,后端用统一的系统提示词 + 用户的自然语言指令 + 上下文(含 traceback)由 LLM 自己判断是优化/排错/编辑。 +``` + +把 §2 第 2 条: +> 2. **Inline Prompt 框(按需展开)**: +> * 用户点击 `🪄 智能编辑` 后,在当前 Cell 下方平滑展开一个微型输入框(带 `Textarea` + `🚀 发送` 按钮)。 + +替换为: +``` +2. **Inline Prompt 输入框(v2 实现)**:点击 `🪄 AI 智能编辑` 按钮后,`OpenCodeInlinePrompt` widget 追加到当前 `CodeCell` 的 DOM 末尾,呈现内嵌输入面板:多行 `textarea`(placeholder "向 AI 描述你想要的修改…",无默认值)+ `🚀 发送` + `✕ 取消` 两个按钮。提交时前端仅收集 cell `source` / `outputs` / `error` 与用户在 textarea 里写的指令,POST `/opencode-bridge/edit`,body 为 `{prompt, context, providerId?, modelId?}`(无 `mode`)。**成功** → 前端调用 `cell.model.sharedModel.setSource(resp.finalSource)` 就地替换 cell 源码 + Notification.info;**失败** → Notification.error,输入框保留(可改文案重发)。Session 由服务端 `SessionManager` 保证 1 notebook 1 sid(前端不感知)。 +``` + +- [ ] **Step 2: 改写 §3 接口契约**(去掉 `action` mode 分类) + +把 §3 整节"接口契约设计 (API Contract)"下的 Request Payload 示例: +``` +"action": "custom_edit", // custom_edit | fix_error | optimize +``` +那行连同它上面的字段说明,改为没有 `mode` / `action` 的描述;强调 body 是 `{prompt, context, providerId?, modelId?}`;响应是 `{ok, finalSource, sessionId, notebookPath}`。 + +- [ ] **Step 3: Commit** + +```bash +git add design.md +git commit -m "docs: design.md v2 interaction + contract (single button, no mode)" +``` + +--- + +### Task 6: 手动验收(用户执行,非自动化) + +启动 `jupyter lab`,打开任一 `.ipynb`: +- active cell 右上角出现单个 `🪄 AI 智能编辑` 按钮(delete 按钮右侧,toolbar 不换行) +- 切换 active cell 按钮随之移动 +- 点击按钮 → cell 底部出现输入框(带边框,textarea + 发送/取消) +- 输入指令 → 发送 → cell 源码被替换为 AI 返回的代码;右上角 Notification.info +- Jupyter Settings Editor 修改 `opencodeProvider` / `opencodeModel` / `opencodeServerUrl` → 下一条请求即生效(无需 reload) +- 非 CodeCell(markdown)不显示按钮