Files
notebook-ai-extension/docs/superpowers/plans/2026-07-23-cell-toolbar-actions-v2.md
2026-07-23 12:29:23 +08:00

39 KiB

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.modeOpenCodeSuccess.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 参数、<instruction> 守卫删除、EditHandler 不读不回 mode):

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 <traceback> 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": "<previous_cell>\n%s\n</previous_cell>\n" % context["previousCode"],
        })

    error = context.get("error")
    if error:
        parts.append({
            "type": "text",
            "text": (
                "<traceback>\n%s: %s\n" % (error["ename"], error["evalue"])
                + "\n".join(error.get("traceback", []))
                + "\n</traceback>\n"
            ),
        })

    parts.append({
        "type": "text",
        "text": "<cell language='%s'>\n%s\n</cell>\n" % (
            context.get("language", "python"),
            context["source"],
        ),
    })

    if prompt:
        parts.append({"type": "text", "text": "<instruction>\n%s\n</instruction>" % 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=<notebook path, URL-encoded>
    """

    @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)把请求体构造:

    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", 一行):

    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,
        },
    })

其余断言(系统提示词含 "你是一个代码编辑助手"、sessionIdnotebookPathsend_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
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

把:

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;
}

改为:

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(...) ... }) 内):

      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 => { ... }) 回调改为:

      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 的 OpenCodeSuccessmode,删除之;若断言响应里含 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
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)、OpenCodeResponseOpenCodeSettingscallOpenCodeEditextractCellContext;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:

/**
 * 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
/**
 * 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

完整新内容:

/**
 * 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<void> {
    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
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

完整新内容(替换整个文件):

/*
    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
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 条:

  1. 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
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)不显示按钮