34 Commits
Author SHA1 Message Date
tao.chenandClaude Fable 5 ce59502e97 feat: inline prompt history area — scrollable session messages
The inline prompt now shows the current session's full message
history (user + assistant), scrollable, replacing the single-output
display from the previous commit. The user can scroll back through
prior exchanges in the same notebook's OpenCode session.

Server (opencode_bridge/):
  - New route: GET /opencode-bridge/session-messages?notebook=<path>
    - Resolves notebook to session via SessionManager.peek (no create).
    - If no session: {"messages": []} (no OpenCode call).
    - Else: GET /session/{id}/message on OpenCode Serve, project the
      raw {info, parts}[] into a frontend-friendly {role, content}[].
  - OpenCodeClient.list_session_messages(sid).
  - SessionManager.peek(notebook_path) -> Optional[str] (read without
    creating, to avoid spawning a session just to report emptiness).
  - Wired the new route in setup_route_handlers.

Client (src/):
  - types.ts: OpenCodeMessage { role, content } + OpenCodeMessagesResponse.
  - api/opencode_client.ts: callOpenCodeSessionMessages(notebook, serverSettings).
  - components/opencode_inline_prompt.ts:
    - Replaces the single .opencode-inline-output area with a
      scrollable .opencode-inline-history (max-height 320px, overflow-y
      auto, auto-scrolls to bottom on update).
    - setMessages(messages): renders user messages as plain text,
      assistant messages via marked.parse. No more setOutput/hideOutput.
  - components/opencode_cell_actions.ts:
    - _showPrompt now fires a _refreshHistory(notebookPath) which
      fetches and calls prompt.setMessages.
    - _handleResponse on success also calls _refreshHistory (the new
      assistant message appears as the last item in the history).
    - Cell source is still NOT replaced.
  - api/opencode_client module is mocked in the cell_actions test to
    avoid jsdom network calls.

style/base.css:
  - .opencode-inline-output* rules replaced with .opencode-inline-history
    (max-height 320px, overflow-y auto, border, padding) and
    .opencode-msg / .opencode-msg-user / .opencode-msg-assistant.
  - pre/code/p/h1-3 content styling scoped under .opencode-inline-history.

Tests:
  - pytest 37/37: FakeOpenCodeClient.list_session_messages + 3 new
    session_messages route tests (no session, projects messages, 400).
  - jest 29/29: setMessages renders user/assistant + history area tests.
  - FakeSessionManager.peek (returns None when session_id is falsy).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 18:45:03 +08:00
tao.chenandClaude Fable 5 1d7f5da9d4 feat: inline markdown output box — render AI reply with marked, do not replace cell
Flow (v3-final corrected):
  1. Model call succeeds.
  2. Server passes the AI reply through unchanged as 'markdown' (the
     system prompt allows ```language fences + a brief explanation, so
     the response is real markdown that marked can render into code
     blocks, headings, etc.).
  3. Client OpenCodeCellActions._handleResponse calls prompt.setOutput(
     resp.markdown); the inline prompt widget renders it with
     marked.parse and shows it in a new output area (hidden until a
     response arrives). The cell source is NOT replaced.
  4. User can close the output or cancel the whole prompt.

Server:
  - New unified system prompt: '你是代码助手 ... 按指令修改代码,可附简
    短说明' (allows ```fences``` + explanation; no more 'no markdown
    fences' restriction).
  - EditHandler returns {ok, markdown, sessionId, notebookPath} (raw
    text, fences intact). _strip_code_fence kept as a helper for any
    future apply-to-cell path; no longer called.
  - finalSource field dropped (the cell-apply path is gone).

Client:
  - OpenCodeSuccess: markdown: string (finalSource removed).
  - OpenCodeInlinePrompt: new output area, setOutput(md) renders via
    marked.parse, hideOutput() closes it.
  - OpenCodeCellActions._handleResponse: setOutput(markdown) instead of
    sharedModel.setSource + auto-hide. The prompt stays open so the
    user can read the output.
  - Uses marked@17 (already in node_modules via JupyterLab; no new dep).
  - CSS: output area styling (border, max-height 320px scroll, code/pre
    styling, close button).

Tests: pytest 34/34, jest 29/29. marked is mocked in jest.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 18:29:58 +08:00
tao.chenandClaude Fable 5 04f90ab5a0 feat: dual select (provider + model) with default[provider] model default
The inline prompt now renders two <select> boxes:
  - Provider: lists every provider from the cached /config/providers
    response. Default = first provider.
  - Model: lists that provider's model keys (Object.keys of the Record).
    Default = default[providerID] (from the same response) when that
    modelID is present in the provider's models; otherwise the first
    model. Changing the provider select rebuilds the model select with
    the new provider's models and its own default[provider].

On submit the two select values are read separately and passed as
providerId / modelId to the OpenCodeRequest (no more '|' delimiter).
The /opencode-bridge/edit body shape is unchanged (providerId, modelId).

Also fix the index.ts startup console log (p.models is a Record, use
Object.values), and drop the unused _cell field on the prompt widget
(now an unused param).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 18:13:14 +08:00
tao.chenandClaude Fable 5 e7579d3779 fix: Provider.models is a Record keyed by modelID, not an array
Per the OpenCode server docs (GET /config/providers), each Provider's
'models' is { [modelID: string]: Model } (a Record), so model IDs can be
referenced as strings (matching the 'default' map of providerID ->
modelID). Our code treated it as Model[], which:
  1. crashed with 'p.models is not iterable' on real responses, and
  2. after the previous Array.isArray guard, skipped EVERY provider
     (a Record is never an array), so the inline picker showed nothing.

Update types.ts (OpenCodeProvider.models -> Record, add OpenCodeModel,
add optional 'default' map to the response) and rewrite
flattenProviders to iterate Object.keys(p.models). Update test fixture
to the Record shape.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 18:01:16 +08:00
tao.chenandClaude Fable 5 f7099be060 fix: guard flattenProviders against non-array p.models
Some OpenCode Serve /config/providers responses include providers
without a 'models' field (or with a non-array value). The un-guarded
for-of on p.models then throws 'p.models is not iterable' and crashes
the inline prompt construction. Skip such providers instead.

Also re-assert the button label as 'AI' (matches the df2f3df label
simplification, which the 02cedac revert had bundled away).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 17:58:29 +08:00
tao.chen 02cedac138 Revert "fix: attach inline prompt to cell.inputArea (under the editor), not cell.node"
This reverts commit 8ed55c43c7.
2026-07-23 17:56:09 +08:00
tao.chenandClaude Fable 5 8ed55c43c7 fix: attach inline prompt to cell.inputArea (under the editor), not cell.node
Previously the inline prompt was Widget.attach'd to cell.node, which
in JupyterLab places it at the very bottom of the cell (after the
output area and In/Out prompt) — so the user couldn't see it, especially
on taller cells. Attaching to cell.inputArea.node puts the prompt
directly under the editor, which is the natural inline position.
Falls back to cell.node if inputArea is unexpectedly null.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 17:54:07 +08:00
tao.chenandClaude Fable 5 df2f3dff95 style: simplify button labels (drop emoji, keep text)
- 🪄 AI 智能编辑 → AI
- 🚀 发送 → 发送
- ✕ 取消 → 取消

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 17:41:38 +08:00
tao.chenandClaude Fable 5 b6c0273ee0 test: cover auth-on-request and close() in OpenCodeClient
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 17:40:58 +08:00
tao.chenandClaude Fable 5 c86de1d31b 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'.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 17:40:57 +08:00
tao.chenandClaude Fable 5 ff932d9095 docs: v4 plan for OpenCodeClient auth + close() bugfixes
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 17:13:41 +08:00
tao.chen 5306ada0c4 style: polish inline prompt model picker layout 2026-07-23 16:11:25 +08:00
tao.chen 74953d0ab8 feat: fully dynamic model picker in inline prompt 2026-07-23 16:11:20 +08:00
tao.chenandClaude Fable 5 74de18646f docs: amend v3 plan to v3-final (no settings, fully dynamic model picker)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 15:59:59 +08:00
tao.chenandClaude Fable 5 1e42459560 docs: design.md v3-final (startup env + dynamic model picker, no settings)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 15:56:07 +08:00
tao.chen b5bcb8f67c build: use node-modules linker so plain jlpm works
Removes the pnpm node-linker so 'jlpm test' / 'jlpm build' run directly
without the YARN_NODE_LINKER=node-modules env override. Re-run 'jlpm
install' to regenerate node_modules in the flat layout (the in-flight
yarn.lock migration in package.json already targets node-modules).
2026-07-23 15:53:30 +08:00
tao.chen f9b93906c6 refactor: move all server config to startup env vars; drop plugin settings 2026-07-23 13:21:31 +08:00
tao.chenandClaude Fable 5 ca2281af3b docs: amend v3 spec to v3-final — remove all plugin settings, fully dynamic model picker
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 13:14:41 +08:00
tao.chenandClaude Fable 5 8b51eda2ff docs: v3 implementation plan for startup env + model selector
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 13:11:18 +08:00
tao.chenandClaude Fable 5 12c0f4c618 docs: v3 spec for startup env config + inline model selector
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 13:11:18 +08:00
tao.chen 24bff6d2eb style: single AI button + inline prompt panel CSS 2026-07-23 12:47:49 +08:00
tao.chen 85e16914b5 feat: single AI action button with inline prompt; replace cell source 2026-07-23 12:47:47 +08:00
tao.chen d03f0fa434 refactor: drop mode from frontend types; react to settings changes 2026-07-23 12:47:44 +08:00
tao.chenandClaude Fable 5 04d011afa1 docs: design.md v2 interaction + contract (single button, no mode)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 12:37:49 +08:00
tao.chen 42ea857313 refactor: drop mode from backend; unify system prompt 2026-07-23 12:31:28 +08:00
tao.chenandClaude Fable 5 b5adf331a6 docs: v2 implementation plan for cell toolbar actions
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 12:29:23 +08:00
tao.chenandClaude Fable 5 50ba847659 docs: v2 spec for single AI button + inline prompt + drop mode
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 12:29:23 +08:00
tao.chenandClaude Fable 5 2bac98d659 docs: update design.md interaction flow for native Cell toolbar
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 10:53:30 +08:00
tao.chenandClaude Fable 5 47a774f237 style: make new files lint-clean (prettier + eqeqeq + remove unused eslint-disables)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 10:52:10 +08:00
tao.chen fe45c80dd2 feat: contribute opencode-cell-actions to Cell toolbar settings + styles 2026-07-23 10:40:33 +08:00
tao.chen 0a05424b29 refactor: register AI actions in native Cell toolbar; remove cell footer chain 2026-07-23 10:30:15 +08:00
tao.chen bb846d64fe feat: add OpenCodeCellActions widget for the native Cell toolbar 2026-07-23 10:22:15 +08:00
tao.chenandClaude Fable 5 714a55653c docs: implementation plan for cell toolbar actions migration
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 10:07:50 +08:00
tao.chenandClaude Fable 5 9e7c36e323 docs: spec for moving AI cell buttons into native Cell toolbar
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 09:54:17 +08:00
30 changed files with 4512 additions and 701 deletions
+1 -1
View File
@@ -4,7 +4,7 @@ enableGlobalCache: false
enableScripts: false enableScripts: false
nodeLinker: pnpm nodeLinker: node-modules
packageExtensions: packageExtensions:
"@module-federation/sdk@*": "@module-federation/sdk@*":
+58 -73
View File
@@ -42,14 +42,15 @@
## 🎨 2. 交互与UI流程设计 ## 🎨 2. 交互与UI流程设计
1. **悬浮 Toolbar(常驻)**:在每个 `CodeCell` 顶部渲染微型操作栏: 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 自己判断是优化/排错/编辑。
* `✨ 优化`:自动提炼代码规范与性能。
* `🐛 智能排错`:(**仅当 Cell 有 Error Output 时亮起**)自动抓取 Traceback 并修复。
* `🪄 智能编辑`:展开内嵌 Prompt 输入框。
2. **Inline Prompt 框(按需展开)** 2. **Inline Prompt 输入框(v3-final**:点击 `🪄 AI 智能编辑` 按钮后,`OpenCodeInlinePrompt` widget 追加到当前 `CodeCell` 的 DOM 末尾,呈现内嵌输入面板
* 用户点击 `🪄 智能编辑` 后,在当前 Cell 下方平滑展开一个微型输入框(带 `Textarea` + `🚀 发送` 按钮) * 顶部一行 `模型: <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 预览** 3. **流式替换/Diff 预览**
@@ -61,43 +62,43 @@
## 🔌 3. 接口契约设计 (API Contract) ## 🔌 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 ```json
{ {
"action": "custom_edit", // custom_edit | fix_error | optimize
"prompt": "把这段代码改为用 plotly 绘制交互式折线图", "prompt": "把这段代码改为用 plotly 绘制交互式折线图",
"context": { "context": {
"notebook_path": "analysis/demo.ipynb", "notebookPath": "analysis/demo.ipynb",
"target_cell": { "cellId": "cell-3",
"index": 3, "language": "python",
"code": "import matplotlib.pyplot as plt\nplt.plot([1, 2, 3])", "cellIndex": 3,
"error_output": "ModuleNotFoundError: No module named 'matplotlib'" "totalCells": 5,
}, "source": "import matplotlib.pyplot as plt\nplt.plot([1, 2, 3])",
"surrounding_cells": [ "previousCode": "import pandas as pd",
{ "index": 2, "code": "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 **Response**(当前 v2 实现:单次 JSON;SSE 流式属于 Slice 4 后续工作):
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"}
```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) ## 🛠️ 4. 核心前端实现逻辑 (TypeScript)
@@ -281,57 +282,41 @@ v0.2.1 不实现自动重试,只 invalidate。前端看到 502 后可重发。
--- ---
## 🎛 6. Provider/Model 选择(v0.2.2 新增 ## ⚙️ 6. 启动配置 + 动态模型选择(v3-final2026-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 `opencode_bridge/config.py` `resolve_config` 统一解析,**不再**从 JupyterLab settings 字典兜底(`jupyter_settings` 参数保留仅为 API 兼容,实际未读)。
"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": ""
}
```
### 6.2 选值如何给用户 ### 6.2 模型选择
JupyterLab settings 是静态 JSON schema**没有原生 dynamic enum**。v0.2.2 用最简方案 `opencodeProvider` / `opencodeModel` v0.2.2 settings 字段**已移除**。inline 输入框顶部 `<select>` 的选项来自 `index.ts` 启动时 `callOpenCodeProviders` 拉取并写入 `setOpenCodeProviders(p)` 的模块级缓存
- 启动时 fetch `/opencode-bridge/providers` → 把 provider/model 列表 `console.log` 出来
- 用户在 Settings Editor 里手填
- 后续 v0.4 考虑用 QuickPick 命令做交互式选择
### 6.3 数据流
``` ```
Settings (JupyterLab) index.ts (启动)
└─ GET /opencode-bridge/providers
├── opencodeProvider = "anthropic" └─ setOpenCodeProviders(data) // 模块级 _providers
└── opencodeModel = "claude-sonnet-4-20250514" └─ OpenCodeInlinePrompt 构造时读
└─ <select> 列出所有 (provider, model)
▼ setOpenCodeRuntime() └─ 用户选一项 → 提交时
Frontend (opencode_cell_footer.ts) └─ OpenCodeRequest.providerId / modelId
└─ POST /opencode-bridge/edit
▼ build request body
POST /opencode-bridge/edit
{ mode, prompt, context, providerId, modelId }
▼ send_message_sync
OpenCode Serve 接受 model = { providerID, modelID }
``` ```
### 6.4 不变量 - 默认选中:下拉框**第一项**(无 settings 可作默认)。
- 拉取失败(OpenCode Serve 未起)→ 缓存为 `null` → inline 不渲染 select → 提交时省略 `providerId`/`modelId`OpenCode Serve 用其默认。**优雅降级**。
- 留空字段("")→ 客户端不发 `providerId`/`modelId` → server 也不加 `model` 字段到 OpenCode 请求 → OpenCode 用默认 ### 6.3 行为变更(v0.2.2 → v3-final
- 用户在 settings 里设的值**不会自动回写到 OpenCode**——只是 request 级别的覆盖
- 改 settings 立即生效(下次 edit 请求就用新值);不需要重启 Jupyter - 之前 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
View File
@@ -1,18 +1,25 @@
"""Configuration resolution for the opencode-bridge extension. """Configuration resolution for the opencode-bridge extension.
Priority order (highest to lowest): All connection fields (URL / user / password / request timeout) are resolved
1. jupyter settings dict (from schema/plugin.json) exclusively from environment variables + built-in defaults at server startup.
2. Environment variables (OPENCODE_BRIDGE_URL, _USER, _PASSWORD) They are NOT stored in the JupyterLab plugin settings.
3. Built-in defaults
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 from __future__ import annotations
import os import os
from typing import NamedTuple, Optional, Tuple from typing import NamedTuple, Optional, Tuple
ENV_URL = "OPENCODE_BRIDGE_URL" ENV_URL = "OPENCODE_BRIDGE_URL"
ENV_USER = "OPENCODE_BRIDGE_USER" ENV_USER = "OPENCODE_BRIDGE_USER"
ENV_PASSWORD = "OPENCODE_BRIDGE_PASSWORD" ENV_PASSWORD = "OPENCODE_BRIDGE_PASSWORD"
ENV_TIMEOUT = "OPENCODE_BRIDGE_TIMEOUT"
DEFAULT_URL = "http://127.0.0.1:4096" DEFAULT_URL = "http://127.0.0.1:4096"
DEFAULT_USER = "opencode" DEFAULT_USER = "opencode"
@@ -23,7 +30,7 @@ class OpenCodeConfig(NamedTuple):
url: str url: str
user: str user: str
password: str password: str
request_timeout_seconds: int = DEFAULT_REQUEST_TIMEOUT request_timeout_seconds: int
@property @property
def auth(self) -> Optional[Tuple[str, str]]: def auth(self) -> Optional[Tuple[str, str]]:
@@ -34,18 +41,18 @@ class OpenCodeConfig(NamedTuple):
def resolve_config(jupyter_settings: dict) -> OpenCodeConfig: def resolve_config(jupyter_settings: dict) -> OpenCodeConfig:
"""Resolve OpenCode connection config from jupyter settings + env + defaults.""" """Resolve OpenCode connection config from environment variables + defaults.
bridge = jupyter_settings.get("opencode_bridge", {}) or {}
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( return OpenCodeConfig(
url=bridge.get("opencodeServerUrl") or os.environ.get(ENV_URL) or DEFAULT_URL, url=os.environ.get(ENV_URL) or DEFAULT_URL,
user=bridge.get("opencodeServerUser") or os.environ.get(ENV_USER) or DEFAULT_USER, user=os.environ.get(ENV_USER) or DEFAULT_USER,
password=( password=os.environ.get(ENV_PASSWORD) or "",
bridge.get("opencodeServerPassword")
or os.environ.get(ENV_PASSWORD)
or ""
),
request_timeout_seconds=int( request_timeout_seconds=int(
bridge.get("requestTimeoutSeconds", DEFAULT_REQUEST_TIMEOUT) os.environ.get(ENV_TIMEOUT) or DEFAULT_REQUEST_TIMEOUT
), )
) )
+52 -7
View File
@@ -1,5 +1,6 @@
"""Async HTTP client for the local OpenCode server.""" """Async HTTP client for the local OpenCode server."""
import atexit
import json import json
from typing import Any, Optional from typing import Any, Optional
@@ -13,6 +14,21 @@ class OpenCodeError(Exception):
"""Raised when an OpenCode API request fails.""" """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: class OpenCodeClient:
def __init__( def __init__(
self, self,
@@ -20,7 +36,24 @@ class OpenCodeClient:
http_client: Optional[AsyncHTTPClient] = None, http_client: Optional[AsyncHTTPClient] = None,
) -> None: ) -> None:
self._config = config 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]: async def health(self) -> dict[str, Any]:
return await self._request("GET", "/global/health") return await self._request("GET", "/global/health")
@@ -58,6 +91,16 @@ class OpenCodeClient:
result = await self._request("DELETE", "/session/%s" % session_id) result = await self._request("DELETE", "/session/%s" % session_id)
return result is not None 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 @property
def endpoint(self) -> str: def endpoint(self) -> str:
return self._config.url return self._config.url
@@ -82,15 +125,17 @@ class OpenCodeClient:
} }
if body is not None: if body is not None:
request_kwargs["body"] = json.dumps(body).encode("utf-8") 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) request = HTTPRequest(url, **request_kwargs)
fetch_kwargs: dict[str, str] = {} response = await self._http_client.fetch(request)
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)
if 200 <= response.code < 300: if 200 <= response.code < 300:
if not response.body: if not response.body:
+80 -25
View File
@@ -14,20 +14,16 @@ from .session_manager import SessionManager
log = logging.getLogger("opencode_bridge.routes") log = logging.getLogger("opencode_bridge.routes")
MODE_SYSTEM_PROMPTS = { # Unified system prompt — the LLM (OpenCode) is asked to return its reply
"optimize": ( # as MARKDOWN (code wrapped in ```language fences; a brief explanation is
"你是一个代码优化专家。请基于用户提供的代码上下文," # fine). The frontend renders this with `marked` directly — no fence
"返回只包含优化后代码的回复,不要任何解释或 markdown 围栏。" # stripping on the server, so the response keeps the structure that makes
), # markdown rendering meaningful (code blocks, headings, etc.).
"fix": ( UNIFIED_SYSTEM_PROMPT = (
"你是一个 Python 排错专家。用户给出了一段产生错误的代码和 traceback" "你是一个代码助手。基于提供的代码上下文(以及可选的 traceback"
"请返回只包含修复后代码的回复,不要任何解释或 markdown 围栏" "按照用户的指令修改代码"
), "可附简短说明。"
"edit": ( )
"你是一个代码编辑助手。基于用户的指令修改给定代码,"
"返回只包含修改后完整代码的回复,不要任何解释或 markdown 围栏。"
),
}
def make_client(handler: APIHandler) -> OpenCodeClient: def make_client(handler: APIHandler) -> OpenCodeClient:
@@ -52,12 +48,13 @@ def get_session_manager(handler: APIHandler) -> SessionManager:
return sm 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. """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] = [] parts: list[dict] = []
if context.get("previousCode"): 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}) 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: 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() s = s.strip()
if s.startswith("```"): if s.startswith("```"):
lines = s.split("\n") lines = s.split("\n")
@@ -157,7 +158,6 @@ class EditHandler(APIHandler):
async def post(self): async def post(self):
try: try:
body = json.loads(self.request.body) body = json.loads(self.request.body)
mode = body["mode"]
prompt = body.get("prompt", "") prompt = body.get("prompt", "")
context = body["context"] context = body["context"]
provider_id = body.get("providerId") or None provider_id = body.get("providerId") or None
@@ -177,7 +177,7 @@ class EditHandler(APIHandler):
sm = get_session_manager(self) sm = get_session_manager(self)
try: try:
sid = await sm.get_or_create(notebook_path) 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( result = await client.send_message_sync(
sid, sid,
request_body["parts"], request_body["parts"],
@@ -191,12 +191,14 @@ class EditHandler(APIHandler):
for p in result.get("parts", []) for p in result.get("parts", [])
if p.get("type") == "text" 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({ self.finish(json.dumps({
"ok": True, "ok": True,
"mode": mode, "markdown": markdown,
"finalSource": final_source,
"sessionId": sid, "sessionId": sid,
"notebookPath": notebook_path, "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): def setup_route_handlers(web_app):
host_pattern = ".*$" host_pattern = ".*$"
base_url = web_app.settings["base_url"] 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", "edit"), EditHandler),
(url_path_join(base_url, "opencode-bridge", "sessions"), SessionListHandler), (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"), SessionReleaseHandler),
(url_path_join(base_url, "opencode-bridge", "session-messages"), SessionMessagesHandler),
] ]
web_app.add_handlers(host_pattern, handlers) web_app.add_handlers(host_pattern, handlers)
+7
View File
@@ -83,6 +83,13 @@ class SessionManager:
def has_session(self, notebook_path: str) -> bool: def has_session(self, notebook_path: str) -> bool:
return notebook_path in self._sessions 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]: def list_sessions(self) -> list[dict]:
return [ return [
{"notebookPath": path, "sessionId": sid} {"notebookPath": path, "sessionId": sid}
+42 -8
View File
@@ -2,6 +2,7 @@
import json import json
from io import BytesIO from io import BytesIO
from unittest.mock import MagicMock
import pytest import pytest
import tornado.httpclient 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 = MockHTTPClient()
mock.responses = responses mock.responses = responses
return OpenCodeClient(config, http_client=mock), mock 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 @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") config = base_config._replace(password="secret")
client, mock = _make_client(config, [(200, {"status": "ok"})]) client, mock = _make_client(config, [(200, {"status": "ok"})])
await client.health() await client.health()
call = mock.calls[0] call = mock.calls[0]
assert call["kwargs"].get("auth_username") == "opencode" # No kwargs forwarded to fetch.
assert call["kwargs"].get("auth_password") == "secret" assert call["kwargs"] == {}
# Auth is on the HTTPRequest.
assert call["request"].auth_username == "opencode"
assert call["request"].auth_password == "secret"
@pytest.mark.asyncio @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"})]) client, mock = _make_client(base_config, [(200, {"status": "ok"})])
await client.health() await client.health()
call = mock.calls[0] call = mock.calls[0]
assert "auth_username" not in call["kwargs"] assert call["kwargs"] == {}
assert "auth_password" not in call["kwargs"] assert call["request"].auth_username is None
assert call["request"].auth_password is None
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -118,7 +129,9 @@ async def test_send_message_sync_omits_model_when_provider_or_model_missing(
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_send_message_sync_includes_system_when_provided() -> None: async def test_send_message_sync_includes_system_when_provided() -> None:
"""system param is forwarded into the request body when not 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 = MockHTTPClient()
mock.responses.append((200, {"info": {}, "parts": []})) mock.responses.append((200, {"info": {}, "parts": []}))
client = OpenCodeClient(config, http_client=mock) 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: with pytest.raises(OpenCodeError) as exc_info:
await client.health() await client.health()
assert "boom" in str(exc_info.value) 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
+8 -5
View File
@@ -17,21 +17,23 @@ def test_env_url_overrides_default(monkeypatch) -> None:
assert config.url == "http://x:1" 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") monkeypatch.setenv("OPENCODE_BRIDGE_URL", "http://x:1")
settings = {"opencode_bridge": {"opencodeServerUrl": "http://y:2"}} settings = {"opencode_bridge": {"opencodeServerUrl": "http://y:2"}}
config = resolve_config(settings) config = resolve_config(settings)
assert config.url == "http://y:2" assert config.url == "http://x:1"
def test_all_env_vars(monkeypatch) -> None: def test_all_env_vars(monkeypatch) -> None:
monkeypatch.setenv("OPENCODE_BRIDGE_URL", "http://x:1") monkeypatch.setenv("OPENCODE_BRIDGE_URL", "http://x:1")
monkeypatch.setenv("OPENCODE_BRIDGE_USER", "u") monkeypatch.setenv("OPENCODE_BRIDGE_USER", "u")
monkeypatch.setenv("OPENCODE_BRIDGE_PASSWORD", "p") monkeypatch.setenv("OPENCODE_BRIDGE_PASSWORD", "p")
monkeypatch.setenv("OPENCODE_BRIDGE_TIMEOUT", "300")
config = resolve_config({}) config = resolve_config({})
assert config.url == "http://x:1" assert config.url == "http://x:1"
assert config.user == "u" assert config.user == "u"
assert config.password == "p" assert config.password == "p"
assert config.request_timeout_seconds == 300
def test_auth_none_when_password_empty() -> None: 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", url="http://127.0.0.1:4096",
user="opencode", user="opencode",
password="secret", password="secret",
request_timeout_seconds=120,
) )
assert config.auth == ("opencode", "secret") assert config.auth == ("opencode", "secret")
def test_request_timeout_from_settings() -> None: def test_request_timeout_from_env(monkeypatch) -> None:
settings = {"opencode_bridge": {"requestTimeoutSeconds": 300}} monkeypatch.setenv("OPENCODE_BRIDGE_TIMEOUT", "300")
config = resolve_config(settings) config = resolve_config({})
assert config.request_timeout_seconds == 300 assert config.request_timeout_seconds == 300
+95 -4
View File
@@ -1,5 +1,8 @@
import json import json
import pytest
import tornado.httpclient
class FakeOpenCodeClient: class FakeOpenCodeClient:
"""Drop-in replacement for OpenCodeClient with recording + canned responses.""" """Drop-in replacement for OpenCodeClient with recording + canned responses."""
@@ -13,6 +16,18 @@ class FakeOpenCodeClient:
"info": {"id": "msg-1"}, "info": {"id": "msg-1"},
"parts": [{"type": "text", "text": "def foo():\n return 42\n"}], "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 @property
def endpoint(self): def endpoint(self):
@@ -38,6 +53,10 @@ class FakeOpenCodeClient:
self.calls.append(("delete_session", session_id)) self.calls.append(("delete_session", session_id))
return True return True
async def list_session_messages(self, session_id):
self.calls.append(("list_session_messages", session_id))
return self.messages_response
class FakeSessionManager: class FakeSessionManager:
"""Drop-in replacement for SessionManager with recording.""" """Drop-in replacement for SessionManager with recording."""
@@ -50,6 +69,12 @@ class FakeSessionManager:
self.calls.append(("get_or_create", notebook_path)) self.calls.append(("get_or_create", notebook_path))
return self._session_id 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: async def release(self, notebook_path: str) -> bool:
self.calls.append(("release", notebook_path)) self.calls.append(("release", notebook_path))
return True return True
@@ -115,7 +140,6 @@ async def test_edit_handler(monkeypatch, jp_fetch):
) )
body = json.dumps({ body = json.dumps({
"mode": "edit",
"prompt": "Add type hints", "prompt": "Add type hints",
"context": { "context": {
"notebookPath": "test.ipynb", "notebookPath": "test.ipynb",
@@ -136,7 +160,9 @@ async def test_edit_handler(monkeypatch, jp_fetch):
assert response.code == 200 assert response.code == 200
payload = json.loads(response.body) payload = json.loads(response.body)
assert payload["ok"] is True 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["sessionId"] == "fake-session-123"
assert payload["notebookPath"] == "test.ipynb" 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_message_sync received the session ID from the manager
send_call = [c for c in fake.calls if c[0] == "send_message_sync"][0] send_call = [c for c in fake.calls if c[0] == "send_message_sync"][0]
assert send_call[1] == "fake-session-123" assert send_call[1] == "fake-session-123"
# system prompt was passed # system prompt was passed (v3+ allows markdown/fences in the reply)
assert "你是一个代码编辑助手" in send_call[3] assert "你是一个代码助手" in send_call[3]
# SessionManager.get_or_create was called with the notebook path # SessionManager.get_or_create was called with the notebook path
sm_call_names = [c[0] for c in fake_sm.calls] 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"} 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): async def test_session_release_handler(monkeypatch, jp_fetch):
fake = FakeOpenCodeClient() fake = FakeOpenCodeClient()
monkeypatch.setattr("opencode_bridge.routes.make_client", lambda h: fake) monkeypatch.setattr("opencode_bridge.routes.make_client", lambda h: fake)
+4 -39
View File
@@ -1,46 +1,11 @@
{ {
"jupyter.lab.shortcuts": [], "jupyter.lab.shortcuts": [],
"jupyter.lab.toolbars": {
"Cell": [{ "name": "opencode-cell-actions", "rank": 100 }]
},
"title": "opencode_bridge", "title": "opencode_bridge",
"description": "opencode_bridge settings.", "description": "opencode_bridge settings.",
"type": "object", "type": "object",
"properties": { "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
}
},
"additionalProperties": false "additionalProperties": false
} }
-33
View File
@@ -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('');
});
});
+420
View File
@@ -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');
});
});
-156
View File
@@ -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);
});
});
+2 -4
View File
@@ -64,7 +64,6 @@ function mockResponse(overrides: {
} }
const sampleRequest: OpenCodeRequest = { const sampleRequest: OpenCodeRequest = {
mode: 'edit',
prompt: 'add type hints', prompt: 'add type hints',
context: { context: {
notebookPath: 'foo.ipynb', notebookPath: 'foo.ipynb',
@@ -86,8 +85,7 @@ describe('callOpenCodeEdit', () => {
it('POSTs to /opencode-bridge/edit with JSON body', async () => { it('POSTs to /opencode-bridge/edit with JSON body', async () => {
const respBody = { const respBody = {
ok: true, ok: true,
mode: 'edit', markdown: '```python\ndef foo(x: int) -> int: return x\n```',
finalSource: 'def foo(x: int) -> int: return x',
sessionId: 'sid', sessionId: 'sid',
notebookPath: 'foo.ipynb', notebookPath: 'foo.ipynb',
}; };
@@ -109,7 +107,7 @@ describe('callOpenCodeEdit', () => {
); );
expect(resp.ok).toBe(true); expect(resp.ok).toBe(true);
if (resp.ok) { if (resp.ok) {
expect(resp.finalSource).toContain('int'); expect(resp.markdown).toContain('int');
} }
}); });
+32 -1
View File
@@ -4,7 +4,7 @@
import { URLExt } from '@jupyterlab/coreutils'; import { URLExt } from '@jupyterlab/coreutils';
import { ServerConnection } from '@jupyterlab/services'; 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). * Call POST /opencode-bridge/edit. Returns parsed response (success or failure).
@@ -54,6 +54,37 @@ export async function callOpenCodeEdit(
return parsed as OpenCodeResponse; 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. * Call GET /opencode-bridge/providers. Returns the parsed JSON response.
* Throws on network error or non-2xx status. * Throws on network error or non-2xx status.
+242
View File
@@ -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);
}
-15
View File
@@ -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();
}
}
-199
View File
@@ -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);
}
+231
View File
@@ -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;
}
}
-35
View File
@@ -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
View File
@@ -3,69 +3,79 @@ import {
JupyterFrontEndPlugin JupyterFrontEndPlugin
} from '@jupyterlab/application'; } from '@jupyterlab/application';
import { INotebookTracker } from '@jupyterlab/notebook'; import { IToolbarWidgetRegistry } from '@jupyterlab/apputils';
import { ISettingRegistry } from '@jupyterlab/settingregistry'; import { Cell, CodeCell } from '@jupyterlab/cells';
import { Widget } from '@lumino/widgets';
import { installOpenCodeEverywhere } from './components/opencode_installer'; import {
import { setOpenCodeRuntime } from './components/opencode_cell_footer'; OpenCodeCellActions,
setOpenCodeProviders,
setOpenCodeServerSettings
} from './components/opencode_cell_actions';
import { requestAPI } from './request'; import { requestAPI } from './request';
import { readOpenCodeSettings } from './types';
import { callOpenCodeProviders } from './api/opencode_client'; import { callOpenCodeProviders } from './api/opencode_client';
/** /**
* Initialization data for the opencode_bridge extension. * 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> = { const plugin: JupyterFrontEndPlugin<void> = {
id: 'opencode_bridge:plugin', id: 'opencode_bridge:plugin',
description: 'A JupyterLab extension bridging the UI to OpenCode Serve.', description: 'A JupyterLab extension bridging the UI to OpenCode Serve.',
autoStart: true, autoStart: true,
optional: [ISettingRegistry], optional: [IToolbarWidgetRegistry],
requires: [INotebookTracker],
activate: ( activate: (
app: JupyterFrontEnd, app: JupyterFrontEnd,
tracker: INotebookTracker, toolbarRegistry: IToolbarWidgetRegistry | null
settingRegistry: ISettingRegistry | null
) => { ) => {
console.log('JupyterLab extension opencode_bridge is activated!'); console.log('JupyterLab extension opencode_bridge is activated!');
// Install the per-cell footer factory on every notebook. // Push the Jupyter server settings into the actions module so that
installOpenCodeEverywhere(tracker); // callOpenCodeEdit can build the right base URL.
setOpenCodeServerSettings(app.serviceManager.serverSettings);
// Load settings + push into toolbar module. // Register the per-cell AI actions into the native Cell toolbar
if (settingRegistry) { // (top-right of the active cell, next to move up/down). Non-code
void settingRegistry // cells get an empty widget so they show no AI buttons.
.load(plugin.id) if (toolbarRegistry) {
.then(settings => { toolbarRegistry.addFactory<Cell>(
const bridge = readOpenCodeSettings(settings.composite); 'Cell',
setOpenCodeRuntime({ 'opencode-cell-actions',
settings: bridge, (cell: Cell) => {
serverSettings: app.serviceManager.serverSettings if (cell instanceof CodeCell) {
}); return new OpenCodeCellActions(cell);
console.log('opencode_bridge settings loaded:', bridge); }
return new Widget();
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);
});
} }
// 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) requestAPI<unknown>('hello', app.serviceManager.serverSettings)
.then(data => { .then(data => {
console.log('hello endpoint:', data); console.log('hello endpoint:', data);
+26 -36
View File
@@ -3,6 +3,10 @@
* *
* These mirror the Python server's `CellContext` / `OpenCodeRequest` / `OpenCodeResponse` * These mirror the Python server's `CellContext` / `OpenCodeRequest` / `OpenCodeResponse`
* shapes in `opencode_bridge/routes.py`. Keep them in sync. * 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 { export interface ErrorOutput {
@@ -22,10 +26,7 @@ export interface CellContext {
error: ErrorOutput | null; error: ErrorOutput | null;
} }
export type OpenCodeMode = 'optimize' | 'fix' | 'edit';
export interface OpenCodeRequest { export interface OpenCodeRequest {
mode: OpenCodeMode;
prompt: string; prompt: string;
context: CellContext; context: CellContext;
providerId?: string; providerId?: string;
@@ -34,8 +35,9 @@ export interface OpenCodeRequest {
export interface OpenCodeSuccess { export interface OpenCodeSuccess {
ok: true; ok: true;
mode: OpenCodeMode; /** The AI's reply as markdown (code in ```fences```, optional explanation).
finalSource: string; * Render with marked. The cell source is NOT replaced. */
markdown: string;
sessionId: string; sessionId: string;
notebookPath: string; notebookPath: string;
} }
@@ -47,46 +49,34 @@ export interface OpenCodeFailure {
export type OpenCodeResponse = OpenCodeSuccess | OpenCodeFailure; export type OpenCodeResponse = OpenCodeSuccess | OpenCodeFailure;
/** Frontend view of the 4 schema/plugin.json fields. */ /** Response from GET /opencode-bridge/session-messages?notebook=... .
export interface OpenCodeSettings { * Projected from OpenCode's {info, parts}[] by the server into a
opencodeServerUrl: string; * frontend-friendly {role, content}[]. */
opencodeServerUser: string; export interface OpenCodeMessage {
opencodeServerPassword: string; role: "user" | "assistant";
requestTimeoutSeconds: number; content: string;
opencodeProvider: string;
opencodeModel: string;
} }
export const DEFAULT_OPENCODE_SETTINGS: OpenCodeSettings = { export interface OpenCodeMessagesResponse {
opencodeServerUrl: 'http://127.0.0.1:4096', messages: OpenCodeMessage[];
opencodeServerUser: 'opencode', }
opencodeServerPassword: '',
requestTimeoutSeconds: 120,
opencodeProvider: '',
opencodeModel: '',
};
export function readOpenCodeSettings(composite: unknown): OpenCodeSettings { /** Response from GET /opencode-bridge/providers (proxies OpenCode /config/providers).
const c = (composite ?? {}) as Partial<OpenCodeSettings>; * Each Provider.models is a Record keyed by modelID (NOT an array). */
return { export interface OpenCodeModel {
opencodeServerUrl: c.opencodeServerUrl || DEFAULT_OPENCODE_SETTINGS.opencodeServerUrl, id: string;
opencodeServerUser: c.opencodeServerUser || DEFAULT_OPENCODE_SETTINGS.opencodeServerUser, name?: string;
opencodeServerPassword: c.opencodeServerPassword || '', [k: string]: unknown;
requestTimeoutSeconds:
typeof c.requestTimeoutSeconds === 'number'
? c.requestTimeoutSeconds
: DEFAULT_OPENCODE_SETTINGS.requestTimeoutSeconds,
opencodeProvider: c.opencodeProvider || '',
opencodeModel: c.opencodeModel || '',
};
} }
/** Response from GET /opencode-bridge/providers. */
export interface OpenCodeProvider { export interface OpenCodeProvider {
id: string; id: string;
models: { id: string; [k: string]: unknown }[]; name?: string;
source?: string;
models: { [modelID: string]: OpenCodeModel };
} }
export interface OpenCodeProvidersResponse { export interface OpenCodeProvidersResponse {
providers: OpenCodeProvider[]; providers: OpenCodeProvider[];
default?: { [providerID: string]: string };
} }
+163
View File
@@ -3,3 +3,166 @@
https://jupyterlab.readthedocs.io/en/stable/developer/css.html 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;
}