1132 lines
39 KiB
Markdown
1132 lines
39 KiB
Markdown
# 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 参数、`<instruction>` 守卫删除、`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 <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)把请求体构造:
|
|
|
|
```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<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**
|
|
|
|
```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)不显示按钮
|