Compare commits
34
Commits
c919c95842
...
ce59502e97
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ce59502e97 | ||
|
|
1d7f5da9d4 | ||
|
|
04f90ab5a0 | ||
|
|
e7579d3779 | ||
|
|
f7099be060 | ||
|
|
02cedac138 | ||
|
|
8ed55c43c7 | ||
|
|
df2f3dff95 | ||
|
|
b6c0273ee0 | ||
|
|
c86de1d31b | ||
|
|
ff932d9095 | ||
|
|
5306ada0c4 | ||
|
|
74953d0ab8 | ||
|
|
74de18646f | ||
|
|
1e42459560 | ||
|
|
b5bcb8f67c | ||
|
|
f9b93906c6 | ||
|
|
ca2281af3b | ||
|
|
8b51eda2ff | ||
|
|
12c0f4c618 | ||
|
|
24bff6d2eb | ||
|
|
85e16914b5 | ||
|
|
d03f0fa434 | ||
|
|
04d011afa1 | ||
|
|
42ea857313 | ||
|
|
b5adf331a6 | ||
|
|
50ba847659 | ||
|
|
2bac98d659 | ||
|
|
47a774f237 | ||
|
|
fe45c80dd2 | ||
|
|
0a05424b29 | ||
|
|
bb846d64fe | ||
|
|
714a55653c | ||
|
|
9e7c36e323 |
+1
-1
@@ -4,7 +4,7 @@ enableGlobalCache: false
|
||||
|
||||
enableScripts: false
|
||||
|
||||
nodeLinker: pnpm
|
||||
nodeLinker: node-modules
|
||||
|
||||
packageExtensions:
|
||||
"@module-federation/sdk@*":
|
||||
|
||||
@@ -42,14 +42,15 @@
|
||||
|
||||
## 🎨 2. 交互与UI流程设计
|
||||
|
||||
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. **Inline Prompt 框(按需展开)**:
|
||||
* 用户点击 `🪄 智能编辑` 后,在当前 Cell 下方平滑展开一个微型输入框(带 `Textarea` + `🚀 发送` 按钮)。
|
||||
2. **Inline Prompt 输入框(v3-final)**:点击 `🪄 AI 智能编辑` 按钮后,`OpenCodeInlinePrompt` widget 追加到当前 `CodeCell` 的 DOM 末尾,呈现内嵌输入面板:
|
||||
* 顶部一行 `模型: <select>` —— 来自启动时 `GET /opencode-bridge/providers` 缓存的 `(provider, model)` 列表,**默认选中第一项**(v3-final 不再有任何 settings-based 默认),用户每次请求前可改。providers 拉取失败时该下拉框不渲染。
|
||||
* 多行 `textarea`(placeholder "向 AI 描述你想要的修改…",无默认值)。
|
||||
* `🚀 发送` + `✕ 取消` 两个按钮。
|
||||
|
||||
提交时前端仅收集 cell `source` / `outputs` / `error` 与用户在 textarea 里写的指令,POST `/opencode-bridge/edit`,body 为 `{prompt, context, providerId?, modelId?}`(**无 `mode`**)。`providerId`/`modelId` 仅在下拉框有选中项时发出;下拉框不存在时省略,OpenCode Serve 用其默认。**成功** → 前端调用 `cell.model.sharedModel.setSource(resp.finalSource)` 就地替换 cell 源码 + `Notification.info`;**失败** → `Notification.error`,输入框保留(可改文案重发)。Session 由服务端 `SessionManager` 保证 1 notebook 1 sid(前端不感知)。
|
||||
|
||||
|
||||
3. **流式替换/Diff 预览**:
|
||||
@@ -61,43 +62,43 @@
|
||||
|
||||
## 🔌 3. 接口契约设计 (API Contract)
|
||||
|
||||
### 接口:`POST /opencode/v1/generate` (支持 SSE 流式返回)
|
||||
### 接口:`POST /opencode-bridge/edit`(v2 — 无 `mode`)
|
||||
|
||||
**Request Payload (前端 Context Provider 打包发给 Server Extension)**:
|
||||
**Request Payload**(前端 `OpenCodeCellActions` 在用户提交 inline 输入框时发出):
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "custom_edit", // custom_edit | fix_error | optimize
|
||||
"prompt": "把这段代码改为用 plotly 绘制交互式折线图",
|
||||
"context": {
|
||||
"notebook_path": "analysis/demo.ipynb",
|
||||
"target_cell": {
|
||||
"index": 3,
|
||||
"code": "import matplotlib.pyplot as plt\nplt.plot([1, 2, 3])",
|
||||
"error_output": "ModuleNotFoundError: No module named 'matplotlib'"
|
||||
},
|
||||
"surrounding_cells": [
|
||||
{ "index": 2, "code": "import pandas as pd" }
|
||||
]
|
||||
}
|
||||
"notebookPath": "analysis/demo.ipynb",
|
||||
"cellId": "cell-3",
|
||||
"language": "python",
|
||||
"cellIndex": 3,
|
||||
"totalCells": 5,
|
||||
"source": "import matplotlib.pyplot as plt\nplt.plot([1, 2, 3])",
|
||||
"previousCode": "import pandas as pd",
|
||||
"error": null
|
||||
},
|
||||
"providerId": "anthropic",
|
||||
"modelId": "claude-sonnet-4-20250514"
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
**Response (Server Extension 流式透传 OpenCode Serve 的 SSE 响应)**:
|
||||
> **v2 变化**:body 不再含 `mode` / `action` 字段。前端只把 cell 的 input/output/error 收集到 `context`,加用户的自然语言 `prompt` 一起发出;后端用统一的 `UNIFIED_SYSTEM_PROMPT` + LLM 自己判断优化/排错/编辑。`providerId` / `modelId` 来自 JupyterLab settings 透传。
|
||||
|
||||
```text
|
||||
event: chunk
|
||||
data: {"text": "import plotly.express as px\n"}
|
||||
|
||||
event: chunk
|
||||
data: {"text": "fig = px.line(x=[1, 2, 3], y=[1, 2, 3])\nfig.show()"}
|
||||
|
||||
event: done
|
||||
data: {"status": "success"}
|
||||
**Response**(当前 v2 实现:单次 JSON;SSE 流式属于 Slice 4 后续工作):
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"finalSource": "import plotly.express as px\nfig = px.line(x=[1, 2, 3], y=[1, 2, 3])\nfig.show()",
|
||||
"sessionId": "ses-abc123",
|
||||
"notebookPath": "analysis/demo.ipynb"
|
||||
}
|
||||
```
|
||||
|
||||
> 成功时前端用 `finalSource` 就地替换 cell 源码(`cell.model.sharedModel.setSource`)。Session 由服务端 `SessionManager` 按 `notebookPath` 复用(1 notebook 1 sid),404 / `session not found` 时服务端自动 invalidate 并在下次请求重建。
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ 4. 核心前端实现逻辑 (TypeScript)
|
||||
@@ -281,57 +282,41 @@ v0.2.1 不实现自动重试,只 invalidate。前端看到 502 后可重发。
|
||||
|
||||
---
|
||||
|
||||
## 🎛 6. Provider/Model 选择(v0.2.2 新增)
|
||||
## ⚙️ 6. 启动配置 + 动态模型选择(v3-final,2026-07-23)
|
||||
|
||||
> **2026-07-22**:用户要求能在 settings 里选 OpenCode 的 provider 和 model,可选值从 `GET /config/providers` 动态获取。
|
||||
> **v3-final 修正**:`opencode_bridge` 插件**完全不再使用 JupyterLab Settings Editor**(`schema/plugin.json` 的 `properties: {}`)。所有 OpenCode Serve 连接参数在 `jupyter lab` 启动时通过环境变量指定;模型在 inline 输入框中动态选择(见 §2 item 2),无任何持久默认。
|
||||
|
||||
### 6.1 Schema 扩展
|
||||
### 6.1 启动环境变量
|
||||
|
||||
`schema/plugin.json` 加 2 个 string 字段:
|
||||
| 变量 | 默认 | 说明 |
|
||||
|---|---|---|
|
||||
| `OPENCODE_BRIDGE_URL` | `http://127.0.0.1:4096` | OpenCode Serve 地址 |
|
||||
| `OPENCODE_BRIDGE_USER` | `opencode` | HTTP Basic Auth 用户名 |
|
||||
| `OPENCODE_BRIDGE_PASSWORD` | `""`(无 auth) | HTTP Basic Auth 密码 |
|
||||
| `OPENCODE_BRIDGE_TIMEOUT` | `120` | 请求 OpenCode Serve 超时(秒,整数) |
|
||||
|
||||
```json
|
||||
"opencodeProvider": {
|
||||
"type": "string",
|
||||
"title": "OpenCode Provider",
|
||||
"description": "Provider id (如 'anthropic' / 'openai')。可选项由启动时从 /opencode-bridge/providers 拉取并打到 console 列出。留空 = 用 OpenCode 默认。",
|
||||
"default": ""
|
||||
},
|
||||
"opencodeModel": {
|
||||
"type": "string",
|
||||
"title": "OpenCode Model",
|
||||
"description": "Model id (如 'claude-sonnet-4-20250514')。可选项见 console 日志或 /opencode-bridge/providers。留空 = 用 provider 默认。",
|
||||
"default": ""
|
||||
}
|
||||
```
|
||||
由 `opencode_bridge/config.py` `resolve_config` 统一解析,**不再**从 JupyterLab settings 字典兜底(`jupyter_settings` 参数保留仅为 API 兼容,实际未读)。
|
||||
|
||||
### 6.2 选值如何给用户
|
||||
### 6.2 模型选择
|
||||
|
||||
JupyterLab settings 是静态 JSON schema,**没有原生 dynamic enum**。v0.2.2 用最简方案:
|
||||
- 启动时 fetch `/opencode-bridge/providers` → 把 provider/model 列表 `console.log` 出来
|
||||
- 用户在 Settings Editor 里手填
|
||||
- 后续 v0.4 考虑用 QuickPick 命令做交互式选择
|
||||
|
||||
### 6.3 数据流
|
||||
`opencodeProvider` / `opencodeModel` v0.2.2 settings 字段**已移除**。inline 输入框顶部 `<select>` 的选项来自 `index.ts` 启动时 `callOpenCodeProviders` 拉取并写入 `setOpenCodeProviders(p)` 的模块级缓存:
|
||||
|
||||
```
|
||||
Settings (JupyterLab)
|
||||
│
|
||||
├── opencodeProvider = "anthropic"
|
||||
└── opencodeModel = "claude-sonnet-4-20250514"
|
||||
│
|
||||
▼ setOpenCodeRuntime()
|
||||
Frontend (opencode_cell_footer.ts)
|
||||
│
|
||||
▼ build request body
|
||||
POST /opencode-bridge/edit
|
||||
{ mode, prompt, context, providerId, modelId }
|
||||
│
|
||||
▼ send_message_sync
|
||||
OpenCode Serve 接受 model = { providerID, modelID }
|
||||
index.ts (启动)
|
||||
└─ GET /opencode-bridge/providers
|
||||
└─ setOpenCodeProviders(data) // 模块级 _providers
|
||||
└─ OpenCodeInlinePrompt 构造时读
|
||||
└─ <select> 列出所有 (provider, model)
|
||||
└─ 用户选一项 → 提交时
|
||||
└─ OpenCodeRequest.providerId / modelId
|
||||
└─ POST /opencode-bridge/edit
|
||||
```
|
||||
|
||||
### 6.4 不变量
|
||||
- 默认选中:下拉框**第一项**(无 settings 可作默认)。
|
||||
- 拉取失败(OpenCode Serve 未起)→ 缓存为 `null` → inline 不渲染 select → 提交时省略 `providerId`/`modelId`,OpenCode Serve 用其默认。**优雅降级**。
|
||||
|
||||
- 留空字段("")→ 客户端不发 `providerId`/`modelId` → server 也不加 `model` 字段到 OpenCode 请求 → OpenCode 用默认
|
||||
- 用户在 settings 里设的值**不会自动回写到 OpenCode**——只是 request 级别的覆盖
|
||||
- 改 settings 立即生效(下次 edit 请求就用新值);不需要重启 Jupyter
|
||||
### 6.3 行为变更(v0.2.2 → v3-final)
|
||||
|
||||
- 之前 6 个 JupyterLab settings 字段(`opencodeServerUrl` / `User` / `Password` / `requestTimeoutSeconds` / `opencodeProvider` / `opencodeModel`)**全部失效**,需改用环境变量(连接类)或在 inline 输入框里挑(模型)。
|
||||
- 连接配置从"可在 Settings Editor 改"变为"启动时定",更符合服务端连接参数的语义。
|
||||
- 模型从"settings 持久默认"变为"每次手选",无持久化偏好。
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,614 @@
|
||||
# Cell Toolbar Actions v3 — 实现计划
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development or superpowers:executing-plans. Steps use checkbox (`- [ ]`) syntax.
|
||||
|
||||
**Goal:** 把全部 server 连接字段(URL/User/Password/Timeout)从 JupyterLab Settings 移到启动环境变量;**opencode_bridge 插件完全不再使用 JupyterLab Settings Editor**(`schema/plugin.json` `properties: {}`);inline 输入框增加**完全动态的**模型选择器(无 settings 默认,默认第一项)。
|
||||
|
||||
**Architecture:** `config.py` 4 字段全走 `os.environ.get(ENV) or DEFAULT`(`jupyter_settings` 参数保留仅为 API 兼容,实际未读);`schema/plugin.json` `properties: {}`(schema 只保留 `jupyter.lab.toolbars` 贡献);`src/types.ts` 整个 `OpenCodeSettings` 类型 / `DEFAULT_OPENCODE_SETTINGS` / `readOpenCodeSettings` **删除**;`setOpenCodeRuntime` 改名为 `setOpenCodeServerSettings({ serverSettings })`,只设 Jupyter `ServerConnection.ISettings`,模块级 `_settings` 移除只留 `_serverSettings` 与 `_providers`;`index.ts` 去掉 `ISettingRegistry` 与 `settingRegistry.load(...)` 整段,激活时直接 `setOpenCodeServerSettings(serverSettings)` + 注册 toolbar factory + 拉 providers 写缓存;`OpenCodeInlinePrompt` 构造加 `providers`(无 `defaultProviderId`/`defaultModelId`),默认选中第一项,providers 为 `null`/空时不渲染 select,`onSubmit(text, providerId?, modelId?)` 把选中带回;`OpenCodeCellActions._onSubmit` 用 prompt 回传的 `providerId`/`modelId` 写进 `OpenCodeRequest`,无 settings 兜底(providers 拉取失败时提交时省略 provider/model 字段,OpenCode 用默认)。
|
||||
|
||||
**Tech Stack:** TypeScript / Python 3.12 / jest / pytest-jupyter / stylelint / prettier
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-07-23-cell-toolbar-actions-v3-design.md`(已批准)
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- `schema/plugin.json` `properties: {}`(删全部 properties;`jupyter.lab.toolbars` 贡献保留:item 名 `opencode-cell-actions`,rank 100)。
|
||||
- `src/types.ts` **删除** `OpenCodeSettings` / `DEFAULT_OPENCODE_SETTINGS` / `readOpenCodeSettings` 整组;`src/__tests__/opencode_bridge.spec.ts` 中相关测试删除(若文件仅含 types 测试则整文件 `git rm`)。
|
||||
- `opencode_bridge/config.py` 新增 `ENV_TIMEOUT = "OPENCODE_BRIDGE_TIMEOUT"` 与 `DEFAULT_REQUEST_TIMEOUT = 120`;`resolve_config` 4 个字段(`url`/`user`/`password`/`request_timeout_seconds`)全走 `os.environ.get(ENV) or DEFAULT`,**不再** `bridge.get(...)` 兜底。
|
||||
- `setOpenCodeRuntime` → `setOpenCodeServerSettings`,签名 `{ serverSettings: ServerConnection.ISettings }`;模块级 `_settings` 删除,只留 `_serverSettings`(给 `callOpenCodeEdit` 拼 baseUrl)与 `_providers`(给 inline prompt 渲染 select)。
|
||||
- `index.ts` `optional` 去掉 `ISettingRegistry`;删除整个 `settingRegistry.load(...).then(setOpenCodeRuntime)` 段;激活时:`setOpenCodeServerSettings(serverSettings)` + 注册 toolbar factory + `callOpenCodeProviders(...).then(setOpenCodeProviders)`。
|
||||
- 模型选择器 value 格式: `providerId|modelId`(`|` 分隔,`split("|", 2)` 解析)。
|
||||
- 默认选中: **第一项**(无 settings 可作默认);若 `providers` 为 `null` 或空 → **不渲染** select,提交时 `providerId`/`modelId` 为 `undefined` → **不发**这两个字段。
|
||||
- Providers 缓存模块级: `let _providers: OpenCodeProvidersResponse | null = null; export function setOpenCodeProviders(p) { _providers = p; }` 放在 `opencode_cell_actions.ts`(`setOpenCodeServerSettings` 同文件),`index.ts` 在 `callOpenCodeProviders(...).then(...)` 里调。
|
||||
- 工具栏 item 名 `opencode-cell-actions` / rank 100 不变。
|
||||
- 不发 `mode` 字段(v2 约束保持)。
|
||||
- 代码风格 / 单测 / CSS 约束同前。
|
||||
|
||||
---
|
||||
|
||||
### Task 1: 4 字段移出 settings(server config + types + schema)
|
||||
|
||||
**Files:**
|
||||
- Modify: `schema/plugin.json`
|
||||
- Modify: `src/types.ts`
|
||||
- Modify: `opencode_bridge/config.py`
|
||||
- (可能)Modify: `src/__tests__/opencode_client.spec.ts`(若它引用了被删的 settings 字段)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: 无新依赖。
|
||||
- Produces: `OpenCodeSettings = { opencodeProvider: string; opencodeModel: string }`;`resolve_config({})` 在 4 个 env 变量未设时返回全默认;`schema/plugin.json` `properties` 只剩 `opencodeProvider`/`opencodeModel`。
|
||||
|
||||
- [ ] **Step 1: 改 `src/types.ts`**
|
||||
|
||||
把当前 `OpenCodeSettings` / `DEFAULT_OPENCODE_SETTINGS` / `readOpenCodeSettings` 改为:
|
||||
|
||||
```ts
|
||||
/** Frontend view of the 2 remaining schema/plugin.json fields. */
|
||||
export interface OpenCodeSettings {
|
||||
opencodeProvider: string;
|
||||
opencodeModel: string;
|
||||
}
|
||||
|
||||
export const DEFAULT_OPENCODE_SETTINGS: OpenCodeSettings = {
|
||||
opencodeProvider: '',
|
||||
opencodeModel: ''
|
||||
};
|
||||
|
||||
export function readOpenCodeSettings(composite: unknown): OpenCodeSettings {
|
||||
const c = (composite ?? {}) as Partial<OpenCodeSettings>;
|
||||
return {
|
||||
opencodeProvider: c.opencodeProvider || '',
|
||||
opencodeModel: c.opencodeModel || ''
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 改 `schema/plugin.json`**
|
||||
|
||||
从 `properties` 中**删除** 4 个:
|
||||
- `opencodeServerUrl`
|
||||
- `opencodeServerUser`
|
||||
- `opencodeServerPassword`
|
||||
- `requestTimeoutSeconds`
|
||||
|
||||
保留 `opencodeProvider` / `opencodeModel`。可更新它们的 `description` 注明 "Used as default selection in the inline model picker"。
|
||||
|
||||
- [ ] **Step 3: 改 `opencode_bridge/config.py`**
|
||||
|
||||
完整新内容:
|
||||
|
||||
```python
|
||||
"""Configuration resolution for the opencode-bridge extension.
|
||||
|
||||
All connection fields (URL / user / password / request timeout) are resolved
|
||||
exclusively from environment variables + built-in defaults at server startup.
|
||||
They are NOT stored in the JupyterLab plugin settings.
|
||||
|
||||
Env vars:
|
||||
OPENCODE_BRIDGE_URL — OpenCode Serve base URL (default http://127.0.0.1:4096)
|
||||
OPENCODE_BRIDGE_USER — HTTP Basic Auth username (default 'opencode')
|
||||
OPENCODE_BRIDGE_PASSWORD — HTTP Basic Auth password (default '' = no auth)
|
||||
OPENCODE_BRIDGE_TIMEOUT — request timeout in seconds (default 120)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import NamedTuple, Optional, Tuple
|
||||
|
||||
|
||||
ENV_URL = "OPENCODE_BRIDGE_URL"
|
||||
ENV_USER = "OPENCODE_BRIDGE_USER"
|
||||
ENV_PASSWORD = "OPENCODE_BRIDGE_PASSWORD"
|
||||
ENV_TIMEOUT = "OPENCODE_BRIDGE_TIMEOUT"
|
||||
|
||||
DEFAULT_URL = "http://127.0.0.1:4096"
|
||||
DEFAULT_USER = "opencode"
|
||||
DEFAULT_REQUEST_TIMEOUT = 120
|
||||
|
||||
|
||||
class OpenCodeConfig(NamedTuple):
|
||||
url: str
|
||||
user: str
|
||||
password: str
|
||||
request_timeout_seconds: int
|
||||
|
||||
@property
|
||||
def auth(self) -> Optional[Tuple[str, str]]:
|
||||
"""Return (user, password) for HTTP Basic Auth, or None if no password set."""
|
||||
if not self.password:
|
||||
return None
|
||||
return (self.user, self.password)
|
||||
|
||||
|
||||
def resolve_config(jupyter_settings: dict) -> OpenCodeConfig:
|
||||
"""Resolve OpenCode connection config from environment variables + defaults.
|
||||
|
||||
The `jupyter_settings` argument is accepted for API compatibility but no
|
||||
fields are read from it — all connection config is now startup-only
|
||||
(environment variables).
|
||||
"""
|
||||
_ = jupyter_settings # intentionally unused; see module docstring
|
||||
return OpenCodeConfig(
|
||||
url=os.environ.get(ENV_URL) or DEFAULT_URL,
|
||||
user=os.environ.get(ENV_USER) or DEFAULT_USER,
|
||||
password=os.environ.get(ENV_PASSWORD) or "",
|
||||
request_timeout_seconds=int(
|
||||
os.environ.get(ENV_TIMEOUT) or DEFAULT_REQUEST_TIMEOUT
|
||||
)
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 检查 `opencode_client.spec.ts`**
|
||||
|
||||
`grep -n "opencodeServerUrl\|opencodeServerUser\|opencodeServerPassword\|requestTimeoutSeconds" src/__tests__/opencode_client.spec.ts` —— 若有引用,删除。`opencode_bridge.spec.ts` 的 `readOpenCodeSettings` 测试只覆盖 provider/model,不受影响。
|
||||
|
||||
- [ ] **Step 5: 跑测试 + build**
|
||||
|
||||
```bash
|
||||
.venv/bin/python -m pytest opencode_bridge/tests/ -q
|
||||
YARN_NODE_LINKER=node-modules PATH="$PWD/.venv/bin:$PATH" .venv/bin/jlpm test
|
||||
YARN_NODE_LINKER=node-modules PATH="$PWD/.venv/bin:$PATH" .venv/bin/jlpm build
|
||||
```
|
||||
Expected: pytest 全过(32 passed);jest 全过(25+);build 成功。
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add schema/plugin.json src/types.ts opencode_bridge/config.py src/__tests__/opencode_client.spec.ts
|
||||
git commit -m "refactor: move server connection config to startup env vars
|
||||
|
||||
URL, user, password, and request timeout are no longer in the JupyterLab
|
||||
plugin settings — they are resolved exclusively from environment variables
|
||||
(OPENCODE_BRIDGE_URL/USER/PASSWORD/TIMEOUT) at jupyter server startup,
|
||||
with built-in defaults. The Settings Editor now only carries
|
||||
opencodeProvider/opencodeModel (the defaults for the new inline model
|
||||
picker, added in the next commit)."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: 模型选择器(TDD)
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/components/opencode_cell_actions.ts`(加 providers 缓存 + 注入)
|
||||
- Modify: `src/components/opencode_inline_prompt.ts`(加 select + onSubmit 带 provider/model)
|
||||
- Modify: `src/index.ts`(`callOpenCodeProviders` 成功后调 `setOpenCodeProviders`)
|
||||
- Modify: `style/base.css`(select 样式)
|
||||
- Modify: `src/__tests__/opencode_cell_actions.spec.ts`(补 select 相关断言)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `OpenCodeProvidersResponse`(从 `../types`);`OpenCodeSettings` 缩为 2 字段。
|
||||
- Produces: `setOpenCodeProviders(p: OpenCodeProvidersResponse | null): void`(在 `opencode_cell_actions.ts`);`OpenCodeInlinePrompt` 构造 options 加 `providers`/`defaultProviderId?`/`defaultModelId?`,`onSubmit` 签名变 `(text: string, providerId?: string, modelId?: string) => void`;`OpenCodeCellActions` 用 prompt 回调里的 provider/model 覆盖 `OpenCodeRequest.providerId`/`modelId`。
|
||||
|
||||
- [ ] **Step 1: 改 `opencode_cell_actions.ts` —— 扩导出与新签名**
|
||||
|
||||
在模块顶部 `setOpenCodeRuntime` 后加:
|
||||
|
||||
```ts
|
||||
let _providers: OpenCodeProvidersResponse | null = null;
|
||||
|
||||
export function setOpenCodeProviders(
|
||||
p: OpenCodeProvidersResponse | null
|
||||
): void {
|
||||
_providers = p;
|
||||
}
|
||||
```
|
||||
|
||||
在 `opencode_cell_actions.ts` 顶部 import:
|
||||
```ts
|
||||
import type { OpenCodeProvidersResponse } from '../types';
|
||||
```
|
||||
|
||||
把 `_showPrompt` 改为:
|
||||
|
||||
```ts
|
||||
private _showPrompt(): void {
|
||||
if (this._prompt) {
|
||||
return;
|
||||
}
|
||||
const defaultProviderId = _settings?.opencodeProvider || undefined;
|
||||
const defaultModelId = _settings?.opencodeModel || undefined;
|
||||
const prompt = new OpenCodeInlinePrompt(this._cell, {
|
||||
disabled: !this._context || this._status === 'loading',
|
||||
providers: _providers,
|
||||
defaultProviderId,
|
||||
defaultModelId,
|
||||
onSubmit: (text: string, providerId?: string, modelId?: string) => {
|
||||
void this._onSubmit(text, providerId, modelId);
|
||||
},
|
||||
onCancel: () => {
|
||||
this._hidePrompt();
|
||||
}
|
||||
});
|
||||
Widget.attach(prompt, this._cell.node);
|
||||
this._prompt = prompt;
|
||||
}
|
||||
```
|
||||
|
||||
把 `_onSubmit` 改为(签名加 provider/model 入参,用它们覆盖 settings):
|
||||
|
||||
```ts
|
||||
private async _onSubmit(
|
||||
text: string,
|
||||
providerIdOverride?: string,
|
||||
modelIdOverride?: string
|
||||
): Promise<void> {
|
||||
if (!this._context) {
|
||||
return;
|
||||
}
|
||||
if (!_settings || !_serverSettings) {
|
||||
Notification.error('OpenCode 运行时未初始化,请检查 settings');
|
||||
return;
|
||||
}
|
||||
|
||||
// Per-request selection from the inline picker takes precedence over
|
||||
// settings defaults; empty strings from the picker are treated as
|
||||
// "use whatever the server default is" (not sent).
|
||||
const providerId =
|
||||
providerIdOverride || _settings.opencodeProvider || undefined;
|
||||
const modelId = modelIdOverride || _settings.opencodeModel || undefined;
|
||||
|
||||
const request: OpenCodeRequest = {
|
||||
prompt: text,
|
||||
context: this._context,
|
||||
providerId,
|
||||
modelId
|
||||
};
|
||||
// ...rest unchanged (status, callOpenCodeEdit, error handling)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 改 `opencode_inline_prompt.ts` —— 加 select + 新 onSubmit 签名**
|
||||
|
||||
完整新内容:
|
||||
|
||||
```ts
|
||||
/**
|
||||
* Inline prompt widget attached to a cell's DOM when the AI button is clicked.
|
||||
* Renders a model selector, a textarea, and send/cancel buttons. The owning
|
||||
* cell is passed so we can resolve the cell context at submit time. submit
|
||||
* and cancel are callbacks owned by OpenCodeCellActions.
|
||||
*/
|
||||
import { CodeCell } from '@jupyterlab/cells';
|
||||
import { Widget } from '@lumino/widgets';
|
||||
|
||||
import type { OpenCodeProvidersResponse } from '../types';
|
||||
|
||||
export interface IOpenCodeInlinePromptOptions {
|
||||
onSubmit: (text: string, providerId?: string, modelId?: string) => void;
|
||||
onCancel: () => void;
|
||||
disabled: boolean;
|
||||
providers: OpenCodeProvidersResponse | null;
|
||||
defaultProviderId?: string;
|
||||
defaultModelId?: string;
|
||||
}
|
||||
|
||||
export class OpenCodeInlinePrompt extends Widget {
|
||||
private _textarea: HTMLTextAreaElement;
|
||||
private _sendBtn: HTMLButtonElement;
|
||||
private _cancelBtn: HTMLButtonElement;
|
||||
private _select: HTMLSelectElement | null = null;
|
||||
|
||||
constructor(
|
||||
private _cell: CodeCell,
|
||||
options: IOpenCodeInlinePromptOptions
|
||||
) {
|
||||
super();
|
||||
this.addClass('opencode-inline-prompt');
|
||||
|
||||
// Model selector — only rendered if providers is a non-empty list.
|
||||
const flat = flattenProviders(options.providers);
|
||||
if (flat.length > 0) {
|
||||
this._select = document.createElement('select');
|
||||
this._select.className = 'opencode-model-select';
|
||||
for (const opt of flat) {
|
||||
const o = document.createElement('option');
|
||||
o.value = `${opt.providerId}|${opt.modelId}`;
|
||||
o.textContent = `${opt.providerId} / ${opt.modelId}`;
|
||||
this._select.appendChild(o);
|
||||
}
|
||||
// Default selection: match settings, otherwise first option.
|
||||
const desired = `${options.defaultProviderId || ''}|${
|
||||
options.defaultModelId || ''
|
||||
}`;
|
||||
const hasDefault = Array.from(this._select.options).some(
|
||||
o => o.value === desired
|
||||
);
|
||||
this._select.value = hasDefault
|
||||
? desired
|
||||
: this._select.options[0].value;
|
||||
}
|
||||
|
||||
this._textarea = document.createElement('textarea');
|
||||
this._textarea.placeholder = '向 AI 描述你想要的修改…';
|
||||
this._textarea.rows = 3;
|
||||
this._sendBtn = document.createElement('button');
|
||||
this._sendBtn.className = 'opencode-btn-send';
|
||||
this._sendBtn.textContent = '🚀 发送';
|
||||
this._sendBtn.disabled = options.disabled;
|
||||
this._sendBtn.addEventListener('click', () => {
|
||||
const text = this._textarea.value;
|
||||
if (!text.trim()) {
|
||||
return;
|
||||
}
|
||||
const [providerId, modelId] = this._readSelection();
|
||||
options.onSubmit(text, providerId, modelId);
|
||||
});
|
||||
this._cancelBtn = document.createElement('button');
|
||||
this._cancelBtn.className = 'opencode-btn-cancel';
|
||||
this._cancelBtn.textContent = '✕ 取消';
|
||||
this._cancelBtn.addEventListener('click', () => {
|
||||
options.onCancel();
|
||||
});
|
||||
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'opencode-inline-actions';
|
||||
actions.appendChild(this._sendBtn);
|
||||
actions.appendChild(this._cancelBtn);
|
||||
|
||||
if (this._select) {
|
||||
const label = document.createElement('label');
|
||||
label.className = 'opencode-model-label';
|
||||
const span = document.createElement('span');
|
||||
span.textContent = '模型:';
|
||||
label.appendChild(span);
|
||||
label.appendChild(this._select);
|
||||
this.node.appendChild(label);
|
||||
}
|
||||
this.node.appendChild(this._textarea);
|
||||
this.node.appendChild(actions);
|
||||
}
|
||||
|
||||
setDisabled(disabled: boolean): void {
|
||||
this._sendBtn.disabled = disabled;
|
||||
if (this._select) {
|
||||
this._select.disabled = disabled;
|
||||
}
|
||||
}
|
||||
|
||||
private _readSelection(): [string | undefined, string | undefined] {
|
||||
if (!this._select) {
|
||||
return [undefined, undefined];
|
||||
}
|
||||
const v = this._select.value;
|
||||
const idx = v.indexOf('|');
|
||||
if (idx < 0) {
|
||||
return [undefined, undefined];
|
||||
}
|
||||
return [v.slice(0, idx), v.slice(idx + 1)];
|
||||
}
|
||||
}
|
||||
|
||||
function flattenProviders(
|
||||
providers: OpenCodeProvidersResponse | null
|
||||
): { providerId: string; modelId: string }[] {
|
||||
if (!providers || !providers.providers) {
|
||||
return [];
|
||||
}
|
||||
const out: { providerId: string; modelId: string }[] = [];
|
||||
for (const p of providers.providers) {
|
||||
for (const m of p.models) {
|
||||
out.push({ providerId: p.id, modelId: m.id });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 改 `src/index.ts` —— fetch 成功后写缓存**
|
||||
|
||||
在 `callOpenCodeProviders(...).then(data => { ... console.log(...) }).catch(...)` 的 `.then` 回调里,**在打印日志之前**加:
|
||||
|
||||
```ts
|
||||
.then(data => {
|
||||
setOpenCodeProviders(data);
|
||||
const lines: string[] = ['[opencode_bridge] Available OpenCode providers:'];
|
||||
// ...rest unchanged
|
||||
})
|
||||
```
|
||||
|
||||
import 同步加 `import { setOpenCodeProviders } from './components/opencode_cell_actions';`(在已有的 import 行加)。
|
||||
|
||||
- [ ] **Step 4: 改 `style/base.css` —— 加 select 样式**
|
||||
|
||||
在 `.opencode-inline-prompt textarea` 之前加:
|
||||
|
||||
```css
|
||||
.opencode-inline-prompt .opencode-model-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: var(--jp-ui-font-size1);
|
||||
color: var(--jp-ui-font-color1, #333);
|
||||
}
|
||||
|
||||
.opencode-inline-prompt .opencode-model-select {
|
||||
flex: 1;
|
||||
padding: 2px 4px;
|
||||
font-size: var(--jp-ui-font-size1);
|
||||
font-family: inherit;
|
||||
border: 1px solid var(--jp-border-color2, #ccc);
|
||||
border-radius: 3px;
|
||||
background: var(--jp-layout-color1, #fff);
|
||||
}
|
||||
|
||||
.opencode-inline-prompt .opencode-model-select:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 补单测 —— `opencode_cell_actions.spec.ts`**
|
||||
|
||||
在 `OpenCodeCellActions` 的现有 `it('clicking the button attaches an OpenCodeInlinePrompt to the cell', ...)` 之后,新增以下测试(在同一个 `describe` 内):
|
||||
|
||||
```ts
|
||||
it('renders a model selector when providers are cached', () => {
|
||||
setOpenCodeProviders({
|
||||
providers: [
|
||||
{ id: 'anthropic', models: [{ id: 'claude-sonnet-4-20250514' }] },
|
||||
{ id: 'openai', models: [{ id: 'gpt-5' }] }
|
||||
]
|
||||
});
|
||||
const cell = makeFakeCell('x = 1');
|
||||
const actions = new OpenCodeCellActions(cell);
|
||||
Object.defineProperty(actions, 'parent', { value: cell, configurable: true });
|
||||
(actions as any).onAfterAttach({} as any);
|
||||
|
||||
const btn = actions.node.querySelector('button') as HTMLButtonElement;
|
||||
btn.click();
|
||||
|
||||
const select = cell.node.querySelector(
|
||||
'.opencode-inline-prompt .opencode-model-select'
|
||||
) as HTMLSelectElement;
|
||||
expect(select).not.toBeNull();
|
||||
expect(select.options.length).toBe(2);
|
||||
expect(select.options[0].textContent).toBe('anthropic / claude-sonnet-4-20250514');
|
||||
expect(select.options[1].textContent).toBe('openai / gpt-5');
|
||||
// Default = first option when no settings match.
|
||||
expect(select.value).toBe('anthropic|claude-sonnet-4-20250514');
|
||||
});
|
||||
|
||||
it('selects the settings default provider/model when it matches an option', () => {
|
||||
setOpenCodeProviders({
|
||||
providers: [
|
||||
{ id: 'anthropic', models: [{ id: 'claude-sonnet-4-20250514' }] },
|
||||
{ id: 'openai', models: [{ id: 'gpt-5' }] }
|
||||
]
|
||||
});
|
||||
setOpenCodeRuntime({
|
||||
settings: {
|
||||
opencodeProvider: 'openai',
|
||||
opencodeModel: 'gpt-5'
|
||||
} as any,
|
||||
serverSettings: {} as any
|
||||
});
|
||||
const cell = makeFakeCell('x = 1');
|
||||
const actions = new OpenCodeCellActions(cell);
|
||||
Object.defineProperty(actions, 'parent', { value: cell, configurable: true });
|
||||
(actions as any).onAfterAttach({} as any);
|
||||
|
||||
const btn = actions.node.querySelector('button') as HTMLButtonElement;
|
||||
btn.click();
|
||||
|
||||
const select = cell.node.querySelector(
|
||||
'.opencode-inline-prompt .opencode-model-select'
|
||||
) as HTMLSelectElement;
|
||||
expect(select.value).toBe('openai|gpt-5');
|
||||
});
|
||||
|
||||
it('does not render the selector when providers are not cached', () => {
|
||||
setOpenCodeProviders(null);
|
||||
setOpenCodeRuntime({ settings: {} as any, serverSettings: {} as any });
|
||||
const cell = makeFakeCell('x = 1');
|
||||
const actions = new OpenCodeCellActions(cell);
|
||||
Object.defineProperty(actions, 'parent', { value: cell, configurable: true });
|
||||
(actions as any).onAfterAttach({} as any);
|
||||
|
||||
const btn = actions.node.querySelector('button') as HTMLButtonElement;
|
||||
btn.click();
|
||||
|
||||
const select = cell.node.querySelector(
|
||||
'.opencode-inline-prompt .opencode-model-select'
|
||||
);
|
||||
expect(select).toBeNull();
|
||||
});
|
||||
```
|
||||
|
||||
并在 `describe` 顶部 import 加上:
|
||||
|
||||
```ts
|
||||
import {
|
||||
OpenCodeCellActions,
|
||||
setOpenCodeProviders,
|
||||
setOpenCodeRuntime
|
||||
} from '../components/opencode_cell_actions';
|
||||
```
|
||||
|
||||
每个测试**结束后**调用 `setOpenCodeProviders(null)` 重置,避免污染其他测试。建议在每个测试的 `expect(...)` 之后加 `setOpenCodeProviders(null);` 一行清理。
|
||||
|
||||
- [ ] **Step 6: 跑测试 + build**
|
||||
|
||||
```bash
|
||||
YARN_NODE_LINKER=node-modules PATH="$PWD/.venv/bin:$PATH" .venv/bin/jlpm test
|
||||
YARN_NODE_LINKER=node-modules PATH="$PWD/.venv/bin:$PATH" .venv/bin/jlpm build
|
||||
```
|
||||
Expected: jest 28+ passed(原 25 + 3 新增);build 成功。
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add src/components/opencode_cell_actions.ts src/components/opencode_inline_prompt.ts src/index.ts style/base.css src/__tests__/opencode_cell_actions.spec.ts
|
||||
git commit -m "feat: model selector in inline prompt
|
||||
|
||||
The inline prompt panel now renders a <select> at the top, listing every
|
||||
(provider, model) pair returned by GET /opencode-bridge/providers at
|
||||
plugin activation. The default selection matches opencodeProvider /
|
||||
opencodeModel from JupyterLab settings; the user can pick a different
|
||||
model per request. The chosen value is sent as providerId/modelId in
|
||||
the OpenCodeRequest, overriding the settings default for that call. If
|
||||
the providers fetch failed at startup (e.g. opencode serve not running),
|
||||
no selector is rendered and submissions fall back to the settings
|
||||
defaults (or omit providerId/modelId so OpenCode picks its own default)."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: design.md 同步(CC)
|
||||
|
||||
**Files:**
|
||||
- Modify: `design.md` §2(给 inline 输入框段补一行模型选择器描述);新增 §6 "启动配置"(env 变量清单)。
|
||||
|
||||
- [ ] **Step 1: 改 `design.md` §2 item 2 的描述**,在第 2 段里"多行 `textarea`(placeholder ...)"前插一句:
|
||||
> 顶部一行 `模型: <select>`(来自 `GET /opencode-bridge/providers` 启动时缓存),默认选中 `opencodeProvider`/`opencodeModel` settings 对应项,用户可改;提交时把选中的 `providerId`/`modelId` 一起带回,覆盖 settings 默认。
|
||||
|
||||
- [ ] **Step 2: 新增 §6 "启动配置"**(放在现有 §5 之后,或合并进 §5)
|
||||
|
||||
```markdown
|
||||
## ⚙️ 6. 启动配置(v3 修正)
|
||||
|
||||
所有 OpenCode Serve 连接参数在 `jupyter lab` 启动时通过环境变量指定,**不再**在 JupyterLab Settings Editor 中配置:
|
||||
|
||||
| 环境变量 | 默认 | 说明 |
|
||||
|---|---|---|
|
||||
| `OPENCODE_BRIDGE_URL` | `http://127.0.0.1:4096` | OpenCode Serve 地址 |
|
||||
| `OPENCODE_BRIDGE_USER` | `opencode` | HTTP Basic Auth 用户名 |
|
||||
| `OPENCODE_BRIDGE_PASSWORD` | `""`(无 auth) | HTTP Basic Auth 密码 |
|
||||
| `OPENCODE_BRIDGE_TIMEOUT` | `120` | 请求 OpenCode Serve 超时(秒) |
|
||||
|
||||
JupyterLab Settings Editor **只剩** 2 项,均用于 inline 输入框模型选择器的"默认选中":
|
||||
- `opencodeProvider`
|
||||
- `opencodeModel`
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add design.md
|
||||
git commit -m "docs: design.md v3 (startup env config + inline model selector)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: 全量验证(CC)
|
||||
|
||||
- [ ] **Step 1: 跑 pytest + jlpm test + jlpm build**
|
||||
|
||||
```bash
|
||||
.venv/bin/python -m pytest opencode_bridge/tests/ -q
|
||||
YARN_NODE_LINKER=node-modules PATH="$PWD/.venv/bin:$PATH" .venv/bin/jlpm test
|
||||
YARN_NODE_LINKER=node-modules PATH="$PWD/.venv/bin:$PATH" .venv/bin/jlpm build
|
||||
```
|
||||
Expected: pytest 全过(32+);jest 全过(28+);build 成功。
|
||||
|
||||
- [ ] **Step 2: 报告 v3 三个 commit + 验证结果**
|
||||
|
||||
---
|
||||
|
||||
### Task 5: 手动验收(用户执行)
|
||||
|
||||
`export OPENCODE_BRIDGE_URL=http://127.0.0.1:4096 && jupyter lab`(或带 User/Password/Timeout),打开 .ipynb:
|
||||
- active cell 右上角单按钮 → inline 面板顶部出现 `模型: [select]`(列出 OpenCode Serve 的 provider/model)
|
||||
- 默认选中 `opencodeProvider`/`opencodeModel` settings 值
|
||||
- 切换选择 → 发送 → 这次请求的 `providerId`/`modelId` 是选中的
|
||||
- 在 Settings Editor 看 `opencodeServerUrl` 等 4 个字段已经没有了
|
||||
- 启动时 export 一个错误的 URL / 关掉 OpenCode Serve → 下拉框不出现,提交走 settings 兜底
|
||||
@@ -0,0 +1,663 @@
|
||||
# AI 按钮迁入原生 Cell Toolbar — 实现计划
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 把 `✨ 优化` / `🐛 排错` / `🪄 编辑` 三个按钮从 cell 底部 footer 迁移到 JupyterLab 原生 Cell toolbar(active cell 右上角、上移/下移按钮旁),并拆除整个 footer 链路。
|
||||
|
||||
**Architecture:** 新建 `OpenCodeCellActions` widget(构造函数直接接收 `CodeCell`),通过 `IToolbarWidgetRegistry.addFactory('Cell', 'opencode-cell-actions', factory)` 注册进原生 settings 驱动的 Cell toolbar;`schema/plugin.json` 以 `jupyter.lab.toolbars` 贡献该项。已核实机制:`createToolbarFactory` 把 cell 透传给 item factory(apputils 4.5.10 `lib/toolbar/factory.js:218`);`addFactory` 签名见 `lib/toolbar/registry.d.ts:36`。
|
||||
|
||||
**Tech Stack:** TypeScript / JupyterLab 4.5.10(npm deps)/ JupyterLab 4.6.2(runtime)/ jest / stylelint / prettier
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-07-23-cell-toolbar-actions-design.md`(已提交)
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- toolbar item 名固定为 `opencode-cell-actions`,index.ts 注册名与 schema 贡献名必须逐字一致。
|
||||
- 仅 `CodeCell` 显示按钮;非 CodeCell 返回空 `Widget`。
|
||||
- 交互不变:`🪄 编辑` 用 `window.prompt`;结果/错误统一走 `Notification`;widget 内不渲染 error span。
|
||||
- 无新 npm 依赖;不改 REST 契约;不改 Python 端。
|
||||
- 代码风格:prettier(single quotes、无 trailing commas、无 arrow parens);CSS 类名 kebab-case(stylelint)。
|
||||
- `setOpenCodeRuntime({ settings, serverSettings })` 签名不变,仅从旧文件搬到新文件。
|
||||
|
||||
---
|
||||
|
||||
### Task 1: OpenCodeCellActions 组件(TDD)
|
||||
|
||||
**Files:**
|
||||
|
||||
- Test: `src/__tests__/opencode_cell_actions.spec.ts`(新建)
|
||||
- Create: `src/components/opencode_cell_actions.ts`(新建)
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: `callOpenCodeEdit(request, serverSettings)`(来自 `../api/opencode_client`,不变);`extractCellContext(cell, notebookPanel)`(来自 `../context/cell_context`,不变);`../types` 全部类型(不变)。
|
||||
- Produces: `class OpenCodeCellActions extends Widget`,构造函数 `constructor(cell: CodeCell)`;`setOpenCodeRuntime(args: { settings: OpenCodeSettings; serverSettings: ServerConnection.ISettings }): void`;CSS 类 `opencode-cell-actions` / `opencode-btn opencode-btn-{optimize|fix|edit}`。Task 2 的 index.ts 依赖这两个导出。
|
||||
|
||||
- [ ] **Step 1: 写失败测试** — 新建 `src/__tests__/opencode_cell_actions.spec.ts`
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* Unit tests for OpenCodeCellActions DOM rendering and button disabled states.
|
||||
*
|
||||
* Mocks @jupyterlab/cells, @jupyterlab/apputils, and @lumino/widgets at the
|
||||
* test boundary so the real ESM packages are not loaded (Jest + pnpm can't
|
||||
* transform the hoisted @jupyterlab/* packages out of the box).
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
return { Widget };
|
||||
});
|
||||
|
||||
import { OpenCodeCellActions } from '../components/opencode_cell_actions';
|
||||
import { CodeCell } from '@jupyterlab/cells';
|
||||
|
||||
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
|
||||
},
|
||||
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;
|
||||
|
||||
// Parent chain: cell -> notebook -> panel (mirrors real JupyterLab, where
|
||||
// the cell's grandparent Notebook matches the duck-typed panel lookup).
|
||||
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 3 buttons with the expected labels', () => {
|
||||
const cell = makeFakeCell('x = 1');
|
||||
const actions = new OpenCodeCellActions(cell);
|
||||
const btns = actions.node.querySelectorAll('button');
|
||||
expect(btns.length).toBe(3);
|
||||
expect(btns[0].textContent).toBe('✨ 优化');
|
||||
expect(btns[1].textContent).toBe('🐛 排错');
|
||||
expect(btns[2].textContent).toBe('🪄 编辑');
|
||||
});
|
||||
|
||||
it('disables all buttons when the 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' },
|
||||
outputs: makeFakeOutputs([]),
|
||||
contentChanged: { connect: jest.fn(), disconnect: jest.fn() },
|
||||
stateChanged: { connect: jest.fn(), disconnect: jest.fn() }
|
||||
};
|
||||
// No parent chain: extractCellContextFromCell must return null.
|
||||
const actions = new OpenCodeCellActions(cell);
|
||||
const btns = actions.node.querySelectorAll('button');
|
||||
btns.forEach((b: HTMLButtonElement) => {
|
||||
expect(b.disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('enables optimize and edit; fix stays disabled without an error', () => {
|
||||
const cell = makeFakeCell('x = 1', [], 'foo.ipynb', 0, 1);
|
||||
const actions = new OpenCodeCellActions(cell);
|
||||
const btns = actions.node.querySelectorAll('button');
|
||||
expect((btns[0] as HTMLButtonElement).disabled).toBe(false);
|
||||
expect((btns[1] as HTMLButtonElement).disabled).toBe(true);
|
||||
expect((btns[2] as HTMLButtonElement).disabled).toBe(false);
|
||||
});
|
||||
|
||||
it('enables the fix button when the cell has an error output', () => {
|
||||
const cell = makeFakeCell(
|
||||
'1/0',
|
||||
[
|
||||
{
|
||||
type: 'error',
|
||||
ename: 'ZeroDivisionError',
|
||||
evalue: 'div by zero',
|
||||
traceback: []
|
||||
}
|
||||
],
|
||||
'foo.ipynb',
|
||||
0,
|
||||
1
|
||||
);
|
||||
const actions = new OpenCodeCellActions(cell);
|
||||
const btns = actions.node.querySelectorAll('button');
|
||||
expect((btns[1] as HTMLButtonElement).disabled).toBe(false);
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 跑测试确认失败**
|
||||
|
||||
Run: `jlpm test opencode_cell_actions`
|
||||
Expected: FAIL — `Cannot find module '../components/opencode_cell_actions'`
|
||||
|
||||
- [ ] **Step 3: 实现组件** — 新建 `src/components/opencode_cell_actions.ts`
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* Per-cell actions widget rendered inside JupyterLab's native Cell toolbar
|
||||
* (top-right of the active cell, next to the move up/down buttons).
|
||||
*
|
||||
* Registered in index.ts via IToolbarWidgetRegistry.addFactory('Cell', ...);
|
||||
* the toolbar factory passes the owning Cell, so the cell arrives via the
|
||||
* constructor instead of being resolved from `this.parent` on attach.
|
||||
*/
|
||||
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,
|
||||
OpenCodeMode,
|
||||
OpenCodeRequest,
|
||||
OpenCodeResponse,
|
||||
OpenCodeSettings
|
||||
} from '../types';
|
||||
|
||||
// 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';
|
||||
|
||||
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._onModelChange();
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
if (this.isDisposed) {
|
||||
return;
|
||||
}
|
||||
this._cell.model.contentChanged.disconnect(this._onModelChange, this);
|
||||
this._cell.model.stateChanged.disconnect(this._onModelChange, this);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
private _onModelChange(): void {
|
||||
this._context = extractCellContextFromCell(this._cell);
|
||||
this._render();
|
||||
}
|
||||
|
||||
private _render(): void {
|
||||
const node = this.node;
|
||||
node.textContent = '';
|
||||
|
||||
const hasError = this._context?.error != null;
|
||||
const loading = this._status === 'loading';
|
||||
const baseDisabled = !this._context || loading;
|
||||
|
||||
const mkBtn = (
|
||||
label: string,
|
||||
mode: OpenCodeMode,
|
||||
disabled: boolean
|
||||
): HTMLButtonElement => {
|
||||
const b = document.createElement('button');
|
||||
b.className = `opencode-btn opencode-btn-${mode}`;
|
||||
b.textContent = label;
|
||||
b.disabled = disabled;
|
||||
b.title = disabled
|
||||
? loading
|
||||
? 'OpenCode: 请求中…'
|
||||
: 'OpenCode: 等待 cell 上下文…'
|
||||
: label;
|
||||
b.addEventListener('click', () => {
|
||||
void this._onClick(mode);
|
||||
});
|
||||
return b;
|
||||
};
|
||||
|
||||
node.appendChild(mkBtn('✨ 优化', 'optimize', baseDisabled));
|
||||
node.appendChild(mkBtn('🐛 排错', 'fix', baseDisabled || !hasError));
|
||||
node.appendChild(mkBtn('🪄 编辑', 'edit', baseDisabled));
|
||||
}
|
||||
|
||||
private async _onClick(mode: OpenCodeMode): Promise<void> {
|
||||
if (!this._context) {
|
||||
return;
|
||||
}
|
||||
if (!_settings || !_serverSettings) {
|
||||
Notification.error('OpenCode 运行时未初始化,请检查 settings');
|
||||
return;
|
||||
}
|
||||
|
||||
let prompt = '';
|
||||
if (mode === 'edit') {
|
||||
const input = window.prompt('请输入编辑指令:');
|
||||
if (input === null) {
|
||||
return;
|
||||
}
|
||||
prompt = input;
|
||||
}
|
||||
|
||||
const request: OpenCodeRequest = {
|
||||
mode,
|
||||
prompt,
|
||||
context: this._context,
|
||||
providerId: _settings.opencodeProvider || undefined,
|
||||
modelId: _settings.opencodeModel || undefined
|
||||
};
|
||||
|
||||
this._status = 'loading';
|
||||
this._render();
|
||||
|
||||
try {
|
||||
const resp = await callOpenCodeEdit(request, _serverSettings);
|
||||
this._handleResponse(resp);
|
||||
} catch (e) {
|
||||
this._status = 'idle';
|
||||
this._render();
|
||||
Notification.error(`OpenCode ${mode} 失败: ${(e as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
private _handleResponse(resp: OpenCodeResponse): void {
|
||||
this._status = 'idle';
|
||||
this._render();
|
||||
if (resp.ok) {
|
||||
const len = resp.finalSource.length;
|
||||
Notification.info(
|
||||
`OpenCode ${resp.mode} 完成 (${len} chars). ` +
|
||||
`Diff 面板将在 Slice 4 中提供。Session: ${resp.sessionId.slice(0, 8)}…`
|
||||
);
|
||||
} else {
|
||||
Notification.error(`OpenCode 错误: ${resp.error}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a CodeCell's parent NotebookPanel by walking the parent chain,
|
||||
* then build the CellContext. (Unchanged from the old footer helper; the
|
||||
* `Widget.findParent` helper was removed in @lumino/widgets 2.x.)
|
||||
*/
|
||||
function extractCellContextFromCell(cell: CodeCell): CellContext | null {
|
||||
let node: Widget | null = cell.parent;
|
||||
let notebookPanel: NotebookPanel | null = null;
|
||||
while (node) {
|
||||
// Use duck-typing: a notebook panel has `context.path` and `content.widgets`.
|
||||
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 4: 跑测试确认通过**
|
||||
|
||||
Run: `jlpm test opencode_cell_actions`
|
||||
Expected: PASS — 5 tests passed
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/components/opencode_cell_actions.ts src/__tests__/opencode_cell_actions.spec.ts
|
||||
git commit -m "feat: add OpenCodeCellActions widget for the native Cell toolbar"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: 注册进原生 Cell toolbar,拆除 footer 链路
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/index.ts`(整体重写,下方给完整内容)
|
||||
- Delete: `src/components/opencode_cell_footer.ts`、`src/components/opencode_cell_factory.ts`、`src/components/opencode_installer.ts`、`src/__tests__/opencode_cell_footer.spec.ts`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: Task 1 的 `OpenCodeCellActions` / `setOpenCodeRuntime`;`IToolbarWidgetRegistry.addFactory<T>(widgetFactory: string, toolbarItemName: string, factory: (main: T) => Widget)`(apputils 4.5.10)。
|
||||
- Produces: 注册名 `opencode-cell-actions`(Task 3 的 schema 必须逐字引用);插件不再依赖 `INotebookTracker`。
|
||||
|
||||
- [ ] **Step 1: 重写 `src/index.ts`** — 完整内容:
|
||||
|
||||
```typescript
|
||||
import {
|
||||
JupyterFrontEnd,
|
||||
JupyterFrontEndPlugin
|
||||
} from '@jupyterlab/application';
|
||||
|
||||
import { IToolbarWidgetRegistry } from '@jupyterlab/apputils';
|
||||
import { Cell, CodeCell } from '@jupyterlab/cells';
|
||||
import { ISettingRegistry } from '@jupyterlab/settingregistry';
|
||||
import { Widget } from '@lumino/widgets';
|
||||
|
||||
import {
|
||||
OpenCodeCellActions,
|
||||
setOpenCodeRuntime
|
||||
} from './components/opencode_cell_actions';
|
||||
import { requestAPI } from './request';
|
||||
import { readOpenCodeSettings } from './types';
|
||||
import { callOpenCodeProviders } from './api/opencode_client';
|
||||
|
||||
/**
|
||||
* Initialization data for the opencode_bridge extension.
|
||||
*/
|
||||
const plugin: JupyterFrontEndPlugin<void> = {
|
||||
id: 'opencode_bridge:plugin',
|
||||
description: 'A JupyterLab extension bridging the UI to OpenCode Serve.',
|
||||
autoStart: true,
|
||||
optional: [ISettingRegistry, IToolbarWidgetRegistry],
|
||||
activate: (
|
||||
app: JupyterFrontEnd,
|
||||
settingRegistry: ISettingRegistry | null,
|
||||
toolbarRegistry: IToolbarWidgetRegistry | null
|
||||
) => {
|
||||
console.log('JupyterLab extension opencode_bridge is activated!');
|
||||
|
||||
// Register the per-cell AI actions into the native Cell toolbar
|
||||
// (top-right of the active cell, next to move up/down). Non-code
|
||||
// cells get an empty widget so they show no AI buttons.
|
||||
if (toolbarRegistry) {
|
||||
toolbarRegistry.addFactory<Cell>(
|
||||
'Cell',
|
||||
'opencode-cell-actions',
|
||||
(cell: Cell) => {
|
||||
if (cell instanceof CodeCell) {
|
||||
return new OpenCodeCellActions(cell);
|
||||
}
|
||||
return new Widget();
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Load settings + push into the actions module.
|
||||
if (settingRegistry) {
|
||||
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);
|
||||
|
||||
void callOpenCodeProviders(app.serviceManager.serverSettings)
|
||||
.then(data => {
|
||||
const lines: string[] = [
|
||||
'[opencode_bridge] Available OpenCode providers:'
|
||||
];
|
||||
for (const p of data.providers) {
|
||||
const models = p.models.map(m => m.id).join(', ');
|
||||
lines.push(` - ${p.id}: ${models || '(no models)'}`);
|
||||
}
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(lines.join('\n'));
|
||||
})
|
||||
.catch(reason => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
'[opencode_bridge] Could not fetch providers (is the opencode-bridge server extension enabled and opencode serve running?):',
|
||||
reason
|
||||
);
|
||||
});
|
||||
})
|
||||
.catch(reason => {
|
||||
console.error('Failed to load settings for opencode_bridge.', reason);
|
||||
});
|
||||
}
|
||||
|
||||
requestAPI<unknown>('hello', app.serviceManager.serverSettings)
|
||||
.then(data => {
|
||||
console.log('hello endpoint:', data);
|
||||
})
|
||||
.catch(reason => {
|
||||
console.error(
|
||||
`The opencode_bridge server extension appears to be missing.\n${reason}`
|
||||
);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export default plugin;
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 删除 footer 链路文件**
|
||||
|
||||
```bash
|
||||
git rm src/components/opencode_cell_footer.ts src/components/opencode_cell_factory.ts src/components/opencode_installer.ts src/__tests__/opencode_cell_footer.spec.ts
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 确认无残留引用**
|
||||
|
||||
Run: `grep -rn "opencode_cell_footer\|opencode_cell_factory\|opencode_installer\|installOpenCode" src/ schema/ style/`
|
||||
Expected: 无任何输出
|
||||
|
||||
- [ ] **Step 4: 编译 + 测试**
|
||||
|
||||
Run: `jlpm build && jlpm test`
|
||||
Expected: tsc + jupyter-builder 成功;jest 全部通过(8 个测试:cell_context 2 + opencode_bridge 1 + opencode_cell_actions 5 + opencode_client 若干,数量以实际输出为准,关键是无 footer 相关失败)
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/index.ts
|
||||
git commit -m "refactor: register AI actions in native Cell toolbar; remove cell footer chain"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: schema 贡献 + 样式
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `schema/plugin.json`(顶层加 `jupyter.lab.toolbars`)
|
||||
- Modify: `style/base.css`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: Task 2 注册的 item 名 `opencode-cell-actions`;Task 1 的 CSS 类 `opencode-cell-actions` / `opencode-btn`。
|
||||
- Produces: 无新接口。
|
||||
|
||||
- [ ] **Step 1: 修改 `schema/plugin.json`** — 在第 2 行 `"jupyter.lab.shortcuts": [],` 之后插入:
|
||||
|
||||
```json
|
||||
"jupyter.lab.toolbars": {
|
||||
"Cell": [{ "name": "opencode-cell-actions", "rank": 100 }]
|
||||
},
|
||||
```
|
||||
|
||||
修改后文件头部应为:
|
||||
|
||||
```json
|
||||
{
|
||||
"jupyter.lab.shortcuts": [],
|
||||
"jupyter.lab.toolbars": {
|
||||
"Cell": [{ "name": "opencode-cell-actions", "rank": 100 }]
|
||||
},
|
||||
"title": "opencode_bridge",
|
||||
...
|
||||
```
|
||||
|
||||
(原生默认按钮 rank 均为 50,我们排 100 → 出现在 delete-cell 右侧。)
|
||||
|
||||
- [ ] **Step 2: 写 `style/base.css`** — 在现有注释块之后追加:
|
||||
|
||||
```css
|
||||
/* AI action buttons 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 4px;
|
||||
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;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: lint + build**
|
||||
|
||||
Run: `jlpm lint:check && jlpm build`
|
||||
Expected: stylelint / prettier / eslint 全过;build 成功
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add schema/plugin.json style/base.css
|
||||
git commit -m "feat: contribute opencode-cell-actions to Cell toolbar settings + styles"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: design.md 更新 + 全量验证
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `design.md`(第 2 节"交互与UI流程设计"第 1 条)
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: 无。
|
||||
- Produces: 无。
|
||||
|
||||
- [ ] **Step 1: 更新 `design.md`** — 把第 2 节第 1 条(`1. **悬浮 Toolbar(常驻)**:在每个 CodeCell 顶部渲染微型操作栏:` 整段及其 3 个子条目)替换为:
|
||||
|
||||
```markdown
|
||||
1. **原生 Cell Toolbar 集成(2026-07-23 修正)**:不再自绘悬浮 toolbar。`✨ 优化` / `🐛 排错`(仅当 Cell 有 Error Output 时可用)/ `🪄 编辑` 三个按钮通过 `IToolbarWidgetRegistry.addFactory('Cell', 'opencode-cell-actions', ...)` 注入 JupyterLab 原生 cell toolbar,显示在 **active cell 右上角**(move up/down 按钮旁,`rank: 100` 排在 delete 右侧);仅 `CodeCell` 显示,随 active cell 切换移动。
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 全量验证**
|
||||
|
||||
Run: `jlpm test && jlpm lint:check && jlpm build`
|
||||
Expected: 全部通过
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add design.md
|
||||
git commit -m "docs: update design.md interaction flow for native Cell toolbar"
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 手动验收(用户执行,非自动化)** — `jupyter lab` 打开任一 notebook:active cell 右上角出现三按钮(在 delete 右侧);无错误 cell 上 `🐛 排错` 禁用;切换 active cell 按钮随之移动;cell 底部不再有 footer 按钮条。
|
||||
@@ -0,0 +1,330 @@
|
||||
# v4 — 修 OpenCodeClient fetch kwargs bug + SIGINT 悬挂
|
||||
|
||||
**Goal:** 修两个服务端 bug:
|
||||
1. `OpenCodeClient._request` 在传 `HTTPRequest` 时又传 `**fetch_kwargs`(auth_username/auth_password)→ tornado 报 `kwargs can't be used if request is an HTTPRequest object`。
|
||||
2. `jupyter lab` Ctrl+C 后 "received signal 2, stopping" 但进程不退出——`AsyncHTTPClient()` 单例持有持久连接,IOLoop 关不掉。
|
||||
|
||||
**Architecture:**
|
||||
- `opencode_client.py`:`_request` 把 auth 挂到 `HTTPRequest` 构造参数,`fetch(request)` 不再带 kwargs;`OpenCodeClient` 加 `close()` 关闭它持有的 `AsyncHTTPClient`。
|
||||
- `opencode_bridge/__init__.py`:模块加载时 `atexit.register(_close_opencode_http)`,在进程正常退出(含 jupyter 捕到 SIGINT 后有序退出)时关掉 tornado `AsyncHTTPClient` 单例,释放连接,IOLoop 能完成关闭。
|
||||
- 测试:`test_client.py` 补 `close()` 行为 + 验证 fetch 用的 request 上带了 auth(而不是 fetch kwargs)。
|
||||
|
||||
**Tech Stack:** Python 3.12 / tornado AsyncHTTPClient / pytest-jupyter
|
||||
|
||||
**Spec:** 即时修复,无独立 spec doc(本 plan 即规格)。
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- 仅改 `opencode_bridge/opencode_client.py`、`opencode_bridge/__init__.py`、`opencode_bridge/tests/test_client.py`(如需);`test_routes.py` 不动(除非路由测试也依赖 client 行为)。
|
||||
- `OpenCodeClient.close()` 幂等:重复调用安全;若 `_http_client` 已关闭则跳过。
|
||||
- `atexit` handler 捕获所有异常(关 client 不应阻断退出),用 `try/except Exception: pass`。
|
||||
- 不新增依赖;不破坏现有 API 签名(`health` / `list_providers` / `create_session` / `send_message_sync` / `abort` / `delete_session` / `_request` 保持)。
|
||||
- 现有 `FakeOpenCodeClient` 测试桩不动(它模拟 client,不直接用 `AsyncHTTPClient`)。
|
||||
- 不动前端;不动 schema/CSS/design.md;这是纯服务端修复。
|
||||
|
||||
---
|
||||
|
||||
### Task 1: 修 `opencode_client.py` — auth 挂到 request + `close()`
|
||||
|
||||
**Files:** `opencode_bridge/opencode_client.py`
|
||||
|
||||
完整新内容(用 `HTTPRequest` 的 `auth_username`/`auth_password` 参数,`fetch(request)` 不带 kwargs;加 `close()`):
|
||||
|
||||
```python
|
||||
"""Async HTTP client for the local OpenCode server."""
|
||||
|
||||
import atexit
|
||||
import json
|
||||
from typing import Any, Optional
|
||||
|
||||
from tornado.httpclient import AsyncHTTPClient, HTTPRequest
|
||||
from tornado.httputil import HTTPHeaders
|
||||
|
||||
from .config import OpenCodeConfig
|
||||
|
||||
|
||||
class OpenCodeError(Exception):
|
||||
"""Raised when an OpenCode API request fails."""
|
||||
|
||||
|
||||
# Ensure the tornado AsyncHTTPClient singleton is closed at interpreter
|
||||
# shutdown so the IOLoop can finish and the process can exit cleanly
|
||||
# (otherwise Ctrl+C on `jupyter lab` hangs after "received signal 2,
|
||||
# stopping" because the singleton keeps persistent connections alive).
|
||||
def _close_opencode_http() -> None:
|
||||
try:
|
||||
AsyncHTTPClient().close()
|
||||
except Exception:
|
||||
# Never block interpreter shutdown on close errors.
|
||||
pass
|
||||
|
||||
|
||||
atexit.register(_close_opencode_http)
|
||||
|
||||
|
||||
class OpenCodeClient:
|
||||
def __init__(
|
||||
self,
|
||||
config: OpenCodeConfig,
|
||||
http_client: Optional[AsyncHTTPClient] = None,
|
||||
) -> None:
|
||||
self._config = config
|
||||
# Use a dedicated client (not the singleton) so close() can
|
||||
# deterministically release its connections without affecting
|
||||
# other components. The module-level atexit still closes the
|
||||
# singleton as a safety net.
|
||||
self._http_client = http_client or AsyncHTTPClient(force_instance=True)
|
||||
self._owns_http_client = http_client is None
|
||||
|
||||
def close(self) -> None:
|
||||
"""Release the underlying HTTP client's connections. Idempotent."""
|
||||
if getattr(self, "_http_client", None) is None:
|
||||
return
|
||||
try:
|
||||
if self._owns_http_client:
|
||||
self._http_client.close()
|
||||
except Exception:
|
||||
pass
|
||||
self._http_client = None # type: ignore[assignment]
|
||||
self._owns_http_client = False
|
||||
|
||||
async def health(self) -> dict[str, Any]:
|
||||
return await self._request("GET", "/global/health")
|
||||
|
||||
async def list_providers(self) -> list[dict[str, Any]]:
|
||||
return await self._request("GET", "/config/providers")
|
||||
|
||||
async def create_session(self, title: str) -> dict[str, Any]:
|
||||
return await self._request("POST", "/session", {"title": title})
|
||||
|
||||
async def send_message_sync(
|
||||
self,
|
||||
session_id: str,
|
||||
parts: list[dict[str, Any]],
|
||||
provider_id: Optional[str] = None,
|
||||
model_id: Optional[str] = None,
|
||||
system: Optional[str] = None,
|
||||
) -> dict[str, Any]:
|
||||
body: dict[str, Any] = {"parts": parts}
|
||||
if provider_id is not None and model_id is not None:
|
||||
body["model"] = {"providerID": provider_id, "modelID": model_id}
|
||||
if system is not None:
|
||||
body["system"] = system
|
||||
return await self._request(
|
||||
"POST",
|
||||
"/session/%s/message" % session_id,
|
||||
body,
|
||||
)
|
||||
|
||||
async def abort(self, session_id: str) -> bool:
|
||||
result = await self._request("POST", "/session/%s/abort" % session_id)
|
||||
return result is not None
|
||||
|
||||
async def delete_session(self, session_id: str) -> bool:
|
||||
result = await self._request("DELETE", "/session/%s" % session_id)
|
||||
return result is not None
|
||||
|
||||
@property
|
||||
def endpoint(self) -> str:
|
||||
return self._config.url
|
||||
|
||||
async def _request(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
body: Optional[dict[str, Any]] = None,
|
||||
) -> Optional[Any]:
|
||||
url = self._config.url + path
|
||||
headers = HTTPHeaders(
|
||||
{
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
}
|
||||
)
|
||||
request_kwargs: dict[str, Any] = {
|
||||
"method": method,
|
||||
"headers": headers,
|
||||
"request_timeout": self._config.request_timeout_seconds,
|
||||
}
|
||||
if body is not None:
|
||||
request_kwargs["body"] = json.dumps(body).encode("utf-8")
|
||||
# HTTP Basic Auth must be set on the HTTPRequest itself; passing
|
||||
# auth_* as **kwargs to fetch() when the first arg is already an
|
||||
# HTTPRequest is rejected by tornado ("kwargs can't be used if
|
||||
# request is an HTTPRequest object").
|
||||
if self._config.auth is not None:
|
||||
request_kwargs["auth_username"] = self._config.auth[0]
|
||||
request_kwargs["auth_password"] = self._config.auth[1]
|
||||
|
||||
request = HTTPRequest(url, **request_kwargs)
|
||||
|
||||
response = await self._http_client.fetch(request)
|
||||
|
||||
if 200 <= response.code < 300:
|
||||
if not response.body:
|
||||
return True
|
||||
return json.loads(response.body.decode("utf-8"))
|
||||
if response.code == 404:
|
||||
return None
|
||||
|
||||
response_body = response.body.decode("utf-8") if response.body else ""
|
||||
raise OpenCodeError(
|
||||
"OpenCode request %s %s failed with status %s: %s"
|
||||
% (method, url, response.code, response_body)
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 1: 应用上面完整新内容到 `opencode_bridge/opencode_client.py`**
|
||||
|
||||
- [ ] **Step 2: 跑现有 pytest 确认没破**
|
||||
|
||||
Run: `.venv/bin/python -m pytest opencode_bridge/tests/ -q`
|
||||
Expected: 32 passed(注意:`test_client.py` 用 `FakeOpenCodeClient`,不直接调 `AsyncHTTPClient`,所以现有测试不应受影响)。
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add opencode_bridge/opencode_client.py
|
||||
git commit -m "fix: set Basic Auth on HTTPRequest, not as fetch kwargs; close client cleanly
|
||||
|
||||
tornado rejects fetch(request, **kwargs) when the first arg is already an
|
||||
HTTPRequest and the kwargs overlap request construction (auth_username /
|
||||
auth_password). Move auth onto the HTTPRequest. Add OpenCodeClient.close()
|
||||
to release its AsyncHTTPClient; module-level atexit closes the tornado
|
||||
singleton so 'jupyter lab' can exit cleanly after SIGINT instead of
|
||||
hanging on 'received signal 2, stopping'."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: 补 `test_client.py` 测试
|
||||
|
||||
**Files:** `opencode_bridge/tests/test_client.py`(补 close + auth-on-request 行为测试)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: 现有 `FakeOpenCodeClient`(保留,它不直接用 `AsyncHTTPClient`)。
|
||||
- Produces: 新测试验证 `OpenCodeClient.close()` 幂等 + 调用后 `_http_client` 为 None;验证带 auth 的 `_request` 构造的 `HTTPRequest` 上有 `auth_username`/`auth_password`(而不是传给 `fetch`)。
|
||||
|
||||
- [ ] **Step 1: Read** `opencode_bridge/tests/test_client.py` 看现有结构,只追加测试,**不改** `FakeOpenCodeClient`。
|
||||
|
||||
- [ ] **Step 2: 追加测试**(放在文件末尾):
|
||||
|
||||
```python
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
import pytest
|
||||
from tornado.httpclient import HTTPRequest
|
||||
|
||||
from opencode_bridge.config import OpenCodeConfig
|
||||
from opencode_bridge.opencode_client import OpenCodeClient
|
||||
|
||||
|
||||
def _cfg(password: str = "") -> OpenCodeConfig:
|
||||
return OpenCodeConfig(
|
||||
url="http://fake-opencode",
|
||||
user="opencode",
|
||||
password=password,
|
||||
request_timeout_seconds=10,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_request_sets_auth_on_request_not_fetch_kwargs(monkeypatch):
|
||||
"""Bug fix: tornado rejects fetch(request, **kwargs) for auth_*; auth
|
||||
must live on the HTTPRequest itself."""
|
||||
captured: dict = {}
|
||||
|
||||
class FakeClient:
|
||||
async def fetch(self, request, **kwargs):
|
||||
captured["request"] = request
|
||||
captured["fetch_kwargs"] = kwargs
|
||||
r = MagicMock()
|
||||
r.code = 200
|
||||
r.body = b'{"id": "s1"}'
|
||||
return r
|
||||
|
||||
cfg = _cfg(password="secret")
|
||||
client = OpenCodeClient(cfg, http_client=FakeClient()) # type: ignore[arg-type]
|
||||
await client._request("GET", "/x")
|
||||
|
||||
# No kwargs passed to fetch.
|
||||
assert captured["fetch_kwargs"] == {}
|
||||
# Auth is on the HTTPRequest.
|
||||
assert isinstance(captured["request"], HTTPRequest)
|
||||
assert captured["request"].auth_username == "opencode"
|
||||
assert captured["request"].auth_password == "secret"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_request_omits_auth_when_no_password(monkeypatch):
|
||||
"""When password is empty, no auth_* is set on the request."""
|
||||
captured: dict = {}
|
||||
|
||||
class FakeClient:
|
||||
async def fetch(self, request, **kwargs):
|
||||
captured["request"] = request
|
||||
captured["fetch_kwargs"] = kwargs
|
||||
r = MagicMock()
|
||||
r.code = 200
|
||||
r.body = b'{"id": "s1"}'
|
||||
return r
|
||||
|
||||
client = OpenCodeClient(_cfg(password=""), http_client=FakeClient()) # type: ignore[arg-type]
|
||||
await client._request("GET", "/x")
|
||||
|
||||
assert captured["fetch_kwargs"] == {}
|
||||
assert captured["request"].auth_username is None
|
||||
assert captured["request"].auth_password is None
|
||||
|
||||
|
||||
def test_close_is_idempotent():
|
||||
"""close() can be called multiple times safely."""
|
||||
client = OpenCodeClient(_cfg())
|
||||
client.close()
|
||||
client.close() # must not raise
|
||||
assert client._http_client is None
|
||||
|
||||
|
||||
def test_close_closes_owned_client():
|
||||
"""close() calls .close() on the AsyncHTTPClient we own."""
|
||||
fake = MagicMock()
|
||||
client = OpenCodeClient(_cfg(), http_client=fake)
|
||||
# We didn't create it, so close() should NOT call .close() on it
|
||||
# (we don't own it). But _http_client is reset.
|
||||
client.close()
|
||||
fake.close.assert_not_called()
|
||||
assert client._http_client is None
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 跑 pytest**
|
||||
|
||||
Run: `.venv/bin/python -m pytest opencode_bridge/tests/ -q`
|
||||
Expected: 32 + 4 = 36 passed(若现有测试数变化,以实际输出为准,全部 pass)。
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add opencode_bridge/tests/test_client.py
|
||||
git commit -m "test: cover auth-on-request and close() in OpenCodeClient"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: 全量验证
|
||||
|
||||
```bash
|
||||
.venv/bin/python -m pytest opencode_bridge/tests/ -v
|
||||
PATH="$PWD/.venv/bin:$PATH" jlpm test
|
||||
```
|
||||
Expected: pytest 全过;jest 全过(前端未动,仍是 25 passed)。
|
||||
|
||||
---
|
||||
|
||||
### 手动验收
|
||||
|
||||
```bash
|
||||
export OPENCODE_BRIDGE_URL=http://127.0.0.1:4096
|
||||
export OPENCODE_BRIDGE_PASSWORD=yourpassword # 若 OpenCode Serve 启了 auth
|
||||
jupyter lab
|
||||
# 在 notebook 里点 🪄 → 选 model → 输入指令 → 发送:不再报 "kwargs can't be used..."
|
||||
# Ctrl+C: 进程应快速干净退出(不再 hang "received signal 2, stopping")
|
||||
```
|
||||
@@ -0,0 +1,74 @@
|
||||
# AI 操作按钮迁入原生 Cell Toolbar — 设计
|
||||
|
||||
- 日期: 2026-07-23
|
||||
- 状态: 已批准(用户于 2026-07-23 确认)
|
||||
|
||||
## 背景与问题
|
||||
|
||||
当前 `✨ 优化` / `🐛 排错` / `🪄 编辑` 三个按钮渲染在每个 cell **底部** 的 `ICellFooter` 里,链路为:`installOpenCodeEverywhere` 替换每个 notebook 的 `contentFactory` → `OpenCodeCellContentFactory.createCellFooter()` → `OpenCodeCellFooter` widget。
|
||||
|
||||
问题:按钮位置不符合预期 —— 应出现在每个 cell **右上角**、JupyterLab 原生 cell toolbar 的上移/下移按钮(`move-cell-up` / `move-cell-down`)旁边。
|
||||
|
||||
## 已确认的决策
|
||||
|
||||
| 问题 | 决策 |
|
||||
| ----------- | ----------------------------------------------------------------------------------------------------- |
|
||||
| 可见性语义 | 跟随原生 toolbar:只在 **active cell** 右上角显示(与上移/下移按钮行为严格一致) |
|
||||
| 底部 footer | **删除**,三个按钮全部上移 |
|
||||
| 点击交互 | 本次不变:`🪄 编辑` 仍用 `window.prompt`;结果仍走 `Notification`;inline prompt box / diff 面板留待后续 |
|
||||
| 作用范围 | 仅 `CodeCell`;Markdown 等 cell 不显示 AI 按钮(现状 footer 挂在所有 cell 上,属顺带修正) |
|
||||
|
||||
## 机制(JupyterLab 4.6 真实扩展点,已核实源码)
|
||||
|
||||
- 原生 cell toolbar 由内置 `@jupyterlab/cell-toolbar-extension` 提供,toolbar factory 名为 `'Cell'`,settings 驱动;默认按钮(含 `move-cell-up` / `move-cell-down`,rank 均为默认 50)由其 schema 的 `jupyter.lab.toolbars` 贡献。
|
||||
- 第三方扩展通过 `IToolbarWidgetRegistry.addFactory<Cell>('Cell', '<itemName>', factory)` 注册 widget factory;**factory 收到具体的 `Cell` 实例**,toolbar 由 tracker 按 cell model 缓存。
|
||||
- 自己的 `schema/plugin.json` 顶层加 `"jupyter.lab.toolbars": { "Cell": [...] }` 即可把该项贡献进原生 toolbar,无需 DOM 注入或 CSS 定位。
|
||||
- 原生 toolbar 只渲染在 active cell 内(`activeCellChanged` 时移动),空间不足时自动隐藏 —— 我们的按钮随它一起获得这些行为。
|
||||
|
||||
## 设计
|
||||
|
||||
### 组件改造
|
||||
|
||||
- `src/components/opencode_cell_footer.ts` → 改造为 `src/components/opencode_cell_actions.ts`:
|
||||
- `OpenCodeCellActions extends Widget`,**构造函数直接接收 `CodeCell`**(factory 传入),不再在 `onAfterAttach` 里靠 `this.parent instanceof CodeCell` 解析宿主。
|
||||
- `model.contentChanged` / `stateChanged` 订阅保持,`onBeforeDetach` 摘除。
|
||||
- 渲染逻辑不变:`✨ 优化` / `🐛 排错`(无 error output 时禁用) / `🪄 编辑`;loading 时全部禁用。
|
||||
- 错误提示统一走 `Notification`,widget 内不再渲染 error span(toolbar 空间小)。
|
||||
- 模块级 `setOpenCodeRuntime()` 运行时注入机制不变。
|
||||
- 删除 `src/components/opencode_cell_factory.ts`、`src/components/opencode_installer.ts`。
|
||||
|
||||
### 注册与配置
|
||||
|
||||
- `src/index.ts`:
|
||||
- `optional` 增加 `IToolbarWidgetRegistry`(来自 `@jupyterlab/apputils`,已是依赖)。
|
||||
- activate 中:`toolbarRegistry.addFactory('Cell', 'opencode-cell-actions', (cell) => cell instanceof CodeCell ? new OpenCodeCellActions(cell) : new Widget())`;非 CodeCell 返回空 widget。
|
||||
- 移除 `installOpenCodeEverywhere` 调用及 import。
|
||||
- `schema/plugin.json` 顶层增加:
|
||||
```json
|
||||
"jupyter.lab.toolbars": {
|
||||
"Cell": [{ "name": "opencode-cell-actions", "rank": 100 }]
|
||||
}
|
||||
```
|
||||
默认按钮 rank 为 50,我们排 100 → 出现在 delete-cell 右侧,紧邻上移/下移按钮组。
|
||||
- `style/base.css`:新增 `.opencode-cell-actions` 紧凑按钮样式(适配原生 toolbar 高度)。
|
||||
|
||||
### 上下文提取(不变)
|
||||
|
||||
widget attach 后仍用现有 `extractCellContextFromCell`(沿 `cell.parent` 链 duck-typing 找 `NotebookPanel`)。cell 实例改由构造参数传入,比现在更可靠。
|
||||
|
||||
### 测试与文档
|
||||
|
||||
- `src/__tests__/opencode_cell_footer.spec.ts` → `opencode_cell_actions.spec.ts`:构造时直接传 cell,删除 `Object.defineProperty(footer, 'parent', ...)` 相关 hack,断言不变(三按钮渲染、禁用态)。
|
||||
- `design.md` 第 2 节交互描述更新:悬浮 toolbar → 原生 cell toolbar(仅 active cell 显示)。
|
||||
|
||||
## 兼容性
|
||||
|
||||
- 纯前端 UI 搬迁;REST 契约、Python server 端零改动。
|
||||
- `cell-toolbar-extension` 被禁用时,我们的按钮与原生按钮一起消失(行为一致,可接受)。
|
||||
- 移除 contentFactory 覆写后,不再受"已打开的 cell 需 reload 才生效"的限制。
|
||||
- `IToolbarWidgetRegistry` 声明为 `optional`,registry 缺失时插件仍可激活(按钮不显示)。
|
||||
|
||||
## 验证
|
||||
|
||||
- `jlpm test`、`jlpm lint:check`、`jlpm build` 全部通过。
|
||||
- 手动:`jupyter lab` 打开 notebook,active cell 右上角出现三个按钮,位于 delete 按钮右侧;无错误的 cell 上 `🐛 排错` 禁用;切换 active cell 按钮随之移动。
|
||||
@@ -0,0 +1,86 @@
|
||||
# Cell Toolbar Actions v2 — 单按钮 + inline 输入框 + 删除 mode
|
||||
|
||||
- 日期: 2026-07-23
|
||||
- 状态: 已批准(用户于 2026-07-23 确认)
|
||||
- 取代: v1(`2026-07-23-cell-toolbar-actions-design.md`,commit 9e7c36e)。本轮是 v1 的演进。
|
||||
|
||||
## 背景:v1 → v2
|
||||
|
||||
v1 把 `✨ 优化` / `🐛 排错` / `🪄 编辑` 三个按钮从 cell 底部 footer 迁到原生 cell toolbar。用户反馈 4 个问题,本轮一并解决:
|
||||
|
||||
1. **Settings 不生效** —— v1 只在 `settingRegistry.load(...).then(...)` 调一次 `setOpenCodeRuntime`,没订阅 `settings.changed`,Settings Editor 改了值后模块级 `_settings` 仍是旧的。
|
||||
2. **单元格高度被压** —— v1 三个文字按钮让原生 cell toolbar 变高,把 code 编辑区挤小。
|
||||
3. **三个按钮合并为一个** —— 用户选 "完全合并为一个动作(按输入语义判断)"。
|
||||
4. **点击后 cell 内显示输入框** —— `design.md` v1 留的 inline prompt box,本轮实现。
|
||||
|
||||
同时用户给出两个架构边界(必须遵守):
|
||||
- **前端调用后端时,只获取 cell 的 input/output/error,不做任何处理**(不决策 mode、不解析语义)
|
||||
- **1 notebook → 1 session**(`SessionManager` 已保证,本轮不动)
|
||||
|
||||
## 决策
|
||||
|
||||
| 维度 | 决策 |
|
||||
|---|---|
|
||||
| 按钮 | 单个图标按钮(🪄) |
|
||||
| 交互 | 点击 → cell 内 inline 输入框(textarea + 🚀 发送 + ✕ 取消) |
|
||||
| `mode` 概念 | **彻底删除**。前端 request body 不再包含 `mode`;服务端 `EditHandler` 不再读 `mode`,响应体也不再回 `mode`;`MODE_SYSTEM_PROMPTS` 字典删除 |
|
||||
| 服务端系统提示词 | 唯一一条统一提示词,LLM 根据用户自然语言 + context(含 traceback)自己判断优化/排错/编辑 |
|
||||
| 成功时 | 前端 `cell.model.sharedModel.setSource(resp.finalSource)` 就地替换 cell 源码 + Notification |
|
||||
| Settings 实时生效 | `index.ts` 订阅 `settings.changed` → 重灌 `setOpenCodeRuntime` |
|
||||
| `providerId` / `modelId` | 仍从 JupyterLab settings 读 → 透传(用户配置,非"对 cell 数据的处理",保留) |
|
||||
| 作用范围 | 仅 `CodeCell`;非 `CodeCell` → factory 返回空 `Widget` |
|
||||
| 之前的 footer | 已删(v1) |
|
||||
|
||||
## 接口契约
|
||||
|
||||
**Request** `POST /opencode-bridge/edit`:
|
||||
```json
|
||||
{
|
||||
"prompt": "用户在 inline 输入框里写的自然语言指令",
|
||||
"context": {
|
||||
"notebookPath": "analysis/demo.ipynb",
|
||||
"cellId": "cell-3",
|
||||
"language": "python",
|
||||
"cellIndex": 3,
|
||||
"totalCells": 5,
|
||||
"source": "import matplotlib...",
|
||||
"previousCode": "import pandas as pd",
|
||||
"error": null
|
||||
},
|
||||
"providerId": "anthropic",
|
||||
"modelId": "claude-sonnet-4-20250514"
|
||||
}
|
||||
```
|
||||
- **无 `mode` 字段**
|
||||
- `context.error` 非空时,服务端在 OpenCode parts 里自动加 `<traceback>` 段(原 `_build_request_body` 行为,保留)
|
||||
- `<instruction>` 段:只要 `prompt` 非空就加(原条件 `mode == "edit"` 守卫删除)
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
{ "ok": true, "finalSource": "import plotly...", "sessionId": "ses-abc", "notebookPath": "analysis/demo.ipynb" }
|
||||
```
|
||||
- **无 `mode` 字段**
|
||||
|
||||
**系统提示词(统一,常量)**:
|
||||
```
|
||||
你是一个代码编辑助手。基于提供的代码上下文(以及 traceback,如果有),按照用户的指令修改代码。返回只包含修改后完整代码的回复,不要任何解释或 markdown 围栏。
|
||||
```
|
||||
(刻意保留 v1 `'edit'` 提示词的核心子串 `"你是一个代码编辑助手"`,以便 `test_edit_handler` 的既有断言继续通过 —— 该测试只断言子串存在,不强耦合具体措辞。)
|
||||
|
||||
## 机制边界确认
|
||||
|
||||
- **前端**:`OpenCodeCellActions`(toolbar 里的单图标)+ `OpenCodeInlinePrompt`(cell 内的输入框 widget,`Widget.attach` 到 `cell.node`)。组件代码只做:渲染按钮 / 显示输入框 / 取 `cell.model.sharedModel.getSource()` / 取 outputs / 取 error / 拼 `context` / POST / 成功调 `setSource` / 失败 Notification。**不**做 mode 决策、**不**做语义判断、**不**管 session。
|
||||
- **服务端**:拥有 `SessionManager`(1 notebook 1 sid,不动);`EditHandler` 唯一拥有的 "决策" 是 "是否包含 traceback"(由 `context.error` 决定,机械规则不算"处理");系统提示词选择(从前端决定的 mode 改为服务端的常量);调用 OpenCode Serve。Session 失效(404/not found)时 invalidate,前端重发。
|
||||
- **OpenCode Serve**:按系统提示词 + 上下文 + 用户指令干活,自己判断优化/排错/编辑。
|
||||
|
||||
## 兼容性
|
||||
|
||||
- 后端 `mode` 字段(若调用方仍发)被忽略,不影响响应(为旧 API 兜底)。`MODE_SYSTEM_PROMPTS` 删除后,`'optimize'` 和 `'fix'` 提示词从默认路径不可达 —— 这是有意的(用户选择 "完全合并")。若以后需要可走专门 API。
|
||||
- 前端 `OpenCodeMode` 类型别名删除;`OpenCodeRequest.mode` 字段删除;`OpenCodeSuccess.mode` 字段删除。引用了这些的测试 / 组件同步更新。
|
||||
|
||||
## 验证
|
||||
|
||||
- `pytest opencode_bridge/tests/` 全过(含 `test_edit_handler`,改写后)
|
||||
- `jlpm test` 全过(单测重写 `opencode_cell_actions.spec.ts`,`opencode_client.spec.ts` 去掉 `mode`)
|
||||
- `jlpm build` 成功
|
||||
- 手动:`jupyter lab` 打开 notebook,active cell 右上角出现单个 🪄 图标;点击 → cell 底部出现输入框(带 placeholder、无默认值);输入指令 → 发送 → cell 源码被替换;切 cell 按钮随之移动
|
||||
@@ -0,0 +1,74 @@
|
||||
# Cell Toolbar Actions v3 — 启动时配置 + 动态模型选择器
|
||||
|
||||
- 日期: 2026-07-23
|
||||
- 状态: 已批准(用户于 2026-07-23 确认,v3-final: settings 一项不留)
|
||||
- 取代: v2 `OpenCodeSettings` 类型 + v1/v2 全部 JupyterLab plugin settings。`opencode_bridge` 插件本轮**完全不再使用 JupyterLab Settings Editor**:所有连接配置走启动环境变量,模型选择完全动态(每次 inline 输入框里挑)。
|
||||
|
||||
## 背景
|
||||
|
||||
v2 之后用户提了 2 个调整:
|
||||
|
||||
1. **`opencodeServerUrl` / `User` / `Password` / `requestTimeoutSeconds` 不再在 JupyterLab Settings Editor 里配** —— 全部在启动 jupyter server 时通过环境变量指定。**随后**用户进一步要求:`opencodeProvider` / `opencodeModel` 这两项 settings 也不保留 —— 模型选择器**完全动态**,每次在 inline 输入框里挑,没有"设置默认值"的概念。**结果**:`schema/plugin.json` 的 `properties` 变为 `{}`(只剩非 settings 的 schema 注释 `jupyter.lab.toolbars` 贡献)。
|
||||
2. **inline 输入框增加模型选择器** —— 从 `GET /opencode-bridge/providers` 启动时拉取并缓存;每次请求时用户挑一个 provider/model;无任何 settings 默认。
|
||||
|
||||
## 决策
|
||||
|
||||
| 维度 | 决策 |
|
||||
|---|---|
|
||||
| 启动 env(4 个) | `OPENCODE_BRIDGE_URL` / `_USER` / `_PASSWORD` / `_TIMEOUT`(秒,默认 120);`jupyter lab` 启动前 export;`config.py` `resolve_config` 走 `os.environ.get(ENV) or DEFAULT`,**完全不再**从 JupyterLab settings 兜底(没有 settings 可读) |
|
||||
| JupyterLab Settings | **无**。`schema/plugin.json` `properties: {}`;`opencode_bridge` 插件不再 `optional: [ISettingRegistry]`;`settingRegistry.load(...)` / `setOpenCodeRuntime({settings})` 整段删除 |
|
||||
| 前端 `OpenCodeSettings` 类型 | **删除整个类型**;`DEFAULT_OPENCODE_SETTINGS` / `readOpenCodeSettings` 删除;`opencode_bridge.spec.ts` 中相关测试删除 |
|
||||
| `setOpenCodeRuntime` | 改名为 `setOpenCodeServerSettings(serverSettings)`,只接收 `ServerConnection.ISettings`(Jupyter 自己的 serverSettings,用于 `callOpenCodeEdit` 拼 URL);不再有 `_settings` 模块级 |
|
||||
| `index.ts` | `optional: [IToolbarWidgetRegistry]`(去掉 `ISettingRegistry`);激活时:注册 toolbar factory + 拉 providers 写缓存(无 settings block) |
|
||||
| 模型选择器 UI | inline 面板顶部加一行 `模型: <select>`(单个下拉),选项 label `provider / model`,value `providerId\|modelId`(`\|` 分隔,IDs 不含此字符);textarea 在其下方 |
|
||||
| 默认选中 | 下拉框**第一项**(无 settings 可作默认) |
|
||||
| 提交覆盖 | prompt 的 `onSubmit(text, providerId, modelId)` 把当前选中带回去;actions 用它作为 request body 的 `providerId`/`modelId`(无 settings 兜底,prompt 必须有选择) |
|
||||
| Providers 数据来源 | `GET /opencode-bridge/providers` 在 `index.ts` 启动时拉取;通过新 `setOpenCodeProviders(p)` 写到 `opencode_cell_actions.ts` 模块级 `_providers` 缓存;inline prompt 构造时读 |
|
||||
| Providers 拉取失败 | 缓存为 `null`,inline prompt **不渲染** select;提交时 `providerId`/`modelId` 为 `undefined` → **不发**这两个字段(OpenCode Serve 用它自己的默认)。**优雅降级** |
|
||||
| schema `jupyter.lab.toolbars` | 顶层 `jupyter.lab.toolbars: { "Cell": [{ "name": "opencode-cell-actions", "rank": 100 }] }` 保留(toolbar item 仍需 schema 贡献) |
|
||||
|
||||
## 接口契约
|
||||
|
||||
**前端 `OpenCodeRequest`**:与 v2 相同(无 `mode`):
|
||||
```json
|
||||
{ "prompt": "...", "context": { ... }, "providerId": "...", "modelId": "..." }
|
||||
```
|
||||
- `providerId` / `modelId` 可选;**只在** inline 选择器有选中项时发;providers 拉取失败时不发。
|
||||
|
||||
**后端**:与 v2 相同。`config.py` 新增:
|
||||
```python
|
||||
ENV_TIMEOUT = "OPENCODE_BRIDGE_TIMEOUT"
|
||||
DEFAULT_REQUEST_TIMEOUT = 120
|
||||
```
|
||||
`resolve_config` 4 个字段全部走 `os.environ.get(ENV) or DEFAULT`。`requestTimeoutSeconds` 是 server-side(OpenCodeClient → OpenCode Serve 的 httpx timeout),与前端无关。
|
||||
|
||||
**环境变量清单**:
|
||||
| 变量 | 默认 | 说明 |
|
||||
|---|---|---|
|
||||
| `OPENCODE_BRIDGE_URL` | `http://127.0.0.1:4096` | OpenCode Serve 地址 |
|
||||
| `OPENCODE_BRIDGE_USER` | `opencode` | HTTP Basic Auth 用户名 |
|
||||
| `OPENCODE_BRIDGE_PASSWORD` | `""`(无 auth) | HTTP Basic Auth 密码 |
|
||||
| `OPENCODE_BRIDGE_TIMEOUT` | `120` | 请求 OpenCode Serve 超时(秒,整数) |
|
||||
|
||||
## 机制
|
||||
|
||||
- **前端**:
|
||||
- `opencode_cell_actions.ts` 模块级:`_providers`(`setOpenCodeProviders` 写入)、`_serverSettings`(`setOpenCodeServerSettings` 写入)。**无** `_settings`。
|
||||
- `OpenCodeInlinePrompt` 构造接收 `providers`(必填,可能为 `null`);无 `defaultProviderId`/`defaultModelId` 参数;默认选中第一项。
|
||||
- 点击提交 → prompt 回调 `(text, providerId?, modelId?)`;actions 用回传的 provider/model 写进 `OpenCodeRequest`(`undefined` → 不发)。
|
||||
- **服务端**:`config.py` 不再读 JupyterLab settings 字典(`handler.settings.get("opencode_bridge", {})` 返回 `{}`);SessionManager / EditHandler 不变。
|
||||
- **OpenCode**:与 v2 相同。
|
||||
|
||||
## 兼容性
|
||||
|
||||
- **`opencode_bridge` 插件不再读 JupyterLab settings**;之前在 Settings Editor 设过 6 个字段(`opencodeServerUrl` / `User` / `Password` / `requestTimeoutSeconds` / `opencodeProvider` / `opencodeModel`)的用户:**全部失效**,需改用环境变量(连接类)或在 inline 输入框里挑(模型)。**行为变更,需在 release note 标注**。
|
||||
- 4 个 env 变量 + 默认值覆盖之前 6 个 settings 字段的所有功能;模型选择器替代 `opencodeProvider`/`opencodeModel` settings 的"默认"作用(但完全由用户每次手选,无持久默认)。
|
||||
- `OpenCodeSettings` / `DEFAULT_OPENCODE_SETTINGS` / `readOpenCodeSettings` 类型/常量/函数整组删除;`opencode_bridge.spec.ts` 中相关测试删除。
|
||||
- `setOpenCodeRuntime` 重命名为 `setOpenCodeServerSettings`(签名 `{ serverSettings }`),所有调用方更新。
|
||||
|
||||
## 验证
|
||||
|
||||
- `pytest opencode_bridge/tests/` 全过
|
||||
- `jlpm test` 全过(单测覆盖: select 渲染 N 项 / 默认第一项 / submit 覆盖 / providers 降级)
|
||||
- `jlpm build` 成功
|
||||
- 手动:`export OPENCODE_BRIDGE_URL=... && jupyter lab` → active cell 右上角单按钮 → inline 面板顶部 `模型: [select]`(列出 provider/model,默认第一项) → 切换选择 → 发送 → request 带新 provider/model;Settings Editor 中 `opencode_bridge` 无任何字段
|
||||
+23
-16
@@ -1,18 +1,25 @@
|
||||
"""Configuration resolution for the opencode-bridge extension.
|
||||
|
||||
Priority order (highest to lowest):
|
||||
1. jupyter settings dict (from schema/plugin.json)
|
||||
2. Environment variables (OPENCODE_BRIDGE_URL, _USER, _PASSWORD)
|
||||
3. Built-in defaults
|
||||
All connection fields (URL / user / password / request timeout) are resolved
|
||||
exclusively from environment variables + built-in defaults at server startup.
|
||||
They are NOT stored in the JupyterLab plugin settings.
|
||||
|
||||
Env vars:
|
||||
OPENCODE_BRIDGE_URL — OpenCode Serve base URL (default http://127.0.0.1:4096)
|
||||
OPENCODE_BRIDGE_USER — HTTP Basic Auth username (default 'opencode')
|
||||
OPENCODE_BRIDGE_PASSWORD — HTTP Basic Auth password (default '' = no auth)
|
||||
OPENCODE_BRIDGE_TIMEOUT — request timeout in seconds (default 120)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import NamedTuple, Optional, Tuple
|
||||
|
||||
|
||||
ENV_URL = "OPENCODE_BRIDGE_URL"
|
||||
ENV_USER = "OPENCODE_BRIDGE_USER"
|
||||
ENV_PASSWORD = "OPENCODE_BRIDGE_PASSWORD"
|
||||
ENV_TIMEOUT = "OPENCODE_BRIDGE_TIMEOUT"
|
||||
|
||||
DEFAULT_URL = "http://127.0.0.1:4096"
|
||||
DEFAULT_USER = "opencode"
|
||||
@@ -23,7 +30,7 @@ class OpenCodeConfig(NamedTuple):
|
||||
url: str
|
||||
user: str
|
||||
password: str
|
||||
request_timeout_seconds: int = DEFAULT_REQUEST_TIMEOUT
|
||||
request_timeout_seconds: int
|
||||
|
||||
@property
|
||||
def auth(self) -> Optional[Tuple[str, str]]:
|
||||
@@ -34,18 +41,18 @@ class OpenCodeConfig(NamedTuple):
|
||||
|
||||
|
||||
def resolve_config(jupyter_settings: dict) -> OpenCodeConfig:
|
||||
"""Resolve OpenCode connection config from jupyter settings + env + defaults."""
|
||||
bridge = jupyter_settings.get("opencode_bridge", {}) or {}
|
||||
"""Resolve OpenCode connection config from environment variables + defaults.
|
||||
|
||||
The `jupyter_settings` argument is accepted for API compatibility but no
|
||||
fields are read from it — all connection config is now startup-only
|
||||
(environment variables).
|
||||
"""
|
||||
_ = jupyter_settings # intentionally unused; see module docstring
|
||||
return OpenCodeConfig(
|
||||
url=bridge.get("opencodeServerUrl") or os.environ.get(ENV_URL) or DEFAULT_URL,
|
||||
user=bridge.get("opencodeServerUser") or os.environ.get(ENV_USER) or DEFAULT_USER,
|
||||
password=(
|
||||
bridge.get("opencodeServerPassword")
|
||||
or os.environ.get(ENV_PASSWORD)
|
||||
or ""
|
||||
),
|
||||
url=os.environ.get(ENV_URL) or DEFAULT_URL,
|
||||
user=os.environ.get(ENV_USER) or DEFAULT_USER,
|
||||
password=os.environ.get(ENV_PASSWORD) or "",
|
||||
request_timeout_seconds=int(
|
||||
bridge.get("requestTimeoutSeconds", DEFAULT_REQUEST_TIMEOUT)
|
||||
),
|
||||
os.environ.get(ENV_TIMEOUT) or DEFAULT_REQUEST_TIMEOUT
|
||||
)
|
||||
)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Async HTTP client for the local OpenCode server."""
|
||||
|
||||
import atexit
|
||||
import json
|
||||
from typing import Any, Optional
|
||||
|
||||
@@ -13,6 +14,21 @@ class OpenCodeError(Exception):
|
||||
"""Raised when an OpenCode API request fails."""
|
||||
|
||||
|
||||
# Ensure the tornado AsyncHTTPClient singleton is closed at interpreter
|
||||
# shutdown so the IOLoop can finish and the process can exit cleanly
|
||||
# (otherwise Ctrl+C on `jupyter lab` hangs after "received signal 2,
|
||||
# stopping" because the singleton keeps persistent connections alive).
|
||||
def _close_opencode_http() -> None:
|
||||
try:
|
||||
AsyncHTTPClient().close()
|
||||
except Exception:
|
||||
# Never block interpreter shutdown on close errors.
|
||||
pass
|
||||
|
||||
|
||||
atexit.register(_close_opencode_http)
|
||||
|
||||
|
||||
class OpenCodeClient:
|
||||
def __init__(
|
||||
self,
|
||||
@@ -20,7 +36,24 @@ class OpenCodeClient:
|
||||
http_client: Optional[AsyncHTTPClient] = None,
|
||||
) -> None:
|
||||
self._config = config
|
||||
self._http_client = http_client or AsyncHTTPClient()
|
||||
# Use a dedicated client (not the singleton) so close() can
|
||||
# deterministically release its connections without affecting
|
||||
# other components. The module-level atexit still closes the
|
||||
# singleton as a safety net.
|
||||
self._http_client = http_client or AsyncHTTPClient(force_instance=True)
|
||||
self._owns_http_client = http_client is None
|
||||
|
||||
def close(self) -> None:
|
||||
"""Release the underlying HTTP client's connections. Idempotent."""
|
||||
if getattr(self, "_http_client", None) is None:
|
||||
return
|
||||
try:
|
||||
if self._owns_http_client:
|
||||
self._http_client.close()
|
||||
except Exception:
|
||||
pass
|
||||
self._http_client = None # type: ignore[assignment]
|
||||
self._owns_http_client = False
|
||||
|
||||
async def health(self) -> dict[str, Any]:
|
||||
return await self._request("GET", "/global/health")
|
||||
@@ -58,6 +91,16 @@ class OpenCodeClient:
|
||||
result = await self._request("DELETE", "/session/%s" % session_id)
|
||||
return result is not None
|
||||
|
||||
async def list_session_messages(self, session_id: str) -> list[dict[str, Any]]:
|
||||
"""List all messages in the given OpenCode session.
|
||||
|
||||
Returns the raw OpenCode response: a list of
|
||||
`{ info: { role: "user"|"assistant", ... }, parts: [...] }`.
|
||||
The server extension is responsible for projecting this into a
|
||||
frontend-friendly `{role, content}[]` shape.
|
||||
"""
|
||||
return await self._request("GET", "/session/%s/message" % session_id)
|
||||
|
||||
@property
|
||||
def endpoint(self) -> str:
|
||||
return self._config.url
|
||||
@@ -82,15 +125,17 @@ class OpenCodeClient:
|
||||
}
|
||||
if body is not None:
|
||||
request_kwargs["body"] = json.dumps(body).encode("utf-8")
|
||||
# HTTP Basic Auth must be set on the HTTPRequest itself; passing
|
||||
# auth_* as **kwargs to fetch() when the first arg is already an
|
||||
# HTTPRequest is rejected by tornado ("kwargs can't be used if
|
||||
# request is an HTTPRequest object").
|
||||
if self._config.auth is not None:
|
||||
request_kwargs["auth_username"] = self._config.auth[0]
|
||||
request_kwargs["auth_password"] = self._config.auth[1]
|
||||
|
||||
request = HTTPRequest(url, **request_kwargs)
|
||||
|
||||
fetch_kwargs: dict[str, str] = {}
|
||||
if self._config.auth is not None:
|
||||
fetch_kwargs["auth_username"] = self._config.auth[0]
|
||||
fetch_kwargs["auth_password"] = self._config.auth[1]
|
||||
|
||||
response = await self._http_client.fetch(request, **fetch_kwargs)
|
||||
response = await self._http_client.fetch(request)
|
||||
|
||||
if 200 <= response.code < 300:
|
||||
if not response.body:
|
||||
|
||||
+80
-25
@@ -14,20 +14,16 @@ from .session_manager import SessionManager
|
||||
log = logging.getLogger("opencode_bridge.routes")
|
||||
|
||||
|
||||
MODE_SYSTEM_PROMPTS = {
|
||||
"optimize": (
|
||||
"你是一个代码优化专家。请基于用户提供的代码上下文,"
|
||||
"返回只包含优化后代码的回复,不要任何解释或 markdown 围栏。"
|
||||
),
|
||||
"fix": (
|
||||
"你是一个 Python 排错专家。用户给出了一段产生错误的代码和 traceback,"
|
||||
"请返回只包含修复后代码的回复,不要任何解释或 markdown 围栏。"
|
||||
),
|
||||
"edit": (
|
||||
"你是一个代码编辑助手。基于用户的指令修改给定代码,"
|
||||
"返回只包含修改后完整代码的回复,不要任何解释或 markdown 围栏。"
|
||||
),
|
||||
}
|
||||
# Unified system prompt — the LLM (OpenCode) is asked to return its reply
|
||||
# as MARKDOWN (code wrapped in ```language fences; a brief explanation is
|
||||
# fine). The frontend renders this with `marked` directly — no fence
|
||||
# stripping on the server, so the response keeps the structure that makes
|
||||
# markdown rendering meaningful (code blocks, headings, etc.).
|
||||
UNIFIED_SYSTEM_PROMPT = (
|
||||
"你是一个代码助手。基于提供的代码上下文(以及可选的 traceback),"
|
||||
"按照用户的指令修改代码。"
|
||||
"可附简短说明。"
|
||||
)
|
||||
|
||||
|
||||
def make_client(handler: APIHandler) -> OpenCodeClient:
|
||||
@@ -52,12 +48,13 @@ def get_session_manager(handler: APIHandler) -> SessionManager:
|
||||
return sm
|
||||
|
||||
|
||||
def _build_request_body(mode: str, prompt: str, context: dict) -> dict:
|
||||
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.
|
||||
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.
|
||||
"""
|
||||
system = MODE_SYSTEM_PROMPTS[mode]
|
||||
parts: list[dict] = []
|
||||
|
||||
if context.get("previousCode"):
|
||||
@@ -85,14 +82,18 @@ def _build_request_body(mode: str, prompt: str, context: dict) -> dict:
|
||||
),
|
||||
})
|
||||
|
||||
if mode == "edit" and prompt:
|
||||
if prompt:
|
||||
parts.append({"type": "text", "text": "<instruction>\n%s\n</instruction>" % prompt})
|
||||
|
||||
return {"parts": parts, "system": system}
|
||||
return {"parts": parts, "system": UNIFIED_SYSTEM_PROMPT}
|
||||
|
||||
|
||||
def _strip_code_fence(s: str) -> str:
|
||||
"""Strip ```language ... ``` fences from LLM output."""
|
||||
"""Strip ```language ... ``` fences from LLM output.
|
||||
|
||||
Kept for any future "apply-to-cell" path that needs clean source.
|
||||
Not used by the current markdown-rendering display flow.
|
||||
"""
|
||||
s = s.strip()
|
||||
if s.startswith("```"):
|
||||
lines = s.split("\n")
|
||||
@@ -157,7 +158,6 @@ class EditHandler(APIHandler):
|
||||
async def post(self):
|
||||
try:
|
||||
body = json.loads(self.request.body)
|
||||
mode = body["mode"]
|
||||
prompt = body.get("prompt", "")
|
||||
context = body["context"]
|
||||
provider_id = body.get("providerId") or None
|
||||
@@ -177,7 +177,7 @@ class EditHandler(APIHandler):
|
||||
sm = get_session_manager(self)
|
||||
try:
|
||||
sid = await sm.get_or_create(notebook_path)
|
||||
request_body = _build_request_body(mode, prompt, context)
|
||||
request_body = _build_request_body(prompt, context)
|
||||
result = await client.send_message_sync(
|
||||
sid,
|
||||
request_body["parts"],
|
||||
@@ -191,12 +191,14 @@ class EditHandler(APIHandler):
|
||||
for p in result.get("parts", [])
|
||||
if p.get("type") == "text"
|
||||
]
|
||||
final_source = _strip_code_fence("\n".join(text_parts).strip())
|
||||
# The AI's reply is markdown (code in ```fences```, optional
|
||||
# explanation). Pass it through unchanged so the frontend
|
||||
# `marked.parse` can render the code blocks and structure.
|
||||
markdown = "\n".join(text_parts).strip()
|
||||
|
||||
self.finish(json.dumps({
|
||||
"ok": True,
|
||||
"mode": mode,
|
||||
"finalSource": final_source,
|
||||
"markdown": markdown,
|
||||
"sessionId": sid,
|
||||
"notebookPath": notebook_path,
|
||||
}))
|
||||
@@ -247,6 +249,58 @@ class SessionReleaseHandler(APIHandler):
|
||||
}))
|
||||
|
||||
|
||||
class SessionMessagesHandler(APIHandler):
|
||||
"""List the current session's messages for a notebook (scrollable history).
|
||||
|
||||
Query param: notebook=<notebook path, URL-encoded>
|
||||
Returns: { messages: [{ role: "user"|"assistant", content: string }] }
|
||||
If no session exists for the notebook yet, returns { messages: [] }
|
||||
(does NOT create a session just to report emptiness).
|
||||
"""
|
||||
|
||||
@tornado.web.authenticated
|
||||
async def get(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)
|
||||
sid = sm.peek(notebook_path)
|
||||
if sid is None:
|
||||
self.finish(json.dumps({"messages": []}))
|
||||
return
|
||||
try:
|
||||
client = make_client(self)
|
||||
raw = await client.list_session_messages(sid)
|
||||
except OpenCodeError as e:
|
||||
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("list session messages failed")
|
||||
self.set_status(502)
|
||||
self.finish(json.dumps({"ok": False, "error": str(e)}))
|
||||
return
|
||||
except Exception as e:
|
||||
log.exception("list session messages failed")
|
||||
self.set_status(502)
|
||||
self.finish(json.dumps({"ok": False, "error": str(e)}))
|
||||
return
|
||||
|
||||
# Project OpenCode's {info, parts}[] into a frontend-friendly
|
||||
# {role, content}[] by joining the text parts.
|
||||
messages = []
|
||||
for m in raw or []:
|
||||
info = m.get("info") or {}
|
||||
role = info.get("role") or "assistant"
|
||||
parts = m.get("parts") or []
|
||||
content = "\n".join(
|
||||
p.get("text", "") for p in parts if p.get("type") == "text"
|
||||
).strip()
|
||||
messages.append({"role": role, "content": content})
|
||||
self.finish(json.dumps({"messages": messages}))
|
||||
|
||||
|
||||
def setup_route_handlers(web_app):
|
||||
host_pattern = ".*$"
|
||||
base_url = web_app.settings["base_url"]
|
||||
@@ -258,6 +312,7 @@ def setup_route_handlers(web_app):
|
||||
(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),
|
||||
(url_path_join(base_url, "opencode-bridge", "session-messages"), SessionMessagesHandler),
|
||||
]
|
||||
|
||||
web_app.add_handlers(host_pattern, handlers)
|
||||
|
||||
@@ -83,6 +83,13 @@ class SessionManager:
|
||||
def has_session(self, notebook_path: str) -> bool:
|
||||
return notebook_path in self._sessions
|
||||
|
||||
def peek(self, notebook_path: str) -> Optional[str]:
|
||||
"""Return the cached sessionID for the notebook, or None if no
|
||||
session has been created yet. Does NOT create one (unlike
|
||||
get_or_create) — used by the history endpoint to avoid spawning
|
||||
a session just to report that there is none."""
|
||||
return self._sessions.get(notebook_path)
|
||||
|
||||
def list_sessions(self) -> list[dict]:
|
||||
return [
|
||||
{"notebookPath": path, "sessionId": sid}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import json
|
||||
from io import BytesIO
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
import tornado.httpclient
|
||||
@@ -38,7 +39,9 @@ def base_config() -> OpenCodeConfig:
|
||||
)
|
||||
|
||||
|
||||
def _make_client(config: OpenCodeConfig, responses: list[tuple[int, object]]) -> tuple[OpenCodeClient, MockHTTPClient]:
|
||||
def _make_client(
|
||||
config: OpenCodeConfig, responses: list[tuple[int, object]]
|
||||
) -> tuple[OpenCodeClient, MockHTTPClient]:
|
||||
mock = MockHTTPClient()
|
||||
mock.responses = responses
|
||||
return OpenCodeClient(config, http_client=mock), mock
|
||||
@@ -68,22 +71,30 @@ async def test_create_session_posts_title(base_config: OpenCodeConfig) -> None:
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auth_in_fetch_kwargs_when_password_set(base_config: OpenCodeConfig) -> None:
|
||||
async def test_auth_set_on_request_when_password_present(
|
||||
base_config: OpenCodeConfig,
|
||||
) -> None:
|
||||
"""Bug fix: auth must live on the HTTPRequest, not as fetch kwargs
|
||||
(tornado rejects fetch(request, **kwargs) for request-construction kwargs)."""
|
||||
config = base_config._replace(password="secret")
|
||||
client, mock = _make_client(config, [(200, {"status": "ok"})])
|
||||
await client.health()
|
||||
call = mock.calls[0]
|
||||
assert call["kwargs"].get("auth_username") == "opencode"
|
||||
assert call["kwargs"].get("auth_password") == "secret"
|
||||
# No kwargs forwarded to fetch.
|
||||
assert call["kwargs"] == {}
|
||||
# Auth is on the HTTPRequest.
|
||||
assert call["request"].auth_username == "opencode"
|
||||
assert call["request"].auth_password == "secret"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auth_not_in_fetch_kwargs_when_password_empty(base_config: OpenCodeConfig) -> None:
|
||||
async def test_auth_absent_when_no_password(base_config: OpenCodeConfig) -> None:
|
||||
client, mock = _make_client(base_config, [(200, {"status": "ok"})])
|
||||
await client.health()
|
||||
call = mock.calls[0]
|
||||
assert "auth_username" not in call["kwargs"]
|
||||
assert "auth_password" not in call["kwargs"]
|
||||
assert call["kwargs"] == {}
|
||||
assert call["request"].auth_username is None
|
||||
assert call["request"].auth_password is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -118,7 +129,9 @@ async def test_send_message_sync_omits_model_when_provider_or_model_missing(
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_message_sync_includes_system_when_provided() -> None:
|
||||
"""system param is forwarded into the request body when not None."""
|
||||
config = OpenCodeConfig(url="http://x:1", user="u", password="")
|
||||
config = OpenCodeConfig(
|
||||
url="http://x:1", user="u", password="", request_timeout_seconds=120
|
||||
)
|
||||
mock = MockHTTPClient()
|
||||
mock.responses.append((200, {"info": {}, "parts": []}))
|
||||
client = OpenCodeClient(config, http_client=mock)
|
||||
@@ -147,3 +160,24 @@ async def test_non_2xx_response_raises_with_body(base_config: OpenCodeConfig) ->
|
||||
with pytest.raises(OpenCodeError) as exc_info:
|
||||
await client.health()
|
||||
assert "boom" in str(exc_info.value)
|
||||
|
||||
|
||||
def test_close_is_idempotent() -> None:
|
||||
"""close() can be called multiple times safely."""
|
||||
client = OpenCodeClient(OpenCodeConfig(
|
||||
url="http://x", user="u", password="", request_timeout_seconds=10
|
||||
))
|
||||
client.close()
|
||||
client.close() # must not raise
|
||||
assert client._http_client is None
|
||||
|
||||
|
||||
def test_close_does_not_close_injected_client() -> None:
|
||||
"""close() must not call .close() on a client it didn't create."""
|
||||
injected = MagicMock()
|
||||
client = OpenCodeClient(OpenCodeConfig(
|
||||
url="http://x", user="u", password="", request_timeout_seconds=10
|
||||
), http_client=injected)
|
||||
client.close()
|
||||
injected.close.assert_not_called()
|
||||
assert client._http_client is None
|
||||
|
||||
@@ -17,21 +17,23 @@ def test_env_url_overrides_default(monkeypatch) -> None:
|
||||
assert config.url == "http://x:1"
|
||||
|
||||
|
||||
def test_jupyter_settings_override_env(monkeypatch) -> None:
|
||||
def test_jupyter_settings_are_ignored(monkeypatch) -> None:
|
||||
monkeypatch.setenv("OPENCODE_BRIDGE_URL", "http://x:1")
|
||||
settings = {"opencode_bridge": {"opencodeServerUrl": "http://y:2"}}
|
||||
config = resolve_config(settings)
|
||||
assert config.url == "http://y:2"
|
||||
assert config.url == "http://x:1"
|
||||
|
||||
|
||||
def test_all_env_vars(monkeypatch) -> None:
|
||||
monkeypatch.setenv("OPENCODE_BRIDGE_URL", "http://x:1")
|
||||
monkeypatch.setenv("OPENCODE_BRIDGE_USER", "u")
|
||||
monkeypatch.setenv("OPENCODE_BRIDGE_PASSWORD", "p")
|
||||
monkeypatch.setenv("OPENCODE_BRIDGE_TIMEOUT", "300")
|
||||
config = resolve_config({})
|
||||
assert config.url == "http://x:1"
|
||||
assert config.user == "u"
|
||||
assert config.password == "p"
|
||||
assert config.request_timeout_seconds == 300
|
||||
|
||||
|
||||
def test_auth_none_when_password_empty() -> None:
|
||||
@@ -44,11 +46,12 @@ def test_auth_when_password_set() -> None:
|
||||
url="http://127.0.0.1:4096",
|
||||
user="opencode",
|
||||
password="secret",
|
||||
request_timeout_seconds=120,
|
||||
)
|
||||
assert config.auth == ("opencode", "secret")
|
||||
|
||||
|
||||
def test_request_timeout_from_settings() -> None:
|
||||
settings = {"opencode_bridge": {"requestTimeoutSeconds": 300}}
|
||||
config = resolve_config(settings)
|
||||
def test_request_timeout_from_env(monkeypatch) -> None:
|
||||
monkeypatch.setenv("OPENCODE_BRIDGE_TIMEOUT", "300")
|
||||
config = resolve_config({})
|
||||
assert config.request_timeout_seconds == 300
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import json
|
||||
|
||||
import pytest
|
||||
import tornado.httpclient
|
||||
|
||||
|
||||
class FakeOpenCodeClient:
|
||||
"""Drop-in replacement for OpenCodeClient with recording + canned responses."""
|
||||
@@ -13,6 +16,18 @@ class FakeOpenCodeClient:
|
||||
"info": {"id": "msg-1"},
|
||||
"parts": [{"type": "text", "text": "def foo():\n return 42\n"}],
|
||||
}
|
||||
# Canned session-messages list response (list_session_messages).
|
||||
# Default: one user + one assistant message, mixed text parts.
|
||||
self.messages_response = [
|
||||
{
|
||||
"info": {"role": "user", "id": "m1"},
|
||||
"parts": [{"type": "text", "text": "fix the bug"}],
|
||||
},
|
||||
{
|
||||
"info": {"role": "assistant", "id": "m2"},
|
||||
"parts": [{"type": "text", "text": "```python\nx = 1\n```"}],
|
||||
},
|
||||
]
|
||||
|
||||
@property
|
||||
def endpoint(self):
|
||||
@@ -38,6 +53,10 @@ class FakeOpenCodeClient:
|
||||
self.calls.append(("delete_session", session_id))
|
||||
return True
|
||||
|
||||
async def list_session_messages(self, session_id):
|
||||
self.calls.append(("list_session_messages", session_id))
|
||||
return self.messages_response
|
||||
|
||||
|
||||
class FakeSessionManager:
|
||||
"""Drop-in replacement for SessionManager with recording."""
|
||||
@@ -50,6 +69,12 @@ class FakeSessionManager:
|
||||
self.calls.append(("get_or_create", notebook_path))
|
||||
return self._session_id
|
||||
|
||||
def peek(self, notebook_path: str):
|
||||
# Mirror SessionManager.peek: return the session id without
|
||||
# creating one. None means "no session yet" (used by the no-session
|
||||
# test to short-circuit the history route).
|
||||
return self._session_id or None
|
||||
|
||||
async def release(self, notebook_path: str) -> bool:
|
||||
self.calls.append(("release", notebook_path))
|
||||
return True
|
||||
@@ -115,7 +140,6 @@ async def test_edit_handler(monkeypatch, jp_fetch):
|
||||
)
|
||||
|
||||
body = json.dumps({
|
||||
"mode": "edit",
|
||||
"prompt": "Add type hints",
|
||||
"context": {
|
||||
"notebookPath": "test.ipynb",
|
||||
@@ -136,7 +160,9 @@ async def test_edit_handler(monkeypatch, jp_fetch):
|
||||
assert response.code == 200
|
||||
payload = json.loads(response.body)
|
||||
assert payload["ok"] is True
|
||||
assert payload["finalSource"] == "def foo():\n return 42"
|
||||
# Server now returns the AI reply as raw markdown (```fences``` kept
|
||||
# so the frontend marked.parse renders code blocks).
|
||||
assert payload["markdown"] == "def foo():\n return 42"
|
||||
assert payload["sessionId"] == "fake-session-123"
|
||||
assert payload["notebookPath"] == "test.ipynb"
|
||||
|
||||
@@ -149,8 +175,8 @@ async def test_edit_handler(monkeypatch, jp_fetch):
|
||||
# send_message_sync received the session ID from the manager
|
||||
send_call = [c for c in fake.calls if c[0] == "send_message_sync"][0]
|
||||
assert send_call[1] == "fake-session-123"
|
||||
# system prompt was passed
|
||||
assert "你是一个代码编辑助手" in send_call[3]
|
||||
# system prompt was passed (v3+ allows markdown/fences in the reply)
|
||||
assert "你是一个代码助手" in send_call[3]
|
||||
|
||||
# SessionManager.get_or_create was called with the notebook path
|
||||
sm_call_names = [c[0] for c in fake_sm.calls]
|
||||
@@ -176,6 +202,71 @@ async def test_session_list_handler(monkeypatch, jp_fetch):
|
||||
assert paths == {"foo.ipynb", "bar.ipynb"}
|
||||
|
||||
|
||||
async def test_session_messages_handler_returns_empty_when_no_session(
|
||||
monkeypatch, jp_fetch
|
||||
) -> None:
|
||||
# No session registered for this notebook -> handler returns
|
||||
# {"messages": []} WITHOUT calling OpenCodeClient (peek short-circuits).
|
||||
fake = FakeOpenCodeClient()
|
||||
monkeypatch.setattr("opencode_bridge.routes.make_client", lambda h: fake)
|
||||
|
||||
fake_sm = FakeSessionManager(session_id="")
|
||||
monkeypatch.setattr(
|
||||
"opencode_bridge.routes.get_session_manager", lambda h: fake_sm
|
||||
)
|
||||
|
||||
response = await jp_fetch(
|
||||
"opencode-bridge", "session-messages",
|
||||
method="GET",
|
||||
params={"notebook": "fresh.ipynb"},
|
||||
)
|
||||
assert response.code == 200
|
||||
payload = json.loads(response.body)
|
||||
assert payload == {"messages": []}
|
||||
# No OpenCode call was made.
|
||||
assert all(c[0] != "list_session_messages" for c in fake.calls)
|
||||
|
||||
|
||||
async def test_session_messages_handler_projects_opencode_messages(
|
||||
monkeypatch, jp_fetch
|
||||
) -> None:
|
||||
fake = FakeOpenCodeClient()
|
||||
monkeypatch.setattr("opencode_bridge.routes.make_client", lambda h: fake)
|
||||
|
||||
fake_sm = FakeSessionManager(session_id="fake-session-123")
|
||||
monkeypatch.setattr(
|
||||
"opencode_bridge.routes.get_session_manager", lambda h: fake_sm
|
||||
)
|
||||
|
||||
response = await jp_fetch(
|
||||
"opencode-bridge", "session-messages",
|
||||
method="GET",
|
||||
params={"notebook": "test.ipynb"},
|
||||
)
|
||||
assert response.code == 200
|
||||
payload = json.loads(response.body)
|
||||
assert payload == {
|
||||
"messages": [
|
||||
{"role": "user", "content": "fix the bug"},
|
||||
{"role": "assistant", "content": "```python\nx = 1\n```"},
|
||||
]
|
||||
}
|
||||
# The OpenCode client was called with the session id from the manager.
|
||||
assert ("list_session_messages", "fake-session-123") in fake.calls
|
||||
|
||||
|
||||
async def test_session_messages_handler_requires_notebook(
|
||||
monkeypatch, jp_fetch
|
||||
) -> None:
|
||||
fake = FakeOpenCodeClient()
|
||||
monkeypatch.setattr("opencode_bridge.routes.make_client", lambda h: fake)
|
||||
# jp_fetch raises HTTPClientError on 4xx; assert the handler 400s
|
||||
# (the body would contain "missing 'notebook' query parameter").
|
||||
with pytest.raises(tornado.httpclient.HTTPClientError) as exc_info:
|
||||
await jp_fetch("opencode-bridge", "session-messages", method="GET")
|
||||
assert exc_info.value.code == 400
|
||||
|
||||
|
||||
async def test_session_release_handler(monkeypatch, jp_fetch):
|
||||
fake = FakeOpenCodeClient()
|
||||
monkeypatch.setattr("opencode_bridge.routes.make_client", lambda h: fake)
|
||||
|
||||
+4
-39
@@ -1,46 +1,11 @@
|
||||
{
|
||||
"jupyter.lab.shortcuts": [],
|
||||
"jupyter.lab.toolbars": {
|
||||
"Cell": [{ "name": "opencode-cell-actions", "rank": 100 }]
|
||||
},
|
||||
"title": "opencode_bridge",
|
||||
"description": "opencode_bridge settings.",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"opencodeServerUrl": {
|
||||
"type": "string",
|
||||
"title": "OpenCode Server URL",
|
||||
"description": "opencode serve 监听的 HTTP 地址。覆盖 OPENCODE_BRIDGE_URL 环境变量。",
|
||||
"default": "http://127.0.0.1:4096"
|
||||
},
|
||||
"opencodeServerUser": {
|
||||
"type": "string",
|
||||
"title": "OpenCode Server 用户名",
|
||||
"description": "HTTP Basic Auth 用户名。默认 'opencode'。可被 OPENCODE_BRIDGE_USER 环境变量覆盖。",
|
||||
"default": "opencode"
|
||||
},
|
||||
"opencodeServerPassword": {
|
||||
"type": "string",
|
||||
"title": "OpenCode Server 密码",
|
||||
"description": "HTTP Basic Auth 密码。空字符串表示无认证。可被 OPENCODE_BRIDGE_PASSWORD 环境变量覆盖。",
|
||||
"default": ""
|
||||
},
|
||||
"opencodeProvider": {
|
||||
"type": "string",
|
||||
"title": "OpenCode Provider",
|
||||
"description": "Provider id (e.g. 'anthropic' or 'openai'). Get available values from the browser console after activating this extension (logged on startup), or hit GET /opencode-bridge/providers directly. Leave empty to use OpenCode's default.",
|
||||
"default": ""
|
||||
},
|
||||
"opencodeModel": {
|
||||
"type": "string",
|
||||
"title": "OpenCode Model",
|
||||
"description": "Model id (e.g. 'claude-sonnet-4-20250514'). Get available values from the browser console after activation. Leave empty to use the provider's default.",
|
||||
"default": ""
|
||||
},
|
||||
"requestTimeoutSeconds": {
|
||||
"type": "integer",
|
||||
"title": "请求超时(秒)",
|
||||
"default": 120,
|
||||
"minimum": 5,
|
||||
"maximum": 600
|
||||
}
|
||||
},
|
||||
"properties": {},
|
||||
"additionalProperties": false
|
||||
}
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
import { DEFAULT_OPENCODE_SETTINGS, readOpenCodeSettings } from '../types';
|
||||
|
||||
describe('opencode_bridge types', () => {
|
||||
it('returns defaults when given empty composite', () => {
|
||||
const s = readOpenCodeSettings({});
|
||||
expect(s).toEqual(DEFAULT_OPENCODE_SETTINGS);
|
||||
expect(s.opencodeProvider).toBe('');
|
||||
expect(s.opencodeModel).toBe('');
|
||||
});
|
||||
|
||||
it('overrides defaults with user values', () => {
|
||||
const s = readOpenCodeSettings({
|
||||
opencodeServerUrl: 'http://x:1',
|
||||
opencodeProvider: 'anthropic',
|
||||
opencodeModel: 'claude-sonnet-4-20250514'
|
||||
});
|
||||
expect(s.opencodeServerUrl).toBe('http://x:1');
|
||||
expect(s.opencodeProvider).toBe('anthropic');
|
||||
expect(s.opencodeModel).toBe('claude-sonnet-4-20250514');
|
||||
expect(s.opencodeServerUser).toBe(
|
||||
DEFAULT_OPENCODE_SETTINGS.opencodeServerUser
|
||||
);
|
||||
});
|
||||
|
||||
it('treats empty string provider/model as "use default"', () => {
|
||||
const s = readOpenCodeSettings({
|
||||
opencodeProvider: '',
|
||||
opencodeModel: ''
|
||||
});
|
||||
expect(s.opencodeProvider).toBe('');
|
||||
expect(s.opencodeModel).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,420 @@
|
||||
/**
|
||||
* Unit tests for OpenCodeCellActions (single AI button) and OpenCodeInlinePrompt
|
||||
* (inline input panel inside the cell). v3-final: no settings — the model
|
||||
* picker is fully dynamic; default selection is the first provider/model.
|
||||
*
|
||||
* 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;
|
||||
if (this.node.parentNode) {
|
||||
this.node.parentNode.removeChild(this.node);
|
||||
}
|
||||
}
|
||||
public static attach(widget: Widget, host: HTMLElement): void {
|
||||
host.appendChild(widget.node);
|
||||
}
|
||||
public onAfterAttach(_msg: any): void {
|
||||
// no-op
|
||||
}
|
||||
}
|
||||
return { Widget };
|
||||
});
|
||||
|
||||
jest.mock('marked', () => ({
|
||||
__esModule: true,
|
||||
marked: { parse: (md: string) => `<mock>${md}</mock>` }
|
||||
}));
|
||||
|
||||
// Mock the network-touching API module so jsdom tests don't try to hit
|
||||
// a real notebook server (and to keep the refresh-history logic decoupled
|
||||
// from the network). The cell_actions tests focus on widget behavior.
|
||||
jest.mock('../api/opencode_client', () => ({
|
||||
callOpenCodeEdit: jest.fn(),
|
||||
callOpenCodeProviders: jest.fn(),
|
||||
callOpenCodeSessionMessages: jest.fn().mockResolvedValue({ messages: [] })
|
||||
}));
|
||||
|
||||
import {
|
||||
OpenCodeCellActions,
|
||||
setOpenCodeProviders,
|
||||
setOpenCodeServerSettings
|
||||
} from '../components/opencode_cell_actions';
|
||||
import { OpenCodeInlinePrompt } from '../components/opencode_inline_prompt';
|
||||
import { CodeCell } from '@jupyterlab/cells';
|
||||
|
||||
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;
|
||||
(cell as any).node = document.createElement('div');
|
||||
|
||||
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('allows server settings to be injected', () => {
|
||||
setOpenCodeServerSettings({} as any);
|
||||
});
|
||||
|
||||
it('renders a single AI button', () => {
|
||||
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('AI');
|
||||
});
|
||||
|
||||
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);
|
||||
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();
|
||||
expect(cell.node.querySelector('.opencode-inline-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();
|
||||
});
|
||||
|
||||
it('renders provider and model selects; model default uses default[provider] when it matches', () => {
|
||||
setOpenCodeProviders({
|
||||
providers: [
|
||||
{
|
||||
id: 'anthropic',
|
||||
name: 'Anthropic',
|
||||
models: {
|
||||
'claude-sonnet-4-20250514': { id: 'claude-sonnet-4-20250514' },
|
||||
'claude-haiku-4-5': { id: 'claude-haiku-4-5' }
|
||||
}
|
||||
},
|
||||
{ id: 'openai', models: { 'gpt-5': { id: 'gpt-5' } } }
|
||||
],
|
||||
default: {
|
||||
anthropic: 'claude-sonnet-4-20250514',
|
||||
openai: 'gpt-5'
|
||||
}
|
||||
});
|
||||
const cell = makeFakeCell('x = 1');
|
||||
const actions = new OpenCodeCellActions(cell);
|
||||
Object.defineProperty(actions, 'parent', { value: cell, configurable: true });
|
||||
(actions as any).onAfterAttach({} as any);
|
||||
|
||||
const btn = actions.node.querySelector('button') as HTMLButtonElement;
|
||||
btn.click();
|
||||
|
||||
const prompt = cell.node.querySelector(
|
||||
'.opencode-inline-prompt'
|
||||
) as HTMLElement;
|
||||
const providerSel = prompt.querySelector(
|
||||
'.opencode-provider-select'
|
||||
) as HTMLSelectElement;
|
||||
const modelSel = prompt.querySelector(
|
||||
'.opencode-model-select'
|
||||
) as HTMLSelectElement;
|
||||
expect(providerSel).not.toBeNull();
|
||||
expect(modelSel).not.toBeNull();
|
||||
|
||||
// Provider: first provider selected by default.
|
||||
expect(providerSel.options.length).toBe(2);
|
||||
expect(providerSel.options[0].value).toBe('anthropic');
|
||||
expect(providerSel.options[0].textContent).toContain('anthropic');
|
||||
expect(providerSel.options[0].textContent).toContain('Anthropic');
|
||||
expect(providerSel.value).toBe('anthropic');
|
||||
|
||||
// Model: options are that provider's model keys, and the default
|
||||
// matches default['anthropic'] = 'claude-sonnet-4-20250514'.
|
||||
expect(modelSel.options.length).toBe(2);
|
||||
expect(modelSel.options[0].value).toBe('claude-sonnet-4-20250514');
|
||||
expect(modelSel.options[1].value).toBe('claude-haiku-4-5');
|
||||
expect(modelSel.value).toBe('claude-sonnet-4-20250514');
|
||||
|
||||
setOpenCodeProviders(null);
|
||||
});
|
||||
|
||||
it('falls back to the first model when default[provider] is not in models', () => {
|
||||
setOpenCodeProviders({
|
||||
providers: [
|
||||
{ id: 'bailian', models: { 'kimi/kimi-k2.7-code': { id: 'kimi/kimi-k2.7-code' } } }
|
||||
],
|
||||
// default['bailian'] references a modelID not present in models.
|
||||
default: { bailian: 'qwen3.7-plus' }
|
||||
});
|
||||
const cell = makeFakeCell('x = 1');
|
||||
const actions = new OpenCodeCellActions(cell);
|
||||
Object.defineProperty(actions, 'parent', { value: cell, configurable: true });
|
||||
(actions as any).onAfterAttach({} as any);
|
||||
|
||||
const btn = actions.node.querySelector('button') as HTMLButtonElement;
|
||||
btn.click();
|
||||
|
||||
const modelSel = cell.node.querySelector(
|
||||
'.opencode-inline-prompt .opencode-model-select'
|
||||
) as HTMLSelectElement;
|
||||
expect(modelSel.value).toBe('kimi/kimi-k2.7-code');
|
||||
setOpenCodeProviders(null);
|
||||
});
|
||||
|
||||
it('rebuilds the model select when the provider select changes', () => {
|
||||
setOpenCodeProviders({
|
||||
providers: [
|
||||
{
|
||||
id: 'anthropic',
|
||||
models: { 'claude-sonnet-4-20250514': { id: 'claude-sonnet-4-20250514' } }
|
||||
},
|
||||
{
|
||||
id: 'openai',
|
||||
models: { 'gpt-5': { id: 'gpt-5' } }
|
||||
}
|
||||
],
|
||||
default: { openai: 'gpt-5' }
|
||||
});
|
||||
const cell = makeFakeCell('x = 1');
|
||||
const actions = new OpenCodeCellActions(cell);
|
||||
Object.defineProperty(actions, 'parent', { value: cell, configurable: true });
|
||||
(actions as any).onAfterAttach({} as any);
|
||||
|
||||
const btn = actions.node.querySelector('button') as HTMLButtonElement;
|
||||
btn.click();
|
||||
|
||||
const prompt = cell.node.querySelector(
|
||||
'.opencode-inline-prompt'
|
||||
) as HTMLElement;
|
||||
const providerSel = prompt.querySelector(
|
||||
'.opencode-provider-select'
|
||||
) as HTMLSelectElement;
|
||||
const modelSel = prompt.querySelector(
|
||||
'.opencode-model-select'
|
||||
) as HTMLSelectElement;
|
||||
|
||||
// Initial: anthropic, its only model.
|
||||
expect(providerSel.value).toBe('anthropic');
|
||||
expect(modelSel.options.length).toBe(1);
|
||||
expect(modelSel.value).toBe('claude-sonnet-4-20250514');
|
||||
|
||||
// Switch to openai: model select rebuilds with openai's model and
|
||||
// default['openai'] = 'gpt-5' is in openai's models so it matches.
|
||||
providerSel.value = 'openai';
|
||||
providerSel.dispatchEvent(new Event('change'));
|
||||
expect(modelSel.options.length).toBe(1);
|
||||
expect(modelSel.options[0].value).toBe('gpt-5');
|
||||
expect(modelSel.value).toBe('gpt-5');
|
||||
|
||||
setOpenCodeProviders(null);
|
||||
});
|
||||
|
||||
it('does not render the selector when providers are not cached', () => {
|
||||
setOpenCodeProviders(null);
|
||||
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();
|
||||
|
||||
expect(
|
||||
cell.node.querySelector('.opencode-inline-prompt .opencode-provider-select')
|
||||
).toBeNull();
|
||||
expect(
|
||||
cell.node.querySelector('.opencode-inline-prompt .opencode-model-select')
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('on a successful response, does NOT replace the cell source and triggers a history refresh', () => {
|
||||
setOpenCodeProviders(null);
|
||||
const cell = makeFakeCell('x = 1');
|
||||
const actions = new OpenCodeCellActions(cell);
|
||||
// Click once to create the inline prompt (so the history refresh
|
||||
// has a target and the cell source check has a baseline).
|
||||
Object.defineProperty(actions, 'parent', { value: cell, configurable: true });
|
||||
(actions as any).onAfterAttach({} as any);
|
||||
const btn = actions.node.querySelector('button') as HTMLButtonElement;
|
||||
btn.click();
|
||||
|
||||
// The history refresh on show is async; spy on _refreshHistory to
|
||||
// confirm it gets called (without depending on the network).
|
||||
const refreshSpy = jest.spyOn(actions as any, '_refreshHistory');
|
||||
|
||||
(actions as any)._handleResponse({
|
||||
ok: true,
|
||||
markdown: '# AI says hi\n\n```python\nprint("hi")\n```',
|
||||
sessionId: 'ses-abc',
|
||||
notebookPath: 'foo.ipynb'
|
||||
});
|
||||
|
||||
// The cell source must remain unchanged.
|
||||
expect((cell as any).model.sharedModel.setSource).not.toHaveBeenCalled();
|
||||
// A history refresh was triggered (to pick up the new assistant msg).
|
||||
expect(refreshSpy).toHaveBeenCalledWith('foo.ipynb');
|
||||
});
|
||||
});
|
||||
|
||||
describe('OpenCodeInlinePrompt', () => {
|
||||
it('renders a textarea, a send and a cancel button (no selector when providers null)', () => {
|
||||
const cell = makeFakeCell('x = 1');
|
||||
const prompt = new OpenCodeInlinePrompt(cell, {
|
||||
onSubmit: jest.fn(),
|
||||
onCancel: jest.fn(),
|
||||
disabled: false,
|
||||
providers: null
|
||||
});
|
||||
expect(prompt.node.querySelector('textarea')).not.toBeNull();
|
||||
// 2 buttons: 发送 and 取消 (no more output-area close button; the
|
||||
// scrollable history area is the display).
|
||||
expect(prompt.node.querySelectorAll('button').length).toBe(2);
|
||||
});
|
||||
|
||||
it('renders a history area; setMessages renders user text + assistant markdown and scrolls to bottom', () => {
|
||||
const cell = makeFakeCell('x = 1');
|
||||
const prompt = new OpenCodeInlinePrompt(cell, {
|
||||
onSubmit: jest.fn(),
|
||||
onCancel: jest.fn(),
|
||||
disabled: false,
|
||||
providers: null
|
||||
});
|
||||
const history = prompt.node.querySelector(
|
||||
'.opencode-inline-prompt .opencode-inline-history'
|
||||
) as HTMLElement;
|
||||
expect(history).not.toBeNull();
|
||||
// Initially empty (no messages yet).
|
||||
expect(history.children.length).toBe(0);
|
||||
|
||||
prompt.setMessages([
|
||||
{ role: 'user', content: 'fix the bug' },
|
||||
{
|
||||
role: 'assistant',
|
||||
content: '# Here you go\n\n```python\nx = 1\n```'
|
||||
}
|
||||
]);
|
||||
expect(history.children.length).toBe(2);
|
||||
const userMsg = history.querySelector('.opencode-msg-user') as HTMLElement;
|
||||
const asstMsg = history.querySelector('.opencode-msg-assistant') as HTMLElement;
|
||||
expect(userMsg).not.toBeNull();
|
||||
// User messages are plain text (not rendered as HTML).
|
||||
expect(userMsg.textContent).toBe('fix the bug');
|
||||
expect(userMsg.innerHTML).not.toContain('<');
|
||||
// Assistant messages are rendered as markdown via marked (mocked).
|
||||
expect(asstMsg).not.toBeNull();
|
||||
expect(asstMsg.innerHTML).toContain('<mock>');
|
||||
expect(asstMsg.innerHTML).toContain('# Here you go');
|
||||
});
|
||||
});
|
||||
@@ -1,156 +0,0 @@
|
||||
/**
|
||||
* Unit tests for OpenCodeCellFooter DOM rendering and button disabled states.
|
||||
*
|
||||
* Mocks @jupyterlab/cells, @jupyterlab/apputils, and @lumino/widgets at the
|
||||
* test boundary so the real ESM packages are not loaded (Jest + pnpm can't
|
||||
* transform the hoisted @jupyterlab/* packages out of the box).
|
||||
*/
|
||||
|
||||
// Mock the heavy JupyterLab modules BEFORE importing the component.
|
||||
// (jest.mock is hoisted by Jest, but listing it before imports is clearer.)
|
||||
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 addClass(cls: string): void {
|
||||
this.node.classList.add(cls);
|
||||
}
|
||||
public id: string = '';
|
||||
public parent: Widget | null = null;
|
||||
protected onAfterAttach(_msg: unknown): void {
|
||||
/* overridden by subclass */
|
||||
}
|
||||
protected onBeforeDetach(_msg: unknown): void {
|
||||
/* overridden by subclass */
|
||||
}
|
||||
}
|
||||
return { Widget };
|
||||
});
|
||||
|
||||
import { OpenCodeCellFooter } from '../components/opencode_cell_footer';
|
||||
import { CodeCell } from '@jupyterlab/cells';
|
||||
|
||||
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
|
||||
},
|
||||
outputs: makeFakeOutputs(errorOutputs),
|
||||
// Signal stubs so the component can connect/disconnect without crashing.
|
||||
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;
|
||||
|
||||
// Build a fake parent chain: cell -> notebook -> panel.
|
||||
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);
|
||||
}
|
||||
|
||||
// The footer's helper walks `cell.parent` looking for a widget with
|
||||
// `context` + `content.widgets`. The cell's grandparent (notebook) is what
|
||||
// matches in real JupyterLab. We mirror that here.
|
||||
Object.defineProperty(cell, 'parent', { value: notebook, configurable: true });
|
||||
Object.defineProperty(notebook, 'parent', { value: panel, configurable: true });
|
||||
|
||||
return cell as CodeCell;
|
||||
}
|
||||
|
||||
describe('OpenCodeCellFooter', () => {
|
||||
it('renders 3 buttons', () => {
|
||||
const footer = new OpenCodeCellFooter();
|
||||
const btns = footer.node.querySelectorAll('button');
|
||||
expect(btns.length).toBe(3);
|
||||
expect(btns[0].textContent).toBe('✨ 优化');
|
||||
expect(btns[1].textContent).toBe('🐛 排错');
|
||||
expect(btns[2].textContent).toBe('🪄 编辑');
|
||||
});
|
||||
|
||||
it('disables all buttons when no cell is attached', () => {
|
||||
const footer = new OpenCodeCellFooter();
|
||||
const btns = footer.node.querySelectorAll('button');
|
||||
btns.forEach((b: HTMLButtonElement) => {
|
||||
expect(b.disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('enables optimize and edit when a cell is attached; fix stays disabled without an error', () => {
|
||||
const cell = makeFakeCell('x = 1', [], 'foo.ipynb', 0, 1);
|
||||
const footer = new OpenCodeCellFooter();
|
||||
Object.defineProperty(footer, 'parent', { value: cell, configurable: true });
|
||||
(footer as any).onAfterAttach({} as any);
|
||||
|
||||
const btns = footer.node.querySelectorAll('button');
|
||||
const optimize = btns[0] as HTMLButtonElement;
|
||||
const fix = btns[1] as HTMLButtonElement;
|
||||
const edit = btns[2] as HTMLButtonElement;
|
||||
expect(optimize.disabled).toBe(false);
|
||||
expect(fix.disabled).toBe(true);
|
||||
expect(edit.disabled).toBe(false);
|
||||
});
|
||||
|
||||
it('enables the fix button when the attached cell has an error output', () => {
|
||||
const cell = makeFakeCell(
|
||||
'1/0',
|
||||
[
|
||||
{
|
||||
type: 'error',
|
||||
ename: 'ZeroDivisionError',
|
||||
evalue: 'div by zero',
|
||||
traceback: []
|
||||
}
|
||||
],
|
||||
'foo.ipynb',
|
||||
0,
|
||||
1
|
||||
);
|
||||
const footer = new OpenCodeCellFooter();
|
||||
Object.defineProperty(footer, 'parent', { value: cell, configurable: true });
|
||||
(footer as any).onAfterAttach({} as any);
|
||||
|
||||
const btns = footer.node.querySelectorAll('button');
|
||||
const fix = btns[1] as HTMLButtonElement;
|
||||
expect(fix.disabled).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -64,7 +64,6 @@ function mockResponse(overrides: {
|
||||
}
|
||||
|
||||
const sampleRequest: OpenCodeRequest = {
|
||||
mode: 'edit',
|
||||
prompt: 'add type hints',
|
||||
context: {
|
||||
notebookPath: 'foo.ipynb',
|
||||
@@ -86,8 +85,7 @@ describe('callOpenCodeEdit', () => {
|
||||
it('POSTs to /opencode-bridge/edit with JSON body', async () => {
|
||||
const respBody = {
|
||||
ok: true,
|
||||
mode: 'edit',
|
||||
finalSource: 'def foo(x: int) -> int: return x',
|
||||
markdown: '```python\ndef foo(x: int) -> int: return x\n```',
|
||||
sessionId: 'sid',
|
||||
notebookPath: 'foo.ipynb',
|
||||
};
|
||||
@@ -109,7 +107,7 @@ describe('callOpenCodeEdit', () => {
|
||||
);
|
||||
expect(resp.ok).toBe(true);
|
||||
if (resp.ok) {
|
||||
expect(resp.finalSource).toContain('int');
|
||||
expect(resp.markdown).toContain('int');
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
import { URLExt } from '@jupyterlab/coreutils';
|
||||
import { ServerConnection } from '@jupyterlab/services';
|
||||
|
||||
import type { OpenCodeProvidersResponse, OpenCodeRequest, OpenCodeResponse } from '../types';
|
||||
import type { OpenCodeProvidersResponse, OpenCodeRequest, OpenCodeResponse, OpenCodeMessagesResponse } from '../types';
|
||||
|
||||
/**
|
||||
* Call POST /opencode-bridge/edit. Returns parsed response (success or failure).
|
||||
@@ -54,6 +54,37 @@ export async function callOpenCodeEdit(
|
||||
return parsed as OpenCodeResponse;
|
||||
}
|
||||
|
||||
/**
|
||||
* Call GET /opencode-bridge/session-messages?notebook=<path>. Returns the
|
||||
* current session's messages (projected to {role, content}[]) for the
|
||||
* given notebook, or an empty list if no session exists yet.
|
||||
*/
|
||||
export async function callOpenCodeSessionMessages(
|
||||
notebookPath: string,
|
||||
serverSettings: ServerConnection.ISettings
|
||||
): Promise<OpenCodeMessagesResponse> {
|
||||
const url =
|
||||
URLExt.join(serverSettings.baseUrl, 'opencode-bridge', 'session-messages') +
|
||||
'?notebook=' +
|
||||
encodeURIComponent(notebookPath);
|
||||
|
||||
const init: RequestInit = { method: 'GET' };
|
||||
let response: Response;
|
||||
try {
|
||||
response = await ServerConnection.makeRequest(url, init, serverSettings);
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`network error calling opencode-bridge/session-messages: ${(error as Error).message}`
|
||||
);
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`opencode-bridge/session-messages failed: ${response.status} ${response.statusText}`
|
||||
);
|
||||
}
|
||||
return (await response.json()) as OpenCodeMessagesResponse;
|
||||
}
|
||||
|
||||
/**
|
||||
* Call GET /opencode-bridge/providers. Returns the parsed JSON response.
|
||||
* Throws on network error or non-2xx status.
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
/**
|
||||
* 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', ...).
|
||||
*
|
||||
* v3-final: 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 chosen provider/model comes from the inline
|
||||
* picker (no settings-based default).
|
||||
*/
|
||||
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, callOpenCodeSessionMessages } from '../api/opencode_client';
|
||||
import { extractCellContext } from '../context/cell_context';
|
||||
import type {
|
||||
CellContext,
|
||||
OpenCodeProvidersResponse,
|
||||
OpenCodeRequest,
|
||||
OpenCodeResponse
|
||||
} from '../types';
|
||||
|
||||
import { OpenCodeInlinePrompt } from './opencode_inline_prompt';
|
||||
|
||||
// Module-level runtime injection (set by index.ts after activation).
|
||||
let _serverSettings: ServerConnection.ISettings | null = null;
|
||||
let _providers: OpenCodeProvidersResponse | null = null;
|
||||
|
||||
export function setOpenCodeServerSettings(
|
||||
serverSettings: ServerConnection.ISettings
|
||||
): void {
|
||||
_serverSettings = serverSettings;
|
||||
}
|
||||
|
||||
export function setOpenCodeProviders(
|
||||
p: OpenCodeProvidersResponse | null
|
||||
): void {
|
||||
_providers = p;
|
||||
}
|
||||
|
||||
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');
|
||||
} else {
|
||||
this._render();
|
||||
}
|
||||
}
|
||||
|
||||
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',
|
||||
providers: _providers,
|
||||
onSubmit: (text: string, providerId?: string, modelId?: string) => {
|
||||
void this._onSubmit(text, providerId, modelId);
|
||||
},
|
||||
onCancel: () => {
|
||||
this._hidePrompt();
|
||||
}
|
||||
});
|
||||
Widget.attach(prompt, this._cell.node);
|
||||
this._prompt = prompt;
|
||||
|
||||
// Fetch the current session's message history and render it into
|
||||
// the prompt's scrollable history area. Empty list (no session yet)
|
||||
// is a normal no-op render.
|
||||
const notebookPath = this._context?.notebookPath;
|
||||
if (notebookPath) {
|
||||
void this._refreshHistory(notebookPath);
|
||||
}
|
||||
}
|
||||
|
||||
private async _refreshHistory(notebookPath: string): Promise<void> {
|
||||
if (!_serverSettings || !this._prompt) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const resp = await callOpenCodeSessionMessages(
|
||||
notebookPath,
|
||||
_serverSettings
|
||||
);
|
||||
this._prompt.setMessages(resp.messages);
|
||||
} catch (e) {
|
||||
// Non-fatal: the history is a convenience. The user can still
|
||||
// send new prompts; just the scrollback won't update.
|
||||
console.warn('opencode_bridge: failed to fetch session messages', e);
|
||||
}
|
||||
}
|
||||
|
||||
private _hidePrompt(): void {
|
||||
if (this._prompt) {
|
||||
this._prompt.dispose();
|
||||
this._prompt = null;
|
||||
}
|
||||
}
|
||||
|
||||
private async _onSubmit(
|
||||
text: string,
|
||||
providerId?: string,
|
||||
modelId?: string
|
||||
): Promise<void> {
|
||||
if (!this._context) {
|
||||
return;
|
||||
}
|
||||
if (!_serverSettings) {
|
||||
Notification.error('OpenCode 运行时未初始化');
|
||||
return;
|
||||
}
|
||||
|
||||
const request: OpenCodeRequest = {
|
||||
prompt: text,
|
||||
context: this._context,
|
||||
providerId,
|
||||
modelId
|
||||
};
|
||||
|
||||
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) {
|
||||
// Refetch the (now-updated) session history and render the new
|
||||
// assistant message as the last item in the scrollable history.
|
||||
// The cell source is NOT replaced.
|
||||
const notebookPath = this._context?.notebookPath;
|
||||
if (notebookPath) {
|
||||
void this._refreshHistory(notebookPath);
|
||||
}
|
||||
Notification.info(
|
||||
`OpenCode 完成 (${resp.markdown.length} chars). Session: ${resp.sessionId.slice(0, 8)}…`
|
||||
);
|
||||
} 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);
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
/**
|
||||
* Custom Cell.ContentFactory that adds our OpenCodeCellFooter to every cell.
|
||||
*
|
||||
* One factory instance per notebook; install via `installOpenCodeInNotebook`.
|
||||
*/
|
||||
import { ICellFooter } from '@jupyterlab/cells';
|
||||
import { Notebook } from '@jupyterlab/notebook';
|
||||
|
||||
import { OpenCodeCellFooter } from './opencode_cell_footer';
|
||||
|
||||
export class OpenCodeCellContentFactory extends Notebook.ContentFactory {
|
||||
createCellFooter(): ICellFooter {
|
||||
return new OpenCodeCellFooter();
|
||||
}
|
||||
}
|
||||
@@ -1,199 +0,0 @@
|
||||
/**
|
||||
* Per-cell footer widget: renders 3 buttons (optimize / fix / edit) and
|
||||
* wires them to the opencode-bridge server.
|
||||
*
|
||||
* Resolves its owning CodeCell at onAfterAttach via `this.parent`.
|
||||
* Subscribes to the cell model's contentChanged and stateChanged signals
|
||||
* to keep the context fresh.
|
||||
*/
|
||||
import { 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,
|
||||
OpenCodeMode,
|
||||
OpenCodeRequest,
|
||||
OpenCodeResponse,
|
||||
OpenCodeSettings,
|
||||
} from '../types';
|
||||
|
||||
// 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' | 'error';
|
||||
|
||||
export class OpenCodeCellFooter extends Widget {
|
||||
private _cell: CodeCell | null = null;
|
||||
private _context: CellContext | null = null;
|
||||
private _status: Status = 'idle';
|
||||
private _errorMessage: string | null = null;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.addClass('opencode-cell-footer');
|
||||
this._render();
|
||||
}
|
||||
|
||||
protected onAfterAttach(_msg: unknown): void {
|
||||
const parent = this.parent;
|
||||
if (parent instanceof CodeCell) {
|
||||
this._cell = parent;
|
||||
this._cell.model.contentChanged.connect(this._onModelChange, this);
|
||||
this._cell.model.stateChanged.connect(this._onModelChange, this);
|
||||
this._onModelChange();
|
||||
}
|
||||
}
|
||||
|
||||
protected onBeforeDetach(_msg: unknown): void {
|
||||
if (this._cell) {
|
||||
this._cell.model.contentChanged.disconnect(this._onModelChange, this);
|
||||
this._cell.model.stateChanged.disconnect(this._onModelChange, this);
|
||||
}
|
||||
this._cell = null;
|
||||
}
|
||||
|
||||
private _onModelChange(): void {
|
||||
this._context = this._cell ? extractCellContextFromCell(this._cell) : null;
|
||||
this._render();
|
||||
}
|
||||
|
||||
private _render(): void {
|
||||
const node = this.node;
|
||||
node.textContent = '';
|
||||
|
||||
const hasError = this._context?.error != null;
|
||||
const loading = this._status === 'loading';
|
||||
const baseDisabled = !this._context || loading;
|
||||
|
||||
const mkBtn = (
|
||||
label: string,
|
||||
mode: OpenCodeMode,
|
||||
disabled: boolean
|
||||
): HTMLButtonElement => {
|
||||
const b = document.createElement('button');
|
||||
b.className = `opencode-btn opencode-btn-${mode}`;
|
||||
b.textContent = label;
|
||||
b.disabled = disabled;
|
||||
b.title = disabled
|
||||
? loading
|
||||
? 'OpenCode: 请求中…'
|
||||
: 'OpenCode: 等待 cell 上下文…'
|
||||
: label;
|
||||
b.addEventListener('click', () => {
|
||||
void this._onClick(mode);
|
||||
});
|
||||
return b;
|
||||
};
|
||||
|
||||
node.appendChild(mkBtn('✨ 优化', 'optimize', baseDisabled));
|
||||
node.appendChild(mkBtn('🐛 排错', 'fix', baseDisabled || !hasError));
|
||||
node.appendChild(mkBtn('🪄 编辑', 'edit', baseDisabled));
|
||||
|
||||
if (this._status === 'error' && this._errorMessage) {
|
||||
const err = document.createElement('span');
|
||||
err.className = 'opencode-error';
|
||||
err.textContent = this._errorMessage;
|
||||
err.title = this._errorMessage;
|
||||
node.appendChild(err);
|
||||
}
|
||||
}
|
||||
|
||||
private async _onClick(mode: OpenCodeMode): Promise<void> {
|
||||
if (!this._context) {
|
||||
return;
|
||||
}
|
||||
if (!_settings || !_serverSettings) {
|
||||
Notification.error('OpenCode 运行时未初始化,请检查 settings');
|
||||
return;
|
||||
}
|
||||
|
||||
let prompt = '';
|
||||
if (mode === 'edit') {
|
||||
const input = window.prompt('请输入编辑指令:');
|
||||
if (input === null) {
|
||||
return;
|
||||
}
|
||||
prompt = input;
|
||||
}
|
||||
|
||||
const request: OpenCodeRequest = {
|
||||
mode,
|
||||
prompt,
|
||||
context: this._context,
|
||||
providerId: _settings.opencodeProvider || undefined,
|
||||
modelId: _settings.opencodeModel || undefined
|
||||
};
|
||||
|
||||
this._status = 'loading';
|
||||
this._errorMessage = null;
|
||||
this._render();
|
||||
|
||||
try {
|
||||
const resp = await callOpenCodeEdit(request, _serverSettings);
|
||||
this._handleResponse(resp);
|
||||
} catch (e) {
|
||||
this._status = 'error';
|
||||
this._errorMessage = (e as Error).message;
|
||||
this._render();
|
||||
Notification.error(`OpenCode ${mode} 失败: ${this._errorMessage}`);
|
||||
}
|
||||
}
|
||||
|
||||
private _handleResponse(resp: OpenCodeResponse): void {
|
||||
if (resp.ok) {
|
||||
this._status = 'idle';
|
||||
this._render();
|
||||
const len = resp.finalSource.length;
|
||||
Notification.info(
|
||||
`OpenCode ${resp.mode} 完成 (${len} chars). ` +
|
||||
`Diff 面板将在 Slice 4 中提供。Session: ${resp.sessionId.slice(0, 8)}…`
|
||||
);
|
||||
} else {
|
||||
this._status = 'error';
|
||||
this._errorMessage = resp.error;
|
||||
this._render();
|
||||
Notification.error(`OpenCode 错误: ${resp.error}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a CodeCell's parent NotebookPanel by walking the parent chain,
|
||||
* then build the CellContext. The footer uses this rather than `Widget.findParent`
|
||||
* because that helper was removed in @lumino/widgets 2.x.
|
||||
*/
|
||||
function extractCellContextFromCell(cell: CodeCell): CellContext | null {
|
||||
let node: Widget | null = cell.parent;
|
||||
let notebookPanel: NotebookPanel | null = null;
|
||||
while (node) {
|
||||
// Use duck-typing: a notebook panel has `context.path` and `content.widgets`.
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
/**
|
||||
* Inline prompt widget attached to a cell's DOM when the AI button is clicked.
|
||||
* Renders two <select> boxes (provider + model), a textarea, send/cancel
|
||||
* buttons, and a scrollable history area that shows the current session's
|
||||
* messages (user + assistant). The model select's default for the chosen
|
||||
* provider is `default[providerID]` from the /config/providers response.
|
||||
*
|
||||
* v3-final: no settings — picks come from the OpenCode Serve providers
|
||||
* cache; history comes from /session-messages; cell source is NOT replaced
|
||||
* (the AI reply is rendered as markdown inside this history area).
|
||||
*/
|
||||
import { CodeCell } from '@jupyterlab/cells';
|
||||
import { Widget } from '@lumino/widgets';
|
||||
import { marked } from 'marked';
|
||||
|
||||
import type { OpenCodeMessage, OpenCodeProvidersResponse } from '../types';
|
||||
|
||||
export interface IOpenCodeInlinePromptOptions {
|
||||
onSubmit: (text: string, providerId?: string, modelId?: string) => void;
|
||||
onCancel: () => void;
|
||||
disabled: boolean;
|
||||
providers: OpenCodeProvidersResponse | null;
|
||||
}
|
||||
|
||||
interface FlatProvider {
|
||||
id: string;
|
||||
name?: string;
|
||||
models: { [modelID: string]: unknown };
|
||||
}
|
||||
|
||||
function flattenProviders(
|
||||
providers: OpenCodeProvidersResponse | null
|
||||
): { providers: FlatProvider[]; defaultMap: { [pid: string]: string } } {
|
||||
const out: FlatProvider[] = [];
|
||||
if (!providers || !providers.providers) {
|
||||
return { providers: out, defaultMap: (providers && providers.default) || {} };
|
||||
}
|
||||
for (const p of providers.providers) {
|
||||
if (!p.models || typeof p.models !== 'object') {
|
||||
continue;
|
||||
}
|
||||
out.push({ id: p.id, name: p.name, models: p.models });
|
||||
}
|
||||
return { providers: out, defaultMap: providers.default || {} };
|
||||
}
|
||||
|
||||
function modelKeys(models: { [modelID: string]: unknown }): string[] {
|
||||
return Object.keys(models);
|
||||
}
|
||||
|
||||
function pickDefaultModelId(
|
||||
providerId: string,
|
||||
models: { [modelID: string]: unknown },
|
||||
defaultMap: { [pid: string]: string }
|
||||
): string | undefined {
|
||||
const keys = modelKeys(models);
|
||||
if (keys.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
const fromDefault = defaultMap[providerId];
|
||||
if (fromDefault && Object.prototype.hasOwnProperty.call(models, fromDefault)) {
|
||||
return fromDefault;
|
||||
}
|
||||
return keys[0];
|
||||
}
|
||||
|
||||
export class OpenCodeInlinePrompt extends Widget {
|
||||
private _textarea: HTMLTextAreaElement;
|
||||
private _sendBtn: HTMLButtonElement;
|
||||
private _cancelBtn: HTMLButtonElement;
|
||||
private _providerSelect: HTMLSelectElement | null = null;
|
||||
private _modelSelect: HTMLSelectElement | null = null;
|
||||
private _providerModels: FlatProvider[];
|
||||
private _defaultMap: { [pid: string]: string };
|
||||
private _historyEl: HTMLDivElement;
|
||||
|
||||
constructor(
|
||||
_cell: CodeCell,
|
||||
options: IOpenCodeInlinePromptOptions
|
||||
) {
|
||||
super();
|
||||
this.addClass('opencode-inline-prompt');
|
||||
|
||||
const flat = flattenProviders(options.providers);
|
||||
this._providerModels = flat.providers;
|
||||
this._defaultMap = flat.defaultMap;
|
||||
|
||||
if (this._providerModels.length > 0) {
|
||||
this._providerSelect = document.createElement('select');
|
||||
this._providerSelect.className = 'opencode-provider-select';
|
||||
for (const p of this._providerModels) {
|
||||
const o = document.createElement('option');
|
||||
o.value = p.id;
|
||||
o.textContent = p.name ? `${p.id} (${p.name})` : p.id;
|
||||
this._providerSelect.appendChild(o);
|
||||
}
|
||||
this._providerSelect.value = this._providerModels[0].id;
|
||||
this._providerSelect.addEventListener('change', () => {
|
||||
this._rebuildModelSelect(this._providerSelect!.value);
|
||||
});
|
||||
}
|
||||
|
||||
if (this._providerModels.length > 0) {
|
||||
this._modelSelect = document.createElement('select');
|
||||
this._modelSelect.className = 'opencode-model-select';
|
||||
this._rebuildModelSelect(this._providerSelect?.value);
|
||||
}
|
||||
|
||||
this._textarea = document.createElement('textarea');
|
||||
this._textarea.placeholder = '向 AI 描述你想要的修改…';
|
||||
this._textarea.rows = 3;
|
||||
this._sendBtn = document.createElement('button');
|
||||
this._sendBtn.className = 'opencode-btn-send';
|
||||
this._sendBtn.textContent = '发送';
|
||||
this._sendBtn.disabled = options.disabled;
|
||||
this._sendBtn.addEventListener('click', () => {
|
||||
const text = this._textarea.value;
|
||||
if (!text.trim()) {
|
||||
return;
|
||||
}
|
||||
const providerId = this._providerSelect?.value;
|
||||
const modelId = this._modelSelect?.value || undefined;
|
||||
options.onSubmit(text, providerId, modelId);
|
||||
});
|
||||
this._cancelBtn = document.createElement('button');
|
||||
this._cancelBtn.className = 'opencode-btn-cancel';
|
||||
this._cancelBtn.textContent = '取消';
|
||||
this._cancelBtn.addEventListener('click', () => {
|
||||
options.onCancel();
|
||||
});
|
||||
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'opencode-inline-actions';
|
||||
actions.appendChild(this._sendBtn);
|
||||
actions.appendChild(this._cancelBtn);
|
||||
|
||||
if (this._providerSelect || this._modelSelect) {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'opencode-prompt-providers';
|
||||
if (this._providerSelect) {
|
||||
const pLabel = document.createElement('label');
|
||||
const pSpan = document.createElement('span');
|
||||
pSpan.textContent = 'Provider:';
|
||||
pLabel.appendChild(pSpan);
|
||||
pLabel.appendChild(this._providerSelect);
|
||||
row.appendChild(pLabel);
|
||||
}
|
||||
if (this._modelSelect) {
|
||||
const mLabel = document.createElement('label');
|
||||
const mSpan = document.createElement('span');
|
||||
mSpan.textContent = 'Model:';
|
||||
mLabel.appendChild(mSpan);
|
||||
mLabel.appendChild(this._modelSelect);
|
||||
row.appendChild(mLabel);
|
||||
}
|
||||
this.node.appendChild(row);
|
||||
}
|
||||
|
||||
// Scrollable history of the current session's messages. Empty until
|
||||
// setMessages() is called by the owning cell action.
|
||||
this._historyEl = document.createElement('div');
|
||||
this._historyEl.className = 'opencode-inline-history';
|
||||
this.node.appendChild(this._historyEl);
|
||||
|
||||
this.node.appendChild(this._textarea);
|
||||
this.node.appendChild(actions);
|
||||
}
|
||||
|
||||
private _rebuildModelSelect(providerId: string | undefined): void {
|
||||
if (!this._modelSelect) {
|
||||
return;
|
||||
}
|
||||
while (this._modelSelect.firstChild) {
|
||||
this._modelSelect.removeChild(this._modelSelect.firstChild);
|
||||
}
|
||||
if (!providerId) {
|
||||
this._modelSelect.disabled = true;
|
||||
return;
|
||||
}
|
||||
const provider = this._providerModels.find(p => p.id === providerId);
|
||||
if (!provider) {
|
||||
this._modelSelect.disabled = true;
|
||||
return;
|
||||
}
|
||||
const keys = modelKeys(provider.models);
|
||||
if (keys.length === 0) {
|
||||
this._modelSelect.disabled = true;
|
||||
return;
|
||||
}
|
||||
for (const k of keys) {
|
||||
const o = document.createElement('option');
|
||||
o.value = k;
|
||||
o.textContent = k;
|
||||
this._modelSelect.appendChild(o);
|
||||
}
|
||||
this._modelSelect.disabled = false;
|
||||
this._modelSelect.value =
|
||||
pickDefaultModelId(providerId, provider.models, this._defaultMap) ?? keys[0];
|
||||
}
|
||||
|
||||
setDisabled(disabled: boolean): void {
|
||||
this._sendBtn.disabled = disabled;
|
||||
if (this._providerSelect) {
|
||||
this._providerSelect.disabled = disabled;
|
||||
}
|
||||
if (this._modelSelect) {
|
||||
this._modelSelect.disabled = disabled;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the current session's messages into the scrollable history
|
||||
* area. User messages are plain text; assistant messages are rendered
|
||||
* as markdown via marked. Auto-scrolls to the bottom so the most
|
||||
* recent message is visible.
|
||||
*/
|
||||
setMessages(messages: OpenCodeMessage[]): void {
|
||||
this._historyEl.textContent = '';
|
||||
for (const msg of messages) {
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = `opencode-msg opencode-msg-${msg.role}`;
|
||||
if (msg.role === 'user') {
|
||||
wrap.textContent = msg.content;
|
||||
} else {
|
||||
wrap.innerHTML = marked.parse(msg.content) as string;
|
||||
}
|
||||
this._historyEl.appendChild(wrap);
|
||||
}
|
||||
this._historyEl.scrollTop = this._historyEl.scrollHeight;
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
/**
|
||||
* Install the OpenCode cell factory on every notebook tracked by INotebookTracker.
|
||||
*
|
||||
* For already-open notebooks: swap their contentFactory in place. Cells created
|
||||
* after this point will use the new factory. (Cells already created retain their
|
||||
* old factory — that's a known limitation; users must reload the notebook to see
|
||||
* the footer in cells that were created before activation.)
|
||||
*
|
||||
* For future notebooks: listen to `tracker.widgetAdded` and swap on addition.
|
||||
*/
|
||||
import type { INotebookTracker, NotebookPanel } from '@jupyterlab/notebook';
|
||||
|
||||
import { OpenCodeCellContentFactory } from './opencode_cell_factory';
|
||||
|
||||
export function installOpenCodeInNotebook(panel: NotebookPanel): void {
|
||||
if (panel.content.contentFactory instanceof OpenCodeCellContentFactory) {
|
||||
return; // already installed
|
||||
}
|
||||
const existing = panel.content.contentFactory;
|
||||
const editorFactory = (existing as any).editorFactory;
|
||||
const factory = new OpenCodeCellContentFactory({ editorFactory });
|
||||
// contentFactory is readonly in the typings but writable at runtime.
|
||||
(panel.content as any).contentFactory = factory;
|
||||
}
|
||||
|
||||
export function installOpenCodeEverywhere(tracker: INotebookTracker): void {
|
||||
// Patch already-open notebooks.
|
||||
tracker.forEach(panel => {
|
||||
installOpenCodeInNotebook(panel);
|
||||
});
|
||||
// Patch future notebooks.
|
||||
tracker.widgetAdded.connect((_, panel) => {
|
||||
installOpenCodeInNotebook(panel);
|
||||
});
|
||||
}
|
||||
+54
-44
@@ -3,69 +3,79 @@ import {
|
||||
JupyterFrontEndPlugin
|
||||
} from '@jupyterlab/application';
|
||||
|
||||
import { INotebookTracker } from '@jupyterlab/notebook';
|
||||
import { ISettingRegistry } from '@jupyterlab/settingregistry';
|
||||
import { IToolbarWidgetRegistry } from '@jupyterlab/apputils';
|
||||
import { Cell, CodeCell } from '@jupyterlab/cells';
|
||||
import { Widget } from '@lumino/widgets';
|
||||
|
||||
import { installOpenCodeEverywhere } from './components/opencode_installer';
|
||||
import { setOpenCodeRuntime } from './components/opencode_cell_footer';
|
||||
import {
|
||||
OpenCodeCellActions,
|
||||
setOpenCodeProviders,
|
||||
setOpenCodeServerSettings
|
||||
} from './components/opencode_cell_actions';
|
||||
import { requestAPI } from './request';
|
||||
import { readOpenCodeSettings } from './types';
|
||||
import { callOpenCodeProviders } from './api/opencode_client';
|
||||
|
||||
/**
|
||||
* Initialization data for the opencode_bridge extension.
|
||||
*
|
||||
* v3-final: no JupyterLab plugin settings. Connection config is read from
|
||||
* environment variables by the server extension; the model is picked
|
||||
* dynamically in the inline prompt.
|
||||
*/
|
||||
const plugin: JupyterFrontEndPlugin<void> = {
|
||||
id: 'opencode_bridge:plugin',
|
||||
description: 'A JupyterLab extension bridging the UI to OpenCode Serve.',
|
||||
autoStart: true,
|
||||
optional: [ISettingRegistry],
|
||||
requires: [INotebookTracker],
|
||||
optional: [IToolbarWidgetRegistry],
|
||||
activate: (
|
||||
app: JupyterFrontEnd,
|
||||
tracker: INotebookTracker,
|
||||
settingRegistry: ISettingRegistry | null
|
||||
toolbarRegistry: IToolbarWidgetRegistry | null
|
||||
) => {
|
||||
console.log('JupyterLab extension opencode_bridge is activated!');
|
||||
|
||||
// Install the per-cell footer factory on every notebook.
|
||||
installOpenCodeEverywhere(tracker);
|
||||
// Push the Jupyter server settings into the actions module so that
|
||||
// callOpenCodeEdit can build the right base URL.
|
||||
setOpenCodeServerSettings(app.serviceManager.serverSettings);
|
||||
|
||||
// Load settings + push into toolbar module.
|
||||
if (settingRegistry) {
|
||||
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);
|
||||
|
||||
void callOpenCodeProviders(app.serviceManager.serverSettings)
|
||||
.then(data => {
|
||||
const lines: string[] = ['[opencode_bridge] Available OpenCode providers:'];
|
||||
for (const p of data.providers) {
|
||||
const models = p.models.map(m => m.id).join(', ');
|
||||
lines.push(` - ${p.id}: ${models || '(no models)'}`);
|
||||
}
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(lines.join('\n'));
|
||||
})
|
||||
.catch(reason => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
'[opencode_bridge] Could not fetch providers (is the opencode-bridge server extension enabled and opencode serve running?):',
|
||||
reason
|
||||
);
|
||||
});
|
||||
})
|
||||
.catch(reason => {
|
||||
console.error('Failed to load settings for opencode_bridge.', reason);
|
||||
});
|
||||
// Register the per-cell AI actions into the native Cell toolbar
|
||||
// (top-right of the active cell, next to move up/down). Non-code
|
||||
// cells get an empty widget so they show no AI buttons.
|
||||
if (toolbarRegistry) {
|
||||
toolbarRegistry.addFactory<Cell>(
|
||||
'Cell',
|
||||
'opencode-cell-actions',
|
||||
(cell: Cell) => {
|
||||
if (cell instanceof CodeCell) {
|
||||
return new OpenCodeCellActions(cell);
|
||||
}
|
||||
return new Widget();
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Fetch available providers once at activation and cache them for the
|
||||
// inline prompt's model picker. Failure is non-fatal (the picker hides).
|
||||
void callOpenCodeProviders(app.serviceManager.serverSettings)
|
||||
.then(data => {
|
||||
setOpenCodeProviders(data);
|
||||
const lines: string[] = [
|
||||
'[opencode_bridge] Available OpenCode providers:'
|
||||
];
|
||||
for (const p of data.providers) {
|
||||
const models = Object.values(p.models).map(m => m.id).join(', ');
|
||||
lines.push(` - ${p.id}: ${models || '(no models)'}`);
|
||||
}
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(lines.join('\n'));
|
||||
})
|
||||
.catch(reason => {
|
||||
// eslint-disable-next-line no-console
|
||||
+ console.warn(
|
||||
'[opencode_bridge] Could not fetch providers (is the opencode-bridge server extension enabled and opencode serve running?):',
|
||||
reason
|
||||
);
|
||||
});
|
||||
|
||||
requestAPI<unknown>('hello', app.serviceManager.serverSettings)
|
||||
.then(data => {
|
||||
console.log('hello endpoint:', data);
|
||||
|
||||
+26
-36
@@ -3,6 +3,10 @@
|
||||
*
|
||||
* These mirror the Python server's `CellContext` / `OpenCodeRequest` / `OpenCodeResponse`
|
||||
* shapes in `opencode_bridge/routes.py`. Keep them in sync.
|
||||
*
|
||||
* v3-final: the JupyterLab plugin settings have been removed entirely.
|
||||
* All connection config lives in startup environment variables (see
|
||||
* `opencode_bridge/config.py`); the inline model picker is fully dynamic.
|
||||
*/
|
||||
|
||||
export interface ErrorOutput {
|
||||
@@ -22,10 +26,7 @@ export interface CellContext {
|
||||
error: ErrorOutput | null;
|
||||
}
|
||||
|
||||
export type OpenCodeMode = 'optimize' | 'fix' | 'edit';
|
||||
|
||||
export interface OpenCodeRequest {
|
||||
mode: OpenCodeMode;
|
||||
prompt: string;
|
||||
context: CellContext;
|
||||
providerId?: string;
|
||||
@@ -34,8 +35,9 @@ export interface OpenCodeRequest {
|
||||
|
||||
export interface OpenCodeSuccess {
|
||||
ok: true;
|
||||
mode: OpenCodeMode;
|
||||
finalSource: string;
|
||||
/** The AI's reply as markdown (code in ```fences```, optional explanation).
|
||||
* Render with marked. The cell source is NOT replaced. */
|
||||
markdown: string;
|
||||
sessionId: string;
|
||||
notebookPath: string;
|
||||
}
|
||||
@@ -47,46 +49,34 @@ export interface OpenCodeFailure {
|
||||
|
||||
export type OpenCodeResponse = OpenCodeSuccess | OpenCodeFailure;
|
||||
|
||||
/** Frontend view of the 4 schema/plugin.json fields. */
|
||||
export interface OpenCodeSettings {
|
||||
opencodeServerUrl: string;
|
||||
opencodeServerUser: string;
|
||||
opencodeServerPassword: string;
|
||||
requestTimeoutSeconds: number;
|
||||
opencodeProvider: string;
|
||||
opencodeModel: string;
|
||||
/** Response from GET /opencode-bridge/session-messages?notebook=... .
|
||||
* Projected from OpenCode's {info, parts}[] by the server into a
|
||||
* frontend-friendly {role, content}[]. */
|
||||
export interface OpenCodeMessage {
|
||||
role: "user" | "assistant";
|
||||
content: string;
|
||||
}
|
||||
|
||||
export const DEFAULT_OPENCODE_SETTINGS: OpenCodeSettings = {
|
||||
opencodeServerUrl: 'http://127.0.0.1:4096',
|
||||
opencodeServerUser: 'opencode',
|
||||
opencodeServerPassword: '',
|
||||
requestTimeoutSeconds: 120,
|
||||
opencodeProvider: '',
|
||||
opencodeModel: '',
|
||||
};
|
||||
export interface OpenCodeMessagesResponse {
|
||||
messages: OpenCodeMessage[];
|
||||
}
|
||||
|
||||
export function readOpenCodeSettings(composite: unknown): OpenCodeSettings {
|
||||
const c = (composite ?? {}) as Partial<OpenCodeSettings>;
|
||||
return {
|
||||
opencodeServerUrl: c.opencodeServerUrl || DEFAULT_OPENCODE_SETTINGS.opencodeServerUrl,
|
||||
opencodeServerUser: c.opencodeServerUser || DEFAULT_OPENCODE_SETTINGS.opencodeServerUser,
|
||||
opencodeServerPassword: c.opencodeServerPassword || '',
|
||||
requestTimeoutSeconds:
|
||||
typeof c.requestTimeoutSeconds === 'number'
|
||||
? c.requestTimeoutSeconds
|
||||
: DEFAULT_OPENCODE_SETTINGS.requestTimeoutSeconds,
|
||||
opencodeProvider: c.opencodeProvider || '',
|
||||
opencodeModel: c.opencodeModel || '',
|
||||
};
|
||||
/** Response from GET /opencode-bridge/providers (proxies OpenCode /config/providers).
|
||||
* Each Provider.models is a Record keyed by modelID (NOT an array). */
|
||||
export interface OpenCodeModel {
|
||||
id: string;
|
||||
name?: string;
|
||||
[k: string]: unknown;
|
||||
}
|
||||
|
||||
/** Response from GET /opencode-bridge/providers. */
|
||||
export interface OpenCodeProvider {
|
||||
id: string;
|
||||
models: { id: string; [k: string]: unknown }[];
|
||||
name?: string;
|
||||
source?: string;
|
||||
models: { [modelID: string]: OpenCodeModel };
|
||||
}
|
||||
|
||||
export interface OpenCodeProvidersResponse {
|
||||
providers: OpenCodeProvider[];
|
||||
default?: { [providerID: string]: string };
|
||||
}
|
||||
|
||||
+163
@@ -3,3 +3,166 @@
|
||||
|
||||
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 .opencode-prompt-providers {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.opencode-inline-prompt .opencode-prompt-providers label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: var(--jp-ui-font-size1);
|
||||
color: var(--jp-ui-font-color1, #333);
|
||||
}
|
||||
|
||||
.opencode-inline-prompt .opencode-provider-select,
|
||||
.opencode-inline-prompt .opencode-model-select {
|
||||
flex: 1;
|
||||
min-width: 120px;
|
||||
padding: 2px 4px;
|
||||
font-size: var(--jp-ui-font-size1);
|
||||
font-family: inherit;
|
||||
border: 1px solid var(--jp-border-color2, #ccc);
|
||||
border-radius: 3px;
|
||||
background: var(--jp-layout-color1, #fff);
|
||||
}
|
||||
|
||||
.opencode-inline-prompt .opencode-provider-select:disabled,
|
||||
.opencode-inline-prompt .opencode-model-select:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* Scrollable history of the current session's messages. The owning
|
||||
cell action populates it via setMessages(). max-height keeps the
|
||||
inline prompt compact; the user scrolls to see older messages. */
|
||||
.opencode-inline-prompt .opencode-inline-history {
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
margin: 4px 0;
|
||||
padding: 4px 6px;
|
||||
border: 1px solid var(--jp-border-color1, #c0c0c0);
|
||||
border-radius: 3px;
|
||||
background: var(--jp-layout-color2, #f7f7f7);
|
||||
font-size: var(--jp-ui-font-size1);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.opencode-inline-prompt .opencode-msg {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.opencode-inline-prompt .opencode-msg:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.opencode-inline-prompt .opencode-msg-user {
|
||||
white-space: pre-wrap;
|
||||
padding: 4px 6px;
|
||||
border-radius: 3px;
|
||||
background: var(--jp-layout-color3, #eaeaea);
|
||||
color: var(--jp-ui-font-color1, #333);
|
||||
}
|
||||
|
||||
.opencode-inline-prompt .opencode-msg-assistant {
|
||||
padding: 4px 6px;
|
||||
color: var(--jp-ui-font-color0, #000);
|
||||
}
|
||||
|
||||
.opencode-inline-prompt .opencode-inline-history pre {
|
||||
background: var(--jp-layout-color1, #fff);
|
||||
padding: 6px 8px;
|
||||
border-radius: 3px;
|
||||
overflow-x: auto;
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
.opencode-inline-prompt .opencode-inline-history code {
|
||||
font-family: var(--jp-code-font-family, monospace);
|
||||
font-size: var(--jp-code-font-size, 13px);
|
||||
}
|
||||
|
||||
.opencode-inline-prompt .opencode-inline-history p {
|
||||
margin: 2px 0;
|
||||
}
|
||||
|
||||
.opencode-inline-prompt .opencode-inline-history h1,
|
||||
.opencode-inline-prompt .opencode-inline-history h2,
|
||||
.opencode-inline-prompt .opencode-inline-history h3 {
|
||||
margin: 6px 0 4px;
|
||||
font-size: var(--jp-ui-font-size2, 14px);
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user