Author SHA1 Message Date
tao.chen 12ec84a7c8 docs: refresh HANDOVER for floating panel work (d412130)
Captures the state after the per-cell -> global floating panel refactor
plus the hide+show accumulation fix. Includes:
- New commit history and validation status (jest 68, pytest 73, tsc OK)
- Full file-change table for the 6 files touched this session
- OpenCodeFloatingPanel design notes (lifecycle, state, API)
- Detailed writeup of the d412130 bug + defensive DOM cleanup fix
  + the test's known limitation (mock is more lenient than real Lumino)
- Outstanding work for next session: real JupyterLab test, Playwright,
  jlpm build:prod, pre-existing lint cleanup
- Critical context: kimi/kimi-k2.7-code was rejected by Codex proxy
  throughout this session; CC fallback was used for almost all code work
2026-07-29 10:37:46 +08:00
tao.chen d412130612 fix: hide+show cycles no longer accumulate prompt nodes in floating panel
Bug: clicking the floating button after the first time caused the panel
content to multiply — every hide+show cycle appended a new prompt while
the previous one stayed in the DOM.

Root cause: the prompt's Lumino parent is null (it's attached to a raw
HTMLDivElement, not a Widget, so neither appendChild nor
Widget.attach(widget, HTMLElement) sets a Lumino parent). The
defensive DOM cleanup in _hidePrompt now removes the prompt's node
explicitly before nulling the reference, so no residual nodes linger
across show+hide cycles. Also use Widget.attach in _showPrompt for the
before-attach / after-attach lifecycle messages the prompt may rely on.

Added a regression test that toggles 5 times and asserts the panel
contains exactly one .opencode-inline-prompt node.

Files:
- src/components/opencode_floating_panel.ts — defensive cleanup + attach
- src/__tests__/opencode_floating_panel.spec.ts — regression test
2026-07-29 09:43:59 +08:00
tao.chen c0eba7e0e6 feat: replace per-cell AI button with global floating panel
The per-cell toolbar AI button (OpenCodeCellActions) is removed. A
single round button is mounted on document.body at the bottom-right of
the JupyterLab shell; clicking it opens a floating panel containing
the same OpenCodeInlinePrompt widget. Clicking again closes it.

The panel resolves the active notebook from app.shell.currentWidget
at open time and re-resolves it whenever currentChanged fires while
the panel is open. The active cell (used by the 📋 插入单元格内容,
↪ Insert, and ⟳ Replace cell actions) is resolved via a new
getActiveCell callback in OpenCodeInlinePrompt, decoupling the prompt
itself from any specific CodeCell instance.

Files:
- src/components/opencode_floating_panel.ts (new) — global panel
- src/components/opencode_cell_actions.ts (deleted) — logic moved up
- src/components/opencode_inline_prompt.ts — getActiveCell option
- src/index.ts — wire floating panel, drop toolbar factory
- style/base.css — .opencode-floating-* styles
- src/__tests__/opencode_floating_panel.spec.ts — new tests (renamed)

All existing functionality preserved: multi-session UI, SSE
streaming, permission/question interaction, history reload.
2026-07-28 19:34:52 +08:00
tao.chenandClaude f9d52f7b8e docs: add HANDOVER.md for next session context
The current session's context window is nearly full. This document
captures the project state, the key design decisions that future
maintainers should NOT re-derive, and the known minor issues that
were intentionally left for a follow-up session.

Contents:
- Project overview + current state (tag v0.1.1, branch main @ 310d507)
- Architecture diagram
- Critical code locations
- All 11 route endpoints in one table
- 5 key design decisions (SSE filter, no cell auto-inject, multi-session
  model, no partial-render markdown, real interactive permission/question
  UI) - written so the next session doesn't accidentally undo them
- How to run the tests
- Known minor issues / TODO list

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-28 18:10:20 +08:00
tao.chen 310d50759a fix: SSE filter no longer drops content events for a different session
Bug: after switching to a new session via the SessionSelector, the
next /edit would not get a reply. Root cause: applyEvent dropped
ANY event whose sessionID didn't match the prompt's _sessionId,
including text deltas / reasoning / tool / idle. OpenCode's sessionID
extraction from event payloads is not perfectly consistent across
event types, so the prompt could end up with a stale _sessionId
that doesn't match the events coming in, and EVERY event got
filtered out -> no reply.

Fix: the cross-session filter is now strict ONLY for
permission.asked / question.asked (so the user can't accidentally
reply to another session's prompt). Content events always pass
through. The server's /events handler is the single source of
truth for session filtering (it has the URL ?session= param); the
client's filter would only ever mask the user's interaction with
their own active session.

Also: drop the cell-context auto-injection. The OpenCodeRequest
now carries only {notebookPath}. The user explicitly attaches
whatever they want via the new '📋 插入单元格内容' button
(inserts the cell source as a markdown code block into the input).
This makes the LLM context match user intent and stops the
OpenCode prompt from being polluted with stale previousCode /
traceback snapshots.

Backend
-------
- _build_request_body: now takes only the prompt, returns
  parts=[{text: prompt}]. No more <previous_cell>/<traceback>/<cell>
  tag wrapping.

Frontend
-------
- types.ts: CellContext collapsed to {notebookPath}. ErrorOutput /
  cellId / source / previousCode / error / cellIndex / totalCells
  / language all removed.
- opencode_cell_actions.ts: extractCellContextFromCell() replaced
  with extractNotebookPathFromCell(). _context field renamed to
  _notebookPath. NotebookPanel / extractCellContext / context/
  cell_context imports all removed.
- src/context/cell_context.ts + src/__tests__/cell_context.spec.ts:
  deleted.

New feature: '📋 插入单元格内容' button
----------------------------------------
The button sits at the start of the actions row (visually
left-aligned via margin-right:auto) and appends the current cell's
source as a markdown code fence to the textarea. Caret is moved
to the end so the user can keep typing. The fence info string is
the cell's model type (falls back to a plain fence if the type
isn't a clean language identifier). No-op for empty cells.

Tests
-----
- 72 jest (was 78: -8 cell-context tests + 1 SSE filter test +
  3 insert-cell tests + rewrites). 73 pytest (unchanged). Build
  green.
2026-07-28 18:02:21 +08:00
tao.chenandClaude 4ff5d7021c refactor: dedup setSessionId calls in session-switch path; add test coverage
Code review of 893f049 flagged 6 findings. This commit addresses them all:

#1 (real issue, production code): setSessionId was being called
TWICE per session switch - once explicitly from the cell-action
callback, and again from the prompt's _handleSwitchSession (which
also assigned _sessionId, called _resetStreamPointers, and set
_sessionSelect.value directly). The explicit setSessionId in the
cell-action callbacks (onCreateSession, onSwitchSession) is now
removed; the prompt's handlers do all of that bookkeeping through
a single setSessionId() call, which already implements the
dropdown + reset-stream-pointers logic.

#2 / #4 (test coverage): the existing switching test only checked
that the right HTTP call was made. New assertions: the prompt's
session-messages mock IS called again (history actually reloaded)
and the rendered history reflects the new session's content
(verifies the user sees the right messages, not stale DOM).

#3 (test coverage): new test for the error path. bind_existing
returns ok:false -> the prompt rolls the dropdown back to the
previous active id, leaves _sessionId unchanged, and shows a
Notification.error. This locks in the rollback behavior so a
future refactor can't silently break it.

#5 (test cleanup): mockedCallOpenCodeSessionMessages (the spy used
to verify history reload) is now declared at the top of the spec
file alongside the other mocked* variables, with a meaningful
name (mockedSessionMessages) instead of an inline jest.mocked()
that was hard to find.

#6 (source-doc): added a TODO(frontend) comment on
callOpenCodeSetActiveSession explaining why it is currently
unused (kept for a future 'switch between already-bound sessions'
flow that would avoid the bind round-trip) and warning a future
maintainer not to delete the corresponding server route without
removing the client function (or vice versa).

Tests: 77 jest (was 76), 73 pytest, build green.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-28 17:00:29 +08:00
tao.chenandClaude 893f049eb7 fix: call bind (not set_active) when switching to a fresh session
Bug: 400 from PUT /opencode-bridge/sessions/active when the user picked
a session from the global list that wasn't yet bound to this notebook.
The cell-action onSwitchSession callback was:
  1. PUT /sessions/active (server checks bound -> 400)
  2. PUT /sessions/notebook (bind, but never reached)
The order was wrong: set_active requires the session to be bound FIRST.

Fix: the PUT /sessions/notebook route does both bind AND set active
in a single call (server handler: bind_existing() then response
includes the new activeSessionId). So the callback now only needs to
call bind-existing. The /sessions/active endpoint is still useful for
future "switch between already-bound sessions" flows but the current
"pick from global list" path doesn't need it.

Tests: updated the switching test to assert bind-existing is called
and set-active is NOT. Also added not.toHaveBeenCalled() to lock in
the new behavior. 76 jest, 73 pytest, build green.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-28 16:39:27 +08:00
tao.chenandClaude d2bd4f1e48 feat: multi-session management (browse all + bind N sessions per notebook)
User can now see EVERY session on the OpenCode Serve side (not just
the per-notebook mapping) and bind multiple sessions to a single
notebook. One of the bound sessions is the "active" one (the one
the next /edit uses); the others are historical. The user can
switch, create, or delete via the new session selector UI in the
inline prompt.

Backend
-------
- SessionManager rewritten for 1-notebook-N-sessions: keeps both an
   map and a  map. New methods: create_new,
  bind_existing, set_active, unbind, unbind_active, delete_session,
  list_sessions_for_notebook. Existing get_or_create / peek /
  invalidate / list_sessions / release semantics preserved
  (release now deletes ALL bound sessions, not just the active).
- New route GET /opencode-bridge/sessions/all — list ALL OpenCode
  sessions via /session.
- New route GET /opencode-bridge/sessions/notebook?notebook=... —
  bound sessions for one notebook, enriched with title/createdAt
  from the global list.
- POST .../sessions/notebook — create a new session, bind, set
  active.
- PUT  .../sessions/notebook — bind an existing sessionId, set
  active (no OpenCode round-trip).
- DELETE .../sessions/notebook — delete the session on OpenCode
  AND remove its binding.
- New route GET/PUT/DELETE /opencode-bridge/sessions/active —
  read / switch / unbind the active session for one notebook.
- OpenCodeClient.list_all_sessions() — proxy OpenCode /session.

Frontend
-------
- SessionSelector dropdown in the inline prompt: lists ALL OpenCode
  sessions with the active one selected, plus a "+ 新建会话"
  sentinel entry. "+ 新建" button + "🔄" refresh button.
  Switching calls onSwitchSession; selecting "+ 新建会话" or
  clicking the new button calls onCreateSession. After both,
  the prompt's history is reloaded for the new active session.
- New cell-action callbacks: onListSessions, onCreateSession,
  onSwitchSession, onReloadHistory. Each closes the SSE first
  so events for the OLD session don't bleed in during the switch.
- 8 new API client functions in api/opencode_client.ts.
- New types in types.ts: OpenCodeSessionMeta,
  OpenCodeNotebookSession, OpenCodeAllSessionsResponse,
  OpenCodeNotebookSessionsResponse, OpenCodeSessionOpResponse.

Tests
-----
- 73 backend pytest (was 64): +6 new SessionManager multi-session
  unit tests + 7 new route tests (all-sessions, notebook GET/POST/
  PUT/DELETE, active GET/PUT/DELETE).
- 76 frontend jest (was 72): +4 new prompt tests (session selector
  rendered when callbacks wired, initialize() populates the list,
  + 新建 creates+binds, dropdown change switches and reloads).
- package.json version bumped 0.1.0 -> 0.1.1 to match the v0.1.1 tag
  that was already published.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-28 16:25:42 +08:00
tao.chenandClaude fcd015ac0e fix: stop running marked.parse on every streaming text delta
CI / CI (push) Successful in 17m54s
The streaming path used to call _renderAssistantMarkdown on every
incoming text delta, which re-ran marked.parse on the WHOLE
accumulated markdown source and re-post-processed (added the
Copy/Insert/Replace toolbar to) every <pre> block.

This is a partial-render problem: a markdown source mid-stream (e.g.
an opening ```py fence with no closing fence yet, or a partial
heading) classifies DIFFERENTLY than the complete message. Each
new delta could re-classify the same text into a different DOM
shape, causing visible flicker - <pre>s appearing and disappearing,
the toolbar popping in and out, paragraphs re-grouping - while the
model is still typing. The bug surfaced because the user could see
the glitchy view during a stream but everything looked correct
after closing+reopening the panel (which goes through the static
setMessages -> marked.parse path on the full text).

Fix: keep the streaming path dumb. _appendToAssistantText now just
appends the raw delta to the assistant element's textContent. The
full markdown render happens exactly once at the end of the turn,
inside _resetStreamPointers (which the prompt invokes when
session.idle arrives, before calling onStreamEnd). Result:

- The user still sees real-time text growth (textContent += delta).
- No more intermediate-state flicker.
- The rendered view at turn end is byte-for-byte equivalent to what
  setMessages would produce from the stored history (they share
  _renderAssistantMarkdown).

The corresponding regression test is updated: it now asserts the
DURING-stream state is raw text (no <pre>, no toolbar) and the
POST-idle state is the rendered DOM with the toolbar attached.

Tests: 72 jest (unchanged count, the one streaming test was
rewritten), 52 pytest, build green.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-27 17:52:53 +08:00
tao.chen d867b2f6d6 Merge remote-tracking branch 'origin/main' 2026-07-27 17:40:49 +08:00
tao.chen b4060d3b2f Merge pull request 'Feat/async sse streaming' (#1) from feat/async-sse-streaming into main
Reviewed-on: #1
2026-07-27 17:38:53 +08:00
dd5667cea4 feat: persist user (provider, model) selection per notebook in localStorage
When the user picks a provider+model in the inline prompt, remember
that choice across panel close/reopen AND across JupyterLab restarts,
keyed by notebookPath. If the stored provider (or the model under it)
is no longer present in the current providers payload, fall back to
the default (first provider / default[pid] model) - never silently
apply a stale value that would result in an empty or disabled select.

- New module src/components/model_selection.ts with
  loadModelSelection / saveModelSelection / clearModelSelection.
  Best-effort: localStorage may be disabled (private mode), quota
  may be exhausted, value may be corrupt - all failure modes return
  null / no-op rather than throw.
- IOpenCodeInlinePromptOptions gains notebookPath?: string.
- Constructor resolves the initial selection: prefer stored+valid,
  else first provider / default[pid]. providerSelect.change and
  modelSelect.change listeners call _persistSelection() to keep
  storage in sync.
- _rebuildModelSelect now accepts a preferredModelId; if it exists
  in the current provider's models it's used, else the default.
- OpenCodeCellActions._showPrompt passes notebookPath =
  this._context?.notebookPath so the prompt can key the storage.

Tests
-----
- 10 unit tests in model_selection.spec.ts (load/save/clear +
  malformed JSON + per-notebook independence).
- 4 prompt behavior tests in opencode_cell_actions.spec.ts
  (valid stored applied, provider removed -> default, model removed
  -> default, change -> saved).
- 72 frontend jest (was 58), 52 backend pytest (unchanged),
  build green.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-27 17:38:53 +08:00
b7089519fd feat: async + SSE message flow with interactive permission/question UI
Replace the synchronous /edit (wait for full markdown response) with an
async + SSE flow per demo.html so the user sees text stream in real-time
and can interact with permission/question events the agent raises.

Backend
-------
- EditHandler now calls OpenCode /session/:id/prompt_async and returns
  immediately with {ok, sessionId, notebookPath}; the LLM reply is no
  longer embedded in this response.
- New GlobalEventHandler proxies OpenCode /global/event as
  text/event-stream. Server forwards ALL events; the client filters.
  A too-eager server-side ?session= filter was silently dropping events
  the client would have accepted, so it was removed.
- New PermissionReplyHandler + QuestionReplyHandler forward user
  replies (once/always/reject and freeform answer) back to OpenCode
  Serve at /session/:sid/permissions/:permId and
  /session/:sid/question/:qId/reply.
- OpenCodeClient gains send_message_async(), stream_global_events()
  (async generator over the SSE feed via tornado streaming_callback),
  reply_permission() and reply_question(). Legacy send_message_sync
  removed.

Frontend
--------
- subscribeOpenCodeEvents() opens a fetch+reader SSE client (XSRF
  token injected from serverSettings); AbortController-backed close()
  is idempotent.
- OpenCodeInlinePrompt.applyEvent() routes events into the streaming
  UI: text delta -> assistant message (re-rendered as markdown on
  every delta so the user sees formatted <pre><code> blocks in
  real-time, not raw fence source); reasoning/tool/permission/question
  get collapsible details blocks. session.idle resets stream pointers
  and fires onStreamEnd.
- Permission/question blocks render real interactive UI: three
  buttons (once/always/reject) for permission, a text input + submit
  for question. Click handlers post through the new API routes and
  show success/failure status in place.
- Centralized idle detection (4 shapes: top-level + payload-nested
  x {session.idle, session.status/idle}) inside the prompt; cell
  action reacts via onStreamEnd callback rather than re-parsing
  event types.
- System / workspace / pty / lsp / mcp / installation events AND
  session-level control events (agent.switched, model.switched,
  file.edited) are not rendered in the frontend.

Tests
-----
- 52 backend pytest (was 43)
- 58 frontend jest (was 46)

design.md section 3 updated for the new async /edit response shape
and the new GET /events SSE endpoint.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-27 17:38:53 +08:00
tao.chenandtao.chen a43174e5bc update: optimize config.py 2026-07-27 17:38:53 +08:00
tao.chen 0bd04a825d update: optimize config.py 2026-07-27 14:33:04 +08:00
tao.chen 380e4a3150 更新 .github/workflows/build.yml 2026-07-24 11:02:27 +08:00
tao.chen b0abeaf329 更新 .github/scripts/create-release.sh 2026-07-24 10:59:47 +08:00
27 changed files with 6186 additions and 1571 deletions
+1 -1
View File
@@ -38,7 +38,7 @@ if [ -z "${RELEASE_ID}" ]; then
fi
echo "Release created with ID ${RELEASE_ID}"
for asset in dist/snapshot-*.whl dist/snapshot-*.tar.gz; do
for asset in dist/*.whl dist/*.tar.gz; do
if [ -f "${asset}" ]; then
echo "Uploading ${asset}..."
curl -fsS -X POST "${API_URL}/repos/${REPOSITORY}/releases/${RELEASE_ID}/assets" \
+2
View File
@@ -7,6 +7,8 @@ on:
tags:
- 'v*'
pull_request:
workflow_dispatch:
jobs:
ci:
+207
View File
@@ -0,0 +1,207 @@
# opencode_bridge 交接文档
## 状态速览
**分支**:`feature/floating-ai-button`(已 push,tracking 已设)
**远端**:`http://101.43.40.124:3000/tao.chen/notebook-ai-extension.git`
**基础**:`main` @ `310d507`(上次 HANDOVER 时的版本)
**当前 HEAD**:`d412130`
**提交**:
```
d412130 fix: hide+show cycles no longer accumulate prompt nodes in floating panel
c0eba7e feat: replace per-cell AI button with global floating panel
f9d52f7 docs: add HANDOVER.md for next session context
310d507 fix: SSE filter no longer drops content events for a different session
```
**验证**:✅ Jest 68/68 · ✅ pytest 73/73 · ✅ tsc exit 0 · ⚠️ 2 个 pre-existing lint 错(`FlatProvider`/`BlockEntry` 命名约定,在 `inline_prompt.ts` 里,我没引入)
**PR**:未提。Gitea 链接:
```
http://101.43.40.124:3000/tao.chen/notebook-ai-extension/pulls/new/feature/floating-ai-button
```
---
## 本次会话做了什么
**架构变更**:把 per-cell 工具栏 AI 按钮(原 `OpenCodeCellActions`)整个换成全局右下角悬浮按钮 + 弹出 panel。**不是并存**,是完全替换 — per-cell 路径已删除。
```
Before After
───────────────────────────────── ─────────────────────────────────
Cell 工具栏 → per-cell 按钮 document.body 右下角固定按钮(🪄)
↓ click ↓ click
内嵌 prompt panel(挂到 cell) 悬浮 prompt panel(挂在按钮上方)
↓ ↓
当前 cell 是 owner 从 app.shell.currentWidget 取 active notebook
↓ ↓
"📋 插入单元格内容" 用 this._cell 通过 getActiveCell() 回调拿 active cell
```
---
## 关键改动文件
| 文件 | 改动 |
|------|------|
| `src/components/opencode_floating_panel.ts` | **新增** — 全局按钮 + panel 容器(详见下文) |
| `src/components/opencode_cell_actions.ts` | **删除** — 逻辑全部搬到 floating panel |
| `src/components/opencode_inline_prompt.ts` | 解耦 CodeCell。新增 `getActiveCell?: () => CodeCell \| null` 选项。3 处方法(`_insertCellSource` / `_insertAtCursor` / `_replaceCell`)改用回调。`setDisabled()` 在构造器末尾调一次,确保 insert button 初始 disabled 状态正确 |
| `src/index.ts` | 删 `toolbarRegistry.addFactory` + `IToolbarWidgetRegistry` optional。新建 `OpenCodeFloatingPanel` 挂到 `document.body` |
| `style/base.css` | 删 `.opencode-cell-actions` 块。新增 `.opencode-floating-root` / `.opencode-floating-button` / `.opencode-floating-panel` 样式(fixed 定位、z-index 1000、480px 宽、60vh 高) |
| `src/__tests__/opencode_floating_panel.spec.ts` | 重命名 + 重写。原 1663 行 → 现在 1556 行。67 → 68 tests |
---
## OpenCodeFloatingPanel 设计要点
### DOM 结构
```
<div class="opencode-floating-root"> (position: fixed; bottom: 24; right: 24)
<button class="opencode-floating-button">🪄</button>
<div class="opencode-floating-panel" hidden>
<!-- OpenCodeInlinePrompt 在这里 -->
</div>
</div>
```
### 生命周期
- 构造:创建 button + panel 容器,挂到 `document.body`,监听 `app.shell.currentChanged`
- toggle():if hidden → _showPrompt;else → _hidePrompt
- `_showPrompt()`:resolve 当前 notebook path + active cell lambda → 构造 `OpenCodeInlinePrompt` → 用 **`Widget.attach(prompt, this._panelEl)`** 挂上 → 调 `prompt.initialize()` 拉 session list → 显示
- `_hidePrompt()`:close SSE → dispose prompt → **defensive DOM cleanup**(见下)→ 设 `this._prompt = null` → 隐藏 panel
### State 全在 panel 上(原来在 cell action 上的都搬过来了)
- `_app: JupyterFrontEnd`
- `_serverSettings: ServerConnection.ISettings | null`(构造器传入)
- `_providers: OpenCodeProvidersResponse | null`(构造器传入,通过 `setProviders()` 方法更新)
- `_prompt: OpenCodeInlinePrompt | null`
- `_sseSub: OpenCodeEventSubscription | null`
- `_notebookPath: string | null`
- `_status: 'idle' | 'loading'`
### API(对外)
```ts
class OpenCodeFloatingPanel extends Widget {
constructor(options: {
app: JupyterFrontEnd;
serverSettings: ServerConnection.ISettings;
providers: OpenCodeProvidersResponse | null;
});
setProviders(p: OpenCodeProvidersResponse | null): void; // index.ts 在 providers fetch 完后调
toggle(): void;
}
```
---
## ⚠️ 刚修的 bug(d412130)
**症状**:点击悬浮按钮 → 显示 panel。点第二次 → 隐藏。点第三次 → **看到 2 个 prompt 同时显示**(节点累积)。
**根因**:`OpenCodeInlinePrompt` 是 Lumino Widget,但我们用 `Widget.attach(prompt, this._panelEl)` 把 prompt 挂到一个裸的 `HTMLDivElement` 上(host 不是 Widget)。Lumino 的 `Widget.dispose()` 检查 `this.parent` 然后 `this.isAttached`:
- `this.parent === null`(HTMLElement host 不会建立 Lumino parent)
- `this.isAttached === true`(节点确实在 DOM 里)
-`Widget.detach(this)` 分支 — **理论上能清理**
但在某些场景下(可能是 message loop 时序 / widget tree 重排),dispose 不会移除 DOM 节点,残留累积。
**修法**(`_hidePrompt` 里加 defensive cleanup):
```ts
if (this._prompt) {
const node = this._prompt.node;
this._prompt.dispose();
// Defensive: ensure DOM cleanup even if dispose() missed it
if (node.parentNode === this._panelEl) {
this._panelEl.removeChild(node);
}
this._prompt = null;
}
```
**Regression test**(`opencode_floating_panel.spec.ts`):
```ts
it('hide+show cycles do not accumulate prompt nodes in the panel (regression)', () => {
for (let i = 0; i < 5; i++) { panel.toggle(); panel.toggle(); }
panel.toggle();
expect(panelEl.querySelectorAll('.opencode-inline-prompt').length).toBe(1);
});
```
⚠️ **测试局限**:测试 mock 的 `Widget.dispose()` 直接用 `node.parentNode.removeChild`,比真实 Lumino 更激进清理。**这个 regression test 在 mock 下永远会过**,对真实 bug 的保护力有限 — 真实 Lumino 才是 bug 触发环境。如果你怀疑类似 bug 复发,**必须在真实 JupyterLab 里手测**,不要只看 jest pass。
---
## 关键设计决策(避免来回改)
1. **不用并存模式** — per-cell 按钮完全删除。Linus "good taste" 原则,消除 special case。
2. **Active cell 通过 shell 拿**`app.shell.currentWidget.content.activeCell`,不是 per-cell 硬绑定。Cell 是 `CodeCell | null`,null 时 `getActiveCell()` 返回 null,prompt 用 `Notification.info('请先选中一个 cell')` 提示。
3. **`_onShellChanged` 是箭头函数 class field**(`= (): void => { ... }`)— Lumino signal 调用时 `this` 不一定正确绑定,arrow function 把 `this` 锁死在实例上。
4. **Defensive DOM cleanup** — 见上面 bug 段。任何把 Lumino widget 挂到非 Widget host 上的代码都应该有这层保险。
5. **Mock 的 `Widget.dispose()` 比真实 Lumino 更"宽容"** — 见 bug 段的测试局限说明。
---
## 仍未做的事(如果继续)
- [ ] **真实 JupyterLab + OpenCode Serve 端到端测试** — 我只做了 build/jest/pytest,没在活的 JupyterLab 里点过。bug d412130 是用户手测报的。
- [ ] **Playwright UI tests** — 项目交接文档里说过写过 spec 但没跑,我没动这块。
- [ ] **`jlpm build:prod`** — 尝试运行但 venv 里 jlpm 入口有问题(`.venv/bin/jlpm` 是 Python 脚本,`main()` 调用对不上),用户没让我再继续 debug 这块。我跑了 `jest` + `tsc --noEmit` 间接验证了编译能过,但完整 labextension 打包没跑过。
- [ ] **pre-existing lint 错的处理**`FlatProvider` / `BlockEntry` 接口名不符合项目 `/^I[A-Z]/u` 规则。这两个是我接手前就存在的。可以单独提一个 cleanup commit。
- [ ] **`tsconfig.json` 没动** — strict mode 让我新加了几处 `as any`(`this._app.shell as any`),是因为 JupyterLab 类型里 `JupyterFrontEnd.shell` 在某些版本是 `JupyterShell | null`。可以后续更精确处理。
---
## 环境 / 工具注意事项(下一个 agent 必读)
### Codex 走得很坎坷 ⚠️⚠️⚠️
CLAUDE.md 强制要求 Codex `model: kimi/kimi-k2.7-code`,但本会话中这个模型在火山方舟 Coding Plan 上**反复返回 HTTP 400**:
| 错误 | 频率 |
|------|------|
| `model: kimi-k2.7-code is not valid`(早期,模型名错) | 2 次 |
| `reasoning is not supported by current model`(用户修了 proxy 后) | 多次 |
| 一次 noop 通了,然后实际任务失败 | 1 次 |
CLAUDE.md 现在的版本是 `kimi/kimi-k2.7-code`(带 `kimi/` 前缀),但**仍然被 proxy 拒绝**。**建议**:
1. 下次会话先 `noop` 测一下,通了再发真任务
2. 不通就走 CC fallback(按 CLAUDE.md fallback 规则:`codex-fail-3x → switch-to-cc`)
3. 真要走 Codex,可能需要 proxy 侧明确配置(我无法触及)
我大部分代码工作都是 CC 手动做的。Codex 临死前**做了一件有用的事**:它已经重写了 spec 文件的 65%(包括 rename + mock 更新 + `getActiveCell` 适配 + `makeFakeApp` helper),省了我不少力气。
### jlpm 在 venv 里不能直接用
`.venv/bin/jlpm` 是 Python 脚本入口(`from jupyter_builder.jlpm import main`)。用户终端环境可能直接有 `jlpm`,但我的环境没有。`python -m jupyter_builder.jlpm` 也没成功。
如果下一个 agent 需要跑 `jlpm build:prod``jlpm watch`,建议先确认 `which jlpm` 能找到。
### 分支保护
`feature/floating-ai-button` 没合并到 `main`,还在 review 阶段。`main` 仍是 `310d507`。如果新会话要在 `main` 上继续,先确认这个分支的 PR 是否已合。
---
## 测试命令(确认改动没破坏)
```bash
.venv/bin/pytest opencode_bridge/tests/ -q # 73 passed
./node_modules/.bin/jest --no-coverage # 68 passed (3 suites)
./node_modules/.bin/tsc --noEmit # exit 0
./node_modules/.bin/eslint --ext .ts,.tsx src/ # 仅 pre-existing 错
```
---
## 上下文管理提示
我已经快把 context 烧完了(写这个交接文档前已经是大半)。如果新会话第一件事是"继续上次工作":
1. 读这份 HANDOVER.md 全文
2.`git log feature/floating-ai-button` 确认 commit hash 没变(`d412130`)
3. 跑上面 4 个验证命令,确认环境干净
4. 然后读这次提交改动的 6 个文件,优先 `src/components/opencode_floating_panel.ts``src/__tests__/opencode_floating_panel.spec.ts`
**不要**:`git checkout main` 然后从 main 重新开始 — 这会丢失当前未合并的分支历史。直接在 `feature/floating-ai-button` 上继续即可。
+25 -5
View File
@@ -62,7 +62,7 @@
## 🔌 3. 接口契约设计 (API Contract)
### 接口:`POST /opencode-bridge/edit`v2无 `mode`
### 接口:`POST /opencode-bridge/edit`v4异步 + SSE
**Request Payload**(前端 `OpenCodeCellActions` 在用户提交 inline 输入框时发出):
@@ -84,20 +84,40 @@
}
```
> **v2 变化**body 不含 `mode` / `action` 字段。前端只把 cell 的 input/output/error 收集到 `context`,加用户的自然语言 `prompt` 一起发出;后端用统一的 `UNIFIED_SYSTEM_PROMPT` + LLM 自己判断优化/排错/编辑。`providerId` / `modelId` 来自 JupyterLab settings 透传
> body 不含 `mode` / `action`。`providerId` / `modelId` 来自 inline 输入框动态选择,无 settings 持久化
**Response**当前 v2 实现:单次 JSONSSE 流式属于 Slice 4 后续工作):
**Response**v4 异步):后端调用 OpenCode `/session/:id/prompt_async` 后**立即返回**——不等 LLM 完成。
```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 并在下次请求重建。
> 响应中**无** `markdown` 字段。AI 的回复通过独立的 SSE 端点(`/opencode-bridge/events`)实时推送给前端,前端在 inline prompt 的 history 区里增量渲染文本流 / 推理 / 工具调用 / 权限提问等。Session 由 `SessionManager` 按 `notebookPath` 复用(1 notebook 1 sid),404 / `session not found` 时服务端自动 invalidate 并在下次请求重建。cell 源码**不**被替换;用户可对 AI 消息中的每个代码块使用 Copy / Insert / Replace 按钮。
### 接口:`GET /opencode-bridge/events?session=<sid>`v4 新增 — SSE 代理)
服务端长连接到 OpenCode Serve 的 `GET /global/event`,按 `session=<sid>` 过滤后以 `text/event-stream` 透传给前端。OpenCode 事件 schema 较松散,事件示例:
```json
{"type": "session.next.text.delta", "properties": {"sessionID": "ses-abc123", "delta": "hel"}}
{"type": "session.next.text.delta", "properties": {"sessionID": "ses-abc123", "delta": "lo"}}
{"type": "session.next.reasoning.delta", "properties": {"sessionID": "ses-abc123", "delta": "thinking..."}}
{"type": "session.next.tool.called", "properties": {"sessionID": "ses-abc123", "name": "bash"}}
{"type": "session.next.tool.input.delta", "properties": {"sessionID": "ses-abc123", "delta": "{\"cmd\": \"ls\"}"}}
{"type": "session.next.tool.success", "properties": {"sessionID": "ses-abc123", "result": {"exitCode": 0}}}
{"type": "session.next.agent.switched", "properties": {"sessionID": "ses-abc123", "agentID": "build"}}
{"type": "session.next.model.switched", "properties": {"sessionID": "ses-abc123", "modelID": "claude-..."}}
{"type": "file.edited", "properties": {"sessionID": "ses-abc123", "path": "foo.py"}}
{"type": "permission.asked", "properties": {"sessionID": "ses-abc123", "id": "perm-1", "description": "rm -rf"}}
{"type": "question.asked", "properties": {"sessionID": "ses-abc123", "id": "q-1", "question": "Which env?"}}
{"type": "session.idle", "properties": {"sessionID": "ses-abc123"}}
```
> v4 前端 `OpenCodeInlinePrompt.applyEvent()` 按 `type` 路由到不同渲染器:text/reasoning/tool 块用 `<details>` 可折叠,permission/question 提示独立块(响应需要 OpenCode Serve 提供对应 API;本扩展 v4 仅渲染,不实现 allow/answer 回调路由)。`session.idle` 触发流指针重置、关闭 SSE 订阅、重新启用输入框。System 事件(`server.*` / `workspace.*` / `lsp.*` / `mcp.*` / `pty.*` / `installation.*`)以日志行形式展示,不参与 session 过滤。
---
+27 -1
View File
@@ -3,12 +3,24 @@ try:
except ImportError:
# Fallback when using the package in dev mode without installing
# in editable mode with pip. It is highly recommended to install
# the package from a stable release or in editable mode: https://pip.pypa.io/en/stable/topics/local-project-installs/#editable-installs
# the package from a stable release or in editable mode: https://pip.pypa.io/en/stable/topics/local-project-install/
import warnings
warnings.warn("Importing 'opencode_bridge' outside a proper installation.")
__version__ = "dev"
from .config import OpencodeConfig
from .routes import setup_route_handlers
# Re-export OpencodeConfig at module level so the JupyterApp CLI parser
# (which auto-discovers Configurable subclasses from extension-point
# modules) recognises ``--OpencodeConfig.<trait>`` flags.
__all__ = [
"OpencodeConfig",
"setup_route_handlers",
"_jupyter_labextension_paths",
"_jupyter_server_extension_points",
"_load_jupyter_server_extension",
]
def _jupyter_labextension_paths():
return [{
@@ -26,11 +38,25 @@ def _jupyter_server_extension_points():
def _load_jupyter_server_extension(server_app):
"""Registers the API handler to receive HTTP requests from the frontend extension.
Also instantiates the :class:`OpencodeConfig` configurable (with the
server app as parent so CLI / config-file overrides take effect) and
exposes it to request handlers via
``web_app.settings["opencode_bridge"]``.
Parameters
----------
server_app: jupyterlab.labapp.LabApp
JupyterLab application instance
"""
# Belt-and-suspenders: ensure the CLI parser recognises --OpencodeConfig.*
# even on JupyterApp versions that don't auto-discover Configurable
# subclasses from extension-point modules.
if OpencodeConfig not in server_app.classes:
server_app.classes.append(OpencodeConfig)
cfg = OpencodeConfig(parent=server_app)
server_app.web_app.settings["opencode_bridge"] = cfg
setup_route_handlers(server_app.web_app)
name = "opencode_bridge"
server_app.log.info(f"Registered {name} server extension")
+113 -11
View File
@@ -1,10 +1,22 @@
"""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.
Connection fields (URL / user / password / request timeout) are resolved
in the following priority order, highest first:
1. ``--OpencodeConfig.<trait>`` CLI flag (e.g. ``--OpencodeConfig.url=...``)
2. ``c.OpencodeConfig.<trait>`` line in a Jupyter config file
3. ``OPENCODE_BRIDGE_*`` environment variable
4. Built-in default
The ``OpencodeConfig`` class is a ``traitlets.config.Configurable``; the
Jupyter server extension instantiates it with ``parent=server_app`` and
stashes it on ``web_app.settings["opencode_bridge"]`` so handlers can pick
it up via ``resolve_config``.
Env vars (kept for backward compatibility — also the default-value source
for the traitlets so a fresh ``OpencodeConfig()`` with no overrides still
honours them):
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)
@@ -15,6 +27,9 @@ from __future__ import annotations
import os
from typing import NamedTuple, Optional, Tuple
from traitlets import Int, Unicode, default
from traitlets.config import Configurable
ENV_URL = "OPENCODE_BRIDGE_URL"
ENV_USER = "OPENCODE_BRIDGE_USER"
@@ -40,19 +55,106 @@ class OpenCodeConfig(NamedTuple):
return (self.user, self.password)
def resolve_config(jupyter_settings: dict) -> OpenCodeConfig:
"""Resolve OpenCode connection config from environment variables + defaults.
class OpencodeConfig(Configurable):
"""Traitlets-configurable connection settings for the opencode-bridge server extension.
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).
Exposed to ``jupyter lab`` / ``jupyter server`` as ``--OpencodeConfig.<trait>``
and to ``jupyter_server_config.py`` as ``c.OpencodeConfig.<trait>``. Defaults
fall back to the ``OPENCODE_BRIDGE_*`` env vars and then to built-in defaults,
so the legacy env-var-only flow keeps working unchanged.
"""
_ = jupyter_settings # intentionally unused; see module docstring
url = Unicode(
DEFAULT_URL,
config=True,
help=(
"Base URL of the locally-running OpenCode Serve process. "
"Overrides the OPENCODE_BRIDGE_URL env var."
),
)
user = Unicode(
DEFAULT_USER,
config=True,
help=(
"HTTP Basic Auth username for OpenCode Serve. "
"Overrides the OPENCODE_BRIDGE_USER env var."
),
)
password = Unicode(
"",
config=True,
help=(
"HTTP Basic Auth password for OpenCode Serve (empty = no auth). "
"Overrides the OPENCODE_BRIDGE_PASSWORD env var."
),
)
request_timeout_seconds = Int(
DEFAULT_REQUEST_TIMEOUT,
config=True,
help=(
"Per-request timeout in seconds when calling OpenCode Serve. "
"Overrides the OPENCODE_BRIDGE_TIMEOUT env var."
),
)
@default("url")
def _default_url(self) -> str:
return os.environ.get(ENV_URL) or DEFAULT_URL
@default("user")
def _default_user(self) -> str:
return os.environ.get(ENV_USER) or DEFAULT_USER
@default("password")
def _default_password(self) -> str:
return os.environ.get(ENV_PASSWORD) or ""
@default("request_timeout_seconds")
def _default_request_timeout_seconds(self) -> int:
return int(os.environ.get(ENV_TIMEOUT) or DEFAULT_REQUEST_TIMEOUT)
def _to_opencode_config(bridge) -> OpenCodeConfig:
"""Project an :class:`OpencodeConfig` (or duck-typed equivalent) to the
internal ``OpenCodeConfig`` NamedTuple consumed by ``OpenCodeClient``.
"""
return OpenCodeConfig(
url=bridge.url,
user=bridge.user,
password=bridge.password,
request_timeout_seconds=bridge.request_timeout_seconds,
)
def resolve_config(jupyter_settings) -> OpenCodeConfig:
"""Resolve OpenCode connection config into an ``OpenCodeConfig`` NamedTuple.
Accepts either a settings dict (legacy) or an :class:`OpencodeConfig`
instance directly, because request handlers call
``resolve_config(handler.settings.get("opencode_bridge", {}))`` and pass
the inner value (which is now an ``OpencodeConfig`` instance after the
server extension's load hook populates it).
Source order (highest priority first):
1. The argument itself is an :class:`OpencodeConfig` instance.
2. ``jupyter_settings["opencode_bridge"]`` is an :class:`OpencodeConfig`
instance (legacy call style).
3. Otherwise fall back to env vars + built-in defaults.
"""
if isinstance(jupyter_settings, OpencodeConfig):
return _to_opencode_config(jupyter_settings)
bridge = (jupyter_settings or {}).get("opencode_bridge")
if isinstance(bridge, OpencodeConfig):
return _to_opencode_config(bridge)
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
)
),
)
+174 -8
View File
@@ -1,8 +1,22 @@
"""Async HTTP client for the local OpenCode server."""
"""Async HTTP client for the local OpenCode server.
Two interaction modes:
- **Request/response** (one-shot) - ``health``, ``list_providers``,
``create_session``, ``send_message_async``, ``abort``, ``delete_session``,
``list_session_messages``. ``send_message_async`` fires the prompt and
returns immediately; the actual LLM response is consumed via the SSE
stream (next mode).
- **Streaming** (long-lived) - :meth:`stream_global_events` is an async
generator over OpenCode Serve's ``GET /global/event`` SSE feed. It
yields each event as a parsed JSON dict until the upstream closes.
"""
import asyncio
import atexit
import json
from typing import Any, Optional
import logging
from typing import Any, AsyncIterator, Optional
from tornado.httpclient import AsyncHTTPClient, HTTPRequest
from tornado.httputil import HTTPHeaders
@@ -10,6 +24,9 @@ from tornado.httputil import HTTPHeaders
from .config import OpenCodeConfig
log = logging.getLogger("opencode_bridge.opencode_client")
class OpenCodeError(Exception):
"""Raised when an OpenCode API request fails."""
@@ -64,22 +81,28 @@ class OpenCodeClient:
async def create_session(self, title: str) -> dict[str, Any]:
return await self._request("POST", "/session", {"title": title})
async def send_message_sync(
async def send_message_async(
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]:
) -> None:
"""Fire-and-forget POST /session/:id/prompt_async.
Returns once OpenCode has accepted the prompt and queued it for
processing - NOT when the LLM is done. Subscribe to
:meth:`stream_global_events` to follow the agent's progress.
"""
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(
await self._request(
"POST",
"/session/%s/message" % session_id,
"/session/%s/prompt_async" % session_id,
body,
)
@@ -87,6 +110,41 @@ class OpenCodeClient:
result = await self._request("POST", "/session/%s/abort" % session_id)
return result is not None
async def reply_permission(
self,
session_id: str,
permission_id: str,
response: str,
) -> bool:
"""Respond to a `permission.asked` event.
``response`` must be one of ``"once"`` / ``"always"`` / ``"reject"``,
matching the OpenCode Serve API. Returns True on success.
"""
result = await self._request(
"POST",
"/session/%s/permissions/%s" % (session_id, permission_id),
{"response": response},
)
return result is not None
async def reply_question(
self,
session_id: str,
question_id: str,
answer: str,
) -> bool:
"""Reply to a `question.asked` event with the user's freeform answer.
Returns True on success.
"""
result = await self._request(
"POST",
"/session/%s/question/%s/reply" % (session_id, question_id),
{"answer": answer},
)
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
@@ -95,16 +153,124 @@ class OpenCodeClient:
"""List all messages in the given OpenCode session.
Returns the raw OpenCode response: a list of
`{ info: { role: "user"|"assistant", ... }, parts: [...] }`.
``{ info: { role: "user"|"assistant", ... }, parts: [...] }``.
The server extension is responsible for projecting this into a
frontend-friendly `{role, content}[]` shape.
frontend-friendly ``{role, content}[]`` shape.
"""
return await self._request("GET", "/session/%s/message" % session_id)
async def list_all_sessions(self) -> list[dict[str, Any]]:
"""List ALL sessions on the OpenCode Serve side (not just the ones
bound to a notebook in our SessionManager).
Returns the raw OpenCode response: a list of session objects with
metadata (``id``, ``title``, ``createdAt``, ``updatedAt``,
``messageCount`` etc., whatever the upstream returns).
"""
return await self._request("GET", "/session")
@property
def endpoint(self) -> str:
return self._config.url
async def stream_global_events(self) -> AsyncIterator[dict[str, Any]]:
"""Stream OpenCode Serve's ``GET /global/event`` SSE feed.
Yields one parsed JSON dict per ``data: {...}`` event block. The
stream runs until:
- the upstream disconnects (end of stream),
- the consumer stops iterating (caller cancels this generator),
- or a network error occurs (logged and the generator returns).
The consumer is responsible for filtering events by session ID
(the upstream pushes events for ALL sessions, plus system events).
"""
queue: asyncio.Queue = asyncio.Queue()
end_sentinel = object()
def on_chunk(chunk: bytes) -> None:
if chunk:
queue.put_nowait(chunk)
headers = HTTPHeaders({
"Accept": "text/event-stream",
"Cache-Control": "no-cache",
})
request_kwargs: dict[str, Any] = {
"method": "GET",
"headers": headers,
"request_timeout": 0, # 0 = no timeout for long-lived streams
# streaming_callback must live on the HTTPRequest itself; tornado
# rejects any fetch() kwargs when an HTTPRequest is passed.
"streaming_callback": on_chunk,
}
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(self._config.url + "/global/event", **request_kwargs)
async def _run_fetch() -> None:
try:
await self._http_client.fetch(request, raise_error=False)
except Exception as e:
log.warning("OpenCode /global/event fetch error: %s", e)
finally:
queue.put_nowait(end_sentinel)
runner_task = asyncio.create_task(_run_fetch())
buffer = ""
try:
while True:
chunk = await queue.get()
if chunk is end_sentinel:
break
if not isinstance(chunk, bytes):
continue
buffer += chunk.decode("utf-8", errors="replace")
while "\n\n" in buffer:
block, buffer = buffer.split("\n\n", 1)
parsed = self._parse_sse_block(block)
if parsed is not None:
yield parsed
# Drain any trailing data after the last \n\n
if buffer.strip():
parsed = self._parse_sse_block(buffer)
if parsed is not None:
yield parsed
finally:
if not runner_task.done():
runner_task.cancel()
try:
await runner_task
except (asyncio.CancelledError, Exception):
pass
@staticmethod
def _parse_sse_block(block: str) -> Optional[dict[str, Any]]:
"""Parse a single SSE event block into a JSON dict (or None).
SSE format reminder: a block is one or more ``field: value`` lines
separated by newlines; multiple ``data:`` lines are concatenated
with ``\\n``. We only care about ``data:``.
"""
data_parts: list[str] = []
for line in block.split("\n"):
if line.startswith("data:"):
data_parts.append(line[5:].lstrip())
if not data_parts:
return None
data = "\n".join(data_parts).strip()
if not data:
return None
try:
result = json.loads(data)
except json.JSONDecodeError:
return None
return result if isinstance(result, dict) else None
async def _request(
self,
method: str,
+438 -54
View File
@@ -48,44 +48,19 @@ def get_session_manager(handler: APIHandler) -> SessionManager:
return sm
def _build_request_body(prompt: str, context: dict) -> dict:
"""Build full request body for OpenCode POST /session/:id/message.
def _build_request_body(prompt: str) -> dict:
"""Build request body for OpenCode POST /session/:id/prompt_async.
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.
v0.2.x: only the user's prompt is sent to the LLM. We no longer
auto-wrap the cell source / previous-cell / traceback in tags —
the user inserts whatever they want via the "📋 插入单元格内容"
button (or pastes manually), so the LLM context exactly matches
user intent. Returns dict with 'parts' and 'system' keys.
"""
parts: list[dict] = []
if context.get("previousCode"):
parts.append({
"type": "text",
"text": "<previous_cell>\n%s\n</previous_cell>\n" % context["previousCode"],
})
error = context.get("error")
if error:
parts.append({
"type": "text",
"text": (
"<traceback>\n%s: %s\n" % (error["ename"], error["evalue"])
+ "\n".join(error.get("traceback", []))
+ "\n</traceback>\n"
),
})
parts.append({
"type": "text",
"text": "<cell language='%s'>\n%s\n</cell>\n" % (
context.get("language", "python"),
context["source"],
),
})
if prompt:
parts.append({"type": "text", "text": "<instruction>\n%s\n</instruction>" % prompt})
return {"parts": parts, "system": UNIFIED_SYSTEM_PROMPT}
return {
"parts": [{"type": "text", "text": prompt}],
"system": UNIFIED_SYSTEM_PROMPT,
}
def _strip_code_fence(s: str) -> str:
@@ -105,6 +80,26 @@ def _strip_code_fence(s: str) -> str:
return s
def _extract_session_id(event: dict) -> str | None:
"""Best-effort session ID extraction from an OpenCode event.
OpenCode's event shape is not strictly documented; this mirrors the
heuristics in demo.html ``handleGlobalEvent``:
properties.sessionID / properties.sessionId /
payload.properties.sessionID / syncEvent.aggregateID
"""
props = event.get("properties") or {}
sid = props.get("sessionID") or props.get("sessionId")
if sid:
return sid
payload_props = (event.get("payload") or {}).get("properties") or {}
sid = payload_props.get("sessionID") or payload_props.get("sessionId")
if sid:
return sid
sync = event.get("syncEvent") or (event.get("payload") or {}).get("syncEvent") or {}
return sync.get("aggregateID")
class HelloRouteHandler(APIHandler):
# The following decorator should be present on all verb methods (head, get, post,
# patch, put, delete, options) to ensure only authorized user can request the
@@ -154,6 +149,13 @@ class ProvidersHandler(APIHandler):
class EditHandler(APIHandler):
"""Async edit: fire prompt_async, return sessionId immediately.
The LLM's response is consumed via the separate ``/opencode-bridge/events``
SSE endpoint. Frontend subscribes to SSE after this call returns and
processes events incrementally.
"""
@tornado.web.authenticated
async def post(self):
try:
@@ -177,45 +179,175 @@ class EditHandler(APIHandler):
sm = get_session_manager(self)
try:
sid = await sm.get_or_create(notebook_path)
request_body = _build_request_body(prompt, context)
result = await client.send_message_sync(
request_body = _build_request_body(prompt)
await client.send_message_async(
sid,
request_body["parts"],
provider_id=provider_id,
model_id=model_id,
system=request_body["system"],
)
text_parts = [
p.get("text", "")
for p in result.get("parts", [])
if p.get("type") == "text"
]
# The AI's reply is markdown (code in ```fences```, optional
# explanation). Pass it through unchanged so the frontend
# `marked.parse` can render the code blocks and structure.
markdown = "\n".join(text_parts).strip()
self.finish(json.dumps({
"ok": True,
"markdown": markdown,
"sessionId": sid,
"notebookPath": notebook_path,
}))
except OpenCodeError as e:
# If session is invalid on OpenCode side, invalidate cache.
# 404 / 410 / "session not found" -> next get_or_create will recreate.
if "404" in str(e) or "not found" in str(e).lower():
sm.invalidate(notebook_path)
log.warning("invalidated dead session for %s", notebook_path)
log.exception("edit failed")
log.exception("edit (async) failed")
self.set_status(502)
self.finish(json.dumps({"ok": False, "error": str(e)}))
except Exception as e:
log.exception("edit failed")
log.exception("edit (async) failed")
self.set_status(502)
self.finish(json.dumps({"ok": False, "error": str(e)}))
class GlobalEventHandler(APIHandler):
"""SSE proxy for OpenCode Serve's ``GET /global/event``.
Streams every event from the upstream SSE feed to the client as
``text/event-stream`` so EventSource / fetch+reader on the frontend
can consume them directly. The frontend filters by session ID —
we deliberately do NOT filter on the server because OpenCode's
event-shape has varied across versions and a too-eager server
filter can silently drop events the client would have accepted.
The connection is held open until the client disconnects, the upstream
closes, or an unrecoverable error occurs.
"""
@tornado.web.authenticated
async def get(self):
# The ?session= query param is accepted for API compatibility
# but the server does NOT use it to filter — see docstring.
_ = self.get_query_argument("session", None)
self.set_header("Content-Type", "text/event-stream")
self.set_header("Cache-Control", "no-cache")
# Disable proxy buffering (e.g. nginx) so chunks reach the client
# immediately.
self.set_header("X-Accel-Buffering", "no")
# Prevent tornado from closing the connection on its own keep-alive
# timer — we hold it open as long as the upstream is alive.
self.set_header("Connection", "keep-alive")
client = make_client(self)
try:
async for event in client.stream_global_events():
payload = json.dumps(event, ensure_ascii=False)
self.write("data: %s\n\n" % payload)
# flush() pushes the chunk to the client; without it tornado
# would buffer until the response is "finished".
await self.flush()
except tornado.iostream.StreamClosedError:
# Client went away — normal disconnect, not an error.
log.info("SSE client disconnected")
except Exception as e:
log.exception("SSE proxy error")
try:
self.write("data: %s\n\n" % json.dumps({
"type": "error",
"message": str(e),
}))
await self.flush()
except Exception:
# Connection already gone; nothing to send.
pass
class PermissionReplyHandler(APIHandler):
"""Respond to a `permission.asked` event for a given session.
POST ``/opencode-bridge/permissions/:permId?session=:sid`` with body
``{"response": "once"|"always"|"reject"}``. Forwards to OpenCode
Serve's ``POST /session/:sid/permissions/:permId``.
"""
VALID_RESPONSES = ("once", "always", "reject")
@tornado.web.authenticated
async def post(self, perm_id: str):
session_id = self.get_query_argument("session", "")
if not session_id:
self.set_status(400)
self.finish(json.dumps({"error": "missing 'session' query parameter"}))
return
try:
body = json.loads(self.request.body) if self.request.body else {}
except json.JSONDecodeError as e:
self.set_status(400)
self.finish(json.dumps({"error": "bad request: %s" % e}))
return
response = body.get("response")
if response not in self.VALID_RESPONSES:
self.set_status(400)
self.finish(json.dumps({
"error": "response must be one of %s" % list(self.VALID_RESPONSES)
}))
return
client = make_client(self)
try:
ok = await client.reply_permission(session_id, perm_id, response)
self.finish(json.dumps({
"ok": ok,
"permissionId": perm_id,
"response": response,
}))
except OpenCodeError as e:
log.exception("permission reply failed")
self.set_status(502)
self.finish(json.dumps({"ok": False, "error": str(e)}))
except Exception as e:
log.exception("permission reply failed")
self.set_status(502)
self.finish(json.dumps({"ok": False, "error": str(e)}))
class QuestionReplyHandler(APIHandler):
"""Reply to a `question.asked` event with a freeform answer.
POST ``/opencode-bridge/questions/:qId/reply?session=:sid`` with body
``{"answer": "..."}``. Forwards to OpenCode Serve's
``POST /session/:sid/question/:qId/reply``.
"""
@tornado.web.authenticated
async def post(self, question_id: str):
session_id = self.get_query_argument("session", "")
if not session_id:
self.set_status(400)
self.finish(json.dumps({"error": "missing 'session' query parameter"}))
return
try:
body = json.loads(self.request.body) if self.request.body else {}
except json.JSONDecodeError as e:
self.set_status(400)
self.finish(json.dumps({"error": "bad request: %s" % e}))
return
answer = body.get("answer")
if not isinstance(answer, str) or not answer.strip():
self.set_status(400)
self.finish(json.dumps({"error": "missing 'answer'"}))
return
client = make_client(self)
try:
ok = await client.reply_question(session_id, question_id, answer)
self.finish(json.dumps({
"ok": ok,
"questionId": question_id,
}))
except OpenCodeError as e:
log.exception("question reply failed")
self.set_status(502)
self.finish(json.dumps({"ok": False, "error": str(e)}))
except Exception as e:
log.exception("question reply failed")
self.set_status(502)
self.finish(json.dumps({"ok": False, "error": str(e)}))
# NO finally delete — session is reused per notebook.
class SessionListHandler(APIHandler):
@@ -301,6 +433,242 @@ class SessionMessagesHandler(APIHandler):
self.finish(json.dumps({"messages": messages}))
# ---------------------------------------------------------------------------
# Multi-session management endpoints
# ---------------------------------------------------------------------------
#
# Model: each notebook can have multiple OpenCode sessions bound to it.
# One of the bound sessions is the "active" one (used by the next /edit).
# The user can:
# - list all OpenCode sessions (global, for the "browse" UI)
# - list sessions bound to a specific notebook
# - create a brand-new session and bind it (becomes active)
# - bind an existing session id (e.g. one they saw in the global list)
# - switch which bound session is active
# - unbind the active one (next /edit will lazy-create a new one)
# - delete a specific bound session (also removes it from OpenCode Serve)
# ---------------------------------------------------------------------------
class AllSessionsListHandler(APIHandler):
"""GET /opencode-bridge/sessions/all — list EVERY session on OpenCode
Serve, regardless of which notebook (if any) it is bound to. Powers
the "browse all conversations" UI in the cell-action panel.
"""
@tornado.web.authenticated
async def get(self):
try:
client = make_client(self)
sessions = await client.list_all_sessions()
except OpenCodeError as e:
log.exception("list all sessions failed")
self.set_status(502)
self.finish(json.dumps({"ok": False, "error": str(e)}))
return
except Exception as e:
log.exception("list all sessions failed")
self.set_status(502)
self.finish(json.dumps({"ok": False, "error": str(e)}))
return
self.finish(json.dumps({"ok": True, "sessions": sessions or []}))
class NotebookSessionsHandler(APIHandler):
"""Manage the list of sessions BOUND to one notebook.
GET /opencode-bridge/sessions/notebook?notebook=...
-> { ok, activeSessionId, sessions: [{sessionId, title, ...}, ...] }
The metadata (title, createdAt, ...) is enriched from
OpenCode's `GET /session` so the frontend can render
the picker without a second round-trip per session.
POST /opencode-bridge/sessions/notebook?notebook=...&title=...
-> { ok, sessionId, activeSessionId } — creates a new
session on OpenCode, binds it, sets as active.
PUT /opencode-bridge/sessions/notebook?notebook=...&sessionId=...
-> { ok, sessionId, activeSessionId } — binds an
existing sessionId (e.g. one the user picked from
the global list) to this notebook and sets as active.
DELETE /opencode-bridge/sessions/notebook?notebook=...&sessionId=...
-> { ok, deleted } — deletes the session on OpenCode
Serve AND removes its binding from this notebook.
If it was the active one, clears active.
"""
@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)
bound_ids = sm.list_sessions_for_notebook(notebook_path)
active = sm.get_active(notebook_path)
# Enrich with metadata from OpenCode Serve so the UI can show
# the title / createdAt without a second round-trip.
try:
all_sessions = await make_client(self).list_all_sessions()
by_id = {s.get("id"): s for s in (all_sessions or [])}
except Exception as e:
log.warning(
"could not enrich bound sessions with metadata: %s", e
)
by_id = {}
sessions = []
for sid in bound_ids:
meta = by_id.get(sid, {})
sessions.append({
"sessionId": sid,
"title": meta.get("title"),
"createdAt": meta.get("createdAt"),
"updatedAt": meta.get("updatedAt"),
"isActive": sid == active,
})
self.finish(json.dumps({
"ok": True,
"notebookPath": notebook_path,
"activeSessionId": active,
"sessions": sessions,
}))
@tornado.web.authenticated
async def post(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
title = self.get_query_argument("title", None) or None
sm = get_session_manager(self)
try:
sid = await sm.create_new(notebook_path, title=title)
except OpenCodeError as e:
log.exception("create session failed")
self.set_status(502)
self.finish(json.dumps({"ok": False, "error": str(e)}))
return
except Exception as e:
log.exception("create session failed")
self.set_status(502)
self.finish(json.dumps({"ok": False, "error": str(e)}))
return
self.finish(json.dumps({
"ok": True,
"notebookPath": notebook_path,
"sessionId": sid,
"activeSessionId": sid,
}))
@tornado.web.authenticated
def put(self):
notebook_path = self.get_query_argument("notebook", "")
session_id = self.get_query_argument("sessionId", "")
if not notebook_path or not session_id:
self.set_status(400)
self.finish(json.dumps({
"error": "missing 'notebook' and/or 'sessionId' query parameter"
}))
return
sm = get_session_manager(self)
sm.bind_existing(notebook_path, session_id)
self.finish(json.dumps({
"ok": True,
"notebookPath": notebook_path,
"sessionId": session_id,
"activeSessionId": sm.get_active(notebook_path),
}))
@tornado.web.authenticated
async def delete(self):
notebook_path = self.get_query_argument("notebook", "")
session_id = self.get_query_argument("sessionId", "")
if not notebook_path or not session_id:
self.set_status(400)
self.finish(json.dumps({
"error": "missing 'notebook' and/or 'sessionId' query parameter"
}))
return
sm = get_session_manager(self)
deleted = await sm.delete_session(notebook_path, session_id)
self.finish(json.dumps({
"ok": True,
"notebookPath": notebook_path,
"sessionId": session_id,
"deleted": deleted,
"activeSessionId": sm.get_active(notebook_path),
}))
class ActiveSessionHandler(APIHandler):
"""Manage the ACTIVE session for one notebook.
GET /opencode-bridge/sessions/active?notebook=...
-> { ok, activeSessionId, notebookPath }
PUT /opencode-bridge/sessions/active?notebook=...&sessionId=...
-> { ok, activeSessionId } — switch the active bound
session. sessionId MUST already be in the bound list.
DELETE /opencode-bridge/sessions/active?notebook=...
-> { ok } — unbind the active session (next /edit will
lazy-create a new one). Does NOT delete the session
on OpenCode Serve (use the notebook DELETE for that).
"""
@tornado.web.authenticated
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)
self.finish(json.dumps({
"ok": True,
"notebookPath": notebook_path,
"activeSessionId": sm.get_active(notebook_path),
}))
@tornado.web.authenticated
def put(self):
notebook_path = self.get_query_argument("notebook", "")
session_id = self.get_query_argument("sessionId", "")
if not notebook_path or not session_id:
self.set_status(400)
self.finish(json.dumps({
"error": "missing 'notebook' and/or 'sessionId' query parameter"
}))
return
sm = get_session_manager(self)
if not sm.set_active(notebook_path, session_id):
self.set_status(400)
self.finish(json.dumps({
"ok": False,
"error": "sessionId is not bound to this notebook; "
"POST /sessions/notebook or PUT /sessions/notebook first"
}))
return
self.finish(json.dumps({
"ok": True,
"notebookPath": notebook_path,
"activeSessionId": sm.get_active(notebook_path),
}))
@tornado.web.authenticated
def delete(self):
notebook_path = self.get_query_argument("notebook", "")
if not notebook_path:
self.set_status(400)
self.finish(json.dumps({"error": "missing 'notebook' query parameter"}))
return
sm = get_session_manager(self)
sm.unbind_active(notebook_path)
self.finish(json.dumps({
"ok": True,
"notebookPath": notebook_path,
"activeSessionId": sm.get_active(notebook_path),
}))
def setup_route_handlers(web_app):
host_pattern = ".*$"
base_url = web_app.settings["base_url"]
@@ -310,9 +678,25 @@ def setup_route_handlers(web_app):
(url_path_join(base_url, "opencode-bridge", "health"), HealthHandler),
(url_path_join(base_url, "opencode-bridge", "providers"), ProvidersHandler),
(url_path_join(base_url, "opencode-bridge", "edit"), EditHandler),
(url_path_join(base_url, "opencode-bridge", "events"), GlobalEventHandler),
(url_path_join(base_url, "opencode-bridge", "sessions"), SessionListHandler),
(url_path_join(base_url, "opencode-bridge", "session"), SessionReleaseHandler),
(url_path_join(base_url, "opencode-bridge", "session-messages"), SessionMessagesHandler),
# Multi-session management (notebook can bind N sessions; one is active).
(url_path_join(base_url, "opencode-bridge", "sessions", "all"), AllSessionsListHandler),
(url_path_join(base_url, "opencode-bridge", "sessions", "notebook"), NotebookSessionsHandler),
(url_path_join(base_url, "opencode-bridge", "sessions", "active"), ActiveSessionHandler),
# /permissions/<perm_id> and /questions/<q_id>/reply
# use a URLSpec with a regex so the path param is forwarded to
# the handler as a positional argument.
tornado.web.URLSpec(
url_path_join(base_url, "opencode-bridge", "permissions", r"([A-Za-z0-9_\-]+)"),
PermissionReplyHandler,
),
tornado.web.URLSpec(
url_path_join(base_url, "opencode-bridge", "questions", r"([A-Za-z0-9_\-]+)", "reply"),
QuestionReplyHandler,
),
]
web_app.add_handlers(host_pattern, handlers)
+211 -60
View File
@@ -1,13 +1,29 @@
"""Per-notebook session manager for OpenCode.
Maps notebookPath -> OpenCode sessionID. Lazy create on first use.
Async-safe via per-path asyncio.Lock. No automatic cleanup.
Maps each notebookPath to one OR MORE OpenCode session IDs. One of
the bound sessions is the "active" one (the one used for the next
``/edit``). The others are historical — they were created in this
notebook at some point but the user switched away (e.g. started a
new conversation topic).
Lifecycle:
- ``get_or_create`` returns the active session, lazily creating one on
first use. This is the path used by ``EditHandler`` on every prompt.
- ``create_new`` always creates a brand-new session on OpenCode Serve,
binds it, sets it as active. Used by the "New session" UI action.
- ``bind_existing`` / ``set_active`` / ``unbind`` / ``delete_session``
are the management operations for the per-notebook session list.
Async-safety: a per-notebook ``asyncio.Lock`` serializes
``get_or_create`` so concurrent prompts on the same notebook don't
double-create. The lock survives across calls (held in ``_locks``) so
subsequent calls can reuse it.
"""
from __future__ import annotations
import asyncio
import logging
from typing import Callable
from typing import Callable, Optional
from .opencode_client import OpenCodeClient
@@ -17,81 +33,216 @@ ClientFactory = Callable[[], OpenCodeClient]
class SessionManager:
"""Tracks one OpenCode session per notebook path.
Threading/async model:
- Multiple coroutines may call get_or_create for the same notebook.
- First call creates; subsequent calls return the same sessionID.
- Per-notebook asyncio.Lock prevents double-create under concurrency.
- Locks remain in the map; they may be needed again for the same path.
"""
def __init__(self, client_factory: ClientFactory) -> None:
self._client_factory = client_factory
self._sessions: dict[str, str] = {} # notebookPath -> sessionID
self._locks: dict[str, asyncio.Lock] = {} # notebookPath -> lock
self._titles: dict[str, str] = {} # notebookPath -> title (for debug)
# notebookPath -> active sessionId
self._active: dict[str, str] = {}
# notebookPath -> ordered list of sessionIds bound to this
# notebook (the first is the oldest; the active one may be
# anywhere in the list). Used by the UI to render the session
# picker.
self._bound: dict[str, list[str]] = {}
# Per-notebook lock for serializing lazy-create.
self._locks: dict[str, asyncio.Lock] = {}
# ---------- active-session lookup / create ----------
async def get_or_create(self, notebook_path: str) -> str:
"""Return session ID for the notebook, creating one if needed.
Idempotent for the same path. Different paths get different sessions.
"""Return the active session id for this notebook, creating one
if no active session is bound yet. This is what ``EditHandler``
calls on every prompt.
"""
existing = self._sessions.get(notebook_path)
if existing is not None:
return existing
active = self._active.get(notebook_path)
if active is not None:
return active
lock = self._locks.setdefault(notebook_path, asyncio.Lock())
async with lock:
existing = self._sessions.get(notebook_path)
if existing is not None:
return existing
client = self._client_factory()
session = await client.create_session(
title="jupyter:%s" % notebook_path
)
sid = session["id"]
self._sessions[notebook_path] = sid
self._titles[notebook_path] = notebook_path
log.info("created opencode session %s for %s", sid, notebook_path)
return sid
active = self._active.get(notebook_path)
if active is not None:
return active
return await self._create_and_bind(notebook_path, title=None)
async def release(self, notebook_path: str) -> bool:
"""Delete session and remove from map. Returns True if a session existed."""
sid = self._sessions.pop(notebook_path, None)
self._titles.pop(notebook_path, None)
self._locks.pop(notebook_path, None)
if sid is None:
async def _create_and_bind(
self, notebook_path: str, title: Optional[str]
) -> str:
client = self._client_factory()
session = await client.create_session(
title=title or "jupyter:%s" % notebook_path
)
sid = session["id"]
self._bind(notebook_path, sid)
log.info("created opencode session %s for %s", sid, notebook_path)
return sid
def get_active(self, notebook_path: str) -> Optional[str]:
"""Return the active sessionId for the notebook, or None if no
active session has been bound/created yet. Does NOT create one.
"""
return self._active.get(notebook_path)
def peek(self, notebook_path: str) -> Optional[str]:
"""Alias for :meth:`get_active` (legacy name)."""
return self.get_active(notebook_path)
def has_session(self, notebook_path: str) -> bool:
"""True iff at least one session has ever been bound to this
notebook (active or not)."""
return notebook_path in self._bound
# ---------- mutation: bind / unbind / set_active / create_new ----------
def _bind(self, notebook_path: str, session_id: str) -> None:
"""Add session_id to the bound list (if not already) and set it
as active. Idempotent on re-bind.
"""
self._active[notebook_path] = session_id
bound = self._bound.setdefault(notebook_path, [])
if session_id not in bound:
bound.append(session_id)
async def create_new(
self, notebook_path: str, title: Optional[str] = None
) -> str:
"""Create a brand new session on OpenCode Serve, bind it to
this notebook, set as active, return its id.
"""
return await self._create_and_bind(notebook_path, title)
def bind_existing(self, notebook_path: str, session_id: str) -> None:
"""Bind an existing sessionId to this notebook and set as active.
Does NOT call OpenCode — the caller must verify the session
exists (e.g. via ``GET /session``).
"""
self._bind(notebook_path, session_id)
def set_active(self, notebook_path: str, session_id: str) -> bool:
"""Switch the active session. Returns False if ``session_id`` is
not currently bound to this notebook (callers should bind first
if uncertain).
"""
bound = self._bound.get(notebook_path, [])
if session_id not in bound:
return False
self._active[notebook_path] = session_id
return True
def unbind(self, notebook_path: str, session_id: str) -> bool:
"""Remove ``session_id`` from this notebook's bindings. If it was
the active one, also clear active (next prompt will lazy-create
a new session). Returns True if it was bound.
"""
bound = self._bound.get(notebook_path)
if not bound or session_id not in bound:
return False
bound.remove(session_id)
if self._active.get(notebook_path) == session_id:
self._active.pop(notebook_path, None)
if not bound:
self._bound.pop(notebook_path, None)
return True
def unbind_active(self, notebook_path: str) -> bool:
"""Remove the active session binding only (leaves the rest of
the notebook's bound sessions intact). Next prompt will
lazy-create a new active session. Returns True if there was an
active binding to remove.
"""
active = self._active.pop(notebook_path, None)
if active is None:
return False
bound = self._bound.get(notebook_path)
if bound is not None:
bound.remove(active)
if not bound:
self._bound.pop(notebook_path, None)
return True
async def delete_session(
self, notebook_path: str, session_id: str
) -> bool:
"""Delete the session on OpenCode Serve AND remove its binding.
Order matters: we ask OpenCode to delete FIRST, and only remove
the local binding if OpenCode confirmed the delete. That way a
failed delete leaves the binding intact (the user can retry);
a deleted session never leaves a phantom binding behind.
Returns True if the session was actually deleted (i.e. it was
bound AND OpenCode acknowledged the delete). If the session is
not bound to this notebook, do NOT call OpenCode (we don't
want to silently delete sessions owned by other tools).
"""
if not self._is_bound(notebook_path, session_id):
return False
try:
client = self._client_factory()
return await client.delete_session(sid)
deleted = await client.delete_session(session_id)
except Exception:
log.warning(
"failed to delete opencode session %s for %s", sid, notebook_path
)
log.warning("failed to delete opencode session %s", session_id)
return False
# Whether the delete succeeded or just returned False (404), the
# session is gone on the OpenCode side — drop the binding to
# keep the UI consistent. We return whether OpenCode said
# "yes" so the caller can surface a warning on a 404.
self.unbind(notebook_path, session_id)
return deleted
def _is_bound(self, notebook_path: str, session_id: str) -> bool:
bound = self._bound.get(notebook_path)
return bool(bound) and session_id in bound
# ---------- legacy / compat ----------
async def release(self, notebook_path: str) -> bool:
"""Tear down ALL state for this notebook: delete every bound
session on OpenCode Serve AND clear every local binding.
Returns True iff at least one session was actually deleted
on the OpenCode side. (For deleting a SPECIFIC session use
:meth:`delete_session`.)
"""
bound = list(self._bound.get(notebook_path, []))
deleted_any = False
for sid in bound:
if await self.delete_session(notebook_path, sid):
deleted_any = True
# `delete_session` clears the binding. Belt-and-suspenders: drop
# any straggler state.
self._bound.pop(notebook_path, None)
self._active.pop(notebook_path, None)
self._locks.pop(notebook_path, None)
return deleted_any
def invalidate(self, notebook_path: str) -> bool:
"""Drop the cached sessionID without calling OpenCode. Returns True if removed.
Use this when an upstream error indicates the session is dead (e.g., 404).
"""Drop the cached active sessionId without calling OpenCode.
Returns True if a binding was removed. Use this when an
upstream error indicates the session is dead (e.g. 404).
"""
sid = self._sessions.pop(notebook_path, None)
self._titles.pop(notebook_path, None)
return sid is not None
def has_session(self, notebook_path: str) -> bool:
return notebook_path in self._sessions
def peek(self, notebook_path: str) -> Optional[str]:
"""Return the cached sessionID for the notebook, or None if no
session has been created yet. Does NOT create one (unlike
get_or_create) — used by the history endpoint to avoid spawning
a session just to report that there is none."""
return self._sessions.get(notebook_path)
was_active = self._active.pop(notebook_path, None)
if was_active is None:
return False
bound = self._bound.get(notebook_path)
if bound is not None:
try:
bound.remove(was_active)
except ValueError:
pass
if not bound:
self._bound.pop(notebook_path, None)
return True
def list_sessions(self) -> list[dict]:
"""Debug: list all (notebookPath, activeSessionId) pairs across
every notebook. One row per notebook — does NOT enumerate the
full bound-list per notebook. Use
:meth:`list_sessions_for_notebook` for that.
"""
return [
{"notebookPath": path, "sessionId": sid}
for path, sid in sorted(self._sessions.items())
for path, sid in sorted(self._active.items())
]
def list_sessions_for_notebook(self, notebook_path: str) -> list[str]:
"""All sessionIds bound to this notebook (in bind order: oldest
first, newest last). Empty list if none.
"""
return list(self._bound.get(notebook_path, []))
+137 -9
View File
@@ -98,28 +98,30 @@ async def test_auth_absent_when_no_password(base_config: OpenCodeConfig) -> None
@pytest.mark.asyncio
async def test_send_message_sync_includes_model_when_provider_and_model_given(
async def test_send_message_async_includes_model_when_provider_and_model_given(
base_config: OpenCodeConfig,
) -> None:
"""send_message_async posts to /prompt_async with provider/model when both set."""
client, mock = _make_client(base_config, [(200, {"done": True})])
parts = [{"type": "text", "text": "hello"}]
result = await client.send_message_sync("s1", parts, provider_id="p1", model_id="m1")
assert result == {"done": True}
result = await client.send_message_async("s1", parts, provider_id="p1", model_id="m1")
assert result is None
call = mock.calls[0]
assert call["request"].url == "http://127.0.0.1:4096/session/s1/message"
assert call["request"].url == "http://127.0.0.1:4096/session/s1/prompt_async"
assert call["request"].method == "POST"
body = json.loads(call["request"].body.decode("utf-8"))
assert body["parts"] == parts
assert body["model"] == {"providerID": "p1", "modelID": "m1"}
@pytest.mark.asyncio
async def test_send_message_sync_omits_model_when_provider_or_model_missing(
async def test_send_message_async_omits_model_when_provider_or_model_missing(
base_config: OpenCodeConfig,
) -> None:
client, mock = _make_client(base_config, [(200, {"done": True})])
parts = [{"type": "text", "text": "hello"}]
result = await client.send_message_sync("s1", parts)
assert result == {"done": True}
result = await client.send_message_async("s1", parts)
assert result is None
call = mock.calls[0]
body = json.loads(call["request"].body.decode("utf-8"))
assert body["parts"] == parts
@@ -127,7 +129,7 @@ async def test_send_message_sync_omits_model_when_provider_or_model_missing(
@pytest.mark.asyncio
async def test_send_message_sync_includes_system_when_provided() -> None:
async def test_send_message_async_includes_system_when_provided() -> None:
"""system param is forwarded into the request body when not None."""
config = OpenCodeConfig(
url="http://x:1", user="u", password="", request_timeout_seconds=120
@@ -136,7 +138,7 @@ async def test_send_message_sync_includes_system_when_provided() -> None:
mock.responses.append((200, {"info": {}, "parts": []}))
client = OpenCodeClient(config, http_client=mock)
await client.send_message_sync("sid", [{"type": "text", "text": "hi"}], system="be brief")
await client.send_message_async("sid", [{"type": "text", "text": "hi"}], system="be brief")
assert len(mock.calls) == 1
body = json.loads(mock.calls[0]["request"].body.decode("utf-8"))
@@ -144,6 +146,83 @@ async def test_send_message_sync_includes_system_when_provided() -> None:
assert body["parts"] == [{"type": "text", "text": "hi"}]
@pytest.mark.asyncio
async def test_stream_global_events_yields_parsed_events(
base_config: OpenCodeConfig,
) -> None:
"""stream_global_events yields one dict per `data: {...}` SSE block."""
class _StreamingMock(MockHTTPClient):
async def fetch(self, request, **kwargs):
self.calls.append({"request": request, "kwargs": kwargs})
# Simulate OpenCode's /global/event: three events back-to-back.
chunks = [
b"data: {\"type\": \"session.next.text.delta\", \"properties\": "
b"{\"sessionID\": \"s1\", \"delta\": \"hello \"}}\n\n",
b"data: {\"type\": \"session.next.text.delta\", \"properties\": "
b"{\"sessionID\": \"s1\", \"delta\": \"world\"}}\n\n",
b"data: {\"type\": \"session.idle\", \"properties\": "
b"{\"sessionID\": \"s1\"}}\n\n",
]
# streaming_callback now lives on the HTTPRequest (per tornado
# API — fetch() rejects kwargs when an HTTPRequest is passed).
cb = getattr(request, "streaming_callback", None)
if cb is not None:
for c in chunks:
cb(c)
# Empty body, code 200 to terminate normally.
return tornado.httpclient.HTTPResponse(
request=request,
code=200,
headers=tornado.httputil.HTTPHeaders(),
buffer=BytesIO(b""),
)
mock = _StreamingMock()
client = OpenCodeClient(base_config, http_client=mock)
events = []
async for ev in client.stream_global_events():
events.append(ev)
assert len(events) == 3
assert events[0]["type"] == "session.next.text.delta"
assert events[0]["properties"]["delta"] == "hello "
assert events[1]["properties"]["delta"] == "world"
assert events[2]["type"] == "session.idle"
# Stream used the SSE accept header and no timeout.
call = mock.calls[0]
assert call["kwargs"] == {"raise_error": False}
assert call["request"].method == "GET"
assert call["request"].url == "http://127.0.0.1:4096/global/event"
assert call["request"].request_timeout == 0
assert call["request"].headers.get("Accept") == "text/event-stream"
assert call["request"].streaming_callback is not None
def test_parse_sse_block_handles_concatenated_data_lines() -> None:
"""Multi-line `data:` blocks are joined with \n (per SSE spec)."""
parsed = OpenCodeClient._parse_sse_block(
"data: {\"a\": 1,\ndata: \"b\": 2}"
)
assert parsed == {"a": 1, "b": 2}
def test_parse_sse_block_ignores_non_data_fields() -> None:
parsed = OpenCodeClient._parse_sse_block("event: ping\ndata: {\"k\": \"v\"}\n\n")
assert parsed == {"k": "v"}
def test_parse_sse_block_returns_none_on_empty() -> None:
assert OpenCodeClient._parse_sse_block("") is None
assert OpenCodeClient._parse_sse_block("event: ping\n\n") is None
assert OpenCodeClient._parse_sse_block(": heartbeat\n\n") is None
def test_parse_sse_block_returns_none_on_bad_json() -> None:
assert OpenCodeClient._parse_sse_block("data: not-json\n\n") is None
@pytest.mark.asyncio
async def test_delete_session_returns_bool_by_status(base_config: OpenCodeConfig) -> None:
client, mock = _make_client(base_config, [(200, True), (404, False)])
@@ -154,6 +233,55 @@ async def test_delete_session_returns_bool_by_status(base_config: OpenCodeConfig
assert mock.calls[0]["request"].url == "http://127.0.0.1:4096/session/s1"
@pytest.mark.asyncio
async def test_reply_permission_posts_response(base_config: OpenCodeConfig) -> None:
"""reply_permission forwards the response string to /session/:id/permissions/:permId."""
client, mock = _make_client(base_config, [(200, True)])
ok = await client.reply_permission("s1", "perm-1", "once")
assert ok is True
call = mock.calls[0]
assert call["request"].url == "http://127.0.0.1:4096/session/s1/permissions/perm-1"
assert call["request"].method == "POST"
body = json.loads(call["request"].body.decode("utf-8"))
assert body == {"response": "once"}
@pytest.mark.asyncio
async def test_reply_permission_returns_false_on_404(
base_config: OpenCodeConfig,
) -> None:
client, mock = _make_client(base_config, [(404, False)])
assert await client.reply_permission("s1", "perm-1", "reject") is False
@pytest.mark.asyncio
async def test_reply_question_posts_answer(base_config: OpenCodeConfig) -> None:
"""reply_question forwards the answer to /session/:id/question/:qid/reply."""
client, mock = _make_client(base_config, [(200, True)])
ok = await client.reply_question("s1", "q-7", "use 3.12")
assert ok is True
call = mock.calls[0]
assert call["request"].url == "http://127.0.0.1:4096/session/s1/question/q-7/reply"
body = json.loads(call["request"].body.decode("utf-8"))
assert body == {"answer": "use 3.12"}
@pytest.mark.asyncio
async def test_list_all_sessions_gets_session(base_config: OpenCodeConfig) -> None:
"""list_all_sessions proxies OpenCode Serve's GET /session and returns
the raw list (no projection)."""
payload = [
{"id": "s1", "title": "first", "createdAt": 1, "updatedAt": 2},
{"id": "s2", "title": "second", "createdAt": 3, "updatedAt": 4},
]
client, mock = _make_client(base_config, [(200, payload)])
sessions = await client.list_all_sessions()
assert sessions == payload
call = mock.calls[0]
assert call["request"].url == "http://127.0.0.1:4096/session"
assert call["request"].method == "GET"
@pytest.mark.asyncio
async def test_non_2xx_response_raises_with_body(base_config: OpenCodeConfig) -> None:
client, mock = _make_client(base_config, [(500, {"error": "boom"})])
+529 -51
View File
@@ -12,10 +12,6 @@ class FakeOpenCodeClient:
self.session_id = "fake-session-123"
self.health_response = {"healthy": True, "version": "0.1.0"}
self.providers_response = {"providers": [{"id": "anthropic", "models": [{"id": "claude"}]}]}
self.message_response = {
"info": {"id": "msg-1"},
"parts": [{"type": "text", "text": "def foo():\n return 42\n"}],
}
# Canned session-messages list response (list_session_messages).
# Default: one user + one assistant message, mixed text parts.
self.messages_response = [
@@ -45,9 +41,13 @@ class FakeOpenCodeClient:
self.calls.append(("create_session", title))
return {"id": self.session_id, "title": title}
async def send_message_sync(self, session_id, parts, provider_id=None, model_id=None, system=None):
self.calls.append(("send_message_sync", session_id, parts, system))
return self.message_response
async def send_message_async(
self, session_id, parts, provider_id=None, model_id=None, system=None
):
self.calls.append(
("send_message_async", session_id, parts, system, provider_id, model_id)
)
return None
async def delete_session(self, session_id):
self.calls.append(("delete_session", session_id))
@@ -57,38 +57,123 @@ class FakeOpenCodeClient:
self.calls.append(("list_session_messages", session_id))
return self.messages_response
class FakeSessionManager:
"""Drop-in replacement for SessionManager with recording."""
def __init__(self, session_id: str = "fake-session-123") -> None:
self._session_id = session_id
self.calls: list = []
async def get_or_create(self, notebook_path: str) -> str:
self.calls.append(("get_or_create", notebook_path))
return self._session_id
def peek(self, notebook_path: str):
# Mirror SessionManager.peek: return the session id without
# creating one. None means "no session yet" (used by the no-session
# test to short-circuit the history route).
return self._session_id or None
async def release(self, notebook_path: str) -> bool:
self.calls.append(("release", notebook_path))
return True
def list_sessions(self) -> list:
async def list_all_sessions(self):
self.calls.append(("list_all_sessions",))
return [
{"notebookPath": path, "sessionId": sid}
for path, sid in sorted(self._sessions.items())
{
"id": "fake-session-123",
"title": "jupyter:foo.ipynb",
"createdAt": 1700000000000,
"updatedAt": 1700000001000,
},
{
"id": "other-session",
"title": "manual session",
"createdAt": 1700000010000,
"updatedAt": 1700000011000,
},
]
def invalidate(self, notebook_path: str) -> bool:
self.calls.append(("invalidate", notebook_path))
async def create_session(self, title):
self.calls.append(("create_session", title))
# Return a fresh id (different from the default "fake-session-123").
new_id = "newly-created-" + title
return {"id": new_id, "title": title}
async def reply_permission(self, session_id, permission_id, response):
self.calls.append(("reply_permission", session_id, permission_id, response))
return True
async def reply_question(self, session_id, question_id, answer):
self.calls.append(("reply_question", session_id, question_id, answer))
return True
async def stream_global_events(self):
"""Async generator — tests can monkey-patch this to control the stream."""
if False: # pragma: no cover - never executed, just makes this an asyncgen
yield {}
class FakeSessionManager:
"""Thin wrapper around the real SessionManager with a recording
``FakeOpenCodeClient``. Lets route tests exercise the real
multi-session logic without touching OpenCode.
"""
def __init__(self, session_id: str = "fake-session-123", client=None) -> None:
from opencode_bridge.session_manager import SessionManager
# `_client` attribute used by some legacy tests. Allow injection
# so a test can share its FakeOpenCodeClient with the SessionManager
# (the real SessionManager calls into its own client_factory, NOT
# the route's make_client).
self._client = client if client is not None else FakeOpenCodeClient()
self._sm = SessionManager(lambda: self._client)
self.calls: list = self._client.calls
# Legacy attribute used by list_sessions() etc.
self._sessions: dict = {}
# ---- delegation to the real SessionManager ----
def _record(self, name, *args):
self.calls.append((name, *args))
return None
def get_or_create(self, notebook_path: str):
self._record("get_or_create", notebook_path)
return self._sm.get_or_create(notebook_path)
def peek(self, notebook_path: str):
return self._sm.peek(notebook_path)
def get_active(self, notebook_path: str):
return self._sm.get_active(notebook_path)
def list_sessions(self) -> list:
return self._sm.list_sessions()
def list_sessions_for_notebook(self, notebook_path: str):
return self._sm.list_sessions_for_notebook(notebook_path)
def bind_existing(self, notebook_path: str, session_id: str) -> None:
self._record("bind_existing", notebook_path, session_id)
self._sm.bind_existing(notebook_path, session_id)
def set_active(self, notebook_path: str, session_id: str) -> bool:
self._record("set_active", notebook_path, session_id)
return self._sm.set_active(notebook_path, session_id)
def unbind(self, notebook_path: str, session_id: str) -> bool:
self._record("unbind", notebook_path, session_id)
return self._sm.unbind(notebook_path, session_id)
def unbind_active(self, notebook_path: str) -> bool:
self._record("unbind_active", notebook_path)
return self._sm.unbind_active(notebook_path)
def invalidate(self, notebook_path: str) -> bool:
self._record("invalidate", notebook_path)
return self._sm.invalidate(notebook_path)
async def release(self, notebook_path: str) -> bool:
self._record("release", notebook_path)
return await self._sm.release(notebook_path)
async def delete_session(self, notebook_path: str, session_id: str) -> bool:
self._record("delete_session", notebook_path, session_id)
return await self._sm.delete_session(notebook_path, session_id)
async def create_new(self, notebook_path: str, title: str | None = None) -> str:
self._record("create_new", notebook_path, title)
return await self._sm.create_new(notebook_path, title)
# ---- internal-state proxies (used by tests that poke internals) ----
@property
def _active(self) -> dict:
return self._sm._active
@property
def _bound(self) -> dict:
return self._sm._bound
async def test_hello(jp_fetch):
# When
@@ -131,6 +216,12 @@ async def test_providers_handler(monkeypatch, jp_fetch):
async def test_edit_handler(monkeypatch, jp_fetch):
"""POST /opencode-bridge/edit fires prompt_async and returns sessionId only.
The async path returns immediately with `{ok, sessionId, notebookPath}` —
the actual LLM response is consumed via the /events SSE endpoint, not
embedded in this response.
"""
fake = FakeOpenCodeClient()
monkeypatch.setattr("opencode_bridge.routes.make_client", lambda h: fake)
@@ -159,37 +250,81 @@ async def test_edit_handler(monkeypatch, jp_fetch):
)
assert response.code == 200
payload = json.loads(response.body)
# The exact sessionId comes from the FakeClient; just check the
# shape — keys are present and ok is true.
assert payload["ok"] is True
# 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 isinstance(payload["sessionId"], str) and payload["sessionId"]
assert payload["notebookPath"] == "test.ipynb"
# Async path: no `markdown` field. The LLM reply is consumed via SSE.
assert "markdown" not in payload
# Session manager was used, not direct create/delete on client
# Session manager was used (not direct create/delete on client).
call_names = [c[0] for c in fake.calls]
assert "create_session" not in call_names
assert "delete_session" not in call_names
assert "send_message_sync" in call_names
assert "send_message_async" in call_names
assert "send_message_sync" not in call_names # legacy sync path is gone
# send_message_sync received the session ID from the manager
send_call = [c for c in fake.calls if c[0] == "send_message_sync"][0]
assert send_call[1] == "fake-session-123"
# system prompt was passed (v3+ allows markdown/fences in the reply)
# send_message_async received a session id from the manager (the
# exact value comes from the FakeClient; just verify it matches
# the response's sessionId).
send_call = [c for c in fake.calls if c[0] == "send_message_async"][0]
assert send_call[1] == payload["sessionId"]
# system prompt was passed.
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.
# (The real SessionManager internally calls create_session on the
# FakeClient too, so the shared calls list has both entries.)
sm_call_names = [c[0] for c in fake_sm.calls]
assert sm_call_names == ["get_or_create"]
assert fake_sm.calls[0][1] == "test.ipynb"
assert "get_or_create" in sm_call_names
get_or_create_call = [c for c in fake_sm.calls if c[0] == "get_or_create"][0]
assert get_or_create_call[1] == "test.ipynb"
async def test_edit_handler_includes_model_when_provider_and_model_given(
monkeypatch, jp_fetch
):
"""providerId/modelId are forwarded into the async send body when both set."""
fake = FakeOpenCodeClient()
monkeypatch.setattr("opencode_bridge.routes.make_client", lambda h: fake)
monkeypatch.setattr(
"opencode_bridge.routes.get_session_manager",
lambda h: FakeSessionManager(),
)
body = json.dumps({
"prompt": "x",
"context": {
"notebookPath": "n.ipynb",
"cellId": "c",
"language": "python",
"cellIndex": 0,
"totalCells": 1,
"source": "",
"previousCode": None,
"error": None,
},
"providerId": "anthropic",
"modelId": "claude-sonnet-4-20250514",
})
response = await jp_fetch("opencode-bridge", "edit", method="POST", body=body)
assert response.code == 200
send_call = [c for c in fake.calls if c[0] == "send_message_async"][0]
# (send_message_async, sid, parts, system, provider_id, model_id)
assert send_call[4] == "anthropic"
assert send_call[5] == "claude-sonnet-4-20250514"
async def test_session_list_handler(monkeypatch, jp_fetch):
fake_sm = FakeSessionManager()
fake_sm._sessions = {
"foo.ipynb": "sid-1",
"bar.ipynb": "sid-2",
} # direct injection
# Seed the underlying real SessionManager's state so list_sessions
# returns the expected debug mapping.
fake_sm._active["foo.ipynb"] = "sid-1"
fake_sm._active["bar.ipynb"] = "sid-2"
fake_sm._bound["foo.ipynb"] = ["sid-1"]
fake_sm._bound["bar.ipynb"] = ["sid-2"]
monkeypatch.setattr(
"opencode_bridge.routes.get_session_manager", lambda h: fake_sm
)
@@ -234,6 +369,10 @@ async def test_session_messages_handler_projects_opencode_messages(
monkeypatch.setattr("opencode_bridge.routes.make_client", lambda h: fake)
fake_sm = FakeSessionManager(session_id="fake-session-123")
# Seed the bound state so peek() returns the session id (the route
# only fetches messages if there's an active session).
fake_sm._active["test.ipynb"] = "fake-session-123"
fake_sm._bound["test.ipynb"] = ["fake-session-123"]
monkeypatch.setattr(
"opencode_bridge.routes.get_session_manager", lambda h: fake_sm
)
@@ -276,6 +415,11 @@ async def test_session_release_handler(monkeypatch, jp_fetch):
"opencode_bridge.routes.get_session_manager", lambda h: fake_sm
)
# Set up state: get_or_create actually binds a session to the
# notebook in the real SessionManager (via the FakeClient). release
# then has something to delete.
await fake_sm.get_or_create("foo.ipynb")
response = await jp_fetch(
"opencode-bridge", "session",
method="DELETE",
@@ -287,3 +431,337 @@ async def test_session_release_handler(monkeypatch, jp_fetch):
assert payload["notebookPath"] == "foo.ipynb"
assert payload["deleted"] is True
assert ("release", "foo.ipynb") in fake_sm.calls
async def test_permission_reply_forwards_response(monkeypatch, jp_fetch):
"""POST /opencode-bridge/permissions/:permId?session=:sid forwards to client."""
fake = FakeOpenCodeClient()
monkeypatch.setattr("opencode_bridge.routes.make_client", lambda h: fake)
response = await jp_fetch(
"opencode-bridge", "permissions", "perm-42",
method="POST",
params={"session": "ses-1"},
body=json.dumps({"response": "once"}),
)
assert response.code == 200
payload = json.loads(response.body)
assert payload == {
"ok": True,
"permissionId": "perm-42",
"response": "once",
}
assert ("reply_permission", "ses-1", "perm-42", "once") in fake.calls
async def test_permission_reply_rejects_invalid_response(monkeypatch, jp_fetch):
"""response must be one of once/always/reject — 400 otherwise."""
fake = FakeOpenCodeClient()
monkeypatch.setattr("opencode_bridge.routes.make_client", lambda h: fake)
with pytest.raises(tornado.httpclient.HTTPClientError) as exc_info:
await jp_fetch(
"opencode-bridge", "permissions", "perm-1",
method="POST",
params={"session": "ses-1"},
body=json.dumps({"response": "yes"}),
)
assert exc_info.value.code == 400
assert "yes" not in fake.calls and fake.calls == []
async def test_permission_reply_requires_session(monkeypatch, jp_fetch):
"""Missing ?session=... is a 400."""
fake = FakeOpenCodeClient()
monkeypatch.setattr("opencode_bridge.routes.make_client", lambda h: fake)
with pytest.raises(tornado.httpclient.HTTPClientError) as exc_info:
await jp_fetch(
"opencode-bridge", "permissions", "perm-1",
method="POST",
body=json.dumps({"response": "once"}),
)
assert exc_info.value.code == 400
async def test_question_reply_forwards_answer(monkeypatch, jp_fetch):
fake = FakeOpenCodeClient()
monkeypatch.setattr("opencode_bridge.routes.make_client", lambda h: fake)
response = await jp_fetch(
"opencode-bridge", "questions", "q-7", "reply",
method="POST",
params={"session": "ses-2"},
body=json.dumps({"answer": "use Python 3.12"}),
)
assert response.code == 200
payload = json.loads(response.body)
assert payload == {"ok": True, "questionId": "q-7"}
assert ("reply_question", "ses-2", "q-7", "use Python 3.12") in fake.calls
async def test_question_reply_rejects_empty_answer(monkeypatch, jp_fetch):
fake = FakeOpenCodeClient()
monkeypatch.setattr("opencode_bridge.routes.make_client", lambda h: fake)
with pytest.raises(tornado.httpclient.HTTPClientError) as exc_info:
await jp_fetch(
"opencode-bridge", "questions", "q-1", "reply",
method="POST",
params={"session": "ses-1"},
body=json.dumps({"answer": " "}),
)
assert exc_info.value.code == 400
assert fake.calls == []
async def test_global_event_handler_forwards_all_events_unfiltered(
monkeypatch, jp_fetch
):
"""Server-side SSE proxy must NOT filter by session — it forwards
everything so the client can decide. (Previously a too-eager server
filter silently dropped events the client would have accepted.)
We mock stream_global_events to push three events with different
sessionID shapes (one matching, one not, one missing). The proxy
should pass all three through as data: lines.
"""
fake = FakeOpenCodeClient()
async def fake_stream():
yield {"type": "session.next.text.delta", "properties": {"sessionID": "ses-1", "delta": "A"}}
yield {"type": "session.next.text.delta", "properties": {"sessionID": "ses-OTHER", "delta": "B"}}
yield {"type": "server.connected", "properties": {}}
fake.stream_global_events = fake_stream
monkeypatch.setattr("opencode_bridge.routes.make_client", lambda h: fake)
# Use ?session=ses-1 to confirm the param is accepted but doesn't filter.
response = await jp_fetch(
"opencode-bridge", "events",
params={"session": "ses-1"},
)
assert response.code == 200
body = response.body.decode("utf-8")
# All three events should appear in the body, regardless of sessionID.
assert '"delta": "A"' in body
assert '"delta": "B"' in body
assert '"server.connected"' in body
# The body should use the SSE format: each event as a `data:` line.
assert body.count("data: ") == 3
# ---------------------------------------------------------------------------
# Multi-session management endpoints
# ---------------------------------------------------------------------------
async def test_all_sessions_handler_returns_global_list(monkeypatch, jp_fetch):
"""GET /sessions/all proxies OpenCode Serve's GET /session."""
fake = FakeOpenCodeClient()
monkeypatch.setattr("opencode_bridge.routes.make_client", lambda h: fake)
response = await jp_fetch("opencode-bridge", "sessions", "all")
assert response.code == 200
payload = json.loads(response.body)
assert payload["ok"] is True
assert len(payload["sessions"]) == 2
# The fake returns both the default session and a hand-rolled "other-session".
ids = {s["id"] for s in payload["sessions"]}
assert "fake-session-123" in ids
assert "other-session" in ids
assert ("list_all_sessions",) in fake.calls
async def test_notebook_sessions_get_returns_bound_with_metadata(
monkeypatch, jp_fetch
):
"""GET /sessions/notebook?notebook=... returns the bound sessions
enriched with title/createdAt from the global list."""
fake = FakeOpenCodeClient()
monkeypatch.setattr("opencode_bridge.routes.make_client", lambda h: fake)
sm = FakeSessionManager()
sm._active["foo.ipynb"] = "fake-session-123"
sm._bound["foo.ipynb"] = ["fake-session-123"]
monkeypatch.setattr(
"opencode_bridge.routes.get_session_manager", lambda h: sm
)
response = await jp_fetch(
"opencode-bridge", "sessions", "notebook",
params={"notebook": "foo.ipynb"},
)
assert response.code == 200
payload = json.loads(response.body)
assert payload["notebookPath"] == "foo.ipynb"
assert payload["activeSessionId"] == "fake-session-123"
assert len(payload["sessions"]) == 1
s = payload["sessions"][0]
assert s["sessionId"] == "fake-session-123"
assert s["title"] == "jupyter:foo.ipynb"
assert s["isActive"] is True
async def test_notebook_sessions_post_creates_and_binds(monkeypatch, jp_fetch):
"""POST /sessions/notebook creates a new session on OpenCode and
binds it as active."""
fake = FakeOpenCodeClient()
monkeypatch.setattr("opencode_bridge.routes.make_client", lambda h: fake)
# Share the client with the SessionManager so the test can assert
# against `fake.calls` (the real SessionManager calls its own
# client_factory, NOT the route's make_client).
monkeypatch.setattr(
"opencode_bridge.routes.get_session_manager",
lambda h: FakeSessionManager(client=fake),
)
response = await jp_fetch(
"opencode-bridge", "sessions", "notebook",
method="POST",
params={"notebook": "foo.ipynb", "title": "fresh"},
body=json.dumps({}),
)
assert response.code == 200
payload = json.loads(response.body)
assert payload["ok"] is True
assert payload["sessionId"] == "newly-created-fresh"
# The new session is the active one.
assert payload["activeSessionId"] == "newly-created-fresh"
# OpenCode was called.
assert ("create_session", "fresh") in fake.calls
async def test_notebook_sessions_put_binds_existing(monkeypatch, jp_fetch):
"""PUT /sessions/notebook binds an existing sessionId without
calling OpenCode. Sets it as active."""
fake = FakeOpenCodeClient()
monkeypatch.setattr("opencode_bridge.routes.make_client", lambda h: fake)
sm = FakeSessionManager()
monkeypatch.setattr(
"opencode_bridge.routes.get_session_manager", lambda h: sm
)
response = await jp_fetch(
"opencode-bridge", "sessions", "notebook",
method="PUT",
params={"notebook": "foo.ipynb", "sessionId": "some-existing-sid"},
body=json.dumps({}),
)
assert response.code == 200
payload = json.loads(response.body)
assert payload["sessionId"] == "some-existing-sid"
assert payload["activeSessionId"] == "some-existing-sid"
# No OpenCode round-trip for the bind.
assert ("create_session", ...) not in [
c for c in fake.calls if c[0] == "create_session"
]
async def test_active_session_set_requires_bound(monkeypatch, jp_fetch):
"""PUT /sessions/active?sessionId=X fails with 400 if X is not bound."""
monkeypatch.setattr(
"opencode_bridge.routes.make_client", lambda h: FakeOpenCodeClient()
)
sm = FakeSessionManager()
monkeypatch.setattr(
"opencode_bridge.routes.get_session_manager", lambda h: sm
)
with pytest.raises(tornado.httpclient.HTTPClientError) as exc_info:
await jp_fetch(
"opencode-bridge", "sessions", "active",
method="PUT",
params={"notebook": "foo.ipynb", "sessionId": "never-bound"},
body=json.dumps({}),
)
assert exc_info.value.code == 400
async def test_active_session_set_switches(monkeypatch, jp_fetch):
"""PUT /sessions/active successfully switches when the sessionId is bound."""
monkeypatch.setattr(
"opencode_bridge.routes.make_client", lambda h: FakeOpenCodeClient()
)
sm = FakeSessionManager()
sm.bind_existing("foo.ipynb", "sid-A")
sm.bind_existing("foo.ipynb", "sid-B")
monkeypatch.setattr(
"opencode_bridge.routes.get_session_manager", lambda h: sm
)
response = await jp_fetch(
"opencode-bridge", "sessions", "active",
method="PUT",
params={"notebook": "foo.ipynb", "sessionId": "sid-A"},
body=json.dumps({}),
)
assert response.code == 200
payload = json.loads(response.body)
assert payload["activeSessionId"] == "sid-A"
async def test_active_session_delete_unbinds_only(monkeypatch, jp_fetch):
"""DELETE /sessions/active unbinds the active session; other
bound sessions are kept; OpenCode is NOT called."""
fake = FakeOpenCodeClient()
monkeypatch.setattr("opencode_bridge.routes.make_client", lambda h: fake)
sm = FakeSessionManager()
sm.bind_existing("foo.ipynb", "sid-A")
sm.bind_existing("foo.ipynb", "sid-B")
monkeypatch.setattr(
"opencode_bridge.routes.get_session_manager", lambda h: sm
)
response = await jp_fetch(
"opencode-bridge", "sessions", "active",
method="DELETE",
params={"notebook": "foo.ipynb"},
)
assert response.code == 200
payload = json.loads(response.body)
assert payload["activeSessionId"] is None
# sid-A is still bound; sid-B was the active and is gone.
assert sm.list_sessions_for_notebook("foo.ipynb") == ["sid-A"]
# No OpenCode call.
assert not any(c[0] == "delete_session" for c in fake.calls)
async def test_active_session_get_returns_id_or_null(monkeypatch, jp_fetch):
monkeypatch.setattr(
"opencode_bridge.routes.make_client", lambda h: FakeOpenCodeClient()
)
sm = FakeSessionManager()
sm.bind_existing("foo.ipynb", "sid-A")
monkeypatch.setattr(
"opencode_bridge.routes.get_session_manager", lambda h: sm
)
response = await jp_fetch(
"opencode-bridge", "sessions", "active",
params={"notebook": "foo.ipynb"},
)
assert response.code == 200
payload = json.loads(response.body)
assert payload["activeSessionId"] == "sid-A"
async def test_notebook_sessions_delete_removes_session(monkeypatch, jp_fetch):
"""DELETE /sessions/notebook?notebook=...&sessionId=... deletes
the session on OpenCode AND removes its binding."""
fake = FakeOpenCodeClient()
monkeypatch.setattr("opencode_bridge.routes.make_client", lambda h: fake)
# Share the client with the SessionManager so the test can assert
# against `fake.calls` (the real SessionManager calls its own
# client_factory, NOT the route's make_client).
sm = FakeSessionManager(client=fake)
sm.bind_existing("foo.ipynb", "sid-A")
sm.bind_existing("foo.ipynb", "sid-B")
monkeypatch.setattr(
"opencode_bridge.routes.get_session_manager", lambda h: sm
)
response = await jp_fetch(
"opencode-bridge", "sessions", "notebook",
method="DELETE",
params={"notebook": "foo.ipynb", "sessionId": "sid-A"},
)
assert response.code == 200
payload = json.loads(response.body)
assert payload["deleted"] is True
# OpenCode delete was called for sid-A.
assert ("delete_session", "sid-A") in fake.calls
# sid-A is gone; sid-B remains active.
assert sm.list_sessions_for_notebook("foo.ipynb") == ["sid-B"]
assert sm.get_active("foo.ipynb") == "sid-B"
+165 -3
View File
@@ -7,13 +7,31 @@ from opencode_bridge.session_manager import SessionManager
class FakeClient:
"""Mimics OpenCodeClient just enough for SessionManager."""
def __init__(self, session_id: str = "sid-from-fake") -> None:
"""Mimics OpenCodeClient just enough for SessionManager.
By default returns the same id for every create_session call
(matching the original legacy behavior; several pre-existing tests
rely on that). Tests that need a fresh id per call should set
``unique_ids_per_call=True`` in the constructor.
"""
def __init__(
self,
session_id: str = "sid-from-fake",
unique_ids_per_call: bool = False,
) -> None:
self._session_id = session_id
self._unique_ids = unique_ids_per_call
self._next_id = 0
self.calls: list = []
async def create_session(self, title: str) -> dict:
self.calls.append(("create_session", title))
if self._unique_ids:
self._next_id += 1
return {
"id": "%s-%d" % (self._session_id, self._next_id),
"title": title,
}
return {"id": self._session_id, "title": title}
async def delete_session(self, session_id: str) -> bool:
@@ -112,7 +130,7 @@ async def test_list_sessions_returns_all_mappings():
sm = SessionManager(lambda: client)
await sm.get_or_create("a.ipynb")
# Manually inject a second to test listing
sm._sessions["b.ipynb"] = "sid-b"
sm._active["b.ipynb"] = "sid-b"
listing = sm.list_sessions()
paths = {s["notebookPath"] for s in listing}
assert paths == {"a.ipynb", "b.ipynb"}
@@ -133,3 +151,147 @@ async def test_concurrent_get_or_create_does_not_double_create():
assert sids[0] == sids[1] == sids[2]
create_calls = [c for c in client.calls if c[0] == "create_session"]
assert len(create_calls) == 1, "concurrent get_or_create must not double-create"
# ---------------------------------------------------------------------------
# Multi-session: 1 notebook can bind N sessions, one is active
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_create_new_binds_and_makes_active():
client = FakeClient(unique_ids_per_call=True)
sm = SessionManager(lambda: client)
sid = await sm.create_new("foo.ipynb", title="custom")
assert sid == "sid-from-fake-1"
# New session is the active one and the only bound one.
assert sm.get_active("foo.ipynb") == sid
assert sm.list_sessions_for_notebook("foo.ipynb") == [sid]
@pytest.mark.asyncio
async def test_create_new_when_active_exists_keeps_old_bound():
client = FakeClient(unique_ids_per_call=True)
sm = SessionManager(lambda: client)
first = await sm.get_or_create("foo.ipynb")
second = await sm.create_new("foo.ipynb")
# Two distinct session ids.
assert first != second
assert first == "sid-from-fake-1"
assert second == "sid-from-fake-2"
# Both are bound; the new one is active.
bound = sm.list_sessions_for_notebook("foo.ipynb")
assert set(bound) == {first, second}
assert sm.get_active("foo.ipynb") == second
# get_or_create still returns the active one (does NOT auto-pick the latest).
assert await sm.get_or_create("foo.ipynb") == second
def test_bind_existing_does_not_call_opencode():
client = FakeClient()
sm = SessionManager(lambda: client)
sm.bind_existing("foo.ipynb", "sid-from-list")
assert client.calls == [] # no OpenCode round-trip
assert sm.get_active("foo.ipynb") == "sid-from-list"
assert "sid-from-list" in sm.list_sessions_for_notebook("foo.ipynb")
def test_set_active_requires_session_to_be_bound():
sm = SessionManager(lambda: FakeClient())
sm.bind_existing("foo.ipynb", "sid-A")
assert sm.set_active("foo.ipynb", "sid-A") is True
# Switching to a session that is NOT bound must fail.
assert sm.set_active("foo.ipynb", "sid-UNKNOWN") is False
# Active didn't change.
assert sm.get_active("foo.ipynb") == "sid-A"
def test_unbind_removes_from_bound_and_clears_active_if_was_active():
sm = SessionManager(lambda: FakeClient())
sm.bind_existing("foo.ipynb", "sid-A")
sm.bind_existing("foo.ipynb", "sid-B")
# B is the active one (last bind wins). Unbind B.
assert sm.get_active("foo.ipynb") == "sid-B"
assert sm.unbind("foo.ipynb", "sid-B") is True
# Active falls back to None (no other session auto-promotes).
assert sm.get_active("foo.ipynb") is None
assert sm.list_sessions_for_notebook("foo.ipynb") == ["sid-A"]
def test_unbind_non_active_leaves_active_intact():
sm = SessionManager(lambda: FakeClient())
sm.bind_existing("foo.ipynb", "sid-A")
sm.bind_existing("foo.ipynb", "sid-B")
assert sm.get_active("foo.ipynb") == "sid-B"
# Unbind A (not the active one).
assert sm.unbind("foo.ipynb", "sid-A") is True
# Active is still B.
assert sm.get_active("foo.ipynb") == "sid-B"
def test_unbind_active_keeps_other_sessions():
sm = SessionManager(lambda: FakeClient())
sm.bind_existing("foo.ipynb", "sid-A")
sm.bind_existing("foo.ipynb", "sid-B")
sm.unbind_active("foo.ipynb")
assert sm.get_active("foo.ipynb") is None
# B is gone from the bound list; A remains.
assert "sid-A" in sm.list_sessions_for_notebook("foo.ipynb")
assert "sid-B" not in sm.list_sessions_for_notebook("foo.ipynb")
@pytest.mark.asyncio
async def test_delete_session_unbinds_and_calls_opencode():
client = FakeClient()
sm = SessionManager(lambda: client)
sm.bind_existing("foo.ipynb", "sid-A")
sm.bind_existing("foo.ipynb", "sid-B")
deleted = await sm.delete_session("foo.ipynb", "sid-A")
assert deleted is True
# OpenCode was called.
assert any(
c[0] == "delete_session" and c[1] == "sid-A" for c in client.calls
)
# sid-A is gone from bindings; sid-B remains as active.
assert "sid-A" not in sm.list_sessions_for_notebook("foo.ipynb")
assert sm.get_active("foo.ipynb") == "sid-B"
@pytest.mark.asyncio
async def test_delete_session_not_bound_returns_false():
client = FakeClient()
sm = SessionManager(lambda: client)
assert await sm.delete_session("foo.ipynb", "never-bound") is False
# No OpenCode call.
assert not any(c[0] == "delete_session" for c in client.calls)
@pytest.mark.asyncio
async def test_release_drops_all_bindings_and_deletes_active():
client = FakeClient()
sm = SessionManager(lambda: client)
sm.bind_existing("foo.ipynb", "sid-A")
sm.bind_existing("foo.ipynb", "sid-B")
ok = await sm.release("foo.ipynb")
assert ok is True
# OpenCode delete called for the active one (sid-B).
assert any(
c[0] == "delete_session" and c[1] == "sid-B" for c in client.calls
)
# Both bindings cleared.
assert sm.list_sessions_for_notebook("foo.ipynb") == []
assert sm.get_active("foo.ipynb") is None
@pytest.mark.asyncio
async def test_get_or_create_after_set_active_returns_the_active_one():
client = FakeClient()
sm = SessionManager(lambda: client)
first = await sm.get_or_create("foo.ipynb")
sm.bind_existing("foo.ipynb", "another-sid")
sm.set_active("foo.ipynb", "another-sid")
# Next /edit-style call returns the manually-set active session.
assert await sm.get_or_create("foo.ipynb") == "another-sid"
# No additional create_session call.
creates = [c for c in client.calls if c[0] == "create_session"]
assert len(creates) == 1 # only the initial get_or_create
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "opencode_bridge",
"version": "0.1.0",
"version": "0.1.1",
"description": "A JupyterLab extension.",
"keywords": [
"jupyter",
-154
View File
@@ -1,154 +0,0 @@
import type { CodeCell, ICodeCellModel } from '@jupyterlab/cells';
import type { NotebookPanel } from '@jupyterlab/notebook';
import { collectErrorOutputs, extractCellContext } from '../context/cell_context';
function makeMockOutputs(items: unknown[]): ICodeCellModel['outputs'] {
return {
get length() {
return items.length;
},
get(i: number) {
return items[i];
},
// Other IObservableList methods are unused in collectErrorOutputs.
} as unknown as ICodeCellModel['outputs'];
}
function makeMockModel(overrides: {
id?: string;
type?: 'code' | 'markdown' | 'raw';
source?: string;
outputs?: unknown[];
}): ICodeCellModel {
return {
id: overrides.id ?? 'cell-1',
type: overrides.type ?? 'code',
sharedModel: {
getSource: () => overrides.source ?? '',
},
outputs: makeMockOutputs(overrides.outputs ?? []),
} as unknown as ICodeCellModel;
}
function makeMockCell(source: string, errorOutputs: unknown[] = []): CodeCell {
return { model: makeMockModel({ source, outputs: errorOutputs }) } as CodeCell;
}
function makeMockNotebook(cells: CodeCell[]): { widgets: CodeCell[] } {
return { widgets: cells };
}
function makeMockPanel(
notebook: { widgets: CodeCell[] },
path: string
): NotebookPanel {
return {
context: { path } as NotebookPanel['context'],
content: notebook as unknown as NotebookPanel['content'],
} as unknown as NotebookPanel;
}
describe('collectErrorOutputs', () => {
it('returns null when there are no outputs', () => {
const model = makeMockModel({ outputs: [] });
expect(collectErrorOutputs(model)).toBeNull();
});
it('returns null when outputs are non-error', () => {
const model = makeMockModel({
outputs: [{ type: 'stream', text: 'hello' }],
});
expect(collectErrorOutputs(model)).toBeNull();
});
it('extracts the first error output', () => {
const errOut = {
type: 'error',
ename: 'ValueError',
evalue: 'bad value',
traceback: ['line 1', 'line 2'],
};
const model = makeMockModel({ outputs: [errOut] });
const result = collectErrorOutputs(model);
expect(result).toEqual({
ename: 'ValueError',
evalue: 'bad value',
traceback: ['line 1', 'line 2'],
});
});
it('handles missing traceback gracefully', () => {
const model = makeMockModel({
outputs: [{ type: 'error', ename: 'E', evalue: 'v' }],
});
const result = collectErrorOutputs(model);
expect(result?.traceback).toEqual([]);
});
});
describe('extractCellContext', () => {
it('returns null when cell is not in notebook', () => {
const cell = makeMockCell('x = 1');
const other = makeMockCell('y = 2');
const notebook = makeMockNotebook([other]);
const panel = makeMockPanel(notebook, 'foo.ipynb');
expect(extractCellContext(cell, panel)).toBeNull();
});
it('extracts source, language, and indices', () => {
const cell = makeMockCell('x = 1');
const notebook = makeMockNotebook([cell]);
const panel = makeMockPanel(notebook, 'foo.ipynb');
const ctx = extractCellContext(cell, panel);
expect(ctx).toMatchObject({
notebookPath: 'foo.ipynb',
cellId: 'cell-1',
language: 'python',
cellIndex: 0,
totalCells: 1,
source: 'x = 1',
previousCode: null,
error: null,
});
});
it('captures the previous cell source as previousCode', () => {
const prev = makeMockCell('import os');
const cell = makeMockCell('os.getcwd()');
const notebook = makeMockNotebook([prev, cell]);
const panel = makeMockPanel(notebook, 'foo.ipynb');
const ctx = extractCellContext(cell, panel);
expect(ctx?.previousCode).toBe('import os');
expect(ctx?.cellIndex).toBe(1);
expect(ctx?.totalCells).toBe(2);
});
it('includes the error when the cell has an error output', () => {
const cell = makeMockCell('1/0', [
{
type: 'error',
ename: 'ZeroDivisionError',
evalue: 'division by zero',
traceback: ['Traceback...'],
},
]);
const notebook = makeMockNotebook([cell]);
const panel = makeMockPanel(notebook, 'foo.ipynb');
const ctx = extractCellContext(cell, panel);
expect(ctx?.error).toEqual({
ename: 'ZeroDivisionError',
evalue: 'division by zero',
traceback: ['Traceback...'],
});
});
it('returns null when given null cell or panel', () => {
expect(
extractCellContext(
null as unknown as CodeCell,
makeMockPanel(makeMockNotebook([]), 'x.ipynb')
)
).toBeNull();
});
});
+99
View File
@@ -0,0 +1,99 @@
/**
* Tests for the localStorage-backed model selection persistence helper.
* jsdom provides a real (per-test-class) localStorage.
*/
import {
clearModelSelection,
loadModelSelection,
saveModelSelection
} from '../components/model_selection';
describe('loadModelSelection', () => {
beforeEach(() => {
localStorage.clear();
});
it('returns null when no entry exists', () => {
expect(loadModelSelection('foo.ipynb')).toBeNull();
});
it('returns null for an empty notebookPath (defensive)', () => {
expect(loadModelSelection('')).toBeNull();
});
it('returns the parsed entry when storage is valid', () => {
localStorage.setItem(
'opencode_bridge:model-selection:foo.ipynb',
JSON.stringify({ providerId: 'anthropic', modelId: 'claude-sonnet-4-20250514' })
);
expect(loadModelSelection('foo.ipynb')).toEqual({
providerId: 'anthropic',
modelId: 'claude-sonnet-4-20250514'
});
});
it('returns null for malformed JSON (does not throw)', () => {
localStorage.setItem(
'opencode_bridge:model-selection:foo.ipynb',
'{not json'
);
expect(loadModelSelection('foo.ipynb')).toBeNull();
});
it('returns null when the entry is missing required fields', () => {
localStorage.setItem(
'opencode_bridge:model-selection:foo.ipynb',
JSON.stringify({ providerId: 'anthropic' })
);
expect(loadModelSelection('foo.ipynb')).toBeNull();
});
it('keys entries independently per notebookPath', () => {
saveModelSelection('a.ipynb', { providerId: 'p1', modelId: 'm1' });
saveModelSelection('b.ipynb', { providerId: 'p2', modelId: 'm2' });
expect(loadModelSelection('a.ipynb')).toEqual({
providerId: 'p1',
modelId: 'm1'
});
expect(loadModelSelection('b.ipynb')).toEqual({
providerId: 'p2',
modelId: 'm2'
});
});
});
describe('saveModelSelection + clearModelSelection', () => {
beforeEach(() => {
localStorage.clear();
});
it('writes a JSON entry that can be re-read', () => {
saveModelSelection('foo.ipynb', { providerId: 'p1', modelId: 'm1' });
expect(loadModelSelection('foo.ipynb')).toEqual({
providerId: 'p1',
modelId: 'm1'
});
});
it('overwrites an existing entry', () => {
saveModelSelection('foo.ipynb', { providerId: 'p1', modelId: 'm1' });
saveModelSelection('foo.ipynb', { providerId: 'p2', modelId: 'm2' });
expect(loadModelSelection('foo.ipynb')).toEqual({
providerId: 'p2',
modelId: 'm2'
});
});
it('clearModelSelection removes the entry', () => {
saveModelSelection('foo.ipynb', { providerId: 'p1', modelId: 'm1' });
clearModelSelection('foo.ipynb');
expect(loadModelSelection('foo.ipynb')).toBeNull();
});
it('no-op for an empty notebookPath (defensive)', () => {
expect(() =>
saveModelSelection('', { providerId: 'p1', modelId: 'm1' })
).not.toThrow();
expect(() => clearModelSelection('')).not.toThrow();
});
});
-677
View File
@@ -1,677 +0,0 @@
/**
* 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: {
// Minimal but realistic mock: produce a real <pre><code> for
// fenced code blocks (so the per-code-block toolbar
// post-processing can find them) and <h1>/<p> for headings/text.
// Not a full markdown renderer — just enough for the prompt tests.
parse: (md: string) => {
let html = '';
let inFence = false;
let fenceLang = '';
let fenceBuf: string[] = [];
const lines = md.split('\n');
for (const line of lines) {
const open = !inFence && /^```(\w*)\s*$/.exec(line);
const close = inFence && /^```\s*$/.test(line);
if (open) {
inFence = true;
fenceLang = open[1] || '';
fenceBuf = [];
continue;
}
if (close) {
html +=
'<pre><code class="language-' +
fenceLang +
'">' +
fenceBuf.join('\n') +
'</code></pre>';
inFence = false;
continue;
}
if (inFence) {
fenceBuf.push(line);
continue;
}
if (/^# /.test(line)) {
html += '<h1>' + line.slice(2) + '</h1>';
} else if (line.trim()) {
html += '<p>' + line + '</p>';
}
}
return html || '<p>' + md + '</p>';
}
}
}));
// jsdom doesn't implement navigator.clipboard; stub it so the copy
// button's happy path can be exercised in tests.
beforeAll(() => {
Object.defineProperty(globalThis.navigator, 'clipboard', {
value: { writeText: jest.fn().mockResolvedValue(undefined) },
configurable: true
});
});
// 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');
// Minimal editor stub: cursor always at (line 0, column 0) so the
// "insert at cursor" tests produce a deterministic offset.
(cell as any).editor = {
getCursorPosition: () => ({ line: 0, column: 0 })
};
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');
});
it('clears the input textarea after a successful response', () => {
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);
// Open the prompt and type something into the textarea.
const aiBtn = actions.node.querySelector('button') as HTMLButtonElement;
aiBtn.click();
const prompt = (actions as any)._prompt;
expect(prompt).not.toBeNull();
const textarea = prompt._textarea as HTMLTextAreaElement;
textarea.value = 'make it faster';
// A successful response clears the input so the user can type a
// follow-up message without manually deleting the previous prompt.
(actions as any)._handleResponse({
ok: true,
markdown: 'ok',
sessionId: 'ses-1',
notebookPath: 'foo.ipynb'
});
expect(textarea.value).toBe('');
});
});
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 content: the marked mock produces a real <h1> + <pre>.
expect(asstMsg).not.toBeNull();
expect(asstMsg.innerHTML).toContain('<h1>Here you go</h1>');
expect(asstMsg.innerHTML).toContain('<pre>');
});
it('renders Copy/Insert/Replace buttons INSIDE each code block (not above the message)', () => {
const cell = makeFakeCell('x = 1');
const prompt = new OpenCodeInlinePrompt(cell, {
onSubmit: jest.fn(),
onCancel: jest.fn(),
disabled: false,
providers: null
});
// User message: no toolbar.
prompt.setMessages([{ role: 'user', content: 'help' }]);
expect(
prompt.node.querySelector('.opencode-msg-user .opencode-code-toolbar')
).toBeNull();
// Assistant with a fenced code block: toolbar inside .opencode-code-block.
prompt.setMessages([
{ role: 'assistant', content: '```py\nprint(1)\n```' }
]);
const asst = prompt.node.querySelector(
'.opencode-msg-assistant'
) as HTMLElement;
// The message-level toolbar must NOT exist.
expect(asst.querySelector('.opencode-msg-toolbar')).toBeNull();
// The per-code-block wrapper + toolbar.
const block = asst.querySelector('.opencode-code-block') as HTMLElement;
expect(block).not.toBeNull();
const toolbar = block.querySelector('.opencode-code-toolbar') as HTMLElement;
expect(toolbar).not.toBeNull();
const buttons = toolbar.querySelectorAll('button');
expect(buttons.length).toBe(3);
expect(buttons[0].textContent).toContain('复制');
expect(buttons[1].textContent).toContain('插入');
expect(buttons[2].textContent).toContain('替换');
// The wrapped <pre> is inside the block, below the toolbar.
expect(block.querySelector('pre')).not.toBeNull();
});
it('assistant with NO code fences has no toolbar', () => {
const cell = makeFakeCell('x = 1');
const prompt = new OpenCodeInlinePrompt(cell, {
onSubmit: jest.fn(),
onCancel: jest.fn(),
disabled: false,
providers: null
});
prompt.setMessages([
{ role: 'assistant', content: 'no code here, just an explanation' }
]);
const asst = prompt.node.querySelector(
'.opencode-msg-assistant'
) as HTMLElement;
expect(asst.querySelector('.opencode-code-toolbar')).toBeNull();
expect(asst.querySelector('.opencode-code-block')).toBeNull();
});
it('Replace button overwrites the cell source with the code block content', () => {
const cell = makeFakeCell('x = 1');
const prompt = new OpenCodeInlinePrompt(cell, {
onSubmit: jest.fn(),
onCancel: jest.fn(),
disabled: false,
providers: null
});
prompt.setMessages([
{ role: 'assistant', content: '```py\nprint(1)\n```' }
]);
const replaceBtn = prompt.node.querySelector(
'.opencode-msg-assistant .opencode-code-block .opencode-code-toolbar button:nth-child(3)'
) as HTMLButtonElement;
replaceBtn.click();
const setSource = (cell as any).model.sharedModel.setSource as jest.Mock;
expect(setSource).toHaveBeenCalledTimes(1);
// The button acts on the code block's text, not the whole markdown
// (no surrounding ```fences```).
expect(setSource).toHaveBeenCalledWith('print(1)');
});
it('Insert button splices the code block at the editor cursor (offset 0)', () => {
const cell = makeFakeCell('x = 1');
const prompt = new OpenCodeInlinePrompt(cell, {
onSubmit: jest.fn(),
onCancel: jest.fn(),
disabled: false,
providers: null
});
prompt.setMessages([
{ role: 'assistant', content: '```py\nprint(1)\n```' }
]);
const insertBtn = prompt.node.querySelector(
'.opencode-msg-assistant .opencode-code-block .opencode-code-toolbar button:nth-child(2)'
) as HTMLButtonElement;
insertBtn.click();
const setSource = (cell as any).model.sharedModel.setSource as jest.Mock;
expect(setSource).toHaveBeenCalledTimes(1);
// cursor at (0,0) with source "x = 1" -> code "print(1)" inserted at 0
expect(setSource).toHaveBeenCalledWith('print(1)x = 1');
});
it('Copy button writes the code block content to the clipboard', () => {
const cell = makeFakeCell('x = 1');
const prompt = new OpenCodeInlinePrompt(cell, {
onSubmit: jest.fn(),
onCancel: jest.fn(),
disabled: false,
providers: null
});
prompt.setMessages([
{ role: 'assistant', content: '```py\nprint(1)\n```' }
]);
const copyBtn = prompt.node.querySelector(
'.opencode-msg-assistant .opencode-code-block .opencode-code-toolbar button:nth-child(1)'
) as HTMLButtonElement;
copyBtn.click();
const writeText = (navigator.clipboard.writeText as unknown as jest.Mock);
expect(writeText).toHaveBeenCalledWith('print(1)');
});
it('Enter in the textarea submits; Shift+Enter does not', () => {
const cell = makeFakeCell('x = 1');
const onSubmit = jest.fn();
const prompt = new OpenCodeInlinePrompt(cell, {
onSubmit,
onCancel: jest.fn(),
disabled: false,
providers: null
});
prompt.setMessages([
{ role: 'user', content: '' },
{ role: 'assistant', content: 'noop' }
]);
const textarea = prompt.node.querySelector(
'textarea'
) as HTMLTextAreaElement;
textarea.value = 'fix the bug';
// Plain Enter submits with the current text + the current
// provider/model selection (both default to "" / first when null).
textarea.dispatchEvent(
new KeyboardEvent('keydown', {
key: 'Enter',
bubbles: true,
cancelable: true
})
);
expect(onSubmit).toHaveBeenCalledTimes(1);
expect(onSubmit).toHaveBeenCalledWith('fix the bug', undefined, undefined);
// Shift+Enter must NOT submit (chat convention: newline).
onSubmit.mockClear();
textarea.dispatchEvent(
new KeyboardEvent('keydown', {
key: 'Enter',
shiftKey: true,
bubbles: true,
cancelable: true
})
);
expect(onSubmit).not.toHaveBeenCalled();
// Ctrl+Enter also must not submit (newline).
onSubmit.mockClear();
textarea.dispatchEvent(
new KeyboardEvent('keydown', {
key: 'Enter',
ctrlKey: true,
bubbles: true,
cancelable: true
})
);
expect(onSubmit).not.toHaveBeenCalled();
});
});
+172 -13
View File
@@ -1,6 +1,12 @@
import { ServerConnection } from '@jupyterlab/services';
import { callOpenCodeEdit, callOpenCodeProviders } from '../api/opencode_client';
import {
callOpenCodeEdit,
callOpenCodeProviders,
callOpenCodeReplyPermission,
callOpenCodeReplyQuestion,
subscribeOpenCodeEvents
} from '../api/opencode_client';
import type { OpenCodeRequest } from '../types';
jest.mock('@jupyterlab/services', () => {
@@ -66,15 +72,8 @@ function mockResponse(overrides: {
const sampleRequest: OpenCodeRequest = {
prompt: 'add type hints',
context: {
notebookPath: 'foo.ipynb',
cellId: 'cell-1',
language: 'python',
cellIndex: 0,
totalCells: 1,
source: 'def foo(x): return x',
previousCode: null,
error: null,
},
notebookPath: 'foo.ipynb'
}
};
describe('callOpenCodeEdit', () => {
@@ -82,10 +81,9 @@ describe('callOpenCodeEdit', () => {
mockedMakeRequest.mockReset();
});
it('POSTs to /opencode-bridge/edit with JSON body', async () => {
it('POSTs to /opencode-bridge/edit and returns async {ok, sessionId, notebookPath}', async () => {
const respBody = {
ok: true,
markdown: '```python\ndef foo(x: int) -> int: return x\n```',
sessionId: 'sid',
notebookPath: 'foo.ipynb',
};
@@ -107,7 +105,10 @@ describe('callOpenCodeEdit', () => {
);
expect(resp.ok).toBe(true);
if (resp.ok) {
expect(resp.markdown).toContain('int');
// Async response has no `markdown` field — the LLM reply arrives
// via the SSE stream, not embedded in this response.
expect(resp.sessionId).toBe('sid');
expect((resp as any).markdown).toBeUndefined();
}
});
@@ -219,3 +220,161 @@ describe('callOpenCodeProviders', () => {
).rejects.toThrow(/network error/);
});
});
describe('callOpenCodeReplyPermission', () => {
beforeEach(() => {
mockedMakeRequest.mockReset();
});
it('POSTs to /opencode-bridge/permissions/:permId with {response}', async () => {
mockedMakeRequest.mockResolvedValue(
mockResponse({
ok: true,
status: 200,
statusText: 'OK',
text: JSON.stringify({ ok: true, permissionId: 'perm-1', response: 'once' })
})
);
const r = await callOpenCodeReplyPermission(
's1',
'perm-1',
'once',
mockServerSettings()
);
expect(r).toEqual({ ok: true, permissionId: 'perm-1', response: 'once' });
expect(mockedMakeRequest).toHaveBeenCalledWith(
'http://localhost:8888/opencode-bridge/permissions/perm-1?session=s1',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ response: 'once' })
}),
expect.anything()
);
});
it('throws on non-2xx', async () => {
mockedMakeRequest.mockResolvedValue(
mockResponse({
ok: false,
status: 400,
statusText: 'Bad Request',
text: 'bad response'
})
);
await expect(
callOpenCodeReplyPermission('s1', 'p', 'reject', mockServerSettings())
).rejects.toThrow(/400/);
});
});
describe('callOpenCodeReplyQuestion', () => {
beforeEach(() => {
mockedMakeRequest.mockReset();
});
it('POSTs to /opencode-bridge/questions/:qId/reply with {answer}', async () => {
mockedMakeRequest.mockResolvedValue(
mockResponse({
ok: true,
status: 200,
statusText: 'OK',
text: JSON.stringify({ ok: true, questionId: 'q-1' })
})
);
const r = await callOpenCodeReplyQuestion(
's1',
'q-1',
'use 3.12',
mockServerSettings()
);
expect(r).toEqual({ ok: true, questionId: 'q-1' });
expect(mockedMakeRequest).toHaveBeenCalledWith(
'http://localhost:8888/opencode-bridge/questions/q-1/reply?session=s1',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ answer: 'use 3.12' })
}),
expect.anything()
);
});
});
/**
* subscribeOpenCodeEvents uses the global `fetch` (not ServerConnection),
* so we mock fetch instead of ServerConnection.makeRequest.
*/
describe('subscribeOpenCodeEvents', () => {
let originalFetch: typeof fetch;
let mockReader: { read: jest.Mock };
let capturedUrl: string | undefined;
let capturedInit: RequestInit | undefined;
beforeEach(() => {
originalFetch = globalThis.fetch;
mockReader = {
read: jest
.fn()
// First call returns a chunk; second returns done.
.mockResolvedValueOnce({
value: new TextEncoder().encode(
'data: {"type":"session.next.text.delta","properties":{"sessionID":"sid","delta":"hello"}}\n\n' +
'data: {"type":"session.idle","properties":{"sessionID":"sid"}}\n\n'
),
done: false
})
.mockResolvedValueOnce({ value: undefined, done: true })
};
globalThis.fetch = jest.fn(async (url: any, init?: RequestInit) => {
capturedUrl = String(url);
capturedInit = init;
return {
ok: true,
status: 200,
statusText: 'OK',
body: {
getReader: () => mockReader
}
} as unknown as Response;
}) as unknown as typeof fetch;
});
afterEach(() => {
globalThis.fetch = originalFetch;
});
it('GETs /opencode-bridge/events?session=<sid> and dispatches parsed events', async () => {
const onEvent = jest.fn();
const settings = mockServerSettings();
const sub = subscribeOpenCodeEvents('sid', settings, { onEvent });
// Let the async fetch + reader resolve.
await new Promise(r => setTimeout(r, 10));
sub.close();
expect(capturedUrl).toBe(
'http://localhost:8888/opencode-bridge/events?session=sid'
);
expect(capturedInit?.method).toBe('GET');
expect((capturedInit?.headers as any).Accept).toBe('text/event-stream');
expect((capturedInit?.headers as any)['X-XSRFToken']).toBe('test');
expect(onEvent).toHaveBeenCalledTimes(2);
expect(onEvent.mock.calls[0][0].type).toBe('session.next.text.delta');
expect(onEvent.mock.calls[0][0].properties.delta).toBe('hello');
expect(onEvent.mock.calls[1][0].type).toBe('session.idle');
});
it('close() is idempotent and aborts the in-flight fetch', async () => {
const onEvent = jest.fn();
const sub = subscribeOpenCodeEvents('sid', mockServerSettings(), { onEvent });
// Let the async fetch actually run and capture the signal.
await new Promise(r => setTimeout(r, 5));
sub.close();
sub.close(); // second call must not throw
const signal = capturedInit?.signal as AbortSignal | undefined;
expect(signal).toBeDefined();
expect(signal?.aborted).toBe(true);
});
});
File diff suppressed because it is too large Load Diff
+476 -6
View File
@@ -1,19 +1,35 @@
/**
* HTTP client for the opencode-bridge server extension.
*
* Two flows:
* - one-shot: POST /edit (async returns immediately with sessionId),
* GET /providers, GET /session-messages.
* - streaming: subscribeOpenCodeEvents() opens a fetch+reader connection
* to GET /events and yields parsed OpenCodeEvent values.
*/
import { URLExt } from '@jupyterlab/coreutils';
import { ServerConnection } from '@jupyterlab/services';
import type { OpenCodeProvidersResponse, OpenCodeRequest, OpenCodeResponse, OpenCodeMessagesResponse } from '../types';
import type {
OpenCodeAllSessionsResponse,
OpenCodeEditResponse,
OpenCodeEvent,
OpenCodeMessagesResponse,
OpenCodeNotebookSessionsResponse,
OpenCodeProvidersResponse,
OpenCodeRequest,
OpenCodeSessionOpResponse
} from '../types';
/**
* Call POST /opencode-bridge/edit. Returns parsed response (success or failure).
* Throws on network error or non-JSON response.
* Call POST /opencode-bridge/edit. The endpoint is async it fires
* prompt_async at OpenCode and returns once the prompt is queued. The
* actual LLM response is consumed via the SSE stream.
*/
export async function callOpenCodeEdit(
request: OpenCodeRequest,
serverSettings: ServerConnection.ISettings
): Promise<OpenCodeResponse> {
): Promise<OpenCodeEditResponse> {
const url = URLExt.join(
serverSettings.baseUrl,
'opencode-bridge',
@@ -23,7 +39,7 @@ export async function callOpenCodeEdit(
const init: RequestInit = {
method: 'POST',
body: JSON.stringify(request),
headers: { 'Content-Type': 'application/json' },
headers: { 'Content-Type': 'application/json' }
};
let response: Response;
@@ -51,7 +67,7 @@ export async function callOpenCodeEdit(
);
}
return parsed as OpenCodeResponse;
return parsed as OpenCodeEditResponse;
}
/**
@@ -115,3 +131,457 @@ export async function callOpenCodeProviders(
return (await response.json()) as OpenCodeProvidersResponse;
}
/**
* Respond to a `permission.asked` event. `response` must be one of
* "once" | "always" | "reject". Returns the parsed JSON body.
*/
export async function callOpenCodeReplyPermission(
sessionId: string,
permissionId: string,
response: 'once' | 'always' | 'reject',
serverSettings: ServerConnection.ISettings
): Promise<{ ok: boolean; permissionId: string; response: string; error?: string }> {
const url =
URLExt.join(
serverSettings.baseUrl,
'opencode-bridge',
'permissions',
encodeURIComponent(permissionId)
) +
'?session=' +
encodeURIComponent(sessionId);
const init: RequestInit = {
method: 'POST',
body: JSON.stringify({ response }),
headers: { 'Content-Type': 'application/json' }
};
let res: Response;
try {
res = await ServerConnection.makeRequest(url, init, serverSettings);
} catch (error) {
throw new Error(
`network error calling opencode-bridge/permissions: ${(error as Error).message}`
);
}
const text = await res.text();
if (!res.ok) {
throw new Error(
`opencode-bridge/permissions failed: ${res.status} ${text}`
);
}
return JSON.parse(text);
}
/**
* Reply to a `question.asked` event with the user's freeform answer.
*/
export async function callOpenCodeReplyQuestion(
sessionId: string,
questionId: string,
answer: string,
serverSettings: ServerConnection.ISettings
): Promise<{ ok: boolean; questionId: string; error?: string }> {
const url =
URLExt.join(
serverSettings.baseUrl,
'opencode-bridge',
'questions',
encodeURIComponent(questionId),
'reply'
) +
'?session=' +
encodeURIComponent(sessionId);
const init: RequestInit = {
method: 'POST',
body: JSON.stringify({ answer }),
headers: { 'Content-Type': 'application/json' }
};
let res: Response;
try {
res = await ServerConnection.makeRequest(url, init, serverSettings);
} catch (error) {
throw new Error(
`network error calling opencode-bridge/questions: ${(error as Error).message}`
);
}
const text = await res.text();
if (!res.ok) {
throw new Error(
`opencode-bridge/questions failed: ${res.status} ${text}`
);
}
return JSON.parse(text);
}
// ---------------------------------------------------------------------------
// Multi-session management (1 notebook can bind N sessions; one is active)
// ---------------------------------------------------------------------------
async function _jsonRequest<T>(
url: string,
init: RequestInit,
serverSettings: ServerConnection.ISettings,
label: string
): Promise<T> {
let res: Response;
try {
res = await ServerConnection.makeRequest(url, init, serverSettings);
} catch (error) {
throw new Error(
`network error calling opencode-bridge/${label}: ${(error as Error).message}`
);
}
const text = await res.text();
if (!res.ok) {
throw new Error(`opencode-bridge/${label} failed: ${res.status} ${text}`);
}
if (!text) {
throw new Error(`empty response from opencode-bridge/${label}`);
}
try {
return JSON.parse(text) as T;
} catch {
throw new Error(
`non-JSON response from opencode-bridge/${label} (status ${res.status})`
);
}
}
/** GET /opencode-bridge/sessions/all — every session on OpenCode Serve. */
export async function callOpenCodeListAllSessions(
serverSettings: ServerConnection.ISettings
): Promise<OpenCodeAllSessionsResponse> {
const url = URLExt.join(
serverSettings.baseUrl,
'opencode-bridge',
'sessions',
'all'
);
return _jsonRequest(
url,
{ method: 'GET' },
serverSettings,
'sessions/all'
);
}
/** GET /opencode-bridge/sessions/notebook?notebook=... sessions bound
* to this notebook (enriched with metadata, marked which is active). */
export async function callOpenCodeListNotebookSessions(
notebookPath: string,
serverSettings: ServerConnection.ISettings
): Promise<OpenCodeNotebookSessionsResponse> {
const url =
URLExt.join(
serverSettings.baseUrl,
'opencode-bridge',
'sessions',
'notebook'
) +
'?notebook=' +
encodeURIComponent(notebookPath);
return _jsonRequest(url, { method: 'GET' }, serverSettings, 'sessions/notebook');
}
/** POST /opencode-bridge/sessions/notebook?notebook=...&title=...
* create a brand-new session on OpenCode, bind it, set as active. */
export async function callOpenCodeCreateNotebookSession(
notebookPath: string,
title: string | undefined,
serverSettings: ServerConnection.ISettings
): Promise<OpenCodeSessionOpResponse> {
const url =
URLExt.join(
serverSettings.baseUrl,
'opencode-bridge',
'sessions',
'notebook'
) +
'?notebook=' +
encodeURIComponent(notebookPath) +
(title ? '&title=' + encodeURIComponent(title) : '');
return _jsonRequest(
url,
{ method: 'POST', body: JSON.stringify({}), headers: { 'Content-Type': 'application/json' } },
serverSettings,
'sessions/notebook (POST)'
);
}
/** PUT /opencode-bridge/sessions/notebook?notebook=...&sessionId=...
* bind an existing sessionId to this notebook (no OpenCode round-trip)
* and set it as active. */
export async function callOpenCodeBindExistingSession(
notebookPath: string,
sessionId: string,
serverSettings: ServerConnection.ISettings
): Promise<OpenCodeSessionOpResponse> {
const url =
URLExt.join(
serverSettings.baseUrl,
'opencode-bridge',
'sessions',
'notebook'
) +
'?notebook=' +
encodeURIComponent(notebookPath) +
'&sessionId=' +
encodeURIComponent(sessionId);
return _jsonRequest(
url,
{ method: 'PUT', body: JSON.stringify({}), headers: { 'Content-Type': 'application/json' } },
serverSettings,
'sessions/notebook (PUT)'
);
}
/** DELETE /opencode-bridge/sessions/notebook?notebook=...&sessionId=...
* delete a specific bound session on OpenCode AND remove its binding. */
export async function callOpenCodeDeleteNotebookSession(
notebookPath: string,
sessionId: string,
serverSettings: ServerConnection.ISettings
): Promise<OpenCodeSessionOpResponse> {
const url =
URLExt.join(
serverSettings.baseUrl,
'opencode-bridge',
'sessions',
'notebook'
) +
'?notebook=' +
encodeURIComponent(notebookPath) +
'&sessionId=' +
encodeURIComponent(sessionId);
return _jsonRequest(
url,
{ method: 'DELETE' },
serverSettings,
'sessions/notebook (DELETE)'
);
}
/** GET /opencode-bridge/sessions/active?notebook=... get the active
* sessionId (or null). */
export async function callOpenCodeGetActiveSession(
notebookPath: string,
serverSettings: ServerConnection.ISettings
): Promise<OpenCodeSessionOpResponse> {
const url =
URLExt.join(
serverSettings.baseUrl,
'opencode-bridge',
'sessions',
'active'
) +
'?notebook=' +
encodeURIComponent(notebookPath);
return _jsonRequest(url, { method: 'GET' }, serverSettings, 'sessions/active');
}
/** PUT /opencode-bridge/sessions/active?notebook=...&sessionId=...
* switch which bound session is active. sessionId MUST already be
* in the bound list.
*
* TODO(frontend): unused in v0.1.x the cell-action's session
* switcher uses PUT /sessions/notebook (which does both bind and set
* active in one call). This is kept for a future "switch between
* already-bound sessions" flow that avoids the bind round-trip.
* Do NOT delete the corresponding server route without also
* removing this client function (or vice versa). */
export async function callOpenCodeSetActiveSession(
notebookPath: string,
sessionId: string,
serverSettings: ServerConnection.ISettings
): Promise<OpenCodeSessionOpResponse> {
const url =
URLExt.join(
serverSettings.baseUrl,
'opencode-bridge',
'sessions',
'active'
) +
'?notebook=' +
encodeURIComponent(notebookPath) +
'&sessionId=' +
encodeURIComponent(sessionId);
return _jsonRequest(
url,
{ method: 'PUT', body: JSON.stringify({}), headers: { 'Content-Type': 'application/json' } },
serverSettings,
'sessions/active (PUT)'
);
}
/** DELETE /opencode-bridge/sessions/active?notebook=... unbind the
* active session (next /edit lazy-creates a new one). Does NOT delete
* the session on OpenCode Serve. */
export async function callOpenCodeUnbindActiveSession(
notebookPath: string,
serverSettings: ServerConnection.ISettings
): Promise<OpenCodeSessionOpResponse> {
const url =
URLExt.join(
serverSettings.baseUrl,
'opencode-bridge',
'sessions',
'active'
) +
'?notebook=' +
encodeURIComponent(notebookPath);
return _jsonRequest(
url,
{ method: 'DELETE' },
serverSettings,
'sessions/active (DELETE)'
);
}
export interface OpenCodeEventHandlers {
onEvent: (event: OpenCodeEvent) => void;
onError?: (err: Error) => void;
onOpen?: () => void;
}
export interface OpenCodeEventSubscription {
/** Cancel the stream. Safe to call multiple times. */
close: () => void;
}
/**
* Subscribe to GET /opencode-bridge/events?session=<sid> as an SSE stream.
*
* Mirrors demo.html `initSSE`: opens a fetch(), reads the response body
* with a TextDecoder, splits on `\n\n` event boundaries, and parses each
* `data: {...}` line as JSON. Use `close()` to cancel.
*
* ServerConnection.makeRequest is unsuitable for SSE (it buffers the
* entire response), so we use plain fetch with the XSRF token injected
* from `serverSettings.token` when present.
*/
export function subscribeOpenCodeEvents(
sessionId: string,
serverSettings: ServerConnection.ISettings,
handlers: OpenCodeEventHandlers
): OpenCodeEventSubscription {
const baseUrl = serverSettings.baseUrl.replace(/\/$/, '');
const url =
URLExt.join(baseUrl, 'opencode-bridge', 'events') +
'?session=' +
encodeURIComponent(sessionId);
const headers: Record<string, string> = {
Accept: 'text/event-stream'
};
if (serverSettings.token) {
headers['X-XSRFToken'] = serverSettings.token;
}
const controller = new AbortController();
let closed = false;
const close = (): void => {
if (closed) {
return;
}
closed = true;
try {
controller.abort();
} catch {
// ignore
}
};
void (async () => {
let response: Response;
try {
response = await fetch(url, { method: 'GET', headers, signal: controller.signal });
} catch (err) {
if ((err as Error).name === 'AbortError') {
return;
}
handlers.onError?.(err as Error);
return;
}
if (!response.ok) {
handlers.onError?.(
new Error(
`opencode-bridge/events failed: ${response.status} ${response.statusText}`
)
);
return;
}
if (!response.body) {
handlers.onError?.(new Error('opencode-bridge/events: no response body'));
return;
}
handlers.onOpen?.();
const reader = response.body.getReader();
const decoder = new TextDecoder('utf-8');
let buffer = '';
try {
while (!closed) {
const { value, done } = await reader.read();
if (done) {
break;
}
buffer += decoder.decode(value, { stream: true });
let sepIdx: number;
while ((sepIdx = buffer.indexOf('\n\n')) !== -1) {
const block = buffer.slice(0, sepIdx);
buffer = buffer.slice(sepIdx + 2);
for (const line of block.split('\n')) {
if (!line.startsWith('data:')) {
continue;
}
const jsonStr = line.slice(5).trim();
if (!jsonStr) {
continue;
}
try {
const event = JSON.parse(jsonStr) as OpenCodeEvent;
handlers.onEvent(event);
} catch (e) {
console.warn(
'opencode_bridge: SSE JSON parse error:',
e,
jsonStr
);
}
}
}
}
// Drain trailing block (no final \n\n).
if (buffer.trim()) {
for (const line of buffer.split('\n')) {
if (!line.startsWith('data:')) {
continue;
}
const jsonStr = line.slice(5).trim();
if (!jsonStr) {
continue;
}
try {
handlers.onEvent(JSON.parse(jsonStr) as OpenCodeEvent);
} catch {
// ignore
}
}
}
} catch (err) {
if ((err as Error).name === 'AbortError') {
return;
}
handlers.onError?.(err as Error);
}
})();
return { close };
}
+82
View File
@@ -0,0 +1,82 @@
/**
* Persistence of the user's (provider, model) selection in the inline
* prompt, keyed by notebook path. Stored in localStorage so the
* preference survives panel close/reopen and JupyterLab restart.
*
* The selection is ALWAYS validated against the current providers
* payload before being applied: if the stored provider (or the model
* under it) is no longer present, the caller falls back to its
* default-selection logic. We never silently apply a stale value that
* would result in an empty / disabled <select>.
*
* Storage is best-effort: localStorage may be disabled (private mode),
* the quota may be exhausted, or the value may be corrupt. All
* failure modes return null / no-op rather than throw.
*/
const STORAGE_PREFIX = 'opencode_bridge:model-selection:';
export interface ModelSelection {
providerId: string;
modelId: string;
}
export function loadModelSelection(notebookPath: string): ModelSelection | null {
if (!notebookPath) {
return null;
}
try {
if (typeof localStorage === 'undefined') {
return null;
}
const raw = localStorage.getItem(STORAGE_PREFIX + notebookPath);
if (!raw) {
return null;
}
const parsed = JSON.parse(raw);
if (
parsed &&
typeof parsed === 'object' &&
typeof (parsed as ModelSelection).providerId === 'string' &&
typeof (parsed as ModelSelection).modelId === 'string'
) {
return {
providerId: (parsed as ModelSelection).providerId,
modelId: (parsed as ModelSelection).modelId
};
}
return null;
} catch {
return null;
}
}
export function saveModelSelection(
notebookPath: string,
sel: ModelSelection
): void {
if (!notebookPath) {
return;
}
try {
if (typeof localStorage === 'undefined') {
return;
}
localStorage.setItem(STORAGE_PREFIX + notebookPath, JSON.stringify(sel));
} catch {
// Ignore quota exceeded / disabled storage.
}
}
export function clearModelSelection(notebookPath: string): void {
if (!notebookPath) {
return;
}
try {
if (typeof localStorage === 'undefined') {
return;
}
localStorage.removeItem(STORAGE_PREFIX + notebookPath);
} catch {
// ignore
}
}
-244
View File
@@ -1,244 +0,0 @@
/**
* 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);
}
// Clear the input so the user can type a follow-up message.
this._prompt?.clearInput();
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);
}
+381
View File
@@ -0,0 +1,381 @@
/**
* Global floating AI button + panel, mounted on document.body. Replaces
* the per-cell toolbar AI button (formerly OpenCodeCellActions). Click
* the round bottom-right button to expand the OpenCodeInlinePrompt; click
* again to collapse it.
*
* The panel operates on the JupyterLab shell's currently-active
* NotebookPanel the (notebookPath, activeCell) pair is resolved at
* panel-open time from `app.shell.currentWidget`, and re-resolved when
* `app.shell.currentChanged` fires while the panel is open.
*
* All session/SSE/history state that used to live on
* OpenCodeCellActions now lives here. The prompt is created lazily on
* the first panel open and disposed on close (recreated on every
* re-open so the user always sees a fresh history fetch).
*/
import { JupyterFrontEnd } from '@jupyterlab/application';
import { Notification } from '@jupyterlab/apputils';
import type { CodeCell } from '@jupyterlab/cells';
import { ServerConnection } from '@jupyterlab/services';
import { Widget } from '@lumino/widgets';
import {
callOpenCodeBindExistingSession,
callOpenCodeCreateNotebookSession,
callOpenCodeEdit,
callOpenCodeListAllSessions,
callOpenCodeReplyPermission,
callOpenCodeReplyQuestion,
callOpenCodeSessionMessages,
subscribeOpenCodeEvents,
type OpenCodeEventSubscription
} from '../api/opencode_client';
import type { OpenCodeProvidersResponse } from '../types';
import { OpenCodeInlinePrompt } from './opencode_inline_prompt';
export interface IOpenCodeFloatingPanelOptions {
app: JupyterFrontEnd;
serverSettings: ServerConnection.ISettings;
providers: OpenCodeProvidersResponse | null;
}
type Status = 'idle' | 'loading';
export class OpenCodeFloatingPanel extends Widget {
private _app: JupyterFrontEnd;
private _serverSettings: ServerConnection.ISettings | null;
private _providers: OpenCodeProvidersResponse | null;
private _button: HTMLButtonElement;
private _panelEl: HTMLDivElement;
private _prompt: OpenCodeInlinePrompt | null = null;
private _sseSub: OpenCodeEventSubscription | null = null;
private _notebookPath: string | null = null;
private _status: Status = 'idle';
constructor(options: IOpenCodeFloatingPanelOptions) {
super();
this._app = options.app;
this._serverSettings = options.serverSettings;
this._providers = options.providers;
this.addClass('opencode-floating-root');
this.node.style.position = 'fixed';
this.node.style.bottom = '24px';
this.node.style.right = '24px';
this.node.style.zIndex = '1000';
this._button = document.createElement('button');
this._button.className = 'opencode-floating-button';
this._button.textContent = '🪄';
this._button.title = 'OpenCode AI';
this._button.addEventListener('click', () => this.toggle());
this.node.appendChild(this._button);
this._panelEl = document.createElement('div');
this._panelEl.className = 'opencode-floating-panel';
this._panelEl.hidden = true;
this.node.appendChild(this._panelEl);
// JupyterFrontEnd.shell is typed as `JupyterShell | null` in some
// JupyterLab versions; cast through any to satisfy strict null checks
// while keeping the public API simple.
(this._app.shell as any).currentChanged.connect(this._onShellChanged, this);
}
dispose(): void {
if (this.isDisposed) {
return;
}
(this._app.shell as any).currentChanged.disconnect(
this._onShellChanged,
this
);
this._closeSse();
this._hidePrompt();
super.dispose();
}
/** Update the providers payload after the initial async fetch. */
setProviders(p: OpenCodeProvidersResponse | null): void {
this._providers = p;
}
/** Toggle the panel open/closed. */
toggle(): void {
if (this._panelEl.hidden) {
this._showPrompt();
} else {
this._hidePrompt();
}
}
private _onShellChanged = (): void => {
// The active notebook changed. If the panel is open, refresh the
// underlying prompt so the (provider, model) selection, history,
// and active-cell context all reflect the new notebook.
//
// Implemented as an arrow-function class field (not a method) so
// `this` is bound regardless of how the signal's slot invokes us.
if (!this._panelEl.hidden) {
this._hidePrompt();
this._showPrompt();
}
};
private _resolveNotebookContext(): {
notebookPath: string | null;
getActiveCell: () => CodeCell | null;
} {
const widget = this._app.shell.currentWidget as any;
const notebookPath =
widget && widget.context && typeof widget.context.path === 'string'
? (widget.context.path as string)
: null;
const getActiveCell = (): CodeCell | null => {
const cur = this._app.shell.currentWidget as any;
if (!cur || !cur.content) {
return null;
}
const active = cur.content.activeCell;
return (active as CodeCell) || null;
};
return { notebookPath, getActiveCell };
}
private _showPrompt(): void {
if (this._prompt) {
this._panelEl.hidden = false;
return;
}
const { notebookPath, getActiveCell } = this._resolveNotebookContext();
this._notebookPath = notebookPath;
const prompt = new OpenCodeInlinePrompt({
disabled: !this._notebookPath || this._status === 'loading',
providers: this._providers,
notebookPath: this._notebookPath ?? undefined,
getActiveCell,
onSubmit: (text: string, providerId?: string, modelId?: string) => {
void this._onSubmit(text, providerId, modelId);
},
onCancel: () => {
this._hidePrompt();
},
onPermissionReply: async (permId, response) => {
if (!this._serverSettings) {
return false;
}
const sessionId = (this._prompt as any)._sessionId as string | null;
if (!sessionId) {
return false;
}
try {
const r = await callOpenCodeReplyPermission(
sessionId,
permId,
response,
this._serverSettings
);
return r.ok;
} catch (e) {
console.warn('opencode_bridge: permission reply failed', e);
return false;
}
},
onQuestionReply: async (qId, answer) => {
if (!this._serverSettings) {
return false;
}
const sessionId = (this._prompt as any)._sessionId as string | null;
if (!sessionId) {
return false;
}
try {
const r = await callOpenCodeReplyQuestion(
sessionId,
qId,
answer,
this._serverSettings
);
return r.ok;
} catch (e) {
console.warn('opencode_bridge: question reply failed', e);
return false;
}
},
onStreamEnd: () => {
// Centralized: the prompt detected "idle" and signals us to
// close the SSE stream + restore the input.
this._closeSse();
},
onListSessions: async () => {
if (!this._serverSettings) {
return [];
}
try {
const r = await callOpenCodeListAllSessions(this._serverSettings);
return r.sessions || [];
} catch (e) {
console.warn('opencode_bridge: list all sessions failed', e);
return [];
}
},
onCreateSession: async () => {
if (!this._serverSettings || !this._notebookPath) {
throw new Error('notebook not ready');
}
this._closeSse();
const r = await callOpenCodeCreateNotebookSession(
this._notebookPath,
undefined,
this._serverSettings
);
if (!r.ok || !r.sessionId) {
throw new Error(r.error || 'create_session failed');
}
return r.sessionId;
},
onSwitchSession: async (sessionId: string) => {
if (!this._serverSettings || !this._notebookPath) {
return false;
}
this._closeSse();
// PUT /sessions/notebook binds AND sets active in one call.
const r = await callOpenCodeBindExistingSession(
this._notebookPath,
sessionId,
this._serverSettings
);
return !!r.ok;
},
onReloadHistory: async () => {
if (!this._notebookPath) {
return;
}
await this._refreshHistory(this._notebookPath);
}
});
// Use Widget.attach (not raw appendChild) so Lumino sends the
// before-attach / after-attach messages — the prompt relies on
// these for isAttached/isVisible tracking. The defensive DOM
// cleanup in _hidePrompt handles the case where dispose() leaves
// a residual node behind (the panel host is an HTMLElement, not a
// Widget, so the Lumino parent stays null and dispose() can't
// always clean up the DOM on its own).
Widget.attach(prompt, this._panelEl);
this._prompt = prompt;
// Load the current session's static history before streaming starts.
if (this._notebookPath) {
void this._refreshHistory(this._notebookPath);
}
prompt.initialize();
this._panelEl.hidden = false;
}
private async _refreshHistory(notebookPath: string): Promise<void> {
if (!this._serverSettings || !this._prompt) {
return;
}
try {
const resp = await callOpenCodeSessionMessages(
notebookPath,
this._serverSettings
);
this._prompt.setMessages(resp.messages);
} catch (e) {
console.warn('opencode_bridge: failed to fetch session messages', e);
}
}
private _hidePrompt(): void {
this._closeSse();
if (this._prompt) {
const node = this._prompt.node;
this._prompt.dispose();
// Defensive DOM cleanup: the prompt is attached via raw DOM
// (appendChild/Widget.attach with an HTMLElement host), so its
// Lumino parent is null. In some scenarios — for example when
// the dispose path runs synchronously while another show+hide
// cycle is already in flight — the dispose() call does not
// remove the node from this._panelEl, leaving a residual node
// that the next _showPrompt appends to. Without this defensive
// removeChild, the user would see the panel content multiply
// with every click.
if (node.parentNode === this._panelEl) {
this._panelEl.removeChild(node);
}
this._prompt = null;
}
this._panelEl.hidden = true;
}
private async _onSubmit(
text: string,
providerId?: string,
modelId?: string
): Promise<void> {
if (!this._notebookPath) {
Notification.info('请先打开一个 notebook');
return;
}
if (!this._serverSettings) {
Notification.error('OpenCode 运行时未初始化');
return;
}
const request = {
prompt: text,
context: { notebookPath: this._notebookPath },
providerId,
modelId
};
this._status = 'loading';
if (this._prompt) {
this._prompt.setDisabled(true);
this._prompt.setSessionId(null);
}
let resp;
try {
resp = await callOpenCodeEdit(request, this._serverSettings);
} catch (e) {
this._status = 'idle';
this._prompt?.setDisabled(false);
Notification.error(`OpenCode 失败: ${(e as Error).message}`);
return;
}
if (!resp.ok) {
this._status = 'idle';
this._prompt?.setDisabled(false);
Notification.error(`OpenCode 错误: ${resp.error}`);
return;
}
this._prompt?.setSessionId(resp.sessionId);
this._prompt?.clearInput();
this._sseSub = subscribeOpenCodeEvents(
resp.sessionId,
this._serverSettings,
{
onEvent: ev => {
this._prompt?.applyEvent(ev);
},
onError: err => {
console.warn('opencode_bridge: SSE error', err);
}
}
);
}
private _closeSse(): void {
if (this._sseSub) {
this._sseSub.close();
this._sseSub = null;
}
this._status = 'idle';
this._prompt?.setDisabled(false);
}
}
File diff suppressed because it is too large Load Diff
-81
View File
@@ -1,81 +0,0 @@
/**
* Extract a CellContext snapshot from a CodeCell + its parent NotebookPanel.
*
* Pure functions, no DOM, no signals testable with plain object mocks.
*/
import type { CodeCell, ICodeCellModel } from '@jupyterlab/cells';
import type { NotebookPanel } from '@jupyterlab/notebook';
import type { CellContext, ErrorOutput } from '../types';
/**
* Find the first error output in a code cell's outputs.
* Returns null if the cell has no error output or is not a code cell.
*/
export function collectErrorOutputs(model: ICodeCellModel): ErrorOutput | null {
const outputs = model.outputs;
for (let i = 0; i < outputs.length; i++) {
const out = outputs.get(i);
if (out && out.type === 'error') {
const e = out as {
ename?: unknown;
evalue?: unknown;
traceback?: unknown;
};
return {
ename: String(e.ename ?? ''),
evalue: String(e.evalue ?? ''),
traceback: Array.isArray(e.traceback) ? (e.traceback as string[]) : [],
};
}
}
return null;
}
/**
* Build a CellContext from a CodeCell and its parent NotebookPanel.
* Returns null if essential data is missing.
*
* @param cell The code cell to inspect.
* @param panel The notebook panel that contains the cell.
*/
export function extractCellContext(
cell: CodeCell,
panel: NotebookPanel
): CellContext | null {
if (!cell || !panel) {
return null;
}
const cells = panel.content.widgets;
const cellIndex = cells ? cells.indexOf(cell) : -1;
if (cellIndex < 0) {
return null;
}
const totalCells = cells ? cells.length : 0;
const previousCell = cellIndex > 0 ? cells[cellIndex - 1] : null;
const previousCode = previousCell
? previousCell.model.sharedModel.getSource()
: null;
// Language: use 'python' as default for code cells.
// Future improvement: read from kernel info_reply.
const language = 'python';
let error: ErrorOutput | null = null;
if (cell.model.type === 'code') {
error = collectErrorOutputs(cell.model as ICodeCellModel);
}
return {
notebookPath: panel.context.path,
cellId: cell.model.id,
language,
cellIndex,
totalCells,
source: cell.model.sharedModel.getSource(),
previousCode,
error,
};
}
+23 -41
View File
@@ -3,74 +3,56 @@ import {
JupyterFrontEndPlugin
} from '@jupyterlab/application';
import { IToolbarWidgetRegistry } from '@jupyterlab/apputils';
import { Cell, CodeCell } from '@jupyterlab/cells';
import { Widget } from '@lumino/widgets';
import {
OpenCodeCellActions,
setOpenCodeProviders,
setOpenCodeServerSettings
} from './components/opencode_cell_actions';
import { OpenCodeFloatingPanel } from './components/opencode_floating_panel';
import { requestAPI } from './request';
import { callOpenCodeProviders } from './api/opencode_client';
/**
* Initialization data for the opencode_bridge extension.
*
* v3-final: no JupyterLab plugin settings. Connection config is read from
* environment variables by the server extension; the model is picked
* dynamically in the inline prompt.
* v5-floating: the per-cell AI button is gone. A single
* OpenCodeFloatingPanel is attached to document.body and operates
* against the JupyterLab shell's currently-active notebook. The model
* is picked dynamically in the inline prompt.
*/
const plugin: JupyterFrontEndPlugin<void> = {
id: 'opencode_bridge:plugin',
description: 'A JupyterLab extension bridging the UI to OpenCode Serve.',
autoStart: true,
optional: [IToolbarWidgetRegistry],
activate: (
app: JupyterFrontEnd,
toolbarRegistry: IToolbarWidgetRegistry | null
) => {
activate: (app: JupyterFrontEnd) => {
console.log('JupyterLab extension opencode_bridge is activated!');
// Push the Jupyter server settings into the actions module so that
// callOpenCodeEdit can build the right base URL.
setOpenCodeServerSettings(app.serviceManager.serverSettings);
// Mount the floating AI button on the body. The panel itself is
// hidden until the user clicks the button.
const floating = new OpenCodeFloatingPanel({
app,
serverSettings: app.serviceManager.serverSettings,
providers: null
});
Widget.attach(floating, document.body);
// Register the per-cell AI actions into the native Cell toolbar
// (top-right of the active cell, next to move up/down). Non-code
// cells get an empty widget so they show no AI buttons.
if (toolbarRegistry) {
toolbarRegistry.addFactory<Cell>(
'Cell',
'opencode-cell-actions',
(cell: Cell) => {
if (cell instanceof CodeCell) {
return new OpenCodeCellActions(cell);
}
return new Widget();
}
);
}
// Fetch available providers once at activation and cache them for the
// inline prompt's model picker. Failure is non-fatal (the picker hides).
// Fetch available providers once at activation and cache them for
// the inline prompt's model picker. Failure is non-fatal (the
// picker hides). Also push the payload into the floating panel so
// the prompt can render selects without an extra round trip.
void callOpenCodeProviders(app.serviceManager.serverSettings)
.then(data => {
setOpenCodeProviders(data);
floating.setProviders(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(', ');
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(
console.warn(
'[opencode_bridge] Could not fetch providers (is the opencode-bridge server extension enabled and opencode serve running?):',
reason
);
+91 -24
View File
@@ -1,29 +1,28 @@
/**
* Shared types for the opencode-bridge frontend.
*
* These mirror the Python server's `CellContext` / `OpenCodeRequest` / `OpenCodeResponse`
* shapes in `opencode_bridge/routes.py`. Keep them in sync.
* Mirrors the Python server's response shapes in `opencode_bridge/routes.py`
* and the OpenCode Serve SSE event shape documented in
* `/Users/taochen/temp/demo.html` (`handleGlobalEvent` / `processSessionEvent`).
*
* 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.
* v4: POST /edit is async (returns immediately with sessionId). The LLM
* reply is consumed via the GET /events SSE stream. Each SSE event is
* a JSON object with a `type` string; consumers route on `type`.
*/
export interface ErrorOutput {
ename: string;
evalue: string;
traceback: string[];
}
/**
* Minimal context carried with each edit request. Only the
* notebookPath is needed (so the server can pick the right OpenCode
* session). The cell source, previous-cell, and traceback are NO
* LONGER auto-injected into the LLM prompt the user inserts them
* manually via the "📋 插入单元格内容" button (or pastes anything
* else they want) so the LLM only sees what they explicitly chose
* to send. v0.1.x used to pack a lot more here; that auto-wrap
* made the LLM context hard to reason about and made the user's
* intent ambiguous.
*/
export interface CellContext {
notebookPath: string;
cellId: string;
language: string;
cellIndex: number;
totalCells: number;
source: string;
previousCode: string | null;
error: ErrorOutput | null;
}
export interface OpenCodeRequest {
@@ -33,21 +32,41 @@ export interface OpenCodeRequest {
modelId?: string;
}
export interface OpenCodeSuccess {
/**
* Response from POST /opencode-bridge/edit (async).
* The actual assistant reply arrives via the /events SSE stream and is
* NOT embedded in this response.
*/
export interface OpenCodeEditSuccess {
ok: true;
/** The AI's reply as markdown (code in ```fences```, optional explanation).
* Render with marked. The cell source is NOT replaced. */
markdown: string;
sessionId: string;
notebookPath: string;
}
export interface OpenCodeFailure {
export interface OpenCodeEditFailure {
ok: false;
error: string;
}
export type OpenCodeResponse = OpenCodeSuccess | OpenCodeFailure;
export type OpenCodeEditResponse = OpenCodeEditSuccess | OpenCodeEditFailure;
/**
* One SSE event from GET /opencode-bridge/events (proxied from OpenCode
* Serve's /global/event). The shape is loosely typed because OpenCode's
* payload envelope is not strictly documented; we follow demo.html's
* defensive property lookup pattern (try top-level then nested under
* `payload`).
*/
export interface OpenCodeEvent {
type: string;
payload?: {
type?: string;
properties?: { [k: string]: any };
syncEvent?: { aggregateID?: string };
};
properties?: { [k: string]: any };
syncEvent?: { aggregateID?: string };
}
/** Response from GET /opencode-bridge/session-messages?notebook=... .
* Projected from OpenCode's {info, parts}[] by the server into a
@@ -61,6 +80,54 @@ export interface OpenCodeMessagesResponse {
messages: OpenCodeMessage[];
}
// ---------------------------------------------------------------------------
// Multi-session management types (1 notebook can bind N sessions, one active)
// ---------------------------------------------------------------------------
/** One session on the OpenCode Serve side (returned by GET /session). */
export interface OpenCodeSessionMeta {
id: string;
title?: string;
createdAt?: number;
updatedAt?: number;
[k: string]: unknown;
}
/** Response from GET /opencode-bridge/sessions/all. */
export interface OpenCodeAllSessionsResponse {
ok: boolean;
sessions: OpenCodeSessionMeta[];
error?: string;
}
/** One session bound to a notebook (returned by GET /sessions/notebook). */
export interface OpenCodeNotebookSession {
sessionId: string;
title?: string;
createdAt?: number;
updatedAt?: number;
isActive: boolean;
}
/** Response from GET /opencode-bridge/sessions/notebook?notebook=... */
export interface OpenCodeNotebookSessionsResponse {
ok: boolean;
notebookPath: string;
activeSessionId: string | null;
sessions: OpenCodeNotebookSession[];
error?: string;
}
/** Response from POST/PUT /sessions/notebook and PUT /sessions/active. */
export interface OpenCodeSessionOpResponse {
ok: boolean;
notebookPath: string;
sessionId?: string;
activeSessionId?: string | null;
deleted?: boolean;
error?: string;
}
/** Response from GET /opencode-bridge/providers (proxies OpenCode /config/providers).
* Each Provider.models is a Record keyed by modelID (NOT an array). */
export interface OpenCodeModel {
+249 -15
View File
@@ -4,30 +4,72 @@
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 {
/* Floating AI launcher (replaces the per-cell toolbar button).
Mounted on document.body with position: fixed so it sits above the
JupyterLab layout regardless of which dock is active. The panel
(when open) renders above the button via column flex. */
.opencode-floating-root {
position: fixed;
bottom: 24px;
right: 24px;
z-index: 1000; /* above JupyterLab's main UI (z-index tops out around 100) */
display: flex;
align-items: center;
flex-direction: column;
align-items: flex-end;
gap: 8px;
}
.opencode-cell-actions .opencode-btn {
.opencode-floating-button {
width: 48px;
height: 48px;
border-radius: 50%;
border: none;
background: transparent;
padding: 0 6px;
font-size: var(--jp-ui-font-size1);
line-height: 20px;
background: var(--jp-brand-color1, #1976d2);
color: white;
font-size: 20px;
cursor: pointer;
white-space: nowrap;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
transition: transform 0.15s ease, box-shadow 0.15s ease;
padding: 0;
line-height: 1;
}
.opencode-floating-button:hover {
transform: scale(1.05);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.25);
}
.opencode-floating-button:focus {
outline: 2px solid var(--jp-brand-color2, #42a5f5);
outline-offset: 2px;
}
.opencode-cell-actions .opencode-btn:hover:enabled {
background: var(--jp-layout-color2);
border-radius: 2px;
.opencode-floating-panel {
width: 480px;
max-width: calc(100vw - 48px);
max-height: 60vh;
overflow: hidden;
display: flex;
flex-direction: column;
background: var(--jp-layout-color1, #fff);
border: 1px solid var(--jp-border-color2, #ddd);
border-radius: 6px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.18);
}
.opencode-floating-panel[hidden] {
display: none;
}
.opencode-cell-actions .opencode-btn:disabled {
opacity: 0.4;
cursor: default;
/* The inline prompt inside the floating panel should fill it: drop
the standalone panel's own margin/border, and let the panel's
scrollable region take over. */
.opencode-floating-panel .opencode-inline-prompt {
margin: 0;
border: none;
border-radius: 0;
flex: 1;
overflow-y: auto;
}
.opencode-floating-panel .opencode-inline-history {
max-height: 40vh;
}
/* Inline prompt panel attached to the cell when the AI button is clicked. */
@@ -191,6 +233,7 @@
display: flex;
justify-content: flex-end;
gap: 4px;
align-items: center;
}
.opencode-inline-prompt .opencode-inline-actions button {
@@ -210,3 +253,194 @@
opacity: 0.4;
cursor: default;
}
/* The "📋 插入单元格内容" button: left-aligned, visually distinct from
send/cancel so the user can tell at a glance it's a "modifier"
rather than a "submit/cancel" action. */
.opencode-inline-prompt .opencode-btn-insert-cell {
margin-right: auto; /* push to the left; send/cancel stay on the right */
background: #dbeafe;
color: #1e40af;
border-color: #93c5fd;
}
.opencode-inline-prompt .opencode-btn-insert-cell:hover:enabled {
background: #bfdbfe;
}
/* ------------------------------------------------------------------
Streaming event blocks (v4+ see opencode_inline_prompt.applyEvent)
------------------------------------------------------------------ */
/* System / status line rendered for server.*, workspace.*, lsp.*, etc. */
.opencode-inline-prompt .opencode-msg-system {
font-size: var(--jp-ui-font-size0, 11px);
color: var(--jp-ui-font-color2, #888);
padding: 2px 4px;
font-family: var(--jp-code-font-family, monospace);
white-space: pre-wrap;
word-break: break-all;
}
/* Base collapsible block used for reasoning / tool / permission / question. */
.opencode-inline-prompt .opencode-msg-block {
margin: 4px 0;
border: 1px solid var(--jp-border-color2, #ddd);
border-radius: 3px;
background: var(--jp-layout-color1, #fafafa);
font-size: var(--jp-ui-font-size0, 11px);
overflow: hidden;
}
.opencode-inline-prompt .opencode-msg-block > summary {
padding: 2px 6px;
font-weight: 600;
cursor: pointer;
background: var(--jp-layout-color2, #f1f5f9);
color: var(--jp-ui-font-color2, #64748b);
user-select: none;
font-size: var(--jp-ui-font-size0, 11px);
}
.opencode-inline-prompt .opencode-msg-block-content {
padding: 4px 8px;
font-family: var(--jp-code-font-family, monospace);
white-space: pre-wrap;
word-break: break-all;
color: var(--jp-ui-font-color1, #334155);
max-height: 240px;
overflow-y: auto;
}
/* Reasoning: green tint (matches demo.html). */
.opencode-inline-prompt .opencode-msg-block-reasoning > summary {
background: #f0fdf4;
color: #15803d;
}
/* Tool call: yellow tint. */
.opencode-inline-prompt .opencode-msg-block-tool > summary {
background: #fef3c7;
color: #b45309;
}
/* Permission / question: pink tint. */
.opencode-inline-prompt .opencode-msg-block-interaction > summary {
background: #fce7f3;
color: #be185d;
}
/* Permission block: description + 3 buttons. */
.opencode-inline-prompt .opencode-perm-desc {
margin-bottom: 6px;
font-size: var(--jp-ui-font-size0, 11px);
color: var(--jp-ui-font-color1, #334155);
}
.opencode-inline-prompt .opencode-perm-buttons {
display: flex;
gap: 6px;
flex-wrap: wrap;
margin-top: 4px;
}
.opencode-inline-prompt .opencode-perm-btn-allow,
.opencode-inline-prompt .opencode-perm-btn-reject {
border: none;
padding: 4px 10px;
border-radius: 3px;
cursor: pointer;
font-size: var(--jp-ui-font-size0, 11px);
color: white;
font-weight: 500;
}
.opencode-inline-prompt .opencode-perm-btn-allow {
background: #10b981;
}
.opencode-inline-prompt .opencode-perm-btn-allow:hover {
background: #059669;
}
.opencode-inline-prompt .opencode-perm-btn-reject {
background: #ef4444;
}
.opencode-inline-prompt .opencode-perm-btn-reject:hover {
background: #dc2626;
}
/* Question block: text + input + submit. */
.opencode-inline-prompt .opencode-q-desc {
margin-bottom: 6px;
font-size: var(--jp-ui-font-size0, 11px);
color: var(--jp-ui-font-color1, #334155);
}
.opencode-inline-prompt .opencode-q-input-row {
display: flex;
gap: 6px;
margin-top: 4px;
}
.opencode-inline-prompt .opencode-q-input {
flex: 1;
padding: 3px 6px;
border: 1px solid var(--jp-border-color2, #ccc);
border-radius: 3px;
font-size: var(--jp-ui-font-size0, 11px);
font-family: inherit;
background: var(--jp-layout-color1, #fff);
}
.opencode-inline-prompt .opencode-q-submit {
border: 1px solid var(--jp-border-color2, #ccc);
background: var(--jp-layout-color2, #f0f0f0);
padding: 3px 10px;
border-radius: 3px;
cursor: pointer;
font-size: var(--jp-ui-font-size0, 11px);
}
.opencode-inline-prompt .opencode-q-submit:hover {
background: var(--jp-layout-color3, #e0e0e0);
}
/* ------------------------------------------------------------------
Session selector (multi-session UI) top of the inline prompt
------------------------------------------------------------------ */
.opencode-inline-prompt .opencode-prompt-sessions {
display: flex;
gap: 6px;
align-items: center;
flex-wrap: wrap;
margin: 2px 0 4px;
}
.opencode-inline-prompt .opencode-prompt-sessions label {
display: flex;
align-items: center;
gap: 4px;
font-size: var(--jp-ui-font-size1);
color: var(--jp-ui-font-color1, #333);
flex: 1;
min-width: 0;
}
.opencode-inline-prompt .opencode-session-select {
flex: 1;
min-width: 0;
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-session-btn-new,
.opencode-inline-prompt .opencode-session-btn-refresh {
border: 1px solid var(--jp-border-color2, #ccc);
background: var(--jp-layout-color2, #f0f0f0);
padding: 2px 8px;
border-radius: 3px;
cursor: pointer;
font-size: var(--jp-ui-font-size0, 11px);
color: var(--jp-ui-font-color1, #333);
}
.opencode-inline-prompt .opencode-session-btn-new:hover,
.opencode-inline-prompt .opencode-session-btn-refresh:hover {
background: var(--jp-layout-color3, #e0e0e0);
}
.opencode-inline-prompt .opencode-session-btn-new {
background: #dbeafe;
color: #1e40af;
border-color: #93c5fd;
}